// ============================================ // CART COMPONENTS - Full updated version // Replace your whole cart JS file with this. // ============================================ function debounce(fn, delay) { let timeoutId = null; return function (...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => { fn.apply(this, args); }, delay); }; } function subscribe(event, callback) { document.addEventListener(event, callback); return () => document.removeEventListener(event, callback); } function publish(event, data) { document.dispatchEvent(new CustomEvent(event, { detail: data })); } function fetchConfig() { return { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json', }, credentials: 'same-origin', }; } function trapFocus(container, elementToFocus) { if (!container) return; const focusableElements = container.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); if (focusableElements.length === 0) return; const firstFocusable = focusableElements[0]; const lastFocusable = focusableElements[focusableElements.length - 1]; if (elementToFocus) { elementToFocus.focus(); } else if (firstFocusable) { firstFocusable.focus(); } container.removeEventListener('keydown', trapFocusHandler); function trapFocusHandler(e) { if (e.key === 'Tab') { if (e.shiftKey) { if (document.activeElement === firstFocusable) { e.preventDefault(); lastFocusable.focus(); } } else { if (document.activeElement === lastFocusable) { e.preventDefault(); firstFocusable.focus(); } } } } container.addEventListener('keydown', trapFocusHandler); } // ============================================ // HELPERS // ============================================ // Shopify's /cart/change needs a 1-BASED line number. // If your markup uses data-index="0" for the first item, we add 1 automatically. function getCartLineOffset() { const nodes = document.querySelectorAll( 'quantity-input input[data-index], cart-remove-button[data-index], cart-items input[data-index], cart-drawer-items input[data-index]' ); let min = Infinity; nodes.forEach((node) => { const value = parseInt(node.dataset.index); if (!isNaN(value) && value < min) min = value; }); return min === 0 ? 1 : 0; } function showCartMessage(message) { const errors = document.getElementById('cart-errors') || document.getElementById('CartDrawer-CartErrors'); if (errors) { errors.textContent = message || ''; return; } if (!message) return; const toast = document.createElement('div'); toast.style.cssText = ` position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: #f44336; color: white; padding: 15px 25px; border-radius: 8px; z-index: 99999; font-weight: bold; box-shadow: 0 4px 12px rgba(0,0,0,0.15); `; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => toast.remove(), 3500); } let cartRefreshPromise = null; // ============================================ // CART REMOVE BUTTON // ============================================ class CartRemoveButton extends HTMLElement { constructor() { super(); this.addEventListener('click', (event) => { event.preventDefault(); const cartItems = this.closest('cart-items') || this.closest('cart-drawer-items'); const index = this.dataset.index; if (cartItems && typeof cartItems.updateQuantity === 'function' && index !== undefined) { cartItems.updateQuantity(index, 0); } else { console.error('CartRemoveButton: cart-items component or index not found'); this.fallbackRemove(); } }); } fallbackRemove() { const index = parseInt(this.dataset.index); if (isNaN(index)) { showCartMessage('Could not remove item. Please refresh and try again.'); return; } const body = JSON.stringify({ line: index + getCartLineOffset(), quantity: 0, }); const cartChangeUrl = window.routes?.cart_change_url || '/cart/change'; fetch(cartChangeUrl, { ...fetchConfig(), body }) .then((response) => response.json()) .then((data) => { if (data.errors || data.description) { showCartMessage('Error: ' + (data.description || data.errors)); } else { location.reload(); } }) .catch((error) => { console.error('Fallback remove error:', error); showCartMessage('Error removing item. Please try again.'); }); } } if (!customElements.get('cart-remove-button')) { customElements.define('cart-remove-button', CartRemoveButton); } // ============================================ // CART ITEMS // ============================================ class CartItems extends HTMLElement { constructor() { super(); this.lineItemStatusElement = document.getElementById('shopping-cart-line-item-status') || document.getElementById('CartDrawer-LineItemStatus'); if (!this.lineItemStatusElement) { this.lineItemStatusElement = document.createElement('div'); this.lineItemStatusElement.id = 'shopping-cart-line-item-status'; this.lineItemStatusElement.setAttribute('aria-hidden', 'true'); this.lineItemStatusElement.style.display = 'none'; document.body.appendChild(this.lineItemStatusElement); } const delay = typeof ON_CHANGE_DEBOUNCE_TIMER !== 'undefined' ? ON_CHANGE_DEBOUNCE_TIMER : 300; this.debouncedOnChange = debounce((event) => { this.onChange(event); }, delay); this.addEventListener('change', this.debouncedOnChange.bind(this)); } cartUpdateUnsubscriber = undefined; isUpdating = false; connectedCallback() { const events = typeof PUB_SUB_EVENTS !== 'undefined' ? PUB_SUB_EVENTS : { cartUpdate: 'cart-update' }; this.cartUpdateUnsubscriber = subscribe(events.cartUpdate, (event) => { if (event.detail && event.detail.source === 'cart-items') return; this.onCartUpdate(); }); } disconnectedCallback() { if (this.cartUpdateUnsubscriber) this.cartUpdateUnsubscriber(); } // ---------- inputs ---------- getQuantityInput(line) { return ( document.getElementById(`Drawer-quantity-${line}`) || document.getElementById(`Quantity-${line}`) || document.querySelector(`quantity-input input[data-index="${line}"]`) ); } resetQuantityInput(line) { const input = this.getQuantityInput(line); if (input) { input.value = input.getAttribute('value') || input.defaultValue || ''; } } setValidity(event, index, message) { event.target.setCustomValidity(message); event.target.reportValidity(); this.resetQuantityInput(index); event.target.select(); } validateQuantity(event) { const input = event.target; const inputValue = parseInt(input.value); const index = input.dataset.index; const min = parseInt(input.dataset.min); const max = parseInt(input.max); const step = parseInt(input.step); const strings = window.quickOrderListStrings || {}; let message = ''; if (isNaN(inputValue)) { message = 'Please enter a valid number'; } else if (!isNaN(min) && inputValue < min) { message = (strings.min_error || 'Minimum quantity is {min}').replace('{min}', min); } else if (!isNaN(max) && inputValue > max) { message = (strings.max_error || 'Maximum quantity is {max}').replace('{max}', max); } else if (!isNaN(step) && step > 0 && inputValue % step !== 0) { message = (strings.step_error || 'Quantity must be in multiples of {step}').replace( '{step}', step ); } if (message) { this.setValidity(event, index, message); } else { input.setCustomValidity(''); input.reportValidity(); this.updateQuantity( index, inputValue, document.activeElement?.getAttribute('name') || '', input.dataset.quantityVariantId ); } } onChange(event) { // Ignore change events that are not from a quantity input (gift note, etc.) if (!event.target || event.target.dataset.index === undefined) return; this.validateQuantity(event); } // ---------- UI refresh ---------- // Re-renders drawer / cart page from the server. Works even with custom // drawers because it does not depend on section names from /cart/change. async refreshCartUI() { if (cartRefreshPromise) return cartRefreshPromise; cartRefreshPromise = (async () => { let updated = false; const swap = (doc, selectors) => { for (const selector of selectors) { const target = document.querySelector(selector); const source = doc.querySelector(selector); if (target && source) { target.innerHTML = source.innerHTML; return true; } } return false; }; const groups = [ ['#CartDrawer', 'cart-drawer'], ['#main-cart-items .js-contents', '#main-cart-items'], ['#main-cart-footer .js-contents', '#main-cart-footer'], ]; // 1) Re-fetch the current page and swap the cart parts try { const res = await fetch(window.location.href, { credentials: 'same-origin', cache: 'no-store', }); if (res.ok) { const doc = new DOMParser().parseFromString(await res.text(), 'text/html'); groups.forEach((selectors) => { if (swap(doc, selectors)) updated = true; }); swap(doc, ['#cart-icon-bubble']); } } catch (e) { console.warn('Page refresh failed:', e); } // 2) Fallback: Section Rendering API if (!updated) { const cartUrl = window.routes?.cart_url || '/cart'; const sectionIds = [ document.getElementById('main-cart-items')?.dataset.id, document.getElementById('main-cart-footer')?.dataset.id, 'cart-drawer', ].filter(Boolean); for (const id of sectionIds) { try { const res = await fetch(`${cartUrl}?section_id=${encodeURIComponent(id)}`, { credentials: 'same-origin', cache: 'no-store', }); if (!res.ok) continue; const doc = new DOMParser().parseFromString(await res.text(), 'text/html'); groups.forEach((selectors) => { if (swap(doc, selectors)) updated = true; }); } catch (e) { console.warn('Section refresh failed:', id, e); } } } return updated; })(); try { return await cartRefreshPromise; } finally { cartRefreshPromise = null; } } onCartUpdate() { this.refreshCartUI().catch((e) => console.error('Error updating cart:', e)); } // Kept for compatibility with other theme files that may call it getSectionsToRender() { const sections = []; const mainCartItems = document.getElementById('main-cart-items'); if (mainCartItems?.dataset.id) { sections.push({ id: 'main-cart-items', section: mainCartItems.dataset.id, selector: '.js-contents', }); } const mainCartFooter = document.getElementById('main-cart-footer'); if (mainCartFooter?.dataset.id) { sections.push({ id: 'main-cart-footer', section: mainCartFooter.dataset.id, selector: '.js-contents', }); } return sections; } // ---------- main action ---------- async updateQuantity(line, quantity, name, variantId) { if (this.isUpdating) return; const uiLine = parseInt(line); // used for element ids in the DOM const quantityNum = parseInt(quantity); if (isNaN(uiLine) || isNaN(quantityNum)) { console.error('Invalid line or quantity:', line, quantity); return; } const shopifyLine = uiLine + getCartLineOffset(); // 1-based for Shopify const cartChangeUrl = window.routes?.cart_change_url || '/cart/change'; this.isUpdating = true; showCartMessage(''); this.enableLoading(uiLine); try { const response = await fetch(cartChangeUrl, { ...fetchConfig(), body: JSON.stringify({ line: shopifyLine, quantity: quantityNum }), }); const text = await response.text(); let state; try { state = JSON.parse(text); } catch (e) { throw new Error(`Invalid JSON response from server (status ${response.status})`); } // Shopify error shape: { status: 422, message: "...", description: "..." } if (!response.ok || state.errors) { const err = new Error( state.description || state.message || `HTTP error ${response.status}` ); err.userMessage = state.description || (typeof state.errors === 'string' ? state.errors : '') || state.message; throw err; } // Did Shopify adjust the quantity (stock limit)? const finalQuantity = state.items?.[shopifyLine - 1]?.quantity; let adjustedMessage = ''; if (quantityNum > 0 && finalQuantity !== undefined && finalQuantity !== quantityNum) { adjustedMessage = ( window.cartStrings?.quantityError || 'Only {quantity} available. Quantity updated.' ).replace('{quantity}', finalQuantity); } const isEmpty = state.item_count === 0; this.classList.toggle('is-empty', isEmpty); document.querySelector('cart-drawer')?.classList.toggle('is-empty', isEmpty); document.getElementById('main-cart-footer')?.classList.toggle('is-empty', isEmpty); // Update the UI on the spot const refreshed = await this.refreshCartUI(); if (!refreshed) { console.warn('Could not re-render cart sections, reloading page.'); location.reload(); return; } if (adjustedMessage) showCartMessage(adjustedMessage); this.updateLiveRegions(uiLine, adjustedMessage); this.handleFocus(uiLine, name, state); const events = typeof PUB_SUB_EVENTS !== 'undefined' ? PUB_SUB_EVENTS : { cartUpdate: 'cart-update' }; publish(events.cartUpdate, { source: 'cart-items', cartData: state, variantId: variantId, }); } catch (error) { console.error('Cart update error:', error); this.resetQuantityInput(uiLine); showCartMessage( error.userMessage || window.cartStrings?.error || 'There was an error while updating your cart. Please try again.' ); } finally { this.isUpdating = false; this.disableLoading(uiLine); } } // ---------- accessibility / loading ---------- handleFocus(line, name, parsedState) { const lineItem = document.getElementById(`CartItem-${line}`) || document.getElementById(`CartDrawer-Item-${line}`); const cartDrawerWrapper = document.querySelector('cart-drawer'); if (lineItem && name && lineItem.querySelector(`[name="${name}"]`)) { if (cartDrawerWrapper) { trapFocus(cartDrawerWrapper, lineItem.querySelector(`[name="${name}"]`)); } else { lineItem.querySelector(`[name="${name}"]`).focus(); } } else if (parsedState.item_count === 0) { if (cartDrawerWrapper) { const emptyElement = cartDrawerWrapper.querySelector('.drawer__inner-empty'); const linkElement = cartDrawerWrapper.querySelector('a'); if (emptyElement) trapFocus(emptyElement, linkElement); } } else if (document.querySelector('.cart-item') && cartDrawerWrapper) { trapFocus(cartDrawerWrapper, document.querySelector('.cart-item__name')); } } updateLiveRegions(line, message) { const lineItemError = document.getElementById(`Line-item-error-${line}`) || document.getElementById(`CartDrawer-LineItemError-${line}`); if (lineItemError) { const errorText = lineItemError.querySelector('.cart-item__error-text'); if (errorText) errorText.textContent = message || ''; } if (this.lineItemStatusElement) { this.lineItemStatusElement.setAttribute('aria-hidden', 'true'); } const cartStatus = document.getElementById('cart-live-region-text') || document.getElementById('CartDrawer-LiveRegionText'); if (cartStatus) { cartStatus.setAttribute('aria-hidden', 'false'); if (message) cartStatus.textContent = message; setTimeout(() => cartStatus.setAttribute('aria-hidden', 'true'), 1000); } } getSectionInnerHTML(html, selector) { try { const parsed = new DOMParser().parseFromString(html, 'text/html'); const element = parsed.querySelector(selector); return element ? element.innerHTML : ''; } catch (e) { console.error('Error parsing section HTML:', e); return ''; } } enableLoading(line) { const mainCartItems = document.getElementById('main-cart-items') || document.getElementById('CartDrawer-CartItems'); if (mainCartItems) mainCartItems.classList.add('cart__items--disabled'); document .querySelectorAll(`#CartItem-${line} .loading__spinner, #CartDrawer-Item-${line} .loading__spinner`) .forEach((overlay) => overlay.classList.remove('hidden')); if (document.activeElement) document.activeElement.blur(); if (this.lineItemStatusElement) this.lineItemStatusElement.setAttribute('aria-hidden', 'false'); } disableLoading() { document .querySelectorAll('.cart__items--disabled') .forEach((el) => el.classList.remove('cart__items--disabled')); document .querySelectorAll('cart-items .loading__spinner, cart-drawer-items .loading__spinner') .forEach((overlay) => overlay.classList.add('hidden')); } } if (!customElements.get('cart-items')) { customElements.define('cart-items', CartItems); } // Some themes use for the drawer. Register it if nobody else did. if (!customElements.get('cart-drawer-items')) { customElements.define('cart-drawer-items', class extends CartItems {}); } // ============================================ // CART NOTE // ============================================ if (!customElements.get('cart-note')) { customElements.define( 'cart-note', class CartNote extends HTMLElement { constructor() { super(); const delay = typeof ON_CHANGE_DEBOUNCE_TIMER !== 'undefined' ? ON_CHANGE_DEBOUNCE_TIMER : 300; this.addEventListener( 'input', debounce((event) => { const body = JSON.stringify({ note: event.target.value }); const cartUpdateUrl = window.routes?.cart_update_url || '/cart/update'; fetch(cartUpdateUrl, { ...fetchConfig(), body }) .then((response) => { if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); return response.json(); }) .then((data) => { if (data.errors) console.error('Cart note update error:', data.errors); }) .catch((error) => console.error('Error updating cart note:', error)); }, delay) ); } } ); } console.log('✅ Cart components loaded successfully!');