146 lines
6.1 KiB
JavaScript
146 lines
6.1 KiB
JavaScript
|
|
// Analytics consent: the opt-in gate in front of the session-scoped tier.
|
||
|
|
//
|
||
|
|
// WHY THIS EXISTS. The anonymous tier (hourly counts, no identifier, nothing
|
||
|
|
// written to your device) needs no permission. The session tier does: it mints
|
||
|
|
// an ephemeral id and stores it in sessionStorage, and storing an identifier on
|
||
|
|
// someone's device engages ePrivacy Art. 5(3). Product analytics is not
|
||
|
|
// "strictly necessary to provide a service the user explicitly requested", so
|
||
|
|
// the exemption does not apply and the lawful basis is opt-in consent.
|
||
|
|
//
|
||
|
|
// RULES THIS FILE ENFORCES, all of them load-bearing:
|
||
|
|
// * Nothing is stored and no id is minted before an explicit "Allow". Not on
|
||
|
|
// first paint, not "pending a choice" — refusing by ignoring the banner must
|
||
|
|
// leave exactly as little behind as refusing explicitly.
|
||
|
|
// * Allow and No are the same size, same weight, same prominence. No pre-tick,
|
||
|
|
// no "legitimate interest" second layer, no cookie-wall.
|
||
|
|
// * Withdrawal is as easy as granting, reachable at any time from the footer
|
||
|
|
// and the privacy page, and it deletes the live session id immediately.
|
||
|
|
// * GPC / DNT are treated as a refusal already given: no banner is shown and
|
||
|
|
// nothing is stored. Stricter than the letter of the law, and it means the
|
||
|
|
// most privacy-conscious visitors are never nagged.
|
||
|
|
//
|
||
|
|
// The consent RECORD itself lives in localStorage. Storing a user's own choice
|
||
|
|
// is strictly necessary to honour it, so it is exempt from the consent it
|
||
|
|
// records — but it is deliberately the only thing kept across sessions.
|
||
|
|
|
||
|
|
const KEY = "thermograph:analytics-consent";
|
||
|
|
//: Bump to re-ask everyone — only when what is collected materially changes.
|
||
|
|
const POLICY_VERSION = 1;
|
||
|
|
//: Re-ask a granter after this long. Never re-ask a refuser inside it: a banner
|
||
|
|
//: that keeps reappearing after "no" is a dark pattern, not a question.
|
||
|
|
const GRANT_TTL_MS = 183 * 24 * 3600 * 1000; // ~6 months
|
||
|
|
const DENY_TTL_MS = 365 * 24 * 3600 * 1000; // ~12 months
|
||
|
|
|
||
|
|
export const signalledOptOut =
|
||
|
|
navigator.globalPrivacyControl === true ||
|
||
|
|
navigator.doNotTrack === "1" ||
|
||
|
|
window.doNotTrack === "1";
|
||
|
|
|
||
|
|
const listeners = [];
|
||
|
|
let state = null; // "granted" | "denied" | null (not asked)
|
||
|
|
|
||
|
|
function read() {
|
||
|
|
try {
|
||
|
|
const raw = localStorage.getItem(KEY);
|
||
|
|
if (!raw) return null;
|
||
|
|
const rec = JSON.parse(raw);
|
||
|
|
if (rec.v !== POLICY_VERSION) return null;
|
||
|
|
const ttl = rec.choice === "granted" ? GRANT_TTL_MS : DENY_TTL_MS;
|
||
|
|
if (!rec.ts || Date.now() - rec.ts > ttl) return null;
|
||
|
|
return rec.choice === "granted" ? "granted" : "denied";
|
||
|
|
} catch { return null; }
|
||
|
|
}
|
||
|
|
|
||
|
|
function write(choice) {
|
||
|
|
try {
|
||
|
|
localStorage.setItem(KEY, JSON.stringify({ v: POLICY_VERSION, choice, ts: Date.now() }));
|
||
|
|
} catch { /* private mode: the choice just won't persist */ }
|
||
|
|
}
|
||
|
|
|
||
|
|
/** "granted" | "denied" | null. GPC/DNT short-circuits to "denied". */
|
||
|
|
export function consentState() {
|
||
|
|
if (signalledOptOut) return "denied";
|
||
|
|
if (state === null) state = read();
|
||
|
|
return state;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const hasConsent = () => consentState() === "granted";
|
||
|
|
|
||
|
|
/** Called with the new state whenever it changes — track.js uses this to drop
|
||
|
|
* the live session id the instant consent is withdrawn. */
|
||
|
|
export function onConsentChange(cb) { listeners.push(cb); }
|
||
|
|
|
||
|
|
export function setConsent(choice) {
|
||
|
|
state = choice === "granted" ? "granted" : "denied";
|
||
|
|
write(state);
|
||
|
|
hideBanner();
|
||
|
|
paintControls();
|
||
|
|
for (const cb of listeners) { try { cb(state); } catch { /* never break */ } }
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- the banner ---------------------------------------------------------------
|
||
|
|
// Rendered only when instrumentation is enabled AND no choice has been recorded
|
||
|
|
// AND no opt-out signal is present. It is a bar, not a modal: it never blocks
|
||
|
|
// reading the page, which is the whole product.
|
||
|
|
|
||
|
|
let banner = null;
|
||
|
|
|
||
|
|
function hideBanner() {
|
||
|
|
if (banner) { banner.remove(); banner = null; }
|
||
|
|
}
|
||
|
|
|
||
|
|
function showBanner() {
|
||
|
|
if (banner) return;
|
||
|
|
banner = document.createElement("div");
|
||
|
|
banner.className = "consent-bar";
|
||
|
|
banner.setAttribute("role", "dialog");
|
||
|
|
banner.setAttribute("aria-label", "Analytics");
|
||
|
|
banner.innerHTML = `
|
||
|
|
<p>Can we count how this page gets used? Anonymous, kept for 30 days, never
|
||
|
|
shared, and never linked to you or to an account.
|
||
|
|
<a class="consent-more" href="">What this collects</a></p>
|
||
|
|
<div class="consent-actions">
|
||
|
|
<button type="button" class="consent-no">No thanks</button>
|
||
|
|
<button type="button" class="consent-yes">Allow</button>
|
||
|
|
</div>`;
|
||
|
|
// The privacy link is resolved against the page, not hardcoded, so it is right
|
||
|
|
// under both the root-served and /thermograph-prefixed deployments.
|
||
|
|
const more = banner.querySelector(".consent-more");
|
||
|
|
more.href = new URL("privacy", document.baseURI).pathname;
|
||
|
|
banner.querySelector(".consent-yes").addEventListener("click", () => setConsent("granted"));
|
||
|
|
banner.querySelector(".consent-no").addEventListener("click", () => setConsent("denied"));
|
||
|
|
document.body.appendChild(banner);
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- the withdrawal control ---------------------------------------------------
|
||
|
|
// Any [data-consent-control] element becomes a live "Analytics: on/off — change"
|
||
|
|
// toggle. The footer carries one on every page, so withdrawing is never more
|
||
|
|
// than one click from wherever the visitor is.
|
||
|
|
|
||
|
|
function paintControls() {
|
||
|
|
const s = consentState();
|
||
|
|
for (const el of document.querySelectorAll("[data-consent-control]")) {
|
||
|
|
if (signalledOptOut) {
|
||
|
|
el.textContent = "Analytics: off (your browser asked us not to)";
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const on = s === "granted";
|
||
|
|
el.innerHTML =
|
||
|
|
`Analytics: ${on ? "on" : "off"} <button type="button" class="consent-toggle">`
|
||
|
|
+ `${on ? "Turn off" : "Turn on"}</button>`;
|
||
|
|
el.querySelector(".consent-toggle").addEventListener(
|
||
|
|
"click", () => setConsent(on ? "denied" : "granted"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function initConsent() {
|
||
|
|
// Instrumentation disabled for this environment: no banner, no control, no
|
||
|
|
// storage. The flag is the outermost gate on everything.
|
||
|
|
if (document.documentElement.dataset.tgEvents !== "1") return;
|
||
|
|
paintControls();
|
||
|
|
if (signalledOptOut) return; // already refused, at the browser level
|
||
|
|
if (consentState() === null) showBanner();
|
||
|
|
}
|
||
|
|
|
||
|
|
initConsent();
|