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
- 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>
363 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|