/** * 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.isActive = false; 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; //TODO: Remove this. this.isOpen = true; this.api = `${jvbSettings.api}${this.provider}/`; 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.isActive) { console.log('Loading saved cards'); 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 = { pass: '#magic-link-form a[data-tab]', toggle: { btn: '.toggle-cart', count: '.toggle-cart .count', icon: '.toggle-cart .icon', }, panels: { checkout: { toggle: 'button[data-tab="checkout"]', form: 'form#checkout' }, order: { toggle: 'button[data-tab="order"]', }, login: { pass: 'button[data-tab="login"]', } }, 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.tabs = window.jvbTabs.registerTab(this.checkout, { updateURL: false }); } initListeners() { this.clickHandler = this.handleClick.bind(this); this.keyHandler = this.handleEscape.bind(this); this.changeHandler = this.handleChange.bind(this); this.ui.panels.checkout.form?.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 (e.target === this.ui.pass) { e.preventDefault(); window.jvbTabs.switchTab('login', window.jvbTabs.getConfig(this.ui.panels.login.pass)); } else 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 = !this.isActive; if (!this.isActive) { this.ui.panels.checkout.toggle.title = 'We\'re experiencing technical difficulties. Try calling or coming in person.'; } 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.foot.tax.classList.remove('typeText'); this.ui.cart.foot.total.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 { const result = await this.processPayment(orderData); this.handleSuccess(result, form); } catch (error) { this.handleError(error); } } /***************************************************************** * ORDER TRACKING *****************************************************************/ trackOrder(orderNum) { this.orderId = orderNum; this.scheduleOrderCheck(); this.ui.panels.order.toggle.hidden = false; } scheduleOrderCheck() { window.debouncer.schedule( 'order', () => this.checkOrderStatus(), 30000 ); } async checkOrderStatus() { const response = await window.auth.fetch(`${this.api}order-status/${this.orderId}`, ); 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; console.log('Yup'); 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;