PageSourceSearch

https://www.fluke.com/modules/custom/igcommerce_sdc/components/gtm_datalayer/gtm_datalayer.js?tlsdxx

js fluke.com collected 2026-10-01 10:20:20 UTC 45,101 bytes, 1,192 lines download raw bytes

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.