Ship tracking-codes Phase 2: cookie consent banner + gated script injection

CookieBanner.tsx (equally-weighted Akzeptieren/Ablehnen, TTDSG) +
useConsent() cookie hook + TrackingScripts.tsx render active
tracking-codes rows only once their consentCategory is actually
accepted ('necessary' always renders). Wired into layout.tsx via the
new getTrackingCodes() fetcher. This is what makes the Phase 1
backend collection (tracking-codes) actually usable end to end.

Also reworks NotifyMeForm back to always-visible input+button (better
UX than a collapse-to-reveal step) — the resulting taller CTA is now
reserved on every card via NotifyMeFormReservedSpace, an invisible
twin rendered behind the real button, so an in-stock card's row
height matches an out-of-stock sibling's without the grid's
row-stretch pushing buttons out of alignment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-08-01 20:03:11 +00:00
parent c6b12e9d60
commit 70ee518e1d
8 changed files with 327 additions and 75 deletions
+35
View File
@@ -1169,3 +1169,38 @@ export async function getSeoSettings(): Promise<SeoSettings> {
(typeof doc.seoDefaultOgImage === "object" && doc.seoDefaultOgImage?.url) || SEO_SETTINGS_FALLBACK.defaultOgImage,
};
}
export type TrackingCode = {
id: number;
provider: "google-analytics" | "facebook-pixel" | "google-tag-manager" | "other";
consentCategory: "necessary" | "analytics" | "marketing";
measurementId: string | null;
pixelId: string | null;
containerId: string | null;
customScript: string | null;
};
// Public read (TrackingCodes.ts's own access.read: () => true), unlike
// most of this file's other Company-Settings-adjacent fetchers — no
// x-order-service-secret header needed. Only `active: true` rows, since
// TrackingScripts.tsx has no reason to even know an inactive one exists.
// Consent-gating itself happens in TrackingScripts.tsx, not here — this
// fetcher runs server-side (no cookie access), the gating decision is a
// client-only concern (useConsent()).
export async function getTrackingCodes(): Promise<TrackingCode[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[active][equals]": "true",
depth: "0",
limit: "50",
});
const res = await fetch(`${PAYLOAD_URL}/api/tracking-codes?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getTrackingCodes: Payload returned ${res.status} ${res.statusText}`);
return [];
}
const data: { docs?: TrackingCode[] } = await res.json();
return Array.isArray(data.docs) ? data.docs : [];
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useCallback, useEffect, useState } from "react";
// Single "analytics" bucket, not one bool per TrackingCodes.consentCategory
// — a plain binary accept/reject banner (TTDSG's own bar for "equally
// prominent" only requires two options) rather than a granular per-category
// settings panel. A tracking-codes row's `marketing` category is gated by
// the same flag as `analytics`; `necessary` always renders regardless of
// this cookie (see TrackingScripts.tsx).
export type Consent = { analytics: boolean };
const COOKIE_NAME = "cookie_consent";
const COOKIE_MAX_AGE_DAYS = 180;
function readConsentCookie(): Consent | null {
if (typeof document === "undefined") return null;
const match = document.cookie.match(new RegExp(`(?:^|; )${COOKIE_NAME}=([^;]*)`));
if (!match) return null;
try {
const parsed = JSON.parse(decodeURIComponent(match[1]));
return typeof parsed?.analytics === "boolean" ? { analytics: parsed.analytics } : null;
} catch {
return null;
}
}
function writeConsentCookie(consent: Consent) {
const maxAge = COOKIE_MAX_AGE_DAYS * 24 * 60 * 60;
document.cookie = `${COOKIE_NAME}=${encodeURIComponent(JSON.stringify(consent))}; path=/; max-age=${maxAge}; SameSite=Lax`;
}
/**
* `consent === null` means "no decision made yet" (CookieBanner.tsx should
* show) distinct from `{ analytics: false }`, an explicit reject.
* `loaded` gates on the client-only cookie read completing, so a server-
* rendered page never briefly flashes the banner before hydration confirms
* there's actually no cookie.
*/
export function useConsent() {
const [consent, setConsentState] = useState<Consent | null>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
setConsentState(readConsentCookie());
setLoaded(true);
}, []);
const setConsent = useCallback((next: Consent) => {
writeConsentCookie(next);
setConsentState(next);
}, []);
return { consent, loaded, setConsent };
}