Jake Vanderwerf
2025-11-04 42fa8304ddb811b0f725f245130f70c0f5e86a6c
assets/js/dash/SquareCheckout.js
@@ -1,44 +1,50 @@
class SquareCheckout {
   constructor(config = {}) {
      this.config = {
         ...squareConfig,
         ...config
      };
      this.checkout = document.querySelector('aside#cart');
      if (!this.checkout) {
         return;
      }
      this.payments     = null;
      this.card         = null;
      this.isInitialized   = false;
      this.cartItems       = new Map();
      this.checkout     = document.querySelector('aside#cart');
      this.config = Object.assign({
         application_id: squareConfig.application_id,
         location_id: squareConfig.location_id,
         api_url: squareConfig.api_url,
         nonce: squareConfig.nonce,
         currency: squareConfig.currency || 'CAD'
      }, config);
      this.stepMultiplier = 1;
      this.isOpen = this.config.isOpen !== '1' || false;
      //User Context
      this.isLoggedIn   = this.config.is_logged_in|| false;
      this.userEmail       = this.config.user_email || '';
      this.savedCards      = [];
      this.selectedCardId  = null;
      this.cartId = null;
      this.cache = new window.jvbCache('cart', {TTL: 8.64e+7});
      this.a11y = window.jvbA11y;
      this.initCart();
      if (this.checkout) {
         this.initElements();
         this.init();
         this.initListeners();
      this.payments = null;
      this.card = null;
      this.isInitialized = false;
         if (this.isLoggedIn) {
            this.loadSavedCards();
         }
      }
      this.stepMultiplier = 1;
      this.clickHandler = this.handleClick.bind(this);
      this.keyHandler = this.handleEscape.bind(this);
      this.changeHandler = this.handleChange.bind(this);
      //Handle the opening and closing of the checkout window
      this.popup = new window.jvbPopup({
         popup: this.checkout,
         toggle: this.toggle,
         name: 'Cart',
         onOpen: this.maybeAddEmptyState.bind(this),
      });
      this.initElements();
      this.bindEvents();
      this.init();
      this.toggle.hidden = false;
      console.log(this.popup);
      // this.toggle.hidden = false;
   }
   async initCart() {
@@ -48,12 +54,9 @@
         this.notifyRestoredCart();
      }
   }
   handleClick(e) {
      if (window.targetCheck(e, '.toggle-cart')) {
         let toggle = window.targetCheck(e, '.toggle-cart');
         console.log('Toggle found. Toggling cart');
         this.toggleCart();
      } else if (window.targetCheck(e, 'button') && window.targetCheck(e, 'div.quantity')) {
      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]')) {
@@ -64,10 +67,6 @@
         this.handleRemoveFromCart(remove);
      } else if (window.targetCheck(e, '[data-clear-cart]')) {
         this.clearCart();
      } else if (this.checkout.classList.contains('expanded') &&
         !this.checkout.contains(e.target) &&
         e.target !== this.toggle) {
         this.closeCart();
      }
   }
