| | |
| | | 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() { |
| | |
| | | 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]')) { |
| | |
| | | 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(); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | 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) { |
| | |
| | | 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); |
| | |
| | | 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'; |
| | | } |
| | |
| | | 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); |
| | | } |
| | |
| | | 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: { |
| | |
| | | borderRadius: '4px' |
| | | }, |
| | | '.input-container.is-focus': { |
| | | borderColor: '#007cba' |
| | | borderColor: '#006AFF', |
| | | borderWidth: '2px', |
| | | outline: '2px solid #006AFF', |
| | | outlineOffset: '2px' |
| | | }, |
| | | '.input-container.is-error': { |
| | | borderColor: '#d63638' |
| | |
| | | } |
| | | |
| | | async handleFormSubmit(event) { |
| | | if (squareConfig.isOpen !== '1') { |
| | | if (!this.isOpen) { |
| | | return; |
| | | } |
| | | event.preventDefault(); |
| | |
| | | } |
| | | |
| | | 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 || '' |
| | | }, |
| | |
| | | |
| | | 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'); |
| | | } |
| | | const response = await fetch(this.config.api_url + 'checkout', { |
| | | |
| | | 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({ |
| | | source_id: token, |
| | | source_id: sourceToken, |
| | | is_saved_card: isSavedCard, |
| | | cart_id: this.getCartId(), |
| | | amount: orderData.total, |
| | | items: orderData.items, |
| | | customer: orderData.customer, |
| | | customer: { |
| | | email: this.isLoggedIn ? this.userEmail : orderData.customer.email, |
| | | name: orderData.customer.name, |
| | | phone: orderData.customer.phone |
| | | }, |
| | | note: orderData.note, |
| | | currency: this.config.currency, |
| | | action: 'jvb_integration_action', |
| | | service: 'square', |
| | | integration_action: 'process_order' |
| | | pickup_time: orderData.pickup_time |
| | | }) |
| | | }); |
| | | |
| | | const result = await response.json(); |
| | | |
| | | if (!response.ok) { |
| | | throw new Error(result.message || 'Payment failed'); |
| | | 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(); |
| | |
| | | /************************************************************** |
| | | * 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', { |