/**
|
* 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.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.initCart();
|
|
if (this.checkout) {
|
this.initElements();
|
this.init(); // provider-specific
|
this.initListeners();
|
|
if (this.isLoggedIn) {
|
this.loadSavedCards();
|
}
|
}
|
|
this.popup = window.jvbPopup.registerPopup({
|
popup: this.checkout,
|
toggle: this.toggle,
|
name: 'Cart',
|
onOpen: this.maybeAddEmptyState.bind(this),
|
});
|
}
|
|
/*****************************************************************
|
* 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() {
|
this.cartItems = await this.cache.get('cart') ?? new Map();
|
if (this.cartItems.size > 0) {
|
this.notifyRestoredCart();
|
}
|
}
|
|
saveCart() {
|
this.updateTotal();
|
this.cache.set('cart', this.cartItems);
|
}
|
|
clearCart() {
|
this.cartItems.clear();
|
window.removeChildren(this.table);
|
this.saveCart();
|
}
|
|
getCartId() {
|
if (!this.cartId) {
|
this.cartId = crypto.randomUUID();
|
this.cache.set('cart_id', this.cartId);
|
}
|
return this.cartId;
|
}
|
|
/*****************************************************************
|
* ELEMENT SETUP
|
*****************************************************************/
|
|
initElements() {
|
this.toggle = document.querySelector('.toggle-cart');
|
if (!this.isOpen) {
|
this.toggle.disabled = true;
|
this.toggle.title = 'Currently closed for online ordering';
|
}
|
this.checkoutPanel = this.checkout.querySelector('button[data-tab="checkout"]');
|
this.itemsList = this.checkout.querySelector('.cart-items');
|
this.table = this.checkout.querySelector('.cart-items tbody');
|
this.total = this.checkout.querySelector('.cart-total');
|
this.totalTax = this.total.querySelector('.tax span');
|
this.grandTotal = this.total.querySelector('.total span');
|
this.checkoutForm = this.checkout.querySelector('form');
|
|
this.tabs = new window.jvbTabs(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);
|
}
|
|
/*****************************************************************
|
* 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]')) {
|
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();
|
} 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 = input.value;
|
|
if (window.targetCheck(e, '.cart-items')) {
|
let main = document.querySelector(`.menu-section [data-id="${item.dataset.id}"] input`);
|
if (main) main.value = input.value;
|
}
|
|
if (value > 0) {
|
this.handleAddToCart(item);
|
} else {
|
this.handleRemoveFromCart(item);
|
}
|
}
|
}
|
|
handleEscape(e) {
|
if (e.key === 'Escape') {
|
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);
|
|
this.cartItems.set(id, {
|
post_id: id,
|
name: item.dataset.name,
|
price: price,
|
quantity: quantity,
|
total: total,
|
catalog_id: item.dataset.catalogId || '',
|
});
|
this.saveCart();
|
}
|
|
handleRemoveFromCart(item) {
|
if (confirm('This will remove this item from the cart. Continue?')) {
|
if (!item.querySelector('[data-id]')) {
|
item = item.closest('.item')?.querySelector('.quantity.field');
|
}
|
let id = item.dataset.id;
|
this.cartItems.delete(id);
|
this.table.querySelector(`[data-id="${id}"]`)?.closest('tr').remove();
|
|
let input = document.querySelector(`[data-id="${id}"] input`);
|
if (input) input.value = 0;
|
|
this.maybeAddEmptyState();
|
this.saveCart();
|
}
|
}
|
|
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 step = parseInt(container.dataset.step);
|
let input = container.querySelector('input');
|
let value = (input.value === '') ? 0 : parseInt(input.value);
|
|
input.value = value + (step * change * this.stepMultiplier);
|
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 (value <= min) {
|
input.value = min;
|
decrease.disabled = true;
|
} else if (value >= max) {
|
input.value = max;
|
increase.disabled = true;
|
} else {
|
increase.disabled = false;
|
decrease.disabled = false;
|
}
|
}
|
|
/*****************************************************************
|
* ITEM ELEMENT CREATION
|
*****************************************************************/
|
|
createItemElement(item) {
|
let element = this.itemsList.querySelector(`[data-id="${item.dataset.id}"]`);
|
let add = false;
|
let price = item.dataset.price;
|
let quantity = item.querySelector('[name="quantity"]')?.value ?? 1;
|
|
if (!element) {
|
add = true;
|
element = window.getTemplate('cartItem');
|
let field = element.querySelector('.quantity');
|
[
|
field.dataset.id,
|
element.querySelector('label').textContent,
|
element.querySelector('.price').textContent,
|
field.dataset.price,
|
field.dataset.catalogId,
|
] = [
|
item.dataset.id,
|
item.dataset.name,
|
window.formatPrice(price),
|
price,
|
item.dataset.catalogId || '',
|
];
|
} else {
|
element = element.closest('tr');
|
}
|
|
[
|
element.querySelector('[name="quantity"]').value,
|
element.querySelector('.total').textContent,
|
] = [
|
quantity,
|
window.formatPrice(quantity * price),
|
];
|
|
if (add) {
|
element.classList.add('adding');
|
this.table.append(element);
|
setTimeout(() => element.classList.remove('adding'), 500);
|
}
|
}
|
|
/*****************************************************************
|
* CART STATE
|
*****************************************************************/
|
|
maybeAddEmptyState() {
|
let empty = this.itemsList.querySelector('.empty');
|
if (empty) empty.remove();
|
|
if (this.cartItems.size === 0) {
|
this.checkoutPanel.disabled = true;
|
this.checkoutPanel.title = 'Add some things to your cart first!';
|
let emptyEl = window.getTemplate('emptyCart');
|
this.itemsList.append(emptyEl);
|
this.table.closest('table').hidden = true;
|
this.total.hidden = true;
|
this.a11y.announce('Nothing in Cart');
|
} else {
|
this.checkoutPanel.disabled = false;
|
this.table.closest('table').hidden = false;
|
this.total.hidden = false;
|
this.checkoutPanel.title = 'Checkout';
|
}
|
}
|
|
notifyRestoredCart() {
|
let restored = window.getTemplate('restoredCart');
|
this.checkout.querySelector('.tab-content[data-tab=cartItems]').insertBefore(restored, this.itemsList);
|
|
this.cartItems.forEach(item => {
|
let element = window.getTemplate('cartItem');
|
let field = element.querySelector('.quantity');
|
[
|
field.dataset.id,
|
element.querySelector('label').textContent,
|
element.querySelector('.price').textContent,
|
field.dataset.price,
|
field.dataset.catalogId,
|
element.querySelector('[name="quantity"]').value,
|
element.querySelector('.total').textContent,
|
] = [
|
item.post_id,
|
item.name,
|
window.formatPrice(item.price),
|
item.price,
|
item.catalog_id || '',
|
item.quantity,
|
window.formatPrice(item.quantity * item.price),
|
];
|
this.table.append(element);
|
});
|
this.updateTotal();
|
}
|
|
/*****************************************************************
|
* TOTALS
|
*****************************************************************/
|
|
updateTotal() {
|
let total = 0;
|
this.cartItems.forEach(item => total += item.total);
|
let tax = total * 0.05;
|
window.eraseText(this.totalTax);
|
window.eraseText(this.grandTotal);
|
window.typeText(this.totalTax, window.formatPrice(tax));
|
window.typeText(this.grandTotal, window.formatPrice(total + tax));
|
this.totalTax.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;
|