From 010a69c047b2b5121448fc916274270beeec6827 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 21:50:48 -0400 Subject: [PATCH] feat(05-08): PermissionDeniedBanner + App mount + CalendarShell onOpenSettings wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create PermissionDeniedBanner.tsx: role=alert banner shown only when permission=denied AND notificationsEnabled=1 (OS-revoked case, D-10) with AlertCircle icon, 'Notifications blocked' heading, inline 'How to enable' button that opens OS-specific instruction sheet (iOS 4-step / Android 4-step) - Mount PermissionDeniedBanner and SettingsSheet in App.tsx; wire onOpenSettings state from avatar tap through CalendarShell → AppNav → PhoneNav/DesktopNav - CalendarShell accepts optional onOpenSettings prop, threads to both AppNav usages - playwright-cli verified: banner renders with exact UI-SPEC copy when permission=denied+was-enabled; banner absent when permission=granted; 'How to enable' opens instruction sheet with correct Android steps; SettingsSheet opens from avatar click with toggle + permission-denied hint --- apps/pwa/src/App.tsx | 20 +- apps/pwa/src/components/CalendarShell.tsx | 4 +- .../src/components/PermissionDeniedBanner.tsx | 280 ++++++++++++++++++ 3 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 apps/pwa/src/components/PermissionDeniedBanner.tsx diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index 59d6d46..6812027 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -15,21 +15,36 @@ * navigateFallback ('/index.html') in vite.config.ts covers SPA deep-links to * /lists/* — the SW denylist only excludes /callback, /api/, and /health, so * /lists/* is served from cache correctly. + * + * Phase 5 additions: + * - PermissionDeniedBanner: shown below AppNav when OS permission revoked (D-10) + * - SettingsSheet: avatar-triggered bottom sheet with master notifications toggle (D-09) */ +import { useState } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router' import { CalendarShell } from './components/CalendarShell.js' import { ListsIndex } from './routes/ListsIndex.js' import { ListDetail } from './routes/ListDetail.js' import { BottomTabBar } from './components/BottomTabBar.js' import { PushPermissionPrompt } from './components/PushPermissionPrompt.js' +import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js' +import { SettingsSheet } from './components/SettingsSheet.js' export default function App() { + const [settingsOpen, setSettingsOpen] = useState(false) + return ( + {/* Permission-denied banner: shown when OS revoked and user had notifications on (D-10) */} + + } /> - } /> + setSettingsOpen(true)} />} + /> } /> } /> @@ -37,6 +52,9 @@ export default function App() { {/* Post-install permission prompt (D-08): renders only when isInstalled() is true and Notification.permission === 'default' and not dismissed */} + + {/* Settings sheet — master notifications toggle (D-09), opened from avatar */} + setSettingsOpen(false)} /> ) } diff --git a/apps/pwa/src/components/CalendarShell.tsx b/apps/pwa/src/components/CalendarShell.tsx index 1d58635..542d417 100644 --- a/apps/pwa/src/components/CalendarShell.tsx +++ b/apps/pwa/src/components/CalendarShell.tsx @@ -72,7 +72,7 @@ function isPhone(): boolean { // ── Component ────────────────────────────────────────────────────────────── -export function CalendarShell() { +export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void } = {}) { // Use per-field selectors so CalendarShell does NOT subscribe to openEventId. // Without selectors, any popover open/close triggers a full re-render here, // which rebuilds the Schedule-X config and causes a visible calendar flash (Bug B). @@ -358,6 +358,7 @@ export function CalendarShell() { members={members} currentUserColor={meQuery.data?.user.color} currentUserName={meQuery.data?.user.displayName ?? undefined} + onOpenSettings={onOpenSettings} /> {calendarContent} @@ -421,6 +422,7 @@ export function CalendarShell() { members={members} currentUserColor={meQuery.data?.user.color} currentUserName={meQuery.data?.user.displayName ?? undefined} + onOpenSettings={onOpenSettings} /> {/* Main content area */} diff --git a/apps/pwa/src/components/PermissionDeniedBanner.tsx b/apps/pwa/src/components/PermissionDeniedBanner.tsx new file mode 100644 index 0000000..c51e500 --- /dev/null +++ b/apps/pwa/src/components/PermissionDeniedBanner.tsx @@ -0,0 +1,280 @@ +/** + * PermissionDeniedBanner — persistent OS-revoked notification banner (D-10). + * + * Shown ONLY when: + * - Notification.permission === 'denied' (OS revoked), AND + * - localStorage.notificationsEnabled was previously '1' (user had it on) + * + * Silent re-subscribe (D-10) covers the expired-subscription case. This banner + * is strictly for the OS-revoked case — the one case the app cannot silently fix. + * + * UI-SPEC §Surface 3: + * - role="alert" (assertive live region — permission loss is high-priority) + * - AlertCircle (--color-destructive) + "Notifications blocked" heading + * - "Re-enable in your browser settings." + "How to enable" inline button + * - No dismiss — persists until OS permission is restored + * - "How to enable" opens an OS-specific instruction sheet (iOS 4-step / Android 4-step) + * + * Security: T-05-24 — all copy is plain-text JSX children, no dangerouslySetInnerHTML. + */ + +import { useState } from 'react' +import { AlertCircle, X } from 'lucide-react' +import { readNotificationsEnabled } from '../hooks/usePushSubscription.js' + +// ── OS detection ────────────────────────────────────────────────────────── + +function isIOS(): boolean { + return /iPad|iPhone|iPod/.test(navigator.userAgent) && + !(window as unknown as { MSStream?: unknown }).MSStream +} + +// ── Instruction steps ──────────────────────────────────────────────────── + +const IOS_STEPS = [ + 'Open Settings on your iPhone', + 'Scroll down and tap Safari', + 'Tap Notifications', + 'Allow notifications for FamilySync', +] + +const ANDROID_STEPS = [ + 'Open Chrome on your phone', + 'Tap the three-dot menu → Settings', + 'Tap Site Settings → Notifications', + 'Find FamilySync and tap Allow', +] + +// ── Instruction sheet ──────────────────────────────────────────────────── + +interface InstructionSheetProps { + onClose: () => void +} + +function InstructionSheet({ onClose }: InstructionSheetProps) { + const steps = isIOS() ? IOS_STEPS : ANDROID_STEPS + const platform = isIOS() ? 'iOS' : 'Android' + + return ( +
{ + if (e.target === e.currentTarget) onClose() + }} + > +
+ {/* Header */} +
+

+ How to enable notifications +

+ +
+ + {/* Steps */} +
    + {steps.map((step, i) => ( +
  1. + + {i + 1} + + + {step} + +
  2. + ))} +
+ + {/* Done button */} + +
+
+ ) +} + +// ── PermissionDeniedBanner ──────────────────────────────────────────────── + +export function PermissionDeniedBanner() { + const [instructionsOpen, setInstructionsOpen] = useState(false) + + // Only show when OS permission is 'denied' AND the user previously had notifications on. + // This is the OS-revoked case (D-10). Silent re-subscribe handles the expired-sub case. + const permissionDenied = + typeof Notification !== 'undefined' && Notification.permission === 'denied' + const wasEnabled = readNotificationsEnabled() + + if (!permissionDenied || !wasEnabled) return null + + return ( + <> +
+
+ + {instructionsOpen && ( + setInstructionsOpen(false)} /> + )} + + ) +}