From f007aad4c1f96556a841a467a751e0fb494b684b Mon Sep 17 00:00:00 2001 From: msa46 Date: Sat, 15 Aug 2026 15:03:00 +0200 Subject: [PATCH] feat: add consent-gated Matomo controller --- public/matomo-consent.js | 135 ++++++++++++++++++++++++++++++++++ tests/matomo-consent.test.mjs | 132 +++++++++++++++++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 public/matomo-consent.js create mode 100644 tests/matomo-consent.test.mjs diff --git a/public/matomo-consent.js b/public/matomo-consent.js new file mode 100644 index 0000000..abf6ae6 --- /dev/null +++ b/public/matomo-consent.js @@ -0,0 +1,135 @@ +(function initializeAnalyticsConsent(window, document) { + 'use strict'; + + var storageKey = 'incognito.analytics-consent.v1'; + var accepted = 'accepted'; + var declined = 'declined'; + var trackerOrigin = 'https://analytics.msvincognito.nl/'; + + if (window.__incognitoMatomoConsentController) return; + window.__incognitoMatomoConsentController = true; + + function readDecision() { + try { + var value = window.localStorage.getItem(storageKey); + return value === accepted || value === declined ? value : null; + } catch (_error) { + return null; + } + } + + function writeDecision(value) { + try { + window.localStorage.setItem(storageKey, value); + } catch (_error) { + // Consent applies to this page load only when browser storage is unavailable. + } + } + + function initializeMatomo() { + var queue = window._paq = window._paq || []; + if (window.__incognitoMatomoInitialized) return; + + window.__incognitoMatomoInitialized = true; + queue.push(['requireConsent']); + queue.push(['disableCookies']); + queue.push(['setConsentGiven']); + queue.push(['setTrackerUrl', trackerOrigin + 'matomo.php']); + queue.push(['setSiteId', '1']); + queue.push(['trackPageView']); + queue.push(['enableLinkTracking']); + + var tracker = document.createElement('script'); + tracker.id = 'incognito-matomo-script'; + tracker.async = true; + tracker.src = trackerOrigin + 'matomo.js'; + document.head.appendChild(tracker); + } + + function createButton(id, label) { + var button = document.createElement('button'); + button.id = id; + button.className = 'incognito-consent-action'; + button.type = 'button'; + button.textContent = label; + return button; + } + + var banner = document.createElement('section'); + banner.id = 'incognito-analytics-consent'; + banner.setAttribute('role', 'dialog'); + banner.setAttribute('aria-labelledby', 'incognito-analytics-consent-title'); + banner.setAttribute('aria-describedby', 'incognito-analytics-consent-description'); + banner.innerHTML = + ''; + + var actions = document.createElement('div'); + actions.className = 'incognito-consent-actions'; + var acceptButton = createButton('incognito-analytics-accept', 'Accept analytics'); + var declineButton = createButton('incognito-analytics-decline', 'Decline'); + actions.appendChild(acceptButton); + actions.appendChild(declineButton); + banner.appendChild(actions); + + var settingsButton = document.createElement('button'); + settingsButton.id = 'incognito-privacy-settings'; + settingsButton.type = 'button'; + settingsButton.textContent = 'Privacy settings'; + + function showBanner(moveFocus) { + banner.hidden = false; + settingsButton.hidden = true; + if (moveFocus) acceptButton.focus(); + } + + function hideBanner(restoreFocus) { + banner.hidden = true; + settingsButton.hidden = false; + if (restoreFocus) settingsButton.focus(); + } + + acceptButton.addEventListener('click', function acceptAnalytics() { + if (banner.hidden) return; + writeDecision(accepted); + if (window.__incognitoMatomoInitialized) { + window._paq.push(['setConsentGiven']); + window._paq.push(['trackPageView']); + } else { + initializeMatomo(); + } + hideBanner(true); + }); + + declineButton.addEventListener('click', function declineAnalytics() { + if (banner.hidden) return; + writeDecision(declined); + if (window._paq) { + window._paq.push(['forgetConsentGiven']); + window._paq.push(['deleteCookies']); + } + hideBanner(true); + }); + + settingsButton.addEventListener('click', function openPrivacySettings() { + showBanner(true); + }); + + document.body.appendChild(banner); + document.body.appendChild(settingsButton); + + var decision = readDecision(); + if (decision === accepted) { + banner.hidden = true; + settingsButton.hidden = false; + initializeMatomo(); + } else if (decision === declined) { + banner.hidden = true; + settingsButton.hidden = false; + } else { + showBanner(false); + } +})(window, document); diff --git a/tests/matomo-consent.test.mjs b/tests/matomo-consent.test.mjs new file mode 100644 index 0000000..b14f28e --- /dev/null +++ b/tests/matomo-consent.test.mjs @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import vm from 'node:vm'; +import { parseHTML } from 'linkedom'; + +const controllerSource = await readFile('public/matomo-consent.js', 'utf8').catch(() => ''); +const storageKey = 'incognito.analytics-consent.v1'; + +function queuedCommands(window) { + return JSON.parse(JSON.stringify(window._paq)); +} + +function runController({ decision, storageError = false } = {}) { + const { document } = parseHTML(''); + const values = new Map(decision ? [[storageKey, decision]] : []); + const localStorage = { + getItem(key) { + if (storageError) throw new Error('storage unavailable'); + return values.get(key) ?? null; + }, + setItem(key, value) { + if (storageError) throw new Error('storage unavailable'); + values.set(key, value); + }, + }; + const window = { localStorage }; + vm.runInNewContext(controllerSource, { window, document }); + return { window, document, values }; +} + +test('first visit offers equal choices without initializing Matomo', () => { + const { window, document } = runController(); + const banner = document.getElementById('incognito-analytics-consent'); + const accept = document.getElementById('incognito-analytics-accept'); + const decline = document.getElementById('incognito-analytics-decline'); + + assert.ok(banner, 'consent banner should exist'); + assert.ok(accept, 'accept button should exist'); + assert.ok(decline, 'decline button should exist'); + assert.equal(window._paq, undefined); + assert.equal(document.querySelector('script[src*="analytics.msvincognito.nl"]'), null); + assert.equal(banner.hidden, false); + assert.equal(accept.className, decline.className); + assert.equal( + document.querySelector('#incognito-analytics-consent a').href, + 'https://msvincognito.nl/privacy-policy', + ); +}); + +test('accepting persists consent and initializes cookieless Matomo once', () => { + const { window, document, values } = runController(); + const accept = document.getElementById('incognito-analytics-accept'); + + assert.ok(accept, 'accept button should exist'); + accept.click(); + accept.click(); + + assert.equal(values.get(storageKey), 'accepted'); + assert.deepEqual(queuedCommands(window), [ + ['requireConsent'], + ['disableCookies'], + ['setConsentGiven'], + ['setTrackerUrl', 'https://analytics.msvincognito.nl/matomo.php'], + ['setSiteId', '1'], + ['trackPageView'], + ['enableLinkTracking'], + ]); + const scripts = document.querySelectorAll('#incognito-matomo-script'); + assert.equal(scripts.length, 1); + assert.equal(scripts[0].src, 'https://analytics.msvincognito.nl/matomo.js'); + assert.equal(scripts[0].async, true); +}); + +test('declining persists the choice without creating Matomo state', () => { + const { window, document, values } = runController(); + document.getElementById('incognito-analytics-decline').click(); + + assert.equal(values.get(storageKey), 'declined'); + assert.equal(window._paq, undefined); + assert.equal(document.getElementById('incognito-matomo-script'), null); + assert.equal(document.getElementById('incognito-privacy-settings').hidden, false); +}); + +test('remembered acceptance tracks on a later page load', () => { + const { window, document } = runController({ decision: 'accepted' }); + + assert.ok(window._paq.some(([method]) => method === 'trackPageView')); + assert.equal(document.querySelectorAll('#incognito-matomo-script').length, 1); + assert.equal(document.getElementById('incognito-analytics-consent').hidden, true); +}); + +test('remembered decline never initializes Matomo', () => { + const { window, document } = runController({ decision: 'declined' }); + + assert.equal(window._paq, undefined); + assert.equal(document.getElementById('incognito-matomo-script'), null); + assert.equal(document.getElementById('incognito-privacy-settings').hidden, false); +}); + +test('storage failure defaults to no tracking and keeps consent available', () => { + const { window, document } = runController({ storageError: true }); + + assert.equal(window._paq, undefined); + assert.equal(document.getElementById('incognito-analytics-consent').hidden, false); +}); + +test('privacy settings allow an accepted visitor to withdraw consent', () => { + const { window, document, values } = runController({ decision: 'accepted' }); + + document.getElementById('incognito-privacy-settings').click(); + document.getElementById('incognito-analytics-decline').click(); + + assert.equal(values.get(storageKey), 'declined'); + assert.deepEqual(queuedCommands(window).slice(-2), [ + ['forgetConsentGiven'], + ['deleteCookies'], + ]); + + const later = runController({ decision: values.get(storageKey) }); + assert.equal(later.window._paq, undefined); + assert.equal(later.document.getElementById('incognito-matomo-script'), null); +}); + +test('running the local controller twice does not duplicate UI or tracking', () => { + const state = runController({ decision: 'accepted' }); + vm.runInNewContext(controllerSource, { window: state.window, document: state.document }); + + assert.equal(state.document.querySelectorAll('#incognito-analytics-consent').length, 1); + assert.equal(state.document.querySelectorAll('#incognito-matomo-script').length, 1); + assert.equal(state.window._paq.filter(([method]) => method === 'trackPageView').length, 1); +});