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:
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { addToCart, useCart } from "../lib/cart";
|
||||
import { useCartFly } from "./CartFly";
|
||||
import { NotifyMeForm } from "./NotifyMeForm";
|
||||
import { NotifyMeForm, NotifyMeFormReservedSpace } from "./NotifyMeForm";
|
||||
|
||||
const FEEDBACK_MS = 2000;
|
||||
|
||||
@@ -119,19 +119,23 @@ export function AddToCartButton({
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{currentlyOutOfStock ? (
|
||||
// Replaces the button slot entirely rather than stacking below a
|
||||
// disabled "Ausverkauft" button — same reasoning as
|
||||
// AddToCartInlineButton's identical swap.
|
||||
<NotifyMeForm productId={numericId} variantName={variants.length > 0 ? (selectedVariant ?? "") : ""} />
|
||||
) : (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
{/* Same reserved-space stack as AddToCartInlineButton.tsx's identical
|
||||
block — see that file's own comment. */}
|
||||
<div className="relative grid w-full">
|
||||
<div className="invisible pointer-events-none [grid-area:1/1]">
|
||||
<NotifyMeFormReservedSpace />
|
||||
</div>
|
||||
<div className="[grid-area:1/1] self-start">
|
||||
{currentlyOutOfStock ? (
|
||||
<NotifyMeForm productId={numericId} variantName={variants.length > 0 ? (selectedVariant ?? "") : ""} />
|
||||
) : (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
{/* CSS-grid text-stack, not just swapping the button's text node
|
||||
directly — this button is inline-flex/content-sized (no w-full),
|
||||
so "Hinzugefügt ✓" being shorter than most labels made the whole
|
||||
@@ -159,8 +163,10 @@ export function AddToCartButton({
|
||||
</span>
|
||||
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : displayLabel}</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { addToCart, useCart } from "../lib/cart";
|
||||
import { useCartFly } from "./CartFly";
|
||||
import { NotifyMeForm } from "./NotifyMeForm";
|
||||
import { NotifyMeForm, NotifyMeFormReservedSpace } from "./NotifyMeForm";
|
||||
|
||||
// Exported so consumers like RelatedProducts.tsx can delay their own
|
||||
// follow-up UI changes (e.g. swapping out this exact card) until after
|
||||
@@ -113,35 +113,46 @@ export function AddToCartInlineButton({
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{currentlyOutOfStock ? (
|
||||
// Replaces the button slot entirely rather than stacking below a
|
||||
// disabled "Ausverkauft" button — this component's collapsed idle
|
||||
// state is a single button, same height as the "In den Warenkorb"
|
||||
// button it replaces, so an out-of-stock card doesn't end up taller
|
||||
// than its in-stock siblings and stretch their own buttons down
|
||||
// (plain CSS Grid rows stretch to the tallest card — see
|
||||
// ProductGrid.tsx's own flex-1-spacer comment on why equal card
|
||||
// height matters here).
|
||||
<NotifyMeForm productId={numericId} variantName={variants.length > 0 ? (selectedVariant ?? "") : ""} />
|
||||
) : (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"text-body-sm transition-colors " +
|
||||
(disabled ? "text-text-muted" : added ? "font-semibold text-success" : "text-text-primary")
|
||||
}
|
||||
>
|
||||
{limitReached ? "Maximale Menge im Warenkorb" : added ? "Hinzugefügt ✓" : label}
|
||||
</span>
|
||||
<Image alt="" src="/icon-cart-outline.png" width={32} height={30} className="h-[1.875rem] w-8 object-contain" />
|
||||
</button>
|
||||
)}
|
||||
{/* grid + [grid-area:1/1] stack — NotifyMeFormReservedSpace (an
|
||||
invisible, non-interactive twin of NotifyMeForm's markup) always
|
||||
contributes its height here, even for an in-stock card that
|
||||
never shows the real form. Without it, only out-of-stock cards
|
||||
would be tall enough to need the input+button, and plain CSS
|
||||
Grid's row-stretch (ProductGrid.tsx has no explicit row height)
|
||||
would push every sibling card's button down to match whichever
|
||||
card in the row happens to be out of stock. */}
|
||||
<div className="relative grid w-full">
|
||||
<div className="invisible pointer-events-none [grid-area:1/1]">
|
||||
<NotifyMeFormReservedSpace />
|
||||
</div>
|
||||
{/* self-start, not the stretch default — the button's TOP edge is
|
||||
what must align across cards (ProductGrid.tsx's flex-1 spacer
|
||||
pins this whole block to a card's bottom already); the extra
|
||||
reserved height below a single button just stays blank. */}
|
||||
<div className="[grid-area:1/1] self-start">
|
||||
{currentlyOutOfStock ? (
|
||||
<NotifyMeForm productId={numericId} variantName={variants.length > 0 ? (selectedVariant ?? "") : ""} />
|
||||
) : (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"text-body-sm transition-colors " +
|
||||
(disabled ? "text-text-muted" : added ? "font-semibold text-success" : "text-text-primary")
|
||||
}
|
||||
>
|
||||
{limitReached ? "Maximale Menge im Warenkorb" : added ? "Hinzugefügt ✓" : label}
|
||||
</span>
|
||||
<Image alt="" src="/icon-cart-outline.png" width={32} height={30} className="h-[1.875rem] w-8 object-contain" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useConsent } from "../lib/useConsent";
|
||||
|
||||
/**
|
||||
* Bottom banner, shown whenever useConsent()'s cookie hasn't recorded a
|
||||
* decision yet — the hard prerequisite for TrackingScripts.tsx to ever
|
||||
* render an analytics/marketing script (see that file's own comment).
|
||||
* Two equally-sized buttons, not a prominent "Akzeptieren" next to a
|
||||
* de-emphasized reject link — TTDSG requires an equally easy way to
|
||||
* decline, not just technically present a way to.
|
||||
*/
|
||||
export function CookieBanner() {
|
||||
const { consent, loaded, setConsent } = useConsent();
|
||||
const visible = loaded && consent === null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
initial={{ y: 80, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 80, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
role="dialog"
|
||||
aria-label="Cookie-Einstellungen"
|
||||
className="fixed inset-x-0 bottom-0 z-50 border-t border-border bg-bg-base px-4 py-5 shadow-[0_-4px_24px_rgba(0,0,0,0.06)] sm:px-6"
|
||||
>
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-body-sm text-text-primary">
|
||||
Wir nutzen Cookies, um diese Seite zu betreiben und ihre Nutzung zu verstehen. Mehr dazu in unserer{" "}
|
||||
<Link href="/datenschutz" className="underline hover:text-brand">
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<div className="flex shrink-0 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConsent({ analytics: false })}
|
||||
className="flex-1 rounded-sm border border-border px-5 py-2.5 text-body-sm font-semibold text-text-primary transition-colors hover:border-brand sm:flex-none"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConsent({ analytics: true })}
|
||||
className="flex-1 rounded-sm bg-brand px-5 py-2.5 text-body-sm font-bold text-text-primary transition-colors hover:bg-brand-hover sm:flex-none"
|
||||
>
|
||||
Akzeptieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -7,21 +7,18 @@ import { isValidEmail } from "../lib/email";
|
||||
* Replaces the (disabled) Add-to-cart button's spot once a product/variant
|
||||
* is out of stock — lets a visitor leave their email to be notified once
|
||||
* lib/jobs/sendBackInStockEmails.ts (Payload backend) sends the "it's
|
||||
* back" mail. `productId` is the numeric Payload id (`product.numericId`),
|
||||
* NOT AddToCartInlineButton/AddToCartButton's own `id`/`productId` props —
|
||||
* those are the commerce slug (see lib/payload.ts's Product.id comment) —
|
||||
* same "numericId, not id" split WishlistButton already uses.
|
||||
* back" mail. Always shows the email input + submit button directly (no
|
||||
* extra click to reveal them) — the resulting taller CTA area is
|
||||
* reserved on every card via NotifyMeFormReservedSpace below, not just
|
||||
* the out-of-stock one, so the grid's row-stretch never pushes sibling
|
||||
* cards' buttons down (see AddToCartInlineButton.tsx/AddToCartButton.tsx's
|
||||
* own comment on why that reservation lives there). `productId` is the
|
||||
* numeric Payload id (`product.numericId`), NOT AddToCartInlineButton/
|
||||
* AddToCartButton's own `id`/`productId` props — those are the commerce
|
||||
* slug (see lib/payload.ts's Product.id comment) — same "numericId, not
|
||||
* id" split WishlistButton already uses.
|
||||
*/
|
||||
export function NotifyMeForm({ productId, variantName = "" }: { productId: number; variantName?: string }) {
|
||||
// Collapsed by default (a single button, same height/style as
|
||||
// AddToCartInlineButton's own "In den Warenkorb" button) — grid card
|
||||
// layouts here rely on every card in a row being stretched to equal
|
||||
// height (plain CSS Grid, no explicit height), so an always-expanded
|
||||
// input+button would make an out-of-stock card taller than its row
|
||||
// siblings and push THEIR buttons down as the row stretches to match.
|
||||
// Expanding only after a click keeps the idle-state card height
|
||||
// identical to every in-stock card next to it.
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
|
||||
const [error, setError] = useState("");
|
||||
@@ -57,25 +54,12 @@ export function NotifyMeForm({ productId, variantName = "" }: { productId: numbe
|
||||
return <p className="text-body-sm text-success">Danke! Wir melden uns, sobald es wieder verfügbar ist.</p>;
|
||||
}
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="flex items-center justify-between px-5 py-3 rounded-sm border border-border w-full hover:border-brand transition-colors"
|
||||
>
|
||||
<span className="text-body-sm text-text-primary">Bei Verfügbarkeit benachrichtigen</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// Stacked, not side-by-side — narrow product cards (shop grid, related
|
||||
// products) don't leave enough width for input + button in one row
|
||||
// without either truncating the placeholder or squeezing the button.
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2 w-full">
|
||||
<input
|
||||
autoFocus
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
@@ -88,9 +72,29 @@ export function NotifyMeForm({ productId, variantName = "" }: { productId: numbe
|
||||
disabled={status === "submitting"}
|
||||
className="w-full rounded-sm border border-border px-4 py-2 text-body-sm font-semibold text-text-primary hover:border-brand transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === "submitting" ? "…" : "Absenden"}
|
||||
{status === "submitting" ? "…" : "Bei Verfügbarkeit benachrichtigen"}
|
||||
</button>
|
||||
{error && <p className="text-label text-red-600">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// A non-interactive, visually identical (markup/classes) twin of
|
||||
// NotifyMeForm's idle state — rendered `invisible` behind every card's
|
||||
// actual CTA (see the two AddToCartButton components) so an in-stock
|
||||
// card's own single-button slot reserves the SAME height an out-of-stock
|
||||
// sibling's input+button would need. Without this, only out-of-stock
|
||||
// cards would be taller, and plain CSS Grid's row-stretch would then push
|
||||
// every other card's button down to match — same class of bug as
|
||||
// ProductGrid.tsx's existing min-h-reserved low-stock line, just for a
|
||||
// taller block instead of one text line.
|
||||
export function NotifyMeFormReservedSpace() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full" aria-hidden="true">
|
||||
<input type="email" tabIndex={-1} disabled placeholder="E-Mail-Adresse" className="w-full rounded-sm border border-border px-3 py-2 text-body-sm" />
|
||||
<button type="button" tabIndex={-1} disabled className="w-full rounded-sm border border-border px-4 py-2 text-body-sm font-semibold">
|
||||
Bei Verfügbarkeit benachrichtigen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import Script from "next/script";
|
||||
import { useConsent } from "../lib/useConsent";
|
||||
import type { TrackingCode } from "../lib/payload";
|
||||
|
||||
// Renders one next/script per active tracking code, but only once consent
|
||||
// actually allows it — `necessary` always renders (nothing in that
|
||||
// category exists yet, but the field exists for future use, e.g. a
|
||||
// consent-management/CMP script itself); `analytics`/`marketing` both gate
|
||||
// on the same single useConsent() flag (see that hook's own comment on why
|
||||
// there's no separate marketing toggle yet). Fetched server-side
|
||||
// (getTrackingCodes(), passed in as a prop from layout.tsx) since this
|
||||
// component itself is a client component and can't call that fetcher
|
||||
// directly without losing the 60s ISR cache.
|
||||
export function TrackingScripts({ codes }: { codes: TrackingCode[] }) {
|
||||
const { consent, loaded } = useConsent();
|
||||
if (!loaded) return null;
|
||||
|
||||
const allowed = codes.filter((code) => code.consentCategory === "necessary" || consent?.analytics === true);
|
||||
|
||||
return (
|
||||
<>
|
||||
{allowed.map((code) => {
|
||||
if (code.provider === "google-analytics" && code.measurementId) {
|
||||
return (
|
||||
<div key={code.id}>
|
||||
<Script src={`https://www.googletagmanager.com/gtag/js?id=${code.measurementId}`} strategy="afterInteractive" />
|
||||
<Script id={`ga-init-${code.id}`} strategy="afterInteractive">
|
||||
{`window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${code.measurementId}');`}
|
||||
</Script>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (code.provider === "facebook-pixel" && code.pixelId) {
|
||||
return (
|
||||
<Script key={code.id} id={`fb-pixel-${code.id}`} strategy="afterInteractive">
|
||||
{`!function(f,b,e,v,n,t,s)
|
||||
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
||||
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
|
||||
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
|
||||
n.queue=[];t=b.createElement(e);t.async=!0;
|
||||
t.src=v;s=b.getElementsByTagName(e)[0];
|
||||
s.parentNode.insertBefore(t,s)}(window, document,'script',
|
||||
'https://connect.facebook.net/en_US/fbevents.js');
|
||||
fbq('init', '${code.pixelId}');
|
||||
fbq('track', 'PageView');`}
|
||||
</Script>
|
||||
);
|
||||
}
|
||||
if (code.provider === "google-tag-manager" && code.containerId) {
|
||||
return (
|
||||
<Script key={code.id} id={`gtm-${code.id}`} strategy="afterInteractive">
|
||||
{`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','${code.containerId}');`}
|
||||
</Script>
|
||||
);
|
||||
}
|
||||
// 'other' — the only provider whose script content is admin-
|
||||
// supplied rather than a fixed loader snippet. Trusted deliberately
|
||||
// (see TrackingCodes.ts's own admin.description) — only reachable
|
||||
// by someone with backend admin access in the first place.
|
||||
if (code.provider === "other" && code.customScript) {
|
||||
return <Script key={code.id} id={`custom-${code.id}`} strategy="afterInteractive" dangerouslySetInnerHTML={{ __html: code.customScript }} />;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+7
-2
@@ -4,7 +4,9 @@ import "./globals.css";
|
||||
import { Navbar } from "./components/Navbar";
|
||||
import { CartFlyProvider } from "./components/CartFly";
|
||||
import { CartSync } from "./components/CartSync";
|
||||
import { getProducts, getSeoSettings, getCompanySettings, getWishlistEnabled, getSearchEnabled } from "./lib/payload";
|
||||
import { CookieBanner } from "./components/CookieBanner";
|
||||
import { TrackingScripts } from "./components/TrackingScripts";
|
||||
import { getProducts, getSeoSettings, getCompanySettings, getWishlistEnabled, getSearchEnabled, getTrackingCodes } from "./lib/payload";
|
||||
import { buildOrganizationSchema } from "./lib/structuredData";
|
||||
|
||||
const inter = Inter({
|
||||
@@ -67,11 +69,12 @@ export default async function RootLayout({
|
||||
// just to know whether Navbar's "Shop" link should behave as an anchor
|
||||
// to the homepage spotlight instead of a real /shop navigation (see
|
||||
// Navbar.tsx/ProductSpotlight.tsx).
|
||||
const [products, seller, wishlistEnabled, searchEnabled] = await Promise.all([
|
||||
const [products, seller, wishlistEnabled, searchEnabled, trackingCodes] = await Promise.all([
|
||||
getProducts(),
|
||||
getCompanySettings(),
|
||||
getWishlistEnabled(),
|
||||
getSearchEnabled(),
|
||||
getTrackingCodes(),
|
||||
]);
|
||||
const singleActiveProduct = products.filter((p) => p.active).length === 1;
|
||||
// Organization JSON-LD on every page — one canonical node (@id) that
|
||||
@@ -90,11 +93,13 @@ export default async function RootLayout({
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }} />
|
||||
<TrackingScripts codes={trackingCodes} />
|
||||
<CartFlyProvider>
|
||||
<CartSync />
|
||||
<Navbar singleActiveProduct={singleActiveProduct} wishlistEnabled={wishlistEnabled} searchEnabled={searchEnabled} />
|
||||
{children}
|
||||
</CartFlyProvider>
|
||||
<CookieBanner />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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 : [];
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user