import { useQueryClient } from '@tanstack/react-query' import { ChevronLeft } from 'lucide-react' import { Fragment, useEffect, useId, useState } from 'react' import { useNavigate } from 'react-router' import { InstallHint } from '../pwa/InstallHint.tsx' import type { PushStatus, SubscribePushResult, UnsubscribePushResult } from '../push/subscribe.ts' import { usePush } from '../push/usePush.ts' import { Button } from '../ui/Button.tsx' import { Card } from '../ui/Card.tsx' import { ErrorBanner } from '../ui/ErrorBanner.tsx' import { Icon } from '../ui/Icon.tsx' import { IconButton } from '../ui/IconButton.tsx' import { Sheet } from '../ui/Sheet.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { TextInput } from '../ui/TextInput.tsx' import { Toggle } from '../ui/Toggle.tsx' import { clockTime, formatClockTime } from './api.ts' import { LeaveGuard } from './LeaveGuard.tsx' import { PUSH_STATUS_KEY, useChatWebhook, useNotificationSettings, usePushStatus, useSaveChatWebhook, useSaveNotificationSettings, useTestChatWebhook, type ChatWebhook, type NotificationEvent, type NotificationSettings, } from './notificationsApi.ts' import styles from './NotificationsPage.module.css' // eventLabels names each event as the Notifications screen lists it. // rotation_review is the next-day Rotation check. const eventLabels: Record = { plan_ready: 'New weekly plan ready', list_ready: 'Shopping list ready', day_of_reminder: 'Day-of dinner reminder', new_suggestions: 'New suggestions to swipe', rotation_review: 'Keep it in rotation?', } // Notifications & Google Chat (handoff mock 1n): which events go by push // and to Google Chat, when the day-of reminder is sent, Web Push on this // device, and the household's Google Chat webhook. Each change is saved as // it is made; only a webhook being replaced is a draft. export function NotificationsPage() { const [dirty, setDirty] = useState(false) return (
) } function Header() { const navigate = useNavigate() return (
void navigate('/settings')}>

Notifications

) } // NotificationsCard is the notification preferences as a card, for the // desktop settings (handoff mock 2e). export function NotificationsCard() { const titleId = useId() return (

Notifications

) } // NotificationPrefs is the event × channel matrix once it loads, then Web // Push on this device. function NotificationPrefs() { const settings = useNotificationSettings() return ( <> {settings.isPending && } {settings.isError && ( void settings.refetch()}> Notification settings could not load: {settings.error.message} )} {settings.isSuccess && } ) } type Channel = 'push' | 'chat' const channelNames: Record = { push: 'Push', chat: 'Google Chat' } // NotificationMatrix saves every change at once. While a save is pending it // shows the settings being saved and takes no other change; a failed save // shows the stored settings again and says why. function NotificationMatrix({ saved }: { saved: NotificationSettings }) { const save = useSaveNotificationSettings() const [editingTime, setEditingTime] = useState(false) const shown = save.isPending ? save.variables : saved const id = useId() const reminder = formatClockTime(shown.reminder_time) function toggle(event: NotificationEvent, channel: Channel, on: boolean) { save.mutate({ ...shown, prefs: shown.prefs.map((p) => (p.event === event ? { ...p, [channel]: on } : p)) }) } return ( <>
Event {shown.prefs.map(({ event, push, chat }) => (
{eventLabels[event]} {event === 'day_of_reminder' && ( )}
{(['push', 'chat'] as const).map((channel) => ( toggle(event, channel, on)} /> ))}
))}
{save.isError && !editingTime && ( save.mutate(save.variables)}> Notification settings were not saved: {save.error.message} )} { save.reset() setEditingTime(false) }} onSave={(reminder_time) => save.mutate({ ...saved, reminder_time }, { onSuccess: () => setEditingTime(false) }) } /> ) } type ReminderTimeSheetProps = { open: boolean saved: NotificationSettings busy: boolean // Why the last save failed, or null. error: string | null onClose: () => void onSave: (reminderTime: string) => void } // ReminderTimeSheet edits when the day-of dinner reminder is sent. function ReminderTimeSheet({ open, saved, busy, error, onClose, onSave }: ReminderTimeSheetProps) { const titleId = useId() const [time, setTime] = useState(saved.reminder_time) useEffect(() => { if (open) setTime(saved.reminder_time) }, [open, saved.reminder_time]) const valid = clockTime.test(time) return (

Day-of dinner reminder

When the reminder is sent on each planned day. This changes it for everyone in the household.

setTime(event.target.value)} /> {!valid &&

Choose a time.

} {error !== null && (

The reminder time was not saved: {error}

)}
) } const pushStatusText: Record = { on: 'On', off: 'Off', denied: 'Blocked', unsupported: 'Not supported', } type PushOutcome = SubscribePushResult | UnsubscribePushResult // PushPermission is whether Web Push is on for this device, with a button // to turn it on or off. Turning on asks for notification permission, which // browsers allow only from a tap, so subscribePush is called straight from // the button's click. function PushPermission() { const status = usePushStatus() const push = usePush() const queryClient = useQueryClient() const [busy, setBusy] = useState(false) const [outcome, setOutcome] = useState(null) const [failure, setFailure] = useState<{ action: 'on' | 'off'; message: string } | null>(null) const labelId = useId() function run(action: 'on' | 'off', pending: Promise) { setBusy(true) setOutcome(null) setFailure(null) void pending .then(setOutcome, (error: unknown) => setFailure({ action, message: error instanceof Error ? error.message : String(error) }), ) .finally(() => { setBusy(false) void queryClient.invalidateQueries({ queryKey: PUSH_STATUS_KEY }) }) } return (

