diff --git a/assets/source/js/matomoTracking.js b/assets/source/js/matomoTracking.js new file mode 100644 index 0000000000..ebbafa19bc --- /dev/null +++ b/assets/source/js/matomoTracking.js @@ -0,0 +1,150 @@ +const matomoTrackingSelector = [ + "button", + "[data-matomo-category]", +].join(", "); + +const defaultTrackingCategory = "UI Interaction"; +const defaultTrackingAction = "Click"; +const handleMatomoTrackingDomReady = () => normalizeMatomoTrackableElements(); + +/** + * Normalize whitespace in tracking labels. + * + * @param {string | null | undefined} value The label to normalize. + * @returns {string} The normalized label. + */ +export function normalizeTrackingLabel(value) { + return (value || "").replace(/\s+/g, " ").trim(); +} + +/** + * Derive a Matomo tracking name for a CTA element. + * + * @param {Element} element The element to derive a name from. + * @returns {string} The derived tracking name. + */ +export function getMatomoTrackingName(element) { + return normalizeTrackingLabel( + element.getAttribute("data-matomo-name") || + element.getAttribute("aria-label") || + element.getAttribute("title") || + element.innerText || + element.textContent, + ); +} + +/** + * Apply default Matomo data attributes unless they are explicitly set. + * + * @param {HTMLElement} element The element to normalize. + * @returns {void} + */ +export function applyMatomoTrackingAttributes(element) { + const trackingName = getMatomoTrackingName(element); + + if (!element.dataset.matomoCategory) { + element.dataset.matomoCategory = defaultTrackingCategory; + } + + if (!element.dataset.matomoAction) { + element.dataset.matomoAction = defaultTrackingAction; + } + + if (!element.dataset.matomoName && trackingName) { + element.dataset.matomoName = trackingName; + } +} + +/** + * Find a trackable CTA element from an event target. + * + * @param {EventTarget | null} target The event target. + * @returns {HTMLElement | null} The closest trackable element. + */ +export function getMatomoTrackableElement(target) { + if (!(target instanceof Element)) { + return null; + } + + const element = target.closest(matomoTrackingSelector); + + return element instanceof HTMLElement ? element : null; +} + +/** + * Normalize trackable elements in the current DOM. + * + * @returns {void} + */ +export function normalizeMatomoTrackableElements() { + document + .querySelectorAll(matomoTrackingSelector) + .forEach((element) => + applyMatomoTrackingAttributes(element), + ); +} + +/** + * Handle delegated click tracking for Matomo. + * + * @param {MouseEvent} event The click event. + * @returns {void} + */ +export function handleMatomoTrackingClick(event) { + const trackableElement = getMatomoTrackableElement(event.target); + + if (!trackableElement) { + return; + } + + applyMatomoTrackingAttributes(trackableElement); + + const trackingQueue = window._paq; + + if (!trackingQueue || typeof trackingQueue.push !== "function") { + return; + } + + const trackingName = trackableElement.dataset.matomoName || ""; + + if (!trackingName) { + return; + } + + trackingQueue.push([ + "trackEvent", + trackableElement.dataset.matomoCategory || defaultTrackingCategory, + trackableElement.dataset.matomoAction || defaultTrackingAction, + trackingName, + ]); +} + +/** + * Initialize global Matomo CTA tracking. + * + * @returns {() => void} Cleanup function for removing tracking listeners. + */ +export function initializeMatomoTracking() { + let hasDomReadyListener = false; + + document.addEventListener("click", handleMatomoTrackingClick); + + if (document.readyState === "loading") { + hasDomReadyListener = true; + document.addEventListener( + "DOMContentLoaded", + handleMatomoTrackingDomReady, + { once: true }, + ); + } else { + normalizeMatomoTrackableElements(); + } + + return () => { + document.removeEventListener("click", handleMatomoTrackingClick); + + if (hasDomReadyListener) { + document.removeEventListener("DOMContentLoaded", handleMatomoTrackingDomReady); + } + }; +} diff --git a/assets/source/js/matomoTracking.test.js b/assets/source/js/matomoTracking.test.js new file mode 100644 index 0000000000..decc872d4d --- /dev/null +++ b/assets/source/js/matomoTracking.test.js @@ -0,0 +1,152 @@ +/** + * @jest-environment jsdom + */ + +import { + getMatomoTrackableElement, + handleMatomoTrackingClick, + initializeMatomoTracking, + normalizeMatomoTrackableElements, +} from "./matomoTracking"; + +describe("matomoTracking", () => { + beforeEach(() => { + document.body.innerHTML = ""; + window._paq = []; + }); + + afterEach(() => { + delete window._paq; + }); + + it("normalizes trackable elements with derived aria-label names", () => { + document.body.innerHTML = ` + + `; + + normalizeMatomoTrackableElements(); + + const button = document.querySelector("button"); + + expect(button.dataset.matomoCategory).toBe("UI Interaction"); + expect(button.dataset.matomoAction).toBe("Click"); + expect(button.dataset.matomoName).toBe("Open menu"); + }); + + it("preserves explicitly provided matomo attributes", () => { + document.body.innerHTML = ` + + Read more + + `; + + normalizeMatomoTrackableElements(); + + const link = document.querySelector("a"); + + expect(link.dataset.matomoCategory).toBe("Custom category"); + expect(link.dataset.matomoAction).toBe("Open"); + expect(link.dataset.matomoName).toBe("Custom name"); + }); + + it("finds the closest trackable element for nested CTA content", () => { + document.body.innerHTML = ` + + Read more + + `; + + const target = document.querySelector(".c-button__label-text"); + const trackableElement = getMatomoTrackableElement(target); + + expect(trackableElement).toBe(document.querySelector("a")); + }); + + it("tracks clicks for delegated button-like links", () => { + document.body.innerHTML = ` + + Read more + + `; + + initializeMatomoTracking(); + + const target = document.querySelector(".c-button__label-text"); + target.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(window._paq).toEqual([ + ["trackEvent", "UI Interaction", "Click", "Read more"], + ]); + expect(document.querySelector("a").dataset.matomoName).toBe("Read more"); + }); + + it("does not track links without matomo tracking attributes", () => { + document.body.innerHTML = ` + + Read more + + `; + + initializeMatomoTracking(); + + document + .querySelector(".c-button__label-text") + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(window._paq).toEqual([]); + }); + + it("tracks dynamically injected buttons through delegated listeners", () => { + initializeMatomoTracking(); + + const button = document.createElement("button"); + button.textContent = "Dynamic CTA"; + document.body.appendChild(button); + + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(window._paq).toEqual([ + ["trackEvent", "UI Interaction", "Click", "Dynamic CTA"], + ]); + expect(button.dataset.matomoName).toBe("Dynamic CTA"); + }); + + it("returns a cleanup function that removes delegated tracking", () => { + document.body.innerHTML = ''; + + const cleanup = initializeMatomoTracking(); + cleanup(); + + document + .querySelector("button") + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(window._paq).toEqual([]); + }); + + it("does not push events when the tracking queue is unavailable", () => { + document.body.innerHTML = ''; + delete window._paq; + + expect(() => + handleMatomoTrackingClick({ + target: document.querySelector("button"), + }), + ).not.toThrow(); + }); +}); diff --git a/assets/source/js/municipio.js b/assets/source/js/municipio.js index 0fdc96c177..88f8b0c216 100644 --- a/assets/source/js/municipio.js +++ b/assets/source/js/municipio.js @@ -6,6 +6,7 @@ import { initializeCollapsibleSearch } from './collapsibleSearch'; import { initializeSessionManager } from './sessionManager'; import { initializeHashHighlightManager } from './hashHighlightManager'; import { initializeHashUpdateManager } from './hashUpdateManager'; +import { initializeMatomoTracking } from './matomoTracking'; import { initPostsListAsync } from './postsList'; import { initializeWpApiSettingsNonceRefresh } from './restApi/wpApiSettings'; @@ -20,4 +21,5 @@ initializeComments(); initializeCollapsibleSearch(); initializeHashHighlightManager(); initializeHashUpdateManager(8 * 10); -initPostsListAsync(); \ No newline at end of file +initializeMatomoTracking(); +initPostsListAsync(); diff --git a/library/Controller/BaseController.php b/library/Controller/BaseController.php index 6211e09aea..d66d24b051 100644 --- a/library/Controller/BaseController.php +++ b/library/Controller/BaseController.php @@ -448,6 +448,13 @@ function ($attributes) { ); } + $this->wpService->addFilter( + 'ComponentLibrary/Component/Attribute', + [$this, 'filterMatomoTrackingAttributes'], + 20, + 1, + ); + //Wordpress hooks $this->data['hook'] = (object) array( 'innerLoopStart' => $this->hook('inner_loop_start'), @@ -511,6 +518,48 @@ private function getCurrentUrl(array $queryParam = []): string return urldecode($permalink ?? ''); } + /** + * Add default Matomo tracking attributes to rendered component links and buttons. + * + * @param array $attributes The component attributes. + * + * @return array The modified component attributes. + */ + public function filterMatomoTrackingAttributes(array $attributes): array + { + if (!$this->shouldAddMatomoTrackingAttributes($attributes)) { + return $attributes; + } + + if (empty($attributes['data-matomo-category'])) { + $attributes['data-matomo-category'] = 'UI Interaction'; + } + + if (empty($attributes['data-matomo-action'])) { + $attributes['data-matomo-action'] = 'Click'; + } + + return $attributes; + } + + /** + * Determine whether a component should receive default Matomo tracking attributes. + * + * @param array $attributes The component attributes. + * + * @return bool True when Matomo tracking attributes should be added. + */ + private function shouldAddMatomoTrackingAttributes(array $attributes): bool + { + $buttonTypes = ['button', 'submit', 'reset']; + + return !empty($attributes['href']) + || in_array($attributes['type'] ?? null, $buttonTypes, true) + || ($attributes['role'] ?? null) === 'button' + || !empty($attributes['data-open']) + || !empty($attributes['aria-controls']); + } + /** * Get the emblem to use *