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; };