diff --git a/marketing-website/app/api/consent/config/route.ts b/marketing-website/app/api/consent/config/route.ts new file mode 100644 index 0000000..3ff64bc --- /dev/null +++ b/marketing-website/app/api/consent/config/route.ts @@ -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 }) + } +} diff --git a/marketing-website/app/api/consent/route.ts b/marketing-website/app/api/consent/route.ts index b2d9f67..16b6918 100644 --- a/marketing-website/app/api/consent/route.ts +++ b/marketing-website/app/api/consent/route.ts @@ -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' }, }) diff --git a/marketing-website/app/datenschutz/page.tsx b/marketing-website/app/datenschutz/page.tsx index 18e0738..4630a1c 100644 --- a/marketing-website/app/datenschutz/page.tsx +++ b/marketing-website/app/datenschutz/page.tsx @@ -87,19 +87,12 @@ export default function DatenschutzPage() {
-

6. Externe Schriften

+

6. Schriften

- 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). -

-

- Weitere Informationen:{' '} - - policies.google.com/privacy - + 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.

@@ -116,8 +109,8 @@ export default function DatenschutzPage() {

8. Empfaenger und Auftragsverarbeiter

+

Schriftarten werden lokal gehostet — kein Drittanbieter-Transfer.

diff --git a/marketing-website/app/globals.css b/marketing-website/app/globals.css index ed08ff7..d89c22b 100644 --- a/marketing-website/app/globals.css +++ b/marketing-website/app/globals.css @@ -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; diff --git a/marketing-website/app/layout.tsx b/marketing-website/app/layout.tsx index 5b698ab..3757dab 100644 --- a/marketing-website/app/layout.tsx +++ b/marketing-website/app/layout.tsx @@ -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({ {children} + diff --git a/marketing-website/components/layout/ConsentBanner.tsx b/marketing-website/components/layout/ConsentBanner.tsx index e54bd81..de665b5 100644 --- a/marketing-website/components/layout/ConsentBanner.tsx +++ b/marketing-website/components/layout/ConsentBanner.tsx @@ -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) { 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(defaultConsent) + const [vendors, setVendors] = useState([]) + const [vendorConsents, setVendorConsents] = useState>({}) 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 = {} + 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" >
- {categories.map(([key, cat]) => ( -