{"version":3,"file":"use-install-prompt.cjs","names":[],"sources":["../../src/hooks/use-install-prompt.ts"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport {\n    buildOpenInChromeIntent,\n    isAndroidWithoutPromptApi,\n    isIOS,\n    isStandalone,\n    type BeforeInstallPromptEvent,\n} from \"./pwa-env\";\n\nconst DEFAULT_DECLINE_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;\nconst DEFAULT_MANUAL_FALLBACK_DELAY_MS = 3000;\nconst DEFAULT_DECLINE_STORAGE_KEY = \"tempest:install-declined-at\";\n\nlet cachedEvent: BeforeInstallPromptEvent | null = null;\nconst eventSubscribers = new Set<(event: BeforeInstallPromptEvent | null) => void>();\n\nif (typeof window !== \"undefined\") {\n    window.addEventListener(\"beforeinstallprompt\", (event: Event) => {\n        event.preventDefault();\n        cachedEvent = event as BeforeInstallPromptEvent;\n        eventSubscribers.forEach((fn) => fn(cachedEvent));\n    });\n    window.addEventListener(\"appinstalled\", () => {\n        cachedEvent = null;\n        eventSubscribers.forEach((fn) => fn(null));\n    });\n}\n\n/**\n * Strategy the UI should use to install the app:\n *\n * - `\"native\"`: trigger `BeforeInstallPromptEvent.prompt()`.\n * - `\"ios\"`: show iOS Safari Share → Add to Home Screen instructions.\n * - `\"manual\"`: show generic browser-menu instructions (Chromium forks that\n *   strip the prompt API, plus the timeout fallback when no event arrives).\n * - `\"none\"`: nothing to offer (already installed or unsupported runtime).\n */\nexport type InstallMethod = \"native\" | \"ios\" | \"manual\" | \"none\";\n\n/** Options for {@link useInstallPrompt}. */\nexport interface UseInstallPromptOptions {\n    /**\n     * `localStorage` key used to persist the decline timestamp. Defaults to\n     * `\"tempest:install-declined-at\"`.\n     */\n    declineStorageKey?: string;\n    /**\n     * How long, in ms, the install CTA stays hidden after the user declines.\n     * Defaults to 7 days.\n     */\n    declineCooldownMs?: number;\n    /**\n     * How long, in ms, to wait for a `beforeinstallprompt` event before\n     * resolving to the `\"manual\"` method. Defaults to `3000`.\n     */\n    manualFallbackDelayMs?: number;\n}\n\n/** State returned by {@link useInstallPrompt}. */\nexport interface UseInstallPromptResult {\n    /** The cached `beforeinstallprompt` event, or `null` when unavailable. */\n    deferredPrompt: BeforeInstallPromptEvent | null;\n    /** True when the app can be installed through any supported method. */\n    canInstall: boolean;\n    /** True when running on iOS/iPadOS Safari. */\n    isIOS: boolean;\n    /** True when already running as an installed PWA. */\n    isStandalone: boolean;\n    /** True when the browser is an Android Chromium fork with no prompt API. */\n    isManualAndroid: boolean;\n    /** True when no `beforeinstallprompt` arrived within the timeout window. */\n    promptTimedOut: boolean;\n    /** The resolved install strategy the UI should follow. */\n    method: InstallMethod;\n    /** An `intent://` URL to re-open the page in Chrome on Android, else `null`. */\n    openInChromeIntent: string | null;\n    /**\n     * Triggers the native prompt.\n     *\n     * @returns `true` when the user accepted the install, `false` otherwise.\n     */\n    install: () => Promise<boolean>;\n    /** Records that the user declined the CTA, starting the cooldown window. */\n    recordDecline: () => void;\n}\n\n/**\n * Whether a recorded decline is still inside its cooldown window.\n *\n * Reading is guarded because `localStorage.getItem` **throws** where this hook\n * matters most: Safari private mode and a cross-origin frame with storage\n * blocked. Unguarded, that exception escapes during render and takes the page\n * down — the install CTA is a courtesy, and no courtesy is worth a blank screen.\n *\n * @param key - Storage key holding the decline timestamp.\n * @param cooldownMs - How long a decline suppresses the CTA.\n * @returns Whether the CTA should stay hidden.\n */\nfunction readDecline(key: string, cooldownMs: number): boolean {\n    if (typeof window === \"undefined\") return false;\n    let raw: string | null;\n    try {\n        raw = window.localStorage.getItem(key);\n    } catch {\n        return false;\n    }\n    if (!raw) return false;\n    const ts = Number(raw);\n    if (!Number.isFinite(ts)) return false;\n    return Date.now() - ts < cooldownMs;\n}\n\n/**\n * React hook that resolves how (and whether) to offer PWA installation.\n *\n * It caches the `beforeinstallprompt` event, detects iOS/iPadOS, detects\n * Android Chromium forks that lack the prompt API, detects standalone display\n * mode, and applies a decline cooldown persisted in `localStorage`. The\n * resulting `method` tells the UI which install affordance to render.\n *\n * Both sides of that persistence are best-effort: `localStorage` throws in\n * Safari private mode and in a cross-origin frame with storage blocked, and an\n * exception raised while resolving an install CTA would take the page down with\n * it. A refused read means \"no decline recorded\"; a refused write means the CTA\n * may come back next visit.\n *\n * The decline persistence is pluggable through\n * {@link UseInstallPromptOptions.declineStorageKey} and\n * {@link UseInstallPromptOptions.declineCooldownMs} — no app-specific storage\n * layer is required, and it is SSR-guarded.\n *\n * @param options - Optional storage key, cooldown, and fallback-delay tuning.\n * @returns The install state plus `install()` and `recordDecline()` actions.\n *\n * @example\n * const { method, install } = useInstallPrompt();\n * if (method === \"native\") return <button onClick={install}>Install</button>;\n */\nexport function useInstallPrompt(options: UseInstallPromptOptions = {}): UseInstallPromptResult {\n    const {\n        declineStorageKey = DEFAULT_DECLINE_STORAGE_KEY,\n        declineCooldownMs = DEFAULT_DECLINE_COOLDOWN_MS,\n        manualFallbackDelayMs = DEFAULT_MANUAL_FALLBACK_DELAY_MS,\n    } = options;\n\n    const [event, setEvent] = useState<BeforeInstallPromptEvent | null>(cachedEvent);\n    const [standalone, setStandalone] = useState<boolean>(isStandalone());\n    const [promptTimedOut, setPromptTimedOut] = useState<boolean>(false);\n\n    useEffect(() => {\n        eventSubscribers.add(setEvent);\n        return () => {\n            eventSubscribers.delete(setEvent);\n        };\n    }, []);\n\n    useEffect(() => {\n        if (typeof window === \"undefined\") return;\n        const mq = window.matchMedia?.(\"(display-mode: standalone)\");\n        const handler = (): void => setStandalone(isStandalone());\n        mq?.addEventListener?.(\"change\", handler);\n        window.addEventListener(\"appinstalled\", handler);\n        return () => {\n            mq?.removeEventListener?.(\"change\", handler);\n            window.removeEventListener(\"appinstalled\", handler);\n        };\n    }, []);\n\n    useEffect(() => {\n        if (event) {\n            setPromptTimedOut(false);\n            return;\n        }\n        const timer = window.setTimeout(() => {\n            if (!cachedEvent) setPromptTimedOut(true);\n        }, manualFallbackDelayMs);\n        return () => window.clearTimeout(timer);\n    }, [event, manualFallbackDelayMs]);\n\n    const ios = isIOS();\n    const manualAndroid = isAndroidWithoutPromptApi();\n\n    const declineActive = readDecline(declineStorageKey, declineCooldownMs);\n\n    let method: InstallMethod = \"none\";\n    if (!standalone && !declineActive) {\n        if (event) method = \"native\";\n        else if (ios) method = \"ios\";\n        else if (manualAndroid || promptTimedOut) method = \"manual\";\n    }\n\n    const recordDecline = (): void => {\n        if (typeof window === \"undefined\") return;\n        try {\n            window.localStorage.setItem(declineStorageKey, String(Date.now()));\n        } catch {\n            /* empty */\n        }\n    };\n\n    const install = async (): Promise<boolean> => {\n        if (!event) return false;\n        await event.prompt();\n        const choice = await event.userChoice;\n        if (choice.outcome === \"dismissed\") recordDecline();\n        cachedEvent = null;\n        eventSubscribers.forEach((fn) => fn(null));\n        return choice.outcome === \"accepted\";\n    };\n\n    return {\n        deferredPrompt: event,\n        canInstall: method !== \"none\",\n        isIOS: ios,\n        isStandalone: standalone,\n        isManualAndroid: manualAndroid,\n        promptTimedOut,\n        method,\n        openInChromeIntent: buildOpenInChromeIntent(),\n        install,\n        recordDecline,\n    };\n}\n"],"mappings":"wDASA,IAAM,EAA8B,OAC9B,EAAmC,IACnC,EAA8B,8BAEhC,EAA+C,KAC7C,EAAmB,IAAI,IAEzB,OAAO,OAAW,MAClB,OAAO,iBAAiB,sBAAwB,GAAiB,CAC7D,EAAM,eAAe,EACrB,EAAc,EACd,EAAiB,QAAS,GAAO,EAAG,CAAW,CAAC,CACpD,CAAC,EACD,OAAO,iBAAiB,mBAAsB,CAC1C,EAAc,KACd,EAAiB,QAAS,GAAO,EAAG,IAAI,CAAC,CAC7C,CAAC,GAyEL,SAAS,EAAY,EAAa,EAA6B,CAC3D,GAAI,OAAO,OAAW,IAAa,MAAO,GAC1C,IAAI,EACJ,GAAI,CACA,EAAM,OAAO,aAAa,QAAQ,CAAG,CACzC,MAAQ,CACJ,MAAO,EACX,CACA,GAAI,CAAC,EAAK,MAAO,GACjB,IAAM,EAAK,OAAO,CAAG,EAErB,OADK,OAAO,SAAS,CAAE,EAChB,KAAK,IAAI,EAAI,EAAK,EADQ,EAErC,CA4BA,SAAgB,EAAiB,EAAmC,CAAC,EAA2B,CAC5F,GAAM,CACF,oBAAoB,EACpB,oBAAoB,EACpB,wBAAwB,GACxB,EAEE,CAAC,EAAO,IAAA,EAAY,EAAA,SAAA,CAA0C,CAAW,EACzE,CAAC,EAAY,IAAA,EAAiB,EAAA,SAAA,CAAkB,EAAA,aAAa,CAAC,EAC9D,CAAC,EAAgB,IAAA,EAAqB,EAAA,SAAA,CAAkB,EAAK,GAEnE,EAAA,EAAA,UAAA,MACI,EAAiB,IAAI,CAAQ,MAChB,CACT,EAAiB,OAAO,CAAQ,CACpC,GACD,CAAC,CAAC,GAEL,EAAA,EAAA,UAAA,KAAgB,CACZ,GAAI,OAAO,OAAW,IAAa,OACnC,IAAM,EAAK,OAAO,aAAa,4BAA4B,EACrD,MAAsB,EAAc,EAAA,aAAa,CAAC,EAGxD,OAFA,GAAI,mBAAmB,SAAU,CAAO,EACxC,OAAO,iBAAiB,eAAgB,CAAO,MAClC,CACT,GAAI,sBAAsB,SAAU,CAAO,EAC3C,OAAO,oBAAoB,eAAgB,CAAO,CACtD,CACJ,EAAG,CAAC,CAAC,GAEL,EAAA,EAAA,UAAA,KAAgB,CACZ,GAAI,EAAO,CACP,EAAkB,EAAK,EACvB,MACJ,CACA,IAAM,EAAQ,OAAO,eAAiB,CAC7B,GAAa,EAAkB,EAAI,CAC5C,EAAG,CAAqB,EACxB,UAAa,OAAO,aAAa,CAAK,CAC1C,EAAG,CAAC,EAAO,CAAqB,CAAC,EAEjC,IAAM,EAAM,EAAA,MAAM,EACZ,EAAgB,EAAA,0BAA0B,EAE1C,EAAgB,EAAY,EAAmB,CAAiB,EAElE,EAAwB,OACxB,CAAC,GAAc,CAAC,IACZ,EAAO,EAAS,SACX,EAAK,EAAS,OACd,GAAiB,KAAgB,EAAS,WAGvD,IAAM,MAA4B,CAC1B,YAAO,OAAW,KACtB,GAAI,CACA,OAAO,aAAa,QAAQ,EAAmB,OAAO,KAAK,IAAI,CAAC,CAAC,CACrE,MAAQ,CAER,CACJ,EAYA,MAAO,CACH,eAAgB,EAChB,WAAY,IAAW,OACvB,MAAO,EACP,aAAc,EACd,gBAAiB,EACjB,iBACA,SACA,mBAAoB,EAAA,wBAAwB,EAC5C,iBAnB0C,CAC1C,GAAI,CAAC,EAAO,MAAO,GACnB,MAAM,EAAM,OAAO,EACnB,IAAM,EAAS,MAAM,EAAM,WAI3B,OAHI,EAAO,UAAY,aAAa,EAAc,EAClD,EAAc,KACd,EAAiB,QAAS,GAAO,EAAG,IAAI,CAAC,EAClC,EAAO,UAAY,UAC9B,EAYI,eACJ,CACJ"}