e8b5c90a49
Build + Deploy / build-dsms-node (push) Successful in 7s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 14s
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 2m30s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 37s
Build + Deploy / build-admin-compliance (push) Successful in 2m6s
Build + Deploy / build-backend-compliance (push) Successful in 2m58s
Build + Deploy / build-ai-sdk (push) Successful in 8s
Build + Deploy / build-developer-portal (push) Successful in 7s
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 / trigger-orca (push) Successful in 2m11s
CI / test-python-backend (push) Successful in 36s
CI / test-python-document-crawler (push) Successful in 26s
CI / test-python-dsms-gateway (push) Successful in 23s
CI / validate-canonical-controls (push) Successful in 14s
Browser blocks direct calls to backend-compliance:8093 due to self-signed SSL certificate. All banner API calls now go through Next.js API proxy at /api/sdk/v1/banner/* which runs server-side. - New catch-all proxy: /api/sdk/v1/banner/[[...path]]/route.ts Maps to backend-compliance:8002/api/compliance/banner/* - Preview page: uses /api/sdk/v1/banner/ instead of https://macmini:8093 - CMP Dashboard: uses proxy for banner stats + compliance proxy for DSR/einwilligungen - Fixes: banner not closeable due to API errors, consent not saving Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
353 lines
16 KiB
TypeScript
353 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import {
|
|
CATEGORY_VENDORS, countNonEWRVendors, isEWR, isOutsideEWR,
|
|
} from '@/components/sdk/cookie-banner-vendors'
|
|
|
|
/**
|
|
* Cookie Banner Live-Vorschau — simulates a real website with the banner.
|
|
*
|
|
* Purpose: Test the full consent flow end-to-end:
|
|
* 1. Visitor lands on simulated website → banner appears
|
|
* 2. Visitor makes consent choice (accept/reject/custom + EWR toggle)
|
|
* 3. Consent is recorded via Banner API (POST /banner/consent)
|
|
* 4. Admin can verify in /sdk/consent-management and /sdk/einwilligungen
|
|
*
|
|
* This page runs OUTSIDE the SDK layout to simulate a real website experience.
|
|
*/
|
|
|
|
// Use Next.js API proxy to avoid SSL cert issues with direct backend calls
|
|
const API_BASE = '/api/sdk/v1/banner'
|
|
const SITE_ID = 'preview-test-site'
|
|
const TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
|
|
|
interface ConsentRecord {
|
|
id: string
|
|
categories: string[]
|
|
ewrOnly: boolean
|
|
blockedVendors: string[]
|
|
timestamp: string
|
|
device_fingerprint: string
|
|
}
|
|
|
|
function generateFingerprint(): string {
|
|
const nav = typeof navigator !== 'undefined' ? navigator : null
|
|
const seed = [
|
|
nav?.userAgent || '',
|
|
nav?.language || '',
|
|
screen?.width || 0,
|
|
screen?.height || 0,
|
|
new Date().getTimezoneOffset(),
|
|
].join('|')
|
|
let hash = 0
|
|
for (let i = 0; i < seed.length; i++) {
|
|
hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0
|
|
}
|
|
return `fp-${Math.abs(hash).toString(36)}-${Date.now().toString(36)}`
|
|
}
|
|
|
|
export default function CookieBannerPreviewPage() {
|
|
const [consent, setConsent] = useState<ConsentRecord | null>(null)
|
|
const [showBanner, setShowBanner] = useState(true)
|
|
const [ewrOnly, setEwrOnly] = useState(false)
|
|
const [categories, setCategories] = useState({ necessary: true, statistics: false, marketing: false, functional: false })
|
|
const [saving, setSaving] = useState(false)
|
|
const [apiResult, setApiResult] = useState<any>(null)
|
|
const [fingerprint] = useState(() => generateFingerprint())
|
|
|
|
// Check for existing consent on this simulated site
|
|
useEffect(() => {
|
|
async function check() {
|
|
try {
|
|
const res = await fetch(
|
|
`${API_BASE}/banner/consent?site_id=${SITE_ID}&device_fingerprint=${fingerprint}`,
|
|
{ headers: { 'x-tenant-id': TENANT_ID } },
|
|
)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
if (data.has_consent) {
|
|
setConsent(data.consent)
|
|
setShowBanner(false)
|
|
}
|
|
}
|
|
} catch { /* first visit */ }
|
|
}
|
|
check()
|
|
}, [fingerprint])
|
|
|
|
const saveConsent = useCallback(async (cats: typeof categories) => {
|
|
setSaving(true)
|
|
const blocked: string[] = []
|
|
if (ewrOnly) {
|
|
for (const [key, cat] of Object.entries(CATEGORY_VENDORS)) {
|
|
if (!cats[key as keyof typeof cats]) continue
|
|
for (const v of cat.vendors) {
|
|
if (isOutsideEWR(v.country)) blocked.push(v.name)
|
|
}
|
|
}
|
|
}
|
|
try {
|
|
const res = await fetch(`${API_BASE}/banner/consent`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'x-tenant-id': TENANT_ID },
|
|
body: JSON.stringify({
|
|
site_id: SITE_ID,
|
|
device_fingerprint: fingerprint,
|
|
categories: Object.entries(cats).filter(([, v]) => v).map(([k]) => k),
|
|
vendors: [],
|
|
consent_string: JSON.stringify({ ewrOnly, blockedVendors: blocked }),
|
|
user_agent: navigator.userAgent,
|
|
}),
|
|
})
|
|
const data = await res.json()
|
|
setApiResult(data)
|
|
setConsent({ ...data, ewrOnly, blockedVendors: blocked, timestamp: new Date().toISOString() })
|
|
setShowBanner(false)
|
|
} catch (err: any) {
|
|
setApiResult({ error: err.message })
|
|
}
|
|
setSaving(false)
|
|
}, [ewrOnly, fingerprint])
|
|
|
|
const nonEWRCount = countNonEWRVendors()
|
|
|
|
return (
|
|
<div className="min-h-screen bg-white">
|
|
{/* Simulated Website Header */}
|
|
<header className="bg-slate-800 text-white px-8 py-4">
|
|
<div className="max-w-6xl mx-auto flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 bg-blue-500 rounded-lg" />
|
|
<span className="font-semibold text-lg">MusterShop GmbH</span>
|
|
</div>
|
|
<nav className="flex items-center gap-6 text-sm text-slate-300">
|
|
<span className="hover:text-white cursor-pointer">Produkte</span>
|
|
<span className="hover:text-white cursor-pointer">Ueber uns</span>
|
|
<span className="hover:text-white cursor-pointer">Kontakt</span>
|
|
<span className="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-sm">Warenkorb (2)</span>
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Simulated Website Content */}
|
|
<main className="max-w-6xl mx-auto px-8 py-12">
|
|
<div className="grid grid-cols-3 gap-8">
|
|
<div className="col-span-2 space-y-6">
|
|
<h1 className="text-3xl font-bold text-gray-900">Willkommen bei MusterShop</h1>
|
|
<p className="text-gray-600 leading-relaxed">
|
|
Dies ist eine simulierte Website um den Cookie-Banner zu testen.
|
|
Die Consent-Daten werden ueber die echte Banner-API gespeichert und
|
|
erscheinen in Ihrem CMP unter Consent-Records und Consent-Verwaltung.
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{['Premium Paket', 'Standard Paket', 'Starter Paket', 'Enterprise'].map(p => (
|
|
<div key={p} className="bg-gray-50 border border-gray-200 rounded-xl p-6">
|
|
<div className="w-full h-24 bg-gray-200 rounded-lg mb-3" />
|
|
<h3 className="font-semibold text-gray-900">{p}</h3>
|
|
<p className="text-sm text-gray-500 mt-1">Lorem ipsum dolor sit amet</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* API Debug Panel */}
|
|
<div className="space-y-4">
|
|
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
|
<h3 className="font-semibold text-slate-800 text-sm flex items-center gap-2">
|
|
<svg className="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
|
</svg>
|
|
API Debug
|
|
</h3>
|
|
<div className="mt-3 space-y-2 text-xs">
|
|
<div className="flex justify-between">
|
|
<span className="text-slate-500">Site ID</span>
|
|
<code className="text-slate-700">{SITE_ID}</code>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-slate-500">Fingerprint</span>
|
|
<code className="text-slate-700 truncate ml-2">{fingerprint}</code>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-slate-500">Consent</span>
|
|
<span className={consent ? 'text-green-600 font-medium' : 'text-amber-600'}>
|
|
{consent ? 'Gespeichert' : 'Ausstehend'}
|
|
</span>
|
|
</div>
|
|
{consent && (
|
|
<>
|
|
<div className="flex justify-between">
|
|
<span className="text-slate-500">Kategorien</span>
|
|
<span className="text-slate-700">{consent.categories?.join(', ')}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-slate-500">EWR-Only</span>
|
|
<span className={consent.ewrOnly ? 'text-blue-600' : 'text-slate-400'}>
|
|
{consent.ewrOnly ? 'Ja' : 'Nein'}
|
|
</span>
|
|
</div>
|
|
{consent.blockedVendors?.length > 0 && (
|
|
<div>
|
|
<span className="text-slate-500">Blockiert:</span>
|
|
<div className="mt-1 flex flex-wrap gap-1">
|
|
{consent.blockedVendors.map(v => (
|
|
<span key={v} className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px]">{v}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
{consent && (
|
|
<button
|
|
onClick={() => { setConsent(null); setShowBanner(true); setApiResult(null) }}
|
|
className="mt-3 w-full text-xs text-purple-600 hover:text-purple-700 underline"
|
|
>
|
|
Consent zuruecksetzen (Banner erneut anzeigen)
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{apiResult && (
|
|
<div className="bg-slate-900 text-green-400 rounded-xl p-4 text-xs font-mono overflow-auto max-h-48">
|
|
<div className="text-slate-500 mb-1">POST /banner/consent Response:</div>
|
|
{JSON.stringify(apiResult, null, 2)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4 text-xs text-purple-800">
|
|
<div className="font-semibold mb-1">Pruefen Sie das Ergebnis in:</div>
|
|
<ul className="space-y-1 mt-2">
|
|
<li><a href="/sdk/consent-management" className="underline hover:text-purple-600">Consent-Verwaltung</a></li>
|
|
<li><a href="/sdk/einwilligungen" className="underline hover:text-purple-600">Consent-Records</a></li>
|
|
<li><a href="/sdk/dsr" className="underline hover:text-purple-600">DSR Portal</a></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
{/* Simulated Website Footer */}
|
|
<footer className="bg-slate-100 border-t border-slate-200 px-8 py-6 mt-12">
|
|
<div className="max-w-6xl mx-auto flex items-center justify-between text-sm text-slate-500">
|
|
<span>MusterShop GmbH — Simulierte Test-Website</span>
|
|
<div className="flex items-center gap-4">
|
|
<button onClick={() => setShowBanner(true)} className="underline hover:text-purple-600">
|
|
Cookie-Einstellungen
|
|
</button>
|
|
<span>Datenschutz</span>
|
|
<span>Impressum</span>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
|
|
{/* === REAL COOKIE BANNER === */}
|
|
{showBanner && (
|
|
<>
|
|
<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 */}
|
|
<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>
|
|
{/* EWR Toggle */}
|
|
<div className="flex flex-col items-end gap-1 shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`text-xs font-medium ${ewrOnly ? 'text-blue-700' : 'text-gray-500'}`}>
|
|
Nur EU/EWR
|
|
</span>
|
|
<button
|
|
onClick={() => setEwrOnly(!ewrOnly)}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors cursor-pointer ${
|
|
ewrOnly ? 'bg-blue-600' : 'bg-gray-200'
|
|
}`}
|
|
>
|
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
|
ewrOnly ? 'translate-x-6' : 'translate-x-1'
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Categories */}
|
|
<div className="px-6 pb-3 space-y-1.5 max-h-[40vh] overflow-y-auto border-t border-gray-100 pt-3">
|
|
{Object.entries(CATEGORY_VENDORS).map(([key, cat]) => {
|
|
const checked = key === 'necessary' ? true : categories[key as keyof typeof categories]
|
|
const nonEU = cat.vendors.filter(v => isOutsideEWR(v.country))
|
|
const blocked = ewrOnly && checked ? nonEU.length : 0
|
|
return (
|
|
<div key={key} className="flex items-center justify-between gap-3 px-4 py-2.5 border border-gray-100 rounded-lg bg-gray-50/50">
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900">
|
|
{cat.label}
|
|
<span className="ml-2 text-xs font-normal text-gray-400">
|
|
{blocked > 0 ? `${cat.vendors.length - blocked} aktiv, ${blocked} blockiert` : `${cat.vendors.length} Verarbeiter`}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-gray-500">{cat.description}</div>
|
|
</div>
|
|
<button
|
|
onClick={() => key !== 'necessary' && setCategories(prev => ({ ...prev, [key]: !prev[key as keyof typeof prev] }))}
|
|
disabled={key === 'necessary'}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors shrink-0 ${
|
|
checked ? (key === 'necessary' ? 'bg-gray-400' : 'bg-purple-600') : 'bg-gray-200'
|
|
} ${key === 'necessary' ? '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>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Buttons */}
|
|
<div className="px-6 py-4 bg-gray-50 border-t border-gray-100">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => saveConsent({ necessary: true, statistics: true, marketing: true, functional: true })}
|
|
disabled={saving}
|
|
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 disabled:opacity-50"
|
|
>
|
|
{saving ? 'Speichern...' : 'Alle akzeptieren'}
|
|
</button>
|
|
<button
|
|
onClick={() => saveConsent(categories)}
|
|
disabled={saving}
|
|
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 disabled:opacity-50"
|
|
>
|
|
Auswahl speichern
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center justify-between mt-3">
|
|
<button
|
|
onClick={() => saveConsent({ 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">
|
|
<span>Datenschutzerklaerung</span>
|
|
<span>Impressum</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|