feat(03-07): InstallPrompt — iOS walkthrough banner + Android beforeinstallprompt

- 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 <InstallPrompt /> 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
This commit is contained in:
Lucas Berger
2026-06-05 18:05:30 -04:00
parent bd8283774d
commit e0fb34b252
2 changed files with 482 additions and 1 deletions
@@ -48,6 +48,7 @@ import { EventDetailPopover } from './EventDetailPopover.js'
import { AppNav } from './AppNav.js' import { AppNav } from './AppNav.js'
import { ColorLegend } from './ColorLegend.js' import { ColorLegend } from './ColorLegend.js'
import { SkeletonCalendar } from './SkeletonCalendar.js' import { SkeletonCalendar } from './SkeletonCalendar.js'
import { InstallPrompt } from './InstallPrompt.js'
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
@@ -314,6 +315,7 @@ export function CalendarShell() {
currentUserColor={meQuery.data?.user.color} currentUserColor={meQuery.data?.user.color}
currentUserName={meQuery.data?.user.displayName ?? undefined} currentUserName={meQuery.data?.user.displayName ?? undefined}
/> />
<InstallPrompt />
<CalendarContent /> <CalendarContent />
<EventDetailPopover /> <EventDetailPopover />
</div> </div>
@@ -341,7 +343,10 @@ export function CalendarShell() {
/> />
{/* Main content area */} {/* Main content area */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<InstallPrompt />
<CalendarContent /> <CalendarContent />
</div>
{/* EventDetailPopover — standalone mode driven by Zustand openEventId */} {/* EventDetailPopover — standalone mode driven by Zustand openEventId */}
<EventDetailPopover /> <EventDetailPopover />
+476
View File
@@ -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<void>
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<BeforeInstallPromptEvent | null>(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 (
<div
role="dialog"
aria-modal="true"
aria-label="Add to Home Screen walkthrough"
style={{
position: 'fixed',
inset: 0,
background: 'var(--color-overlay, rgba(0,0,0,0.5))',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
zIndex: 1000,
}}
onClick={(e) => {
// Close on backdrop click
if (e.target === e.currentTarget) onClose()
}}
>
<div
style={{
background: 'var(--color-surface, #ffffff)',
borderRadius: '12px 12px 0 0',
padding: 'var(--space-6, 24px)',
maxHeight: '90dvh',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-4, 16px)',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<h2
style={{
margin: 0,
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 'var(--text-heading-weight, 600)',
lineHeight: 'var(--text-heading-line-height, 1.25)',
color: 'var(--color-text-primary, #111318)',
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
}}
>
Add to Home Screen
</h2>
<button
onClick={onClose}
aria-label="Close walkthrough"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
minWidth: '44px',
minHeight: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-secondary, #5c6472)',
borderRadius: 'var(--space-1, 4px)',
}}
>
<X size={20} aria-hidden="true" />
</button>
</div>
{/* Steps */}
<ol
style={{
margin: 0,
padding: 0,
listStyle: 'none',
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-4, 16px)',
}}
>
{IOS_STEPS.map((step, i) => (
<li
key={i}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 'var(--space-3, 12px)',
}}
>
{/* Step number with accent color annotation (#F5A623 — --color-member-2) */}
<span
style={{
flexShrink: 0,
width: '28px',
height: '28px',
borderRadius: '50%',
background: '#F5A623',
color: '#ffffff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '13px',
fontWeight: 600,
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
}}
>
{i + 1}
</span>
<span
style={{
fontSize: 'var(--text-body-size, 15px)',
lineHeight: 'var(--text-body-line-height, 1.5)',
color: 'var(--color-text-primary, #111318)',
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
paddingTop: '4px',
}}
>
{step}
</span>
</li>
))}
</ol>
{/* Done button */}
<button
onClick={onClose}
style={{
background: 'var(--color-text-primary, #111318)',
color: '#ffffff',
border: 'none',
borderRadius: 'var(--space-1, 4px)',
minHeight: '44px',
padding: '0 var(--space-4, 16px)',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
cursor: 'pointer',
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
alignSelf: 'stretch',
}}
>
Done
</button>
</div>
</div>
)
}
// ── 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<boolean>(
() => 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 (
<>
<div
role="banner"
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3, 12px)',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
background: 'var(--color-surface-raised, #ffffff)',
borderBottom: '1px solid var(--color-border, #e2e4e9)',
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
}}
>
{/* Icon */}
<Smartphone
size={24}
aria-hidden="true"
style={{ color: 'var(--color-text-secondary, #5c6472)', flexShrink: 0 }}
/>
{/* Text content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '13px',
fontWeight: 600,
color: 'var(--color-text-primary, #111318)',
lineHeight: 1.4,
}}
>
Install FamilySync
</div>
<div
style={{
fontSize: '13px',
color: 'var(--color-text-secondary, #5c6472)',
lineHeight: 1.4,
}}
>
Add to your Home Screen for the best experience.{' '}
<button
onClick={() => setWalkthroughOpen(true)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
fontSize: '13px',
color: 'var(--color-focus-ring, #4A90D9)',
textDecoration: 'underline',
fontFamily: 'inherit',
}}
>
How to install
</button>
</div>
</div>
{/* Dismiss */}
<button
onClick={dismiss}
aria-label="Dismiss install prompt"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
minWidth: '44px',
minHeight: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-secondary, #5c6472)',
flexShrink: 0,
borderRadius: 'var(--space-1, 4px)',
}}
>
<X size={16} aria-hidden="true" />
</button>
</div>
{walkthroughOpen && <WalkthroughSheet onClose={() => setWalkthroughOpen(false)} />}
</>
)
}
// ── Android banner ──────────────────────────────────────────────────────
if (canInstall && !dismissed) {
return (
<div
role="banner"
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3, 12px)',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
background: 'var(--color-surface-raised, #ffffff)',
borderBottom: '1px solid var(--color-border, #e2e4e9)',
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
}}
>
{/* Icon */}
<Smartphone
size={24}
aria-hidden="true"
style={{ color: 'var(--color-text-secondary, #5c6472)', flexShrink: 0 }}
/>
{/* Text content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '13px',
color: 'var(--color-text-secondary, #5c6472)',
lineHeight: 1.4,
}}
>
Install FamilySync to your Home Screen for the best experience.
</div>
</div>
{/* Install CTA */}
<button
onClick={() => {
void triggerInstall().then(() => dismiss())
}}
style={{
background: 'var(--color-text-primary, #111318)',
color: '#ffffff',
border: 'none',
borderRadius: 'var(--space-1, 4px)',
minHeight: '44px',
padding: '0 var(--space-4, 16px)',
fontSize: '13px',
fontWeight: 600,
cursor: 'pointer',
fontFamily: 'inherit',
flexShrink: 0,
whiteSpace: 'nowrap',
}}
>
Install
</button>
{/* Dismiss */}
<button
onClick={dismiss}
aria-label="Dismiss install prompt"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
minWidth: '44px',
minHeight: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-secondary, #5c6472)',
flexShrink: 0,
borderRadius: 'var(--space-1, 4px)',
}}
>
<X size={16} aria-hidden="true" />
</button>
</div>
)
}
// Nothing applicable — desktop, already installed, or dismissed
return null
}