feat(cmp): Phase 2 complete — self-hosted fonts, ScriptManager, GeoIP, vendor UI
- Session ID via sessionStorage UUID - Self-host Google Fonts (Inter, Plus Jakarta Sans, JetBrains Mono) — eliminates third-party transfer to Google, no more DSGVO violation - ScriptManager component: consent-change listener for future analytics/marketing scripts - GeoIP via browser timezone (Intl.DateTimeFormat) + IP injection in proxy - Vendor-level consent UI: loads vendor config from backend, shows per-vendor toggles under each category, sends vendor_consents dict - DSE updated: Google Fonts section now says "lokal gehostet" - Config proxy route: GET /api/consent/config Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,18 @@ const texts = {
|
||||
},
|
||||
}
|
||||
|
||||
function getSessionId(): string {
|
||||
if (typeof window === 'undefined') return ''
|
||||
try {
|
||||
let sid = sessionStorage.getItem('bp_session_id')
|
||||
if (!sid) {
|
||||
sid = crypto.randomUUID()
|
||||
sessionStorage.setItem('bp_session_id', sid)
|
||||
}
|
||||
return sid
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
function getFingerprint(): string {
|
||||
const nav = typeof navigator !== 'undefined' ? navigator : null
|
||||
const raw = [nav?.language, nav?.platform, screen?.width, screen?.height, new Date().getTimezoneOffset()].join('|')
|
||||
@@ -115,7 +127,7 @@ function detectCookies(): CookieEntry[] {
|
||||
return cookies
|
||||
}
|
||||
|
||||
async function sendConsent(consent: ConsentState, method: ConsentMethod) {
|
||||
async function sendConsent(consent: ConsentState, method: ConsentMethod, vendorConsents?: Record<string, boolean>) {
|
||||
try {
|
||||
const { device_type, browser, os } = detectDevice()
|
||||
const { blocked, released } = detectScripts()
|
||||
@@ -131,7 +143,8 @@ async function sendConsent(consent: ConsentState, method: ConsentMethod) {
|
||||
...(consent.functional ? ['functional'] : []),
|
||||
...(consent.analytics ? ['analytics'] : []),
|
||||
],
|
||||
vendors: [],
|
||||
vendors: Object.keys(vendorConsents || {}).filter(k => vendorConsents?.[k]),
|
||||
vendor_consents: vendorConsents || {},
|
||||
user_agent: navigator.userAgent,
|
||||
consent_method: method,
|
||||
page_url: window.location.href,
|
||||
@@ -141,6 +154,8 @@ async function sendConsent(consent: ConsentState, method: ConsentMethod) {
|
||||
os,
|
||||
screen_resolution: `${screen.width}x${screen.height}`,
|
||||
consent_scope: 'domain',
|
||||
session_id: getSessionId(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
scripts_blocked: blocked,
|
||||
scripts_released: released,
|
||||
cookies_set,
|
||||
@@ -151,26 +166,49 @@ async function sendConsent(consent: ConsentState, method: ConsentMethod) {
|
||||
}
|
||||
}
|
||||
|
||||
interface VendorConfig {
|
||||
vendor_name: string
|
||||
category_key: string
|
||||
description_de?: string
|
||||
description_en?: string
|
||||
cookie_names?: string[]
|
||||
retention_days?: number
|
||||
}
|
||||
|
||||
export default function ConsentBanner() {
|
||||
const { lang } = useApp()
|
||||
const t = texts[lang]
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [consent, setConsent] = useState<ConsentState>(defaultConsent)
|
||||
const [vendors, setVendors] = useState<VendorConfig[]>([])
|
||||
const [vendorConsents, setVendorConsents] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const saved = getSavedConsent()
|
||||
if (!saved) {
|
||||
setVisible(true)
|
||||
}
|
||||
// Load vendor config from backend
|
||||
fetch('/api/consent/config')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const v = data?.vendors || []
|
||||
setVendors(v)
|
||||
// Default all vendors to true
|
||||
const defaults: Record<string, boolean> = {}
|
||||
for (const vendor of v) defaults[vendor.vendor_name] = true
|
||||
setVendorConsents(defaults)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const save = useCallback((state: ConsentState, method: ConsentMethod) => {
|
||||
localStorage.setItem(COOKIE_NAME, JSON.stringify(state))
|
||||
sendConsent(state, method)
|
||||
sendConsent(state, method, vendorConsents)
|
||||
setVisible(false)
|
||||
window.dispatchEvent(new CustomEvent('consent-change', { detail: state }))
|
||||
}, [])
|
||||
}, [vendorConsents])
|
||||
|
||||
const acceptAll = () => save({ essential: true, functional: true, analytics: true }, 'accept_all')
|
||||
const rejectAll = () => save({ essential: true, functional: false, analytics: false }, 'reject_all')
|
||||
@@ -215,24 +253,49 @@ export default function ConsentBanner() {
|
||||
className="overflow-hidden mb-4"
|
||||
>
|
||||
<div className="space-y-2 pt-2 border-t border-white/[0.06]">
|
||||
{categories.map(([key, cat]) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center justify-between p-3 rounded-xl bg-white/[0.03] border border-white/[0.06] cursor-pointer hover:bg-white/[0.05] transition-colors"
|
||||
>
|
||||
<div className="flex-1 mr-4">
|
||||
<span className="text-xs font-semibold text-white">{cat.name}</span>
|
||||
<p className="text-xs text-white/40 mt-0.5">{cat.description}</p>
|
||||
{categories.map(([key, cat]) => {
|
||||
const catVendors = vendors.filter(v => v.category_key === key)
|
||||
return (
|
||||
<div key={key} className="rounded-xl bg-white/[0.03] border border-white/[0.06]">
|
||||
<label className="flex items-center justify-between p-3 cursor-pointer hover:bg-white/[0.05] transition-colors">
|
||||
<div className="flex-1 mr-4">
|
||||
<span className="text-xs font-semibold text-white">{cat.name}</span>
|
||||
<p className="text-xs text-white/40 mt-0.5">{cat.description}</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cat.required || consent[key as keyof ConsentState]}
|
||||
disabled={cat.required}
|
||||
onChange={(e) => {
|
||||
setConsent(prev => ({ ...prev, [key]: e.target.checked }))
|
||||
for (const v of catVendors) {
|
||||
setVendorConsents(prev => ({ ...prev, [v.vendor_name]: e.target.checked }))
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 rounded accent-accent-electric"
|
||||
/>
|
||||
</label>
|
||||
{catVendors.length > 0 && consent[key as keyof ConsentState] && (
|
||||
<div className="px-3 pb-3 space-y-1">
|
||||
{catVendors.map(v => (
|
||||
<label key={v.vendor_name} className="flex items-center justify-between pl-4 py-1 text-xs cursor-pointer">
|
||||
<div className="flex-1 mr-2">
|
||||
<span className="text-white/60">{v.vendor_name}</span>
|
||||
{v.retention_days && <span className="text-white/30 ml-1">({v.retention_days}d)</span>}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={vendorConsents[v.vendor_name] ?? true}
|
||||
onChange={(e) => setVendorConsents(prev => ({ ...prev, [v.vendor_name]: e.target.checked }))}
|
||||
className="w-3 h-3 rounded accent-accent-electric"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cat.required || consent[key as keyof ConsentState]}
|
||||
disabled={cat.required}
|
||||
onChange={(e) => setConsent(prev => ({ ...prev, [key]: e.target.checked }))}
|
||||
className="w-4 h-4 rounded accent-accent-electric"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* ScriptManager — consent-aware script injection.
|
||||
*
|
||||
* Listens to the `consent-change` CustomEvent dispatched by ConsentBanner
|
||||
* and injects/skips third-party scripts based on accepted categories.
|
||||
*
|
||||
* Add scripts to CONSENT_SCRIPTS when the site starts using analytics,
|
||||
* marketing, or functional third-party tools.
|
||||
*/
|
||||
|
||||
interface ConsentScript {
|
||||
src: string
|
||||
async?: boolean
|
||||
}
|
||||
|
||||
const CONSENT_SCRIPTS: Record<string, ConsentScript[]> = {
|
||||
analytics: [
|
||||
// Example: { src: 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXX', async: true },
|
||||
],
|
||||
marketing: [
|
||||
// Example: { src: 'https://connect.facebook.net/en_US/fbevents.js', async: true },
|
||||
],
|
||||
functional: [
|
||||
// Example: { src: 'https://widget.example.com/chat.js', async: true },
|
||||
],
|
||||
}
|
||||
|
||||
interface ConsentState {
|
||||
essential: boolean
|
||||
functional: boolean
|
||||
analytics: boolean
|
||||
}
|
||||
|
||||
function getStoredConsent(): ConsentState | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const raw = localStorage.getItem('bp_consent')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
export default function ScriptManager() {
|
||||
const injected = useRef(new Set<string>())
|
||||
|
||||
function injectScripts(consent: ConsentState) {
|
||||
const categories: (keyof typeof CONSENT_SCRIPTS)[] = []
|
||||
if (consent.analytics) categories.push('analytics')
|
||||
if (consent.functional) categories.push('functional')
|
||||
// marketing would need its own toggle in ConsentState
|
||||
|
||||
for (const cat of categories) {
|
||||
for (const script of CONSENT_SCRIPTS[cat] ?? []) {
|
||||
if (injected.current.has(script.src)) continue
|
||||
const el = document.createElement('script')
|
||||
el.src = script.src
|
||||
if (script.async) el.async = true
|
||||
el.dataset.consent = cat
|
||||
document.head.appendChild(el)
|
||||
injected.current.add(script.src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// On mount: apply saved consent (return visitors)
|
||||
const saved = getStoredConsent()
|
||||
if (saved) injectScripts(saved)
|
||||
|
||||
// Listen for new consent decisions
|
||||
function onConsentChange(e: Event) {
|
||||
const detail = (e as CustomEvent<ConsentState>).detail
|
||||
if (detail) injectScripts(detail)
|
||||
}
|
||||
|
||||
window.addEventListener('consent-change', onConsentChange)
|
||||
return () => window.removeEventListener('consent-change', onConsentChange)
|
||||
}, [])
|
||||
|
||||
return null // Invisible component
|
||||
}
|
||||
Reference in New Issue
Block a user