/** * 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' // ── 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) useEffect(() => { const handler = (e: Event) => { e.preventDefault() setDeferredPrompt(e as BeforeInstallPromptEvent) } const installedHandler = () => setDeferredPrompt(null) 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 } } // ── 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. */ export function InstallPrompt() { const [dismissed, setDismissed] = useState( () => localStorage.getItem('installPromptDismissed') === '1', ) const [walkthroughOpen, setWalkthroughOpen] = useState(false) const { canInstall, triggerInstall } = useAndroidInstallPrompt() // Re-check isInstalled on mount — matchMedia is only available in the browser const [installed, setInstalled] = useState(false) useEffect(() => { setInstalled(isInstalled()) }, []) // Nothing to show when already installed if (installed) return null function dismiss() { localStorage.setItem('installPromptDismissed', '1') 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 }