Jake Vanderwerf
2026-06-29 4089ba01e0881c89a72332e13bc3a80b6bddec2a
assets/js/concise/Checkout.js
@@ -15,13 +15,19 @@
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;
      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    = [];
@@ -31,6 +37,7 @@
      this.cache = new window.jvbCache('cart', { TTL: 8.64e+7 });
      this.a11y  = window.jvbA11y;
      this.T = window.jvbTemplates;
      this.initCart();
@@ -38,20 +45,39 @@
         this.initElements();
         this.init();        // provider-specific
         this.initListeners();
         this.defineTemplates();
         if (this.isLoggedIn) {
         if (this.isLoggedIn && this.isActive) {
            console.log('Loading saved cards');
            this.loadSavedCards();
         }
      }
      this.popup = new window.jvbPopup({
      if (!this.isOpen)
      {
         this.closeShop();
      }
      this.popup = window.jvbPopup.registerPopup({
         popup:  this.checkout,
         toggle: this.toggle,
         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
    *****************************************************************/
@@ -81,20 +107,30 @@
    *****************************************************************/
   async initCart() {
      this.cartItems = await this.cache.get('cart') ?? new Map();
      if (this.cartItems.size > 0) {
      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();
      window.removeChildren(this.table);
      Array.from(this.ui.cart.table.children).forEach(child => {
         if (child.tagName === 'TBODY') {
            child.remove();
         }
      });
      this.saveCart();
   }
@@ -109,22 +145,93 @@
   /*****************************************************************
    * 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.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.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();
      this.tabs = new window.jvbTabs(this.checkoutForm, {
      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
      });
   }
@@ -134,9 +241,10 @@
      this.keyHandler    = this.handleEscape.bind(this);
      this.changeHandler = this.handleChange.bind(this);
      this.checkoutForm.addEventListener('submit', (e) => this.handleFormSubmit(e));
      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);
   }
   /*****************************************************************
@@ -144,17 +252,21 @@
    *****************************************************************/
   handleClick(e) {
      if (window.targetCheck(e, 'button') && window.targetCheck(e, 'div.quantity')) {
      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]')) {
      } 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]')) {
      } 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();
      }
@@ -164,12 +276,7 @@
      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;
         }
         let value = e.target.value;
         if (value > 0) {
            this.handleAddToCart(item);
@@ -181,6 +288,10 @@
   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);
@@ -196,38 +307,62 @@
   handleAddToCart(item) {
      let id       = item.dataset.id;
      let price    = parseFloat(item.dataset.price);
      let quantity = parseInt(item.querySelector('.quantity-input')?.value) ?? 1;
      let quantity = parseInt(item.querySelector('.quantity input')?.value) ?? 1;
      let total    = parseFloat(price * quantity);
      this.createItemElement(item);
      this.cartItems.set(id, {
      let parent = this.determineParent(item);
      let stored = {};
      if (this.cartItems.has(id)) {
         stored = this.cartItems.get(id);
      }
      let newValue = {
         post_id:    id,
         name:       item.dataset.name,
         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: item.dataset.catalogId || '',
      });
         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?')) {
         if (!item.querySelector('[data-id]')) {
            item = item.closest('.item')?.querySelector('.quantity.field');
         let id = item.dataset.id??item.id??false;
         if (!id) {
            console.error('No ID found..');
         }
         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.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();
@@ -236,11 +371,15 @@
      else if (e.target.closest('.decrease'))  change -= 1;
      if (change !== 0) {
         let step  = parseInt(container.dataset.step);
         let input = container.querySelector('input');
         let step  = parseInt(input.step);
         let value = (input.value === '') ? 0 : parseInt(input.value);
         if (value === 0 && change < 0) return;
         input.value = value + (step * change * this.stepMultiplier);
         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);
      }
@@ -254,10 +393,12 @@
      let decrease = container.querySelector('.decrease');
      let value    = parseInt(input.value);
      if (value <= min) {
      if (min !== undefined && value <= min) {
         console.log('Resetting to minimum value');
         input.value      = min;
         decrease.disabled = true;
      } else if (value >= max) {
      } else if (max !== undefined && value >= max) {
         console.log('Resetting to maximum value');
         input.value      = max;
         increase.disabled = true;
      } else {
@@ -271,100 +412,166 @@
    *****************************************************************/
   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;
      let element = this.maybeCreateCartItem(item);
      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');
      }
      let quantity = item.tagName === undefined ?
         item.quantity :
         item.querySelector('input').value ?? 1;
      let price = item.tagName === undefined ?
         item.price :
         item.dataset.price;
      [
         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);
      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() {
      let empty = this.itemsList.querySelector('.empty');
      if (empty) empty.remove();
      this.toggleButton(true);
      this.findEmpty();
      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.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.checkoutPanel.disabled       = false;
         this.table.closest('table').hidden = false;
         this.total.hidden                  = false;
         this.checkoutPanel.title           = 'Checkout';
         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');
      this.checkout.querySelector('.tab-content[data-tab=cartItems]').insertBefore(restored, this.itemsList);
      document.body.append(restored);
      restored.classList.toggle('show');
      this.restoreElement = restored;
      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.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
    *****************************************************************/
@@ -373,11 +580,14 @@
      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');
      // 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');
   }
   /*****************************************************************
@@ -422,13 +632,10 @@
      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?.();
      }
   }
@@ -439,7 +646,7 @@
   trackOrder(orderNum) {
      this.orderId = orderNum;
      this.scheduleOrderCheck();
      this.checkout.querySelector('button[data-tab=order]').hidden = false;
      this.ui.panels.order.toggle.hidden = false;
   }
   scheduleOrderCheck() {
@@ -451,9 +658,7 @@
   }
   async checkOrderStatus() {
      const response = await fetch(`${this.config.api_url}order-status/${this.orderId}`, {
         headers: { 'X-WP-Nonce': this.config.nonce }
      });
      const response = await window.auth.fetch(`${this.api}order-status/${this.orderId}`, );
      const data = await response.json();
      if (data.status !== 'ready') {
         this.scheduleOrderCheck();
@@ -478,6 +683,7 @@
      const container = document.getElementById('saved-cards');
      if (!container || this.savedCards.length === 0) return;
      console.log('Yup');
      const html = `
         <div class="saved-cards-section">
            <h4>Saved Payment Methods</h4>