/** * 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 = `

Saved Payment Methods

${this.savedCards.map(card => ` `).join('')}
`; 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;