1/** 2 * Zephyr Cart Drawer Component 3 * 4 * Alpine.js store and helper functions for the sliding cart drawer 5 */ 6 7// Open a design proof in a new tab. 8// 9// The proof endpoint only serves designs the requester owns, over a form-key-validated 10// POST - as a bare GET with an incrementing designId it let anyone page through every 11// customer's artwork. The form is built detached and appended to <body> so it can never 12// nest inside the cart's own form, which the parser would drop. 13function pceDownloadProof(designId) { 14 if (!designId) { return false; } 15 16 var form = document.createElement('form'); 17 form.method = 'post'; 18 form.action = '/designer/index/getItemProofPng'; 19 form.target = '_blank'; 20 form.style.display = 'none'; 21 22 var keyInput = document.querySelector('input[name="form_key"]'); 23 var add = function (name, value) { 24 var input = document.createElement('input'); 25 input.type = 'hidden'; 26 input.name = name; 27 input.value = value; 28 form.appendChild(input); 29 }; 30 add('designId', designId); 31 add('form_key', keyInput ? keyInput.value : (window.PCE_FORM_KEY || '')); 32 33 document.body.appendChild(form); 34 form.submit(); 35 document.body.removeChild(form); 36 37 return false; 38} 39 40// Cart drawer configuration (set from PHP template) 41var cartDrawerConfig = { 42 getItemsUrl: '', 43 deleteUrl: '', 44 removeItemsUrl: '', 45 updatePostUrl: '', 46 clearUrl: '', 47 checkoutUrl: '', 48 cartUrl: '', 49 formKey: '', 50 // Delete method settings (from admin config) 51 enableCheckboxDelete: true, 52 enableIndividualDelete: true, 53 showClearCart: true, 54 checkoutButtonText: 'Proceed to Checkout' 55}; 56 57/** 58 * Alpine state for the per-line pricing breakdown popover. Shared by the 59 * server-rendered drawer items (cart_drawer.phtml) and the AJAX-rendered 60 * items built in this file, so open/position behavior lives in one place. 61 * Positions the popover fixed against the viewport (the drawer's scroll 62 * container would clip an absolutely-positioned card), flipping below the 63 * trigger when there is no room above. 64 */ 65window.cartLineBreakdown = function () { 66 return { 67 bdOpen: false, 68 popStyle: 'position:fixed;top:-9999px;left:-9999px;', 69 showBd: function () { 70 this.bdOpen = true; 71 this.placeBd(); 72 }, 73 clickBd: function () { 74 // Hover already opened it: a click while still hovering must not 75 // close it (desktop users who click out of habit). Keyboard and 76 // touch activations (no :hover) still toggle. 77 if (this.bdOpen && window.matchMedia('(hover: hover)').matches && this.$root.matches(':hover')) { return; } 78 this.bdOpen ? (this.bdOpen = false) : this.showBd(); 79 }, 80 placeBd: function () { 81 var self = this; 82 this.$nextTick(function () { 83 if (!self.$refs.bdBtn || !self.$refs.bdPop) { return; } 84 var t = self.$refs.bdBtn.getBoundingClientRect(); 85 var h = self.$refs.bdPop.offsetHeight; 86 var w = self.$refs.bdPop.offsetWidth; 87 // Open below the trigger; flip above only when it would run 88 // off the bottom of the viewport. Then clamp both axes, so a 89 // trigger near an edge (or off screen) cannot push the panel out. 90 var top = t.bottom + 8; 91 if (top + h > window.innerHeight - 8) { top = t.top - h - 8; } 92 var maxTop = Math.max(8, window.innerHeight - h - 8); 93 var maxLeft = Math.max(8, window.innerWidth - w - 8); 94 top = Math.min(Math.max(8, top), maxTop); 95 var left = Math.min(Math.max(8, t.left), maxLeft); 96 self.popStyle = 'position:fixed;top:' + top + 'px;left:' + left + 'px;'; 97 }); 98 } 99 }; 100}; 101 102/** 103 * Build the breakdown trigger + popover HTML for an AJAX-rendered cart line. 104 * Rows are static (from the getItems JSON); behavior comes from the shared 105 * cartLineBreakdown() Alpine component, which Alpine initializes on injection. 106 */ 107function buildBreakdownWidgetHtml(item) { 108 var bd = item.breakdown; 109 if (!bd || !bd.lines || !bd.lines.length) { return ''; } 110 // Icon shows only when the line actually carries paid addons.
111 if (!item.has_paid_addons) { return ''; } 112 function esc(s) { 113 return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); 114 } 115 var rows = ''; 116 bd.lines.forEach(function (line) { 117 rows += '<div class="flex justify-between gap-3 py-0.5 ' + (line.type === 'discount' ? 'text-green-700' : 'text-gray-600') + '">' + 118 '<span class="min-w-0 break-words">' + esc(line.label) + '</span>' + 119 '<span class="whitespace-nowrap font-medium">' + esc(line.amount_formatted) + '</span></div>'; 120 }); 121 var totals = '<div class="flex justify-between gap-3 pt-1.5 mt-1.5 border-t border-gray-200 font-semibold text-gray-900">' + 122 '<span>Per unit</span><span>' + esc(bd.unit_total_formatted) + '</span></div>'; 123 if (bd.qty > 1) { 124 totals += '<div class="flex justify-between gap-3 py-0.5 text-gray-600"><span>Ã ' + esc(bd.qty) + '</span>' + 125 '<span class="font-medium">' + esc(bd.row_total_formatted) + '</span></div>'; 126 } 127 return '<div x-data="cartLineBreakdown()"' + 128 ' @mouseenter="if (window.matchMedia(\'(hover: hover)\').matches) showBd()"' + 129 ' @mouseleave="if (window.matchMedia(\'(hover: hover)\').matches) bdOpen = false">' + 130 '<button type="button" x-ref="bdBtn"' + 131 ' @click="clickBd()"' + 132 ' @click.outside="bdOpen = false"' + 133 ' class="flex items-center justify-center text-gray-400 hover:text-gray-600 transition-colors"' + 134 ' aria-label="Pricing breakdown">' + 135 '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">' + 136 '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg></button>' + 137 '<div x-show="bdOpen" x-transition.opacity x-cloak x-ref="bdPop" :style="popStyle"' + 138 ' class="w-64 bg-white border border-gray-200 rounded-lg shadow-lg p-3 z-[70] text-xs text-left">' + 139 '<p class="font-semibold text-gray-900 mb-2">Pricing breakdown</p>' + rows + totals + '</div></div>'; 140} 141 142document.addEventListener('alpine:init', function() { 143 // Cart Store 144 Alpine.store('cart', { 145 drawerOpen: false, 146 drawerFocused: false, 147 _focusTimer: null, 148 _scrollToNew: false, 149 lastFetch: null, 150 // Set true once the auth/status AJAX response hydrates the summary figures 151 // below (see foot.phtml fetchStatus()) â the async replacement for the 152 // synchronous window.__zephyrState the ESI block used to provide. 153 _esiReady: false, 154 isRefreshing: false, 155 156 // Footer data (reactive) 157 subtotal: '', 158 subtotalNumeric: 0, 159 originalSubtotal: null, 160 grandTotal: '', 161 totals: [], 162 couponCode: null, 163 expeditedProduct: null, 164 expeditedInCart: false, 165 expeditedProductId: null, 166 isEmpty: true, 167 // True while an item is blocked from checkout (server-side item error, e.g. a 168 // reordered item whose product gained a required option); disables the drawer 169 // checkout button. Seeded from PHP in cart_drawer.phtml, refreshed from getItems. 170 hasError: false, 171 // Set once the checkout button was clicked while blocked; bolds the hint under it. 172 nudged: false, 173 /** Nudge the checkout button when clicked while an item is blocked (PDP .shake pattern). */ 174 shake: function(el) { 175 this.nudged = true; 176 el.classList.remove('shake'); 177 void el.offsetWidth; 178 el.classList.add('shake'); 179 setTimeout(function() { el.classList.remove('shake'); }, 500); 180 }, 181 itemCount: 0, 182 totalSavings: null, 183 savings: null, 184 185 toggleDrawer: function() { 186 this.drawerOpen = !this.drawerOpen; 187 if (window.innerWidth < 768) { 188 document.body.style.overflow = this.drawerOpen ? 'hidden' : ''; 189 } 190 }, 191 192 openDrawer: function() { 193 this.drawerOpen = true; 194 if (window.innerWidth < 768) { 195 document.body.style.overflow = 'hidden'; 196 } 197 // Safety refresh if data is stale (5+ seconds) 198 if (!this.lastFetch || Date.now() - this.lastFetch > 5000) { 199 this.refresh(); 200 } else { 201 // Data is fresh, fire view_cart with current items 202 this.fireViewCart(); 203 } 204 }, 205 206 closeDrawer: function() { 207 this.drawerOpen = false;
208 clearTimeout(this._focusTimer); 209 this.drawerFocused = false; 210 document.body.style.overflow = ''; // always clear on close 211 }, 212 213 refresh: function() { 214 var self = this; 215 216 if (this.isRefreshing) { 217 console.log('[Cart] Already refreshing, skipping...'); 218 return; // Prevent duplicate requests 219 } 220 221 console.log('[Cart] Starting refresh, isRefreshing = true'); 222 var startTime = Date.now(); 223 this.isRefreshing = true; 224 this.lastFetch = Date.now(); 225 226 console.log('[Cart] Fetching cart data from JSON API...'); 227 fetch(cartDrawerConfig.getItemsUrl, { 228 credentials: 'same-origin', 229 headers: { 230 'X-Requested-With': 'XMLHttpRequest' 231 } 232 }) 233 .then(function(response) { 234 console.log('[Cart] Fetch completed in ' + (Date.now() - startTime) + 'ms'); 235 return response.json(); 236 }) 237 .then(function(data) { 238 console.log('[Cart] Updating UI from JSON data...'); 239 self.updateCartFromJson(data); 240 self.isRefreshing = false; 241 console.log('[Cart] Complete! Total time: ' + (Date.now() - startTime) + 'ms'); 242 // Dispatch event for scroll indicator to update 243 window.dispatchEvent(new CustomEvent('cart-refreshed')); 244 // Fire GA4 view_cart if drawer is open 245 self.fireViewCart(); 246 }) 247 .catch(function(error) { 248 console.error('Cart refresh failed:', error); 249 self.isRefreshing = false; 250 }); 251 }, 252 253 updateCartFromJson: function(data) { 254 var self = this; 255 256 if (!data.success) { 257 console.error('[Cart] Failed to load cart data:', data.error); 258 return; 259 } 260 261 self.hasError = !!data.has_error; 262 263 // Update cart items container 264 var container = document.getElementById('cart-items-container'); 265 if (!container) { 266 console.error('[Cart] Cart items container not found'); 267 return; 268 } 269 270 if (data.is_empty || data.items.length === 0) { 271 // Show empty cart message 272 container.innerHTML = '\ 273 <div class="text-center py-12">\ 274 <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 275 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />\ 276 </svg>\ 277 <p class="mt-4 text-gray-500">Your cart is empty</p>\ 278 </div>\ 279 '; 280 } else { 281 // Build selection toolbar and cart items HTML 282 var itemsHtml = ''; 283 284 // Only show selection toolbar if checkbox delete is enabled 285 if (cartDrawerConfig.enableCheckboxDelete) { 286 itemsHtml += '\ 287 <div class="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-200" id="cart-selection-toolbar">\ 288 <label class="flex items-center gap-2 cursor-pointer">\ 289 <input type="checkbox"\ 290 id="cart-select-all"\ 291 onchange="toggleSelectAllCartItems(this.checked)"\ 292 class="w-4 h-4 rounded border-gray-300 text-brand-primary focus:ring-brand-primary cursor-pointer">\ 293 <span class="text-sm text-gray-600">Select All</span>\ 294 </label>\ 295 <button type="button"\ 296 id="cart-delete-selected-btn"\ 297 onclick="deleteSelectedCartItems()"\ 298 class="hidden text-sm text-brand-danger hover:text-red-700 font-medium transition-colors">\ 299 Delete Selected\ 300 </button>\ 301 </div>\ 302 '; 303 } 304 305 itemsHtml += '\ 306 <div class="p-4">\ 307 <div class="space-y-4" id="cart-items-list">'; 308 309 var freshExpeditedProductId = data.expedited_product_id || null;
310 data.items.forEach(function(item) { 311 var isExpedited = freshExpeditedProductId && item.product_id == freshExpeditedProductId; 312 313 // Design dropdown is no longer shown below the image for wizard items. 314 // It has been moved inline next to the qty stepper (see inlineEditBtn below). 315 var designDropdown = ''; 316 317 // Build options toggle + pills if custom options exist 318 var optionsToggle = ''; 319 var optionsPills = ''; 320 var hasOptions = item.options && item.options.length > 0; 321 var itemOptsId = 'cart-opts-' + item.id; 322 323 if (hasOptions) { 324 optionsToggle = '\ 325 <button type="button"\ 326 onclick="toggleCartOptions(\'' + itemOptsId + '\')"\ 327 class="inline-flex items-center gap-1 py-1 text-xs text-gray-500 hover:text-gray-700 transition-colors ml-auto"\ 328 id="' + itemOptsId + '-btn">\ 329 <svg class="w-3 h-3 transition-transform duration-200" id="' + itemOptsId + '-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 330 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />\ 331 </svg>\ 332 <span id="' + itemOptsId + '-text">Show Options</span>\ 333 </button>\ 334 '; 335 336 var pillsList = ''; 337 item.options.forEach(function(opt) { 338 var escapedLabel = opt.label.replace(/</g, '<').replace(/>/g, '>'); 339 var escapedValue = opt.value.replace(/</g, '<').replace(/>/g, '>'); 340 pillsList += '\ 341 <span class="inline px-2.5 py-1 rounded-full bg-white border border-gray-200 text-sm leading-relaxed">\ 342 <span class="font-medium text-gray-700">' + escapedLabel + ':</span>\ 343 <span class="text-gray-500">' + escapedValue + '</span>\ 344 </span>\ 345 '; 346 }); 347 348 optionsPills = '\ 349 <div id="' + itemOptsId + '" class="relative mt-2 bg-gray-50 border border-gray-200 rounded-lg p-3 flex flex-wrap gap-1.5" style="display:none">\ 350 <div class="absolute -top-2 right-8 w-3 h-3 bg-gray-50 border-l border-t border-gray-200 rotate-45"></div>\ 351 ' + pillsList + '\ 352 </div>\ 353 '; 354 } 355 356 // Inline edit/update button next to qty stepper 357 var inlineEditBtn = ''; 358 if (item.wizard && item.wizard.enabled && item.wizard.update_url) {
359 // Wizard items: edit icon with dropdown (Update Design, Download Proof, Duplicate) 360 var wizDropdownItems = ''; 361 362 // Update Design 363 wizDropdownItems += '\ 364 <button type="button"\ 365 onclick="openWizardModal(\'' + item.wizard.update_url + '\'); return false;"\ 366 class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors text-left">\ 367 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 368 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />\ 369 </svg>\ 370 Update Design\ 371 </button>\ 372 '; 373 374 // Download Proof 375 if (item.wizard.design_id) { 376 wizDropdownItems += '\ 377 <a href="#" onclick="return pceDownloadProof(' + item.wizard.design_id + ');"\ 378 class="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">\ 379 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 380 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />\ 381 </svg>\ 382 Download Proof\ 383 </a>\ 384 '; 385 } 386 387 // Duplicate Design 388 var duplicateUrl = item.wizard.update_url.replace('&update=', '&duplicate='); 389 wizDropdownItems += '\ 390 <button type="button"\ 391 onclick="openWizardModal(\'' + duplicateUrl + '\'); return false;"\ 392 class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors text-left">\ 393 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 394 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />\ 395 </svg>\ 396 Duplicate Design\ 397 </button>\ 398 '; 399 400 inlineEditBtn = '\ 401 <div class="relative ml-1" x-data="{ open: false }" @click.away="open = false">\ 402 <button type="button"\ 403 @click="open = !open"\ 404 class="w-6 h-6 flex items-center justify-center rounded text-brand-primary hover:text-brand-primary-hover hover:bg-brand-primary/10 transition-colors"\ 405 title="Design options">\ 406 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 407 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />\ 408 </svg>\ 409 </button>\ 410 <div x-show="open"\ 411 x-transition:enter="transition ease-out duration-100"\ 412 x-transition:enter-start="opacity-0 scale-95"\ 413 x-transition:enter-end="opacity-100 scale-100"\ 414 x-transition:leave="transition ease-in duration-75"\ 415 x-transition:leave-start="opacity-100 scale-100"\ 416 x-transition:leave-end="opacity-0 scale-95"\ 417 x-effect="if (open) { $nextTick(() => $el.scrollIntoView({ behavior: \'smooth\', block: \'nearest\' })) }"\ 418 class="absolute right-0 mt-1 w-44 bg-white border border-gray-200 rounded-lg shadow-lg z-20 py-1">\ 419 ' + wizDropdownItems + '\ 420 </div>\ 421 </div>\ 422 '; 423 } else if (item.configure_url) { 424 // Non-wizard items with options: edit icon linking to configure page 425 inlineEditBtn = '\ 426 <a href="' + item.configure_url + '"\ 427 class="w-6 h-6 flex items-center justify-center rounded text-brand-primary hover:text-brand-primary-hover hover:bg-brand-primary/10 transition-colors ml-1"\ 428 title="Edit options">\ 429 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 430 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />\ 431 </svg>\ 432 </a>\ 433 '; 434 } 435 436 // Escape for HTML attributes (data-* values) 437 var htmlEscapedName = item.name.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>'); 438 var htmlEscapedFullsize = item.fullsize_url.replace(/&/g, '&').replace(/"/g, '"'); 439 var htmlEscapedImage = item.image_url.replace(/&/g, '&').replace(/"/g, '"'); 440 var htmlEscapedProductUrl = item.product_url ? item.product_url.replace(/&/g, '&').replace(/"/g, '"') : ''; 441 442 // Build individual delete button if enabled (trash icon inline with qty stepper) 443 var individualDeleteBtn = ''; 444 if (cartDrawerConfig.enableIndividualDelete) { 445 individualDeleteBtn = '\ 446 <button type="button"\ 447 onclick="removeCartItem(' + item.id + ')"\ 448 class="w-6 h-6 flex items-center justify-center rounded text-red-400 hover:text-red-600 hover:bg-red-50 transition-colors ml-1"\ 449 title="Remove item">\ 450 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 451 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />\ 452 </svg>\ 453 </button>\ 454 '; 455 } 456 457 // Build checkbox column if checkbox delete is enabled 458 var checkboxColumn = ''; 459 if (cartDrawerConfig.enableCheckboxDelete) { 460 checkboxColumn = '\ 461 <div class="flex-shrink-0 flex items-center h-20">\ 462 <input type="checkbox"\ 463 class="cart-item-checkbox w-4 h-4 rounded border-gray-300 text-brand-primary focus:ring-brand-primary curs
463or-pointer"\ 464 data-item-id="' + item.id + '"\ 465 onchange="updateCartSelectionState()">\ 466 </div>\ 467 '; 468 } 469 470 // GA4 item data for remove_from_cart tracking 471 var ga4ItemJson = JSON.stringify({ 472 item_id: item.sku || '', 473 item_name: item.name, 474 price: item.price_numeric || 0, 475 quantity: item.qty 476 }).replace(/'/g, ''').replace(/"/g, '"'); 477 478 itemsHtml += '\ 479 <div class="pb-4 border-b border-gray-200 last:border-0" data-cart-item-id="' + item.id + '" data-product-id="' + item.product_id + '" data-ga4-item="' + ga4ItemJson + '">\ 480 <div class="flex gap-3">\ 481 ' + checkboxColumn + '\ 482 <!-- Clickable thumbnail with preview + Design button -->\ 483 <div class="flex-shrink-0 flex flex-col items-center gap-1.5">\ 484 <button\ 485 type="button"\ 486 data-fullsize="' + htmlEscapedFullsize + '"\ 487 data-name="' + htmlEscapedName + '"\ 488 data-link="' + (item.product_url ? htmlEscapedProductUrl : '') + '"\ 489 onclick="Alpine.store(\'imagePreview\').open(this.dataset.fullsize, this.dataset.name, this.dataset.link)"\ 490 class="relative group cursor-zoom-in rounded overflow-hidden"\ 491 title="Click to enlarge"\ 492 >\ 493 <img src="' + htmlEscapedImage + '" alt="' + htmlEscapedName + '" class="w-20 h-20 object-cover transition-transform duration-200 group-hover:scale-105" />\ 494 <!-- Hover overlay with expand icon -->\ 495 <div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-200 flex items-center justify-center">\ 496 <svg class="w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 497 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />\ 498 </svg>\ 499 </div>\ 500 </button>\ 501 ' + designDropdown + '\ 502 </div>\ 503 <div class="flex-1 min-w-0">\ 504 <h4 class="text-sm font-medium text-gray-900 mb-1">' + (item.product_url ? '<a href="' + htmlEscapedProductUrl + '" target="_blank" class="text-gray-900 hover:text-brand-primary hover:underline transition-colors">' + htmlEscapedName + '</a>' : '<span class="text-gray-900">' + htmlEscapedName + '</span>') + '</h4>' + (item.error ? '<p class="mb-1 flex items-start gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2 py-1.5 text-xs text-amber-800"><svg class=\"w-4 h-4 mt-0.5 flex-shrink-0\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\"/></svg><span>' + (item.needs_options ? ((item.missing_options && item.missing_options.length ? 'Missing option(s): <span class="font-semibold">' + item.missing_options.join(', ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + '</span>. ' : '') + (item.configure_url ? '<a href="' + String(item.configure_url).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<') + '" class="font-semibold underline hover:text-amber-900 whitespace-nowrap">Edit this product</a>' : (item.wizard && item.wizard.enabled && item.wizard.update_url ? '<button type="button" data-wizard-url="' + String(item.wizard.update_url).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<') + '" onclick="openWizardModal(this.dataset.wizardUrl); return false;" class="font-semibold underline hover:text-amber-900 whitespace-nowrap">Edit this product</button>' : '<span class="font-semibold">Edit this product</span>')) + ' and finish making your selection(s) before continuing to checkout.')
504: item.error.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')) + '</span></p>' : '') + '\ 505 <!-- Qty Stepper -->\ 506 <div class="flex items-center gap-1.5 mb-2" x-data="{ editQty: \'' + item.qty + '\' }">\ 507 <div class="flex items-center border border-gray-300 rounded' + (isExpedited ? ' opacity-50 cursor-not-allowed' : '') + '">\ 508 <button type="button"\ 509 @click="editQty = String(Math.max(1, parseInt(editQty) - 1))"\ 510 :disabled="parseInt(editQty) <= 1"\ 511 class="w-6 h-6 flex items-center justify-center text-gray-600 hover:bg-gray-100 rounded-l transition-colors disabled:opacity-50 disabled:cursor-not-allowed"\ 512 ' + (isExpedited ? 'disabled' : '') + '>\ 513 <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 514 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />\ 515 </svg>\ 516 </button>\ 517 <input type="text" inputmode="numeric"\ 518 x-model="editQty"\ 519 @keydown.enter="let v=parseInt(editQty);if(v>0)updateCartItemQty(' + item.id + ',v);$event.target.blur()"\ 520 @keydown.up.prevent="editQty = String(parseInt(editQty) + 1)"\ 521 @keydown.down.prevent="editQty = String(Math.max(1, parseInt(editQty) - 1))"\ 522 @focus="$nextTick(() => $event.target.select())"\ 523 @click="$event.target.select()"\ 524 class="text-sm font-medium text-gray-700 w-8 text-center border-0 bg-transparent p-0 focus:ring-0 focus:outline-none"\ 525 style="-moz-appearance:textfield;-webkit-appearance:none"\ 526 ' + (isExpedited ? 'disabled' : '') + '>\ 527 <button type="button"\ 528 @click="editQty = String(parseInt(editQty) + 1)"\ 529 class="w-6 h-6 flex items-center justify-center text-gray-600 hover:bg-gray-100 rounded-r transition-colors disabled:opacity-50 disabled:cursor-not-allowed"\ 530 ' + (isExpedited ? 'disabled' : '') + '>\ 531 <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 532 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />\ 533 </svg>\ 534 </button>\ 535 </div>\ 536 <button type="button"\ 537 @click="let v=parseInt(editQty);if(v>0)updateCartItemQty(' + item.id + ',v)"\ 538 :disabled="!(parseInt(editQty) > 0 && parseInt(editQty) !== ' + item.qty + ')"\ 539 class="h-6 px-2 flex items-center justify-center rounded transition-colors"\ 540 :class="parseInt(editQty) > 0 && parseInt(editQty) !== ' + item.qty + ' ? \'bg-brand-primary text-white hover:bg-brand-primary-hover\' : \'bg-gray-100 text-gray-300 cursor-default\'"\ 541 title="Update quantity">\ 542 <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 543 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />\ 544 </svg>\ 545 </button>\ 546 ' + inlineEditBtn + '\ 547 ' + individualDeleteBtn + '\ 548 </div>\ 549 <div class="flex items-center gap-2 mb-1">\ 550 <p class="text-base font-semibold text-gray-900">' + (item.has_discount ? '<span class="text-gray-400 line-through text-xs font-normal">
550' + item.original_row_total + '</span> ' : '') + item.row_total + (item.qty > 1 ? '<span class="text-xs font-normal text-gray-500 ml-1">(' + (item.has_discount ? '<span class="text-gray-400 line-through">' + item.original_price + '</span> ' : '') + item.price + ' ea)</span>' : '') + '</p>\ 551 ' + buildBreakdownWidgetHtml(item) + '\ 552 ' + optionsToggle + '\ 553 </div>\ 554 </div>\ 555 </div>\ 556 ' + optionsPills + '\ 557 </div>\ 558 '; 559 }); 560 561 itemsHtml += '</div></div>'; // close cart-items-list and p-4 divs 562 container.innerHTML = itemsHtml; 563 } 564 565 // Update cart store data (footer will react automatically via Alpine) 566 this.subtotal = data.subtotal; 567 this.subtotalNumeric = parseFloat(data.subtotal_numeric) || 0; 568 this.originalSubtotal = data.original_subtotal || null; 569 this.grandTotal = data.grand_total; 570 this.totals = data.totals || []; 571 this.couponCode = data.coupon_code; 572 this.expeditedProduct = data.expedited_product; 573 this.expeditedInCart = data.expedited_in_cart; 574 this.expeditedProductId = data.expedited_product_id || null; 575 this.isEmpty = data.is_empty; 576 this.itemCount = data.item_count; 577 this.totalSavings = data.total_savings || null; 578 this.savings = data.savings || null; 579 580 // Update cart count badges 581 this.updateCartBadgesFromCount(data.item_count); 582 583 // Sync coupon code with promo store 584 if (Alpine.store('promo')) { 585 Alpine.store('promo').setAppliedCode(data.coupon_code); 586 } 587 }, 588 589 fireViewCart: function() { 590 if (typeof zGA4 !== 'function' || !this.drawerOpen) return; 591 592 var items = document.querySelectorAll('#cart-items-container [data-ga4-item]'); 593 if (items.length === 0) return; 594 595 var ga4Items = []; 596 items.forEach(function(el) { 597 try { 598 ga4Items.push(JSON.parse(el.dataset.ga4Item)); 599 } catch (e) {} 600 }); 601 602 if (ga4Items.length > 0) { 603 zGA4('view_cart', { 604 ecommerce: { 605 items: ga4Items 606 } 607 }); 608 } 609 }, 610 611 updateCartBadgesFromCount: function(itemCount) { 612 // Badges are now Alpine-reactive via $store.cart.itemCount 613 // This method is kept for any non-Alpine badge elements 614 }, 615 616 /** 617 * Lightweight fetch of cart count only (for page load badge) 618 * Uses /zephyr/cart/count which avoids expensive quote recalculation 619 */ 620 fetchCount: function() { 621 var self = this; 622 var countUrl = cartDrawerConfig.getItemsUrl.replace('/getItems', '/count'); 623 fetch(countUrl, { 624 credentials: 'same-origin', 625 headers: { 'X-Requested-With': 'XMLHttpRequest' } 626 }) 627 .then(function(response) { return response.json(); }) 628 .then(function(data) { 629 self.itemCount = data.count || 0; 630 self.isEmpty = self.itemCount === 0; 631 if (self.itemCount === 0) { 632 self.subtotalNumeric = 0; 633 } 634 }) 635 .catch(function() {}); 636 } 637 }); 638 639}); 640 641// Remove individual cart item via AJAX 642function removeCartItem(itemId) { 643 var itemElement = document.querySelector('[data-cart-item-id="' + itemId + '"]'); 644 var itemName = 'this item'; 645 var itemImage = ''; 646 647 if (itemElement) { 648 var nameEl = itemElement.querySelector('h3, h4'); 649 if (nameEl) { 650 itemName = nameEl.textContent.trim(); 651 } 652 var imgEl = itemElement.querySelector('img'); 653 if (imgEl) { 654 itemImage = imgEl.src; 655 } 656 } 657 658 var confirmHtml = '<div class="flex flex-col items-center gap-3">'; 659 if (itemImage) { 660 confirmHtml += '<img src="' + itemImage + '" alt="" class="w-20 h-20 object-cover rounded-lg border border-gray-200" />'; 661 } 662 confirmHtml += '<p>Are you sure you want to remove <strong class="text-gray-900">' + itemName + '</strong> from your cart?</p>'; 663 confirmHtml += '</div>'; 664 665 Alpine.store('confirm').show({ 666 title: 'Remove Item', 667 html: confirmHtml, 668 confirmText: 'Remove', 669 cancelText: 'Keep Item', 670 confirmStyle: 'danger', 671 onConfirm: function() { 672 doRemoveCartItem(itemId); 673 } 674 }); 675} 676 677// Actual removal logic (called after confirmation) 678function doRemoveCartItem(itemId) { 679 // Optimistic UI: Remove item from DOM immediately 680 var itemElement = document.querySelector('[data-cart-item-id="' + itemId + '"]'); 681 var itemHTML = itemElement ? itemElement.outerHTML : null; 682 var removedProductId = itemElement ? itemElement.dataset.productId : null; 683 684 // GA4 remove_from_cart event 685 if (itemElement && typeof zGA4 === 'function') { 686 try { 687 var ga4Data = JSON.parse(itemElement.dataset.ga4Item); 688 zGA4('remove_from_cart', { 689 ecommerce: { 690 items: [ga4Data] 691 } 692 }); 693 } catch (e) { 694 console.warn('[GA4] Could not parse item data for remove_from_cart:', e); 695 } 696 } 697 698 // Check if this is the expedited product being removed 699 var expeditedProductId = Alpine.store('cart').expeditedProductId; 700 var wasExpeditedProduct = removedProductId && expeditedProductId && String(removedProductId) === String(expeditedProductId); 701 702 if (wasExpeditedProduct) { 703 // Immediately show the expedited button again 704 Alpine.store('cart').expeditedInCart = false;
705 } 706 707 if (itemElement) { 708 itemElement.style.transition = 'opacity 0.2s, transform 0.2s'; 709 itemElement.style.opacity = '0'; 710 itemElement.style.transform = 'translateX(20px)'; 711 712 setTimeout(function() { 713 itemElement.remove(); 714 715 // Check if cart is now empty 716 var remainingItems = document.querySelectorAll('[data-cart-item-id]'); 717 if (remainingItems.length === 0) { 718 // Show empty cart message and update store 719 Alpine.store('cart').isEmpty = true; 720 var container = document.getElementById('cart-items-container'); 721 if (container) { 722 container.innerHTML = '\ 723 <div class="text-center py-12">\ 724 <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 725 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />\ 726 </svg>\ 727 <p class="mt-4 text-gray-500">Your cart is empty</p>\ 728 </div>\ 729 '; 730 } 731 } 732 }, 200); 733 } 734 735 // Show loading state for totals update 736 Alpine.store('cart').isRefreshing = true; 737 738 // Send AJAX request to remove item 739 fetch(cartDrawerConfig.deleteUrl, { 740 method: 'POST', 741 headers: { 742 'Content-Type': 'application/x-www-form-urlencoded', 743 'X-Requested-With': 'XMLHttpRequest' 744 }, 745 credentials: 'same-origin', 746 body: 'id=' + itemId + '&form_key=' + cartDrawerConfig.formKey 747 }) 748 .then(function(response) { 749 return response.text(); 750 }) 751 .then(function() { 752 // Refresh to update totals and badges 753 Alpine.store('cart').isRefreshing = false; 754 Alpine.store('cart').refresh(); 755 }) 756 .catch(function(error) { 757 console.error('Error removing item:', error); 758 // Restore the item on error 759 if (itemHTML) { 760 var container = document.getElementById('cart-items-list'); 761 if (container) { 762 container.insertAdjacentHTML('beforeend', itemHTML); 763 } 764 } 765 Alpine.store('confirm').show({ 766 title: 'Error', 767 message: 'There was a problem removing the item. Please try again.', 768 confirmText: 'OK', 769 cancelText: 'Close', 770 confirmStyle: 'primary' 771 }); 772 }); 773} 774 775// POST the clear-cart endpoint; rejects if the server reports a failure 776function postCartClear() { 777 return fetch(cartDrawerConfig.clearUrl, { 778 method: 'POST', 779 headers: { 780 'Content-Type': 'application/x-www-form-urlencoded', 781 'X-Requested-With': 'XMLHttpRequest' 782 }, 783 credentials: 'same-origin' 784 }) 785 .then(function(response) { 786 return response.json(); 787 }) 788 .then(function(data) { 789 if (!data.success) { 790 throw new Error(data.error || 'Unable to clear cart'); 791 } 792 return data; 793 }); 794} 795 796// POST several item ids to the batch remove endpoint (one request, one quote save) 797function postCartRemoveItems(itemIds) { 798 var body = itemIds.map(function(itemId) { 799 return 'item_ids[]=' + encodeURIComponent(itemId); 800 }); 801 body.push('form_key=' + encodeURIComponent(cartDrawerConfig.formKey)); 802 803 return fetch(cartDrawerConfig.removeItemsUrl, { 804 method: 'POST', 805 headers: { 806 'Content-Type': 'application/x-www-form-urlencoded', 807 'X-Requested-With': 'XMLHttpRequest' 808 }, 809 credentials: 'same-origin', 810 body: body.join('&') 811 }) 812 .then(function(response) { 813 return response.json(); 814 }) 815 .then(function(data) { 816 if (!data.success) { 817 throw new Error(data.error || 'Unable to remove items'); 818 } 819 return data; 820 }); 821} 822 823// Clear entire cart via AJAX 824function clearCart() { 825 var cartItems = document.querySelectorAll('[data-cart-item-id]'); 826 if (cartItems.length === 0) { 827 return; 828 } 829 830 var itemCount = cartItems.length; 831 Alpine.store('confirm').show({
832 title: 'Clear Cart', 833 message: 'Are you sure you want to remove all ' + itemCount + ' item' + (itemCount > 1 ? 's' : '') + ' from your cart?', 834 confirmText: 'Clear Cart', 835 cancelText: 'Keep Items', 836 confirmStyle: 'danger', 837 onConfirm: function() { 838 doClearCart(); 839 } 840 }); 841} 842 843// Actual clear cart logic (called after confirmation) 844function doClearCart() { 845 // Get all cart item elements for optimistic UI 846 var cartItems = document.querySelectorAll('[data-cart-item-id]'); 847 848 if (cartItems.length === 0) { 849 return; 850 } 851 852 // GA4 remove_from_cart with all items in a single event 853 if (typeof zGA4 === 'function') { 854 var ga4Items = []; 855 cartItems.forEach(function(el) { 856 try { 857 ga4Items.push(JSON.parse(el.dataset.ga4Item)); 858 } catch (e) {} 859 }); 860 if (ga4Items.length > 0) { 861 zGA4('remove_from_cart', { 862 ecommerce: { 863 items: ga4Items 864 } 865 }); 866 } 867 } 868 869 // Optimistic UI: Fade out and remove all items immediately 870 var itemsHTML = []; 871 cartItems.forEach(function(item, index) { 872 itemsHTML.push(item.outerHTML); 873 item.style.transition = 'opacity 0.2s, transform 0.2s'; 874 item.style.transitionDelay = (index * 0.05) + 's'; 875 item.style.opacity = '0'; 876 item.style.transform = 'translateX(20px)'; 877 }); 878 879 setTimeout(function() { 880 var container = document.getElementById('cart-items-container'); 881 if (container) { 882 container.innerHTML = '\ 883 <div class="text-center py-12">\ 884 <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 885 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />\ 886 </svg>\ 887 <p class="mt-4 text-gray-500">Your cart is empty</p>\ 888 </div>\ 889 '; 890 } 891 }, 200 + (cartItems.length * 50)); 892 893 // Use dedicated clear cart endpoint that properly resets quote totals 894 postCartClear() 895 .then(function() { 896 // Refresh to update totals and badges 897 Alpine.store('cart').refresh(); 898 }) 899 .catch(function(error) { 900 console.error('Error clearing cart:', error); 901 // Restore items on error 902 var container = document.getElementById('cart-items-container'); 903 if (container && itemsHTML.length > 0) { 904 container.innerHTML = '<div class="space-y-4" id="cart-items-list">' + itemsHTML.join('') + '</div>'; 905 } 906 Alpine.store('confirm').show({ 907 title: 'Error', 908 message: 'There was a problem clearing your cart. Please try again.', 909 confirmText: 'OK', 910 cancelText: 'Close', 911 confirmStyle: 'primary' 912 }); 913 }); 914} 915 916 917// Step qty up or down locally without submitting (shows save button) 918function stepCartQty(button, delta) { 919 var container = button.closest('.flex'); 920 var input = container.querySelector('input[data-item-id]'); 921 if (!input) return; 922 var currentVal = parseInt(input.value) || 1; 923 var newVal = currentVal + delta; 924 if (newVal < 1) return; 925 input.value = newVal; 926 toggleQtySave(input); 927} 928 929// Show/hide the qty save button based on whether the input value changed 930function toggleQtySave(input) { 931 var saveBtn = input.closest('.flex').querySelector('.qty-save-btn'); 932 if (!saveBtn) return; 933 var originalQty = parseInt(input.dataset.originalQty); 934 var newVal = parseInt(input.value); 935 if (newVal > 0 && newVal !== originalQty) { 936 saveBtn.style.display = 'inline-flex'; 937 } else { 938 saveBtn.style.display = 'none'; 939 } 940} 941 942// Submit qty from input field (used by Enter key and save button) 943function submitQtyInput(input) { 944 if (!input) return; 945 var v = parseInt(input.value); 946 var originalQty = parseInt(input.dataset.originalQty); 947 if (v > 0 && v !== originalQty) { 948 updateCartItemQty(parseInt(input.dataset.itemId), v); 949 } else if (!v || v < 1) { 950 input.value = originalQty; 951 toggleQtySave(input); 952 } 953} 954 955// Toggle cart item options pills 956function toggleCartOptions(id) { 957 var el = document.getElementById(id); 958 var icon = document.getElementById(id + '-icon'); 959 var text = document.getElementById(id + '-text'); 960 if (!el) return; 961 962 var isHidden = el.style.display === 'none' || el.style.display === '';
963 if (isHidden) { 964 el.style.display = 'flex'; 965 if (icon) icon.style.transform = 'rotate(180deg)'; 966 if (text) text.textContent = 'Hide Options'; 967 setTimeout(function() { 968 el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 969 }, 50); 970 } else { 971 el.style.display = 'none'; 972 if (icon) icon.style.transform = 'rotate(0deg)'; 973 if (text) text.textContent = 'Show Options'; 974 } 975} 976 977// Toggle select all cart items 978function toggleSelectAllCartItems(checked) { 979 var checkboxes = document.querySelectorAll('.cart-item-checkbox'); 980 checkboxes.forEach(function(cb) { 981 cb.checked = checked; 982 }); 983 updateCartSelectionState(); 984} 985 986// Update cart selection state (show/hide delete button, update select all) 987function updateCartSelectionState() { 988 var checkboxes = document.querySelectorAll('.cart-item-checkbox'); 989 var checkedBoxes = document.querySelectorAll('.cart-item-checkbox:checked'); 990 var selectAllCheckbox = document.getElementById('cart-select-all'); 991 var deleteBtn = document.getElementById('cart-delete-selected-btn'); 992 993 // Update select all checkbox state 994 if (selectAllCheckbox) { 995 if (checkboxes.length === 0) { 996 selectAllCheckbox.checked = false; 997 selectAllCheckbox.indeterminate = false; 998 } else if (checkedBoxes.length === 0) { 999 selectAllCheckbox.checked = false; 1000 selectAllCheckbox.indeterminate = false; 1001 } else if (checkedBoxes.length === checkboxes.length) { 1002 selectAllCheckbox.checked = true; 1003 selectAllCheckbox.indeterminate = false; 1004 } else { 1005 selectAllCheckbox.checked = false; 1006 selectAllCheckbox.indeterminate = true; 1007 } 1008 } 1009 1010 // Show/hide delete button 1011 if (deleteBtn) { 1012 if (checkedBoxes.length > 0) { 1013 deleteBtn.classList.remove('hidden'); 1014 deleteBtn.textContent = 'Delete Selected (' + checkedBoxes.length + ')'; 1015 } else { 1016 deleteBtn.classList.add('hidden'); 1017 } 1018 } 1019} 1020 1021// Delete selected cart items 1022function deleteSelectedCartItems() { 1023 var checkedBoxes = document.querySelectorAll('.cart-item-checkbox:checked'); 1024 if (checkedBoxes.length === 0) return; 1025 1026 var itemCount = checkedBoxes.length; 1027 Alpine.store('confirm').show({ 1028 title: 'Remove Selected Items', 1029 message: 'Are you sure you want to remove ' + itemCount + ' selected item' + (itemCount > 1 ? 's' : '') + ' from your cart?', 1030 confirmText: 'Remove ' + itemCount + ' Item' + (itemCount > 1 ? 's' : ''), 1031 cancelText: 'Cancel', 1032 confirmStyle: 'danger', 1033 onConfirm: function() { 1034 doDeleteSelectedCartItems(); 1035 } 1036 }); 1037} 1038 1039// Actual delete selected logic (called after confirmation) 1040function doDeleteSelectedCartItems() { 1041 var checkedBoxes = document.querySelectorAll('.cart-item-checkbox:checked'); 1042 if (checkedBoxes.length === 0) return; 1043 1044 // GA4 remove_from_cart with all selected items in a single event 1045 if (typeof zGA4 === 'function') { 1046 var ga4Items = []; 1047 checkedBoxes.forEach(function(cb) { 1048 var itemEl = cb.closest('[data-cart-item-id]'); 1049 if (itemEl) { 1050 try { 1051 ga4Items.push(JSON.parse(itemEl.dataset.ga4Item)); 1052 } catch (e) {} 1053 } 1054 }); 1055 if (ga4Items.length > 0) { 1056 zGA4('remove_from_cart', { 1057 ecommerce: { 1058 items: ga4Items 1059 } 1060 }); 1061 } 1062 } 1063 1064 // Get all selected item IDs 1065 var itemIds = Array.from(checkedBoxes).map(function(cb) { 1066 return cb.dataset.itemId; 1067 }); 1068 1069 // Show loading state 1070 Alpine.store('cart').isRefreshing = true; 1071 1072 var deletePromise; 1073 var totalItems = document.querySelectorAll('.cart-item-checkbox').length; 1074 1075 if (itemIds.length >= totalItems) { 1076 // Everything is selected: one clear-cart request instead of N deletes 1077 deletePromise = postCartClear(); 1078 } else if (cartDrawerConfig.removeItemsUrl) { 1079 // One batch request for the whole selection 1080 deletePromise = postCartRemoveItems(itemIds); 1081 } else { 1082 // Fallback (stale page config): delete items sequentially 1083 deletePromise = Promise.resolve(); 1084 1085 itemIds.forEach(function(itemId) { 1086 deletePromise = deletePromise.then(function() { 1087 return fetch(cartDrawerConfig.deleteUrl, { 1088 method: 'POST', 1089 headers: { 1090 'Content-Type': 'application/x-www-form-urlencoded', 1091 'X-Requested-With': 'XMLHttpRequest' 1092 }, 1093 credentials: 'same-origin',
1094 body: 'id=' + itemId + '&form_key=' + cartDrawerConfig.formKey 1095 }); 1096 }); 1097 }); 1098 } 1099 1100 deletePromise 1101 .then(function() { 1102 Alpine.store('cart').isRefreshing = false; 1103 Alpine.store('cart').refresh(); 1104 }) 1105 .catch(function(error) { 1106 console.error('Error deleting items:', error); 1107 Alpine.store('cart').isRefreshing = false; 1108 Alpine.store('confirm').show({ 1109 title: 'Error', 1110 message: 'There was a problem removing the selected items. Please try again.', 1111 confirmText: 'OK', 1112 cancelText: 'Close', 1113 confirmStyle: 'primary' 1114 }); 1115 }); 1116} 1117 1118// Update cart item quantity via AJAX 1119function updateCartItemQty(itemId, newQty) { 1120 // Validate qty 1121 if (newQty < 1) { 1122 // If qty would be 0 or less, ask to remove instead 1123 removeCartItem(itemId); 1124 return; 1125 } 1126 1127 // Show loading state 1128 Alpine.store('cart').isRefreshing = true; 1129 1130 // Send AJAX request to update qty 1131 fetch(cartDrawerConfig.updatePostUrl, { 1132 method: 'POST', 1133 headers: { 1134 'Content-Type': 'application/x-www-form-urlencoded', 1135 'X-Requested-With': 'XMLHttpRequest' 1136 }, 1137 credentials: 'same-origin', 1138 body: 'cart[' + itemId + '][qty]=' + newQty + '&update_cart_action=update_qty&form_key=' + cartDrawerConfig.formKey 1139 }) 1140 .then(function(response) { 1141 return response.text(); 1142 }) 1143 .then(function(html) { 1144 // Reset flag before calling refresh so it doesn't skip 1145 Alpine.store('cart').isRefreshing = false; 1146 // Refresh cart to get updated data 1147 Alpine.store('cart').refresh(); 1148 }) 1149 .catch(function(error) { 1150 console.error('Error updating quantity:', error); 1151 Alpine.store('cart').isRefreshing = false; 1152 alert('Error updating quantity. Please try again.'); 1153 }); 1154} 1155 1156// Add expedited shipping product to cart via AJAX 1157function addExpeditedToCart() { 1158 var expeditedProduct = Alpine.store('cart').expeditedProduct; 1159 if (!expeditedProduct || !expeditedProduct.add_url) { 1160 console.error('No expedited product configured'); 1161 return; 1162 } 1163 1164 // Scroll to newly added item after refresh 1165 Alpine.store('cart')._scrollToNew = true; 1166 1167 // Show loading state 1168 Alpine.store('cart').isRefreshing = true; 1169 1170 // Extract product ID and form key from the add URL 1171 // The add URL format is: /checkout/cart/add/product/{id}/... 1172 fetch(expeditedProduct.add_url, { 1173 method: 'GET', 1174 credentials: 'same-origin', 1175 headers: { 1176 'X-Requested-With': 'XMLHttpRequest' 1177 } 1178 }) 1179 .then(function(response) { 1180 // The add to cart will redirect, but we just need to refresh the cart 1181 Alpine.store('cart').isRefreshing = false; 1182 Alpine.store('cart').refresh(); 1183 if (response.ok) { 1184 // Show the expedited confirmation modal 1185 window.dispatchEvent(new CustomEvent('expedited-added')); 1186 } 1187 }) 1188 .catch(function(error) { 1189 console.error('Error adding expedited product:', error); 1190 Alpine.store('cart').isRefreshing = false; 1191 // Still refresh in case it was added 1192 Alpine.store('cart').refresh(); 1193 }); 1194}
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.