From bf3b8ea3daa578f6995f7e60185e0a5d9651c234 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Thu, 15 Oct 2026 04:16:42 +0200 Subject: [PATCH] Analytics: queued events, once-per-order revenue, heatmap on product pages trackEvent queues events until the deferred Umami script exists (they were dropped when fired in onMounted) and drops them silently after 10 s. The result page sends purchase-completed with revenue/currency once per order (localStorage guard, set only after sending). The plugin honours Do Not Track and loads recorder.js only on /details/* for Umami heatmaps. bun test utils/trackEvent.test.ts: 4 pass. Netcup #115. --- pages/checkout/result/[uuid].vue | 17 +++++++++++ plugins/umami.client.ts | 42 +++++++++++++++++++------- utils/trackEvent.test.ts | 51 ++++++++++++++++++++++++++++++++ utils/trackEvent.ts | 49 ++++++++++++++++++++++++++++-- 4 files changed, 145 insertions(+), 14 deletions(-) create mode 100644 utils/trackEvent.test.ts diff --git a/pages/checkout/result/[uuid].vue b/pages/checkout/result/[uuid].vue index 068c707..1cddd8b 100644 --- a/pages/checkout/result/[uuid].vue +++ b/pages/checkout/result/[uuid].vue @@ -151,6 +151,23 @@ onMounted(async () => { orderTotal: order.value.total, itemCount: order.value.cart?.length ?? 0 }); + + // Revenue for Umami's revenue report: exactly once per order, even if the + // customer reloads this page or opens it again from the confirmation mail. + const purchaseKey = `mp-purchase-tracked-${order.value.uuid}`; + if (!localStorage.getItem(purchaseKey)) { + const products = (order.value.cart ?? []).map((item) => item.product?.slug).filter(Boolean); + trackEvent( + "purchase-completed", + { + revenue: order.value.total, + currency: "EUR", + itemCount: (order.value.cart ?? []).reduce((sum, item) => sum + (item.count ?? 1), 0), + products: products.join(",").slice(0, 400) + }, + () => localStorage.setItem(purchaseKey, new Date().toISOString()) + ); + } } if (order.value?.invoice?.url) { diff --git a/plugins/umami.client.ts b/plugins/umami.client.ts index 6438387..8f605c9 100644 --- a/plugins/umami.client.ts +++ b/plugins/umami.client.ts @@ -1,15 +1,35 @@ export default defineNuxtPlugin(() => { const config = useRuntimeConfig(); + const scriptUrl = config.public.umamiScriptUrl as string; + const websiteId = config.public.umamiWebsiteId as string; + if (!scriptUrl || !websiteId) return; - if (config.public.umamiScriptUrl && config.public.umamiWebsiteId) { - useHead({ - script: [ - { - src: config.public.umamiScriptUrl, - defer: true, - "data-website-id": config.public.umamiWebsiteId - } - ] - }); - } + useHead({ + script: [ + { + src: scriptUrl, + defer: true, + "data-website-id": websiteId, + "data-do-not-track": "true" + } + ] + }); + + // Heatmap recorder only on product pages (no personal data there). It needs + // heatmapEnabled on the Umami website, set via umami/analytics/reports.json. + const recorderUrl = scriptUrl.replace(/script\.js(\?.*)?$/, "recorder.js"); + let recorderLoaded = false; + const loadRecorder = (path: string) => { + if (recorderLoaded || !path.startsWith("/details/")) return; + if (navigator.doNotTrack === "1") return; + recorderLoaded = true; + const el = document.createElement("script"); + el.src = recorderUrl; + el.defer = true; + el.dataset.websiteId = websiteId; + document.head.appendChild(el); + }; + const router = useRouter(); + loadRecorder(router.currentRoute.value.path); + router.afterEach((to) => loadRecorder(to.path)); }); diff --git a/utils/trackEvent.test.ts b/utils/trackEvent.test.ts new file mode 100644 index 0000000..99d43b2 --- /dev/null +++ b/utils/trackEvent.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; + +const sent: Array<[string, unknown]> = []; +beforeEach(() => { + sent.length = 0; + (globalThis as any).window = {}; +}); +afterEach(async () => { + const { __resetTrackQueue } = await import("./trackEvent"); + __resetTrackQueue(); + delete (globalThis as any).window; +}); +const umami = { track: (e: string, d: unknown) => sent.push([e, d]) }; +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +test("sends immediately when umami is loaded", async () => { + const { trackEvent } = await import("./trackEvent"); + (globalThis as any).window.umami = umami; + trackEvent("a", { x: 1 }); + expect(sent).toEqual([["a", { x: 1 }]]); +}); + +test("queues until umami appears, then flushes in order and calls onSent", async () => { + const { trackEvent } = await import("./trackEvent"); + let confirmed = false; + trackEvent("first"); + trackEvent("purchase-completed", { revenue: 12.5, currency: "EUR" }, () => (confirmed = true)); + expect(sent).toEqual([]); + await wait(300); + (globalThis as any).window.umami = umami; + await wait(400); + expect(sent.map((s) => s[0])).toEqual(["first", "purchase-completed"]); + expect(confirmed).toBe(true); +}); + +test("never throws and drops events when umami never loads", async () => { + const { trackEvent } = await import("./trackEvent"); + let confirmed = false; + trackEvent("lost", undefined, () => (confirmed = true)); + await wait(10_500); + (globalThis as any).window.umami = umami; + await wait(400); + expect(sent).toEqual([]); + expect(confirmed).toBe(false); +}, 15_000); + +test("is a no-op on the server", async () => { + delete (globalThis as any).window; + const { trackEvent } = await import("./trackEvent"); + expect(() => trackEvent("ssr")).not.toThrow(); +}); diff --git a/utils/trackEvent.ts b/utils/trackEvent.ts index b55e74c..efe7336 100644 --- a/utils/trackEvent.ts +++ b/utils/trackEvent.ts @@ -6,8 +6,51 @@ declare global { } } -export const trackEvent = (event: string, data?: Record) => { - if (import.meta.client && window.umami) { - window.umami.track(event, data); +type Pending = { event: string; data?: Record; onSent?: () => void }; + +// Events fired during page setup (onMounted) often run before the deferred +// Umami script has loaded; they used to be dropped silently. Queue them and +// flush once the tracker exists. If it never appears (blocked, offline), +// they are discarded after ~10 s without throwing. +const queue: Pending[] = []; +let timer: ReturnType | null = null; +let attempts = 0; +const MAX_ATTEMPTS = 40; +const INTERVAL_MS = 250; + +function send(item: Pending) { + try { + window.umami!.track(item.event, item.data); + item.onSent?.(); + } catch { + // tracking must never break the shop } +} + +function flush() { + if (window.umami) { + while (queue.length) send(queue.shift()!); + } + if (window.umami || ++attempts >= MAX_ATTEMPTS) { + queue.length = 0; + if (timer) clearInterval(timer); + timer = null; + attempts = 0; + } +} + +export const trackEvent = (event: string, data?: Record, onSent?: () => void) => { + if (typeof window === "undefined") return; + const item = { event, data, onSent }; + if (window.umami) return send(item); + queue.push(item); + if (!timer) timer = setInterval(flush, INTERVAL_MS); +}; + +// Test hook: reset module state between cases. +export const __resetTrackQueue = () => { + queue.length = 0; + if (timer) clearInterval(timer); + timer = null; + attempts = 0; }; -- 2.36.6