129 lines
4.2 KiB
TypeScript
129 lines
4.2 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef } from "react";
|
||
import { usePathname } from "next/navigation";
|
||
|
||
// Admin-Routen und API-Routen tracken wir nicht
|
||
const EXCLUDED_PREFIXES = ["/admin", "/api", "/_next"];
|
||
|
||
/**
|
||
* PageTracker – Client-seitige Komponente für Web-Analytics
|
||
*
|
||
* Wird in app/layout.tsx eingebunden (global, einmalig)
|
||
* - Erfasst Seitenaufrufe
|
||
* - Misst Verweildauer via pagehide-Event
|
||
* - Verwendet sessionStorage für Session-ID (nicht persistent)
|
||
* - Sendet Daten an /api/analytics/track
|
||
*/
|
||
export function PageTracker() {
|
||
const pathname = usePathname();
|
||
const viewIdRef = useRef<string | null>(null);
|
||
const startTimeRef = useRef<number>(Date.now());
|
||
|
||
/**
|
||
* Gibt oder erstellt eine Session-ID aus sessionStorage
|
||
* Diese ID ist Tab-gebunden (nicht persistent über Reload)
|
||
*/
|
||
function getSessionId(): string {
|
||
let sid = sessionStorage.getItem("_mpv_sid");
|
||
if (!sid) {
|
||
// UUID v4 via Crypto API (kein externe Library)
|
||
sid = crypto.randomUUID();
|
||
sessionStorage.setItem("_mpv_sid", sid);
|
||
}
|
||
return sid;
|
||
}
|
||
|
||
useEffect(() => {
|
||
// Skip ausgeschlossene Routen
|
||
if (EXCLUDED_PREFIXES.some((p) => pathname.startsWith(p))) return;
|
||
|
||
startTimeRef.current = Date.now();
|
||
viewIdRef.current = null;
|
||
|
||
// ──── Seitenaufruf tracken ────────────────────────────────────────────
|
||
fetch("/api/analytics/track", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
path: pathname,
|
||
session_id: getSessionId(),
|
||
referrer: typeof document !== "undefined" ? document.referrer || undefined : undefined,
|
||
}),
|
||
})
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
viewIdRef.current = d.view_id ?? null;
|
||
})
|
||
.catch(() => {
|
||
// Tracking-Fehler nie nach oben propagieren
|
||
});
|
||
|
||
// ──── Verweildauer beim Verlassen der Seite senden ────────────────────
|
||
function sendDuration() {
|
||
if (!viewIdRef.current) return;
|
||
const ms = Date.now() - startTimeRef.current;
|
||
|
||
// sendBeacon ist zuverlässiger als fetch bei pagehide (safari, mobile)
|
||
// Blob mit application/json damit req.json() im Handler funktioniert
|
||
const blob = new Blob(
|
||
[
|
||
JSON.stringify({
|
||
path: pathname,
|
||
session_id: getSessionId(),
|
||
view_id: viewIdRef.current,
|
||
duration_ms: ms,
|
||
}),
|
||
],
|
||
{ type: "application/json" }
|
||
);
|
||
navigator.sendBeacon("/api/analytics/track", blob);
|
||
}
|
||
|
||
// ──── Phone-Click Tracking ────────────────────────────────────────
|
||
function trackPhoneClick(event: Event) {
|
||
const target = event.target as HTMLElement;
|
||
const link = target.closest('a[href^="tel:"]');
|
||
if (!link) return;
|
||
|
||
const phoneHref = link.getAttribute("href");
|
||
const phoneNumber = phoneHref?.replace("tel:", "").trim();
|
||
|
||
if (!phoneNumber) return;
|
||
|
||
const sourceElement =
|
||
link.getAttribute("data-source-element") ||
|
||
link.closest("[data-source-element]")?.getAttribute("data-source-element") ||
|
||
"unknown";
|
||
|
||
fetch("/api/analytics/track-phone-click", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
phone_number: phoneNumber,
|
||
source_page: pathname,
|
||
source_element: sourceElement,
|
||
session_id: getSessionId(),
|
||
}),
|
||
}).catch(() => {
|
||
// Fehler ignorieren
|
||
});
|
||
}
|
||
|
||
// pagehide: zuverlässiger als beforeunload (Safari, Mobile)
|
||
window.addEventListener("pagehide", sendDuration);
|
||
|
||
// Phone-Click Tracking
|
||
document.addEventListener("click", trackPhoneClick);
|
||
|
||
return () => {
|
||
document.removeEventListener("click", trackPhoneClick);
|
||
window.removeEventListener("pagehide", sendDuration);
|
||
// Auch beim Route-Wechsel (SPA) die Verweildauer senden
|
||
sendDuration();
|
||
};
|
||
}, [pathname]);
|
||
|
||
return null; // Kein Markup
|
||
}
|