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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user