← back to Homesonspec
apps/web/src/components/InstallPrompt.tsx
181 lines
"use client";
import { useEffect, useState } from "react";
import { track } from "../lib/analytics";
// A dismissible "install this app" prompt. Handles both install mechanics:
// • Android / desktop Chrome → capture `beforeinstallprompt`, show a real Install button.
// • iOS Safari (no such event) → show a hint to tap Share → Add to Home Screen.
// Self-suppresses when already installed, and remembers dismissal for 14 days.
const DISMISS_KEY = "hos-install-dismissed";
const DISMISS_DAYS = 14;
const VISITS_KEY = "hos-visits";
// iOS gets a manual hint (no browser install event). Only show it to visitors who've
// engaged — ≥2 page loads — so a cold bouncer isn't nagged over the listing they're reading.
const IOS_MIN_VISITS = 2;
function bumpVisits(): number {
try {
const n = Number(localStorage.getItem(VISITS_KEY) || "0") + 1;
localStorage.setItem(VISITS_KEY, String(n));
return n;
} catch {
return 1;
}
}
type BIPEvent = Event & { prompt: () => Promise<void>; userChoice: Promise<{ outcome: string }> };
function recentlyDismissed(): boolean {
try {
const raw = localStorage.getItem(DISMISS_KEY);
if (!raw) return false;
const ts = Number(raw);
if (!Number.isFinite(ts)) return false;
return Date.now() - ts < DISMISS_DAYS * 24 * 60 * 60 * 1000;
} catch {
return false;
}
}
function isStandalone(): boolean {
if (typeof window === "undefined") return false;
return (
window.matchMedia?.("(display-mode: standalone)").matches ||
// iOS Safari exposes this non-standard flag when launched from the home screen.
(window.navigator as unknown as { standalone?: boolean }).standalone === true
);
}
function isIOS(): boolean {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent;
const iOSDevice = /iphone|ipad|ipod/i.test(ua);
// iPadOS 13+ masquerades as Mac — detect the touch Mac case too.
const iPadOS = navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
return iOSDevice || iPadOS;
}
export default function InstallPrompt() {
const [mode, setMode] = useState<"none" | "button" | "ios">("none");
const [deferred, setDeferred] = useState<BIPEvent | null>(null);
useEffect(() => {
if (isStandalone() || recentlyDismissed()) return;
const visits = bumpVisits();
// Chromium path: intercept the native mini-infobar and drive install ourselves.
const onBIP = (e: Event) => {
e.preventDefault();
setDeferred(e as BIPEvent);
setMode("button");
};
window.addEventListener("beforeinstallprompt", onBIP);
// Once installed, clear the prompt — and record the conversion.
const onInstalled = () => {
track("pwa_installed");
setMode("none");
};
window.addEventListener("appinstalled", onInstalled);
// iOS never fires beforeinstallprompt — offer the manual hint after a short delay
// (so it doesn't compete with first paint), and only to engaged visitors.
let t: ReturnType<typeof setTimeout> | undefined;
if (isIOS() && visits >= IOS_MIN_VISITS) {
t = setTimeout(() => setMode((m) => (m === "none" ? "ios" : m)), 2500);
}
return () => {
window.removeEventListener("beforeinstallprompt", onBIP);
window.removeEventListener("appinstalled", onInstalled);
if (t) clearTimeout(t);
};
}, []);
// Record when the prompt actually becomes visible (once per show, either surface).
useEffect(() => {
if (mode === "ios") track("pwa_prompt_shown", { surface: "ios" });
else if (mode === "button") track("pwa_prompt_shown", { surface: "chromium" });
}, [mode]);
const dismiss = () => {
track("pwa_prompt_dismissed", { surface: mode });
try {
localStorage.setItem(DISMISS_KEY, String(Date.now()));
} catch {
/* ignore */
}
setMode("none");
};
const install = async () => {
if (!deferred) return;
await deferred.prompt();
const res = await deferred.userChoice.catch(() => null);
track("pwa_install_result", { outcome: res?.outcome ?? "unknown" });
setDeferred(null);
setMode("none");
};
if (mode === "none") return null;
return (
<div
role="dialog"
aria-label="Install Homes on Spec"
// Stable hook for the mobile app's WebView CSS to hide this banner
// inside the in-app Browse tab (the native app has its own install path).
data-hos-install-prompt
className="fixed inset-x-3 bottom-3 z-[1000] mx-auto max-w-md rounded-2xl border border-brand-200 bg-white p-4 shadow-lg shadow-brand-950/10"
>
<div className="flex items-start gap-3">
<img src="/icon-192.png" alt="" width={44} height={44} className="rounded-xl" />
<div className="min-w-0 flex-1">
<p className="font-display text-sm font-semibold text-brand-800">
Install Homes on Spec
</p>
{mode === "button" ? (
<p className="mt-0.5 text-xs text-neutral-600">
Add it to your home screen for full-screen, one-tap access — no App Store needed.
</p>
) : (
<p className="mt-0.5 text-xs text-neutral-600">
Tap the Share button{" "}
<span aria-hidden className="mx-0.5 font-semibold text-brand-700">
↑
</span>{" "}
then <span className="font-semibold text-brand-700">Add to Home Screen</span>.
</p>
)}
<div className="mt-2.5 flex items-center gap-2">
{mode === "button" && (
<button
onClick={install}
className="rounded-full bg-accent-500 px-4 py-1.5 text-xs font-semibold text-brand-950 hover:bg-accent-400"
>
Install
</button>
)}
<button
onClick={dismiss}
className="rounded-full px-3 py-1.5 text-xs font-medium text-neutral-500 hover:bg-neutral-100"
>
Not now
</button>
</div>
</div>
<button
onClick={dismiss}
aria-label="Dismiss install prompt"
className="-mr-1 -mt-1 rounded-full p-1.5 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
</div>
);
}