[object Object]

← back to Homesonspec

web(pwa): instrument install-conversion funnel — provider-agnostic track() (gtag+dataLayer+first-party /api/pwa-event sink) wired into InstallPrompt (shown/dismissed/result/installed)

ac289689f7e448733ab488d8fac65dd7507b482b · 2026-08-07 07:11:30 -0700 · Steve

Files touched

Diff

commit ac289689f7e448733ab488d8fac65dd7507b482b
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 7 07:11:30 2026 -0700

    web(pwa): instrument install-conversion funnel — provider-agnostic track() (gtag+dataLayer+first-party /api/pwa-event sink) wired into InstallPrompt (shown/dismissed/result/installed)
---
 apps/web/src/app/api/pwa-event/route.ts   | 22 +++++++++++++++++
 apps/web/src/components/InstallPrompt.tsx | 18 +++++++++++---
 apps/web/src/lib/analytics.ts             | 41 +++++++++++++++++++++++++++++++
 3 files changed, 78 insertions(+), 3 deletions(-)

diff --git a/apps/web/src/app/api/pwa-event/route.ts b/apps/web/src/app/api/pwa-event/route.ts
new file mode 100644
index 00000000..9181b62d
--- /dev/null
+++ b/apps/web/src/app/api/pwa-event/route.ts
@@ -0,0 +1,22 @@
+import { NextRequest, NextResponse } from "next/server";
+
+// First-party sink for PWA install-funnel events. Anonymous — no PII, just funnel counters.
+// Logs a structured line captured by pm2's stdout, so the funnel is queryable with zero infra:
+//   pm2 logs homesonspec-web | grep pwa-event
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function POST(req: NextRequest) {
+  let evt: unknown = null;
+  try {
+    evt = await req.json();
+  } catch {
+    /* ignore malformed body */
+  }
+  const line = JSON.stringify({
+    ...(evt && typeof evt === "object" ? evt : {}),
+    ua: req.headers.get("user-agent")?.slice(0, 80) ?? null,
+  });
+  console.log("[pwa-event]", line);
+  return NextResponse.json({ ok: true });
+}
diff --git a/apps/web/src/components/InstallPrompt.tsx b/apps/web/src/components/InstallPrompt.tsx
index 3b99669b..15f5343a 100644
--- a/apps/web/src/components/InstallPrompt.tsx
+++ b/apps/web/src/components/InstallPrompt.tsx
@@ -1,6 +1,7 @@
 "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.
@@ -71,8 +72,11 @@ export default function InstallPrompt() {
     };
     window.addEventListener("beforeinstallprompt", onBIP);
 
-    // Once installed, clear the prompt.
-    const onInstalled = () => setMode("none");
+    // 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
@@ -89,7 +93,14 @@ export default function InstallPrompt() {
     };
   }, []);
 
+  // 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 {
@@ -101,7 +112,8 @@ export default function InstallPrompt() {
   const install = async () => {
     if (!deferred) return;
     await deferred.prompt();
-    await deferred.userChoice.catch(() => null);
+    const res = await deferred.userChoice.catch(() => null);
+    track("pwa_install_result", { outcome: res?.outcome ?? "unknown" });
     setDeferred(null);
     setMode("none");
   };
diff --git a/apps/web/src/lib/analytics.ts b/apps/web/src/lib/analytics.ts
new file mode 100644
index 00000000..5dc18089
--- /dev/null
+++ b/apps/web/src/lib/analytics.ts
@@ -0,0 +1,41 @@
+// Fire an analytics event down every available channel, best-effort and non-throwing —
+// analytics must never break the page.
+//   • gtag       → GA4 (no-op until a GA4 tag is added; lights up the instant it is)
+//   • dataLayer  → Google Tag Manager
+//   • /api/pwa-event beacon → first-party sink so the funnel is measurable TODAY,
+//     with no external analytics account required.
+type Params = Record<string, string | number | boolean | undefined>;
+
+export function track(event: string, params: Params = {}): void {
+  if (typeof window === "undefined") return;
+  const payload = { event, ...params };
+
+  // gtag + dataLayer (dormant-safe)
+  try {
+    const w = window as unknown as {
+      gtag?: (...a: unknown[]) => void;
+      dataLayer?: unknown[];
+    };
+    if (typeof w.gtag === "function") w.gtag("event", event, params);
+    (w.dataLayer = w.dataLayer || []).push(payload);
+  } catch {
+    /* ignore */
+  }
+
+  // First-party sink — anonymous funnel counters, no PII.
+  try {
+    const body = JSON.stringify({ ...payload, t: Date.now(), path: location.pathname });
+    if (navigator.sendBeacon) {
+      navigator.sendBeacon("/api/pwa-event", new Blob([body], { type: "application/json" }));
+    } else {
+      void fetch("/api/pwa-event", {
+        method: "POST",
+        body,
+        keepalive: true,
+        headers: { "content-type": "application/json" },
+      }).catch(() => {});
+    }
+  } catch {
+    /* ignore */
+  }
+}

← ae8f049d web(pwa): add smart install prompt (iOS hint + Chromium butt  ·  back to Homesonspec  ·  chore(web): v0.1.0 → v0.2.0 (PWA install + conversion tracki a1eab94c →