ac6c3d6b71
Uses Klaro's own built-in contextual-consent mechanism (not loadTrackingCode.ts's script-injection path) — the iframe renders with data-name="google-maps" and its real src; Klaro's DOM scan finds it after mount and blanks/restores src based on that service's consent state, showing its own placeholder (styled via KlaroConsentManager.tsx's KlaroTheme) in the meantime. No map embedded anywhere in the app yet — this is prep work only, per explicit request ahead of an actual page needing one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
166 lines
7.5 KiB
TypeScript
166 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } 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.
|
|
//
|
|
// `buildKlaroConfig`'s `styling` CSS-var overrides (brand colors, corner
|
|
// position) only get Klaro so far — its bundled klaro.css still draws its
|
|
// own border/shadow/spacing/typography, which read as an obviously
|
|
// bolted-on library widget next to this site's own hand-designed
|
|
// components (NewsletterModal.tsx, NotifyMeForm.tsx, etc.). The <style>
|
|
// block below is a real CSS override pass against Klaro's actual DOM
|
|
// classnames (confirmed against kiprotect/klaro's own src/scss/*.scss,
|
|
// not guessed) — same "plain unlayered <style> tag wins the cascade"
|
|
// approach the Payload admin's own AdminUIStyles.tsx uses, `!important`
|
|
// added only where needed to beat klaro.css's own rules of otherwise-equal
|
|
// specificity. Kills Klaro's default border entirely (replaced with a
|
|
// soft shadow, matching this site's own card language) and restyles
|
|
// every button to the site's actual rounded/weighted look instead of
|
|
// Klaro's generic flat rectangles.
|
|
function KlaroTheme() {
|
|
return (
|
|
<style>{`
|
|
/* Blanket reset first — the previous pass only targeted
|
|
.cookie-notice/.cookie-modal directly and a hard black border
|
|
still showed up in production (screenshot-confirmed), so every
|
|
descendant gets border/outline stripped here regardless of which
|
|
specific Klaro rule was actually drawing it; the two rules below
|
|
re-add exactly the borders this theme actually wants. */
|
|
.klaro, .klaro * { box-sizing: border-box; border: 0 !important; outline: 0 !important; }
|
|
.klaro .cookie-notice, .klaro .cookie-modal {
|
|
box-shadow: 0 20px 44px -14px rgba(26,26,24,0.22), 0 4px 14px rgba(26,26,24,0.07) !important;
|
|
border-radius: 18px !important;
|
|
font-family: var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
|
|
}
|
|
.klaro .cookie-notice .cn-body { padding: 22px 24px !important; }
|
|
.klaro .cookie-notice p, .klaro .cookie-modal p {
|
|
font-size: 14px !important;
|
|
line-height: 1.6 !important;
|
|
margin-top: 0 !important;
|
|
margin-bottom: 10px !important;
|
|
}
|
|
.klaro h1, .klaro h2 {
|
|
font-family: var(--font-lora), Georgia, serif !important;
|
|
font-weight: 700 !important;
|
|
letter-spacing: -0.01em !important;
|
|
}
|
|
.klaro .cm-btn {
|
|
border: none !important;
|
|
border-radius: 8px !important;
|
|
font-weight: 700 !important;
|
|
font-size: 13px !important;
|
|
padding: 10px 18px !important;
|
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
|
}
|
|
.klaro .cm-btn:hover { opacity: 0.88; }
|
|
.klaro .cm-btn:active { transform: scale(0.97); }
|
|
.klaro .cm-btn:focus-visible { outline: 2px solid #f6a701 !important; outline-offset: 2px; }
|
|
/* The "Karte laden?" placeholder ContextualConsentNotice renders in
|
|
place of a gated embed (GoogleMapsEmbed.tsx) — fills that embed's
|
|
own aspect-ratio box, so this needs to look like a real card
|
|
slot, not a bare unstyled div floating inside it. */
|
|
.klaro.cm-as-context-notice {
|
|
height: 100% !important;
|
|
display: flex !important;
|
|
align-items: center !important;
|
|
justify-content: center !important;
|
|
}
|
|
.klaro .context-notice {
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
display: flex !important;
|
|
flex-direction: column !important;
|
|
align-items: center !important;
|
|
justify-content: center !important;
|
|
gap: 4px !important;
|
|
padding: 24px !important;
|
|
background: #fffdf8 !important;
|
|
text-align: center !important;
|
|
}
|
|
.klaro .context-notice p { text-align: center !important; max-width: 32ch; }
|
|
.klaro .context-notice .cm-buttons { display: flex !important; gap: 10px !important; margin-top: 6px !important; }
|
|
.klaro a.cm-link, .klaro .cookie-notice a, .klaro .cookie-modal a {
|
|
color: #a06b00 !important;
|
|
text-decoration: underline !important;
|
|
font-weight: 600 !important;
|
|
}
|
|
.klaro select, .klaro .cm-list-input + label {
|
|
border-radius: 6px !important;
|
|
}
|
|
`}</style>
|
|
);
|
|
}
|
|
|
|
export function KlaroConsentManager({ codes }: { codes: TrackingCode[] }) {
|
|
// Kept in state (not just called once) so the persistent reopen button
|
|
// below can call Klaro.show() itself, any time after the initial
|
|
// accept/reject decision — a visitor must always be able to revisit
|
|
// their choice, not just on first load.
|
|
const [klaroModule, setKlaroModule] = useState<typeof import("klaro/dist/klaro-no-css") | null>(null);
|
|
|
|
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);
|
|
setKlaroModule(Klaro);
|
|
});
|
|
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.
|
|
}, []);
|
|
|
|
if (codes.length === 0) return null;
|
|
|
|
return (
|
|
<>
|
|
<KlaroTheme />
|
|
{klaroModule && (
|
|
<button
|
|
type="button"
|
|
onClick={() => klaroModule.show()}
|
|
aria-label="Cookie-Einstellungen öffnen"
|
|
title="Cookie-Einstellungen"
|
|
className="fixed bottom-5 left-5 z-40 flex h-11 w-11 items-center justify-center rounded-full border border-border bg-bg-base shadow-md hover:border-brand transition-colors"
|
|
>
|
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
|
|
<circle cx="9" cy="9.5" r="1.1" fill="currentColor" />
|
|
<circle cx="14" cy="8.5" r="1" fill="currentColor" />
|
|
<circle cx="15" cy="13.5" r="1.1" fill="currentColor" />
|
|
<circle cx="10.5" cy="14.5" r="1" fill="currentColor" />
|
|
<circle cx="12" cy="11" r="0.9" fill="currentColor" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</>
|
|
);
|
|
}
|