Pre-fetch ServiceWorkerRegistration into component state via useEffect in both PushPermissionPrompt and SettingsSheet. Gate the subscribe tap target as disabled until both vapidKey AND swRegistration are ready. The tap handler now has zero awaits between the user gesture and registration.pushManager.subscribe(), fully satisfying the iOS user-gesture requirement.
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
/**
|
|
* PushPermissionPrompt — post-install permission bottom sheet (D-08, UI-SPEC Surface 1).
|
|
*
|
|
* Shows after the PWA is installed (isInstalled() === true) and when:
|
|
* - Notification.permission === 'default' (not yet asked)
|
|
* - The user hasn't dismissed via "Not now" (localStorage pushPermissionDismissed !== '1')
|
|
*
|
|
* Accessibility:
|
|
* - role="dialog", aria-modal="true", aria-labelledby pointing to the heading
|
|
* - 48px primary CTA, 44px secondary ghost button
|
|
* - Backdrop does NOT dismiss (permission UX must be explicit — per UI-SPEC Surface 1)
|
|
*
|
|
* Security: T-02d-01 — all text is plain-text JSX children; no dangerouslySetInnerHTML.
|
|
*
|
|
* iOS constraint (D-08 / CLAUDE.md iOS table):
|
|
* subscribe() is called synchronously inside the onClick handler — no await before
|
|
* the pushManager.subscribe() call satisfies the iOS user-gesture requirement.
|
|
*/
|
|
|
|
import { useState, useEffect, useId } from 'react'
|
|
import { Bell, Loader2, X } from 'lucide-react'
|
|
import { usePushSubscription } from '../hooks/usePushSubscription.js'
|
|
|
|
// fetchVapidKey is the internal helper; we import it directly via the module
|
|
// rather than re-exporting it through usePushSubscription, since we need to
|
|
// store the resolved key in state (not just warm the cache).
|
|
async function fetchVapidKeyForPrompt(): Promise<string | null> {
|
|
try {
|
|
const cached = sessionStorage.getItem('vapidPublicKey')
|
|
if (cached) return cached
|
|
const res = await fetch('/api/push/vapid-public-key', { credentials: 'include' })
|
|
if (!res.ok) return null
|
|
const data = (await res.json()) as { publicKey: string }
|
|
if (data.publicKey) {
|
|
sessionStorage.setItem('vapidPublicKey', data.publicKey)
|
|
}
|
|
return data.publicKey ?? null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// ── installed state check ──────────────────────────────────────────────────
|
|
|
|
function isInstalled(): boolean {
|
|
return (
|
|
window.matchMedia('(display-mode: standalone)').matches ||
|
|
(navigator as unknown as { standalone?: boolean }).standalone === true
|
|
)
|
|
}
|
|
|
|
// ── localStorage guards ────────────────────────────────────────────────────
|
|
|
|
function readDismissed(): boolean {
|
|
try {
|
|
return localStorage.getItem('pushPermissionDismissed') === '1'
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function persistDismissed(): void {
|
|
try {
|
|
localStorage.setItem('pushPermissionDismissed', '1')
|
|
} catch {
|
|
// Private mode / storage disabled — ignore
|
|
}
|
|
}
|
|
|
|
// ── PushPermissionPrompt ───────────────────────────────────────────────────
|
|
|
|
interface PushPermissionPromptProps {
|
|
/** Optional callback after the prompt is closed (granted or dismissed) */
|
|
onClose?: () => void
|
|
}
|
|
|
|
export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
|
|
const [dismissed, setDismissed] = useState<boolean>(readDismissed)
|
|
const [installed, setInstalled] = useState(false)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
// CR-04: Pre-fetch the VAPID key into state so the tap handler can call
|
|
// subscribe(registration, vapidKey) without any await before pushManager.subscribe().
|
|
// Button is disabled until the key is ready (null = not yet loaded).
|
|
const [vapidKey, setVapidKey] = useState<string | null>(null)
|
|
// NEW-CR-01: Pre-resolve the ServiceWorkerRegistration into state so the tap
|
|
// handler has ZERO awaits between the user gesture and pushManager.subscribe().
|
|
// Any await (including navigator.serviceWorker.ready) between the tap and
|
|
// pushManager.subscribe() breaks the iOS user-gesture requirement.
|
|
const [swRegistration, setSwRegistration] = useState<ServiceWorkerRegistration | null>(null)
|
|
const headingId = useId()
|
|
|
|
const { subscribe, permission } = usePushSubscription()
|
|
|
|
useEffect(() => {
|
|
setInstalled(isInstalled())
|
|
}, [])
|
|
|
|
// Pre-fetch the VAPID key into state while the prompt is visible.
|
|
// CR-04: We store the resolved key in state (not just sessionStorage) so the
|
|
// tap handler has synchronous access — no network await inside the tap path.
|
|
useEffect(() => {
|
|
if (!installed) return
|
|
if (permission !== 'default') return
|
|
if (dismissed) return
|
|
void fetchVapidKeyForPrompt().then((key) => {
|
|
if (key) setVapidKey(key)
|
|
})
|
|
}, [installed, permission, dismissed])
|
|
|
|
// NEW-CR-01: Pre-resolve the ServiceWorkerRegistration in a useEffect so the
|
|
// tap handler never has to await navigator.serviceWorker.ready.
|
|
// navigator.serviceWorker.ready resolves once the SW is active; doing this
|
|
// eagerly means the result is in state before the user can tap the button.
|
|
useEffect(() => {
|
|
if (!installed) return
|
|
if (permission !== 'default') return
|
|
if (dismissed) return
|
|
if (!navigator.serviceWorker) return
|
|
void navigator.serviceWorker.ready.then((reg) => {
|
|
setSwRegistration(reg)
|
|
})
|
|
}, [installed, permission, dismissed])
|
|
|
|
// Don't render when: not installed, already granted/denied, or dismissed
|
|
if (!installed) return null
|
|
if (permission !== 'default') return null
|
|
if (dismissed) return null
|
|
|
|
function handleDismiss() {
|
|
persistDismissed()
|
|
setDismissed(true)
|
|
onClose?.()
|
|
}
|
|
|
|
// onClick handler — subscribe() called synchronously (iOS user-gesture requirement).
|
|
// NEW-CR-01: Both vapidKey AND swRegistration are pre-resolved in state (useEffects above).
|
|
// There is ZERO await between the tap gesture and registration.pushManager.subscribe()
|
|
// inside subscribe() — the iOS gesture gate is fully satisfied.
|
|
function handleEnableClick() {
|
|
if (loading || !vapidKey || !swRegistration) return
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
// Capture both synchronously — no await in this scope before subscribe().
|
|
const resolvedVapidKey = vapidKey
|
|
const resolvedRegistration = swRegistration
|
|
|
|
void (async () => {
|
|
try {
|
|
await subscribe(resolvedRegistration, resolvedVapidKey)
|
|
// On success: close the prompt (permission is now 'granted')
|
|
setLoading(false)
|
|
onClose?.()
|
|
} catch (err) {
|
|
setLoading(false)
|
|
if (
|
|
err instanceof Error &&
|
|
err.name === 'NotAllowedError'
|
|
) {
|
|
// User denied in the native browser dialog — close the sheet
|
|
setDismissed(true)
|
|
onClose?.()
|
|
} else {
|
|
setError('Something went wrong. Please try again.')
|
|
}
|
|
}
|
|
})()
|
|
}
|
|
|
|
return (
|
|
// Full-screen backdrop — NO backdrop click-dismiss (permission UX must be explicit)
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={headingId}
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
background: 'var(--color-overlay, rgba(0,0,0,0.32))',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
justifyContent: 'flex-end',
|
|
zIndex: 1000,
|
|
}}
|
|
>
|
|
{/* Sheet */}
|
|
<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
|
|
id={headingId}
|
|
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)',
|
|
}}
|
|
>
|
|
Stay in the loop
|
|
</h2>
|
|
{/* Dismiss X — "Not now" via header close */}
|
|
<button
|
|
onClick={handleDismiss}
|
|
aria-label="Dismiss notification prompt"
|
|
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)',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<X size={20} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Bell icon + body */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-3, 12px)',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
<Bell
|
|
size={40}
|
|
aria-hidden="true"
|
|
style={{ color: 'var(--color-member-0, #4A90D9)' }}
|
|
/>
|
|
<p
|
|
style={{
|
|
margin: 0,
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
lineHeight: 'var(--text-body-line-height, 1.5)',
|
|
color: 'var(--color-text-secondary, #5c6472)',
|
|
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
|
}}
|
|
>
|
|
Get notified when events are coming up or your family makes changes.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Error message */}
|
|
{error && (
|
|
<p
|
|
role="alert"
|
|
style={{
|
|
margin: 0,
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
color: 'var(--color-destructive, #DC2626)',
|
|
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 'var(--space-2, 8px)',
|
|
}}
|
|
>
|
|
{/* Primary CTA — 48px, accent color */}
|
|
{/* NEW-CR-01: disabled until BOTH vapidKey and swRegistration are ready */}
|
|
<button
|
|
onClick={handleEnableClick}
|
|
disabled={loading || !vapidKey || !swRegistration}
|
|
aria-busy={loading}
|
|
style={{
|
|
background: 'var(--color-member-0, #4A90D9)',
|
|
color: '#ffffff',
|
|
border: 'none',
|
|
borderRadius: 'var(--space-1, 4px)',
|
|
minHeight: '48px',
|
|
padding: '0 var(--space-4, 16px)',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: 600,
|
|
cursor: loading ? 'not-allowed' : 'pointer',
|
|
fontFamily: 'inherit',
|
|
alignSelf: 'stretch',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 'var(--space-2, 8px)',
|
|
opacity: loading ? 0.7 : 1,
|
|
}}
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<Loader2
|
|
size={16}
|
|
aria-hidden="true"
|
|
style={{ animation: 'spin 1s linear infinite' }}
|
|
/>
|
|
Enabling...
|
|
</>
|
|
) : (
|
|
'Enable Notifications'
|
|
)}
|
|
</button>
|
|
|
|
{/* Secondary — "Not now", 44px ghost */}
|
|
<button
|
|
onClick={handleDismiss}
|
|
disabled={loading}
|
|
style={{
|
|
background: 'none',
|
|
color: 'var(--color-text-secondary, #5c6472)',
|
|
border: 'none',
|
|
borderRadius: 'var(--space-1, 4px)',
|
|
minHeight: '44px',
|
|
padding: '0 var(--space-4, 16px)',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: 400,
|
|
cursor: loading ? 'not-allowed' : 'pointer',
|
|
fontFamily: 'inherit',
|
|
alignSelf: 'stretch',
|
|
}}
|
|
>
|
|
Not now
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Spin animation for loader */}
|
|
<style>{`
|
|
@keyframes spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
)
|
|
}
|