1/** 2 * Google Analytics 4 Ecommerce Tracking Helpers 3 * Namespace: window.eviivo.googleAnalytics 4 * 5 * Provides consistent item object construction for client-side tracking. 6 * This helper ensures that Google Analytics 4 ecommerce items are tracked consistently 7 * across all events (view_item, view_item_list, select_item, add_to_cart, etc.) 8 */ 9(function (window, $) { 10 'use strict'; 11 12 // Initialize eviivo namespace 13 window.eviivo = window.eviivo || {}; 14 window.eviivo.googleAnalytics = window.eviivo.googleAnalytics || {}; 15 16 /** 17 * Helper function to safely decode HTML entities in a string value 18 * Uses eviivo.util.decodedHtml if available, falls back to raw string 19 * 20 * @param {*} value - The value to decode (will be converted to string) 21 * @returns {string} The decoded string value, or original string if decoding unavailable 22 */ 23 function decodeHtmlString(value) { 24 if (value === null || value === undefined) { 25 return ''; 26 } 27 const stringValue = String(value); 28 return window.eviivo?.util?.decodedHtml 29 ? window.eviivo.util.decodedHtml(stringValue) 30 : stringValue; 31 } 32 33 /** 34 * Builds a Google Analytics 4 ecommerce item object with validation 35 * Constructs a standardized item object for GA4 event tracking 36 * 37 * GA4 Item Fields (all items must have these): 38 * - item_id: Room type unique identifier 39 * - item_name: Room display name (HTML entities will be decoded) 40 * - item_brand: Property name (branding) (HTML entities will be decoded) 41 * - price: Room rate/price 42 * - quantity: Default 1 (qty booked) 43 * 44 * Optional enrichment fields: 45 * - item_variant: Rate plan name (highly recommended for proper ecommerce tracking) (HTML entities will be decoded) 46 * - item_category: GA4 item category (e.g., 'Unit') (HTML entities will be decoded) 47 * - item_category2: GA4 secondary category (e.g., 'Vacation Rental') (HTML entities will be decoded) 48 * - index: Position in list (auto-set if not provided) 49 * - discount: Discount amount applied 50 * - affiliation: Referrer/channel attribution (HTML entities will be decoded) 51 * 52 * HTML Entity Decoding: 53 * All string fields (itemName, itemBrand, itemVariant, itemCategory, itemCategory2, itemAffiliation) 54 * are automatically decoded to convert HTML entities (', &, ", ô, etc.) to their actual characters. 55 * This is necessary because Razor templates HTML-encode data attributes for security, and GA4 56 * expects the decoded values. Decoding is only performed if eviivo.util.decodedHtml is available. 57 * 58 * Note: Check-in/check-out dates belong at event level, not on items 59 * 60 * @param {Object} options - Item properties 61 * @param {number} options.itemId - Required: Unique item identifier 62 * @param {string} options.itemName - Required: Item display name (will be decoded) 63 * @param {string} options.itemBrand - Required: Brand name (property name) (will be decoded) 64 * @param {number} options.price - Required: Item price 65 * @param {string} [options.itemVariant] - Optional but recommended: Item variant (rate plan name) (will be decoded) 66 * @param {number} [options.quantity=1] - Quantity 67 * @param {string} [options.itemCategory] - Optional: Item category (will be decoded) 68 * @param {string} [options.itemCategory2] - Optional: Item secondary category (will be decoded) 69 * @param {number} [options.discount] - Optional: Discount amount 70 * @param {number} [options.index] - Optional: Item position in list 71 * @param {string} [options.affiliation] - Optional: Affiliation (referrer/channel) (will be decoded) 72 * @param {string} [options.itemAffiliation] - Optional: Point of sale name (will be decoded) 73 * @returns {Object|null} GA4 item object or null if validation fails 74 */ 75 function buildItemObject(options) { 76 // Validate core required fields (id, name, brand are mandatory) 77 // item_variant is highly recommended but optional due to UI limitations in non-search mode 78 const { itemId, itemName, itemBrand, itemVariant, ...rest } = options || {}; 79 80 if (!itemId || !itemName || !itemBrand) { 81 console.warn('GA4 Helper: Missing required fields. itemId, itemName, and itemBrand are required for GA4 tracking.'); 82 return null; 83 } 84 85 // Log warning if item_variant is missing - it should be populated for proper ecommerce tracking 86 if (!itemVariant) {
87 console.warn('GA4 Helper: itemVariant (rate plan name) is missing - GA4 ecommerce tracking will have incomplete item data. This may occur in non-search modes where rate plan data is unavailable.'); 88 } 89 90 // Decode all HTML entities from string fields 91 // Razor automatically encodes special characters in data attributes for security (XSS prevention) 92 // We decode here before sending to GA4 to ensure proper data representation 93 const item = { 94 item_id: Number(itemId), 95 item_name: decodeHtmlString(itemName), 96 item_brand: decodeHtmlString(itemBrand), 97 price: Number(rest.price) || 0, 98 quantity: Number(rest.quantity) || 1 99 }; 100 101 // Add item_variant only if provided (optional in UI tracking, but highly recommended) 102 if (itemVariant) { 103 item.item_variant = decodeHtmlString(itemVariant); 104 } 105 106 if (rest.itemCategory) { 107 item.item_category = decodeHtmlString(rest.itemCategory); 108 } 109 110 if (rest.itemCategory2) { 111 item.item_category2 = decodeHtmlString(rest.itemCategory2); 112 } 113 114 if (rest.discount && Number(rest.discount) > 0) { 115 item.discount = Number(rest.discount); 116 } 117 118 if (typeof rest.index !== 'undefined' && rest.index !== null) { 119 item.index = Number(rest.index); 120 } 121 122 if (rest.itemAffiliation) { 123 item.affiliation = decodeHtmlString(rest.itemAffiliation); 124 } 125 126 return item; 127 } 128 129 /** 130 * Builds a GA4 item object from DOM data attributes 131 * Used for client-side event tracking (click events on room selection buttons) 132 * 133 * Expected DOM attributes on element: 134 * - data-room-type-id: Room type ID (required) 135 * - data-item-name: Room name (required) - will be HTML-decoded 136 * - data-rate-plan-name: Rate plan variant (recommended) - will be HTML-decoded 137 * - data-price: Room price (required) 138 * - data-discount: Discount amount (optional) 139 * 140 * HTML entities in data attributes (e.g., ', &, ") are automatically decoded 141 * before sending to GA4, since Razor encodes these for HTML security. 142 * 143 * @param {jQuery} $element - Element with data attributes (room button/link) 144 * @param {Object} contextData - Additional context from page 145 * @param {string} contextData.propertyName - Required: Property name (used for item_brand) - will be HTML-decoded 146 * @param {string} [contextData.itemCategory] - Optional: Item category - will be HTML-decoded 147 * @param {string} [contextData.itemCategory2] - Optional: Item secondary category - will be HTML-decoded 148 * @param {string} [contextData.affiliation] - Optional: Affiliation (referrer/channel) 149 * @param {string} [contextData.itemAffiliation] - Optional: Point of sale name 150 * @returns {Object|null} GA4 item object or null if required data is missing 151 */ 152 function buildItemFromDOM($element, contextData) { 153 if (!$element?.length) { 154 console.warn('GA4 Helper: Invalid element provided to buildItemFromDOM'); 155 return null; 156 } 157 158 if (!contextData?.propertyName) { 159 console.warn('GA4 Helper: Missing propertyName in contextData - required for item_brand in GA4 events'); 160 return null; 161 } 162 163 // Extract DOM data in single operation 164 const roomTypeId = $element.data('room-type-id'); 165 const itemName = $element.data('item-name'); 166 const ratePlanName = $element.data('rate-plan-name'); 167 const price = $element.data('price'); 168 const discount = $element.data('discount'); 169 170 // Validate core required properties 171 if (!roomTypeId || !itemName) { 172 console.warn('GA4 Helper: Missing required DOM attributes for item'); 173 return null; 174 } 175
176 // Log warning if rate plan name is missing, but don't block the event 177 if (!ratePlanName) { 178 console.warn('GA4 Helper: Missing data-rate-plan-name attribute - item_variant will be empty. This may occur in non-search modes or when rate plan data is unavailable.'); 179 } 180 181 return buildItemObject({ 182 itemId: roomTypeId, 183 itemName: itemName, 184 itemBrand: contextData.propertyName, 185 itemVariant: ratePlanName, 186 price: price, 187 discount: discount, 188 itemCategory: contextData.itemCategory, 189 itemCategory2: contextData.itemCategory2, 190 affiliation: contextData.affiliation, 191 itemAffiliation: contextData.itemAffiliation 192 }); 193 } 194 195 /** 196 * Validates that gtag function is available before tracking 197 * Logs warning if gtag is not defined, which indicates GA4 initialization issue 198 * 199 * @returns {boolean} True if gtag function is available, false otherwise 200 */ 201 function isGtagAvailable() { 202 if (typeof gtag === 'undefined') { 203 console.warn('GA4 Helper: gtag is not defined - GA4 tracking will not work'); 204 return false; 205 } 206 return true; 207 } 208 209 /** 210 * Safely sends a Google Analytics 4 event to gtag 211 * Includes error handling and gtag availability check 212 * 213 * @param {string} eventName - GA4 event name (e.g., "view_item", "purchase") 214 * @param {Object} eventParams - Event parameters (items array, currency, value, etc.) 215 * @returns {void} 216 */ 217 function sendEvent(eventName, eventParams) { 218 if (!window.eviivo.googleAnalytics.isGtagAvailable()) { 219 return; 220 } 221 222 try { 223 gtag('event', eventName, eventParams); 224 } catch (error) { 225 console.error(`GA4 Helper: Error sending event "${eventName}":`, error); 226 } 227 } 228 229 window.eviivo.googleAnalytics.buildItemObject = buildItemObject; 230 window.eviivo.googleAnalytics.buildItemFromDOM = buildItemFromDOM; 231 window.eviivo.googleAnalytics.isGtagAvailable = isGtagAvailable; 232 window.eviivo.googleAnalytics.sendEvent = sendEvent; 233 234})(window, jQuery); 235 236/** 237 * Google Analytics 4 Event Wiring Engine 238 * Namespace: window.eviivo.googleAnalytics.events 239 * 240 * Auto-discovers DOM elements with Google Analytics 4 event data attributes and wires event listeners 241 */ 242(function (window, $) { 243 'use strict'; 244 245 // Initialize eviivo namespace 246 window.eviivo = window.eviivo || {}; 247 window.eviivo.googleAnalytics = window.eviivo.googleAnalytics || {}; 248 window.eviivo.googleAnalytics.events = window.eviivo.googleAnalytics.events || {}; 249 250 var options; 251 252 const eventDefinitions = { 253 'view_item': { 254 gaEventName: 'view_item', 255 selector: '[data-ga4-event="view_item"]', 256 handler: handleViewItemEvent, 257 }, 258 // Future events can be added here like the one above 259 }; 260 261 /** 262 * Handles 'view_item' event for Google Analytics 4 263 * Fired when user clicks "More..." button to view unit details in results 264 * 265 * GA4 Event: view_item 266 * Purpose: Track which specific unit item user is interested in 267 * Includes: Unit details, currency, price, check-in/out dates 268 * 269 * @param {Event} e - Click event from view details button 270 * @returns {void} 271 */ 272 function handleViewItemEvent(e) { 273 if (!window.eviivo?.googleAnalytics?.isGtagAvailable?.()) { 274 return; 275 } 276 277 const $button = $(this); 278 const { propertyName, propertyType, propertyShortName, pointOfSaleName, constants, checkInDate, checkOutDate, singularRoomTypeTranslation } = options || {}; 279 280 const contextData = { 281 itemCategory: singularRoomTypeTranslation ?? constants?.unit, 282 itemCategory2: propertyType ?? constants?.accommodation, 283 itemAffiliation: pointOfSaleName, 284 propertyName: propertyName, 285 checkInDate: checkInDate, 286 checkOutDate: checkOutDate 287 }; 288 289 // Build Google Analytics 4 item from DOM attributes 290 const itemData = window.eviivo.googleAnalytics.buildItemFromDOM($button, contextData); 291 292 if (!itemData) { 293 console.warn('GA4 Event Wiring: Could not build item data for view_item event'); 294 return; 295 } 296 297 // Resolve item_list_id and item_list_name from constants or use defaults 298 const roomListId = constants?.room_list ?? 'room_list'; 299 const roomListName = constants?.available_rooms ?? 'Available Rooms'; 300 301 // Send Google Analytics 4 event 302 const eventData = { 303 currency: $button.data('currency-code'), 304 value: itemData.price, 305 item_list_id: roomListId, 306 item_list_name: roomListName, 307 items: [itemData] 308 }; 309 310 if (checkInDate?.trim?.()) { 311 eventData.check_in_date = checkInDate; 312 } 313 314 if (checkOutDate?.trim?.()) { 315 eventData.check_out_date = checkOutDate; 316 } 317 318 window.eviivo.googleAnalytics.sendEvent('view_item', eventData); 319 } 320 321 /** 322 * Generic click event handler dispatcher 323 * Routes clicks to the appropriate event handler based on event definition 324 * @param {Event} e - Click event 325 */ 326 function onGoogleAnalyticsEventClick(e) { 327 const $element = $(this); 328 329 const eventType = $element.data('ga4-event'); 330 const definition = eventDefinitions[eventType]; 331 const { handler } = definition ?? {}; 332 333 if (!handler) { 334 console.warn(`GA4 Event Wiring: No handler found for event type: ${eventType}`); 335 return; 336 } 337 338 // Call the event-specific handler 339 handler.call(this, e); 340 } 341 342 /** 343 * Initialize Google Analytics 4 event wiring 344 * Auto-discovers all elements with Google Analytics 4 event data attributes and attaches listeners 345 * Should be called after DOM is ready and Google Analytics 4 helpers are loaded 346 */ 347 function init(customOptions) { 348 if (!window.eviivo?.googleAnalytics) { 349 console.warn('GA4 Event Wiring: eviivo.googleAnalytics helpers not found. Make sure eviivo.googleAnalytics.helpers.js is loaded first.'); 350 return; 351 } 352 353 options = $.extend({}, options, customOptions); 354 355 // Attach event listeners for each defined event
356 Object.entries(eventDefinitions).forEach(([, definition]) => { 357 $(document).on('click', definition.selector, onGoogleAnalyticsEventClick); 358 }); 359 } 360 361 window.eviivo.googleAnalytics.events.init = init; 362 363})(window, jQuery); 364
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.