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.
507 lines
17 KiB
TypeScript
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;
|
|
}
|