1/** 2 * Event Tracking Framework 3 * 4 * A generic, reusable system for tracking events via GTM's dataLayer. 5 * Supports two trigger types: 6 * - "impression": fires when an element becomes visible in the viewport (50% threshold) 7 * - "click": fires when an element is clicked 8 * 9 * Usage: 10 * window.gaEventTracking.register({ 11 * selector: 'img.my-element', // CSS selector to find trackable elements 12 * eventName: 'my_{trigger}', // event name â {trigger} is replaced with "click" or "impression" 13 * triggers: ['click', 'impression'], // which events to track 14 * getPayload: function(el) { // returns the event data object pushed to the dataLayer 15 * return { id: el.dataset.id }; 16 * } 17 * }); 18 * 19 * Events are pushed to GTM's dataLayer. All custom event names should be 20 * prefixed with "in_" so a single GTM trigger (regex: ^in_) can catch them all. 21 * Page-level custom dimensions (pageCategory, pageSection, pageType) from 22 * SEOmatic are automatically included as individual properties in every event. 23 * 24 * Elements are discovered both on registration and dynamically via MutationObserver, 25 * so elements added to the DOM after page load (e.g. via Sprig/htmx) are tracked automatically. 26 * 27 * Wrapped in try/catch to ensure errors never affect other site functionality. 28 */ 29try { 30 if (localStorage.getItem("in_debug") === "1") console.log("[Event Tracking] ga-event-tracking.js loaded"); 31 // Prefix used to extract custom event attributes from data-event-attribute-* HTML attributes 32 var PREFIX = "data-event-attribute-"; 33 34 // Tracks which (element, tracker) pairs have been initialised to prevent duplicate listeners. 35 // Each element maps to a Set of tracker references it has already been bound to, 36 // allowing multiple trackers to independently track the same element. 37 var processed = new WeakMap(); 38 39 // Stores all registered tracker configurations 40 var trackers = []; 41 42 // Ensure the dataLayer exists 43 window.dataLayer = window.dataLayer || []; 44 45 /** 46 * Retrieve page-level custom dimensions from the dataLayer. 47 * These are set by SEOmatic/DestinationCore at page load 48 * (e.g. pageCategory, pageSection, pageType) and are explicitly 49 * included in the payload of every event. 50 */ 51 var PAGE_DIMENSIONS = ["pageCategory", "entryId", "entryTitle", "pageSection", "pageType", "relatedBusinessId", "relatedBusinessTitle"]; 52 53 function getPageDimensions() { 54 var dimensions = {}; 55 for (var i = 0; i < window.dataLayer.length; i++) { 56 var entry = window.dataLayer[i]; 57 for (var j = 0; j < PAGE_DIMENSIONS.length; j++) { 58 var key = PAGE_DIMENSIONS[j]; 59 if (entry.hasOwnProperty(key)) { 60 dimensions[key] = entry[key]; 61 } 62 } 63 } 64 return dimensions; 65 } 66 67 /** 68 * Send an event via GTM's dataLayer, with a fallback to gtag() if GTM is not present. 69 * GTM routes these events to GA4 via a trigger matching the "in_" prefix. 70 * If GTM is not loaded but gtag() is available (e.g. via standalone GA4), 71 * the event is sent directly to GA4 via gtag(). 72 * Page-level custom dimensions are included as individual properties. 73 */ 74 function sendEvent(eventName, attributes) { 75 try { 76 var params = {}; 77 var dimensions = getPageDimensions(); 78 for (var key in dimensions) { 79 if (dimensions.hasOwnProperty(key)) { 80 params[key] = dimensions[key]; 81 } 82 } 83 for (var key in attributes) { 84 if (attributes.hasOwnProperty(key)) { 85 params[key] = attributes[key]; 86 } 87 } 88 89 if (window.google_tag_manager) { 90 // GTM is present â push to dataLayer for GTM to route to GA4 91 params.event = eventName; 92 window.dataLayer.push(params); 93 94 // Reset event-specific attributes so they don't bleed into 95 // subsequent events via dataLayer's sticky state 96 var reset = {}; 97 for (var attr in attributes) { 98 if (attributes.hasOwnProperty(attr)) { 99 reset[attr] = undefined; 100 } 101 } 102 window.dataLayer.push(reset); 103 } else if (typeof gtag === "function") { 104 // No GTM, but gtag is available â send directly to GA4 105 gtag("event", eventName, params); 106 } 107 } catch (e) { 108 console.error("[Event Tracking]", e); 109 } 110 } 111 112 /** 113 * Extract event attributes from an element's data-event-attribute-* HTML attributes. 114 * e.g. data-event-attribute-ad_id="123" becomes { ad_id: 123 } 115 * Numeric string values are automatically parsed to numbers. 116 */ 117 function getEventAttributes(el) { 118 var attributes = {}; 119 for (var i = 0; i < el.attributes.length; i++) { 120 var attr = el.attributes[i]; 121 if (attr.name.indexOf(PREFIX) === 0) { 122 var key = attr.name.slice(PREFIX.length); 123 var value = 124 isNaN(attr.value) || attr.value === "" 125 ? attr.value 126 : parseFloat(attr.value); 127 attributes[key] = value; 128 } 129 } 130 return attributes; 131 } 132 133 /** 134 * IntersectionObserver for impression tracking. 135 * Fires the impression event once when at least 50% of the element is visible 136 * in the viewport, then stops observing that element. 137 */ 138 var impressionObserver = new IntersectionObserver( 139 function (entries) {
140 entries.forEach(function (entry) { 141 if (!entry.isIntersecting) { 142 return; 143 } 144 145 var el = entry.target; 146 var elTrackers = el._gaTrackers || []; 147 for (var t = 0; t < elTrackers.length; t++) { 148 var tracker = elTrackers[t]; 149 if (tracker.triggers.indexOf("impression") === -1) { 150 continue; 151 } 152 var payload = tracker.getPayload(el); 153 if (payload !== null) { 154 sendEvent( 155 tracker.eventName.replace("{trigger}", "impression"), 156 payload 157 ); 158 } 159 } 160 161 // Only track one impression per element per page load 162 impressionObserver.unobserve(el); 163 }); 164 }, 165 { threshold: 0.5 } 166 ); 167 168 /** 169 * Initialise tracking on a single DOM element. 170 * Attaches the tracker config to the element, sets up the IntersectionObserver 171 * for impressions, and adds a click event listener for clicks. 172 * Each (element, tracker) pair is only initialised once, but multiple 173 * trackers can independently bind to the same element. 174 */ 175 function initElement(el, tracker) { 176 var trackerSet = processed.get(el); 177 if (!trackerSet) { 178 trackerSet = new Set(); 179 processed.set(el, trackerSet); 180 } 181 if (trackerSet.has(tracker)) { 182 return; 183 } 184 trackerSet.add(tracker); 185 186 // Store tracker configs on the element so the IntersectionObserver callback can access them 187 if (!el._gaTrackers) { 188 el._gaTrackers = []; 189 } 190 el._gaTrackers.push(tracker); 191 192 if (tracker.triggers.indexOf("impression") !== -1) { 193 impressionObserver.observe(el); 194 } 195 196 if (tracker.triggers.indexOf("click") !== -1) { 197 var clickHandler = function () { 198 var payload = tracker.getPayload(el); 199 if (payload === null) { 200 return; 201 } 202 sendEvent( 203 tracker.eventName.replace("{trigger}", "click"), 204 payload 205 ); 206 }; 207 el.addEventListener("click", clickHandler); 208 el.addEventListener("auxclick", clickHandler); 209 } 210 } 211 212 /** 213 * Scan a DOM subtree (or the full document) for elements matching 214 * any registered tracker's selector, and initialise tracking on them. 215 */ 216 function scanForElements(root) { 217 trackers.forEach(function (tracker) { 218 var els = (root || document).querySelectorAll(tracker.selector); 219 els.forEach(function (el) { 220 initElement(el, tracker); 221 }); 222 }); 223 } 224 225 /** 226 * MutationObserver to detect dynamically added DOM elements. 227 * When new nodes are added (e.g. via Sprig/htmx AJAX swaps), each node 228 * and its descendants are checked against all registered tracker selectors. 229 */ 230 var domObserver = new MutationObserver(function (mutations) { 231 for (var i = 0; i < mutations.length; i++) { 232 var addedNodes = mutations[i].addedNodes; 233 for (var j = 0; j < addedNodes.length; j++) { 234 var node = addedNodes[j]; 235 236 // Skip non-element nodes (text nodes, comments, etc.) 237 if (node.nodeType !== 1 || !node.querySelectorAll) { 238 continue; 239 } 240 241 // Check if the added node itself, or any of its descendants, match a tracker selector 242 trackers.forEach(function (tracker) { 243 if (node.matches && node.matches(tracker.selector)) { 244 initElement(node, tracker); 245 } 246 var els = node.querySelectorAll(tracker.selector); 247 for (var k = 0; k < els.length; k++) { 248 initElement(els[k], tracker); 249 } 250 }); 251 } 252 } 253 }); 254 255 domObserver.observe(document.body, { childList: true, subtree: true }); 256 257 /** 258 * Public API â exposed on window so other scripts can register tracker configurations. 259 * On registration, immediately scans the current DOM for matching elements. 260 * Any elements added later are picked up automatically by the MutationObserver. 261 */ 262 window.gaEventTracking = { 263 register: function (config) { 264 try { 265 trackers.push(config); 266 scanForElements(); 267 } catch (e) { 268 console.error("[Event Tracking]", e); 269 } 270 }, 271 }; 272} catch (e) { 273 console.error("[Event Tracking]", e); 274}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.