Analytics: queued events, once-per-order revenue, heatmap on product pages (#17)
build / build (push) Failing after 8s

Analytics: queued events, once-per-order revenue, heatmap on product pages (#17)
Co-authored-by: Michael Czechowski <mail@librete.ch>
(cherry picked from commit d5dd109c63)
This commit is contained in:
libretech committed 2026-10-15 09:04:02 +02:00
1 parent 024df873ac
commit 60b8f1ab58
4 files changed
+145 -14

No files matched your search

+17
View File
@@ -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) {
+31 -11
View File
@@ -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));
});
+51
View File
@@ -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();
});
+46 -3
View File
@@ -6,8 +6,51 @@ declare global {
}
}
export const trackEvent = (event: string, data?: Record<string, unknown>) => {
if (import.meta.client && window.umami) {
window.umami.track(event, data);
type Pending = { event: string; data?: Record<string, unknown>; 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<typeof setInterval> | 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<string, unknown>, 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;
};