Files
Marco 23f8efcc2b Fix Klaro backdrop only covering a column, not full viewport
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>
2026-08-01 22:20:16 +00:00

297 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
)}
</>
);
}