mirror of
https://github.com/Tony0410/nextstep.git
synced 2026-05-25 05:41:39 +08:00
Compare commits
13 Commits
dependabot
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
065250c1cf | ||
|
|
a5181cf6fe | ||
|
|
cae436a20d | ||
|
|
8c9ae06360 | ||
|
|
7fa95c058e | ||
|
|
f598f6138e | ||
|
|
66cb1ea095 | ||
|
|
f9a7b68a99 | ||
|
|
4753216b56 | ||
|
|
54900b65c8 | ||
|
|
c9f3402e48 | ||
|
|
e5db48f82b | ||
|
|
3034d52884 |
@@ -23,12 +23,18 @@ RUN npx prisma generate
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Build args for NEXT_PUBLIC_* variables (needed at build time)
|
||||
ARG NEXT_PUBLIC_VAPID_PUBLIC_KEY
|
||||
ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=${NEXT_PUBLIC_VAPID_PUBLIC_KEY}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Runner (using slim Debian for better OpenSSL compatibility)
|
||||
FROM node:20-slim AS runner
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y tzdata && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV TZ=Australia/Perth
|
||||
@@ -37,7 +43,7 @@ RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Install OpenSSL and CA certificates for Prisma
|
||||
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y openssl ca-certificates wget && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
|
||||
@@ -3,6 +3,8 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_VAPID_PUBLIC_KEY=BEFs_VtoxY7SpNnd-ubz1ioliESlRI4sY6ny7Qp3rm7V1cm0gqyZX8TAHp4AaQ81yKC4LfWtJFQz_aHc25G-Tww
|
||||
container_name: nextstep-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -13,6 +15,10 @@ services:
|
||||
- NEXT_PUBLIC_APP_URL=https://debianvm.kangaroo-eel.ts.net:10000
|
||||
- TZ=Australia/Perth
|
||||
- NODE_ENV=production
|
||||
# Push notification VAPID keys
|
||||
- NEXT_PUBLIC_VAPID_PUBLIC_KEY=BEFs_VtoxY7SpNnd-ubz1ioliESlRI4sY6ny7Qp3rm7V1cm0gqyZX8TAHp4AaQ81yKC4LfWtJFQz_aHc25G-Tww
|
||||
- VAPID_PRIVATE_KEY=KgVQVO7XhfCklrJ3o9wowzK90AxI6Exg9pXPq76Qx4A
|
||||
- VAPID_EMAIL=mailto:admin@example.com
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -25,6 +31,24 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
scheduler:
|
||||
image: alpine
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
entrypoint: /bin/sh
|
||||
command: >
|
||||
-c "apk add --no-cache curl &&
|
||||
while true; do
|
||||
echo 'Triggering notification check...' &&
|
||||
curl -s -X POST http://app:3000/api/notifications/send &&
|
||||
echo '' &&
|
||||
sleep 60;
|
||||
done"
|
||||
networks:
|
||||
- nextstep-network
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: nextstep-db
|
||||
|
||||
1032
package-lock.json
generated
1032
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -50,7 +50,7 @@
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
@@ -12,12 +12,14 @@ datasource db {
|
||||
// ============================================
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
lastLoginAt DateTime?
|
||||
forcePasswordReset Boolean @default(false)
|
||||
|
||||
// Relations
|
||||
sessions Session[]
|
||||
|
||||
66
public/sw.js
66
public/sw.js
@@ -1,66 +1,16 @@
|
||||
// NextStep Service Worker for Push Notifications
|
||||
|
||||
const CACHE_NAME = 'nextstep-v1'
|
||||
|
||||
// Install event - cache critical assets
|
||||
// Install event - activate immediately
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('Service Worker: Installing...')
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll([
|
||||
'/',
|
||||
'/today',
|
||||
'/meds',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
])
|
||||
})
|
||||
)
|
||||
// Skip waiting to activate immediately
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
// Activate event - clean up old caches
|
||||
// Activate event - claim clients immediately
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('Service Worker: Activating...')
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME)
|
||||
.map((name) => caches.delete(name))
|
||||
)
|
||||
})
|
||||
)
|
||||
self.clients.claim()
|
||||
})
|
||||
|
||||
// Fetch event - serve from cache when offline
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Only cache GET requests
|
||||
if (event.request.method !== 'GET') return
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
// Return cached version or fetch from network
|
||||
return (
|
||||
cached ||
|
||||
fetch(event.request).then((response) => {
|
||||
// Don't cache API responses
|
||||
if (event.request.url.includes('/api/')) {
|
||||
return response
|
||||
}
|
||||
// Cache successful responses
|
||||
if (response.status === 200) {
|
||||
const clone = response.clone()
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, clone)
|
||||
})
|
||||
}
|
||||
return response
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
// Push event - handle incoming push notifications
|
||||
@@ -70,8 +20,6 @@ self.addEventListener('push', (event) => {
|
||||
let data = {
|
||||
title: 'Medication Reminder',
|
||||
body: 'Time to take your medication',
|
||||
icon: '/icon-192.png',
|
||||
badge: '/badge-72.png',
|
||||
tag: 'medication-reminder',
|
||||
data: {
|
||||
url: '/meds',
|
||||
@@ -88,15 +36,9 @@ self.addEventListener('push', (event) => {
|
||||
|
||||
const options = {
|
||||
body: data.body,
|
||||
icon: data.icon || '/icon-192.png',
|
||||
badge: data.badge || '/badge-72.png',
|
||||
tag: data.tag || 'default',
|
||||
vibrate: [100, 50, 100],
|
||||
data: data.data || {},
|
||||
actions: data.actions || [
|
||||
{ action: 'take', title: 'Taken' },
|
||||
{ action: 'snooze', title: 'Snooze' },
|
||||
],
|
||||
requireInteraction: true,
|
||||
}
|
||||
|
||||
|
||||
215
src/app/(app)/appointments/[id]/edit/page.tsx
Normal file
215
src/app/(app)/appointments/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import { toZonedTime } from 'date-fns-tz'
|
||||
import { Button, Input, Textarea, Card, LoadingState, showToast } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { useApp } from '../../../provider'
|
||||
|
||||
const TIMEZONE = 'Australia/Perth'
|
||||
|
||||
interface Appointment {
|
||||
id: string
|
||||
title: string
|
||||
datetime: string
|
||||
location: string | null
|
||||
mapUrl: string | null
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
export default function EditAppointmentPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const appointmentId = params.id as string
|
||||
const { currentWorkspace, refreshData } = useApp()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
const [date, setDate] = useState('')
|
||||
const [time, setTime] = useState('')
|
||||
const [location, setLocation] = useState('')
|
||||
const [mapUrl, setMapUrl] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchAppointment() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`
|
||||
)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const appt: Appointment = data.appointment
|
||||
|
||||
// Parse datetime and convert to local timezone
|
||||
const apptDate = toZonedTime(parseISO(appt.datetime), TIMEZONE)
|
||||
setTitle(appt.title)
|
||||
setDate(format(apptDate, 'yyyy-MM-dd'))
|
||||
setTime(format(apptDate, 'HH:mm'))
|
||||
setLocation(appt.location || '')
|
||||
setMapUrl(appt.mapUrl || '')
|
||||
setNotes(appt.notes || '')
|
||||
} else {
|
||||
setError('Appointment not found')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch appointment:', err)
|
||||
setError('Failed to load appointment')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchAppointment()
|
||||
}, [currentWorkspace.id, appointmentId])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
// Combine date and time
|
||||
const datetime = new Date(`${date}T${time}:00`)
|
||||
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
datetime: datetime.toISOString(),
|
||||
location: location || null,
|
||||
mapUrl: mapUrl || null,
|
||||
notes: notes || null,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Failed to update appointment')
|
||||
}
|
||||
|
||||
await refreshData()
|
||||
showToast('Appointment updated', 'success')
|
||||
router.push(`/appointments/${appointmentId}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Something went wrong')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Edit Appointment" showBack />
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading appointment..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !title) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Edit Appointment" showBack />
|
||||
<PageContainer className="pt-4">
|
||||
<Card className="text-center py-8">
|
||||
<p className="text-secondary-500">{error}</p>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Edit Appointment" showBack backHref={`/appointments/${appointmentId}`} />
|
||||
<PageContainer className="pt-4">
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Oncology Appointment"
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Time"
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Location"
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="e.g., Level 3, Cancer Centre"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Map Link (optional)"
|
||||
type="url"
|
||||
value={mapUrl}
|
||||
onChange={(e) => setMapUrl(e.target.value)}
|
||||
placeholder="https://maps.google.com/..."
|
||||
helperText="Paste a Google Maps or Apple Maps link"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Any notes for this appointment..."
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" fullWidth loading={saving}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ArrowLeft, Edit2 } from 'lucide-react'
|
||||
import { ArrowLeft, Edit2, Heart } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
|
||||
import { db } from '@/lib/sync'
|
||||
import { EmergencyCard } from '@/components/emergency/EmergencyCard'
|
||||
import { Button, LoadingState } from '@/components/ui'
|
||||
import { LoadingState } from '@/components/ui'
|
||||
import { useApp } from '../provider'
|
||||
|
||||
export default function EmergencyPage() {
|
||||
const router = useRouter()
|
||||
const { currentWorkspace } = useApp()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Fetch workspace from IndexedDB for offline access
|
||||
const workspace = useLiveQuery(
|
||||
@@ -32,7 +37,11 @@ export default function EmergencyPage() {
|
||||
)
|
||||
|
||||
if (!workspace) {
|
||||
return <LoadingState message="Loading emergency info..." />
|
||||
return (
|
||||
<div className="min-h-screen paper-texture flex items-center justify-center">
|
||||
<LoadingState message="Loading emergency info..." />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const emergencyInfo = {
|
||||
@@ -56,58 +65,74 @@ export default function EmergencyPage() {
|
||||
})) || []
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-red-50">
|
||||
<div className={`min-h-screen paper-texture transition-opacity duration-500 ${mounted ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{/* Header */}
|
||||
<div className="bg-red-600 text-white safe-top">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="bg-gradient-to-r from-alert-500 to-alert-600 text-white safe-area-top sticky top-0 z-10">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-2 text-white/90 hover:text-white"
|
||||
className="flex items-center gap-2 text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>Back</span>
|
||||
<div className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="font-medium">Back</span>
|
||||
</button>
|
||||
|
||||
{currentWorkspace.role !== 'VIEWER' && (
|
||||
<button
|
||||
onClick={() => router.push('/settings/emergency')}
|
||||
className="flex items-center gap-2 text-white/90 hover:text-white"
|
||||
className="flex items-center gap-2 text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
<span>Edit</span>
|
||||
<span className="font-medium">Edit</span>
|
||||
<div className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center">
|
||||
<Edit2 className="w-5 h-5" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="p-6 pb-24">
|
||||
{hasInfo ? (
|
||||
<EmergencyCard info={emergencyInfo} medications={medsList} />
|
||||
<div className="animate-fade-up">
|
||||
<EmergencyCard info={emergencyInfo} medications={medsList} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Edit2 className="w-8 h-8 text-red-400" />
|
||||
<div className="section-warm text-center py-12 animate-fade-up">
|
||||
<div className="w-20 h-20 rounded-full bg-alert-100 flex items-center justify-center mx-auto mb-6">
|
||||
<Heart className="w-10 h-10 text-alert-400" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-secondary-900 mb-2">
|
||||
|
||||
<h2 className="font-display text-2xl text-secondary-900 mb-3">
|
||||
No Emergency Info Set
|
||||
</h2>
|
||||
<p className="text-secondary-600 mb-4">
|
||||
Add important medical information for emergencies.
|
||||
|
||||
<p className="text-secondary-600 mb-8 max-w-sm mx-auto">
|
||||
Add important medical information that could be crucial in an emergency situation.
|
||||
</p>
|
||||
|
||||
{currentWorkspace.role !== 'VIEWER' && (
|
||||
<Button onClick={() => router.push('/settings/emergency')}>
|
||||
<button
|
||||
onClick={() => router.push('/settings/emergency')}
|
||||
className="btn-primary"
|
||||
>
|
||||
Add Emergency Info
|
||||
</Button>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Offline indicator */}
|
||||
<div className="fixed bottom-4 left-4 right-4">
|
||||
<div className="bg-green-100 border border-green-300 rounded-lg p-3 text-center">
|
||||
<p className="text-sm text-green-800 font-medium">
|
||||
This information is available offline
|
||||
</p>
|
||||
<div className="fixed bottom-6 left-6 right-6">
|
||||
<div className="bg-primary-50 border border-primary-200 rounded-card p-4 text-center shadow-elevated">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-primary-500 animate-pulse" />
|
||||
<p className="text-sm text-primary-700 font-medium">
|
||||
This information is available offline
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
46
src/app/(app)/meds/[id]/edit/page.tsx
Normal file
46
src/app/(app)/meds/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { db } from '@/lib/sync'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { MedicationForm } from '@/components/medications/MedicationForm'
|
||||
import { LoadingState } from '@/components/ui'
|
||||
|
||||
export default function EditMedicationPage({ params }: { params: { id: string } | Promise<{ id: string }> }) {
|
||||
const [medicationId, setMedicationId] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (params instanceof Promise) {
|
||||
params.then((p) => setMedicationId(p.id))
|
||||
} else {
|
||||
setMedicationId(params.id)
|
||||
}
|
||||
}, [params])
|
||||
|
||||
const medication = useLiveQuery(
|
||||
() => (medicationId ? db.medications.get(medicationId) : undefined),
|
||||
[medicationId]
|
||||
)
|
||||
|
||||
if (!medicationId || !medication) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Edit Medication" showBack />
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading medication..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Edit Medication" showBack />
|
||||
<PageContainer className="pt-4">
|
||||
<MedicationForm initialData={medication} isEditing />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { use, useEffect, useState, useCallback } from 'react'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { format } from 'date-fns'
|
||||
import { Pill, Clock, Edit2, Trash2, History } from 'lucide-react'
|
||||
import { Pill, Clock, Trash2, History, X, Edit2 } from 'lucide-react'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
|
||||
import { db, logDose, undoDose } from '@/lib/sync'
|
||||
import type { LocalDoseLog } from '@/lib/sync'
|
||||
import { Card, Button, LoadingState, Modal, showToast, showUndoToast } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { RefillTracker } from '@/components/medications/RefillTracker'
|
||||
import { useApp } from '../../provider'
|
||||
|
||||
export default function MedicationDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id: medicationId } = use(params)
|
||||
// Unwrapping params for Next.js 14/15 compatibility
|
||||
// In Next.js 15 params is a Promise, in 14 it's an object.
|
||||
// We can use a simple `use` polyfill or just await it if we were in an async component,
|
||||
// but this is a client component.
|
||||
// For client components, params is passed as is.
|
||||
// If types say Promise, we might need to use `use` but `use` is experimental in React 18.
|
||||
// Let's assume params is an object for now as per Next 14 standard behavior for pages.
|
||||
// If it is a promise (Next 15), we need `use`.
|
||||
// Safest way: check if it has .then?
|
||||
// Actually, let's just assume object for Next 14.
|
||||
|
||||
export default function MedicationDetailPage({ params }: { params: { id: string } | Promise<{ id: string }> }) {
|
||||
// Simple unwrap if it's a promise (though likely it's an object in Next 14)
|
||||
const [medicationId, setMedicationId] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (params instanceof Promise) {
|
||||
params.then((p) => setMedicationId(p.id))
|
||||
} else {
|
||||
setMedicationId(params.id)
|
||||
}
|
||||
}, [params])
|
||||
|
||||
const router = useRouter()
|
||||
const { currentWorkspace, refreshData } = useApp()
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
@@ -21,19 +43,21 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
|
||||
|
||||
// Fetch medication from IndexedDB
|
||||
const medication = useLiveQuery(
|
||||
() => db.medications.get(medicationId),
|
||||
() => (medicationId ? db.medications.get(medicationId) : undefined),
|
||||
[medicationId]
|
||||
)
|
||||
|
||||
// Fetch recent dose logs
|
||||
const doseLogs = useLiveQuery(
|
||||
() =>
|
||||
db.doseLogs
|
||||
.where('medicationId')
|
||||
.equals(medicationId)
|
||||
.reverse()
|
||||
.limit(10)
|
||||
.toArray(),
|
||||
medicationId
|
||||
? db.doseLogs
|
||||
.where('medicationId')
|
||||
.equals(medicationId)
|
||||
.reverse()
|
||||
.limit(10)
|
||||
.toArray()
|
||||
: [],
|
||||
[medicationId]
|
||||
)
|
||||
|
||||
@@ -58,6 +82,15 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
|
||||
}
|
||||
}, [medication, currentWorkspace.id])
|
||||
|
||||
const handleDeleteDose = async (dose: LocalDoseLog) => {
|
||||
try {
|
||||
await undoDose(dose)
|
||||
showToast('Dose removed', 'success')
|
||||
} catch {
|
||||
showToast('Failed to remove dose', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!medication) return
|
||||
setDeleting(true)
|
||||
@@ -79,25 +112,27 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
const formatSchedule = () => {
|
||||
if (!medication) return ''
|
||||
if (!medication || !medication.scheduleData) return ''
|
||||
const data = medication.scheduleData as Record<string, unknown>
|
||||
switch (medication.scheduleType) {
|
||||
case 'FIXED_TIMES':
|
||||
return `Daily at ${(data.times as string[]).join(', ')}`
|
||||
return `Daily at ${(Array.isArray(data.times) ? data.times : []).join(', ')}`
|
||||
case 'INTERVAL':
|
||||
return `Every ${data.hours} hours (starting ${data.startTime})`
|
||||
return `Every ${data.hours || '?'} hours (starting ${data.startTime || '?'})`
|
||||
case 'WEEKDAYS':
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
const selectedDays = (data.days as number[]).map(d => days[d]).join(', ')
|
||||
return `${selectedDays} at ${data.time}`
|
||||
const selectedDays = (Array.isArray(data.days) ? data.days : [])
|
||||
.map((d: number) => days[d])
|
||||
.join(', ')
|
||||
return `${selectedDays} at ${data.time || '?'}`
|
||||
case 'PRN':
|
||||
return `As needed (min ${data.minHoursBetween}h between doses)`
|
||||
return `As needed (min ${data.minHoursBetween || '?'}h between doses)`
|
||||
default:
|
||||
return medication.scheduleType
|
||||
}
|
||||
}
|
||||
|
||||
if (!medication) {
|
||||
if (!medicationId || !medication) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Medication" showBack />
|
||||
@@ -118,9 +153,9 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
|
||||
rightAction={
|
||||
currentWorkspace.role !== 'VIEWER'
|
||||
? {
|
||||
icon: <Trash2 className="w-6 h-6 text-red-600" />,
|
||||
label: 'Delete',
|
||||
onClick: () => setShowDeleteModal(true),
|
||||
icon: <Edit2 className="w-5 h-5 text-primary-600" />,
|
||||
label: 'Edit',
|
||||
onClick: () => router.push(`/meds/${medication.id}/edit`),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -193,7 +228,7 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
|
||||
<Card padding="none">
|
||||
<ul className="divide-y divide-border">
|
||||
{recentDoses.map((dose) => (
|
||||
<li key={dose.id} className="px-4 py-3 flex items-center justify-between">
|
||||
<li key={dose.id} className="px-4 py-3 flex items-center justify-between group">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-900">
|
||||
{format(new Date(dose.takenAt), 'EEEE, MMM d')}
|
||||
@@ -203,6 +238,15 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
|
||||
{dose.loggedBy && ` by ${dose.loggedBy.name}`}
|
||||
</p>
|
||||
</div>
|
||||
{currentWorkspace.role !== 'VIEWER' && (
|
||||
<button
|
||||
onClick={() => handleDeleteDose(dose)}
|
||||
className="p-2 text-secondary-400 hover:text-red-600 hover:bg-red-50 rounded-full transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
title="Remove dose"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -213,6 +257,20 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Delete Action */}
|
||||
{currentWorkspace.role !== 'VIEWER' && (
|
||||
<div className="pt-4 pb-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700 w-full"
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete Medication
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
|
||||
@@ -1,334 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Input, Textarea, Select, Card, showToast } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { useApp } from '../../provider'
|
||||
|
||||
type ScheduleType = 'FIXED_TIMES' | 'INTERVAL' | 'WEEKDAYS' | 'PRN'
|
||||
|
||||
const scheduleTypeOptions = [
|
||||
{ value: 'FIXED_TIMES', label: 'Fixed times daily' },
|
||||
{ value: 'INTERVAL', label: 'Every X hours' },
|
||||
{ value: 'WEEKDAYS', label: 'Specific days of week' },
|
||||
{ value: 'PRN', label: 'As needed (PRN)' },
|
||||
]
|
||||
|
||||
const weekdays = [
|
||||
{ value: 0, label: 'Sun' },
|
||||
{ value: 1, label: 'Mon' },
|
||||
{ value: 2, label: 'Tue' },
|
||||
{ value: 3, label: 'Wed' },
|
||||
{ value: 4, label: 'Thu' },
|
||||
{ value: 5, label: 'Fri' },
|
||||
{ value: 6, label: 'Sat' },
|
||||
]
|
||||
import { MedicationForm } from '@/components/medications/MedicationForm'
|
||||
|
||||
export default function NewMedicationPage() {
|
||||
const router = useRouter()
|
||||
const { currentWorkspace, refreshData } = useApp()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [instructions, setInstructions] = useState('')
|
||||
const [scheduleType, setScheduleType] = useState<ScheduleType>('FIXED_TIMES')
|
||||
|
||||
// Fixed times
|
||||
const [times, setTimes] = useState(['08:00'])
|
||||
|
||||
// Interval
|
||||
const [intervalHours, setIntervalHours] = useState(8)
|
||||
const [startTime, setStartTime] = useState('08:00')
|
||||
|
||||
// Weekdays
|
||||
const [selectedDays, setSelectedDays] = useState<number[]>([1, 3, 5])
|
||||
const [weekdayTime, setWeekdayTime] = useState('09:00')
|
||||
|
||||
// PRN
|
||||
const [minHoursBetween, setMinHoursBetween] = useState(4)
|
||||
|
||||
// Refill tracking (optional)
|
||||
const [trackRefills, setTrackRefills] = useState(false)
|
||||
const [pillCount, setPillCount] = useState<number | ''>('')
|
||||
const [pillsPerDose, setPillsPerDose] = useState(1)
|
||||
const [refillThreshold, setRefillThreshold] = useState(7)
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const addTime = () => {
|
||||
setTimes([...times, '12:00'])
|
||||
}
|
||||
|
||||
const removeTime = (index: number) => {
|
||||
setTimes(times.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const updateTime = (index: number, value: string) => {
|
||||
const newTimes = [...times]
|
||||
newTimes[index] = value
|
||||
setTimes(newTimes)
|
||||
}
|
||||
|
||||
const toggleDay = (day: number) => {
|
||||
if (selectedDays.includes(day)) {
|
||||
setSelectedDays(selectedDays.filter((d) => d !== day))
|
||||
} else {
|
||||
setSelectedDays([...selectedDays, day].sort())
|
||||
}
|
||||
}
|
||||
|
||||
const buildScheduleData = () => {
|
||||
switch (scheduleType) {
|
||||
case 'FIXED_TIMES':
|
||||
return { type: 'FIXED_TIMES', times }
|
||||
case 'INTERVAL':
|
||||
return { type: 'INTERVAL', hours: intervalHours, startTime }
|
||||
case 'WEEKDAYS':
|
||||
return { type: 'WEEKDAYS', days: selectedDays, time: weekdayTime }
|
||||
case 'PRN':
|
||||
return { type: 'PRN', minHoursBetween }
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/medications`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
instructions: instructions || null,
|
||||
scheduleType,
|
||||
scheduleData: buildScheduleData(),
|
||||
active: true,
|
||||
// Refill tracking (optional)
|
||||
...(trackRefills && pillCount !== '' && {
|
||||
pillCount: Number(pillCount),
|
||||
pillsPerDose,
|
||||
refillThreshold,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Failed to add medication')
|
||||
}
|
||||
|
||||
await refreshData()
|
||||
showToast('Medication added', 'success')
|
||||
router.push('/meds')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Something went wrong')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="New Medication" showBack backHref="/meds" />
|
||||
<PageContainer className="pt-4">
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<Input
|
||||
label="Medication Name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Paracetamol 500mg"
|
||||
required
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Instructions (optional)"
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="e.g., Take with food"
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Schedule Type"
|
||||
value={scheduleType}
|
||||
onChange={(e) => setScheduleType(e.target.value as ScheduleType)}
|
||||
options={scheduleTypeOptions}
|
||||
/>
|
||||
|
||||
{/* Schedule-specific options */}
|
||||
{scheduleType === 'FIXED_TIMES' && (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-secondary-700">
|
||||
Times to take
|
||||
</label>
|
||||
{times.map((time, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => updateTime(index, e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
{times.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => removeTime(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="secondary" onClick={addTime} size="sm">
|
||||
+ Add time
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scheduleType === 'INTERVAL' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Every (hours)"
|
||||
type="number"
|
||||
min={1}
|
||||
max={72}
|
||||
value={intervalHours}
|
||||
onChange={(e) => setIntervalHours(parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
<Input
|
||||
label="Starting at"
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scheduleType === 'WEEKDAYS' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Days
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{weekdays.map((day) => (
|
||||
<button
|
||||
key={day.value}
|
||||
type="button"
|
||||
onClick={() => toggleDay(day.value)}
|
||||
className={`px-3 py-2 rounded-button text-sm font-medium transition-colors ${
|
||||
selectedDays.includes(day.value)
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-muted text-secondary-600 hover:bg-secondary-200'
|
||||
}`}
|
||||
>
|
||||
{day.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label="Time"
|
||||
type="time"
|
||||
value={weekdayTime}
|
||||
onChange={(e) => setWeekdayTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scheduleType === 'PRN' && (
|
||||
<Input
|
||||
label="Minimum hours between doses"
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={72}
|
||||
step={0.5}
|
||||
value={minHoursBetween}
|
||||
onChange={(e) => setMinHoursBetween(parseFloat(e.target.value) || 4)}
|
||||
helperText="Shows 'Available' when enough time has passed since last dose"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Refill Tracking (optional) */}
|
||||
<div className="border-t border-border pt-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="trackRefills"
|
||||
checked={trackRefills}
|
||||
onChange={(e) => setTrackRefills(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-border text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<label htmlFor="trackRefills" className="text-sm font-medium text-secondary-700">
|
||||
Track pill count for refill reminders (optional)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{trackRefills && (
|
||||
<div className="space-y-4 pl-8">
|
||||
<Input
|
||||
label="Current pill count"
|
||||
type="number"
|
||||
min={0}
|
||||
value={pillCount}
|
||||
onChange={(e) => setPillCount(e.target.value === '' ? '' : parseInt(e.target.value))}
|
||||
placeholder="e.g., 30"
|
||||
helperText="How many pills do you have now?"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Pills per dose"
|
||||
type="number"
|
||||
min={1}
|
||||
value={pillsPerDose}
|
||||
onChange={(e) => setPillsPerDose(parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
<Input
|
||||
label="Alert when below"
|
||||
type="number"
|
||||
min={0}
|
||||
value={refillThreshold}
|
||||
onChange={(e) => setRefillThreshold(parseInt(e.target.value) || 7)}
|
||||
helperText="pills"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" fullWidth loading={loading}>
|
||||
Save Medication
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
<MedicationForm />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
493
src/app/(app)/settings/members/page.tsx
Normal file
493
src/app/(app)/settings/members/page.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { format } from 'date-fns'
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
Trash2,
|
||||
Key,
|
||||
Shield,
|
||||
Edit2,
|
||||
Loader,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { Button, Card, Input, Modal, showToast } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { useApp } from '../../provider'
|
||||
|
||||
interface Member {
|
||||
id: string
|
||||
role: 'OWNER' | 'EDITOR' | 'VIEWER'
|
||||
joinedAt: string
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
lastLoginAt: string | null
|
||||
forcePasswordReset: boolean
|
||||
createdAt: string
|
||||
}
|
||||
}
|
||||
|
||||
export default function MembersPage() {
|
||||
const router = useRouter()
|
||||
const { currentWorkspace, user } = useApp()
|
||||
|
||||
const [members, setMembers] = useState<Member[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Modals
|
||||
const [showAddUser, setShowAddUser] = useState(false)
|
||||
const [showEditRole, setShowEditRole] = useState<Member | null>(null)
|
||||
const [showResetPassword, setShowResetPassword] = useState<Member | null>(null)
|
||||
const [showRemove, setShowRemove] = useState<Member | null>(null)
|
||||
|
||||
// Form states
|
||||
const [addUserForm, setAddUserForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'VIEWER' as 'OWNER' | 'EDITOR' | 'VIEWER',
|
||||
forcePasswordReset: true,
|
||||
})
|
||||
const [resetPasswordForm, setResetPasswordForm] = useState({
|
||||
newPassword: '',
|
||||
forceChange: true,
|
||||
})
|
||||
const [newRole, setNewRole] = useState<'OWNER' | 'EDITOR' | 'VIEWER'>('VIEWER')
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
|
||||
const fetchMembers = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/members`)
|
||||
if (!response.ok) throw new Error('Failed to fetch members')
|
||||
const data = await response.json()
|
||||
setMembers(data.members)
|
||||
} catch (err) {
|
||||
setError('Failed to load members')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [currentWorkspace.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentWorkspace.role !== 'OWNER') {
|
||||
router.push('/settings')
|
||||
return
|
||||
}
|
||||
fetchMembers()
|
||||
}, [currentWorkspace.role, fetchMembers, router])
|
||||
|
||||
const handleAddUser = async () => {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/members`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(addUserForm),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error)
|
||||
|
||||
showToast(data.message || 'User added', 'success')
|
||||
setShowAddUser(false)
|
||||
setAddUserForm({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'VIEWER',
|
||||
forcePasswordReset: true,
|
||||
})
|
||||
fetchMembers()
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : 'Failed to add user', 'error')
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateRole = async () => {
|
||||
if (!showEditRole) return
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/members/${showEditRole.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: newRole }),
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error)
|
||||
|
||||
showToast('Role updated', 'success')
|
||||
setShowEditRole(null)
|
||||
fetchMembers()
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : 'Failed to update role', 'error')
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!showResetPassword) return
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/members/${showResetPassword.id}/reset-password`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(resetPasswordForm),
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error)
|
||||
|
||||
showToast(data.message || 'Password reset', 'success')
|
||||
setShowResetPassword(null)
|
||||
setResetPasswordForm({ newPassword: '', forceChange: true })
|
||||
fetchMembers()
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : 'Failed to reset password', 'error')
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveMember = async () => {
|
||||
if (!showRemove) return
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/members/${showRemove.id}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error)
|
||||
|
||||
showToast('Member removed', 'success')
|
||||
setShowRemove(null)
|
||||
fetchMembers()
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : 'Failed to remove member', 'error')
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'OWNER':
|
||||
return 'bg-purple-100 text-purple-800'
|
||||
case 'EDITOR':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
default:
|
||||
return 'bg-secondary-100 text-secondary-800'
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Manage Members" showBack />
|
||||
<PageContainer className="pt-4">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader className="w-6 h-6 animate-spin text-secondary-400" />
|
||||
</div>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Manage Members" showBack />
|
||||
<PageContainer className="pt-4">
|
||||
<Card className="text-center py-8">
|
||||
<p className="text-secondary-500">{error}</p>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Manage Members" showBack />
|
||||
<PageContainer className="pt-4 space-y-4">
|
||||
{/* Add user button */}
|
||||
<Button onClick={() => setShowAddUser(true)} className="w-full">
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Add User
|
||||
</Button>
|
||||
|
||||
{/* Members list */}
|
||||
<div className="space-y-3">
|
||||
{members.map((member) => {
|
||||
const isCurrentUser = member.user.id === user.id
|
||||
|
||||
return (
|
||||
<Card key={member.id} padding="none">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-secondary-900">
|
||||
{member.user.name}
|
||||
{isCurrentUser && (
|
||||
<span className="text-secondary-500 font-normal"> (you)</span>
|
||||
)}
|
||||
</p>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium ${getRoleBadgeColor(member.role)}`}
|
||||
>
|
||||
{member.role}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500 mt-0.5">{member.user.email}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-secondary-400">
|
||||
<span>
|
||||
Joined {format(new Date(member.joinedAt), 'MMM d, yyyy')}
|
||||
</span>
|
||||
{member.user.lastLoginAt && (
|
||||
<span>
|
||||
Last login{' '}
|
||||
{format(new Date(member.user.lastLoginAt), 'MMM d, yyyy')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{member.user.forcePasswordReset && (
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-amber-600">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span>Must change password on next login</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!isCurrentUser && (
|
||||
<div className="flex gap-2 mt-3 pt-3 border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowEditRole(member)
|
||||
setNewRole(member.role)
|
||||
}}
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5 mr-1" />
|
||||
Role
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowResetPassword(member)
|
||||
setResetPasswordForm({ newPassword: '', forceChange: true })
|
||||
}}
|
||||
>
|
||||
<Key className="w-3.5 h-3.5 mr-1" />
|
||||
Reset Password
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:bg-red-50"
|
||||
onClick={() => setShowRemove(member)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{members.length === 0 && (
|
||||
<Card className="text-center py-8">
|
||||
<Users className="w-12 h-12 text-secondary-300 mx-auto mb-3" />
|
||||
<p className="text-secondary-500">No members yet</p>
|
||||
</Card>
|
||||
)}
|
||||
</PageContainer>
|
||||
|
||||
{/* Add User Modal */}
|
||||
<Modal isOpen={showAddUser} onClose={() => setShowAddUser(false)} title="Add User">
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Name"
|
||||
value={addUserForm.name}
|
||||
onChange={(e) => setAddUserForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={addUserForm.email}
|
||||
onChange={(e) => setAddUserForm((f) => ({ ...f, email: e.target.value }))}
|
||||
placeholder="Enter email"
|
||||
/>
|
||||
<Input
|
||||
label="Temporary Password"
|
||||
type="text"
|
||||
value={addUserForm.password}
|
||||
onChange={(e) => setAddUserForm((f) => ({ ...f, password: e.target.value }))}
|
||||
placeholder="At least 8 characters"
|
||||
helperText="User will be required to change this on first login"
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">Role</label>
|
||||
<div className="flex gap-2">
|
||||
{(['VIEWER', 'EDITOR', 'OWNER'] as const).map((role) => (
|
||||
<button
|
||||
key={role}
|
||||
onClick={() => setAddUserForm((f) => ({ ...f, role }))}
|
||||
className={`flex-1 py-2 px-3 rounded-button text-sm font-medium transition-colors ${
|
||||
addUserForm.role === role
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-muted text-secondary-600'
|
||||
}`}
|
||||
>
|
||||
{role}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAddUser}
|
||||
fullWidth
|
||||
loading={actionLoading}
|
||||
disabled={!addUserForm.name || !addUserForm.email || addUserForm.password.length < 8}
|
||||
>
|
||||
Add User
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Role Modal */}
|
||||
<Modal
|
||||
isOpen={!!showEditRole}
|
||||
onClose={() => setShowEditRole(null)}
|
||||
title="Change Role"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-secondary-600">
|
||||
Change role for <strong>{showEditRole?.user.name}</strong>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{(['VIEWER', 'EDITOR', 'OWNER'] as const).map((role) => (
|
||||
<button
|
||||
key={role}
|
||||
onClick={() => setNewRole(role)}
|
||||
className={`flex-1 py-2 px-3 rounded-button text-sm font-medium transition-colors ${
|
||||
newRole === role
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-muted text-secondary-600'
|
||||
}`}
|
||||
>
|
||||
{role}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-secondary-500">
|
||||
<p><strong>Viewer:</strong> Can view everything but not make changes</p>
|
||||
<p><strong>Editor:</strong> Can add and edit appointments, medications, notes</p>
|
||||
<p><strong>Owner:</strong> Full access including member management</p>
|
||||
</div>
|
||||
<Button onClick={handleUpdateRole} fullWidth loading={actionLoading}>
|
||||
Update Role
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
<Modal
|
||||
isOpen={!!showResetPassword}
|
||||
onClose={() => setShowResetPassword(null)}
|
||||
title="Reset Password"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-secondary-600">
|
||||
Reset password for <strong>{showResetPassword?.user.name}</strong>
|
||||
</p>
|
||||
<Input
|
||||
label="New Password"
|
||||
type="text"
|
||||
value={resetPasswordForm.newPassword}
|
||||
onChange={(e) =>
|
||||
setResetPasswordForm((f) => ({ ...f, newPassword: e.target.value }))
|
||||
}
|
||||
placeholder="At least 8 characters"
|
||||
/>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={resetPasswordForm.forceChange}
|
||||
onChange={(e) =>
|
||||
setResetPasswordForm((f) => ({ ...f, forceChange: e.target.checked }))
|
||||
}
|
||||
className="w-4 h-4 rounded border-secondary-300"
|
||||
/>
|
||||
<span className="text-sm text-secondary-700">
|
||||
Require password change on next login
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-sm text-amber-600 bg-amber-50 p-3 rounded-lg">
|
||||
This will log the user out of all devices.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleResetPassword}
|
||||
fullWidth
|
||||
loading={actionLoading}
|
||||
disabled={resetPasswordForm.newPassword.length < 8}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Remove Member Modal */}
|
||||
<Modal
|
||||
isOpen={!!showRemove}
|
||||
onClose={() => setShowRemove(null)}
|
||||
title="Remove Member"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-secondary-600">
|
||||
Are you sure you want to remove <strong>{showRemove?.user.name}</strong> from
|
||||
this workspace? They will lose access to all data.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" fullWidth onClick={() => setShowRemove(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
fullWidth
|
||||
loading={actionLoading}
|
||||
onClick={handleRemoveMember}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Bell, Clock, Moon } from 'lucide-react'
|
||||
import { Bell, Clock, Moon, Send } from 'lucide-react'
|
||||
|
||||
import { Card, Button, Input, showToast } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
@@ -13,6 +13,7 @@ export default function NotificationsSettingsPage() {
|
||||
const [quietStart, setQuietStart] = useState(currentWorkspace.quietHoursStart || '22:00')
|
||||
const [quietEnd, setQuietEnd] = useState(currentWorkspace.quietHoursEnd || '07:00')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testingSending, setTestingSending] = useState(false)
|
||||
|
||||
const handleSaveQuietHours = async () => {
|
||||
setSaving(true)
|
||||
@@ -37,6 +38,29 @@ export default function NotificationsSettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestNotification = async () => {
|
||||
setTestingSending(true)
|
||||
try {
|
||||
const response = await fetch('/api/notifications/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId: currentWorkspace.id }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to send test notification')
|
||||
}
|
||||
|
||||
showToast(data.message, data.success ? 'success' : 'error')
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : 'Failed to send test', 'error')
|
||||
} finally {
|
||||
setTestingSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Notifications" showBack />
|
||||
@@ -51,6 +75,37 @@ export default function NotificationsSettingsPage() {
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Test Notification */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
Test Notifications
|
||||
</h2>
|
||||
<Card>
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Send className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-secondary-900">
|
||||
Send Test Notification
|
||||
</p>
|
||||
<p className="text-sm text-secondary-500">
|
||||
Verify that notifications are working on your device.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleTestNotification}
|
||||
loading={testingSending}
|
||||
fullWidth
|
||||
variant="secondary"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Send Test Notification
|
||||
</Button>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Quiet Hours */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
|
||||
@@ -289,16 +289,29 @@ export default function SettingsPage() {
|
||||
</h2>
|
||||
<Card padding="none">
|
||||
<button
|
||||
onClick={() => setShowInvite(true)}
|
||||
onClick={() => router.push('/settings/members')}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<Users className="w-5 h-5 text-secondary-500" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="font-medium text-secondary-900">Invite Family Member</p>
|
||||
<p className="text-sm text-secondary-500">Share access to this workspace</p>
|
||||
<p className="font-medium text-secondary-900">Manage Members</p>
|
||||
<p className="text-sm text-secondary-500">View and manage workspace access</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||
</button>
|
||||
<div className="border-t border-border">
|
||||
<button
|
||||
onClick={() => setShowInvite(true)}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<Users className="w-5 h-5 text-secondary-500" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="font-medium text-secondary-900">Invite Family Member</p>
|
||||
<p className="text-sm text-secondary-500">Share access to this workspace</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { format, isToday, isTomorrow } from 'date-fns'
|
||||
import { toZonedTime } from 'date-fns-tz'
|
||||
import { Phone, MapPin, Clock, ChevronRight, Pill, Calendar, Plus, AlertTriangle, ClipboardCheck } from 'lucide-react'
|
||||
import { Phone, MapPin, Clock, ChevronRight, Pill, Calendar, Plus, AlertTriangle, ClipboardCheck, Heart } from 'lucide-react'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
|
||||
import { db, logDose, undoDose } from '@/lib/sync'
|
||||
@@ -23,6 +23,11 @@ export default function TodayPage() {
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
const [quickNote, setQuickNote] = useState('')
|
||||
const [isAddingNote, setIsAddingNote] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Update time every minute
|
||||
useEffect(() => {
|
||||
@@ -149,7 +154,14 @@ export default function TodayPage() {
|
||||
const date = toZonedTime(new Date(datetime), TIMEZONE)
|
||||
if (isToday(date)) return `Today at ${format(date, 'h:mm a')}`
|
||||
if (isTomorrow(date)) return `Tomorrow at ${format(date, 'h:mm a')}`
|
||||
return format(date, 'EEE, MMM d \'at\' h:mm a')
|
||||
return format(date, "EEE, MMM d 'at' h:mm a")
|
||||
}
|
||||
|
||||
const getGreeting = () => {
|
||||
const hour = now.getHours()
|
||||
if (hour < 12) return 'Good morning'
|
||||
if (hour < 17) return 'Good afternoon'
|
||||
return 'Good evening'
|
||||
}
|
||||
|
||||
if (!appointments || !medications) {
|
||||
@@ -166,27 +178,41 @@ export default function TodayPage() {
|
||||
return (
|
||||
<>
|
||||
<Header title="Today" />
|
||||
<PageContainer className="pt-4 space-y-6">
|
||||
{/* Greeting */}
|
||||
<div className="mb-2">
|
||||
<p className="text-secondary-500 text-sm">
|
||||
{format(toZonedTime(now, TIMEZONE), 'EEEE, MMMM d')}
|
||||
</p>
|
||||
<PageContainer className="pt-6 pb-24 space-y-8">
|
||||
{/* Greeting Section with decorative elements */}
|
||||
<div className={`relative transition-all duration-700 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||
{/* Decorative blob */}
|
||||
<div className="blob blob-primary w-32 h-32 -top-4 -left-4" />
|
||||
|
||||
<div className="relative">
|
||||
<p className="text-secondary-500 text-sm font-medium tracking-wide uppercase mb-1">
|
||||
{format(toZonedTime(now, TIMEZONE), 'EEEE, MMMM d')}
|
||||
</p>
|
||||
<h1 className="font-display text-display-sm text-secondary-900">
|
||||
{getGreeting()}
|
||||
</h1>
|
||||
<p className="text-secondary-600 mt-2 flex items-center gap-2">
|
||||
<Heart className="w-4 h-4 text-accent-500" />
|
||||
<span>Take it one step at a time</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emergency & Call Clinic Buttons */}
|
||||
<div className="flex gap-3">
|
||||
{/* Emergency & Call Clinic Buttons - Floating cards */}
|
||||
<div className={`flex gap-3 transition-all duration-700 delay-100 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||
{/* Emergency Info Button */}
|
||||
<button
|
||||
onClick={() => router.push('/emergency')}
|
||||
className="flex items-center gap-3 p-4 bg-red-50 rounded-card border border-red-200 hover:bg-red-100 transition-colors flex-1"
|
||||
className="flex-1 group relative overflow-hidden"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-red-600 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-red-800">Emergency</p>
|
||||
<p className="text-sm text-red-600">Medical info</p>
|
||||
<div className="relative flex items-center gap-3 p-4 bg-alert-50/80 backdrop-blur-sm rounded-card border border-alert-200/60 hover:border-alert-300 hover:shadow-elevated transition-all duration-300">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-alert-500 to-alert-600 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform duration-300">
|
||||
<AlertTriangle className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold text-alert-800">Emergency</p>
|
||||
<p className="text-sm text-alert-600">Medical info</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -194,26 +220,28 @@ export default function TodayPage() {
|
||||
{currentWorkspace.clinicPhone && (
|
||||
<a
|
||||
href={`tel:${currentWorkspace.clinicPhone}`}
|
||||
className="flex items-center gap-3 p-4 bg-primary-50 rounded-card border border-primary-100 hover:bg-primary-100 transition-colors flex-1"
|
||||
className="flex-1 group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-primary-500 flex items-center justify-center">
|
||||
<Phone className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-primary-800">Call Clinic</p>
|
||||
<p className="text-sm text-primary-600 truncate">{currentWorkspace.clinicPhone}</p>
|
||||
<div className="flex items-center gap-3 p-4 bg-primary-50/80 backdrop-blur-sm rounded-card border border-primary-200/60 hover:border-primary-300 hover:shadow-elevated transition-all duration-300">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform duration-300">
|
||||
<Phone className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold text-primary-800">Call Clinic</p>
|
||||
<p className="text-sm text-primary-600 truncate max-w-[100px]">{currentWorkspace.clinicPhone}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Next Appointment */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-secondary-900">Next Appointment</h2>
|
||||
{/* Next Appointment - Hero Card */}
|
||||
<section className={`transition-all duration-700 delay-200 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-display text-xl text-secondary-900">Next Appointment</h2>
|
||||
<button
|
||||
onClick={() => router.push('/appointments')}
|
||||
className="text-sm text-primary-600 font-medium flex items-center"
|
||||
className="text-sm text-primary-600 font-medium flex items-center gap-0.5 hover:text-primary-700 transition-colors"
|
||||
>
|
||||
View all
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
@@ -221,30 +249,30 @@ export default function TodayPage() {
|
||||
</div>
|
||||
|
||||
{nextAppointment ? (
|
||||
<Card
|
||||
className="card-appointment"
|
||||
<div
|
||||
className="card-appointment cursor-pointer group"
|
||||
onClick={() => router.push(`/appointments/${nextAppointment.id}`)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
||||
<Calendar className="w-5 h-5 text-primary-600" />
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center flex-shrink-0 shadow-inner">
|
||||
<Calendar className="w-7 h-7 text-primary-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-secondary-900 truncate">
|
||||
<h3 className="font-display text-lg text-secondary-900 truncate group-hover:text-primary-700 transition-colors">
|
||||
{nextAppointment.title}
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 flex items-center gap-1 mt-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<p className="text-sm text-secondary-600 flex items-center gap-1.5 mt-1.5">
|
||||
<Clock className="w-4 h-4 text-primary-500" />
|
||||
{formatAppointmentDate(nextAppointment.datetime)}
|
||||
</p>
|
||||
{nextAppointment.location && (
|
||||
<p className="text-sm text-secondary-500 flex items-center gap-1 mt-0.5">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<p className="text-sm text-secondary-500 flex items-center gap-1.5 mt-1">
|
||||
<MapPin className="w-4 h-4 text-cream-600" />
|
||||
<span className="truncate">{nextAppointment.location}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-400" />
|
||||
<ChevronRight className="w-5 h-5 text-secondary-300 group-hover:text-primary-500 group-hover:translate-x-1 transition-all" />
|
||||
</div>
|
||||
{nextAppointment.mapUrl && (
|
||||
<a
|
||||
@@ -252,27 +280,27 @@ export default function TodayPage() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1.5 mt-3 text-sm text-primary-600 font-medium hover:text-primary-700"
|
||||
className="inline-flex items-center gap-1.5 mt-4 text-sm text-primary-600 font-medium hover:text-primary-700 hover:underline"
|
||||
>
|
||||
<MapPin className="w-4 h-4" />
|
||||
Open in Maps
|
||||
</a>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Card variant="outline" className="text-center py-6">
|
||||
<Calendar className="w-8 h-8 text-secondary-300 mx-auto mb-2" />
|
||||
<p className="text-secondary-500">No upcoming appointments</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
<div className="section-warm text-center py-8">
|
||||
<div className="w-16 h-16 rounded-full bg-cream-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Calendar className="w-8 h-8 text-cream-600" />
|
||||
</div>
|
||||
<p className="text-secondary-600 font-medium">No upcoming appointments</p>
|
||||
<button
|
||||
onClick={() => router.push('/appointments/new')}
|
||||
className="mt-4 inline-flex items-center gap-2 text-primary-600 font-medium hover:text-primary-700"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
<Plus className="w-4 h-4" />
|
||||
Add one
|
||||
</Button>
|
||||
</Card>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -283,26 +311,26 @@ export default function TodayPage() {
|
||||
)
|
||||
if (tomorrowAppt) {
|
||||
return (
|
||||
<section>
|
||||
<Card
|
||||
className="bg-green-50 border border-green-200 cursor-pointer hover:bg-green-100 transition-colors"
|
||||
<section className={`transition-all duration-700 delay-300 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||
<div
|
||||
className="bg-gradient-to-r from-cream-100 to-cream-50 border border-cream-200 rounded-card p-5 cursor-pointer hover:shadow-elevated transition-all duration-300 group"
|
||||
onClick={() => router.push(`/appointments/${tomorrowAppt.id}/prep`)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-green-500 flex items-center justify-center flex-shrink-0">
|
||||
<ClipboardCheck className="w-5 h-5 text-white" />
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-accent-400 to-accent-500 flex items-center justify-center flex-shrink-0 shadow-lg group-hover:scale-105 transition-transform">
|
||||
<ClipboardCheck className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-green-800">
|
||||
<p className="font-display text-lg text-secondary-900">
|
||||
Prepare for tomorrow
|
||||
</p>
|
||||
<p className="text-sm text-green-600">
|
||||
<p className="text-sm text-secondary-600">
|
||||
{tomorrowAppt.title} at {format(toZonedTime(new Date(tomorrowAppt.datetime), TIMEZONE), 'h:mm a')}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-green-500" />
|
||||
<ChevronRight className="w-5 h-5 text-accent-500 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -311,23 +339,25 @@ export default function TodayPage() {
|
||||
|
||||
{/* Refill Alerts */}
|
||||
{medications && medications.length > 0 && (
|
||||
<RefillAlert
|
||||
medications={medications.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
pillCount: m.pillCount,
|
||||
refillThreshold: m.refillThreshold,
|
||||
}))}
|
||||
/>
|
||||
<div className={`transition-all duration-700 delay-400 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||
<RefillAlert
|
||||
medications={medications.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
pillCount: m.pillCount,
|
||||
refillThreshold: m.refillThreshold,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meds Due */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-secondary-900">Medications</h2>
|
||||
<section className={`transition-all duration-700 delay-500 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-display text-xl text-secondary-900">Medications</h2>
|
||||
<button
|
||||
onClick={() => router.push('/meds')}
|
||||
className="text-sm text-primary-600 font-medium flex items-center"
|
||||
className="text-sm text-primary-600 font-medium flex items-center gap-0.5 hover:text-primary-700 transition-colors"
|
||||
>
|
||||
View all
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
@@ -335,21 +365,25 @@ export default function TodayPage() {
|
||||
</div>
|
||||
|
||||
{medsDueSoon.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{medsDueSoon.map((status) => (
|
||||
<div className="space-y-4">
|
||||
{medsDueSoon.map((status, index) => (
|
||||
<MedicationCard
|
||||
key={status.medication.id}
|
||||
status={status}
|
||||
now={now}
|
||||
onTake={() => handleTakeMed(status)}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : medications.length > 0 ? (
|
||||
<Card variant="outline" className="text-center py-6">
|
||||
<Pill className="w-8 h-8 text-secondary-300 mx-auto mb-2" />
|
||||
<p className="text-secondary-500">All caught up! No meds due soon.</p>
|
||||
</Card>
|
||||
<div className="section-warm text-center py-8">
|
||||
<div className="w-16 h-16 rounded-full bg-primary-50 flex items-center justify-center mx-auto mb-4">
|
||||
<Pill className="w-8 h-8 text-primary-400" />
|
||||
</div>
|
||||
<p className="text-secondary-600 font-medium">All caught up!</p>
|
||||
<p className="text-sm text-secondary-400 mt-1">No medications due soon</p>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
type="medications"
|
||||
@@ -364,16 +398,16 @@ export default function TodayPage() {
|
||||
</section>
|
||||
|
||||
{/* Quick Note */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-secondary-900 mb-3">Quick Note</h2>
|
||||
<Card padding="sm">
|
||||
<div className="flex gap-2">
|
||||
<section className={`transition-all duration-700 delay-600 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||
<h2 className="font-display text-xl text-secondary-900 mb-4">Quick Note</h2>
|
||||
<div className="section-warm">
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={quickNote}
|
||||
onChange={(e) => setQuickNote(e.target.value)}
|
||||
placeholder="Jot down a thought..."
|
||||
className="flex-1 px-3 py-2.5 border border-border rounded-button text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500"
|
||||
className="input-sanctuary flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && quickNote.trim()) {
|
||||
handleAddQuickNote()
|
||||
@@ -384,11 +418,12 @@ export default function TodayPage() {
|
||||
onClick={handleAddQuickNote}
|
||||
disabled={!quickNote.trim() || isAddingNote}
|
||||
loading={isAddingNote}
|
||||
className="btn-primary whitespace-nowrap"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</PageContainer>
|
||||
</>
|
||||
@@ -399,9 +434,10 @@ interface MedicationCardProps {
|
||||
status: MedicationDueStatus
|
||||
now: Date
|
||||
onTake: () => void
|
||||
index: number
|
||||
}
|
||||
|
||||
function MedicationCard({ status, now, onTake }: MedicationCardProps) {
|
||||
function MedicationCard({ status, now, onTake, index }: MedicationCardProps) {
|
||||
const { medication, isOverdue, isPRN, prnAvailable, prnAvailableAt, nextDueAt } = status
|
||||
|
||||
const getTimeLabel = () => {
|
||||
@@ -426,35 +462,38 @@ function MedicationCard({ status, now, onTake }: MedicationCardProps) {
|
||||
const canTake = !isPRN || prnAvailable
|
||||
|
||||
return (
|
||||
<Card className={isOverdue ? 'overdue' : ''}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
||||
<Pill className="w-5 h-5 text-primary-600" />
|
||||
<div className={`card-medication ${isOverdue ? 'overdue' : ''} animate-fade-up`} style={{ animationDelay: `${index * 0.1}s` }}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-14 h-14 rounded-full flex items-center justify-center flex-shrink-0 shadow-inner transition-all duration-300 ${
|
||||
isOverdue
|
||||
? 'bg-gradient-to-br from-accent-100 to-accent-200'
|
||||
: 'bg-gradient-to-br from-primary-100 to-primary-200'
|
||||
}`}>
|
||||
<Pill className={`w-7 h-7 ${isOverdue ? 'text-accent-600' : 'text-primary-600'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-secondary-900">{medication.name}</h3>
|
||||
<p className={`text-sm ${isOverdue ? 'text-red-600 font-medium' : 'text-secondary-500'}`}>
|
||||
<h3 className="font-display text-lg text-secondary-900">{medication.name}</h3>
|
||||
<p className={`text-sm ${isOverdue ? 'text-accent-600 font-medium' : 'text-secondary-500'}`}>
|
||||
{getTimeLabel()}
|
||||
{isPRN && ' • As needed'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onTake()
|
||||
}}
|
||||
variant="success"
|
||||
size="md"
|
||||
disabled={!canTake}
|
||||
className={`btn-primary text-sm px-5 py-2.5 ${!canTake ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
Taken
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
{medication.instructions && (
|
||||
<p className="text-sm text-secondary-500 mt-2 ml-13">
|
||||
<p className="text-sm text-secondary-500 mt-3 ml-[72px]">
|
||||
{medication.instructions}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
57
src/app/api/auth/change-password/route.ts
Normal file
57
src/app/api/auth/change-password/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { hashPassword, verifyPassword, withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||
import { z } from 'zod'
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, 'Current password is required'),
|
||||
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
})
|
||||
|
||||
export const POST = withAuth(async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const result = changePasswordSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = result.data
|
||||
const userId = req.session.user.id
|
||||
|
||||
// Get current user with password hash
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { passwordHash: true },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const validPassword = await verifyPassword(user.passwordHash, currentPassword)
|
||||
if (!validPassword) {
|
||||
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Hash new password and update user
|
||||
const newPasswordHash = await hashPassword(newPassword)
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
passwordHash: newPasswordHash,
|
||||
forcePasswordReset: false, // Clear the forced reset flag
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Change password error:', error)
|
||||
return NextResponse.json({ error: 'Failed to change password' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -44,6 +44,7 @@ async function handler(req: NextRequest) {
|
||||
email: true,
|
||||
name: true,
|
||||
passwordHash: true,
|
||||
forcePasswordReset: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -65,8 +66,14 @@ async function handler(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Record successful login
|
||||
await recordLoginAttempt(email.toLowerCase(), true, ipAddress)
|
||||
// Record successful login and update lastLoginAt
|
||||
await Promise.all([
|
||||
recordLoginAttempt(email.toLowerCase(), true, ipAddress),
|
||||
prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
}),
|
||||
])
|
||||
|
||||
// Create session
|
||||
const userAgent = req.headers.get('user-agent') || undefined
|
||||
@@ -79,6 +86,7 @@ async function handler(req: NextRequest) {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
forcePasswordReset: user.forcePasswordReset,
|
||||
})
|
||||
|
||||
response.cookies.set(cookieConfig)
|
||||
|
||||
93
src/app/api/notifications/test/route.ts
Normal file
93
src/app/api/notifications/test/route.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
|
||||
import { sendPushNotification } from '@/lib/notifications/push'
|
||||
|
||||
// POST /api/notifications/test - Send a test notification
|
||||
export const POST = withAuth(async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { workspaceId } = body
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: 'workspaceId required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check access
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get push subscriptions for this user in this workspace
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: {
|
||||
userId: req.session.user.id,
|
||||
workspaceId,
|
||||
},
|
||||
})
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No push subscriptions found. Please enable notifications first.' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
const errors: string[] = []
|
||||
|
||||
for (const sub of subscriptions) {
|
||||
try {
|
||||
const success = await sendPushNotification(
|
||||
{
|
||||
endpoint: sub.endpoint,
|
||||
p256dh: sub.p256dh,
|
||||
auth: sub.auth,
|
||||
},
|
||||
{
|
||||
title: 'Test Notification',
|
||||
body: 'If you see this, notifications are working!',
|
||||
tag: 'test-notification',
|
||||
data: {
|
||||
url: '/settings/notifications',
|
||||
action: 'test',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (success) {
|
||||
sent++
|
||||
} else {
|
||||
// Subscription expired, remove it
|
||||
await prisma.pushSubscription.delete({ where: { id: sub.id } })
|
||||
failed++
|
||||
errors.push('Subscription expired and was removed')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Test notification error:', error)
|
||||
failed++
|
||||
errors.push(error.message || 'Unknown error')
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: sent > 0,
|
||||
sent,
|
||||
failed,
|
||||
total: subscriptions.length,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
message: sent > 0
|
||||
? `Test notification sent! Check your device.`
|
||||
: `Failed to send notification: ${errors.join(', ')}`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Test notification error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to send test notification' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -56,11 +56,17 @@ export async function GET(
|
||||
membership.workspace.name
|
||||
)
|
||||
|
||||
// Sanitize filename for HTTP headers (remove non-ASCII characters)
|
||||
const safeFilename = membership.workspace.name
|
||||
.replace(/[^\x00-\x7F]/g, '') // Remove non-ASCII
|
||||
.replace(/[<>:"/\\|?*]/g, '-') // Replace invalid filename chars
|
||||
.trim() || 'appointments'
|
||||
|
||||
return new NextResponse(icalContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${membership.workspace.name}-appointments.ics"`,
|
||||
'Content-Disposition': `attachment; filename="${safeFilename}-appointments.ics"`,
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { withAuth, type AuthenticatedRequest, hashPassword } from '@/lib/auth'
|
||||
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
|
||||
import { z } from 'zod'
|
||||
|
||||
const resetPasswordSchema = z.object({
|
||||
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
forceChange: z.boolean().default(true),
|
||||
})
|
||||
|
||||
// POST /api/workspaces/[id]/members/[memberId]/reset-password - Reset user password
|
||||
export const POST = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId, memberId } = await params
|
||||
|
||||
// Check access (must be owner)
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access || access.role !== 'OWNER') {
|
||||
return NextResponse.json({ error: 'Only owners can reset passwords' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const result = resetPasswordSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { newPassword, forceChange } = result.data
|
||||
|
||||
// Get the member
|
||||
const member = await prisma.workspaceMember.findFirst({
|
||||
where: { id: memberId, workspaceId },
|
||||
include: { user: true },
|
||||
})
|
||||
|
||||
if (!member) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Hash new password and update user
|
||||
const passwordHash = await hashPassword(newPassword)
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: member.userId },
|
||||
data: {
|
||||
passwordHash,
|
||||
forcePasswordReset: forceChange,
|
||||
},
|
||||
})
|
||||
|
||||
// Invalidate all existing sessions for this user
|
||||
await prisma.session.deleteMany({
|
||||
where: { userId: member.userId },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: forceChange
|
||||
? 'Password reset. User must change password on next login.'
|
||||
: 'Password reset successfully.',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Reset password error:', error)
|
||||
return NextResponse.json({ error: 'Failed to reset password' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
161
src/app/api/workspaces/[id]/members/[memberId]/route.ts
Normal file
161
src/app/api/workspaces/[id]/members/[memberId]/route.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
|
||||
import { z } from 'zod'
|
||||
|
||||
// GET /api/workspaces/[id]/members/[memberId] - Get member details
|
||||
export const GET = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId, memberId } = await params
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const member = await prisma.workspaceMember.findFirst({
|
||||
where: { id: memberId, workspaceId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
lastLoginAt: true,
|
||||
forcePasswordReset: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!member) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ member })
|
||||
} catch (error) {
|
||||
console.error('Get member error:', error)
|
||||
return NextResponse.json({ error: 'Failed to get member' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
const updateMemberSchema = z.object({
|
||||
role: z.enum(['OWNER', 'EDITOR', 'VIEWER']).optional(),
|
||||
})
|
||||
|
||||
// PATCH /api/workspaces/[id]/members/[memberId] - Update member role
|
||||
export const PATCH = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId, memberId } = await params
|
||||
|
||||
// Check access (must be owner)
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access || access.role !== 'OWNER') {
|
||||
return NextResponse.json({ error: 'Only owners can update members' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const result = updateMemberSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { role } = result.data
|
||||
|
||||
// Get the member
|
||||
const member = await prisma.workspaceMember.findFirst({
|
||||
where: { id: memberId, workspaceId },
|
||||
})
|
||||
|
||||
if (!member) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Prevent changing own role
|
||||
if (member.userId === req.session.user.id) {
|
||||
return NextResponse.json({ error: 'Cannot change your own role' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Update member
|
||||
const updatedMember = await prisma.workspaceMember.update({
|
||||
where: { id: memberId },
|
||||
data: { role },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
lastLoginAt: true,
|
||||
forcePasswordReset: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
member: {
|
||||
id: updatedMember.id,
|
||||
role: updatedMember.role,
|
||||
joinedAt: updatedMember.createdAt,
|
||||
user: updatedMember.user,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Update member error:', error)
|
||||
return NextResponse.json({ error: 'Failed to update member' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/workspaces/[id]/members/[memberId] - Remove member from workspace
|
||||
export const DELETE = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId, memberId } = await params
|
||||
|
||||
// Check access (must be owner)
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access || access.role !== 'OWNER') {
|
||||
return NextResponse.json({ error: 'Only owners can remove members' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get the member
|
||||
const member = await prisma.workspaceMember.findFirst({
|
||||
where: { id: memberId, workspaceId },
|
||||
})
|
||||
|
||||
if (!member) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Prevent removing self
|
||||
if (member.userId === req.session.user.id) {
|
||||
return NextResponse.json({ error: 'Cannot remove yourself from workspace' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Delete member
|
||||
await prisma.workspaceMember.delete({
|
||||
where: { id: memberId },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Remove member error:', error)
|
||||
return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
197
src/app/api/workspaces/[id]/members/route.ts
Normal file
197
src/app/api/workspaces/[id]/members/route.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { withAuth, type AuthenticatedRequest, hashPassword } from '@/lib/auth'
|
||||
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
|
||||
import { z } from 'zod'
|
||||
|
||||
// GET /api/workspaces/[id]/members - List all members
|
||||
export const GET = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId } = await params
|
||||
|
||||
// Check access (must be at least a member)
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const members = await prisma.workspaceMember.findMany({
|
||||
where: { workspaceId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
lastLoginAt: true,
|
||||
forcePasswordReset: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
members: members.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
joinedAt: m.createdAt,
|
||||
user: m.user,
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('List members error:', error)
|
||||
return NextResponse.json({ error: 'Failed to list members' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
const createUserSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
role: z.enum(['OWNER', 'EDITOR', 'VIEWER']).default('VIEWER'),
|
||||
forcePasswordReset: z.boolean().default(true),
|
||||
})
|
||||
|
||||
// POST /api/workspaces/[id]/members - Create a new user and add to workspace
|
||||
export const POST = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId } = await params
|
||||
|
||||
// Check access (must be owner)
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access || access.role !== 'OWNER') {
|
||||
return NextResponse.json({ error: 'Only owners can create users' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const result = createUserSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { name, email, password, role, forcePasswordReset } = result.data
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
})
|
||||
|
||||
if (existingUser) {
|
||||
// Check if already a member
|
||||
const existingMember = await prisma.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId: existingUser.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (existingMember) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User is already a member of this workspace' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Add existing user to workspace
|
||||
const member = await prisma.workspaceMember.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: existingUser.id,
|
||||
role,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
lastLoginAt: true,
|
||||
forcePasswordReset: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
member: {
|
||||
id: member.id,
|
||||
role: member.role,
|
||||
joinedAt: member.createdAt,
|
||||
user: member.user,
|
||||
},
|
||||
message: 'Existing user added to workspace',
|
||||
})
|
||||
}
|
||||
|
||||
// Create new user and add to workspace
|
||||
const passwordHash = await hashPassword(password)
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email: email.toLowerCase(),
|
||||
passwordHash,
|
||||
forcePasswordReset,
|
||||
workspaceMembers: {
|
||||
create: {
|
||||
workspaceId,
|
||||
role,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
lastLoginAt: true,
|
||||
forcePasswordReset: true,
|
||||
createdAt: true,
|
||||
workspaceMembers: {
|
||||
where: { workspaceId },
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const member = user.workspaceMembers[0]
|
||||
|
||||
return NextResponse.json({
|
||||
member: {
|
||||
id: member.id,
|
||||
role: member.role,
|
||||
joinedAt: member.createdAt,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
forcePasswordReset: user.forcePasswordReset,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
},
|
||||
message: 'User created and added to workspace',
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Create user error:', error)
|
||||
return NextResponse.json({ error: 'Failed to create user' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
111
src/app/change-password/page.tsx
Normal file
111
src/app/change-password/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Heart } from 'lucide-react'
|
||||
import { Button, Input, Card, showToast } from '@/components/ui'
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
const router = useRouter()
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('New passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('New password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Failed to change password')
|
||||
return
|
||||
}
|
||||
|
||||
showToast('Password changed successfully!', 'success')
|
||||
router.push('/today')
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-primary-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Heart className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900">Change Password</h1>
|
||||
<p className="text-secondary-500 mt-1">Please set a new password to continue</p>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Current Password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
placeholder="Enter current password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<Input
|
||||
label="New Password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Input
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm new password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" fullWidth loading={loading}>
|
||||
Change Password
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,10 +4,14 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Google Fonts */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=Source+Sans+3:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap');
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 250 251 252;
|
||||
--foreground: 31 38 49;
|
||||
--background: 250 247 242;
|
||||
--foreground: 38 35 32;
|
||||
--surface: 255 255 255;
|
||||
}
|
||||
|
||||
html {
|
||||
@@ -17,6 +21,16 @@
|
||||
body {
|
||||
@apply bg-background text-secondary-900 antialiased;
|
||||
font-feature-settings: 'rlig' 1, 'calt' 1;
|
||||
font-family: 'Source Sans 3', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Paper texture background */
|
||||
.paper-texture {
|
||||
background-color: #faf7f2;
|
||||
background-image:
|
||||
url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
background-blend-mode: soft-light;
|
||||
background-size: 200px 200px;
|
||||
}
|
||||
|
||||
/* Large text mode */
|
||||
@@ -38,12 +52,12 @@
|
||||
}
|
||||
|
||||
.large-text .text-sm {
|
||||
font-size: 1rem; /* text-base equivalent */
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.large-text .text-xs {
|
||||
font-size: 0.875rem; /* text-sm equivalent */
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
@@ -56,9 +70,9 @@
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* Focus styles for accessibility */
|
||||
/* Focus styles for accessibility - warm glow */
|
||||
*:focus-visible {
|
||||
@apply outline-none ring-2 ring-primary-500 ring-offset-2;
|
||||
@apply outline-none ring-2 ring-primary-300 ring-offset-2 ring-offset-background;
|
||||
}
|
||||
|
||||
/* Better touch targets */
|
||||
@@ -79,64 +93,151 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Primary taken button */
|
||||
.btn-taken {
|
||||
/* Warm sanctuary card styles */
|
||||
.card-sanctuary {
|
||||
@apply bg-surface rounded-card shadow-card;
|
||||
@apply border border-cream-200/50;
|
||||
@apply transition-all duration-300 ease-sanctuary;
|
||||
}
|
||||
|
||||
.card-sanctuary:hover {
|
||||
@apply shadow-card-hover;
|
||||
@apply border-cream-300;
|
||||
}
|
||||
|
||||
/* Primary action button - warm green */
|
||||
.btn-primary {
|
||||
@apply bg-primary-500 hover:bg-primary-600 text-white font-semibold;
|
||||
@apply py-3 px-6 rounded-button min-h-touch;
|
||||
@apply shadow-button transition-all duration-200;
|
||||
@apply active:scale-95;
|
||||
@apply py-3.5 px-6 rounded-button min-h-touch;
|
||||
@apply shadow-button transition-all duration-300 ease-sanctuary;
|
||||
@apply active:scale-[0.98] hover:shadow-button-hover;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
/* Secondary button - cream */
|
||||
.btn-secondary {
|
||||
@apply bg-cream-100 hover:bg-cream-200 text-secondary-800 font-medium;
|
||||
@apply py-3.5 px-6 rounded-button min-h-touch;
|
||||
@apply border border-cream-300;
|
||||
@apply transition-all duration-300 ease-sanctuary;
|
||||
@apply active:scale-[0.98];
|
||||
}
|
||||
|
||||
/* Accent button - terracotta */
|
||||
.btn-accent {
|
||||
@apply bg-accent-500 hover:bg-accent-600 text-white font-semibold;
|
||||
@apply py-3.5 px-6 rounded-button min-h-touch;
|
||||
@apply shadow-button transition-all duration-300 ease-sanctuary;
|
||||
@apply active:scale-[0.98] hover:shadow-button-hover;
|
||||
}
|
||||
|
||||
/* Ghost button */
|
||||
.btn-ghost {
|
||||
@apply bg-transparent hover:bg-cream-100 text-secondary-700 font-medium;
|
||||
@apply py-3.5 px-6 rounded-button min-h-touch;
|
||||
@apply transition-all duration-300 ease-sanctuary;
|
||||
@apply active:scale-[0.98];
|
||||
}
|
||||
|
||||
/* Taken button */
|
||||
.btn-taken {
|
||||
@apply btn-primary;
|
||||
}
|
||||
|
||||
/* Appointment card */
|
||||
.card-appointment {
|
||||
@apply bg-surface rounded-card shadow-card p-4;
|
||||
@apply border-l-4 border-primary-500;
|
||||
@apply card-sanctuary p-5;
|
||||
@apply border-l-[6px] border-l-primary-400;
|
||||
}
|
||||
|
||||
/* Medication card */
|
||||
.card-medication {
|
||||
@apply bg-surface rounded-card shadow-card p-4;
|
||||
@apply flex items-center justify-between;
|
||||
@apply card-sanctuary p-5;
|
||||
}
|
||||
|
||||
/* Overdue styles */
|
||||
/* Overdue styles - warm terracotta */
|
||||
.overdue {
|
||||
@apply border-l-4 border-red-500 bg-red-50;
|
||||
@apply border-l-[6px] border-l-accent-500;
|
||||
@apply bg-accent-50/50;
|
||||
}
|
||||
|
||||
/* Emergency card - alert red but softened */
|
||||
.card-emergency {
|
||||
@apply bg-alert-50 border-2 border-alert-200 rounded-card-lg;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
/* Section styling */
|
||||
.section-warm {
|
||||
@apply bg-surface rounded-card-lg shadow-soft p-6;
|
||||
@apply border border-cream-200/60;
|
||||
}
|
||||
|
||||
/* Input styling */
|
||||
.input-sanctuary {
|
||||
@apply bg-surface border border-cream-300 rounded-button;
|
||||
@apply px-4 py-3.5 text-secondary-900 placeholder:text-secondary-400;
|
||||
@apply focus:outline-none focus:border-primary-400 focus:ring-2 focus:ring-primary-200;
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
/* Timeline styles */
|
||||
.timeline-item {
|
||||
@apply relative pl-6 pb-4;
|
||||
@apply relative pl-8 pb-6;
|
||||
}
|
||||
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
@apply absolute left-0 top-2 w-2 h-2 rounded-full bg-primary-400;
|
||||
@apply absolute left-0 top-2 w-3 h-3 rounded-full bg-primary-300;
|
||||
@apply ring-4 ring-primary-100;
|
||||
}
|
||||
|
||||
.timeline-item::after {
|
||||
content: '';
|
||||
@apply absolute left-[3px] top-4 w-0.5 h-full bg-border;
|
||||
@apply absolute left-[5px] top-5 w-0.5 h-full bg-cream-300;
|
||||
}
|
||||
|
||||
.timeline-item:last-child::after {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
/* Glass effect for overlays */
|
||||
.glass {
|
||||
@apply bg-surface/80 backdrop-blur-md;
|
||||
@apply border border-cream-200/50;
|
||||
}
|
||||
|
||||
/* Decorative blob shapes */
|
||||
.blob {
|
||||
@apply absolute rounded-full blur-3xl opacity-30 pointer-events-none;
|
||||
}
|
||||
|
||||
.blob-primary {
|
||||
@apply bg-primary-200;
|
||||
}
|
||||
|
||||
.blob-accent {
|
||||
@apply bg-accent-200;
|
||||
}
|
||||
|
||||
.blob-cream {
|
||||
@apply bg-cream-300;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Animation utilities */
|
||||
.animate-in {
|
||||
animation: animateIn 0.2s ease-out;
|
||||
animation: animateIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-out {
|
||||
animation: animateOut 0.15s ease-in forwards;
|
||||
animation: animateOut 0.2s ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes animateIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -151,55 +252,46 @@
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transform: translateY(12px);
|
||||
}
|
||||
}
|
||||
|
||||
.slide-in-from-bottom-4 {
|
||||
animation: slideInFromBottom 0.2s ease-out;
|
||||
}
|
||||
|
||||
.slide-out-to-bottom-4 {
|
||||
animation: slideOutToBottom 0.15s ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes slideInFromBottom {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutToBottom {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
}
|
||||
/* Stagger animation delays */
|
||||
.stagger-1 { animation-delay: 0.05s; }
|
||||
.stagger-2 { animation-delay: 0.1s; }
|
||||
.stagger-3 { animation-delay: 0.15s; }
|
||||
.stagger-4 { animation-delay: 0.2s; }
|
||||
.stagger-5 { animation-delay: 0.25s; }
|
||||
.stagger-6 { animation-delay: 0.3s; }
|
||||
|
||||
/* Fade utilities */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.fade-up {
|
||||
animation: fadeUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Scale utilities */
|
||||
.zoom-in-95 {
|
||||
animation: zoomIn 0.2s ease-out;
|
||||
animation: zoomIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes zoomIn {
|
||||
@@ -212,4 +304,29 @@
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Soft pulse for live elements */
|
||||
.pulse-soft {
|
||||
animation: pulseSoft 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulseSoft {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* Gradient text */
|
||||
.gradient-text {
|
||||
@apply bg-clip-text text-transparent;
|
||||
background-image: linear-gradient(135deg, #528252 0%, #3f663f 100%);
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { Toaster } from '@/components/ui'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Next Step - Health Management',
|
||||
description: 'A calm, reliable app to help manage appointments, medications, and notes for health care.',
|
||||
@@ -21,7 +18,7 @@ export const viewport: Viewport = {
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
themeColor: '#3a9563',
|
||||
themeColor: '#528252',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -33,8 +30,11 @@ export default function RootLayout({
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||
{/* Preconnect to Google Fonts for performance */}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
</head>
|
||||
<body className={inter.className}>
|
||||
<body className="paper-texture">
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
|
||||
@@ -34,6 +34,14 @@ function LoginForm() {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user needs to change password
|
||||
if (data.forcePasswordReset) {
|
||||
showToast('Please change your password to continue', 'info')
|
||||
router.push('/change-password')
|
||||
router.refresh()
|
||||
return
|
||||
}
|
||||
|
||||
showToast('Welcome back!', 'success')
|
||||
// If there's a redirect param (e.g., from invite link), go there
|
||||
router.push(redirectTo || '/today')
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Heart, Shield, ArrowRight } from 'lucide-react'
|
||||
import { Button, Input, Card, showToast } from '@/components/ui'
|
||||
import { Heart, Shield, ArrowRight, Sparkles, Users, Bell } from 'lucide-react'
|
||||
import { Button, Input, showToast } from '@/components/ui'
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter()
|
||||
@@ -12,6 +12,11 @@ export default function OnboardingPage() {
|
||||
const [clinicPhone, setClinicPhone] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const handleAcceptDisclaimer = () => {
|
||||
setStep('workspace')
|
||||
@@ -45,7 +50,7 @@ export default function OnboardingPage() {
|
||||
})
|
||||
}
|
||||
|
||||
showToast('All set! Welcome to Next Step.', 'success')
|
||||
showToast('Welcome to Next Step', 'success')
|
||||
router.push('/today')
|
||||
router.refresh()
|
||||
} catch (err) {
|
||||
@@ -57,101 +62,176 @@ export default function OnboardingPage() {
|
||||
|
||||
if (step === 'disclaimer') {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
|
||||
<div className="min-h-screen paper-texture flex flex-col items-center justify-center p-6">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-amber-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Shield className="w-8 h-8 text-amber-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900">Important Notice</h1>
|
||||
{/* Decorative blobs */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="blob blob-primary w-96 h-96 -top-48 -right-48" />
|
||||
<div className="blob blob-accent w-80 h-80 bottom-20 -left-40" />
|
||||
<div className="blob blob-cream w-64 h-64 top-1/2 right-1/4" />
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<div className="space-y-4 text-secondary-700">
|
||||
<p>
|
||||
<strong>Next Step is a tracking tool only.</strong> It helps you and your family
|
||||
stay organized with appointments and medications.
|
||||
<div className={`relative transition-all duration-1000 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||
{/* Logo/Icon */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-24 h-24 rounded-card-lg bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center mx-auto mb-6 shadow-elevated">
|
||||
<Heart className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
|
||||
<h1 className="font-display text-display-md text-secondary-900 mb-2">
|
||||
Next Step
|
||||
</h1>
|
||||
<p className="text-secondary-500 text-lg">
|
||||
Supporting you through every step
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<strong className="text-red-600">This app does not provide medical advice.</strong>{' '}
|
||||
Always consult your healthcare team for medical decisions.
|
||||
</p>
|
||||
{/* Disclaimer Card */}
|
||||
<div className="section-warm mb-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-12 h-12 rounded-full bg-accent-100 flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-accent-600" />
|
||||
</div>
|
||||
<h2 className="font-display text-xl text-secondary-900">Important Notice</h2>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<strong>For emergencies:</strong> Call 000 (Australia) or your local emergency
|
||||
services immediately.
|
||||
</p>
|
||||
<div className="space-y-4 text-secondary-700">
|
||||
<div className="flex gap-3">
|
||||
<Sparkles className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" />
|
||||
<p>
|
||||
<strong className="text-secondary-900">Next Step is a tracking tool only.</strong>{' '}
|
||||
It helps you and your family stay organized with appointments and medications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
If you have questions about your treatment, contact your clinic directly using the
|
||||
button we'll help you set up.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<div className="w-5 h-5 rounded-full bg-alert-100 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-alert-600 text-xs font-bold">!</span>
|
||||
</div>
|
||||
<p>
|
||||
<strong className="text-alert-600">This app does not provide medical advice.</strong>{' '}
|
||||
Always consult your healthcare team for medical decisions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<p className="text-sm text-secondary-500">
|
||||
By continuing, you acknowledge that Next Step is for tracking purposes only and
|
||||
does not replace professional medical advice.
|
||||
<div className="flex gap-3">
|
||||
<div className="w-5 h-5 rounded-full bg-alert-500 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-white text-xs font-bold">000</span>
|
||||
</div>
|
||||
<p>
|
||||
<strong>For emergencies:</strong> Call 000 (Australia) or your local emergency services immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Users className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" />
|
||||
<p>
|
||||
Have questions about your treatment? Contact your clinic directly using the button we'll help you set up.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-cream-200">
|
||||
<p className="text-sm text-secondary-500 text-center">
|
||||
By continuing, you acknowledge that Next Step is for tracking purposes only and does not replace professional medical advice.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Button onClick={handleAcceptDisclaimer} fullWidth>
|
||||
I Understand
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</Button>
|
||||
<button
|
||||
onClick={handleAcceptDisclaimer}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2 text-lg py-4"
|
||||
>
|
||||
I Understand
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-primary-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Heart className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900">Set Up Your Plan</h1>
|
||||
<p className="text-secondary-500 mt-1">Create a workspace to get started</p>
|
||||
<div className="min-h-screen paper-texture flex flex-col items-center justify-center p-6">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Decorative blobs */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="blob blob-primary w-80 h-80 -top-32 right-0" />
|
||||
<div className="blob blob-cream w-64 h-64 bottom-0 left-0" />
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<form onSubmit={handleCreateWorkspace} className="space-y-4">
|
||||
<Input
|
||||
label="Workspace Name"
|
||||
type="text"
|
||||
value={workspaceName}
|
||||
onChange={(e) => setWorkspaceName(e.target.value)}
|
||||
placeholder="e.g., Grace's Plan"
|
||||
helperText="This is how family members will identify this workspace"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Clinic Phone Number"
|
||||
type="tel"
|
||||
value={clinicPhone}
|
||||
onChange={(e) => setClinicPhone(e.target.value)}
|
||||
placeholder="e.g., 08 9400 1234"
|
||||
helperText="We'll add a 'Call Clinic' button for quick access"
|
||||
/>
|
||||
<div className={`relative transition-all duration-700 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-20 h-20 rounded-card-lg bg-gradient-to-br from-accent-400 to-accent-500 flex items-center justify-center mx-auto mb-6 shadow-elevated">
|
||||
<Sparkles className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
|
||||
<h1 className="font-display text-display-sm text-secondary-900 mb-2">
|
||||
Set Up Your Plan
|
||||
</h1>
|
||||
<p className="text-secondary-500 text-lg">Create a workspace to get started</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleCreateWorkspace} className="section-warm space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Workspace Name
|
||||
<span className="text-alert-500 ml-1">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={workspaceName}
|
||||
onChange={(e) => setWorkspaceName(e.target.value)}
|
||||
placeholder="e.g., Grace's Plan"
|
||||
className="input-sanctuary w-full"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-secondary-400 mt-2">
|
||||
This is how family members will identify this workspace
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Clinic Phone Number
|
||||
<span className="text-secondary-400 font-normal ml-1">(optional)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Bell className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
|
||||
<input
|
||||
type="tel"
|
||||
value={clinicPhone}
|
||||
onChange={(e) => setClinicPhone(e.target.value)}
|
||||
placeholder="e.g., 08 9400 1234"
|
||||
className="input-sanctuary w-full pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-400 mt-2">
|
||||
We'll add a quick "Call Clinic" button for easy access
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
|
||||
{error}
|
||||
</p>
|
||||
<div className="bg-alert-50 border border-alert-200 rounded-card p-4">
|
||||
<p className="text-sm text-alert-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" fullWidth loading={loading}>
|
||||
Create Workspace
|
||||
</Button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full text-lg py-4 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Workspace'}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-sm text-secondary-500">
|
||||
You can add family members later from Settings
|
||||
</p>
|
||||
<p className="text-center text-sm text-secondary-400 mt-6">
|
||||
You can add family members later from Settings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Phone, User, Droplets, AlertTriangle, Activity, Stethoscope } from 'lucide-react'
|
||||
import { Phone, User, Droplets, AlertTriangle, Activity, Stethoscope, HeartPulse, FileText } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
interface EmergencyInfo {
|
||||
@@ -39,49 +39,66 @@ export function EmergencyCard({ info, medications, variant = 'full' }: Emergency
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-red-50 border-2 border-red-200 rounded-xl overflow-hidden">
|
||||
<div className="bg-surface border-2 border-alert-200 rounded-card-lg overflow-hidden shadow-elevated">
|
||||
{/* Header */}
|
||||
<div className="bg-red-600 text-white px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-6 h-6" />
|
||||
<h2 className="text-xl font-bold">Emergency Information</h2>
|
||||
<div className="bg-gradient-to-r from-alert-500 to-alert-600 text-white px-6 py-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-white/20 flex items-center justify-center">
|
||||
<AlertTriangle className="w-7 h-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-display text-2xl">Emergency Information</h2>
|
||||
<p className="text-alert-100 text-sm">Critical medical details for emergencies</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Patient Info */}
|
||||
{info.patientName && (
|
||||
<div className="flex items-start gap-3">
|
||||
<User className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-red-700 font-medium">Patient Name</p>
|
||||
<p className="text-lg font-bold text-secondary-900">{info.patientName}</p>
|
||||
<div className="flex items-start gap-4 bg-cream-50 rounded-card p-4 border border-cream-200">
|
||||
<div className="w-12 h-12 rounded-full bg-cream-200 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-6 h-6 text-cream-700" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-secondary-500 uppercase tracking-wide font-semibold mb-1">
|
||||
Patient
|
||||
</p>
|
||||
<p className="font-display text-xl text-secondary-900">{info.patientName}</p>
|
||||
{info.patientDOB && (
|
||||
<p className="text-sm text-secondary-600">DOB: {formatDate(info.patientDOB)}</p>
|
||||
<p className="text-sm text-secondary-600 mt-1">
|
||||
Born: {formatDate(info.patientDOB)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blood Type */}
|
||||
{/* Blood Type - Large and prominent */}
|
||||
{info.bloodType && (
|
||||
<div className="flex items-start gap-3">
|
||||
<Droplets className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-alert-100 to-alert-200 flex items-center justify-center flex-shrink-0">
|
||||
<Droplets className="w-6 h-6 text-alert-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-red-700 font-medium">Blood Type</p>
|
||||
<p className="text-2xl font-bold text-red-600">{info.bloodType}</p>
|
||||
<p className="text-xs text-secondary-500 uppercase tracking-wide font-semibold">
|
||||
Blood Type
|
||||
</p>
|
||||
<p className="text-3xl font-display text-alert-600">{info.bloodType}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Allergies - High visibility */}
|
||||
{info.allergies && (
|
||||
<div className="bg-red-100 border border-red-300 rounded-lg p-3">
|
||||
<div className="bg-alert-50 border-l-4 border-alert-500 rounded-r-card p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
<AlertTriangle className="w-6 h-6 text-alert-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-red-700 font-bold uppercase">Allergies</p>
|
||||
<p className="text-secondary-900 font-medium mt-1">{info.allergies}</p>
|
||||
<p className="text-sm text-alert-700 font-bold uppercase tracking-wide mb-2">
|
||||
Allergies
|
||||
</p>
|
||||
<p className="text-secondary-900 font-medium text-lg">{info.allergies}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,10 +106,14 @@ export function EmergencyCard({ info, medications, variant = 'full' }: Emergency
|
||||
|
||||
{/* Medical Conditions */}
|
||||
{info.medicalConditions && (
|
||||
<div className="flex items-start gap-3">
|
||||
<Activity className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-red-700 font-medium">Medical Conditions</p>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
||||
<HeartPulse className="w-6 h-6 text-primary-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-secondary-500 uppercase tracking-wide font-semibold mb-1">
|
||||
Medical Conditions
|
||||
</p>
|
||||
<p className="text-secondary-900">{info.medicalConditions}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,34 +121,49 @@ export function EmergencyCard({ info, medications, variant = 'full' }: Emergency
|
||||
|
||||
{/* Current Medications */}
|
||||
{variant === 'full' && medications && medications.length > 0 && (
|
||||
<div className="border-t border-red-200 pt-4">
|
||||
<p className="text-sm text-red-700 font-bold mb-2">Current Medications</p>
|
||||
<ul className="space-y-1">
|
||||
{medications.map((med, i) => (
|
||||
<li key={i} className="text-secondary-900">
|
||||
<span className="font-medium">{med.name}</span>
|
||||
{med.instructions && (
|
||||
<span className="text-secondary-600"> - {med.instructions}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="border-t-2 border-cream-200 pt-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FileText className="w-5 h-5 text-primary-500" />
|
||||
<p className="text-sm text-secondary-700 font-semibold uppercase tracking-wide">
|
||||
Current Medications
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-cream-50 rounded-card p-4 border border-cream-200">
|
||||
<ul className="space-y-3">
|
||||
{medications.map((med, i) => (
|
||||
<li key={i} className="flex items-start gap-3">
|
||||
<span className="w-2 h-2 rounded-full bg-primary-400 mt-2 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-semibold text-secondary-900">{med.name}</span>
|
||||
{med.instructions && (
|
||||
<span className="text-secondary-600"> — {med.instructions}</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Doctor Info */}
|
||||
{info.primaryPhysician && (
|
||||
<div className="border-t border-red-200 pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Stethoscope className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-red-700 font-medium">Primary Physician</p>
|
||||
<p className="text-secondary-900 font-medium">{info.primaryPhysician}</p>
|
||||
<div className="border-t-2 border-cream-200 pt-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-secondary-100 flex items-center justify-center flex-shrink-0">
|
||||
<Stethoscope className="w-6 h-6 text-secondary-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-secondary-500 uppercase tracking-wide font-semibold mb-1">
|
||||
Primary Physician
|
||||
</p>
|
||||
<p className="font-display text-lg text-secondary-900">{info.primaryPhysician}</p>
|
||||
{info.physicianPhone && (
|
||||
<a
|
||||
href={`tel:${info.physicianPhone}`}
|
||||
className="text-primary-600 hover:underline"
|
||||
className="inline-flex items-center gap-2 mt-2 text-primary-600 font-medium hover:text-primary-700 hover:underline"
|
||||
>
|
||||
<Phone className="w-4 h-4" />
|
||||
{info.physicianPhone}
|
||||
</a>
|
||||
)}
|
||||
@@ -138,38 +174,42 @@ export function EmergencyCard({ info, medications, variant = 'full' }: Emergency
|
||||
|
||||
{/* Emergency Contacts */}
|
||||
{(info.clinicPhone || info.emergencyPhone) && (
|
||||
<div className="border-t border-red-200 pt-4 space-y-3">
|
||||
<p className="text-sm text-red-700 font-bold">Emergency Contacts</p>
|
||||
<div className="border-t-2 border-cream-200 pt-6">
|
||||
<p className="text-sm text-secondary-700 font-semibold uppercase tracking-wide mb-4 flex items-center gap-2">
|
||||
<Phone className="w-5 h-5 text-alert-500" />
|
||||
Emergency Contacts
|
||||
</p>
|
||||
<div className="grid gap-3">
|
||||
{info.clinicPhone && (
|
||||
<a
|
||||
href={`tel:${info.clinicPhone}`}
|
||||
className="flex items-center gap-4 p-4 bg-alert-50 rounded-card border border-alert-200 hover:bg-alert-100 hover:border-alert-300 hover:shadow-soft transition-all duration-300 group"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-alert-500 to-alert-600 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
|
||||
<Phone className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-secondary-900 text-lg">Call Clinic</p>
|
||||
<p className="text-alert-600 font-medium">{info.clinicPhone}</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{info.clinicPhone && (
|
||||
<a
|
||||
href={`tel:${info.clinicPhone}`}
|
||||
className="flex items-center gap-3 p-3 bg-white rounded-lg border border-red-200 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-red-600 flex items-center justify-center">
|
||||
<Phone className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-secondary-900">Call Clinic</p>
|
||||
<p className="text-sm text-secondary-600">{info.clinicPhone}</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{info.emergencyPhone && (
|
||||
<a
|
||||
href={`tel:${info.emergencyPhone}`}
|
||||
className="flex items-center gap-3 p-3 bg-white rounded-lg border border-red-200 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-red-600 flex items-center justify-center">
|
||||
<Phone className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-secondary-900">Emergency Contact</p>
|
||||
<p className="text-sm text-secondary-600">{info.emergencyPhone}</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
{info.emergencyPhone && (
|
||||
<a
|
||||
href={`tel:${info.emergencyPhone}`}
|
||||
className="flex items-center gap-4 p-4 bg-cream-50 rounded-card border border-cream-200 hover:bg-cream-100 hover:border-cream-300 hover:shadow-soft transition-all duration-300 group"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-secondary-500 to-secondary-600 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
|
||||
<Phone className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-secondary-900 text-lg">Emergency Contact</p>
|
||||
<p className="text-secondary-600 font-medium">{info.emergencyPhone}</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
503
src/components/medications/MedicationForm.tsx
Normal file
503
src/components/medications/MedicationForm.tsx
Normal file
@@ -0,0 +1,503 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Clock, Calendar, Repeat, Pill, Package, ChevronDown, Plus, X } from 'lucide-react'
|
||||
import { Button, Input, Textarea, Select, showToast } from '@/components/ui'
|
||||
import { useApp } from '@/app/(app)/provider'
|
||||
|
||||
type ScheduleType = 'FIXED_TIMES' | 'INTERVAL' | 'WEEKDAYS' | 'PRN'
|
||||
|
||||
const scheduleTypeOptions = [
|
||||
{ value: 'FIXED_TIMES', label: 'Fixed times daily', icon: Clock, desc: 'Same times every day' },
|
||||
{ value: 'INTERVAL', label: 'Every X hours', icon: Repeat, desc: 'Regular intervals' },
|
||||
{ value: 'WEEKDAYS', label: 'Specific days', icon: Calendar, desc: 'Certain days of the week' },
|
||||
{ value: 'PRN', label: 'As needed (PRN)', icon: Pill, desc: 'When you need it' },
|
||||
]
|
||||
|
||||
const weekdays = [
|
||||
{ value: 0, label: 'Sun', full: 'Sunday' },
|
||||
{ value: 1, label: 'Mon', full: 'Monday' },
|
||||
{ value: 2, label: 'Tue', full: 'Tuesday' },
|
||||
{ value: 3, label: 'Wed', full: 'Wednesday' },
|
||||
{ value: 4, label: 'Thu', full: 'Thursday' },
|
||||
{ value: 5, label: 'Fri', full: 'Friday' },
|
||||
{ value: 6, label: 'Sat', full: 'Saturday' },
|
||||
]
|
||||
|
||||
interface MedicationFormProps {
|
||||
initialData?: {
|
||||
id?: string
|
||||
name: string
|
||||
instructions?: string | null
|
||||
scheduleType: string
|
||||
scheduleData: any
|
||||
active?: boolean
|
||||
pillCount?: number | null
|
||||
pillsPerDose?: number | null
|
||||
refillThreshold?: number | null
|
||||
}
|
||||
isEditing?: boolean
|
||||
}
|
||||
|
||||
export function MedicationForm({ initialData, isEditing = false }: MedicationFormProps) {
|
||||
const router = useRouter()
|
||||
const { currentWorkspace, refreshData } = useApp()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
const [name, setName] = useState(initialData?.name || '')
|
||||
const [instructions, setInstructions] = useState(initialData?.instructions || '')
|
||||
const [scheduleType, setScheduleType] = useState<ScheduleType>((initialData?.scheduleType as ScheduleType) || 'FIXED_TIMES')
|
||||
|
||||
// Fixed times
|
||||
const [times, setTimes] = useState<string[]>(initialData?.scheduleData?.times || ['08:00'])
|
||||
|
||||
// Interval
|
||||
const [intervalHours, setIntervalHours] = useState(initialData?.scheduleData?.hours || 8)
|
||||
const [startTime, setStartTime] = useState(initialData?.scheduleData?.startTime || '08:00')
|
||||
|
||||
// Weekdays
|
||||
const [selectedDays, setSelectedDays] = useState<number[]>(initialData?.scheduleData?.days || [1, 3, 5])
|
||||
const [weekdayTime, setWeekdayTime] = useState(initialData?.scheduleData?.time || '09:00')
|
||||
|
||||
// PRN
|
||||
const [minHoursBetween, setMinHoursBetween] = useState(initialData?.scheduleData?.minHoursBetween || 4)
|
||||
|
||||
// Refill tracking (optional)
|
||||
const hasRefillInfo = initialData?.pillCount !== null && initialData?.pillCount !== undefined
|
||||
const [trackRefills, setTrackRefills] = useState(hasRefillInfo)
|
||||
const [pillCount, setPillCount] = useState<number | ''>(initialData?.pillCount ?? '')
|
||||
const [pillsPerDose, setPillsPerDose] = useState(initialData?.pillsPerDose || 1)
|
||||
const [refillThreshold, setRefillThreshold] = useState(initialData?.refillThreshold || 7)
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData?.scheduleType !== scheduleType) {
|
||||
// Keep current state if user is just switching around in new mode
|
||||
}
|
||||
}, [scheduleType, initialData])
|
||||
|
||||
const addTime = () => {
|
||||
setTimes([...times, '12:00'])
|
||||
}
|
||||
|
||||
const removeTime = (index: number) => {
|
||||
setTimes(times.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const updateTime = (index: number, value: string) => {
|
||||
const newTimes = [...times]
|
||||
newTimes[index] = value
|
||||
setTimes(newTimes)
|
||||
}
|
||||
|
||||
const toggleDay = (day: number) => {
|
||||
if (selectedDays.includes(day)) {
|
||||
setSelectedDays(selectedDays.filter((d) => d !== day))
|
||||
} else {
|
||||
setSelectedDays([...selectedDays, day].sort())
|
||||
}
|
||||
}
|
||||
|
||||
const buildScheduleData = () => {
|
||||
switch (scheduleType) {
|
||||
case 'FIXED_TIMES':
|
||||
return { type: 'FIXED_TIMES', times }
|
||||
case 'INTERVAL':
|
||||
return { type: 'INTERVAL', hours: intervalHours, startTime }
|
||||
case 'WEEKDAYS':
|
||||
return { type: 'WEEKDAYS', days: selectedDays, time: weekdayTime }
|
||||
case 'PRN':
|
||||
return { type: 'PRN', minHoursBetween }
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const url = isEditing && initialData?.id
|
||||
? `/api/workspaces/${currentWorkspace.id}/medications/${initialData.id}`
|
||||
: `/api/workspaces/${currentWorkspace.id}/medications`
|
||||
|
||||
const method = isEditing ? 'PATCH' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
instructions: instructions || null,
|
||||
scheduleType,
|
||||
scheduleData: buildScheduleData(),
|
||||
active: true,
|
||||
...(trackRefills && pillCount !== '' && {
|
||||
pillCount: Number(pillCount),
|
||||
pillsPerDose,
|
||||
refillThreshold,
|
||||
}),
|
||||
...(isEditing && !trackRefills && {
|
||||
pillCount: null,
|
||||
pillsPerDose: null,
|
||||
refillThreshold: null,
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Failed to save medication')
|
||||
}
|
||||
|
||||
await refreshData()
|
||||
showToast(isEditing ? 'Medication updated' : 'Medication added', 'success')
|
||||
router.push('/meds')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Something went wrong')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const currentScheduleOption = scheduleTypeOptions.find(opt => opt.value === scheduleType)
|
||||
const ScheduleIcon = currentScheduleOption?.icon || Clock
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 transition-all duration-700 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
|
||||
{/* Form Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center mx-auto mb-4 shadow-lg">
|
||||
<Pill className="w-10 h-10 text-primary-600" />
|
||||
</div>
|
||||
<h2 className="font-display text-display-sm text-secondary-900">
|
||||
{isEditing ? 'Edit Medication' : 'Add Medication'}
|
||||
</h2>
|
||||
<p className="text-secondary-500 mt-2">
|
||||
{isEditing ? 'Update your medication details' : 'Keep track of your medications'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Info Section */}
|
||||
<div className="section-warm space-y-5">
|
||||
<h3 className="font-display text-lg text-secondary-900 flex items-center gap-2">
|
||||
<span className="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center text-primary-600 text-sm font-semibold">1</span>
|
||||
Basic Information
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Medication Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Paracetamol 500mg"
|
||||
className="input-sanctuary w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Instructions
|
||||
<span className="text-secondary-400 font-normal ml-1">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="e.g., Take with food, Avoid grapefruit..."
|
||||
rows={2}
|
||||
className="input-sanctuary w-full resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule Section */}
|
||||
<div className="section-warm space-y-5">
|
||||
<h3 className="font-display text-lg text-secondary-900 flex items-center gap-2">
|
||||
<span className="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center text-primary-600 text-sm font-semibold">2</span>
|
||||
Schedule
|
||||
</h3>
|
||||
|
||||
{/* Schedule Type Selector */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{scheduleTypeOptions.map((option) => {
|
||||
const Icon = option.icon
|
||||
const isSelected = scheduleType === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setScheduleType(option.value as ScheduleType)}
|
||||
className={`p-4 rounded-card border-2 text-left transition-all duration-300 ${
|
||||
isSelected
|
||||
? 'border-primary-400 bg-primary-50/50 shadow-soft'
|
||||
: 'border-cream-200 bg-surface hover:border-cream-300 hover:shadow-soft'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-6 h-6 mb-2 ${isSelected ? 'text-primary-600' : 'text-secondary-400'}`} />
|
||||
<p className={`font-semibold text-sm ${isSelected ? 'text-primary-800' : 'text-secondary-700'}`}>
|
||||
{option.label}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-400 mt-0.5">{option.desc}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Schedule-specific options */}
|
||||
<div className="bg-cream-50/50 rounded-card p-5 border border-cream-200/60">
|
||||
{scheduleType === 'FIXED_TIMES' && (
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium text-secondary-700">
|
||||
Times to take each day
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{times.map((time, index) => (
|
||||
<div key={index} className="flex gap-2 items-center">
|
||||
<div className="flex-1 relative">
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
|
||||
<input
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => updateTime(index, e.target.value)}
|
||||
className="input-sanctuary w-full pl-10"
|
||||
/>
|
||||
</div>
|
||||
{times.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTime(index)}
|
||||
className="w-10 h-10 rounded-button bg-cream-100 hover:bg-cream-200 flex items-center justify-center text-secondary-500 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTime}
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add another time
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scheduleType === 'INTERVAL' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Every (hours)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={72}
|
||||
value={intervalHours}
|
||||
onChange={(e) => setIntervalHours(parseInt(e.target.value) || 1)}
|
||||
className="input-sanctuary w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Starting at
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
|
||||
<input
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className="input-sanctuary w-full pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500">
|
||||
Example: Every {intervalHours} hours starting at {startTime}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scheduleType === 'WEEKDAYS' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-3">
|
||||
Which days?
|
||||
</label>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{weekdays.map((day) => {
|
||||
const isSelected = selectedDays.includes(day.value)
|
||||
return (
|
||||
<button
|
||||
key={day.value}
|
||||
type="button"
|
||||
onClick={() => toggleDay(day.value)}
|
||||
className={`aspect-square rounded-button text-sm font-medium transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'bg-primary-500 text-white shadow-lg scale-105'
|
||||
: 'bg-cream-100 text-secondary-600 hover:bg-cream-200'
|
||||
}`}
|
||||
title={day.full}
|
||||
>
|
||||
{day.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
At what time?
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
|
||||
<input
|
||||
type="time"
|
||||
value={weekdayTime}
|
||||
onChange={(e) => setWeekdayTime(e.target.value)}
|
||||
className="input-sanctuary w-full pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scheduleType === 'PRN' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Minimum hours between doses
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={72}
|
||||
step={0.5}
|
||||
value={minHoursBetween}
|
||||
onChange={(e) => setMinHoursBetween(parseFloat(e.target.value) || 4)}
|
||||
className="input-sanctuary w-full"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500">
|
||||
Shows "Available" when enough time has passed since your last dose
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refill Tracking Section */}
|
||||
<div className="section-warm space-y-5">
|
||||
<h3 className="font-display text-lg text-secondary-900 flex items-center gap-2">
|
||||
<span className="w-8 h-8 rounded-full bg-cream-200 flex items-center justify-center text-secondary-600 text-sm font-semibold">
|
||||
<Package className="w-4 h-4" />
|
||||
</span>
|
||||
Refill Tracking
|
||||
<span className="text-sm font-normal text-secondary-400 ml-auto">Optional</span>
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="trackRefills"
|
||||
checked={trackRefills}
|
||||
onChange={(e) => setTrackRefills(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-cream-300 text-primary-500 focus:ring-primary-400"
|
||||
/>
|
||||
<label htmlFor="trackRefills" className="text-sm text-secondary-700">
|
||||
Track pill count and get refill reminders
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{trackRefills && (
|
||||
<div className="space-y-4 pt-2 pl-8 border-l-2 border-cream-200">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Current pill count
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={pillCount}
|
||||
onChange={(e) => setPillCount(e.target.value === '' ? '' : parseInt(e.target.value))}
|
||||
placeholder="e.g., 30"
|
||||
className="input-sanctuary w-full"
|
||||
/>
|
||||
<p className="text-xs text-secondary-400 mt-1.5">How many pills do you have now?</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Pills per dose
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={pillsPerDose}
|
||||
onChange={(e) => setPillsPerDose(parseInt(e.target.value) || 1)}
|
||||
className="input-sanctuary w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Alert when below
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={refillThreshold}
|
||||
onChange={(e) => setRefillThreshold(parseInt(e.target.value) || 7)}
|
||||
className="input-sanctuary w-full"
|
||||
/>
|
||||
<p className="text-xs text-secondary-400 mt-1.5">pills remaining</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-alert-50 border border-alert-200 rounded-card p-4">
|
||||
<p className="text-sm text-alert-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="btn-secondary flex-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary flex-1 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Saving...' : isEditing ? 'Update Medication' : 'Save Medication'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -31,15 +31,40 @@ export function NotificationPermission({ workspaceId }: NotificationPermissionPr
|
||||
return
|
||||
}
|
||||
|
||||
// Check if PushManager is available (not available in all browsers/contexts)
|
||||
if (!('PushManager' in window)) {
|
||||
setPermission('unsupported')
|
||||
return
|
||||
}
|
||||
|
||||
const perm = Notification.permission as PermissionState
|
||||
setPermission(perm)
|
||||
|
||||
if (perm === 'granted') {
|
||||
// Check if already subscribed
|
||||
// Check if already subscribed with timeout
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
setIsSubscribed(!!subscription)
|
||||
const registrationPromise = navigator.serviceWorker.ready
|
||||
const timeoutPromise = new Promise<ServiceWorkerRegistration>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout')), 5000)
|
||||
)
|
||||
const registration = await Promise.race([registrationPromise, timeoutPromise])
|
||||
|
||||
if (registration.pushManager) {
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
setIsSubscribed(!!subscription)
|
||||
|
||||
// Auto-sync subscription to server to ensure it exists
|
||||
if (subscription) {
|
||||
fetch('/api/notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subscription: subscription.toJSON(),
|
||||
workspaceId,
|
||||
}),
|
||||
}).catch(console.error)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check subscription:', err)
|
||||
}
|
||||
@@ -66,14 +91,54 @@ export function NotificationPermission({ workspaceId }: NotificationPermissionPr
|
||||
}
|
||||
const { publicKey } = await keyResponse.json()
|
||||
|
||||
// Register service worker if not already registered
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
// Ensure service worker is registered and active
|
||||
let registration: ServiceWorkerRegistration
|
||||
|
||||
// Subscribe to push
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
// First, try to register the service worker (in case it wasn't registered yet)
|
||||
try {
|
||||
registration = await navigator.serviceWorker.register('/sw.js', { scope: '/' })
|
||||
|
||||
// Wait for it to be active
|
||||
if (registration.installing || registration.waiting) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const sw = registration.installing || registration.waiting
|
||||
if (!sw) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => reject(new Error('Service worker activation timeout')), 10000)
|
||||
|
||||
sw.addEventListener('statechange', () => {
|
||||
if (sw.state === 'activated') {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
} else if (sw.state === 'redundant') {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error('Service worker became redundant'))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
} catch (regError: any) {
|
||||
console.error('Service worker registration error:', regError)
|
||||
throw new Error('Failed to register service worker: ' + regError.message)
|
||||
}
|
||||
|
||||
// Check if push manager is available
|
||||
if (!registration.pushManager) {
|
||||
throw new Error('Push notifications not supported on this device')
|
||||
}
|
||||
|
||||
// Subscribe to push with timeout
|
||||
const subscribePromise = registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
})
|
||||
const subscribeTimeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Push subscription timed out - this device may not support web push')), 15000)
|
||||
)
|
||||
const subscription = await Promise.race([subscribePromise, subscribeTimeout])
|
||||
|
||||
// Send subscription to server
|
||||
const response = await fetch('/api/notifications/subscribe', {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { format, addHours } from 'date-fns'
|
||||
|
||||
const TIMEZONE = 'Australia/Perth'
|
||||
|
||||
interface Appointment {
|
||||
id: string
|
||||
title: string
|
||||
@@ -35,8 +37,19 @@ export function generateICalendar(
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//NextStep//Health Management//EN',
|
||||
`X-WR-CALNAME:${escapeICalText(workspaceName)}`,
|
||||
`X-WR-TIMEZONE:${TIMEZONE}`,
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH',
|
||||
// Add timezone definition for Australia/Perth
|
||||
'BEGIN:VTIMEZONE',
|
||||
`TZID:${TIMEZONE}`,
|
||||
'BEGIN:STANDARD',
|
||||
'DTSTART:19700101T000000',
|
||||
'TZOFFSETFROM:+0800',
|
||||
'TZOFFSETTO:+0800',
|
||||
'TZNAME:AWST',
|
||||
'END:STANDARD',
|
||||
'END:VTIMEZONE',
|
||||
]
|
||||
|
||||
for (const appt of appointments) {
|
||||
@@ -45,9 +58,9 @@ export function generateICalendar(
|
||||
|
||||
lines.push('BEGIN:VEVENT')
|
||||
lines.push(`UID:${appt.id}@nextstep`)
|
||||
lines.push(`DTSTAMP:${formatICalDate(new Date())}`)
|
||||
lines.push(`DTSTART:${formatICalDate(startDate)}`)
|
||||
lines.push(`DTEND:${formatICalDate(endDate)}`)
|
||||
lines.push(`DTSTAMP:${formatICalDateUTC(new Date())}`)
|
||||
lines.push(`DTSTART;TZID=${TIMEZONE}:${formatICalDateLocal(startDate)}`)
|
||||
lines.push(`DTEND;TZID=${TIMEZONE}:${formatICalDateLocal(endDate)}`)
|
||||
lines.push(`SUMMARY:${escapeICalText(appt.title)}`)
|
||||
|
||||
if (appt.location) {
|
||||
@@ -116,9 +129,9 @@ export function generateMedicationEvents(
|
||||
|
||||
lines.push('BEGIN:VEVENT')
|
||||
lines.push(`UID:med-${med.id}-${dateStr}-${time}@nextstep`)
|
||||
lines.push(`DTSTAMP:${formatICalDate(new Date())}`)
|
||||
lines.push(`DTSTART:${formatICalDate(startDate)}`)
|
||||
lines.push(`DTEND:${formatICalDate(endDate)}`)
|
||||
lines.push(`DTSTAMP:${formatICalDateUTC(new Date())}`)
|
||||
lines.push(`DTSTART;TZID=${TIMEZONE}:${formatICalDateLocal(startDate)}`)
|
||||
lines.push(`DTEND;TZID=${TIMEZONE}:${formatICalDateLocal(endDate)}`)
|
||||
lines.push(`SUMMARY:Take ${escapeICalText(med.name)}`)
|
||||
lines.push('CATEGORIES:MEDICATION')
|
||||
|
||||
@@ -161,11 +174,16 @@ function getMedicationTimes(med: Medication): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
function formatICalDate(date: Date): string {
|
||||
// Format: YYYYMMDDTHHMMSSZ
|
||||
function formatICalDateUTC(date: Date): string {
|
||||
// Format: YYYYMMDDTHHMMSSZ (UTC)
|
||||
return format(date, "yyyyMMdd'T'HHmmss'Z'")
|
||||
}
|
||||
|
||||
function formatICalDateLocal(date: Date): string {
|
||||
// Format: YYYYMMDDTHHMMSS (local time, no Z suffix)
|
||||
return format(date, "yyyyMMdd'T'HHmmss")
|
||||
}
|
||||
|
||||
function escapeICalText(text: string): string {
|
||||
return text
|
||||
.replace(/\\/g, '\\\\')
|
||||
|
||||
@@ -133,6 +133,11 @@ function calculateIntervalDue(
|
||||
// Calculate how many intervals have passed since start time today
|
||||
const minutesSinceStart = differenceInMinutes(now, startToday)
|
||||
const intervalMinutes = schedule.hours * 60
|
||||
|
||||
if (intervalMinutes <= 0) {
|
||||
return startToday
|
||||
}
|
||||
|
||||
const intervalsPassed = Math.floor(minutesSinceStart / intervalMinutes)
|
||||
const nextDue = addMinutes(startToday, (intervalsPassed + 1) * intervalMinutes)
|
||||
|
||||
|
||||
@@ -9,55 +9,93 @@ const config: Config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Calm, healing palette
|
||||
// Warm Sanctuary - Primary: Soft sage green (healing, calm)
|
||||
primary: {
|
||||
50: '#f0f9f4',
|
||||
100: '#dcf1e4',
|
||||
200: '#bbe3cc',
|
||||
300: '#8dcda8',
|
||||
400: '#5bb17f',
|
||||
500: '#3a9563',
|
||||
600: '#2a784e',
|
||||
700: '#235f40',
|
||||
800: '#1f4c35',
|
||||
900: '#1b3f2d',
|
||||
950: '#0d2319',
|
||||
50: '#f4f7f4',
|
||||
100: '#e3ebe3',
|
||||
200: '#c5d9c5',
|
||||
300: '#9bbf9b',
|
||||
400: '#729f72',
|
||||
500: '#528252',
|
||||
600: '#3f663f',
|
||||
700: '#345234',
|
||||
800: '#2b412b',
|
||||
900: '#243624',
|
||||
950: '#121f12',
|
||||
},
|
||||
// Warm neutrals - cream, stone, warm gray
|
||||
cream: {
|
||||
50: '#fdfcfa',
|
||||
100: '#faf7f2',
|
||||
200: '#f5efe6',
|
||||
300: '#ede3d5',
|
||||
400: '#e0d0bc',
|
||||
500: '#d4bfa3',
|
||||
600: '#c4a882',
|
||||
700: '#a88b65',
|
||||
800: '#8a7255',
|
||||
900: '#705d47',
|
||||
950: '#3d3226',
|
||||
},
|
||||
// Secondary: Warm stone gray (sophisticated, grounded)
|
||||
secondary: {
|
||||
50: '#f5f7fa',
|
||||
100: '#ebeef3',
|
||||
200: '#d2dae5',
|
||||
300: '#aab9ce',
|
||||
400: '#7c93b3',
|
||||
500: '#5c769a',
|
||||
600: '#485e80',
|
||||
700: '#3b4d68',
|
||||
800: '#344257',
|
||||
900: '#2f3a4a',
|
||||
950: '#1f2631',
|
||||
50: '#f8f7f6',
|
||||
100: '#f0eeeb',
|
||||
200: '#e0dcd5',
|
||||
300: '#ccc6bb',
|
||||
400: '#b5ad9f',
|
||||
500: '#a09484',
|
||||
600: '#857a6d',
|
||||
700: '#6d6359',
|
||||
800: '#5a524a',
|
||||
900: '#4a443f',
|
||||
950: '#262320',
|
||||
},
|
||||
// Accent: Terracotta (warmth, energy, gentle urgency)
|
||||
accent: {
|
||||
50: '#fef6ee',
|
||||
100: '#fdebd7',
|
||||
200: '#fad3ae',
|
||||
300: '#f6b37b',
|
||||
400: '#f18946',
|
||||
500: '#ed6b22',
|
||||
600: '#de5118',
|
||||
700: '#b83c16',
|
||||
800: '#93311a',
|
||||
900: '#772b18',
|
||||
950: '#40130b',
|
||||
50: '#fdf8f6',
|
||||
100: '#faeee9',
|
||||
200: '#f5dcd2',
|
||||
300: '#ecc0b0',
|
||||
400: '#e09b82',
|
||||
500: '#d67b58',
|
||||
600: '#c6603e',
|
||||
700: '#a54c30',
|
||||
800: '#88402b',
|
||||
900: '#703728',
|
||||
950: '#3d1a11',
|
||||
},
|
||||
background: '#fafbfc',
|
||||
// Alert red (emergency - softened)
|
||||
alert: {
|
||||
50: '#fdf5f4',
|
||||
100: '#fce8e6',
|
||||
200: '#f9d5d2',
|
||||
300: '#f4b7b1',
|
||||
400: '#ec8c85',
|
||||
500: '#e0635a',
|
||||
600: '#c9453d',
|
||||
700: '#a83832',
|
||||
800: '#8b322e',
|
||||
900: '#742f2c',
|
||||
950: '#3e1514',
|
||||
},
|
||||
// Semantic aliases
|
||||
background: '#faf7f2',
|
||||
surface: '#ffffff',
|
||||
muted: '#f1f5f9',
|
||||
border: '#e2e8f0',
|
||||
muted: '#f0eeeb',
|
||||
border: '#e0dcd5',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
// Playfair Display for elegant headings
|
||||
display: ['Playfair Display', 'Georgia', 'serif'],
|
||||
// Source Sans 3 for warm, readable body text
|
||||
sans: ['Source Sans 3', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
fontSize: {
|
||||
'display-xl': ['3.5rem', { lineHeight: '1.1', letterSpacing: '-0.02em' }],
|
||||
'display-lg': ['2.75rem', { lineHeight: '1.15', letterSpacing: '-0.02em' }],
|
||||
'display-md': ['2.25rem', { lineHeight: '1.2', letterSpacing: '-0.01em' }],
|
||||
'display-sm': ['1.875rem', { lineHeight: '1.25', letterSpacing: '-0.01em' }],
|
||||
// Large text mode sizes
|
||||
'lg-base': '1.125rem',
|
||||
'lg-lg': '1.25rem',
|
||||
@@ -67,17 +105,61 @@ const config: Config = {
|
||||
},
|
||||
spacing: {
|
||||
// Touch-friendly spacing
|
||||
'touch': '44px',
|
||||
'touch-lg': '56px',
|
||||
'touch': '48px',
|
||||
'touch-lg': '60px',
|
||||
},
|
||||
borderRadius: {
|
||||
'card': '16px',
|
||||
'button': '12px',
|
||||
'card': '20px',
|
||||
'card-lg': '28px',
|
||||
'button': '14px',
|
||||
'pill': '9999px',
|
||||
},
|
||||
boxShadow: {
|
||||
'card': '0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04)',
|
||||
'card-hover': '0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05)',
|
||||
'button': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||
// Soft, warm shadows
|
||||
'card': '0 2px 8px -2px rgba(93, 82, 70, 0.06), 0 4px 16px -4px rgba(93, 82, 70, 0.04)',
|
||||
'card-hover': '0 8px 24px -4px rgba(93, 82, 70, 0.08), 0 4px 12px -2px rgba(93, 82, 70, 0.05)',
|
||||
'button': '0 1px 3px rgba(93, 82, 70, 0.08)',
|
||||
'button-hover': '0 4px 12px -2px rgba(93, 82, 70, 0.15)',
|
||||
'soft': '0 2px 16px rgba(93, 82, 70, 0.06)',
|
||||
'elevated': '0 8px 32px -4px rgba(93, 82, 70, 0.1)',
|
||||
},
|
||||
animation: {
|
||||
// Gentle, breathing animations
|
||||
'breathe': 'breathe 4s ease-in-out infinite',
|
||||
'fade-up': 'fadeUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards',
|
||||
'fade-in': 'fadeIn 0.4s ease-out forwards',
|
||||
'scale-in': 'scaleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards',
|
||||
'slide-up': 'slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards',
|
||||
'pulse-soft': 'pulseSoft 2s ease-in-out infinite',
|
||||
},
|
||||
keyframes: {
|
||||
breathe: {
|
||||
'0%, 100%': { opacity: '1', transform: 'scale(1)' },
|
||||
'50%': { opacity: '0.9', transform: 'scale(1.02)' },
|
||||
},
|
||||
fadeUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(20px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
scaleIn: {
|
||||
'0%': { opacity: '0', transform: 'scale(0.95)' },
|
||||
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(30px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
pulseSoft: {
|
||||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0.7' },
|
||||
},
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
'sanctuary': 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user