Files
hope-events/frontend/src/lib/analytics.ts
T
joshuaandClaude Opus 5.5 cc62f52ab3 Add registration funnel stats
First-party, consent-based funnel tracking: a cookie notice asks once,
and only visitors who allow it get an anonymous visit id. Event page
views and registration starts are tracked from the browser; submit and
paid come from the registration. Adds the Registration funnel report
(conversion per stage, daily trend, by source/device/ticket option), a
conversion tile on the supervisor/admin dashboards, short-link source
tagging, and a daily rollup of raw rows older than 90 days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:14:18 +02:00

69 lines
2.3 KiB
TypeScript

"use client";
/**
* First-party registration-funnel tracking. Nothing is tracked until the visitor accepts
* cookies (CookieConsent); then a random session id is kept in localStorage and the
* event page ("view") and registration page ("start") report to our own API. No
* third-party analytics, and no personal data — the server records only the stage, the
* traffic source and a device type.
*/
import { API_BASE } from "@/lib/api";
const CONSENT_KEY = "hope_events_cookie_consent";
const SESSION_KEY = "hope_events_analytics_sid";
export const CONSENT_EVENT = "hope-events-consent";
export type Consent = "accepted" | "declined" | null;
export function getConsent(): Consent {
try {
const v = localStorage.getItem(CONSENT_KEY);
return v === "accepted" || v === "declined" ? v : null;
} catch {
return null;
}
}
export function setConsent(v: "accepted" | "declined") {
try {
localStorage.setItem(CONSENT_KEY, v);
if (v === "declined") localStorage.removeItem(SESSION_KEY);
} catch { /* storage unavailable */ }
try { window.dispatchEvent(new Event(CONSENT_EVENT)); } catch { /* no window */ }
}
/** The anonymous session id — only when cookies were accepted. */
export function analyticsSessionId(): string | undefined {
if (getConsent() !== "accepted") return undefined;
try {
let id = localStorage.getItem(SESSION_KEY);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(SESSION_KEY, id);
}
return id;
} catch {
return undefined;
}
}
/** Where the visitor came from: ?utm_source=… or ?src=<short-link alias>. */
function sourceFromUrl(): string | undefined {
try {
const p = new URLSearchParams(window.location.search);
return p.get("utm_source") || p.get("src") || undefined;
} catch {
return undefined;
}
}
/** Reports a funnel step. Silently does nothing without consent or on any error. */
export function trackFunnel(eventId: string, stage: "view" | "start") {
const sessionId = analyticsSessionId();
if (!sessionId || !eventId) return;
const body = JSON.stringify({ sessionId, eventId, stage, source: sourceFromUrl() });
try {
fetch(`${API_BASE}/api/analytics/track`, { method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true }).catch(() => {});
} catch { /* ignore */ }
}