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) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
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`, {
|
const res = await fetch(`${BACKEND_URL}/consent`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -13,13 +19,11 @@ export async function POST(req: NextRequest) {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Tenant-ID': TENANT_ID,
|
'X-Tenant-ID': TENANT_ID,
|
||||||
},
|
},
|
||||||
body,
|
body: JSON.stringify(data),
|
||||||
// Accept self-signed certs on internal network
|
|
||||||
...(process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0' ? {} : {}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.text()
|
const resBody = await res.text()
|
||||||
return new NextResponse(data, {
|
return new NextResponse(resBody, {
|
||||||
status: res.status,
|
status: res.status,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -87,19 +87,12 @@ export default function DatenschutzPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
<p>
|
||||||
Diese Website laedt Schriftarten von Google Fonts. Dabei wird Ihre IP-Adresse an Google LLC,
|
Diese Website verwendet die Schriftarten Inter, Plus Jakarta Sans und JetBrains Mono.
|
||||||
1600 Amphitheatre Parkway, Mountain View, CA 94043, USA uebermittelt.
|
Die Schriften werden lokal auf unserem Server gehostet — es findet kein Abruf von
|
||||||
Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO.
|
externen Servern (z.B. Google Fonts) statt. Es werden keine personenbezogenen Daten
|
||||||
Google ist unter dem EU-US Data Privacy Framework (DPF) zertifiziert (Angemessenheitsbeschluss
|
an Dritte uebermittelt.
|
||||||
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>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,8 +109,8 @@ export default function DatenschutzPage() {
|
|||||||
<h2 className="text-lg font-semibold text-white mb-2">8. Empfaenger und Auftragsverarbeiter</h2>
|
<h2 className="text-lg font-semibold text-white mb-2">8. Empfaenger und Auftragsverarbeiter</h2>
|
||||||
<ul className="list-disc list-inside space-y-1">
|
<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>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>
|
</ul>
|
||||||
|
<p className="mt-1">Schriftarten werden lokal gehostet — kein Drittanbieter-Transfer.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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');
|
/* Self-hosted fonts — kein Drittlandtransfer zu Google */
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
|
@font-face {
|
||||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
|
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 base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { AppProvider } from '@/lib/context'
|
import { AppProvider } from '@/lib/context'
|
||||||
import ConsentBanner from '@/components/layout/ConsentBanner'
|
import ConsentBanner from '@/components/layout/ConsentBanner'
|
||||||
|
import ScriptManager from '@/components/layout/ScriptManager'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -27,6 +28,7 @@ export default function RootLayout({
|
|||||||
<AppProvider>
|
<AppProvider>
|
||||||
{children}
|
{children}
|
||||||
<ConsentBanner />
|
<ConsentBanner />
|
||||||
|
<ScriptManager />
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 {
|
function getFingerprint(): string {
|
||||||
const nav = typeof navigator !== 'undefined' ? navigator : null
|
const nav = typeof navigator !== 'undefined' ? navigator : null
|
||||||
const raw = [nav?.language, nav?.platform, screen?.width, screen?.height, new Date().getTimezoneOffset()].join('|')
|
const raw = [nav?.language, nav?.platform, screen?.width, screen?.height, new Date().getTimezoneOffset()].join('|')
|
||||||
@@ -115,7 +127,7 @@ function detectCookies(): CookieEntry[] {
|
|||||||
return cookies
|
return cookies
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendConsent(consent: ConsentState, method: ConsentMethod) {
|
async function sendConsent(consent: ConsentState, method: ConsentMethod, vendorConsents?: Record<string, boolean>) {
|
||||||
try {
|
try {
|
||||||
const { device_type, browser, os } = detectDevice()
|
const { device_type, browser, os } = detectDevice()
|
||||||
const { blocked, released } = detectScripts()
|
const { blocked, released } = detectScripts()
|
||||||
@@ -131,7 +143,8 @@ async function sendConsent(consent: ConsentState, method: ConsentMethod) {
|
|||||||
...(consent.functional ? ['functional'] : []),
|
...(consent.functional ? ['functional'] : []),
|
||||||
...(consent.analytics ? ['analytics'] : []),
|
...(consent.analytics ? ['analytics'] : []),
|
||||||
],
|
],
|
||||||
vendors: [],
|
vendors: Object.keys(vendorConsents || {}).filter(k => vendorConsents?.[k]),
|
||||||
|
vendor_consents: vendorConsents || {},
|
||||||
user_agent: navigator.userAgent,
|
user_agent: navigator.userAgent,
|
||||||
consent_method: method,
|
consent_method: method,
|
||||||
page_url: window.location.href,
|
page_url: window.location.href,
|
||||||
@@ -141,6 +154,8 @@ async function sendConsent(consent: ConsentState, method: ConsentMethod) {
|
|||||||
os,
|
os,
|
||||||
screen_resolution: `${screen.width}x${screen.height}`,
|
screen_resolution: `${screen.width}x${screen.height}`,
|
||||||
consent_scope: 'domain',
|
consent_scope: 'domain',
|
||||||
|
session_id: getSessionId(),
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
scripts_blocked: blocked,
|
scripts_blocked: blocked,
|
||||||
scripts_released: released,
|
scripts_released: released,
|
||||||
cookies_set,
|
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() {
|
export default function ConsentBanner() {
|
||||||
const { lang } = useApp()
|
const { lang } = useApp()
|
||||||
const t = texts[lang]
|
const t = texts[lang]
|
||||||
const [visible, setVisible] = useState(false)
|
const [visible, setVisible] = useState(false)
|
||||||
const [showDetails, setShowDetails] = useState(false)
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
const [consent, setConsent] = useState<ConsentState>(defaultConsent)
|
const [consent, setConsent] = useState<ConsentState>(defaultConsent)
|
||||||
|
const [vendors, setVendors] = useState<VendorConfig[]>([])
|
||||||
|
const [vendorConsents, setVendorConsents] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = getSavedConsent()
|
const saved = getSavedConsent()
|
||||||
if (!saved) {
|
if (!saved) {
|
||||||
setVisible(true)
|
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) => {
|
const save = useCallback((state: ConsentState, method: ConsentMethod) => {
|
||||||
localStorage.setItem(COOKIE_NAME, JSON.stringify(state))
|
localStorage.setItem(COOKIE_NAME, JSON.stringify(state))
|
||||||
sendConsent(state, method)
|
sendConsent(state, method, vendorConsents)
|
||||||
setVisible(false)
|
setVisible(false)
|
||||||
window.dispatchEvent(new CustomEvent('consent-change', { detail: state }))
|
window.dispatchEvent(new CustomEvent('consent-change', { detail: state }))
|
||||||
}, [])
|
}, [vendorConsents])
|
||||||
|
|
||||||
const acceptAll = () => save({ essential: true, functional: true, analytics: true }, 'accept_all')
|
const acceptAll = () => save({ essential: true, functional: true, analytics: true }, 'accept_all')
|
||||||
const rejectAll = () => save({ essential: true, functional: false, analytics: false }, 'reject_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"
|
className="overflow-hidden mb-4"
|
||||||
>
|
>
|
||||||
<div className="space-y-2 pt-2 border-t border-white/[0.06]">
|
<div className="space-y-2 pt-2 border-t border-white/[0.06]">
|
||||||
{categories.map(([key, cat]) => (
|
{categories.map(([key, cat]) => {
|
||||||
<label
|
const catVendors = vendors.filter(v => v.category_key === key)
|
||||||
key={key}
|
return (
|
||||||
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 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">
|
<div className="flex-1 mr-4">
|
||||||
<span className="text-xs font-semibold text-white">{cat.name}</span>
|
<span className="text-xs font-semibold text-white">{cat.name}</span>
|
||||||
<p className="text-xs text-white/40 mt-0.5">{cat.description}</p>
|
<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>
|
</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>
|
</div>
|
||||||
</motion.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