// ============================================
// 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!');
