23f8efcc2b
Root cause: .cookie-modal is the full-viewport wrapper, NOT the dialog box — the previous centering fix wrongly applied position/transform to it. Adding a transform to that wrapper created a new containing block for its position:fixed children, so .cm-bg (the backdrop) started positioning itself relative to the now- shrunken/centered wrapper instead of the real viewport — the dark overlay only covered a 640px-wide column (screenshot-confirmed 2026-08-02) instead of the whole screen. Moved all sizing/position/shadow overrides to .cm-modal.cm-klaro (the actual dialog box, a sibling of .cm-bg — both children of the untouched full-screen .cookie-modal). Also fixed several selectors that were quietly matching nothing: .cn-body doesn't exist in the settings modal (that's .cm-header/.cm-body), and the footer button row is .cm-footer-buttons, not .cm-buttons (that class belongs to the small notice/context-notice components only). Restyled the modal's close button and structural padding to match, now that the real selectors are confirmed against klaro's own consent-modal.jsx source. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
297 lines
15 KiB
TypeScript
297 lines
15 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; }
|
||
/* Matches NewsletterModal.tsx's own backdrop color
|
||
(bg-[rgba(134,134,134,0.9)]) instead of Klaro's default plain
|
||
black at 50% — same dimming purpose, but consistent with how
|
||
every other modal on this site already looks. .cm-bg is a
|
||
*sibling* of the actual dialog box (.cm-modal.cm-klaro), both
|
||
direct children of the full-screen .cookie-modal wrapper — see
|
||
that wrapper's own comment below on why it must never be resized/
|
||
transformed itself. */
|
||
.klaro .cm-bg { background: rgba(134, 134, 134, 0.9) !important; }
|
||
.klaro .cookie-notice, .klaro .cm-modal.cm-klaro {
|
||
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 .cm-modal.cm-klaro 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; }
|
||
/* Side-by-side by default (accept/decline in the notice, the
|
||
modal's own bottom action row), only wrapping to a stacked
|
||
layout once they genuinely don't fit — flex-wrap does this
|
||
naturally at any width, no fixed breakpoint needed. Klaro's own
|
||
.cn-ok/.cn-buttons rely on inline-block flow for the same
|
||
result, which is fragile; this is the explicit version. */
|
||
.klaro .cn-ok, .klaro .cn-buttons, .klaro .cm-buttons {
|
||
display: flex !important;
|
||
flex-wrap: wrap !important;
|
||
gap: 10px !important;
|
||
width: auto !important;
|
||
}
|
||
.klaro .cn-buttons .cm-btn, .klaro .cm-buttons .cm-btn { width: auto !important; margin: 0 !important; }
|
||
/* 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;
|
||
}
|
||
|
||
/* ---- Settings modal service/purpose list — a real pass modeled on
|
||
well-known CMPs like Cookiebot (roomier modal, clear row
|
||
separators, bigger switches, muted-but-legible descriptions),
|
||
not just inherited from the notice's own styling.
|
||
|
||
DOM structure (confirmed against kiprotect/klaro's own
|
||
src/components/consent-modal.jsx — the FIRST version of this
|
||
pass guessed wrong and broke the backdrop, see the incident note
|
||
below):
|
||
.cookie-modal full-viewport wrapper (position:
|
||
fixed, 100%×100%) — MUST stay
|
||
untouched; giving it its own
|
||
transform/position (tried first)
|
||
creates a new containing block
|
||
for its position:fixed children,
|
||
so .cm-bg below started
|
||
positioning itself relative to
|
||
THIS shrunken/centered box
|
||
instead of the real viewport —
|
||
the backdrop only covered a
|
||
640px-wide column, screenshot-
|
||
confirmed 2026-08-02.
|
||
.cm-bg the dark backdrop (styled above)
|
||
.cm-modal.cm-klaro the actual dialog box — every
|
||
size/position override belongs
|
||
HERE, not on .cookie-modal.
|
||
.cm-header close (×) + h1.title + intro <p>
|
||
.cm-body the service/purpose list
|
||
.cm-footer > .cm-footer-buttons decline/accept/accept-all
|
||
row (NOT .cm-buttons — that
|
||
class belongs to the small
|
||
notice/context-notice components
|
||
only, an earlier pass wrongly
|
||
reused it here too and the rule
|
||
silently matched nothing). */
|
||
.klaro .cm-modal.cm-klaro {
|
||
position: fixed !important;
|
||
left: 50% !important;
|
||
top: 50% !important;
|
||
transform: translate(-50%, -50%) !important;
|
||
width: calc(100% - 40px) !important;
|
||
max-width: 640px !important;
|
||
max-height: 88vh !important;
|
||
overflow: auto !important;
|
||
margin: 0 !important;
|
||
}
|
||
.klaro .cm-header { padding: 32px 36px 0 !important; }
|
||
.klaro .cm-body { padding: 8px 36px !important; }
|
||
.klaro .cm-footer { padding: 0 36px 32px !important; }
|
||
.klaro .cm-header h1.title { font-size: 26px !important; margin-bottom: 4px !important; }
|
||
.klaro .cm-header > p { color: #6b6b69 !important; margin-bottom: 0 !important; }
|
||
.klaro .cm-header .hide {
|
||
position: absolute !important;
|
||
top: 28px !important;
|
||
right: 28px !important;
|
||
color: #6b6b69 !important;
|
||
font-size: 20px !important;
|
||
}
|
||
.klaro .cm-header .hide:hover { color: #1a1a18 !important; }
|
||
|
||
/* Re-adds a deliberate row separator the blanket border-reset above
|
||
removes — each purpose/service row genuinely benefits from one,
|
||
unlike the notice's own outer border which just looked heavy. */
|
||
.klaro .cm-switch-container {
|
||
border-bottom: 1px solid #e5e0d8 !important;
|
||
padding: 16px 4px !important;
|
||
padding-left: 70px !important;
|
||
}
|
||
.klaro .cm-switch-container:last-child { border-bottom: 0 !important; }
|
||
.klaro .cm-list-title { font-size: 15px !important; font-weight: 700 !important; }
|
||
.klaro .cm-list-description { font-size: 13.5px !important; line-height: 1.5 !important; padding-top: 5px !important; max-width: 46ch; }
|
||
.klaro p.purposes { font-size: 12px !important; font-weight: 600 !important; text-transform: uppercase; letter-spacing: 0.04em; margin-top: 6px !important; }
|
||
|
||
/* Bigger, clearer toggle (44×24 vs. Klaro's cramped 50×30-but-
|
||
visually-thin default) — brand amber when on, plain border-tone
|
||
off, matching this site's own bg-brand/bg-border pairing. */
|
||
.klaro .cm-switch, .klaro .cm-list-input { width: 44px !important; height: 24px !important; }
|
||
.klaro .slider { border: 1px solid #e5e0d8 !important; }
|
||
.klaro .slider::before { width: 18px !important; height: 18px !important; left: 3px !important; bottom: 2px !important; box-shadow: 0 1px 2px rgba(0,0,0,0.15) !important; }
|
||
.klaro .cm-list-input:checked + .cm-list-label .slider::before { transform: translateX(19px) !important; }
|
||
|
||
.klaro .cm-caret { color: #6b6b69 !important; }
|
||
|
||
/* Bottom action row (decline/accept/accept-all) — real classname
|
||
is .cm-footer-buttons here, NOT .cm-buttons (see the DOM-
|
||
structure note above); reads as a distinct footer, not just the
|
||
last list item — separated + given the same side-by-side/wrap
|
||
treatment as the notice's own buttons. */
|
||
.klaro .cm-footer-buttons {
|
||
display: flex !important;
|
||
flex-wrap: wrap !important;
|
||
gap: 10px !important;
|
||
border-top: 1px solid #e5e0d8 !important;
|
||
margin-top: 16px !important;
|
||
padding-top: 20px !important;
|
||
}
|
||
.klaro .cm-footer-buttons .cm-btn { width: auto !important; margin: 0 !important; }
|
||
.klaro .cm-powered-by { display: none !important; }
|
||
|
||
/* Hides the "alle umschalten" toggle's own boilerplate description
|
||
("Mit diesem Schalter können Sie alle Dienste aktivieren oder
|
||
deaktivieren.") — redundant next to a plainly-labeled switch.
|
||
CSS hide, not an empty translation override — Klaro's own t()
|
||
treats an empty string as "translation missing" and renders a
|
||
literal "[missing translation: ...]" debug string instead
|
||
(confirmed via screenshot 2026-08-02), which is worse than the
|
||
original text. */
|
||
.klaro .cm-toggle-all .cm-list-description { display: none !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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|