"use client"; import { useEffect, useState } from "react"; export interface BeforeInstallPromptEvent extends Event { prompt: () => Promise; userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; } function isStandalone(): boolean { if (typeof window === "undefined") return false; return ( window.matchMedia("(display-mode: standalone)").matches || // iOS Safari's own (non-standard, but the only signal it exposes). (window.navigator as Navigator & { standalone?: boolean }).standalone === true ); } function isIos(): boolean { if (typeof navigator === "undefined") return false; return /iphone|ipad|ipod/i.test(navigator.userAgent); } /** * Shared `beforeinstallprompt` capture + standalone/iOS detection — the * one PWA install implementation both `InstallAppButton` (Settings, every * role) and `WelcomeInstallCard` (the unauthenticated Welcome page) use. * See the brief's explicit "do not create a competing second installer". * All detection lives in `useEffect`, never a `useState` lazy * initializer — reading `window`/`navigator` during the initializer would * run during SSR too and mismatch the client's first render (the same * hydration bug this app's push-subscribe-toggle.tsx fixed in Phase 7). */ export function useInstallPrompt() { const [deferredPrompt, setDeferredPrompt] = useState(null); const [installed, setInstalled] = useState(false); const [ios, setIos] = useState(false); useEffect(() => { // Async chain, not a direct setState call in the effect body — same // pattern push-subscribe-toggle.tsx uses (see that file's own // comment) to satisfy react-hooks/set-state-in-effect while still // reading browser-only APIs that can't run during SSR. void Promise.resolve().then(() => { setInstalled(isStandalone()); setIos(isIos()); }); const onBeforeInstallPrompt = (event: Event) => { event.preventDefault(); setDeferredPrompt(event as BeforeInstallPromptEvent); }; const onInstalled = () => { setInstalled(true); setDeferredPrompt(null); }; window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt); window.addEventListener("appinstalled", onInstalled); return () => { window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt); window.removeEventListener("appinstalled", onInstalled); }; }, []); const promptInstall = async (): Promise => { if (!deferredPrompt) return; await deferredPrompt.prompt(); await deferredPrompt.userChoice; setDeferredPrompt(null); }; return { installed, ios, canPromptNatively: !!deferredPrompt, promptInstall }; }