feat: Vendor-level consent + Consent analytics (F4 + F6)
F4: Granular Vendor-Level Consent - Migration 113: vendor_consents JSONB on banner_consents + audit_log - ConsentCreate schema + BannerConsentDB model extended - banner_consent_service stores vendor_consents alongside categories - Audit trail includes vendor-level decisions + user_agent F6: Consent Rate Analytics - Migration 114: user_agent on audit_log + time-series index - BannerAnalyticsService: time series, category breakdown, device stats - banner_analytics_routes: 4 endpoints (overview, time-series, categories, devices) - AnalyticsDashboard.tsx: KPIs, bar chart, category bars, device breakdown - New "Analytik" tab in cookie-banner page [migration-approved] Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface TimeSeriesPoint {
|
||||
period: string
|
||||
given: number
|
||||
updated: number
|
||||
withdrawn: number
|
||||
total: number
|
||||
opt_in_rate: number
|
||||
}
|
||||
|
||||
interface CategoryStats {
|
||||
[key: string]: { count: number; total: number; rate: number }
|
||||
}
|
||||
|
||||
interface DeviceStats {
|
||||
desktop: number
|
||||
mobile: number
|
||||
tablet: number
|
||||
unknown: number
|
||||
}
|
||||
|
||||
interface OverviewStats {
|
||||
period_days: number
|
||||
total_interactions: number
|
||||
consents_given: number
|
||||
consents_updated: number
|
||||
consents_withdrawn: number
|
||||
opt_in_rate: number
|
||||
}
|
||||
|
||||
const PERIODS = [
|
||||
{ value: 7, label: '7 Tage' },
|
||||
{ value: 30, label: '30 Tage' },
|
||||
{ value: 90, label: '90 Tage' },
|
||||
]
|
||||
|
||||
const CAT_COLORS: Record<string, string> = {
|
||||
necessary: '#22c55e',
|
||||
statistics: '#eab308',
|
||||
marketing: '#ef4444',
|
||||
functional: '#3b82f6',
|
||||
preferences: '#8b5cf6',
|
||||
}
|
||||
|
||||
export function AnalyticsDashboard({ siteId }: { siteId?: string }) {
|
||||
const [days, setDays] = useState(30)
|
||||
const [overview, setOverview] = useState<OverviewStats | null>(null)
|
||||
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([])
|
||||
const [categories, setCategories] = useState<CategoryStats>({})
|
||||
const [devices, setDevices] = useState<DeviceStats>({ desktop: 0, mobile: 0, tablet: 0, unknown: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const sid = siteId || 'preview-test-site'
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
const base = `/api/sdk/v1/compliance/banner/analytics/${sid}`
|
||||
Promise.all([
|
||||
fetch(`${base}/overview?days=${days}`).then(r => r.ok ? r.json() : null),
|
||||
fetch(`${base}/time-series?days=${days}&period=daily`).then(r => r.ok ? r.json() : []),
|
||||
fetch(`${base}/categories?days=${days}`).then(r => r.ok ? r.json() : {}),
|
||||
fetch(`${base}/devices?days=${days}`).then(r => r.ok ? r.json() : {}),
|
||||
]).then(([o, ts, cats, devs]) => {
|
||||
setOverview(o)
|
||||
setTimeSeries(ts || [])
|
||||
setCategories(cats || {})
|
||||
setDevices(devs || { desktop: 0, mobile: 0, tablet: 0, unknown: 0 })
|
||||
}).catch(() => {}).finally(() => setLoading(false))
|
||||
}, [sid, days])
|
||||
|
||||
const deviceTotal = devices.desktop + devices.mobile + devices.tablet + devices.unknown
|
||||
|
||||
if (loading) return <div className="text-center py-12 text-gray-400">Lade Analytik...</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Period Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
{PERIODS.map(p => (
|
||||
<button key={p.value} onClick={() => setDays(p.value)}
|
||||
className={`px-3 py-1.5 text-xs rounded-full border transition-colors ${
|
||||
days === p.value ? 'bg-purple-100 border-purple-300 text-purple-700' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'
|
||||
}`}>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Overview KPIs */}
|
||||
{overview && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-xs text-gray-500">Opt-In-Rate</div>
|
||||
<div className="text-2xl font-bold text-green-600">{overview.opt_in_rate}%</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-xs text-gray-500">Einwilligungen</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{overview.consents_given}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-xs text-gray-500">Aktualisiert</div>
|
||||
<div className="text-2xl font-bold text-blue-600">{overview.consents_updated}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-xs text-gray-500">Widerrufen</div>
|
||||
<div className="text-2xl font-bold text-red-600">{overview.consents_withdrawn}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Series (simple bar visualization) */}
|
||||
{timeSeries.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Opt-In-Rate im Zeitverlauf</h3>
|
||||
<div className="flex items-end gap-1 h-32">
|
||||
{timeSeries.map((pt, i) => {
|
||||
const height = Math.max(pt.opt_in_rate, 2)
|
||||
const date = new Date(pt.period)
|
||||
return (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1 group relative">
|
||||
<div className="w-full bg-purple-500 rounded-t transition-all hover:bg-purple-600"
|
||||
style={{ height: `${height}%` }}
|
||||
title={`${date.toLocaleDateString('de-DE')}: ${pt.opt_in_rate}% (${pt.total} Interaktionen)`}
|
||||
/>
|
||||
{i % Math.max(1, Math.floor(timeSeries.length / 6)) === 0 && (
|
||||
<span className="text-[8px] text-gray-400">{date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Category Acceptance */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Akzeptanz nach Kategorie</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(categories).map(([cat, stats]) => (
|
||||
<div key={cat}>
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-700 capitalize">{cat}</span>
|
||||
<span className="font-medium text-gray-900">{stats.rate}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all" style={{ width: `${stats.rate}%`, backgroundColor: CAT_COLORS[cat] || '#9ca3af' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(categories).length === 0 && (
|
||||
<p className="text-xs text-gray-400">Noch keine Daten vorhanden</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device Breakdown */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Geraete-Verteilung</h3>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ key: 'desktop', label: 'Desktop', color: 'bg-blue-500' },
|
||||
{ key: 'mobile', label: 'Mobile', color: 'bg-green-500' },
|
||||
{ key: 'tablet', label: 'Tablet', color: 'bg-purple-500' },
|
||||
].map(d => {
|
||||
const count = devices[d.key as keyof DeviceStats]
|
||||
const pct = deviceTotal > 0 ? Math.round(count / deviceTotal * 100) : 0
|
||||
return (
|
||||
<div key={d.key}>
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-700">{d.label}</span>
|
||||
<span className="font-medium text-gray-900">{pct}% ({count})</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${d.color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{deviceTotal === 0 && (
|
||||
<p className="text-xs text-gray-400">Noch keine Geraetedaten vorhanden</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import { CategoryCard } from './_components/CategoryCard'
|
||||
import { VendorTable } from './_components/VendorTable'
|
||||
import { EmbeddableVendorHTML } from './_components/EmbeddableVendorHTML'
|
||||
import { SiteSelector } from './_components/SiteSelector'
|
||||
import { AnalyticsDashboard } from './_components/AnalyticsDashboard'
|
||||
|
||||
type BannerTab = 'config' | 'vendors' | 'embed'
|
||||
type BannerTab = 'config' | 'vendors' | 'embed' | 'analytics'
|
||||
|
||||
export default function CookieBannerPage() {
|
||||
const { state } = useSDK()
|
||||
@@ -75,6 +76,7 @@ export default function CookieBannerPage() {
|
||||
{ id: 'config' as const, label: 'Konfiguration' },
|
||||
{ id: 'vendors' as const, label: 'Verarbeiter' },
|
||||
{ id: 'embed' as const, label: 'Einbettung' },
|
||||
{ id: 'analytics' as const, label: 'Analytik' },
|
||||
]).map(tab => (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||
@@ -91,6 +93,9 @@ export default function CookieBannerPage() {
|
||||
{/* Tab: Einbettung */}
|
||||
{activeTab === 'embed' && <EmbeddableVendorHTML siteId={activeSiteId || undefined} />}
|
||||
|
||||
{/* Tab: Analytik */}
|
||||
{activeTab === 'analytics' && <AnalyticsDashboard siteId={activeSiteId || undefined} />}
|
||||
|
||||
{/* Tab: Konfiguration */}
|
||||
{activeTab !== 'config' ? null : (<>
|
||||
{/* Stats */}
|
||||
|
||||
Reference in New Issue
Block a user