diff --git a/apps/web/components/pwa-install-prompt.tsx b/apps/web/components/pwa-install-prompt.tsx index a140ac87..b7776825 100644 --- a/apps/web/components/pwa-install-prompt.tsx +++ b/apps/web/components/pwa-install-prompt.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect, useCallback } from "react" +import { useState, useEffect, useCallback, useRef } from "react" import { cn } from "@lib/utils" import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" import { XIcon, Brain, Sparkles, Globe } from "lucide-react" @@ -10,14 +10,32 @@ import { AnimatePresence, motion } from "motion/react" const PWA_DISMISS_KEY = "pwa-install-dismissed" const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days -function getDeviceInfo() { - if (typeof window === "undefined") return { isIOS: false, isAndroid: false } +type DeviceInfo = { + isIOS: boolean + isAndroid: boolean + isSafari: boolean + isChrome: boolean +} + +/** In-memory fallback when localStorage is unavailable */ +let memoryDismissed = false + +function getDeviceInfo(): DeviceInfo { + if (typeof window === "undefined") + return { isIOS: false, isAndroid: false, isSafari: false, isChrome: false } const ua = navigator.userAgent const isIOS = /iPad|iPhone|iPod/.test(ua) || - (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) + (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1) const isAndroid = /Android/.test(ua) - return { isIOS, isAndroid } + + // Safari: contains "Safari" but not "CriOS", "FxiOS", "Chrome", "Edg", etc. + const isSafari = + /Safari/.test(ua) && !/CriOS|FxiOS|Chrome|Chromium|Edg|OPR|Opera/.test(ua) + // Chrome on Android: contains "Chrome" but not "Edg", "OPR", "Opera" + const isChrome = /Chrome/.test(ua) && !/Edg|OPR|Opera/.test(ua) + + return { isIOS, isAndroid, isSafari, isChrome } } function isStandalone() { @@ -31,6 +49,7 @@ function isStandalone() { function isDismissed() { if (typeof window === "undefined") return true + if (memoryDismissed) return true try { const dismissed = localStorage.getItem(PWA_DISMISS_KEY) if (!dismissed) return false @@ -51,28 +70,95 @@ const FEATURES = [ export function PWAInstallPrompt() { const [show, setShow] = useState(false) - const [device, setDevice] = useState<{ isIOS: boolean; isAndroid: boolean }>({ + const [device, setDevice] = useState({ isIOS: false, isAndroid: false, + isSafari: false, + isChrome: false, }) + const [nativePrompt, setNativePrompt] = + useState(null) + const panelRef = useRef(null) useEffect(() => { const info = getDeviceInfo() setDevice(info) + + // Listen for the native install prompt (Chromium browsers on Android) + const handleBeforeInstall = (e: Event) => { + e.preventDefault() + setNativePrompt(e as BeforeInstallPromptEvent) + } + window.addEventListener("beforeinstallprompt", handleBeforeInstall) + const isMobile = info.isIOS || info.isAndroid if (isMobile && !isStandalone() && !isDismissed()) { const timer = setTimeout(() => setShow(true), 1500) - return () => clearTimeout(timer) + return () => { + clearTimeout(timer) + window.removeEventListener("beforeinstallprompt", handleBeforeInstall) + } + } + return () => { + window.removeEventListener("beforeinstallprompt", handleBeforeInstall) } }, []) const dismiss = useCallback(() => { setShow(false) + memoryDismissed = true try { localStorage.setItem(PWA_DISMISS_KEY, Date.now().toString()) } catch {} }, []) + // Escape key handler + useEffect(() => { + if (!show) return + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") dismiss() + } + document.addEventListener("keydown", handler) + return () => document.removeEventListener("keydown", handler) + }, [show, dismiss]) + + // Focus the panel when shown for accessibility + useEffect(() => { + if (show && panelRef.current) { + panelRef.current.focus() + } + }, [show]) + + const handleInstall = useCallback(async () => { + if (nativePrompt) { + nativePrompt.prompt() + const { outcome } = await nativePrompt.userChoice + if (outcome === "accepted") { + setShow(false) + } + setNativePrompt(null) + } + dismiss() + }, [nativePrompt, dismiss]) + + /** + * Determine which instructions to show: + * - iOS + Safari → standard iOS steps + * - iOS + non-Safari → "open in Safari" hint + * - Android + Chrome (with native prompt) → trigger native install + * - Android + Chrome (no native prompt) → manual Chrome steps + * - Android + non-Chrome → "open in Chrome" hint + */ + const renderSteps = () => { + if (device.isIOS) { + if (device.isSafari) return + return + } + // Android + if (device.isChrome) return + return + } + return ( {show && ( @@ -85,13 +171,18 @@ export function PWAInstallPrompt() { onClick={dismiss} > e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="pwa-install-title" + tabIndex={-1} className={cn( - "w-full max-w-lg bg-[#1B1F24] rounded-t-[22px] p-6 pb-8 flex flex-col gap-5", + "w-full max-w-lg bg-[#1B1F24] rounded-t-[22px] p-6 pb-8 flex flex-col gap-5 outline-none", dmSansClassName(), )} style={{ @@ -107,6 +198,7 @@ export function PWAInstallPrompt() {

Install for a better experience

- {device.isIOS ? : } + {renderSteps()}

- {/* Actions */} + {/* Action */}
-
@@ -227,6 +315,23 @@ function IOSSteps() { ) } +function IOSNonSafariSteps() { + return ( +
+ + Open this page in Safari + + + Tap the share button{" "} + + + + Tap Add to Home Screen + +
+ ) +} + function AndroidSteps() { return (
@@ -245,6 +350,23 @@ function AndroidSteps() { ) } +function AndroidNonChromeSteps() { + return ( +
+ + Open this page in Chrome + + + Tap the menu button{" "} + + + + Tap Add to Home screen + +
+ ) +} + function ShareIcon({ className }: { className?: string }) { return ( ) } + +/** + * Type for the `beforeinstallprompt` event (not yet in TS lib). + * @see https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent + */ +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise + userChoice: Promise<{ outcome: "accepted" | "dismissed" }> +} + +declare global { + interface WindowEventMap { + beforeinstallprompt: BeforeInstallPromptEvent + } +}