Files
familysync/apps/pwa/src/components/InstallPrompt.tsx
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

507 lines
17 KiB
TypeScript

/**
* 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<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);
// 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 (
<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.
*/
// 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<boolean>(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 <PushPermissionPrompt />;
}
// 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 (
<>
<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;
}