← 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
A apps/web/src/app/api/pwa-event/route.tsM apps/web/src/components/InstallPrompt.tsxA apps/web/src/lib/analytics.ts
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 →