refactor: Cookie banner — categories always visible (CNIL/DSK compliant)
Build + Deploy / build-admin-compliance (push) Successful in 1m57s
Build + Deploy / build-backend-compliance (push) Successful in 8s
Build + Deploy / build-ai-sdk (push) Successful in 8s
Build + Deploy / build-developer-portal (push) Successful in 8s
Build + Deploy / build-tts (push) Successful in 7s
Build + Deploy / build-document-crawler (push) Successful in 7s
Build + Deploy / build-dsms-gateway (push) Successful in 7s
Build + Deploy / build-dsms-node (push) Successful in 8s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 18s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m10s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-python-document-crawler (push) Successful in 30s
CI / test-python-dsms-gateway (push) Successful in 28s
CI / validate-canonical-controls (push) Successful in 15s
CI / test-go (push) Successful in 42s
CI / test-python-backend (push) Successful in 42s
Build + Deploy / trigger-orca (push) Successful in 2m16s

- All 4 categories with toggles visible on first layer (no "Einstellungen" step)
- Removed showSettings state — single-view banner
- EWR toggle + info button in header, always visible
- Two equal-weight buttons: "Alle akzeptieren" + "Auswahl speichern"
- "Nur notwendige" as text link below (not hidden, but less prominent)
- Vendor tables expandable per category via chevron
- DSK OH Telemedien 2022 + CNIL 2020 compliant layout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-02 22:36:27 +02:00
parent 9510ce0ff9
commit 61c3f8fd4a
@@ -7,14 +7,11 @@ import {
} from './cookie-banner-vendors' } from './cookie-banner-vendors'
/** /**
* CookieBannerOverlay — Cookie consent banner with Drittland-Schutz. * CookieBannerOverlay — DSGVO/CNIL-konformer Cookie-Banner mit "Nur EU/EWR" Toggle.
* *
* Unique feature: Users can accept a category (e.g. Marketing) but block * Alle 4 Kategorien sind auf der ersten Ebene sichtbar (DSK OH Telemedien 2022).
* all vendors that transfer data to non-EU countries. This means: * Vendor-Details aufklappbar per Kategorie. EWR-Toggle blockiert Non-EU-Anbieter
* - Marketing = ON, Drittland-Schutz = ON → LinkedIn (EU) loads, Facebook (USA) does NOT * auch bei aktivierter Kategorie — einzigartiges CMP-Feature.
* - The consent state records both category consent AND blocked vendors
*
* No other CMP offers per-vendor country-based blocking within accepted categories.
*/ */
const STORAGE_KEY = 'bp-sdk-cookie-consent' const STORAGE_KEY = 'bp-sdk-cookie-consent'
@@ -29,24 +26,26 @@ interface ConsentState {
timestamp: string timestamp: string
} }
function getStoredConsent(): ConsentState | null {
if (typeof window === 'undefined') return null
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
return JSON.parse(raw)
} catch {
return null
}
}
export function CookieBannerOverlay() { export function CookieBannerOverlay() {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [consent, setConsent] = useState<ConsentState>({ const [consent, setConsent] = useState<ConsentState>({
necessary: true, necessary: true, statistics: false, marketing: false, functional: false,
statistics: false, ewrOnly: false, blockedVendors: [], timestamp: '',
marketing: false,
functional: false,
ewrOnly: false,
blockedVendors: [],
timestamp: '',
}) })
const nonEWRCount = useMemo(() => countNonEWRVendors(), []) const nonEWRCount = useMemo(() => countNonEWRVendors(), [])
// Compute which vendors are actually blocked
const blockedVendors = useMemo(() => { const blockedVendors = useMemo(() => {
if (!consent.ewrOnly) return [] if (!consent.ewrOnly) return []
const blocked: string[] = [] const blocked: string[] = []
@@ -54,9 +53,7 @@ export function CookieBannerOverlay() {
const catEnabled = key === 'necessary' || consent[key as keyof ConsentState] const catEnabled = key === 'necessary' || consent[key as keyof ConsentState]
if (!catEnabled) continue if (!catEnabled) continue
for (const v of cat.vendors) { for (const v of cat.vendors) {
if (isOutsideEWR(v.country)) { if (isOutsideEWR(v.country)) blocked.push(v.name)
blocked.push(v.name)
}
} }
} }
return blocked return blocked
@@ -64,24 +61,17 @@ export function CookieBannerOverlay() {
useEffect(() => { useEffect(() => {
const stored = getStoredConsent() const stored = getStoredConsent()
if (!stored) { if (!stored) setIsOpen(true)
setIsOpen(true) else setConsent(stored)
} else {
setConsent(stored)
}
}, []) }, [])
useEffect(() => { useEffect(() => {
const handler = () => { const handler = () => setIsOpen(true)
setIsOpen(true)
setShowSettings(true)
}
window.addEventListener('openCookieBanner', handler) window.addEventListener('openCookieBanner', handler)
return () => window.removeEventListener('openCookieBanner', handler) return () => window.removeEventListener('openCookieBanner', handler)
}, []) }, [])
const saveConsent = useCallback((state: ConsentState) => { const saveConsent = useCallback((state: ConsentState) => {
// Compute blocked vendors before saving
const blocked: string[] = [] const blocked: string[] = []
if (state.ewrOnly) { if (state.ewrOnly) {
for (const [key, cat] of Object.entries(CATEGORY_VENDORS)) { for (const [key, cat] of Object.entries(CATEGORY_VENDORS)) {
@@ -96,90 +86,41 @@ export function CookieBannerOverlay() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(withMeta)) localStorage.setItem(STORAGE_KEY, JSON.stringify(withMeta))
setConsent(withMeta) setConsent(withMeta)
setIsOpen(false) setIsOpen(false)
setShowSettings(false)
window.dispatchEvent(new CustomEvent('sdkCookieConsentUpdated', { detail: withMeta })) window.dispatchEvent(new CustomEvent('sdkCookieConsentUpdated', { detail: withMeta }))
}, []) }, [])
const handleAcceptAll = () => {
saveConsent({ ...consent, necessary: true, statistics: true, marketing: true, functional: true })
}
const handleRejectAll = () => {
saveConsent({ ...consent, necessary: true, statistics: false, marketing: false, functional: false })
}
const handleSaveSettings = () => {
saveConsent(consent)
}
if (!isOpen) return null if (!isOpen) return null
return ( return (
<> <>
<div <div className="fixed inset-0 bg-black/40 z-[9998]" />
className="fixed inset-0 bg-black/40 z-[9998] transition-opacity duration-300"
onClick={() => {}}
/>
<div className="fixed bottom-0 left-0 right-0 z-[9999] animate-in slide-in-from-bottom duration-300"> <div className="fixed bottom-0 left-0 right-0 z-[9999]">
<div className="max-w-3xl mx-auto m-4 bg-white rounded-2xl shadow-2xl border border-gray-200 overflow-hidden"> <div className="max-w-3xl mx-auto m-4 bg-white rounded-2xl shadow-2xl border border-gray-200 overflow-hidden">
{/* Header */}
<div className="px-6 pt-6 pb-4"> {/* Header with EWR toggle */}
<div className="px-6 pt-5 pb-3">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div className="flex-1">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2"> <h2 className="text-lg font-semibold text-gray-900">Cookie-Einstellungen</h2>
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <p className="text-sm text-gray-600 mt-1">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> Waehlen Sie, welche Cookie-Kategorien Sie zulassen moechten.
</svg>
Cookie-Einstellungen
</h2>
<p className="text-sm text-gray-600 mt-2 leading-relaxed">
Wir verwenden Cookies und aehnliche Technologien, um Ihnen die bestmoegliche Erfahrung zu bieten.
</p> </p>
</div> </div>
{/* EWR-Only Toggle — always visible in header */}
<EWRToggle <EWRToggle
checked={consent.ewrOnly} checked={consent.ewrOnly}
onChange={() => setConsent(prev => ({ ...prev, ewrOnly: !prev.ewrOnly }))} onChange={() => setConsent(prev => ({ ...prev, ewrOnly: !prev.ewrOnly }))}
blockedCount={blockedVendors.length} blockedCount={blockedVendors.length}
nonEWRCount={nonEWRCount}
/> />
</div> </div>
{/* Blocked vendors pills */}
{consent.ewrOnly && blockedVendors.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{blockedVendors.map(name => (
<span key={name} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-50 text-red-600 text-[10px] font-medium border border-red-100">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
{name}
</span>
))}
</div>
)}
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
<a href="/sdk/einwilligungen/cookie-banner" className="hover:text-purple-600 underline">
Datenschutzerklaerung
</a>
<span>|</span>
<a href="/sdk/einwilligungen" className="hover:text-purple-600 underline">
Impressum
</a>
</div>
</div> </div>
{/* Settings */} {/* Categories — always visible (CNIL/DSK compliant) */}
{showSettings && ( <div className="px-6 pb-3 space-y-1.5 max-h-[45vh] overflow-y-auto border-t border-gray-100 pt-3">
<div className="border-t border-gray-100">
{/* Category Sections */}
<div className="px-6 py-4 space-y-1 max-h-[40vh] overflow-y-auto">
{Object.entries(CATEGORY_VENDORS).map(([key, cat]) => ( {Object.entries(CATEGORY_VENDORS).map(([key, cat]) => (
<CategorySection <CategorySection
key={key} key={key}
categoryKey={key}
label={cat.label} label={cat.label}
description={cat.description} description={cat.description}
vendors={cat.vendors} vendors={cat.vendors}
@@ -190,55 +131,41 @@ export function CookieBannerOverlay() {
/> />
))} ))}
</div> </div>
</div>
)}
{/* Buttons */} {/* Buttons — two equal-weight options */}
<div className="px-6 py-4 bg-gray-50 border-t border-gray-100 flex flex-wrap items-center gap-3"> <div className="px-6 py-4 bg-gray-50 border-t border-gray-100">
{!showSettings ? ( <div className="flex items-center gap-3">
<>
<button <button
onClick={handleAcceptAll} onClick={() => saveConsent({ ...consent, necessary: true, statistics: true, marketing: true, functional: true })}
className="flex-1 min-w-[140px] px-4 py-2.5 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors text-sm" className="flex-1 px-4 py-2.5 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors text-sm"
> >
Alle akzeptieren Alle akzeptieren
</button> </button>
<button <button
onClick={handleRejectAll} onClick={() => saveConsent(consent)}
className="flex-1 min-w-[140px] px-4 py-2.5 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors text-sm" className="flex-1 px-4 py-2.5 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors text-sm"
>
Nur notwendige
</button>
<button
onClick={() => setShowSettings(true)}
className="flex-1 min-w-[140px] px-4 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-100 transition-colors text-sm"
>
Einstellungen
</button>
</>
) : (
<>
<button
onClick={handleSaveSettings}
className="flex-1 min-w-[140px] px-4 py-2.5 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors text-sm"
> >
Auswahl speichern Auswahl speichern
</button> </button>
<button
onClick={handleAcceptAll}
className="flex-1 min-w-[140px] px-4 py-2.5 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors text-sm"
>
Alle akzeptieren
</button>
<button
onClick={() => setShowSettings(false)}
className="px-4 py-2.5 text-gray-500 hover:text-gray-700 text-sm"
>
Zurueck
</button>
</>
)}
</div> </div>
<div className="flex items-center justify-between mt-3">
<button
onClick={() => saveConsent({ ...consent, necessary: true, statistics: false, marketing: false, functional: false })}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Nur notwendige Cookies
</button>
<div className="flex items-center gap-3 text-xs text-gray-400">
<a href="/sdk/einwilligungen/cookie-banner" className="hover:text-purple-600 underline">
Datenschutzerklaerung
</a>
<a href="/sdk/einwilligungen" className="hover:text-purple-600 underline">
Impressum
</a>
</div>
</div>
</div>
</div> </div>
</div> </div>
</> </>
@@ -273,55 +200,84 @@ export function CookieBannerFAB() {
} }
function CategorySection({ // ─── EWR Toggle with Info Button ──────────────────────────
categoryKey,
label, function EWRToggle({ checked, onChange, blockedCount, nonEWRCount }: {
description, checked: boolean; onChange: () => void; blockedCount: number; nonEWRCount: number
vendors, }) {
checked, const [showInfo, setShowInfo] = useState(false)
disabled,
ewrOnly, return (
onChange, <div className="relative flex flex-col items-end gap-1 shrink-0">
}: { <div className="flex items-center gap-2">
categoryKey: string <button
label: string onClick={() => setShowInfo(!showInfo)}
description: string className="w-5 h-5 rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 flex items-center justify-center text-xs font-bold"
vendors: VendorInfo[] aria-label="Info zu Nur EU/EWR"
checked: boolean >
disabled?: boolean i
ewrOnly: boolean </button>
onChange: (v: boolean) => void <span className={`text-xs font-medium whitespace-nowrap ${checked ? 'text-blue-700' : 'text-gray-500'}`}>
Nur EU/EWR
</span>
<button
onClick={onChange}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors shrink-0 cursor-pointer ${
checked ? 'bg-blue-600' : 'bg-gray-200'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
checked ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
{checked && blockedCount > 0 && (
<span className="text-[10px] text-red-600 font-medium">{blockedCount} blockiert</span>
)}
{showInfo && (
<div className="absolute right-0 top-12 w-72 p-3 bg-blue-50 border border-blue-200 rounded-lg shadow-lg z-10 text-xs text-blue-800 leading-relaxed">
<div className="font-semibold mb-1">Nur EU/EWR-Anbieter</div>
<p>
Erlaubt nur Anbieter mit Sitz im EWR (EU + Island, Liechtenstein, Norwegen) oder
der Schweiz. {nonEWRCount} Anbieter ausserhalb werden blockiert auch bei
aktivierter Cookie-Kategorie.
</p>
<button onClick={() => setShowInfo(false)} className="mt-2 text-blue-600 hover:text-blue-800 font-medium">
Verstanden
</button>
</div>
)}
</div>
)
}
// ─── Category Section with Vendor Table ───────────────────
function CategorySection({ label, description, vendors, checked, disabled, ewrOnly, onChange }: {
label: string; description: string; vendors: VendorInfo[]; checked: boolean
disabled?: boolean; ewrOnly: boolean; onChange: (v: boolean) => void
}) { }) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const euVendors = vendors.filter(v => isEWR(v.country))
const nonEuVendors = vendors.filter(v => isOutsideEWR(v.country)) const nonEuVendors = vendors.filter(v => isOutsideEWR(v.country))
const blockedCount = ewrOnly && checked ? nonEuVendors.length : 0 const blockedCount = ewrOnly && checked ? nonEuVendors.length : 0
const activeCount = checked ? vendors.length - blockedCount : 0 const activeCount = checked ? vendors.length - blockedCount : 0
return ( return (
<div className="border border-gray-100 rounded-lg overflow-hidden"> <div className="border border-gray-100 rounded-lg overflow-hidden">
<div className="flex items-center justify-between gap-3 px-4 py-3 bg-gray-50/50"> <div className="flex items-center justify-between gap-3 px-4 py-2.5 bg-gray-50/50">
<button <button onClick={() => setExpanded(!expanded)} className="flex items-center gap-2 flex-1 text-left">
onClick={() => setExpanded(!expanded)} <svg className={`w-3.5 h-3.5 text-gray-400 transition-transform ${expanded ? 'rotate-90' : ''}`}
className="flex items-center gap-2 flex-1 text-left" fill="none" stroke="currentColor" viewBox="0 0 24 24">
>
<svg
className={`w-4 h-4 text-gray-400 transition-transform ${expanded ? 'rotate-90' : ''}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
<div> <div>
<div className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-gray-900">
{label} {label}
<span className="ml-2 text-xs font-normal text-gray-400"> <span className="ml-2 text-xs font-normal text-gray-400">
{checked {checked && blockedCount > 0
? blockedCount > 0
? `${activeCount} aktiv, ${blockedCount} blockiert` ? `${activeCount} aktiv, ${blockedCount} blockiert`
: `${vendors.length} Verarbeiter` : `${vendors.length} Verarbeiter`}
: `${vendors.length} Verarbeiter`
}
</span> </span>
</div> </div>
<div className="text-xs text-gray-500">{description}</div> <div className="text-xs text-gray-500">{description}</div>
@@ -331,9 +287,7 @@ function CategorySection({
onClick={() => !disabled && onChange(!checked)} onClick={() => !disabled && onChange(!checked)}
disabled={disabled} disabled={disabled}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors shrink-0 ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors shrink-0 ${
checked checked ? (disabled ? 'bg-gray-400' : 'bg-purple-600') : 'bg-gray-200'
? disabled ? 'bg-gray-400' : 'bg-purple-600'
: 'bg-gray-200'
} ${disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`} } ${disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
> >
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${ <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
@@ -343,29 +297,29 @@ function CategorySection({
</div> </div>
{expanded && ( {expanded && (
<div className="px-4 pb-3"> <div className="px-4 pb-2.5">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <thead>
<tr className="text-gray-400 border-b border-gray-100"> <tr className="text-gray-400 border-b border-gray-100">
<th className="text-left py-1.5 font-medium w-5"></th> <th className="text-left py-1 font-medium w-5"></th>
<th className="text-left py-1.5 font-medium">Verarbeiter</th> <th className="text-left py-1 font-medium">Verarbeiter</th>
<th className="text-left py-1.5 font-medium">Cookies</th> <th className="text-left py-1 font-medium">Cookies</th>
<th className="text-left py-1.5 font-medium">Dauer</th> <th className="text-left py-1 font-medium">Dauer</th>
<th className="text-left py-1.5 font-medium">Land</th> <th className="text-left py-1 font-medium">Land</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{vendors.map((v, i) => { {vendors.map((v, i) => {
const isBlocked = ewrOnly && checked && isOutsideEWR(v.country) const blocked = ewrOnly && checked && isOutsideEWR(v.country)
const isActive = checked && !isBlocked const active = checked && !blocked
return ( return (
<tr key={i} className={`border-b border-gray-50 last:border-0 ${isBlocked ? 'opacity-40' : ''}`}> <tr key={i} className={`border-b border-gray-50 last:border-0 ${blocked ? 'opacity-40' : ''}`}>
<td className="py-1.5 w-5"> <td className="py-1 w-5">
{isBlocked ? ( {blocked ? (
<svg className="w-3.5 h-3.5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3.5 h-3.5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg> </svg>
) : isActive ? ( ) : active ? (
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
@@ -375,16 +329,12 @@ function CategorySection({
</svg> </svg>
)} )}
</td> </td>
<td className={`py-1.5 font-medium ${isBlocked ? 'line-through text-gray-400' : 'text-gray-700'}`}> <td className={`py-1 font-medium ${blocked ? 'line-through text-gray-400' : 'text-gray-700'}`}>{v.name}</td>
{v.name} <td className={`py-1 font-mono ${blocked ? 'line-through text-gray-300' : 'text-gray-500'}`}>{v.cookies}</td>
</td> <td className="py-1 text-gray-500">{v.retention}</td>
<td className={`py-1.5 font-mono ${isBlocked ? 'line-through text-gray-300' : 'text-gray-500'}`}> <td className="py-1">
{v.cookies}
</td>
<td className="py-1.5 text-gray-500">{v.retention}</td>
<td className="py-1.5">
{isOutsideEWR(v.country) ? ( {isOutsideEWR(v.country) ? (
<span className={`inline-flex items-center gap-1 ${isBlocked ? 'text-red-400' : 'text-amber-600'}`}> <span className={`inline-flex items-center gap-1 ${blocked ? 'text-red-400' : 'text-amber-600'}`}>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg> </svg>
@@ -410,81 +360,3 @@ function CategorySection({
</div> </div>
) )
} }
function EWRToggle({
checked,
onChange,
blockedCount,
}: {
checked: boolean
onChange: () => void
blockedCount: number
}) {
const [showInfo, setShowInfo] = useState(false)
return (
<div className="flex flex-col items-end gap-1.5 shrink-0">
{/* Toggle Row */}
<div className="flex items-center gap-2">
{/* Info Button */}
<button
onClick={() => setShowInfo(!showInfo)}
className="w-5 h-5 rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 flex items-center justify-center text-xs font-bold transition-colors"
aria-label="Info zu Nur EU/EWR"
>
i
</button>
<span className={`text-xs font-medium whitespace-nowrap ${checked ? 'text-blue-700' : 'text-gray-500'}`}>
Nur EU/EWR
</span>
<button
onClick={onChange}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors shrink-0 cursor-pointer ${
checked ? 'bg-blue-600' : 'bg-gray-200'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
checked ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
{/* Blocked count badge */}
{checked && blockedCount > 0 && (
<span className="text-[10px] text-red-600 font-medium">
{blockedCount} Anbieter blockiert
</span>
)}
{/* Info Tooltip */}
{showInfo && (
<div className="absolute right-6 top-16 w-72 p-3 bg-blue-50 border border-blue-200 rounded-lg shadow-lg z-10 text-xs text-blue-800 leading-relaxed">
<div className="font-semibold mb-1 flex items-center gap-1.5">
<svg className="w-4 h-4 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Nur EU/EWR-Anbieter
</div>
<p>
Erlaubt nur Datenverarbeitung durch Anbieter mit Sitz im Europaeischen
Wirtschaftsraum (EU + Island, Liechtenstein, Norwegen) oder der Schweiz.
</p>
<p className="mt-1.5">
Anbieter ausserhalb (z.B. USA) werden blockiert auch wenn Sie einer
Cookie-Kategorie zustimmen. So behalten Sie die volle Kontrolle ueber
internationale Datentransfers.
</p>
<button
onClick={() => setShowInfo(false)}
className="mt-2 text-blue-600 hover:text-blue-800 font-medium"
>
Verstanden
</button>
</div>
)}
</div>
)
}