PageSourceSearch

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

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

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.