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() {