1/** 2 * wishlist_page.js â /wishlist page remove-button handler (BUG-W1 fix, 2026-05-17) 3 * 4 * Replaces webshop's broken .remove-wish handler which 403-silent-fails because 5 * webshop's Wishlist DocType has no Website User role permission, and the bound 6 * callback only checks r.exc (unset on PermissionError success-callback path). 7 * 8 * Uses the CSL custom endpoint cslshoes_erp.api.wishlist.toggle_wishlist_item 9 * (same one Session C + PDP main heart use). Reads website_item from a new 10 * data attribute on the .remove-wish element (template edit pairs with this). 11 * 12 * Loading: defer-loaded via hooks.py:web_include_js. Per ERP-083, NO frappe.ready 13 * wrapper (callback would never dispatch on defer-loaded files). IIFE runs at 14 * script-load time, binds single document-level CAPTURE-phase delegate so it 15 * fires BEFORE webshop's jQuery bubble delegate at $('.page_content').on(...). 16 * stopImmediatePropagation prevents webshop's handler from also firing â no 17 * spurious 403 in network tab. 18 * 19 * Auditor C1: button disabled on click to defeat double-click â toggle endpoint 20 * would otherwise re-ADD on the second click (non-idempotent). 21 * Auditor C2: empty-state mirrors webshop's render_empty_state â appends to 22 * .container after clearing the wishlist row, NOT nested inside .row. 23 * Auditor C3: uses window.cslTileWishlist.updateAllCountBadges (exposed in 24 * tile_wishlist.js v20260517b) for header badge sync. 25 * Auditor C4: trusts data.message.count from response, NOT wish_count cookie 26 * (webshop sets cookie via its endpoint; ours doesn't, so cookie is stale). 27 * Auditor C6: capture-phase delegate verified to beat webshop's jQuery bubble. 28 */ 29(function () { 30 if (typeof window === 'undefined') return; 31 if (window.cslWishlistPage && window.cslWishlistPage.__bound__) return; 32 33 var ENDPOINT_TOGGLE = '/api/method/cslshoes_erp.api.wishlist.toggle_wishlist_item'; 34 35 function getCsrfHeader() { 36 var h = { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }; 37 if (typeof frappe !== 'undefined' && frappe.csrf_token) { 38 h['X-Frappe-CSRF-Token'] = frappe.csrf_token; 39 } 40 return h; 41 } 42 43 function isGuest() { 44 return (typeof frappe !== 'undefined' && 45 frappe.session && frappe.session.user === 'Guest'); 46 } 47 48 function showToast(message, type) { 49 // Reuse Session C's toast helper if loaded; otherwise minimal inline fallback. 50 if (window.cslTileWishlist && typeof cslTileWishlist.showToast === 'function') { 51 cslTileWishlist.showToast(message, type); 52 return; 53 } 54 var t = document.createElement('div'); 55 t.className = 'csl-toast ' + (type || 'info'); 56 t.setAttribute('role', 'status'); 57 t.setAttribute('aria-live', 'polite'); 58 t.textContent = message; 59 t.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);' + 60 'background:#111827;color:#fff;padding:10px 18px;border-radius:6px;font-size:14px;' + 61 'box-shadow:0 4px 12px rgba(0,0,0,0.2);z-index:9999;'; 62 document.body.appendChild(t); 63 setTimeout(function () { t.remove(); }, 3000); 64 } 65 66 function updateHeaderBadges(count) { 67 // Prefer Session C's helper (exposed in tile_wishlist.js v20260517b). 68 // Falls back to local update for cold-load edge cases (script ordering). 69 if (window.cslTileWishlist && typeof cslTileWishlist.updateAllCountBadges === 'function') { 70 cslTileWishlist.updateAllCountBadges(count); 71 return; 72 } 73 // Match the selector union in tile_wishlist.js v20260517c. 74 var badges = document.querySelectorAll( 75 '.csl-wishlist-count, #csl-wishlist-count, ' + 76 '.wishlist-count, #header-wishlist-count, #wishlist-count' 77 ); 78 badges.forEach(function (b) { 79 b.textContent = count > 0 ? count : ''; 80 b.style.display = count > 0 ? 'flex' : 'none'; 81 }); 82 } 83 84 function renderEmptyState() { 85 // Mirrors webshop's render_empty_state pattern (wishlist.js:182-191). The 86 // existing wishlist.html structure puts the items <div class="row"> at top 87 // of .container; replace its parent <div class="row"> chain with empty-state. 88 var container = document.querySelector('.container'); 89 if (!container) return; 90 // Remove the items row (the only .row inside .container on this page when items present). 91 var itemsRow = container.querySelector('.row');
92 if (itemsRow) itemsRow.remove(); 93 var empty = document.createElement('div'); 94 empty.className = 'cart-empty frappe-card'; 95 empty.innerHTML = 96 '<div class="cart-empty-state">' + 97 '<img src="/assets/webshop/images/cart-empty-state.png" alt="Empty Wishlist">' + 98 '</div>' + 99 '<div class="cart-empty-message mt-4">Wishlist is empty!</div>'; 100 container.appendChild(empty); 101 } 102 103 function removeWishlistItem(triggerEl) { 104 var websiteItem = triggerEl.getAttribute('data-website-item'); 105 if (!websiteItem) { 106 showToast('Could not identify item to remove (missing website-item ID)', 'error'); 107 return; 108 } 109 if (triggerEl.dataset.cslBusy === '1') return; // Auditor C1: defeat double-click race 110 111 if (isGuest()) { 112 showToast('Please log in to manage your wishlist', 'info'); 113 setTimeout(function () { 114 window.location.href = '/login?redirect-to=' + encodeURIComponent(window.location.pathname); 115 }, 1500); 116 return; 117 } 118 119 triggerEl.dataset.cslBusy = '1'; 120 triggerEl.style.opacity = '0.5'; 121 triggerEl.style.cursor = 'progress'; 122 var card = triggerEl.closest('.wishlist-card'); 123 124 fetch(ENDPOINT_TOGGLE, { 125 method: 'POST', 126 headers: getCsrfHeader(), 127 credentials: 'same-origin', 128 body: JSON.stringify({ website_item: websiteItem }) 129 }).then(function (r) { 130 if (r.status === 403) { 131 delete triggerEl.dataset.cslBusy; 132 triggerEl.style.opacity = ''; 133 triggerEl.style.cursor = ''; 134 showToast('Session expired. Please log in again.', 'info'); 135 setTimeout(function () { 136 window.location.href = '/login?redirect-to=' + encodeURIComponent(window.location.pathname); 137 }, 1500); 138 return null; 139 } 140 if (!r.ok) { 141 delete triggerEl.dataset.cslBusy; 142 triggerEl.style.opacity = ''; 143 triggerEl.style.cursor = ''; 144 showToast('Could not remove (HTTP ' + r.status + ')', 'error'); 145 return null; 146 } 147 return r.json(); 148 }).then(function (data) { 149 if (!data) return; 150 var msg = data.message || {}; 151 if (msg.success && msg.action === 'removed') { 152 // Fade out card, then remove from DOM. 153 if (card) { 154 card.style.transition = 'opacity 250ms ease, transform 250ms ease'; 155 card.style.opacity = '0'; 156 card.style.transform = 'scale(0.95)'; 157 setTimeout(function () { 158 card.remove(); 159 // If no cards left, render empty state. 160 var remaining = document.querySelectorAll('.wishlist-card').length; 161 if (remaining === 0) renderEmptyState(); 162 }, 260); 163 } 164 updateHeaderBadges(msg.count); 165 showToast('Removed from wishlist', 'success'); 166 } else if (msg.success && msg.action === 'added') { 167 // Unexpected â toggle endpoint re-ADDED an item. Should be impossible 168 // since the page only shows items already in the wishlist, but if it 169 // happens (e.g. concurrent removal in another tab), just leave the UI 170 // stable and reset the button. 171 delete triggerEl.dataset.cslBusy; 172 triggerEl.style.opacity = ''; 173 triggerEl.style.cursor = ''; 174 showToast('Wishlist state was out of sync â refreshed', 'info'); 175 updateHeaderBadges(msg.count); 176 } else if (msg.login_required) { 177 delete triggerEl.dataset.cslBusy; 178 triggerEl.style.opacity = ''; 179 triggerEl.style.cursor = ''; 180 showToast('Please log in to manage your wishlist', 'info'); 181 setTimeout(function () { 182 window.location.href = '/login?redirect-to=' + encodeURIComponent(window.location.pathname); 183 }, 1500); 184 } else { 185 delete triggerEl.dataset.cslBusy; 186 triggerEl.style.opacity = ''; 187 triggerEl.style.cursor = ''; 188 showToast('Could not remove (unexpected response)', 'error'); 189 } 190 }).catch(function () { 191 delete triggerEl.dataset.cslBusy; 192 triggerEl.style.opacity = ''; 193 triggerEl.style.cursor = ''; 194 showToast('Could not remove (network error)', 'error'); 195 }); 196 } 197 198 // ========================================================================= 199 // BUG-W1b â Move to Cart silent wishlist-remove fix (2026-05-17) 200 // 201 // Webshop's bind_move_to_cart_action (apps/webshop/webshop/public/js/wishlist.js:40-58) 202 // fires shopping_cart_update (cart-add OK for logged-in) then add_remove_from_wishlist 203 // ("remove", ...) which hits the same broken endpoint as BUG-W1 (403-silent-fail).
204 // Item ends up in cart AND still in wishlist after refresh. 205 // 206 // Fix mirrors BUG-W1 + auditor C1-C7: 207 // - Same IIFE so __bound__ guard covers both listeners (auditor C1). 208 // - Sequenced: cart-add first; toggle ONLY if cart-add returns message.success===true 209 // (auditor C2 â webshop's update_cart can 200 with internal failure). 210 // - action==='added' edge case: one-shot retry, then hard-fail (auditor C3). 211 // - Guest guard at top of handler (auditor C5 â page is logged-in-only but 212 // cached HTML + mid-session expiry are the edge case). 213 // - Partial-failure UX: cart badge MUST refresh even when wishlist-remove fails 214 // because cart DID change (auditor C7). Otherwise badge looks stale and user 215 // thinks the whole action failed. 216 // ========================================================================= 217 var ENDPOINT_CART = '/api/method/cslshoes_erp.api.guest_cart.update_guest_cart'; 218 219 function getGuestCartId() { 220 var m = document.cookie.match(/guest_cart_id=([^;]+)/); 221 return m ? m[1] : null; 222 } 223 224 function restoreMoveBtn(btn, originalLabel) { 225 delete btn.dataset.cslBusy; 226 btn.disabled = false; 227 btn.style.opacity = ''; 228 btn.style.cursor = ''; 229 var label = btn.querySelector('.csl-move-to-cart-label'); 230 if (label && originalLabel) label.textContent = originalLabel; 231 } 232 233 function fadeCardAndRemove(card) { 234 if (!card) return; 235 card.style.transition = 'opacity 250ms ease, transform 250ms ease'; 236 card.style.opacity = '0'; 237 card.style.transform = 'scale(0.95)'; 238 setTimeout(function () { 239 card.remove(); 240 var remaining = document.querySelectorAll('.wishlist-card').length; 241 if (remaining === 0) renderEmptyState(); 242 }, 260); 243 } 244 245 function refreshHeaderCounts() { 246 if (typeof window.updateCslCartCount === 'function') window.updateCslCartCount(); 247 if (typeof window.updateCslWishlistCount === 'function') window.updateCslWishlistCount(); 248 } 249 250 // Wishlist-remove step â used after cart-add success. Returns Promise. 251 function postWishlistToggle(websiteItem) { 252 return fetch(ENDPOINT_TOGGLE, { 253 method: 'POST', 254 headers: getCsrfHeader(), 255 credentials: 'same-origin', 256 body: JSON.stringify({ website_item: websiteItem }) 257 }).then(function (r) { 258 if (r.status === 403) return { __auth_fail: true }; 259 if (!r.ok) return { __http_fail: r.status }; 260 return r.json().then(function (data) { return data; }); 261 }).catch(function () { 262 return { __network_fail: true }; 263 }); 264 } 265 266 function moveToCart(btn) { 267 var itemCode = btn.getAttribute('data-item-code'); 268 var websiteItem = btn.getAttribute('data-website-item'); 269 if (!itemCode || !websiteItem) { 270 showToast('Could not identify item (missing data attributes)', 'error'); 271 return; 272 } 273 if (btn.dataset.cslBusy === '1') return; // auditor C1 â double-click guard 274 275 // Auditor C5: guest guard at top â page is logged-in-only via wishlist.py, 276 // but cached HTML + mid-session expiry create the edge case. 277 if (isGuest()) { 278 showToast('Please log in to manage your wishlist', 'info'); 279 setTimeout(function () { 280 window.location.href = '/login?redirect-to=' + encodeURIComponent(window.location.pathname); 281 }, 1500); 282 return; 283 } 284 285 btn.dataset.cslBusy = '1'; 286 btn.disabled = true; 287 btn.style.opacity = '0.7'; 288 btn.style.cursor = 'progress'; 289 var label = btn.querySelector('.csl-move-to-cart-label'); 290 var originalLabel = label ? label.textContent : null; 291 if (label) label.textContent = 'Moving...'; 292 var card = btn.closest('.wishlist-card'); 293 294 // Step 1: cart-add via cslshoes_erp.api.guest_cart.update_guest_cart 295 // (delegates to webshop's update_cart for logged-in users per 296 // api/guest_cart.py:184). 297 fetch(ENDPOINT_CART, { 298 method: 'POST', 299 headers: getCsrfHeader(), 300 credentials: 'same-origin', 301 body: JSON.stringify({ item_code: itemCode, qty: 1, cart_id: getGuestCartId() }) 302 }).then(function (r) { 303 if (r.status === 403) { 304 restoreMoveBtn(btn, originalLabel); 305 showToast('Session expired. Please log in again.', 'info'); 306 setTimeout(function () { 307 window.location.href = '/login?redirect-to=' + encodeURIComponent(window.location.pathname); 308 }, 1500); 309 return null; 310 } 311 if (!r.ok) { 312 restoreMoveBtn(btn, originalLabel); 313 showToast('Could not move to cart (HTTP ' + r.status + ')', 'error'); 314 return null; 315 } 316 return r.json(); 317 }).then(function (data) { 318 if (!data) return null; 319 var msg = data.message || {}; 320 // Auditor C2 â explicit success-payload check. webshop's update_cart 321 // can return 200 with success:false (out-of-stock, item disabled, etc). 322 if (msg.success === false) { 323 restoreMoveBtn(btn, originalLabel); 324 showToast(msg.error || 'Could not add to cart', 'error'); 325 return null; 326 } 327 // Cart-add succeeded. Step 2: remove from wishlist. 328 return postWishlistToggle(websiteItem).then(function (wlData) { 329 // Auditor C7: cart count refreshes even on wishlist-remove failure 330 // â cart DID change, badge MUST reflect that. 331 if (typeof window.updateCslCartCount === 'function') window.updateCslCartCount(); 332 333 if (!wlData || wlData.__auth_fail) { 334 restoreMoveBtn(btn, originalLabel); 335 showToast('Added to cart, but session expired during wishlist update', 'info'); 336 setTimeout(function () { 337 window.location.href = '/login?redirect-to=' + encodeURIComponent(window.location.pathname); 338 }, 1500); 339 return; 340 } 341 if (wlData.__http_fail || wlData.__network_fail) { 342 restoreMoveBtn(btn, originalLabel); 343 showToast('Added to cart but could not remove from wishlist â click X to remove manually', 'error'); 344 return; 345 } 346 var wlMsg = wlData.message || {}; 347 if (wlMsg.success && wlMsg.action === 'removed') { 348 fadeCardAndRemove(card); 349 if (typeof window.updateCslWishlistCount === 'function') window.updateCslWishlistCount(); 350 showToast('Moved to cart', 'success'); 351 return; 352 } 353 // Auditor C3: action==='added' means wishlist was empty when toggle hit 354 // (cross-tab removal race). One-shot retry to remove again. 355 if (wlMsg.success && wlMsg.action === 'added') {
356 postWishlistToggle(websiteItem).then(function (retryData) { 357 var retryMsg = (retryData && retryData.message) || {}; 358 if (retryMsg.success && retryMsg.action === 'removed') { 359 fadeCardAndRemove(card); 360 if (typeof window.updateCslWishlistCount === 'function') window.updateCslWishlistCount(); 361 showToast('Moved to cart', 'success'); 362 } else { 363 restoreMoveBtn(btn, originalLabel); 364 showToast('Added to cart but wishlist state is out of sync â please refresh', 'error'); 365 } 366 }); 367 return; 368 } 369 if (wlMsg.login_required) { 370 restoreMoveBtn(btn, originalLabel); 371 showToast('Added to cart. Please log in to update your wishlist', 'info'); 372 setTimeout(function () { 373 window.location.href = '/login?redirect-to=' + encodeURIComponent(window.location.pathname); 374 }, 1500); 375 return; 376 } 377 restoreMoveBtn(btn, originalLabel); 378 showToast('Added to cart but wishlist-remove had an unexpected response', 'error'); 379 }); 380 }).catch(function () { 381 restoreMoveBtn(btn, originalLabel); 382 showToast('Could not move to cart (network error)', 'error'); 383 }); 384 } 385 386 function onDocumentClick(e) { 387 if (!e.target || !e.target.closest) return; 388 // .remove-wish (BUG-W1) â X delete button. 389 var removeTrigger = e.target.closest('.remove-wish[data-website-item]'); 390 if (removeTrigger) { 391 // Capture phase + stopImmediatePropagation â beats webshop's jQuery bubble 392 // delegate at $('.page_content').on('click', '.remove-wish', ...). 393 e.preventDefault(); 394 e.stopPropagation(); 395 if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation(); 396 removeWishlistItem(removeTrigger); 397 return; 398 } 399 // .btn-add-to-cart inside .wishlist-card (BUG-W1b) â Move to Cart button. 400 // Scoped to .wishlist-card so we don't intercept .btn-add-to-cart on /all-products 401 // or other pages where webshop's stock binding is the correct handler. 402 var moveTrigger = e.target.closest('.wishlist-card .btn-add-to-cart[data-website-item]'); 403 if (moveTrigger) { 404 e.preventDefault(); 405 e.stopPropagation(); 406 if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation(); 407 moveToCart(moveTrigger); 408 return; 409 } 410 } 411 412 function onKeydown(e) { 413 if (e.key !== 'Enter' && e.key !== ' ') return; 414 if (!e.target || !e.target.classList) return; 415 if (e.target.classList.contains('remove-wish') && e.target.getAttribute('data-website-item')) { 416 e.preventDefault(); 417 removeWishlistItem(e.target); 418 } else if (e.target.classList.contains('btn-add-to-cart') && 419 e.target.getAttribute('data-website-item') && 420 e.target.closest('.wishlist-card')) { 421 e.preventDefault(); 422 moveToCart(e.target); 423 } 424 } 425 426 document.addEventListener('click', onDocumentClick, true); 427 document.addEventListener('keydown', onKeydown, false); 428 429 window.cslWishlistPage = { 430 __bound__: true, 431 __version__: '20260517c', 432 removeWishlistItem: removeWishlistItem, 433 moveToCart: moveToCart // exported for future programmatic use 434 }; 435})();
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.