From e0fb34b2522fed79ff26ea88d14f034ae0e66e2e Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 18:05:30 -0400 Subject: [PATCH] =?UTF-8?q?feat(03-07):=20InstallPrompt=20=E2=80=94=20iOS?= =?UTF-8?q?=20walkthrough=20banner=20+=20Android=20beforeinstallprompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement isIOSSafariNonStandalone(): iOS UA + navigator.standalone detection - Implement useAndroidInstallPrompt(): captures beforeinstallprompt, exposes canInstall/triggerInstall - InstallPrompt renders nothing when display-mode:standalone or navigator.standalone (already installed) - iOS branch: dismissible banner with 'Install FamilySync' heading, 'How to install' link opens 5-step walkthrough sheet (exact UI-SPEC copy, orange #F5A623 step number annotation) - Android branch: banner with 'Install' button shown only when canInstall=true - localStorage.installPromptDismissed persists banner dismissal - role="banner", dismiss aria-label="Dismiss install prompt", 44px touch targets - Mount in CalendarShell (phone: below AppNav; desktop: top of content area) - InstallPrompt.test.tsx GREEN (5 behavior tests); full PWA suite 44 tests green; tsc clean --- apps/pwa/src/components/CalendarShell.tsx | 7 +- apps/pwa/src/components/InstallPrompt.tsx | 476 ++++++++++++++++++++++ 2 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 apps/pwa/src/components/InstallPrompt.tsx diff --git a/apps/pwa/src/components/CalendarShell.tsx b/apps/pwa/src/components/CalendarShell.tsx index 2a9aa44..e05c6a3 100644 --- a/apps/pwa/src/components/CalendarShell.tsx +++ b/apps/pwa/src/components/CalendarShell.tsx @@ -48,6 +48,7 @@ import { EventDetailPopover } from './EventDetailPopover.js' import { AppNav } from './AppNav.js' import { ColorLegend } from './ColorLegend.js' import { SkeletonCalendar } from './SkeletonCalendar.js' +import { InstallPrompt } from './InstallPrompt.js' // ── Helpers ──────────────────────────────────────────────────────────────── @@ -314,6 +315,7 @@ export function CalendarShell() { currentUserColor={meQuery.data?.user.color} currentUserName={meQuery.data?.user.displayName ?? undefined} /> + @@ -341,7 +343,10 @@ export function CalendarShell() { /> {/* Main content area */} - +
+ + +
{/* EventDetailPopover — standalone mode driven by Zustand openEventId */} diff --git a/apps/pwa/src/components/InstallPrompt.tsx b/apps/pwa/src/components/InstallPrompt.tsx new file mode 100644 index 0000000..a6d6a4d --- /dev/null +++ b/apps/pwa/src/components/InstallPrompt.tsx @@ -0,0 +1,476 @@ +/** + * 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 +}