From b1685084d162e00ce80f240863bdce9e5be8a1ec Mon Sep 17 00:00:00 2001 From: Jeremy Prevost Date: Thu, 12 Feb 2026 11:19:58 -0500 Subject: [PATCH] Generated code for exploration of concept before we tune it up --- app/helpers/search_helper.rb | 4 +- app/javascript/application.js | 1 + app/javascript/filters.js | 30 ++++ app/javascript/loading_spinner.js | 27 ++++ app/javascript/matomo_events.js | 235 ++++++++++++++++++++++++++++++ app/javascript/search_form.js | 33 ++++- app/views/layouts/_head.html.erb | 12 ++ 7 files changed, 339 insertions(+), 3 deletions(-) create mode 100644 app/javascript/matomo_events.js diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 82c69c4e..1681deef 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -19,7 +19,7 @@ def format_highlight_label(field_name) def link_to_result(result) if result[:source_link].present? - link_to(result[:title], result[:source_link]) + link_to(result[:title], result[:source_link], data: { 'matomo-record-id': result[:identifier], 'matomo-record-title': result[:title] }) else result[:title] end @@ -45,7 +45,7 @@ def link_to_tab(target, label = nil) end def view_record(record_id) - link_to 'View full record', record_path(id: record_id), class: 'button button-primary' + link_to 'View full record', record_path(id: record_id), class: 'button button-primary', data: { 'matomo-record-id': record_id, 'matomo-record-title': "View full record for #{record_id}" } end # 'Coverage' and 'issued' seem to be the most prevalent types; 'coverage' is typically formatted as diff --git a/app/javascript/application.js b/app/javascript/application.js index b3e67a5a..4162f95f 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,6 +1,7 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails import "@hotwired/turbo-rails" import "controllers" +import "matomo_events" import "loading_spinner" // Show the progress bar after 200 milliseconds, not the default 500 diff --git a/app/javascript/filters.js b/app/javascript/filters.js index 32a79cee..f660fe05 100644 --- a/app/javascript/filters.js +++ b/app/javascript/filters.js @@ -15,3 +15,33 @@ function initFilterToggle() { } initFilterToggle(); + +// Track filter clicks +document.addEventListener('click', function(event) { + const clickedElement = event.target; + + // Check if the click is on a filter term link (nested inside .category-terms) + const filterLink = clickedElement.closest('.category-terms .term a'); + if (filterLink) { + // Find the parent filter-category to get the filter name + const filterCategory = filterLink.closest('.filter-category'); + if (filterCategory) { + // Get the filter name from the summary + const filterLabel = filterCategory.querySelector('.filter-label'); + if (filterLabel && window.matomoTracker) { + // Extract the filter term from the link text (it's in a .name span) + const termSpan = filterLink.querySelector('.name'); + const filterTerm = termSpan ? termSpan.textContent.trim() : 'unknown'; + + // Extract the filter category from the label text + const filterCategoryName = filterLabel.textContent.trim(); + + // Determine if this is adding or removing a filter + const isApplied = filterLink.classList.contains('applied'); + const action = isApplied ? 'remove' : 'add'; + + window.matomoTracker.trackFilterClick(filterCategoryName, filterTerm, action); + } + } + } +}, true); // Use capture phase to catch events before they propagate diff --git a/app/javascript/loading_spinner.js b/app/javascript/loading_spinner.js index 9aa6655c..1828ee6e 100644 --- a/app/javascript/loading_spinner.js +++ b/app/javascript/loading_spinner.js @@ -62,6 +62,15 @@ document.addEventListener('click', function(event) { // Position the window at the top of the results window.scrollTo({ top: 0, behavior: 'smooth' }); + // Track pagination click with page information + const clickedParams = new URLSearchParams(clickedElement.search); + const pageParam = clickedParams.get('page'); + const direction = clickedElement.matches('.first a') ? 'first' : + clickedElement.matches('.previous a') ? 'previous' : 'next'; + if (window.matomoTracker) { + window.matomoTracker.trackPagination(pageParam || 1, direction); + } + window.pendingFocusAction = 'pagination'; } @@ -80,6 +89,11 @@ document.addEventListener('click', function(event) { // Position the window at the top of the results window.scrollTo({ top: 0, behavior: 'smooth' }); + // Track tab click + if (window.matomoTracker) { + window.matomoTracker.trackTabClick(newTab || 'unknown'); + } + swapTabs(newTab); window.pendingFocusAction = 'tab'; @@ -100,4 +114,17 @@ document.addEventListener('turbo:load', function(event) { } }); +// Track record/result clicks +document.addEventListener('click', function(event) { + const clickedElement = event.target; + + // Check if click is on a record link (has matomo-record-id data attribute) + const recordLink = clickedElement.closest('[data-matomo-record-id]'); + if (recordLink && window.matomoTracker) { + const recordId = recordLink.getAttribute('data-matomo-record-id'); + const recordTitle = recordLink.getAttribute('data-matomo-record-title'); + window.matomoTracker.trackRecordClick(recordTitle || 'unknown', recordId || ''); + } +}, true); // Use capture phase to ensure we catch the event + diff --git a/app/javascript/matomo_events.js b/app/javascript/matomo_events.js new file mode 100644 index 00000000..07c199c7 --- /dev/null +++ b/app/javascript/matomo_events.js @@ -0,0 +1,235 @@ +/** + * Matomo Events Tracking Utility + * + * This module provides a unified interface for tracking events with Matomo, + * supporting both Matomo Tag Manager (_mtm) and Legacy Matomo (_paq). + * + * Usage: + * window.matomoTracker.trackSearch({ keyword: 'biology', filters: ['peer-reviewed'] }) + * window.matomoTracker.trackTabClick('primo') + * window.matomoTracker.trackFilterClick('language', 'English') + * window.matomoTracker.trackPagination(2) + * window.matomoTracker.trackRecordClick('title-of-record') + */ + +const MatomoTracker = (() => { + /** + * Detect which Matomo mode is active + * @returns {string} 'tagmanager' | 'legacy' | 'none' + */ + const detectMode = () => { + if (typeof window._mtm !== 'undefined') { + return 'tagmanager'; + } + if (typeof window._paq !== 'undefined') { + return 'legacy'; + } + return 'none'; + }; + + /** + * Push event to Matomo Tag Manager via _mtm data layer + * Events pushed to _mtm are processed by Matomo Tag Manager container triggers + * + * @param {string} eventName - Event name (e.g., 'search', 'tab_click') + * @param {object} eventData - Key-value pairs for event properties + */ + const pushToTagManager = (eventName, eventData = {}) => { + if (typeof window._mtm === 'undefined') return; + + const event = { + event: `matomo_${eventName}`, + ...eventData, + }; + + window._mtm.push(event); + + // Log for debugging in browser console + if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { + console.log(`[Matomo Tag Manager] Event pushed:`, event); + } + }; + + /** + * Push event to Legacy Matomo via _paq + * Uses trackEvent() method for custom events + * + * @param {string} category - Event category (e.g., 'search') + * @param {string} action - Event action (e.g., 'submit') + * @param {string} name - Event name (e.g., 'biology') + * @param {number} value - Optional numeric value + */ + const pushToLegacy = (category, action, name, value) => { + if (typeof window._paq === 'undefined') return; + + const args = ['trackEvent', category, action, name]; + if (value !== undefined && value !== null) { + args.push(value); + } + + window._paq.push(args); + + // Log for debugging in browser console + if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { + console.log(`[Matomo Legacy] Event pushed:`, { category, action, name, value }); + } + }; + + /** + * Generic push method that routes to the appropriate Matomo mode + * For Tag Manager: sends as named event with object data + * For Legacy: sends as category/action/name tuple + * + * @param {object} config - Configuration object + * - eventName: string (e.g., 'search_submit') + * - category: string for legacy (e.g., 'Search') + * - action: string for legacy (e.g., 'Submit') + * - value: string/number for legacy (e.g., 'biology') + * - data: object for tag manager (e.g., { keyword: 'biology', filters: [...] }) + */ + const push = (config) => { + const mode = detectMode(); + + if (mode === 'tagmanager') { + pushToTagManager(config.eventName, config.data); + } else if (mode === 'legacy') { + pushToLegacy(config.category, config.action, config.value); + } + }; + + // Public API + return { + /** + * Track a search submission + * @param {object} options + * - keyword: string (search query) + * - filters: array of applied filter strings (optional) + */ + trackSearch: (options = {}) => { + push({ + eventName: 'search_submit', + category: 'Search', + action: 'Submit', + value: options.keyword || '', + data: { + keyword: options.keyword || '', + filters: options.filters || [], + filterCount: (options.filters || []).length, + }, + }); + }, + + /** + * Track a tab switch event + * @param {string} tabName - Name of the tab ('primo', 'timdex', 'all') + */ + trackTabClick: (tabName) => { + push({ + eventName: 'tab_click', + category: 'Navigation', + action: 'Tab Switch', + value: tabName || '', + data: { + tab: tabName || '', + }, + }); + }, + + /** + * Track a filter interaction + * @param {string} category - Filter category (e.g., 'language', 'content_type') + * @param {string} term - Filter term (e.g., 'English') + * @param {string} action - 'add' or 'remove' + */ + trackFilterClick: (category, term, action = 'add') => { + push({ + eventName: 'filter_click', + category: 'Filters', + action: `${action}_${category}`, + value: term || '', + data: { + filterCategory: category || '', + filterTerm: term || '', + filterAction: action || 'add', + }, + }); + }, + + /** + * Track pagination interaction + * @param {number} pageNumber - Page number (or offset) + * @param {string} direction - 'first' | 'previous' | 'next' | 'direct' + */ + trackPagination: (pageNumber, direction = 'direct') => { + push({ + eventName: 'pagination_click', + category: 'Navigation', + action: 'Pagination', + value: String(pageNumber), + data: { + page: pageNumber, + direction: direction, + }, + }); + }, + + /** + * Track a record (result) click + * @param {string} recordTitle - Title of the record/result clicked + * @param {string} recordId - Unique identifier (if available) + */ + trackRecordClick: (recordTitle, recordId = '') => { + push({ + eventName: 'record_click', + category: 'Results', + action: 'Click', + value: recordTitle || '', + data: { + recordTitle: recordTitle || '', + recordId: recordId || '', + }, + }); + }, + + /** + * Track advanced search panel toggle + * @param {string} panelName - Name of the panel ('advanced', 'geobox', 'geodistance') + * @param {boolean} isOpen - Whether panel is now open + */ + trackAdvancedSearchToggle: (panelName, isOpen) => { + push({ + eventName: 'search_panel_toggle', + category: 'Search', + action: `Toggle ${panelName}`, + value: isOpen ? 'open' : 'closed', + data: { + panel: panelName, + state: isOpen ? 'open' : 'closed', + }, + }); + }, + + /** + * Track a virtual page view (for SPA navigation in Tag Manager mode) + * This supplements or replaces trackPageView for Hotwire navigation + * @param {string} url - Page URL + * @param {string} title - Page title + */ + trackPageView: (url, title) => { + // For Tag Manager, push a virtual pageview event + pushToTagManager('page_view', { + pageUrl: url || window.location.href, + pageTitle: title || document.title, + }); + + // Legacy Matomo handles this via turbo:load listener in _head.html.erb + }, + + mode: detectMode, + }; +})(); + +// Expose globally for use in inline event handlers and other scripts +window.matomoTracker = MatomoTracker; + +export default MatomoTracker; diff --git a/app/javascript/search_form.js b/app/javascript/search_form.js index fa603040..44e76254 100644 --- a/app/javascript/search_form.js +++ b/app/javascript/search_form.js @@ -17,6 +17,15 @@ function togglePanelState(currentPanel) { // Finally, enable or disable the search type of the current panel, based on whether it is open or not. toggleSearch(currentPanel); + + // Track panel toggle + if (window.matomoTracker) { + const panelName = currentPanel.id + .replace('-search-panel', '') + .replace('-', '_'); + const isOpen = currentPanel.open || false; + window.matomoTracker.trackAdvancedSearchToggle(panelName, isOpen); + } } function toggleRequiredFieldset(panel) { @@ -77,4 +86,26 @@ if (Array.from(allPanels).includes(geoboxPanel && geodistancePanel)) { }); } -console.log('search_form.js loaded'); +// Track search form submission +document.addEventListener('submit', function(event) { + const form = event.target; + + // Check if this is the search form (has the basic-search-main field) + if (form.querySelector('input[name="q"]') && window.matomoTracker) { + const keyword = form.querySelector('input[name="q"]').value || ''; + + // Collect applied filters from the URL if available + const filters = []; + const urlParams = new URLSearchParams(window.location.search); + for (const [key, value] of urlParams.entries()) { + if (key.startsWith('filter_') && value) { + filters.push(`${key}=${value}`); + } + } + + window.matomoTracker.trackSearch({ + keyword: keyword, + filters: filters + }); + } +}, true); // Use capture phase to catch events before form submission diff --git a/app/views/layouts/_head.html.erb b/app/views/layouts/_head.html.erb index 8647e56f..baee297a 100644 --- a/app/views/layouts/_head.html.erb +++ b/app/views/layouts/_head.html.erb @@ -36,6 +36,18 @@ var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; g.async=true; g.src='<%= ENV['MATOMO_CONTAINER_URL'] %>'; s.parentNode.insertBefore(g,s); })(); + + // Track SPA page navigation for Tag Manager mode + // matomo_events.js must be loaded before this listener can be used + (function() { + var previousPageUrl = null; + addEventListener('turbo:load', function(event) { + if (previousPageUrl && window.matomoTracker) { + window.matomoTracker.trackPageView(window.location.href, document.title); + } + previousPageUrl = window.location.href; + }); + })(); <% elsif (ENV['MATOMO_URL'].present? && ENV['MATOMO_SITE_ID'].present?) %>