13 Commits

Author SHA1 Message Date
Gemini Agent
065250c1cf Redesign: Warm Sanctuary aesthetic for core pages
- Implement cohesive 'Warm Sanctuary' design system
- Add Playfair Display + Source Sans 3 typography
- Create paper texture background and warm color palette
- Redesign Today Dashboard with elegant cards and animations
- Redesign Medication Form with step-by-step visual flow
- Redesign Emergency Card with clear visual hierarchy
- Redesign Onboarding with floating blobs and welcoming feel
- Update Tailwind config with new colors, shadows, and animations
2026-03-01 07:06:58 +00:00
Gemini Agent
a5181cf6fe Add timezone support and auto-sync push subscriptions
- Install tzdata in Docker for proper Australia/Perth timezone handling
- Update VAPID email to standard placeholder
- Auto-sync browser push subscriptions to server on page load

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 14:29:42 +00:00
Gemini Agent
cae436a20d Add scheduler service for push notifications 2026-01-25 02:25:48 +00:00
Gemini Agent
8c9ae06360 Implement Edit Medication feature, refactor medication form, and fix build issues 2026-01-25 02:20:16 +00:00
Gemini Agent
7fa95c058e Fix medication scheduling bugs and add delete dose feature 2026-01-25 02:13:51 +00:00
Gemini Agent
f598f6138e Add test notification feature for push notification debugging
- Add POST /api/notifications/test endpoint to send test notifications
- Add "Send Test Notification" button to notifications settings page
- Shows success/failure feedback and removes expired subscriptions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 22:44:05 +00:00
Gemini Agent
66cb1ea095 Add admin panel with member management and password reset
Features:
- Admin panel at /settings/members for workspace owners
- View all workspace members with roles and last login
- Create new users directly (with temporary password)
- Change member roles (Owner/Editor/Viewer)
- Reset user passwords (forces change on next login)
- Remove members from workspace
- Force password reset flow on login
- Track last login timestamp for users

API Routes:
- GET/POST /api/workspaces/[id]/members
- GET/PATCH/DELETE /api/workspaces/[id]/members/[memberId]
- POST /api/workspaces/[id]/members/[memberId]/reset-password
- POST /api/auth/change-password

Schema changes:
- Added lastLoginAt DateTime? to User model
- Added forcePasswordReset Boolean to User model

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 01:22:10 +00:00
Gemini Agent
f9a7b68a99 Fix service worker install failure due to missing icon files
The service worker was failing to install because it tried to cache
icon files that don't exist (icon-192.png, icon-512.png).

Simplified the service worker to focus only on push notifications:
- Removed caching during install (was causing "redundant" state)
- Removed fetch handler caching
- Removed references to non-existent icon files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:26:40 +00:00
Gemini Agent
4753216b56 Fix service worker registration for push notifications
Instead of waiting on navigator.serviceWorker.ready (which may never
resolve if registration hasn't completed), explicitly register the
service worker and wait for it to activate.

This fixes the "service worker not ready" error on iOS Safari PWAs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:00:39 +00:00
Gemini Agent
54900b65c8 Add timeouts and better error handling for push notifications
- Add 10s timeout for service worker ready state
- Add 15s timeout for push subscription
- Check for PushManager support early (shows unsupported on incompatible devices)
- Provide specific error messages for different failure modes

This prevents the enable button from spinning forever on iOS devices
where push subscription may hang silently.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:54:40 +00:00
Gemini Agent
c9f3402e48 Configure VAPID keys for push notifications
Added VAPID key configuration to enable PWA push notifications:
- Generated VAPID public/private key pair
- Added build arg for NEXT_PUBLIC_VAPID_PUBLIC_KEY (needed at Next.js build time)
- Added runtime env vars for VAPID keys in docker-compose.yml

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:45:38 +00:00
Gemini Agent
e5db48f82b Fix iCal endpoint crash from non-ASCII characters in workspace name
The Content-Disposition header was including the workspace name directly,
causing a "Cannot convert argument to ByteString" error when workspace
names contained smart apostrophes or other non-ASCII characters (e.g.,
"Grace's Plan" with curly apostrophe U+2019).

Sanitize filename by removing non-ASCII characters before using in header.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:34:00 +00:00
Gemini Agent
3034d52884 Fix appointment edit page and iCal timezone issues
- Create appointment edit page at /appointments/[id]/edit
- Fix iCal calendar timezone handling:
  - Add VTIMEZONE block for Australia/Perth
  - Use TZID parameter for DTSTART/DTEND
  - Properly format local times without Z suffix
- Appointments now appear correctly in Google Calendar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:22:23 +00:00
33 changed files with 3768 additions and 1124 deletions

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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[]

View File

@@ -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,
}

View 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>
</>
)
}

View File

@@ -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>

View 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>
</>
)
}

View File

@@ -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 */}

View File

@@ -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>
</>
)
}
}

View 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>
</>
)
}

View File

@@ -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">

View File

@@ -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>
)}

View File

@@ -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>
)
}

View 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 })
}
})

View File

@@ -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)

View 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 }
)
}
})

View File

@@ -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',
},
})

View File

@@ -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 })
}
})

View 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 })
}
})

View 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 })
}
})

View 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>
)
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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')

View File

@@ -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>
)

View File

@@ -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>

View 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>
)
}

View File

@@ -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', {

View File

@@ -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, '\\\\')

View File

@@ -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)

View File

@@ -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)',
},
},
},