gate locale behind functional consent

This commit is contained in:
2026-08-12 18:03:11 +02:00
parent 195d4169f5
commit 4bf50514bf
212 changed files with 173 additions and 840 deletions
-3
View File
@@ -1,3 +0,0 @@
export { };
//# sourceMappingURL=ConsentContext.d.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"sources":["ConsentContext.d.ts"],"sourcesContent":["import { type ReactNode } from 'react';\nimport type { ConsentTexts } from './texts.js';\nimport { useConsent } from './useConsent.js';\ntype ConsentContextValue = {\n texts: ConsentTexts;\n} & ReturnType<typeof useConsent>;\nexport declare function ConsentProvider({ children, texts }: {\n children: ReactNode;\n texts: ConsentTexts;\n}): import(\"react/jsx-runtime\").JSX.Element;\nexport declare function useConsentContext(): ConsentContextValue;\nexport {};\n"],"names":[],"mappings":"AAWA,WAAU"}
-6
View File
@@ -1,6 +0,0 @@
/**
* 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 { };
//# sourceMappingURL=CookieBanner.d.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"sources":["CookieBanner.d.ts"],"sourcesContent":["/**\n * Optional class overrides — for when tokens aren't enough, e.g. turning the\n * bottom bar into a centred modal. Replaces the slot's classes outright.\n */\nexport type CookieBannerClassNames = {\n primaryButton?: string;\n root?: string;\n secondaryButton?: string;\n};\nexport declare function CookieBanner({ classNames }?: {\n classNames?: CookieBannerClassNames;\n}): import(\"react/jsx-runtime\").JSX.Element | null;\n"],"names":[],"mappings":"AAAA;;;CAGC,GAMD,WAEmD"}
-12
View File
@@ -1,12 +0,0 @@
/**
* 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 { };
//# sourceMappingURL=CookieButton.d.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"sources":["CookieButton.d.ts"],"sourcesContent":["/**\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 declare function CookieButton({ className }?: {\n className?: string;\n}): import(\"react/jsx-runtime\").JSX.Element | null;\n"],"names":[],"mappings":"AAAA;;;;;;;;;CASC,GACD,WAEmD"}
-6
View File
@@ -1,6 +0,0 @@
/**
* Cookie consent categories (GDPR). 'necessary' is always granted and cannot be
* disabled. The rest default to false until the user opts in.
*/ export { };
//# sourceMappingURL=categories.d.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"sources":["categories.d.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 declare const CONSENT_CATEGORIES: readonly [\"necessary\", \"functional\", \"analytics\", \"marketing\"];\nexport type ConsentCategory = (typeof CONSENT_CATEGORIES)[number];\nexport type ConsentState = Record<ConsentCategory, boolean>;\nexport declare const DEFAULT_CONSENT: ConsentState;\nexport declare const ACCEPT_ALL_CONSENT: ConsentState;\nexport declare const REJECT_ALL_CONSENT: ConsentState;\n"],"names":[],"mappings":"AAAA;;;CAGC,GAMD,WAAsD"}
-6
View File
@@ -1,6 +0,0 @@
export { ConsentProvider, useConsentContext } from './ConsentContext.js';
export { CookieBanner } from './CookieBanner.js';
export { CookieButton } from './CookieButton.js';
export { useConsent } from './useConsent.js';
//# sourceMappingURL=client.d.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"sources":["client.d.ts"],"sourcesContent":["export { ConsentProvider, useConsentContext } from './ConsentContext.js';\nexport { CookieBanner } from './CookieBanner.js';\nexport type { CookieBannerClassNames } from './CookieBanner.js';\nexport { CookieButton } from './CookieButton.js';\nexport { useConsent } from './useConsent.js';\n"],"names":["ConsentProvider","useConsentContext","CookieBanner","CookieButton","useConsent"],"mappings":"AAAA,SAASA,eAAe,EAAEC,iBAAiB,QAAQ,sBAAsB;AACzE,SAASC,YAAY,QAAQ,oBAAoB;AAEjD,SAASC,YAAY,QAAQ,oBAAoB;AACjD,SAASC,UAAU,QAAQ,kBAAkB"}
-3
View File
@@ -1,3 +0,0 @@
export { };
//# sourceMappingURL=getConsentTexts.d.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"sources":["getConsentTexts.d.ts"],"sourcesContent":["import type { BasePayload } from 'payload';\nimport type { I18nConfig } from '../i18n/index.js';\nimport type { ConsentTexts } from './texts.js';\ntype GetConsentTextsArgs = {\n config: I18nConfig;\n locale?: string;\n payload: BasePayload;\n /**\n * Privacy-policy label + the system page (from SiteSettings.privacyPolicy,\n * read with locale:'all') to link to. Optional — omit for no link.\n */\n privacyPolicy?: {\n label: string;\n page: {\n slug?: null | Record<string, unknown>;\n } | null;\n };\n};\n/**\n * Resolves consent banner texts from the CookieSettings global, falling back to\n * English defaults per field. The privacy-policy link is built from the pages\n * module (system page role), not stored in CookieSettings — one source of truth.\n */\nexport declare function getConsentTexts({ config, locale, payload, privacyPolicy, }: GetConsentTextsArgs): Promise<ConsentTexts>;\nexport {};\n"],"names":[],"mappings":"AAwBA,WAAU"}
-3
View File
@@ -1,3 +0,0 @@
/** Updates consent after the user decides. */ export { };
//# sourceMappingURL=googleConsent.d.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"sources":["googleConsent.d.ts"],"sourcesContent":["import type { ConsentState } from './categories.js';\ndeclare global {\n interface Window {\n dataLayer?: unknown[];\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 declare function gtag(..._args: unknown[]): void;\n/** Sets the default consent state (call before Google tags load). */\nexport declare function setDefaultConsent(state: ConsentState): void;\n/** Updates consent after the user decides. */\nexport declare function updateConsent(state: ConsentState): void;\n"],"names":[],"mappings":"AAqBA,4CAA4C,GAC5C,WAAiE"}
-6
View File
@@ -1,6 +0,0 @@
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.d.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"sources":["index.d.ts"],"sourcesContent":["export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, DEFAULT_CONSENT, REJECT_ALL_CONSENT, } from './categories.js';\nexport type { ConsentCategory, ConsentState } from './categories.js';\nexport { getConsentTexts } from './getConsentTexts.js';\nexport { setDefaultConsent, updateConsent } from './googleConsent.js';\nexport { CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, parseConsent, serializeConsent, } 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,SAASA,kBAAkB,EAAEC,kBAAkB,EAAEC,eAAe,EAAEC,kBAAkB,QAAS,kBAAkB;AAE/G,SAASC,eAAe,QAAQ,uBAAuB;AACvD,SAASC,iBAAiB,EAAEC,aAAa,QAAQ,qBAAqB;AACtE,SAASC,cAAc,EAAEC,eAAe,EAAEC,eAAe,EAAEC,YAAY,EAAEC,gBAAgB,QAAS,eAAe"}
-3
View File
@@ -1,3 +0,0 @@
export { };
//# sourceMappingURL=storage.d.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"sources":["storage.d.ts"],"sourcesContent":["import type { ConsentState } from './categories.js';\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 declare const CONSENT_COOKIE = \"cookie-consent\";\nexport declare const CONSENT_VERSION = 1;\nexport declare const CONSENT_MAX_AGE: number;\nexport declare function serializeConsent(state: ConsentState): string;\nexport declare function parseConsent(raw: string | undefined): ConsentState | null;\n"],"names":[],"mappings":"AAUA,WAAmF"}
-3
View File
@@ -1,3 +0,0 @@
export { };
//# sourceMappingURL=texts.d.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"sources":["texts.d.ts"],"sourcesContent":["import type { ConsentCategory } from './categories.js';\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, {\n description: string;\n title: string;\n }>;\n message: string;\n /** null = no privacy-policy link shown */\n privacyLink: {\n href: string;\n label: string;\n } | null;\n settingsTitle: string;\n};\n"],"names":[],"mappings":"AACA,WAmBE"}
-3
View File
@@ -1,3 +0,0 @@
export { };
//# sourceMappingURL=useConsent.d.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"sources":["useConsent.d.ts"],"sourcesContent":["import type { ConsentCategory, ConsentState } from './categories.js';\nexport declare function useConsent(): {\n acceptAll: () => void;\n decided: boolean;\n rejectAll: () => void;\n reopen: () => void;\n savePreferences: (choices: Partial<Record<ConsentCategory, boolean>>) => void;\n state: ConsentState;\n};\n"],"names":[],"mappings":"AACA,WAOE"}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ConsentCategory, ConsentState } from './categories.js';
export declare function useConsent(): {
export declare function useConsent(cookieMap?: Partial<Record<ConsentCategory, string[]>>): {
acceptAll: () => void;
decided: boolean;
rejectAll: () => void;
+31 -2
View File
@@ -1,9 +1,22 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { LOCALE_COOKIE_NAME } from '../i18n/index.js';
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() {
/**
* Cookies to delete when consent for a category is withdrawn. Keyed by
* category. The plugin knows about its own functional cookie (locale); clients
* can extend this via the `cookieMap` arg to useConsent if they set their own.
*/ const DEFAULT_COOKIE_MAP = {
functional: [
LOCALE_COOKIE_NAME
]
};
/** Expire a cookie now, across the whole site. */ function deleteCookie(name) {
document.cookie = `${name}=;path=/;max-age=0;samesite=lax`;
}
export function useConsent(cookieMap = DEFAULT_COOKIE_MAP) {
const [state, setState] = useState(DEFAULT_CONSENT);
const [decided, setDecided] = useState(false);
useEffect(()=>{
@@ -15,6 +28,19 @@ export function useConsent() {
}
}, []);
const persist = useCallback((next)=>{
// Withdrawal cleanup: for any category flipping true → false, delete the
// cookies tied to it. GDPR: withdrawing consent must stop the processing,
// so a functional cookie (e.g. locale) written under consent has to go when
// that consent is revoked. Uses the current `state` as the previous value.
for (const cat of Object.keys(cookieMap)){
const wasGranted = state[cat] === true;
const nowDenied = next[cat] !== true;
if (wasGranted && nowDenied) {
for (const name of cookieMap[cat] ?? []){
deleteCookie(name);
}
}
}
document.cookie = `${CONSENT_COOKIE}=${serializeConsent(next)};path=/;max-age=${CONSENT_MAX_AGE};samesite=lax`;
setState(next);
setDecided(true);
@@ -23,7 +49,10 @@ export function useConsent() {
// the rest of the session and never write their cookies — the banner would
// look like it worked while nothing changed.
updateConsent(next);
}, []);
}, [
state,
cookieMap
]);
const acceptAll = useCallback(()=>persist(ACCEPT_ALL_CONSENT), [
persist
]);
File diff suppressed because one or more lines are too long