Files
breakpilot-compliance/admin-compliance/components/sdk/CookieBannerOverlay.tsx
T
Benjamin Admin 61c3f8fd4a
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
refactor: Cookie banner — categories always visible (CNIL/DSK compliant)
- 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>
2026-05-02 22:36:58 +02:00

363 lines
15 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import {
CATEGORY_VENDORS, countNonEWRVendors, isEWR, isOutsideEWR,
type VendorInfo,
} from './cookie-banner-vendors'
/**
* CookieBannerOverlay — DSGVO/CNIL-konformer Cookie-Banner mit "Nur EU/EWR" Toggle.
*
* Alle 4 Kategorien sind auf der ersten Ebene sichtbar (DSK OH Telemedien 2022).
* Vendor-Details aufklappbar per Kategorie. EWR-Toggle blockiert Non-EU-Anbieter
* auch bei aktivierter Kategorie — einzigartiges CMP-Feature.
*/
const STORAGE_KEY = 'bp-sdk-cookie-consent'
interface ConsentState {
necessary: boolean
statistics: boolean
marketing: boolean
functional: boolean
ewrOnly: boolean
blockedVendors: 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() {
const [isOpen, setIsOpen] = useState(false)
const [consent, setConsent] = useState<ConsentState>({
necessary: true, statistics: false, marketing: false, functional: false,
ewrOnly: false, blockedVendors: [], timestamp: '',
})
const nonEWRCount = useMemo(() => countNonEWRVendors(), [])
const blockedVendors = useMemo(() => {
if (!consent.ewrOnly) return []
const blocked: string[] = []
for (const [key, cat] of Object.entries(CATEGORY_VENDORS)) {
const catEnabled = key === 'necessary' || consent[key as keyof ConsentState]
if (!catEnabled) continue
for (const v of cat.vendors) {
if (isOutsideEWR(v.country)) blocked.push(v.name)
}
}
return blocked
}, [consent])
useEffect(() => {
const stored = getStoredConsent()
if (!stored) setIsOpen(true)
else setConsent(stored)
}, [])
useEffect(() => {
const handler = () => setIsOpen(true)
window.addEventListener('openCookieBanner', handler)
return () => window.removeEventListener('openCookieBanner', handler)
}, [])
const saveConsent = useCallback((state: ConsentState) => {
const blocked: string[] = []
if (state.ewrOnly) {
for (const [key, cat] of Object.entries(CATEGORY_VENDORS)) {
const catEnabled = key === 'necessary' || state[key as keyof ConsentState]
if (!catEnabled) continue
for (const v of cat.vendors) {
if (isOutsideEWR(v.country)) blocked.push(v.name)
}
}
}
const withMeta = { ...state, blockedVendors: blocked, timestamp: new Date().toISOString() }
localStorage.setItem(STORAGE_KEY, JSON.stringify(withMeta))
setConsent(withMeta)
setIsOpen(false)
window.dispatchEvent(new CustomEvent('sdkCookieConsentUpdated', { detail: withMeta }))
}, [])
if (!isOpen) return null
return (
<>
<div className="fixed inset-0 bg-black/40 z-[9998]" />
<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">
{/* Header with EWR toggle */}
<div className="px-6 pt-5 pb-3">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h2 className="text-lg font-semibold text-gray-900">Cookie-Einstellungen</h2>
<p className="text-sm text-gray-600 mt-1">
Waehlen Sie, welche Cookie-Kategorien Sie zulassen moechten.
</p>
</div>
<EWRToggle
checked={consent.ewrOnly}
onChange={() => setConsent(prev => ({ ...prev, ewrOnly: !prev.ewrOnly }))}
blockedCount={blockedVendors.length}
nonEWRCount={nonEWRCount}
/>
</div>
</div>
{/* Categories — always visible (CNIL/DSK compliant) */}
<div className="px-6 pb-3 space-y-1.5 max-h-[45vh] overflow-y-auto border-t border-gray-100 pt-3">
{Object.entries(CATEGORY_VENDORS).map(([key, cat]) => (
<CategorySection
key={key}
label={cat.label}
description={cat.description}
vendors={cat.vendors}
checked={key === 'necessary' ? true : consent[key as keyof ConsentState] as boolean}
disabled={key === 'necessary'}
ewrOnly={consent.ewrOnly}
onChange={(v) => key !== 'necessary' && setConsent(prev => ({ ...prev, [key]: v }))}
/>
))}
</div>
{/* Buttons — two equal-weight options */}
<div className="px-6 py-4 bg-gray-50 border-t border-gray-100">
<div className="flex items-center gap-3">
<button
onClick={() => saveConsent({ ...consent, necessary: true, statistics: true, marketing: true, functional: true })}
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
</button>
<button
onClick={() => saveConsent(consent)}
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"
>
Auswahl speichern
</button>
</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>
</>
)
}
export function CookieBannerFAB() {
const [hasConsent, setHasConsent] = useState(false)
useEffect(() => {
setHasConsent(!!getStoredConsent())
const handler = () => setHasConsent(true)
window.addEventListener('sdkCookieConsentUpdated', handler)
return () => window.removeEventListener('sdkCookieConsentUpdated', handler)
}, [])
if (!hasConsent) return null
return (
<button
onClick={() => window.dispatchEvent(new Event('openCookieBanner'))}
className="fixed bottom-6 right-[10rem] w-14 h-14 bg-gray-700 hover:bg-gray-800 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
aria-label="Cookie-Einstellungen oeffnen"
title="Cookie-Einstellungen"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
</svg>
</button>
)
}
// ─── EWR Toggle with Info Button ──────────────────────────
function EWRToggle({ checked, onChange, blockedCount, nonEWRCount }: {
checked: boolean; onChange: () => void; blockedCount: number; nonEWRCount: number
}) {
const [showInfo, setShowInfo] = useState(false)
return (
<div className="relative flex flex-col items-end gap-1 shrink-0">
<div className="flex items-center gap-2">
<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"
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>
{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 nonEuVendors = vendors.filter(v => isOutsideEWR(v.country))
const blockedCount = ewrOnly && checked ? nonEuVendors.length : 0
const activeCount = checked ? vendors.length - blockedCount : 0
return (
<div className="border border-gray-100 rounded-lg overflow-hidden">
<div className="flex items-center justify-between gap-3 px-4 py-2.5 bg-gray-50/50">
<button onClick={() => setExpanded(!expanded)} className="flex items-center gap-2 flex-1 text-left">
<svg className={`w-3.5 h-3.5 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" />
</svg>
<div>
<div className="text-sm font-medium text-gray-900">
{label}
<span className="ml-2 text-xs font-normal text-gray-400">
{checked && blockedCount > 0
? `${activeCount} aktiv, ${blockedCount} blockiert`
: `${vendors.length} Verarbeiter`}
</span>
</div>
<div className="text-xs text-gray-500">{description}</div>
</div>
</button>
<button
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors shrink-0 ${
checked ? (disabled ? 'bg-gray-400' : 'bg-purple-600') : 'bg-gray-200'
} ${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 ${
checked ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
{expanded && (
<div className="px-4 pb-2.5">
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400 border-b border-gray-100">
<th className="text-left py-1 font-medium w-5"></th>
<th className="text-left py-1 font-medium">Verarbeiter</th>
<th className="text-left py-1 font-medium">Cookies</th>
<th className="text-left py-1 font-medium">Dauer</th>
<th className="text-left py-1 font-medium">Land</th>
</tr>
</thead>
<tbody>
{vendors.map((v, i) => {
const blocked = ewrOnly && checked && isOutsideEWR(v.country)
const active = checked && !blocked
return (
<tr key={i} className={`border-b border-gray-50 last:border-0 ${blocked ? 'opacity-40' : ''}`}>
<td className="py-1 w-5">
{blocked ? (
<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" />
</svg>
) : active ? (
<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" />
</svg>
) : (
<svg className="w-3.5 h-3.5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
)}
</td>
<td className={`py-1 font-medium ${blocked ? 'line-through text-gray-400' : 'text-gray-700'}`}>{v.name}</td>
<td className={`py-1 font-mono ${blocked ? 'line-through text-gray-300' : 'text-gray-500'}`}>{v.cookies}</td>
<td className="py-1 text-gray-500">{v.retention}</td>
<td className="py-1">
{isOutsideEWR(v.country) ? (
<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">
<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>
{v.country}
</span>
) : (
<span className="text-green-600 flex items-center gap-1">
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4" />
</svg>
{v.country}
</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}