Replace custom cookie banner with Klaro (open-source, self-hosted CMP)
Swaps the hand-rolled CookieBanner.tsx/useConsent.ts/TrackingScripts.tsx for kiprotect/klaro — brings a real per-service consent list and bundled German UI translations that a custom implementation would have had to build from scratch (per user feedback that a proper CMP is worth it over a purely custom binary accept/reject banner). klaroConfig.ts builds Klaro's config dynamically from the existing tracking-codes backend collection (one Klaro "service" per row, grouped by consentCategory as its purpose). loadTrackingCode.ts is the actual script-injection side effect, wired in via each service's `callback(consent)` — same GA4/Facebook-Pixel/GTM/custom loader logic TrackingScripts.tsx had, just triggered imperatively instead of declaratively rendered. Brand color applied via Klaro's CSS custom property overrides (styling.green1 etc.), not custom SCSS. No @types/klaro package exists — types/klaro.d.ts declares only the small surface actually used. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import type { TrackingCode } from "./payload";
|
||||
|
||||
// Klaro's own config shape (kiprotect/klaro, see node_modules/klaro's
|
||||
// dist/config.js for the fully-annotated reference) — only the fields we
|
||||
// actually set are typed here, not Klaro's full surface.
|
||||
export type KlaroConfig = {
|
||||
version: number;
|
||||
elementID: string;
|
||||
styling: Record<string, string | string[]>;
|
||||
storageMethod: "cookie";
|
||||
cookieName: string;
|
||||
cookieExpiresAfterDays: number;
|
||||
default: boolean;
|
||||
mustConsent: boolean;
|
||||
acceptAll: boolean;
|
||||
hideDeclineAll: boolean;
|
||||
noticeAsModal: boolean;
|
||||
translations: Record<string, Record<string, unknown>>;
|
||||
services: {
|
||||
name: string;
|
||||
title: string;
|
||||
purposes: string[];
|
||||
required: boolean;
|
||||
default: boolean;
|
||||
onlyOnce: boolean;
|
||||
callback: (consent: boolean) => void;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function serviceName(code: TrackingCode): string {
|
||||
return `tracking-code-${code.id}`;
|
||||
}
|
||||
|
||||
function providerTitle(code: TrackingCode): string {
|
||||
switch (code.provider) {
|
||||
case "google-analytics":
|
||||
return "Google Analytics";
|
||||
case "facebook-pixel":
|
||||
return "Facebook Pixel";
|
||||
case "google-tag-manager":
|
||||
return "Google Tag Manager";
|
||||
default:
|
||||
return "Sonstiges Tracking-Skript";
|
||||
}
|
||||
}
|
||||
|
||||
// `onAccept` is threaded in rather than importing loadTrackingCode.ts
|
||||
// directly here — this module only builds a plain config object (no DOM
|
||||
// access), kept separate from the actual script-injection side effect so
|
||||
// it stays trivially testable/reusable if the loading mechanism ever
|
||||
// changes.
|
||||
export function buildKlaroConfig(codes: TrackingCode[], onAccept: (code: TrackingCode) => void): KlaroConfig {
|
||||
const serviceTranslations: Record<string, { title: string }> = {};
|
||||
for (const code of codes) serviceTranslations[serviceName(code)] = { title: providerTitle(code) };
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
elementID: "klaro",
|
||||
// 'light' (not 'dark'), 'bottom' (matches the previous custom
|
||||
// CookieBanner.tsx's own position), 'wide' (roomier than Klaro's
|
||||
// narrow default notice, closer to the old banner's proportions).
|
||||
// Extra keys beyond `theme` override individual CSS custom properties
|
||||
// (Klaro's injectStyles() applies any non-'theme' `styling` key as a
|
||||
// `--<key>` var) — green1 is what colors the "Alle akzeptieren"
|
||||
// button (.cm-btn-success), so this is how the accept button gets the
|
||||
// site's actual brand color instead of Klaro's default green.
|
||||
styling: {
|
||||
theme: ["light", "bottom", "wide"],
|
||||
green1: "#f6a701", // --color-brand
|
||||
"button-text-color": "#1a1a18", // --color-text-primary — dark text reads better on the brand orange than Klaro's default white
|
||||
"border-radius": "6px",
|
||||
"font-family": "inherit",
|
||||
},
|
||||
storageMethod: "cookie",
|
||||
cookieName: "klaro-consent",
|
||||
cookieExpiresAfterDays: 180,
|
||||
default: false,
|
||||
mustConsent: false,
|
||||
acceptAll: true,
|
||||
hideDeclineAll: false,
|
||||
noticeAsModal: false,
|
||||
translations: {
|
||||
de: {
|
||||
consentModal: {
|
||||
title: "Cookie-Einstellungen",
|
||||
description: "Hier kannst du einsehen und anpassen, welche Cookies/Skripte diese Seite verwendet.",
|
||||
},
|
||||
consentNotice: {
|
||||
description: "Wir nutzen Cookies, um diese Seite zu betreiben und ihre Nutzung zu verstehen.",
|
||||
learnMore: "Einstellungen",
|
||||
},
|
||||
purposes: {
|
||||
necessary: "Notwendig",
|
||||
analytics: "Analyse",
|
||||
marketing: "Marketing",
|
||||
},
|
||||
...serviceTranslations,
|
||||
},
|
||||
},
|
||||
services: codes.map((code) => ({
|
||||
name: serviceName(code),
|
||||
title: providerTitle(code),
|
||||
purposes: [code.consentCategory],
|
||||
required: code.consentCategory === "necessary",
|
||||
default: code.consentCategory === "necessary",
|
||||
// A restock/page-reload shouldn't re-fire the loader for a service
|
||||
// the visitor already accepted in an earlier session within this
|
||||
// same page load — Klaro still calls `callback` once per accepted
|
||||
// service per page load either way, `onlyOnce` just governs repeat
|
||||
// toggling within one session.
|
||||
onlyOnce: true,
|
||||
callback: (consent: boolean) => {
|
||||
if (consent) onAccept(code);
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { TrackingCode } from "./payload";
|
||||
|
||||
// Imperative <script> injection, not next/script — this fires from
|
||||
// KlaroConsentManager.tsx's per-service `callback(consent)`, i.e. at an
|
||||
// arbitrary point after mount (whenever a visitor actually accepts), not
|
||||
// declaratively on every render. Idempotent via the DOM id check, since
|
||||
// Klaro's `onlyOnce: true` (see klaroConfig.ts) already tries to guarantee
|
||||
// a single callback-with-consent-true per page load, but this is a cheap
|
||||
// second guard against ever injecting the same script twice.
|
||||
function injectScript(id: string, src?: string, inlineJs?: string) {
|
||||
if (document.getElementById(id)) return;
|
||||
const script = document.createElement("script");
|
||||
script.id = id;
|
||||
script.async = true;
|
||||
if (src) script.src = src;
|
||||
else script.textContent = inlineJs ?? "";
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
// One loader per TrackingCodes.provider (google-analytics/facebook-pixel/
|
||||
// google-tag-manager/other) — same fixed loader snippets per provider as
|
||||
// the Payload backend's own admin.description promises, 'other' is the
|
||||
// only one whose content is admin-supplied rather than a fixed snippet
|
||||
// (trusted deliberately, see TrackingCodes.ts on the backend).
|
||||
export function loadTrackingCode(code: TrackingCode): void {
|
||||
const baseId = `tracking-code-${code.id}`;
|
||||
|
||||
if (code.provider === "google-analytics" && code.measurementId) {
|
||||
injectScript(`${baseId}-loader`, `https://www.googletagmanager.com/gtag/js?id=${code.measurementId}`);
|
||||
injectScript(
|
||||
`${baseId}-init`,
|
||||
undefined,
|
||||
`window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${code.measurementId}');`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (code.provider === "facebook-pixel" && code.pixelId) {
|
||||
injectScript(
|
||||
baseId,
|
||||
undefined,
|
||||
`!function(f,b,e,v,n,t,s)
|
||||
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
||||
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
|
||||
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
|
||||
n.queue=[];t=b.createElement(e);t.async=!0;
|
||||
t.src=v;s=b.getElementsByTagName(e)[0];
|
||||
s.parentNode.insertBefore(t,s)}(window, document,'script',
|
||||
'https://connect.facebook.net/en_US/fbevents.js');
|
||||
fbq('init', '${code.pixelId}');
|
||||
fbq('track', 'PageView');`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (code.provider === "google-tag-manager" && code.containerId) {
|
||||
injectScript(
|
||||
baseId,
|
||||
undefined,
|
||||
`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','${code.containerId}');`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (code.provider === "other" && code.customScript) {
|
||||
injectScript(baseId, undefined, code.customScript);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
// Single "analytics" bucket, not one bool per TrackingCodes.consentCategory
|
||||
// — a plain binary accept/reject banner (TTDSG's own bar for "equally
|
||||
// prominent" only requires two options) rather than a granular per-category
|
||||
// settings panel. A tracking-codes row's `marketing` category is gated by
|
||||
// the same flag as `analytics`; `necessary` always renders regardless of
|
||||
// this cookie (see TrackingScripts.tsx).
|
||||
export type Consent = { analytics: boolean };
|
||||
|
||||
const COOKIE_NAME = "cookie_consent";
|
||||
const COOKIE_MAX_AGE_DAYS = 180;
|
||||
|
||||
function readConsentCookie(): Consent | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${COOKIE_NAME}=([^;]*)`));
|
||||
if (!match) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(decodeURIComponent(match[1]));
|
||||
return typeof parsed?.analytics === "boolean" ? { analytics: parsed.analytics } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeConsentCookie(consent: Consent) {
|
||||
const maxAge = COOKIE_MAX_AGE_DAYS * 24 * 60 * 60;
|
||||
document.cookie = `${COOKIE_NAME}=${encodeURIComponent(JSON.stringify(consent))}; path=/; max-age=${maxAge}; SameSite=Lax`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `consent === null` means "no decision made yet" (CookieBanner.tsx should
|
||||
* show) — distinct from `{ analytics: false }`, an explicit reject.
|
||||
* `loaded` gates on the client-only cookie read completing, so a server-
|
||||
* rendered page never briefly flashes the banner before hydration confirms
|
||||
* there's actually no cookie.
|
||||
*/
|
||||
export function useConsent() {
|
||||
const [consent, setConsentState] = useState<Consent | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setConsentState(readConsentCookie());
|
||||
setLoaded(true);
|
||||
}, []);
|
||||
|
||||
const setConsent = useCallback((next: Consent) => {
|
||||
writeConsentCookie(next);
|
||||
setConsentState(next);
|
||||
}, []);
|
||||
|
||||
return { consent, loaded, setConsent };
|
||||
}
|
||||
Reference in New Issue
Block a user