0f6cdc93fd
- Cookies werden je Vendor nach Name dedupliziert (Consent-Phasen-Dubletten; BMW 2196 → ~772) — in cookie-check + get_snapshot, behebt aufgeblähte Kachel-/Finding-Zahlen. - Impressum-Snapshot-Check überspringt den ~40s-LLM-Schritt (context skip_llm) → Tab lädt sofort statt leer zu bleiben. - Vendor-Tabelle zeigt nur die Cookie-Zahl (kein 'Cookies'-Wort je Zeile). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
293 lines
12 KiB
TypeScript
293 lines
12 KiB
TypeScript
'use client'
|
||
|
||
/**
|
||
* CookieResultView — strukturierte Cookie-/Vendor-Auswertung aus einem
|
||
* gespeicherten Snapshot (cmp_vendors), OHNE Re-Crawl.
|
||
*
|
||
* Zwei Sichten (Umschalter):
|
||
* - Rechtliche Rolle: Eigene / Auftragsverarbeiter / Joint Controller (VVT)
|
||
* - Banner-Kategorie: Notwendig / Funktional / Statistik / Marketing — die im
|
||
* Consent-Banner implementierte Einteilung. Pro Cookie wird die tatsächliche
|
||
* Kategorie laut Library gegengeprüft → '→ sollte: Marketing' bei
|
||
* Fehl-Einsortierung (Tracker als notwendig = § 25 TDDDG-relevant).
|
||
*/
|
||
|
||
import React, { useMemo, useState } from 'react'
|
||
|
||
export interface SnapshotCookie {
|
||
name: string
|
||
expiry?: string
|
||
purpose?: string
|
||
is_third_party?: boolean
|
||
functional_role?: string
|
||
}
|
||
|
||
export interface SnapshotVendor {
|
||
name: string
|
||
cookies?: SnapshotCookie[]
|
||
category?: string
|
||
country?: string
|
||
recipient_type?: string
|
||
compliance_score?: number
|
||
compliance_flags?: string[]
|
||
opt_out_ok?: boolean
|
||
}
|
||
|
||
interface Snapshot {
|
||
id: string
|
||
site_domain?: string
|
||
created_at?: string
|
||
cmp_vendors?: SnapshotVendor[]
|
||
}
|
||
|
||
// name_lower → tatsächliche Kategorie laut Library (aus /cookie-check).
|
||
export type LibCategories = Record<string, string>
|
||
|
||
const ROLE_LABEL: Record<string, string> = {
|
||
unknown: 'Unbekannt', ad_pixel: 'Werbe-Pixel', auth_token: 'Auth-Token',
|
||
preference: 'Präferenz', visitor_id: 'Besucher-ID', consent_state: 'Consent',
|
||
tracking: 'Tracking',
|
||
}
|
||
const CAT_COLOR: Record<string, string> = {
|
||
necessary: 'bg-green-100 text-green-700', functional: 'bg-blue-100 text-blue-700',
|
||
statistics: 'bg-amber-100 text-amber-700', marketing: 'bg-red-100 text-red-700',
|
||
}
|
||
const EEA = new Set([
|
||
'DE','FR','IE','NL','AT','BE','BG','HR','CY','CZ','DK','EE','FI','GR','HU',
|
||
'IT','LV','LT','LU','MT','PL','PT','RO','SK','SI','ES','SE','IS','LI','NO',
|
||
])
|
||
const GROUPS = [
|
||
{ key: 'own', label: 'Eigene Verarbeitungen (VVT, Art. 30)', test: (r: string) => !r || r === 'INTERNAL' || r === 'GROUP' },
|
||
{ key: 'proc', label: 'Auftragsverarbeiter (AVV, Art. 28)', test: (r: string) => r === 'PROCESSOR' },
|
||
{ key: 'joint', label: 'Eigenverantwortliche Dritte / Joint Controller (Art. 26)', test: (r: string) => r === 'JOINT_CONTROLLER' || r === 'CONTROLLER' },
|
||
{ key: 'other', label: 'Sonstige Empfänger', test: () => true },
|
||
]
|
||
|
||
// Banner-Kategorie-Sicht: kanonische Buckets + Labels.
|
||
const CAT_CANON: Record<string, string> = {
|
||
necessary: 'necessary', essential: 'necessary', notwendig: 'necessary',
|
||
essenziell: 'necessary', security: 'necessary', 'strictly necessary': 'necessary',
|
||
functional: 'functional', funktional: 'functional', preferences: 'functional',
|
||
preference: 'functional', präferenzen: 'functional',
|
||
statistics: 'statistics', statistik: 'statistics', analytics: 'statistics',
|
||
performance: 'statistics',
|
||
marketing: 'marketing', targeting: 'marketing', advertising: 'marketing',
|
||
werbung: 'marketing', social_media: 'marketing', social: 'marketing', ad: 'marketing',
|
||
}
|
||
const CANON_LABEL: Record<string, string> = {
|
||
necessary: 'Notwendig', functional: 'Funktional',
|
||
statistics: 'Statistik', marketing: 'Marketing', unknown: '—',
|
||
}
|
||
const CATEGORY_GROUPS = [
|
||
{ key: 'necessary', label: 'Notwendig (essenziell)' },
|
||
{ key: 'functional', label: 'Funktional' },
|
||
{ key: 'statistics', label: 'Statistik' },
|
||
{ key: 'marketing', label: 'Marketing' },
|
||
{ key: 'unknown', label: 'Ohne Kategorie' },
|
||
]
|
||
|
||
function canonCat(c?: string): string {
|
||
return CAT_CANON[(c || '').toLowerCase().trim()] || 'unknown'
|
||
}
|
||
|
||
// Tatsächliche Kategorie laut Library vs. deklarierte Banner-Kategorie.
|
||
function mismatch(name: string, declaredCanon: string, lib?: LibCategories) {
|
||
const raw = lib?.[name.toLowerCase()]
|
||
if (!raw) return null
|
||
const actual = canonCat(raw)
|
||
if (actual === 'unknown' || actual === declaredCanon) return null
|
||
// severe: als notwendig deklariert, laut Library einwilligungspflichtig.
|
||
const severe = declaredCanon === 'necessary'
|
||
&& (actual === 'marketing' || actual === 'statistics')
|
||
return { actual, severe }
|
||
}
|
||
|
||
function scoreColor(s?: number): string {
|
||
if (s == null) return 'text-gray-400'
|
||
return s >= 80 ? 'text-green-700' : s >= 50 ? 'text-amber-700' : 'text-red-700'
|
||
}
|
||
|
||
function Tile({ label, value, tone }: { label: string; value: React.ReactNode; tone: string }) {
|
||
return (
|
||
<div className="border border-gray-200 rounded-lg p-3 bg-white">
|
||
<div className={`text-2xl font-semibold leading-none ${tone}`}>{value}</div>
|
||
<div className="text-xs text-gray-500 mt-1.5">{label}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function VendorRow({ v, lib }: { v: SnapshotVendor; lib?: LibCategories }) {
|
||
const [open, setOpen] = useState(false)
|
||
const cookies = v.cookies || []
|
||
const cat = (v.category || '').toLowerCase()
|
||
const declaredCanon = canonCat(v.category)
|
||
const drittland = !!v.country && !EEA.has((v.country || '').toUpperCase())
|
||
return (
|
||
<div>
|
||
<button
|
||
onClick={() => setOpen(o => !o)}
|
||
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-gray-50 text-xs"
|
||
>
|
||
<span className={`text-gray-400 transition-transform ${open ? 'rotate-90' : ''}`}>›</span>
|
||
<span className="font-medium text-gray-800 flex-1 min-w-0 truncate">{v.name}</span>
|
||
{cat && (
|
||
<span className={`px-1.5 py-0.5 rounded text-[10px] ${CAT_COLOR[cat] || 'bg-gray-100 text-gray-600'}`}>
|
||
{v.category}
|
||
</span>
|
||
)}
|
||
{drittland && (
|
||
<span className="px-1.5 py-0.5 rounded text-[10px] bg-red-50 text-red-600" title="außerhalb EWR">
|
||
{v.country}
|
||
</span>
|
||
)}
|
||
<span className="text-gray-500 w-12 text-right" title="Cookies">{cookies.length}</span>
|
||
<span className={`w-10 text-right font-semibold ${scoreColor(v.compliance_score)}`}>
|
||
{v.compliance_score != null ? `${v.compliance_score}%` : '—'}
|
||
</span>
|
||
</button>
|
||
{open && cookies.length > 0 && (
|
||
<div className="ml-6 mb-1 border-l-2 border-gray-200">
|
||
<table className="w-full text-[11px]">
|
||
<thead className="text-gray-400">
|
||
<tr>
|
||
<th className="px-2 py-1 text-left font-normal">Cookie</th>
|
||
<th className="px-2 py-1 text-left font-normal">Rolle</th>
|
||
<th className="px-2 py-1 text-left font-normal">Zweck</th>
|
||
<th className="px-2 py-1 text-left font-normal">Laufzeit</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{cookies.map((c, i) => {
|
||
const mm = mismatch(c.name, declaredCanon, lib)
|
||
return (
|
||
<tr key={i} className="border-t border-gray-100 align-top">
|
||
<td className="px-2 py-1 font-mono text-gray-700 break-all w-40">
|
||
{c.name}
|
||
{mm && (
|
||
<span
|
||
className={`ml-1 inline-block px-1 py-0.5 rounded text-[9px] font-sans ${mm.severe ? 'bg-red-100 text-red-700' : 'bg-amber-100 text-amber-700'}`}
|
||
title="tatsächliche Kategorie laut Library"
|
||
>
|
||
→ sollte: {CANON_LABEL[mm.actual]}
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="px-2 py-1 text-gray-500 w-24">
|
||
{c.functional_role && c.functional_role !== 'unknown'
|
||
? (ROLE_LABEL[c.functional_role] || c.functional_role)
|
||
: <span className="text-gray-300">—</span>}
|
||
</td>
|
||
<td className="px-2 py-1 text-gray-500 break-words">
|
||
{c.purpose
|
||
? c.purpose
|
||
: <span className="text-amber-600 italic">kein Zweck</span>}
|
||
</td>
|
||
<td className="px-2 py-1 text-gray-400 w-24 whitespace-nowrap">{c.expiry || '—'}</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function CookieResultView(
|
||
{ snapshot, cookieCategories }:
|
||
{ snapshot: Snapshot; cookieCategories?: LibCategories },
|
||
) {
|
||
const vendors = snapshot.cmp_vendors || []
|
||
const [viewMode, setViewMode] = useState<'role' | 'category'>('role')
|
||
|
||
const stats = useMemo(() => {
|
||
const cookies = vendors.reduce((n, v) => n + (v.cookies?.length || 0), 0)
|
||
const marketing = vendors.filter(v => (v.category || '').toLowerCase() === 'marketing').length
|
||
const drittland = vendors.filter(v => v.country && !EEA.has(v.country.toUpperCase())).length
|
||
let misplaced = 0
|
||
for (const v of vendors) {
|
||
const dc = canonCat(v.category)
|
||
for (const c of v.cookies || []) {
|
||
if (mismatch(c.name, dc, cookieCategories)?.severe) misplaced++
|
||
}
|
||
}
|
||
return { cookies, marketing, drittland, misplaced }
|
||
}, [vendors, cookieCategories])
|
||
|
||
const grouped = useMemo(() => {
|
||
const sortByScore = (a: SnapshotVendor, b: SnapshotVendor) =>
|
||
(a.compliance_score ?? 100) - (b.compliance_score ?? 100)
|
||
if (viewMode === 'category') {
|
||
return CATEGORY_GROUPS
|
||
.map(g => ({ ...g, vendors: vendors.filter(v => canonCat(v.category) === g.key).sort(sortByScore) }))
|
||
.filter(g => g.vendors.length > 0)
|
||
}
|
||
return GROUPS
|
||
.map(g => ({
|
||
...g,
|
||
vendors: vendors
|
||
.filter(v => GROUPS.find(gg => gg.test((v.recipient_type || '').toUpperCase()))?.key === g.key)
|
||
.sort(sortByScore),
|
||
}))
|
||
.filter(g => g.vendors.length > 0)
|
||
}, [vendors, viewMode])
|
||
|
||
const toggleBtn = (mode: 'role' | 'category', label: string) => (
|
||
<button
|
||
onClick={() => setViewMode(mode)}
|
||
className={`px-2.5 py-1 rounded text-xs ${viewMode === mode ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
)
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-gray-900">
|
||
Cookie-Auswertung — {snapshot.site_domain || 'Snapshot'}
|
||
</h2>
|
||
<p className="text-xs text-gray-500 mt-0.5">
|
||
aus gespeichertem Snapshot (kein Re-Crawl) ·{' '}
|
||
{snapshot.created_at ? snapshot.created_at.slice(0, 19).replace('T', ' ') : ''}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-[11px] text-gray-500 mr-1">Gruppierung:</span>
|
||
{toggleBtn('role', 'Rechtliche Rolle')}
|
||
{toggleBtn('category', 'Banner-Kategorie')}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||
<Tile label="Anbieter" value={vendors.length} tone="text-gray-800" />
|
||
<Tile label="Cookies gesamt" value={stats.cookies} tone="text-gray-800" />
|
||
<Tile label="Marketing-Anbieter" value={stats.marketing} tone={stats.marketing > 0 ? 'text-red-700' : 'text-gray-800'} />
|
||
<Tile label="Drittland (außerhalb EWR)" value={stats.drittland} tone={stats.drittland > 0 ? 'text-amber-700' : 'text-gray-800'} />
|
||
<Tile label="Falsch einsortiert (lt. Library)" value={stats.misplaced} tone={stats.misplaced > 0 ? 'text-red-700' : 'text-gray-800'} />
|
||
</div>
|
||
|
||
{viewMode === 'category' && (
|
||
<p className="text-[11px] text-gray-500 -mt-1">
|
||
Banner-Kategorie wie im Consent-Tool deklariert. Badge{' '}
|
||
<span className="px-1 py-0.5 rounded text-[9px] bg-red-100 text-red-700">→ sollte: …</span>{' '}
|
||
zeigt die tatsächliche Kategorie laut Library (Fehl-Einsortierung).
|
||
</p>
|
||
)}
|
||
|
||
{grouped.map(g => (
|
||
<div key={g.key} className="border rounded-lg overflow-hidden">
|
||
<div className="px-3 py-2 bg-slate-50 border-b text-xs font-semibold text-gray-700">
|
||
{g.label} <span className="text-gray-400 font-normal">({g.vendors.length})</span>
|
||
</div>
|
||
<div className="divide-y divide-gray-100">
|
||
{g.vendors.map((v, i) => <VendorRow key={i} v={v} lib={cookieCategories} />)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|