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:
@@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.CONSENT_BACKEND_URL || 'https://macmini:3007/api/sdk/v1/banner'
|
||||
const TENANT_ID = process.env.CONSENT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const SITE_ID = process.env.NEXT_PUBLIC_CONSENT_SITE_ID || 'breakpilot-marketing'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const siteId = req.nextUrl.searchParams.get('site_id') || SITE_ID
|
||||
const res = await fetch(`${BACKEND_URL}/config/${siteId}`, {
|
||||
headers: { 'X-Tenant-ID': TENANT_ID },
|
||||
})
|
||||
const data = await res.text()
|
||||
return new NextResponse(data, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json({ categories: [], vendors: [] }, { status: 200 })
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,13 @@ const TENANT_ID = process.env.CONSENT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.text()
|
||||
const data = await req.json()
|
||||
|
||||
// Inject client IP for backend GeoIP resolution
|
||||
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
|| req.headers.get('x-real-ip')
|
||||
|| null
|
||||
if (ip) data.ip_address = ip
|
||||
|
||||
const res = await fetch(`${BACKEND_URL}/consent`, {
|
||||
method: 'POST',
|
||||
@@ -13,13 +19,11 @@ export async function POST(req: NextRequest) {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': TENANT_ID,
|
||||
},
|
||||
body,
|
||||
// Accept self-signed certs on internal network
|
||||
...(process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0' ? {} : {}),
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
const data = await res.text()
|
||||
return new NextResponse(data, {
|
||||
const resBody = await res.text()
|
||||
return new NextResponse(resBody, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
@@ -87,19 +87,12 @@ export default function DatenschutzPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white mb-2">6. Externe Schriften</h2>
|
||||
<h2 className="text-lg font-semibold text-white mb-2">6. Schriften</h2>
|
||||
<p>
|
||||
Diese Website laedt Schriftarten von Google Fonts. Dabei wird Ihre IP-Adresse an Google LLC,
|
||||
1600 Amphitheatre Parkway, Mountain View, CA 94043, USA uebermittelt.
|
||||
Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO.
|
||||
Google ist unter dem EU-US Data Privacy Framework (DPF) zertifiziert (Angemessenheitsbeschluss
|
||||
der EU-Kommission vom 10. Juli 2023).
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
Weitere Informationen:{' '}
|
||||
<a href="https://policies.google.com/privacy" className="text-accent-electric hover:underline" target="_blank" rel="noopener noreferrer">
|
||||
policies.google.com/privacy
|
||||
</a>
|
||||
Diese Website verwendet die Schriftarten Inter, Plus Jakarta Sans und JetBrains Mono.
|
||||
Die Schriften werden lokal auf unserem Server gehostet — es findet kein Abruf von
|
||||
externen Servern (z.B. Google Fonts) statt. Es werden keine personenbezogenen Daten
|
||||
an Dritte uebermittelt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -116,8 +109,8 @@ export default function DatenschutzPage() {
|
||||
<h2 className="text-lg font-semibold text-white mb-2">8. Empfaenger und Auftragsverarbeiter</h2>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Hetzner Online GmbH, Industriestr. 25, 91710 Gunzenhausen — Hosting (AVV nach Art. 28 DSGVO)</li>
|
||||
<li>Google LLC — Schriftarten (Google Fonts CDN, EU-US DPF)</li>
|
||||
</ul>
|
||||
<p className="mt-1">Schriftarten werden lokal gehostet — kein Drittanbieter-Transfer.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
/* Self-hosted fonts — kein Drittlandtransfer zu Google */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300 900;
|
||||
font-display: swap;
|
||||
src: url('/fonts/Inter-Latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Plus Jakarta Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400 800;
|
||||
font-display: swap;
|
||||
src: url('/fonts/PlusJakartaSans-Latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url('/fonts/JetBrainsMono-Latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { AppProvider } from '@/lib/context'
|
||||
import ConsentBanner from '@/components/layout/ConsentBanner'
|
||||
import ScriptManager from '@/components/layout/ScriptManager'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -27,6 +28,7 @@ export default function RootLayout({
|
||||
<AppProvider>
|
||||
{children}
|
||||
<ConsentBanner />
|
||||
<ScriptManager />
|
||||
</AppProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user