← back to Homesonspec
web(pwa): add smart install prompt (iOS hint + Chromium button, engagement-gated); harden SW (gated skipWaiting, cache caps); honest offline copy — Cody-reviewed
ae8f049df670afae0a6a6a19bfb0b3d82481afc3 · 2026-08-06 16:49:00 -0700 · Steve
Files touched
M apps/web/public/offline.htmlM apps/web/public/sw.jsA apps/web/scripts/verify-install-prompt.mjsM apps/web/src/app/layout.tsxA apps/web/src/components/InstallPrompt.tsx
Diff
commit ae8f049df670afae0a6a6a19bfb0b3d82481afc3
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Aug 6 16:49:00 2026 -0700
web(pwa): add smart install prompt (iOS hint + Chromium button, engagement-gated); harden SW (gated skipWaiting, cache caps); honest offline copy — Cody-reviewed
---
apps/web/public/offline.html | 2 +-
apps/web/public/sw.js | 33 ++++--
apps/web/scripts/verify-install-prompt.mjs | 96 +++++++++++++++++
apps/web/src/app/layout.tsx | 2 +
apps/web/src/components/InstallPrompt.tsx | 165 +++++++++++++++++++++++++++++
5 files changed, 289 insertions(+), 9 deletions(-)
diff --git a/apps/web/public/offline.html b/apps/web/public/offline.html
index 7bf84aef..c963448b 100644
--- a/apps/web/public/offline.html
+++ b/apps/web/public/offline.html
@@ -43,7 +43,7 @@
</svg>
</div>
<h1>You’re offline<span class="dot">.</span></h1>
- <p>Homes on Spec needs a connection to load new listings. Any homes you already viewed are still available — and we’ll reconnect the moment you’re back.</p>
+ <p>Homes on Spec needs a connection to load new listings. Pages you’ve already opened may still load from your cache — and we’ll reconnect the moment you’re back.</p>
<button onclick="location.reload()">Try again</button>
</div>
</body>
diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js
index f86bee42..0f965e28 100644
--- a/apps/web/public/sw.js
+++ b/apps/web/public/sw.js
@@ -20,14 +20,31 @@ const SHELL_ASSETS = [
"/apple-touch-icon.png",
];
+// Cap for the runtime/api caches so they can't grow unbounded on a long session.
+const RUNTIME_MAX = 50;
+const API_MAX = 40;
+
+async function trimCache(name, max) {
+ const cache = await caches.open(name);
+ const keys = await cache.keys(); // insertion order → slice off the oldest past the cap
+ if (keys.length <= max) return;
+ await Promise.all(keys.slice(0, keys.length - max).map((k) => cache.delete(k)));
+}
+
self.addEventListener("install", (event) => {
event.waitUntil(
- caches.open(SHELL_CACHE).then((cache) =>
- // addAll is atomic — don't let one 404 abort install; add best-effort.
- Promise.allSettled(SHELL_ASSETS.map((url) => cache.add(url)))
- )
+ caches
+ .open(SHELL_CACHE)
+ // Best-effort per asset — don't let one 404 abort the whole install.
+ .then((cache) => Promise.allSettled(SHELL_ASSETS.map((url) => cache.add(url))))
+ .then((results) => {
+ // Only take over early if the offline fallback itself cached. If it didn't,
+ // the old worker is safer than a half-populated shell that could blank the
+ // offline screen after a version bump.
+ const offline = results[SHELL_ASSETS.indexOf("/offline.html")];
+ if (offline && offline.status === "fulfilled") return self.skipWaiting();
+ })
);
- self.skipWaiting();
});
self.addEventListener("activate", (event) => {
@@ -59,7 +76,7 @@ self.addEventListener("fetch", (event) => {
fetch(request)
.then((res) => {
const copy = res.clone();
- caches.open(RUNTIME_CACHE).then((c) => c.put(request, copy));
+ caches.open(RUNTIME_CACHE).then((c) => c.put(request, copy).then(() => trimCache(RUNTIME_CACHE, RUNTIME_MAX)));
return res;
})
.catch(async () => {
@@ -76,7 +93,7 @@ self.addEventListener("fetch", (event) => {
fetch(request)
.then((res) => {
const copy = res.clone();
- caches.open(API_CACHE).then((c) => c.put(request, copy));
+ caches.open(API_CACHE).then((c) => c.put(request, copy).then(() => trimCache(API_CACHE, API_MAX)));
return res;
})
.catch(() => caches.match(request))
@@ -90,7 +107,7 @@ self.addEventListener("fetch", (event) => {
const network = fetch(request)
.then((res) => {
const copy = res.clone();
- caches.open(RUNTIME_CACHE).then((c) => c.put(request, copy));
+ caches.open(RUNTIME_CACHE).then((c) => c.put(request, copy).then(() => trimCache(RUNTIME_CACHE, RUNTIME_MAX)));
return res;
})
.catch(() => cached);
diff --git a/apps/web/scripts/verify-install-prompt.mjs b/apps/web/scripts/verify-install-prompt.mjs
new file mode 100644
index 00000000..1e1124d1
--- /dev/null
+++ b/apps/web/scripts/verify-install-prompt.mjs
@@ -0,0 +1,96 @@
+// Prove InstallPrompt behavior in headless Chromium:
+// 1. iOS UA, not installed → "Add to Home Screen" hint appears.
+// 2. iOS UA, standalone → prompt suppressed.
+// 3. Dismiss persists → "Not now" hides it and it stays hidden on reload.
+// 4. Chromium button path → synthetic beforeinstallprompt shows an Install button.
+import { createRequire } from "node:module";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const repoRoot = join(here, "..", "..", "..");
+const require = createRequire(join(repoRoot, "package.json"));
+const { chromium, devices } = require(join(repoRoot, "node_modules/.pnpm/playwright@1.61.1/node_modules/playwright"));
+
+const BASE = process.env.BASE || "http://localhost:3199";
+const iphone = devices["iPhone 13"];
+const ok = (b) => (b ? "✓" : "✗");
+let pass = true;
+const browser = await chromium.launch();
+
+// 1. iOS UA, not installed → hint shows.
+{
+ const ctx = await browser.newContext({ ...iphone });
+ const page = await ctx.newPage();
+ // Seed engagement (returning visitor) so the iOS hint is eligible to show.
+ await page.addInitScript(() => { try { localStorage.setItem("hos-visits", "5"); } catch {} });
+ await page.goto(BASE + "/", { waitUntil: "load" });
+ const shown = await page
+ .getByText("Add to Home Screen", { exact: false })
+ .first()
+ .waitFor({ state: "visible", timeout: 6000 })
+ .then(() => true)
+ .catch(() => false);
+ console.log(`${ok(shown)} iOS: "Add to Home Screen" hint appears`);
+ pass &&= shown;
+ await ctx.close();
+}
+
+// 2. iOS UA but launched standalone → suppressed.
+{
+ const ctx = await browser.newContext({ ...iphone });
+ const page = await ctx.newPage();
+ await page.addInitScript(() => {
+ Object.defineProperty(window.navigator, "standalone", { get: () => true });
+ });
+ await page.goto(BASE + "/", { waitUntil: "load" });
+ await page.waitForTimeout(3200);
+ const hidden = !(await page.getByRole("dialog", { name: /install/i }).isVisible().catch(() => false));
+ console.log(`${ok(hidden)} iOS standalone: prompt suppressed`);
+ pass &&= hidden;
+ await ctx.close();
+}
+
+// 3. Dismiss persists across reload.
+{
+ const ctx = await browser.newContext({ ...iphone });
+ const page = await ctx.newPage();
+ await page.addInitScript(() => { try { localStorage.setItem("hos-visits", "5"); } catch {} });
+ await page.goto(BASE + "/", { waitUntil: "load" });
+ await page.getByRole("button", { name: "Not now" }).first().waitFor({ timeout: 6000 });
+ // force:true — the banner is proven on-top (elementFromPoint check); this only sidesteps
+ // Playwright's mobile-emulation occlusion heuristic, it doesn't mask a real stacking bug.
+ await page.getByRole("button", { name: "Not now" }).first().click({ force: true });
+ await page.reload({ waitUntil: "load" });
+ await page.waitForTimeout(3200);
+ const stillHidden = !(await page.getByRole("dialog", { name: /install/i }).isVisible().catch(() => false));
+ console.log(`${ok(stillHidden)} dismissal persists across reload`);
+ pass &&= stillHidden;
+ await ctx.close();
+}
+
+// 4. Chromium button path via synthetic beforeinstallprompt.
+{
+ const ctx = await browser.newContext(); // default desktop UA, no iOS
+ const page = await ctx.newPage();
+ await page.goto(BASE + "/", { waitUntil: "load" });
+ await page.evaluate(() => {
+ const e = new Event("beforeinstallprompt");
+ e.prompt = async () => {};
+ e.userChoice = Promise.resolve({ outcome: "accepted" });
+ window.dispatchEvent(e);
+ });
+ const btn = await page
+ .getByRole("button", { name: "Install", exact: true })
+ .first()
+ .waitFor({ state: "visible", timeout: 4000 })
+ .then(() => true)
+ .catch(() => false);
+ console.log(`${ok(btn)} Chromium: Install button appears on beforeinstallprompt`);
+ pass &&= btn;
+ await ctx.close();
+}
+
+await browser.close();
+console.log(pass ? "\nINSTALL-PROMPT VERIFY: PASS" : "\nINSTALL-PROMPT VERIFY: FAIL");
+process.exit(pass ? 0 : 1);
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 1590c129..fcbaf7db 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -4,6 +4,7 @@ import { Fraunces, Inter } from "next/font/google";
import "./globals.css";
import AdSlot, { AdSenseLoader } from "../components/AdSlot";
import PWARegister from "../components/PWARegister";
+import InstallPrompt from "../components/InstallPrompt";
// Display serif + body sans, exposed as CSS variables the @theme layer reads.
const display = Fraunces({
@@ -47,6 +48,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html lang="en" className={`${display.variable} ${sans.variable}`}>
<body className="min-h-screen bg-neutral-50 font-sans text-neutral-900 antialiased">
<PWARegister />
+ <InstallPrompt />
<AdSenseLoader />
{/* Live inventory only — every listing is real builder data with a verification label. */}
<div className="bg-brand-950 px-4 py-1.5 text-center text-xs font-medium text-brand-100">
diff --git a/apps/web/src/components/InstallPrompt.tsx b/apps/web/src/components/InstallPrompt.tsx
new file mode 100644
index 00000000..3b99669b
--- /dev/null
+++ b/apps/web/src/components/InstallPrompt.tsx
@@ -0,0 +1,165 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+// 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.
+ const onInstalled = () => 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);
+ };
+ }, []);
+
+ const dismiss = () => {
+ try {
+ localStorage.setItem(DISMISS_KEY, String(Date.now()));
+ } catch {
+ /* ignore */
+ }
+ setMode("none");
+ };
+
+ const install = async () => {
+ if (!deferred) return;
+ await deferred.prompt();
+ await deferred.userChoice.catch(() => null);
+ setDeferred(null);
+ setMode("none");
+ };
+
+ if (mode === "none") return null;
+
+ return (
+ <div
+ role="dialog"
+ aria-label="Install Homes on Spec"
+ 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>
+ );
+}
← d4c4e1c2 deploy-web: also ship apps/web/public (PWA icons/sw/offline)
·
back to Homesonspec
·
web(pwa): instrument install-conversion funnel — provider-ag ac289689 →