219 lines
8.2 KiB
TypeScript
219 lines
8.2 KiB
TypeScript
"use client";
|
||
|
||
import { ReactNode, useState, useEffect } from "react";
|
||
|
||
interface PhoneKpis {
|
||
callsToday: number;
|
||
callsTodayTrend: number;
|
||
uniqueNumbers: number;
|
||
topSourceElement: string | null;
|
||
topSourceElementPercent: number;
|
||
}
|
||
|
||
interface PhoneNumber {
|
||
phone_number: string;
|
||
click_count: number;
|
||
}
|
||
|
||
interface ElementStat {
|
||
source_element: string;
|
||
count: number;
|
||
percent: number;
|
||
}
|
||
|
||
interface TimeseriesEntry {
|
||
date: string;
|
||
count: number;
|
||
}
|
||
|
||
interface PhoneData {
|
||
kpis: PhoneKpis;
|
||
phoneNumbers: PhoneNumber[];
|
||
elements: ElementStat[];
|
||
timeseries: TimeseriesEntry[];
|
||
}
|
||
|
||
function balkenBreite(wert: number, max: number): string {
|
||
if (max === 0) return "0%";
|
||
return `${Math.max(4, Math.round((wert / max) * 100))}%`;
|
||
}
|
||
|
||
function PhoneCallsView() {
|
||
const [data, setData] = useState<PhoneData | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [range, setRange] = useState("30days");
|
||
|
||
useEffect(() => {
|
||
setLoading(true);
|
||
fetch(`/api/admin/analytics/phone-calls?range=${range}`)
|
||
.then((r) => r.json())
|
||
.then((d) => { setData(d); setLoading(false); })
|
||
.catch(() => setLoading(false));
|
||
}, [range]);
|
||
|
||
if (loading) {
|
||
return <div className="py-12 text-center text-slate-500">Lädt Phone-Click-Daten…</div>;
|
||
}
|
||
|
||
if (!data) {
|
||
return <div className="py-12 text-center text-red-500">Fehler beim Laden der Daten</div>;
|
||
}
|
||
|
||
const maxTimeseries = Math.max(0, ...data.timeseries.map((t) => t.count));
|
||
const maxElements = Math.max(0, ...data.elements.map((e) => e.count));
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header */}
|
||
<div className="bg-[#18212f] border border-gray-800 rounded-lg py-6 px-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h2 className="text-xl font-bold text-white tracking-tight">Phone-Click-Tracking</h2>
|
||
<p className="text-slate-500 text-sm mt-1">Telefon-Link-Klicks · DSGVO-konform</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{[["today", "Heute"], ["7days", "7 Tage"], ["30days", "30 Tage"]].map(([val, label]) => (
|
||
<button
|
||
key={val}
|
||
onClick={() => setRange(val)}
|
||
className={`px-3 py-1 rounded text-sm font-medium transition-all ${
|
||
range === val ? "bg-orange-500 text-white" : "bg-gray-800 text-slate-400 hover:bg-gray-700 hover:text-white"
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* KPI-Cards */}
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<div className="bg-[#18212f] border border-gray-800 p-4 rounded-lg">
|
||
<div className="text-sm text-slate-500">Klicks heute</div>
|
||
<div className="text-2xl font-bold text-white">{data.kpis.callsToday}</div>
|
||
{data.kpis.callsTodayTrend !== 0 && (
|
||
<div className={`text-xs mt-1 ${data.kpis.callsTodayTrend > 0 ? "text-green-400" : "text-red-400"}`}>
|
||
{data.kpis.callsTodayTrend > 0 ? "+" : ""}{data.kpis.callsTodayTrend}% vs. gestern
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="bg-[#18212f] border border-gray-800 p-4 rounded-lg">
|
||
<div className="text-sm text-slate-500">Eindeutige Nummern</div>
|
||
<div className="text-2xl font-bold text-white">{data.kpis.uniqueNumbers}</div>
|
||
</div>
|
||
<div className="bg-[#18212f] border border-gray-800 p-4 rounded-lg">
|
||
<div className="text-sm text-slate-500">Top Quelle</div>
|
||
<div className="text-2xl font-bold text-white">{data.kpis.topSourceElement ?? "–"}</div>
|
||
{data.kpis.topSourceElementPercent > 0 && (
|
||
<div className="text-xs text-slate-500 mt-1">{data.kpis.topSourceElementPercent}% aller Klicks</div>
|
||
)}
|
||
</div>
|
||
<div className="bg-[#18212f] border border-gray-800 p-4 rounded-lg">
|
||
<div className="text-sm text-slate-500">Klicks gesamt</div>
|
||
<div className="text-2xl font-bold text-white">
|
||
{data.phoneNumbers.reduce((s, p) => s + p.click_count, 0)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Zeitreihe */}
|
||
{data.timeseries.length > 0 && (
|
||
<div className="bg-[#18212f] border border-gray-800 rounded-lg p-6">
|
||
<h3 className="text-base font-bold text-white mb-4">Klicks über Zeit</h3>
|
||
<div className="space-y-2">
|
||
{data.timeseries.map(({ date, count }) => (
|
||
<div key={date} className="flex items-center gap-3">
|
||
<div className="w-24 text-sm font-mono text-slate-500">{date}</div>
|
||
<div className="flex-1 h-7 bg-[#111925] rounded flex items-center overflow-hidden">
|
||
<div className="h-full bg-orange-500" style={{ width: balkenBreite(count, maxTimeseries) }} />
|
||
</div>
|
||
<div className="w-8 text-right text-sm font-semibold text-white">{count}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Quellen */}
|
||
{data.elements.length > 0 && (
|
||
<div className="bg-[#18212f] border border-gray-800 rounded-lg p-6">
|
||
<h3 className="text-base font-bold text-white mb-4">Klicks nach Quelle</h3>
|
||
<div className="space-y-3">
|
||
{data.elements.map(({ source_element, count }) => (
|
||
<div key={source_element} className="flex items-center gap-3">
|
||
<div className="w-36 text-sm font-medium text-slate-400 truncate">{source_element}</div>
|
||
<div className="flex-1 h-6 bg-[#111925] rounded overflow-hidden">
|
||
<div className="h-full bg-orange-500" style={{ width: balkenBreite(count, maxElements) }} />
|
||
</div>
|
||
<div className="w-8 text-right text-sm font-semibold text-white">{count}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Nummern-Tabelle */}
|
||
{data.phoneNumbers.length > 0 && (
|
||
<div className="bg-[#18212f] border border-gray-800 rounded-lg overflow-hidden">
|
||
<div className="p-4 border-b border-gray-800">
|
||
<h3 className="font-bold text-white">Gerufene Nummern</h3>
|
||
</div>
|
||
<table className="w-full text-sm">
|
||
<thead className="border-b border-gray-800">
|
||
<tr className="text-xs text-slate-500 uppercase tracking-wide">
|
||
<th className="px-4 py-3 text-left font-medium">Telefonnummer</th>
|
||
<th className="px-4 py-3 text-right font-medium">Klicks</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-800/50">
|
||
{data.phoneNumbers.map((p) => (
|
||
<tr key={p.phone_number} className="hover:bg-[#111925] transition-colors">
|
||
<td className="px-4 py-3 font-mono text-slate-300">{p.phone_number}</td>
|
||
<td className="px-4 py-3 text-right font-semibold text-white">{p.click_count}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{data.phoneNumbers.length === 0 && (
|
||
<div className="text-center py-12 text-slate-500">
|
||
Noch keine Phone-Klicks im gewählten Zeitraum
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AnalyticsTabs({ overviewContent }: { overviewContent: ReactNode }) {
|
||
const [activeTab, setActiveTab] = useState<"overview" | "phone">("overview");
|
||
|
||
return (
|
||
<div>
|
||
<div className="flex gap-1 mb-0 border-b border-gray-800 bg-[#18212f] px-4">
|
||
{[
|
||
{ key: "overview", label: "Seitenaufrufe" },
|
||
{ key: "phone", label: "Phone-Klicks" },
|
||
].map((tab) => (
|
||
<button
|
||
key={tab.key}
|
||
onClick={() => setActiveTab(tab.key as "overview" | "phone")}
|
||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px ${
|
||
activeTab === tab.key
|
||
? "border-orange-500 text-orange-400"
|
||
: "border-transparent text-slate-500 hover:text-white"
|
||
}`}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="pt-6">
|
||
{activeTab === "overview" ? overviewContent : <PhoneCallsView />}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|