class SquareCheckout {
|
constructor(config = {}) {
|
this.config = {
|
...squareConfig,
|
...config
|
};
|
|
this.payments = null;
|
this.card = null;
|
this.isInitialized = false;
|
this.cartItems = new Map();
|
this.checkout = document.querySelector('aside#cart');
|
|
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();
|
|
if (this.isLoggedIn) {
|
this.loadSavedCards();
|
}
|
}
|
|
this.stepMultiplier = 1;
|
|
//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),
|
});
|
|
console.log(this.popup);
|
// this.toggle.hidden = false;
|
}
|
|
async initCart() {
|
this.cartItems = await this.cache.get('cart') ?? new Map();
|
console.log('cart',this.cartItems);
|
if (this.cartItems.size > 0) {
|
this.notifyRestoredCart();
|
}
|
}
|
|
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();
|
}
|
}
|
|
handleChange(e, container) {
|
console.log('Checkout change');
|
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);
|
}
|
}
|
}
|
|
handleNumberClick(e, container) {
|
console.log(container);
|
e.preventDefault();
|
let change = 0;
|
let action = '';
|
if (e.target.closest('.increase')) {
|
change += 1;
|
} else if (e.target.closest('.decrease')) {
|
change -=1;
|
}
|
if (change !== 0) {
|
let [
|
step,
|
input
|
] = [
|
parseInt(container.dataset.step),
|
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,
|
max,
|
input,
|
increase,
|
decrease
|
] = [
|
container.dataset.min,
|
container.dataset.max,
|
container.querySelector('input'),
|
container.querySelector('.increase'),
|
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 = false;
|
} else if (increase.disabled) {
|
increase.disabled = false;
|
} else if (decrease.disabled) {
|
decrease.disabled = false;
|
}
|
}
|
|
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 empty = window.getTemplate('emptyCart');
|
this.itemsList.append(empty);
|
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';
|
}
|
}
|
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);
|
}
|
}
|
|
handleAddToCart(item) {
|
let id = item.dataset.id;
|
this.createItemElement(item);
|
let price = parseFloat(item.dataset.price);
|
let quantity = parseInt(item.querySelector('.quantity-input')?.value)??1;
|
let total = parseFloat(price * quantity);
|
this.cartItems.set(id, {
|
post_id: id,
|
name: item.dataset.name,
|
price: price,
|
quantity: quantity,
|
total: total,
|
square_catalog_id: item.dataset.squareCatalogId
|
});
|
this.saveCart();
|
}
|
|
notifyRestoredCart() {
|
let restored = window.getTemplate('restoredCart');
|
this.checkout.querySelector('.tab-content[data-tab=cartItems]').insertBefore(restored, this.itemsList);
|
|
this.cartItems.forEach(item => {
|
console.log(item);
|
let element = window.getTemplate('cartItem');
|
let field = element.querySelector('.quantity');
|
let price = item.price;
|
let quantity = item.quantity;
|
[
|
field.dataset.id,
|
element.querySelector('label').textContent,
|
element.querySelector('.price').textContent,
|
field.dataset.price,
|
field.dataset.squareCatalogId,
|
element.querySelector('[name="quantity"]').value,
|
element.querySelector('.total').textContent
|
] = [
|
item['post_id'],
|
item.name,
|
window.formatPrice(price),
|
price,
|
item['square_catalog_id'],
|
quantity,
|
window.formatPrice(quantity * price)
|
];
|
this.table.append(element);
|
});
|
this.updateTotal();
|
}
|
|
handleRemoveFromCart(item) {
|
if (confirm('This will remove this item from the cart. Continue?')) {
|
if (!item.querySelector('[data-id]')) {
|
//it's a remove button
|
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();
|
//Reset the field value to 0 too
|
let input = document.querySelector(`[data-id="${id}"] input`);
|
if (input){
|
input.value = 0;
|
}
|
this.maybeAddEmptyState();
|
this.saveCart();
|
}
|
}
|
|
clearCart() {
|
this.cartItems.clear();
|
window.removeChildren(this.table);
|
this.saveCart();
|
}
|
|
saveCart() {
|
this.updateTotal();
|
this.cache.set('cart', this.cartItems);
|
}
|
|
updateTotal() {
|
let total = 0;
|
this.cartItems.forEach(item => {
|
console.log(item);
|
total += item.total;
|
});
|
let tax = total * .05;
|
total = window.formatPrice(total + tax);
|
tax = window.formatPrice(tax);
|
window.eraseText(this.totalTax);
|
window.eraseText(this.grandTotal);
|
window.typeText(this.totalTax, tax);
|
window.typeText(this.grandTotal, total);
|
this.totalTax.classList.remove('typeText');
|
}
|
|
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.squareCatalogId
|
] = [
|
item.dataset.id,
|
item.dataset.name,
|
window.formatPrice(price),
|
price,
|
item.dataset.squareCatalogId
|
];
|
}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);
|
}
|
|
}
|
|
async init() {
|
if (!window.Square) {
|
console.error('Square Web Payments SDK not loaded');
|
return;
|
}
|
|
try {
|
this.payments = window.Square.payments(
|
this.config.application_id,
|
this.config.location_id
|
);
|
|
await this.initializePaymentMethods();
|
|
this.isInitialized = true;
|
|
// Trigger ready event
|
document.dispatchEvent(new CustomEvent('squareCheckoutReady', {
|
detail: { checkout: this }
|
}));
|
|
} catch (error) {
|
console.error('Failed to initialize Square payments:', error);
|
this.handleError(error);
|
}
|
}
|
|
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
|
});
|
|
console.log('Initialized Checkout');
|
}
|
|
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);
|
}
|
|
async initializePaymentMethods() {
|
const cardContainer = document.getElementById('square-card-container');
|
if (!cardContainer) return;
|
|
try {
|
this.card = await this.payments.card({
|
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: {
|
fontSize: '16px',
|
fontFamily: 'inherit',
|
color: '#333',
|
backgroundColor: '#fff'
|
},
|
'.input-container': {
|
borderColor: '#ccc',
|
borderRadius: '4px'
|
},
|
'.input-container.is-focus': {
|
borderColor: '#006AFF',
|
borderWidth: '2px',
|
outline: '2px solid #006AFF',
|
outlineOffset: '2px'
|
},
|
'.input-container.is-error': {
|
borderColor: '#d63638'
|
}
|
};
|
}
|
|
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();
|
}
|
}
|
|
extractOrderData(form) {
|
const items = Array.from(this.cartItems.values()).map(item => ({
|
catalog_object_id: item.square_catalog_id,
|
quantity: String(item.quantity),
|
price: item.price,
|
note: item.note || ''
|
}));
|
|
const total = items.reduce((sum, item) =>
|
sum + (item.price * item.quantity), 0
|
);
|
|
// Pre-fill customer info if logged in
|
return {
|
total: Math.round(total * 100),
|
items: items,
|
customer: {
|
email: this.isLoggedIn ? this.userEmail : (form.querySelector('[name="email"]')?.value || ''),
|
name: form.querySelector('[name="name"]')?.value || '',
|
phone: form.querySelector('[name="phone"]')?.value || ''
|
},
|
note: form.querySelector('[name="special_instructions"]')?.value || '',
|
pickup_time: form.querySelector('[name="pickup_time"]')?.value || ''
|
};
|
}
|
|
async processPayment(orderData) {
|
try {
|
let sourceToken = null;
|
|
// Check if using saved card or new card
|
if (this.selectedCardId) {
|
// Use saved card
|
sourceToken = this.selectedCardId;
|
} else {
|
// 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(sourceToken, orderData, isSavedCard = false) {
|
if (!this.isOpen) {
|
throw new Error('Store is currently closed');
|
}
|
|
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: sourceToken,
|
is_saved_card: isSavedCard,
|
cart_id: this.getCartId(),
|
amount: orderData.total,
|
items: orderData.items,
|
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 || '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();
|
this.checkout.querySelector('button[data-tab=order]').hidden = false;
|
}
|
|
scheduleOrderCheck(){
|
window.debouncer.schedule(
|
'order',
|
() => { this.checkOrderStatus()},
|
30000 //30seconds
|
)
|
}
|
|
async checkOrderStatus() {
|
const response = await fetch(`/wp-json/jvb/v1/square/order-status/${this.orderId}`);
|
const data = await response.json();
|
if (data.status !== 'ready') {
|
this.scheduleOrderCheck();
|
}
|
this.updateOrderStatus(data);
|
}
|
|
updateOrderStatus(data) {
|
// Update status timeline
|
this.checkout.querySelectorAll('.status-item').forEach(item => {
|
if(item.dataset.status === data.status) {
|
item.classList.add('active');
|
}
|
});
|
|
// Update ETA
|
this.checkout.querySelector('#eta').textContent = data.eta || 'In progress';
|
}
|
/**************************************************************
|
* Customer Data
|
**************************************************************/
|
/**
|
* 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 result = await response.json();
|
|
if (result.success && result.cards) {
|
this.savedCards = result.cards;
|
this.renderSavedCards();
|
}
|
} catch (error) {
|
console.error('Failed to load saved cards:', error);
|
}
|
}
|
|
/**
|
* Render saved cards in the checkout form
|
*/
|
renderSavedCards() {
|
const container = document.getElementById('saved-cards');
|
if (!container || this.savedCards.length === 0) {
|
return;
|
}
|
|
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>
|
</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', {
|
detail: { result, form }
|
}));
|
|
// Default redirect behavior
|
const successUrl = form.dataset.successUrl ||
|
`/order-confirmation/?order=${result.wp_order_id}`;
|
window.location.href = successUrl;
|
}
|
|
handleError(error) {
|
console.error('Square checkout error:', error);
|
|
// Trigger error event
|
document.dispatchEvent(new CustomEvent('squareCheckoutError', {
|
detail: { error }
|
}));
|
|
// Default error display
|
window.jvbNotifications?.show?.(error.message || 'Payment failed', 'error');
|
}
|
}
|
|
document.addEventListener('DOMContentLoaded', () =>{
|
window.squareCheckout = new SquareCheckout();
|
});
|
|
|
// Usage example
|
// document.addEventListener('DOMContentLoaded', async function() {
|
// // Get config from WordPress
|
// const configResponse = await fetch('/wp-json/jvb/v1/square/payment-form');
|
// const config = await configResponse.json();
|
//
|
// const checkout = new SquareCheckout(config);
|
//
|
// // Handle form submission
|
// document.getElementById('checkout-form').addEventListener('submit', async function(e) {
|
// e.preventDefault();
|
//
|
// const orderData = {
|
// total: parseFloat(this.dataset.total),
|
// items: JSON.parse(this.dataset.items),
|
// customer: {
|
// email: this.querySelector('[name="email"]').value,
|
// name: this.querySelector('[name="name"]').value
|
// }
|
// };
|
//
|
// try {
|
// const result = await checkout.processPayment(orderData);
|
// // Handle success - redirect or show confirmation
|
// window.location.href = '/order-confirmation/?order=' + result.wp_order_id;
|
// } catch (error) {
|
// // Handle error
|
// alert('Payment failed: ' + error.message);
|
// }
|
// });
|
// });
|