/** * PushPermissionPrompt — post-install permission bottom sheet (D-08, UI-SPEC Surface 1). * * Shows after the PWA is installed (isInstalled() === true) and when: * - Notification.permission === 'default' (not yet asked) * - The user hasn't dismissed via "Not now" (localStorage pushPermissionDismissed !== '1') * * Accessibility: * - role="dialog", aria-modal="true", aria-labelledby pointing to the heading * - 48px primary CTA, 44px secondary ghost button * - Backdrop does NOT dismiss (permission UX must be explicit — per UI-SPEC Surface 1) * * Security: T-02d-01 — all text is plain-text JSX children; no dangerouslySetInnerHTML. * * iOS constraint (D-08 / CLAUDE.md iOS table): * subscribe() is called synchronously inside the onClick handler — no await before * the pushManager.subscribe() call satisfies the iOS user-gesture requirement. */ import { useState, useEffect, useId } from 'react'; import { Bell, Loader2, X } from 'lucide-react'; import { usePushSubscription } from '../hooks/usePushSubscription.js'; // fetchVapidKey is the internal helper; we import it directly via the module // rather than re-exporting it through usePushSubscription, since we need to // store the resolved key in state (not just warm the cache). async function fetchVapidKeyForPrompt(): 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; } } // ── installed state check ────────────────────────────────────────────────── function isInstalled(): boolean { return ( window.matchMedia('(display-mode: standalone)').matches || (navigator as unknown as { standalone?: boolean }).standalone === true ); } // ── localStorage guards ──────────────────────────────────────────────────── function readDismissed(): boolean { try { return localStorage.getItem('pushPermissionDismissed') === '1'; } catch { return false; } } function persistDismissed(): void { try { localStorage.setItem('pushPermissionDismissed', '1'); } catch { // Private mode / storage disabled — ignore } } // ── PushPermissionPrompt ─────────────────────────────────────────────────── interface PushPermissionPromptProps { /** Optional callback after the prompt is closed (granted or dismissed) */ onClose?: () => void; } export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) { const [dismissed, setDismissed] = useState(readDismissed); const [installed, setInstalled] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // CR-04: Pre-fetch the VAPID key into state so the tap handler can call // subscribe(registration, vapidKey) without any await before pushManager.subscribe(). // Button is disabled until the key is ready (null = not yet loaded). const [vapidKey, setVapidKey] = useState(null); // NEW-CR-01: Pre-resolve the ServiceWorkerRegistration into state so the tap // handler has ZERO awaits between the user gesture and pushManager.subscribe(). // Any await (including navigator.serviceWorker.ready) between the tap and // pushManager.subscribe() breaks the iOS user-gesture requirement. const [swRegistration, setSwRegistration] = useState(null); const headingId = useId(); const { subscribe, permission } = usePushSubscription(); useEffect(() => { setInstalled(isInstalled()); }, []); // Pre-fetch the VAPID key into state while the prompt is visible. // CR-04: We store the resolved key in state (not just sessionStorage) so the // tap handler has synchronous access — no network await inside the tap path. useEffect(() => { if (!installed) return; if (permission !== 'default') return; if (dismissed) return; void fetchVapidKeyForPrompt().then((key) => { if (key) setVapidKey(key); }); }, [installed, permission, dismissed]); // NEW-CR-01: Pre-resolve the ServiceWorkerRegistration in a useEffect so the // tap handler never has to await navigator.serviceWorker.ready. // navigator.serviceWorker.ready resolves once the SW is active; doing this // eagerly means the result is in state before the user can tap the button. useEffect(() => { if (!installed) return; if (permission !== 'default') return; if (dismissed) return; if (!navigator.serviceWorker) return; void navigator.serviceWorker.ready.then((reg) => { setSwRegistration(reg); }); }, [installed, permission, dismissed]); // Don't render when: not installed, already granted/denied, or dismissed if (!installed) return null; if (permission !== 'default') return null; if (dismissed) return null; function handleDismiss() { persistDismissed(); setDismissed(true); onClose?.(); } // onClick handler — subscribe() called synchronously (iOS user-gesture requirement). // NEW-CR-01: Both vapidKey AND swRegistration are pre-resolved in state (useEffects above). // There is ZERO await between the tap gesture and registration.pushManager.subscribe() // inside subscribe() — the iOS gesture gate is fully satisfied. function handleEnableClick() { if (loading || !vapidKey || !swRegistration) return; setLoading(true); setError(null); // Capture both synchronously — no await in this scope before subscribe(). const resolvedVapidKey = vapidKey; const resolvedRegistration = swRegistration; void (async () => { try { await subscribe(resolvedRegistration, resolvedVapidKey); // On success: close the prompt (permission is now 'granted') setLoading(false); onClose?.(); } catch (err) { setLoading(false); if (err instanceof Error && err.name === 'NotAllowedError') { // User denied in the native browser dialog — close the sheet setDismissed(true); onClose?.(); } else { setError('Something went wrong. Please try again.'); } } })(); } return ( // Full-screen backdrop — NO backdrop click-dismiss (permission UX must be explicit)
{/* Sheet */}
{/* Header */}

Stay in the loop

{/* Dismiss X — "Not now" via header close */}
{/* Bell icon + body */}
{/* Error message */} {error && (

{error}

)} {/* Actions */}
{/* Primary CTA — 48px, accent color */} {/* NEW-CR-01: disabled until BOTH vapidKey and swRegistration are ready */} {/* Secondary — "Not now", 44px ghost */}
); }