Milestone v1.0: FamilySync MVP #1
@@ -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}
|
||||
/>
|
||||
<InstallPrompt />
|
||||
<CalendarContent />
|
||||
<EventDetailPopover />
|
||||
</div>
|
||||
@@ -341,7 +343,10 @@ export function CalendarShell() {
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<CalendarContent />
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<InstallPrompt />
|
||||
<CalendarContent />
|
||||
</div>
|
||||
|
||||
{/* EventDetailPopover — standalone mode driven by Zustand openEventId */}
|
||||
<EventDetailPopover />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user