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; storageMethod: "cookie"; cookieName: string; cookieExpiresAfterDays: number; default: boolean; mustConsent: boolean; acceptAll: boolean; hideDeclineAll: boolean; noticeAsModal: boolean; disablePoweredBy: boolean; translations: Record>; services: { name: string; title: string; purposes: string[]; required: boolean; default: boolean; onlyOnce: boolean; callback: (consent: boolean) => void; }[]; }; // "google-maps" is a fixed name, not id-based like every other provider — // GoogleMapsEmbed.tsx has to hardcode a `data-name` matching this exact // string for Klaro's own contextual-consent DOM scan // (renderContextualConsentNotices in node_modules/klaro) to find it, and // there's no per-row distinguishing ID the way GA/FB/GTM each have one // (a Maps embed URL is per-page, not stored on this row at all — see // TrackingCodes.ts's own field comment). Only ever create ONE // google-maps row in the admin; a second one would collide on this name. export function serviceName(code: TrackingCode): string { if (code.provider === "google-maps") return "google-maps"; 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"; case "google-maps": return "Google Maps"; default: return "Sonstiges Tracking-Skript"; } } // Shown per service in the modal's expanded list — without this, Klaro // only ever shows the bare provider title (see providerTitle above), // which is thin for real TTDSG transparency ("what does this actually // do") and reads as less trustworthy than a CMP that explains itself. // Generic per-provider text, not per-row — a second Google Analytics row // would get the identical description, which is fine since the "what // GA does" fact doesn't change between rows, only which property it's // wired to (already visible via the row's own `name` if an admin sets a // distinguishing one). function providerDescription(code: TrackingCode): string { switch (code.provider) { case "google-analytics": return "Erfasst anonymisierte Nutzungsstatistiken (z. B. welche Seiten wie oft besucht werden), damit wir verstehen, was bei euch ankommt. Setzt die Cookies _ga (Laufzeit 2 Jahre, unterscheidet Besucher:innen) und _ga_ (2 Jahre, Sitzungsstatus)."; case "facebook-pixel": return "Misst, ob ein Besuch von hier zu einer Anzeige bei Facebook/Instagram passt, und hilft uns, Werbung zielgerichteter zu schalten. Setzt den Cookie _fbp (3 Monate, eigene Domain); ist ein Facebook-Konto im selben Browser aktiv, setzt facebook.com zusätzlich den Cookie fr (3 Monate)."; case "google-tag-manager": return "Lädt weitere Mess-/Marketing-Skripte je nach Konfiguration nach, ohne dass wir dafür jedes Mal den Code der Seite ändern müssen. Setzt selbst keine eigenen Cookies — welche Cookies letztlich gesetzt werden, hängt von den darüber geladenen Diensten ab (siehe deren eigene Einträge hier)."; case "google-maps": return "Bindet eine interaktive Karte von Google ein. Beim Laden überträgt dein Browser deine IP-Adresse an Google und Google setzt eigene Cookies (u. a. NID), deren genauer Umfang außerhalb unserer Kontrolle liegt."; default: return "Ein zusätzliches Skript, das diese Seite einbindet."; } } // `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 = {}; for (const code of codes) { serviceTranslations[serviceName(code)] = { title: providerTitle(code), description: providerDescription(code) }; } return { version: 1, elementID: "klaro", // 'light' + 'bottom' + 'left' — a compact corner notice (Klaro's own // default max-width, ~400px), not a full-width bar. 'wide' (tried // first) stretched it across nearly the whole viewport, which read as // too dominant compared to a typical bottom-left corner CMP. // Extra keys beyond `theme` override individual CSS custom properties // (Klaro's injectStyles() applies any non-'theme' `styling` key as a // `--` var, see node_modules/klaro's own utils/styling.js) — this // is the color pass; the border/shadow/typography pass lives in // KlaroConsentManager.tsx's instead (a real CSS override, // needed because Klaro's own default border/spacing/font still read as // an obviously-bolted-on library widget even with the right colors). // dark1/light1 are (confusingly named) the notice's background/text // colors, not a dark-mode toggle. green1 colors the accept button. // blue1/blue2 color the "Einstellungen" (.cm-btn-info) button and // in-modal links — Klaro's default blue clashed with the brand just as // much as its default green did, easy to miss since it only shows // once the modal (not just the notice) is open. styling: { theme: ["light", "bottom", "left"], dark1: "#fffdf8", // --color-bg-paper — notice/modal background dark2: "#e5e0d8", // --color-border // NOT a light background tint despite the name — Klaro's own // .cm-list-description rule (each service's description text in // the modal) uses dark3 as a TEXT color, not a background. A near- // white value here (tried first) made every service description // almost invisible against the modal's own light background — // confirmed via screenshot 2026-08-02. --color-text-muted instead. dark3: "#6b6b69", light1: "#1a1a18", // --color-text-primary — notice/modal text light2: "#6b6b69", // --color-text-muted light3: "#1a1a18", green1: "#f6a701", // --color-brand — accept button green2: "#d99000", blue1: "#e5e0d8", blue2: "#6b6b69", "button-text-color": "#1a1a18", "notice-max-width": "380px", }, storageMethod: "cookie", cookieName: "klaro-consent", cookieExpiresAfterDays: 180, default: false, mustConsent: false, acceptAll: true, hideDeclineAll: false, noticeAsModal: false, // Removes the "Realisiert mit Klaro!" attribution link in the modal — // BSD-3-Clause has no on-page-attribution requirement (only source/ // binary copyright-notice retention), this is just the maintainers' // own polite ask via a dedicated config flag, not a license term. disablePoweredBy: true, translations: { de: { consentModal: { title: "Deine Cookie-Einstellungen", description: "Hier siehst du genau, was wir einsetzen — und entscheidest frei, was du erlaubst. Nichts davon läuft, bevor du zustimmst.", }, consentNotice: { description: "Wir verwenden ein paar Cookies, damit diese Seite reibungslos läuft und wir sehen, was bei euch gut ankommt.", learnMore: "Einstellungen", }, purposes: { necessary: "Notwendig", analytics: "Analyse", marketing: "Marketing", content: "Inhalte", }, ...serviceTranslations, }, }, services: codes.map((code) => ({ name: serviceName(code), title: providerTitle(code), purposes: [code.consentCategory], required: code.consentCategory === "necessary", default: code.consentCategory === "necessary", // For "google-maps", `onAccept` below is a no-op (see // loadTrackingCode.ts) — an embed's src isn't a script this app // injects, Klaro's OWN contextual-consent DOM scan // (renderContextualConsentNotices, runs as part of Klaro.render()) // finds any `data-name="google-maps"` iframe on the page and // blanks/restores its `src` itself, purely by service name + // consent state. This service entry's only job is telling Klaro // that name exists and which purpose gates it. // // 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); }, })), }; }