Notifications on this device {': '} {status.isPending && } {status.isSuccess && {pushStatusText[status.data]}}

{status.data === 'off' && ( )} {status.data === 'on' && ( )}
{status.data === 'denied' && (

This browser blocks notifications from MealQuest. Allow them in the browser's site settings.

)} {status.data === 'unsupported' && (

This browser can't receive notifications.

)} {outcome === 'dismissed' && (

The permission prompt was closed without an answer, so notifications are still off.

)} {status.isError && ( void status.refetch()}> Could not check notifications on this device: {status.error.message} )} {failure !== null && ( Notifications could not be turned {failure.action}: {failure.message} )}
) } type ChatWebhookCardProps = { // Called with whether a webhook being entered is unsaved. onDirtyChange: (dirty: boolean) => void } // ChatWebhookCard is the household's Google Chat webhook: its masked URL, // the space it posts to, and a test message. export function ChatWebhookCard({ onDirtyChange }: ChatWebhookCardProps) { const webhook = useChatWebhook() const titleId = useId() return (

Google Chat webhook

{webhook.isPending && } {webhook.isError && ( void webhook.refetch()}> The Google Chat webhook could not load: {webhook.error.message} )} {webhook.isSuccess && }
) } function ChatWebhookForm({ saved, onDirtyChange }: ChatWebhookCardProps & { saved: ChatWebhook }) { const connected = saved.url_masked !== null const [replacing, setReplacing] = useState(!connected) const [url, setUrl] = useState('') const [space, setSpace] = useState('') const save = useSaveChatWebhook() const test = useTestChatWebhook() const dirty = replacing && (url !== '' || space !== '') useEffect(() => { onDirtyChange(dirty) }, [dirty, onDirtyChange]) useEffect(() => () => onDirtyChange(false), [onDirtyChange]) function close() { save.reset() setReplacing(false) setUrl('') setSpace('') } if (replacing) { return (
setUrl(event.target.value)} /> setSpace(event.target.value)} /> {save.isError && (

The webhook was not saved: {save.error.message}

)}
{connected && ( )}
) } return ( <>

Connected · {saved.space_label}

) } // TestResult says how the last Send test ended, under the URL: sent, the // HTTP status Google Chat refused it with, or why it was not sent. function TestResult({ test }: { test: ReturnType }) { if (test.isSuccess) { const status = test.data.http_status const ok = status >= 200 && status < 300 return (

{ok ? 'Test message sent.' : `Google Chat refused the test message: HTTP ${status}.`}

) } if (test.isError) { return (

Test message not sent: {test.error.message}

) } return null } // ChatPreview shows what a Google Chat message from MealQuest looks like. function ChatPreview() { return (

MealQuest · preview
This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →

) }