← 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">
                &#x2191;
              </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>
  );
}