407 lines
13 KiB
TypeScript
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");
|
|
});
|
|
};
|