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 itemCount: 0, 168 totalSavings: null, 169 savings: null, 170 171 toggleDrawer: function() { 172 this.drawerOpen = !this.drawerOpen; 173 if (window.innerWidth < 768) { 174 document.body.style.overflow = this.drawerOpen ? 'hidden' : ''; 175 } 176 }, 177 178 openDrawer: function() { 179 this.drawerOpen = true; 180 if (window.innerWidth < 768) { 181 document.body.style.overflow = 'hidden'; 182 } 183 // Safety refresh if data is stale (5+ seconds) 184 if (!this.lastFetch || Date.now() - this.lastFetch > 5000) { 185 this.refresh(); 186 } else { 187 // Data is fresh, fire view_cart with current items 188 this.fireViewCart(); 189 } 190 }, 191 192 closeDrawer: function() { 193 this.drawerOpen = false; 194 clearTimeout(this._focusTimer); 195 this.drawerFocused = false; 196 document.body.style.overflow = ''; // always clear on close 197 }, 198 199 refresh: function() { 200 var self = this; 201 202 if (this.isRefreshing) { 203 console.log('[Cart] Already refreshing, skipping...'); 204 return; // Prevent duplicate requests 205 } 206 207 console.log('[Cart] Starting refresh, isRefreshing = true'); 208 var startTime = Date.now(); 209 this.isRefreshing = true; 210 this.lastFetch = Date.now(); 211 212 console.log('[Cart] Fetching cart data from JSON API...'); 213 fetch(cartDrawerConfig.getItemsUrl, { 214 credentials: 'same-origin', 215 headers: { 216 'X-Requested-With': 'XMLHttpRequest' 217 } 218 }) 219 .then(function(response) { 220 console.log('[Cart] Fetch completed in ' + (Date.now() - startTime) + 'ms'); 221 return response.json(); 222 }) 223 .then(function(data) { 224 console.log('[Cart] Updating UI from JSON data...'); 225 self.updateCartFromJson(data); 226 self.isRefreshing = false;
227 console.log('[Cart] Complete! Total time: ' + (Date.now() - startTime) + 'ms'); 228 // Dispatch event for scroll indicator to update 229 window.dispatchEvent(new CustomEvent('cart-refreshed')); 230 // Fire GA4 view_cart if drawer is open 231 self.fireViewCart(); 232 }) 233 .catch(function(error) { 234 console.error('Cart refresh failed:', error); 235 self.isRefreshing = false; 236 }); 237 }, 238 239 updateCartFromJson: function(data) { 240 var self = this; 241 242 if (!data.success) { 243 console.error('[Cart] Failed to load cart data:', data.error); 244 return; 245 } 246 247 // Update cart items container 248 var container = document.getElementById('cart-items-container'); 249 if (!container) { 250 console.error('[Cart] Cart items container not found'); 251 return; 252 } 253 254 if (data.is_empty || data.items.length === 0) { 255 // Show empty cart message 256 container.innerHTML = '\ 257 <div class="text-center py-12">\ 258 <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 259 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />\ 260 </svg>\ 261 <p class="mt-4 text-gray-500">Your cart is empty</p>\ 262 </div>\ 263 '; 264 } else { 265 // Build selection toolbar and cart items HTML 266 var itemsHtml = ''; 267 268 // Only show selection toolbar if checkbox delete is enabled 269 if (cartDrawerConfig.enableCheckboxDelete) { 270 itemsHtml += '\ 271 <div class="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-200" id="cart-selection-toolbar">\ 272 <label class="flex items-center gap-2 cursor-pointer">\ 273 <input type="checkbox"\ 274 id="cart-select-all"\ 275 onchange="toggleSelectAllCartItems(this.checked)"\ 276 class="w-4 h-4 rounded border-gray-300 text-brand-primary focus:ring-brand-primary cursor-pointer">\ 277 <span class="text-sm text-gray-600">Select All</span>\ 278 </label>\ 279 <button type="button"\ 280 id="cart-delete-selected-btn"\ 281 onclick="deleteSelectedCartItems()"\ 282 class="hidden text-sm text-brand-danger hover:text-red-700 font-medium transition-colors">\ 283 Delete Selected\ 284 </button>\ 285 </div>\ 286 '; 287 } 288 289 itemsHtml += '\ 290 <div class="p-4">\ 291 <div class="space-y-4" id="cart-items-list">'; 292 293 var freshExpeditedProductId = data.expedited_product_id || null; 294 data.items.forEach(function(item) { 295 var isExpedited = freshExpeditedProductId && item.product_id == freshExpeditedProductId; 296 297 // Design dropdown is no longer shown below the image for wizard items. 298 // It has been moved inline next to the qty stepper (see inlineEditBtn below). 299 var designDropdown = ''; 300 301 // Build options toggle + pills if custom options exist 302 var optionsToggle = ''; 303 var optionsPills = ''; 304 var hasOptions = item.options && item.options.length > 0; 305 var itemOptsId = 'cart-opts-' + item.id; 306 307 if (hasOptions) { 308 optionsToggle = '\ 309 <button type="button"\ 310 onclick="toggleCartOptions(\'' + itemOptsId + '\')"\ 311 class="inline-flex items-center gap-1 py-1 text-xs text-gray-500 hover:text-gray-700 transition-colors ml-auto"\ 312 id="' + itemOptsId + '-btn">\ 313 <svg class="w-3 h-3 transition-transform duration-200" id="' + itemOptsId + '-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 314 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />\ 315 </svg>\
316 <span id="' + itemOptsId + '-text">Show Options</span>\ 317 </button>\ 318 '; 319 320 var pillsList = ''; 321 item.options.forEach(function(opt) { 322 var escapedLabel = opt.label.replace(/</g, '<').replace(/>/g, '>'); 323 var escapedValue = opt.value.replace(/</g, '<').replace(/>/g, '>'); 324 pillsList += '\ 325 <span class="inline px-2.5 py-1 rounded-full bg-white border border-gray-200 text-sm leading-relaxed">\ 326 <span class="font-medium text-gray-700">' + escapedLabel + ':</span>\ 327 <span class="text-gray-500">' + escapedValue + '</span>\ 328 </span>\ 329 '; 330 }); 331 332 optionsPills = '\ 333 <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">\ 334 <div class="absolute -top-2 right-8 w-3 h-3 bg-gray-50 border-l border-t border-gray-200 rotate-45"></div>\ 335 ' + pillsList + '\ 336 </div>\ 337 '; 338 } 339 340 // Inline edit/update button next to qty stepper 341 var inlineEditBtn = ''; 342 if (item.wizard && item.wizard.enabled && item.wizard.update_url) {
343 // Wizard items: edit icon with dropdown (Update Design, Download Proof, Duplicate) 344 var wizDropdownItems = ''; 345 346 // Update Design 347 wizDropdownItems += '\ 348 <button type="button"\ 349 onclick="openWizardModal(\'' + item.wizard.update_url + '\'); return false;"\ 350 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">\ 351 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 352 <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" />\ 353 </svg>\ 354 Update Design\ 355 </button>\ 356 '; 357 358 // Download Proof 359 if (item.wizard.design_id) { 360 wizDropdownItems += '\ 361 <a href="#" onclick="return pceDownloadProof(' + item.wizard.design_id + ');"\ 362 class="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">\ 363 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 364 <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" />\ 365 </svg>\ 366 Download Proof\ 367 </a>\ 368 '; 369 } 370 371 // Duplicate Design 372 var duplicateUrl = item.wizard.update_url.replace('&update=', '&duplicate='); 373 wizDropdownItems += '\ 374 <button type="button"\ 375 onclick="openWizardModal(\'' + duplicateUrl + '\'); return false;"\ 376 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">\ 377 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 378 <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" />\ 379 </svg>\ 380 Duplicate Design\ 381 </button>\ 382 '; 383 384 inlineEditBtn = '\ 385 <div class="relative ml-1" x-data="{ open: false }" @click.away="open = false">\ 386 <button type="button"\ 387 @click="open = !open"\ 388 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"\ 389 title="Design options">\ 390 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 391 <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" />\ 392 </svg>\ 393 </button>\ 394 <div x-show="open"\ 395 x-transition:enter="transition ease-out duration-100"\ 396 x-transition:enter-start="opacity-0 scale-95"\ 397 x-transition:enter-end="opacity-100 scale-100"\ 398 x-transition:leave="transition ease-in duration-75"\ 399 x-transition:leave-start="opacity-100 scale-100"\ 400 x-transition:leave-end="opacity-0 scale-95"\ 401 x-effect="if (open) { $nextTick(() => $el.scrollIntoView({ behavior: \'smooth\', block: \'nearest\' })) }"\ 402 class="absolute right-0 mt-1 w-44 bg-white border border-gray-200 rounded-lg shadow-lg z-20 py-1">\ 403 ' + wizDropdownItems + '\ 404 </div>\ 405 </div>\ 406 '; 407 } else if (item.configure_url) { 408 // Non-wizard items with options: edit icon linking to configure page 409 inlineEditBtn = '\ 410 <a href="' + item.configure_url + '"\ 411 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"\ 412 title="Edit options">\ 413 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 414 <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" />\ 415 </svg>\ 416 </a>\ 417 '; 418 } 419 420 // Escape for HTML attributes (data-* values) 421 var htmlEscapedName = item.name.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>'); 422 var htmlEscapedFullsize = item.fullsize_url.replace(/&/g, '&').replace(/"/g, '"'); 423 var htmlEscapedImage = item.image_url.replace(/&/g, '&').replace(/"/g, '"'); 424 var htmlEscapedProductUrl = item.product_url ? item.product_url.replace(/&/g, '&').replace(/"/g, '"') : ''; 425 426 // Build individual delete button if enabled (trash icon inline with qty stepper) 427 var individualDeleteBtn = ''; 428 if (cartDrawerConfig.enableIndividualDelete) { 429 individualDeleteBtn = '\ 430 <button type="button"\ 431 onclick="removeCartItem(' + item.id + ')"\ 432 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"\ 433 title="Remove item">\ 434 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 435 <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" />\ 436 </svg>\ 437 </button>\ 438 '; 439 } 440 441 // Build checkbox column if checkbox delete is enabled 442 var checkboxColumn = ''; 443 if (cartDrawerConfig.enableCheckboxDelete) { 444 checkboxColumn = '\ 445 <div class="flex-shrink-0 flex items-center h-20">\ 446 <input type="checkbox"\ 447 class="cart-item-checkbox w-4 h-4 rounded border-gray-300 text-brand-primary focus:ring-brand-primary curs
447or-pointer"\ 448 data-item-id="' + item.id + '"\ 449 onchange="updateCartSelectionState()">\ 450 </div>\ 451 '; 452 } 453 454 // GA4 item data for remove_from_cart tracking 455 var ga4ItemJson = JSON.stringify({ 456 item_id: item.sku || '', 457 item_name: item.name, 458 price: item.price_numeric || 0, 459 quantity: item.qty 460 }).replace(/'/g, ''').replace(/"/g, '"'); 461 462 itemsHtml += '\ 463 <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 + '">\ 464 <div class="flex gap-3">\ 465 ' + checkboxColumn + '\ 466 <!-- Clickable thumbnail with preview + Design button -->\ 467 <div class="flex-shrink-0 flex flex-col items-center gap-1.5">\ 468 <button\ 469 type="button"\ 470 data-fullsize="' + htmlEscapedFullsize + '"\ 471 data-name="' + htmlEscapedName + '"\ 472 data-link="' + (item.product_url ? htmlEscapedProductUrl : '') + '"\ 473 onclick="Alpine.store(\'imagePreview\').open(this.dataset.fullsize, this.dataset.name, this.dataset.link)"\ 474 class="relative group cursor-zoom-in rounded overflow-hidden"\ 475 title="Click to enlarge"\ 476 >\ 477 <img src="' + htmlEscapedImage + '" alt="' + htmlEscapedName + '" class="w-20 h-20 object-cover transition-transform duration-200 group-hover:scale-105" />\ 478 <!-- Hover overlay with expand icon -->\ 479 <div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-200 flex items-center justify-center">\ 480 <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">\ 481 <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" />\ 482 </svg>\ 483 </div>\ 484 </button>\ 485 ' + designDropdown + '\ 486 </div>\ 487 <div class="flex-1 min-w-0">\ 488 <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>\ 489 <!-- Qty Stepper -->\ 490 <div class="flex items-center gap-1.5 mb-2" x-data="{ editQty: \'' + item.qty + '\' }">\ 491 <div class="flex items-center border border-gray-300 rounded' + (isExpedited ? ' opacity-50 cursor-not-allowed' : '') + '">\ 492 <button type="button"\ 493 @click="editQty = String(Math.max(1, parseInt(editQty) - 1))"\ 494 :disabled="parseInt(editQty) <= 1"\ 495 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"\ 496 ' + (isExpedited ? 'disabled' : '') + '>\ 497 <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 498 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />\ 499 </svg>\ 500 </button>\ 501 <input type="text" inputmode="numeric"\ 502 x-model="editQty"\ 503 @keydown.enter="let v=parseInt(editQty);if(v>0)updateCartItemQty(' + item.id + ',v);$event.target.blur()"\ 504 @keydown.up.prevent="editQty = String(parseInt(editQty) + 1)"\ 505 @keydown.down.prevent="editQty = String(Math.max(1, parseInt(editQty) - 1))"\ 506 @focus="$nextTick(() => $event.target.select())"\ 507 @click="$event.target.select()"\ 508 class="text-sm font-medium text-gray-700 w-8 text-center border-0 bg-transparent p-0 focus:ring-0 focus:outline-none"\ 509 style="-moz-appearance:textfield;-webkit-appearance:none"\ 510 ' + (isExpedited ? 'disabled' : '') + '>\ 511 <button type="button"\ 512 @click="editQty = String(parseInt(editQty) + 1)"\ 513 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"\ 514 ' + (isExpedited ? 'disabled' : '') + '>\ 515 <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 516 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />\ 517 </svg>\ 518 </button>\ 519 </div>\ 520 <button type="button"\ 521 @click="let v=parseInt(editQty);if(v>0)updateCartItemQty(' + item.id + ',v)"\ 522 :disabled="!(parseInt(editQty) > 0 && parseInt(editQty) !== ' + item.qty + ')"\ 523 class="h-6 px-2 flex items-center justify-center rounded transition-colors"\ 524 :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\'"\ 525 title="Update quantity">\ 526 <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 527 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />\ 528 </svg>\ 529 </button>\ 530 ' + inlineEditBtn + '\ 531 ' + individualDeleteBtn + '\ 532 </div>\ 533 <div class="flex items-center gap-2 mb-1">\ 534 <p class="text-base font-semibold text-gray-900">' + (item.has_discount ? '<span class="text-gray-400 line-through text-xs font-normal">
534' + 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>\ 535 ' + buildBreakdownWidgetHtml(item) + '\ 536 ' + optionsToggle + '\ 537 </div>\ 538 </div>\ 539 </div>\ 540 ' + optionsPills + '\ 541 </div>\ 542 '; 543 }); 544 545 itemsHtml += '</div></div>'; // close cart-items-list and p-4 divs 546 container.innerHTML = itemsHtml; 547 } 548 549 // Update cart store data (footer will react automatically via Alpine) 550 this.subtotal = data.subtotal; 551 this.subtotalNumeric = parseFloat(data.subtotal_numeric) || 0; 552 this.originalSubtotal = data.original_subtotal || null; 553 this.grandTotal = data.grand_total; 554 this.totals = data.totals || []; 555 this.couponCode = data.coupon_code; 556 this.expeditedProduct = data.expedited_product; 557 this.expeditedInCart = data.expedited_in_cart; 558 this.expeditedProductId = data.expedited_product_id || null; 559 this.isEmpty = data.is_empty; 560 this.itemCount = data.item_count; 561 this.totalSavings = data.total_savings || null; 562 this.savings = data.savings || null; 563 564 // Update cart count badges 565 this.updateCartBadgesFromCount(data.item_count); 566 567 // Sync coupon code with promo store 568 if (Alpine.store('promo')) { 569 Alpine.store('promo').setAppliedCode(data.coupon_code); 570 } 571 }, 572 573 fireViewCart: function() { 574 if (typeof zGA4 !== 'function' || !this.drawerOpen) return; 575 576 var items = document.querySelectorAll('#cart-items-container [data-ga4-item]'); 577 if (items.length === 0) return; 578 579 var ga4Items = []; 580 items.forEach(function(el) { 581 try { 582 ga4Items.push(JSON.parse(el.dataset.ga4Item)); 583 } catch (e) {} 584 }); 585 586 if (ga4Items.length > 0) { 587 zGA4('view_cart', { 588 ecommerce: { 589 items: ga4Items 590 } 591 }); 592 } 593 }, 594 595 updateCartBadgesFromCount: function(itemCount) { 596 // Badges are now Alpine-reactive via $store.cart.itemCount 597 // This method is kept for any non-Alpine badge elements 598 }, 599 600 /** 601 * Lightweight fetch of cart count only (for page load badge) 602 * Uses /zephyr/cart/count which avoids expensive quote recalculation 603 */ 604 fetchCount: function() { 605 var self = this; 606 var countUrl = cartDrawerConfig.getItemsUrl.replace('/getItems', '/count'); 607 fetch(countUrl, { 608 credentials: 'same-origin', 609 headers: { 'X-Requested-With': 'XMLHttpRequest' } 610 }) 611 .then(function(response) { return response.json(); }) 612 .then(function(data) { 613 self.itemCount = data.count || 0; 614 self.isEmpty = self.itemCount === 0; 615 if (self.itemCount === 0) { 616 self.subtotalNumeric = 0; 617 } 618 }) 619 .catch(function() {}); 620 } 621 }); 622 623}); 624 625// Remove individual cart item via AJAX 626function removeCartItem(itemId) { 627 var itemElement = document.querySelector('[data-cart-item-id="' + itemId + '"]'); 628 var itemName = 'this item'; 629 var itemImage = ''; 630 631 if (itemElement) { 632 var nameEl = itemElement.querySelector('h3, h4'); 633 if (nameEl) { 634 itemName = nameEl.textContent.trim(); 635 } 636 var imgEl = itemElement.querySelector('img'); 637 if (imgEl) { 638 itemImage = imgEl.src; 639 } 640 } 641 642 var confirmHtml = '<div class="flex flex-col items-center gap-3">'; 643 if (itemImage) { 644 confirmHtml += '<img src="' + itemImage + '" alt="" class="w-20 h-20 object-cover rounded-lg border border-gray-200" />'; 645 } 646 confirmHtml += '<p>Are you sure you want to remove <strong class="text-gray-900">' + itemName + '</strong> from your cart?</p>'; 647 confirmHtml += '</div>'; 648 649 Alpine.store('confirm').show({ 650 title: 'Remove Item', 651 html: confirmHtml, 652 confirmText: 'Remove', 653 cancelText: 'Keep Item', 654 confirmStyle: 'danger', 655 onConfirm: function() { 656 doRemoveCartItem(itemId); 657 } 658 }); 659} 660 661// Actual removal logic (called after confirmation) 662function doRemoveCartItem(itemId) { 663 // Optimistic UI: Remove item from DOM immediately 664 var itemElement = document.querySelector('[data-cart-item-id="' + itemId + '"]'); 665 var itemHTML = itemElement ? itemElement.outerHTML : null; 666 var removedProductId = itemElement ? itemElement.dataset.productId : null; 667 668 // GA4 remove_from_cart event 669 if (itemElement && typeof zGA4 === 'function') { 670 try { 671 var ga4Data = JSON.parse(itemElement.dataset.ga4Item); 672 zGA4('remove_from_cart', { 673 ecommerce: { 674 items: [ga4Data] 675 } 676 }); 677 } catch (e) { 678 console.warn('[GA4] Could not parse item data for remove_from_cart:', e); 679 } 680 } 681 682 // Check if this is the expedited product being removed 683 var expeditedProductId = Alpine.store('cart').expeditedProductId; 684 var wasExpeditedProduct = removedProductId && expeditedProductId && String(removedProductId) === String(expeditedProductId); 685 686 if (wasExpeditedProduct) { 687 // Immediately show the expedited button again 688 Alpine.store('cart').expeditedInCart = false;
689 } 690 691 if (itemElement) { 692 itemElement.style.transition = 'opacity 0.2s, transform 0.2s'; 693 itemElement.style.opacity = '0'; 694 itemElement.style.transform = 'translateX(20px)'; 695 696 setTimeout(function() { 697 itemElement.remove(); 698 699 // Check if cart is now empty 700 var remainingItems = document.querySelectorAll('[data-cart-item-id]'); 701 if (remainingItems.length === 0) { 702 // Show empty cart message and update store 703 Alpine.store('cart').isEmpty = true; 704 var container = document.getElementById('cart-items-container'); 705 if (container) { 706 container.innerHTML = '\ 707 <div class="text-center py-12">\ 708 <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 709 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />\ 710 </svg>\ 711 <p class="mt-4 text-gray-500">Your cart is empty</p>\ 712 </div>\ 713 '; 714 } 715 } 716 }, 200); 717 } 718 719 // Show loading state for totals update 720 Alpine.store('cart').isRefreshing = true; 721 722 // Send AJAX request to remove item 723 fetch(cartDrawerConfig.deleteUrl, { 724 method: 'POST', 725 headers: { 726 'Content-Type': 'application/x-www-form-urlencoded', 727 'X-Requested-With': 'XMLHttpRequest' 728 }, 729 credentials: 'same-origin', 730 body: 'id=' + itemId + '&form_key=' + cartDrawerConfig.formKey 731 }) 732 .then(function(response) { 733 return response.text(); 734 }) 735 .then(function() { 736 // Refresh to update totals and badges 737 Alpine.store('cart').isRefreshing = false; 738 Alpine.store('cart').refresh(); 739 }) 740 .catch(function(error) { 741 console.error('Error removing item:', error); 742 // Restore the item on error 743 if (itemHTML) { 744 var container = document.getElementById('cart-items-list'); 745 if (container) { 746 container.insertAdjacentHTML('beforeend', itemHTML); 747 } 748 } 749 Alpine.store('confirm').show({ 750 title: 'Error', 751 message: 'There was a problem removing the item. Please try again.', 752 confirmText: 'OK', 753 cancelText: 'Close', 754 confirmStyle: 'primary' 755 }); 756 }); 757} 758 759// POST the clear-cart endpoint; rejects if the server reports a failure 760function postCartClear() { 761 return fetch(cartDrawerConfig.clearUrl, { 762 method: 'POST', 763 headers: { 764 'Content-Type': 'application/x-www-form-urlencoded', 765 'X-Requested-With': 'XMLHttpRequest' 766 }, 767 credentials: 'same-origin' 768 }) 769 .then(function(response) { 770 return response.json(); 771 }) 772 .then(function(data) { 773 if (!data.success) { 774 throw new Error(data.error || 'Unable to clear cart'); 775 } 776 return data; 777 }); 778} 779 780// POST several item ids to the batch remove endpoint (one request, one quote save) 781function postCartRemoveItems(itemIds) { 782 var body = itemIds.map(function(itemId) { 783 return 'item_ids[]=' + encodeURIComponent(itemId); 784 }); 785 body.push('form_key=' + encodeURIComponent(cartDrawerConfig.formKey)); 786 787 return fetch(cartDrawerConfig.removeItemsUrl, { 788 method: 'POST', 789 headers: { 790 'Content-Type': 'application/x-www-form-urlencoded', 791 'X-Requested-With': 'XMLHttpRequest' 792 }, 793 credentials: 'same-origin', 794 body: body.join('&') 795 }) 796 .then(function(response) { 797 return response.json(); 798 }) 799 .then(function(data) { 800 if (!data.success) { 801 throw new Error(data.error || 'Unable to remove items'); 802 } 803 return data; 804 }); 805} 806 807// Clear entire cart via AJAX 808function clearCart() { 809 var cartItems = document.querySelectorAll('[data-cart-item-id]'); 810 if (cartItems.length === 0) { 811 return; 812 } 813 814 var itemCount = cartItems.length; 815 Alpine.store('confirm').show({
816 title: 'Clear Cart', 817 message: 'Are you sure you want to remove all ' + itemCount + ' item' + (itemCount > 1 ? 's' : '') + ' from your cart?', 818 confirmText: 'Clear Cart', 819 cancelText: 'Keep Items', 820 confirmStyle: 'danger', 821 onConfirm: function() { 822 doClearCart(); 823 } 824 }); 825} 826 827// Actual clear cart logic (called after confirmation) 828function doClearCart() { 829 // Get all cart item elements for optimistic UI 830 var cartItems = document.querySelectorAll('[data-cart-item-id]'); 831 832 if (cartItems.length === 0) { 833 return; 834 } 835 836 // GA4 remove_from_cart with all items in a single event 837 if (typeof zGA4 === 'function') { 838 var ga4Items = []; 839 cartItems.forEach(function(el) { 840 try { 841 ga4Items.push(JSON.parse(el.dataset.ga4Item)); 842 } catch (e) {} 843 }); 844 if (ga4Items.length > 0) { 845 zGA4('remove_from_cart', { 846 ecommerce: { 847 items: ga4Items 848 } 849 }); 850 } 851 } 852 853 // Optimistic UI: Fade out and remove all items immediately 854 var itemsHTML = []; 855 cartItems.forEach(function(item, index) { 856 itemsHTML.push(item.outerHTML); 857 item.style.transition = 'opacity 0.2s, transform 0.2s'; 858 item.style.transitionDelay = (index * 0.05) + 's'; 859 item.style.opacity = '0'; 860 item.style.transform = 'translateX(20px)'; 861 }); 862 863 setTimeout(function() { 864 var container = document.getElementById('cart-items-container'); 865 if (container) { 866 container.innerHTML = '\ 867 <div class="text-center py-12">\ 868 <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">\ 869 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />\ 870 </svg>\ 871 <p class="mt-4 text-gray-500">Your cart is empty</p>\ 872 </div>\ 873 '; 874 } 875 }, 200 + (cartItems.length * 50)); 876 877 // Use dedicated clear cart endpoint that properly resets quote totals 878 postCartClear() 879 .then(function() { 880 // Refresh to update totals and badges 881 Alpine.store('cart').refresh(); 882 }) 883 .catch(function(error) { 884 console.error('Error clearing cart:', error); 885 // Restore items on error 886 var container = document.getElementById('cart-items-container'); 887 if (container && itemsHTML.length > 0) { 888 container.innerHTML = '<div class="space-y-4" id="cart-items-list">' + itemsHTML.join('') + '</div>'; 889 } 890 Alpine.store('confirm').show({ 891 title: 'Error', 892 message: 'There was a problem clearing your cart. Please try again.', 893 confirmText: 'OK', 894 cancelText: 'Close', 895 confirmStyle: 'primary' 896 }); 897 }); 898} 899 900 901// Step qty up or down locally without submitting (shows save button) 902function stepCartQty(button, delta) { 903 var container = button.closest('.flex'); 904 var input = container.querySelector('input[data-item-id]'); 905 if (!input) return; 906 var currentVal = parseInt(input.value) || 1; 907 var newVal = currentVal + delta; 908 if (newVal < 1) return; 909 input.value = newVal; 910 toggleQtySave(input); 911} 912 913// Show/hide the qty save button based on whether the input value changed 914function toggleQtySave(input) { 915 var saveBtn = input.closest('.flex').querySelector('.qty-save-btn'); 916 if (!saveBtn) return; 917 var originalQty = parseInt(input.dataset.originalQty); 918 var newVal = parseInt(input.value); 919 if (newVal > 0 && newVal !== originalQty) { 920 saveBtn.style.display = 'inline-flex'; 921 } else { 922 saveBtn.style.display = 'none'; 923 } 924} 925 926// Submit qty from input field (used by Enter key and save button) 927function submitQtyInput(input) { 928 if (!input) return; 929 var v = parseInt(input.value); 930 var originalQty = parseInt(input.dataset.originalQty); 931 if (v > 0 && v !== originalQty) { 932 updateCartItemQty(parseInt(input.dataset.itemId), v); 933 } else if (!v || v < 1) { 934 input.value = originalQty; 935 toggleQtySave(input); 936 } 937} 938 939// Toggle cart item options pills 940function toggleCartOptions(id) { 941 var el = document.getElementById(id); 942 var icon = document.getElementById(id + '-icon'); 943 var text = document.getElementById(id + '-text'); 944 if (!el) return; 945 946 var isHidden = el.style.display === 'none' || el.style.display === '';
947 if (isHidden) { 948 el.style.display = 'flex'; 949 if (icon) icon.style.transform = 'rotate(180deg)'; 950 if (text) text.textContent = 'Hide Options'; 951 setTimeout(function() { 952 el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 953 }, 50); 954 } else { 955 el.style.display = 'none'; 956 if (icon) icon.style.transform = 'rotate(0deg)'; 957 if (text) text.textContent = 'Show Options'; 958 } 959} 960 961// Toggle select all cart items 962function toggleSelectAllCartItems(checked) { 963 var checkboxes = document.querySelectorAll('.cart-item-checkbox'); 964 checkboxes.forEach(function(cb) { 965 cb.checked = checked; 966 }); 967 updateCartSelectionState(); 968} 969 970// Update cart selection state (show/hide delete button, update select all) 971function updateCartSelectionState() { 972 var checkboxes = document.querySelectorAll('.cart-item-checkbox'); 973 var checkedBoxes = document.querySelectorAll('.cart-item-checkbox:checked'); 974 var selectAllCheckbox = document.getElementById('cart-select-all'); 975 var deleteBtn = document.getElementById('cart-delete-selected-btn'); 976 977 // Update select all checkbox state 978 if (selectAllCheckbox) { 979 if (checkboxes.length === 0) { 980 selectAllCheckbox.checked = false; 981 selectAllCheckbox.indeterminate = false; 982 } else if (checkedBoxes.length === 0) { 983 selectAllCheckbox.checked = false; 984 selectAllCheckbox.indeterminate = false; 985 } else if (checkedBoxes.length === checkboxes.length) { 986 selectAllCheckbox.checked = true; 987 selectAllCheckbox.indeterminate = false; 988 } else { 989 selectAllCheckbox.checked = false; 990 selectAllCheckbox.indeterminate = true; 991 } 992 } 993 994 // Show/hide delete button 995 if (deleteBtn) { 996 if (checkedBoxes.length > 0) { 997 deleteBtn.classList.remove('hidden'); 998 deleteBtn.textContent = 'Delete Selected (' + checkedBoxes.length + ')'; 999 } else { 1000 deleteBtn.classList.add('hidden'); 1001 } 1002 } 1003} 1004 1005// Delete selected cart items 1006function deleteSelectedCartItems() { 1007 var checkedBoxes = document.querySelectorAll('.cart-item-checkbox:checked'); 1008 if (checkedBoxes.length === 0) return; 1009 1010 var itemCount = checkedBoxes.length; 1011 Alpine.store('confirm').show({ 1012 title: 'Remove Selected Items', 1013 message: 'Are you sure you want to remove ' + itemCount + ' selected item' + (itemCount > 1 ? 's' : '') + ' from your cart?', 1014 confirmText: 'Remove ' + itemCount + ' Item' + (itemCount > 1 ? 's' : ''), 1015 cancelText: 'Cancel', 1016 confirmStyle: 'danger', 1017 onConfirm: function() { 1018 doDeleteSelectedCartItems(); 1019 } 1020 }); 1021} 1022 1023// Actual delete selected logic (called after confirmation) 1024function doDeleteSelectedCartItems() { 1025 var checkedBoxes = document.querySelectorAll('.cart-item-checkbox:checked'); 1026 if (checkedBoxes.length === 0) return; 1027 1028 // GA4 remove_from_cart with all selected items in a single event 1029 if (typeof zGA4 === 'function') { 1030 var ga4Items = []; 1031 checkedBoxes.forEach(function(cb) { 1032 var itemEl = cb.closest('[data-cart-item-id]'); 1033 if (itemEl) { 1034 try { 1035 ga4Items.push(JSON.parse(itemEl.dataset.ga4Item)); 1036 } catch (e) {} 1037 } 1038 }); 1039 if (ga4Items.length > 0) { 1040 zGA4('remove_from_cart', { 1041 ecommerce: { 1042 items: ga4Items 1043 } 1044 }); 1045 } 1046 } 1047 1048 // Get all selected item IDs 1049 var itemIds = Array.from(checkedBoxes).map(function(cb) { 1050 return cb.dataset.itemId; 1051 }); 1052 1053 // Show loading state 1054 Alpine.store('cart').isRefreshing = true; 1055 1056 var deletePromise; 1057 var totalItems = document.querySelectorAll('.cart-item-checkbox').length; 1058 1059 if (itemIds.length >= totalItems) { 1060 // Everything is selected: one clear-cart request instead of N deletes 1061 deletePromise = postCartClear(); 1062 } else if (cartDrawerConfig.removeItemsUrl) { 1063 // One batch request for the whole selection 1064 deletePromise = postCartRemoveItems(itemIds); 1065 } else { 1066 // Fallback (stale page config): delete items sequentially 1067 deletePromise = Promise.resolve(); 1068 1069 itemIds.forEach(function(itemId) { 1070 deletePromise = deletePromise.then(function() { 1071 return fetch(cartDrawerConfig.deleteUrl, { 1072 method: 'POST', 1073 headers: { 1074 'Content-Type': 'application/x-www-form-urlencoded', 1075 'X-Requested-With': 'XMLHttpRequest' 1076 }, 1077 credentials: 'same-origin',
1078 body: 'id=' + itemId + '&form_key=' + cartDrawerConfig.formKey 1079 }); 1080 }); 1081 }); 1082 } 1083 1084 deletePromise 1085 .then(function() { 1086 Alpine.store('cart').isRefreshing = false; 1087 Alpine.store('cart').refresh(); 1088 }) 1089 .catch(function(error) { 1090 console.error('Error deleting items:', error); 1091 Alpine.store('cart').isRefreshing = false; 1092 Alpine.store('confirm').show({ 1093 title: 'Error', 1094 message: 'There was a problem removing the selected items. Please try again.', 1095 confirmText: 'OK', 1096 cancelText: 'Close', 1097 confirmStyle: 'primary' 1098 }); 1099 }); 1100} 1101 1102// Update cart item quantity via AJAX 1103function updateCartItemQty(itemId, newQty) { 1104 // Validate qty 1105 if (newQty < 1) { 1106 // If qty would be 0 or less, ask to remove instead 1107 removeCartItem(itemId); 1108 return; 1109 } 1110 1111 // Show loading state 1112 Alpine.store('cart').isRefreshing = true; 1113 1114 // Send AJAX request to update qty 1115 fetch(cartDrawerConfig.updatePostUrl, { 1116 method: 'POST', 1117 headers: { 1118 'Content-Type': 'application/x-www-form-urlencoded', 1119 'X-Requested-With': 'XMLHttpRequest' 1120 }, 1121 credentials: 'same-origin', 1122 body: 'cart[' + itemId + '][qty]=' + newQty + '&update_cart_action=update_qty&form_key=' + cartDrawerConfig.formKey 1123 }) 1124 .then(function(response) { 1125 return response.text(); 1126 }) 1127 .then(function(html) { 1128 // Reset flag before calling refresh so it doesn't skip 1129 Alpine.store('cart').isRefreshing = false; 1130 // Refresh cart to get updated data 1131 Alpine.store('cart').refresh(); 1132 }) 1133 .catch(function(error) { 1134 console.error('Error updating quantity:', error); 1135 Alpine.store('cart').isRefreshing = false; 1136 alert('Error updating quantity. Please try again.'); 1137 }); 1138} 1139 1140// Add expedited shipping product to cart via AJAX 1141function addExpeditedToCart() { 1142 var expeditedProduct = Alpine.store('cart').expeditedProduct; 1143 if (!expeditedProduct || !expeditedProduct.add_url) { 1144 console.error('No expedited product configured'); 1145 return; 1146 } 1147 1148 // Scroll to newly added item after refresh 1149 Alpine.store('cart')._scrollToNew = true; 1150 1151 // Show loading state 1152 Alpine.store('cart').isRefreshing = true; 1153 1154 // Extract product ID and form key from the add URL 1155 // The add URL format is: /checkout/cart/add/product/{id}/... 1156 fetch(expeditedProduct.add_url, { 1157 method: 'GET', 1158 credentials: 'same-origin', 1159 headers: { 1160 'X-Requested-With': 'XMLHttpRequest' 1161 } 1162 }) 1163 .then(function(response) { 1164 // The add to cart will redirect, but we just need to refresh the cart 1165 Alpine.store('cart').isRefreshing = false; 1166 Alpine.store('cart').refresh(); 1167 if (response.ok) { 1168 // Show the expedited confirmation modal 1169 window.dispatchEvent(new CustomEvent('expedited-added')); 1170 } 1171 }) 1172 .catch(function(error) { 1173 console.error('Error adding expedited product:', error); 1174 Alpine.store('cart').isRefreshing = false; 1175 // Still refresh in case it was added 1176 Alpine.store('cart').refresh(); 1177 }); 1178}
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.