/** * SettingsSheet — master notifications toggle (D-09). * * A bottom sheet opened by tapping the user avatar in AppNav. * Contains a single master on/off toggle for all FamilySync push notifications. * * UI-SPEC §Surface 2: * - Bottom sheet, role="dialog", aria-modal, zIndex 301 (backdrop 300) * - Heading "Settings" + X close button * - Section label "Notifications" (uppercase, muted) * - Bell icon + toggle row * - Permission-denied hint when Notification.permission === 'denied' * * Toggle behavior: * on → off: DELETE subscription + localStorage.notificationsEnabled='0' * off → on (permission granted): silently subscribe (no OS dialog) * off → on (permission default): triggers tap-gated subscribe() (OS dialog) * off → on (permission denied): no-op, shows permission-denied hint inline * * Accessibility: role="switch", aria-checked, 44px touch targets, Escape closes. * Security: T-05-24 — all copy is plain-text JSX children, no dangerouslySetInnerHTML. */ import { useEffect, useRef, useState } from 'react' import { X, Bell, AlertCircle, Loader2 } from 'lucide-react' import { usePushSubscription } from '../hooks/usePushSubscription.js' // CR-04: fetch VAPID key (from sessionStorage cache if available) for the // tap-gated subscribe() path. Same logic as PushPermissionPrompt. async function fetchVapidKeyForSettings(): Promise { try { const cached = sessionStorage.getItem('vapidPublicKey') if (cached) return cached const res = await fetch('/api/push/vapid-public-key', { credentials: 'include' }) if (!res.ok) return null const data = (await res.json()) as { publicKey: string } if (data.publicKey) { sessionStorage.setItem('vapidPublicKey', data.publicKey) } return data.publicKey ?? null } catch { return null } } interface SettingsSheetProps { isOpen: boolean onClose: () => void } export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) { const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription() const [isTogglingOn, setIsTogglingOn] = useState(false) // CR-04: pre-fetch the VAPID key into state so the toggle tap handler can call // subscribe(registration, vapidKey) without any network await before pushManager.subscribe(). const [vapidKey, setVapidKey] = useState(null) const closeButtonRef = useRef(null) // Compute initial toggle on/off state per UI-SPEC toggle initial state rule: // on when notificationsEnabled !== '0' AND permission === 'granted' AND isSubscribed const isOn = permission === 'granted' && isSubscribed // Escape key listener (CreateListSheet pattern) useEffect(() => { if (!isOpen) return const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } document.addEventListener('keydown', onKeyDown) return () => document.removeEventListener('keydown', onKeyDown) }, [isOpen, onClose]) // Focus close button on open (a11y) useEffect(() => { if (isOpen && closeButtonRef.current) { closeButtonRef.current.focus() } }, [isOpen]) // CR-04: Pre-fetch the VAPID key while the sheet is open so the OS-dialog path // (permission === 'default') has the key ready before the user taps. useEffect(() => { if (!isOpen) return if (permission === 'denied') return void fetchVapidKeyForSettings().then((key) => { if (key) setVapidKey(key) }) }, [isOpen, permission]) if (!isOpen) return null const handleToggle = async () => { if (permission === 'denied') return // no-op — show hint below if (isOn) { // on → off: unsubscribe await setEnabled(false) } else if (permission === 'granted') { // off → on, permission already granted: silent subscribe setIsTogglingOn(true) try { await setEnabled(true) } finally { setIsTogglingOn(false) } } else { // off → on, permission 'default': needs tap-gated subscribe with OS dialog // The toggle click IS the tap gesture — call subscribe() directly here. // CR-04: vapidKey was pre-fetched into state (useEffect above); pass it directly // so there is no await-fetch between the tap and pushManager.subscribe(). if (!vapidKey) return // key not ready — this should be rare; button should show spinner const resolvedVapidKey = vapidKey setIsTogglingOn(true) try { const registration = await navigator.serviceWorker?.ready if (registration) { await subscribe(registration, resolvedVapidKey) } } catch { // Permission denied by OS or error — permission state will update reactively } finally { setIsTogglingOn(false) } } } const isDisabled = permission === 'denied' return ( <> {/* Backdrop */}