diff --git a/apps/pwa/src/components/AppNav.tsx b/apps/pwa/src/components/AppNav.tsx index faa3cc2..42b156a 100644 --- a/apps/pwa/src/components/AppNav.tsx +++ b/apps/pwa/src/components/AppNav.tsx @@ -19,25 +19,42 @@ interface AppNavProps { members?: LegendMember[] currentUserColor?: string currentUserName?: string + /** Called when the user avatar is tapped — opens the Settings sheet. */ + onOpenSettings?: () => void } -export function AppNav({ members = [], currentUserColor, currentUserName }: AppNavProps) { +export function AppNav({ members = [], currentUserColor, currentUserName, onOpenSettings }: AppNavProps) { const isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches if (isMobile) { - return + return ( + + ) } - return + return ( + + ) } /** Phone: 48px top bar — app name left, user avatar right */ function PhoneNav({ currentUserColor, currentUserName, + onOpenSettings, }: { currentUserColor?: string currentUserName?: string + onOpenSettings?: () => void }) { const displayName = currentUserName ?? 'User' const color = currentUserColor ?? 'var(--color-member-0)' @@ -69,43 +86,50 @@ function PhoneNav({ FamilySync - {/* User color avatar — aria-label + title per reviewer note */} -
+ ) } -/** Tablet/Desktop: 240px left sidebar — app name + nav links + color legend */ -function DesktopNav({ members }: { members: LegendMember[] }) { +/** Tablet/Desktop: 240px left sidebar — app name + nav links + color legend + avatar */ +function DesktopNav({ + members, + currentUserColor, + currentUserName, + onOpenSettings, +}: { + members: LegendMember[] + currentUserColor?: string + currentUserName?: string + onOpenSettings?: () => void +}) { const navLinkStyle = ({ isActive }: { isActive: boolean }): React.CSSProperties => ({ display: 'flex', alignItems: 'center', @@ -183,6 +207,50 @@ function DesktopNav({ members }: { members: LegendMember[] }) { Calendars
+ + {/* User avatar — at bottom of sidebar, opens Settings sheet (D-09) */} +
+ +
) } diff --git a/apps/pwa/src/components/SettingsSheet.tsx b/apps/pwa/src/components/SettingsSheet.tsx new file mode 100644 index 0000000..62a2689 --- /dev/null +++ b/apps/pwa/src/components/SettingsSheet.tsx @@ -0,0 +1,344 @@ +/** + * 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' + +interface SettingsSheetProps { + isOpen: boolean + onClose: () => void +} + +export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) { + const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription() + const [isTogglingOn, setIsTogglingOn] = useState(false) + 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]) + + 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. + setIsTogglingOn(true) + try { + const registration = await navigator.serviceWorker?.ready + if (registration) { + await subscribe(registration) + } + } catch { + // Permission denied by OS or error — permission state will update reactively + } finally { + setIsTogglingOn(false) + } + } + } + + const isDisabled = permission === 'denied' + + return ( + <> + {/* Backdrop */} +