1/** 2 * W2W Popup Manager - Frontend Logic 3 * 4 * Handles popup triggers, display, and session tracking. 5 * Designed to work with AJAX-loaded content and dynamic fields. 6 */ 7 8(function () { 9 "use strict"; 10 11 window.W2WPopups = { 12 // Popup configurations from PHP 13 popups: [], 14 15 // Trigger functions (registered via addTrigger) 16 triggers: {}, 17 18 // Popups shown this session (for show_once functionality) 19 shownPopups: [], 20 21 // Currently displayed popup 22 currentPopup: null, 23 24 // Debug flag (set from PHP) 25 debug: false, 26 27 // Flag to track if page has fully initialized 28 initialized: false, 29 30 // Track if initial page load is complete (to avoid triggering on initial AJAX fields) 31 initialLoadComplete: false, 32 33 // Cart data (updated on cart/checkout changes) 34 cartData: { 35 products: [], 36 categories: [], 37 tags: [], 38 }, 39 40 /** 41 * Debug logging helper 42 */ 43 log: function (message, ...args) { 44 if (this.debug) { 45 console.log("[W2W Popups] " + message, ...args); 46 } 47 }, 48 49 /** 50 * Initialize the popup system 51 */ 52 init: function () { 53 this.log("=== INIT START ==="); 54 this.log("Document readyState:", document.readyState); 55 this.log("Timestamp:", new Date().toISOString()); 56 57 // Check if config exists 58 if (typeof w2wPopupConfig === "undefined") { 59 console.error("W2W Popups: Configuration not found"); 60 return; 61 } 62 63 this.debug = w2wPopupConfig.debug || false; 64 this.popups = w2wPopupConfig.popups; 65 this.cartData = w2wPopupConfig.cartData || { 66 products: [], 67 categories: [], 68 tags: [], 69 }; 70 this.log("Debug mode:", this.debug); 71 this.log("Initialized with popups:", this.popups); 72 this.log("Current page:", w2wPopupConfig.currentPage); 73 this.log("Cart data:", this.cartData); 74 75 // Register triggers from config 76 if (w2wPopupConfig.triggers) { 77 for (const popupId in w2wPopupConfig.triggers) { 78 const triggerCode = w2wPopupConfig.triggers[popupId]; 79 try { 80 // Wrap trigger code in return statement to handle const/let declarations 81 // The code should return a boolean 82 // Security Note: triggerCode is server-controlled via wp_localize_script, 83 // not user input. It's defined in PHP and sanitized during registration. 84 const wrappedCode = 85 "return (function() { " + triggerCode + " })();"; 86 this.triggers[popupId] = new Function(wrappedCode); 87 this.log("Registered trigger for:", popupId); 88 } catch (error) { 89 console.error("Failed to register trigger for " + popupId, error); 90 } 91 } 92 this.log("Registered triggers:", Object.keys(this.triggers)); 93 } 94 this.loadShownPopups(); 95 this.log("Previously shown popups:", this.shownPopups); 96 97 this.log(">>> About to bind events..."); 98 this.bindEvents(); 99 this.log(">>> Events bound successfully"); 100 101 // Check if checkout form already exists (fallback if init_checkout doesn't fire) 102 const checkoutForm = document.querySelector( 103 ".woocommerce-checkout, form.checkout" 104 ); 105 this.log(">>> Checking for checkout form:", !!checkoutForm); 106 if (checkoutForm) { 107 this.log("Checkout form detected, marking as ready"); 108 this.markAsReady(); 109 } else { 110 this.log("No checkout form found yet, waiting for WooCommerce events"); 111 } 112 113 this.log("=== INIT COMPLETE ==="); 114 }, 115 116 /** 117 * Mark system as ready for user interactions 118 * Called after WooCommerce checkout is fully initialized 119 */ 120 markAsReady: function () { 121 if (this.initialized) { 122 this.log("Already initialized, skipping"); 123 return; // Already initialized 124 } 125 126 this.initialized = true; 127 this.log("=== SYSTEM READY ==="); 128 129 // Check on-load triggers first 130 this.checkTriggersOnLoad(); 131 132 // Don't set initialLoadComplete yet - wait for init_checkout event 133 // This ensures we don't trigger on WooCommerce's initial field additions 134 this.log("Waiting for init_checkout event to enable observer..."); 135 }, 136 137 /** 138 * Register a trigger function for a popup 139 * 140 * @param {string} popupId - The popup ID 141 * @param {function} triggerFn - Function that returns true to show popup 142 */ 143 addTrigger: function (popupId, triggerFn) { 144 this.log("Registering trigger for:", popupId); 145 this.triggers[popupId] = triggerFn; 146 }, 147 148 /** 149 * Bind event listeners 150 */ 151 bindEvents: function () { 152 const self = this; 153 154 this.log("=== BINDING EVENTS ==="); 155 156 // Listen for Select2 change events (jQuery event) 157 // Only check on select2:select (when user actually selects a value) 158 if (typeof jQuery !== "undefined") { 159 jQuery(document.body).on("select2:select", "select", function (e) { 160 self.log( 161 ">>> SELECT2:SELECT EVENT on", 162 e.target.id || e.target.name, 163 "value:", 164 e.target.value 165 ); 166 self.checkTriggersForField(e.target, "select2"); 167 }); 168 this.log("Bound: select2:select event"); 169 } 170 171 // Listen to native change events for non-Select2 fields 172 document.body.addEventListener("change", function (e) { 173 // Skip Select2 elements (both the hidden select and the search input) 174 if ( 175 e.target.classList.contains("select2-hidden-accessible") || 176 e.target.closest(".select2-container") 177 ) { 178 return; 179 } 180 181 // Only trigger for form elements 182 if (e.target.matches("input, select, textarea")) { 183 self.log( 184 ">>> CHANGE EVENT on", 185 e.target.id || e.target.name, 186 "value:", 187 e.target.value 188 ); 189 190 // Determine field type 191 const fieldType = 192 e.target.type === "checkbox" 193 ? "checkbox" 194 : e.target.tagName.toLowerCase() === "select" 195 ? "select" 196 : "input"; 197 self.checkTriggersForField(e.target, fieldType); 198 } 199 }); 200 this.log("Bound: change event (non-Select2 only)"); 201 202 // WooCommerce events are jQuery custom events, must use jQuery to listen 203 if (typeof jQuery !== "undefined") { 204 // === CHECKOUT PAGE EVENTS === 205 206 // WooCommerce checkout initialized (fields are populated) 207 jQuery(document.body).on("init_checkout", function (e) { 208 self.log(">>> INIT_CHECKOUT EVENT", e); 209 self.markAsReady(); 210 }); 211 this.log("Bound: init_checkout event (jQuery)"); 212 213 // WooCommerce checkout updated (AJAX refresh complete) 214 // This fires AFTER all DOM mutations, so it's safe to enable observer 215 jQuery(document.body).on("updated_checkout", function (e) { 216 self.log(">>> UPDATED_CHECKOUT EVENT", e); 217 self.refreshCartProducts(); 218 self.markAsReady(); 219 220 // Enable observer AFTER first checkout update completes 221 // This ensures we don't trigger on initial field additions 222 if (!self.initialLoadComplete) { 223 self.initialLoadComplete = true; 224 self.log("=== INITIAL LOAD COMPLETE - Observer now active ==="); 225 } 226 }); 227 this.log("Bound: updated_checkout event (jQuery)"); 228 229 // WooCommerce checkout error (validation failed) 230 // Don't check triggers here - user didn't change fields, just submitted invali
230d data 231 jQuery(document.body).on("checkout_error", function (e) { 232 self.log(">>> CHECKOUT_ERROR EVENT (not checking triggers)", e); 233 // Intentionally not calling checkTriggers() to avoid re-showing popups 234 }); 235 this.log("Bound: checkout_error event (jQuery)"); 236 237 // === CART PAGE EVENTS === 238 239 // Cart totals updated (cart quantities changed, items removed, etc.) 240 jQuery(document.body).on("updated_cart_totals", function (e) { 241 self.log(">>> UPDATED_CART_TOTALS EVENT", e); 242 self.refreshCartProducts(); 243 self.checkTriggers(); 244 }); 245 this.log("Bound: updated_cart_totals event (jQuery)"); 246 247 // Cart div updated (broader cart refresh) 248 jQuery(document.body).on("updated_wc_div", function (e) { 249 self.log(">>> UPDATED_WC_DIV EVENT", e); 250 self.refreshCartProducts(); 251 self.checkTriggers(); 252 }); 253 this.log("Bound: updated_wc_div event (jQuery)"); 254 255 // WooCommerce fragments loaded (mini-cart, etc.) 256 jQuery(document.body).on("wc_fragments_loaded", function (e) { 257 self.log(">>> WC_FRAGMENTS_LOADED EVENT", e); 258 self.refreshCartProducts(); 259 }); 260 this.log("Bound: wc_fragments_loaded event (jQuery)"); 261 262 // WooCommerce fragments refreshed (listen for our custom fragment) 263 jQuery(document.body).on( 264 "wc_fragments_refreshed", 265 function (e, fragments) { 266 self.log(">>> WC_FRAGMENTS_REFRESHED EVENT", e, fragments); 267 268 // Check if our cart data fragment was updated 269 if (fragments && fragments.w2w_cart_products) { 270 try { 271 const cartData = JSON.parse(fragments.w2w_cart_products); 272 self.cartData = cartData; 273 // Also update global config 274 if (typeof w2wPopupConfig !== "undefined") { 275 w2wPopupConfig.cartData = cartData; 276 } 277 self.log("Cart data updated from fragments:", cartData); 278 } catch (e) { 279 self.log("Failed to parse cart data fragment:", e); 280 } 281 } 282 283 self.refreshCartProducts(); 284 } 285 ); 286 this.log("Bound: wc_fragments_refreshed event (jQuery)"); 287 } else { 288 this.log("â ï¸ jQuery not available, WooCommerce events will not fire"); 289 } 290 291 // Close popup on overlay click 292 document.body.addEventListener("click", function (e) { 293 if (e.target.classList.contains("w2w-popup-overlay")) { 294 const popupId = e.target.dataset.popupId; 295 self.log(">>> OVERLAY CLICK, closing:", popupId); 296 self.closePopup(popupId); 297 } 298 }); 299 300 // Close popup on close button click 301 document.body.addEventListener("click", function (e) { 302 if ( 303 e.target.classList.contains("w2w-popup-close") || 304 e.target.closest(".w2w-popup-close") 305 ) { 306 const overlay = e.target.closest(".w2w-popup-overlay"); 307 if (overlay) { 308 self.log( 309 ">>> CLOSE BUTTON CLICK, closing:", 310 overlay.dataset.popupId 311 ); 312 self.closePopup(overlay.dataset.popupId); 313 } 314 } 315 }); 316 317 // Close popup on ESC key 318 document.addEventListener("keydown", function (e) { 319 if (e.key === "Escape" && self.currentPopup) { 320 self.log(">>> ESC KEY, closing:", self.currentPopup); 321 self.closePopup(self.currentPopup); 322 } 323 }); 324 325 // Watch for dynamically added fields 326 this.observeDOM(); 327 this.log("=== EVENTS BOUND ==="); 328 }, 329 330 /** 331 * Observe DOM for dynamically added content (smart mode) 332 */ 333 observeDOM: function () { 334 const self = this; 335 336 // Observe DOM for dynamically loaded checkout forms and field changes 337 const observer = new MutationObserver(function (mutations) { 338 // PHASE 1: Before initialization - look for checkout form 339 if (!self.initialized) { 340 self.log(">>> MUTATION DETECTED, checking for checkout form..."); 341 342 // Check if checkout form now exists 343 if (document.querySelector(".woocommerce-checkout, form.checkout")) { 344 self.log(">>> Checkout form found via mutation observer!"); 345 self.markAsReady(); 346 } 347 return; 348 } 349 350 // PHASE 2: After initialization - watch for new watched fields 351 self.log( 352 ">>> MUTATION DETECTED (post-init), checking for watched fields..." 353 ); 354 355 // Check if any watched fields were added (e.g., via AJAX) 356 const hasNewWatchedFields = mutations.some(function (mutation) { 357 return Array.from(mutation.addedNodes).some(function (node) { 358 // Only check HTML elements 359 if (node.nodeType !== 1) return false;
360 361 // Check if this node or its children match any watched field 362 return self.popups.some(function (popup) { 363 if (!popup.watch_fields || popup.watch_fields.length === 0) { 364 return false; 365 } 366 367 return popup.watch_fields.some(function (wf) { 368 // Check the node itself 369 if (node.matches && node.matches(wf.selector)) { 370 self.log( 371 " ð¯ Found watched field:", 372 wf.selector, 373 "in added node" 374 ); 375 return true; 376 } 377 378 // Check children of the node 379 if (node.querySelector) { 380 const found = node.querySelector(wf.selector); 381 if (found) { 382 self.log( 383 " ð¯ Found watched field:", 384 wf.selector, 385 "in child of added node" 386 ); 387 return true; 388 } 389 } 390 391 return false; 392 }); 393 }); 394 }); 395 }); 396 397 if (hasNewWatchedFields) { 398 // Only trigger checks if initial load is complete 399 // This prevents popups from showing when fields are first added on page load 400 if (!self.initialLoadComplete) { 401 self.log( 402 " â¹ï¸ Watched fields detected but initial load not complete, skipping" 403 ); 404 return; 405 } 406 407 self.log( 408 " â New watched fields detected after AJAX, checking triggers..." 409 ); 410 self.checkTriggers(); 411 } else { 412 self.log(" â¹ï¸ Mutation detected but no watched fields added"); 413 } 414 }); 415 416 observer.observe(document.body, { 417 childList: true, 418 subtree: true, 419 attributes: false, // Don't care about attribute changes 420 }); 421 422 this.log("=== DOM OBSERVER ACTIVE (SMART MODE) ==="); 423 }, 424 425 /** 426 * Refresh cart data 427 * Called after cart/checkout AJAX updates 428 * 429 * Note: WooCommerce fragments update the w2wPopupConfig.cartData 430 * via the woocommerce_update_order_review_fragments filter 431 */ 432 refreshCartProducts: function () { 433 if (typeof w2wPopupConfig !== "undefined" && w2wPopupConfig.cartData) { 434 this.cartData = w2wPopupConfig.cartData; 435 this.log("Cart data refreshed:", this.cartData); 436 } 437 }, 438 439 /** 440 * Helper: Check a single constraint type (products/categories/tags) 441 */ 442 checkConstraintType: function (popup, type, label) { 443 const items = popup[type]; 444 const matchMode = popup[type + "_match"] || "any"; 445 446 if (!items || items.length === 0) { 447 return true; // No constraints = pass 448 } 449 450 const matches = items.map((item) => { 451 const found = this.cartData[type].includes(item); 452 this.log(" " + label + ":", item, "â", found ? "â" : "â"); 453 return found; 454 }); 455 456 const passed = 457 matchMode === "all" 458 ? matches.every((m) => m) 459 : matches.some((m) => m); 460 this.log(" " + label + " (" + matchMode + "):", passed); 461 return passed; 462 }, 463 464 /** 465 * Check if popup's product/category/tag constraints are met 466 * 467 * @param {object} popup - Popup configuration 468 * @return {boolean} - True if constraints met (or no constraints) 469 */ 470 checkProductConstraints: function (popup) { 471 const productsPassed = this.checkConstraintType( 472 popup, 473 "products", 474 "Products" 475 ); 476 const categoriesPassed = this.checkConstraintType( 477 popup, 478 "categories", 479 "Categories" 480 ); 481 const tagsPassed = this.checkConstraintType(popup, "tags", "Tags"); 482 483 return productsPassed && categoriesPassed && tagsPassed; 484 }, 485 486 /** 487 * Core popup evaluation engine 488 * Evaluates popups based on filter function and shows first match 489 */ 490 evaluatePopups: function (filterFn, context) { 491 this.log("=== EVALUATE POPUPS:", context, "==="); 492 493 if (!this.initialized) { 494 this.log("Not initialized, skipping"); 495 return false;
496 } 497 498 const popupsToCheck = this.popups.filter(filterFn); 499 this.log("Checking", popupsToCheck.length, "popups"); 500 501 for (const popup of popupsToCheck) { 502 this.log("--- Checking popup:", popup.id); 503 504 // Check product constraints first 505 if (!this.checkProductConstraints(popup)) { 506 this.log(" â SKIP: Product constraints not met"); 507 continue; 508 } 509 510 // Skip if already shown and show_once is enabled 511 if (popup.show_once && this.shownPopups.includes(popup.id)) { 512 this.log(" â SKIP: Already shown"); 513 continue; 514 } 515 516 // Skip if currently displayed 517 if (this.currentPopup === popup.id) { 518 this.log(" â SKIP: Currently displayed"); 519 continue; 520 } 521 522 // Check if trigger exists 523 if (!this.triggers[popup.id]) { 524 this.log(" â SKIP: No trigger registered"); 525 continue; 526 } 527 528 this.log(" â¡ Evaluating trigger..."); 529 530 // Evaluate trigger 531 try { 532 const result = this.triggers[popup.id](); 533 this.log(" â Trigger result:", result); 534 535 if (result) { 536 this.log(" ð¯ MATCH! Showing popup:", popup.id); 537 this.showPopup(popup.id); 538 return true; // Popup shown 539 } 540 } catch (error) { 541 this.log(" â ERROR evaluating trigger:", error); 542 } 543 } 544 545 this.log("â No popups triggered"); 546 return false; // No popup shown 547 }, 548 549 /** 550 * Check triggers for popups with check_on_load enabled 551 */ 552 checkTriggersOnLoad: function () { 553 return this.evaluatePopups( 554 (popup) => popup.check_on_load, 555 "on load" 556 ); 557 }, 558 559 /** 560 * Check triggers for a specific field that changed 561 * Only evaluates popups that are watching this field 562 */ 563 checkTriggersForField: function (fieldElement, fieldType) { 564 this.log( 565 "Field changed:", 566 fieldElement.id || fieldElement.name, 567 "Type:", 568 fieldType 569 ); 570 571 if (!this.initialized) { 572 this.log("System not ready yet, skipping"); 573 return false; 574 } 575 576 // Build selector for this field 577 const fieldSelector = fieldElement.id 578 ? "#" + fieldElement.id 579 : fieldElement.name 580 ? '[name="' + fieldElement.name + '"]' 581 : null; 582 583 if (!fieldSelector) { 584 this.log("â ï¸ Field has no ID or name, cannot match"); 585 return false; 586 } 587 588 return this.evaluatePopups((popup) => { 589 // If no watch_fields specified, check all (backward compatibility) 590 if (!popup.watch_fields || popup.watch_fields.length === 0) { 591 return true; 592 } 593 594 // Check if any watch_field matches this field 595 return popup.watch_fields.some((watchField) => { 596 const selectorMatches = fieldElement.matches(watchField.selector); 597 const typeMatches = !watchField.type || watchField.type === fieldType; 598 return selectorMatches && typeMatches; 599 }); 600 }, "field " + fieldSelector); 601 }, 602 603 /** 604 * Check all popup triggers (manual check) 605 */ 606 checkTriggers: function () { 607 if (this.debug) { 608 // Only query DOM for debug logging 609 const billingState = document.getElementById("billing_state"); 610 const shippingState = document.getElementById("shipping_state"); 611 this.log("billing_state:", billingState?.value); 612 this.log("shipping_state:", shippingState?.value); 613 } 614 615 return this.evaluatePopups( 616 () => true, // Check all popups 617 "manual check" 618 ); 619 }, 620 621 /** 622 * Show a popup 623 */ 624 showPopup: function (popupId) { 625 this.log("=== SHOW POPUP:", popupId, "==="); 626 627 // Close any currently open popup 628 if (this.currentPopup) { 629 this.log("Closing current popup:", this.currentPopup); 630 this.closePopup(this.currentPopup, true); 631 } 632 633 const popup = document.getElementById("w2w-popup-" + popupId); 634 635 if (!popup) { 636 this.log("â ERROR: Popup element not found in DOM"); 637 return; 638 } 639 640 this.log("Showing popup with CSS animation..."); 641 642 // Two-step process to trigger CSS transition:
643 // 1. Add showing class (sets display: flex, opacity still 0) 644 popup.classList.add("w2w-popup--showing"); 645 646 // 2. Force reflow to ensure display:flex is rendered 647 popup.offsetHeight; 648 649 // 3. Add visible class (triggers opacity transition to 1) 650 popup.classList.add("w2w-popup--visible"); 651 652 document.body.classList.add("w2w-popup-open"); 653 654 // Track current popup 655 this.currentPopup = popupId; 656 657 // Track for session if show_once is enabled 658 const popupConfig = this.popups.find((p) => p.id === popupId); 659 if (popupConfig && popupConfig.show_once) { 660 this.log("Tracking popup as shown (show_once enabled)"); 661 this.shownPopups.push(popupId); 662 this.saveShownPopups(); 663 } 664 665 // Trigger custom event 666 document.body.dispatchEvent( 667 new CustomEvent("w2w_popup_shown", { 668 detail: { popupId: popupId }, 669 }) 670 ); 671 672 this.log("=== POPUP SHOWN ==="); 673 }, 674 675 /** 676 * Close a popup 677 */ 678 closePopup: function (popupId, silent) { 679 this.log("=== CLOSE POPUP:", popupId, "silent:", silent, "==="); 680 681 const popup = document.getElementById("w2w-popup-" + popupId); 682 683 if (!popup) { 684 this.log("â Popup element not found"); 685 return; 686 } 687 688 this.log("Closing popup with CSS animation..."); 689 690 // Remove visible class - triggers opacity fade out 691 popup.classList.remove("w2w-popup--visible"); 692 693 // Wait for opacity transition to complete before removing showing class 694 const handleTransitionEnd = (e) => { 695 // Only handle opacity transition on the overlay itself 696 if (e.target === popup && e.propertyName === "opacity") { 697 // Transition complete - now remove showing class (sets display: none) 698 popup.classList.remove("w2w-popup--showing"); 699 this.log("â Popup fade-out complete"); 700 popup.removeEventListener("transitionend", handleTransitionEnd); 701 } 702 }; 703 704 popup.addEventListener("transitionend", handleTransitionEnd); 705 706 document.body.classList.remove("w2w-popup-open"); 707 708 if (this.currentPopup === popupId) { 709 this.currentPopup = null; 710 } 711 712 // Trigger custom event 713 if (!silent) { 714 document.body.dispatchEvent( 715 new CustomEvent("w2w_popup_closed", { 716 detail: { popupId: popupId }, 717 }) 718 ); 719 } 720 721 this.log("=== POPUP CLOSED ==="); 722 }, 723 724 /** 725 * Load shown popups from sessionStorage 726 */ 727 loadShownPopups: function () { 728 try { 729 const stored = sessionStorage.getItem("w2w_shown_popups"); 730 if (stored) { 731 this.shownPopups = JSON.parse(stored); 732 } 733 } catch (e) { 734 console.warn("W2W Popups: Failed to load session data", e); 735 } 736 }, 737 738 /** 739 * Save shown popups to sessionStorage 740 */ 741 saveShownPopups: function () { 742 try { 743 sessionStorage.setItem( 744 "w2w_shown_popups", 745 JSON.stringify(this.shownPopups) 746 ); 747 } catch (e) { 748 console.warn("W2W Popups: Failed to save session data", e); 749 } 750 }, 751 }; 752 753 // Initialize on DOM ready 754 if (document.readyState === "loading") { 755 document.addEventListener("DOMContentLoaded", function () { 756 W2WPopups.init(); 757 }); 758 } else {
759 W2WPopups.init(); 760 } 761})();
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.