PageSourceSearch

https://angiesgardensapothecary.com/wp-content/plugins/saveto-wish…avetowishlist-frontend-scripts.js?ver=1.1.6

js angiesgardensapothecary.com collected 2026-10-09 22:38:10 UTC 41,100 bytes, 1,020 lines download raw bytes

1(function ($) {
2  const modal = $('<div id="stw-confirmation-modal" class="stw-modal"><div class="stw-modal-content"><div class="stw-modal-header"><span class="stw-close">&times;</span></div><div class="stw-modal-body"><p>' + stwlite_frontend_js_vars.i18n.added_to_wishlist + '</p></div><div class="stw-modal-footer"><button id="stw-confirm-button" class="stw-modal-button">' + stwlite_frontend_js_vars.i18n.view_wishlist + '</button></div></div></div>')
3  $('body').append(modal)
4
5  const modalElement = $('#stw-confirmation-modal')
6
7  const span = modalElement.find('.stw-close')
8
9  const confirmBtn = modalElement.find('#stw-confirm-button')
10  const cancelBtn = modalElement.find('#stw-cancel-button')
11
12  // Mobile: detect touch-primary devices (phones/tablets — not desktop)
13  const isTouchPrimary = window.matchMedia('(hover: none) and (pointer: coarse)').matches
14  const listsBackdrop = $('<div class="stw-mobile-lists-backdrop" aria-hidden="true"></div>')
15
16  function closeMobileListsPopup () {
17    const popup = $('.save-to-wishlist-multiple-lists-container.stw-multiple-lists-open')
18    if (!popup.length) return
19    popup.removeClass('stw-multiple-lists-open')
20    listsBackdrop.removeClass('stw-active')
21    // Restore the popup to its original DOM position
22    const prevSibling = popup.data('stw-prev-sibling')
23    const originalParent = popup.data('stw-original-parent')
24    if (prevSibling && prevSibling.length) {
25      popup.insertAfter(prevSibling)
26    } else if (originalParent && originalParent.length) {
27      originalParent.prepend(popup)
28    }
29  }
30
31  function openMobileListsPopup (popup) {
32    closeMobileListsPopup()
33    // Store the popup's current DOM position so it can be restored on close
34    popup.data('stw-prev-sibling', popup.prev())
35    popup.data('stw-original-parent', popup.parent())
36    // Move popup to <body> to escape any ancestor stacking context.
37    // Placement containers (e.g. top_right) have position:absolute + z-index:1,
38    // which caps everything inside at that stacking level — keeping the popup
39    // below the backdrop (z-index:99998) no matter how high the popup's own
40    // z-index is. Moving it to <body> puts it in the root stacking context.
41    $('body').append(popup)
42    popup.addClass('stw-multiple-lists-open')
43    listsBackdrop.addClass('stw-active')
44  }
45
46  /**
47   * Open the confirmation modal and bind confirm/cancel handlers.
48   *
49   * @since 1.0.0
50   *
51   * @param {Function|null} callback - Callback invoked with a boolean indicating whether the user confirmed.
52   */
53  function openModal(callback) {
54    modalElement.show()
55
56    if (callback !== null) {
57      confirmBtn.off('click').on('click', function () {
58        if (typeof callback === 'function') {
59          callback(true)
60        }
61        closeModal()
62      })
63
64      cancelBtn.off('click').on('click', function () {
65        if (typeof callback === 'function') {
66          callback(false)
67        }
68        closeModal()
69      })
70    }
71  }
72
73  // Names of the two add-to-wishlist signals, kept here so the emit and clear
74  // sites below can never drift apart. The readme and the Cypress specs assert
75  // these same names independently — a rename has to be mirrored there too.
76  const SIGNAL_EVENT_NAME = 'stwlite_added_to_wishlist'
77  const SIGNAL_COOKIE_NAME = 'stwlite_wishlist_added'
78
79  /**
80   * Emit the client-side add-to-wishlist signal for popup/marketing integrations.
81   *
82   * Fires a generic "stwlite_added_to_wishlist" DOM CustomEvent for instant
83   * triggers (e.g. opening a Popup Maker popup the moment a product is
84   * wishlisted) and sets a "stwlite_wishlist_added" cookie so cross-page-load
85   * targeting (e.g. exit-intent on a return visit) can react. Both are generic
86   * and harmless when nothing is listening. Called only after an add is
87   * confirmed — never on a failed add.
88   *
89   * @since 1.0.10
90   *
91   * @param {number} productId    - The product or variation ID that was added; 0 when several products were added at once.
92   * @param {number} collectionId - The wishlist collection the product was added to; 0 for guests (localStorage has no collection).
93   */
94  function emitWishlistAddSignal(productId, collectionId) {
95    setWishlistAddSignalCookie(encodeURIComponent(productId))
96
97    // Listener exceptions are isolated by the browser and never propagate here.
98    document.dispatchEvent(new CustomEvent(SIGNAL_EVENT_NAME, {
99      bubbles: true,
100      detail: {
101        productId: productId,
102        collectionId: collectionId
103      }
104    }))
105  }
106
107  /**
108   * Write the "stwlite_wishlist_added" signal cookie.
109   *
110   * @since 1.0.10
111   *
112   * @param {string} value   - The already-encoded cookie value; empty to clear the cookie.
113   * @param {number} maxAge  - The cookie lifetime in seconds; 0 expires it immediately.
114   */
115  function setWishlistAddSignalCookie(value, maxAge = 30 * 24 * 60 * 60) {
116    try {
117      document.cookie = SIGNAL_COOKIE_NAME + '=' + value +
118        '; path=/; max-age=' + maxAge + '; SameSite=Lax' +
119        (window.location.protocol === 'https:' ? '; Secure' : '')
120    } catch (e) {
121      // A cookie write failure must never block the add/remove flow.
122      console.error('Save to Wishlist: Could not write the wishlist add signal cookie.', e)
123    }
124  }
125
126  /**
127   * Clear the add-to-wishlist signal so removals cannot leave a stale "added" flag behind.
128   *
129   * The cookie is a store-wide "this customer has wishlisted something" flag, not
130   * a per-product one, so it only goes stale once the customer's last wishlist
131   * item is gone. Callers must therefore clear it only on an emptying removal —
132   * clearing on any removal would suppress the documented exit-intent popup while
133   * other products are still wishlisted.
134   *
135   * @since 1.0.10
136   */
137  function clearWishlistAddSignal() {
138    setWishlistAddSignalCookie('', 0)
139  }
140
141  /**
142   * Add a product to the wishlist for either a guest (localStorage) or logged-in user (AJAX).
143   *
144   * @since 1.0.0
145   *
146   * @param {jQuery} button         - The wishlist button element that was clicked.
147   * @param {number} productId      - The product or variation ID to add.
148   * @param {number} collectionId   - The target wishlist collection ID.
149   * @param {string} [urlCode]      - Optional wishlist URL code; falls back to the default wishlist's code.
150   * @param {string}
150 [wishlistName] - Optional wishlist name used in fade notifications.
151   */
152  function addItemToWishlist(button, productId, collectionId, urlCode = '', wishlistName = '') {
153    wishlistName = wishlistName || (stwlite_frontend_js_vars.stwOptions.defaultWishlist && stwlite_frontend_js_vars.stwOptions.defaultWishlist.name) || ''
154    const urlProductCode = urlCode || stwlite_frontend_js_vars.stwOptions.defaultWishlist.url_code
155
156    // Check if user is logged in.
157    if (parseInt(stwlite_frontend_js_vars.user_id) === 0 && stwlite_frontend_js_vars.stwOptions.disableGuestWishlist === 'no') {
158      if (!productId) {
159        console.error('Save to Wishlist: Missing data-product-id attribute.')
160        return
161      }
162
163      const wishlist = getGuestWishlist()
164
165      const productIdStr = String(productId)
166
167      let quantity = 1
168      let showPopup = true
169      let wasAdded = false
170      if (Object.prototype.hasOwnProperty.call(wishlist, productIdStr)) {
171        if (!stwlite_frontend_js_vars.stwOptions.allowMultipleProductsInWishlist) {
172          if (stwlite_frontend_js_vars.stwOptions.allowRemoveProductFromWishlist) {
173            delete wishlist[productIdStr]
174            setWishlistButtonState(button, false)
175            showPopup = false
176
177            if (Object.keys(wishlist).length === 0) {
178              clearWishlistAddSignal()
179            }
180          }
181        } else {
182          quantity = wishlist[productIdStr].quantity + 1
183
184          // Update only the quantity for the product in the wishlist.
185          wishlist[productIdStr].quantity = quantity
186          wasAdded = true
187        }
188      } else {
189        // Add product to wishlist.
190        wishlist[productIdStr] = {
191          productId,
192          quantity,
193          addedAt: new Date().toISOString()
194        }
195        setWishlistButtonState(button, true)
196        wasAdded = true
197      }
198
199      try {
200        localStorage.setItem('stw_wishlist', JSON.stringify(wishlist))
201
202        if (wasAdded) {
203          // Guests have no server-side collection; their items live in localStorage keyed by product ID.
204          emitWishlistAddSignal(productId, 0)
205        }
206
207        // Show popup if not removed.
208        if (showPopup) {
209          if (stwlite_frontend_js_vars.stwOptions.wishlistAddActionButton === 'fade') {
210            showFadeNotification(button, wishlistName)
211          } else {
212            // Update modal content.
213            let modalContent = '<p>' + stwlite_frontend_js_vars.i18n.added_to_wishlist + '</p>'
214            if (stwlite_frontend_js_vars.stwOptions.showLoginCreateLink === 'yes') {
215              modalContent += '<p><a href="' + stwlite_frontend_js_vars.stwOptions.myAccountUrl + '">' + stwlite_frontend_js_vars.i18n.guest_wishlist_description + '</a></p>'
216            }
217            modalElement.find('.stw-modal-body').html(modalContent)
218
219            openModal(function (confirmed) {
220              if (confirmed) {
221                window.location.href = stwlite_frontend_js_vars.stwOptions.wishlistUrl
222              } else {
223                closeModal()
224              }
225            })
226          }
227        }
228      } catch (e) {
229        console.error('Save to Wishlist: Could not save wishlist to localStorage.', e)
230      }
231
232      return
233    }
234
235    if (button.hasClass('stw-active')) {
236      // When the admin has selected "View Wishlist" as the selected-state action
237      // and a wishlist page is configured, a click on the active button sends the
238      // customer to the wishlist page instead of removing the product. Append the
239      // url_code of the wishlist that holds this product so the customer lands on
240      // the specific list (the bare wishlist URL renders the list-of-wishlists
241      // view, which doesn't show the product item), falling back to the default
242      // wishlist's code, then the bare URL. The remove flow below runs only when
243      // the action is not "view" or no wishlist page is configured.
244      if (
245        stwlite_frontend_js_vars.stwOptions.selectedWishlistButtonAction === 'view' &&
246        stwlite_frontend_js_vars.stwOptions.wishlistUrl
247      ) {
248        const defaultWishlist = stwlite_frontend_js_vars.stwOptions.defaultWishlist
249        const viewWishlistUrlCode = button.data('wishlist-url-code') || (defaultWishlist && defaultWishlist.url_code) || ''
250        window.location.href = stwlite_frontend_js_vars.stwOptions.wishlistUrl + viewWishlistUrlCode
251        return
252      }
253
254      if (stwlite_frontend_js_vars.stwOptions.allowRemoveProductFromWishlist) {
255        button.prop('disabled', true)
256        button.addClass('stw-removing')
257        button.find('.stw-text-label').text(stwlite_frontend_js_vars.i18n.removing)
258
259        jQuery.ajax({
260          url: stwlite_frontend_js_vars.ajax_url,
261          type: 'POST',
262          data: {
263            action: 'stwlite_remove_product_from_wishlist',
264            nonce: stwlite_frontend_js_vars.nonce,
265            product_id: productId,
266            collection_id: collectionId
267          },
268          success: function (response) {
269            if (response.status === 'success') {
270              setWishlistButtonState(button, false)
271              button.removeAttr('disabled')
272              button.removeClass('stw-removing')
273
274              // `remaining` counts the customer's wishlist items left after this
275              // removal. Absent (older cached JS against updated PHP, or the
276              // reverse) means unknown, in which case the flag is left alone —
277              // a lingering flag is less disruptive than suppressing a popup
278              // for a customer who still has wishlisted products.
279              if (parseInt(response.remaining, 10) === 0) {
280                clearWishlistAddSignal()
281              }
282            }
283          }
284        })
285
286        return
287      }
288
289      if (!stwlite_frontend_js_vars.stwOptions.allowMultipleProductsInWishlist) {
290        return
291      }
292    }
293
294    // Captured before the "Adding..." swap so a failed save can put the button
295    // back exactly as the customer found it. Reading the live text rather than
296    // assuming the inactive label keeps this correct when multiple products per
297    // wishlist are allowed and an already-active button reaches this path.
298    const label = button.find('.stw-text-label')
299    const previousLabel = label.text()
300
301    button.prop('disabled', true)
302    button.addClass('stw-adding')
303    label.text(stwlite_frontend_js_vars.i18n.adding)
304
305    jQuery.ajax({
306      url: stwlite_frontend_js_vars.ajax_url,
307      type: 'POST',
308      data: {
309        action: 'stwlite_add_to_wishlist',
310        nonce: stwlite_frontend_js_vars.nonce,
311        product_id: productId,
312        collection_id: collectionId
313      },
314      success: function (response) {
315        // `added` reports whether the row was actually written; `status` alone is
316        // always 'success' for a well-formed request, even when nothing was saved.
317        // A save that did not land must not render a confirmation — the customer
318        // would walk away believing the product is on their list. The known case
319        // is a first-ever save that loses the default-wishlist provisioning lock
320        // and so has no collection to write to; the next click succeeds, which is
321        // why this surfaces a retry rather than a hard error.
322        if (response.status !== 'success' || response.added !== true) {
323          button.removeClass('stw-adding')
324          button.removeAttr('disabled')
325          label.text(previousLabel)
326          showRetryNotification(button)
327
328          return
329        }
330
331        emitWishlistAddSignal(productId, collectionId)
332
333        if (stwlite_frontend_js_vars.stwOptions.wishlistAddActionButton === 'redirect') {
334          window.location.href = stwlite_frontend_js_vars.stwOptions.wishlistUrl + urlProductCode
335        } else if (stwlite_frontend_js_vars.stwOptions.wishlistAddActionButton === 'fade') {
336          resetButton(button)
337          showFadeNotification(button, wishlistName)
338        } else {
339          openModal(function (confirmed) {
340            if (confirmed) {
341              window.location.href = stwlite_frontend_js_vars.stwOptions.wishlistUrl + urlProductCode
342            } else {
343              closeModal()
344            }
345          })
346
347          resetButton(button)
348        }
349      }
350    })
351  }
352
353  /**
354   * Toggle the wishlist button's active state and update its label.
355   *
356   * A single product can be represented by more than one button on the page —
357   * e.g. the single-product page's button and the Fast Cart popup's per-item
358   * button both target the same product. When the state changes we sync
359   * every button that shares the same product id so the label stays
360   * consistent no matter which one the user clicked.
361   *
362   * @since 1.0.5
363   *
364   * @param {jQuery}  $button  - The wishlist button element that was interacted with.
365   * @param {boolean} isActive - Whether the button should be marked as active (product in wishlist).
366   */
367  function setWishlistButtonState($button, isActive) {
368    const label = isActive
369      ? stwlite_frontend_js_vars.buttonLabels.selectedShopWishlistLabel
370      : stwlite_frontend_js_vars.buttonLabels.shopWishlistLabel
371    const productId = $button.data('product-id')
372    const $targets = productId
373      ? $('.save-to-wishlist-button[data-product-id="' + productId + '"]')
374      : $button
375    $targets.toggleClass('stw-active', isActive)
376    $targets.find('.stw-text-label').text(label)
377    syncButtonAccessibleName($targets, isActive)
378    syncButtonLinkRole($targets)
379  }
380
381  /**
382   * Keep an icon-only button's accessible name in step with its saved state.
383   *
384   * An icon-only button has no text, so PHP renders the wording for each state
385   * onto the element and we pick the matching one here; without this the button
386   * keeps announcing "add" after the product was saved. A state whose wording is
387   * empty renders visible text, and that text is its accessible name — so drop
388   * the aria-label rather than overriding what the customer can read.
389   *
390   * Buttons with no wordings at all carry neither attribute and are left alone.
391   *
392   * @since 1.1.3
393   *
394   * @param {jQuery}  $buttons - The wishlist buttons to sync.
395   * @param {boolean} isActive - Whether the product is in the wishlist.
396   */
397  function syncButtonAccessibleName($buttons, isActive) {
398    $buttons.each(function () {
399      const $button = $(this)
400      const accessibleName = isActive
401        ? $button.attr('data-selected-label')
402        : $button.attr('data-default-label')
403
404      if (typeof accessibleName === 'undefined') {
405        return
406      }
407
408      if (accessibleName) {
409        $button.attr('aria-label', accessibleName)
410      } else {
411        $button.removeAttr('aria-label')
412      }
413    })
414  }
415
416  /**
417   * Hint the button's link-like behavior to assistive technology.
418   *
419   * When the selected-state action is "View Wishlist", clicking an active
420   * button navigates to the wishlist page instead of performing an in-page
421   * action, so the active button carries role="link".
422   *
423   * @since 1.0.8
424   *
425   * @param {jQuery} $button - The wishlist button element.
426   */
427  function syncButtonLinkRole($button) {
428    // A guest's click on an active button never navigates — it removes the
429    // item in-page and returns before the view-navigation block — so the
430    // link role would be misleading for guests. Mirror the navigation path's
431    // implicit logged-in-only reachability.
432    if (
433      parseInt(stwlite_frontend_js_vars.user_id) === 0 ||
434      stwlite_frontend_js_vars.stwOptions.selectedWishlistButtonAction !== 'view' ||
435      !stwlite_frontend_js_vars.stwOptions.wishlistUrl
436    ) {
437      return
438    }
439
440    if ($button.hasClass('stw-active')) {
441      $button.attr('role', 'link')
442    } else {
443      $button.removeAttr('role')
444    }
445  }
446
447  function disableIfCartItemButton(button) {
448    if (button.closest('[class*="stw-wishlist-button-placement-cart_item"]').length) {
449      button.prop('disabled', true)
450      return true
451    }
452    return false
453  }
454
455  /**
456   * Reset the wishlist button to its active state and remove loading classes.
457   *
458   * @since 1.0.4
459   *
460   * @param {jQuery} button - The wishlist button element to reset.
461   */
462  function resetButton(button) {
463    setWishlistButtonState(button, true)
464    button.removeClass('stw-adding')
465    button.removeAttr('disabled')
466  }
467
468  /**
469   * Hide the confirmation modal.
470   *
471   * @since 1.0.0
472   */
473  function closeModal() {
474    modalElement.hide()
475  }
476
477  /**
478   * Show a transient fade-in/out notification next to the wishlist button.
479   *
480   * @since 1.0.4
481   *
482   * @param {jQuery} button       - The wishlist button the notification is anchored to.
483   * @param {string} wishlistName - The wishlist name rendered in the notification message.
484   */
485  function showFadeNotification(button, wishlistName) {
486    const message = stwlite_frontend_js_vars.i18n.added_to_wishlist_name.replace('%s', wishlistName)
487    const checkIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>'
488
489    renderFadeNotification(button, message, checkIcon, false)
490  }
491
492  /**
493   * Show a transient failure notification telling the customer the save did not
494   * stick and is worth retrying.
495   *
496   * @since 1.1.1
497   *
498   * @param {jQuery} button - The wishlist button the notification is anchored to.
499   */
500  function showRetryNotification(button) {
501    const alertIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>'
502
503    renderFadeNotification(button, stwlite_frontend_js_vars.i18n.wishlist_save_failed, alertIcon, true)
504  }
505
506  /**
507   * Render a transient fade-in/out notification next to the wishlist button.
508   *
509   * Shared by the success and failure notifications so both keep the same
510   * placement, timing and screen-reader behavior. A failure is announced
511   * assertively — the customer needs to know the product was not saved before
512   * the notice fades.
513   *
514   * @since 1.1.1
515   *
516   * @param {jQuery}  button  - The wishlist button the notification is anchored to.
517   * @param {string}  message - The message to render. Escaped before insertion.
518   * @param {string}  icon    - Inline SVG markup rendered ahead of the message.
519   * @param {boolean} isError - Whether this reports a failed save.
520   */
521  function renderFadeNotification(button, message, icon, isError) {
522    const container = button.closest('.stw-wishlist-button-container')
523    container.find('.stw-fade-notification').remove()
524    const errorClass = isError ? ' stw-fade-notification-error' : ''
525    const liveRole = isError ? 'alert' : 'status'
526    const politeness = isError ? 'assertive' : 'polite'
527    const notification = $('<span class="stw-fade-notification' + errorClass + '" role="' + liveRole + '" aria-live="' + politeness + '" aria-atomic="true">' + icon + $('<span>').text(message).html() + '</span>')
528    notification.css('display', 'block').hide()
529    // Append to the container so the notification renders for guests too.
530    // .save-to-wishlist-multiple-lists-container only exists for logged-in
531    // users (gated by is_user_logged_in() in Buttons::wishlist_button_html),
532    // so the prior .next().after() insertion was a no-op for guests.
533    container.append(notification)
534    notification.fadeIn(300, function () {
535      setTimeout(function () {
536        notification.fadeOut(400, function () {
537          notification.remove()
538        })
539      }, 2000)
540    })
541  }
542
543  span.on('click', function () {
544    closeModal()
545  })
546
547  $(window).on('click', function (event) {
548    if (event.target == modalElement[0]) {
549      closeModal()
550    }
551  })
552
553  if (isTouchPrimary) {
554    $('body').append(listsBackdrop)
555    listsBackdrop.on('click', closeMobileListsPopup)
556  }
557
558  /**
559   * Read the guest wishlist from localStorage.
560   *
561   * Always resolves to a plain object so callers can index it without further
562   * guarding. Returns an empty object when the entry is absent, when storage is
563   * unavailable (private browsing), when the value is not valid JSON, or when it
564   * decodes to anything other than a plain object (the literal `null`, an array,
565   * a number, a string). Arrays are rejected explicitly: `typeof [] === 'object'`
566   * passes the object check, but a numeric product ID is a valid array index, so
567   * storing one would re-serialise the wishlist as a sparse array padded with a
568   * `null` per unused index up to that product ID.
569   *
570   * @since 1.0.9
571   *
572   * @return {Object} Product ID keyed wishlist map; empty when unavailable.
573   */
574  function getGuestWishlist() {
575    try {
576      const storedWishlist = localStorage.getItem('stw_wishlist')
577      if (!storedWishlist) {
578        return {}
579      }
580
581      const wishlist = JSON.parse(storedWishlist)
582      return wishlist && typeof wishlist === 'object' && !Array.isArray(wishlist) ? wishlist : {}
583    } catch (e) {
584      console.error('Save to Wishlist: Could not parse wishlist from localStorage.', e)
585      return {}
586    }
587  }
588
589  /**
590   * Apply the guest wishlist's in-wishlist state (from localStorage) to every
591   * wishlist button currently in the DOM.
592   *
593   * Logged-in state is rendered server-side, but a guest's wishlist lives only
594   * in localStorage, so buttons must be marked active on the client.
595   * No-op for logged-in users; idempotent and cheap to re-run.
596   *
597   * @since 1.0.9
598   */
599  function applyGuestWishlistState() {
600    if (parseInt(stwlite_frontend_js_vars.user_id) !== 0 || stwlite_frontend_js_vars.stwOptions.disableGuestWishlist !== 'no') {
601      return
602    }
603
604    // Get guest wishlist products.
605    const wishlist = getGuestWishlist()
606    if (!Object.keys(wishlist).length) {
607      return
608    }
609
610    // Loop through all .save-to-wishlist-button buttons and add stw-active if productId matches
611    $('.save-to-wishlist-button').each(function () {
612      const $btn = $(this)
613      const btnProductId = String($btn.data('product-id'))
614      if (Object.prototype.hasOwnProperty.call(wishlist, btnProductId)) {
615        setWishlistButtonState($btn, true)
616        disableIfCartItemButton($btn)
617      }
618    })
619  }
620
621  applyGuestWishlistState()
622
623  // Public re-sync entry point for integrations whose plugin replaces the
624  // product loop markup after load (Search & Filter, for one). Server-rendered
625  // markup already carries the logged-in state, so this only has work to do for
626  // guests — but it is safe and cheap to call in either case.
627  window.stwliteRefreshWishlistButtons = applyGuestWishlistState
628
629  // Store wishlist from localStorage to database after account is created/logged in.
630  const wishlist = getGuestWishlist()
631  if (parseInt(stwlite_frontend_js_vars.user_id) > 0 && Object.keys(wishlist).length) {
632    $.ajax({
633      url: stwlite_frontend_js_vars.ajax_url,
634      type: 'POST',
635      data: {
636        action: 'stwlite_store_wishlist_from_local_storage',
637        wishlist,
638        nonce: stwlite_frontend_js_vars.nonce
639      },
640      success: function (response) {
641        if (response.status === 'success' && response.items.length > 0) {
642          // Remove all items from localStorage.
643          localStorage.removeItem('stw_wishlist')
644
645          // Update wishlist items.
646          response.items.forEach(function (itemId) {
647            const button = $('.save-to-wishlist-button[data-product-id="' + itemId + '"]')
648            button.addClass('stw-active')
649            syncButtonAccessibleName(button, true)
650            button.each(function () {
651              syncButtonLinkRole($(this))
652              disableIfCartItemButton($(this))
653            })
654          })
655        }
656      }
657    })
658  }
659
660  $(document).on('click', '.save-to-wishlist-button', function (e) {
661    e.preventDefault()
662
663    // On touch devices: first tap opens the choose-list bottom sheet instead of
664    // adding immediately. If the popup is already open (user tapped again after
665    // it was somehow still visible), fall through to normal add behaviour.
666    if (isTouchPrimary) {
667      const container = $(this).closest('.stw-wishlist-button-container')
668      const popup = container.find('.save-to-wishlist-multiple-lists-container')
669      if (popup.length && !popup.hasClass('stw-multiple-lists-open')) {
670        openMobileListsPopup(popup)
671        return
672      }
673    }
674
675    const button = $(this)
676    const collectionId = button.data('collection-id') ?? stwlite_frontend_js_vars.stwOptions.defaultWishlist?.id
677    let productId = button.data('product-id')
678
679    const variationId = getVariationId(button)
680    if (variationId > 0) {
681      productId = variationId
682    }
683
684    addItemToWishlist(button, productId, collectionId)
685  })
686
687  $(document).on('click', '.save-to-wishlist-multiple-lists-container', function (e) {
688    // Stop propagation so clicks inside the popup sheet don't bubble up and
689    // accidentally trigger other handlers. e.preventDefault() is intentionally
690    // omitted — it has no effect on <div> clicks but can interfere with the
691    // iOS native select picker opening inside the sheet.
692    e.preventDefault()
693    e.stopPropagation()
694  })
695
696  $(document).on('change', '.save-to-wishlist-multiple-lists-button-content select', function (e) {
697    e.preventDefault()
698    const select = $(this)
699    let productId = select.data('product-id')
700    const button = $('.save-to-wishlist-product-id-' + productId)
701    const wishlistData = select.val()
702    const collectionId = wishlistData.split(':')[0]
703    const urlCode = wishlistData.split(':')[1]
704
705    const variationId = getVariationId(button)
706    if (variationId > 0) {
707      productId = variationId
708    }
709
710    // Reset the select field to the first option.
711    select.val(select.find('option:first').val())
712
713    addItemToWishlist(button, productId, collectionId, urlCode)
714
715    // Close the mobile bottom sheet after a list is selected.
716    if (isTouchPrimary) {
717      closeMobileListsPopup()
718    }
719  })
720
721  /**
722   * Resolve the currently selected variation ID for a wishlist button.
723   *
724   * @since 1.0.4
725   *
726   * @param {jQuery} button - The wishlist button element.
727   * @return {number} The variation ID, or 0 when no variation is selected.
728   */
729  function getVariationId(button) {
730    let variationId = button.data('variation-id')
731
732    if (!variationId) {
733      const variationsForm = button.closest('form.variations_form, .product').find('form.variations_form').first()
734      if (variationsForm.length > 0) {
735        const selectedVariationId = variationsForm.find('input[name="variation_id"]').val()
736        if (selectedVariationId && selectedVariationId !== '') {
737          variationId = selectedVariationId
738        }
739      }
740    }
741
742    return variationId ? parseInt(variationId) : 0
743  }
744
745  /**
746   * Add cart items to the given wishlist collection for logged-in users.
747   *
748   * @since 1.0.0.1
749   *
750   * @param {jQuery}        button         - The cart wishlist button element.
751   * @param {number}        collectionId   - The target wishlist collection ID.
752   * @param {number[]|null} selectedIds    - Optional list of product IDs to add; when null/empty, all cart items are added.
753   * @param {string}        [urlCode]      - Optional wishlist URL code; falls back to the default wishlist's code.
754   * @param {string}        [wishlistName] - Optional wishlist name used in fade notifications.
755   */
756  function addItemToWishlistCart(button, collectionId, selectedIds, urlCode = '', wishlistName = '') {
757    wishlistName = wishlistName || (stwlite_frontend_js_vars.stwOptions.defaultWishlist && stwlite_frontend_js_vars.stwOptions.defaultWishlist.name) || ''
758    const urlProductCode = urlCode || stwlite_frontend_js_vars.stwOptions.defaultWishlist.url_code
759
760    button.prop('disabled', true)
761    button.addClass('stw-adding')
762    button.find('.stw-text-label').text(stwlite_frontend_js_vars.i18n.adding)
763
764    const postData = {
765      action: 'stwlite_add_product_cart_to_wishlist',
766      nonce: stwlite_frontend_js_vars.nonce,
767      collection_id: collectionId
768    }
769
770    if (selectedIds && selectedIds.length > 0) {
771      postData.product_ids = selectedIds
772    }
773
774    jQuery.ajax({
775      url: stwlite_frontend_js_vars.ajax_url,
776      type: 'POST',
777      data: postData,
778      success: function (response) {
779        // If all submitted items were already in the wishlist, disable the button
780        // and inform the user without triggering the normal post-add flow.
781        if (response.all_in_wishlist) {
782          button.prop('disabled', true)
783          button.attr('title', stwlite_frontend_js_vars.i18n.all_cart_items_in_wishlist)
784          button.find('.stw-text-label').text(button.find('.stw-text-label').attr('data-label'))
785          button.removeClass('stw-adding')
786          return
787        }
788
789        // Signal a cart save as a single add; productId 0 means "several products".
790        if (response.added > 0) {
791          emitWishlistAddSignal(0, collectionId)
792        }
793
794        if (stwlite_frontend_js_vars.stwOptions.wishlistAddActionButton === 'redirect') {
795          window.location.href = stwlite_frontend_js_vars.stwOptions.wishlistUrl + urlProductCode
796        } else if (stwlite_frontend_js_vars.stwOptions.wishlistAddActionButton === 'fade') {
797          button.fadeOut(400, function () {
798            button.find('.stw-text-label').text(button.find('.stw-text-label').attr('data-label'))
799            button.removeAttr('disabled')
800            button.removeClass('stw-adding')
801            button.fadeIn(400, function () {
802              showFadeNotification(button, wishlistName)
803            })
804          })
805        } else {
806          // Update modal content.
807          modalElement.find('.stw-modal-body').html('<p>' + response.message + '</p>')
808
809          openModal(function (confirmed) {
810            if (confirmed) {
811              window.location.href = stwlite_frontend_js_vars.stwOptions.wishlistUrl + urlProductCode
812            } else {
813              closeModal()
814            }
815          })
816
817          button.find('.stw-text-label').text(button.find('.stw-text-label').attr('data-label'))
818          button.removeAttr('disabled')
819          button.removeClass('stw-adding')
820        }
821      }
822    })
823  }
824
825  $(document).on('change', '.save-to-wishlist-multiple-lists-cart-button-content select', function (e) {
826    e.preventDefault()
827    const select = $(this)
828    const button = $('.save-to-wishlist-cart-button')
829    const wishlistData = select.val()
830    const collectionId = wishlistData.split(':')[0]
831    const urlCode = wishlistData.split(':')[1]
832    const wishlistName = select.find(':selected').text()
833
834    addItemToWishlistCart(button, collectionId, null, urlCode, wishlistName)
835  })
836
837  $(document).on('click', '.save-to-wishlist-cart-button', function (e) {
838    e.preventDefault()
839    const button = $(this)
840    const collectionId = stwlite_frontend_js_vars.stwOptions.defaultWishlist?.id
841
842    addItemToWishlistCart(button, collectionId, null)
843  })
844
845  /**
846   * Handle variation changes on single product page.
847   * This method triggers when variations are changed in WooCommerce.
848   *
849   * @since 1.0.4
850   *
851   * @param {Object} event     - The jQuery event object.
852   * @param {Object} variation - The variation data object from WooCommerce.
853   */
854  function handleVariationChange(event, variation) {
855    // Get the wishlist button for this product.
856    const variationsForm = $(event.target).closest('form.variations_form')
857    const productId = variationsForm.find('input[name="product_id"]').val() || variationsForm.data('product_id')
858    const button = $('.save-to-wishlist-button[data-product-id="' + productId + '"]')
859
860    if (button.length === 0) {
861      return
862    }
863
864    // Get the selected variation ID.
865    const variationId = variationsForm.find('input[name="variation_id"]').val()
866
867    // Update button data attribute with variation ID if available.
868    if (variationId && parseInt(variationId) > 0) {
869      button.data('variation-id', variationId)
870
871      // Check if this variation is already in the wishlist.
872      if (parseInt(stwlite_frontend_js_vars.user_id) > 0) {
873        // For logged-in users, check via AJAX.
874        jQuery.ajax({
875          url: stwlite_frontend_js_vars.ajax_url,
876          type: 'POST',
877          data: {
878            action: 'stwlite_check_product_in_wishlist',
879            nonce: stwlite_frontend_js_vars.nonce,
880            product_id: productId,
881            variation_id: variationId
882          },
883          success: function (response) {
884            setWishlistButtonState(button, response.status === 'success' && response.exists === true)
885            button.removeAttr('disabled')
886          }
887        })
888      } else {
889        // For guests, check localStorage.
890        setWishlistButtonState(button, Object.prototype.hasOwnProperty.call(getGuestWishlist(), String(variationId)))
891      }
892    } else {
893      // No variation selected, remove variation-id data attribute.
894      button.removeData('variation-id')
895      setWishlistButtonState(button, false)
896    }
897
898    // Trigger custom event for other scripts to hook into.
899    $(document).trigger('stwlite_variation_changed', {
900      productId: productId,
901      variationId: variationId,
902      variation: variation,
903      button: button
904    })
905  }
906
907  /**
908   * Initialize variation change listeners on single product page.
909   *
910   * @since 1.0.4
911   */
912  function initVariationChangeListener() {
913    // Listen to WooCommerce found_variation event — fires after all attributes are selected
914    // and WooCommerce has resolved + set the variation_id hidden input.
915    $(document.body).on('found_variation', '.variations_form', handleVariationChange)
916
917    // Listen to reset_data event — fires when attributes are cleared or no matching variation exists.
918    $(document.body).on('reset_data', '.variations_form', function (event) {
919      handleVariationChange(event, null)
920    })
921  }
922
923  // Initialize variation change listener when document is ready.
924  $(document).ready(function () {
925    initVariationChangeListener()
926  })
927
928  /**
929   * Inject wishlist buttons into WooCommerce block cart line items.
930   *
931   * The block cart renders items client-side via React, so there is no PHP
932   * hook equivalent to woocommerce_after_cart_item_name. PHP pre-generates the
933   * button HTML for each cart item and passes it via blockCartItems. A
934   * MutationObserver watches the block cart container and injects the button
935   * after a.wc-block-cart-item__product-name once each row is rendered,
936   * matching by the product permalink path.
937   */
938  ;(function () {
939    const blockItems = stwlite_frontend_js_vars.blockCartItems
940    if (!blockItems || !blockItems.length) return
941
942    // Normalise a URL pathname so trailing-slash differences don't break matching.
943    function normalisePath (raw) {
944      try {
945        return new URL(raw).pathname.replace(/\/$/, '')
946      } catch (e) {
947        return raw.replace(/\/$/, '')
948      }
949    }
950
951    // Build normalised permalink path → button HTML lookup map
952    const normalisedButtonMap = {}
953    blockItems.forEach(function (item) {
954      normalisedButtonMap[normalisePath(item.permalink)] = item.button_html
955    })
956
957    function injectBlockCartButtons () {
958      document.querySelectorAll('.wc-block-cart-items__row').forEach(function (row) {
959        // Skip rows that already have a button injected
960        if (row.querySelector('.stw-wishlist-button-container')) return
961
962        // In current WooCommerce Blocks, .wc-block-components-product-name is
963        // applied directly to the <a>. Older versions wrapped the link in a
964        // <div> carrying the class, so fall back to an inner <a> for compat.
965        const nameWrapper = row.querySelector('.wc-block-components-product-name')
966        if (!nameWrapper) return
967
968        const nameLink = nameWrapper.tagName === 'A' ? nameWrapper : nameWrapper.querySelector('a')
969        if (!nameLink) return
970
971        const buttonHtml = normalisedButtonMap[normalisePath(nameLink.href)]
972        if (!buttonHtml) return
973
974        // Insert after the element carrying the product-name class so the button
975        // sits below the name in both current (anchor) and legacy (wrapper div)
976        // block-cart markup, mirroring the classic cart's woocommerce_after_cart_item_name position.
977        const tmp = document.createElement('div')
978        tmp.innerHTML = buttonHtml
979        const buttonNode = tmp.firstElementChild
980        if (buttonNode) {
981          nameWrapper.after(buttonNode)
982        }
983      })
984    }
985
986    $(document).ready(function () {
987      // Observe document.body for the full page lifetime. injectBlockCartButtons
988      // is idempotent (skips rows already carrying .stw-wishlist-button-container),
989      // so re-runs on unrelated mutations are cheap. This handles the cases where
990      // .wc-block-cart is not yet mounted on DOMContentLoaded and where React
991      // replaces the cart subtree during hydration — both without relying on a
992      // timing-based setTimeout.
993      const observer = new MutationObserver(injectBlockCartButtons)
994      observer.observe(document.body, { childList: true, subtree: true })
995
996      // Attempt an immediate injection for server-rendered rows already in the DOM.
997      injectBlockCartButtons()
998    })
999  }())
1000
1001  /**
1002   * Move the wishlist button to the top right of the product image.
1003   *
1004   * Scoped to the gallery by CLASS rather than by the container id: the id now carries
1005   * the product it wraps (#351), so an exact-id selector no longer matches. The gallery
1006   * scope is what the exact id used to provide implicitly — the `top_image_right`
1007   * placement renders through `woocommerce_product_thumbnails`, which fires inside
1008   * `.woocommerce-product-gallery`. It also keeps this relocation away from listings that
1009   * render the same placement per row (the Wholesale Order Form's product table among
1010   * them), which have no gallery for the button to be moved into.
1011   */
1012  const gallery = $('.woocommerce-product-gallery').first()
1013  const galleryButton = gallery.find('.stw-wishlist-button-placement-top_image_right').first()
1014
1015  // Moved, not cloned into every gallery: `prepend` on a set inserts a copy into each
1016  // match, so a theme that renders a second (mobile) gallery used to end up with two
1017  // containers carrying one id. `prependTo` relocates the single node instead, which also
1018  // keeps whatever the click handlers bound to the original element.
1019  galleryButton.prependTo(gallery)
1020})(jQuery)

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.