1/* eslint-disable no-console */ 2 3// click attributes events to check against 4const analyticsClickEventType = [ 5 'module_interaction', 6 'nav_click', 7 'search', 8 'select_content', 9 'pagination', 10 'filter_applied', 11 'share', 12 'sweeps_email_submit', 13 'sweeps_email_code_submit', 14 'sweeps_final_submission', 15 'sweeps_photo_click', 16 'sweeps_video_click', 17 'sweeps_form_error', 18]; 19// focus attributes events to check against 20const analyticsFocusEventType = [ 21 'search_start', 22]; 23 24const sweepsStepEventTypes = [ 25 'sweeps_email_submit', 26 'sweeps_email_code_submit', 27 'sweeps_final_submission', 28]; 29 30const sweepsErrorFeatureMap = { 31 registration: 'Initial Email Submit', 32 email_confirmation: 'Email Code Submit', 33 data_collection: 'Final Submit', 34}; 35 36const sweepsNextStepKeyMap = { 37 registration: 'email_confirmation', 38 email_confirmation: 'data_collection', 39}; 40const sweepsTrackedFormIds = ['primary_entry', 'secondary_entry']; 41 42let pendingSweepsStepEvent = null; 43const processedSweepsValidationRequests = new Set(); 44 45const SWEEPS_ENTRY_FORM_SELECTOR = 'form[id^="webform-submission-primary-entry-"], form[id^="webform-submission-secondary-entry-"]'; 46 47/** 48 * Gets the sweeps entry form element. 49 * 50 * @param {ParentNode|Element|Document} [root=document] 51 * Optional root to scope the search to. 52 * 53 * @return {HTMLFormElement|null} 54 * The sweeps form if found, otherwise null. 55 */ 56function getSweepsEntryForm(root = document) { 57 if (typeof root.matches === 'function' && root.matches(SWEEPS_ENTRY_FORM_SELECTOR)) { 58 return root; 59 } 60 61 if (typeof root.querySelector === 'function') { 62 return root.querySelector(SWEEPS_ENTRY_FORM_SELECTOR); 63 } 64 65 return null; 66} 67 68/** 69 * Gets the current sweeps webform page key from the DOM. 70 * 71 * @param {ParentNode|Element|Document} [root=document] 72 * Optional root to scope the search to. 73 * 74 * @return {string} 75 * The page key (for example, registration) or an empty string. 76 */ 77function getCurrentSweepsWebformKey(root = document) { 78 const sweepsForm = getSweepsEntryForm(root); 79 if (!sweepsForm) { 80 if (testAnalytics) { 81 console.log('[Sweeps Step Detection] No sweeps form found'); 82 } 83 return ''; 84 } 85 86 const pageElement = sweepsForm.querySelector('[data-webform-key]'); 87 const webformKey = pageElement?.getAttribute('data-webform-key') || ''; 88 if (testAnalytics) { 89 console.log('[Sweeps Step Detection] Found form, webform-key:', webformKey); 90 } 91 return webformKey; 92} 93 94/** 95 * Gets the sweeps webform id from a form element. 96 * 97 * @param {HTMLFormElement|null} form 98 * The form element. 99 * 100 * @return {string} 101 * The webform id (for example, primary_entry) or an empty string. 102 */ 103function getSweepsFormId(form) { 104 if (!form || !form.id) { 105 return ''; 106 } 107 108 if (form.id.startsWith('webform-submission-primary-entry-')) { 109 return 'primary_entry'; 110 } 111 112 if (form.id.startsWith('webform-submission-secondary-entry-')) { 113 return 'secondary_entry'; 114 } 115 116 return ''; 117} 118 119/** 120 * Removes sweep wizard hash fragments from the browser URL for analytics. 121 * 122 * Some sweeps submissions keep a #enter... hash in the URL (for example 123 * #enter?page=2). Remove only that hash pattern so GTM/page_location does not 124 * include the wizard anchor on final submission events. 125 */ 126function removeSweepsWizardHashForAnalytics() { 127 const hash = window.location.hash || ''; 128 if (!hash) { 129 return; 130 } 131 132 // Keep only page=<value> from hash query params (for example #enter?page=4) 133 // so analytics emits a canonical final-submission URL. 134 const url = new URL(window.location.href); 135 const hashQueryIndex = hash.indexOf('?'); 136 let pageValue = ''; 137 if (hashQueryIndex !== -1) { 138 const hashParams = new URLSearchParams(hash.substring(hashQueryIndex + 1)); 139 pageValue = hashParams.get('page') || ''; 140 } 141 142 url.search = ''; 143 if (pageValue) { 144 url.searchParams.set('page', pageValue); 145 } 146 147 url.hash = ''; 148 const cleanUrl = url.pathname + (url.search ? url.search : ''); 149 window.history.replaceState(window.history.state, document.title, cleanUrl); 150 151 if (testAnalytics) { 152 console.log('[Sweeps Step Analytics] Removed sweeps hash from URL:', hash); 153 } 154} 155 156// Target only the global search input, can extend if other inputs need focus events in future 157const headerSearchInput = document.querySelector('.headerSearch'); 158 159// Feature flag to enable console logging of dataLayer.push events
160const urlParams = new URLSearchParams(window.location.search); 161const testAnalytics = urlParams.has('testAnalytics') && 162 (window.location.hostname.endsWith('-ssb-demo.pantheonsite.io') || 163 window.location.hostname.endsWith('.ddev.site') || 164 /^pr-\d{3,}.+\.pantheonsite\.io$/.test(window.location.hostname)); 165 166 167/** 168 * Pushes one sweeps validation error event. 169 * 170 * @param {string} step 171 * Wizard step key. 172 * @param {{field?: string, code?: string, name?: string}} error 173 * Structured validation error payload from drupalSettings. 174 */ 175function pushSweepsValidationEvent(step, error) { 176 const eventParams = { 177 form_name: 'Sweeps: ' + document.title, 178 error_feature: sweepsErrorFeatureMap[step] || 'Unknown Step', 179 error_code: error.code || 'VALIDATION_ERROR', 180 error_name: error.name || 'Validation Error', 181 error_field: error.field || 'form', 182 }; 183 184 dataLayer.push({ 185 'event': 'sweeps_form_error', 186 'event_params': eventParams, 187 }, { event_params: undefined }); 188 189 if (testAnalytics) { 190 console.log('[Sweeps Validation Analytics] dataLayer.push:', { 191 'event': 'sweeps_form_error',
192 'event_params': eventParams, 193 }); 194 } 195} 196 197// Similar to document clicks, on focus grab the eventType from the event target, check it, 198// and then push eventType to data layer, no params needed 199if (headerSearchInput) { 200 headerSearchInput.addEventListener('focus', (event) => { 201 let searchInput = event.target; 202 if (analyticsFocusEventType.includes(searchInput.dataset.event)) { 203 let eventType = searchInput.dataset.event; 204 dataLayer.push({ 205 'event': eventType, 206 }); 207 if (testAnalytics) { 208 console.log('Event disabled due to logging being enabled.'); 209 console.log('dataLayer.push:', { 'event': eventType }); 210 } 211 } 212 }); 213}; 214// Fast filter form event listener to gather all form values and push to dataLayer 215document.addEventListener('DOMContentLoaded', function() { 216 // Once content is loaded, find all fastFilter forms on the page, there can be more than one 217 const forms = document.querySelectorAll('.fastFilterForm'); 218 // Iterate over each form and attach a submit event listener 219 forms.forEach((form) => { 220 form.addEventListener('submit', (event) => { 221 if (testAnalytics) { 222 console.log('Form submit event disabled due to logging being enabled.'); 223 event.preventDefault(); 224 } 225 // find all the form elements 226 const elements = form.elements; 227 // New object to push filter values to 228 const formValues = {}; 229 // New object to push individual filter values into 230 const individualFilters = {}; 231 // Create an empty array to push selected filter values into 232 const selectedFilterValues = []; 233 // set the filter category to fast_filter_form 234 formValues['filter_category'] = 'fast_filter_form'; 235 // Loop through all elements in form 236 for (const element of elements) { 237 // Get url of form and add to formValues object 238 if (element.name === 'uri') { 239 formValues['filter_url'] = element.value; 240 } 241 // get all elements with data-filter_value items in form and add text from each to selectedFilterValues array to combine into comma sperated filter_values 242 if (element.dataset.filter_value) { 243 selectedFilterValues.push(element.options[element.selectedIndex].innerText); 244 // pass each data filter value items to individual filters object to combine with comma separated filter_values parameter 245 individualFilters[element.dataset.filter_value] = element.options[element.selectedIndex].innerText; 246 } 247 // Nested loop to find all select elements and push their values to the dataLayer individually for reporting 248 if (element.tagName === 'SELECT') { 249 const filterSelectValue = element.options[element.selectedIndex].innerText; 250 dataLayer.push({ 251 'event': 'filter_applied', 252 'event_params': { 253 'filter_value': filterSelectValue, 254 }, 255 }, {event_params: undefined}); 256 if (testAnalytics) { 257 console.log('dataLayer.push:', { 258 'event': 'filter_applied', 259 'event_params': { 'filter_value': filterSelectValue }, 260 }); 261 } 262 } 263 } 264 // Combine all form select values into one comma separated value in filter_values in event params 265 formValues['filter_values'] = selectedFilterValues.join(', '); 266 // Combine all form values and individual filters into one object to send to event params 267 const fastFilterEventParams = { ...formValues, ...individualFilters }; 268 dataLayer.push({ 269 'event': 'filter_applied', 270 'event_params': fastFilterEventParams, 271 }, {event_params: undefined}); 272 if (testAnalytics) { 273 console.log('dataLayer.push:', { 274 'event': 'filter_applied', 275 'event_params': fastFilterEventParams, 276 }); 277 } 278 }); 279 }); 280 281}); 282// Fires event on click only if the data-event attribute is present and matches the attributes listed in the analyticsClickEventType array 283// Uses capture phase (3rd arg = true) so the event is caught before Drupal's AJAX handler calls stopPropagation() 284document.addEventListener('click', (event) => { 285 // pushing node.type from analyticsVars pageType in html.html.twig to window to grab and use here to add to 286 // filtered parameters for component links 287 const pageType = window.drupalPageType; 288 // set trigger to the event target 289 let trigger = event.target; 290 // check against analyticsClickEventType array and if the trigger has a data-event attribute, if it doesn't, bubble up to parentElement to find the data-event attribute 291 while (trigger && !analyticsClickEventType.includes(trigger.dataset.event)) { 292 trigger = trigger.parentElement; 293 } 294 // if trigger is null, return out of the function 295 if (trigger === null) { 296 return; 297 } 298 if (testAnalytics) { 299 // Don't block photo/video upload clicks â the AJAX upload must complete 300 // for the upload success events to fire. 301 const allowThrough = [...sweepsStepEventTypes, 'sweeps_photo_click', 'sweeps_video_click']; 302 if (!allowThrough.includes(trigger.dataset.event)) { 303 console.log('Event disabled due to logging being enabled.'); 304 event.preventDefault(); 305 } 306 } 307 // if trigger has a data-event attribute and it matches the analyticsClickEventType array, set the data 308 if (trigger && analyticsClickEventType.includes(trigger.dataset.event)) { 309 let trackingData = trigger.dataset; 310 // Pluck the 'event' from the data set and use for the dataLayer event 311 let eventType = trackingData.event;
312 // define empty eventParams object to assign filtered parameters 313 let eventParams = {}; 314 // add all tracking data to object allParams to filter out eventType so it doesn't get sent with 'event_params' 315 let allParams = {...trackingData}; 316 // remove 'event' type from allParams object and assign rest of values to new filteredParams object to send to the 'event_params' 317 // in dataLayer push event 318 let { event: _, ...filteredParams } = allParams; 319 320 // if a standard component module interaction, add the page type with the pageType variable taken from the window to filteredParams object 321 // otherwise assume a nav_click interaction and therefore no need to add page type since global 322 if (eventType === 'module_interaction' || eventType === 'select_content') { 323 eventParams = { ...filteredParams, page_type: pageType }; 324 } else if (eventType.startsWith('sweeps_')) { 325 // For sweeps forms, submit the form name param. 326 eventParams = { ...filteredParams, form_name: `Sweeps: ${document.title}` }; 327 } else { 328 eventParams = {...filteredParams}; 329 } 330 331 if (sweepsStepEventTypes.includes(eventType)) { 332 pendingSweepsStepEvent = { 333 eventType, 334 eventParams, 335 sourceStepKey: getCurrentSweepsWebformKey(), 336 }; 337 if (testAnalytics) { 338 console.log('[Sweeps Step Analytics] Pending step event:', pendingSweepsStepEvent); 339 } 340 return; 341 } 342 343 // if search form is submitted, let's get the input value that was entered to the search form and add with the event 344 if (eventType === 'search') { 345 let searchInput = trigger.parentElement.querySelector('input'); 346 let searchInputValue = searchInput.value; 347 dataLayer.push({ 348 'event': eventType, 349 'event_params': { 350 'search_term': searchInputValue, 351 }, 352 }, {event_params: undefined}); 353 if (testAnalytics) { 354 console.log('dataLayer.push:', { 355 'event': eventType, 356 'event_params': { search_term: searchInputValue }, 357 }); 358 } 359 } else { 360 // push the eventType and filtered eventParams to GTM and then set as undefined 361 dataLayer.push({ 362 'event': eventType,
363 'event_params': eventParams 364 }, {event_params: undefined}); 365 if (testAnalytics) { 366 console.log('dataLayer.push:', { 367 'event': eventType, 368 'event_params': eventParams, 369 }); 370 } 371 } 372 } 373}, true); 374 375// Consume structured validation data from drupalSettings attached by server-side 376// validation callbacks and emit GA4 error events. 377Drupal.behaviors.ssbSweepsValidationAnalytics = { 378 attach: function(context, settings) { 379 const validation = settings.ssbSweeps?.validation; 380 381 if (!validation || !validation.requestId) { 382 return; 383 } 384 385 if (validation.formId && !sweepsTrackedFormIds.includes(validation.formId)) { 386 return; 387 } 388 389 if (processedSweepsValidationRequests.has(validation.requestId)) { 390 return; 391 } 392 processedSweepsValidationRequests.add(validation.requestId); 393 394 // Validation errors must suppress pending step-success analytics. 395 pendingSweepsStepEvent = null; 396 397 // errors is emitted as a JSON string to avoid Drupal's AJAX deep-merge 398 // accumulating stale array entries. 399 let errors = []; 400 if (typeof validation.errors === 'string') { 401 try { 402 const parsed = JSON.parse(validation.errors || '[]'); 403 errors = Array.isArray(parsed) ? parsed : []; 404 } 405 catch (parseError) { 406 if (testAnalytics) { 407 console.warn('[Sweeps Validation Analytics] Invalid validation.errors payload', parseError); 408 } 409 errors = []; 410 } 411 } 412 413 errors.forEach((error) => { 414 pushSweepsValidationEvent(validation.step || '', error || {}); 415 }); 416 }, 417}; 418 419// Listen for successful file/video uploads on sweeps forms. 420// Uses Drupal.behaviors so it runs automatically after every AJAX response 421// (e.g., wizard page navigation, file widget replacement after upload). 422// When Drupal replaces the managed file widget after upload, the new element 423// arrives with fids already populated â we detect that and fire the event. 424Drupal.behaviors.sweepsUploadAnalytics = { 425 processed: new WeakSet(), 426 attach: function (context) { 427 const elements = (context || document).querySelectorAll('[data-upload-event]'); 428 elements.forEach((el) => { 429 if (this.processed.has(el)) return; 430 this.processed.add(el); 431 432 const fidsInput = el.querySelector('input[name$="[fids]"]'); 433 if (fidsInput && fidsInput.value) { 434 const uploadEvent = el.dataset.uploadEvent; 435 const eventParams = { form_name: 'Sweeps: ' + document.title }; 436 dataLayer.push({ 437 'event': uploadEvent,
438 'event_params': eventParams, 439 }, {event_params: undefined}); 440 if (testAnalytics) { 441 console.log('dataLayer.push:', { 442 'event': uploadEvent, 443 'event_params': eventParams, 444 }); 445 } 446 } 447 }); 448 }, 449}; 450 451// Fire deferred sweeps step events only after the next AJAX response succeeds 452// without rendering validation errors. 453Drupal.behaviors.sweepsStepCompletionAnalytics = { 454 processedConfirmations: new WeakSet(), 455 attach: function(context) { 456 if (!pendingSweepsStepEvent) { 457 return; 458 } 459 460 const { eventType, eventParams, sourceStepKey } = pendingSweepsStepEvent; 461 462 // Final submission only fires when confirmation appears, and only after 463 // two animation frames so the success state is fully rendered. 464 if (eventType === 'sweeps_final_submission') { 465 const root = context || document; 466 const confirmationElement = root.querySelector('.webform-confirmation'); 467 if (!confirmationElement || this.processedConfirmations.has(confirmationElement)) { 468 return; 469 } 470 471 this.processedConfirmations.add(confirmationElement); 472 pendingSweepsStepEvent = null; 473 474 requestAnimationFrame(() => { 475 requestAnimationFrame(() => { 476 removeSweepsWizardHashForAnalytics(); 477 dataLayer.push({ 478 'event': eventType,
479 'event_params': eventParams, 480 }, {event_params: undefined}); 481 482 if (testAnalytics) { 483 console.log('[Sweeps Step Analytics] Final event fired after render:', { 484 'event': eventType, 485 'event_params': eventParams, 486 }); 487 } 488 }); 489 }); 490 return; 491 } 492 493 const sweepsForm = getSweepsEntryForm(); 494 if (!sweepsForm) { 495 return; 496 } 497 498 const currentStepKey = getCurrentSweepsWebformKey(sweepsForm); 499 const errorContainer = sweepsForm.querySelector('#form-error-messages'); 500 const hasErrors = !!errorContainer && !!( 501 errorContainer.querySelector('[id^="error-message-"]') || 502 errorContainer.querySelector('.messages__item') 503 ); 504 505 const expectedNextStepKey = sweepsNextStepKeyMap[sourceStepKey]; 506 const didAdvanceStep = expectedNextStepKey === currentStepKey; 507 508 if (!didAdvanceStep) { 509 if (testAnalytics) { 510 console.log('[Sweeps Step Analytics] Not fired yet:', { 511 eventType, 512 sourceStepKey, 513 expectedNextStepKey, 514 currentStepKey, 515 hasErrors, 516 }); 517 } 518 return; 519 } 520 521 pendingSweepsStepEvent = null; 522 dataLayer.push({ 523 'event': eventType,
524 'event_params': eventParams, 525 }, {event_params: undefined}); 526 527 if (testAnalytics) { 528 console.log('[Sweeps Step Analytics] Step event fired:', { 529 'event': eventType, 530 'event_params': eventParams, 531 }); 532 } 533 }, 534};
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.