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
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:
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user