Files
breakpilot-compliance/admin-compliance/components/sdk/CookieBannerOverlay.tsx
T
Benjamin Admin 6ed2505871
Build + Deploy / build-admin-compliance (push) Successful in 2m3s
Build + Deploy / build-backend-compliance (push) Failing after 3m19s
Build + Deploy / build-ai-sdk (push) Successful in 50s
Build + Deploy / build-developer-portal (push) Successful in 1m12s
Build + Deploy / build-tts (push) Successful in 1m44s
Build + Deploy / build-document-crawler (push) Successful in 37s
Build + Deploy / build-dsms-gateway (push) Successful in 22s
Build + Deploy / build-dsms-node (push) Successful in 10s
Build + Deploy / trigger-orca (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / test-go (push) Successful in 41s
CI / test-python-dsms-gateway (push) Successful in 22s
CI / validate-canonical-controls (push) Successful in 13s
CI / loc-budget (push) Failing after 17s
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 2m44s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-python-backend (push) Successful in 40s
CI / test-python-document-crawler (push) Successful in 25s
feat: Cookie banner vendors per category + {{COOKIE_TABLE}} generator
- CookieBannerOverlay: shows vendors per category with expandable tables
  (Verarbeiter, Cookies, Dauer, Land) for full transparency
- Demo vendors: 4 necessary, 3 statistics, 3 marketing, 3 functional
- cookie_table_generator.py: renders {{COOKIE_TABLE}} Markdown tables
  from vendor configs (DB) or service registry (fallback)
- SERVICE_COOKIES: 16 known vendor-to-cookie mappings with provider + country

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 20:07:20 +02:00

364 lines
14 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
/**
* CookieBannerOverlay — Live cookie consent banner for the Compliance SDK.
*
* - Opens automatically on first visit (localStorage check)
* - Can be reopened via FAB button (right-[10rem])
* - Records consent choice to localStorage
* - Fires custom event 'sdkCookieConsentUpdated' for other components
*/
const STORAGE_KEY = 'bp-sdk-cookie-consent'
interface ConsentState {
necessary: boolean
statistics: boolean
marketing: boolean
functional: boolean
timestamp: string
}
interface VendorInfo {
name: string
cookies: string
provider: string
retention: string
country: string
}
// Demo vendors per category — mirrors service registry + cookie_table_generator.py
const CATEGORY_VENDORS: Record<string, { label: string; description: string; vendors: VendorInfo[] }> = {
necessary: {
label: 'Notwendig',
description: 'Fuer die Grundfunktionen der Website erforderlich.',
vendors: [
{ name: 'Session', cookies: 'session_id', provider: 'Eigener Server', retention: 'Session', country: 'DE' },
{ name: 'Consent-Cookie', cookies: 'bp_consent', provider: 'Eigener Server', retention: '12 Monate', country: 'DE' },
{ name: 'Cloudflare', cookies: '__cf_bm', provider: 'Cloudflare Inc.', retention: '30 Min.', country: 'USA (DPF)' },
{ name: 'Stripe', cookies: '__stripe_mid', provider: 'Stripe Inc.', retention: 'Session', country: 'USA (DPF)' },
],
},
statistics: {
label: 'Statistik',
description: 'Helfen uns zu verstehen, wie Besucher mit der Website interagieren.',
vendors: [
{ name: 'Google Analytics', cookies: '_ga, _gid', provider: 'Google LLC', retention: '2 Jahre', country: 'USA (DPF)' },
{ name: 'Hotjar', cookies: '_hj*', provider: 'Hotjar Ltd.', retention: '1 Jahr', country: 'EU (Malta)' },
{ name: 'Google Tag Manager', cookies: '_gcl_au', provider: 'Google LLC', retention: '90 Tage', country: 'USA (DPF)' },
],
},
marketing: {
label: 'Marketing',
description: 'Werden verwendet, um Besuchern relevante Werbung zu zeigen.',
vendors: [
{ name: 'Facebook Pixel', cookies: '_fbp, _fbc', provider: 'Meta Platforms', retention: '90 Tage', country: 'USA (DPF)' },
{ name: 'Google Ads', cookies: '_gcl_aw, IDE', provider: 'Google LLC', retention: '90 Tage', country: 'USA (DPF)' },
{ name: 'LinkedIn Insight', cookies: 'bcookie, li_sugr', provider: 'LinkedIn Ireland', retention: '6 Monate', country: 'EU (Irland)' },
],
},
functional: {
label: 'Funktional',
description: 'Ermoeglichen erweiterte Funktionen und Personalisierung.',
vendors: [
{ name: 'Spracheinstellung', cookies: 'bp_lang', provider: 'Eigener Server', retention: '12 Monate', country: 'DE' },
{ name: 'YouTube', cookies: 'YSC, VISITOR_INFO1_LIVE', provider: 'Google LLC', retention: '6 Monate', country: 'USA (DPF)' },
{ name: 'HubSpot Chat', cookies: '__hstc, hubspotutk', provider: 'HubSpot Inc.', retention: '13 Monate', country: 'USA (DPF)' },
],
},
}
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 [showSettings, setShowSettings] = useState(false)
const [consent, setConsent] = useState<ConsentState>({
necessary: true,
statistics: false,
marketing: false,
functional: false,
timestamp: '',
})
// Check on mount if consent was already given
useEffect(() => {
const stored = getStoredConsent()
if (!stored) {
// First visit — show banner
setIsOpen(true)
} else {
setConsent(stored)
}
}, [])
// Listen for reopen event from FAB button
useEffect(() => {
const handler = () => {
setIsOpen(true)
setShowSettings(true)
}
window.addEventListener('openCookieBanner', handler)
return () => window.removeEventListener('openCookieBanner', handler)
}, [])
const saveConsent = useCallback((state: ConsentState) => {
const withTimestamp = { ...state, timestamp: new Date().toISOString() }
localStorage.setItem(STORAGE_KEY, JSON.stringify(withTimestamp))
setConsent(withTimestamp)
setIsOpen(false)
setShowSettings(false)
window.dispatchEvent(new CustomEvent('sdkCookieConsentUpdated', { detail: withTimestamp }))
}, [])
const handleAcceptAll = () => {
saveConsent({ necessary: true, statistics: true, marketing: true, functional: true, timestamp: '' })
}
const handleRejectAll = () => {
saveConsent({ necessary: true, statistics: false, marketing: false, functional: false, timestamp: '' })
}
const handleSaveSettings = () => {
saveConsent(consent)
}
if (!isOpen) return null
return (
<>
{/* Overlay */}
<div
className="fixed inset-0 bg-black/40 z-[9998] transition-opacity duration-300"
onClick={() => {/* Don't close on overlay click — consent is required */}}
/>
{/* Banner */}
<div className="fixed bottom-0 left-0 right-0 z-[9999] animate-in slide-in-from-bottom duration-300">
<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">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<svg className="w-5 h-5 text-purple-600" 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>
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.
Sie koennen Ihre Praeferenzen jederzeit aendern.
</p>
<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>
{/* Category Settings (expandable) */}
{showSettings && (
<div className="px-6 pb-4 space-y-1 border-t border-gray-100 pt-4 max-h-[50vh] overflow-y-auto">
{Object.entries(CATEGORY_VENDORS).map(([key, cat]) => (
<CategorySection
key={key}
categoryKey={key}
label={cat.label}
description={cat.description}
vendors={cat.vendors}
checked={key === 'necessary' ? true : consent[key as keyof ConsentState] as boolean}
disabled={key === 'necessary'}
onChange={(v) => key !== 'necessary' && setConsent(prev => ({ ...prev, [key]: v }))}
/>
))}
</div>
)}
{/* Buttons */}
<div className="px-6 py-4 bg-gray-50 border-t border-gray-100 flex flex-wrap items-center gap-3">
{!showSettings ? (
<>
<button
onClick={handleAcceptAll}
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"
>
Alle akzeptieren
</button>
<button
onClick={handleRejectAll}
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"
>
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
</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>
</>
)
}
/**
* FAB button to reopen the cookie banner settings.
* Positioned next to ComplianceAdvisor and PipelineSidebar.
*/
export function CookieBannerFAB() {
const [hasConsent, setHasConsent] = useState(false)
useEffect(() => {
setHasConsent(!!getStoredConsent())
const handler = () => setHasConsent(true)
window.addEventListener('sdkCookieConsentUpdated', handler)
return () => window.removeEventListener('sdkCookieConsentUpdated', handler)
}, [])
// Only show FAB after consent was given (banner is closed)
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>
)
}
function CategorySection({
categoryKey,
label,
description,
vendors,
checked,
disabled,
onChange,
}: {
categoryKey: string
label: string
description: string
vendors: VendorInfo[]
checked: boolean
disabled?: boolean
onChange: (v: boolean) => void
}) {
const [expanded, setExpanded] = useState(false)
return (
<div className="border border-gray-100 rounded-lg overflow-hidden">
{/* Category Header with Toggle */}
<div className="flex items-center justify-between gap-3 px-4 py-3 bg-gray-50/50">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 flex-1 text-left"
>
<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" />
</svg>
<div>
<div className="text-sm font-medium text-gray-900">
{label}
<span className="ml-2 text-xs font-normal text-gray-400">
({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>
{/* Vendor Table (expandable) */}
{expanded && (
<div className="px-4 pb-3">
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400 border-b border-gray-100">
<th className="text-left py-1.5 font-medium">Verarbeiter</th>
<th className="text-left py-1.5 font-medium">Cookies</th>
<th className="text-left py-1.5 font-medium">Dauer</th>
<th className="text-left py-1.5 font-medium">Land</th>
</tr>
</thead>
<tbody>
{vendors.map((v, i) => (
<tr key={i} className="border-b border-gray-50 last:border-0">
<td className="py-1.5 text-gray-700 font-medium">{v.name}</td>
<td className="py-1.5 text-gray-500 font-mono">{v.cookies}</td>
<td className="py-1.5 text-gray-500">{v.retention}</td>
<td className="py-1.5 text-gray-500">{v.country}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}