1/** 2 * Front-end js file. 3 * 4 * @package GTM_Server_Side 5 */ 6 7jQuery( document ).ready( 8 function () { 9 jQuery( document ).on( 10 'click', 11 'body.woocommerce-cart [name=update_cart]', 12 function () { 13 pluginGtmServerSide.changeCartQty(); 14 } 15 ); 16 17 jQuery( document ).on( 18 'keypress', 19 'body.woocommerce-cart .woocommerce-cart-form input[type=number]', 20 function () { 21 pluginGtmServerSide.changeCartQty(); 22 } 23 ); 24 25 jQuery( document ).on( 26 'click', 27 '.add_to_cart_button:not(.product_type_variable, .product_type_grouped, .single_add_to_cart_button)', 28 function ( e ) { 29 var el = e.currentTarget || e.target.closest( '.add_to_cart_button' ); 30 if ( ! el || ! el.dataset ) { 31 return; 32 } 33 34 if ( ! el.dataset.gtm_item_id ) { 35 return; 36 } 37 38 let gtmData = pluginGtmServerSide.getGtmItemData( el.dataset ); 39 let customData = pluginGtmServerSide.getCustomItemData( el.dataset ); 40 41 pluginGtmServerSide.trackAddToCart( gtmData, this ); 42 pluginGtmServerSide.pushSelectItem( gtmData, customData ); 43 } 44 ); 45 46 jQuery( document ).on( 47 'click', 48 '.wc-block-grid__product .add_to_cart_button:not(.product_type_variable, .product_type_grouped, .single_add_to_cart_button)', 49 function ( e ) { 50 var $el = jQuery( this ).closest( '.wc-block-grid__product' ); 51 if ( ! $el.length ) { 52 return; 53 } 54 55 if ( ! $el.data( 'gtm_item_id' ) ) { 56 return; 57 } 58 59 let gtmData = pluginGtmServerSide.getGtmItemData( $el.data() ); 60 let customData = pluginGtmServerSide.getCustomItemData( $el.data() ); 61 62 pluginGtmServerSide.trackAddToCart( gtmData, this ); 63 pluginGtmServerSide.pushSelectItem( gtmData, customData ); 64 } 65 ); 66 67 jQuery( document ).on( 68 'click', 69 '.single_add_to_cart_button:not(.disabled)', 70 function ( e ) { 71 // The "Add to Cart with Options" block's form has the `cart` class 72 // only in its legacy (page-load) mode; otherwise it adds through 73 // the Store API. 74 var $elForm = jQuery( this ).closest( 'form.cart, form.wc-block-add-to-cart-with-options' ); 75 if ( ! $elForm.length ) { 76 return true; 77 } 78 79 if ( $elForm.find( '[name=variation_id]' ).length > 0 ) { 80 pluginGtmServerSide.pushVariationProduct( $elForm, this ); 81 return; 82 } 83 84 if ( $elForm.hasClass( 'grouped_form' ) || $elForm.find( '[name^=quantity\\[]' ).length > 0 ) { 85 pluginGtmServerSide.pushGroupProduct( $elForm, this ); 86 return; 87 } 88 89 pluginGtmServerSide.pushSimpleProduct( $elForm, this ); 90 } 91 ); 92 93 /** 94 * Add over AJAX without WooCommerce's own handler: a theme or plugin that 95 * AJAX-ifies an add-to-cart link or form.cart posts it and then triggers 96 * added_to_cart to refresh the fragments. The click left the item pending; 97 * this is where it is pushed, because no page render follows to drain the 98 * server-side stash. 99 */ 100 jQuery( document.body ).on( 101 'added_to_cart', 102 function () { 103 pluginGtmServerSide.flushPendingAddToCart(); 104 } 105 ); 106 107 /** 108 * Delete from minicart 109 */ 110 jQuery( document ).on( 111 'removed_from_cart', 112 function ( e, fragments, cart_hash, $thisbutton ) { 113 if ( ! $thisbutton.length ) { 114 return; 115 } 116 117 if ( ! $thisbutton.data( 'gtm_item_id' ) ) { 118 return; 119 } 120 121 pluginGtmServerSide.removeFromCart( 122 pluginGtmServerSide.getGtmItemData( $thisbutton.data() ) 123 ); 124 } 125 ); 126 127 /** 128 * Delete from page: /cart 129 */ 130 jQuery( document ).on( 131 'click', 132 '.woocommerce-cart-form .product-remove > a', 133 function ( e ) { 134 var $el = jQuery( e.currentTarget ); 135 136 if ( ! $el.data( 'gtm_item_id' ) ) { 137 return; 138 } 139 140 pluginGtmServerSide.removeFromCart( 141 pluginGtmServerSide.getGtmItemData( $el.data() ) 142 ); 143 } 144 ); 145 } 146); 147 148var pluginGtmServerSide = { 149 /** 150 * Add built at click time that has not been pushed yet, with the moment it 151 * was built. See trackAddToCart(). 152 */ 153 pendingAddToCart: null, 154 155 /** 156 * How long a pending add stays eligible for its added_to_cart, in ms. 157 * 158 * A click that neither loads a page nor completes an AJAX add (a failed 159 * validation, an aborted request) would otherwise leave its item behind and 160 * see it pushed by an unrelated add later on. 161 */ 162 PENDING_ADD_TO_CART_MAX_AGE: 30000, 163 164 /** 165 * Push an add_to_cart for a click, or hold it until the add lands. 166 * 167 * The item is held only when the click is expected to load a page: there 168 * the click-time push is lost and GTM_Server_Side_Event_AddToCart emits the 169 * event on the next render instead, and a pending item dies with the page. 170 * If a theme or plugin AJAX-ifies that link or form, added_to_cart pushes 171 * the held item. Every other add â WooCommerce's own AJAX handler, or a 172 * WooCommerce Blocks button adding through the Store API, which triggers no 173 * added_to_cart â is pushed at click time; the server stashes nothing for 174 * an AJAX or Store API add, so the push is the only one. 175 * 176 * @param object|array item Item or list of items. 177 * @param element el Clicked element. 178 */ 179 trackAddToCart: function ( item, el ) { 180 if ( this.isAjaxAddToCart( el ) || ! this.isPageLoadAddToCart( el ) ) { 181 this.pendingAddToCart = null; 182 this.pushAddToCart( item ); 183 184 return; 185 } 186 187 this.pendingAddToCart = { 188 item: item, 189 time: Date.now(), 190 }; 191 }, 192 193 /** 194 * Push the add held by trackAddToCart(), if there is still one to push. 195 */ 196 flushPendingAddToCart: function () {
197 var pending = this.pendingAddToCart; 198 this.pendingAddToCart = null; 199 200 if ( ! pending ) { 201 return; 202 } 203 204 if ( ( Date.now() - pending.time ) > this.PENDING_ADD_TO_CART_MAX_AGE ) { 205 return; 206 } 207 208 this.pushAddToCart( pending.item ); 209 }, 210 211 /** 212 * Whether the clicked control adds the product over AJAX. 213 * 214 * This reproduces the condition WooCommerce's own `wc-add-to-cart.js` 215 * applies before it takes a click over AJAX, so the answer follows the 216 * handler that is actually present rather than a class name alone: 217 * 218 * - the script is enqueued. "Enable AJAX add to cart buttons on archives" 219 * is what decides that, and the script localises `wc_add_to_cart_params`, 220 * so the global is present exactly when the handler is; 221 * - the clicked control itself carries `ajax_add_to_cart` (the class is set 222 * from the product's own `ajax_add_to_cart` support, independently of the 223 * option, so it is not sufficient on its own); 224 * - it carries `data-product_id`, which the handler posts. 225 * 226 * @param element el Clicked element. 227 * @returns bool 228 */ 229 isAjaxAddToCart: function ( el ) { 230 if ( 'undefined' === typeof wc_add_to_cart_params ) { 231 return false; 232 } 233 234 var $el = jQuery( el ); 235 236 return $el.is( '.ajax_add_to_cart' ) && !! $el.attr( 'data-product_id' ); 237 }, 238 239 /** 240 * Whether the clicked control is expected to add the product by loading a 241 * page. 242 * 243 * That is a submit of the product's `form.cart`, or a link that goes 244 * somewhere, such as the plain `?add-to-cart=` link on archives with AJAX 245 * off. A WooCommerce Blocks product button is a `<button>` outside any 246 * form, so it does not qualify. 247 * 248 * @param element el Clicked element. 249 * @returns bool 250 */ 251 isPageLoadAddToCart: function ( el ) { 252 var $el = jQuery( el ); 253 254 if ( $el.closest( 'form.cart' ).length ) { 255 return true; 256 } 257 258 if ( ! $el.is( 'a' ) ) { 259 return false; 260 } 261 262 var href = $el.attr( 'href' ); 263 264 if ( ! href || 0 === href.indexOf( '#' ) ) { 265 return false; 266 } 267 268 try { 269 var protocol = new URL( href, window.location.href ).protocol; 270 271 return 'http:' === protocol || 'https:' === protocol; 272 } catch ( e ) { 273 return false; 274 } 275 }, 276 277 pushSimpleProduct: function ( $elForm, el ) { 278 var item = this.convertInputsToObject( 279 $elForm.find( '[name^=gtm_]' ) 280 ); 281 item = this.getGtmItemData( item ); 282 283 var $elQty = $elForm.find( '[name=quantity]' ); 284 if ( $elQty.length ) { 285 item.quantity = $elQty.val(); 286 } 287 288 this.trackAddToCart( item, el ); 289 }, 290 291 pushVariationProduct: function ( $elForm, el ) { 292 var item = this.convertInputsToObject( 293 $elForm.find( '[name^=gtm_]' ) 294 ); 295 item = this.getGtmItemData( item ); 296 297 var $elQty = $elForm.find( '[name=quantity]' ); 298 if ( $elQty.length ) { 299 item.quantity = $elQty.val(); 300 } 301 302 var variations = []; 303 $elForm.find( '[name^=attribute_] option:selected' ).each( 304 function () { 305 variations.push( jQuery( this ).text() ); 306 } 307 ); 308 309 if ( variations.length ) { 310 item.item_variant = variations.join( ',' ); 311 } 312 313 this.trackAddToCart( item, el ); 314 }, 315 316 pushGroupProduct: function ( $elForm, el ) { 317 var items = []; 318 $elForm.find( '[name^=quantity\\[]' ).each( 319 function () { 320 var $elQty = jQuery( this ); 321 if ( $elQty.is( ':checkbox' ) && ! $elQty.is( ':checked' ) ) { 322 return; 323 } 324 325 if ( ! ( parseFloat( $elQty.val() ) > 0 ) ) { 326 return; 327 } 328 329 var match = /^quantity\[(\d+)\]$/.exec( $elQty.attr( 'name' ) ); 330 if ( ! match ) { 331 return; 332 } 333 334 var item = { 335 quantity: $elQty.val(), 336 }; 337 $elForm.find( '[name^=gtm_][name$="[' + match[1] + ']"]' ).each( 338 function () { 339 item[ jQuery( this ).data( 'name' ) ] = jQuery( this ).val(); 340 } 341 ); 342 items.push( item ); 343 } 344 ); 345 this.trackAddToCart( items, el ); 346 }, 347 348 /** 349 * Remove from cart 350 * 351 * @param object item 352 */ 353 removeFromCart: function ( item ) { 354 item.quantity = item.quantity || 1; 355 var eventData = { 356 'event': this.getDataLayerEventName( 'remove_from_cart' ), 357 'ecommerce': { 358 'currency': varGtmServerSide.currency, 359 'items': [ 360 item, 361 ], 362 }, 363 }; 364 365 if ( varGtmServerSide.user_data ) { 366 eventData.user_data = {}; 367 for ( var key in varGtmServerSide.user_data ) { 368 eventData.user_data[ key ] = varGtmServerSide.user_data[ key ]; 369 } 370 } 371 372 if ( 'yes' === varGtmServerSide.is_custom_event_name ) { 373 this._pushWithStateCartData( eventData ); 374 375 return; 376 } 377 378 dataLayer.push( { ecommerce: null } ); 379 dataLayer.push( eventData ); 380 }, 381 382 /** 383 * Change product quantity in cart 384 */ 385 changeCartQty: function () { 386 var $this = this; 387 388 document.querySelectorAll( '.product-quantity input.qty' ).forEach( 389 function ( el ) { 390 var originalValue = el.defaultValue; 391 392 var currentValue = parseInt( el.value ); 393 if ( isNaN( currentValue ) ) { 394 currentValue = originalValue; 395 } 396 397 if ( originalValue != currentValue ) { 398 var elCartItem = el.closest( '.cart_item' ); 399 var elDataset = elCartItem && elCartItem.querySelector( '.remove' ); 400 if ( ! elDataset ) { 401 return; 402 } 403 404 if ( originalValue < currentValue ) { 405 var item = $this.getGtmItemData( elDataset.dataset ); 406 item['quantity'] = currentValue - originalValue; 407 408 $this.pushAddToCart( item ); 409 } 410 } 411 } 412 ); 413 }, 414 415 /** 416 * Return gtm custom data. 417 * 418 * @param object items List items. 419 * @returns object 420 */ 421 getGtmItemData: function ( items ) { 422 return this._getItemData( items, 'gtm_' ); 423 }, 424 425 /** 426 * Return gtm custom data. 427 * 428 * @param object items List items. 429 * @returns object 430 */ 431 getCustomItemData: function ( items ) { 432 return this._getItemData( items, 'custom_gtm_' ); 433 }, 434 435 /** 436 * Return item data. 437 * 438 * @param object items List items. 439 * @returns object 440 */ 441 _getItemData: function ( items, prefix ) { 442 var result = {}; 443 for ( var key in items ) { 444 if ( 0 !== key.indexOf( prefix ) ) { 445 continue; 446 } 447
448 var itemKey = key.replace( prefix, '' ) 449 result[ itemKey ] = items[ key ]; 450 } 451 return result; 452 }, 453 454 /** 455 * Convert input elements to object. 456 * 457 * @param object $els Elements. 458 * @returns object 459 */ 460 convertInputsToObject( $els ) { 461 var data = {}; 462 if ( ! $els.length ) { 463 return data; 464 } 465 466 $els.each( 467 function () { 468 data[ jQuery( this ).attr( 'name' ) ] = jQuery( this ).val(); 469 } 470 ); 471 return data; 472 }, 473 474 /** 475 * Filter item price. 476 * 477 * @param object item List items. 478 * @returns object 479 */ 480 filterItemPrice: function ( item ) { 481 if ( typeof item.price == 'string' ) { 482 item.price = parseFloat( item.price ); 483 if ( isNaN( item.price ) ) { 484 item.price = 0; 485 } 486 } else if ( typeof item.price != 'number' ) { 487 item.price = 0; 488 } 489 item.price = item.price.toFixed( 2 ); 490 491 return item; 492 }, 493 494 /** 495 * Push add_to_cart to dataLayer. 496 * 497 * @param mixed item List items. 498 */ 499 pushAddToCart: function ( item ) { 500 this._pushToDataLayer( item, 'add_to_cart', 'product' ) 501 }, 502 503 /** 504 * Push add_to_cart to dataLayer. 505 * 506 * @param object item List items. 507 * @param object custom Custom data. 508 */ 509 pushSelectItem: function ( item, custom ) { 510 if ( custom?.pagetype ) { // phpcs:ignore WordPress.WhiteSpace.OperatorSpacing.NoSpaceBefore, WordPress.WhiteSpace.OperatorSpacing.NoSpaceAfter 511 this._pushToDataLayer( 512 item, 513 'select_item', 514 custom.pagetype, 515 { 516 collection_id: custom?.collection_id, // phpcs:ignore WordPress.WhiteSpace.OperatorSpacing.NoSpaceBefore, WordPress.WhiteSpace.OperatorSpacing.NoSpaceAfter 517 item_list_name: custom?.item_list_name, // phpcs:ignore WordPress.WhiteSpace.OperatorSpacing.NoSpaceBefore, WordPress.WhiteSpace.OperatorSpacing.NoSpaceAfter 518 } 519 ) 520 } 521 }, 522 523 /** 524 * Push to dataLayer. 525 * 526 * @param object|array originalItem Single item or list of items. 527 * @param string event Event name. 528 * @param string pagetype Page type name. 529 * @param object customEcommerce Ecommerce data. 530 */ 531 _pushToDataLayer: function ( originalItem, event, pagetype, customEcommerce = {} ) { 532 var originalItems = Array.isArray( originalItem ) ? originalItem : [ originalItem ]; 533 534 var items = []; 535 var value = 0; 536 var index = 1; 537 for ( var original of originalItems ) { 538 var item_loop = Object.assign( {}, original ); 539 item_loop.index = index++; 540 item_loop.quantity = item_loop.quantity ? parseInt( item_loop.quantity, 10 ) : 1; 541 item_loop = this.filterItemPrice( item_loop ); 542 value = parseFloat( value + ( item_loop.price * item_loop.quantity ) ); 543 items.push( item_loop ); 544 } 545 546 if ( ! items.length ) { 547 return; 548 } 549 550 let eventDataEcommerce = { 551 'currency': varGtmServerSide.currency, 552 'value': value.toFixed( 2 ), 553 'items': items, 554 } 555 556 var eventData = { 557 'event': this.getDataLayerEventName( event ), 558 'ecomm_pagetype': pagetype, 559 'ecommerce': Object.assign( {}, eventDataEcommerce, customEcommerce ), 560 }; 561 if ( varGtmServerSide.user_data ) { 562 eventData.user_data = {}; 563 for ( var key in varGtmServerSide.user_data ) { 564 eventData.user_data[ key ] = varGtmServerSide.user_data[ key ]; 565 } 566 } 567 568 if ( 'add_to_cart' === event && 'yes' === varGtmServerSide.is_custom_event_name ) { 569 this._pushWithStateCartData( eventData ); 570 571 return; 572 } 573 574 dataLayer.push( { ecommerce: null } ); 575 dataLayer.push( eventData ); 576 }, 577 578 /** 579 * Return data layer event name. 580 * 581 * @param string $event_name Event name. 582 * @return string 583 */ 584 getDataLayerEventName: function( event_name ) { 585 if ( 'yes' === varGtmServerSide.is_custom_event_name ) { 586 return event_name + varGtmServerSide.DATA_LAYER_CUSTOM_EVENT_NAME; 587 } 588 return event_name; 589 }, 590 591 /** 592 * Internal helper: actually performs the AJAX request and pushes to the dataLayer. 593 * 594 * @param {object} eventData eventData object. 595 * @return {void} 596 */ 597 _sendStateCartDataAjax: function( eventData ) { 598 jQuery.post( 599 varGtmServerSide.ajax, 600 { 601 action: 'gtm_server_side_state_cart_data', 602 security: varGtmServerSide.security, 603 }, 604 function ( response ) { 605 if ( ! response || ! response.success ) { 606 dataLayer.push( { ecommerce: null } ); 607 dataLayer.push( eventData ); 608 609 return false;
610 } 611 612 eventData['cart_state'] = response.data; 613 614 dataLayer.push( { ecommerce: null } ); 615 dataLayer.push( eventData ); 616 } 617 ); 618 }, 619 620 /** 621 * Push dataLayer with state cart data 622 * 623 * @param {object} eventData Event data object. 624 * @return {void} 625 */ 626 _pushWithStateCartData: function( eventData ) { 627 let self = this; 628 let fired = false; 629 630 var handler = function( event, xhr, settings ) { 631 if ( fired ) { 632 return; 633 } 634 635 if ( ! settings || ! settings.url ) { 636 return; 637 } 638 639 let url = settings.url; 640 641 if ( 642 url.indexOf( 'wc-ajax=add_to_cart' ) !== -1 || 643 url.indexOf( 'wc-ajax=remove_from_cart' ) !== -1 644 ) { 645 fired = true; 646 647 jQuery( document ).off( 'ajaxComplete', handler ); 648 649 self._sendStateCartDataAjax( eventData ); 650 } 651 }; 652 653 jQuery( document ).on( 'ajaxComplete', handler ); 654 655 setTimeout( 656 function() { 657 if ( fired ) { 658 return; 659 } 660 661 jQuery( document ).off( 'ajaxComplete', handler ); 662 self._sendStateCartDataAjax( eventData ); 663 }, 664 1500 665 ); 666 } 667};
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.