- 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>
112 lines
4.0 KiB
TypeScript
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 */ }
|
|
}
|