"use client"; import { useEffect, useState } from "react"; /** * "Install the app" helpers: platform/browser detection plus the small amount * of persistence the bottom sheet needs. * * Everything here is browser-only and side-effect free until called. The * install prompt is never shown on desktop, never inside an installed app, and * at most once per 7 days after a dismissal. */ export type InstallPlatform = "ios" | "android" | "other"; export type InstallBrowser = "safari" | "chrome" | "other" | "in-app"; /** Chrome's `beforeinstallprompt` event (not in the DOM typings). */ export type BeforeInstallPromptEvent = Event & { prompt: () => Promise; userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>; }; declare global { interface Window { __freezmoInstallPrompt?: BeforeInstallPromptEvent; } } const DISMISS_KEY = "freezmo.install.dismissedAt"; const INSTALLED_KEY = "freezmo.install.installed"; const THEME_KEY = "freezmo.theme"; /** How long a dismissal silences the automatic prompt. */ export const INSTALL_REMIND_MS = 7 * 24 * 60 * 60 * 1000; /** Fired by Settings to open the sheet manually (ignores the dismissal). */ export const OPEN_INSTALL_EVENT = "freezmo:open-install"; /** Fired by the inline head script when Chrome offers an install prompt. */ export const INSTALL_PROMPT_EVENT = "freezmo:installprompt"; /** Fired by the inline head script when the app gets installed. */ export const INSTALLED_EVENT = "freezmo:installed"; /** * iPhone/iPad (including iPadOS 13+, which reports itself as a Mac — a Mac * with more than one touch point is really an iPad) or Android. Everything * else is desktop/unknown and never sees the prompt. */ export function detectPlatform( ua: string = typeof navigator === "undefined" ? "" : navigator.userAgent, maxTouchPoints: number = typeof navigator === "undefined" ? 0 : navigator.maxTouchPoints || 0, ): InstallPlatform { if (/iPhone|iPad|iPod/i.test(ua)) return "ios"; if (/Macintosh/i.test(ua) && maxTouchPoints > 1) return "ios"; if (/Android/i.test(ua)) return "android"; return "other"; } /** Embedded webviews (Instagram, Facebook, …) can't install anything. */ const IN_APP_PATTERN = /(FBAN|FBAV|FB_IAB|Instagram|Line\/|Twitter|TikTok|SnapChat|Pinterest|MicroMessenger|GSA\/|; wv\))/i; /** * Which browser is this? Used to adapt step 1 of the instructions: * iOS outside Safari (and Android outside Chrome) has to switch browsers * first, and in-app browsers can't install at all. */ export function detectBrowser( platform: InstallPlatform, ua: string = typeof navigator === "undefined" ? "" : navigator.userAgent, ): InstallBrowser { if (IN_APP_PATTERN.test(ua)) return "in-app"; if (platform === "ios") { const otherIos = /(CriOS|FxiOS|EdgiOS|OPiOS|DuckDuckGo)/.test(ua); return /Safari\//.test(ua) && !otherIos ? "safari" : "other"; } if (platform === "android") { const otherAndroid = /(SamsungBrowser|OPR\/|Firefox|EdgA|DuckDuckGo|YaBrowser|Vivaldi)/.test(ua); return /Chrome\//.test(ua) && !otherAndroid ? "chrome" : "other"; } return "other"; } /** True when the app is already running from the home screen / app switcher. */ export function isStandalone(): boolean { if (typeof window === "undefined") return false; try { const displayMode = window.matchMedia("(display-mode: standalone)").matches; const iosStandalone = (window.navigator as Navigator & { standalone?: boolean }).standalone === true; return displayMode || iosStandalone; } catch { return false; } } export function markInstalled(): void { try { window.localStorage.setItem(INSTALLED_KEY, "1"); } catch { /* private mode */ } } export function isMarkedInstalled(): boolean { try { return window.localStorage.getItem(INSTALLED_KEY) === "1"; } catch { return false; } } /** Installed now, or recorded as installed earlier in this browser. */ export function isInstalled(): boolean { return isStandalone() || isMarkedInstalled(); } /** True while a previous dismissal is still within its 7-day window. */ export function isDismissed(now: number = Date.now()): boolean { try { const raw = window.localStorage.getItem(DISMISS_KEY); if (!raw) return false; const at = Number.parseInt(raw, 10); return Number.isFinite(at) && now - at < INSTALL_REMIND_MS; } catch { return false; } } export function dismissInstallPrompt(now: number = Date.now()): void { try { window.localStorage.setItem(DISMISS_KEY, String(now)); } catch { /* private mode */ } } /** Opens the sheet manually (used by the Settings row). */ export function openInstallSheet(): void { if (typeof window === "undefined") return; window.dispatchEvent(new CustomEvent(OPEN_INSTALL_EVENT)); } /** * The sheet follows the app's *explicit* theme choice: dark unless the user * picked light in the app (a system preference alone doesn't count). */ export function installSheetTheme(): "dark" | "light" { try { return window.localStorage.getItem(THEME_KEY) === "light" ? "light" : "dark"; } catch { return "dark"; } } /** * Settings helper: is the "Install app" row relevant here? (Mobile platform * and not installed yet.) Starts false so server rendering never mismatches. */ export function useInstallAvailable(): boolean { const [available, setAvailable] = useState(false); useEffect(() => { setAvailable(detectPlatform() !== "other" && !isInstalled()); }, []); return available; }