Files
hope-events/frontend/src/lib/analytics.ts
T
joshuaandClaude Opus 5.5 38262022ef Add event structured data, canonical URLs and referrer-based traffic sources
- Event pages emit schema.org Event JSON-LD (dates, venue, image,
  organizer, ZAR offers, sold-out status) for Google event results.
- Event pages, the events list and home page set a canonical URL so
  tagged and alias links don't split rankings.
- Funnel tracking falls back to the referring site (Facebook, Instagram,
  Google, ...) and fbclid/gclid when a link has no utm_source/src.
- Share and Save QR on the event page tag their links as share / qr.
- getServerSettings moves to lib/serverSettings.ts for reuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:26:02 +02:00

112 lines
4.0 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 */ }
}
/** Forgets the visitor's choice (and any visit id) so the cookie notice asks again. */
export function resetConsent() {
try {
localStorage.removeItem(CONSENT_KEY);
localStorage.removeItem(SESSION_KEY);
} catch { /* storage unavailable */ }
}
/** The anonymous session id — only when cookies were accepted. */
export function analyticsSessionId(): string | undefined {
if (getConsent() !== "accepted") return undefined;
try {
if (localStorage.getItem("hope_events_impersonation")) return undefined;
let id = localStorage.getItem(SESSION_KEY);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(SESSION_KEY, id);
}
return id;
} catch {
return undefined;
}
}
// Referring sites we name ourselves; any other site is recorded by its hostname.
const REFERRER_SOURCES: [RegExp, string][] = [
[/(^|\.)(facebook\.com|fb\.com|fb\.me)$/, "facebook"],
[/(^|\.)instagram\.com$/, "instagram"],
[/(^|\.)(whatsapp\.com|wa\.me)$/, "whatsapp"],
[/(^|\.)google\.[a-z.]+$/, "google"],
[/(^|\.)bing\.com$/, "bing"],
[/(^|\.)duckduckgo\.com$/, "duckduckgo"],
[/(^|\.)yahoo\.com$/, "yahoo"],
[/(^|\.)(twitter\.com|x\.com|t\.co)$/, "x"],
[/(^|\.)(linkedin\.com|lnkd\.in)$/, "linkedin"],
[/(^|\.)(youtube\.com|youtu\.be)$/, "youtube"],
[/(^|\.)tiktok\.com$/, "tiktok"],
];
/** The referring site as a source label; undefined when there's none or it's this site. */
function sourceFromReferrer(): string | undefined {
try {
if (!document.referrer) return undefined;
const host = new URL(document.referrer).hostname.toLowerCase();
if (!host || host === window.location.hostname.toLowerCase()) return undefined;
for (const [re, label] of REFERRER_SOURCES) if (re.test(host)) return label;
return host.replace(/^www\./, "");
} catch {
return undefined;
}
}
/**
* Where the visitor came from: ?utm_source=… or ?src=<short-link alias> when the link
* says so, else the referring site (Facebook, Google, …), else Facebook's/Google's click
* ids. WhatsApp sends no referrer, so it's only known from a tagged link.
*/
function sourceFromUrl(): string | undefined {
try {
const p = new URLSearchParams(window.location.search);
return p.get("utm_source") || p.get("src") || sourceFromReferrer()
|| (p.has("fbclid") ? "facebook" : undefined)
|| (p.has("gclid") ? "google" : 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 */ }
}