All files / src/scripts cookie-consent.ts

100% Statements 35/35
100% Branches 18/18
100% Functions 7/7
100% Lines 30/30

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99                                  1x                   1x           21x 21x         8x                   7x         1x     21x         11x 11x       11x 10x     10x 10x 3x 2x 2x 2x 3x     7x 7x 11x 5x   11x 11x 5x 11x       1x     1x 1x    
// Cookie banner + Google Consent Mode v2 (#1086).
//
// GoogleTagManager.astro sets the consent default (all four types denied)
// inline, before GTM, and re-applies a stored "granted" on every page load.
// This module owns the banner: CookieBanner.astro renders it `hidden` (the
// edge cache keys on URL only, so the server cannot know the visitor's
// answer), `syncBanner` shows it until they answer, and a click on Accept or
// Reject pushes the consent update and stores the answer for a year.
//
// No side effects on import: GoogleTagManager.astro imports CONSENT_COOKIE
// during SSR, where `document` does not exist. CookieBanner.astro's bundled
// script calls `initCookieConsent()`.
//
// ponytail: withdrawing consent stops new analytics/ads cookies but does not
// delete `_ga` / `_fbp` already set. Delete them here if the owner or a
// regulator asks.
 
export const CONSENT_COOKIE = "ir_consent";
 
export type ConsentChoice = "granted" | "denied";
 
declare global {
	interface Window {
		gtag?: (...args: unknown[]) => void;
	}
}
 
const CHOICE_RE = new RegExp(
	`(?:^|;\\s*)${CONSENT_COOKIE}=(granted|denied)(?:;|$)`,
);
 
/** The stored answer in a `document.cookie` string, or null if none. */
export function readChoice(cookie: string): ConsentChoice | null {
	const match = CHOICE_RE.exec(cookie);
	return match ? (match[1] as ConsentChoice) : null;
}
 
/** The four Consent Mode v2 types, all set to one choice. */
export function consentState(choice: ConsentChoice): Record<string, string> {
	return {
		ad_storage: choice,
		analytics_storage: choice,
		ad_user_data: choice,
		ad_personalization: choice,
	};
}
 
/** The `document.cookie` assignment that stores a choice for 12 months. */
export function cookieFor(choice: ConsentChoice, secure: boolean): string {
	return `${CONSENT_COOKIE}=${choice}; Max-Age=31536000; Path=/; SameSite=Lax${secure ? "; Secure" : ""}`;
}
 
// The "Cookie settings" control that reopened the banner. Focus goes back to
// it after a choice; on a first visit there is none and focus stays put.
let opener: HTMLElement | null = null;
 
function banner(): HTMLElement | null {
	return document.querySelector<HTMLElement>("[data-cookie-banner]");
}
 
/** Show the banner until the visitor has answered; keep it hidden after. */
export function syncBanner(): void {
	const el = banner();
	if (el) el.hidden = readChoice(document.cookie) !== null;
}
 
export function handleConsentClick(e: MouseEvent): void {
	if (!(e.target instanceof Element)) return;
	const el = banner();
 
	// "Cookie settings" (footer, privacy page) reopens the banner to change the answer.
	const settings = e.target.closest<HTMLElement>("[data-cookie-settings]");
	if (settings) {
		if (!el) return;
		opener = settings;
		el.hidden = false;
		el.querySelector<HTMLButtonElement>("button")?.focus();
		return;
	}
 
	const button = e.target.closest<HTMLElement>("[data-consent-choice]");
	const choice = button?.dataset.consentChoice;
	if (choice !== "granted" && choice !== "denied") return;
	window.gtag?.("consent", "update", consentState(choice));
	// biome-ignore lint/suspicious/noDocumentCookie: the async Cookie Store API is missing in older Safari and Firefox
	document.cookie = cookieFor(choice, location.protocol === "https:");
	if (el) el.hidden = true;
	opener?.focus();
	opener = null;
}
 
export function initCookieConsent(): void {
	syncBanner();
	// One delegated listener survives ClientRouter navigations; after-swap
	// (not page-load) re-syncs the freshly swapped-in banner before paint.
	document.addEventListener("click", handleConsentClick);
	document.addEventListener("astro:after-swap", syncBanner);
}