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">×</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.