feat: "Nur EU/EWR" toggle in Cookie Banner — blocks non-EWR vendors
Build + Deploy / build-admin-compliance (push) Successful in 2m13s
Build + Deploy / build-backend-compliance (push) Successful in 3m19s
Build + Deploy / build-ai-sdk (push) Successful in 54s
Build + Deploy / build-developer-portal (push) Successful in 1m17s
Build + Deploy / build-tts (push) Successful in 1m46s
Build + Deploy / build-document-crawler (push) Successful in 41s
Build + Deploy / build-dsms-gateway (push) Successful in 23s
Build + Deploy / build-dsms-node (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 19s
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 2m59s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 43s
CI / test-python-backend (push) Successful in 1m4s
CI / test-python-document-crawler (push) Successful in 34s
CI / test-python-dsms-gateway (push) Successful in 26s
CI / validate-canonical-controls (push) Successful in 16s
Build + Deploy / trigger-orca (push) Successful in 3m18s
Build + Deploy / build-admin-compliance (push) Successful in 2m13s
Build + Deploy / build-backend-compliance (push) Successful in 3m19s
Build + Deploy / build-ai-sdk (push) Successful in 54s
Build + Deploy / build-developer-portal (push) Successful in 1m17s
Build + Deploy / build-tts (push) Successful in 1m46s
Build + Deploy / build-document-crawler (push) Successful in 41s
Build + Deploy / build-dsms-gateway (push) Successful in 23s
Build + Deploy / build-dsms-node (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 19s
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 2m59s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 43s
CI / test-python-backend (push) Successful in 1m4s
CI / test-python-document-crawler (push) Successful in 34s
CI / test-python-dsms-gateway (push) Successful in 26s
CI / validate-canonical-controls (push) Successful in 16s
Build + Deploy / trigger-orca (push) Successful in 3m18s
Game-changing CMP feature: Users accept a category (e.g. Marketing) but can restrict data processing to EU/EWR-only vendors. Non-EWR vendors are blocked even when the category is accepted. - Toggle "Nur EU/EWR-Anbieter" with globe icon in blue gradient bar - Blocked vendors shown as red pills with strikethrough icon - Per-vendor status icons: green checkmark (active), red slash (blocked), gray dash (category disabled) - Country column: green circle+check for EWR, amber warning for non-EWR - EWR = EU27 + IS/LI/NO + CH (Angemessenheitsbeschluss) - Vendor data extracted to cookie-banner-vendors.ts (under 500 LOC) - Consent state includes ewrOnly flag + blockedVendors list Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,20 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
CATEGORY_VENDORS, countNonEWRVendors, isEWR, isOutsideEWR,
|
||||||
|
type VendorInfo,
|
||||||
|
} from './cookie-banner-vendors'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CookieBannerOverlay — Live cookie consent banner for the Compliance SDK.
|
* CookieBannerOverlay — Cookie consent banner with Drittland-Schutz.
|
||||||
*
|
*
|
||||||
* - Opens automatically on first visit (localStorage check)
|
* Unique feature: Users can accept a category (e.g. Marketing) but block
|
||||||
* - Can be reopened via FAB button (right-[10rem])
|
* all vendors that transfer data to non-EU countries. This means:
|
||||||
* - Records consent choice to localStorage
|
* - Marketing = ON, Drittland-Schutz = ON → LinkedIn (EU) loads, Facebook (USA) does NOT
|
||||||
* - Fires custom event 'sdkCookieConsentUpdated' for other components
|
* - 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'
|
||||||
@@ -18,68 +24,12 @@ interface ConsentState {
|
|||||||
statistics: boolean
|
statistics: boolean
|
||||||
marketing: boolean
|
marketing: boolean
|
||||||
functional: boolean
|
functional: boolean
|
||||||
|
ewrOnly: boolean
|
||||||
|
blockedVendors: string[]
|
||||||
timestamp: string
|
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() {
|
export function CookieBannerOverlay() {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
@@ -89,21 +39,38 @@ export function CookieBannerOverlay() {
|
|||||||
statistics: false,
|
statistics: false,
|
||||||
marketing: false,
|
marketing: false,
|
||||||
functional: false,
|
functional: false,
|
||||||
|
ewrOnly: false,
|
||||||
|
blockedVendors: [],
|
||||||
timestamp: '',
|
timestamp: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check on mount if consent was already given
|
const nonEWRCount = useMemo(() => countNonEWRVendors(), [])
|
||||||
|
|
||||||
|
// Compute which vendors are actually blocked
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const stored = getStoredConsent()
|
const stored = getStoredConsent()
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
// First visit — show banner
|
|
||||||
setIsOpen(true)
|
setIsOpen(true)
|
||||||
} else {
|
} else {
|
||||||
setConsent(stored)
|
setConsent(stored)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Listen for reopen event from FAB button
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
setIsOpen(true)
|
setIsOpen(true)
|
||||||
@@ -114,20 +81,31 @@ export function CookieBannerOverlay() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const saveConsent = useCallback((state: ConsentState) => {
|
const saveConsent = useCallback((state: ConsentState) => {
|
||||||
const withTimestamp = { ...state, timestamp: new Date().toISOString() }
|
// Compute blocked vendors before saving
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(withTimestamp))
|
const blocked: string[] = []
|
||||||
setConsent(withTimestamp)
|
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)
|
setIsOpen(false)
|
||||||
setShowSettings(false)
|
setShowSettings(false)
|
||||||
window.dispatchEvent(new CustomEvent('sdkCookieConsentUpdated', { detail: withTimestamp }))
|
window.dispatchEvent(new CustomEvent('sdkCookieConsentUpdated', { detail: withMeta }))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleAcceptAll = () => {
|
const handleAcceptAll = () => {
|
||||||
saveConsent({ necessary: true, statistics: true, marketing: true, functional: true, timestamp: '' })
|
saveConsent({ ...consent, necessary: true, statistics: true, marketing: true, functional: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRejectAll = () => {
|
const handleRejectAll = () => {
|
||||||
saveConsent({ necessary: true, statistics: false, marketing: false, functional: false, timestamp: '' })
|
saveConsent({ ...consent, necessary: true, statistics: false, marketing: false, functional: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveSettings = () => {
|
const handleSaveSettings = () => {
|
||||||
@@ -138,13 +116,11 @@ export function CookieBannerOverlay() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Overlay */}
|
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/40 z-[9998] transition-opacity duration-300"
|
className="fixed inset-0 bg-black/40 z-[9998] transition-opacity duration-300"
|
||||||
onClick={() => {/* Don't close on overlay click — consent is required */}}
|
onClick={() => {}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Banner */}
|
|
||||||
<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] 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">
|
<div className="max-w-3xl mx-auto m-4 bg-white rounded-2xl shadow-2xl border border-gray-200 overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -170,9 +146,56 @@ export function CookieBannerOverlay() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category Settings (expandable) */}
|
{/* Settings */}
|
||||||
{showSettings && (
|
{showSettings && (
|
||||||
<div className="px-6 pb-4 space-y-1 border-t border-gray-100 pt-4 max-h-[50vh] overflow-y-auto">
|
<div className="border-t border-gray-100">
|
||||||
|
{/* === DRITTLAND-SCHUTZ === */}
|
||||||
|
<div className="px-6 py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-blue-100">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
<svg className="w-5 h-5 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>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-blue-900">
|
||||||
|
Nur EU/EWR-Anbieter
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-blue-700 mt-0.5 leading-relaxed">
|
||||||
|
Erlaubt nur Anbieter mit Sitz im Europaeischen Wirtschaftsraum (EWR) oder
|
||||||
|
der Schweiz. Anbieter ausserhalb werden blockiert — auch wenn Sie einer
|
||||||
|
Kategorie zustimmen. {nonEWRCount} Anbieter betroffen.
|
||||||
|
</p>
|
||||||
|
{consent.ewrOnly && blockedVendors.length > 0 && (
|
||||||
|
<div className="mt-2 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-100 text-red-700 text-[10px] font-medium">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setConsent(prev => ({ ...prev, ewrOnly: !prev.ewrOnly }))}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors shrink-0 mt-1 ${
|
||||||
|
consent.ewrOnly ? 'bg-blue-600' : 'bg-gray-200'
|
||||||
|
} cursor-pointer`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
||||||
|
consent.ewrOnly ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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}
|
||||||
@@ -182,10 +205,12 @@ export function CookieBannerOverlay() {
|
|||||||
vendors={cat.vendors}
|
vendors={cat.vendors}
|
||||||
checked={key === 'necessary' ? true : consent[key as keyof ConsentState] as boolean}
|
checked={key === 'necessary' ? true : consent[key as keyof ConsentState] as boolean}
|
||||||
disabled={key === 'necessary'}
|
disabled={key === 'necessary'}
|
||||||
|
ewrOnly={consent.ewrOnly}
|
||||||
onChange={(v) => key !== 'necessary' && setConsent(prev => ({ ...prev, [key]: v }))}
|
onChange={(v) => key !== 'necessary' && setConsent(prev => ({ ...prev, [key]: v }))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
@@ -241,10 +266,6 @@ export function CookieBannerOverlay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FAB button to reopen the cookie banner settings.
|
|
||||||
* Positioned next to ComplianceAdvisor and PipelineSidebar.
|
|
||||||
*/
|
|
||||||
export function CookieBannerFAB() {
|
export function CookieBannerFAB() {
|
||||||
const [hasConsent, setHasConsent] = useState(false)
|
const [hasConsent, setHasConsent] = useState(false)
|
||||||
|
|
||||||
@@ -255,7 +276,6 @@ export function CookieBannerFAB() {
|
|||||||
return () => window.removeEventListener('sdkCookieConsentUpdated', handler)
|
return () => window.removeEventListener('sdkCookieConsentUpdated', handler)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Only show FAB after consent was given (banner is closed)
|
|
||||||
if (!hasConsent) return null
|
if (!hasConsent) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -280,6 +300,7 @@ function CategorySection({
|
|||||||
vendors,
|
vendors,
|
||||||
checked,
|
checked,
|
||||||
disabled,
|
disabled,
|
||||||
|
ewrOnly,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
categoryKey: string
|
categoryKey: string
|
||||||
@@ -288,13 +309,18 @@ function CategorySection({
|
|||||||
vendors: VendorInfo[]
|
vendors: VendorInfo[]
|
||||||
checked: boolean
|
checked: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
ewrOnly: boolean
|
||||||
onChange: (v: boolean) => void
|
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 blockedCount = ewrOnly && checked ? nonEuVendors.length : 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">
|
||||||
{/* Category Header with Toggle */}
|
|
||||||
<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-3 bg-gray-50/50">
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
@@ -310,7 +336,12 @@ function CategorySection({
|
|||||||
<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">
|
||||||
({vendors.length} Verarbeiter)
|
{checked
|
||||||
|
? blockedCount > 0
|
||||||
|
? `${activeCount} aktiv, ${blockedCount} blockiert`
|
||||||
|
: `${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>
|
||||||
@@ -325,20 +356,18 @@ function CategorySection({
|
|||||||
: 'bg-gray-200'
|
: 'bg-gray-200'
|
||||||
} ${disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
|
} ${disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
|
||||||
>
|
>
|
||||||
<span
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
|
||||||
checked ? 'translate-x-6' : 'translate-x-1'
|
checked ? 'translate-x-6' : 'translate-x-1'
|
||||||
}`}
|
}`} />
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vendor Table (expandable) */}
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="px-4 pb-3">
|
<div className="px-4 pb-3">
|
||||||
<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.5 font-medium">Verarbeiter</th>
|
<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">Cookies</th>
|
||||||
<th className="text-left py-1.5 font-medium">Dauer</th>
|
<th className="text-left py-1.5 font-medium">Dauer</th>
|
||||||
@@ -346,14 +375,54 @@ function CategorySection({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{vendors.map((v, i) => (
|
{vendors.map((v, i) => {
|
||||||
<tr key={i} className="border-b border-gray-50 last:border-0">
|
const isBlocked = ewrOnly && checked && isOutsideEWR(v.country)
|
||||||
<td className="py-1.5 text-gray-700 font-medium">{v.name}</td>
|
const isActive = checked && !isBlocked
|
||||||
<td className="py-1.5 text-gray-500 font-mono">{v.cookies}</td>
|
return (
|
||||||
|
<tr key={i} className={`border-b border-gray-50 last:border-0 ${isBlocked ? 'opacity-40' : ''}`}>
|
||||||
|
<td className="py-1.5 w-5">
|
||||||
|
{isBlocked ? (
|
||||||
|
<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>
|
||||||
|
) : isActive ? (
|
||||||
|
<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.5 font-medium ${isBlocked ? 'line-through text-gray-400' : 'text-gray-700'}`}>
|
||||||
|
{v.name}
|
||||||
|
</td>
|
||||||
|
<td className={`py-1.5 font-mono ${isBlocked ? 'line-through text-gray-300' : 'text-gray-500'}`}>
|
||||||
|
{v.cookies}
|
||||||
|
</td>
|
||||||
<td className="py-1.5 text-gray-500">{v.retention}</td>
|
<td className="py-1.5 text-gray-500">{v.retention}</td>
|
||||||
<td className="py-1.5 text-gray-500">{v.country}</td>
|
<td className="py-1.5">
|
||||||
|
{isOutsideEWR(v.country) ? (
|
||||||
|
<span className={`inline-flex items-center gap-1 ${isBlocked ? '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>
|
</tr>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Cookie Banner — Vendor data and EWR classification.
|
||||||
|
*
|
||||||
|
* Demo vendors per category, mirroring the service registry + cookie_table_generator.py.
|
||||||
|
* Used by CookieBannerOverlay for vendor display and EWR filtering.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface VendorInfo {
|
||||||
|
name: string
|
||||||
|
cookies: string
|
||||||
|
provider: string
|
||||||
|
retention: string
|
||||||
|
country: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryVendorData {
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
vendors: VendorInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// EWR = EU + Island, Liechtenstein, Norwegen. CH has adequacy decision.
|
||||||
|
const EWR_SAFE = ['de', 'at', 'fr', 'nl', 'ie', 'se', 'dk', 'fi', 'be', 'it', 'es',
|
||||||
|
'pt', 'pl', 'cz', 'hu', 'ro', 'bg', 'hr', 'sk', 'si', 'lt', 'lv', 'ee', 'cy',
|
||||||
|
'mt', 'lu', 'gr', 'is', 'li', 'no', 'ch', // CH: Angemessenheitsbeschluss
|
||||||
|
'eu', 'ewr', 'eigener server']
|
||||||
|
|
||||||
|
export function isEWR(country: string): boolean {
|
||||||
|
if (!country) return true // No country info = assume first party
|
||||||
|
const lower = country.toLowerCase()
|
||||||
|
return EWR_SAFE.some(safe => lower.includes(safe))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOutsideEWR(country: string): boolean {
|
||||||
|
return !isEWR(country)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countNonEWRVendors(): number {
|
||||||
|
let count = 0
|
||||||
|
for (const cat of Object.values(CATEGORY_VENDORS)) {
|
||||||
|
count += cat.vendors.filter(v => isOutsideEWR(v.country)).length
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demo vendors per category — mirrors service registry + cookie_table_generator.py
|
||||||
|
export const CATEGORY_VENDORS: Record<string, CategoryVendorData> = {
|
||||||
|
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)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user