diff --git a/app/javascript/application.js b/app/javascript/application.js index b3e67a5a..5eef2669 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -2,6 +2,7 @@ import "@hotwired/turbo-rails" import "controllers" import "loading_spinner" +import "matomo_tracking" // Show the progress bar after 200 milliseconds, not the default 500 Turbo.config.drive.progressBarDelay = 200; \ No newline at end of file diff --git a/app/javascript/matomo_tracking.js b/app/javascript/matomo_tracking.js new file mode 100644 index 00000000..dc44df52 --- /dev/null +++ b/app/javascript/matomo_tracking.js @@ -0,0 +1,262 @@ +// Matomo event tracking via data attributes. +// +// CLICK TRACKING +// Add `data-matomo-click="Category, Action, Name"` to any element to track +// clicks as Matomo events. The Name segment is optional. +// +// Examples: +// Download +// +// +// Event delegation on `document` means this works for elements loaded +// asynchronously (Turbo frames, content-loader, etc.) without re-binding. +// +// SEEN TRACKING +// Add `data-matomo-seen="Category, Action, Name"` to any element to fire a +// Matomo event when that element becomes visible in the viewport. The Name +// segment is optional. Each element fires at most once per page load. +// Works for elements present on initial page load and for elements injected +// later by Turbo frames or async content loaders. +// +// Examples: +//
...
+// ... +// +// DYNAMIC VALUES ({{...}} interpolation) +// Wrap a helper name in double curly braces anywhere inside a segment to have +// it replaced with the return value of that function at tracking time. Helpers +// must be registered on `window.MatomoHelpers` (see bottom of this file). +// Multiple tokens in one segment are supported. +// +// Examples: +//

...

