33f0a64ff6
Both DocCheckTab and BannerCheckTab now: - Store full scan results per history entry in localStorage - History entries are clickable — loads the saved result immediately - No need to re-scan to see old results - Fallback to last result if specific entry not found - Banner-Check sends HTML email report to mailpit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
325 lines
14 KiB
TypeScript
325 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState } from 'react'
|
|
import { ChecklistView } from './ChecklistView'
|
|
|
|
interface CheckItem {
|
|
id: string
|
|
label: string
|
|
passed: boolean
|
|
severity: string
|
|
matched_text: string
|
|
level?: number
|
|
parent?: string | null
|
|
skipped?: boolean
|
|
hint?: string
|
|
}
|
|
|
|
interface BannerResult {
|
|
banner_detected: boolean
|
|
banner_provider: string
|
|
banner_checks?: {
|
|
violations: { code: string; text: string; severity: string }[]
|
|
has_impressum_link?: boolean
|
|
has_dse_link?: boolean
|
|
}
|
|
structured_checks?: CheckItem[]
|
|
completeness_pct?: number
|
|
correctness_pct?: number
|
|
phases?: {
|
|
before_consent: { cookies: string[]; scripts: string[]; tracking_services: string[]; violations: any[] }
|
|
after_reject: { cookies: string[]; scripts: string[]; new_tracking: string[]; violations: any[] }
|
|
after_accept: { cookies: string[]; scripts: string[]; new_tracking: string[]; undocumented: string[] }
|
|
}
|
|
email_status?: string
|
|
}
|
|
|
|
const CATEGORIES = [
|
|
{ id: 'all', label: 'Alle Kategorien' },
|
|
{ id: 'necessary', label: 'Notwendig' },
|
|
{ id: 'statistics', label: 'Statistik' },
|
|
{ id: 'marketing', label: 'Marketing' },
|
|
{ id: 'functional', label: 'Funktional' },
|
|
{ id: 'preferences', label: 'Praeferenzen' },
|
|
]
|
|
|
|
export function BannerCheckTab() {
|
|
const [url, setUrl] = useState(() =>
|
|
typeof window !== 'undefined' ? localStorage.getItem('banner-check-url') || '' : ''
|
|
)
|
|
const [loading, setLoading] = useState(false)
|
|
const [progress, setProgress] = useState('')
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [result, setResult] = useState<BannerResult | null>(() => {
|
|
if (typeof window === 'undefined') return null
|
|
try { const s = localStorage.getItem('banner-check-result'); return s ? JSON.parse(s) : null } catch { return null }
|
|
})
|
|
const [categories, setCategories] = useState<string[]>(['all'])
|
|
const [history, setHistory] = useState<{ url: string; date: string; provider: string; violations: number; pct: number; resultKey: string }[]>(() => {
|
|
if (typeof window === 'undefined') return []
|
|
try { return JSON.parse(localStorage.getItem('banner-check-history') || '[]') } catch { return [] }
|
|
})
|
|
|
|
// Persist URL
|
|
React.useEffect(() => { localStorage.setItem('banner-check-url', url) }, [url])
|
|
|
|
const toggleCategory = (id: string) => {
|
|
if (id === 'all') {
|
|
setCategories(['all'])
|
|
return
|
|
}
|
|
setCategories(prev => {
|
|
const without = prev.filter(c => c !== 'all' && c !== id)
|
|
const next = prev.includes(id) ? without : [...without, id]
|
|
return next.length === 0 ? ['all'] : next
|
|
})
|
|
}
|
|
|
|
const handleScan = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!url.trim()) return
|
|
|
|
setLoading(true)
|
|
setError(null)
|
|
setResult(null)
|
|
setProgress('Cookie-Banner wird analysiert...')
|
|
|
|
const selectedCategories = categories.includes('all') ? [] : categories
|
|
|
|
try {
|
|
const res = await fetch('/api/sdk/v1/agent/banner-check', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url: url.trim(), categories: selectedCategories }),
|
|
})
|
|
if (!res.ok) throw new Error(`Fehler: ${res.status}`)
|
|
const data = await res.json()
|
|
setResult(data)
|
|
localStorage.setItem('banner-check-result', JSON.stringify(data))
|
|
|
|
// Add to history with persistent result
|
|
const violations = data.structured_checks?.filter((c: CheckItem) => !c.passed && !c.skipped).length || 0
|
|
const resultKey = `banner-check-result-${Date.now()}`
|
|
try { localStorage.setItem(resultKey, JSON.stringify(data)) } catch { /* quota */ }
|
|
const entry = {
|
|
url: url.trim(),
|
|
date: new Date().toISOString(),
|
|
provider: data.banner_provider || 'Unbekannt',
|
|
violations,
|
|
pct: data.completeness_pct ?? 0,
|
|
resultKey,
|
|
}
|
|
const updated = [entry, ...history].slice(0, 30)
|
|
setHistory(updated)
|
|
localStorage.setItem('banner-check-history', JSON.stringify(updated))
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
|
} finally {
|
|
setLoading(false)
|
|
setProgress('')
|
|
}
|
|
}
|
|
|
|
const loadFromHistory = (entry: { url: string; resultKey?: string }) => {
|
|
setUrl(entry.url)
|
|
if (entry.resultKey) {
|
|
try {
|
|
const saved = localStorage.getItem(entry.resultKey)
|
|
if (saved) { setResult(JSON.parse(saved)); return }
|
|
} catch {}
|
|
}
|
|
// Fallback: load last result
|
|
try {
|
|
const last = localStorage.getItem('banner-check-result')
|
|
if (last) setResult(JSON.parse(last))
|
|
} catch {}
|
|
}
|
|
|
|
const structuredChecks = result?.structured_checks || []
|
|
const hasStructured = structuredChecks.length > 0
|
|
const compPct = result?.completeness_pct ?? 0
|
|
const corrPct = result?.correctness_pct ?? 0
|
|
|
|
const checklistResults = hasStructured ? [{
|
|
label: `Cookie-Banner: ${result?.banner_provider || 'Unbekannt'}`,
|
|
url: url,
|
|
doc_type: 'banner',
|
|
word_count: 0,
|
|
completeness_pct: compPct,
|
|
correctness_pct: corrPct,
|
|
checks: structuredChecks,
|
|
findings_count: structuredChecks.filter(c => !c.passed && !c.skipped).length,
|
|
error: '',
|
|
}] : []
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
<h3 className="text-sm font-semibold text-blue-900">Cookie-Banner Compliance Check</h3>
|
|
<p className="text-xs text-blue-700 mt-1">
|
|
Playwright-basierter 3-Phasen-Test: Vor Interaktion, nach Ablehnen, nach Akzeptieren.
|
|
Prueft Dark Patterns, Pre-Consent-Cookies, Farbkontrast, Klick-Paritaet und 36 weitere Kriterien.
|
|
</p>
|
|
</div>
|
|
|
|
<form onSubmit={handleScan} className="space-y-3">
|
|
<div className="flex gap-3">
|
|
<input
|
|
type="url" value={url} onChange={e => setUrl(e.target.value)}
|
|
placeholder="https://www.example.com/"
|
|
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
|
|
disabled={loading} required
|
|
/>
|
|
<button type="submit" disabled={loading || !url.trim()}
|
|
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium whitespace-nowrap">
|
|
{loading ? (
|
|
<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>Pruefe...</>
|
|
) : 'Banner pruefen'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
{CATEGORIES.map(cat => (
|
|
<label key={cat.id}
|
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium cursor-pointer border transition-colors ${
|
|
categories.includes(cat.id)
|
|
? 'bg-purple-100 border-purple-300 text-purple-800'
|
|
: 'bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
<input type="checkbox" checked={categories.includes(cat.id)}
|
|
onChange={() => toggleCategory(cat.id)} className="sr-only" />
|
|
<span className={`w-3 h-3 rounded-sm border flex items-center justify-center ${
|
|
categories.includes(cat.id) ? 'bg-purple-600 border-purple-600' : 'border-gray-400'
|
|
}`}>
|
|
{categories.includes(cat.id) && (
|
|
<svg className="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 12 12">
|
|
<path d="M10 3L4.5 8.5 2 6" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
)}
|
|
</span>
|
|
{cat.label}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</form>
|
|
|
|
{progress && (
|
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-sm text-purple-700 flex items-center gap-3">
|
|
<svg className="animate-spin w-5 h-5 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
{progress}
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{error}</div>
|
|
)}
|
|
|
|
{result && (
|
|
<div className="space-y-4">
|
|
{result.phases && (
|
|
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
|
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-2xl">{result.banner_detected ? '\u{1F6E1}\u{FE0F}' : '\u26A0\u{FE0F}'}</span>
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-gray-900">
|
|
{result.banner_detected
|
|
? `Banner erkannt: ${result.banner_provider || 'Unbekannter Anbieter'}`
|
|
: 'Kein Cookie-Banner erkannt'}
|
|
</h3>
|
|
<p className="text-xs text-gray-500 mt-0.5">3-Phasen-Analyse: Cookies und Scripts vor/nach Interaktion</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="px-6 py-3 grid grid-cols-3 gap-4">
|
|
<PhaseBox label="Vor Consent" icon="\uD83D\uDD12"
|
|
cookies={result.phases.before_consent.cookies?.length ?? 0}
|
|
scripts={result.phases.before_consent.scripts?.length ?? 0}
|
|
violations={result.phases.before_consent.violations?.length ?? 0} />
|
|
<PhaseBox label="Nach Ablehnen" icon="\uD83D\uDEAB"
|
|
cookies={result.phases.after_reject.cookies?.length ?? 0}
|
|
scripts={result.phases.after_reject.scripts?.length ?? 0}
|
|
violations={result.phases.after_reject.violations?.length ?? 0} />
|
|
<PhaseBox label="Nach Akzeptieren" icon="\u2705"
|
|
cookies={result.phases.after_accept.cookies?.length ?? 0}
|
|
scripts={result.phases.after_accept.scripts?.length ?? 0}
|
|
violations={0} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{hasStructured && (
|
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
|
<ChecklistView results={checklistResults} />
|
|
</div>
|
|
)}
|
|
|
|
{result.email_status && (
|
|
<div className="text-xs text-gray-500 flex items-center gap-2">
|
|
<span className={`w-2 h-2 rounded-full ${result.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
|
E-Mail: {result.email_status === 'sent' ? 'Gesendet' : result.email_status}
|
|
</div>
|
|
)}
|
|
|
|
{!result.banner_detected && !hasStructured && (
|
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
|
<p className="text-sm text-gray-500">
|
|
Kein Cookie-Banner auf dieser Seite gefunden. Falls Cookies gesetzt werden, ist ein Banner nach §25 TDDDG Pflicht.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* History */}
|
|
{history.length > 0 && (
|
|
<div className="border border-gray-200 rounded-xl p-4">
|
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Banner-Checks</h4>
|
|
<div className="space-y-1">
|
|
{history.map((h, i) => (
|
|
<button key={i} onClick={() => loadFromHistory(h)}
|
|
className="w-full flex items-center justify-between p-2.5 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium text-gray-900 truncate">{h.url}</div>
|
|
<div className="text-xs text-gray-500">
|
|
{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
|
{' · '}{h.provider}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3 shrink-0 ml-3">
|
|
<span className={`text-xs font-medium ${h.violations > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
|
{h.violations} Findings
|
|
</span>
|
|
<span className={`text-xs font-medium ${h.pct === 100 ? 'text-green-700' : h.pct >= 50 ? 'text-yellow-700' : 'text-red-700'}`}>
|
|
{h.pct}%
|
|
</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PhaseBox({ label, icon, cookies, scripts, violations }: {
|
|
label: string; icon: string; cookies: number; scripts: number; violations: number
|
|
}) {
|
|
return (
|
|
<div className="text-center">
|
|
<div className="text-lg">{icon}</div>
|
|
<div className="text-xs font-medium text-gray-700">{label}</div>
|
|
<div className="text-xs text-gray-500 mt-1">{cookies} Cookies, {scripts} Scripts</div>
|
|
{violations > 0 && <div className="text-xs text-red-600 font-medium">{violations} Verstoesse</div>}
|
|
</div>
|
|
)
|
|
}
|