feat: Live cookie banner overlay in SDK — auto-open + FAB reopen button
Build + Deploy / build-admin-compliance (push) Successful in 2m16s
Build + Deploy / build-backend-compliance (push) Failing after 4m47s
Build + Deploy / build-ai-sdk (push) Successful in 51s
Build + Deploy / build-developer-portal (push) Successful in 1m17s
Build + Deploy / build-tts (push) Successful in 2m30s
Build + Deploy / build-document-crawler (push) Successful in 45s
Build + Deploy / build-dsms-gateway (push) Successful in 29s
Build + Deploy / build-dsms-node (push) Successful in 11s
Build + Deploy / trigger-orca (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 28s
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 2m56s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 53s
CI / test-python-backend (push) Successful in 43s
CI / test-python-document-crawler (push) Successful in 33s
CI / test-python-dsms-gateway (push) Successful in 26s
CI / validate-canonical-controls (push) Successful in 19s
Build + Deploy / build-admin-compliance (push) Successful in 2m16s
Build + Deploy / build-backend-compliance (push) Failing after 4m47s
Build + Deploy / build-ai-sdk (push) Successful in 51s
Build + Deploy / build-developer-portal (push) Successful in 1m17s
Build + Deploy / build-tts (push) Successful in 2m30s
Build + Deploy / build-document-crawler (push) Successful in 45s
Build + Deploy / build-dsms-gateway (push) Successful in 29s
Build + Deploy / build-dsms-node (push) Successful in 11s
Build + Deploy / trigger-orca (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 28s
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 2m56s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 53s
CI / test-python-backend (push) Successful in 43s
CI / test-python-document-crawler (push) Successful in 33s
CI / test-python-dsms-gateway (push) Successful in 26s
CI / validate-canonical-controls (push) Successful in 19s
- CookieBannerOverlay: opens automatically on first visit (localStorage check) - CookieBannerFAB: shield icon button at right-[10rem] to reopen settings - 3 consent modes: accept all, reject all (nur notwendige), custom settings - 4 categories: Notwendig (locked on), Statistik, Marketing, Funktional - Category toggles with descriptions in settings view - Datenschutzerklaerung + Impressum links in banner - Consent persisted to localStorage, custom event fired on change - Comprehensive Playwright E2E tests (16 tests): - First visit auto-open, button visibility, category toggles - Accept all / reject all / custom settings persistence - FAB reopen behavior, disabled toggle for necessary category Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
'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
|
||||
}
|
||||
|
||||
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-3 border-t border-gray-100 pt-4">
|
||||
{/* Necessary — always on */}
|
||||
<CategoryToggle
|
||||
label="Notwendig"
|
||||
description="Fuer die Grundfunktionen der Website erforderlich."
|
||||
checked={true}
|
||||
disabled={true}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<CategoryToggle
|
||||
label="Statistik"
|
||||
description="Helfen uns zu verstehen, wie Besucher mit der Website interagieren."
|
||||
checked={consent.statistics}
|
||||
onChange={(v) => setConsent(prev => ({ ...prev, statistics: v }))}
|
||||
/>
|
||||
<CategoryToggle
|
||||
label="Marketing"
|
||||
description="Werden verwendet, um Besuchern relevante Werbung zu zeigen."
|
||||
checked={consent.marketing}
|
||||
onChange={(v) => setConsent(prev => ({ ...prev, marketing: v }))}
|
||||
/>
|
||||
<CategoryToggle
|
||||
label="Funktional"
|
||||
description="Ermoeglichen erweiterte Funktionen und Personalisierung."
|
||||
checked={consent.functional}
|
||||
onChange={(v) => setConsent(prev => ({ ...prev, functional: 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 CategoryToggle({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
onChange: (v: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 py-2">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">{label}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{description}</div>
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user