+// ... + +// --------------------------------------------------------------------------- +// Shared helper +// --------------------------------------------------------------------------- + +// Parse a "Category, Action, Name" attribute string and push a trackEvent call +// to the Matomo queue. Name is optional; returns early if fewer than 2 parts. +// `context` is the DOM element that triggered the event; it is forwarded to +// every helper so functions like getElementText can reference it. +function pushMatomoEvent(raw, context) { + + // Split on commas, trim whitespace from each part, drop any empty strings. + const parts = (raw || "").split(",").map((s) => s.trim()).filter(Boolean); + // Matomo requires at least a Category and an Action. + if (parts.length < 2) return; + + // Resolve any {{functionName}} tokens by calling the matching helper. + // Each token is replaced in-place, so it can appear anywhere in a segment. + // The context element is passed as the first argument so helpers can + // inspect the element that triggered the event (e.g. getElementText). + const helpers = window.MatomoHelpers || {}; + const resolved = parts.map((part) => + part.replace(/\{\{(\w+)\}\}/g, (_, fnName) => { + const fn = helpers[fnName]; + // Call the function if it exists; otherwise leave the token as-is. + return (typeof fn === "function") ? fn(context) : `{{${fnName}}}`; + }) + ); + + // Destructure into named variables; `name` will be undefined if not provided. + const [category, action, name] = resolved; + + // Ensure _paq exists even if the Matomo snippet hasn't loaded yet + // (e.g. in development). Matomo will replay queued calls once it initialises. + window._paq = window._paq || []; + const payload = ["trackEvent", category, action]; + if (name) payload.push(name); + window._paq.push(payload); +} + +// --------------------------------------------------------------------------- +// Click tracking +// --------------------------------------------------------------------------- + +// Attach a single click listener to the entire document using the capture +// phase (third argument { capture: true }). Capture phase fires top-down +// before any bubble-phase listeners, which guarantees helpers like +// getActiveTabName() read pre-click DOM state before other listeners +// (e.g. loading_spinner.js's swapTabs) synchronously update it. +document.addEventListener("click", (event) => { + // Walk up the DOM from the clicked element to find the nearest ancestor + // (or the element itself) that has a data-matomo-click attribute. + const el = event.target.closest("[data-matomo-click]"); + // If no such element exists in the ancestor chain, ignore this click. + if (!el) return; + + // Only fire when the click originated from an interactive element (link, + // button, or form control). This allows data-matomo-click to be placed on + // a container and track only meaningful interactions within it, ignoring + // clicks on surrounding text, padding, or decorative children. + const interactive = event.target.closest("a, button, input, select, textarea"); + if (!interactive) return; + + // Confirm the interactive element is actually inside the tracked container + // (guards against the unlikely case where closest() finds an ancestor of el). + if (!el.contains(interactive) && el !== interactive) return; + + // Pass the interactive element as context so helpers like getElementText + // can read the text of the specific link or button that was clicked. + pushMatomoEvent(el.dataset.matomoClick, interactive); +}, { capture: true }); + +// --------------------------------------------------------------------------- +// Seen tracking +// --------------------------------------------------------------------------- + +// Track elements already registered with the viewport observer to avoid +// double-registration if the same node is added to the DOM more than once. +const seenRegistered = new WeakSet(); + +// Fire a Matomo event when an observed element intersects the viewport. +// Unobserve immediately so the event fires at most once per element. +const viewportObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + // Stop watching — we only want to fire once per element. + viewportObserver.unobserve(entry.target); + pushMatomoEvent(entry.target.dataset.matomoSeen, entry.target); + }); +}); + +// Register a single element with the viewport observer if it carries +// data-matomo-seen and hasn't been registered yet. +function registerIfSeen(el) { + // Only process element nodes (not text nodes, comments, etc.). + if (el.nodeType !== Node.ELEMENT_NODE) return; + // Skip if already registered. + if (seenRegistered.has(el)) return; + + // Register the element itself if it has the attribute. + if (el.dataset.matomoSeen) { + seenRegistered.add(el); + viewportObserver.observe(el); + } + + // Also register any descendants — content loaders often inject a whole + // subtree at once, so walking deep ensures every marked element is caught. + el.querySelectorAll("[data-matomo-seen]").forEach((child) => { + if (seenRegistered.has(child)) return; + seenRegistered.add(child); + viewportObserver.observe(child); + }); +} + +// Register all elements already present in the DOM on initial page load. +document.querySelectorAll("[data-matomo-seen]").forEach((el) => { + seenRegistered.add(el); + viewportObserver.observe(el); +}); + +// Watch for any new nodes added to the DOM after initial load. +// --------------------------------------------------------------------------- +// Matomo native content tracking +// --------------------------------------------------------------------------- + +// Matomo's built-in content tracking (data-track-content / data-content-name / +// data-content-piece) only scans the DOM at page load. For content injected +// asynchronously (e.g. by the content-loader Stimulus controller), we must +// manually notify Matomo by calling trackContentImpressionsWithinNode on the +// newly-added node. +function trackContentImpressionsIfPresent(el) { + if (el.nodeType !== Node.ELEMENT_NODE) return; + // Check the element itself or any descendant for data-track-content. + const hasContent = + el.hasAttribute("data-track-content") || + el.querySelector("[data-track-content]") !== null; + if (!hasContent) return; + + window._paq = window._paq || []; + // Ask Matomo to scan the subtree for content impressions. + window._paq.push(["trackContentImpressionsWithinNode", el]); +} + +// Watch for any new nodes added to the DOM after initial load. +// MutationObserver fires synchronously after each DOM mutation, so it catches +// both Turbo frame renders and content-loader replacements immediately. +const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + // Each mutation record lists the nodes that were added in this batch. + mutation.addedNodes.forEach((node) => { + registerIfSeen(node); + trackContentImpressionsIfPresent(node); + }); + }); +}); + +// Observe the entire document subtree so no async insertion is missed. +observer.observe(document.body, { childList: true, subtree: true }); + +// Turbo Drive navigation replaces document.body with a brand new element, +// which detaches the MutationObserver from the old body. Re-scan and +// re-attach on every turbo:load so full-page navigations are handled. +// (Turbo frame and content-loader updates are covered by the observer above +// because they mutate within the existing body rather than replacing it.) +document.addEventListener("turbo:load", () => { + // Register any seen elements that arrived with the navigation. + document.querySelectorAll("[data-matomo-seen]").forEach((el) => { + if (seenRegistered.has(el)) return; + seenRegistered.add(el); + viewportObserver.observe(el); + }); + + // Re-attach the MutationObserver to the new document.body instance. + observer.observe(document.body, { childList: true, subtree: true }); +}); + + +// =========================================================================== +// HELPER FUNCTIONS +// Custom JS to enhance the payload information we provide to Matomo. +// =========================================================================== + +// --------------------------------------------------------------------------- +// Get the name of the active search results tab, if any. +// --------------------------------------------------------------------------- +function getActiveTabName() { + var tabs = document.querySelector('#tabs'); + if (!tabs) { + return "None"; // #tabs not found + } + + var activeAnchor = tabs.querySelector('a.active'); + if (!activeAnchor) { + return "None"; // no active tab + } + + return activeAnchor.textContent.trim(); +} + +// --------------------------------------------------------------------------- +// Get the visible text of the element that triggered the event. +// For click tracking this is the interactive element (link, button, etc.). +// For seen tracking this is the element carrying data-matomo-seen. +// Returns an empty string if no context element is available. +// --------------------------------------------------------------------------- +function getElementText(el) { + if (!el) return ""; + return el.textContent.trim(); +} + +// --------------------------------------------------------------------------- +// Get the current results page number from the `page` URL parameter. +// Returns "1" when the parameter is absent (the first page has no page param). +// --------------------------------------------------------------------------- +function getCurrentResultsPage() { + const params = new URLSearchParams(window.location.search); + return params.get("page") || "1"; +} + +// --------------------------------------------------------------------------- +// Register helpers on window.MatomoHelpers so they can be referenced with the +// {{functionName}} syntax in data-matomo-seen and data-matomo-click attributes. +// Add new helpers here as needed. +// --------------------------------------------------------------------------- +window.MatomoHelpers = { + getActiveTabName, + getElementText, + getCurrentResultsPage, +}; \ No newline at end of file diff --git a/app/views/search/_results_callouts.html.erb b/app/views/search/_results_callouts.html.erb index 39df2a48..b6e778ed 100644 --- a/app/views/search/_results_callouts.html.erb +++ b/app/views/search/_results_callouts.html.erb @@ -1,4 +1,4 @@ -
+
<% if ['cdi', 'alma', 'all'].include?(@active_tab) %> <%= render partial: "results_callout_component", locals: { fa_name: 'magnifying-glass', diff --git a/app/views/search/_results_sidebar.html.erb b/app/views/search/_results_sidebar.html.erb index 989adaee..38eb5a24 100644 --- a/app/views/search/_results_sidebar.html.erb +++ b/app/views/search/_results_sidebar.html.erb @@ -1,12 +1,12 @@