@@ -150,22 +149,6 @@
      }
   }
   toggleCart() {
      if (!this.checkout.classList.contains('expanded')) {
         this.openCart();
      } else {
         this.closeCart();
      }
   }
   openCart(message = 'Opened Cart') {
      this.checkout.classList.add('expanded');
      this.toggle.title = 'Hide cart';
      this.toggle.ariaExpanded = true;
      this.toggle.querySelector('span').textContent = 'Close Cart';
      this.a11y.announce(message);
      this.maybeAddEmptyState();
      document.addEventListener('keydown', this.keyHandler);
   }
   maybeAddEmptyState() {
      let empty = this.itemsList.querySelector('.empty');
      if(empty) {
@@ -187,18 +170,8 @@
         this.checkoutPanel.title = 'Checkout';
      }
   }
   closeCart(message = 'Closed Cart') {
      this.checkout.classList.remove('expanded');
      this.toggle.title = 'Show Cart';
      this.toggle.ariaExpanded = false;
      this.toggle.querySelector('span').textContent = '';
      this.a11y.announce(message);
      document.removeEventListener('keydown', this.keyHandler);
   }
   handleEscape(e) {
      if (e.key === 'Escape') {
         this.closeCart('Closed Cart with escape key');
         this.stepMultiplier = 1;
      } else if (e.ctrlKey && e.shiftKey) {
         this.stepMultiplier = Math.max(parseInt(this.stepMultiplier) * 100, 1000);
@@ -375,7 +348,7 @@
   initElements() {
      this.toggle = document.querySelector('.toggle-cart');
      if (squareConfig.isOpen !== '1') {
      if (!this.isOpen) {
         this.toggle.disabled = true;
         this.toggle.title = 'Currently closed for online ordering';
      }
@@ -394,9 +367,12 @@
      console.log('Initialized Checkout');
   }
   bindEvents() {
      this.checkoutForm.addEventListener('submit', (e) => this.handleFormSubmit(e));
   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);
   }
@@ -410,12 +386,18 @@
            style: this.getCardStyle()
         });
         await this.card.attach('#square-card-container');
         this.card.addEventListener('cardBrandChanged', (event) => {
            console.log('Card brand:', event.detail.cardBrand);
            // You could show card brand icon here
         });
      } catch (error) {
         console.error('Failed to initialize card:', error);
         throw error;
      }
   }
   getCardStyle() {
      return {
         input: {
@@ -429,7 +411,10 @@
            borderRadius: '4px'
         },
         '.input-container.is-focus': {
            borderColor: '#007cba'
            borderColor: '#006AFF',
            borderWidth: '2px',
            outline: '2px solid #006AFF',
            outlineOffset: '2px'
         },
         '.input-container.is-error': {
            borderColor: '#d63638'
@@ -438,7 +423,7 @@
   }
   async handleFormSubmit(event) {
      if (squareConfig.isOpen !== '1') {
      if (!this.isOpen) {
         return;
      }
      event.preventDefault();
@@ -463,23 +448,23 @@
   }
   extractOrderData(form) {
      // Convert cart items Map to array with proper structure
      const items = Array.from(this.cartItems.values()).map(item => ({
         post_id: item.post_id,
         quantity: item.quantity,
         catalog_object_id: item.square_catalog_id,
         quantity: String(item.quantity),
         price: item.price,
         name: item.name
         note: item.note || ''
      }));
      const total = items.reduce((sum, item) =>
         sum + (item.price * item.quantity), 0
      );
      // Pre-fill customer info if logged in
      return {
         total: total * 100, // Square expects amount in cents
         total: Math.round(total * 100),
         items: items,
         customer: {
            email: form.querySelector('[name="email"]')?.value || '',
            email: this.isLoggedIn ? this.userEmail : (form.querySelector('[name="email"]')?.value || ''),
            name: form.querySelector('[name="name"]')?.value || '',
            phone: form.querySelector('[name="phone"]')?.value || ''
         },
@@ -490,52 +475,101 @@
   async processPayment(orderData) {
      try {
         const result = await this.card.tokenize();
         let sourceToken = null;
         if (result.status === 'OK') {
            return await this.submitToServer(result.token, orderData);
         // Check if using saved card or new card
         if (this.selectedCardId) {
            // Use saved card
            sourceToken = this.selectedCardId;
         } else {
            throw new Error('Card tokenization failed: ' + (result.errors?.join(', ') || 'Unknown error'));
            // Tokenize new card
            const tokenResult = await this.card.tokenize({
               verificationDetails: {
                  amount: String(orderData.total),
                  currencyCode: this.config.currency || 'CAD',
                  intent: 'CHARGE',
                  customerInitiated: true,
                  billingContact: {
                     givenName: orderData.customer.name.split(' ')[0],
                     familyName: orderData.customer.name.split(' ').slice(1).join(' '),
                     email: orderData.customer.email,
                     phone: orderData.customer.phone,
                     addressLines: [form.querySelector('[name="address"]')?.value || ''],
                     city: form.querySelector('[name="city"]')?.value || '',
                     state: form.querySelector('[name="state"]')?.value || '',
                     postalCode: form.querySelector('[name="postal_code"]')?.value || '',
                     countryCode: 'CA' // or 'US'
                  }
               }
            });
            if (tokenResult.status !== 'OK') {
               const errors = tokenResult.errors?.map(e => e.message).join(', ') || 'Unknown error';
               throw new Error(`Card tokenization failed: ${errors}`);
            }
            sourceToken = tokenResult.token;
            if (tokenResult.details?.userChallenged) {
               console.log('3D Secure verification completed');
            }
         }
         // Send to server
         return await this.submitToServer(sourceToken, orderData, !!this.selectedCardId);
      } catch (error) {
         console.error('Payment processing failed:', error);
         throw error;
      }
   }
   async submitToServer(token, orderData) {
      if (squareConfig.isOpen !== '1') {
         return;
   async submitToServer(sourceToken, orderData, isSavedCard = false) {
      if (!this.isOpen) {
         throw new Error('Store is currently closed');
      }
      // Square Web Payments SDK handles EVERYTHING
      // We just need to track the order for status updates
      const response = await fetch(this.config.api_url + 'save-order', {
      const response = await fetch(this.config.api_url + 'process-payment', {
         method: 'POST',
         headers: {
            'Content-Type': 'application/json',
            'X-WP-Nonce': this.config.nonce
         },
         body: JSON.stringify({
            order_id: token.orderId,  // From Square SDK response
            payment_id: token.paymentId,  // From Square SDK response
            customer: orderData.customer,
            source_id: sourceToken,
            is_saved_card: isSavedCard,
            cart_id: this.getCartId(),
            amount: orderData.total,
            items: orderData.items,
            action: 'jvb_integration_action',
            service: 'square',
            integration_action: 'save_order'
            customer: {
               email: this.isLoggedIn ? this.userEmail : orderData.customer.email,
               name: orderData.customer.name,
               phone: orderData.customer.phone
            },
            note: orderData.note,
            pickup_time: orderData.pickup_time
         })
      });
      const result = await response.json();
      if (!response.ok) {
         throw new Error(result.message || 'Failed to save order');
         throw new Error(result.message || 'Payment processing failed');
      }
      this.clearCart();
      return result;
   }
   getCartId() {
      // Generate once per cart session
      if (!this.cartId) {
         this.cartId = crypto.randomUUID();
         this.cache.set('cart_id', this.cartId);
      }
      return this.cartId;
   }
   trackOrder(orderNum) {
      this.orderId = orderNum;
      this.scheduleOrderCheck();
@@ -573,43 +607,75 @@
   /**************************************************************
    * Customer Data
   **************************************************************/
   async loadCustomerProfile(email) {
      const response = await fetch('/wp-json/jvb/v1/square/customer', {
         method: 'POST',
         headers: {
            'Content-Type': 'application/json',
            'X-WP-Nonce': this.config.nonce
         },
         body: JSON.stringify({ email })
      });
   /**
    * Load saved cards for logged-in user
    */
   async loadSavedCards() {
      try {
         const response = await fetch(this.config.api_url + 'saved-cards', {
            method: 'GET',
            headers: {
               'X-WP-Nonce': this.config.nonce
            }
         });
      const profile = await response.json();
         const result = await response.json();
      if (profile) {
         this.displaySavedCards(profile.cards);
         this.fillCustomerInfo(profile.customer);
         if (result.success && result.cards) {
            this.savedCards = result.cards;
            this.renderSavedCards();
         }
      } catch (error) {
         console.error('Failed to load saved cards:', error);
      }
   }
   displaySavedCards(cards) {
   /**
    * Render saved cards in the checkout form
    */
   renderSavedCards() {
      const container = document.getElementById('saved-cards');
      if (!cards.length) return;
      if (!container || this.savedCards.length === 0) {
         return;
      }
      container.innerHTML = `
            <h3>Saved Payment Methods</h3>
            ${cards.map(card => `
                <label>
                    <input type="radio" name="payment_method" value="${card.id}">
                    •••• ${card.last_4} (${card.card_brand})
      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>
            `).join('')}
            <label>
                <input type="radio" name="payment_method" value="new" checked>
                Use new card
            </label>
            </div>
        `;
      container.innerHTML = html;
      // Listen for payment method selection
      container.querySelectorAll('input[name="payment-method"]').forEach(radio => {
         radio.addEventListener('change', (e) => {
            const useNewCard = e.target.value === 'new';
            const cardContainer = document.getElementById('square-card-container');
            if (cardContainer) {
               cardContainer.style.display = useNewCard ? 'block' : 'none';
            }
            this.selectedCardId = useNewCard ? null : e.target.dataset.cardId;
         });
      });
   }
   handleSuccess(result, form) {
      // Trigger success event
      document.dispatchEvent(new CustomEvent('squareCheckoutSuccess', {