PageSourceSearch

https://ways2well.com/wp-content/plugins/w2w-core/inc/popup-manager/popup-manager.js?ver=1790794182

js ways2well.com collected 2026-10-01 13:13:20 UTC 24,403 bytes, 761 lines download raw bytes

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.