ldc-ker-chant/resources/ts/front/lib/cookie.ts
2026-07-13 14:35:54 +02:00

407 lines
13 KiB
TypeScript

import {
CN_EXPIRATION_KEY,
CN_INIT_COOKIE,
CONSENT_DURATION_DAYS,
} from "../Components/cookie/constants";
import type { CookieName, CookieStatusMap } from "../types/cookie";
/* ------------------------------------------------------------------ */
/* Utilitaires cookies */
/* ------------------------------------------------------------------ */
/**
* Read a cookie
* @param name string
* @returns string | null
*/
export const getCookie = (name: string): string | null => {
if (typeof document === "undefined") return null;
const match = document.cookie
.split("; ")
.find((row) => row.startsWith(`${name}=`));
return match ? match.split("=").slice(1).join("=") : null;
};
/**
* Write a cookie
* @param name string
* @param value string
* @param days number
* @returns void
*/
export const setCookie = (name: string, value: string, days: number) => {
if (typeof document === "undefined") return;
const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
document.cookie = `${name}=${value}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`;
};
/**
* Revoke and delete cookie by his name
* @returns void
*/
export const deleteCookie = (name: string) => {
if (typeof document === "undefined") return;
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
};
/**
* Decpde cn-init cookie
* @param raw
* @returns Record<string, string>
*/
export const parseCnInit = (raw: string | null): Record<string, string> => {
if (!raw) return {};
return Object.fromEntries(
raw
.split(",")
.filter(Boolean)
.map((pair) => pair.split("=") as [string, string]),
);
};
/**
* Encode statuses and expiration for cn-init cookie format
* @param services CookieName[]
* @param statuses CookieStatusMap
* @param expiresAt Date
* @returns string
*/
export const serializeCnInit = (
services: CookieName[],
statuses: CookieStatusMap,
expiresAt: Date,
): string =>
[
...services.map((name) => `${name}=${statuses[name]}`),
`${CN_EXPIRATION_KEY}=${String(expiresAt.getTime())}`,
].join(",");
/**
* Persist current statuses in cn-init cookie
* @param services CookieName[]
* @param statuses CookieStatusMap
* @returns void
*/
export const persistCnInit = (
services: CookieName[],
statuses: CookieStatusMap,
) => {
const expiresAt = new Date(
Date.now() + CONSENT_DURATION_DAYS * 24 * 60 * 60 * 1000,
);
setCookie(
CN_INIT_COOKIE,
serializeCnInit(services, statuses, expiresAt),
CONSENT_DURATION_DAYS,
);
};
/**
* If a service is in waiting status
* @param services
* @param statuses
* @returns boolean
*/
export const hasPendingDecision = (
services: CookieName[],
statuses: CookieStatusMap,
): boolean => services.some((name) => statuses[name] === "wait");
/* ------------------------------------------------------------------ */
/* Dynamic loading of scripts */
/* ------------------------------------------------------------------ */
/**
* Inject a <script> into the DOM if not already present (indempotent)
* @param id string
* @param src string
* @param attrs Record<string, string>
* @returns Promise<void>
*/
export const loadScript = (
id: string,
src: string,
attrs: Record<string, string> = {},
): Promise<void> =>
new Promise((resolve, reject) => {
if (document.getElementById(id)) {
resolve(); // déjà chargé
return;
}
const script = document.createElement("script");
script.id = id;
script.src = src;
script.async = true;
Object.entries(attrs).forEach(([key, value]) => {
script.setAttribute(key, value);
});
script.onload = () => {
resolve();
};
script.onerror = () => {
reject(new Error(`Échec du chargement : ${src}`));
};
document.head.appendChild(script);
});
/**
* Delete a <script> from the DOM (don't cancel executed code)
* @param id string
* @returns void
*/
export const unloadScript = (id: string) => {
document.getElementById(id)?.remove();
};
/* ------------------------------------------------------------------ */
/* reCAPTCHA v2 */
/* ------------------------------------------------------------------ */
const RECAPTCHA_V2_SCRIPT_ID = "script-recaptcha-v2";
const RECAPTCHA_CONTAINER_SELECTOR = ".r_captcha";
/**
* Load Recaptcha V2 API, and display widget in every RECAPTCHA_CONTAINER_SELECTOR container of the page, activating the associate form
* @param sitekey string
* @returns Promise<void>
*/
export const renderRecaptchaWidgets = (sitekey: string): Promise<void> =>
loadScript(
RECAPTCHA_V2_SCRIPT_ID,
"https://www.google.com/recaptcha/api.js?render=explicit",
).then(
() =>
new Promise<void>((resolve) => {
window.grecaptcha?.ready(() => {
document
.querySelectorAll<HTMLElement>(RECAPTCHA_CONTAINER_SELECTOR)
.forEach((container) => {
if (container.querySelector(":scope > div")) return; // already displayed
const widget = document.createElement("div");
container.appendChild(widget);
window.grecaptcha?.render(widget, { sitekey });
container.closest("form")?.removeAttribute("inert");
});
resolve();
});
}),
);
/**
* Delete the recaptcha v2 widgets and inert the associate forms
* @param mode string
* @returns void
*/
export const resetRecaptchaWidgets = (mode?: string) => {
document
.querySelectorAll<HTMLElement>(RECAPTCHA_CONTAINER_SELECTOR)
.forEach((container) => {
container.innerHTML = "";
if (mode !== "testing") {
container.closest("form")?.setAttribute("inert", "");
}
});
};
/**
* Notify the app that a consent has changed (not used actually but available if needed)
* @param name CookieName
* @param accepted boolean
* @returns void
*/
export const notifyConsentChange = (name: CookieName, accepted: boolean) => {
window.dispatchEvent(
new CustomEvent("cookie-notice-changed", {
detail: { name, accepted },
}),
);
};
/* ------------------------------------------------------------------ */
/* Service-specific side effects */
/* ------------------------------------------------------------------ */
/**
* Third-party identifiers required by the services (reCAPTCHA key, Matomo URL/ID, GA ID).
* The store is a module outside the React tree and therefore cannot call `usePage()`.
* These values must be read from a component (e.g. `usePage().props.appEnv`)
* and then passed to the store via
* `useCookieNoticeStore.getState().setCredentials(...)`.
*/
export interface CookieServiceCredentials {
recaptchaKey: string | null;
matomoUrl: string | null;
matomoSiteId: string | null;
gaMeasurementId: string | null;
}
export const EMPTY_CREDENTIALS: CookieServiceCredentials = {
recaptchaKey: null,
matomoUrl: null,
matomoSiteId: null,
gaMeasurementId: null,
};
/**
* Creates side-effect handlers using the provided credentials
* @param credentials CookieServiceCredentials
* @param mode string|undefined
* @returns Record<CookieName, (accepted: boolean) => void>
*/
export const createServiceHandlers = (
credentials: CookieServiceCredentials,
mode?: string,
): Record<CookieName, (accepted: boolean) => void> => ({
recaptcha: (accepted) => {
// Invisible reCAPTCHA v3 (RecaptchaForm/useGoogleReCaptcha) is handled by
// ConsentProvider + GoogleReCaptchaProvider, which already mounts/unmounts
// its own script based on the same consent status — we do not duplicate that logic here.
//
// This code only manages explicit reCAPTCHA v2 widgets
// rendered inside `.r_captcha` containers.
if (accepted) {
if (!credentials.recaptchaKey) {
console.error(
"[CookieNotice] recaptchaKey manquant dans les credentials du store — reCAPTCHA non chargé",
);
return;
}
renderRecaptchaWidgets(credentials.recaptchaKey).catch(console.error);
} else {
resetRecaptchaWidgets(mode);
unloadScript("script-recaptcha-v2");
deleteCookie("_GRECAPTCHA");
// The reCAPTCHA badge stays in the DOM and is not removed until the page is reloaded.
document.querySelector(".grecaptcha-badge")?.parentElement?.remove();
delete window.grecaptcha;
}
notifyConsentChange("recaptcha", accepted);
},
matomo: (accepted) => {
if (!accepted) {
// No "requireConsent" is configured for Matomo in the original script
// (cn-cookies.blade.php): declining consent simply removes the script,
// without calling the Matomo consent API.
unloadScript("script-matomo");
notifyConsentChange("matomo", accepted);
return;
}
if (!credentials.matomoUrl || !credentials.matomoSiteId) {
console.error(
"[CookieNotice] matomoUrl/matomoSiteId manquant dans les credentials du store — Matomo non chargé",
);
return;
}
// If the script is already in the DOM, Matomo is already running — do not
// re-push into _paq (would trigger "setTrackerUrl/setSiteId registered more
// than once" warnings caused by applyDecidedStatuses being called multiple times).
// Added 13/07/2026
if (document.getElementById("script-matomo")) {
notifyConsentChange("matomo", accepted);
return;
}
const w = window;
w._paq = w._paq ?? [];
w._paq.push(["trackPageView"]);
w._paq.push(["enableLinkTracking"]);
w._paq.push(["setTrackerUrl", `${credentials.matomoUrl}matomo.php`]);
w._paq.push(["setSiteId", credentials.matomoSiteId]);
loadScript("script-matomo", `${credentials.matomoUrl}matomo.js`).catch(
console.error,
);
notifyConsentChange("matomo", accepted);
},
analytics: (accepted) => {
const w = window;
if (accepted) {
if (!credentials.gaMeasurementId) {
console.error(
"[CookieNotice] gaMeasurementId manquant dans les credentials du store — Google Analytics non chargé",
);
return;
}
w[`ga-disable-${credentials.gaMeasurementId}`] = false;
loadScript(
"script-ga",
`https://www.googletagmanager.com/gtag/js?id=${credentials.gaMeasurementId}`,
)
.then(() => {
w.dataLayer = w.dataLayer ?? [];
const dl = w.dataLayer;
w.gtag = function gtag(...args: unknown[]) {
dl.push(args);
};
w.gtag("js", new Date());
w.gtag("config", credentials.gaMeasurementId);
})
.catch(console.error);
} else if (credentials.gaMeasurementId) {
// Official Google flag that disables tracking, even when the script is loaded.
w[`ga-disable-${credentials.gaMeasurementId}`] = true;
unloadScript("script-ga");
// gtag.js tracker cookies (see cn-cookies.blade.php): _gat_gtag_<id with "-" replaced by "_">
const gatCookie = `_gat_gtag_${credentials.gaMeasurementId.replace(/-/g, "_")}`;
["_ga", "_gid", gatCookie].forEach(deleteCookie);
}
notifyConsentChange("analytics", accepted);
},
// YouTube / Vimeo: no global script is loaded. Consent controls whether
// embedded <iframe> elements are rendered (facade pattern).
youtube: (accepted) => {
notifyConsentChange("youtube", accepted);
},
vimeo: (accepted) => {
notifyConsentChange("vimeo", accepted);
},
});
/**
* Applies side effects (script loading) for already-determined consent states,
* without persisting or reloading — used on mount to re-initialize scripts
* (each page load starts with a fresh <head>, even if the cookie already says "true").
*
* @param services CookieName[]
* @param statuses CookieStatusMap
* @param credentials CookieServiceCredentials
* @param mode string
* @returns void
*/
export const applyDecidedStatuses = (
services: CookieName[],
statuses: CookieStatusMap,
credentials: CookieServiceCredentials,
mode: string,
) => {
const serviceHandlers = createServiceHandlers(credentials, mode);
services.forEach((name) => {
if (statuses[name] === "wait") return;
serviceHandlers[name](statuses[name] === "true");
});
};
/**
* Persists consent and applies side effects; reloads the page when consent is revoked.
* @param services CookieName[]
* @param nextStatuses CookieStatusMap
* @param credentials CookieServiceCredentials
* @param mode string
* @returns void
*/
export const persistAndApply = (
services: CookieName[],
nextStatuses: CookieStatusMap,
credentials: CookieServiceCredentials,
mode?: string,
) => {
persistCnInit(services, nextStatuses);
const serviceHandlers = createServiceHandlers(credentials, mode);
services.forEach((name) => {
if (nextStatuses[name] === "wait") return;
serviceHandlers[name](nextStatuses[name] === "true");
});
};