/** * InstallPrompt — PWA install guidance for iOS and Android (PWA-01, PWA-02). * * Behaviors: * - Renders nothing when the app is already installed (display-mode: standalone * or navigator.standalone — iOS PWA). * - iOS Safari non-standalone: shows a dismissible banner with a "How to install" * link that opens a full-screen 5-step walkthrough sheet. * - Android (Chrome/Edge): shows a banner with an "Install" button only when the * `beforeinstallprompt` event has fired and canInstall is true. * - Neither surface shows when the app is already installed. * * localStorage key: `installPromptDismissed` — persists banner dismissal. * * Security: T-02d-01 — all text is plain-text JSX children; no dangerouslySetInnerHTML. * Accessibility: role="banner", dismiss aria-label="Dismiss install prompt", 44px touch targets. * * CLAUDE.md PWA constraints: * - iOS 16.4+ minimum; Home-Screen install required for push. * - pushManager.subscribe() must be in a tap handler (enforced in Phase 5). * - EU DMA iOS 17.4+ caveat: PWA may open in Safari tabs; install guide addresses this. */ import { useState, useEffect } from 'react'; import { Smartphone, X } from 'lucide-react'; import { PushPermissionPrompt } from './PushPermissionPrompt.js'; // ── iOS detection ────────────────────────────────────────────────────────── /** * Returns true when the browser is iOS Safari in non-standalone (browser-tab) mode. * Returns false when already installed (navigator.standalone === true) or on non-iOS. * * Detection logic: * 1. Check for iPad/iPhone/iPod in UA (standard iOS Safari UA; excludes Android Chrome * which also contains "Safari"). * 2. Exclude IE/Edge for IE (MSStream property) — belt-and-suspenders. * 3. Check navigator.standalone is NOT true (standalone = already installed). */ export function isIOSSafariNonStandalone(): boolean { const ua = navigator.userAgent; const isIOS = /iPad|iPhone|iPod/.test(ua) && !(window as unknown as { MSStream?: unknown }).MSStream; const isStandalone = (navigator as unknown as { standalone?: boolean }).standalone === true; return isIOS && !isStandalone; } // ── Installed state check ────────────────────────────────────────────────── /** * Returns true when the app is running in standalone mode (already installed). * Covers both Android (display-mode media query) and iOS (navigator.standalone). */ function isInstalled(): boolean { return ( window.matchMedia('(display-mode: standalone)').matches || (navigator as unknown as { standalone?: boolean }).standalone === true ); } // ── Android beforeinstallprompt hook ───────────────────────────────────── interface BeforeInstallPromptEvent extends Event { prompt(): Promise; userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; } /** * Captures the browser's `beforeinstallprompt` event and exposes it as a hook. * Only fires on Chrome/Edge on Android — never on iOS. * * Returns: * canInstall: true when the event has been captured and install is available * triggerInstall: function that calls prompt() on the deferred event */ export function useAndroidInstallPrompt() { const [deferredPrompt, setDeferredPrompt] = useState(null); // justInstalled: set to true when the appinstalled event fires, enabling the // post-install push permission prompt trigger (D-08). const [justInstalled, setJustInstalled] = useState(false); useEffect(() => { const handler = (e: Event) => { e.preventDefault(); setDeferredPrompt(e as BeforeInstallPromptEvent); }; const installedHandler = () => { setDeferredPrompt(null); setJustInstalled(true); }; window.addEventListener('beforeinstallprompt', handler); window.addEventListener('appinstalled', installedHandler); return () => { window.removeEventListener('beforeinstallprompt', handler); window.removeEventListener('appinstalled', installedHandler); }; }, []); const triggerInstall = async () => { if (!deferredPrompt) return; await deferredPrompt.prompt(); const { outcome } = await deferredPrompt.userChoice; if (outcome === 'accepted') { setDeferredPrompt(null); } }; return { canInstall: deferredPrompt !== null, triggerInstall, justInstalled }; } // ── iOS Walkthrough Sheet ───────────────────────────────────────────────── const IOS_STEPS = [ 'Open FamilySync in Safari', 'Tap the Share button', "Scroll down and tap 'Add to Home Screen'", "Tap 'Add' in the top right", 'Open FamilySync from your Home Screen — it opens without the browser bar', ]; interface WalkthroughSheetProps { onClose: () => void; } function WalkthroughSheet({ onClose }: WalkthroughSheetProps) { return (
{ // Close on backdrop click if (e.target === e.currentTarget) onClose(); }} >
{/* Header */}

Add to Home Screen

{/* Steps */}
    {IOS_STEPS.map((step, i) => (
  1. {/* Step number with accent color annotation (#F5A623 — --color-member-2) */} {i + 1} {step}
  2. ))}
{/* Done button */}
); } // ── InstallPrompt ───────────────────────────────────────────────────────── /** * InstallPrompt renders the appropriate install surface based on platform: * - iOS Safari non-standalone: dismissible banner + optional walkthrough sheet * - Android (beforeinstallprompt available): install banner with native prompt button * - Already installed / desktop: nothing * * Mount this at the top level of CalendarShell, below the nav bar. */ // IN-03: localStorage access is guarded — in private-mode / SSR contexts the API can // throw on read or write. Mirrors calendarStore.ts's pattern so a storage failure // degrades gracefully (treated as "not dismissed") instead of crashing the component. function readDismissed(): boolean { try { return localStorage.getItem('installPromptDismissed') === '1'; } catch { return false; } } function persistDismissed(): void { try { localStorage.setItem('installPromptDismissed', '1'); } catch { // Ignore write failures (private mode / storage disabled) } } export function InstallPrompt() { const [dismissed, setDismissed] = useState(readDismissed); const [walkthroughOpen, setWalkthroughOpen] = useState(false); const { canInstall, triggerInstall, justInstalled } = useAndroidInstallPrompt(); // Re-check isInstalled on mount — matchMedia is only available in the browser const [installed, setInstalled] = useState(false); useEffect(() => { setInstalled(isInstalled()); }, []); // Show post-install permission prompt (D-08) immediately after the appinstalled event // fires — before the page knows it is in standalone mode. This covers Android Chrome // where the page stays loaded after install without a reload. if (justInstalled) { return ; } // Nothing to show when already installed (App.tsx mounts PushPermissionPrompt there) if (installed) return null; function dismiss() { persistDismissed(); setDismissed(true); } // ── iOS banner ────────────────────────────────────────────────────────── if (isIOSSafariNonStandalone() && !dismissed) { return ( <>
{/* Icon */}
{walkthroughOpen && setWalkthroughOpen(false)} />} ); } // ── Android banner ────────────────────────────────────────────────────── if (canInstall && !dismissed) { return (
{/* Icon */}
); } // Nothing applicable — desktop, already installed, or dismissed return null; }