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:
Marco
2026-08-01 20:33:10 +00:00
parent 259002f5c0
commit 537543bf91
8 changed files with 249 additions and 195 deletions
-60
View File
@@ -1,60 +0,0 @@
"use client";
import Link from "next/link";
import { AnimatePresence, motion } from "motion/react";
import { useConsent } from "../lib/useConsent";
/**
* Bottom banner, shown whenever useConsent()'s cookie hasn't recorded a
* decision yet — the hard prerequisite for TrackingScripts.tsx to ever
* render an analytics/marketing script (see that file's own comment).
* Two equally-sized buttons, not a prominent "Akzeptieren" next to a
* de-emphasized reject link — TTDSG requires an equally easy way to
* decline, not just technically present a way to.
*/
export function CookieBanner() {
const { consent, loaded, setConsent } = useConsent();
const visible = loaded && consent === null;
return (
<AnimatePresence>
{visible && (
<motion.div
initial={{ y: 80, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 80, opacity: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
role="dialog"
aria-label="Cookie-Einstellungen"
className="fixed inset-x-0 bottom-0 z-50 border-t border-border bg-bg-base px-4 py-5 shadow-[0_-4px_24px_rgba(0,0,0,0.06)] sm:px-6"
>
<div className="mx-auto flex max-w-5xl flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-body-sm text-text-primary">
Wir nutzen Cookies, um diese Seite zu betreiben und ihre Nutzung zu verstehen. Mehr dazu in unserer{" "}
<Link href="/datenschutz" className="underline hover:text-brand">
Datenschutzerklärung
</Link>
.
</p>
<div className="flex shrink-0 gap-3">
<button
type="button"
onClick={() => setConsent({ analytics: false })}
className="flex-1 rounded-sm border border-border px-5 py-2.5 text-body-sm font-semibold text-text-primary transition-colors hover:border-brand sm:flex-none"
>
Ablehnen
</button>
<button
type="button"
onClick={() => setConsent({ analytics: true })}
className="flex-1 rounded-sm bg-brand px-5 py-2.5 text-body-sm font-bold text-text-primary transition-colors hover:bg-brand-hover sm:flex-none"
>
Akzeptieren
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useEffect } from "react";
import "klaro/dist/klaro.css";
import type { TrackingCode } from "../lib/payload";
import { buildKlaroConfig } from "../lib/klaroConfig";
import { loadTrackingCode } from "../lib/loadTrackingCode";
// Replaced a hand-rolled CookieBanner.tsx + useConsent.ts + TrackingScripts.tsx
// with kiprotect/klaro (open source, self-hosted, npm install klaro) —
// the custom banner only ever covered the accept/reject UI itself; Klaro
// additionally brings a real per-service consent list, bundled German UI
// translations, and (via `cookies`, not used here yet) cookie-deletion on
// withdrawal — all things a hand-rolled version would have had to build
// from scratch. See project memory for the fuller reasoning.
//
// Dynamically imported inside an effect (client-only, after mount) rather
// than a static top-level import — Klaro touches `window`/`document` at
// module-eval time in places, which isn't SSR-safe. The CSS import above
// stays static (Next.js requires CSS imports to be static, not inside a
// dynamic import()), paired with the "-no-css" JS build so the stylesheet
// isn't loaded twice.
export function KlaroConsentManager({ codes }: { codes: TrackingCode[] }) {
useEffect(() => {
// Nothing to ask consent for — don't even load/render Klaro. A cookie
// banner with zero services to list would just be visual noise.
if (codes.length === 0) return;
let cancelled = false;
import("klaro/dist/klaro-no-css").then((Klaro) => {
if (cancelled) return;
const config = buildKlaroConfig(codes, loadTrackingCode);
Klaro.setup(config);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- `codes` comes
// from a server-fetched, 60s-ISR-cached layout prop; it's stable for
// the lifetime of this component in practice, and Klaro.setup() isn't
// meant to be called more than once per page load anyway.
}, []);
return null;
}
-76
View File
@@ -1,76 +0,0 @@
"use client";
import Script from "next/script";
import { useConsent } from "../lib/useConsent";
import type { TrackingCode } from "../lib/payload";
// Renders one next/script per active tracking code, but only once consent
// actually allows it — `necessary` always renders (nothing in that
// category exists yet, but the field exists for future use, e.g. a
// consent-management/CMP script itself); `analytics`/`marketing` both gate
// on the same single useConsent() flag (see that hook's own comment on why
// there's no separate marketing toggle yet). Fetched server-side
// (getTrackingCodes(), passed in as a prop from layout.tsx) since this
// component itself is a client component and can't call that fetcher
// directly without losing the 60s ISR cache.
export function TrackingScripts({ codes }: { codes: TrackingCode[] }) {
const { consent, loaded } = useConsent();
if (!loaded) return null;
const allowed = codes.filter((code) => code.consentCategory === "necessary" || consent?.analytics === true);
return (
<>
{allowed.map((code) => {
if (code.provider === "google-analytics" && code.measurementId) {
return (
<div key={code.id}>
<Script src={`https://www.googletagmanager.com/gtag/js?id=${code.measurementId}`} strategy="afterInteractive" />
<Script id={`ga-init-${code.id}`} strategy="afterInteractive">
{`window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${code.measurementId}');`}
</Script>
</div>
);
}
if (code.provider === "facebook-pixel" && code.pixelId) {
return (
<Script key={code.id} id={`fb-pixel-${code.id}`} strategy="afterInteractive">
{`!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');`}
</Script>
);
}
if (code.provider === "google-tag-manager" && code.containerId) {
return (
<Script key={code.id} id={`gtm-${code.id}`} strategy="afterInteractive">
{`(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}');`}
</Script>
);
}
// 'other' — the only provider whose script content is admin-
// supplied rather than a fixed loader snippet. Trusted deliberately
// (see TrackingCodes.ts's own admin.description) — only reachable
// by someone with backend admin access in the first place.
if (code.provider === "other" && code.customScript) {
return <Script key={code.id} id={`custom-${code.id}`} strategy="afterInteractive" dangerouslySetInnerHTML={{ __html: code.customScript }} />;
}
return null;
})}
</>
);
}
+2 -4
View File
@@ -4,8 +4,7 @@ import "./globals.css";
import { Navbar } from "./components/Navbar";
import { CartFlyProvider } from "./components/CartFly";
import { CartSync } from "./components/CartSync";
import { CookieBanner } from "./components/CookieBanner";
import { TrackingScripts } from "./components/TrackingScripts";
import { KlaroConsentManager } from "./components/KlaroConsentManager";
import { getProducts, getSeoSettings, getCompanySettings, getWishlistEnabled, getSearchEnabled, getTrackingCodes } from "./lib/payload";
import { buildOrganizationSchema } from "./lib/structuredData";
@@ -93,13 +92,12 @@ export default async function RootLayout({
>
<body className="min-h-full flex flex-col">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }} />
<TrackingScripts codes={trackingCodes} />
<KlaroConsentManager codes={trackingCodes} />
<CartFlyProvider>
<CartSync />
<Navbar singleActiveProduct={singleActiveProduct} wishlistEnabled={wishlistEnabled} searchEnabled={searchEnabled} />
{children}
</CartFlyProvider>
<CookieBanner />
</body>
</html>
);
+117
View File
@@ -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);
},
})),
};
}
+75
View File
@@ -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);
}
}
-55
View File
@@ -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 };
}