feat(05-04): usePushSubscription hook + PushPermissionPrompt + App mount

- Create apps/pwa/src/hooks/usePushSubscription.ts: subscribe (in tap handler, VAPID key cached), unsubscribe, permission; health-check on mount (D-10); urlBase64ToUint8Array helper; prefetchVapidKey export
- Create apps/pwa/src/components/PushPermissionPrompt.tsx: WalkthroughSheet-style bottom sheet, Bell icon, 'Stay in the loop' heading, 48px Enable CTA (var(--color-member-0)), 44px Not-now ghost, no backdrop-dismiss, pushPermissionDismissed key, Loader2 spinner while awaiting
- Mount PushPermissionPrompt in App.tsx (for installed-PWA path) and InstallPrompt.tsx (for post-Android-install justInstalled path)
- Build green; tsc clean
This commit is contained in:
Lucas Berger
2026-06-09 21:09:41 -04:00
parent e5953ebb31
commit bf8f63b47c
4 changed files with 553 additions and 4 deletions
+18 -4
View File
@@ -23,6 +23,7 @@
import { useState, useEffect } from 'react'
import { Smartphone, X } from 'lucide-react'
import { PushPermissionPrompt } from './PushPermissionPrompt.js'
// ── iOS detection ──────────────────────────────────────────────────────────
@@ -75,13 +76,19 @@ interface BeforeInstallPromptEvent extends 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)
const installedHandler = () => {
setDeferredPrompt(null)
setJustInstalled(true)
}
window.addEventListener('beforeinstallprompt', handler)
window.addEventListener('appinstalled', installedHandler)
@@ -101,7 +108,7 @@ export function useAndroidInstallPrompt() {
}
}
return { canInstall: deferredPrompt !== null, triggerInstall }
return { canInstall: deferredPrompt !== null, triggerInstall, justInstalled }
}
// ── iOS Walkthrough Sheet ─────────────────────────────────────────────────
@@ -300,7 +307,7 @@ function persistDismissed(): void {
export function InstallPrompt() {
const [dismissed, setDismissed] = useState<boolean>(readDismissed)
const [walkthroughOpen, setWalkthroughOpen] = useState(false)
const { canInstall, triggerInstall } = useAndroidInstallPrompt()
const { canInstall, triggerInstall, justInstalled } = useAndroidInstallPrompt()
// Re-check isInstalled on mount — matchMedia is only available in the browser
const [installed, setInstalled] = useState(false)
@@ -308,7 +315,14 @@ export function InstallPrompt() {
setInstalled(isInstalled())
}, [])
// Nothing to show when already installed
// 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() {
@@ -0,0 +1,314 @@
/**
* 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, useRef } from 'react'
import { Bell, Loader2, X } from 'lucide-react'
import { usePushSubscription, prefetchVapidKey } from '../hooks/usePushSubscription.js'
// ── 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)
const headingId = useRef(`push-prompt-heading-${Math.random().toString(36).slice(2)}`)
const { subscribe, permission } = usePushSubscription()
useEffect(() => {
setInstalled(isInstalled())
}, [])
// Pre-fetch the VAPID key while the prompt is visible so the tap is instant
useEffect(() => {
if (!installed) return
if (permission !== 'default') return
if (dismissed) return
void prefetchVapidKey()
}, [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).
// No await before subscribe() in this scope; the async execution starts inside subscribe().
function handleEnableClick() {
if (loading) return
setLoading(true)
setError(null)
void (async () => {
try {
const registration = await navigator.serviceWorker.ready
await subscribe(registration)
// 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.current}
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.current}
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 */}
<button
onClick={handleEnableClick}
disabled={loading}
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>
)
}