https://www.fluke.com/modules/custom/igcommerce_sdc/components/gtm_datalayer/gtm_datalayer.js?tlsdxx
1/** 2 * @file 3 * GTM DataLayer SDC - Centralized GTM Service 4 * 5 * Consolidated GTM tracking for all modules including: 6 * - Product page views and CTA clicks 7 * - Where to Buy dropdown and PriceSpider widget interactions 8 */ 9 10(function ($, Drupal, once) { 11 'use strict'; 12 13 14 // Initialize dataLayer if it doesn't exist 15 window.dataLayer = window.dataLayer || []; 16 17 /** 18 * Centralized GTM DataLayer Service 19 */ 20 Drupal.gtmService = { 21 // Configuration constants 22 MAX_STRING_LENGTH: 500, 23 POLLING_MAX_ATTEMPTS: 20, 24 POLLING_INTERVAL_MS: 500, 25 PRICESPIDER_SETUP_DELAY_MS: 1000, 26 DUPLICATE_CLICK_THRESHOLD_MS: 500, 27 28 // Enable or disable debug logging 29 debugMode: false, 30 // Store for retailer ID to URL mapping 31 retailersMap: null, 32 // Flag indicating JSON mapping load in progress 33 retailersMapLoading: false, 34 35 /** 36 * Load retailer mapping from Drupal settings or JSON file 37 */ 38 loadRetailersMap: function() { 39 // Only load once 40 if (this.retailersMap !== null || this.retailersMapLoading) { 41 return; 42 } 43 this.retailersMapLoading = true; 44 45 // Try to get from Drupal settings first 46 if (typeof drupalSettings !== 'undefined' && 47 drupalSettings.gtmRetailersMap && 48 typeof drupalSettings.gtmRetailersMap === 'object') { 49 this.retailersMap = drupalSettings.gtmRetailersMap; 50 this.retailersMapLoading = false; 51 if (this.debugMode) { 52 console.log('GTM Service: Loaded retailers map from Drupal settings with', Object.keys(this.retailersMap).length, 'retailers'); 53 } 54 return; 55 } 56 57 // Fallback: load from JSON via AJAX with timeout and error handling 58 this.retailersMap = {}; 59 // Get JSON path from Drupal settings or use default 60 const jsonPath = (typeof drupalSettings !== 'undefined' && drupalSettings.gtmRetailersMapPath) 61 ? drupalSettings.gtmRetailersMapPath 62 : '/modules/custom/igcommerce_sdc/components/gtm_datalayer/retailers-map.json'; 63 64 $.ajax({ 65 url: jsonPath, 66 method: 'GET', 67 dataType: 'json', 68 timeout: 5000, 69 cache: true 70 }) 71 .done((data) => { 72 // Validate response is an object 73 if (data && typeof data === 'object') { 74 this.retailersMap = data; 75 if (this.debugMode) { 76 console.log('GTM Service: Loaded retailers map from JSON with', Object.keys(data).length, 'retailers'); 77 } 78 } else { 79 if (this.debugMode) { 80 console.warn('GTM Service: Invalid JSON response format'); 81 } 82 this.loadInlineRetailersMap(); 83 } 84 this.retailersMapLoading = false; 85 }) 86 .fail((jqxhr, textStatus, error) => { 87 if (this.debugMode) { 88 console.warn('GTM Service: Failed to load JSON retailers map:', textStatus, error); 89 } 90 this.retailersMapLoading = false; 91 this.loadInlineRetailersMap(); 92 }); 93 }, 94 95 /** 96 * Fallback: Load a subset of critical retailer mappings inline 97 */ 98 loadInlineRetailersMap: function() { 99 // Inline mapping for critical retailers (can be expanded as needed) 100 this.retailersMap = { 101 "25503650": { 102 "id": 25503650, 103 "name": "Rubix (UK)", 104 "url": "https://uk.rubix.com/en/search?text=fluke", 105 "countryCode": "GB" 106 }, 107 "25372284": { 108 "id": 25372284, 109 "name": "RS Components (AT)", 110 "url": "https://at.rs-online.com/web/c/?searchTerm=fluke&selectedNavigation=brands=Fluke", 111 "countryCode": "AT" 112 }, 113 "3522263": { 114 "id": 3522263, 115 "name": "RS Components (UK)", 116 "url": "https://uk.rs-online.com/web/c/?searchTerm=fluke&selectedNavigation=brands=Fluke", 117 "countryCode": "GB" 118 }, 119 "23369180": { 120 "id": 23369180, 121 "name": "Amazon (UK)", 122 "url": "https://www.amazon.co.uk/s?k=fluke&ref=nb_sb_noss_2", 123 "countryCode": "GB" 124 } 125 }; 126 this.retailersMapLoading = false;
127 if (this.debugMode) { 128 console.log('GTM Service: Using inline retailers map with', Object.keys(this.retailersMap).length, 'retailers'); 129 } 130 }, 131 132 /** 133 * Get retailer URL by ID, fallback to ID if not found 134 */ 135 getRetailerUrl: function(retailerId) { 136 // Validate input 137 if (!retailerId || typeof retailerId !== 'string') { 138 if (this.debugMode) { 139 console.warn('GTM Service: Invalid retailer ID provided:', retailerId); 140 } 141 return ''; 142 } 143 144 // Ensure map is loaded (synchronous for inline, async for JSON) 145 if (this.retailersMap === null) { 146 this.loadRetailersMap(); 147 } 148 149 // Handle case where retailersMap is still loading (async) 150 if (this.retailersMap === null) { 151 if (this.debugMode) { 152 console.warn('GTM Service: Retailers map still loading, using retailer ID as fallback:', retailerId); 153 } 154 return retailerId; 155 } 156 157 // Return URL if found in mapping, validate URL format 158 if (this.retailersMap && 159 this.retailersMap[retailerId] && 160 this.retailersMap[retailerId].url && 161 typeof this.retailersMap[retailerId].url === 'string') { 162 163 const url = this.retailersMap[retailerId].url; 164 // Basic URL validation 165 if (url.startsWith('http://') || url.startsWith('https://')) { 166 if (this.debugMode) { 167 console.log('GTM Service: Mapped retailer ID', retailerId, 'to URL:', url); 168 } 169 return url; 170 } else { 171 if (this.debugMode) { 172 console.warn('GTM Service: Invalid URL format for retailer:', retailerId, url); 173 } 174 } 175 } 176 177 // Fallback to the original ID if no mapping found 178 if (this.debugMode) { 179 console.warn('GTM Service: No URL mapping found for retailer ID:', retailerId, '- using ID as fallback'); 180 } 181 return retailerId; 182 }, 183 184 /** 185 * Initialize category dropdown tracking. 186 * 187 * Tracks the native <select> (#edit-category) used on the Where to Buy 188 * page. The Chosen jQuery plugin has been removed, so open/close and 189 * in-dropdown search events no longer exist for native selects; tracking 190 * is limited to selection changes and the CTA search button click. 191 */ 192 initCategoryDropdown: function(context) { 193 const self = this; 194 195 const $select = $('#edit-category', context); 196 if (!$select.length) { 197 return; 198 } 199 200 // Get dropdown context data. 201 function getDropdownContext() { 202 // Get the English value from the select element (not the translated display text). 203 let productFamilyVal = ''; 204 const selectedValue = $select.val(); 205 if (selectedValue && selectedValue !== '' && selectedValue !== '0') { 206 productFamilyVal = selectedValue; 207 } 208 209 // Get display text for selectedCategory from the selected option. 210 let selectedCategory = ''; 211 const selectedText = $select.find('option:selected').text(); 212 if (selectedText) { 213 selectedCategory = selectedText.trim(); 214 } 215 216 let fieldLabel = ''; 217 const $label = $("label[for='edit-category']"); 218 if ($label.length) { 219 fieldLabel = $label.text().trim(); 220 } 221 222 // Use display text for fieldLabel if appropriate. 223 const normalizedFieldLabel = fieldLabel.replace(/:$/, '').trim(); 224 if (selectedCategory && selectedCategory !== normalizedFieldLabel) { 225 fieldLabel = selectedCategory; 226 } 227 228 const productVal = self.getUrlParam('from'); 229 230 return { selectedCategory, fieldLabel, productFamilyVal, productVal }; 231 } 232 233 // Track category selection via the native select's change event. 234 $select.off('change.gtm-wtb').on('change.gtm-wtb', function() { 235 const ctx = getDropdownContext(); 236 self.logAndPush({ 237 description: 'Product - Where to Buy - Select product category - dropdown - select', 238 element: 'dropdown', 239 action: 'select', 240 actionValue: ctx.productFamilyVal, 241 productFamily: ctx.productFamilyVal, 242 product: ctx.productVal, 243 field: 'Select product category' 244 }); 245 }); 246 247 // CTA search button. 248 $(document).off('click.gtm-wtb').on('click.gtm-wtb', '#wtb-search-submit input', function() { 249 self.logAndPush({ 250 description: 'Product - Where to Buy - Select product category - CTA button - Search - Click', 251 element: 'cta_button', 252 action: 'click', 253 field: 'Select product category' 254 }); 255 }); 256 }, 257 258 /** 259 * Initialize PriceSpider tracking 260 */ 261 initPriceSpiderTracking: function() { 262 const self = this; 263 let isSetup = false;
264 265 // Strategy 1: MutationObserver 266 const observer = new MutationObserver(function(mutations) { 267 if (isSetup) return; 268 269 mutations.forEach(function(mutation) { 270 if (mutation.type === 'childList') { 271 mutation.addedNodes.forEach(function(node) { 272 if (node.nodeType === 1) { 273 const $node = $(node); 274 if ($node.find('.ps-online-seller-details-wrapper, .ps-last-online-seller-details-wrapper').length > 0) { 275 self.setupPriceSpiderListeners(); 276 isSetup = true; 277 } 278 } 279 }); 280 } 281 }); 282 }); 283 284 observer.observe(document.body, { childList: true, subtree: true }); 285 286 // Strategy 2: Polling fallback 287 let pollCount = 0; 288 const polling = setInterval(function() { 289 if (isSetup || pollCount++ > self.POLLING_MAX_ATTEMPTS) { 290 clearInterval(polling); 291 return; 292 } 293 294 if ($('.ps-online-seller-details-wrapper, .ps-last-online-seller-details-wrapper').length > 0) { 295 self.setupPriceSpiderListeners(); 296 isSetup = true; 297 clearInterval(polling); 298 } 299 }, self.POLLING_INTERVAL_MS); 300 301 // Strategy 3: Delayed check 302 $(document).ready(function() { 303 setTimeout(function() { 304 if (!isSetup && $('.ps-online-seller-details-wrapper, .ps-last-online-seller-details-wrapper').length > 0) { 305 self.setupPriceSpiderListeners(); 306 isSetup = true; 307 } 308 }, self.PRICESPIDER_SETUP_DELAY_MS); 309 }); 310 }, 311 312 /** 313 * Set up PriceSpider event listeners 314 */ 315 setupPriceSpiderListeners: function() { 316 const self = this; 317 $(document).off('.pricespider'); 318 319 function getPriceSpiderContext() { 320 // Get the English value from the select element (not the translated display text) 321 let productFamilyVal = ''; 322 const $selectElement = $('#edit-category'); 323 if ($selectElement.length) { 324 const selectedValue = $selectElement.val(); 325 if (selectedValue && selectedValue !== '') { 326 productFamilyVal = selectedValue; 327 } 328 } 329 330 const productVal = self.getUrlParam('from'); 331 332 return { productFamilyVal, productVal }; 333 } 334 335 // Helper function to check for location search errors after a search action 336 let lastErrorValue = ''; 337 function checkForLocationError() { 338 setTimeout(function () { 339 const $errorElement = $('.ps-location-error'); 340 if ($errorElement.length > 0) { 341 const computedStyle = window.getComputedStyle($errorElement[0]); 342 const isVisible = computedStyle.opacity === '1' || computedStyle.opacity === 1; 343 344 if (isVisible) { 345 const context = getPriceSpiderContext(); 346 const $textbox = $('.ps-map-location-textbox'); 347 const invalidValue = $textbox.val() || ''; 348 const $errorMessage = $('.ps-location-error-message'); 349 const errorMessage = $errorMessage.length ? $errorMessage.text().trim() : ''; 350 351 // Only track if there's an error message and it's different from last time 352 if (errorMessage && errorMessage !== '' && invalidValue !== lastErrorValue) { 353 self.logAndPush({ 354 description: 'Product - Where to Buy - Find Nearby - Search button - Click - Invalid', 355 element: 'cta_button', 356 action: 'invalid', 357 actionValue: invalidValue, 358 linkText: errorMessage, 359 linkURL: '', 360 product: context.productVal, 361 productFamily: context.productFamilyVal, 362 field: 'Find Nearby' 363 }); 364 365 lastErrorValue = invalidValue; 366 } 367 } else { 368 // Reset when error is not visible 369 lastErrorValue = ''; 370 } 371 } 372 }, 500); // Delay to allow PriceSpider to display the error 373 } 374 375 // Track online distributor clicks 376 // Use specific PriceSpider selectors to improve performance 377 $(document).on('mousedown.pricespider', '.ps-widget, .ps-online-seller-details-wrapper, .ps-last-online-seller-details-wrapper, .ps-online-seller-option', function(e) { 378 const $target = $(e.target); 379 const $closest = $target.closest('.ps-online-seller-details-wrapper, .ps-last-online-seller-details-wrapper, .ps-online-seller-option'); 380 381 if ($closest.length > 0) { 382 const retailerName = $closest.attr('data-retailer') || 383 $closest.find('[data-retailer]').first().attr('data-retailer') || 384 'Unknown'; 385 386 let actionValue = ''; 387 const $ctaBtn = $closest.find('.ps-online-buy-button, button').first(); 388 if ($ctaBtn.length) { 389 const $span = $ctaBtn.find('span').first(); 390 actionValue = $span.length ? $span.text().trim() : $ctaBtn.text().trim(); 391 } 392 393 const retailerId = $closest.attr('data-seller') || ''; 394 const linkURL = self.getRetailerUrl(retailerId); 395 const context = getPriceSpiderContext(); 396 397 self.logAndPush({ 398 description: 'Product - Where to Buy - Find Online - Distributor tile - Outbound link click', 399 element: 'link', 400 action: 'click', 401 linkText: retailerName, 402 linkURL: linkURL, 403 actionValue: actionValue, 404 product: context.productVal, 405 productFamily: context.productFamilyVal, 406 field: 'Find Online' 407 }); 408 409 e.stopPropagation(); 410 } 411 }); 412 413 // Track Find Nearby geolocation button click 414 // Use closure to prevent duplicate tracking 415 (function () { 416 let lastGeolocationTrackTime = 0; 417 418 $(document).on('click.pricespider', '.ps-map-geolocation-button', function (e) { 419 const currentTime = Date.now(); 420 421 // Prevent duplicate tracking within threshold 422 if (currentTime - lastGeolocationTrackTime < self.DUPLICATE_CLICK_THRESHOLD_MS) { 423 return; 424 } 425 lastGeolocationTrackTime = currentTime; 426 427 const context = getPriceSpiderContext(); 428 429 self.logAndPush({ 430 description: 'Product - Where to Buy - Find Nearby - Geolocation button - Click', 431 element: 'button', 432 action: 'click', 433 actionValue: 'Use Current Location', 434 product: context.productVal, 435 productFamily: context.productFamilyVal, 436 field: 'Find Nearby' 437 }); 438 }); 439 })(); 440 441 // Track Find Nearby location textbox focus and blur 442 // Track when user enters the field and when they leave with final value 443 (function () { 444 let hasFocusedTextbox = false;
445 446 // Track focus event - fires when user enters the field 447 $(document).on('focus.pricespider', '.ps-map-location-textbox', function (e) { 448 if (!hasFocusedTextbox) { 449 hasFocusedTextbox = true; 450 451 const context = getPriceSpiderContext(); 452 const currentValue = $(this).val() || ''; 453 454 self.logAndPush({ 455 description: 'Product - Where to Buy - Find Nearby - map location textbox - Focus', 456 element: 'textbox', 457 action: 'focus', 458 actionValue: currentValue, 459 product: context.productVal, 460 productFamily: context.productFamilyVal, 461 field: 'Find Nearby' 462 }); 463 } 464 }); 465 466 // Track blur event - fires when user leaves the field with final value 467 $(document).on('blur.pricespider', '.ps-map-location-textbox', function (e) { 468 if (hasFocusedTextbox) { 469 const context = getPriceSpiderContext(); 470 const finalValue = $(this).val() || ''; 471 472 self.logAndPush({ 473 description: 'Product - Where to Buy - Find Nearby - map location textbox - Input in field', 474 element: 'textbox', 475 action: 'blur', 476 actionValue: finalValue, 477 product: context.productVal, 478 productFamily: context.productFamilyVal, 479 field: 'Find Nearby' 480 }); 481 482 // Reset flag after tracking blur 483 hasFocusedTextbox = false; 484 } 485 }); 486 })(); 487 488 // Track Find Nearby location textbox Enter key press (initiates search) 489 $(document).on('keydown.pricespider', '.ps-map-location-textbox', function(e) { 490 // Enter key 491 if (e.key === 'Enter') { 492 const context = getPriceSpiderContext(); 493 const searchValue = $(this).val() || ''; 494 495 self.logAndPush({ 496 description: 'Product - Where to Buy - Find Nearby - Location textbox - Enter key search', 497 element: 'textbox', 498 action: 'keydown_enter', 499 actionValue: searchValue, 500 product: context.productVal, 501 productFamily: context.productFamilyVal, 502 field: 'Find Nearby' 503 }); 504 505 // Check for errors after search completes 506 checkForLocationError(); 507 } 508 }); 509 510 // Track Find Nearby search button click 511 // Use closure to prevent duplicate tracking 512 (function () { 513 let lastSearchButtonTrackTime = 0; 514 515 $(document).on('click.pricespider', '.ps-map-location-button', function (e) { 516 const currentTime = Date.now(); 517 518 // Prevent duplicate tracking within threshold 519 if (currentTime - lastSearchButtonTrackTime < self.DUPLICATE_CLICK_THRESHOLD_MS) { 520 return; 521 } 522 lastSearchButtonTrackTime = currentTime; 523 524 const context = getPriceSpiderContext(); 525 const $textbox = $('.ps-map-location-textbox'); 526 const searchValue = $textbox.val() || ''; 527 528 self.logAndPush({ 529 description: 'Product - Where to Buy - Find Nearby - Search button - Click', 530 element: 'button', 531 action: 'click', 532 actionValue: searchValue, 533 product: context.productVal, 534 productFamily: context.productFamilyVal, 535 field: 'Find Nearby' 536 }); 537 538 // Check for errors after search completes 539 checkForLocationError(); 540 }); 541 })(); 542 543 // Track Find Nearby outbound email clicks 544 $(document).on('click.pricespider', '.ps-store-badge a[href^="mailto:"]', function(e) { 545 const context = getPriceSpiderContext(); 546 const emailAddress = $(this).attr('href').replace('mailto:', ''); 547 const linkText = $(this).find('.ps-badge-text').text().trim() || emailAddress; 548 549 self.logAndPush({ 550 description: 'Product - Where to Buy - Find Nearby - Email link - Outbound link click', 551 element: 'link', 552 action: 'click', 553 linkText: linkText, 554 linkURL: emailAddress, 555 actionValue: emailAddress, 556 product: context.productVal, 557 productFamily: context.productFamilyVal, 558 field: 'Find Nearby' 559 }); 560 }); 561 562 // Track Find Nearby View Online and Get Directions button clicks 563 // Use closure to encapsulate tracking state and prevent global pollution 564 (function() { 565 let lastTrackedButton = null; 566 let lastTrackedTime = 0; 567 568 $(document).on('mousedown.pricespider click.pricespider', '.ps-local-seller-button, .ps-get-directions-button', function(e) { 569 const $button = $(this); 570 const buttonText = $button.text().trim(); 571 const context = getPriceSpiderContext(); 572 const ariaLabel = $button.attr('aria-label') || ''; 573 const currentTime = Date.now(); 574 575 // Create more robust button ID for duplicate detection 576 const buttonId = [ 577 $button.attr('class') || '', 578 buttonText, 579 ariaLabel, 580 $button.closest('.ps-local-seller-item, .ps-store-item').index() || 0 581 ].join('|'); 582 583 // Prevent duplicate tracking within threshold of the same button 584 if (lastTrackedButton === buttonId && (currentTime - lastTrackedTime) < self.DUPLICATE_CLICK_THRESHOLD_MS) { 585 return; 586 } 587 588 // Process mousedown for ps-local-seller-button (View Online) since click events don't fire 589 // Process click for ps-get-directions-button (normal behavior) 590 const shouldProcess = ($button.hasClass('ps-local-seller-button') && e.type === 'mousedown') || 591 ($button.hasClass('ps-get-directions-button') && e.type === 'click'); 592 593 if (shouldProcess) { 594 lastTrackedButton = buttonId; 595 lastTrackedTime = currentTime; 596 597 // Debug logging 598 if (self.debugMode) { 599 console.log('[GTM Debug] Button interaction tracked:', { 600 eventType: e.type, 601 buttonClass: $button.attr('class'), 602 buttonText: buttonText, 603 ariaLabel: ariaLabel.substring(0, 100) // Limit log size 604 }); 605 } 606 607 // Extract clean link text 608 const linkText = $button.find('span').not('.ps-mobile').first().text().trim() || 609 ($button.hasClass('ps-get-directions-button') ? 'Get Directions' : 'View Online'); 610 611 // Check for Get Directions button 612 if ($button.hasClass('ps-get-directions-button') || buttonText.includes('Directions')) { 613 self.logAndPush({ 614 description: 'Product - Where to Buy - Find Nearby - Get Directions button - Click', 615 element: 'button', 616 action: 'click', 617 linkText: linkText, 618 linkURL: ariaLabel, 619 actionValue: linkText, 620 product: context.productVal, 621 productFamily: context.productFamilyVal, 622 field: 'Find Nearby' 623 }); 624 } 625 // Check for View Online button 626 else if ($button.hasClass('ps-local-seller-button') && (buttonText.includes('View Online') || buttonText.includes('View'))) { 627 self.logAndPush({ 628 description: 'Product - Where to Buy - Find Nearby - View Online button - Click', 629 element: 'button', 630 action: 'click', 631 linkText: linkText, 632 linkURL: ariaLabel, 633 actionValue: linkText, 634 product: context.productVal, 635 productFamily: context.productFamilyVal, 636 field: 'Find Nearby' 637 }); 638 } 639 } 640 }); 641 })(); 642 643 // Alternative approach: Listen for keyboard events on these buttons (since they have tabindex) 644 $(document).on('keydown.pricespider', '.ps-local-seller-button, .ps-get-directions-button', function(e) { 645 // Enter or Space key activation 646 if (e.key === 'Enter' || e.key === ' ') { 647 const $button = $(this); 648 const buttonText = $button.text().trim(); 649 const context = getPriceSpiderContext(); 650 const ariaLabel = $button.attr('aria-label') || ''; 651 652 if (self.debugMode) { 653 console.log('[GTM Debug] Keyboard activation detected:', { 654 key: e.which, 655 buttonClass: $button.attr('class'), 656 buttonText: buttonText 657 }); 658 } 659 660 // Check for Get Directions button 661 if ($button.hasClass('ps-get-directions-button') || buttonText.includes('Directions')) { 662 const linkText = $button.find('span').not('.ps-mobile').first().text().trim() || 'Get Directions'; 663 664 self.logAndPush({ 665 description: 'Product - Where to Buy - Find Nearby - Get Directions button - Keyboard activation', 666 element: 'button', 667 action: 'keydown', 668 linkText: linkText, 669 linkURL: ariaLabel, 670 actionValue: linkText, 671 product: context.productVal, 672 productFamily: context.productFamilyVal, 673 field: 'Find Nearby' 674 }); 675 } 676 // Check for View Online button 677 else if ($button.hasClass('ps-local-seller-button') && (buttonText.includes('View Online') || buttonText.includes('View'))) { 678 const linkText = $button.find('span').not('.ps-mobile').first().text().trim() || 'View Online'; 679 680 self.logAndPush({ 681 description: 'Product - Where to Buy - Find Nearby - View Online button - Keyboard activation', 682 element: 'button', 683 action: 'keydown', 684 linkText: linkText, 685 linkURL: ariaLabel, 686 actionValue: linkText, 687 product: context.productVal, 688 productFamily: context.productFamilyVal, 689 field: 'Find Nearby' 690 }); 691 } 692 } 693 }); 694 }, 695 696 /** 697 * Core method to log and push events to GTM dataLayer 698 */ 699 logAndPush: function(eventData) { 700 // Validate input 701 if (!eventData || typeof eventData !== 'object') { 702 if (this.debugMode) { 703 console.warn('[GTM] Invalid event data provided:', eventData); 704 } 705 return; 706 } 707 708 // Sanitize user-input values to prevent XSS (only actionValue contains user input) 709 const sanitizeUserInput = (value) => { 710 if (typeof value !== 'string') return value; 711 // Remove potentially dangerous characters and limit length 712 return value 713 .replace(/[<>"']/g, '') 714 .trim() 715 .substring(0, this.MAX_STRING_LENGTH); 716 }; 717 718 // Simple validation for trusted string values (just trim and limit length) 719 const validateString = (value) => { 720 if (typeof value !== 'string') return value; 721 return value.trim().substring(0, this.MAX_STRING_LENGTH); 722 }; 723 724 // Set default values for common GTM schema fields 725 // Only sanitize actionValue (user input), validate others (trusted sources) 726 const gtmEvent = { 727 event: validateString(eventData.event) || 'product', 728 description: validateString(eventData.description) || '', 729 productFamily: validateString(eventData.productFamily) || '', 730 product: validateString(eventData.product) || '', 731 component: validateString(eventData.component) || 'where_to_buy', 732 element: validateString(eventData.element) || '', 733 field: validateString(eventData.field) || '', 734 action: validateString(eventData.action) || '', 735 actionValue: sanitizeUserInput(eventData.actionValue) || '', // User input - sanitize 736 linkText: validateString(eventData.linkText) || '', 737 linkURL: validateString(eventData.linkURL) || '', // Trusted retailer URL - no sanitization 738 'lang-locale': validateString(eventData['lang-locale']) || document.documentElement.lang || 'en' 739 }; 740 741 // Add any additional custom properties from eventData
742 Object.keys(eventData).forEach(key => { 743 if (!(key in gtmEvent)) { 744 gtmEvent[key] = validateString(eventData[key]); 745 } 746 }); 747 748 // Debug logging BEFORE pushing (so you see what's being sent) 749 if (this.debugMode) { 750 console.log('[GTM Debug] Event pushed to dataLayer:', { 751 event: gtmEvent.event, 752 description: gtmEvent.description, 753 productFamily: gtmEvent.productFamily, 754 product: gtmEvent.product 755 }); 756 } 757 758 // Ensure dataLayer exists and push event 759 if (window.dataLayer && Array.isArray(window.dataLayer)) { 760 window.dataLayer.push(gtmEvent); 761 } else { 762 console.error('[GTM] dataLayer is not available or not an array'); 763 } 764 }, 765 766 /** 767 * Get product family from breadcrumbs with error handling 768 */ 769 getProductFamilyFromBreadcrumbs: function() { 770 try { 771 const breadcrumbsNav = document.querySelector('#breadcrumbs ol'); 772 if (!breadcrumbsNav) return ''; 773 774 const breadcrumbItems = breadcrumbsNav.querySelectorAll('li'); 775 if (!breadcrumbItems || breadcrumbItems.length === 0) return ''; 776 777 const lastBreadcrumb = breadcrumbItems[breadcrumbItems.length - 1]; 778 if (!lastBreadcrumb) return ''; 779 780 const link = lastBreadcrumb.querySelector('a'); 781 if (link && link.textContent) { 782 return link.textContent.trim(); 783 } 784 785 // Safe fallback 786 const textContent = lastBreadcrumb.textContent; 787 return textContent ? textContent.replace(/\s*âº\s*$/, '').trim() : ''; 788 } catch (error) { 789 console.error('[GTM] Error getting product family from breadcrumbs:', error); 790 return ''; 791 } 792 }, 793 794 /** 795 * Get URL parameter value 796 */ 797 getUrlParam: function(param) { 798 try { 799 const urlParams = new URLSearchParams(window.location.search); 800 return urlParams.get(param) || ''; 801 } catch (e) { 802 return ''; 803 } 804 }, 805 806 /** 807 * Get English event label from CTA class list 808 */ 809 getEventLabelFromClassList: function (classList) { 810 if (!classList || !classList.length) return ''; 811 const eventLabelMap = { 812 'event-apply-cta': 'Apply', 813 'event-buy-it-cta': 'Buy it', 814 'event-buy-now-cta': 'Buy now', 815 'event-check-cta': 'Check availability', 816 'event-contact-me-cta': 'Contact me', 817 'event-contact-us-cta': 'Contact us', 818 'event-distributor-cta': 'Find a distributor', 819 'event-download-free-cta': 'Download for free', 820 'event-download-free-app-note-cta': 'Download the free application note', 821 'event-download-guide-cta': 'Download the guide', 822 'event-download-guidebook-cta': 'Download the guidebook', 823 'event-download-now-cta': 'Download now', 824 'event-download-page-cta': 'Go to the download page', 825 'event-download-prem-care-flyer-cta': 'Download Fluke Premium Care flyer', 826 'event-get-assistance-cta': 'Get assistance', 827 'event-get-a-free-demo-cta': 'Get a free demo', 828 'event-get-started-cta': 'Get started', 829 'event-kmi-fcal-cta': 'Keep me informed', 830 'event-learn-how-cta': 'Learn how', 831 'event-learn-more-cta': 'Learn more', 832 'event-more-info-cta': 'More info', 833 'event-need-help-ti-cta': 'Need help? Find the right thermal camera', 834 'event-newsletter-signup': 'Sign up for the newsletter', 835 'event-product-updates-cta': 'Get product updates', 836 'event-register-cta': 'Register', 837 'event-register-now-cta': 'Register now', 838 'event-request-demo-cta': 'Request a demo', 839 'event-request-quote-cta': 'Request a quote', 840 'event-requets-fcal-service-cta': 'Request for Service / Service Information', 841 'event-seminar-cta': 'Seminar', 842 'event-shop-now-cta': 'Shop now', 843 'event-speak-specialist-cta': 'Speak with a specialist', 844 'event-talk-expert-cta': 'Talk to an expert', 845 'event-talk-specialist-cta': 'Talk to a specialist', 846 'event-talk-to-a-specialist-cta': 'Talk to a specialist', 847 'event-talk-to-an-expert-cta': 'Talk to an expert', 848 'event-venue-details-cta': 'Venue details', 849 'event-visit-portal-cta': 'Visit portal', 850 'event-watch-now-cta': 'Watch now', 851 'event-watch-video-cta': 'Watch the video', 852 'event-where-buy-cta': 'Where to buy', 853 'toc-cta-selector-tool-ti': 'Find the right thermal camera', 854 'toc-cta-selector-tool-cal': 'Find the right process calibration tool', 855 'toc-cta-selector-tool-dmm': 'Find the right digital multimeter', 856 'toc-cta-selector-tool-clmp': 'Find the right clamp meter' 857 }; 858 859 for (const cls of classList) { 860 if (eventLabelMap[cls]) { 861 return eventLabelMap[cls]; 862 } 863 } 864 865 return ''; 866 }, 867 868 /** 869 * Get product and/or product family from x-default link 870 * @param {string} pageType - 'product' for individual product pages, 'products' for category/TOC pages 871 * @returns {object} Object with productFamily and product properties 872 */ 873 getProductInfoFromXDefault: function (pageType) { 874 const result = { productFamily: '', product: '' }; 875
876 try { 877 const xDefaultLink = document.querySelector('link[rel="alternate"][hreflang="x-default"]'); 878 if (!xDefaultLink) return result; 879 880 const href = xDefaultLink.getAttribute('href') || ''; 881 if (!href) return result; 882 883 // Check for the appropriate page type 884 const searchString = pageType === 'products' ? '/products/' : '/product/'; 885 if (!href.includes(searchString)) return result; 886 887 const urlParts = href.split('/').filter(part => part.length > 0); 888 const lastSegment = urlParts[urlParts.length - 1] || ''; 889 const secondLastSegment = urlParts[urlParts.length - 2] || ''; 890 891 // Format helper: replace dashes with spaces and capitalize words 892 const formatSegment = (segment) => { 893 if (!segment) return ''; 894 return segment.split('-').map(word => 895 word.charAt(0).toUpperCase() + word.slice(1) 896 ).join(' '); 897 }; 898 899 if (pageType === 'products') { 900 // For TOC pages, only get product family from last segment 901 result.productFamily = formatSegment(lastSegment); 902 } else { 903 // For product pages, get both product (last) and product family (second-to-last) 904 result.product = formatSegment(lastSegment); 905 result.productFamily = formatSegment(secondLastSegment); 906 } 907 } catch (error) { 908 if (this.debugMode) { 909 console.error('[GTM] Error parsing x-default link:', error); 910 } 911 } 912 913 return result; 914 }, 915 916 /** 917 * Track product page views 918 */ 919 trackProductView: function(productFamily, product, additionalData = {}) { 920 this.logAndPush({ 921 event: 'product_view', 922 description: 'Product family page visited', 923 productFamily: productFamily, 924 product: product, 925 ...additionalData 926 }); 927 }, 928 929 /** 930 * Clean up event listeners and observers 931 */ 932 cleanup: function() { 933 // Remove event listeners 934 $(document).off('.pricespider'); 935 $(document).off('.gtm-wtb'); 936 937 // Clean up mutation observers 938 if (window.gtmObservers && Array.isArray(window.gtmObservers)) { 939 window.gtmObservers.forEach(observer => { 940 if (observer && typeof observer.disconnect === 'function') { 941 observer.disconnect(); 942 } 943 }); 944 window.gtmObservers = []; 945 } 946 947 // Reset state 948 this.retailersMap = null; 949 this.retailersMapLoading = false; 950 } 951 }; 952 953 /** 954 * Product Page View Tracking Behavior 955 */ 956 Drupal.behaviors.gtmProductPageView = { 957 attach: function(context, settings) { 958 if (context !== document) return; 959 960 // Wait for GTM service to be fully initialized 961 if (!Drupal.gtmService) { 962 const self = this; 963 setTimeout(() => { 964 if (Drupal.gtmService) { 965 self.attach(context, settings); 966 } 967 }, 100); 968 return; 969 } 970 971 once('gtm-product-view', 'body', context).forEach(function(element) { 972 // Look for product page indicators - try multiple selectors 973 const productContainer = document.querySelector('#fluke-product-display') || 974 document.querySelector('#fluke-product-atf-ctas') || 975 document.querySelector('.page-product'); 976 977 if (Drupal.gtmService.debugMode) { 978 console.log('[GTM] Product page detection:', { 979 'fluke-product-display': !!document.querySelector('#fluke-product-display'), 980 'fluke-product-atf-ctas': !!document.querySelector('#fluke-product-atf-ctas'), 981 'page-product': !!document.querySelector('.page-product'), 982 'selected': productContainer?.id || productContainer?.className || 'none' 983 }); 984 } 985 986 if (!productContainer) { 987 if (Drupal.gtmService.debugMode) { 988 console.log('[GTM] No product container found - not a product page'); 989 } 990 return; 991 } 992 993 // Try to get English product family and product name from x-default link 994 const xDefaultInfo = Drupal.gtmService.getProductInfoFromXDefault('product'); 995 let productFamily = xDefaultInfo.productFamily; 996 let product = xDefaultInfo.product; 997 998 // Fallback to existing sources if x-default didn't work 999 if (!productFamily) { 1000 productFamily = Drupal.gtmService.getProductFamilyFromBreadcrumbs(); 1001 } 1002 if (!product) {
1003 product = productContainer?.dataset?.product || 1004 document.querySelector('#fluke-product-display-title')?.textContent?.trim() || 1005 document.querySelector('h1')?.textContent?.trim() || ''; 1006 } 1007 1008 const langLocale = document.documentElement.lang || 'en'; 1009 1010 if (Drupal.gtmService.debugMode) { 1011 console.log('[GTM] Product page data extracted:', { 1012 productFamily: productFamily, 1013 product: product, 1014 langLocale: langLocale, 1015 source: 'gtmProductPageView behavior' 1016 }); 1017 } 1018 1019 Drupal.gtmService.trackProductView(productFamily, product, { 1020 component: 'product_display', 1021 'lang-locale': langLocale 1022 }); 1023 }); 1024 } 1025 }; 1026 1027 /** 1028 * Product Category/TOC Page View Tracking Behavior 1029 */ 1030 Drupal.behaviors.gtmProductCategoryPageView = { 1031 attach: function(context, settings) { 1032 if (context !== document) return; 1033 1034 // Wait for GTM service to be fully
1034initialized 1035 if (!Drupal.gtmService) { 1036 const self = this; 1037 setTimeout(() => { 1038 if (Drupal.gtmService) { 1039 self.attach(context, settings); 1040 } 1041 }, 100); 1042 return; 1043 } 1044 1045 once('gtm-product-category-view', 'body', context).forEach(function (element) { 1046 // Try to get English product family from x-default link 1047 const xDefaultInfo = Drupal.gtmService.getProductInfoFromXDefault('products'); 1048 let productFamily = xDefaultInfo.productFamily; 1049 1050 // Fallback to h1.toc-title if x-default link not found or returned empty 1051 if (!productFamily) { 1052 const tocTitle = document.querySelector('h1.toc-title'); 1053 if (tocTitle) { 1054 productFamily = tocTitle.textContent?.trim() || ''; 1055 } 1056 } 1057 1058 // If still no product family found, not a product category page 1059 if (!productFamily) { 1060 if (Drupal.gtmService.debugMode) { 1061 console.log('[GTM] No product family found - not a product category page'); 1062 } 1063 return; 1064 } 1065 const product = Drupal.gtmService.getUrlParam('from'); // Only get product from querystring 1066 const langLocale = document.documentElement.lang || 'en'; 1067 1068 if (Drupal.gtmService.debugMode) { 1069 console.log('[GTM] Product category page data extracted:', { 1070 productFamily: productFamily, 1071 product: product, 1072 langLocale: langLocale, 1073 source: 'gtmProductCategoryPageView behavior' 1074 }); 1075 } 1076 1077 // Use product_family_view event for TOC/category pages - FAMS:1176, should pull english. 1078 Drupal.gtmService.logAndPush({ 1079 event: 'product_family_view', 1080 description: 'Product family page visited', 1081 component: 'product_family', 1082 productFamily: productFamily, 1083 product: product, 1084 'lang-locale': langLocale 1085 }); 1086 }); 1087 } 1088 }; 1089 1090 /** 1091 * Product CTA Click Tracking Behavior 1092 */ 1093 Drupal.behaviors.gtmProductCTAs = { 1094 attach: function(context) { 1095 once('gtm-product-ctas', '#fluke-product-display-ctas', context).forEach(function(element) { 1096 const $root = $(element); 1097 1098 // Try to get English product family and product name from x-default link 1099 const xDefaultInfo = Drupal.gtmService.getProductInfoFromXDefault('product'); 1100 let productFamily = xDefaultInfo.productFamily; 1101 let product = xDefaultInfo.product; 1102 1103 // Fallback to existing sources if x-default didn't work 1104 if (!productFamily) { 1105 productFamily = Drupal.gtmService.getProductFamilyFromBreadcrumbs(); 1106 } 1107 if (!product) { 1108 product = element?.dataset?.product || ''; 1109 } 1110 1111 $root.on('click', 'a', function(event) { 1112 const linkURL = this.getAttribute('href') || ''; 1113 const linkText = (this.getAttribute('data-event-label') || this.textContent || '').trim(); 1114 1115 Drupal.gtmService.logAndPush({ 1116 event: 'product', 1117 description: `Product - CTA button - ${linkText || 'Unknown'} - Click`, 1118 component: 'product_display', 1119 element: 'cta_button', 1120 action: 'click', 1121 productFamily: productFamily, 1122 product: product, 1123 linkText: linkText, 1124 linkURL: linkURL 1125 }); 1126 }); 1127 }); 1128 } 1129 }; 1130 1131 1132 /** 1133 * Duplicate behavior removed - all functionality now in Drupal.gtmService 1134 * This eliminates duplicate event firing (open/open, select/select, etc.) 1135 */ 1136 1137 /** 1138 * Main GTM DataLayer behavior - initializes the service 1139 */ 1140 Drupal.behaviors.gtmDataLayer = { 1141 attach: function(context, settings) { 1142 once('gtm-service', 'body', context).forEach(function(element) { 1143 // Enable debug mode from data attributes 1144 if (element.dataset.gtmDebug === 'true') { 1145 Drupal.gtmService.debugMode = true; 1146 } 1147 1148 // Add data-event-label to CTAs missing it 1149 const ctaLinks = element.querySelectorAll('a[class*="event-"], a[class*="toc-cta-selector-tool-"]'); 1150 ctaLinks.forEach(function (link) { 1151 if (!link.hasAttribute('data-event-label')) { 1152 const label = Drupal.gtmService.getEventLabelFromClassList(link.classList); 1153 if (label) { 1154 link.setAttribute('data-event-label', label); 1155 } 1156 } 1157 }); 1158 // Initialize retailers mapping 1159 Drupal.gtmService.loadRetailersMap(); 1160 1161 // Set up Where to Buy tracking behaviors 1162 Drupal.gtmService.initCategoryDropdown(context); 1163 Drupal.gtmService.initPriceSpiderTracking(); 1164 1165 // Process any GTM SDC template instances with error handling 1166 const gtmElements = element.querySelectorAll('[data-gtm-datalayer]');
1167 gtmElements.forEach(function(gtmElement) { 1168 try { 1169 const eventDataString = gtmElement.getAttribute('data-gtm-event-data') || '{}'; 1170 const eventData = JSON.parse(eventDataString); 1171 const eventName = gtmElement.getAttribute('data-gtm-event'); 1172 const enableDebug = gtmElement.getAttribute('data-gtm-debug') === 'true'; 1173 1174 if (enableDebug) { 1175 Drupal.gtmService.debugMode = true; 1176 } 1177 1178 if (eventName && typeof eventName === 'string') { 1179 Drupal.gtmService.logAndPush({ 1180 event: eventName, 1181 ...eventData 1182 }); 1183 } 1184 } catch (error) { 1185 console.error('[GTM] Error processing GTM element:', error, gtmElement); 1186 } 1187 }); 1188 }); 1189 } 1190 }; 1191 1192})(jQuery, Drupal, once);
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.