1/** 2 * A class that handles Google Analytics events. 3 */ 4class GoogleAnalytics { 5 6 /** 7 * A boolean indicating whether Google Analytics has been initialized. 8 * 9 * @type {boolean} 10 */ 11 static #isInitialized = false; 12 13 /** 14 * A mapping from page types to functions that should be called when the current 15 * page is of that type. 16 * 17 * @type Record<string, () => any> 18 */ 19 static #PAGE_VIEW_HANDLER_MAP = { 20 "cart": GoogleAnalytics.onCartPageView, 21 "catalog": GoogleAnalytics.onCatalogPageView, 22 "checkout": GoogleAnalytics.onCheckoutStarted, 23 "product": GoogleAnalytics.onProductPageView, 24 "promotion": GoogleAnalytics.onPromotionPageView, 25 "order-review": GoogleAnalytics.onPurchase, 26 }; 27 28 /** 29 * A mapping from event names to information about how to deduplicate them. 30 * @type Record<string, { getKey: () => any, keyDescriptor: string }> 31 */ 32 static #EVENT_DEDUPLICATION_MAP = { 33 "begin_checkout": { 34 getKey: () => GoogleAnalytics.#getStructuredData().cart.id, 35 keyDescriptor: "cart ID", 36 sessionOnly: false, 37 }, 38 "add_shipping_info": { 39 getKey: () => GoogleAnalytics.#getStructuredData().cart.id, 40 keyDescriptor: "cart ID", 41 sessionOnly: false, 42 }, 43 "add_payment_info": { 44 getKey: () => GoogleAnalytics.#getStructuredData().cart.id, 45 keyDescriptor: "cart ID", 46 sessionOnly: false, 47 }, 48 "purchase": { 49 getKey: () => GoogleAnalytics.#getStructuredData().order.id, 50 keyDescriptor: "order ID", 51 sessionOnly: false, 52 }, 53 }; 54 55 /** 56 * Initializes a new GoogleAnalytics instance. 57 */ 58 static initialize() { 59 60 if (GoogleAnalytics.#isInitialized) return; 61 else GoogleAnalytics.#isInitialized = true; 62 63 let data, measurementID; 64 65 try { 66 67 data = GoogleAnalytics.#getStructuredData(); 68 measurementID = GoogleAnalytics.#getMeasurementID(); 69 70 } catch (error) { 71 72 console.error("Google Analytics failed to initialize!\n", error); 73 74 return; 75 76 } 77 78 window.dataLayer = window.dataLayer || []; 79 window.gtag = function() { dataLayer.push(arguments); } 80 gtag("js", new Date()); 81 gtag("config", measurementID); 82 83 console.info("Google Analytics initialized!"); 84 85 const pageViewHandler = 86 GoogleAnalytics.#PAGE_VIEW_HANDLER_MAP[data?.page_type]; 87 88 if (pageViewHandler !== undefined) pageViewHandler(); 89 90 } 91 92 /** 93 * Gets the structured data for the current page, throwing an error if it is not 94 * available. 95 * 96 * @returns {object} The structured data for the current page. 97 * @throws {Error} If structured data is not available. 98 */ 99 static #getStructuredData() { 100 101 const STRUCTURED_DATA_VARIABLE = "V5_STRUCTURED_DATA"; 102 103 if (STRUCTURED_DATA_VARIABLE in window) { 104 105 return window[STRUCTURED_DATA_VARIABLE]; 106 107 } else { 108 109 throw new Error( 110 "Structured data is not available " + 111 `(\`window.${STRUCTURED_DATA_VARIABLE}\` is missing)!` 112 ); 113 114 } 115 116 } 117 118 /** 119 * Returns the measurement ID for the configured Google Analytics property. 120 * 121 * @returns {string} The measurement ID for the configured Google Analytics 122 * property. 123 * @throws {Error} If the measurement ID is not available. 124 */ 125 static #getMeasurementID() { 126 127 const data = GoogleAnalytics.#getStructuredData(); 128 129 const measurementID = data?.google_analytics?.measurement_id; 130 131 if (measurementID === undefined) { 132 133 throw new Error("No measurement ID was provided via structured data."); 134 135 } 136 137 return measurementID; 138 139 } 140 141 /** 142 * Maps one or more V5_STRUCTURED_DATA products to Google Analytics items. 143 * 144 * @param {object | array} products The product(s) to map. 145 * @param includeIndex Whether to include the index of the product in the 146 * resulting item. 147 * @param includePrice Whether to include the price of the product in the 148 * resulting item. 149 * @param includeQuantity Whether to include the quantity of the product in 150 * the resulting item. 151 * @returns {object | array} The mapped product(s). If an array was passed 152 * in, an array will be returned. Otherwise, an object will be returned. 153 */ 154 static #productToItem(products, { 155 includeIndex = true, 156 includePrice = true, 157 includeQuantity = true, 158 } = {}) { 159 160 const callback = (product, index) => { 161 162 let result = { 163 item_id: product.retailSku, 164 item_name: product.name, 165 item_brand: product.brand, 166 item_category: product.categories[0], 167 item_category2: product.categories[1], 168 item_category3: product.categories[2], 169 item_url: product.url, 170 }; 171 172 if (includeIndex) result.index = index; 173 if (includePrice) result.price = product.unit_price; 174 if (includeQuantity) result.quantity = product.quantity; 175 176 return result; 177 178 }; 179
180 if (Array.isArray(products)) return products.map(callback); 181 else return callback(products, 1); 182 183 } 184 185 /** 186 * Searches the structured data for a product with the given local product 187 * ID. 188 * 189 * @param {string} localProductID The local ID of the product to search for. 190 * @returns {object} The product with the given local product ID. 191 * @throws {Error} If no product with the given local product ID was found. 192 */ 193 static #searchForProduct(localProductID) { 194 195 const data = GoogleAnalytics.#getStructuredData(); 196 197 const allProducts = [ 198 data?.page_product, 199 ...(data?.page_product_cross_sells ?? []), 200 ...(data?.cart?.products ?? []), 201 ...(data?.order?.products ?? []), 202 ]; 203 204 const result = allProducts.filter( 205 product => product !== undefined && product !== null 206 ).find(product => product.local_id == localProductID); 207 208 if (result === undefined) { 209 210 throw new Error( 211 `Failed to find product with local ID '${localProductID}'!` 212 ); 213 214 } 215 216 return result; 217 218 } 219 220 /** 221 * Returns the properties of the current cart to send to Google Analytics. 222 * 223 * @returns {object} The properties of the current cart to send to Google 224 * Analytics. 225 */ 226 static #getCartProperties() { 227 228 const data = GoogleAnalytics.#getStructuredData(); 229 230 return { 231 send_to: GoogleAnalytics.#getMeasurementID(), 232 currency: data.currency, 233 value: data.cart.value, 234 items: GoogleAnalytics.#productToItem(data.cart.products), 235 }; 236 237 } 238 239 /** 240 * Sends the given event (and optional properties) to Google Analytics, 241 * after checking for duplicates. 242 * 243 * @param event The name of the event to send. 244 * @param properties An optional value to send as the event's properties. 245 * @returns {void} 246 */ 247 static #sendEvent(event, properties = undefined) { 248 249 if (event in GoogleAnalytics.#EVENT_DEDUPLICATION_MAP) { 250 251 const { 252 getKey, 253 keyDescriptor, 254 sessionOnly, 255 } = GoogleAnalytics.#EVENT_DEDUPLICATION_MAP[event]; 256 257 const storageType = sessionOnly ? "sessionStorage" : "localStorage"; 258 const storage = window[storageType]; 259 const key = getKey(properties); 260 const eventID = `ga_event_${event}_${key}`; 261 262 // If the event has already been sent, don't send it again. 263 if (storage.getItem(eventID) !== null) { 264 265 console.warn( 266 "An attempt was made (and blocked) to fire a duplicate " + 267 `Google Analytics event: ${event}, identified by ` + 268 `${keyDescriptor} '${key}'.` 269 ); 270 271 return; 272 273 } else storage.setItem(eventID, "true"); 274 275 } 276 277 console.groupCollapsed(`Google Analytics event: ${event}`); 278 279 const gtagParams = ["event", event]; 280 281 if (properties !== undefined || Object.keys(properties).length <= 0) { 282 283 gtagParams.push(properties); 284 console.dir(properties); 285 286 } else { 287 288 console.info( 289 "No properties provided for event - sending event without " + 290 "properties." 291 ); 292 293 } 294 295 gtag(...gtagParams); 296 297 console.groupEnd(); 298 299 } 300 301 /** 302 * Sends a 'search' event to Google Analytics. 303 * 304 * @param {string} query The search query to that originated this event. 305 * @returns {void} 306 */ 307 static onSearch(query) { 308 309 // TODO - Add search results to the event. 310 311 GoogleAnalytics.#sendEvent("search", { 312 send_to: GoogleAnalytics.#getMeasurementID(), 313 "search_term": query, 314 }); 315 316 } 317 318 /** 319 * Sends a 'view_promotion' event to Google Analytics with the appropriate 320 * properties. 321 * 322 * @returns {void} 323 */ 324 static onPromotionPageView() { 325 326 GoogleAnalytics.#sendEvent("view_promotion", { 327 send_to: GoogleAnalytics.#getMeasurementID(), 328 creative_name: "", 329 creative_slot: "", 330 promotion_id: "", 331 promotion_name: "", 332 items: "", 333 }); 334 335 } 336 337 /** 338 * Sends a 'view_item_list' event to Google Analytics with the appropriate 339 * properties. 340 * 341 * @returns {void} 342 */ 343 static onCatalogPageView() { 344 345 GoogleAnalytics.#sendEvent("view_item_list", { 346 send_to: GoogleAnalytics.#getMeasurementID(), 347 item_list_id: "", 348 item_list_name: "", 349 items: "", 350 }); 351 352 } 353 354 /** 355 * Sends a 'view_item' event to Google Analytics with the appropriate 356 * properties. 357 * 358 * @returns {void} 359 */ 360 static onProductPageView() { 361 362 const data = GoogleAnalytics.#getStructuredData(); 363 const item = GoogleAnalytics.#productToItem(data.page_product, {
364 includeIndex: false, 365 includeQuantity: false, 366 }); 367 368 GoogleAnalytics.#sendEvent("view_item", { 369 send_to: GoogleAnalytics.#getMeasurementID(), 370 currency: data.currency, 371 value: data.page_product.value, 372 items: [item], 373 }); 374 375 } 376 377 /** 378 * Sends a 'view_cart' event to Google Analytics with the appropriate 379 * properties. 380 * 381 * @returns {void} 382 */ 383 static onCartPageView() { 384 385 const data = GoogleAnalytics.#getStructuredData(); 386 let properties = undefined; 387 388 if ("cart" in data) { 389 390 properties = GoogleAnalytics.#getCartProperties(); 391 392 } 393 394 GoogleAnalytics.#sendEvent("view_cart", properties); 395 396 } 397 398 /** 399 * Sends a 'generate_lead' event to Google Analytics with the appropriate 400 * properties. 401 * 402 * @returns {void} 403 */ 404 static onCustomFormSubmission() { 405 406 /* GoogleAnalytics.#sendEvent("generate_lead", { 407 send_to: GoogleAnalytics.#getMeasurementID() 408 });*/ 409 410 } 411 412 /** 413 * Sends a 'generate_lead' event to Google Analytics with the appropriate 414 * properties. 415 * 416 * @returns {void} 417 */ 418 static onEmailSignupFormSubmission() { 419 420 GoogleAnalytics.#sendEvent("generate_lead", { 421 send_to: GoogleAnalytics.#getMeasurementID(), 422 form_type: "email_signup", 423 }); 424 425 } 426 427 /** 428 * Sends a 'generate_lead' event to Google Analytics with the appropriate 429 * properties. 430 * 431 * @returns {void} 432 */ 433 static onProductRequestFormSubmission(retailSku) { 434 435 const data = GoogleAnalytics.#getStructuredData(); 436 437 GoogleAnalytics.#sendEvent("generate_lead", { 438 send_to: GoogleAnalytics.#getMeasurementID(), 439 form_type: "product_request", 440 product_id: data.page_product.retailSku, 441 }); 442 443 } 444 445 /** 446 * Sends a 'generate_lead' event to Google Analytics with the appropriate 447 * properties. 448 * 449 * @returns {void} 450 */ 451 static onContactFormSubmission() { 452 453 GoogleAnalytics.#sendEvent("generate_lead", { 454 send_to: GoogleAnalytics.#getMeasurementID(), 455 form_type: "contact", 456 }); 457 458 } 459 460 /** 461 * Sends a 'sign_up' event to Google Analytics with the appropriate 462 * properties. 463 * 464 * @param method The name of the method that was used to sign up. 465 * Examples: "Email", "Google", "Facebook", etc. 466 * @returns {void} 467 */
468 static onSignUp(method) { 469 470 GoogleAnalytics.#sendEvent("sign_up", { 471 send_to: GoogleAnalytics.#getMeasurementID(), 472 method 473 }); 474 475 } 476 477 /** 478 * Sends a 'login' event to Google Analytics with the appropriate 479 * properties. 480 * 481 * @param method The name of the method that was used to sign in. 482 * Examples: "Email", "Google", "Facebook", etc. 483 * @returns {void} 484 */ 485 static onSignIn(method) { 486 487 GoogleAnalytics.#sendEvent("login", { 488 send_to: GoogleAnalytics.#getMeasurementID(), 489 method 490 }); 491 492 } 493 494 /** 495 * Sends an 'add_to_wishlist' event to Google Analytics with the appropriate 496 * properties. 497 * 498 * @param localProductID The local ID of the product that was added to the 499 * wishlist. 500 * @returns {void} 501 */ 502 static onAddToWishlist(localProductID) { 503 504 const data = GoogleAnalytics.#getStructuredData(); 505 const product = GoogleAnalytics.#searchForProduct(localProductID); 506 const item = GoogleAnalytics.#productToItem(product, { 507 includeIndex: false, 508 includeQuantity: false, 509 }); 510 511 GoogleAnalytics.#sendEvent("add_to_wishlist", { 512 send_to: GoogleAnalytics.#getMeasurementID(), 513 currency: data.currency, 514 value: product.value, 515 items: [item], 516 }); 517 518 } 519 520 /** 521 * Sends a 'add_to_cart' event to Google Analytics with the appropriate 522 * properties. 523 * 524 * @param {string} localProductID The local ID of the product that was added 525 * to the cart. 526 * @param {number} quantity The quantity of the product that was added to 527 * the cart. 528 * @returns {void} 529 */ 530 static onAddToCart(localProductID, quantity) { 531 532 //Multiple products can be added to cart through cross sells 533 const productIds = localProductID.toString().split(','); 534 535 quantity = parseFloat(quantity); 536 537 const data = GoogleAnalytics.#getStructuredData(); 538 539 productIds.forEach((localProduct) => { 540 const product = GoogleAnalytics.#searchForProduct(localProduct); 541 542 const item = GoogleAnalytics.#productToItem(product, { 543 includeIndex: false, 544 includeQuantity: false, 545 }); 546 547 item.quantity = quantity; 548 549 if (product.unit_price === undefined) { 550 551 throw new Error( 552 "Cannot add product with no unit price to cart! " + 553 `(local ID: '${localProduct}')` 554 ); 555 556 } 557 558 const value = product.unit_price * quantity; 559 560 // V5_STRUCTURED_DATA.cart ??= {}; 561 // V5_STRUCTURED_DATA.cart.value ??= 0; 562 // V5_STRUCTURED_DATA.cart.coupons ??= []; 563 // V5_STRUCTURED_DATA.cart.products ??= []; 564 // 565 // V5_STRUCTURED_DATA.cart.value += value; 566 // V5_STRUCTURED_DATA.cart.products.push(product); 567 568 GoogleAnalytics.#sendEvent("add_to_cart", { 569 send_to: GoogleAnalytics.#getMeasurementID(), 570 currency: data.currency, 571 value, 572 items: [item] 573 }); 574 }); 575 576 } 577 578 /** 579 * Sends a 'remove_from_cart' event to Google Analytics with the appropriate 580 * properties. 581 * 582 * @param {string} localProductID The local ID of the product that was 583 * removed from the cart. 584 * @param {number} quantity The quantity of the product that was removed 585 * from the cart. 586 * @returns {void} 587 */ 588 static onRemoveFromCart(localProductID, quantity) { 589 590 quantity = parseFloat(quantity); 591 592 const data = GoogleAnalytics.#getStructuredData(); 593 const product = GoogleAnalytics.#searchForProduct(localProductID); 594 595 const item = GoogleAnalytics.#productToItem(product, { 596 includeIndex: false, 597 includeQuantity: false, 598 }); 599 600 item.quantity = quantity; 601 602 if (product.unit_price === undefined) { 603 604 throw new Error( 605 "Cannot remove product with no unit price from cart! " + 606 `(local ID: '${localProductID}')` 607 ); 608 609 } 610 611 const value = product.unit_price * quantity; 612 613 // V5_STRUCTURED_DATA.cart ??= {}; 614 // V5_STRUCTURED_DATA.cart.value ??= 0; 615 // V5_STRUCTURED_DATA.cart.coupons ??= []; 616 // V5_STRUCTURED_DATA.cart.products ??= []; 617 // 618 // V5_STRUCTURED_DATA.cart.value += value; 619 // V5_STRUCTURED_DATA.cart.products.push(product); 620 621 GoogleAnalytics.#sendEvent("remove_from_cart", { 622 send_to: GoogleAnalytics.#getMeasurementID(), 623 currency: data.currency, 624 value, 625 items: [item] 626 }); 627 628 } 629 630 631 /** 632 * Sends a 'begin_checkout' event to Google Analytics with the appropriate 633 * properties. 634 * 635 * @returns {void} 636 */ 637 static onCheckoutStarted() { 638 639 GoogleAnalytics.#sendEvent("begin_checkout", { 640 send_to: GoogleAnalytics.#getMeasurementID(), 641 ...GoogleAnalytics.#getCartProperties(), 642 }); 643 644 } 645 646 /** 647 * Sends a 'add_shipping_info' event to Google Analytics with the 648 * appropriate properties. 649 * 650 * @returns {void} 651 */ 652 static onAddShippingInfo() { 653 654 GoogleAnalytics.#sendEvent("add_shipping_info", { 655 send_to: GoogleAnalytics.#getMeasurementID(), 656 ...GoogleAnalytics.#getCartProperties(), 657 }); 658 659 } 660 661 /** 662 * Sends a 'add_payment_info' event to Google Analytics with the appropriate 663 * properties. 664 * 665 * @returns {void} 666 */
667 static onAddPaymentInfo(payment_type) { 668 669 GoogleAnalytics.#sendEvent("add_payment_info", { 670 send_to: GoogleAnalytics.#getMeasurementID(), 671 ...GoogleAnalytics.#getCartProperties(), 672 payment_type, 673 }); 674 675 } 676 677 /** 678 * Sends a 'purchase' event to Google Analytics with the appropriate 679 * properties. 680 * 681 * @returns {void} 682 */ 683 static onPurchase() { 684 685 const data = GoogleAnalytics.#getStructuredData(); 686 687 const properties = { 688 send_to: GoogleAnalytics.#getMeasurementID(), 689 currency: data.currency, 690 transaction_id: data.order.order_number, 691 value: data.order.value, 692 shipping: data.order.delivery + data.order.shipping, 693 tax: data.order.tax, 694 items: GoogleAnalytics.#productToItem(data.order.products), 695 }; 696 697 const first_coupon = (data?.order?.coupons ?? [])[0]; 698 699 if (first_coupon !== undefined) properties.coupon = first_coupon; 700 701 GoogleAnalytics.#sendEvent("purchase", properties); 702 703 } 704 705} 706 707document.addEventListener("DOMContentLoaded", GoogleAnalytics.initialize);
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.