/**
|
* CartCheckout — Provider-agnostic cart & checkout base class
|
*
|
* Handles: cart add/remove/clear, quantity management, totals,
|
* saved cards UI, order tracking, localStorage persistence.
|
*
|
* Subclasses must implement:
|
* - init() → initialize the payment provider SDK
|
* - processPayment() → handle payment with provider
|
* - submitToServer() → send payment data to WP REST endpoint
|
* - loadSavedCards() → fetch saved cards from provider
|
*
|
* HTML structure expected: see Checkout.php (JVBase\ui\Checkout)
|
*/
|
class Checkout {
|
constructor(config = {}) {
|
this.config = config;
|
|
this.isInitialized = false;
|
this.cartItems = new Map();
|
this.checkout = document.querySelector('aside#cart');
|
this.provider = this.checkout?.querySelector('form')?.dataset.provider || '';
|
|
this.isOpen = this.config.isOpen === '1' || false;
|
this.isOpen = true;
|
|
this.isLoggedIn = this.config.is_logged_in || false;
|
this.userEmail = this.config.user_email || '';
|
this.savedCards = [];
|
this.selectedCardId = null;
|
this.cartId = null;
|
this.stepMultiplier = 1;
|
|
this.cache = new window.jvbCache('cart', { TTL: 8.64e+7 });
|
this.a11y = window.jvbA11y;
|
this.T = window.jvbTemplates;
|
|
this.initCart();
|
|
if (this.checkout) {
|
this.initElements();
|
this.init(); // provider-specific
|
this.initListeners();
|
this.defineTemplates();
|
|
if (this.isLoggedIn) {
|
this.loadSavedCards();
|
}
|
}
|
|
if (!this.isOpen)
|
{
|
this.closeShop();
|
}
|
|
this.popup = window.jvbPopup.registerPopup({
|
popup: this.checkout,
|
toggle: this.ui.toggle.btn,
|
name: 'Cart',
|
onOpen: this.maybeAddEmptyState.bind(this),
|
onClose: this.toggleButton.bind(this),
|
});
|
}
|
|
closeShop() {
|
document.querySelectorAll('[data-add-to-cart]').forEach(item => {
|
item.querySelectorAll('button, input').forEach(i => {
|
i.disabled = true;
|
i.title = 'Sorry, we are currently closed.';
|
});
|
|
});
|
}
|
|
|
/*****************************************************************
|
* ABSTRACT — subclasses must implement
|
*****************************************************************/
|
|
/** Initialize provider SDK (Square payments, Helcim iframe, etc.) */
|
async init() {
|
throw new Error('init() must be implemented by subclass');
|
}
|
|
/** Process payment through provider and return result */
|
async processPayment(orderData) {
|
throw new Error('processPayment() must be implemented by subclass');
|
}
|
|
/** Submit payment token/result to server */
|
async submitToServer(tokenOrData, orderData) {
|
throw new Error('submitToServer() must be implemented by subclass');
|
}
|
|
/** Load saved cards from provider */
|
async loadSavedCards() {
|
// Default no-op; override if provider supports saved cards
|
}
|
|
/*****************************************************************
|
* CART PERSISTENCE
|
*****************************************************************/
|
|
async initCart() {
|
let stored = await this.cache.get('cart') ?? new Map();
|
if (stored.size > 0) {
|
this.cartItems = stored;
|
this.updateToggle();
|
this.notifyRestoredCart();
|
}
|
}
|
|
saveCart() {
|
this.updateTotal();
|
this.updateToggle();
|
this.cache.set('cart', this.cartItems);
|
}
|
|
clearCart() {
|
for (let [id, item] of this.cartItems.entries()) {
|
this.removeFromCart(id);
|
}
|
this.cartItems.clear();
|
Array.from(this.ui.cart.table.children).forEach(child => {
|
if (child.tagName === 'TBODY') {
|
child.remove();
|
}
|
});
|
this.saveCart();
|
}
|
|
getCartId() {
|
if (!this.cartId) {
|
this.cartId = crypto.randomUUID();
|
this.cache.set('cart_id', this.cartId);
|
}
|
return this.cartId;
|
}
|
|
/*****************************************************************
|
* ELEMENT SETUP
|
*****************************************************************/
|
defineTemplates() {
|
// const checkout = this;
|
this.T.define('mainCartItem', {
|
refs: {
|
header: '.header th'
|
},
|
setup({el, refs, manyRefs, data}) {
|
refs.header.textContent = data.label;
|
el.dataset.label = data.label;
|
el.dataset.id = data.parentId;
|
}
|
});
|
|
this.T.define('childCartItem', {
|
refs: {
|
quantity: '.quantity',
|
label: 'label',
|
price: 'span.price',
|
button: '[data-remove-from-cart]'
|
},
|
manyRefs: {},
|
setup({el, refs, manyRefs, data}) {
|
[
|
refs.quantity.dataset.id,
|
refs.quantity.dataset.price,
|
refs.quantity.dataset.catalogId,
|
refs.label.textContent,
|
refs.price.textContent,
|
refs.button.dataset.id
|
] = [
|
data.id,
|
data.price,
|
data.catalogId || '',
|
data.name,
|
window.formatPrice(data.price),
|
data.id
|
];
|
}
|
});
|
this.T.define('emptyCart');
|
}
|
initElements() {
|
this.restoreElement = false;
|
this.selectors = {
|
toggle: {
|
btn: '.toggle-cart',
|
count: '.toggle-cart .count',
|
icon: '.toggle-cart .icon',
|
},
|
panels: {
|
checkout: {
|
toggle: 'button[data-tab="checkout"]',
|
form: '#cart form'
|
}
|
},
|
cart: {
|
wrap: '#cart .cart-items',
|
empty: '#cart .cart-items .empty',
|
table: '#cart .cart-items table',
|
body: '#cart .cart-items tbody',
|
total: '#cart .cart-total',
|
foot: {
|
foot: '#cart tfoot',
|
total: '#cart tfoot .total',
|
tax: '#cart tfoot .tax',
|
},
|
grandTotal: '#cart .total span',
|
tax: '#cart .cart-total .tax span',
|
}
|
};
|
this.ui = window.uiFromSelectors(this.selectors);
|
this.ui.cart.items = new Map();
|
|
if (!this.isOpen) {
|
this.ui.toggle.btn.disabled = true;
|
this.ui.toggle.btn.title = 'Currently closed for online ordering';
|
}
|
|
this.checkoutForm = this.checkout.querySelector('form');
|
|
this.tabs = window.jvbTabs.registerTab(this.checkoutForm, {
|
updateURL: false
|
});
|
}
|
|
initListeners() {
|
this.clickHandler = this.handleClick.bind(this);
|
this.keyHandler = this.handleEscape.bind(this);
|
this.changeHandler = this.handleChange.bind(this);
|
|
this.checkoutForm.addEventListener('submit', (e) => this.handleFormSubmit(e));
|
document.addEventListener('click', this.clickHandler);
|
document.addEventListener('change', this.changeHandler);
|
// document.addEventListener('keydown', this.keyHandler);
|
}
|
|
/*****************************************************************
|
* EVENT HANDLERS
|
*****************************************************************/
|
|
handleClick(e) {
|
if (window.targetCheck(e, 'button') && window.targetCheck(e, 'div.quantity')) {
|
let quantity = window.targetCheck(e, 'div.quantity');
|
this.handleNumberClick(e, quantity);
|
} else if (window.targetCheck(e, '[data-add-to-cart]') && window.targetCheck(e, 'button')) {
|
let add = window.targetCheck(e, '[data-add-to-cart]');
|
this.handleAddToCart(add);
|
} else if (window.targetCheck(e, '[data-remove-from-cart]') ) {
|
let remove = window.targetCheck(e, '[data-remove-from-cart]');
|
this.handleRemoveFromCart(remove);
|
} else if (window.targetCheck(e, '[data-clear-cart]')) {
|
this.clearCart();
|
document.querySelector('.restored')?.remove();
|
} else if (window.targetCheck(e, '[data-dismiss]')) {
|
window.targetCheck(e, '[data-dismiss]').closest('.restored')?.remove();
|
}
|
}
|
|
handleChange(e) {
|
let input = window.targetCheck(e, '.quantity-input');
|
if (input) {
|
let item = e.target.closest('.quantity');
|
let value = e.target.value;
|
|
if (value > 0) {
|
this.handleAddToCart(item);
|
} else {
|
this.handleRemoveFromCart(item);
|
}
|
}
|
}
|
|
handleEscape(e) {
|
if (e.key === 'Escape') {
|
if (this.restoreElement) {
|
this.restoreElement.classList.remove('show');
|
this.restoreElement = false;
|
}
|
this.stepMultiplier = 1;
|
} else if (e.ctrlKey && e.shiftKey) {
|
this.stepMultiplier = Math.max(parseInt(this.stepMultiplier) * 100, 1000);
|
} else if (e.shiftKey) {
|
this.stepMultiplier = Math.max(parseInt(this.stepMultiplier) * 10, 1000);
|
}
|
}
|
|
/*****************************************************************
|
* CART ITEM MANAGEMENT
|
*****************************************************************/
|
|
handleAddToCart(item) {
|
let id = item.dataset.id;
|
let price = parseFloat(item.dataset.price);
|
let quantity = parseInt(item.querySelector('.quantity input')?.value) ?? 1;
|
let total = parseFloat(price * quantity);
|
|
this.createItemElement(item);
|
|
let parent = this.determineParent(item);
|
|
let stored = {};
|
if (this.cartItems.has(id)) {
|
stored = this.cartItems.get(id);
|
}
|
let newValue = {
|
post_id: id,
|
name: stored.name ?? item.dataset.name,
|
label: stored.label ?? parent.dataset.label ??'',
|
parentId: stored.parentId ?? parent.dataset.id ??'',
|
price: price,
|
quantity: quantity,
|
total: total,
|
catalog_id: stored.catalogId ?? item.dataset.catalogId ?? '',
|
};
|
this.cartItems.set(id, newValue);
|
this.saveCart();
|
}
|
|
handleRemoveFromCart(item) {
|
console.log(item);
|
if (confirm('This will remove this item from the cart. Continue?')) {
|
let id = item.dataset.id??item.id??false;
|
if (!id) {
|
console.error('No ID found..');
|
}
|
this.removeFromCart(id);
|
|
this.maybeAddEmptyState();
|
this.saveCart();
|
}
|
}
|
removeFromCart(id) {
|
let parent = this.findParentItem(id);
|
if (parent) {
|
let el = parent.items.get(id);
|
el.el.remove();
|
parent.items.delete(id);
|
if (el.reference){
|
el.reference.value = 0;
|
}
|
if (parent.items.size === 0) {
|
console.log('Removing parent item');
|
parent.el.remove();
|
this.ui.cart.items.delete(parent.id);
|
}
|
}
|
|
this.cartItems.delete(id);
|
}
|
|
handleNumberClick(e, container) {
|
e.preventDefault();
|
let change = 0;
|
if (e.target.closest('.increase')) change += 1;
|
else if (e.target.closest('.decrease')) change -= 1;
|
|
if (change !== 0) {
|
let input = container.querySelector('input');
|
let step = parseInt(input.step);
|
let value = (input.value === '') ? 0 : parseInt(input.value);
|
if (value === 0 && change < 0) return;
|
|
let increaseBy = step * change * this.stepMultiplier;
|
|
let newValue = value + increaseBy;
|
input.value = Math.max(newValue, 0);
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
this.handleNumberLimits(container);
|
}
|
}
|
|
handleNumberLimits(container) {
|
let min = container.dataset.min;
|
let max = container.dataset.max;
|
let input = container.querySelector('input');
|
let increase = container.querySelector('.increase');
|
let decrease = container.querySelector('.decrease');
|
let value = parseInt(input.value);
|
|
if (min !== undefined && value <= min) {
|
console.log('Resetting to minimum value');
|
input.value = min;
|
decrease.disabled = true;
|
} else if (max !== undefined && value >= max) {
|
console.log('Resetting to maximum value');
|
input.value = max;
|
increase.disabled = true;
|
} else {
|
increase.disabled = false;
|
decrease.disabled = false;
|
}
|
}
|
|
/*****************************************************************
|
* ITEM ELEMENT CREATION
|
*****************************************************************/
|
|
createItemElement(item) {
|
let element = this.maybeCreateCartItem(item);
|
|
let quantity = item.tagName === undefined ?
|
item.quantity :
|
item.querySelector('input').value ?? 1;
|
let price = item.tagName === undefined ?
|
item.price :
|
item.dataset.price;
|
|
element.quantity.value = quantity;
|
element.total.textContent = window.formatPrice(quantity * price);
|
if (element.reference) {
|
element.reference.value = quantity;
|
}
|
}
|
maybeCreateCartItem(item) {
|
let normalized = {};
|
if (item.tagName === undefined) {
|
//We're getting it from the cached cart
|
normalized = {
|
id: String(item.post_id),
|
name: item.name,
|
price: item.price,
|
catalogId: item.catalog_id || '',
|
quantity: item.quantity,
|
label: item.label,
|
parentId: String(item.parentId)
|
};
|
} else if (item.closest('#cart')) {
|
// quantity field clicked from inside the cart itself — item already exists
|
let id = item.dataset.id;
|
let parent = this.findParentItem(id);
|
if (parent) {
|
return parent.items.get(id);
|
}
|
// fallback: shouldn't normally happen, but avoid crashing
|
console.warn('Cart quantity changed but no existing row found for id:', id);
|
}else {
|
//We're getting it direct from the quantity field
|
normalized = {
|
id: item.dataset.id,
|
name: item.dataset.name,
|
price: item.dataset.price,
|
catalogId: item.dataset.catalogId || '',
|
}
|
let parent = this.determineParent(item);
|
normalized.parentId = parent.dataset.id??parent.id;
|
normalized.label = parent.dataset.label??parent.label;
|
}
|
|
if (!this.ui.cart.items.has(normalized.parentId)) {
|
let parentEl = this.T.create('mainCartItem', normalized);
|
this.ui.cart.items.set(normalized.parentId, {
|
el: parentEl,
|
id: normalized.parentId,
|
label: normalized.label,
|
items: new Map()
|
});
|
this.ui.cart.table.insertBefore(parentEl, this.ui.cart.foot.foot);
|
}
|
|
let parent = this.ui.cart.items.get(normalized.parentId);
|
if (!parent.items.has(normalized.id)) {
|
let el = this.T.create('childCartItem', normalized);
|
parent.items.set(normalized.id, {
|
el: el,
|
quantity: el.querySelector('[name="quantity"]'),
|
total: el.querySelector('.total'),
|
reference: document.querySelector(`.menu-items [data-id="${normalized.id}"] input`),
|
});
|
parent.el.append(el);
|
}
|
|
return parent.items.get(normalized.id);
|
}
|
determineParent(item) {
|
if (item.tagName !== undefined) {
|
return this.parentFromHTML(item);
|
}
|
}
|
parentFromHTML(item) {
|
let parent = item.closest('.menu-item');
|
if (parent) return parent;
|
let id = item.dataset.id;
|
return this.findParentItem(id).el??false;
|
}
|
findParentItem(searchId) {
|
let parentItem = false;
|
for (let [id, data] of this.ui.cart.items.entries()) {
|
if (data.items.has(searchId)) {
|
parentItem = this.ui.cart.items.get(id);
|
}
|
}
|
return parentItem;
|
}
|
|
|
/*****************************************************************
|
* CART STATE
|
*****************************************************************/
|
toggleButton(open = false)
|
{
|
this.ui.toggle.icon.hidden = open;
|
}
|
maybeAddEmptyState() {
|
this.toggleButton(true);
|
this.findEmpty();
|
|
if (this.cartItems.size === 0) {
|
this.ui.panels.checkout.toggle.disabled = true;
|
this.ui.panels.checkout.toggle.title = 'Add some things to your cart first!';
|
this.ui.cart.empty.hidden = false;
|
this.ui.cart.table.hidden = true;
|
this.ui.cart.total.hidden = true;
|
this.a11y.announce('Nothing in Cart');
|
} else {
|
this.ui.cart.empty.hidden = true;
|
this.ui.panels.checkout.toggle.disabled = false;
|
this.ui.cart.table.hidden = false;
|
this.ui.cart.total.hidden = false;
|
this.ui.panels.checkout.toggle.title = 'Checkout';
|
}
|
}
|
findEmpty() {
|
if (!this.ui.cart.empty) {
|
this.ui.cart.empty = this.T.create('emptyCart');
|
this.ui.cart.wrap.append(this.ui.cart.empty);
|
}
|
}
|
|
notifyRestoredCart() {
|
let restored = window.getTemplate('restoredCart');
|
document.body.append(restored);
|
restored.classList.toggle('show');
|
this.restoreElement = restored;
|
|
this.cartItems.forEach(item => {
|
this.createItemElement(item);
|
});
|
this.updateTotal();
|
}
|
|
updateToggle() {
|
if (this.cartItems.size > 0 && this.ui.toggle.btn.classList.contains('empty')) {
|
this.ui.toggle.btn.classList.remove('empty');
|
}
|
if (this.cartItems.size === 0 && !this.ui.toggle.btn.classList.contains('empty')) {
|
this.ui.toggle.btn.classList.add('empty');
|
}
|
let total = 0;
|
for (let [id, item] of this.cartItems.entries()) {
|
total += item.quantity;
|
}
|
|
this.ui.toggle.count.textContent = this.cartItems.size === 0 ? '' : total;
|
}
|
|
/*****************************************************************
|
* TOTALS
|
*****************************************************************/
|
|
updateTotal() {
|
let total = 0;
|
this.cartItems.forEach(item => total += item.total);
|
let tax = total * 0.05;
|
window.eraseText(this.ui.cart.tax);
|
window.eraseText(this.ui.cart.foot.tax);
|
window.eraseText(this.ui.cart.foot.total);
|
window.typeText(this.ui.cart.foot.tax, window.formatPrice(tax));
|
window.typeText(this.ui.cart.grandTotal, window.formatPrice(total + tax));
|
window.typeText(this.ui.cart.foot.total, window.formatPrice(total + tax));
|
this.ui.cart.tax.classList.remove('typeText');
|
}
|
|
/*****************************************************************
|
* FORM SUBMIT
|
*****************************************************************/
|
|
extractOrderData(form) {
|
const items = Array.from(this.cartItems.values()).map(item => ({
|
catalog_id: item.catalog_id,
|
quantity: String(item.quantity),
|
price: item.price,
|
note: item.note || '',
|
}));
|
|
const total = items.reduce((sum, item) =>
|
sum + (item.price * item.quantity), 0
|
);
|
|
return {
|
total: Math.round(total * 100),
|
items: items,
|
customer: {
|
email: this.isLoggedIn ? this.userEmail : (form.querySelector('[name="cart_email"]')?.value || ''),
|
name: form.querySelector('[name="cart_name"]')?.value || '',
|
phone: form.querySelector('[name="cart_phone"]')?.value || '',
|
},
|
note: form.querySelector('[name="special_instructions"]')?.value || '',
|
pickup_time: form.querySelector('[name="pickup_time"]')?.value || '',
|
};
|
}
|
|
async handleFormSubmit(event) {
|
if (!this.isOpen) return;
|
event.preventDefault();
|
|
if (!this.isInitialized) {
|
this.handleError('Checkout not initialized');
|
return;
|
}
|
|
const form = event.target;
|
const orderData = this.extractOrderData(form);
|
|
try {
|
window.jvbLoading?.showLoading?.('Processing payment...');
|
const result = await this.processPayment(orderData);
|
this.handleSuccess(result, form);
|
} catch (error) {
|
this.handleError(error);
|
} finally {
|
window.jvbLoading?.hideLoading?.();
|
}
|
}
|
|
/*****************************************************************
|
* ORDER TRACKING
|
*****************************************************************/
|
|
trackOrder(orderNum) {
|
this.orderId = orderNum;
|
this.scheduleOrderCheck();
|
this.checkout.querySelector('button[data-tab=order]').hidden = false;
|
}
|
|
scheduleOrderCheck() {
|
window.debouncer.schedule(
|
'order',
|
() => this.checkOrderStatus(),
|
30000
|
);
|
}
|
|
async checkOrderStatus() {
|
const response = await fetch(`${this.config.api_url}order-status/${this.orderId}`, {
|
headers: { 'X-WP-Nonce': this.config.nonce }
|
});
|
const data = await response.json();
|
if (data.status !== 'ready') {
|
this.scheduleOrderCheck();
|
}
|
this.updateOrderStatus(data);
|
}
|
|
updateOrderStatus(data) {
|
this.checkout.querySelectorAll('.status-item').forEach(item => {
|
if (item.dataset.status === data.status) {
|
item.classList.add('active');
|
}
|
});
|
this.checkout.querySelector('#eta').textContent = data.eta || 'In progress';
|
}
|
|
/*****************************************************************
|
* SAVED CARDS — shared rendering, provider fetches cards
|
*****************************************************************/
|
|
renderSavedCards() {
|
const container = document.getElementById('saved-cards');
|
if (!container || this.savedCards.length === 0) return;
|
|
const html = `
|
<div class="saved-cards-section">
|
<h4>Saved Payment Methods</h4>
|
${this.savedCards.map(card => `
|
<label class="saved-card">
|
<input type="radio" name="payment-method" value="saved" data-card-id="${card.id}">
|
<span class="card-info">
|
<strong>${card.card_brand}</strong> ending in ${card.last_4}
|
<small>Exp: ${card.exp_month}/${card.exp_year}</small>
|
</span>
|
</label>
|
`).join('')}
|
<label class="saved-card">
|
<input type="radio" name="payment-method" value="new" checked>
|
<span>Use a new card</span>
|
</label>
|
</div>
|
`;
|
|
container.innerHTML = html;
|
|
container.querySelectorAll('input[name="payment-method"]').forEach(radio => {
|
radio.addEventListener('change', (e) => {
|
const useNewCard = e.target.value === 'new';
|
const paymentContainer = document.getElementById('payment-container');
|
if (paymentContainer) {
|
paymentContainer.style.display = useNewCard ? 'block' : 'none';
|
}
|
this.selectedCardId = useNewCard ? null : e.target.dataset.cardId;
|
});
|
});
|
}
|
|
/*****************************************************************
|
* RESULT HANDLERS
|
*****************************************************************/
|
|
handleSuccess(result, form) {
|
document.dispatchEvent(new CustomEvent('checkoutSuccess', {
|
detail: { result, form, provider: this.provider }
|
}));
|
|
const successUrl = form.dataset.successUrl ||
|
`/order-confirmation/?order=${result.order_id || result.wp_order_id}`;
|
window.location.href = successUrl;
|
}
|
|
handleError(error) {
|
console.error(`${this.provider} checkout error:`, error);
|
|
document.dispatchEvent(new CustomEvent('checkoutError', {
|
detail: { error, provider: this.provider }
|
}));
|
|
window.jvbNotifications?.show?.(
|
error.message || error || 'Payment failed',
|
'error'
|
);
|
}
|
}
|
|
window.jvbCheckout = Checkout;
|