"use client"; import { AnimatePresence, motion, useDragControls, useReducedMotion } from "framer-motion"; import { useCallback, useEffect, useState } from "react"; import { AddToHomeIcon, ChevronDownIcon, ChromeIcon, CompassIcon, DownloadIcon, EllipsisIcon, PlusIcon, ShareIcon, XIcon, } from "@/components/icons"; import { Spinner } from "@/components/ui"; import { useI18n } from "@/lib/i18n"; import { useBackClose } from "@/lib/use-back-close"; import { detectBrowser, detectPlatform, dismissInstallPrompt, INSTALL_PROMPT_EVENT, INSTALLED_EVENT, installSheetTheme, isDismissed, isInstalled, isStandalone, markInstalled, OPEN_INSTALL_EVENT, type BeforeInstallPromptEvent, type InstallBrowser, type InstallPlatform, } from "@/lib/install"; import { cn } from "@/lib/utils"; type SheetInfo = { platform: InstallPlatform; browser: InstallBrowser; theme: "dark" | "light"; }; /** * "Install the app" bottom sheet. Mounted once in the root layout so it can * appear over any screen (including the API-key gate) about a second after * load. Phones/tablets only, never when already installed, and at most once * per 7 days after a dismissal. Closing it leaves the site exactly as it was. */ export function InstallSheet() { const [info, setInfo] = useState(null); const [open, setOpen] = useState(false); const [promptEvent, setPromptEvent] = useState(null); const [installing, setInstalling] = useState(false); const dragControls = useDragControls(); const reduceMotion = useReducedMotion(); const { t } = useI18n(); const domain = typeof window === "undefined" ? "" : window.location.hostname; /* System Back closes the sheet first. */ useBackClose(open, () => setOpen(false)); /** Everything needed to render the sheet, or null if it shouldn't appear. */ const prepare = useCallback((): SheetInfo | null => { const platform = detectPlatform(); if (platform === "other" || isInstalled()) return null; return { platform, browser: detectBrowser(platform), theme: installSheetTheme() }; }, []); // Automatic prompt: about a second after load, unless dismissed recently. useEffect(() => { if (isStandalone()) { markInstalled(); return; } const next = prepare(); if (!next || isDismissed()) return; const timer = window.setTimeout(() => { setInfo(next); setOpen(true); }, 1000); return () => window.clearTimeout(timer); }, [prepare]); // Manual open from the Settings row (ignores the 7-day window). useEffect(() => { const onOpen = () => { const next = prepare(); if (!next) return; setInfo(next); setOpen(true); }; window.addEventListener(OPEN_INSTALL_EVENT, onOpen); return () => window.removeEventListener(OPEN_INSTALL_EVENT, onOpen); }, [prepare]); // Chrome's deferred install prompt + the "installed" signal. useEffect(() => { const sync = () => setPromptEvent(window.__freezmoInstallPrompt ?? null); const onInstalled = () => { markInstalled(); setPromptEvent(null); setInstalling(false); setOpen(false); }; window.addEventListener(INSTALL_PROMPT_EVENT, sync); window.addEventListener(INSTALLED_EVENT, onInstalled); window.addEventListener("appinstalled", onInstalled); sync(); return () => { window.removeEventListener(INSTALL_PROMPT_EVENT, sync); window.removeEventListener(INSTALLED_EVENT, onInstalled); window.removeEventListener("appinstalled", onInstalled); }; }, []); const close = useCallback(() => { dismissInstallPrompt(); setOpen(false); setInstalling(false); }, []); // Escape closes, like every other sheet in the app. useEffect(() => { if (!open) return; const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") close(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, close]); const runPrompt = async () => { if (!promptEvent || installing) return; setInstalling(true); try { await promptEvent.prompt(); const choice = await promptEvent.userChoice; if (choice.outcome === "accepted") { markInstalled(); setOpen(false); setInstalling(false); return; } } catch { /* the native prompt can fail; the steps below still work */ } setInstalling(false); }; // The exact iOS flow: Safari users skip the "open in Safari" step entirely. const steps: React.ReactNode[] = []; if (info?.platform === "ios") { if (info.browser !== "safari") { steps.push( <> {t("install.openIn")} }>Safari , ); } steps.push( <> {t("install.tap")}{" "} } label={t("install.moreOptions")} />{" "} {t("install.inBrowserBar")} , ); steps.push( <> {t("install.tap")} }>{t("install.share")} {t("install.then")} }>{t("install.more")} , ); steps.push( <> {t("install.choose")}{" "} }>{t("install.addToHome")} , ); steps.push( <> {t("install.tapConfirm")}{" "} }>{t("install.add")}{" "} {t("install.toConfirm")} , ); } else if (info?.platform === "android") { if (info.browser !== "chrome") { steps.push( <> {t("install.openIn")} }>Chrome , ); } steps.push( <> {t("install.tap")}{" "} } label={t("install.moreOptions")} />{" "} {t("install.inTopRight")} , ); steps.push( <> {t("install.choose")}{" "} }>{t("install.installApp")}{" "} {t("install.or")}{" "} }>{t("install.addToHomeScreen")} , ); steps.push( <> {t("install.tapConfirm")}{" "} }>{t("install.install")}{" "} {t("install.toConfirm")} , ); } const showNativePrompt = info?.platform === "android" && Boolean(promptEvent); return ( // The sheet is dark by default; only an explicit light choice in the app // switches the palette (the wrapper scopes the app's .dark tokens).
{open && info ? (
{ if (gesture.offset.y > 90 || gesture.delta.y > 140 || gesture.velocity.y > 700) close(); }} initial={reduceMotion ? { opacity: 0 } : { y: "100%" }} animate={reduceMotion ? { opacity: 1 } : { y: 0 }} exit={reduceMotion ? { opacity: 0 } : { y: "100%" }} transition={{ duration: 0.34, ease: [0.22, 1, 0.36, 1] }} className="gate-card absolute inset-x-0 bottom-0 mx-auto w-full rounded-b-none border-b-0 sm:max-w-xl" >
dragControls.start(event)} className="mx-auto flex h-6 w-full cursor-grab touch-none items-center justify-center active:cursor-grabbing" >

{info.platform === "ios" ? t("install.titleIos") : t("install.titleAndroid")}

{/* app card */}
{/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */} Freezmo {domain}
{showNativePrompt ? ( ) : null} {showNativePrompt ? (

{t("install.orManually")}

) : null}
    {steps.map((step, index) => (
  1. {index + 1} {step}
  2. ))}
) : null}
); } /** Small pill for a UI element the user has to tap. */ function Chip({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) { return ( {icon} {children} ); } /** Icon-only pill, for buttons that are just a glyph (the ⋯ menu). */ function IconChip({ icon, label }: { icon: React.ReactNode; label: string }) { return ( {icon} ); } /** The one coloured element: the final confirmation button (iOS blue). */ function AccentPill({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) { return ( {icon} {children} ); }