495 lines
20 KiB
TypeScript
495 lines
20 KiB
TypeScript
import { Suspense } from "react";
|
||
import Link from "next/link";
|
||
import { ArrowLeft } from "lucide-react";
|
||
import { verifySessionToken } from "@/lib/admin-auth";
|
||
import { createServiceClient } from "@/lib/supabase";
|
||
import { cookies } from "next/headers";
|
||
import { redirect } from "next/navigation";
|
||
import type { Metadata } from "next";
|
||
import { AnalyticsTabs } from "@/components/admin/AnalyticsTabs";
|
||
|
||
export const metadata: Metadata = { title: "Web-Analytics" };
|
||
export const dynamic = "force-dynamic";
|
||
|
||
interface PageView {
|
||
id: string;
|
||
path: string;
|
||
timestamp: string;
|
||
device_type: string | null;
|
||
browser: string | null;
|
||
os: string | null;
|
||
session_id: string;
|
||
duration_ms: number | null;
|
||
}
|
||
|
||
interface FilterOption {
|
||
label: string;
|
||
tage: number;
|
||
beschreibung: string;
|
||
}
|
||
|
||
const FILTER_OPTIONS: FilterOption[] = [
|
||
{ label: "Heute", tage: 1, beschreibung: "letzter Tag" },
|
||
{ label: "Woche", tage: 7, beschreibung: "letzte 7 Tage" },
|
||
{ label: "Monat", tage: 30, beschreibung: "letzte 30 Tage" },
|
||
{ label: "3 Monate", tage: 90, beschreibung: "letzte 90 Tage" },
|
||
{ label: "Jahr", tage: 365, beschreibung: "letztes Jahr" },
|
||
];
|
||
|
||
// Hilfsfunktionen (aus statistik/page.tsx übernommen)
|
||
function fmt(n: number, nachkomma = 0) {
|
||
return n.toLocaleString("de-DE", {
|
||
minimumFractionDigits: nachkomma,
|
||
maximumFractionDigits: nachkomma,
|
||
});
|
||
}
|
||
|
||
function balkenBreite(wert: number, max: number): string {
|
||
if (max === 0) return "0%";
|
||
return `${Math.max(4, Math.round((wert / max) * 100))}%`;
|
||
}
|
||
|
||
function fmtDuration(ms: number | null): string {
|
||
if (!ms) return "–";
|
||
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
||
return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
|
||
}
|
||
|
||
// Hilfsfunktion: Deutsches Datumsformat (DD.MM.YYYY)
|
||
function fmtDateDE(isoDate: string): string {
|
||
const [year, month, day] = isoDate.split("-");
|
||
return `${day}.${month}.${year}`;
|
||
}
|
||
|
||
async function AnalyticsContent({
|
||
vonISO,
|
||
bisISO,
|
||
tage = null
|
||
}: {
|
||
vonISO: string;
|
||
bisISO: string;
|
||
tage?: number | null;
|
||
}) {
|
||
// ──── Auth Check ────────────────────────────────────────────────────────
|
||
const cookieStore = await cookies();
|
||
const token = cookieStore.get("admin_session")?.value;
|
||
|
||
if (!token) {
|
||
redirect("/admin/login");
|
||
}
|
||
|
||
const session = await verifySessionToken(token);
|
||
if (!session) {
|
||
redirect("/admin/login");
|
||
}
|
||
|
||
// ──── Daten abrufen (dynamischer Zeitraum, ohne Bots) ──────────────────────────
|
||
const db = createServiceClient();
|
||
const heute = new Date().toISOString().slice(0, 10);
|
||
|
||
// Konvertiere ISO-Dates zu vollständigen Timestamps für korrekte DB-Filterung
|
||
const vonTimestamp = vonISO + "T00:00:00Z";
|
||
const bisTimestamp = bisISO + "T23:59:59.999Z";
|
||
|
||
const { data: allViews, error } = await db
|
||
.from("page_views")
|
||
.select("id, path, timestamp, device_type, browser, os, session_id, duration_ms")
|
||
.eq("is_bot", false)
|
||
.gte("timestamp", vonTimestamp)
|
||
.lte("timestamp", bisTimestamp)
|
||
.order("timestamp", { ascending: false });
|
||
|
||
if (error) {
|
||
console.error("❌ Analytics Query Error:", error);
|
||
}
|
||
|
||
const views: PageView[] = allViews ?? [];
|
||
|
||
// ──── KPIs ──────────────────────────────────────────────────────────────
|
||
const heuteViews = views.filter((v) => v.timestamp.startsWith(heute));
|
||
const uniqueSessionsHeute = new Set(heuteViews.map((v) => v.session_id)).size;
|
||
const mitDauer = views.filter((v) => v.duration_ms != null && v.duration_ms > 0);
|
||
const avgDuration =
|
||
mitDauer.length > 0 ? Math.round(mitDauer.reduce((s, v) => s + v.duration_ms!, 0) / mitDauer.length) : 0;
|
||
const mobileCount = views.filter((v) => v.device_type === "mobile").length;
|
||
const mobileRate = views.length > 0 ? Math.round((mobileCount / views.length) * 100) : 0;
|
||
|
||
// ──── Tagesstatistik (letzte 30 Tage) ────────────────────────────────────
|
||
const tagesMap: Record<string, number> = {};
|
||
for (const v of views) {
|
||
const tag = v.timestamp.slice(0, 10);
|
||
tagesMap[tag] = (tagesMap[tag] ?? 0) + 1;
|
||
}
|
||
const tagesStats = Object.entries(tagesMap)
|
||
.sort(([a], [b]) => a.localeCompare(b))
|
||
.map(([date, count]) => ({ date, count }));
|
||
const maxTagesWert = Math.max(0, ...tagesStats.map((s) => s.count));
|
||
|
||
// ──── Top-Seiten ────────────────────────────────────────────────────────
|
||
const pathMap: Record<string, { count: number; durSum: number; durCount: number }> = {};
|
||
for (const v of views) {
|
||
if (!pathMap[v.path]) pathMap[v.path] = { count: 0, durSum: 0, durCount: 0 };
|
||
pathMap[v.path].count++;
|
||
if (v.duration_ms) {
|
||
pathMap[v.path].durSum += v.duration_ms;
|
||
pathMap[v.path].durCount++;
|
||
}
|
||
}
|
||
const topSeiten = Object.entries(pathMap)
|
||
.map(([path, d]) => ({
|
||
path,
|
||
count: d.count,
|
||
avgDuration: d.durCount > 0 ? Math.round(d.durSum / d.durCount) : null,
|
||
}))
|
||
.sort((a, b) => b.count - a.count)
|
||
.slice(0, 20);
|
||
const maxPathCount = Math.max(0, ...topSeiten.map((s) => s.count));
|
||
|
||
// ──── Browser-Verteilung ────────────────────────────────────────────────
|
||
const browserMap: Record<string, number> = {};
|
||
for (const v of views) browserMap[v.browser ?? "Other"] = (browserMap[v.browser ?? "Other"] ?? 0) + 1;
|
||
const sortedBrowsers = Object.entries(browserMap)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 8);
|
||
|
||
// ──── Gerät-Verteilung ──────────────────────────────────────────────────
|
||
const deviceMap: Record<string, number> = {};
|
||
for (const v of views) deviceMap[v.device_type ?? "desktop"] = (deviceMap[v.device_type ?? "desktop"] ?? 0) + 1;
|
||
|
||
// ──── OS-Verteilung ─────────────────────────────────────────────────────
|
||
const osMap: Record<string, number> = {};
|
||
for (const v of views) osMap[v.os ?? "Other"] = (osMap[v.os ?? "Other"] ?? 0) + 1;
|
||
const sortedOs = Object.entries(osMap)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 8);
|
||
|
||
// ──── Filter-Button Label bestimmen ──────────────────────────────────
|
||
let filterDesc: string;
|
||
if (tage !== null) {
|
||
// Schnellauswahl
|
||
filterDesc = FILTER_OPTIONS.find((f) => f.tage === tage)?.beschreibung || `letzte ${tage} Tage`;
|
||
} else {
|
||
// Freier Zeitraum (deutsches Datumsformat)
|
||
filterDesc = `${fmtDateDE(vonISO)} – ${fmtDateDE(bisISO)}`;
|
||
}
|
||
|
||
// Debug: Wenn Fehler oder keine Daten
|
||
if (error) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="bg-gradient-to-r from-[#1c1917] to-[#2d2b29] text-white py-8 px-6 rounded-lg">
|
||
<h1 className="text-2xl font-bold tracking-tight">Web-Analytics</h1>
|
||
</div>
|
||
<div className="bg-red-50 border border-red-300 p-4 rounded-lg">
|
||
<p className="text-red-800 font-semibold">❌ Fehler beim Laden der Daten:</p>
|
||
<p className="text-red-700 text-sm mt-2">{error.message}</p>
|
||
<p className="text-red-600 text-xs mt-3">
|
||
Prüfe in Supabase, ob die <code className="bg-red-100 px-1 rounded">page_views</code> Tabelle existiert und RLS korrekt konfiguriert ist.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const overviewContent = (
|
||
<div className="space-y-6">
|
||
{/* Header */}
|
||
<div className="bg-gradient-to-r from-[#1c1917] to-[#2d2b29] text-white py-8 px-6 rounded-lg">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div>
|
||
<h1 className="text-2xl font-bold tracking-tight">Web-Analytics</h1>
|
||
<p className="text-white/70 text-sm mt-1">{filterDesc} · Bots gefiltert · IPs anonymisiert</p>
|
||
</div>
|
||
<Link
|
||
href="/admin"
|
||
className="inline-flex items-center gap-2 text-white/60 hover:text-white transition-colors"
|
||
>
|
||
<ArrowLeft className="w-4 h-4" />
|
||
Zurück
|
||
</Link>
|
||
</div>
|
||
|
||
{/* Filter Buttons & Custom Date Range */}
|
||
<div className="space-y-3">
|
||
{/* Schnellauswahl-Buttons */}
|
||
<div className="flex flex-wrap gap-2">
|
||
{FILTER_OPTIONS.map((option) => (
|
||
<Link
|
||
key={option.tage}
|
||
href={`/admin/analytics?tage=${option.tage}`}
|
||
className={`px-3 py-1 rounded text-sm font-medium transition-all ${
|
||
tage === option.tage && tage !== null
|
||
? "bg-[#f7d334] text-[#1c1917] shadow-md"
|
||
: "bg-white/15 text-white hover:bg-white/25"
|
||
}`}
|
||
>
|
||
{option.label}
|
||
</Link>
|
||
))}
|
||
</div>
|
||
|
||
{/* Datumsauswahl-Formular */}
|
||
<form method="GET" action="/admin/analytics" className="flex flex-col sm:flex-row gap-2 items-end bg-white/10 p-3 rounded">
|
||
<div className="flex-1 min-w-0">
|
||
<label htmlFor="von" className="block text-xs font-medium text-white mb-1">
|
||
Von
|
||
</label>
|
||
<input
|
||
id="von"
|
||
type="date"
|
||
name="von"
|
||
defaultValue={vonISO}
|
||
className="w-full px-3 py-2 rounded text-sm text-slate-900 bg-white border-2 border-white font-medium"
|
||
/>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<label htmlFor="bis" className="block text-xs font-medium text-white mb-1">
|
||
Bis
|
||
</label>
|
||
<input
|
||
id="bis"
|
||
type="date"
|
||
name="bis"
|
||
defaultValue={bisISO}
|
||
className="w-full px-3 py-2 rounded text-sm text-slate-900 bg-white border-2 border-white font-medium"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="submit"
|
||
className="px-4 py-2 bg-[#f7d334] text-[#1c1917] text-sm font-bold rounded hover:bg-[#d4a030] transition-all whitespace-nowrap"
|
||
>
|
||
Anwenden
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
{/* KPI-Cards */}
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<div className="bg-white border border-slate-200 p-4 rounded-lg">
|
||
<div className="text-sm text-slate-500">Seitenaufrufe heute</div>
|
||
<div className="text-2xl font-bold text-slate-900">{heuteViews.length}</div>
|
||
</div>
|
||
<div className="bg-white border border-slate-200 p-4 rounded-lg">
|
||
<div className="text-sm text-slate-500">Unique Sessions heute</div>
|
||
<div className="text-2xl font-bold text-slate-900">{uniqueSessionsHeute}</div>
|
||
</div>
|
||
<div className="bg-white border border-slate-200 p-4 rounded-lg">
|
||
<div className="text-sm text-slate-500">Ø Verweildauer</div>
|
||
<div className="text-2xl font-bold text-slate-900">{fmtDuration(avgDuration)}</div>
|
||
</div>
|
||
<div className="bg-white border border-slate-200 p-4 rounded-lg">
|
||
<div className="text-sm text-slate-500">Mobile-Anteil</div>
|
||
<div className="text-2xl font-bold text-slate-900">{mobileRate}%</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tagesdiagramm */}
|
||
<div className="bg-white border border-slate-200 rounded-lg p-6">
|
||
<h2 className="text-lg font-bold text-slate-900 mb-4">Seitenaufrufe ({filterDesc})</h2>
|
||
<div className="space-y-2">
|
||
{tagesStats.length === 0 ? (
|
||
<p className="text-slate-500 text-sm">Keine Daten vorhanden</p>
|
||
) : (
|
||
tagesStats.map(({ date, count }) => (
|
||
<div key={date} className="flex items-center gap-3">
|
||
<div className="w-24 text-sm font-mono text-slate-600">{date}</div>
|
||
<div className="flex-1 h-8 bg-slate-100 rounded flex items-center justify-start overflow-hidden">
|
||
<div
|
||
className="h-full bg-[#f7d334] transition-all duration-300"
|
||
style={{ width: balkenBreite(count, maxTagesWert) }}
|
||
/>
|
||
</div>
|
||
<div className="w-12 text-right text-sm font-semibold text-slate-900">{count}</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Top-Seiten */}
|
||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||
<div className="p-6 border-b border-slate-200">
|
||
<h2 className="text-lg font-bold text-slate-900">Top-Seiten</h2>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-slate-50 border-b border-slate-200">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left font-semibold text-slate-700">Seite</th>
|
||
<th className="px-6 py-3 text-left font-semibold text-slate-700">Aufrufe</th>
|
||
<th className="px-6 py-3 text-left font-semibold text-slate-700">Ø Verweildauer</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-200">
|
||
{topSeiten.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={3} className="px-6 py-8 text-center text-slate-500">
|
||
Keine Daten vorhanden
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
topSeiten.map(({ path, count, avgDuration }) => (
|
||
<tr key={path} className="hover:bg-slate-50 transition-colors">
|
||
<td className="px-6 py-3 font-mono text-slate-900 max-w-sm truncate">{path}</td>
|
||
<td className="px-6 py-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-32 h-6 bg-slate-100 rounded flex items-center overflow-hidden">
|
||
<div
|
||
className="h-full bg-[#f7d334]"
|
||
style={{ width: balkenBreite(count, maxPathCount) }}
|
||
/>
|
||
</div>
|
||
<span className="font-semibold text-slate-900 w-8">{count}</span>
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-3 text-slate-600">{fmtDuration(avgDuration)}</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Browser & OS Verteilung */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
{/* Browser */}
|
||
<div className="bg-white border border-slate-200 rounded-lg p-6">
|
||
<h2 className="text-lg font-bold text-slate-900 mb-4">Browser</h2>
|
||
<div className="space-y-3">
|
||
{sortedBrowsers.length === 0 ? (
|
||
<p className="text-slate-500 text-sm">Keine Daten vorhanden</p>
|
||
) : (
|
||
sortedBrowsers.map(([browser, count]) => (
|
||
<div key={browser} className="flex items-center gap-3">
|
||
<div className="w-24 text-sm font-medium text-slate-700">{browser}</div>
|
||
<div className="flex-1 h-6 bg-slate-100 rounded flex items-center overflow-hidden">
|
||
<div
|
||
className="h-full bg-[#f7d334]"
|
||
style={{
|
||
width: balkenBreite(count, Math.max(...sortedBrowsers.map((s) => s[1]))),
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="w-8 text-right text-sm font-semibold text-slate-900">{count}</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* OS */}
|
||
<div className="bg-white border border-slate-200 rounded-lg p-6">
|
||
<h2 className="text-lg font-bold text-slate-900 mb-4">Betriebssystem</h2>
|
||
<div className="space-y-3">
|
||
{sortedOs.length === 0 ? (
|
||
<p className="text-slate-500 text-sm">Keine Daten vorhanden</p>
|
||
) : (
|
||
sortedOs.map(([os, count]) => (
|
||
<div key={os} className="flex items-center gap-3">
|
||
<div className="w-24 text-sm font-medium text-slate-700">{os}</div>
|
||
<div className="flex-1 h-6 bg-slate-100 rounded flex items-center overflow-hidden">
|
||
<div
|
||
className="h-full bg-[#f7d334]"
|
||
style={{
|
||
width: balkenBreite(count, Math.max(...sortedOs.map((s) => s[1]))),
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="w-8 text-right text-sm font-semibold text-slate-900">{count}</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Gerät-Verteilung */}
|
||
<div className="bg-white border border-slate-200 rounded-lg p-6">
|
||
<h2 className="text-lg font-bold text-slate-900 mb-4">Geräte-Verteilung</h2>
|
||
<div className="grid grid-cols-3 gap-4">
|
||
{Object.entries(deviceMap).map(([device, count]) => (
|
||
<div key={device} className="text-center">
|
||
<div className="text-2xl font-bold text-slate-900">{count}</div>
|
||
<div className="text-sm text-slate-600 capitalize">
|
||
{device === "desktop" ? "Desktop" : device === "tablet" ? "Tablet" : "Mobile"}
|
||
</div>
|
||
<div className="text-xs text-slate-500 mt-1">
|
||
{views.length > 0 ? `${Math.round((count / views.length) * 100)}%` : "0%"}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* DSGVO-Hinweis */}
|
||
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg">
|
||
<p className="text-sm text-blue-800">
|
||
<strong>🔒 Datenschutz:</strong> IP-Adressen werden anonymisiert (letztes Oktett = 0). Es werden keine
|
||
Cookies für Analytics gesetzt. Session-IDs sind Tab-gebunden und werden nicht persistent gespeichert.
|
||
Weitere Details in der{" "}
|
||
<Link href="/datenschutz" className="underline hover:no-underline">
|
||
Datenschutzerklärung
|
||
</Link>
|
||
.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return <AnalyticsTabs overviewContent={overviewContent} />;
|
||
}
|
||
|
||
export default async function AnalyticsPage({ searchParams }: { searchParams: Promise<Record<string, string | string[]>> }) {
|
||
const params = await searchParams;
|
||
const von = String(params.von || "");
|
||
const bis = String(params.bis || "");
|
||
const tageParam = String(params.tage || "");
|
||
|
||
const heute = new Date().toISOString().slice(0, 10);
|
||
let vonISO: string;
|
||
let bisISO: string;
|
||
let tage: number | null;
|
||
|
||
// Hilfsfunktion: Datum um X Tage zurück (ohne Zeitzone-Probleme)
|
||
function datumMinusTage(isoDate: string, tage: number): string {
|
||
const [year, month, day] = isoDate.split("-").map(Number);
|
||
const date = new Date(year, month - 1, day);
|
||
date.setDate(date.getDate() - tage);
|
||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||
}
|
||
|
||
// Berechne den Zeitraum basierend auf Parametern
|
||
if (von && bis) {
|
||
// Freier Datumsbereich
|
||
vonISO = von;
|
||
bisISO = bis;
|
||
tage = null; // null bedeutet: freier Bereich, nicht Schnellauswahl
|
||
} else if (tageParam) {
|
||
// Schnellauswahl: von heute rückwärts
|
||
tage = Math.max(1, Math.min(365, parseInt(tageParam, 10) || 30));
|
||
bisISO = heute;
|
||
vonISO = datumMinusTage(heute, tage);
|
||
} else {
|
||
// Standard: 30 Tage von heute rückwärts
|
||
tage = 30;
|
||
bisISO = heute;
|
||
vonISO = datumMinusTage(heute, 30);
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||
<Suspense
|
||
fallback={
|
||
<div className="text-center py-12">
|
||
<p className="text-slate-500">Lädt Analytics-Daten...</p>
|
||
</div>
|
||
}
|
||
>
|
||
<AnalyticsContent vonISO={vonISO} bisISO={bisISO} tage={tage} />
|
||
</Suspense>
|
||
</div>
|
||
);
|
||
}
|