PageSourceSearch

https://cdn.simplystamps.com/skin/frontend/zephyr/default/js/components/cart-drawer.js?v=1114

js simplystamps.com collected 2026-09-24 20:30:17 UTC 55,601 bytes, 1,178 lines download raw bytes

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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, '&lt;').replace(/>/g, '&gt;');
323                            var escapedValue = opt.value.replace(/</g, '&lt;').replace(/>/g, '&gt;');
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, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
422                    var htmlEscapedFullsize = item.fullsize_url.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
423                    var htmlEscapedImage = item.image_url.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
424                    var htmlEscapedProductUrl = item.product_url ? item.product_url.replace(/&/g, '&amp;').replace(/"/g, '&quot;') : '';
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, '&#39;').replace(/"/g, '&quot;');
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.