This commit is contained in:
2026-07-31 23:21:51 +02:00
parent f6b4b36df8
commit f14f2cfd98
294 changed files with 5880 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
import { type ReactNode } from 'react';
import type { ConsentTexts } from './texts.js';
import { useConsent } from './useConsent.js';
type ConsentContextValue = {
texts: ConsentTexts;
} & ReturnType<typeof useConsent>;
export declare function ConsentProvider({ children, texts }: {
children: ReactNode;
texts: ConsentTexts;
}): import("react/jsx-runtime").JSX.Element;
export declare function useConsentContext(): ConsentContextValue;
export {};
+24
View File
@@ -0,0 +1,24 @@
'use client';
import { jsx as _jsx } from "react/jsx-runtime";
import { createContext, use } from 'react';
import { useConsent } from './useConsent.js';
const ConsentContext = /*#__PURE__*/ createContext(null);
export function ConsentProvider({ children, texts }) {
const consent = useConsent();
return /*#__PURE__*/ _jsx(ConsentContext, {
value: {
...consent,
texts
},
children: children
});
}
export function useConsentContext() {
const ctx = use(ConsentContext);
if (!ctx) {
throw new Error('useConsentContext must be used within ConsentProvider');
}
return ctx;
}
//# sourceMappingURL=ConsentContext.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/consent/ConsentContext.tsx"],"sourcesContent":["'use client'\nimport { createContext, type ReactNode, use } from 'react'\n\nimport type { ConsentTexts } from './texts.js'\n\nimport { useConsent } from './useConsent.js'\n\ntype ConsentContextValue = { texts: ConsentTexts } & ReturnType<typeof useConsent>\n\nconst ConsentContext = createContext<ConsentContextValue | null>(null)\n\nexport function ConsentProvider({ children, texts }: { children: ReactNode; texts: ConsentTexts }) {\n const consent = useConsent()\n return <ConsentContext value={{ ...consent, texts }}>{children}</ConsentContext>\n}\n\nexport function useConsentContext(): ConsentContextValue {\n const ctx = use(ConsentContext)\n if (!ctx) {throw new Error('useConsentContext must be used within ConsentProvider')}\n return ctx\n}\n"],"names":["createContext","use","useConsent","ConsentContext","ConsentProvider","children","texts","consent","value","useConsentContext","ctx","Error"],"mappings":"AAAA;;AACA,SAASA,aAAa,EAAkBC,GAAG,QAAQ,QAAO;AAI1D,SAASC,UAAU,QAAQ,kBAAiB;AAI5C,MAAMC,+BAAiBH,cAA0C;AAEjE,OAAO,SAASI,gBAAgB,EAAEC,QAAQ,EAAEC,KAAK,EAAgD;IAC/F,MAAMC,UAAUL;IAChB,qBAAO,KAACC;QAAeK,OAAO;YAAE,GAAGD,OAAO;YAAED;QAAM;kBAAID;;AACxD;AAEA,OAAO,SAASI;IACd,MAAMC,MAAMT,IAAIE;IAChB,IAAI,CAACO,KAAK;QAAC,MAAM,IAAIC,MAAM;IAAwD;IACnF,OAAOD;AACT"}
+12
View File
@@ -0,0 +1,12 @@
/**
* Optional class overrides — for when tokens aren't enough, e.g. turning the
* bottom bar into a centred modal. Replaces the slot's classes outright.
*/
export type CookieBannerClassNames = {
primaryButton?: string;
root?: string;
secondaryButton?: string;
};
export declare function CookieBanner({ classNames }?: {
classNames?: CookieBannerClassNames;
}): import("react/jsx-runtime").JSX.Element | null;
+179
View File
@@ -0,0 +1,179 @@
'use client';
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { Cookie } from 'lucide-react';
import { useEffect, useState } from 'react';
import { CONSENT_CATEGORIES } from './categories.js';
import { useConsentContext } from './ConsentContext.js';
/**
* Colours and radius come from CSS custom properties with built-in fallbacks,
* so the banner looks right with no setup, and re-skinning per client means
* declaring a few variables — no imports, no props, no specificity fights:
*
* ```css
* :root {
* --ipal-primary: #16a34a;
* --ipal-radius: 1rem;
* }
* ```
*
* Available: --ipal-surface, --ipal-border, --ipal-text, --ipal-text-strong,
* --ipal-text-muted, --ipal-primary, --ipal-primary-hover, --ipal-primary-text,
* --ipal-hover, --ipal-radius. Each has a `-dark` counterpart used under
* Tailwind's `dark:` variant (e.g. --ipal-surface-dark).
*
* Layout stays in Tailwind: spacing and flow aren't things a brand changes, and
* exposing them as tokens would mean re-inventing CSS one variable at a time.
*/ const surface = 'bg-[var(--ipal-surface,#fff)] dark:bg-[var(--ipal-surface-dark,#171717)]';
const border = 'border-[var(--ipal-border,#e5e5e5)] dark:border-[var(--ipal-border-dark,#262626)]';
const radius = 'rounded-[var(--ipal-radius,0.375rem)]';
const accent = 'text-[var(--ipal-primary,#2563eb)]';
const defaults = {
primaryButton: `${radius} px-3 py-1.5 text-sm font-medium bg-[var(--ipal-primary,#2563eb)] text-[var(--ipal-primary-text,#fff)] hover:bg-[var(--ipal-primary-hover,#1d4ed8)]`,
root: `fixed inset-x-0 bottom-0 z-50 border-t p-4 shadow-lg ${surface} ${border}`,
secondaryButton: `${radius} px-3 py-1.5 text-sm font-medium text-[var(--ipal-text,#404040)] hover:bg-[var(--ipal-hover,#f5f5f5)] dark:text-[var(--ipal-text-dark,#e5e5e5)] dark:hover:bg-[var(--ipal-hover-dark,#262626)]`
};
const bodyText = 'text-[var(--ipal-text,#404040)] dark:text-[var(--ipal-text-dark,#d4d4d4)]';
const strongText = 'text-[var(--ipal-text-strong,#171717)] dark:text-[var(--ipal-text-strong-dark,#f5f5f5)]';
const mutedText = 'text-[var(--ipal-text-muted,#737373)] dark:text-[var(--ipal-text-muted-dark,#a3a3a3)]';
export function CookieBanner({ classNames } = {}) {
const { acceptAll, decided, rejectAll, savePreferences, state, texts } = useConsentContext();
const [showSettings, setShowSettings] = useState(false);
const [choices, setChoices] = useState(state);
useEffect(()=>{
setChoices(state);
}, [
state
]);
if (decided) {
return null;
}
const toggle = (cat)=>{
if (cat === 'necessary') {
return;
}
setChoices((prev)=>({
...prev,
[cat]: !prev[cat]
}));
};
const rootClass = classNames?.root ?? defaults.root;
const primaryClass = classNames?.primaryButton ?? defaults.primaryButton;
const secondaryClass = classNames?.secondaryButton ?? defaults.secondaryButton;
return /*#__PURE__*/ _jsx("div", {
"aria-label": "Cookie consent",
className: rootClass,
"data-ipal": "banner",
role: "dialog",
children: /*#__PURE__*/ _jsx("div", {
className: "mx-auto max-w-4xl",
children: !showSettings ? /*#__PURE__*/ _jsxs("div", {
className: "flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between",
children: [
/*#__PURE__*/ _jsxs("div", {
className: "flex items-start gap-3",
children: [
/*#__PURE__*/ _jsx(Cookie, {
"aria-hidden": "true",
className: `h-6 w-6 shrink-0 ${accent}`
}),
/*#__PURE__*/ _jsxs("p", {
className: `text-justify text-sm ${bodyText}`,
children: [
texts.message,
texts.privacyLink && /*#__PURE__*/ _jsxs(_Fragment, {
children: [
' ',
/*#__PURE__*/ _jsx("a", {
className: `${accent} underline hover:no-underline`,
href: texts.privacyLink.href,
children: texts.privacyLink.label
})
]
})
]
})
]
}),
/*#__PURE__*/ _jsxs("div", {
className: "flex shrink-0 gap-2",
children: [
/*#__PURE__*/ _jsx("button", {
className: secondaryClass,
onClick: ()=>setShowSettings(true),
children: texts.buttons.settings
}),
/*#__PURE__*/ _jsx("button", {
className: secondaryClass,
onClick: rejectAll,
children: texts.buttons.reject
}),
/*#__PURE__*/ _jsx("button", {
className: primaryClass,
onClick: acceptAll,
children: texts.buttons.acceptAll
})
]
})
]
}) : /*#__PURE__*/ _jsxs("div", {
className: "flex flex-col gap-4",
children: [
/*#__PURE__*/ _jsxs("h2", {
className: `flex items-center gap-2 text-base font-semibold ${strongText}`,
children: [
/*#__PURE__*/ _jsx(Cookie, {
"aria-hidden": "true",
className: `h-5 w-5 ${accent}`
}),
texts.settingsTitle
]
}),
/*#__PURE__*/ _jsx("ul", {
className: "flex flex-col gap-3",
children: CONSENT_CATEGORIES.map((cat)=>/*#__PURE__*/ _jsxs("li", {
className: "flex items-start justify-between gap-4",
children: [
/*#__PURE__*/ _jsxs("div", {
children: [
/*#__PURE__*/ _jsx("p", {
className: `text-sm font-medium ${strongText}`,
children: texts.categories[cat].title
}),
/*#__PURE__*/ _jsx("p", {
className: `text-xs ${mutedText}`,
children: texts.categories[cat].description
})
]
}),
/*#__PURE__*/ _jsx("input", {
checked: cat === 'necessary' ? true : choices[cat],
className: "mt-1 h-4 w-4 shrink-0 accent-[var(--ipal-primary,#2563eb)]",
disabled: cat === 'necessary',
onChange: ()=>toggle(cat),
type: "checkbox"
})
]
}, cat))
}),
/*#__PURE__*/ _jsxs("div", {
className: "flex justify-end gap-2",
children: [
/*#__PURE__*/ _jsx("button", {
className: secondaryClass,
onClick: ()=>setShowSettings(false),
children: texts.buttons.back
}),
/*#__PURE__*/ _jsx("button", {
className: primaryClass,
onClick: ()=>savePreferences(choices),
children: texts.buttons.save
})
]
})
]
})
})
});
}
//# sourceMappingURL=CookieBanner.js.map
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
/**
* Floating "cookie settings" button — lets visitors reopen the consent panel
* after deciding (GDPR: withdrawing consent must be as easy as giving it).
* Hidden while the banner is showing.
*
* Colours follow the same CSS custom properties as CookieBanner
* (--ipal-surface, --ipal-border, --ipal-hover, --ipal-text), so a client's
* palette applies to both without touching either component. Pass className to
* replace the styling outright.
*/
export declare function CookieButton({ className }?: {
className?: string;
}): import("react/jsx-runtime").JSX.Element | null;
+32
View File
@@ -0,0 +1,32 @@
'use client';
import { jsx as _jsx } from "react/jsx-runtime";
import { Cookie } from 'lucide-react';
import { useConsentContext } from './ConsentContext.js';
/**
* Floating "cookie settings" button — lets visitors reopen the consent panel
* after deciding (GDPR: withdrawing consent must be as easy as giving it).
* Hidden while the banner is showing.
*
* Colours follow the same CSS custom properties as CookieBanner
* (--ipal-surface, --ipal-border, --ipal-hover, --ipal-text), so a client's
* palette applies to both without touching either component. Pass className to
* replace the styling outright.
*/ export function CookieButton({ className } = {}) {
const { decided, reopen } = useConsentContext();
if (!decided) {
return null;
}
const cls = className ?? 'fixed bottom-4 left-4 z-40 flex h-11 w-11 items-center justify-center rounded-full border shadow-md transition-colors ' + 'bg-[var(--ipal-surface,#fff)] border-[var(--ipal-border,#e5e5e5)] hover:bg-[var(--ipal-hover,#f5f5f5)] ' + 'dark:bg-[var(--ipal-surface-dark,#171717)] dark:border-[var(--ipal-border-dark,#262626)] dark:hover:bg-[var(--ipal-hover-dark,#262626)]';
return /*#__PURE__*/ _jsx("button", {
"aria-label": "Cookie settings",
className: cls,
"data-ipal": "cookie-button",
onClick: reopen,
children: /*#__PURE__*/ _jsx(Cookie, {
"aria-hidden": "true",
className: "h-5 w-5 text-[var(--ipal-text,#525252)] dark:text-[var(--ipal-text-dark,#d4d4d4)]"
})
});
}
//# sourceMappingURL=CookieButton.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/consent/CookieButton.tsx"],"sourcesContent":["'use client'\nimport { Cookie } from 'lucide-react'\n\nimport { useConsentContext } from './ConsentContext.js'\n\n/**\n * Floating \"cookie settings\" button — lets visitors reopen the consent panel\n * after deciding (GDPR: withdrawing consent must be as easy as giving it).\n * Hidden while the banner is showing.\n *\n * Colours follow the same CSS custom properties as CookieBanner\n * (--ipal-surface, --ipal-border, --ipal-hover, --ipal-text), so a client's\n * palette applies to both without touching either component. Pass className to\n * replace the styling outright.\n */\nexport function CookieButton({ className }: { className?: string } = {}) {\n const { decided, reopen } = useConsentContext()\n\n if (!decided) {return null}\n\n const cls =\n className ??\n 'fixed bottom-4 left-4 z-40 flex h-11 w-11 items-center justify-center rounded-full border shadow-md transition-colors ' +\n 'bg-[var(--ipal-surface,#fff)] border-[var(--ipal-border,#e5e5e5)] hover:bg-[var(--ipal-hover,#f5f5f5)] ' +\n 'dark:bg-[var(--ipal-surface-dark,#171717)] dark:border-[var(--ipal-border-dark,#262626)] dark:hover:bg-[var(--ipal-hover-dark,#262626)]'\n\n return (\n <button aria-label=\"Cookie settings\" className={cls} data-ipal=\"cookie-button\" onClick={reopen}>\n <Cookie\n aria-hidden=\"true\"\n className=\"h-5 w-5 text-[var(--ipal-text,#525252)] dark:text-[var(--ipal-text-dark,#d4d4d4)]\"\n />\n </button>\n )\n}\n"],"names":["Cookie","useConsentContext","CookieButton","className","decided","reopen","cls","button","aria-label","data-ipal","onClick","aria-hidden"],"mappings":"AAAA;;AACA,SAASA,MAAM,QAAQ,eAAc;AAErC,SAASC,iBAAiB,QAAQ,sBAAqB;AAEvD;;;;;;;;;CASC,GACD,OAAO,SAASC,aAAa,EAAEC,SAAS,EAA0B,GAAG,CAAC,CAAC;IACrE,MAAM,EAAEC,OAAO,EAAEC,MAAM,EAAE,GAAGJ;IAE5B,IAAI,CAACG,SAAS;QAAC,OAAO;IAAI;IAE1B,MAAME,MACJH,aACA,2HACE,4GACA;IAEJ,qBACE,KAACI;QAAOC,cAAW;QAAkBL,WAAWG;QAAKG,aAAU;QAAgBC,SAASL;kBACtF,cAAA,KAACL;YACCW,eAAY;YACZR,WAAU;;;AAIlB"}
+10
View File
@@ -0,0 +1,10 @@
/**
* Cookie consent categories (GDPR). 'necessary' is always granted and cannot be
* disabled. The rest default to false until the user opts in.
*/
export declare const CONSENT_CATEGORIES: readonly ["necessary", "functional", "analytics", "marketing"];
export type ConsentCategory = (typeof CONSENT_CATEGORIES)[number];
export type ConsentState = Record<ConsentCategory, boolean>;
export declare const DEFAULT_CONSENT: ConsentState;
export declare const ACCEPT_ALL_CONSENT: ConsentState;
export declare const REJECT_ALL_CONSENT: ConsentState;
+26
View File
@@ -0,0 +1,26 @@
/**
* Cookie consent categories (GDPR). 'necessary' is always granted and cannot be
* disabled. The rest default to false until the user opts in.
*/ export const CONSENT_CATEGORIES = [
'necessary',
'functional',
'analytics',
'marketing'
];
export const DEFAULT_CONSENT = {
necessary: true,
functional: false,
analytics: false,
marketing: false
};
export const ACCEPT_ALL_CONSENT = {
necessary: true,
functional: true,
analytics: true,
marketing: true
};
export const REJECT_ALL_CONSENT = {
...DEFAULT_CONSENT
};
//# sourceMappingURL=categories.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/consent/categories.ts"],"sourcesContent":["/**\n * Cookie consent categories (GDPR). 'necessary' is always granted and cannot be\n * disabled. The rest default to false until the user opts in.\n */\nexport const CONSENT_CATEGORIES = ['necessary', 'functional', 'analytics', 'marketing'] as const\n\nexport type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]\n\nexport type ConsentState = Record<ConsentCategory, boolean>\n\nexport const DEFAULT_CONSENT: ConsentState = {\n necessary: true,\n functional: false,\n analytics: false,\n marketing: false,\n}\n\nexport const ACCEPT_ALL_CONSENT: ConsentState = {\n necessary: true,\n functional: true,\n analytics: true,\n marketing: true,\n}\n\nexport const REJECT_ALL_CONSENT: ConsentState = { ...DEFAULT_CONSENT }\n"],"names":["CONSENT_CATEGORIES","DEFAULT_CONSENT","necessary","functional","analytics","marketing","ACCEPT_ALL_CONSENT","REJECT_ALL_CONSENT"],"mappings":"AAAA;;;CAGC,GACD,OAAO,MAAMA,qBAAqB;IAAC;IAAa;IAAc;IAAa;CAAY,CAAS;AAMhG,OAAO,MAAMC,kBAAgC;IAC3CC,WAAW;IACXC,YAAY;IACZC,WAAW;IACXC,WAAW;AACb,EAAC;AAED,OAAO,MAAMC,qBAAmC;IAC9CJ,WAAW;IACXC,YAAY;IACZC,WAAW;IACXC,WAAW;AACb,EAAC;AAED,OAAO,MAAME,qBAAmC;IAAE,GAAGN,eAAe;AAAC,EAAC"}
+5
View File
@@ -0,0 +1,5 @@
export { ConsentProvider, useConsentContext } from './ConsentContext.js';
export { CookieBanner } from './CookieBanner.js';
export type { CookieBannerClassNames } from './CookieBanner.js';
export { CookieButton } from './CookieButton.js';
export { useConsent } from './useConsent.js';
+8
View File
@@ -0,0 +1,8 @@
'use client';
export { ConsentProvider, useConsentContext } from './ConsentContext.js';
export { CookieBanner } from './CookieBanner.js';
export { CookieButton } from './CookieButton.js';
// Client-only consent exports — hook, provider, and UI components.
export { useConsent } from './useConsent.js';
//# sourceMappingURL=client.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/consent/client.ts"],"sourcesContent":["'use client'\nexport { ConsentProvider, useConsentContext } from './ConsentContext.js'\nexport { CookieBanner } from './CookieBanner.js'\nexport type { CookieBannerClassNames } from './CookieBanner.js'\nexport { CookieButton } from './CookieButton.js'\n// Client-only consent exports — hook, provider, and UI components.\nexport { useConsent } from './useConsent.js'\n"],"names":["ConsentProvider","useConsentContext","CookieBanner","CookieButton","useConsent"],"mappings":"AAAA;AACA,SAASA,eAAe,EAAEC,iBAAiB,QAAQ,sBAAqB;AACxE,SAASC,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,YAAY,QAAQ,oBAAmB;AAChD,mEAAmE;AACnE,SAASC,UAAU,QAAQ,kBAAiB"}
+25
View File
@@ -0,0 +1,25 @@
import type { BasePayload } from 'payload';
import type { I18nConfig } from '../i18n/index.js';
import type { ConsentTexts } from './texts.js';
type GetConsentTextsArgs = {
config: I18nConfig;
locale?: string;
payload: BasePayload;
/**
* Privacy-policy label + the system page (from SiteSettings.privacyPolicy,
* read with locale:'all') to link to. Optional — omit for no link.
*/
privacyPolicy?: {
label: string;
page: {
slug?: null | Record<string, unknown>;
} | null;
};
};
/**
* Resolves consent banner texts from the CookieSettings global, falling back to
* English defaults per field. The privacy-policy link is built from the pages
* module (system page role), not stored in CookieSettings — one source of truth.
*/
export declare function getConsentTexts({ config, locale, payload, privacyPolicy, }: GetConsentTextsArgs): Promise<ConsentTexts>;
export {};
+79
View File
@@ -0,0 +1,79 @@
import { getSystemPagePath } from '../pages/index.js';
import { getGlobal } from '../payload/index.js';
import { CONSENT_CATEGORIES } from './categories.js';
/** English fallback used when the global has no value for a field. */ const FALLBACK = {
buttons: {
acceptAll: 'Accept all',
back: 'Back',
reject: 'Reject',
save: 'Save preferences',
settings: 'Settings'
},
categories: {
analytics: {
description: 'Help us understand usage.',
title: 'Analytics'
},
functional: {
description: 'Remember preferences.',
title: 'Functional'
},
marketing: {
description: 'Used to show relevant ads.',
title: 'Marketing'
},
necessary: {
description: 'Required for the site to work, including your language and theme preferences. Always on.',
title: 'Necessary'
}
},
message: 'We use cookies to run the site, analyze traffic, and improve your experience. You can accept all, reject non-essential, or choose which to allow.',
privacyLink: null,
settingsTitle: 'Cookie settings'
};
/**
* Resolves consent banner texts from the CookieSettings global, falling back to
* English defaults per field. The privacy-policy link is built from the pages
* module (system page role), not stored in CookieSettings — one source of truth.
*/ export async function getConsentTexts({ config, locale, payload, privacyPolicy }) {
const g = await getGlobal(payload, 'cookie-settings', {
locale
});
const categories = {};
for (const cat of CONSENT_CATEGORIES){
const entry = g.categories?.find((c)=>c.key === cat);
categories[cat] = {
description: entry?.description || FALLBACK.categories[cat].description,
title: entry?.title || FALLBACK.categories[cat].title
};
}
let privacyLink = null;
if (privacyPolicy?.page && locale) {
const href = getSystemPagePath({
config,
locale,
page: privacyPolicy.page
});
if (href) {
privacyLink = {
href,
label: privacyPolicy.label
};
}
}
return {
buttons: {
acceptAll: g.buttons?.acceptAll || FALLBACK.buttons.acceptAll,
back: g.buttons?.back || FALLBACK.buttons.back,
reject: g.buttons?.reject || FALLBACK.buttons.reject,
save: g.buttons?.save || FALLBACK.buttons.save,
settings: g.buttons?.settings || FALLBACK.buttons.settings
},
categories,
message: g.message || FALLBACK.message,
privacyLink,
settingsTitle: g.settingsTitle || FALLBACK.settingsTitle
};
}
//# sourceMappingURL=getConsentTexts.js.map
File diff suppressed because one or more lines are too long
+23
View File
@@ -0,0 +1,23 @@
import type { ConsentState } from './categories.js';
declare global {
interface Window {
dataLayer?: unknown[];
}
}
/**
* Mirrors Google's canonical snippet: `function gtag(){dataLayer.push(arguments)}`.
*
* Pushing `arguments` rather than a plain array is not a stylistic detail.
* Google's tags recognise a consent command by the Arguments object it arrives
* in; a real array lands in the dataLayer as ordinary data and the command is
* silently ignored — the tag loads, but consent never updates and analytics
* stays denied. The rest parameter exists only to type the call sites.
*
* Exported for the analytics module (which needs `js`/`config` commands) —
* intentionally not re-exported from the package's public API.
*/
export declare function gtag(..._args: unknown[]): void;
/** Sets the default consent state (call before Google tags load). */
export declare function setDefaultConsent(state: ConsentState): void;
/** Updates consent after the user decides. */
export declare function updateConsent(state: ConsentState): void;
+42
View File
@@ -0,0 +1,42 @@
function toSignals(state) {
const g = (b)=>b ? 'granted' : 'denied';
return {
ad_personalization: g(state.marketing),
ad_storage: g(state.marketing),
ad_user_data: g(state.marketing),
analytics_storage: g(state.analytics),
functionality_storage: g(state.functional),
personalization_storage: g(state.functional),
security_storage: 'granted'
};
}
/**
* Mirrors Google's canonical snippet: `function gtag(){dataLayer.push(arguments)}`.
*
* Pushing `arguments` rather than a plain array is not a stylistic detail.
* Google's tags recognise a consent command by the Arguments object it arrives
* in; a real array lands in the dataLayer as ordinary data and the command is
* silently ignored — the tag loads, but consent never updates and analytics
* stays denied. The rest parameter exists only to type the call sites.
*
* Exported for the analytics module (which needs `js`/`config` commands) —
* intentionally not re-exported from the package's public API.
*/ export function gtag(..._args) {
if (typeof window === 'undefined') {
return;
}
window.dataLayer = window.dataLayer || [];
// eslint-disable-next-line prefer-rest-params
window.dataLayer.push(arguments);
}
/** Sets the default consent state (call before Google tags load). */ export function setDefaultConsent(state) {
gtag('consent', 'default', {
...toSignals(state),
wait_for_update: 500
});
}
/** Updates consent after the user decides. */ export function updateConsent(state) {
gtag('consent', 'update', toSignals(state));
}
//# sourceMappingURL=googleConsent.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/consent/googleConsent.ts"],"sourcesContent":["import type { ConsentState } from './categories.js'\n\ntype ConsentValue = 'denied' | 'granted'\ntype GoogleConsentSignals = Record<string, ConsentValue>\n\nfunction toSignals(state: ConsentState): GoogleConsentSignals {\n const g = (b: boolean): ConsentValue => (b ? 'granted' : 'denied')\n return {\n ad_personalization: g(state.marketing),\n ad_storage: g(state.marketing),\n ad_user_data: g(state.marketing),\n analytics_storage: g(state.analytics),\n functionality_storage: g(state.functional),\n personalization_storage: g(state.functional),\n security_storage: 'granted', // necessary — always on\n }\n}\n\ndeclare global {\n interface Window {\n dataLayer?: unknown[]\n }\n}\n\n/**\n * Mirrors Google's canonical snippet: `function gtag(){dataLayer.push(arguments)}`.\n *\n * Pushing `arguments` rather than a plain array is not a stylistic detail.\n * Google's tags recognise a consent command by the Arguments object it arrives\n * in; a real array lands in the dataLayer as ordinary data and the command is\n * silently ignored — the tag loads, but consent never updates and analytics\n * stays denied. The rest parameter exists only to type the call sites.\n *\n * Exported for the analytics module (which needs `js`/`config` commands) —\n * intentionally not re-exported from the package's public API.\n */\nexport function gtag(..._args: unknown[]) {\n if (typeof window === 'undefined') {return}\n window.dataLayer = window.dataLayer || []\n // eslint-disable-next-line prefer-rest-params\n window.dataLayer.push(arguments)\n}\n\n/** Sets the default consent state (call before Google tags load). */\nexport function setDefaultConsent(state: ConsentState) {\n gtag('consent', 'default', {\n ...toSignals(state),\n wait_for_update: 500, // ms to wait for an update before firing\n })\n}\n\n/** Updates consent after the user decides. */\nexport function updateConsent(state: ConsentState) {\n gtag('consent', 'update', toSignals(state))\n}\n"],"names":["toSignals","state","g","b","ad_personalization","marketing","ad_storage","ad_user_data","analytics_storage","analytics","functionality_storage","functional","personalization_storage","security_storage","gtag","_args","window","dataLayer","push","arguments","setDefaultConsent","wait_for_update","updateConsent"],"mappings":"AAKA,SAASA,UAAUC,KAAmB;IACpC,MAAMC,IAAI,CAACC,IAA8BA,IAAI,YAAY;IACzD,OAAO;QACLC,oBAAoBF,EAAED,MAAMI,SAAS;QACrCC,YAAYJ,EAAED,MAAMI,SAAS;QAC7BE,cAAcL,EAAED,MAAMI,SAAS;QAC/BG,mBAAmBN,EAAED,MAAMQ,SAAS;QACpCC,uBAAuBR,EAAED,MAAMU,UAAU;QACzCC,yBAAyBV,EAAED,MAAMU,UAAU;QAC3CE,kBAAkB;IACpB;AACF;AAQA;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,KAAK,GAAGC,KAAgB;IACtC,IAAI,OAAOC,WAAW,aAAa;QAAC;IAAM;IAC1CA,OAAOC,SAAS,GAAGD,OAAOC,SAAS,IAAI,EAAE;IACzC,8CAA8C;IAC9CD,OAAOC,SAAS,CAACC,IAAI,CAACC;AACxB;AAEA,mEAAmE,GACnE,OAAO,SAASC,kBAAkBnB,KAAmB;IACnDa,KAAK,WAAW,WAAW;QACzB,GAAGd,UAAUC,MAAM;QACnBoB,iBAAiB;IACnB;AACF;AAEA,4CAA4C,GAC5C,OAAO,SAASC,cAAcrB,KAAmB;IAC/Ca,KAAK,WAAW,UAAUd,UAAUC;AACtC"}
+6
View File
@@ -0,0 +1,6 @@
export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, DEFAULT_CONSENT, REJECT_ALL_CONSENT, } from './categories.js';
export type { ConsentCategory, ConsentState } from './categories.js';
export { getConsentTexts } from './getConsentTexts.js';
export { setDefaultConsent, updateConsent } from './googleConsent.js';
export { CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, parseConsent, serializeConsent, } from './storage.js';
export type { ConsentTexts } from './texts.js';
+9
View File
@@ -0,0 +1,9 @@
// Server-safe exports (logic, types, helper). Client components (banner,
// provider, hook) are exported separately via ./client to keep the RSC/client
// boundary clean.
export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, DEFAULT_CONSENT, REJECT_ALL_CONSENT } from './categories.js';
export { getConsentTexts } from './getConsentTexts.js';
export { setDefaultConsent, updateConsent } from './googleConsent.js';
export { CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, parseConsent, serializeConsent } from './storage.js';
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/consent/index.ts"],"sourcesContent":["// Server-safe exports (logic, types, helper). Client components (banner,\n// provider, hook) are exported separately via ./client to keep the RSC/client\n// boundary clean.\nexport {\n ACCEPT_ALL_CONSENT,\n CONSENT_CATEGORIES,\n DEFAULT_CONSENT,\n REJECT_ALL_CONSENT,\n} from './categories.js'\nexport type { ConsentCategory, ConsentState } from './categories.js'\nexport { getConsentTexts } from './getConsentTexts.js'\nexport { setDefaultConsent, updateConsent } from './googleConsent.js'\nexport {\n CONSENT_COOKIE,\n CONSENT_MAX_AGE,\n CONSENT_VERSION,\n parseConsent,\n serializeConsent,\n} from './storage.js'\nexport type { ConsentTexts } from './texts.js'\n"],"names":["ACCEPT_ALL_CONSENT","CONSENT_CATEGORIES","DEFAULT_CONSENT","REJECT_ALL_CONSENT","getConsentTexts","setDefaultConsent","updateConsent","CONSENT_COOKIE","CONSENT_MAX_AGE","CONSENT_VERSION","parseConsent","serializeConsent"],"mappings":"AAAA,yEAAyE;AACzE,8EAA8E;AAC9E,kBAAkB;AAClB,SACEA,kBAAkB,EAClBC,kBAAkB,EAClBC,eAAe,EACfC,kBAAkB,QACb,kBAAiB;AAExB,SAASC,eAAe,QAAQ,uBAAsB;AACtD,SAASC,iBAAiB,EAAEC,aAAa,QAAQ,qBAAoB;AACrE,SACEC,cAAc,EACdC,eAAe,EACfC,eAAe,EACfC,YAAY,EACZC,gBAAgB,QACX,eAAc"}
+11
View File
@@ -0,0 +1,11 @@
import type { ConsentState } from './categories.js';
/**
* Consent persistence in a cookie. Stored with a version so we can invalidate
* old consents when the policy changes, and a timestamp (GDPR: consent must be
* dated). Readable both client-side (banner) and server-side (script gating).
*/
export declare const CONSENT_COOKIE = "cookie-consent";
export declare const CONSENT_VERSION = 1;
export declare const CONSENT_MAX_AGE: number;
export declare function serializeConsent(state: ConsentState): string;
export declare function parseConsent(raw: string | undefined): ConsentState | null;
+42
View File
@@ -0,0 +1,42 @@
import { CONSENT_CATEGORIES, DEFAULT_CONSENT } from './categories.js';
/**
* Consent persistence in a cookie. Stored with a version so we can invalidate
* old consents when the policy changes, and a timestamp (GDPR: consent must be
* dated). Readable both client-side (banner) and server-side (script gating).
*/ export const CONSENT_COOKIE = 'cookie-consent';
export const CONSENT_VERSION = 1;
export const CONSENT_MAX_AGE = 60 * 60 * 24 * 180 // 180 days
;
export function serializeConsent(state) {
const payload = {
state,
timestamp: new Date().toISOString(),
version: CONSENT_VERSION
};
return encodeURIComponent(JSON.stringify(payload));
}
export function parseConsent(raw) {
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(decodeURIComponent(raw));
if (parsed.version !== CONSENT_VERSION) {
return null;
}
const state = {
...DEFAULT_CONSENT
};
for (const cat of CONSENT_CATEGORIES){
if (typeof parsed.state?.[cat] === 'boolean') {
state[cat] = parsed.state[cat];
}
}
state.necessary = true;
return state;
} catch {
return null;
}
}
//# sourceMappingURL=storage.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/consent/storage.ts"],"sourcesContent":["import type { ConsentState } from './categories.js'\n\nimport { CONSENT_CATEGORIES, DEFAULT_CONSENT } from './categories.js'\n\n/**\n * Consent persistence in a cookie. Stored with a version so we can invalidate\n * old consents when the policy changes, and a timestamp (GDPR: consent must be\n * dated). Readable both client-side (banner) and server-side (script gating).\n */\nexport const CONSENT_COOKIE = 'cookie-consent'\nexport const CONSENT_VERSION = 1\nexport const CONSENT_MAX_AGE = 60 * 60 * 24 * 180 // 180 days\n\ntype StoredConsent = {\n state: ConsentState\n timestamp: string\n version: number\n}\n\nexport function serializeConsent(state: ConsentState): string {\n const payload: StoredConsent = {\n state,\n timestamp: new Date().toISOString(),\n version: CONSENT_VERSION,\n }\n return encodeURIComponent(JSON.stringify(payload))\n}\n\nexport function parseConsent(raw: string | undefined): ConsentState | null {\n if (!raw) {return null}\n try {\n const parsed = JSON.parse(decodeURIComponent(raw)) as StoredConsent\n if (parsed.version !== CONSENT_VERSION) {return null}\n const state = { ...DEFAULT_CONSENT }\n for (const cat of CONSENT_CATEGORIES) {\n if (typeof parsed.state?.[cat] === 'boolean') {state[cat] = parsed.state[cat]}\n }\n state.necessary = true\n return state\n } catch {\n return null\n }\n}\n"],"names":["CONSENT_CATEGORIES","DEFAULT_CONSENT","CONSENT_COOKIE","CONSENT_VERSION","CONSENT_MAX_AGE","serializeConsent","state","payload","timestamp","Date","toISOString","version","encodeURIComponent","JSON","stringify","parseConsent","raw","parsed","parse","decodeURIComponent","cat","necessary"],"mappings":"AAEA,SAASA,kBAAkB,EAAEC,eAAe,QAAQ,kBAAiB;AAErE;;;;CAIC,GACD,OAAO,MAAMC,iBAAiB,iBAAgB;AAC9C,OAAO,MAAMC,kBAAkB,EAAC;AAChC,OAAO,MAAMC,kBAAkB,KAAK,KAAK,KAAK,IAAI,WAAW;CAAZ;AAQjD,OAAO,SAASC,iBAAiBC,KAAmB;IAClD,MAAMC,UAAyB;QAC7BD;QACAE,WAAW,IAAIC,OAAOC,WAAW;QACjCC,SAASR;IACX;IACA,OAAOS,mBAAmBC,KAAKC,SAAS,CAACP;AAC3C;AAEA,OAAO,SAASQ,aAAaC,GAAuB;IAClD,IAAI,CAACA,KAAK;QAAC,OAAO;IAAI;IACtB,IAAI;QACF,MAAMC,SAASJ,KAAKK,KAAK,CAACC,mBAAmBH;QAC7C,IAAIC,OAAON,OAAO,KAAKR,iBAAiB;YAAC,OAAO;QAAI;QACpD,MAAMG,QAAQ;YAAE,GAAGL,eAAe;QAAC;QACnC,KAAK,MAAMmB,OAAOpB,mBAAoB;YACpC,IAAI,OAAOiB,OAAOX,KAAK,EAAE,CAACc,IAAI,KAAK,WAAW;gBAACd,KAAK,CAACc,IAAI,GAAGH,OAAOX,KAAK,CAACc,IAAI;YAAA;QAC/E;QACAd,MAAMe,SAAS,GAAG;QAClB,OAAOf;IACT,EAAE,OAAM;QACN,OAAO;IACT;AACF"}
+21
View File
@@ -0,0 +1,21 @@
import type { ConsentCategory } from './categories.js';
export type ConsentTexts = {
buttons: {
acceptAll: string;
back: string;
reject: string;
save: string;
settings: string;
};
categories: Record<ConsentCategory, {
description: string;
title: string;
}>;
message: string;
/** null = no privacy-policy link shown */
privacyLink: {
href: string;
label: string;
} | null;
settingsTitle: string;
};
+3
View File
@@ -0,0 +1,3 @@
export { };
//# sourceMappingURL=texts.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/consent/texts.ts"],"sourcesContent":["import type { ConsentCategory } from './categories.js'\n\nexport type ConsentTexts = {\n buttons: {\n acceptAll: string\n back: string\n reject: string\n save: string\n settings: string\n }\n categories: Record<ConsentCategory, { description: string; title: string }>\n message: string\n /** null = no privacy-policy link shown */\n privacyLink: { href: string; label: string } | null\n settingsTitle: string\n}\n"],"names":[],"mappings":"AAEA,WAaC"}
+9
View File
@@ -0,0 +1,9 @@
import type { ConsentCategory, ConsentState } from './categories.js';
export declare function useConsent(): {
acceptAll: () => void;
decided: boolean;
rejectAll: () => void;
reopen: () => void;
savePreferences: (choices: Partial<Record<ConsentCategory, boolean>>) => void;
state: ConsentState;
};
+51
View File
@@ -0,0 +1,51 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { ACCEPT_ALL_CONSENT, DEFAULT_CONSENT, REJECT_ALL_CONSENT } from './categories.js';
import { updateConsent } from './googleConsent.js';
import { CONSENT_COOKIE, CONSENT_MAX_AGE, parseConsent, serializeConsent } from './storage.js';
export function useConsent() {
const [state, setState] = useState(DEFAULT_CONSENT);
const [decided, setDecided] = useState(false);
useEffect(()=>{
const raw = document.cookie.split('; ').find((c)=>c.startsWith(`${CONSENT_COOKIE}=`))?.split('=')[1];
const parsed = parseConsent(raw);
if (parsed) {
setState(parsed);
setDecided(true);
}
}, []);
const persist = useCallback((next)=>{
document.cookie = `${CONSENT_COOKIE}=${serializeConsent(next)};path=/;max-age=${CONSENT_MAX_AGE};samesite=lax`;
setState(next);
setDecided(true);
// Tell Google right away. Tags are already on the page holding whatever
// defaults Analytics set at load; without this update they keep them for
// the rest of the session and never write their cookies — the banner would
// look like it worked while nothing changed.
updateConsent(next);
}, []);
const acceptAll = useCallback(()=>persist(ACCEPT_ALL_CONSENT), [
persist
]);
const rejectAll = useCallback(()=>persist(REJECT_ALL_CONSENT), [
persist
]);
const savePreferences = useCallback((choices)=>persist({
...DEFAULT_CONSENT,
...choices,
necessary: true
}), [
persist
]);
const reopen = useCallback(()=>setDecided(false), []);
return {
acceptAll,
decided,
rejectAll,
reopen,
savePreferences,
state
};
}
//# sourceMappingURL=useConsent.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/consent/useConsent.ts"],"sourcesContent":["'use client'\nimport { useCallback, useEffect, useState } from 'react'\n\nimport type { ConsentCategory, ConsentState } from './categories.js'\n\nimport { ACCEPT_ALL_CONSENT, DEFAULT_CONSENT, REJECT_ALL_CONSENT } from './categories.js'\nimport { updateConsent } from './googleConsent.js'\nimport { CONSENT_COOKIE, CONSENT_MAX_AGE, parseConsent, serializeConsent } from './storage.js'\n\nexport function useConsent() {\n const [state, setState] = useState<ConsentState>(DEFAULT_CONSENT)\n const [decided, setDecided] = useState(false)\n\n useEffect(() => {\n const raw = document.cookie\n .split('; ')\n .find((c) => c.startsWith(`${CONSENT_COOKIE}=`))\n ?.split('=')[1]\n const parsed = parseConsent(raw)\n if (parsed) {\n setState(parsed)\n setDecided(true)\n }\n }, [])\n\n const persist = useCallback((next: ConsentState) => {\n document.cookie = `${CONSENT_COOKIE}=${serializeConsent(next)};path=/;max-age=${CONSENT_MAX_AGE};samesite=lax`\n setState(next)\n setDecided(true)\n // Tell Google right away. Tags are already on the page holding whatever\n // defaults Analytics set at load; without this update they keep them for\n // the rest of the session and never write their cookies — the banner would\n // look like it worked while nothing changed.\n updateConsent(next)\n }, [])\n\n const acceptAll = useCallback(() => persist(ACCEPT_ALL_CONSENT), [persist])\n const rejectAll = useCallback(() => persist(REJECT_ALL_CONSENT), [persist])\n const savePreferences = useCallback(\n (choices: Partial<Record<ConsentCategory, boolean>>) =>\n persist({ ...DEFAULT_CONSENT, ...choices, necessary: true }),\n [persist],\n )\n const reopen = useCallback(() => setDecided(false), [])\n\n return { acceptAll, decided, rejectAll, reopen, savePreferences, state }\n}\n"],"names":["useCallback","useEffect","useState","ACCEPT_ALL_CONSENT","DEFAULT_CONSENT","REJECT_ALL_CONSENT","updateConsent","CONSENT_COOKIE","CONSENT_MAX_AGE","parseConsent","serializeConsent","useConsent","state","setState","decided","setDecided","raw","document","cookie","split","find","c","startsWith","parsed","persist","next","acceptAll","rejectAll","savePreferences","choices","necessary","reopen"],"mappings":"AAAA;AACA,SAASA,WAAW,EAAEC,SAAS,EAAEC,QAAQ,QAAQ,QAAO;AAIxD,SAASC,kBAAkB,EAAEC,eAAe,EAAEC,kBAAkB,QAAQ,kBAAiB;AACzF,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,cAAc,EAAEC,eAAe,EAAEC,YAAY,EAAEC,gBAAgB,QAAQ,eAAc;AAE9F,OAAO,SAASC;IACd,MAAM,CAACC,OAAOC,SAAS,GAAGX,SAAuBE;IACjD,MAAM,CAACU,SAASC,WAAW,GAAGb,SAAS;IAEvCD,UAAU;QACR,MAAMe,MAAMC,SAASC,MAAM,CACxBC,KAAK,CAAC,MACNC,IAAI,CAAC,CAACC,IAAMA,EAAEC,UAAU,CAAC,GAAGf,eAAe,CAAC,CAAC,IAC5CY,MAAM,IAAI,CAAC,EAAE;QACjB,MAAMI,SAASd,aAAaO;QAC5B,IAAIO,QAAQ;YACVV,SAASU;YACTR,WAAW;QACb;IACF,GAAG,EAAE;IAEL,MAAMS,UAAUxB,YAAY,CAACyB;QAC3BR,SAASC,MAAM,GAAG,GAAGX,eAAe,CAAC,EAAEG,iBAAiBe,MAAM,gBAAgB,EAAEjB,gBAAgB,aAAa,CAAC;QAC9GK,SAASY;QACTV,WAAW;QACX,wEAAwE;QACxE,yEAAyE;QACzE,2EAA2E;QAC3E,6CAA6C;QAC7CT,cAAcmB;IAChB,GAAG,EAAE;IAEL,MAAMC,YAAY1B,YAAY,IAAMwB,QAAQrB,qBAAqB;QAACqB;KAAQ;IAC1E,MAAMG,YAAY3B,YAAY,IAAMwB,QAAQnB,qBAAqB;QAACmB;KAAQ;IAC1E,MAAMI,kBAAkB5B,YACtB,CAAC6B,UACCL,QAAQ;YAAE,GAAGpB,eAAe;YAAE,GAAGyB,OAAO;YAAEC,WAAW;QAAK,IAC5D;QAACN;KAAQ;IAEX,MAAMO,SAAS/B,YAAY,IAAMe,WAAW,QAAQ,EAAE;IAEtD,OAAO;QAAEW;QAAWZ;QAASa;QAAWI;QAAQH;QAAiBhB;IAAM;AACzE"}