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.
352 lines
12 KiB
TypeScript
352 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>
|
|
</div>
|
|
);
|
|
}
|