2 files deleted
47 files modified
9 files added
| | |
| | | use JVBase\rest\routes\FeedRoutes; |
| | | use JVBase\rest\routes\FavouritesRoutes; |
| | | use JVBase\rest\routes\IntegrationsSquareRoutes; |
| | | use JVBase\rest\routes\IntegrationsHelcimRoutes; |
| | | use JVBase\rest\routes\NotificationsRoutes; |
| | | use JVBase\rest\routes\ContentRoutes; |
| | | use JVBase\rest\routes\TermRoutes; |
| | |
| | | use JVBase\rest\routes\VoteRoutes; |
| | | use JVBase\rest\routes\Invitations; |
| | | use JVBase\rest\routes\ApprovalRoutes; |
| | | //use JVBase\rest\routes\AdminRoutes; |
| | | use JVBase\rest\routes\AdminRoutes; |
| | | use JVBase\rest\routes\IntegrationsRoutes; |
| | | use JVBase\utility\Features; |
| | | |
| | |
| | | if (Features::hasIntegration('square')) { |
| | | $this->routes['square'] = new IntegrationsSquareRoutes(); |
| | | } |
| | | if (Features::hasIntegration('helcim')) { |
| | | $this->routes['helcim'] = new IntegrationsHelcimRoutes(); |
| | | } |
| | | |
| | | if (Features::forSite()->has('feed_block')) { |
| | | $this->routes['feed'] = new FeedRoutes(); |
| | |
| | | |
| | | if (jvbSiteHasDashboard()) { |
| | | $this->routes['error'] = new ErrorRoutes(); |
| | | // $this->routes['admin'] = new AdminRoutes(); |
| | | $this->routes['admin'] = new AdminRoutes(); |
| | | $this->routes['content'] = new ContentRoutes(); |
| | | // $this->routes['bio'] = new BioRoutes(); |
| | | // $this->routes['shop'] = new ShopRoutes(); |
| | |
| | | document.addEventListener("DOMContentLoaded", function(e) { |
| | | console.log('working'); |
| | | // Tabs functionality for settings pages |
| | | let tabs = document.querySelectorAll('.jvb-settings-tab'); |
| | | // Tabs functionality for settings pages |
| | | let tabs = document.querySelectorAll('.jvb-settings-tab'); |
| | | |
| | | tabs.forEach(tab => { |
| | | tab.addEventListener('click', (e) => { |
| | | removeActiveTab(tabs); |
| | | tab.classList.add('active'); |
| | | setActiveSection(tab); |
| | | }); |
| | | }); |
| | | tabs.forEach(tab => { |
| | | tab.addEventListener('click', (e) => { |
| | | removeActiveTab(tabs); |
| | | tab.classList.add('active'); |
| | | setActiveSection(tab); |
| | | }); |
| | | }); |
| | | |
| | | // Check for hash in URL and activate corresponding tab |
| | | if (window.location.hash) { |
| | | var hash = window.location.hash.substring(1); |
| | | document.querySelector('.jvb-settings-tab[data-tab="' + hash + '"]')?.click(); |
| | | } else { |
| | | // Activate first tab by default |
| | | document.querySelector('.jvb-settings-tab')?.click(); |
| | | } |
| | | // Check for hash in URL and activate corresponding tab |
| | | if (window.location.hash) { |
| | | var hash = window.location.hash.substring(1); |
| | | document.querySelector('.jvb-settings-tab[data-tab="' + hash + '"]')?.click(); |
| | | } else { |
| | | // Activate first tab by default |
| | | document.querySelector('.jvb-settings-tab')?.click(); |
| | | } |
| | | |
| | | let confirm = document.querySelector('.jvb-confirm-action'); |
| | | if (confirm) { |
| | | confirm.addEventListener('click', (e) => { |
| | | if (!window.confirm(confirm.dataset.confirm || 'Are you sure?')) { |
| | | e.preventDefault(); |
| | | return false; |
| | | } |
| | | }); |
| | | } |
| | | let confirm = document.querySelector('.jvb-confirm-action'); |
| | | if (confirm) { |
| | | confirm.addEventListener('click', (e) => { |
| | | if (!window.confirm(confirm.dataset.confirm || 'Are you sure?')) { |
| | | e.preventDefault(); |
| | | return false; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | // Admin action buttons |
| | | document.querySelectorAll('a[data-action]').forEach(action => { |
| | | action.addEventListener('click', (e) => { |
| | | e.preventDefault(); |
| | | let loader = action.querySelector('.loader'); |
| | | loader.classList.add('loading'); |
| | | |
| | | let a = action.dataset.action; |
| | | |
| | | fetch(jvbSettings.api, { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/json', |
| | | 'X-WP-Nonce': jvbSettings.nonce, |
| | | 'action_nonce': jvbSettings.action |
| | | }, |
| | | body: JSON.stringify({ |
| | | action: a |
| | | }) |
| | | }) |
| | | .then(response => { |
| | | if (!response.ok) { |
| | | throw new Error('Network response was not ok'); |
| | | } |
| | | return response.json(); |
| | | }) |
| | | .then(data => { |
| | | if (data.success === true) { |
| | | loader.classList.remove('loading'); |
| | | loader.classList.add('loaded'); |
| | | setTimeout(() => { |
| | | loader.classList.remove('loaded'); |
| | | }, 3000); |
| | | } else { |
| | | throw new Error(data.message || 'Action failed'); |
| | | } |
| | | }) |
| | | .catch(error => { |
| | | console.error('Error:', error); |
| | | loader.classList.remove('loading'); |
| | | // You might want to add an error state class here |
| | | }); |
| | | }); |
| | | }); |
| | | // Initialize admin actions |
| | | initAdminActions(); |
| | | initCacheActions(); |
| | | initIconActions(); |
| | | }); |
| | | |
| | | function removeActiveTab(tabs) { |
| | | let active = document.querySelectorAll('.active'); |
| | | active.forEach(tab => { |
| | | tab.classList.remove('active'); |
| | | if (tab.dataset.tab) { |
| | | setActiveSection(tab, false); |
| | | window.location.hash = tab.dataset.tab; |
| | | } |
| | | }); |
| | | let active = document.querySelectorAll('.active'); |
| | | active.forEach(tab => { |
| | | tab.classList.remove('active'); |
| | | if (tab.dataset.tab) { |
| | | setActiveSection(tab, false); |
| | | window.location.hash = tab.dataset.tab; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | function setActiveSection(tab, set = true) { |
| | | let id = tab.dataset.tab; |
| | | if (!id) return; |
| | | let id = tab.dataset.tab; |
| | | if (!id) return; |
| | | |
| | | let section = document.querySelector('#' + id); |
| | | if (!section) return; |
| | | let section = document.querySelector('#' + id); |
| | | if (!section) return; |
| | | |
| | | if (set) { |
| | | section.classList.add('active'); |
| | | } else { |
| | | section.classList.remove('active'); |
| | | } |
| | | if (set) { |
| | | section.classList.add('active'); |
| | | } else { |
| | | section.classList.remove('active'); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Initialize admin action buttons (dashboard quick actions) |
| | | */ |
| | | function initAdminActions() { |
| | | document.querySelectorAll('a[data-action]').forEach(action => { |
| | | action.addEventListener('click', (e) => { |
| | | e.preventDefault(); |
| | | let loader = action.querySelector('.loader'); |
| | | loader.classList.add('loading'); |
| | | |
| | | let a = action.dataset.action; |
| | | |
| | | makeAdminRequest('admin-action', { action: a }) |
| | | .then(data => { |
| | | if (data.success === true) { |
| | | loader.classList.remove('loading'); |
| | | loader.classList.add('loaded'); |
| | | setTimeout(() => { |
| | | loader.classList.remove('loaded'); |
| | | }, 3000); |
| | | } else { |
| | | throw new Error(data.message || 'Action failed'); |
| | | } |
| | | }) |
| | | .catch(error => { |
| | | console.error('Error:', error); |
| | | loader.classList.remove('loading'); |
| | | alert('Error: ' + error.message); |
| | | }); |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Initialize cache management actions |
| | | */ |
| | | function initCacheActions() { |
| | | const flushAllBtn = document.querySelector('[data-cache-action="flush-all"]'); |
| | | if (flushAllBtn) { |
| | | flushAllBtn.addEventListener('click', function() { |
| | | const originalText = this.innerHTML; |
| | | this.disabled = true; |
| | | this.innerHTML = 'Flushing...'; |
| | | |
| | | makeAdminRequest('admin-cache', { action: 'flush-all' }) |
| | | .then(data => handleActionResponse(data, this, originalText)) |
| | | .catch(error => handleActionError(error, this, originalText)); |
| | | }); |
| | | } |
| | | |
| | | document.querySelectorAll('[data-cache-action="flush-cache"]').forEach(btn => { |
| | | btn.addEventListener('click', function() { |
| | | const group = this.getAttribute('data-group'); |
| | | const originalText = this.innerHTML; |
| | | this.disabled = true; |
| | | this.innerHTML = 'Flushing...'; |
| | | |
| | | makeAdminRequest('admin-cache', { action: 'flush-cache', group: group }) |
| | | .then(data => handleActionResponse(data, this, originalText)) |
| | | .catch(error => handleActionError(error, this, originalText)); |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Initialize icon management actions |
| | | */ |
| | | function initIconActions() { |
| | | const currentSource = document.getElementById('icon-source-select')?.value || 'icons'; |
| | | |
| | | // Select all checkbox |
| | | const selectAll = document.getElementById('select-all-versions'); |
| | | if (selectAll) { |
| | | selectAll.addEventListener('change', function() { |
| | | document.querySelectorAll('.version-checkbox').forEach(checkbox => { |
| | | checkbox.checked = this.checked; |
| | | checkbox.dispatchEvent(new Event('change')); |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | // Enable/disable merge button based on selection |
| | | document.querySelectorAll('.version-checkbox').forEach(checkbox => { |
| | | checkbox.addEventListener('change', updateMergeButtonState); |
| | | }); |
| | | |
| | | // Toggle icon list view |
| | | document.querySelectorAll('.view-icon-list-btn').forEach(btn => { |
| | | btn.addEventListener('click', function() { |
| | | const timestamp = this.getAttribute('data-timestamp'); |
| | | const row = document.getElementById('icon-list-' + timestamp); |
| | | if (row) { |
| | | row.style.display = row.style.display === 'none' ? '' : 'none'; |
| | | this.textContent = row.style.display === 'none' ? '(view)' : '(hide)'; |
| | | } |
| | | }); |
| | | }); |
| | | |
| | | // Force refresh button |
| | | const refreshBtn = document.querySelector('[data-icon-action="refresh-icons"]'); |
| | | if (refreshBtn) { |
| | | refreshBtn.addEventListener('click', function() { |
| | | const originalText = this.innerHTML; |
| | | this.disabled = true; |
| | | this.innerHTML = 'Regenerating...'; |
| | | |
| | | const source = this.getAttribute('data-source') || currentSource; |
| | | makeAdminRequest('admin-icons', { action: 'refresh-icons', source: source }) |
| | | .then(data => { |
| | | handleActionResponse(data, this, originalText); |
| | | }) |
| | | .catch(error => { |
| | | handleActionError(error, this, originalText); |
| | | }); |
| | | |
| | | }); |
| | | } |
| | | |
| | | // Merge versions button |
| | | const mergeBtn = document.getElementById('merge-versions-btn'); |
| | | if (mergeBtn) { |
| | | mergeBtn.addEventListener('click', function() { |
| | | const checkboxes = document.querySelectorAll('.version-checkbox:checked'); |
| | | const timestamps = Array.from(checkboxes).map(cb => parseInt(cb.value)); |
| | | |
| | | if (timestamps.length < 2) { |
| | | alert('Please select at least 2 versions to merge'); |
| | | return; |
| | | } |
| | | |
| | | if (confirm(`Merge ${timestamps.length} versions? This will create a new CSS file with all unique icons.`)) { |
| | | const originalText = this.innerHTML; |
| | | this.disabled = true; |
| | | this.innerHTML = 'Merging...'; |
| | | |
| | | const source = this.getAttribute('data-source') || currentSource; |
| | | makeAdminRequest('admin-icons', { |
| | | action: 'merge-icon-versions', |
| | | source: source, |
| | | timestamps: timestamps |
| | | }) |
| | | .then(data => { |
| | | handleActionResponse(data, this, originalText); |
| | | }) |
| | | .catch(error => { |
| | | handleActionError(error, this, originalText); |
| | | }); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | // Restore version buttons |
| | | document.querySelectorAll('[data-icon-action="restore-icon-version"]').forEach(btn => { |
| | | btn.addEventListener('click', function() { |
| | | const timestamp = parseInt(this.getAttribute('data-timestamp')); |
| | | const source = this.getAttribute('data-source') || currentSource; |
| | | |
| | | if (confirm('Restore this icon version? This will reload the page.')) { |
| | | const originalText = this.innerHTML; |
| | | this.disabled = true; |
| | | this.innerHTML = 'Restoring...'; |
| | | |
| | | makeAdminRequest('admin-icons', { |
| | | action: 'restore-icon-version', |
| | | source: source, |
| | | timestamp: timestamp |
| | | }) |
| | | .then(data => { |
| | | handleActionResponse(data, this, originalText); |
| | | }) |
| | | .catch(error => { |
| | | handleActionError(error, this, originalText); |
| | | }); |
| | | } |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Update merge button state based on selected checkboxes |
| | | */ |
| | | function updateMergeButtonState() { |
| | | const checkedCount = document.querySelectorAll('.version-checkbox:checked').length; |
| | | const mergeBtn = document.getElementById('merge-versions-btn'); |
| | | if (mergeBtn) { |
| | | mergeBtn.disabled = checkedCount < 2; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Make an admin API request |
| | | * @param {string} endpoint - The API endpoint (without 'jvb/v1/') |
| | | * @param {object} data - The data to send |
| | | * @returns {Promise} |
| | | */ |
| | | function makeAdminRequest(endpoint, data = {}) { |
| | | if (typeof jvbSettings === 'undefined') { |
| | | console.error('jvbSettings is not defined'); |
| | | return Promise.reject(new Error('jvbSettings is not defined. Scripts may not be loaded correctly.')); |
| | | } |
| | | |
| | | console.log('Making request to:', jvbSettings.api + endpoint, 'with data:', data); |
| | | |
| | | return fetch(jvbSettings.api + endpoint, { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/json', |
| | | 'X-WP-Nonce': jvbSettings.nonce, |
| | | 'X-Action-Nonce': jvbSettings.action |
| | | }, |
| | | body: JSON.stringify(data) |
| | | }) |
| | | .then(response => { |
| | | console.log('Response status:', response.status); |
| | | if (!response.ok) { |
| | | return response.json().then(err => { |
| | | throw new Error(err.message || 'Network response was not ok'); |
| | | }); |
| | | } |
| | | return response.json(); |
| | | }) |
| | | .then(data => { |
| | | console.log('Response data:', data); |
| | | return data; |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Handle successful action response |
| | | */ |
| | | function handleActionResponse(data, button = null, originalText = null) { |
| | | if (!data.success) { |
| | | throw new Error(data.message || 'Unknown error'); |
| | | } |
| | | |
| | | if (button && originalText) { |
| | | button.innerHTML = '✓ Success!'; |
| | | setTimeout(() => { |
| | | button.disabled = false; |
| | | button.innerHTML = originalText; |
| | | }, 1500); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle action error |
| | | */ |
| | | function handleActionError(error, button = null, originalText = null) { |
| | | console.error('Error:', error); |
| | | |
| | | if (button && originalText) { |
| | | button.innerHTML = '✗ ' + (error.message || 'Error'); |
| | | setTimeout(() => { |
| | | button.disabled = false; |
| | | button.innerHTML = originalText; |
| | | }, 2000); |
| | | } |
| | | } |
| New file |
| | |
| | | /** |
| | | * 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.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.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.initCart(); |
| | | |
| | | if (this.checkout) { |
| | | this.initElements(); |
| | | this.init(); // provider-specific |
| | | this.initListeners(); |
| | | |
| | | if (this.isLoggedIn) { |
| | | this.loadSavedCards(); |
| | | } |
| | | } |
| | | |
| | | this.popup = new window.jvbPopup({ |
| | | popup: this.checkout, |
| | | toggle: this.toggle, |
| | | name: 'Cart', |
| | | onOpen: this.maybeAddEmptyState.bind(this), |
| | | }); |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * 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() { |
| | | this.cartItems = await this.cache.get('cart') ?? new Map(); |
| | | if (this.cartItems.size > 0) { |
| | | this.notifyRestoredCart(); |
| | | } |
| | | } |
| | | |
| | | saveCart() { |
| | | this.updateTotal(); |
| | | this.cache.set('cart', this.cartItems); |
| | | } |
| | | |
| | | clearCart() { |
| | | this.cartItems.clear(); |
| | | window.removeChildren(this.table); |
| | | this.saveCart(); |
| | | } |
| | | |
| | | getCartId() { |
| | | if (!this.cartId) { |
| | | this.cartId = crypto.randomUUID(); |
| | | this.cache.set('cart_id', this.cartId); |
| | | } |
| | | return this.cartId; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * ELEMENT SETUP |
| | | *****************************************************************/ |
| | | |
| | | 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 |
| | | }); |
| | | } |
| | | |
| | | 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); |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * EVENT HANDLERS |
| | | *****************************************************************/ |
| | | |
| | | 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(); |
| | | } 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 = 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); |
| | | } |
| | | } |
| | | } |
| | | |
| | | 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); |
| | | } |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * 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); |
| | | |
| | | this.cartItems.set(id, { |
| | | post_id: id, |
| | | name: item.dataset.name, |
| | | price: price, |
| | | quantity: quantity, |
| | | total: total, |
| | | catalog_id: item.dataset.catalogId || '', |
| | | }); |
| | | this.saveCart(); |
| | | } |
| | | |
| | | handleRemoveFromCart(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; |
| | | 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.maybeAddEmptyState(); |
| | | this.saveCart(); |
| | | } |
| | | } |
| | | |
| | | 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 step = parseInt(container.dataset.step); |
| | | let input = 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 = 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 (value <= min) { |
| | | input.value = min; |
| | | decrease.disabled = true; |
| | | } else if (value >= max) { |
| | | input.value = max; |
| | | increase.disabled = true; |
| | | } else { |
| | | increase.disabled = false; |
| | | decrease.disabled = false; |
| | | } |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * ITEM ELEMENT CREATION |
| | | *****************************************************************/ |
| | | |
| | | 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.catalogId, |
| | | ] = [ |
| | | item.dataset.id, |
| | | item.dataset.name, |
| | | window.formatPrice(price), |
| | | price, |
| | | item.dataset.catalogId || '', |
| | | ]; |
| | | } 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); |
| | | } |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * CART STATE |
| | | *****************************************************************/ |
| | | |
| | | 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 emptyEl = window.getTemplate('emptyCart'); |
| | | this.itemsList.append(emptyEl); |
| | | 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'; |
| | | } |
| | | } |
| | | |
| | | notifyRestoredCart() { |
| | | let restored = window.getTemplate('restoredCart'); |
| | | this.checkout.querySelector('.tab-content[data-tab=cartItems]').insertBefore(restored, this.itemsList); |
| | | |
| | | 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.updateTotal(); |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * TOTALS |
| | | *****************************************************************/ |
| | | |
| | | updateTotal() { |
| | | 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'); |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * 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 { |
| | | window.jvbLoading?.showLoading?.('Processing payment...'); |
| | | const result = await this.processPayment(orderData); |
| | | this.handleSuccess(result, form); |
| | | } catch (error) { |
| | | this.handleError(error); |
| | | } finally { |
| | | window.jvbLoading?.hideLoading?.(); |
| | | } |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * ORDER TRACKING |
| | | *****************************************************************/ |
| | | |
| | | trackOrder(orderNum) { |
| | | this.orderId = orderNum; |
| | | this.scheduleOrderCheck(); |
| | | this.checkout.querySelector('button[data-tab=order]').hidden = false; |
| | | } |
| | | |
| | | scheduleOrderCheck() { |
| | | window.debouncer.schedule( |
| | | 'order', |
| | | () => this.checkOrderStatus(), |
| | | 30000 |
| | | ); |
| | | } |
| | | |
| | | async checkOrderStatus() { |
| | | const response = await fetch(`${this.config.api_url}order-status/${this.orderId}`, { |
| | | headers: { 'X-WP-Nonce': this.config.nonce } |
| | | }); |
| | | 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; |
| | | |
| | | 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; |
| | | |
| | | 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; |
| New file |
| | |
| | | /** |
| | | * HelcimCheckout — extends CartCheckout for HelcimPay.js payments |
| | | * |
| | | * Payment flow: |
| | | * 1. User clicks checkout → extractOrderData() |
| | | * 2. Server call to /helcim/initialize-checkout → returns checkoutToken |
| | | * 3. Call appendHelcimPayIframe(checkoutToken) → Helcim renders modal |
| | | * 4. Listen for window 'message' event → SUCCESS / CANCELLED / ERROR |
| | | * 5. On SUCCESS, validate transaction server-side |
| | | * |
| | | * @see https://devdocs.helcim.com/docs/helcim-pay-js |
| | | */ |
| | | class CheckoutHelcim extends window.jvbCheckout { |
| | | constructor(config = {}) { |
| | | super({ |
| | | ...window.helcimConfig, |
| | | ...config, |
| | | }); |
| | | this.pendingSecretToken = null; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * INIT — HelcimPay.js SDK (loaded externally) |
| | | *****************************************************************/ |
| | | |
| | | async init() { |
| | | // HelcimPay.js is loaded via <script> tag, no SDK init needed. |
| | | // We just need the global appendHelcimPayIframe function. |
| | | if (typeof window.appendHelcimPayIframe !== 'function') { |
| | | console.warn('HelcimPay.js SDK not loaded — payment will initialize on first checkout'); |
| | | } |
| | | |
| | | this.isInitialized = true; |
| | | |
| | | // Listen for HelcimPay.js message events |
| | | window.addEventListener('message', (e) => this.handleHelcimMessage(e)); |
| | | |
| | | document.dispatchEvent(new CustomEvent('checkoutReady', { |
| | | detail: { checkout: this, provider: 'helcim' } |
| | | })); |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * PAYMENT FLOW |
| | | *****************************************************************/ |
| | | |
| | | async processPayment(orderData) { |
| | | // If using a saved card, process server-side directly |
| | | if (this.selectedCardId) { |
| | | return this.submitToServer({ |
| | | card_id: this.selectedCardId, |
| | | is_saved: true, |
| | | }, orderData); |
| | | } |
| | | |
| | | // Otherwise, initialize HelcimPay.js checkout |
| | | const session = await this.initializeCheckoutSession(orderData); |
| | | if (!session.success) { |
| | | throw new Error(session.message || 'Failed to initialize checkout'); |
| | | } |
| | | |
| | | // Store secretToken for server-side validation after payment |
| | | this.pendingSecretToken = session.secretToken; |
| | | this.pendingOrderData = orderData; |
| | | |
| | | // Open HelcimPay.js iframe modal |
| | | window.appendHelcimPayIframe(session.checkoutToken, { |
| | | type: 'modal', // 'modal' or 'inline' |
| | | }); |
| | | |
| | | // The flow continues in handleHelcimMessage() when the iframe posts back |
| | | // Return a promise that resolves when payment completes |
| | | return new Promise((resolve, reject) => { |
| | | this._paymentResolve = resolve; |
| | | this._paymentReject = reject; |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Server call: initialize a HelcimPay.js checkout session |
| | | */ |
| | | async initializeCheckoutSession(orderData) { |
| | | const response = await fetch(this.config.api_url + 'initialize-checkout', { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/json', |
| | | 'X-WP-Nonce': this.config.nonce, |
| | | }, |
| | | body: JSON.stringify({ |
| | | amount: orderData.total / 100, // Convert cents back to dollars |
| | | customer: orderData.customer, |
| | | items: orderData.items, |
| | | cart_id: this.getCartId(), |
| | | }), |
| | | }); |
| | | |
| | | return response.json(); |
| | | } |
| | | |
| | | /** |
| | | * Handle postMessage events from HelcimPay.js iframe |
| | | */ |
| | | handleHelcimMessage(event) { |
| | | const data = event.data; |
| | | |
| | | // HelcimPay.js sends messages with specific event types |
| | | if (!data || typeof data !== 'object') return; |
| | | |
| | | // Helcim sends eventStatus: 'ABORTED' | 'SUCCESS' | 'FAILED' |
| | | if (data.eventStatus === 'SUCCESS') { |
| | | this.handleHelcimSuccess(data); |
| | | } else if (data.eventStatus === 'ABORTED') { |
| | | this.handleHelcimCancelled(); |
| | | } else if (data.eventStatus === 'FAILED') { |
| | | this.handleHelcimError(data); |
| | | } |
| | | } |
| | | |
| | | async handleHelcimSuccess(data) { |
| | | try { |
| | | // Validate the transaction server-side using secretToken |
| | | const result = await this.submitToServer({ |
| | | transaction_id: data.transactionId, |
| | | secret_token: this.pendingSecretToken, |
| | | event_data: data, |
| | | }, this.pendingOrderData); |
| | | |
| | | this.clearPending(); |
| | | this._paymentResolve?.(result); |
| | | } catch (error) { |
| | | this.clearPending(); |
| | | this._paymentReject?.(error); |
| | | } |
| | | } |
| | | |
| | | handleHelcimCancelled() { |
| | | this.clearPending(); |
| | | window.jvbLoading?.hideLoading?.(); |
| | | this.a11y.announce('Payment cancelled'); |
| | | this._paymentReject?.(new Error('Payment cancelled by user')); |
| | | } |
| | | |
| | | handleHelcimError(data) { |
| | | this.clearPending(); |
| | | window.jvbLoading?.hideLoading?.(); |
| | | const message = data.errorMessage || 'Payment failed'; |
| | | this._paymentReject?.(new Error(message)); |
| | | } |
| | | |
| | | clearPending() { |
| | | this.pendingSecretToken = null; |
| | | this.pendingOrderData = null; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * SERVER COMMUNICATION |
| | | *****************************************************************/ |
| | | |
| | | async submitToServer(paymentData, orderData) { |
| | | if (!this.isOpen) { |
| | | throw new Error('Store is currently closed'); |
| | | } |
| | | |
| | | const endpoint = paymentData.is_saved |
| | | ? 'process-saved-payment' |
| | | : 'validate-transaction'; |
| | | |
| | | const response = await fetch(this.config.api_url + endpoint, { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/json', |
| | | 'X-WP-Nonce': this.config.nonce, |
| | | }, |
| | | body: JSON.stringify({ |
| | | ...paymentData, |
| | | 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; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * SAVED CARDS |
| | | *****************************************************************/ |
| | | |
| | | 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); |
| | | } |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * INVOICES — Helcim-specific (source of truth is Helcim) |
| | | *****************************************************************/ |
| | | |
| | | async loadInvoices() { |
| | | try { |
| | | const response = await fetch(this.config.api_url + 'invoices', { |
| | | headers: { 'X-WP-Nonce': this.config.nonce }, |
| | | }); |
| | | const result = await response.json(); |
| | | if (result.success) { |
| | | return result.invoices || []; |
| | | } |
| | | } catch (error) { |
| | | console.error('Failed to load invoices:', error); |
| | | } |
| | | return []; |
| | | } |
| | | |
| | | async payInvoice(invoiceId) { |
| | | const session = await fetch(this.config.api_url + 'initialize-checkout', { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/json', |
| | | 'X-WP-Nonce': this.config.nonce, |
| | | }, |
| | | body: JSON.stringify({ |
| | | invoice_id: invoiceId, |
| | | }), |
| | | }).then(r => r.json()); |
| | | |
| | | if (!session.success) { |
| | | throw new Error(session.message || 'Failed to initialize invoice payment'); |
| | | } |
| | | |
| | | this.pendingSecretToken = session.secretToken; |
| | | this.pendingOrderData = { total: 0, items: [], customer: {} }; |
| | | |
| | | window.appendHelcimPayIframe(session.checkoutToken, { type: 'modal' }); |
| | | |
| | | return new Promise((resolve, reject) => { |
| | | this._paymentResolve = resolve; |
| | | this._paymentReject = reject; |
| | | }); |
| | | } |
| | | } |
| | | |
| | | document.addEventListener('DOMContentLoaded', () => { |
| | | // Only init if Helcim is the active provider |
| | | const form = document.querySelector('#checkout[data-provider="helcim"]'); |
| | | if (form) { |
| | | window.jvbHelcim = new CheckoutHelcim(); |
| | | } |
| | | }); |
| New file |
| | |
| | | /** |
| | | * CheckoutSquare — Square Web Payments SDK checkout |
| | | * |
| | | * Extends CartCheckout with: |
| | | * - Square Web Payments SDK initialization |
| | | * - Card tokenization (new card or saved card) |
| | | * - Square-specific API submission |
| | | * - Saved cards via Square Customers API |
| | | * |
| | | * All cart management, totals, UI, order tracking, etc. are |
| | | * inherited from CartCheckout (cart-checkout.min.js). |
| | | * |
| | | * HTML structure: Checkout.php (JVBase\ui\Checkout) |
| | | * - Mounts card form into #payment-container |
| | | * - Uses data-catalog-id (mapped to catalog_object_id for Square API) |
| | | */ |
| | | class CheckoutSquare extends window.jvbCheckout { |
| | | constructor(config = {}) { |
| | | super({ |
| | | ...window.squareConfig, |
| | | ...config, |
| | | }); |
| | | |
| | | // Square-specific |
| | | this.payments = null; |
| | | this.card = null; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * INIT — Square Web Payments SDK |
| | | *****************************************************************/ |
| | | |
| | | 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; |
| | | |
| | | document.dispatchEvent(new CustomEvent('checkoutReady', { |
| | | detail: { checkout: this, provider: 'square' } |
| | | })); |
| | | |
| | | } catch (error) { |
| | | console.error('Failed to initialize Square payments:', error); |
| | | this.handleError(error); |
| | | } |
| | | } |
| | | |
| | | async initializePaymentMethods() { |
| | | const cardContainer = document.getElementById('payment-container'); |
| | | if (!cardContainer) return; |
| | | |
| | | try { |
| | | this.card = await this.payments.card({ |
| | | style: this.getCardStyle() |
| | | }); |
| | | await this.card.attach('#payment-container'); |
| | | |
| | | this.card.addEventListener('cardBrandChanged', (event) => { |
| | | console.log('Card brand:', event.detail.cardBrand); |
| | | }); |
| | | } 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' |
| | | } |
| | | }; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * PAYMENT — Square tokenization |
| | | *****************************************************************/ |
| | | |
| | | async processPayment(orderData) { |
| | | try { |
| | | let sourceToken = null; |
| | | |
| | | 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, |
| | | } |
| | | } |
| | | }); |
| | | |
| | | 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'); |
| | | } |
| | | } |
| | | |
| | | return await this.submitToServer(sourceToken, orderData, !!this.selectedCardId); |
| | | |
| | | } catch (error) { |
| | | console.error('Payment processing failed:', error); |
| | | throw error; |
| | | } |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * SERVER — Square REST endpoint |
| | | *****************************************************************/ |
| | | |
| | | 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; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * ORDER DATA — Square-specific field mapping |
| | | * |
| | | * Base class builds the generic structure. We override to map |
| | | * catalog_id → catalog_object_id for the Square Orders API. |
| | | *****************************************************************/ |
| | | |
| | | extractOrderData(form) { |
| | | const base = super.extractOrderData(form); |
| | | |
| | | // Remap for Square's Orders API |
| | | base.items = base.items.map(item => ({ |
| | | catalog_object_id: item.catalog_id, |
| | | quantity: item.quantity, |
| | | price: item.price, |
| | | note: item.note, |
| | | })); |
| | | |
| | | return base; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * SAVED CARDS — Square Customers API |
| | | *****************************************************************/ |
| | | |
| | | 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); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * BOOTSTRAP — only init if Square is the active provider |
| | | *****************************************************************/ |
| | | |
| | | document.addEventListener('DOMContentLoaded', () => { |
| | | const form = document.querySelector('#checkout[data-provider="square"]'); |
| | | if (form) { |
| | | window.squareCheckout = new CheckoutSquare(); |
| | | } |
| | | }); |
| | |
| | | field: '.field', //querySelectorAll |
| | | label: 'label', |
| | | success: '.success', |
| | | error: '.success', |
| | | error: '.error', |
| | | message: '.validation-message', |
| | | }, |
| | | repeater: { |
| | |
| | | remove: '.remove-tag', |
| | | label: '.tag-label', |
| | | items: '.tag-items', |
| | | item: '.tag-item', |
| | | inputs: this.inputSelectors, //querySelectorAll |
| | | value: 'input[type="hidden"]' //querySelectorAll |
| | | }, |
| | |
| | | }); |
| | | } |
| | | |
| | | if (Object.hasOwn(field.dataset, 'repeater-id') || Object.hasOwn(field.dataset,'tag-list-id')) { |
| | | if (field.dataset.fieldType === 'repeater' || field.dataset.fieldType === 'tag-list') { |
| | | this.updateCollectionField(field); |
| | | return; |
| | | } |
| | |
| | | let conf = this.quantityFields.get(e.target.closest('[data-num-id]')?.dataset.numId); |
| | | if(!conf) return; |
| | | let change = 0; |
| | | if (conf.increase.contains(e.target)) { |
| | | if (conf.ui.increase.contains(e.target)) { |
| | | change++; |
| | | } else if (conf.decrease.contains(e.target)) { |
| | | } else if (conf.ui.decrease.contains(e.target)) { |
| | | change--; |
| | | } |
| | | if (change === 0) return; |
| | | let field = this.getField(e.target); |
| | | let step = conf.input.step; |
| | | let step = conf.ui.input.step; |
| | | step = Math.max(step, 1); |
| | | if (e.ctrlKey && e.shiftKey) { |
| | | step = step * 50; |
| | |
| | | } else if (e.shiftKey) { |
| | | step = step * 10; |
| | | } |
| | | let value = (conf.input.value === '') ? 0 : parseFloat(conf.input.value); |
| | | conf.input.value = (value + (step * change)); |
| | | let value = (conf.ui.input.value === '') ? 0 : parseFloat(conf.ui.input.value); |
| | | conf.ui.input.value = (value + (step * change)); |
| | | |
| | | value = parseFloat(conf.input.value); |
| | | value = parseFloat(conf.ui.input.value); |
| | | |
| | | if (conf.input.min && value < conf.input.min) { |
| | | conf.input.value = conf.input.min; |
| | | conf.decrease.disabled = true; |
| | | } else if (conf.input.max && value > conf.input.max) { |
| | | conf.input.value = conf.input.max; |
| | | conf.increase.disabled = true; |
| | | if (conf.ui.input.min && value < conf.ui.input.min) { |
| | | conf.ui.input.value = conf.ui.input.min; |
| | | conf.ui.decrease.disabled = true; |
| | | } else if (conf.ui.input.max && value > conf.ui.input.max) { |
| | | conf.ui.input.value = conf.ui.input.max; |
| | | conf.ui.increase.disabled = true; |
| | | } else { |
| | | if (conf.decrease.disabled) conf.decrease.disabled = false; |
| | | if (conf.increase.disabled) conf.increase.disabled = false; |
| | | if (conf.ui.decrease.disabled) conf.ui.decrease.disabled = false; |
| | | if (conf.ui.increase.disabled) conf.ui.increase.disabled = false; |
| | | } |
| | | } |
| | | checkForRepeaters(form) { |
| | |
| | | |
| | | |
| | | manyRefs.inputs?.forEach(input => { |
| | | window.prefixInput(input, `${data.repeater.dataset.fieldName}:${index}:`, el); |
| | | window.prefixInput(input, `${data.repeater.dataset.field}:${index}:`, el); |
| | | }); |
| | | } |
| | | }, |
| | |
| | | } |
| | | handleRepeaterClick(e) { |
| | | if (e.target.matches(this.selectors.repeater.add)) { |
| | | console.log('Add Repeater Row'); |
| | | this.addRepeaterRow(e.target.closest('[data-repeater-id]')); |
| | | } else if (e.target.matches(this.selectors.repeater.remove)) { |
| | | console.log('Remove Repeater Row'); |
| | | this.removeRepeaterRow(e.target.closest('[data-index]')); |
| | | } |
| | | } |
| | |
| | | form: form.dataset.formId, |
| | | format: field.dataset.tagFormat??'first_field' |
| | | }; |
| | | console.log('Registering Tag List with config', config); |
| | | if (!config.ui.input || !config.ui.add || !config.ui.items) return; |
| | | |
| | | field.dataset.tagListId = config.id; |
| | | config.fieldName = field.dataset.field; |
| | | |
| | | let template = field.querySelector('template'); |
| | | this.templates.define( |
| | |
| | | el.dataset.index = index; |
| | | manyRefs.inputs?.forEach(input => { |
| | | let wrapper = input.closest('.tag-item'); |
| | | window.prefixInput(input, `${el.dataset.fieldName}:${index}:`, wrapper) |
| | | window.prefixInput(input, `${data.fieldName}:${index}:`, wrapper) |
| | | }); |
| | | |
| | | if (refs.label) { |
| | |
| | | config.ui.inputs = Array.from(field.querySelectorAll(this.selectors.tagList.inputs)); |
| | | config.ui.value = Array.from(field.querySelectorAll(this.selectors.tagList.value)); |
| | | this.tagLists.set(config.id, config); |
| | | console.log('Adding tag list listeners to ', field); |
| | | this.addTagListListeners(field); |
| | | }); |
| | | |
| | |
| | | handleTagListClick(e) { |
| | | if (window.targetCheck(e,this.selectors.tagList.add)) { |
| | | this.addTagListItem(e.target.closest('[data-tag-list-id]')); |
| | | } else if (e.target.matches(this.selectors.tagList.remove)) { |
| | | this.removeTagListItem(e.target.closest(this.selectors.tagList.remove)); |
| | | } else if (window.targetCheck(e, this.selectors.tagList.remove)) { |
| | | this.removeTagListItem(e.target.closest(this.selectors.tagList.item)); |
| | | } |
| | | } |
| | | addTagListItem(tagList) { |
| | | let config = this.tagLists.get(tagList.dataset.tagListId); |
| | | if (!config) return; |
| | | addTagListItem(tagList) { |
| | | let config = this.tagLists.get(tagList.dataset.tagListId); |
| | | if (!config) return; |
| | | |
| | | let data = {}; |
| | | let hasValue = false; |
| | | for (let input of config.ui.inputs) { |
| | | this.validateField(input); |
| | | const fieldName = input.name.replace('new_',''); |
| | | const value = this.getFieldValue(input); |
| | | if (value) hasValue = true; |
| | | data[fieldName] = value; |
| | | let data = {}; |
| | | let hasValue = false; |
| | | let isValid = true; |
| | | |
| | | //clear values and validation |
| | | if (['checkbox', 'radio'].includes(input.type)) { |
| | | input.checked = false; |
| | | } else { |
| | | input.value = ''; |
| | | // First pass: validate all inputs |
| | | for (let input of config.ui.inputs) { |
| | | const isRequired = input.required || input.dataset.required === 'true'; |
| | | const value = this.getFieldValue(input); |
| | | |
| | | if (value) hasValue = true; |
| | | |
| | | // Validate and check for errors |
| | | const valid = this.validateField(input); |
| | | |
| | | if (isRequired && !value) { |
| | | this.showError(input, 'This field is required'); |
| | | isValid = false; |
| | | } else if (!valid) { |
| | | isValid = false; |
| | | } |
| | | |
| | | const fieldName = input.name.replace('new_',''); |
| | | data[fieldName] = value; |
| | | } |
| | | |
| | | // Stop if validation failed |
| | | if (!isValid) { |
| | | this.a11y.announce('Please correct the errors before adding'); |
| | | const firstInvalid = config.ui.inputs.find(input => { |
| | | const isRequired = input.required || input.dataset.required === 'true'; |
| | | return (isRequired && !this.getFieldValue(input)); |
| | | }); |
| | | if (firstInvalid) firstInvalid.focus(); |
| | | return; |
| | | } |
| | | |
| | | if (!hasValue) { |
| | | this.a11y.announce('Please fill in at least one field'); |
| | | config.ui.inputs[0].focus(); |
| | | return; |
| | | } |
| | | |
| | | // Build label |
| | | let label; |
| | | switch (config.format) { |
| | | case 'first_field': |
| | | label = Object.values(data)[0]; |
| | | break; |
| | | case 'all_fields': |
| | | label = Object.values(data).join(', '); |
| | | break; |
| | | default: |
| | | if (config.format.includes('{')) { |
| | | label = config.format; |
| | | for (const [key, value] of Object.entries(data)) { |
| | | label = label.replace(`{${key}}`, value); |
| | | } |
| | | this.clearValidation(input); |
| | | } else { |
| | | label = data[config.format]??Object.values(data)[0]; |
| | | } |
| | | break; |
| | | } |
| | | |
| | | if (!hasValue) { |
| | | this.a11y.announce('Please fill in at least one field'); |
| | | config.ui.inputs[0].focus(); |
| | | return; |
| | | } |
| | | let newItem = this.templates.create(tagList.dataset.tagListId, { |
| | | label: label, |
| | | fieldName: config.fieldName |
| | | }); |
| | | |
| | | let label; |
| | | switch (config.format) { |
| | | case 'first_field': |
| | | label = Object.values(data)[0]; |
| | | break; |
| | | case 'all_fields': |
| | | label = Object.values(data).join(', '); |
| | | break; |
| | | default: |
| | | if (config.format.includes('{')) { |
| | | let label = config.format; |
| | | for (const [key, value] of Object.entries(data)) { |
| | | label = label.replace(`{${key}}`, value); |
| | | } |
| | | } else { |
| | | label = data[config.format]??Object.values(data)[0]; |
| | | } |
| | | break; |
| | | } |
| | | const index = config.ui.items?.children?.length ?? 0; |
| | | newItem?.querySelectorAll('input[type=hidden]')?.forEach(input => { |
| | | const fieldKey = input.dataset.field; |
| | | input.name = `${config.fieldName}:${index}:${fieldKey}`; |
| | | input.id = `${config.fieldName}:${index}:${fieldKey}`; |
| | | input.value = data[fieldKey] || ''; |
| | | }); |
| | | |
| | | let newItem = this.templates.create(tagList.dataset.tagListId, { |
| | | label: label |
| | | }); |
| | | config.ui.items.append(newItem); |
| | | |
| | | const index = config.ui.items?.children?.length ?? 0; |
| | | newItem?.querySelectorAll('input[type=hidden]')?.forEach(input => { |
| | | const fieldKey = input.dataset.field; |
| | | input.name = `${config.element.field}:${index}:${fieldKey}`; |
| | | input.value = data[fieldKey] || ''; |
| | | }); |
| | | |
| | | config.ui.items.append(newItem); |
| | | config.ui.inputs[0]?.focus(); |
| | | |
| | | this.updateCollectionField(tagList); |
| | | |
| | | this.a11y.announce('Item added'); |
| | | // Clear inputs AFTER success |
| | | for (let input of config.ui.inputs) { |
| | | if (['checkbox', 'radio'].includes(input.type)) { |
| | | input.checked = false; |
| | | } else { |
| | | input.value = ''; |
| | | } |
| | | removeTagListItem(tag) { |
| | | let tagList = tag.closest('[data-tag-list-id]'); |
| | | tag.remove(); |
| | | this.reindexList(tagList); |
| | | this.a11y.announce('Item removed'); |
| | | } |
| | | this.clearValidation(input); |
| | | } |
| | | |
| | | config.ui.inputs[0]?.focus(); |
| | | this.updateCollectionField(tagList); |
| | | this.a11y.announce('Item added'); |
| | | } |
| | | removeTagListItem(item) { |
| | | let tagList = item.closest('[data-tag-list-id]'); |
| | | if (!tagList) return; |
| | | item.remove(); |
| | | this.reindexList(tagList); |
| | | this.updateCollectionField(tagList); |
| | | this.a11y.announce('Item removed'); |
| | | } |
| | | handleTagListInput(e) { |
| | | let target = e.target; |
| | | let field = target.closest('[data-tag-list-id]'); |
| | |
| | | if (data.field) { |
| | | const fieldWrapper = form.querySelector(`[data-field="${data.field}"]`); |
| | | if (fieldWrapper) { |
| | | // Use existing showError method for consistency |
| | | this.showError(fieldWrapper, data.message); |
| | | |
| | | // Mark as touched so validation persists |
| | | this.touchedFields.add(data.field); |
| | | |
| | | // Scroll to error |
| | | fieldWrapper.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| | | |
| | | // Focus the input for better UX |
| | | const input = fieldWrapper.querySelector('input, textarea, select'); |
| | | if (input) { |
| | | input.focus(); |
| | | } |
| | | } |
| | | } else { |
| | | // General form error (not field-specific) |
| | | const error = document.createElement('div'); |
| | | error.className = 'form-error error-message'; |
| | | error.textContent = data.message; |
| | | |
| | | // Add icon for consistency |
| | | const icon = window.getIcon?.('close-circle'); |
| | | if (icon) { |
| | | icon.classList.add('error-icon'); |
| | |
| | | } |
| | | |
| | | form.insertBefore(error, form.firstChild); |
| | | |
| | | // Scroll to top to show the error |
| | | form.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
| | | } |
| | | |
| | | // Announce error for accessibility |
| | | if (window.jvbA11y) { |
| | | const announcement = data.field |
| | | ? `Error in ${data.field}: ${data.message}` |
| | |
| | | window.jvbA11y.announce(announcement); |
| | | } |
| | | |
| | | // Trigger custom event |
| | | form.dispatchEvent(new CustomEvent('jvb-form-error', { |
| | | detail: data |
| | | })); |
| | |
| | | **********************************************************************/ |
| | | getForm(element) { |
| | | let form = element.closest('[data-form-id]'); |
| | | if (!form) return false; |
| | | let id = form.dataset.formId; |
| | | if (!id) return false; |
| | | let config = this.forms.get(id); |
| | |
| | | if (!value || !Array.isArray(value)) return; |
| | | |
| | | const container = field.querySelector('.repeater-items'); |
| | | let template = field.querySelector('template')?.className??false; |
| | | let template = field.querySelector('template')?.className ?? false; |
| | | if (!container || !template) return; |
| | | |
| | | window.removeChildren(container); |
| | |
| | | value.forEach((data, index) => { |
| | | data.index = index; |
| | | const row = this.templates.create(template, data); |
| | | let fields = row.querySelectorAll('.field'); |
| | | this.populate(fields, data); |
| | | if (!row) return; |
| | | |
| | | for (let [fieldName, fieldValue] of Object.entries(data)) { |
| | | if (fieldName === 'index') continue; |
| | | let subField = row.querySelector(`[data-field="${fieldName}"]`); |
| | | if (subField) { |
| | | this.populateField(subField, fieldName, fieldValue); |
| | | } |
| | | } |
| | | |
| | | container.append(row); |
| | | }); |
| | | } |
| | |
| | | if (!value || !Array.isArray(value)) return; |
| | | |
| | | const container = field.querySelector('.tag-items'); |
| | | let template = field.querySelector('template')?.className??false; |
| | | let template = field.querySelector('template')?.className ?? false; |
| | | if (!container || !template) return; |
| | | |
| | | window.removeChildren(container); |
| | | |
| | | value.forEach((data, index) => { |
| | | data.index = index; |
| | | const row = this.templates.create(template, data); |
| | | let fields = row.querySelectorAll('.field'); |
| | | this.populate(fields, data); |
| | | const row = this.templates.create(template, { |
| | | label: this.getTagLabel(data, field.dataset.tagFormat ?? 'first_field'), |
| | | fieldName: name, |
| | | ...data |
| | | }); |
| | | if (!row) return; |
| | | |
| | | // Set hidden input values directly |
| | | row.querySelectorAll('input[type="hidden"]').forEach(input => { |
| | | const key = input.dataset.field; |
| | | if (key && data[key] !== undefined) { |
| | | input.value = data[key]; |
| | | } |
| | | }); |
| | | |
| | | container.append(row); |
| | | }); |
| | | } |
| | | /** |
| | | * Build tag label from data - mirrors addTagListItem logic |
| | | */ |
| | | getTagLabel(data, format) { |
| | | const values = Object.values(data).filter(v => !this.isEmptyValue(v)); |
| | | switch (format) { |
| | | case 'first_field': |
| | | return values[0] ?? 'New Item'; |
| | | case 'all_fields': |
| | | return values.join(', ') || 'New Item'; |
| | | default: |
| | | if (format.includes('{')) { |
| | | let label = format; |
| | | for (const [key, value] of Object.entries(data)) { |
| | | label = label.replace(`{${key}}`, value); |
| | | } |
| | | return label; |
| | | } |
| | | return data[format] ?? values[0] ?? 'New Item'; |
| | | } |
| | | } |
| | | |
| | | populateLocation(field, name, value) { |
| | | const subFields = ['address', 'lat', 'lng', 'street', 'city', 'province', 'postal_code', 'country']; |
| | | subFields.forEach(subField => { |
| | |
| | | window.auth=new class{constructor(){this.initialized=!1,this.isAuthenticating=!1,this.authenticated=!1,this.user=!1,this.nonces={},this.subscribers=new Set,this.storageKey="jvb_auth_state",this.cacheMetaKey="jvb_auth_meta",this.cacheExpiry=3e5,this.init()}async init(){if(this.isAuthenticating)return new Promise((t=>{const e=setInterval((()=>{this.initialized&&(clearInterval(e),t())}),50)}));this.isAuthenticating=!0;try{const t=this.getCachedAuth();if(t)return this.setAuthData(t),this.initialized=!0,this.isAuthenticating=!1,void this.notify("auth-loaded",{fromCache:!0});await this.fetchAuth()}catch(t){console.error("Failed to initialize auth:",t),this.clearAuthData(),this.initialized=!0,this.isAuthenticating=!1,this.notify("auth-error",{error:t})}}async refreshNonce(t="wp_rest"){try{return await this.fetchAuth(),this.getNonce(t)}catch(t){return console.error("Failed to refresh nonce:",t),null}}async fetch(t,e={}){const i=async(s=0)=>{const a={"Content-Type":"application/json",...e.headers,"X-WP-Nonce":this.getNonce()},h=await fetch(t,{...e,credentials:"same-origin",headers:a});if((403===h.status||401===h.status)&&0===s){const t=await h.clone().json();if("rest_cookie_invalid_nonce"===t.code||t.message?.includes("Cookie check"))return console.log("Nonce invalid, refreshing auth..."),await this.refresh(),i(1)}return h};return i()}async fetchAuth(){const t=await fetch(`${jvbSettings.api}auth/status`,{method:"GET",credentials:"same-origin",headers:{"Content-Type":"application/json"}});console.log(t);const e=await t.json();if(console.log(e),!t.ok)throw new Error("Auth check failed");const i=sessionStorage.getItem(this.cacheMetaKey);if(i){const t=JSON.parse(i);t.session_id&&t.session_id!==e.session_id&&(this.clearCachedAuth(),this.notify("session-changed",{}))}this.cacheAuth(e),this.setAuthData(e),this.initialized=!0,this.isAuthenticating=!1,this.notify("auth-loaded",{fromCache:!1})}setAuthData(t){this.authenticated=t.authenticated||!1,this.user=t.user||!1,this.nonces=t.nonces||{}}clearAuthData(){this.authenticated=!1,this.user=null,this.nonces={},sessionStorage.removeItem(this.storageKey),sessionStorage.removeItem(this.cacheMetaKey)}getCachedAuth(){try{const t=sessionStorage.getItem(this.storageKey),e=sessionStorage.getItem(this.cacheMetaKey);if(!t||!e)return null;const i=JSON.parse(e),s=JSON.parse(t);return Date.now()-i.timestamp>this.cacheExpiry?(this.clearCachedAuth(),null):s}catch(t){return console.error("Error reading cached auth:",t),null}}cacheAuth(t){try{sessionStorage.setItem(this.storageKey,JSON.stringify(t)),sessionStorage.setItem(this.cacheMetaKey,JSON.stringify({session_id:t.session_id||null,timestamp:Date.now()}))}catch(t){console.error("Error caching auth:",t)}}clearCachedAuth(){sessionStorage.removeItem(this.storageKey),sessionStorage.removeItem(this.cacheMetaKey)}async refresh(){this.isAuthenticating=!0,this.initialized=!1;try{await this.fetchAuth(),this.notify("auth-refreshed",{})}catch(t){console.error("Failed to refresh auth:",t),this.clearAuthData(),this.initialized=!0,this.isAuthenticating=!1,this.notify("auth-error",{error:t})}}getNonce(t="wp_rest"){return this.nonces[t]||""}getUser(){return this.user}isAuthenticated(){return this.authenticated}async handleLogin(t=null){if(sessionStorage.removeItem(this.storageKey),sessionStorage.removeItem(this.cacheMetaKey),t)return this.cacheAuth(t),this.setAuthData(t),this.initialized=!0,this.isAuthenticating=!1,void this.notify("auth-loaded",{fromCache:!1,fromLogin:!0});await this.refresh()}handleLogout(){this.clearAuthData(),this.notify("logged-out",{})}subscribe(t){return this.subscribers.add(t),this.initialized&&t("auth-loaded",{fromCache:!1,immediate:!0}),()=>this.subscribers.delete(t)}notify(t,e){this.subscribers.forEach((i=>{try{i(t,e)}catch(t){console.error("Subscriber error:",t)}}))}ready(){return this.initialized?Promise.resolve():new Promise((t=>{const e=this.subscribe((i=>{"auth-loaded"!==i&&"auth-error"!==i||(e(),t())}))}))}}; |
| | | window.auth=new class{constructor(){this.initialized=!1,this.isAuthenticating=!1,this.authenticated=!1,this.user=!1,this.nonces={},this.subscribers=new Set,this.storageKey="jvb_auth_state",this.cacheMetaKey="jvb_auth_meta",this.cacheExpiry=3e5,this.init()}async init(){if(this.isAuthenticating)return new Promise((t=>{const e=setInterval((()=>{this.initialized&&(clearInterval(e),t())}),50)}));this.isAuthenticating=!0;try{const t=this.getCachedAuth();if(t)return this.setAuthData(t),this.initialized=!0,this.isAuthenticating=!1,void this.notify("auth-loaded",{fromCache:!0});await this.fetchAuth()}catch(t){console.error("Failed to initialize auth:",t),this.clearAuthData(),this.initialized=!0,this.isAuthenticating=!1,this.notify("auth-error",{error:t})}}async refreshNonce(t="wp_rest"){try{return await this.fetchAuth(),this.getNonce(t)}catch(t){return console.error("Failed to refresh nonce:",t),null}}async fetch(t,e={}){const i=async(s=0)=>{const a={"Content-Type":"application/json",...e.headers,"X-WP-Nonce":this.getNonce()},h=await fetch(t,{...e,credentials:"same-origin",headers:a});if((403===h.status||401===h.status)&&0===s){const t=await h.clone().json();if("rest_cookie_invalid_nonce"===t.code||t.message?.includes("Cookie check"))return console.log("Nonce invalid, refreshing auth..."),await this.refresh(),i(1)}return h};return i()}async fetchAuth(){const t=await fetch(`${jvbSettings.api}auth/status`,{method:"GET",credentials:"same-origin",headers:{"Content-Type":"application/json"}});if(!t.ok)throw new Error("Auth check failed");const e=await t.json(),i=sessionStorage.getItem(this.cacheMetaKey);if(i){const t=JSON.parse(i);t.session_id&&t.session_id!==e.session_id&&(this.clearCachedAuth(),this.notify("session-changed",{}))}this.cacheAuth(e),this.setAuthData(e),this.initialized=!0,this.isAuthenticating=!1,this.notify("auth-loaded",{fromCache:!1})}setAuthData(t){this.authenticated=t.authenticated||!1,this.user=t.user||!1,this.nonces=t.nonces||{}}clearAuthData(){this.authenticated=!1,this.user=null,this.nonces={},sessionStorage.removeItem(this.storageKey),sessionStorage.removeItem(this.cacheMetaKey)}getCachedAuth(){try{const t=sessionStorage.getItem(this.storageKey),e=sessionStorage.getItem(this.cacheMetaKey);if(!t||!e)return null;const i=JSON.parse(e),s=JSON.parse(t);return Date.now()-i.timestamp>this.cacheExpiry?(this.clearCachedAuth(),null):s}catch(t){return console.error("Error reading cached auth:",t),null}}cacheAuth(t){try{sessionStorage.setItem(this.storageKey,JSON.stringify(t)),sessionStorage.setItem(this.cacheMetaKey,JSON.stringify({session_id:t.session_id||null,timestamp:Date.now()}))}catch(t){console.error("Error caching auth:",t)}}clearCachedAuth(){sessionStorage.removeItem(this.storageKey),sessionStorage.removeItem(this.cacheMetaKey)}async refresh(){this.isAuthenticating=!0,this.initialized=!1;try{await this.fetchAuth(),this.notify("auth-refreshed",{})}catch(t){console.error("Failed to refresh auth:",t),this.clearAuthData(),this.initialized=!0,this.isAuthenticating=!1,this.notify("auth-error",{error:t})}}getNonce(t="wp_rest"){return this.nonces[t]||""}getUser(){return this.user}isAuthenticated(){return this.authenticated}async handleLogin(t=null){if(sessionStorage.removeItem(this.storageKey),sessionStorage.removeItem(this.cacheMetaKey),t)return this.cacheAuth(t),this.setAuthData(t),this.initialized=!0,this.isAuthenticating=!1,void this.notify("auth-loaded",{fromCache:!1,fromLogin:!0});await this.refresh()}handleLogout(){this.clearAuthData(),this.notify("logged-out",{})}subscribe(t){return this.subscribers.add(t),this.initialized&&t("auth-loaded",{fromCache:!1,immediate:!0}),()=>this.subscribers.delete(t)}notify(t,e){this.subscribers.forEach((i=>{try{i(t,e)}catch(t){console.error("Subscriber error:",t)}}))}ready(){return this.initialized?Promise.resolve():new Promise((t=>{const e=this.subscribe((i=>{"auth-loaded"!==i&&"auth-error"!==i||(e(),t())}))}))}}; |
| New file |
| | |
| | | window.jvbCheckout=class{constructor(t={}){this.config=t,this.isInitialized=!1,this.cartItems=new Map,this.checkout=document.querySelector("aside#cart"),this.provider=this.checkout?.querySelector("form")?.dataset.provider||"",this.isOpen="1"!==this.config.isOpen||!1,this.isLoggedIn=this.config.is_logged_in||!1,this.userEmail=this.config.user_email||"",this.savedCards=[],this.selectedCardId=null,this.cartId=null,this.stepMultiplier=1,this.cache=new window.jvbCache("cart",{TTL:864e5}),this.a11y=window.jvbA11y,this.initCart(),this.checkout&&(this.initElements(),this.init(),this.initListeners(),this.isLoggedIn&&this.loadSavedCards()),this.popup=new window.jvbPopup({popup:this.checkout,toggle:this.toggle,name:"Cart",onOpen:this.maybeAddEmptyState.bind(this)})}async init(){throw new Error("init() must be implemented by subclass")}async processPayment(t){throw new Error("processPayment() must be implemented by subclass")}async submitToServer(t,e){throw new Error("submitToServer() must be implemented by subclass")}async loadSavedCards(){}async initCart(){this.cartItems=await this.cache.get("cart")??new Map,this.cartItems.size>0&&this.notifyRestoredCart()}saveCart(){this.updateTotal(),this.cache.set("cart",this.cartItems)}clearCart(){this.cartItems.clear(),window.removeChildren(this.table),this.saveCart()}getCartId(){return this.cartId||(this.cartId=crypto.randomUUID(),this.cache.set("cart_id",this.cartId)),this.cartId}initElements(){this.toggle=document.querySelector(".toggle-cart"),this.isOpen||(this.toggle.disabled=!0,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:!1})}initListeners(){this.clickHandler=this.handleClick.bind(this),this.keyHandler=this.handleEscape.bind(this),this.changeHandler=this.handleChange.bind(this),this.checkoutForm.addEventListener("submit",(t=>this.handleFormSubmit(t))),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}handleClick(t){if(window.targetCheck(t,"button")&&window.targetCheck(t,"div.quantity")){let e=window.targetCheck(t,"div.quantity");this.handleNumberClick(t,e)}else if(window.targetCheck(t,"[data-add-to-cart]")){let e=window.targetCheck(t,"[data-add-to-cart]");this.handleAddToCart(e)}else if(window.targetCheck(t,"[data-remove-from-cart]")){let e=window.targetCheck(t,"[data-remove-from-cart]");this.handleRemoveFromCart(e)}else window.targetCheck(t,"[data-clear-cart]")?this.clearCart():window.targetCheck(t,"[data-dismiss]")&&window.targetCheck(t,"[data-dismiss]").closest(".restored")?.remove()}handleChange(t){let e=window.targetCheck(t,".quantity-input");if(e){let a=t.target.closest(".quantity"),i=e.value;if(window.targetCheck(t,".cart-items")){let t=document.querySelector(`.menu-section [data-id="${a.dataset.id}"] input`);t&&(t.value=e.value)}i>0?this.handleAddToCart(a):this.handleRemoveFromCart(a)}}handleEscape(t){"Escape"===t.key?this.stepMultiplier=1:t.ctrlKey&&t.shiftKey?this.stepMultiplier=Math.max(100*parseInt(this.stepMultiplier),1e3):t.shiftKey&&(this.stepMultiplier=Math.max(10*parseInt(this.stepMultiplier),1e3))}handleAddToCart(t){let e=t.dataset.id,a=parseFloat(t.dataset.price),i=parseInt(t.querySelector(".quantity-input")?.value)??1,s=parseFloat(a*i);this.createItemElement(t),this.cartItems.set(e,{post_id:e,name:t.dataset.name,price:a,quantity:i,total:s,catalog_id:t.dataset.catalogId||""}),this.saveCart()}handleRemoveFromCart(t){if(confirm("This will remove this item from the cart. Continue?")){t.querySelector("[data-id]")||(t=t.closest(".item")?.querySelector(".quantity.field"));let e=t.dataset.id;this.cartItems.delete(e),this.table.querySelector(`[data-id="${e}"]`)?.closest("tr").remove();let a=document.querySelector(`[data-id="${e}"] input`);a&&(a.value=0),this.maybeAddEmptyState(),this.saveCart()}}handleNumberClick(t,e){t.preventDefault();let a=0;if(t.target.closest(".increase")?a+=1:t.target.closest(".decrease")&&(a-=1),0!==a){let t=parseInt(e.dataset.step),i=e.querySelector("input"),s=""===i.value?0:parseInt(i.value);i.value=s+t*a*this.stepMultiplier,i.dispatchEvent(new Event("change",{bubbles:!0})),this.handleNumberLimits(e)}}handleNumberLimits(t){let e=t.dataset.min,a=t.dataset.max,i=t.querySelector("input"),s=t.querySelector(".increase"),r=t.querySelector(".decrease"),n=parseInt(i.value);n<=e?(i.value=e,r.disabled=!0):n>=a?(i.value=a,s.disabled=!0):(s.disabled=!1,r.disabled=!1)}createItemElement(t){let e=this.itemsList.querySelector(`[data-id="${t.dataset.id}"]`),a=!1,i=t.dataset.price,s=t.querySelector('[name="quantity"]')?.value??1;if(e)e=e.closest("tr");else{a=!0,e=window.getTemplate("cartItem");let s=e.querySelector(".quantity");[s.dataset.id,e.querySelector("label").textContent,e.querySelector(".price").textContent,s.dataset.price,s.dataset.catalogId]=[t.dataset.id,t.dataset.name,window.formatPrice(i),i,t.dataset.catalogId||""]}[e.querySelector('[name="quantity"]').value,e.querySelector(".total").textContent]=[s,window.formatPrice(s*i)],a&&(e.classList.add("adding"),this.table.append(e),setTimeout((()=>e.classList.remove("adding")),500))}maybeAddEmptyState(){let t=this.itemsList.querySelector(".empty");if(t&&t.remove(),0===this.cartItems.size){this.checkoutPanel.disabled=!0,this.checkoutPanel.title="Add some things to your cart first!";let t=window.getTemplate("emptyCart");this.itemsList.append(t),this.table.closest("table").hidden=!0,this.total.hidden=!0,this.a11y.announce("Nothing in Cart")}else this.checkoutPanel.disabled=!1,this.table.closest("table").hidden=!1,this.total.hidden=!1,this.checkoutPanel.title="Checkout"}notifyRestoredCart(){let t=window.getTemplate("restoredCart");this.checkout.querySelector(".tab-content[data-tab=cartItems]").insertBefore(t,this.itemsList),this.cartItems.forEach((t=>{let e=window.getTemplate("cartItem"),a=e.querySelector(".quantity");[a.dataset.id,e.querySelector("label").textContent,e.querySelector(".price").textContent,a.dataset.price,a.dataset.catalogId,e.querySelector('[name="quantity"]').value,e.querySelector(".total").textContent]=[t.post_id,t.name,window.formatPrice(t.price),t.price,t.catalog_id||"",t.quantity,window.formatPrice(t.quantity*t.price)],this.table.append(e)})),this.updateTotal()}updateTotal(){let t=0;this.cartItems.forEach((e=>t+=e.total));let e=.05*t;window.eraseText(this.totalTax),window.eraseText(this.grandTotal),window.typeText(this.totalTax,window.formatPrice(e)),window.typeText(this.grandTotal,window.formatPrice(t+e)),this.totalTax.classList.remove("typeText")}extractOrderData(t){const e=Array.from(this.cartItems.values()).map((t=>({catalog_id:t.catalog_id,quantity:String(t.quantity),price:t.price,note:t.note||""}))),a=e.reduce(((t,e)=>t+e.price*e.quantity),0);return{total:Math.round(100*a),items:e,customer:{email:this.isLoggedIn?this.userEmail:t.querySelector('[name="cart_email"]')?.value||"",name:t.querySelector('[name="cart_name"]')?.value||"",phone:t.querySelector('[name="cart_phone"]')?.value||""},note:t.querySelector('[name="special_instructions"]')?.value||"",pickup_time:t.querySelector('[name="pickup_time"]')?.value||""}}async handleFormSubmit(t){if(!this.isOpen)return;if(t.preventDefault(),!this.isInitialized)return void this.handleError("Checkout not initialized");const e=t.target,a=this.extractOrderData(e);try{window.jvbLoading?.showLoading?.("Processing payment...");const t=await this.processPayment(a);this.handleSuccess(t,e)}catch(t){this.handleError(t)}finally{window.jvbLoading?.hideLoading?.()}}trackOrder(t){this.orderId=t,this.scheduleOrderCheck(),this.checkout.querySelector("button[data-tab=order]").hidden=!1}scheduleOrderCheck(){window.debouncer.schedule("order",(()=>this.checkOrderStatus()),3e4)}async checkOrderStatus(){const t=await fetch(`${this.config.api_url}order-status/${this.orderId}`,{headers:{"X-WP-Nonce":this.config.nonce}}),e=await t.json();"ready"!==e.status&&this.scheduleOrderCheck(),this.updateOrderStatus(e)}updateOrderStatus(t){this.checkout.querySelectorAll(".status-item").forEach((e=>{e.dataset.status===t.status&&e.classList.add("active")})),this.checkout.querySelector("#eta").textContent=t.eta||"In progress"}renderSavedCards(){const t=document.getElementById("saved-cards");if(!t||0===this.savedCards.length)return;const e=`\n\t\t\t<div class="saved-cards-section">\n\t\t\t\t<h4>Saved Payment Methods</h4>\n\t\t\t\t${this.savedCards.map((t=>`\n\t\t\t\t\t<label class="saved-card">\n\t\t\t\t\t\t<input type="radio" name="payment-method" value="saved" data-card-id="${t.id}">\n\t\t\t\t\t\t<span class="card-info">\n\t\t\t\t\t\t\t<strong>${t.card_brand}</strong> ending in ${t.last_4}\n\t\t\t\t\t\t\t<small>Exp: ${t.exp_month}/${t.exp_year}</small>\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</label>\n\t\t\t\t`)).join("")}\n\t\t\t\t<label class="saved-card">\n\t\t\t\t\t<input type="radio" name="payment-method" value="new" checked>\n\t\t\t\t\t<span>Use a new card</span>\n\t\t\t\t</label>\n\t\t\t</div>\n\t\t`;t.innerHTML=e,t.querySelectorAll('input[name="payment-method"]').forEach((t=>{t.addEventListener("change",(t=>{const e="new"===t.target.value,a=document.getElementById("payment-container");a&&(a.style.display=e?"block":"none"),this.selectedCardId=e?null:t.target.dataset.cardId}))}))}handleSuccess(t,e){document.dispatchEvent(new CustomEvent("checkoutSuccess",{detail:{result:t,form:e,provider:this.provider}}));const a=e.dataset.successUrl||`/order-confirmation/?order=${t.order_id||t.wp_order_id}`;window.location.href=a}handleError(t){console.error(`${this.provider} checkout error:`,t),document.dispatchEvent(new CustomEvent("checkoutError",{detail:{error:t,provider:this.provider}})),window.jvbNotifications?.show?.(t.message||t||"Payment failed","error")}}; |
| | |
| | | (()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.queue=window.jvbQueue,this.populate=window.jvbPopulate,this.changes=new Map,this.forms=new Map,this.inputs=new Map,this.repeaters=new Map,this.tagLists=new Map,this.charLimits=new Map,this.quantityFields=new Map,this.quillInstances=new Map,this.dependencies=new Map,this.subscribers=new Set,this.isRestoring=!1,this.hasListeners=!1,this.summaryTemplate=!1,this.init()}init(){this.templates=window.jvbTemplates,this.defineSummaryTemplate(),this.initElements(),this.initListeners(),this.initStore(),this.initValidators()}initElements(){this.inputSelectors="input, textarea, select",this.selectors={tabs:{nav:"nav.tabs",sections:".tab.content",progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},buttons:"nav.tabs button"},dependsOn:"[data-depends-on]",forms:{status:{status:".fstatus",message:".fstatus .message",icon:".fstatus .icon",actions:".fstatus .actions"}},inputs:this.inputSelectors,fields:{field:".field",label:"label",success:".success",error:".success",message:".validation-message"},repeater:{repeater:".repeater",header:".repeater-row-header",remove:".remove-row",add:".add-repeater-row",template:"template",items:".repeater-items",inputs:this.inputSelectors},tagList:{tagList:".field.tag-list",input:".row",add:".add-tag",remove:".remove-tag",label:".tag-label",items:".tag-items",inputs:this.inputSelectors,value:'input[type="hidden"]'},tag:{label:".tag-label"},number:{number:".field div.quantity",increase:"button.increase",decrease:"button.decrease",input:'input[type="number"]'},limits:{hasLimit:"[data-limit]",limit:".limit",current:".current"}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.blurHandler=this.handleBlur.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.quantityClick=this.handleQuantityClick.bind(this),this.repeaterClick=this.handleRepeaterClick.bind(this),this.tagListClick=this.handleTagListClick.bind(this),this.tagListInput=this.handleTagListInput.bind(this)}addFormListeners(e){e.addEventListener("click",this.clickHandler),e.addEventListener("change",this.changeHandler),e.addEventListener("input",this.inputHandler),e.addEventListener("blur",this.blurHandler),e.addEventListener("submit",this.submitHandler)}removeFormListeners(e){e.removeEventListener("click",this.clickHandler),e.removeEventListener("change",this.changeHandler),e.removeEventListener("input",this.inputHandler),e.removeEventListener("blur",this.blurHandler),e.removeEventListener("submit",this.submitHandler)}initStore(){const e=window.jvbStore.register("forms",{storeName:"forms",keyPath:"id",indexes:[{name:"src",keyPath:"src"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:1008e4});this.store=e.forms,this.store.subscribe(((e,t)=>{if("data-ready"===e){let e=this.store.getFiltered().filter((e=>e.src===window.location.pathname));for(let t of e)this.showPendingNotification(t.id,t.changes)}else"operation-status"===e&&"completed"===t.status&&t.config&&this.store.delete(t.config.id)}))}showPendingNotification(e,t){let s=this.forms.get(e);if(!s)return;let i=s.element;if(!i)return void console.warn(`Form element not found for: ${e}`);const a=document.createElement("div");a.className="pendingChanges",a.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n <button class="restore" type="button" data-form-id="${e}">Restore</button>\n <button class="discard" type="button" data-form-id="${e}">Discard</button>`,i.insertBefore(a,s.ui.status.status),a.querySelector(".restore").addEventListener("click",(async()=>{this.isRestoring=!0;let e={fields:t};this.populate.populate(i,e),this.a11y.announce("Previous changes restored"),this.isRestoring=!1,a.remove()})),a.querySelector(".discard").addEventListener("click",(async()=>{await this.store.delete(e),this.a11y.announce("Previous changes discarded"),a.remove()}))}initValidators(){this.validators={email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with https://"},phone:{pattern:/^[\d\s\-+().]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const s=parseFloat(e);if(isNaN(s))return"Please enter a valid number";const i=t.dataset.min,a=t.dataset.max;return void 0!==i&&s<parseFloat(i)?`Value must be at least ${i}`:!(void 0!==a&&s>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const s=t.dataset.minlength,i=t.dataset.maxlength;return s&&e.length<parseInt(s)?`Must be at least ${s} characters`:!(i&&e.length>parseInt(i))||`Must be no more than ${i} characters`}}}}validateField(e){const t=this.performValidation(e);return this.updateValidationUI(e,t),t.isValid}performValidation(e){const t=e.closest(".field"),s=this.getFieldCheckedValue(e);if(!s&&!e.required)return{isValid:!0,message:""};if(e.required)if("checkbox"===e.type){if(!e.checked)return{isValid:!1,message:"This field is required"}}else if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`);if(!Array.from(t).some((e=>e.checked)))return{isValid:!1,message:"Please select an option"}}else if(!s)return{isValid:!1,message:"This field is required"};if(e.checkValidity&&!e.checkValidity())return{isValid:!1,message:e.validationMessage};if(s&&Object.hasOwn(t.dataset,"pattern")){if(!new RegExp(t.dataset.pattern).test(s))return{isValid:!1,message:t.dataset.validationMessage||"Invalid format"}}if(Object.hasOwn(t.dataset,"validate")||e.type){const i=this.validators[t.dataset.validate||e.type];if(i&&i.pattern&&!i.pattern.test(s))return{isValid:!1,message:i.message};if(i&&i.test){const e=i.test(s,t);if(!0!==e)return{isValid:!1,message:e}}}return{isValid:!0,message:""}}updateValidationUI(e,t){t.isValid?this.showSuccess(e,t.message):this.showError(e,t.message)}handleClick(e){let t=this.getForm(e.target);if(!t)return;const s=window.targetCheck(e,"[data-action]");if(s){switch(s.dataset.action){case"clear-form":this.store.delete(t.id),t.element.reset(),t.ui.status.status.hidden=!0,this.a11y.announce("Form cleared, starting fresh");break;case"dismiss-restore":t.ui.status.status.hidden=!0}}}handleChange(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getField(e.target);if(this.dependencies.has(t.dataset.field)){this.dependencies.get(t.dataset.field).items.forEach((e=>{this.checkFieldDependency(e,t.dataset.field)}))}if(Object.hasOwn(t.dataset,"repeater-id")||Object.hasOwn(t.dataset,"tag-list-id"))return void this.updateCollectionField(t);let s=this.getForm(e.target);this.updateItem(t.dataset.field,this.getFieldValue(e.target),s)}handleBlur(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target).dataset.field;window.debouncer.cancel(`form:${t.id}:validate:${s}`),this.validateField(e.target),this.updateItem(s,this.getFieldValue(e.target),t)}handleInput(e){let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target);if(!s)return;const i=e.target,a=s.dataset.field;this.showFormStatus(t.id,"pending"),window.debouncer.schedule(`form:${t.id}:validate:${a}`,(()=>this.validateField(i)),500)}async handleSubmit(e){let t=this.getForm(e.target);if(t){if(this.subscribers.size>0)if(e.preventDefault(),t.options.cache){this.cancelBackup(),await this.backup();const e=await this.store.get(t.id);this.notify("form-submit",{config:t,data:e.changes})}else this.notify("form-submit",{config:t,data:this.changes.get(t.id)?.changes??{}});if(t.options.showSummary){const e=await this.store.get(t.id);this.showSummary({config:t,changes:e?.changes})}}}updateItem(e,t,s){this.changes.has(s.id)||this.changes.set(s.id,{id:s.id,timestamp:Date.now(),src:window.location.pathname,changes:{}});let i=this.changes.get(s.id);i.changes[e]=t,this.changes.set(s.id,i),s.options.cache&&this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule("form_changes",(async()=>{this.changes.size>0&&await this.backup()}),2e3)}cancelBackup(){window.debouncer.cancel("form_changes")}async backup(){const e=new Map;for(let[t,s]of this.changes.entries()){const i=await this.store.get(t);i?e.set(t,{...i,...s,changes:{...i.changes,...s.changes},timestamp:Date.now()}):e.set(t,s)}await this.store.saveMany(e);for(let e of this.changes.keys())this.showFormStatus(e,"autosaved");this.changes.clear()}saveCache(e){if(!this.changes.has(e))return;let t=this.changes.get(e);0!==t.size&&(this.store.save(t).then((()=>{})),this.changes.delete(e))}registerForm(e,t){if(Object.hasOwn(e.dataset,"formId")&&this.forms.has(e.dataset.formId))return;Object.hasOwn(e.dataset,"formId")||(e.dataset.formId=window.generateID("form_"));const s=e.dataset.formId;this.addFormListeners(e);const i={element:e,id:s,status:"",options:{autoUpload:t.autoUpload??!1,imageMeta:t.imageMeta??!0,delay:t.delay??1500,endpoint:t.save??e.dataset.save??"",showStatus:t.showStatus??!0,showSummary:t.showSummary??!1,cache:t.cache??!0,ignore:t.ignore??[]},ui:window.uiFromSelectors(this.selectors.forms,e)};return this.initializeFields(e,i),this.forms.set(s,i),i}clearForm(e){const t=this.forms.get(e);if(!t)return;t.unsubscribeTabs&&t.unsubscribeTabs(),t.tabs&&window.jvbTabs.removeTab(t.element),t.cache&&this.changes.has(e)&&this.saveCache(e);for(let[t,s]of this.inputs.entries())s.form===e&&this.inputs.delete(t);if(this.dependencies.forEach(((t,s)=>{t.items=t.items.filter((t=>t.form!==e)),0===t.items.length&&this.dependencies.delete(s)})),Object.hasOwn(t,"hasQuill")&&this.quillInstances.has(e)){this.quillInstances.get(e).forEach((e=>{e.disable(),e.off("text-change"),e.off("selection-change");const t=e.container.parentElement,s=t?.querySelector(".ql-toolbar");if(s&&s.remove(),e.setText(""),t&&t.classList.contains("editor-container")){const e=t.nextElementSibling;"TEXTAREA"===e?.tagName&&(e.style.display=""),t.remove()}})),this.quillInstances.delete(e)}let s={repeater:this.repeaters,tagList:this.tagLists,charLimit:this.charLimits,quantity:this.quantityFields};for(let[t,i]of Object.entries(s)){if(0===i.size)continue;let s=Array.from(i.values()).filter((t=>t.form===e));s.length>0&&s.forEach((e=>{switch(t){case"repeater":this.removeRepeaterListeners(e.element);break;case"tagList":this.removeTagListListeners(e.element);break;case"charLimit":this.removeCharacterLimitListeners(e.element);break;case"quantity":this.removeQuantityListeners(e.element)}i.has(e.id)&&i.delete(e.id)}))}this.removeFormListeners(t.element),this.forms.delete(e),window.debouncer.cancel("form_changes")}defineSummaryTemplate(){this.summaryTemplate=!0;let e=this;this.templates.define("formSummary",{refs:{result:".result",h3:"h3",p:"p"},setup({el:t,refs:s,manyRefs:i,data:a}){const r=["sendAll",...a.config.options.ignore??[]];for(let[i,n]of Object.entries(a.changes)){if(r.includes(i)||e.isEmptyValue(n))continue;let a=Array.from(e.inputs.values()).find((e=>e.field?.dataset.field===i));if(!a)continue;let l=s.result.cloneNode(!0),o=l.querySelector("h3"),d=l.querySelector("p");const c=a.field?.querySelector("legend");o.textContent=c?c.textContent.replace("*","").trim():a.ui.label?.textContent.replace("*","").trim();const u=e.formatValueForSummary(n,a);u instanceof HTMLElement?d.replaceWith(u):d.textContent=u,t.append(l)}let n=a.config?.element?.querySelectorAll("[data-upload-field]");n&&n.forEach((e=>{let i=e.querySelector("h2")?.textContent??"Upload:",a=e.querySelectorAll(".item-grid.preview img"),r=s.result.cloneNode(!0);if(a){let e=s.result.cloneNode(!0),n=r.querySelector("h3"),l=r.querySelector("p");l?.remove(),n&&(n.textContent=i),a.forEach((t=>{t=t.cloneNode(!0),e.append(t)})),t.append(e)}})),s.result?.remove(),a.config.element.after(t),window.fade(a.config.element,!1)}})}initializeFields(e,t=null){const s={"[data-editor]":()=>this.checkForQuill(e,t),"div.quantity":()=>this.checkForQuantity(e),".repeater":()=>this.checkForRepeaters(e,t),".field.tag-list":()=>this.checkForTagLists(e),"[data-depends-on]":()=>this.checkForConditionalFields(e),"[data-limit]":()=>this.checkForCharacterLimits(e),"[data-uploader],[data-upload-field]":()=>this.checkForImageUploads(e,t),"nav.tabs":()=>this.checkForTabs(e,t),'[data-type="selector"]':()=>this.checkForSelectors(e)};for(const[t,i]of Object.entries(s))e.querySelector(t)&&i();Array.from(e.querySelectorAll(this.inputSelectors)).map((e=>{this.getItem(e,t?.id)}))}checkForQuill(e,t){if(!e.querySelector("[data-editor]"))return;t&&!Object.hasOwn(t,"hasQuill")&&(t.hasQuill=!0,this.forms.set(t.id,t)),this.quillInstances.has(t.id)||this.quillInstances.set(t.id,new Set);window.jvbQuill(e).forEach((e=>{this.quillInstances.get(t.id).add(e)}))}checkForQuantity(e){e.querySelector(this.selectors.number.number)&&e.querySelectorAll(this.selectors.number.number).forEach((t=>{let s={id:window.generateID("quant"),form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.number,t),element:t};t.dataset.numId=s.id,this.quantityFields.set(s.id,s),this.addQuantityListeners(t)}))}addQuantityListeners(e){e.addEventListener("click",this.quantityClick)}removeQuantityListeners(e){e.removeEventListener("click",this.quantityClick)}handleQuantityClick(e){let t=this.quantityFields.get(e.target.closest("[data-num-id]")?.dataset.numId);if(!t)return;let s=0;if(t.increase.contains(e.target)?s++:t.decrease.contains(e.target)&&s--,0===s)return;this.getField(e.target);let i=t.input.step;i=Math.max(i,1),e.ctrlKey&&e.shiftKey?i*=50:e.ctrlKey?i*=5:e.shiftKey&&(i*=10);let a=""===t.input.value?0:parseFloat(t.input.value);t.input.value=a+i*s,a=parseFloat(t.input.value),t.input.min&&a<t.input.min?(t.input.value=t.input.min,t.decrease.disabled=!0):t.input.max&&a>t.input.max?(t.input.value=t.input.max,t.increase.disabled=!0):(t.decrease.disabled&&(t.decrease.disabled=!1),t.increase.disabled&&(t.increase.disabled=!1))}checkForRepeaters(e){e.querySelector(this.selectors.repeater.repeater)&&e.querySelectorAll(this.selectors.repeater.repeater).forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("repeater"),ui:window.uiFromSelectors(this.selectors.repeater,t),form:e.dataset.formId,element:t,field:this.getField(t),sortable:!1};if(!s.ui.add)return;let i=t.querySelector("template");this.templates.define(i.className,{manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{window.prefixInput(t,`${a.repeater.dataset.fieldName}:${r}:`,e)}))}}),window.Sortable&&(s.sortable=new Sortable(t,{handle:this.selectors.repeater.header,animation:150,onEnd:()=>{this.reindexList(t)}})),t.dataset.repeaterId=s.id,this.addRepeaterListeners(t),this.repeaters.set(s.id,s)}))}addRepeaterListeners(e){e.addEventListener("click",this.repeaterClick)}removeRepeaterListeners(e){e.removeEventListener("click",this.repeaterClick)}handleRepeaterClick(e){e.target.matches(this.selectors.repeater.add)?(console.log("Add Repeater Row"),this.addRepeaterRow(e.target.closest("[data-repeater-id]"))):e.target.matches(this.selectors.repeater.remove)&&(console.log("Remove Repeater Row"),this.removeRepeaterRow(e.target.closest("[data-index]")))}addRepeaterRow(e){let t={};t.repeater=e,e.append(this.templates.create(e.dataset.repeaterId,t)),this.initializeFields(e,this.getField(e).config??{}),this.a11y.announce("Row added")}removeRepeaterRow(e){let t=e.closest("[data-repeater-id]");e.remove(),this.reindexList(t),this.a11y.announce("Row removed")}checkForTagLists(e){e.querySelectorAll(this.selectors.tagList.tagList)?.forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("tagList"),ui:window.uiFromSelectors(this.selectors.tagList,t),element:t,form:e.dataset.formId,format:t.dataset.tagFormat??"first_field"};if(console.log("Registering Tag List with config",s),!s.ui.input||!s.ui.add||!s.ui.items)return;t.dataset.tagListId=s.id;let i=t.querySelector("template");this.templates.define(i.className,{refs:{label:this.selectors.tagList.label},manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{let s=t.closest(".tag-item");window.prefixInput(t,`${e.dataset.fieldName}:${r}:`,s)})),t.label&&(t.label.textContent=a.label)}}),s.ui.inputs=Array.from(t.querySelectorAll(this.selectors.tagList.inputs)),s.ui.value=Array.from(t.querySelectorAll(this.selectors.tagList.value)),this.tagLists.set(s.id,s),console.log("Adding tag list listeners to ",t),this.addTagListListeners(t)}))}addTagListListeners(e){e.addEventListener("click",this.tagListClick),e.addEventListener("keypress",this.tagListInput,{passive:!0})}removeTagListListeners(e){e.removeEventListener("click",this.tagListClick),e.removeEventListener("keypress",this.tagListInput)}handleTagListClick(e){window.targetCheck(e,this.selectors.tagList.add)?this.addTagListItem(e.target.closest("[data-tag-list-id]")):e.target.matches(this.selectors.tagList.remove)&&this.removeTagListItem(e.target.closest(this.selectors.tagList.remove))}addTagListItem(e){let t=this.tagLists.get(e.dataset.tagListId);if(!t)return;let s,i={},a=!1;for(let e of t.ui.inputs){this.validateField(e);const t=e.name.replace("new_",""),s=this.getFieldValue(e);s&&(a=!0),i[t]=s,["checkbox","radio"].includes(e.type)?e.checked=!1:e.value="",this.clearValidation(e)}if(!a)return this.a11y.announce("Please fill in at least one field"),void t.ui.inputs[0].focus();switch(t.format){case"first_field":s=Object.values(i)[0];break;case"all_fields":s=Object.values(i).join(", ");break;default:if(t.format.includes("{")){let e=t.format;for(const[t,s]of Object.entries(i))e=e.replace(`{${t}}`,s)}else s=i[t.format]??Object.values(i)[0]}let r=this.templates.create(e.dataset.tagListId,{label:s});const n=t.ui.items?.children?.length??0;r?.querySelectorAll("input[type=hidden]")?.forEach((e=>{const s=e.dataset.field;e.name=`${t.element.field}:${n}:${s}`,e.value=i[s]||""})),t.ui.items.append(r),t.ui.inputs[0]?.focus(),this.updateCollectionField(e),this.a11y.announce("Item added")}removeTagListItem(e){let t=e.closest("[data-tag-list-id]");e.remove(),this.reindexList(t),this.a11y.announce("Item removed")}handleTagListInput(e){let t=e.target,s=t.closest("[data-tag-list-id]");if(!s)return;let i=this.tagLists.get(s.dataset.tagListId);if(i&&"Enter"===e.key)if(t===i.ui.inputs[i.ui.inputs.length-1])e.preventDefault(),this.addTagListItem(t.closest("[data-tag-list-id]"));else{e.preventDefault();let s=i.ui.inputs.indexOf(t);i.ui.inputs[s+1].focus()}}checkForConditionalFields(e){e.querySelectorAll(this.selectors.dependsOn).forEach((t=>{const s=t.dataset.dependsOn,i=t.dataset.dependsValue,a=t.dataset.dependsOperatior??"==";if(!this.dependencies.has(s)){let e=document.querySelector(`[field="${s}"]`);e&&this.dependencies.set(s,{element:e,items:[]})}let r=this.dependencies.get(s);r.items.push({field:t,form:e.dataset.formId,requiredValue:i,operator:a}),this.dependencies.set(s,r),this.checkFieldDependency(r,s)}))}checkFieldDependency(e,t){const s=this.dependencies.get(t);if(!s)return;const i=this.getFieldCheckedValue(s.element),a=this.evaluateCondition(i,e.requiredValue,e.operator);this.toggleFieldVisibility(e.field,a)}evaluateCondition(e,t,s){const i=String(e||""),a=String(t||"");switch(s){case"==":default:return i===a;case"!=":return i!==a;case">":return parseFloat(i)>parseFloat(a);case"<":return parseFloat(i)<parseFloat(a);case">=":return parseFloat(i)>=parseFloat(a);case"<=":return parseFloat(i)<=parseFloat(a);case"contains":return i.includes(a);case"empty":return""===i;case"not_empty":return""!==i}}toggleFieldVisibility(e,t){const s=e.closest(".field, fieldset");s&&(s.hidden=!t,s.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}checkForCharacterLimits(e){e.querySelector(this.selectors.limits.hasLimit)&&(this.countUpdaters=this.updateCount.bind(this),e.querySelectorAll(`${this.selectors.limits.hasLimit}`).forEach((t=>{let s=window.generateID("limit");t.dataset.charLimitId=s;let i={element:t,form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.limits,t.closest(".field"))};i.ui.limit.textContent=t.dataset.limit,this.charLimits.set(s,i),this.addCharacterLimitListeners(t)})))}addCharacterLimitListeners(e){e.addEventListener("input",this.countUpdaters,{passive:!0})}removeCharacterLimitListeners(e){e.removeEventListener("input",this.countUpdaters,{passive:!0})}updateCount(e){let t=e.target,s=this.charLimits.get(t.dataset.charLimitId);if(!s)return;let i=t.value.length,a=t.dataset.limit;s.ui.current&&(s.ui.current.textContent=i,s.ui.current.classList.toggle("exceeded",i>=a)),i>a&&(t.value=t.value.slice(0,a))}checkForImageUploads(e,t){window.jvbUploads.scanFields(e,t.options.autoUpload,t.options.imageMeta)}checkForTabs(e,t){window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=window.jvbTabs.registerTab(e,{preCheck:(e,s)=>this.validateStep(e,t)}),t.ui.tabs=window.uiFromSelectors(this.selectors.tabs,e),t.ui.tabs.sections=Array.from(e.querySelectorAll(this.selectors.tabs.sections)),t.ui.tabs.inputs={},t.ui.tabs.sections.forEach((e=>{t.ui.tabs.inputs[e.dataset.tab]=Array.from(e.querySelectorAll(this.inputs))})),t.ui.tabs.buttons=Array.from(e.querySelectorAll(this.selectors.tabs.buttons)),t.unsubscribeTabs=window.jvbTabs.subscribe(((e,s)=>{if("tab-switched"===e&&t.ui.tabs.progress){const e=t.ui.tabs.sections.filter((e=>e.dataset.tab===s.current))[0]??!1;if(!e)return;const i=e.dataset.step,a=t.ui.sections.length;window.showProgress(t.ui.tabs.progress,i,a)}})),this.forms.set(t.id,t))}validateStep(e,t){const s=e.closest("[data-form-id]")?.dataset.formId;if(!s)return!0;if(!this.forms.get(s))return!0;return Array.from(this.inputs.values()).filter((t=>t&&t.form===s&&t.section===e.dataset.tab&&!t.element.closest("[hidden]"))).every((e=>!0===this.validateField(e.element)))}checkForSelectors(e){window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}reindexList(e){const t=e.dataset.field||e.dataset.repeaterId||e.dataset.tagListId;Array.from(e.children).forEach(((e,s)=>{e.dataset.index=`${s}`;e.querySelectorAll("input, select, textarea").forEach((i=>{if("file"===i.type)return;i.dataset.field||i.name.split(":").pop();window.prefixInput(i,`${t}:${s}:`,e)}))})),this.updateCollectionField(e)}updateCollectionField(e){const t=e.closest("[data-field]");if(!t)return;const s=t.dataset.fieldType;if(!["repeater","tag-list"].includes(s))return;const i=this.getForm(e);if(!i)return;const a=this.getFieldValue(t.querySelector("input, select, textarea"));this.updateItem(t.dataset.field,a,i)}clearValidation(e){let t=this.getField(e);if(!t)return;let s=this.getItem(e);s&&(t.classList.remove("has-error","has-success"),s.ui.success&&(s.ui.success.hidden=!0),s.ui.error&&(s.ui.error.hidden=!0),s.ui.message&&(s.ui.message.hidden=!0,s.ui.message.textContent=""))}showError(e,t="Invalid field"){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-success"),s.classList.add("has-error"),i.ui.success&&(i.ui.success.hidden=!0),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=!1,i.ui.message.textContent=t))}showSuccess(e,t=""){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-error"),s.classList.add("has-success"),i.ui.success&&(i.ui.success.hidden=!1),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=""===t,i.ui.message.textContent=t))}handleFormSuccess(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error").forEach((e=>e.classList.remove("field-error"))),e.classList.add("form-success"),t.message){const s=document.createElement("div");s.className="form-success-message success-message",s.textContent=t.message,e.insertBefore(s,e.firstChild);const i=window.getIcon?.("check-circle");i&&(i.classList.add("success-icon"),s.prepend(i))}if(t.title||t.description){const s=document.createElement("div");if(s.className="success-box",t.title){const e=document.createElement("h3");e.textContent=t.title,s.appendChild(e)}if(t.description){(Array.isArray(t.description)?t.description:[t.description]).forEach((e=>{const t=document.createElement("p");t.textContent=e,s.appendChild(t)}))}e.insertBefore(s,e.firstChild)}if(e.dataset.formId){this.store.delete(e.dataset.formId).catch((e=>{console.warn("Failed to clear form cache:",e)}));const t=this.forms.get(e.dataset.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}window.jvbA11y&&window.jvbA11y.announce(t.message||"Form submitted successfully")}handleFormError(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error, .has-error").forEach((e=>{e.classList.remove("field-error","has-error")})),e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)})),t.field){const s=e.querySelector(`[data-field="${t.field}"]`);if(s){this.showError(s,t.message),this.touchedFields.add(t.field),s.scrollIntoView({behavior:"smooth",block:"center"});const e=s.querySelector("input, textarea, select");e&&e.focus()}}else{const s=document.createElement("div");s.className="form-error error-message",s.textContent=t.message;const i=window.getIcon?.("close-circle");i&&(i.classList.add("error-icon"),s.prepend(i)),e.insertBefore(s,e.firstChild),e.scrollIntoView({behavior:"smooth",block:"start"})}if(window.jvbA11y){const e=t.field?`Error in ${t.field}: ${t.message}`:`Form error: ${t.message}`;window.jvbA11y.announce(e)}e.dispatchEvent(new CustomEvent("jvb-form-error",{detail:t}))}showFormStatus(e,t,s=""){let i=this.forms.get(e);i&&i.options.showStatus&&i.ui?.status?.status&&i.status!==t&&(i.status=t,i.ui.status.status.hidden=!1,i.ui.status.status.classList.toggle("loading",["uploading","saving"].includes(t)),i.ui.status.message.textContent=""===s?this.getDefaultMessage(t):s,i.ui.status.icon.className="icon icon-"+this.getDefaultIcon(t),setTimeout((()=>i.ui.status.status.hidden=!0),"submitted"===t?3e3:1e4))}getDefaultMessage(e){return{saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",restored:"Welcome back! We've restored your previous entry.",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"}[e]??e}getDefaultIcon(e){return{autosaved:"check-circle",submitted:"check-circle",restored:"history",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[e]??""}showSummary(e){let t=this.templates.create("formSummary",e);e.config.element.after(t),window.fade(e.config.element,!1)}getForm(e){let t=e.closest("[data-form-id]").dataset.formId;if(!t)return!1;let s=this.forms.get(t);return s||!1}getField(e){return e.closest("[data-field]")}getFieldType(e){let t=this.getField(e);if(t)return t.dataset.fieldType}getFieldValue(e){let t=this.getFieldType(e),s=this.getItem(e),i=s.field?.dataset.field??!1;if(!i)return!1;switch(t){case"repeater":return this.getRepeaterValue(e,s);case"tag-list":return this.getTagListValue(e,s);case"group":break;case"location":return this.getLocationValue(e,s);case"selector":case"upload":return this.getHiddenInputValue(e,s,i);case"true-false":return"1"===e.value||"on"===e.value||"true"===e.value;case"checkbox":return e.name.endsWith("[]")?this.getCheckboxGroupValue(e,s):e.checked?e.value:"";default:return e.value}}getCheckboxGroupValue(e,t){return t.checkboxGroup||(t.checkboxGroup=t.field?.querySelectorAll(`input[type="checkbox"][name="${e.name}"]`),this.saveItem(t)),Array.from(t.checkboxGroup).filter((e=>e.checked)).map((e=>e.value))}getFieldCheckedValue(e){if("checkbox"===e.type){return"true-false"===this.getFieldType(e)?e.checked:e.checked?e.value:""}if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`),s=Array.from(t).find((e=>e.checked));return s?s.value:""}return this.getFieldValue(e)}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getRepeaterValue(e,t){t.container||(t.container=t.field?.querySelector(".repeater-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t={};e.querySelectorAll("[data-field]").forEach((e=>{t[e.dataset.field]=this.getFieldValue(e)})),s.push(t)})),s}getTagListValue(e,t){t.container||(t.container=t.field?.querySelector(".tag-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t=e.querySelectorAll('input[type="hidden"]'),i={};t.forEach((e=>{i[e.dataset.field]=e.value})),s.push(i)})),s}getLocationValue(e,t){t.values||(t.values=Array.from(t.field?.querySelectorAll("[data-location-field]")),this.saveItem(t));let s={};return t.values.forEach((e=>{s[e.dataset.locationField]=e.value})),s}getHiddenInputValue(e,t,s){return t.value||(t.value=t.field?.querySelector(`input[type=hidden][name="${s}"]`),this.saveItem(t)),t.value.value}formatValueForSummary(e,t){const s=this.getFieldType(t.element);if(this.isEmptyValue(e))return"";switch(s){case"repeater":return this.formatRepeaterForSummary(e,t);case"tag-list":return this.formatTagListForSummary(e,t);case"location":return this.formatLocationForSummary(e);case"true-false":return e?"Yes":"No";case"checkbox":return Array.isArray(e)?this.formatCheckboxGroupForSummary(e,t):this.getDisplayLabel(t,e);case"selector":case"upload":return this.formatHiddenFieldForSummary(e,t,s);default:return"string"==typeof e?this.getDisplayLabel(t,e):"string"==typeof e&&e.includes("\n")?this.convertLineBreaks(e):e}}formatCheckboxGroupForSummary(e,t){return e.map((e=>this.getDisplayLabel(t,e))).join(", ")}convertLineBreaks(e){const t=document.createElement("span");return t.innerHTML=e.split("\n").join("<br>"),t}formatRepeaterForSummary(e,t){const s=document.createElement("div");return s.className="summary-repeater",e.forEach(((e,i)=>{const a=document.createElement("div");a.className="summary-repeater-row";const r=document.createElement("strong");r.textContent=`Entry ${i+1}:`,a.appendChild(r);const n=document.createElement("ul");n.className="summary-repeater-fields";for(const[s,i]of Object.entries(e)){if(this.isEmptyValue(i))continue;const e=document.createElement("li"),a=t.field?.querySelector(`[data-field="${s}"]`),r=a?.closest(".field")?.querySelector("label")?.textContent.replace("*","").trim()||s;e.innerHTML=`<span class="field-label">${r}:</span> <span class="field-value">${i}</span>`,n.appendChild(e)}a.appendChild(n),s.appendChild(a)})),s}formatTagListForSummary(e,t){const s=document.createElement("div");s.className="summary-taglist";const i=document.createElement("ul");return i.className="summary-tags",e.forEach((e=>{const t=document.createElement("li");t.className="summary-tag";const s=Object.values(e).find((e=>!this.isEmptyValue(e)))||"",a=Object.entries(e).filter((([e,t])=>!this.isEmptyValue(t)));a.length>1?t.textContent=a.map((([e,t])=>t)).join(", "):t.textContent=s,i.appendChild(t)})),s.appendChild(i),s}formatLocationForSummary(e){const t=[];return e.street&&t.push(e.street),e.city&&t.push(e.city),e.province&&t.push(e.province),e.postal_code&&t.push(e.postal_code),e.country&&t.push(e.country),t.length>0?t.join(", "):e.address||""}formatHiddenFieldForSummary(e,t,s){if("upload"===s){const s=t.field?.querySelector("[data-upload-field]");if(s){const e=s.querySelectorAll(".item-grid.preview img");if(e.length>0){const t=document.createElement("div");return t.className="summary-uploads",e.forEach((e=>{const s=e.cloneNode(!0);s.style.maxWidth="100px",s.style.maxHeight="100px",t.appendChild(s)})),t}}return`${e.split(",").length} file(s) uploaded`}return e}getDisplayLabel(e,t){if(!e.element)return t;const s=e.element.type;if("radio"===s){const s=e.field.querySelectorAll(`input[type="radio"][name="${e.element.name}"]`),i=Array.from(s).find((e=>e.value===t));if(i){const t=i.closest("label")||e.field.querySelector(`label[for="${i.id}"]`);if(t)return t.textContent.replace("*","").trim()}}if("checkbox"===s&&"true-false"!==this.getFieldType(e.element)){const s=e.field.querySelector(`input[type="checkbox"][value="${t}"]`);if(s){const t=s.closest("label")||e.field.querySelector(`label[for="${s.id}"]`);if(t){const e=t.querySelector("span");return e?e.textContent.trim():t.textContent.replace("*","").trim()}}}return t}getItem(e,t=null){const s=Object.hasOwn(e.dataset,"ref");let i=s?e.dataset.ref:window.generateID("input");if(s||(e.dataset.ref=i),!this.inputs.has(i)){t||(t=e.closest("[data-form-id]")?.dataset.formId??!1);let s=this.getField(e);this.inputs.set(i,{id:i,element:e,form:t,field:s,section:e.closest("[data-tab]")?.dataset.tab??!1,ui:window.uiFromSelectors(this.selectors.fields,s)})}return this.inputs.get(i)}saveItem(e){this.inputs.set(e.id,e)}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("HandleSelection subscriber error:",e)}}))}destroy(){this.forms.size>0&&(Array.from(this.forms.values()).forEach((e=>{this.removeFormListeners(e)})),this.forms.clear()),this.repeaters.size>0&&(Array.from(this.repeaters.values()).forEach((e=>{this.removeRepeaterListeners(e.element),e.sortable?.destroy()})),this.repeaters.clear()),this.quantityFields.size>0&&(Array.from(this.quantityFields.values()).forEach((e=>{this.removeQuantityListeners(e.element)})),this.quantityFields.clear()),this.tagLists.size>0&&(Array.from(this.tagLists.values()).forEach((e=>{this.removeTagListListeners(e.element)})),this.tagLists.clear()),this.charLimits.size>0&&Array.from(this.charLimits.values()).forEach((e=>{e.removeEventListener("input",this.countUpdaters)})),this.inputs.clear(),this.forms.clear(),this.charLimits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbForm=new e)}))}))})(); |
| | | (()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.queue=window.jvbQueue,this.populate=window.jvbPopulate,this.changes=new Map,this.forms=new Map,this.inputs=new Map,this.repeaters=new Map,this.tagLists=new Map,this.charLimits=new Map,this.quantityFields=new Map,this.quillInstances=new Map,this.dependencies=new Map,this.subscribers=new Set,this.isRestoring=!1,this.hasListeners=!1,this.summaryTemplate=!1,this.init()}init(){this.templates=window.jvbTemplates,this.defineSummaryTemplate(),this.initElements(),this.initListeners(),this.initStore(),this.initValidators()}initElements(){this.inputSelectors="input, textarea, select",this.selectors={tabs:{nav:"nav.tabs",sections:".tab.content",progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},buttons:"nav.tabs button"},dependsOn:"[data-depends-on]",forms:{status:{status:".fstatus",message:".fstatus .message",icon:".fstatus .icon",actions:".fstatus .actions"}},inputs:this.inputSelectors,fields:{field:".field",label:"label",success:".success",error:".error",message:".validation-message"},repeater:{repeater:".repeater",header:".repeater-row-header",remove:".remove-row",add:".add-repeater-row",template:"template",items:".repeater-items",inputs:this.inputSelectors},tagList:{tagList:".field.tag-list",input:".row",add:".add-tag",remove:".remove-tag",label:".tag-label",items:".tag-items",item:".tag-item",inputs:this.inputSelectors,value:'input[type="hidden"]'},tag:{label:".tag-label"},number:{number:".field div.quantity",increase:"button.increase",decrease:"button.decrease",input:'input[type="number"]'},limits:{hasLimit:"[data-limit]",limit:".limit",current:".current"}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.blurHandler=this.handleBlur.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.quantityClick=this.handleQuantityClick.bind(this),this.repeaterClick=this.handleRepeaterClick.bind(this),this.tagListClick=this.handleTagListClick.bind(this),this.tagListInput=this.handleTagListInput.bind(this)}addFormListeners(e){e.addEventListener("click",this.clickHandler),e.addEventListener("change",this.changeHandler),e.addEventListener("input",this.inputHandler),e.addEventListener("blur",this.blurHandler),e.addEventListener("submit",this.submitHandler)}removeFormListeners(e){e.removeEventListener("click",this.clickHandler),e.removeEventListener("change",this.changeHandler),e.removeEventListener("input",this.inputHandler),e.removeEventListener("blur",this.blurHandler),e.removeEventListener("submit",this.submitHandler)}initStore(){const e=window.jvbStore.register("forms",{storeName:"forms",keyPath:"id",indexes:[{name:"src",keyPath:"src"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:1008e4});this.store=e.forms,this.store.subscribe(((e,t)=>{if("data-ready"===e){let e=this.store.getFiltered().filter((e=>e.src===window.location.pathname));for(let t of e)this.showPendingNotification(t.id,t.changes)}else"operation-status"===e&&"completed"===t.status&&t.config&&this.store.delete(t.config.id)}))}showPendingNotification(e,t){let s=this.forms.get(e);if(!s)return;let i=s.element;if(!i)return void console.warn(`Form element not found for: ${e}`);const a=document.createElement("div");a.className="pendingChanges",a.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n <button class="restore" type="button" data-form-id="${e}">Restore</button>\n <button class="discard" type="button" data-form-id="${e}">Discard</button>`,i.insertBefore(a,s.ui.status.status),a.querySelector(".restore").addEventListener("click",(async()=>{this.isRestoring=!0;let e={fields:t};this.populate.populate(i,e),this.a11y.announce("Previous changes restored"),this.isRestoring=!1,a.remove()})),a.querySelector(".discard").addEventListener("click",(async()=>{await this.store.delete(e),this.a11y.announce("Previous changes discarded"),a.remove()}))}initValidators(){this.validators={email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with https://"},phone:{pattern:/^[\d\s\-+().]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const s=parseFloat(e);if(isNaN(s))return"Please enter a valid number";const i=t.dataset.min,a=t.dataset.max;return void 0!==i&&s<parseFloat(i)?`Value must be at least ${i}`:!(void 0!==a&&s>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const s=t.dataset.minlength,i=t.dataset.maxlength;return s&&e.length<parseInt(s)?`Must be at least ${s} characters`:!(i&&e.length>parseInt(i))||`Must be no more than ${i} characters`}}}}validateField(e){const t=this.performValidation(e);return this.updateValidationUI(e,t),t.isValid}performValidation(e){const t=e.closest(".field"),s=this.getFieldCheckedValue(e);if(!s&&!e.required)return{isValid:!0,message:""};if(e.required)if("checkbox"===e.type){if(!e.checked)return{isValid:!1,message:"This field is required"}}else if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`);if(!Array.from(t).some((e=>e.checked)))return{isValid:!1,message:"Please select an option"}}else if(!s)return{isValid:!1,message:"This field is required"};if(e.checkValidity&&!e.checkValidity())return{isValid:!1,message:e.validationMessage};if(s&&Object.hasOwn(t.dataset,"pattern")){if(!new RegExp(t.dataset.pattern).test(s))return{isValid:!1,message:t.dataset.validationMessage||"Invalid format"}}if(Object.hasOwn(t.dataset,"validate")||e.type){const i=this.validators[t.dataset.validate||e.type];if(i&&i.pattern&&!i.pattern.test(s))return{isValid:!1,message:i.message};if(i&&i.test){const e=i.test(s,t);if(!0!==e)return{isValid:!1,message:e}}}return{isValid:!0,message:""}}updateValidationUI(e,t){t.isValid?this.showSuccess(e,t.message):this.showError(e,t.message)}handleClick(e){let t=this.getForm(e.target);if(!t)return;const s=window.targetCheck(e,"[data-action]");if(s){switch(s.dataset.action){case"clear-form":this.store.delete(t.id),t.element.reset(),t.ui.status.status.hidden=!0,this.a11y.announce("Form cleared, starting fresh");break;case"dismiss-restore":t.ui.status.status.hidden=!0}}}handleChange(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getField(e.target);if(this.dependencies.has(t.dataset.field)){this.dependencies.get(t.dataset.field).items.forEach((e=>{this.checkFieldDependency(e,t.dataset.field)}))}if("repeater"===t.dataset.fieldType||"tag-list"===t.dataset.fieldType)return void this.updateCollectionField(t);let s=this.getForm(e.target);this.updateItem(t.dataset.field,this.getFieldValue(e.target),s)}handleBlur(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target).dataset.field;window.debouncer.cancel(`form:${t.id}:validate:${s}`),this.validateField(e.target),this.updateItem(s,this.getFieldValue(e.target),t)}handleInput(e){let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target);if(!s)return;const i=e.target,a=s.dataset.field;this.showFormStatus(t.id,"pending"),window.debouncer.schedule(`form:${t.id}:validate:${a}`,(()=>this.validateField(i)),500)}async handleSubmit(e){let t=this.getForm(e.target);if(t){if(this.subscribers.size>0)if(e.preventDefault(),t.options.cache){this.cancelBackup(),await this.backup();const e=await this.store.get(t.id);this.notify("form-submit",{config:t,data:e.changes})}else this.notify("form-submit",{config:t,data:this.changes.get(t.id)?.changes??{}});if(t.options.showSummary){const e=await this.store.get(t.id);this.showSummary({config:t,changes:e?.changes})}}}updateItem(e,t,s){this.changes.has(s.id)||this.changes.set(s.id,{id:s.id,timestamp:Date.now(),src:window.location.pathname,changes:{}});let i=this.changes.get(s.id);i.changes[e]=t,this.changes.set(s.id,i),s.options.cache&&this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule("form_changes",(async()=>{this.changes.size>0&&await this.backup()}),2e3)}cancelBackup(){window.debouncer.cancel("form_changes")}async backup(){const e=new Map;for(let[t,s]of this.changes.entries()){const i=await this.store.get(t);i?e.set(t,{...i,...s,changes:{...i.changes,...s.changes},timestamp:Date.now()}):e.set(t,s)}await this.store.saveMany(e);for(let e of this.changes.keys())this.showFormStatus(e,"autosaved");this.changes.clear()}saveCache(e){if(!this.changes.has(e))return;let t=this.changes.get(e);0!==t.size&&(this.store.save(t).then((()=>{})),this.changes.delete(e))}registerForm(e,t){if(Object.hasOwn(e.dataset,"formId")&&this.forms.has(e.dataset.formId))return;Object.hasOwn(e.dataset,"formId")||(e.dataset.formId=window.generateID("form_"));const s=e.dataset.formId;this.addFormListeners(e);const i={element:e,id:s,status:"",options:{autoUpload:t.autoUpload??!1,imageMeta:t.imageMeta??!0,delay:t.delay??1500,endpoint:t.save??e.dataset.save??"",showStatus:t.showStatus??!0,showSummary:t.showSummary??!1,cache:t.cache??!0,ignore:t.ignore??[]},ui:window.uiFromSelectors(this.selectors.forms,e)};return this.initializeFields(e,i),this.forms.set(s,i),i}clearForm(e){const t=this.forms.get(e);if(!t)return;t.unsubscribeTabs&&t.unsubscribeTabs(),t.tabs&&window.jvbTabs.removeTab(t.element),t.cache&&this.changes.has(e)&&this.saveCache(e);for(let[t,s]of this.inputs.entries())s.form===e&&this.inputs.delete(t);if(this.dependencies.forEach(((t,s)=>{t.items=t.items.filter((t=>t.form!==e)),0===t.items.length&&this.dependencies.delete(s)})),Object.hasOwn(t,"hasQuill")&&this.quillInstances.has(e)){this.quillInstances.get(e).forEach((e=>{e.disable(),e.off("text-change"),e.off("selection-change");const t=e.container.parentElement,s=t?.querySelector(".ql-toolbar");if(s&&s.remove(),e.setText(""),t&&t.classList.contains("editor-container")){const e=t.nextElementSibling;"TEXTAREA"===e?.tagName&&(e.style.display=""),t.remove()}})),this.quillInstances.delete(e)}let s={repeater:this.repeaters,tagList:this.tagLists,charLimit:this.charLimits,quantity:this.quantityFields};for(let[t,i]of Object.entries(s)){if(0===i.size)continue;let s=Array.from(i.values()).filter((t=>t.form===e));s.length>0&&s.forEach((e=>{switch(t){case"repeater":this.removeRepeaterListeners(e.element);break;case"tagList":this.removeTagListListeners(e.element);break;case"charLimit":this.removeCharacterLimitListeners(e.element);break;case"quantity":this.removeQuantityListeners(e.element)}i.has(e.id)&&i.delete(e.id)}))}this.removeFormListeners(t.element),this.forms.delete(e),window.debouncer.cancel("form_changes")}defineSummaryTemplate(){this.summaryTemplate=!0;let e=this;this.templates.define("formSummary",{refs:{result:".result",h3:"h3",p:"p"},setup({el:t,refs:s,manyRefs:i,data:a}){const r=["sendAll",...a.config.options.ignore??[]];for(let[i,n]of Object.entries(a.changes)){if(r.includes(i)||e.isEmptyValue(n))continue;let a=Array.from(e.inputs.values()).find((e=>e.field?.dataset.field===i));if(!a)continue;let l=s.result.cloneNode(!0),o=l.querySelector("h3"),d=l.querySelector("p");const c=a.field?.querySelector("legend");o.textContent=c?c.textContent.replace("*","").trim():a.ui.label?.textContent.replace("*","").trim();const u=e.formatValueForSummary(n,a);u instanceof HTMLElement?d.replaceWith(u):d.textContent=u,t.append(l)}let n=a.config?.element?.querySelectorAll("[data-upload-field]");n&&n.forEach((e=>{let i=e.querySelector("h2")?.textContent??"Upload:",a=e.querySelectorAll(".item-grid.preview img"),r=s.result.cloneNode(!0);if(a){let e=s.result.cloneNode(!0),n=r.querySelector("h3"),l=r.querySelector("p");l?.remove(),n&&(n.textContent=i),a.forEach((t=>{t=t.cloneNode(!0),e.append(t)})),t.append(e)}})),s.result?.remove(),a.config.element.after(t),window.fade(a.config.element,!1)}})}initializeFields(e,t=null){const s={"[data-editor]":()=>this.checkForQuill(e,t),"div.quantity":()=>this.checkForQuantity(e),".repeater":()=>this.checkForRepeaters(e,t),".field.tag-list":()=>this.checkForTagLists(e),"[data-depends-on]":()=>this.checkForConditionalFields(e),"[data-limit]":()=>this.checkForCharacterLimits(e),"[data-uploader],[data-upload-field]":()=>this.checkForImageUploads(e,t),"nav.tabs":()=>this.checkForTabs(e,t),'[data-type="selector"]':()=>this.checkForSelectors(e)};for(const[t,i]of Object.entries(s))e.querySelector(t)&&i();Array.from(e.querySelectorAll(this.inputSelectors)).map((e=>{this.getItem(e,t?.id)}))}checkForQuill(e,t){if(!e.querySelector("[data-editor]"))return;t&&!Object.hasOwn(t,"hasQuill")&&(t.hasQuill=!0,this.forms.set(t.id,t)),this.quillInstances.has(t.id)||this.quillInstances.set(t.id,new Set);window.jvbQuill(e).forEach((e=>{this.quillInstances.get(t.id).add(e)}))}checkForQuantity(e){e.querySelector(this.selectors.number.number)&&e.querySelectorAll(this.selectors.number.number).forEach((t=>{let s={id:window.generateID("quant"),form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.number,t),element:t};t.dataset.numId=s.id,this.quantityFields.set(s.id,s),this.addQuantityListeners(t)}))}addQuantityListeners(e){e.addEventListener("click",this.quantityClick)}removeQuantityListeners(e){e.removeEventListener("click",this.quantityClick)}handleQuantityClick(e){let t=this.quantityFields.get(e.target.closest("[data-num-id]")?.dataset.numId);if(!t)return;let s=0;if(t.ui.increase.contains(e.target)?s++:t.ui.decrease.contains(e.target)&&s--,0===s)return;this.getField(e.target);let i=t.ui.input.step;i=Math.max(i,1),e.ctrlKey&&e.shiftKey?i*=50:e.ctrlKey?i*=5:e.shiftKey&&(i*=10);let a=""===t.ui.input.value?0:parseFloat(t.ui.input.value);t.ui.input.value=a+i*s,a=parseFloat(t.ui.input.value),t.ui.input.min&&a<t.ui.input.min?(t.ui.input.value=t.ui.input.min,t.ui.decrease.disabled=!0):t.ui.input.max&&a>t.ui.input.max?(t.ui.input.value=t.ui.input.max,t.ui.increase.disabled=!0):(t.ui.decrease.disabled&&(t.ui.decrease.disabled=!1),t.ui.increase.disabled&&(t.ui.increase.disabled=!1))}checkForRepeaters(e){e.querySelector(this.selectors.repeater.repeater)&&e.querySelectorAll(this.selectors.repeater.repeater).forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("repeater"),ui:window.uiFromSelectors(this.selectors.repeater,t),form:e.dataset.formId,element:t,field:this.getField(t),sortable:!1};if(!s.ui.add)return;let i=t.querySelector("template");this.templates.define(i.className,{manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{window.prefixInput(t,`${a.repeater.dataset.field}:${r}:`,e)}))}}),window.Sortable&&(s.sortable=new Sortable(t,{handle:this.selectors.repeater.header,animation:150,onEnd:()=>{this.reindexList(t)}})),t.dataset.repeaterId=s.id,this.addRepeaterListeners(t),this.repeaters.set(s.id,s)}))}addRepeaterListeners(e){e.addEventListener("click",this.repeaterClick)}removeRepeaterListeners(e){e.removeEventListener("click",this.repeaterClick)}handleRepeaterClick(e){e.target.matches(this.selectors.repeater.add)?this.addRepeaterRow(e.target.closest("[data-repeater-id]")):e.target.matches(this.selectors.repeater.remove)&&this.removeRepeaterRow(e.target.closest("[data-index]"))}addRepeaterRow(e){let t={};t.repeater=e,e.append(this.templates.create(e.dataset.repeaterId,t)),this.initializeFields(e,this.getField(e).config??{}),this.a11y.announce("Row added")}removeRepeaterRow(e){let t=e.closest("[data-repeater-id]");e.remove(),this.reindexList(t),this.a11y.announce("Row removed")}checkForTagLists(e){e.querySelectorAll(this.selectors.tagList.tagList)?.forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("tagList"),ui:window.uiFromSelectors(this.selectors.tagList,t),element:t,form:e.dataset.formId,format:t.dataset.tagFormat??"first_field"};if(!s.ui.input||!s.ui.add||!s.ui.items)return;t.dataset.tagListId=s.id,s.fieldName=t.dataset.field;let i=t.querySelector("template");this.templates.define(i.className,{refs:{label:this.selectors.tagList.label},manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((e=>{let t=e.closest(".tag-item");window.prefixInput(e,`${a.fieldName}:${r}:`,t)})),t.label&&(t.label.textContent=a.label)}}),s.ui.inputs=Array.from(t.querySelectorAll(this.selectors.tagList.inputs)),s.ui.value=Array.from(t.querySelectorAll(this.selectors.tagList.value)),this.tagLists.set(s.id,s),this.addTagListListeners(t)}))}addTagListListeners(e){e.addEventListener("click",this.tagListClick),e.addEventListener("keypress",this.tagListInput,{passive:!0})}removeTagListListeners(e){e.removeEventListener("click",this.tagListClick),e.removeEventListener("keypress",this.tagListInput)}handleTagListClick(e){window.targetCheck(e,this.selectors.tagList.add)?this.addTagListItem(e.target.closest("[data-tag-list-id]")):window.targetCheck(e,this.selectors.tagList.remove)&&this.removeTagListItem(e.target.closest(this.selectors.tagList.item))}addTagListItem(e){let t=this.tagLists.get(e.dataset.tagListId);if(!t)return;let s,i={},a=!1,r=!0;for(let e of t.ui.inputs){const t=e.required||"true"===e.dataset.required,s=this.getFieldValue(e);s&&(a=!0);const n=this.validateField(e);t&&!s?(this.showError(e,"This field is required"),r=!1):n||(r=!1);const l=e.name.replace("new_","");i[l]=s}if(!r){this.a11y.announce("Please correct the errors before adding");const e=t.ui.inputs.find((e=>(e.required||"true"===e.dataset.required)&&!this.getFieldValue(e)));return void(e&&e.focus())}if(!a)return this.a11y.announce("Please fill in at least one field"),void t.ui.inputs[0].focus();switch(t.format){case"first_field":s=Object.values(i)[0];break;case"all_fields":s=Object.values(i).join(", ");break;default:if(t.format.includes("{")){s=t.format;for(const[e,t]of Object.entries(i))s=s.replace(`{${e}}`,t)}else s=i[t.format]??Object.values(i)[0]}let n=this.templates.create(e.dataset.tagListId,{label:s,fieldName:t.fieldName});const l=t.ui.items?.children?.length??0;n?.querySelectorAll("input[type=hidden]")?.forEach((e=>{const s=e.dataset.field;e.name=`${t.fieldName}:${l}:${s}`,e.id=`${t.fieldName}:${l}:${s}`,e.value=i[s]||""})),t.ui.items.append(n);for(let e of t.ui.inputs)["checkbox","radio"].includes(e.type)?e.checked=!1:e.value="",this.clearValidation(e);t.ui.inputs[0]?.focus(),this.updateCollectionField(e),this.a11y.announce("Item added")}removeTagListItem(e){let t=e.closest("[data-tag-list-id]");t&&(e.remove(),this.reindexList(t),this.updateCollectionField(t),this.a11y.announce("Item removed"))}handleTagListInput(e){let t=e.target,s=t.closest("[data-tag-list-id]");if(!s)return;let i=this.tagLists.get(s.dataset.tagListId);if(i&&"Enter"===e.key)if(t===i.ui.inputs[i.ui.inputs.length-1])e.preventDefault(),this.addTagListItem(t.closest("[data-tag-list-id]"));else{e.preventDefault();let s=i.ui.inputs.indexOf(t);i.ui.inputs[s+1].focus()}}checkForConditionalFields(e){e.querySelectorAll(this.selectors.dependsOn).forEach((t=>{const s=t.dataset.dependsOn,i=t.dataset.dependsValue,a=t.dataset.dependsOperatior??"==";if(!this.dependencies.has(s)){let e=document.querySelector(`[field="${s}"]`);e&&this.dependencies.set(s,{element:e,items:[]})}let r=this.dependencies.get(s);r.items.push({field:t,form:e.dataset.formId,requiredValue:i,operator:a}),this.dependencies.set(s,r),this.checkFieldDependency(r,s)}))}checkFieldDependency(e,t){const s=this.dependencies.get(t);if(!s)return;const i=this.getFieldCheckedValue(s.element),a=this.evaluateCondition(i,e.requiredValue,e.operator);this.toggleFieldVisibility(e.field,a)}evaluateCondition(e,t,s){const i=String(e||""),a=String(t||"");switch(s){case"==":default:return i===a;case"!=":return i!==a;case">":return parseFloat(i)>parseFloat(a);case"<":return parseFloat(i)<parseFloat(a);case">=":return parseFloat(i)>=parseFloat(a);case"<=":return parseFloat(i)<=parseFloat(a);case"contains":return i.includes(a);case"empty":return""===i;case"not_empty":return""!==i}}toggleFieldVisibility(e,t){const s=e.closest(".field, fieldset");s&&(s.hidden=!t,s.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}checkForCharacterLimits(e){e.querySelector(this.selectors.limits.hasLimit)&&(this.countUpdaters=this.updateCount.bind(this),e.querySelectorAll(`${this.selectors.limits.hasLimit}`).forEach((t=>{let s=window.generateID("limit");t.dataset.charLimitId=s;let i={element:t,form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.limits,t.closest(".field"))};i.ui.limit.textContent=t.dataset.limit,this.charLimits.set(s,i),this.addCharacterLimitListeners(t)})))}addCharacterLimitListeners(e){e.addEventListener("input",this.countUpdaters,{passive:!0})}removeCharacterLimitListeners(e){e.removeEventListener("input",this.countUpdaters,{passive:!0})}updateCount(e){let t=e.target,s=this.charLimits.get(t.dataset.charLimitId);if(!s)return;let i=t.value.length,a=t.dataset.limit;s.ui.current&&(s.ui.current.textContent=i,s.ui.current.classList.toggle("exceeded",i>=a)),i>a&&(t.value=t.value.slice(0,a))}checkForImageUploads(e,t){window.jvbUploads.scanFields(e,t.options.autoUpload,t.options.imageMeta)}checkForTabs(e,t){window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=window.jvbTabs.registerTab(e,{preCheck:(e,s)=>this.validateStep(e,t)}),t.ui.tabs=window.uiFromSelectors(this.selectors.tabs,e),t.ui.tabs.sections=Array.from(e.querySelectorAll(this.selectors.tabs.sections)),t.ui.tabs.inputs={},t.ui.tabs.sections.forEach((e=>{t.ui.tabs.inputs[e.dataset.tab]=Array.from(e.querySelectorAll(this.inputs))})),t.ui.tabs.buttons=Array.from(e.querySelectorAll(this.selectors.tabs.buttons)),t.unsubscribeTabs=window.jvbTabs.subscribe(((e,s)=>{if("tab-switched"===e&&t.ui.tabs.progress){const e=t.ui.tabs.sections.filter((e=>e.dataset.tab===s.current))[0]??!1;if(!e)return;const i=e.dataset.step,a=t.ui.sections.length;window.showProgress(t.ui.tabs.progress,i,a)}})),this.forms.set(t.id,t))}validateStep(e,t){const s=e.closest("[data-form-id]")?.dataset.formId;if(!s)return!0;if(!this.forms.get(s))return!0;return Array.from(this.inputs.values()).filter((t=>t&&t.form===s&&t.section===e.dataset.tab&&!t.element.closest("[hidden]"))).every((e=>!0===this.validateField(e.element)))}checkForSelectors(e){window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}reindexList(e){const t=e.dataset.field||e.dataset.repeaterId||e.dataset.tagListId;Array.from(e.children).forEach(((e,s)=>{e.dataset.index=`${s}`;e.querySelectorAll("input, select, textarea").forEach((i=>{if("file"===i.type)return;i.dataset.field||i.name.split(":").pop();window.prefixInput(i,`${t}:${s}:`,e)}))})),this.updateCollectionField(e)}updateCollectionField(e){const t=e.closest("[data-field]");if(!t)return;const s=t.dataset.fieldType;if(!["repeater","tag-list"].includes(s))return;const i=this.getForm(e);if(!i)return;const a=this.getFieldValue(t.querySelector("input, select, textarea"));this.updateItem(t.dataset.field,a,i)}clearValidation(e){let t=this.getField(e);if(!t)return;let s=this.getItem(e);s&&(t.classList.remove("has-error","has-success"),s.ui.success&&(s.ui.success.hidden=!0),s.ui.error&&(s.ui.error.hidden=!0),s.ui.message&&(s.ui.message.hidden=!0,s.ui.message.textContent=""))}showError(e,t="Invalid field"){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-success"),s.classList.add("has-error"),i.ui.success&&(i.ui.success.hidden=!0),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=!1,i.ui.message.textContent=t))}showSuccess(e,t=""){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-error"),s.classList.add("has-success"),i.ui.success&&(i.ui.success.hidden=!1),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=""===t,i.ui.message.textContent=t))}handleFormSuccess(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error").forEach((e=>e.classList.remove("field-error"))),e.classList.add("form-success"),t.message){const s=document.createElement("div");s.className="form-success-message success-message",s.textContent=t.message,e.insertBefore(s,e.firstChild);const i=window.getIcon?.("check-circle");i&&(i.classList.add("success-icon"),s.prepend(i))}if(t.title||t.description){const s=document.createElement("div");if(s.className="success-box",t.title){const e=document.createElement("h3");e.textContent=t.title,s.appendChild(e)}if(t.description){(Array.isArray(t.description)?t.description:[t.description]).forEach((e=>{const t=document.createElement("p");t.textContent=e,s.appendChild(t)}))}e.insertBefore(s,e.firstChild)}if(e.dataset.formId){this.store.delete(e.dataset.formId).catch((e=>{console.warn("Failed to clear form cache:",e)}));const t=this.forms.get(e.dataset.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}window.jvbA11y&&window.jvbA11y.announce(t.message||"Form submitted successfully")}handleFormError(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error, .has-error").forEach((e=>{e.classList.remove("field-error","has-error")})),e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)})),t.field){const s=e.querySelector(`[data-field="${t.field}"]`);if(s){this.showError(s,t.message),s.scrollIntoView({behavior:"smooth",block:"center"});const e=s.querySelector("input, textarea, select");e&&e.focus()}}else{const s=document.createElement("div");s.className="form-error error-message",s.textContent=t.message;const i=window.getIcon?.("close-circle");i&&(i.classList.add("error-icon"),s.prepend(i)),e.insertBefore(s,e.firstChild),e.scrollIntoView({behavior:"smooth",block:"start"})}if(window.jvbA11y){const e=t.field?`Error in ${t.field}: ${t.message}`:`Form error: ${t.message}`;window.jvbA11y.announce(e)}e.dispatchEvent(new CustomEvent("jvb-form-error",{detail:t}))}showFormStatus(e,t,s=""){let i=this.forms.get(e);i&&i.options.showStatus&&i.ui?.status?.status&&i.status!==t&&(i.status=t,i.ui.status.status.hidden=!1,i.ui.status.status.classList.toggle("loading",["uploading","saving"].includes(t)),i.ui.status.message.textContent=""===s?this.getDefaultMessage(t):s,i.ui.status.icon.className="icon icon-"+this.getDefaultIcon(t),setTimeout((()=>i.ui.status.status.hidden=!0),"submitted"===t?3e3:1e4))}getDefaultMessage(e){return{saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",restored:"Welcome back! We've restored your previous entry.",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"}[e]??e}getDefaultIcon(e){return{autosaved:"check-circle",submitted:"check-circle",restored:"history",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[e]??""}showSummary(e){let t=this.templates.create("formSummary",e);e.config.element.after(t),window.fade(e.config.element,!1)}getForm(e){let t=e.closest("[data-form-id]");if(!t)return!1;let s=t.dataset.formId;if(!s)return!1;let i=this.forms.get(s);return i||!1}getField(e){return e.closest("[data-field]")}getFieldType(e){let t=this.getField(e);if(t)return t.dataset.fieldType}getFieldValue(e){let t=this.getFieldType(e),s=this.getItem(e),i=s.field?.dataset.field??!1;if(!i)return!1;switch(t){case"repeater":return this.getRepeaterValue(e,s);case"tag-list":return this.getTagListValue(e,s);case"group":break;case"location":return this.getLocationValue(e,s);case"selector":case"upload":return this.getHiddenInputValue(e,s,i);case"true-false":return"1"===e.value||"on"===e.value||"true"===e.value;case"checkbox":return e.name.endsWith("[]")?this.getCheckboxGroupValue(e,s):e.checked?e.value:"";default:return e.value}}getCheckboxGroupValue(e,t){return t.checkboxGroup||(t.checkboxGroup=t.field?.querySelectorAll(`input[type="checkbox"][name="${e.name}"]`),this.saveItem(t)),Array.from(t.checkboxGroup).filter((e=>e.checked)).map((e=>e.value))}getFieldCheckedValue(e){if("checkbox"===e.type){return"true-false"===this.getFieldType(e)?e.checked:e.checked?e.value:""}if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`),s=Array.from(t).find((e=>e.checked));return s?s.value:""}return this.getFieldValue(e)}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getRepeaterValue(e,t){t.container||(t.container=t.field?.querySelector(".repeater-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t={};e.querySelectorAll("[data-field]").forEach((e=>{t[e.dataset.field]=this.getFieldValue(e)})),s.push(t)})),s}getTagListValue(e,t){t.container||(t.container=t.field?.querySelector(".tag-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t=e.querySelectorAll('input[type="hidden"]'),i={};t.forEach((e=>{i[e.dataset.field]=e.value})),s.push(i)})),s}getLocationValue(e,t){t.values||(t.values=Array.from(t.field?.querySelectorAll("[data-location-field]")),this.saveItem(t));let s={};return t.values.forEach((e=>{s[e.dataset.locationField]=e.value})),s}getHiddenInputValue(e,t,s){return t.value||(t.value=t.field?.querySelector(`input[type=hidden][name="${s}"]`),this.saveItem(t)),t.value.value}formatValueForSummary(e,t){const s=this.getFieldType(t.element);if(this.isEmptyValue(e))return"";switch(s){case"repeater":return this.formatRepeaterForSummary(e,t);case"tag-list":return this.formatTagListForSummary(e,t);case"location":return this.formatLocationForSummary(e);case"true-false":return e?"Yes":"No";case"checkbox":return Array.isArray(e)?this.formatCheckboxGroupForSummary(e,t):this.getDisplayLabel(t,e);case"selector":case"upload":return this.formatHiddenFieldForSummary(e,t,s);default:return"string"==typeof e?this.getDisplayLabel(t,e):"string"==typeof e&&e.includes("\n")?this.convertLineBreaks(e):e}}formatCheckboxGroupForSummary(e,t){return e.map((e=>this.getDisplayLabel(t,e))).join(", ")}convertLineBreaks(e){const t=document.createElement("span");return t.innerHTML=e.split("\n").join("<br>"),t}formatRepeaterForSummary(e,t){const s=document.createElement("div");return s.className="summary-repeater",e.forEach(((e,i)=>{const a=document.createElement("div");a.className="summary-repeater-row";const r=document.createElement("strong");r.textContent=`Entry ${i+1}:`,a.appendChild(r);const n=document.createElement("ul");n.className="summary-repeater-fields";for(const[s,i]of Object.entries(e)){if(this.isEmptyValue(i))continue;const e=document.createElement("li"),a=t.field?.querySelector(`[data-field="${s}"]`),r=a?.closest(".field")?.querySelector("label")?.textContent.replace("*","").trim()||s;e.innerHTML=`<span class="field-label">${r}:</span> <span class="field-value">${i}</span>`,n.appendChild(e)}a.appendChild(n),s.appendChild(a)})),s}formatTagListForSummary(e,t){const s=document.createElement("div");s.className="summary-taglist";const i=document.createElement("ul");return i.className="summary-tags",e.forEach((e=>{const t=document.createElement("li");t.className="summary-tag";const s=Object.values(e).find((e=>!this.isEmptyValue(e)))||"",a=Object.entries(e).filter((([e,t])=>!this.isEmptyValue(t)));a.length>1?t.textContent=a.map((([e,t])=>t)).join(", "):t.textContent=s,i.appendChild(t)})),s.appendChild(i),s}formatLocationForSummary(e){const t=[];return e.street&&t.push(e.street),e.city&&t.push(e.city),e.province&&t.push(e.province),e.postal_code&&t.push(e.postal_code),e.country&&t.push(e.country),t.length>0?t.join(", "):e.address||""}formatHiddenFieldForSummary(e,t,s){if("upload"===s){const s=t.field?.querySelector("[data-upload-field]");if(s){const e=s.querySelectorAll(".item-grid.preview img");if(e.length>0){const t=document.createElement("div");return t.className="summary-uploads",e.forEach((e=>{const s=e.cloneNode(!0);s.style.maxWidth="100px",s.style.maxHeight="100px",t.appendChild(s)})),t}}return`${e.split(",").length} file(s) uploaded`}return e}getDisplayLabel(e,t){if(!e.element)return t;const s=e.element.type;if("radio"===s){const s=e.field.querySelectorAll(`input[type="radio"][name="${e.element.name}"]`),i=Array.from(s).find((e=>e.value===t));if(i){const t=i.closest("label")||e.field.querySelector(`label[for="${i.id}"]`);if(t)return t.textContent.replace("*","").trim()}}if("checkbox"===s&&"true-false"!==this.getFieldType(e.element)){const s=e.field.querySelector(`input[type="checkbox"][value="${t}"]`);if(s){const t=s.closest("label")||e.field.querySelector(`label[for="${s.id}"]`);if(t){const e=t.querySelector("span");return e?e.textContent.trim():t.textContent.replace("*","").trim()}}}return t}getItem(e,t=null){const s=Object.hasOwn(e.dataset,"ref");let i=s?e.dataset.ref:window.generateID("input");if(s||(e.dataset.ref=i),!this.inputs.has(i)){t||(t=e.closest("[data-form-id]")?.dataset.formId??!1);let s=this.getField(e);this.inputs.set(i,{id:i,element:e,form:t,field:s,section:e.closest("[data-tab]")?.dataset.tab??!1,ui:window.uiFromSelectors(this.selectors.fields,s)})}return this.inputs.get(i)}saveItem(e){this.inputs.set(e.id,e)}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("HandleSelection subscriber error:",e)}}))}destroy(){this.forms.size>0&&(Array.from(this.forms.values()).forEach((e=>{this.removeFormListeners(e)})),this.forms.clear()),this.repeaters.size>0&&(Array.from(this.repeaters.values()).forEach((e=>{this.removeRepeaterListeners(e.element),e.sortable?.destroy()})),this.repeaters.clear()),this.quantityFields.size>0&&(Array.from(this.quantityFields.values()).forEach((e=>{this.removeQuantityListeners(e.element)})),this.quantityFields.clear()),this.tagLists.size>0&&(Array.from(this.tagLists.values()).forEach((e=>{this.removeTagListListeners(e.element)})),this.tagLists.clear()),this.charLimits.size>0&&Array.from(this.charLimits.values()).forEach((e=>{e.removeEventListener("input",this.countUpdaters)})),this.inputs.clear(),this.forms.clear(),this.charLimits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbForm=new e)}))}))})(); |
| New file |
| | |
| | | (()=>{class e extends window.jvbCheckout{constructor(e={}){super({...window.helcimConfig,...e}),this.pendingSecretToken=null}async init(){"function"!=typeof window.appendHelcimPayIframe&&console.warn("HelcimPay.js SDK not loaded — payment will initialize on first checkout"),this.isInitialized=!0,window.addEventListener("message",(e=>this.handleHelcimMessage(e))),document.dispatchEvent(new CustomEvent("checkoutReady",{detail:{checkout:this,provider:"helcim"}}))}async processPayment(e){if(this.selectedCardId)return this.submitToServer({card_id:this.selectedCardId,is_saved:!0},e);const t=await this.initializeCheckoutSession(e);if(!t.success)throw new Error(t.message||"Failed to initialize checkout");return this.pendingSecretToken=t.secretToken,this.pendingOrderData=e,window.appendHelcimPayIframe(t.checkoutToken,{type:"modal"}),new Promise(((e,t)=>{this._paymentResolve=e,this._paymentReject=t}))}async initializeCheckoutSession(e){return(await fetch(this.config.api_url+"initialize-checkout",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({amount:e.total/100,customer:e.customer,items:e.items,cart_id:this.getCartId()})})).json()}handleHelcimMessage(e){const t=e.data;t&&"object"==typeof t&&("SUCCESS"===t.eventStatus?this.handleHelcimSuccess(t):"ABORTED"===t.eventStatus?this.handleHelcimCancelled():"FAILED"===t.eventStatus&&this.handleHelcimError(t))}async handleHelcimSuccess(e){try{const t=await this.submitToServer({transaction_id:e.transactionId,secret_token:this.pendingSecretToken,event_data:e},this.pendingOrderData);this.clearPending(),this._paymentResolve?.(t)}catch(e){this.clearPending(),this._paymentReject?.(e)}}handleHelcimCancelled(){this.clearPending(),window.jvbLoading?.hideLoading?.(),this.a11y.announce("Payment cancelled"),this._paymentReject?.(new Error("Payment cancelled by user"))}handleHelcimError(e){this.clearPending(),window.jvbLoading?.hideLoading?.();const t=e.errorMessage||"Payment failed";this._paymentReject?.(new Error(t))}clearPending(){this.pendingSecretToken=null,this.pendingOrderData=null}async submitToServer(e,t){if(!this.isOpen)throw new Error("Store is currently closed");const i=e.is_saved?"process-saved-payment":"validate-transaction",n=await fetch(this.config.api_url+i,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({...e,cart_id:this.getCartId(),amount:t.total,items:t.items,customer:{email:this.isLoggedIn?this.userEmail:t.customer.email,name:t.customer.name,phone:t.customer.phone},note:t.note,pickup_time:t.pickup_time})}),s=await n.json();if(!n.ok)throw new Error(s.message||"Payment processing failed");return this.clearCart(),s}async loadSavedCards(){try{const e=await fetch(this.config.api_url+"saved-cards",{method:"GET",headers:{"X-WP-Nonce":this.config.nonce}}),t=await e.json();t.success&&t.cards&&(this.savedCards=t.cards,this.renderSavedCards())}catch(e){console.error("Failed to load saved cards:",e)}}async loadInvoices(){try{const e=await fetch(this.config.api_url+"invoices",{headers:{"X-WP-Nonce":this.config.nonce}}),t=await e.json();if(t.success)return t.invoices||[]}catch(e){console.error("Failed to load invoices:",e)}return[]}async payInvoice(e){const t=await fetch(this.config.api_url+"initialize-checkout",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({invoice_id:e})}).then((e=>e.json()));if(!t.success)throw new Error(t.message||"Failed to initialize invoice payment");return this.pendingSecretToken=t.secretToken,this.pendingOrderData={total:0,items:[],customer:{}},window.appendHelcimPayIframe(t.checkoutToken,{type:"modal"}),new Promise(((e,t)=>{this._paymentResolve=e,this._paymentReject=t}))}}document.addEventListener("DOMContentLoaded",(()=>{document.querySelector('#checkout[data-provider="helcim"]')&&(window.jvbHelcim=new e)}))})(); |
| | |
| | | (()=>{class e{constructor(){this.templates=window.jvbTemplates,this.formHelper=window.jvbForm,this.defineTemplates(),this.data=null,this.form=null}populate(e,t={}){if(this.data=t,this.mergeRootData(),this.form=e,this.formHelper||(this.formHelper=window.jvbForm),this.formHelper){if(Object.hasOwn(this.data,"fields")&&0!==Object.keys(this.data.fields).length)for(let[t,i]of Object.entries(this.data.fields)){let a=e.querySelector(`[data-field="${t}"]`);a&&this.populateField(a,t,i)}}else requestAnimationFrame((()=>{this.populate(e,t)}))}mergeRootData(){["status","date","modified"].forEach((e=>{this.data.fields[`post_${e}`]=this.data[e]}))}populateField(e,t,i){let a=this.formHelper.getFieldType(e);if(!a||this.isEmptyValue(t)||this.isEmptyValue(i))return;const l={repeater:this.populateRepeater.bind(this),"tag-list":this.populateTagList.bind(this),location:this.populateLocation.bind(this),selector:this.populateTaxonomy.bind(this),user:this.populateUser.bind(this),upload:this.populateUpload.bind(this),set:this.populateMultiValue.bind(this),checkbox:this.populateMultiValue.bind(this),select:this.populateSingleValue.bind(this),radio:this.populateSingleValue.bind(this),"true-false":this.populateBoolean.bind(this),date:this.populateDate.bind(this),time:this.populateDate.bind(this),datetime:this.populateDate.bind(this),number:this.populateNumber.bind(this),textarea:this.populateTextarea.bind(this)};Object.hasOwn(l,a)?l[a](e,t,i):this.populateText(e,t,i)}populateRepeater(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".repeater-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let o=i.querySelectorAll(".field");this.populate(o,e),a.append(i)})))}populateTagList(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".tag-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let o=i.querySelectorAll(".field");this.populate(o,e),a.append(i)})))}populateLocation(e,t,i){["address","lat","lng","street","city","province","postal_code","country"].forEach((t=>{if(Object.hasOwn(i,t)){let a=e.querySelector(`[data-location-field="${t}"]`);a&&(a.value=String(i[t]||""))}}))}populateTaxonomy(e,t,i){let a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector(`input[type="hidden"][name="${t}"]`);l&&(l.value=a.join(","),window.jvbSelector&&requestAnimationFrame((()=>{window.jvbSelector.updateFieldFromInput(l)})))}populateUser(e,t,i){this.populateTaxonomy(e,t,i)}populateUpload(e,t,i){if("timeline"===t||e.dataset.subtype&&"timeline"===e.dataset.subtype)return void this.populateTimelineGallery(e,t,i);if(this.isEmptyValue(i))return;const a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector('input[type="hidden"]');l&&(l.value=a.join(","));const o=e.querySelector(".item-grid");e.querySelector(".file-upload-container").hidden=a.length>0,e.querySelector(".progress")?.remove(),o&&(window.removeChildren(o),a.forEach((e=>{let t=this.data.images[e]??{};t.field={config:{showMeta:!0}},t.id=e,o.append(this.templates.create("uploadItem",t))}))),this.populateUploadMeta(e,t,i)}populateUploadMeta(e,t,i){const a=e.querySelector('[data-field="image_data"]');if(!a)return;let l=this.data.images[i]??!1;if(!l)return;a.dataset.attachmentId=l.id,a.setAttribute("data-ignore","");const o=["image-title","image-alt-text","image-caption"];for(const e of o){const t=a.querySelector(`[data-field="${e}"] input, [data-field="${e}"] textarea`);t&&""!==l[e]&&(t.value=l[e])}}populateTimelineGallery(e,t,i){if(!i||!Array.isArray(i)||0===i.length)return;let a=e.querySelector(".item-grid");if(e.querySelector(".file-upload-container").hidden=i.length>0,a){window.removeChildren(a),e.querySelector(".progress")?.remove();for(let e of i){let t=this.templates.create("timelineItem",e);t&&a.append(t)}}}populateMultiValue(e,t,i){if("string"==typeof i)try{i=JSON.parse(i)}catch(e){i=i.split(",").map((e=>e.trim()))}Array.isArray(i)||(i=[String(i)]);let a=e.querySelector(`select[name="${t}"]`);if(a&&a.multiple)for(let e of a.options)e.selected=i.includes(e.value);else e.querySelectorAll(`[type="checkbox"][name=${t}]`).forEach((e=>{e.checked=i.includes(e.value)}))}populateSingleValue(e,t,i){i=String(i||"");let a=e.querySelector(`select[name="${t}"]`);if(a)return void(a.value=i);let l=e.querySelector(`[name="${t}"][value="${i}"]`);l&&(l.checked=!0)}populateBoolean(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="checkbox"]`);a&&(a.checked=Boolean(i))}populateDate(e,t,i){const a=e.querySelector(`[name="${t}"], input`);if(a){"object"==typeof i&&Object.hasOwn(i,"date")&&(i=i.date);try{const e=new Date(i);if(!isNaN(e.getTime()))switch(a.type){case"date":a.value=e.toISOString().split("T")[0];break;case"time":a.value=e.toTimeString().slice(0,5);break;case"datetime-local":a.value=e.toISOString().slice(0,16);break;default:a.value=i}}catch(e){a.value=i}}}populateNumber(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="number"]`);a&&(a.value=Number(i)||0)}populateTextarea(e,t,i){let a=e.querySelector("textarea");a.dataset.editor?(a.value=String(i||""),a.dispatchEvent(new Event("change",{bubbles:!0}))):this.populateText(e,t,i)}populateText(e,t,i){let a=e.querySelector(`[name="${t}"], input, textarea`);a&&"file"!==a.type&&(a.value=String(i||""))}getFormHelper(){window.requestAnimationFrame((()=>{this.formHelper=window.jvbForm}))}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e)&&e>0))}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}defineTemplates(){const e=this.templates,t=this;e.define("timelineItem",{refs:{select:'[name="select-item"]',video:"video",file:".select-item span",img:"img",details:"details[data-field]",imgAlt:'[name="image-alt-text"]',imgTitle:'[name="image-title"]',imgDesc:'[name="image-caption"]'},manyRefs:{fields:".field"},setup({el:e,refs:i,manyRefs:a,data:l}){if(e.dataset.itemId=l.id,i.select){let e=i.select.closest(".preview");window.prefixInput(i.select,`${l.id}-`,e)}i.video&&i.video.remove(),i.file&&i.file.remove();let o=t.data.images[l.post_thumbnail]??!1;if(i.img&&o&&(i.img.src=o.medium||o.small||o.large||"",i.img.title=o["image-title"]??"",i.img.alt=o["image-alt-text"]??""),i.details){let e=t.data.images[l.post_thumbnail];i.details.setAttribute("data-ignore",""),i.details.dataset.attachmentId=l.post_thumbnail,Object.hasOwn(e,"image-alt-text")&&i.alt&&(i.alt.value=e["image-alt-text"]),(Object.hasOwn(e,"image-title")||Object.hasOwn(l,"file"))&&i.title&&(i.title.value=e["image-title"]||l.file.name),Object.hasOwn(e,"image-caption")&&i.description&&(i.description.value=e["image-caption"])}if(a.fields)for(let e of a.fields){if("group"===e.dataset.fieldType)continue;if("post_thumbnail"===e.dataset.field){e.remove();continue}let i=e.dataset.field,a=l[i]??"";t.isEmptyValue(a)||t.populateField(e,i,a);const o=e.querySelector('input:not([type="file"])');o&&window.prefixInput(o,`[${l.id}]`,e)}}})}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbPopulate=new e)}))}))})(); |
| | | (()=>{class e{constructor(){this.templates=window.jvbTemplates,this.formHelper=window.jvbForm,this.defineTemplates(),this.data=null,this.form=null}populate(e,t={}){if(this.data=t,this.mergeRootData(),this.form=e,this.formHelper||(this.formHelper=window.jvbForm),this.formHelper){if(Object.hasOwn(this.data,"fields")&&0!==Object.keys(this.data.fields).length)for(let[t,i]of Object.entries(this.data.fields)){let a=e.querySelector(`[data-field="${t}"]`);a&&this.populateField(a,t,i)}}else requestAnimationFrame((()=>{this.populate(e,t)}))}mergeRootData(){["status","date","modified"].forEach((e=>{this.data.fields[`post_${e}`]=this.data[e]}))}populateField(e,t,i){let a=this.formHelper.getFieldType(e);if(!a||this.isEmptyValue(t)||this.isEmptyValue(i))return;const l={repeater:this.populateRepeater.bind(this),"tag-list":this.populateTagList.bind(this),location:this.populateLocation.bind(this),selector:this.populateTaxonomy.bind(this),user:this.populateUser.bind(this),upload:this.populateUpload.bind(this),set:this.populateMultiValue.bind(this),checkbox:this.populateMultiValue.bind(this),select:this.populateSingleValue.bind(this),radio:this.populateSingleValue.bind(this),"true-false":this.populateBoolean.bind(this),date:this.populateDate.bind(this),time:this.populateDate.bind(this),datetime:this.populateDate.bind(this),number:this.populateNumber.bind(this),textarea:this.populateTextarea.bind(this)};Object.hasOwn(l,a)?l[a](e,t,i):this.populateText(e,t,i)}populateRepeater(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".repeater-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);if(i){for(let[t,a]of Object.entries(e)){if("index"===t)continue;let e=i.querySelector(`[data-field="${t}"]`);e&&this.populateField(e,t,a)}a.append(i)}})))}populateTagList(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".tag-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((i,s)=>{const r=this.templates.create(l,{label:this.getTagLabel(i,e.dataset.tagFormat??"first_field"),fieldName:t,...i});r&&(r.querySelectorAll('input[type="hidden"]').forEach((e=>{const t=e.dataset.field;t&&void 0!==i[t]&&(e.value=i[t])})),a.append(r))})))}getTagLabel(e,t){const i=Object.values(e).filter((e=>!this.isEmptyValue(e)));switch(t){case"first_field":return i[0]??"New Item";case"all_fields":return i.join(", ")||"New Item";default:if(t.includes("{")){let i=t;for(const[t,a]of Object.entries(e))i=i.replace(`{${t}}`,a);return i}return e[t]??i[0]??"New Item"}}populateLocation(e,t,i){["address","lat","lng","street","city","province","postal_code","country"].forEach((t=>{if(Object.hasOwn(i,t)){let a=e.querySelector(`[data-location-field="${t}"]`);a&&(a.value=String(i[t]||""))}}))}populateTaxonomy(e,t,i){let a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector(`input[type="hidden"][name="${t}"]`);l&&(l.value=a.join(","),window.jvbSelector&&requestAnimationFrame((()=>{window.jvbSelector.updateFieldFromInput(l)})))}populateUser(e,t,i){this.populateTaxonomy(e,t,i)}populateUpload(e,t,i){if("timeline"===t||e.dataset.subtype&&"timeline"===e.dataset.subtype)return void this.populateTimelineGallery(e,t,i);if(this.isEmptyValue(i))return;const a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector('input[type="hidden"]');l&&(l.value=a.join(","));const s=e.querySelector(".item-grid");e.querySelector(".file-upload-container").hidden=a.length>0,e.querySelector(".progress")?.remove(),s&&(window.removeChildren(s),a.forEach((e=>{let t=this.data.images[e]??{};t.field={config:{showMeta:!0}},t.id=e,s.append(this.templates.create("uploadItem",t))}))),this.populateUploadMeta(e,t,i)}populateUploadMeta(e,t,i){const a=e.querySelector('[data-field="image_data"]');if(!a)return;let l=this.data.images[i]??!1;if(!l)return;a.dataset.attachmentId=l.id,a.setAttribute("data-ignore","");const s=["image-title","image-alt-text","image-caption"];for(const e of s){const t=a.querySelector(`[data-field="${e}"] input, [data-field="${e}"] textarea`);t&&""!==l[e]&&(t.value=l[e])}}populateTimelineGallery(e,t,i){if(!i||!Array.isArray(i)||0===i.length)return;let a=e.querySelector(".item-grid");if(e.querySelector(".file-upload-container").hidden=i.length>0,a){window.removeChildren(a),e.querySelector(".progress")?.remove();for(let e of i){let t=this.templates.create("timelineItem",e);t&&a.append(t)}}}populateMultiValue(e,t,i){if("string"==typeof i)try{i=JSON.parse(i)}catch(e){i=i.split(",").map((e=>e.trim()))}Array.isArray(i)||(i=[String(i)]);let a=e.querySelector(`select[name="${t}"]`);if(a&&a.multiple)for(let e of a.options)e.selected=i.includes(e.value);else e.querySelectorAll(`[type="checkbox"][name=${t}]`).forEach((e=>{e.checked=i.includes(e.value)}))}populateSingleValue(e,t,i){i=String(i||"");let a=e.querySelector(`select[name="${t}"]`);if(a)return void(a.value=i);let l=e.querySelector(`[name="${t}"][value="${i}"]`);l&&(l.checked=!0)}populateBoolean(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="checkbox"]`);a&&(a.checked=Boolean(i))}populateDate(e,t,i){const a=e.querySelector(`[name="${t}"], input`);if(a){"object"==typeof i&&Object.hasOwn(i,"date")&&(i=i.date);try{const e=new Date(i);if(!isNaN(e.getTime()))switch(a.type){case"date":a.value=e.toISOString().split("T")[0];break;case"time":a.value=e.toTimeString().slice(0,5);break;case"datetime-local":a.value=e.toISOString().slice(0,16);break;default:a.value=i}}catch(e){a.value=i}}}populateNumber(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="number"]`);a&&(a.value=Number(i)||0)}populateTextarea(e,t,i){let a=e.querySelector("textarea");a.dataset.editor?(a.value=String(i||""),a.dispatchEvent(new Event("change",{bubbles:!0}))):this.populateText(e,t,i)}populateText(e,t,i){let a=e.querySelector(`[name="${t}"], input, textarea`);a&&"file"!==a.type&&(a.value=String(i||""))}getFormHelper(){window.requestAnimationFrame((()=>{this.formHelper=window.jvbForm}))}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e)&&e>0))}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}defineTemplates(){const e=this.templates,t=this;e.define("timelineItem",{refs:{select:'[name="select-item"]',video:"video",file:".select-item span",img:"img",details:"details[data-field]",imgAlt:'[name="image-alt-text"]',imgTitle:'[name="image-title"]',imgDesc:'[name="image-caption"]'},manyRefs:{fields:".field"},setup({el:e,refs:i,manyRefs:a,data:l}){if(e.dataset.itemId=l.id,i.select){let e=i.select.closest(".preview");window.prefixInput(i.select,`${l.id}-`,e)}i.video&&i.video.remove(),i.file&&i.file.remove();let s=t.data.images[l.post_thumbnail]??!1;if(i.img&&s&&(i.img.src=s.medium||s.small||s.large||"",i.img.title=s["image-title"]??"",i.img.alt=s["image-alt-text"]??""),i.details){let e=t.data.images[l.post_thumbnail];i.details.setAttribute("data-ignore",""),i.details.dataset.attachmentId=l.post_thumbnail,Object.hasOwn(e,"image-alt-text")&&i.alt&&(i.alt.value=e["image-alt-text"]),(Object.hasOwn(e,"image-title")||Object.hasOwn(l,"file"))&&i.title&&(i.title.value=e["image-title"]||l.file.name),Object.hasOwn(e,"image-caption")&&i.description&&(i.description.value=e["image-caption"])}if(a.fields)for(let e of a.fields){if("group"===e.dataset.fieldType)continue;if("post_thumbnail"===e.dataset.field){e.remove();continue}let i=e.dataset.field,a=l[i]??"";t.isEmptyValue(a)||t.populateField(e,i,a);const s=e.querySelector('input:not([type="file"])');s&&window.prefixInput(s,`[${l.id}]`,e)}}})}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbPopulate=new e)}))}))})(); |
| | |
| | | (()=>{class e{constructor(e={}){this.config={...squareConfig,...e},this.payments=null,this.card=null,this.isInitialized=!1,this.cartItems=new Map,this.checkout=document.querySelector("aside#cart"),this.isOpen="1"!==this.config.isOpen||!1,this.isLoggedIn=this.config.is_logged_in||!1,this.userEmail=this.config.user_email||"",this.savedCards=[],this.selectedCardId=null,this.cartId=null,this.cache=new window.jvbCache("cart",{TTL:864e5}),this.a11y=window.jvbA11y,this.initCart(),this.checkout&&(this.initElements(),this.init(),this.initListeners(),this.isLoggedIn&&this.loadSavedCards()),this.stepMultiplier=1,this.popup=new window.jvbPopup({popup:this.checkout,toggle:this.toggle,name:"Cart",onOpen:this.maybeAddEmptyState.bind(this)}),console.log(this.popup)}async initCart(){this.cartItems=await this.cache.get("cart")??new Map,console.log("cart",this.cartItems),this.cartItems.size>0&&this.notifyRestoredCart()}handleClick(e){if(window.targetCheck(e,"button")&&window.targetCheck(e,"div.quantity")){let t=window.targetCheck(e,"div.quantity");this.handleNumberClick(e,t)}else if(window.targetCheck(e,"[data-add-to-cart]")){let t=window.targetCheck(e,"[data-add-to-cart]");this.handleAddToCart(t)}else if(window.targetCheck(e,"[data-remove-from-cart]")){let t=window.targetCheck(e,"[data-remove-from-cart]");this.handleRemoveFromCart(t)}else window.targetCheck(e,"[data-clear-cart]")&&this.clearCart()}handleChange(e,t){console.log("Checkout change");let a=window.targetCheck(e,".quantity-input");if(a){let t=e.target.closest(".quantity"),i=a.value;if(window.targetCheck(e,".cart-items")){let e=document.querySelector(`.menu-section [data-id="${t.dataset.id}"] input`);e&&(e.value=a.value)}i>0?this.handleAddToCart(t):this.handleRemoveFromCart(t)}}handleNumberClick(e,t){console.log(t),e.preventDefault();let a=0;if(e.target.closest(".increase")?a+=1:e.target.closest(".decrease")&&(a-=1),0!==a){let[e,i]=[parseInt(t.dataset.step),t.querySelector("input")],s=""===i.value?0:parseInt(i.value);i.value=s+e*a*this.stepMultiplier,i.dispatchEvent(new Event("change",{bubbles:!0})),this.handleNumberLimits(t)}}handleNumberLimits(e){let[t,a,i,s,r]=[e.dataset.min,e.dataset.max,e.querySelector("input"),e.querySelector(".increase"),e.querySelector(".decrease")],o=parseInt(i.value);o<t?(i.value=t,r.disabled=!0):o>a?(i.value=a,s.disabled=!1):s.disabled?s.disabled=!1:r.disabled&&(r.disabled=!1)}maybeAddEmptyState(){let e=this.itemsList.querySelector(".empty");if(e&&e.remove(),0===this.cartItems.size){this.checkoutPanel.disabled=!0,this.checkoutPanel.title="Add some things to your cart first!";let e=window.getTemplate("emptyCart");this.itemsList.append(e),this.table.closest("table").hidden=!0,this.total.hidden=!0,this.a11y.announce("Nothing in Cart")}else this.checkoutPanel.disabled=!1,this.table.closest("table").hidden=!1,this.total.hidden=!1,this.checkoutPanel.title="Checkout"}handleEscape(e){"Escape"===e.key?this.stepMultiplier=1:e.ctrlKey&&e.shiftKey?this.stepMultiplier=Math.max(100*parseInt(this.stepMultiplier),1e3):e.shiftKey&&(this.stepMultiplier=Math.max(10*parseInt(this.stepMultiplier),1e3))}handleAddToCart(e){let t=e.dataset.id;this.createItemElement(e);let a=parseFloat(e.dataset.price),i=parseInt(e.querySelector(".quantity-input")?.value)??1,s=parseFloat(a*i);this.cartItems.set(t,{post_id:t,name:e.dataset.name,price:a,quantity:i,total:s,square_catalog_id:e.dataset.squareCatalogId}),this.saveCart()}notifyRestoredCart(){let e=window.getTemplate("restoredCart");this.checkout.querySelector(".tab-content[data-tab=cartItems]").insertBefore(e,this.itemsList),this.cartItems.forEach((e=>{console.log(e);let t=window.getTemplate("cartItem"),a=t.querySelector(".quantity"),i=e.price,s=e.quantity;[a.dataset.id,t.querySelector("label").textContent,t.querySelector(".price").textContent,a.dataset.price,a.dataset.squareCatalogId,t.querySelector('[name="quantity"]').value,t.querySelector(".total").textContent]=[e.post_id,e.name,window.formatPrice(i),i,e.square_catalog_id,s,window.formatPrice(s*i)],this.table.append(t)})),this.updateTotal()}handleRemoveFromCart(e){if(confirm("This will remove this item from the cart. Continue?")){e.querySelector("[data-id]")||(e=e.closest(".item")?.querySelector(".quantity.field"));let t=e.dataset.id;this.cartItems.delete(t),this.table.querySelector(`[data-id="${t}"]`)?.closest("tr").remove();let a=document.querySelector(`[data-id="${t}"] input`);a&&(a.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 e=0;this.cartItems.forEach((t=>{console.log(t),e+=t.total}));let t=.05*e;e=window.formatPrice(e+t),t=window.formatPrice(t),window.eraseText(this.totalTax),window.eraseText(this.grandTotal),window.typeText(this.totalTax,t),window.typeText(this.grandTotal,e),this.totalTax.classList.remove("typeText")}createItemElement(e){let t=this.itemsList.querySelector(`[data-id="${e.dataset.id}"]`),a=!1,i=e.dataset.price,s=e.querySelector('[name="quantity"]')?.value??1;if(t)t=t.closest("tr");else{a=!0,t=window.getTemplate("cartItem");let s=t.querySelector(".quantity");[s.dataset.id,t.querySelector("label").textContent,t.querySelector(".price").textContent,s.dataset.price,s.dataset.squareCatalogId]=[e.dataset.id,e.dataset.name,window.formatPrice(i),i,e.dataset.squareCatalogId]}[t.querySelector('[name="quantity"]').value,t.querySelector(".total").textContent]=[s,window.formatPrice(s*i)],a&&(t.classList.add("adding"),this.table.append(t),setTimeout((()=>{t.classList.remove("adding")}),500))}async init(){if(window.Square)try{this.payments=window.Square.payments(this.config.application_id,this.config.location_id),await this.initializePaymentMethods(),this.isInitialized=!0,document.dispatchEvent(new CustomEvent("squareCheckoutReady",{detail:{checkout:this}}))}catch(e){console.error("Failed to initialize Square payments:",e),this.handleError(e)}else console.error("Square Web Payments SDK not loaded")}initElements(){this.toggle=document.querySelector(".toggle-cart"),this.isOpen||(this.toggle.disabled=!0,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:!1}),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(){if(document.getElementById("square-card-container"))try{this.card=await this.payments.card({style:this.getCardStyle()}),await this.card.attach("#square-card-container"),this.card.addEventListener("cardBrandChanged",(e=>{console.log("Card brand:",e.detail.cardBrand)}))}catch(e){throw console.error("Failed to initialize card:",e),e}}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(e){if(!this.isOpen)return;if(e.preventDefault(),!this.isInitialized)return void this.handleError("Checkout not initialized");const t=e.target,a=this.extractOrderData(t);try{window.jvbLoading.showLoading("Processing payment...");const e=await this.processPayment(a);this.handleSuccess(e,t)}catch(e){this.handleError(e)}finally{window.jvbLoading.hideLoading()}}extractOrderData(e){const t=Array.from(this.cartItems.values()).map((e=>({catalog_object_id:e.square_catalog_id,quantity:String(e.quantity),price:e.price,note:e.note||""}))),a=t.reduce(((e,t)=>e+t.price*t.quantity),0);return{total:Math.round(100*a),items:t,customer:{email:this.isLoggedIn?this.userEmail:e.querySelector('[name="email"]')?.value||"",name:e.querySelector('[name="name"]')?.value||"",phone:e.querySelector('[name="phone"]')?.value||""},note:e.querySelector('[name="special_instructions"]')?.value||"",pickup_time:e.querySelector('[name="pickup_time"]')?.value||""}}async processPayment(e){try{let t=null;if(this.selectedCardId)t=this.selectedCardId;else{const a=await this.card.tokenize({verificationDetails:{amount:String(e.total),currencyCode:this.config.currency||"CAD",intent:"CHARGE",customerInitiated:!0,billingContact:{givenName:e.customer.name.split(" ")[0],familyName:e.customer.name.split(" ").slice(1).join(" "),email:e.customer.email,phone:e.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"}}});if("OK"!==a.status){const e=a.errors?.map((e=>e.message)).join(", ")||"Unknown error";throw new Error(`Card tokenization failed: ${e}`)}t=a.token,a.details?.userChallenged&&console.log("3D Secure verification completed")}return await this.submitToServer(t,e,!!this.selectedCardId)}catch(e){throw console.error("Payment processing failed:",e),e}}async submitToServer(e,t,a=!1){if(!this.isOpen)throw new Error("Store is currently closed");const i=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:e,is_saved_card:a,cart_id:this.getCartId(),amount:t.total,items:t.items,customer:{email:this.isLoggedIn?this.userEmail:t.customer.email,name:t.customer.name,phone:t.customer.phone},note:t.note,pickup_time:t.pickup_time})}),s=await i.json();if(!i.ok)throw new Error(s.message||"Payment processing failed");return this.clearCart(),s}getCartId(){return this.cartId||(this.cartId=crypto.randomUUID(),this.cache.set("cart_id",this.cartId)),this.cartId}trackOrder(e){this.orderId=e,this.scheduleOrderCheck(),this.checkout.querySelector("button[data-tab=order]").hidden=!1}scheduleOrderCheck(){window.debouncer.schedule("order",(()=>{this.checkOrderStatus()}),3e4)}async checkOrderStatus(){const e=await fetch(`/wp-json/jvb/v1/square/order-status/${this.orderId}`),t=await e.json();"ready"!==t.status&&this.scheduleOrderCheck(),this.updateOrderStatus(t)}updateOrderStatus(e){this.checkout.querySelectorAll(".status-item").forEach((t=>{t.dataset.status===e.status&&t.classList.add("active")})),this.checkout.querySelector("#eta").textContent=e.eta||"In progress"}async loadSavedCards(){try{const e=await fetch(this.config.api_url+"saved-cards",{method:"GET",headers:{"X-WP-Nonce":this.config.nonce}}),t=await e.json();t.success&&t.cards&&(this.savedCards=t.cards,this.renderSavedCards())}catch(e){console.error("Failed to load saved cards:",e)}}renderSavedCards(){const e=document.getElementById("saved-cards");if(!e||0===this.savedCards.length)return;const t=`\n <div class="saved-cards-section">\n <h4>Saved Payment Methods</h4>\n ${this.savedCards.map((e=>`\n <label class="saved-card">\n <input type="radio" name="payment-method" value="saved" data-card-id="${e.id}">\n <span class="card-info">\n <strong>${e.card_brand}</strong> ending in ${e.last_4}\n <small>Exp: ${e.exp_month}/${e.exp_year}</small>\n </span>\n </label>\n `)).join("")}\n <label class="saved-card">\n <input type="radio" name="payment-method" value="new" checked>\n <span>Use a new card</span>\n </label>\n </div>\n `;e.innerHTML=t,e.querySelectorAll('input[name="payment-method"]').forEach((e=>{e.addEventListener("change",(e=>{const t="new"===e.target.value,a=document.getElementById("square-card-container");a&&(a.style.display=t?"block":"none"),this.selectedCardId=t?null:e.target.dataset.cardId}))}))}handleSuccess(e,t){document.dispatchEvent(new CustomEvent("squareCheckoutSuccess",{detail:{result:e,form:t}}));const a=t.dataset.successUrl||`/order-confirmation/?order=${e.wp_order_id}`;window.location.href=a}handleError(e){console.error("Square checkout error:",e),document.dispatchEvent(new CustomEvent("squareCheckoutError",{detail:{error:e}})),window.jvbNotifications?.show?.(e.message||"Payment failed","error")}}document.addEventListener("DOMContentLoaded",(()=>{window.squareCheckout=new e}))})(); |
| | | (()=>{class e extends window.jvbCheckout{constructor(e={}){super({...window.squareConfig,...e}),this.payments=null,this.card=null}async init(){if(window.Square)try{this.payments=window.Square.payments(this.config.application_id,this.config.location_id),await this.initializePaymentMethods(),this.isInitialized=!0,document.dispatchEvent(new CustomEvent("checkoutReady",{detail:{checkout:this,provider:"square"}}))}catch(e){console.error("Failed to initialize Square payments:",e),this.handleError(e)}else console.error("Square Web Payments SDK not loaded")}async initializePaymentMethods(){if(document.getElementById("payment-container"))try{this.card=await this.payments.card({style:this.getCardStyle()}),await this.card.attach("#payment-container"),this.card.addEventListener("cardBrandChanged",(e=>{console.log("Card brand:",e.detail.cardBrand)}))}catch(e){throw console.error("Failed to initialize card:",e),e}}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 processPayment(e){try{let t=null;if(this.selectedCardId)t=this.selectedCardId;else{const i=await this.card.tokenize({verificationDetails:{amount:String(e.total),currencyCode:this.config.currency||"CAD",intent:"CHARGE",customerInitiated:!0,billingContact:{givenName:e.customer.name.split(" ")[0],familyName:e.customer.name.split(" ").slice(1).join(" "),email:e.customer.email,phone:e.customer.phone}}});if("OK"!==i.status){const e=i.errors?.map((e=>e.message)).join(", ")||"Unknown error";throw new Error(`Card tokenization failed: ${e}`)}t=i.token,i.details?.userChallenged&&console.log("3D Secure verification completed")}return await this.submitToServer(t,e,!!this.selectedCardId)}catch(e){throw console.error("Payment processing failed:",e),e}}async submitToServer(e,t,i=!1){if(!this.isOpen)throw new Error("Store is currently closed");const a=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:e,is_saved_card:i,cart_id:this.getCartId(),amount:t.total,items:t.items,customer:{email:this.isLoggedIn?this.userEmail:t.customer.email,name:t.customer.name,phone:t.customer.phone},note:t.note,pickup_time:t.pickup_time})}),r=await a.json();if(!a.ok)throw new Error(r.message||"Payment processing failed");return this.clearCart(),r}extractOrderData(e){const t=super.extractOrderData(e);return t.items=t.items.map((e=>({catalog_object_id:e.catalog_id,quantity:e.quantity,price:e.price,note:e.note}))),t}async loadSavedCards(){try{const e=await fetch(this.config.api_url+"saved-cards",{method:"GET",headers:{"X-WP-Nonce":this.config.nonce}}),t=await e.json();t.success&&t.cards&&(this.savedCards=t.cards,this.renderSavedCards())}catch(e){console.error("Failed to load saved cards:",e)}}}document.addEventListener("DOMContentLoaded",(()=>{document.querySelector('#checkout[data-provider="square"]')&&(window.squareCheckout=new e)}))})(); |
| | |
| | | <?php |
| | | namespace JVBase\admin; |
| | | |
| | | use JVBase\managers\IconsManager; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | |
| | | true |
| | | ); |
| | | |
| | | IconsManager::for()->enqueueIconStyles(); |
| | | IconsManager::for('dash')->enqueueIconStyles(); |
| | | IconsManager::for('form')->enqueueIconStyles(); |
| | | |
| | | $queue = [ |
| | | 'api' => rest_url('jvb/v1/'), |
| | | 'redirect' => wp_login_url(home_url(add_query_arg(null, null))), |
| | |
| | | namespace JVBase\importers; |
| | | |
| | | use WP_Error; |
| | | use JVBase\managers\ReferralManager; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | |
| | | <?php |
| | | namespace JVBase\integrations; |
| | | |
| | | use JVBase\meta\Meta; |
| | | use Exception; |
| | | use WP_Error; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | use WP_Post; |
| | | use JVBase\ui\Checkout; |
| | | use JVBase\managers\queue\TypeConfig; |
| | | use JVBase\managers\queue\executors\IntegrationExecutor; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Helcim Integration for JVBase |
| | | * Handles bidirectional sync, customer management, and order processing |
| | | * Helcim Integration Class |
| | | * |
| | | * Handles HelcimPay.js checkout, invoice retrieval, customer/card management, |
| | | * and bidirectional product sync via API token authentication. |
| | | * |
| | | * Helcim is the source of truth for invoices and orders. |
| | | * Products sync bidirectionally through the standard integration field flow. |
| | | * |
| | | * @since 1.0.0 |
| | | */ |
| | | class Helcim extends Integrations |
| | | { |
| | | // Helcim-specific configuration |
| | | private string $api_token; |
| | | private string $account_id; |
| | | private string $terminal_id; |
| | | private string $webhook_secret; |
| | | protected string $service_name = 'helcim'; |
| | | protected string|array $apiBase = 'https://api.helcim.com/v2'; |
| | | |
| | | // Field mapping cache |
| | | private array $field_mappings = []; |
| | | private array $category_cache = []; |
| | | protected bool $isOAuthService = false; |
| | | |
| | | // Order processing |
| | | private bool $is_test_mode = false; |
| | | private array $payment_form_settings = []; |
| | | |
| | | // User security |
| | | private const PASSWORD_RESET_INTERVAL = 3; // Reset password every 3 logins |
| | | /** |
| | | * Helcim API rate limits |
| | | * @see https://devdocs.helcim.com/docs/api-rate-limits |
| | | */ |
| | | protected array $rate_limits = [ |
| | | 'per_second' => 5, |
| | | 'per_minute' => 60, |
| | | 'per_hour' => 1000 |
| | | ]; |
| | | |
| | | public function __construct(?int $userID = null) |
| | | { |
| | | $this->service_name = 'helcim'; |
| | | $this->title = 'Helcim'; |
| | | $this->icon = 'currency-circle-dollar'; |
| | | $this->icon = 'credit-card'; |
| | | |
| | | // Helcim API endpoints |
| | | $this->apiBase = [ |
| | | 'production' => 'https://api.helcim.com/v2', |
| | | 'sandbox' => 'https://api-sandbox.helcim.com/v2' |
| | | $this->fields = [ |
| | | 'api_token' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'API Token', |
| | | 'hint' => 'Found in Helcim Dashboard → Settings → API Access', |
| | | 'required' => true, |
| | | ], |
| | | 'currency' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Currency', |
| | | 'options' => [ |
| | | 'CAD' => 'CAD', |
| | | 'USD' => 'USD', |
| | | ], |
| | | 'default' => 'CAD', |
| | | ], |
| | | ]; |
| | | |
| | | $this->apiEndpoints = [ |
| | | 'commerce/invoice', |
| | | 'commerce/transaction', |
| | | 'commerce/customer', |
| | | 'commerce/card-batch', |
| | | 'commerce/terminal', |
| | | 'commerce/product', |
| | | 'commerce/order', |
| | | 'payment/purchase', |
| | | 'payment/verify', |
| | | 'payment/capture', |
| | | 'payment/refund', |
| | | 'customer/create', |
| | | 'customer/update', |
| | | 'customer/get', |
| | | 'inventory/product', |
| | | 'inventory/batch' |
| | | $this->advanced = [ |
| | | 'fee_saver' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Fee Saver', |
| | | 'hint' => 'Pass processing fees to customers (not compatible with Google Pay)', |
| | | ], |
| | | 'allow_ach' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Allow ACH/Bank Payments', |
| | | 'hint' => 'Enable bank account payments alongside credit card', |
| | | ], |
| | | ]; |
| | | |
| | | $this->instructions = [ |
| | | 'Go to <a href="https://my.helcim.com" target="_blank">Helcim Dashboard</a>', |
| | | 'Navigate to Settings → API Access', |
| | | 'Create a new API Access Configuration', |
| | | 'Enable permissions: General (Customers, Invoices, Products), Transaction Processing', |
| | | 'Copy the API Token and paste it below', |
| | | ]; |
| | | |
| | | $this->canSync = [ |
| | | 'create' => true, |
| | | 'update' => true, |
| | | 'delete' => true |
| | | 'delete' => false, |
| | | ]; |
| | | |
| | | $this->fields = [ |
| | | 'test_mode' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Environment', |
| | | 'options' => [ |
| | | '1' => 'Test Mode', |
| | | '0' => 'Production' |
| | | ], |
| | | 'default' => '1' |
| | | ], |
| | | 'api_token' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'required' => true, |
| | | 'hint' => 'Your Helcim API Token' |
| | | ], |
| | | 'account_id' => [ |
| | | 'type' => 'text', |
| | | 'required' => true, |
| | | 'label' => 'Account ID', |
| | | 'hint' => 'Your Helcim Account ID', |
| | | ], |
| | | 'webhook_secret' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'Webhook Secret', |
| | | 'hint' => 'For webhook verification', |
| | | 'required' => true, |
| | | ] |
| | | ]; |
| | | |
| | | $this->advanced = [ |
| | | |
| | | ]; |
| | | |
| | | $this->instructions = [ |
| | | 'Once you are set up, add this URL to your Helcim webhook settings: <code>'.esc_html(rest_url('jvb/v1/webhooks/helcim')).'</code>' |
| | | ]; |
| | | |
| | | $this->defaults = [ |
| | | |
| | | ]; |
| | | |
| | | $this->handleWebhooks = true; |
| | | $this->handleWebhooks = false; |
| | | |
| | | parent::__construct($userID); |
| | | |
| | | $this->actions = array_merge( |
| | | $this->actions, |
| | | [ |
| | | 'import_from_helcim' => 'handleImportFromHelcim', |
| | | 'sync_to_helcim' => 'handleSyncToHelcim' |
| | | ] |
| | | ); |
| | | // Initialize field mappings |
| | | $this->initializeFieldMappings(); |
| | | // Helcim-specific actions (processAction dispatches these) |
| | | $this->actions = array_merge($this->actions, [ |
| | | 'initialize_checkout' => 'initializeCheckout', |
| | | 'get_invoices' => 'handleGetInvoices', |
| | | 'get_invoice' => 'handleGetInvoice', |
| | | 'get_customer_cards' => 'handleGetCustomerCards', |
| | | ]); |
| | | |
| | | $this->buttons = array_merge($this->buttons, [ |
| | | 'import_from_helcim' => 'Import Products from Helcim', |
| | | 'sync_to_helcim' => 'Sync Site to Helcim', |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Initialize service-specific settings |
| | | */ |
| | | /***************************************************************** |
| | | * ABSTRACT IMPLEMENTATIONS |
| | | *****************************************************************/ |
| | | |
| | | protected function initialize(): void |
| | | { |
| | | $this->api_token = $this->credentials['api_token'] ?? ''; |
| | | $this->account_id = $this->credentials['account_id'] ?? ''; |
| | | $this->terminal_id = $this->credentials['terminal_id'] ?? ''; |
| | | $this->webhook_secret = $this->credentials['webhook_secret'] ?? ''; |
| | | $this->is_test_mode = (bool)($this->credentials['test_mode'] ?? false); |
| | | if (empty($this->credentials)) { |
| | | $this->loadCredentials(); |
| | | } |
| | | |
| | | // Set the appropriate API base |
| | | $this->apiBase = $this->is_test_mode ? $this->apiBase['sandbox'] : $this->apiBase['production']; |
| | | |
| | | // Load payment form settings |
| | | $this->payment_form_settings = [ |
| | | 'card' => $this->credentials['enable_card_payments'] ?? true, |
| | | 'ach' => $this->credentials['enable_ach_payments'] ?? false, |
| | | 'apple_pay' => $this->credentials['enable_apple_pay'] ?? false, |
| | | 'google_pay' => $this->credentials['enable_google_pay'] ?? false, |
| | | $this->apiEndpoints = [ |
| | | 'connection-test', |
| | | 'helcim-pay/initialize', |
| | | 'invoices', |
| | | 'customers', |
| | | 'payment/purchase', |
| | | 'payment/preauth', |
| | | 'payment/capture', |
| | | 'payment/refund', |
| | | 'payment/verify', |
| | | 'card-transactions', |
| | | 'card-batches', |
| | | ]; |
| | | } |
| | | |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | return [ |
| | | 'api-token' => $this->credentials['api_token'] ?? '', |
| | | 'Content-Type' => 'application/json', |
| | | 'Accept' => 'application/json', |
| | | ]; |
| | | } |
| | | |
| | | protected function performConnectionTest(): bool |
| | | { |
| | | try { |
| | | $response = $this->getRequest('connection-test', [], null, 'none', true); |
| | | return !is_wp_error($response) && !$this->isErrorResponse($response ?? []); |
| | | } catch (Exception $e) { |
| | | $this->logError('Connection test failed', ['error' => $e->getMessage()]); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * CONTENT TYPES — product field definitions |
| | | *****************************************************************/ |
| | | |
| | | protected function setContentTypes(): void |
| | | { |
| | | $this->has_content = true; |
| | | $this->defaultContent = 'REGULAR'; |
| | | $types = ['REGULAR', 'SERVICE', 'DIGITAL', 'FOOD_AND_BEV', 'EVENT', 'SUBSCRIPTION', 'DONATION']; |
| | | |
| | | foreach ($types as $type) { |
| | | $t = $type === 'REGULAR' ? null : $type; |
| | | $this->contentTypes[$type] = $this->getHelcimMeta($t); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Register additional WordPress hooks |
| | | * Get Helcim product meta fields by type. |
| | | * |
| | | * Used by FieldRegistry when 'use_helcim' => true is set |
| | | * in a JVB_CONTENT definition. |
| | | */ |
| | | public function getHelcimMeta(?string $type = null): array |
| | | { |
| | | $fields = [ |
| | | // Basic Product Fields |
| | | 'price' => [ |
| | | 'type' => 'number', |
| | | 'bulkEdit' => true, |
| | | 'label' => 'Price', |
| | | 'step' => 0.01, |
| | | 'max' => 99999, |
| | | 'description' => 'Product price' |
| | | ], |
| | | |
| | | 'product_type' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Product Type', |
| | | 'options' => [ |
| | | 'REGULAR' => 'Regular Product', |
| | | 'SERVICE' => 'Service', |
| | | 'DIGITAL' => 'Digital Product', |
| | | 'FOOD_AND_BEV' => 'Food & Beverage', |
| | | 'EVENT' => 'Event/Ticket', |
| | | 'SUBSCRIPTION' => 'Subscription', |
| | | 'DONATION' => 'Donation' |
| | | ], |
| | | 'default' => $type ?? 'REGULAR' |
| | | ], |
| | | |
| | | 'cart_quantity' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Quantity', |
| | | 'hidden' => true, |
| | | ], |
| | | |
| | | // Tax & Shipping |
| | | 'tax_exempt' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Tax Exempt', |
| | | 'section' => 'helcim-tax' |
| | | ], |
| | | |
| | | 'shipping_required' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Shipping Required', |
| | | 'section' => 'helcim-shipping' |
| | | ], |
| | | |
| | | 'shipping_weight' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Shipping Weight (kg)', |
| | | 'step' => 0.01, |
| | | 'section' => 'helcim-shipping', |
| | | 'condition' => [ |
| | | 'field' => 'shipping_required', |
| | | 'operator' => '==', |
| | | 'value' => true |
| | | ] |
| | | ], |
| | | |
| | | // Availability |
| | | 'available_online' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available Online', |
| | | 'section' => 'helcim-availability', |
| | | 'default' => true |
| | | ], |
| | | |
| | | 'available_for_pickup' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available for Pickup', |
| | | 'section' => 'helcim-availability', |
| | | 'default' => true |
| | | ], |
| | | |
| | | 'available_for_delivery' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available for Delivery', |
| | | 'section' => 'helcim-availability', |
| | | 'default' => false |
| | | ], |
| | | |
| | | '_helcim_sku' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'SKU', |
| | | 'description' => 'Stock keeping unit', |
| | | 'section' => 'helcim-config' |
| | | ], |
| | | |
| | | // Product Variations |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->getHelcimVariationMeta($type) |
| | | ], |
| | | |
| | | // Product Options |
| | | 'options' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Product Options', |
| | | 'section'=> 'helcim-options', |
| | | 'fields' => [ |
| | | 'max_order' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Maximum per order', |
| | | 'default' => 50 |
| | | ], |
| | | 'min_order' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Minimum per order', |
| | | 'default' => 0, |
| | | ], |
| | | 'step' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Order increment', |
| | | 'default' => 1, |
| | | ], |
| | | 'preparation_time' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Preparation time (minutes)', |
| | | 'description' => 'Time needed to prepare this item', |
| | | 'condition' => [ |
| | | 'field' => 'product_type', |
| | | 'operator' => 'in', |
| | | 'value' => ['FOOD_AND_BEV', 'SERVICE'] |
| | | ] |
| | | ] |
| | | ] |
| | | ], |
| | | |
| | | // Subscription Fields |
| | | 'subscription_settings' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Subscription Settings', |
| | | 'section' => 'helcim-subscription', |
| | | 'condition' => [ |
| | | 'field' => 'product_type', |
| | | 'operator' => '==', |
| | | 'value' => 'SUBSCRIPTION' |
| | | ], |
| | | 'fields' => [ |
| | | 'billing_cycle' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Billing Cycle', |
| | | 'options' => [ |
| | | 'daily' => 'Daily', |
| | | 'weekly' => 'Weekly', |
| | | 'monthly' => 'Monthly', |
| | | 'quarterly' => 'Quarterly', |
| | | 'yearly' => 'Yearly' |
| | | ], |
| | | 'default' => 'monthly' |
| | | ], |
| | | 'trial_period' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Trial Period (days)', |
| | | 'description' => 'Free trial period before billing starts', |
| | | 'default' => 0 |
| | | ], |
| | | 'setup_fee' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Setup Fee', |
| | | 'step' => 0.01, |
| | | 'description' => 'One-time setup fee' |
| | | ] |
| | | ] |
| | | ], |
| | | |
| | | // Food & Beverage Specific |
| | | 'food_settings' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Food & Beverage Settings', |
| | | 'section' => 'helcim-food', |
| | | 'condition' => [ |
| | | 'field' => 'product_type', |
| | | 'operator' => '==', |
| | | 'value' => 'FOOD_AND_BEV' |
| | | ], |
| | | 'fields' => [ |
| | | 'ingredients' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Ingredients', |
| | | 'description' => 'List ingredients (comma separated)' |
| | | ], |
| | | 'allergens' => [ |
| | | 'type' => 'checkbox_list', |
| | | 'label' => 'Allergens', |
| | | 'options' => [ |
| | | 'gluten' => 'Contains Gluten', |
| | | 'dairy' => 'Contains Dairy', |
| | | 'nuts' => 'Contains Nuts', |
| | | 'soy' => 'Contains Soy', |
| | | 'eggs' => 'Contains Eggs', |
| | | 'seafood' => 'Contains Seafood' |
| | | ] |
| | | ], |
| | | 'dietary_options' => [ |
| | | 'type' => 'checkbox_list', |
| | | 'label' => 'Dietary Options', |
| | | 'options' => [ |
| | | 'vegetarian' => 'Vegetarian', |
| | | 'vegan' => 'Vegan', |
| | | 'gluten_free' => 'Gluten Free', |
| | | 'dairy_free' => 'Dairy Free', |
| | | 'keto' => 'Keto Friendly', |
| | | 'halal' => 'Halal', |
| | | 'kosher' => 'Kosher' |
| | | ] |
| | | ], |
| | | 'spice_level' => [ |
| | | 'type' => 'range', |
| | | 'label' => 'Spice Level', |
| | | 'min' => 0, |
| | | 'max' => 5, |
| | | 'default' => 0 |
| | | ], |
| | | 'serving_size' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Serving Size', |
| | | 'description' => 'e.g., "Serves 2-3"' |
| | | ] |
| | | ] |
| | | ], |
| | | |
| | | // Service Specific |
| | | 'service_settings' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Service Settings', |
| | | 'section' => 'helcim-service', |
| | | 'condition' => [ |
| | | 'field' => 'product_type', |
| | | 'operator' => '==', |
| | | 'value' => 'SERVICE' |
| | | ], |
| | | 'fields' => [ |
| | | 'service_duration' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Duration (minutes)', |
| | | 'description' => 'Service duration in minutes' |
| | | ], |
| | | 'booking_required' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Booking Required' |
| | | ], |
| | | 'capacity' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Service Capacity', |
| | | 'description' => 'Maximum number of customers per service' |
| | | ], |
| | | 'staff_required' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Staff Required', |
| | | 'description' => 'Number of staff needed', |
| | | 'default' => 1 |
| | | ] |
| | | ] |
| | | ], |
| | | |
| | | // Event Specific |
| | | 'event_settings' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Event Settings', |
| | | 'section' => 'helcim-event', |
| | | 'condition' => [ |
| | | 'field' => 'product_type', |
| | | 'operator' => '==', |
| | | 'value' => 'EVENT' |
| | | ], |
| | | 'fields' => [ |
| | | 'event_date' => [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Event Date & Time' |
| | | ], |
| | | 'event_location' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Event Location' |
| | | ], |
| | | 'max_attendees' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Maximum Attendees' |
| | | ], |
| | | 'early_bird_price' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Early Bird Price', |
| | | 'step' => 0.01, |
| | | 'description' => 'Discounted price for early registrations' |
| | | ], |
| | | 'early_bird_deadline' => [ |
| | | 'type' => 'date', |
| | | 'label' => 'Early Bird Deadline' |
| | | ] |
| | | ] |
| | | ] |
| | | ]; |
| | | |
| | | // Add inventory fields if configured |
| | | if ($config['hasInventory'] ?? false) { |
| | | $fields['_helcim_inventory'] = [ |
| | | 'type' => 'number', |
| | | 'label' => 'Inventory', |
| | | 'bulkEdit' => true, |
| | | 'section' => 'inventory' |
| | | ]; |
| | | |
| | | $fields['track_inventory'] = [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Track Inventory', |
| | | 'section' => 'inventory', |
| | | 'default' => true |
| | | ]; |
| | | |
| | | $fields['low_stock_threshold'] = [ |
| | | 'type' => 'number', |
| | | 'label' => 'Low Stock Alert', |
| | | 'description' => 'Alert when stock falls below this level', |
| | | 'section' => 'inventory', |
| | | 'default' => 5 |
| | | ]; |
| | | |
| | | $fields['product_variations']['fields']['inventory'] = [ |
| | | 'type' => 'number', |
| | | 'label' => 'Stock Quantity', |
| | | 'description' => 'Current stock for this variation' |
| | | ]; |
| | | } |
| | | |
| | | return $fields; |
| | | } |
| | | |
| | | public function getHelcimVariationMeta(?string $type = null):array |
| | | { |
| | | |
| | | $base = [ |
| | | 'name' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Variation Name', |
| | | 'description' => 'e.g., "Small", "Large", "Red"' |
| | | ], |
| | | 'price' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Price', |
| | | 'step' => 0.01, |
| | | 'max' => 99999, |
| | | 'description' => 'Price for this variation' |
| | | ], |
| | | 'sku' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'SKU', |
| | | 'description' => 'Stock keeping unit for this variation' |
| | | ], |
| | | 'track_inventory' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Track Inventory', |
| | | ], |
| | | '_helcim_variation_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Helcim Variation ID', |
| | | 'description' => 'Helcim ID for this variation', |
| | | 'hidden' => true |
| | | ], |
| | | '_helcim_last_sync' => [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Last Sync', |
| | | 'hidden' => true |
| | | ], |
| | | 'options' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Variation Options', |
| | | 'collapsible' => true, |
| | | 'fields' => [ |
| | | 'color' => [ |
| | | 'type' => 'color', |
| | | 'label' => 'Color', |
| | | 'description' => 'Visual color for this variation' |
| | | ], |
| | | 'size' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Size', |
| | | 'options' => [ |
| | | '' => 'N/A', |
| | | 'xs' => 'Extra Small', |
| | | 's' => 'Small', |
| | | 'm' => 'Medium', |
| | | 'l' => 'Large', |
| | | 'xl' => 'Extra Large', |
| | | 'xxl' => '2X Large', |
| | | 'custom'=> 'Custom' |
| | | ] |
| | | ], |
| | | 'custom_size' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Custom Size', |
| | | 'condition' => [ |
| | | 'field' => 'size', |
| | | 'operator' => '==', |
| | | 'value' => 'custom' |
| | | ] |
| | | ], |
| | | 'weight' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Weight (kg)', |
| | | 'step' => 0.01, |
| | | 'description' => 'Weight of this variation' |
| | | ], |
| | | 'dimensions' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Dimensions', |
| | | 'fields' => [ |
| | | 'length' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Length (cm)', |
| | | 'step' => 0.1 |
| | | ], |
| | | 'width' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Width (cm)', |
| | | 'step' => 0.1 |
| | | ], |
| | | 'height' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Height (cm)', |
| | | 'step' => 0.1 |
| | | ] |
| | | ] |
| | | ] |
| | | ] |
| | | ] |
| | | ]; |
| | | |
| | | $extras = [ |
| | | 'SERVICE' => [ |
| | | 'service_duration' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Duration (minutes)', |
| | | 'description' => 'Duration for this service variation' |
| | | ], |
| | | 'available_for_booking' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available for Booking' |
| | | ] |
| | | ], |
| | | |
| | | 'FOOD_AND_BEV' => [ |
| | | 'portion_size' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Portion Size', |
| | | 'options' => [ |
| | | 'small' => 'Small', |
| | | 'regular' => 'Regular', |
| | | 'large' => 'Large', |
| | | 'family' => 'Family Size' |
| | | ] |
| | | ], |
| | | 'calories' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Calories', |
| | | 'description' => 'Calorie count for this variation' |
| | | ] |
| | | ], |
| | | |
| | | 'DIGITAL' => [ |
| | | 'download_limit' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Download Limit', |
| | | 'description' => 'Maximum number of downloads', |
| | | 'default' => -1 |
| | | ], |
| | | 'expiry_days' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Access Duration (days)', |
| | | 'description' => 'Days until download expires', |
| | | 'default' => 0 |
| | | ] |
| | | ] |
| | | ]; |
| | | if ($type && array_key_exists($type, $extras)){ |
| | | $base = array_merge($base, $extras[$type]); |
| | | } |
| | | return $base; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * HELCIMPAY.JS — Frontend Scripts & Checkout Initialization |
| | | *****************************************************************/ |
| | | |
| | | protected function registerAdditionalHooks(): void |
| | | { |
| | | $this->ensureInitialized(); |
| | | if (!$this->isSetUp()) { |
| | | return; |
| | | } |
| | | // User login tracking for security |
| | | add_action('wp_login', [$this, 'trackUserLogin'], 10, 2); |
| | | |
| | | add_action('wp_footer', [$this, 'outputCheckout']); |
| | | |
| | | // Enqueue checkout scripts |
| | | add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']); |
| | | |
| | | // REST API endpoints for checkout |
| | | add_action('rest_api_init', [$this, 'registerRestRoutes']); |
| | | } |
| | | // Shared checkout UI (replaces provider-specific outputCheckout) |
| | | add_filter('jvbAdditionalActions', [Checkout::class, 'render']); |
| | | |
| | | /** |
| | | * Register REST API routes |
| | | */ |
| | | public function registerRestRoutes(): void |
| | | { |
| | | register_rest_route('jvb/v1', '/helcim/checkout', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleCheckout'], |
| | | 'permission_callback' => '__return_true' |
| | | ]); |
| | | |
| | | register_rest_route('jvb/v1', '/helcim/customer', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleCustomerLookup'], |
| | | 'permission_callback' => '__return_true' |
| | | ]); |
| | | |
| | | register_rest_route('jvb/v1', '/helcim/order-status/(?P<order_id>[a-zA-Z0-9-]+)', [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'handleOrderStatus'], |
| | | 'permission_callback' => '__return_true' |
| | | ]); |
| | | |
| | | register_rest_route('jvb/v1', '/helcim/create-account', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleAccountCreation'], |
| | | 'permission_callback' => '__return_true' |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Initialize field mappings for all content types |
| | | */ |
| | | private function initializeFieldMappings(): void |
| | | { |
| | | foreach (JVB_CONTENT as $key => $config) { |
| | | if (isset($config['integrations']['helcim'])) { |
| | | $post_type = jvbCheckBase($key); |
| | | $this->field_mappings[$post_type] = $this->getFieldMapping($post_type); |
| | | // Checkout description filter |
| | | add_filter('jvb_checkout_description', function (string $desc, string $provider) { |
| | | if ($provider === 'helcim') { |
| | | return 'Securely checkout with your name, email, and payments processed by Helcim.'; |
| | | } |
| | | } |
| | | return $desc; |
| | | }, 10, 2); |
| | | |
| | | // Register queue operation types with IntegrationExecutor |
| | | $this->registerQueueTypes(); |
| | | |
| | | // Register webhook endpoint (handled by parent) |
| | | $this->registerWebhookEndpoint(); |
| | | } |
| | | |
| | | /** |
| | | * Get field mapping for a post type |
| | | */ |
| | | public function getFieldMapping(string $post_type): array |
| | | { |
| | | return apply_filters(BASE . '_helcim_field_mapping', [ |
| | | 'name' => 'title', |
| | | 'description' => 'content', |
| | | 'price' => 'price', |
| | | 'sku' => '_helcim_sku', |
| | | 'product_code' => '_helcim_product_code', |
| | | 'inventory' => '_helcim_inventory', |
| | | 'product_type' => 'product_type', |
| | | 'tax_exempt' => 'tax_exempt', |
| | | 'shipping_required' => 'shipping_required' |
| | | ], $post_type); |
| | | } |
| | | |
| | | /** |
| | | * Output checkout form |
| | | */ |
| | | public function outputCheckout(): void |
| | | { |
| | | if (is_singular(BASE.'dash') || is_post_type_archive(BASE.'dash')) { |
| | | return; |
| | | } |
| | | ?> |
| | | <button type="button" class="toggle-cart row" title="Your Cart" data-action="toggle-cart" aria-label="Open Cart" aria-controls="checkout" aria-expanded="false" hidden> |
| | | <?= jvbIcon('shopping-cart')?><span class="abs"></span><span class="abs count"></span> |
| | | </button> |
| | | <aside id="cart" class="main"> |
| | | <form id="checkout" data-form-id="checkout" data-save="checkout"> |
| | | <?php |
| | | $tabs = [ |
| | | 'cartItems' => [ |
| | | 'title' => 'Your Order', |
| | | 'icon' => 'cart', |
| | | 'description' => 'Review and modify your order items', |
| | | 'content' => $this->cartContent() |
| | | ], |
| | | 'account' => [ |
| | | 'title' => 'Account', |
| | | 'icon' => 'user', |
| | | 'description' => $this->getAccountTabDescription(), |
| | | 'content' => $this->renderAccountSection() |
| | | ], |
| | | 'checkout' => [ |
| | | 'title' => 'Checkout', |
| | | 'icon' => 'checkout', |
| | | 'description' => 'Complete your order with Helcim secure payments', |
| | | 'content' => $this->renderCheckoutSection() |
| | | ], |
| | | 'order' => [ |
| | | 'title' => 'Order Status', |
| | | 'icon' => 'truck', |
| | | 'hidden' => true, |
| | | 'description' => 'Track your order status', |
| | | 'content' => $this->renderOrderStatus() |
| | | ] |
| | | ]; |
| | | jvbRenderTabs($tabs); |
| | | ?> |
| | | <div class="cart-total row end"> |
| | | <p class="tax">Tax: <span></span></p> |
| | | <p class="total">GRAND TOTAL: <span></span></p> |
| | | </div> |
| | | </form> |
| | | </aside> |
| | | <?php |
| | | $this->outputCheckoutTemplates(); |
| | | } |
| | | |
| | | /** |
| | | * Get account tab description based on login status |
| | | */ |
| | | private function getAccountTabDescription(): string |
| | | { |
| | | if (is_user_logged_in()) { |
| | | return 'Manage your account and view order history'; |
| | | } |
| | | return 'Login or create an account for faster checkout'; |
| | | } |
| | | |
| | | /** |
| | | * Render account section |
| | | */ |
| | | private function renderAccountSection(): string |
| | | { |
| | | ob_start(); |
| | | ?> |
| | | <div class="account-section"> |
| | | <?php if (is_user_logged_in()): ?> |
| | | <?php $this->renderLoggedInAccount(); ?> |
| | | <?php else: ?> |
| | | <?php $this->renderGuestAccount(); ?> |
| | | <?php endif; ?> |
| | | </div> |
| | | <?php |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | | /** |
| | | * Render logged in account view |
| | | */ |
| | | private function renderLoggedInAccount(): void |
| | | { |
| | | $user = wp_get_current_user(); |
| | | $customer_id = get_user_meta($user->ID, BASE . '_helcim_customer_id', true); |
| | | ?> |
| | | <div class="logged-in-account"> |
| | | <p>Welcome back, <?= esc_html($user->display_name) ?>!</p> |
| | | |
| | | <div class="account-actions"> |
| | | <button type="button" class="button" onclick="helcimCheckout.loadSavedCards()"> |
| | | <?= jvbIcon('credit-card') ?> Saved Cards |
| | | </button> |
| | | <button type="button" class="button" onclick="helcimCheckout.loadOrderHistory()"> |
| | | <?= jvbIcon('receipt') ?> Order History |
| | | </button> |
| | | <button type="button" class="button" onclick="helcimCheckout.loadFavorites()"> |
| | | <?= jvbIcon('heart') ?> Favorites |
| | | </button> |
| | | </div> |
| | | |
| | | <div id="account-content"></div> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | /** |
| | | * Render guest account view |
| | | */ |
| | | private function renderGuestAccount(): void |
| | | { |
| | | ?> |
| | | <div class="guest-account"> |
| | | <div class="login-section"> |
| | | <h3>Returning Customer?</h3> |
| | | <p>Login with your email to access saved cards and order history</p> |
| | | |
| | | <div class="email-login"> |
| | | <input type="email" |
| | | id="login-email" |
| | | placeholder="Enter your email" |
| | | autocomplete="email"> |
| | | <button type="button" |
| | | class="button primary" |
| | | onclick="helcimCheckout.loginWithEmail()"> |
| | | Continue |
| | | </button> |
| | | </div> |
| | | |
| | | <div id="login-status"></div> |
| | | </div> |
| | | |
| | | <div class="guest-checkout"> |
| | | <h3>New Customer?</h3> |
| | | <p>You can checkout as a guest or create an account after your order</p> |
| | | |
| | | <label> |
| | | <input type="checkbox" id="create-account-offer"> |
| | | Offer to create account after checkout |
| | | </label> |
| | | </div> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | /** |
| | | * Render checkout section |
| | | */ |
| | | private function renderCheckoutSection(): string |
| | | { |
| | | ob_start(); |
| | | ?> |
| | | <div class="checkout-section"> |
| | | <h3>Customer Information</h3> |
| | | |
| | | <input type="text" name="name" placeholder="Full Name" required autocomplete="name"> |
| | | <input type="email" name="email" placeholder="Email" required autocomplete="email"> |
| | | <input type="tel" name="phone" placeholder="Phone" required autocomplete="tel"> |
| | | |
| | | <h3>Pickup/Delivery Details</h3> |
| | | <select name="fulfillment_type" required> |
| | | <option value="pickup">Pickup</option> |
| | | <option value="delivery">Delivery</option> |
| | | </select> |
| | | |
| | | <div class="pickup-details" data-show-if="fulfillment_type:pickup"> |
| | | <input type="datetime-local" name="pickup_time" required> |
| | | </div> |
| | | |
| | | <div class="delivery-details" data-show-if="fulfillment_type:delivery" style="display:none;"> |
| | | <input type="text" name="delivery_address" placeholder="Delivery Address" autocomplete="street-address"> |
| | | <input type="text" name="delivery_instructions" placeholder="Delivery Instructions"> |
| | | </div> |
| | | |
| | | <textarea name="special_instructions" placeholder="Special instructions or dietary notes"></textarea> |
| | | |
| | | <h3>Payment Information</h3> |
| | | <div id="saved-cards"></div> |
| | | <div id="helcim-card-container"></div> |
| | | |
| | | <button type="submit" class="button primary checkout-button"> |
| | | Place Order |
| | | </button> |
| | | </div> |
| | | <?php |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | | /** |
| | | * Render order status section |
| | | */ |
| | | protected function renderOrderStatus(): string |
| | | { |
| | | ob_start(); |
| | | ?> |
| | | <div class="order-confirmation"> |
| | | <h2>Order Confirmed!</h2> |
| | | <div id="order-status" data-order=""> |
| | | <p>Order #<span class="order-num"></span></p> |
| | | <div class="status-timeline"> |
| | | <div class="status-item active" data-status="received">Order Received</div> |
| | | <div class="status-item" data-status="preparing">Preparing</div> |
| | | <div class="status-item" data-status="ready">Ready</div> |
| | | <div class="status-item" data-status="complete">Complete</div> |
| | | </div> |
| | | <div class="order-eta"> |
| | | Estimated time: <span id="eta">Calculating...</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <?php |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | | /** |
| | | * Output checkout templates |
| | | */ |
| | | private function outputCheckoutTemplates(): void |
| | | { |
| | | ?> |
| | | <template class="cartItem"> |
| | | <tr class="item"> |
| | | <td class="item"> |
| | | <label for="quantity"></label> |
| | | <div class="quantity field" data-min="0" data-max="50" data-step="1" data-price="" data-id=""> |
| | | <button type="button" class="decrease" aria-label="Decrease quantity"> |
| | | <?= jvbIcon('minus-square') ?> |
| | | </button> |
| | | <input type="number" name="quantity" value="1" min="0" max="50"> |
| | | <button type="button" class="increase" aria-label="Increase quantity"> |
| | | <?= jvbIcon('plus') ?> |
| | | </button> |
| | | </div> |
| | | </td> |
| | | <td class="price"></td> |
| | | <td class="total"></td> |
| | | <td> |
| | | <button type="button" class="remove" aria-label="Remove item"> |
| | | <?= jvbIcon('trash') ?> |
| | | </button> |
| | | </td> |
| | | </tr> |
| | | </template> |
| | | |
| | | <template class="savedCard"> |
| | | <label class="saved-card-option"> |
| | | <input type="radio" name="payment_method" value=""> |
| | | <span class="card-details"> |
| | | <span class="card-brand"></span> |
| | | •••• <span class="last-4"></span> |
| | | <span class="exp-date"></span> |
| | | </span> |
| | | </label> |
| | | </template> |
| | | <?php |
| | | } |
| | | |
| | | /** |
| | | * Cart content section |
| | | */ |
| | | private function cartContent(): string |
| | | { |
| | | ob_start(); |
| | | ?> |
| | | <div class="cart-items"> |
| | | <table> |
| | | <thead> |
| | | <tr> |
| | | <th>Item</th> |
| | | <th>Price</th> |
| | | <th>Total</th> |
| | | <th></th> |
| | | </tr> |
| | | </thead> |
| | | <tbody></tbody> |
| | | </table> |
| | | |
| | | <div class="cart-actions"> |
| | | <button type="button" class="button" onclick="helcimCheckout.clearCart()"> |
| | | <?= jvbIcon('trash') ?> Clear Cart |
| | | </button> |
| | | </div> |
| | | </div> |
| | | <?php |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | | /** |
| | | * Enqueue checkout scripts |
| | | */ |
| | | public function enqueueScripts(): void |
| | | { |
| | | $this->ensureInitialized(); |
| | | if (!$this->isSetUp()) { |
| | | return; |
| | | } |
| | | // Helcim JS SDK |
| | | $sdk_url = $this->is_test_mode |
| | | ? 'https://helcim-js-sandbox.helcim.com/v1/helcim.js' |
| | | : 'https://js.helcim.com/v1/helcim.js'; |
| | | |
| | | // HelcimPay.js SDK |
| | | wp_enqueue_script( |
| | | 'helcim-js-sdk', |
| | | $sdk_url, |
| | | 'helcim-pay-sdk', |
| | | 'https://secure.helcim.app/helcim-pay/services/start.js', |
| | | [], |
| | | null, |
| | | [ |
| | | 'strategy' => 'defer', |
| | | 'in_footer' => true |
| | | ] |
| | | true |
| | | ); |
| | | |
| | | // Register custom checkout script |
| | | // Base cart checkout (shared with Square) |
| | | wp_register_script( |
| | | 'jvb-checkout', |
| | | JVB_URL . 'assets/js/min/checkout.min.js', |
| | | ['jvb-utility', 'jvb-queue', 'jvb-a11y', 'jvb-cache', 'jvb-tabs', 'jvb-popup'], |
| | | '1.1.31', |
| | | true |
| | | ); |
| | | |
| | | // Helcim checkout (extends CartCheckout) |
| | | wp_register_script( |
| | | 'jvb-helcim-checkout', |
| | | JVB_URL . 'assets/js/min/helcim.min.js', |
| | | [ |
| | | 'jvb-utility', |
| | | 'jvb-queue', |
| | | 'jvb-a11y', |
| | | 'jvb-cache', |
| | | 'jvb-tabs', |
| | | 'jvb-modal', |
| | | ], |
| | | '1.0.0', |
| | | [ |
| | | 'strategy' => 'defer', |
| | | 'in_footer' => true |
| | | ] |
| | | ['jvb-checkout', 'helcim-pay-sdk'], |
| | | '1.1.31', |
| | | true |
| | | ); |
| | | |
| | | wp_localize_script('jvb-helcim-checkout', 'helcimConfig', [ |
| | | 'api_url' => rest_url('jvb/v1/helcim/'), |
| | | 'nonce' => wp_create_nonce('wp_rest'), |
| | | 'currency' => $this->credentials['currency'] ?? 'CAD', |
| | | 'is_logged_in' => is_user_logged_in(), |
| | | 'user_email' => is_user_logged_in() ? wp_get_current_user()->user_email : '', |
| | | 'isOpen' => apply_filters('jvb_store_is_open', '1'), |
| | | ]); |
| | | |
| | | wp_enqueue_script('jvb-helcim-checkout'); |
| | | |
| | | // Localize the checkout script with Helcim config |
| | | wp_localize_script( |
| | | 'jvb-helcim-checkout', |
| | | 'helcimConfig', |
| | | [ |
| | | 'isOpen' => jvbIsOpen(), |
| | | 'apiUrl' => rest_url('jvb/v1/helcim/'), |
| | | 'nonce' => wp_create_nonce('wp_rest'), |
| | | 'accountId' => $this->account_id, |
| | | 'testMode' => $this->is_test_mode |
| | | ] |
| | | ); |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * POST SYNC METHODS |
| | | ******************************************************************/ |
| | | |
| | | protected function registerQueueTypes(): void |
| | | { |
| | | $queue = JVB()->queue(); |
| | | $executor = new IntegrationExecutor(); |
| | | |
| | | $queue->registry()->register('helcim_sync_to', new TypeConfig( |
| | | executor: $executor, |
| | | chunkKey: 'items', |
| | | chunkSize: 10, |
| | | maxRetries: 3 |
| | | )); |
| | | |
| | | $queue->registry()->register('helcim_sync_from', new TypeConfig( |
| | | executor: $executor, |
| | | chunkKey: 'items', |
| | | chunkSize: 10, |
| | | maxRetries: 3 |
| | | )); |
| | | |
| | | $queue->registry()->register('helcim_delete_from', new TypeConfig( |
| | | executor: $executor, |
| | | chunkKey: 'external_ids', |
| | | chunkSize: 20, |
| | | maxRetries: 2 |
| | | )); |
| | | |
| | | $queue->registry()->register('helcim_import', new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 3 |
| | | )); |
| | | |
| | | $queue->registry()->register('helcim_sync_customer', new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 2 |
| | | )); |
| | | } |
| | | |
| | | /** |
| | | * Handle post save for Helcim sync |
| | | * Initialize a HelcimPay.js checkout session. |
| | | * |
| | | * Server-side: POST /helcim-pay/initialize → returns checkoutToken + secretToken. |
| | | * Client-side: appendHelcimPayIframe(checkoutToken) renders the payment modal. |
| | | * Tokens are valid for 60 minutes. |
| | | * |
| | | * @param array $data [ |
| | | * 'amount' => float, // Required |
| | | * 'invoiceId' => string, // Optional — pay a specific invoice |
| | | * 'customerId' => int, // Optional — associate with Helcim customer |
| | | * 'paymentType' => string, // purchase|preauth|verify (default: purchase) |
| | | * ] |
| | | */ |
| | | public function initializeCheckout(array $data): array |
| | | { |
| | | if (empty($data['amount']) || (float) $data['amount'] <= 0) { |
| | | return ['success' => false, 'message' => 'Invalid amount']; |
| | | } |
| | | |
| | | $paymentMethod = !empty($this->credentials['allow_ach']) ? 'cc-ach' : 'cc'; |
| | | |
| | | $body = [ |
| | | 'paymentType' => $data['paymentType'] ?? 'purchase', |
| | | 'amount' => (float) $data['amount'], |
| | | 'currency' => $this->credentials['currency'] ?? 'CAD', |
| | | 'paymentMethod' => $paymentMethod, |
| | | ]; |
| | | |
| | | if (!empty($data['invoiceId'])) { |
| | | $body['invoiceNumber'] = $data['invoiceId']; |
| | | } |
| | | |
| | | if (!empty($data['customerId'])) { |
| | | $body['customerId'] = (int) $data['customerId']; |
| | | } |
| | | |
| | | if (!empty($this->credentials['fee_saver'])) { |
| | | $body['hasConvenienceFee'] = true; |
| | | } |
| | | |
| | | $response = $this->postRequest('helcim-pay/initialize', $body); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return ['success' => false, 'message' => $response->get_error_message()]; |
| | | } |
| | | |
| | | if (empty($response['checkoutToken'])) { |
| | | return ['success' => false, 'message' => 'Failed to initialize checkout']; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'checkoutToken' => $response['checkoutToken'], |
| | | 'secretToken' => $response['secretToken'] ?? '', |
| | | ]; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * INVOICES — Helcim is source of truth |
| | | *****************************************************************/ |
| | | |
| | | /** |
| | | * Get invoices for a customer. |
| | | * |
| | | * @param array $data ['email' => string] or ['customerId' => int] |
| | | */ |
| | | public function handleGetInvoices(array $data): array |
| | | { |
| | | $customerId = $data['customerId'] ?? null; |
| | | |
| | | if (empty($customerId) && !empty($data['email'])) { |
| | | $customerId = $this->getCustomerIdByEmail($data['email']); |
| | | } |
| | | |
| | | if (!$customerId) { |
| | | return ['success' => true, 'invoices' => []]; |
| | | } |
| | | |
| | | $response = $this->getRequest('invoices', ['customerId' => $customerId], null, 'minimal'); |
| | | |
| | | if (is_wp_error($response) || !is_array($response)) { |
| | | return ['success' => false, 'message' => 'Failed to fetch invoices']; |
| | | } |
| | | |
| | | return ['success' => true, 'invoices' => $response]; |
| | | } |
| | | |
| | | /** |
| | | * Get a single invoice by ID. |
| | | */ |
| | | public function handleGetInvoice(array $data): array |
| | | { |
| | | $invoiceId = $data['invoiceId'] ?? null; |
| | | |
| | | if (!$invoiceId) { |
| | | return ['success' => false, 'message' => 'Invoice ID required']; |
| | | } |
| | | |
| | | $response = $this->getRequest("invoices/{$invoiceId}", [], null, 'minimal'); |
| | | |
| | | if (is_wp_error($response) || !is_array($response)) { |
| | | return ['success' => false, 'message' => 'Failed to fetch invoice']; |
| | | } |
| | | |
| | | return ['success' => true, 'invoice' => $response]; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * CUSTOMERS & CARDS |
| | | *****************************************************************/ |
| | | |
| | | /** |
| | | * Find Helcim customer ID by email. |
| | | */ |
| | | public function getCustomerIdByEmail(string $email): ?int |
| | | { |
| | | $cacheKey = 'customer_email_' . md5($email); |
| | | $cached = $this->cache->get($cacheKey); |
| | | |
| | | if ($cached !== false) { |
| | | return (int) $cached; |
| | | } |
| | | |
| | | $response = $this->getRequest('customers', ['search' => $email], null, 'none', true); |
| | | |
| | | if (is_wp_error($response) || empty($response)) { |
| | | return null; |
| | | } |
| | | |
| | | $customers = is_array($response) ? $response : []; |
| | | $emailLower = strtolower($email); |
| | | |
| | | foreach ($customers as $customer) { |
| | | $contactEmail = strtolower($customer['contactEmail'] ?? $customer['email'] ?? ''); |
| | | if ($contactEmail === $emailLower) { |
| | | $this->cache->set($cacheKey, $customer['id'], $this->cacheStrategy['aggressive']); |
| | | return (int) $customer['id']; |
| | | } |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Get or create a Helcim customer. |
| | | */ |
| | | public function getOrCreateCustomer(array $info): ?int |
| | | { |
| | | if (empty($info['email'])) { |
| | | return null; |
| | | } |
| | | |
| | | $existing = $this->getCustomerIdByEmail($info['email']); |
| | | if ($existing) { |
| | | return $existing; |
| | | } |
| | | |
| | | $response = $this->postRequest('customers', [ |
| | | 'contactName' => $info['name'] ?? '', |
| | | 'contactEmail' => $info['email'], |
| | | 'cellphone' => $info['phone'] ?? '', |
| | | ]); |
| | | |
| | | if (is_wp_error($response) || empty($response['id'])) { |
| | | return null; |
| | | } |
| | | |
| | | return (int) $response['id']; |
| | | } |
| | | |
| | | /** |
| | | * Get saved cards for a customer. |
| | | */ |
| | | public function handleGetCustomerCards(array $data): array |
| | | { |
| | | $customerId = $data['customerId'] ?? null; |
| | | |
| | | if (empty($customerId) && !empty($data['email'])) { |
| | | $customerId = $this->getCustomerIdByEmail($data['email']); |
| | | } |
| | | |
| | | if (!$customerId) { |
| | | return ['success' => true, 'cards' => []]; |
| | | } |
| | | |
| | | $response = $this->getRequest("customers/{$customerId}/cards", [], null, 'moderate'); |
| | | |
| | | if (is_wp_error($response) || !is_array($response)) { |
| | | return ['success' => false, 'message' => 'Failed to fetch cards']; |
| | | } |
| | | |
| | | return ['success' => true, 'cards' => $response]; |
| | | } |
| | | |
| | | /** |
| | | * Get bank accounts for a customer. |
| | | */ |
| | | public function getCustomerBankAccounts(int $customerId): array |
| | | { |
| | | $response = $this->getRequest("customers/{$customerId}/bank-accounts", [], null, 'moderate'); |
| | | return (!is_wp_error($response) && is_array($response)) ? $response : []; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * TRANSACTIONS |
| | | *****************************************************************/ |
| | | |
| | | public function getTransactions(array $params = []): array |
| | | { |
| | | $response = $this->getRequest('card-transactions', $params, null, 'minimal'); |
| | | return (!is_wp_error($response) && is_array($response)) ? $response : []; |
| | | } |
| | | |
| | | public function refundPayment(array $data): array |
| | | { |
| | | $response = $this->postRequest('payment/refund', $data); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return ['success' => false, 'message' => $response->get_error_message()]; |
| | | } |
| | | |
| | | return ['success' => true, 'transaction' => $response]; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * PRODUCT SYNC |
| | | *****************************************************************/ |
| | | |
| | | protected function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings): void |
| | | { |
| | | // Queue the sync operation |
| | | $this->queueOperation('sync_to_helcim', [ |
| | | 'items' => [$postID], |
| | | $fields = $this->getSyncFields($postID, 'post', ['share_to_helcim', 'schedule_helcim']); |
| | | |
| | | if (empty($fields['share_to_helcim'])) { |
| | | return; |
| | | } |
| | | |
| | | // Uses IntegrationExecutor via TypeRegistry instead of FilteredExecutor |
| | | $this->queueOperation('sync_to', [ |
| | | 'items' => [$postID], |
| | | 'user_id' => $this->userID, |
| | | 'content_type' => $settings['content_type'] ?? 'REGULAR' |
| | | ], [ |
| | | 'priority' => 'high', |
| | | 'delay' => 30, // Small delay to batch multiple saves |
| | | 'delay' => 30, |
| | | ]); |
| | | |
| | | update_post_meta($postID, BASE . '_helcim_sync_status', 'queued'); |
| | | } |
| | | |
| | | /** |
| | | * Handle post deletion |
| | | */ |
| | | public function handleDeletePost(int $postID): void |
| | | protected function handleImportFromHelcim(): array |
| | | { |
| | | $helcim_id = get_post_meta($postID, BASE . '_helcim_product_id', true); |
| | | $this->queueOperation('import_products', [ |
| | | 'user_id' => $this->userID, |
| | | ], ['priority' => 'normal']); |
| | | |
| | | if ($helcim_id) { |
| | | $this->queueOperation('delete_from_helcim', [ |
| | | 'helcim_ids' => [$helcim_id], |
| | | 'post_id' => $postID |
| | | ], [ |
| | | 'priority' => 'high' |
| | | ]); |
| | | } |
| | | return ['success' => true, 'message' => 'Import from Helcim queued']; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * USER ↔ CUSTOMER LINKING |
| | | *****************************************************************/ |
| | | |
| | | public function linkUserToCustomer(int $userId, int $helcimCustomerId): void |
| | | { |
| | | update_user_meta($userId, BASE . '_helcim_customer_id', $helcimCustomerId); |
| | | } |
| | | |
| | | public function getUserCustomerId(int $userId): ?int |
| | | { |
| | | $id = get_user_meta($userId, BASE . '_helcim_customer_id', true); |
| | | return $id ? (int) $id : null; |
| | | } |
| | | |
| | | /** |
| | | * Process queued operations |
| | | * Resolve customer ID from user meta, falling back to email lookup + auto-link. |
| | | */ |
| | | public function processOperation(WP_Error|array $result, object $operation, array $data): WP_Error|array |
| | | public function resolveCustomerId(int $userId): ?int |
| | | { |
| | | $base = strtolower($this->service_name).'_'; |
| | | $helcim = (array_key_exists('user', $data)) ? new self((int)$data['user']) : $this; |
| | | |
| | | switch ($operation->type) { |
| | | case $base.'sync_to_helcim': |
| | | return $helcim->processSyncToHelcim($data); |
| | | |
| | | case $base.'delete_from_helcim': |
| | | return $helcim->processDeleteFromHelcim($data); |
| | | |
| | | case $base.'import_catalog': |
| | | return $helcim->processImportCatalog($data); |
| | | |
| | | case $base.'sync_customer': |
| | | return $helcim->processSyncCustomer($data); |
| | | |
| | | default: |
| | | return $result; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Process sync to Helcim |
| | | */ |
| | | private function processSyncToHelcim(array $data): array |
| | | { |
| | | $items = $data['items'] ?? []; |
| | | $content_type = $data['content_type'] ?? 'REGULAR'; |
| | | $success_count = 0; |
| | | $errors = []; |
| | | |
| | | foreach ($items as $post_id) { |
| | | try { |
| | | $post = get_post($post_id); |
| | | if (!$post) continue; |
| | | |
| | | $meta = Meta::forPost($post_id); |
| | | $field_map = $this->field_mappings[$post->post_type] ?? []; |
| | | |
| | | // Prepare product data for Helcim |
| | | $product_data = [ |
| | | 'name' => $post->post_title, |
| | | 'description' => $post->post_content, |
| | | 'productCode' => get_post_meta($post_id, BASE . '_helcim_product_code', true) ?: 'WP-' . $post_id, |
| | | 'type' => $content_type, |
| | | 'price' => floatval($meta->get('price')) * 100, // Convert to cents |
| | | 'taxable' => (bool)$meta->get('is_taxable'), |
| | | ]; |
| | | |
| | | // Handle variations |
| | | $variations = $meta->get('product_variations'); |
| | | if (!empty($variations)) { |
| | | $product_data['variations'] = $this->prepareVariations($variations); |
| | | } |
| | | |
| | | // Check if product exists |
| | | $helcim_id = get_post_meta($post_id, BASE . '_helcim_product_id', true); |
| | | |
| | | if ($helcim_id) { |
| | | // Update existing product |
| | | $response = $this->putRequest('inventory/product/' . $helcim_id, $product_data); |
| | | } else { |
| | | // Create new product |
| | | $response = $this->postRequest('inventory/product', $product_data); |
| | | |
| | | if (!is_wp_error($response) && isset($response['productId'])) { |
| | | update_post_meta($post_id, BASE . '_helcim_product_id', $response['productId']); |
| | | $helcim_id = $response['productId']; |
| | | } |
| | | } |
| | | |
| | | if (!is_wp_error($response)) { |
| | | update_post_meta($post_id, BASE . '_helcim_sync_status', 'success'); |
| | | update_post_meta($post_id, BASE . '_helcim_last_sync', current_time('mysql')); |
| | | $success_count++; |
| | | } else { |
| | | throw new Exception($response->get_error_message()); |
| | | } |
| | | |
| | | } catch (Exception $e) { |
| | | $errors[] = "Post $post_id: " . $e->getMessage(); |
| | | update_post_meta($post_id, BASE . '_helcim_sync_status', 'failed'); |
| | | update_post_meta($post_id, BASE . '_helcim_sync_error', $e->getMessage()); |
| | | } |
| | | $id = $this->getUserCustomerId($userId); |
| | | if ($id) { |
| | | return $id; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => count($errors) === 0, |
| | | 'result' => [ |
| | | 'synced' => $success_count, |
| | | 'errors' => $errors |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Prepare variations for Helcim |
| | | */ |
| | | private function prepareVariations(array $variations): array |
| | | { |
| | | $helcim_variations = []; |
| | | |
| | | foreach ($variations as $index => $variation) { |
| | | $helcim_variations[] = [ |
| | | 'name' => $variation['name'] ?? '', |
| | | 'price' => floatval($variation['price'] ?? 0) * 100, |
| | | 'sku' => $variation['sku'] ?? '', |
| | | 'inventory' => intval($variation['inventory'] ?? 0), |
| | | ]; |
| | | } |
| | | |
| | | return $helcim_variations; |
| | | } |
| | | |
| | | /** |
| | | * Process delete from Helcim |
| | | */ |
| | | private function processDeleteFromHelcim(array $data): array |
| | | { |
| | | $helcim_ids = $data['helcim_ids'] ?? []; |
| | | $success_count = 0; |
| | | |
| | | foreach ($helcim_ids as $helcim_id) { |
| | | $response = $this->deleteRequest('inventory/product/' . $helcim_id); |
| | | |
| | | if (!is_wp_error($response)) { |
| | | $success_count++; |
| | | } |
| | | } |
| | | |
| | | return [ |
| | | 'success' => $success_count > 0, |
| | | 'result' => ['deleted' => $success_count] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Process import from Helcim catalog |
| | | */ |
| | | private function processImportCatalog(array $data): array |
| | | { |
| | | $page = 1; |
| | | $imported = 0; |
| | | |
| | | do { |
| | | $response = $this->getRequest('inventory/product', [ |
| | | 'page' => $page, |
| | | 'limit' => 100 |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | break; |
| | | } |
| | | |
| | | $products = $response['products'] ?? []; |
| | | |
| | | foreach ($products as $product) { |
| | | $this->importHelcimProduct($product); |
| | | $imported++; |
| | | } |
| | | |
| | | $page++; |
| | | $has_more = count($products) === 100; |
| | | |
| | | } while ($has_more); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => ['imported' => $imported] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Import a single Helcim product |
| | | */ |
| | | private function importHelcimProduct(array $product): void |
| | | { |
| | | // Find existing post by Helcim ID |
| | | $args = [ |
| | | 'post_type' => $this->syncPostTypes, |
| | | 'meta_key' => BASE . '_helcim_product_id', |
| | | 'meta_value' => $product['productId'], |
| | | 'posts_per_page' => 1 |
| | | ]; |
| | | |
| | | $existing = get_posts($args); |
| | | |
| | | if ($existing) { |
| | | $post_id = $existing[0]->ID; |
| | | |
| | | // Update existing post |
| | | wp_update_post([ |
| | | 'ID' => $post_id, |
| | | 'post_title' => $product['name'], |
| | | 'post_content' => $product['description'] ?? '' |
| | | ]); |
| | | } else { |
| | | // Create new post |
| | | $post_id = wp_insert_post([ |
| | | 'post_title' => $product['name'], |
| | | 'post_content' => $product['description'] ?? '', |
| | | 'post_type' => $this->syncPostTypes[0] ?? 'post', |
| | | 'post_status' => 'publish' |
| | | ]); |
| | | } |
| | | |
| | | if ($post_id) { |
| | | // Update meta data |
| | | $meta = Meta::forPost($post_id); |
| | | $meta->setAll([ |
| | | 'price' => $product['price'] / 100, // Convert from cents |
| | | '_helcim_product_id' => $product['productId'], |
| | | '_helcim_product_code' => $product['productCode'], |
| | | '_helcim_last_sync' => current_time('mysql') |
| | | ]); |
| | | } |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * CUSTOMER MANAGEMENT |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Track user login for security |
| | | */ |
| | | public function trackUserLogin(string $user_login, \WP_User $user): void |
| | | { |
| | | // Check if user has Helcim integration |
| | | $user_roles = $user->roles; |
| | | |
| | | foreach ($user_roles as $role) { |
| | | $role_key = jvbNoBase($role); |
| | | if (isset(JVB_USER[$role_key]['integrations']['helcim']['is_customer'])) { |
| | | $login_count = (int)get_user_meta($user->ID, BASE . '_helcim_login_count', true); |
| | | $login_count++; |
| | | |
| | | update_user_meta($user->ID, BASE . '_helcim_login_count', $login_count); |
| | | update_user_meta($user->ID, BASE . '_helcim_last_login', current_time('mysql')); |
| | | |
| | | // Check if password reset is needed |
| | | if ($login_count % self::PASSWORD_RESET_INTERVAL === 0) { |
| | | $this->schedulePasswordReset($user->ID); |
| | | } |
| | | |
| | | break; |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Schedule password reset for security |
| | | */ |
| | | private function schedulePasswordReset(int $user_id): void |
| | | { |
| | | update_user_meta($user_id, BASE . '_helcim_password_reset_required', true); |
| | | |
| | | // Send notification |
| | | $user = get_user_by('ID', $user_id); |
| | | if ($user) { |
| | | JVB()->email()->sendEmail( |
| | | $user->user_email, |
| | | 'Security: Password Reset Required', |
| | | 'For your security, please reset your password to continue accessing your account and saved payment methods.', |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle customer lookup |
| | | */ |
| | | public function handleCustomerLookup(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $email = sanitize_email($request->get_param('email')); |
| | | |
| | | if (!$email) { |
| | | return new WP_REST_Response(['error' => 'Email required'], 400); |
| | | } |
| | | |
| | | // Check WordPress user first |
| | | $user = get_user_by('email', $email); |
| | | |
| | | if ($user) { |
| | | // Check if user has customer role |
| | | $has_customer_role = false; |
| | | foreach ($user->roles as $role) { |
| | | $role_key = jvbNoBase($role); |
| | | if (isset(JVB_USER[$role_key]['integrations']['helcim']['is_customer'])) { |
| | | $has_customer_role = true; |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if ($has_customer_role) { |
| | | // Get saved cards and order history |
| | | $customer_id = get_user_meta($user->ID, BASE . '_helcim_customer_id', true); |
| | | |
| | | if ($customer_id) { |
| | | $customer_data = $this->getHelcimCustomer($customer_id); |
| | | |
| | | return new WP_REST_Response([ |
| | | 'exists' => true, |
| | | 'has_account' => true, |
| | | 'customer' => [ |
| | | 'name' => $user->display_name, |
| | | 'email' => $user->user_email |
| | | ], |
| | | 'cards' => $customer_data['cards'] ?? [], |
| | | 'orders' => $this->getUserOrders($user->ID) |
| | | ]); |
| | | } |
| | | } |
| | | |
| | | return new WP_REST_Response([ |
| | | 'exists' => true, |
| | | 'has_account' => true, |
| | | 'no_customer_role' => true, |
| | | 'message' => 'Account exists but not set up for orders. Would you like to enable ordering?' |
| | | ]); |
| | | } |
| | | |
| | | // Check Helcim for customer |
| | | $helcim_customer = $this->searchHelcimCustomer($email); |
| | | |
| | | if ($helcim_customer) { |
| | | return new WP_REST_Response([ |
| | | 'exists' => true, |
| | | 'has_account' => false, |
| | | 'helcim_only' => true, |
| | | 'message' => 'Found your previous orders. Create an account to access them?' |
| | | ]); |
| | | } |
| | | |
| | | return new WP_REST_Response([ |
| | | 'exists' => false, |
| | | 'message' => 'New customer' |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Get Helcim customer data |
| | | */ |
| | | private function getHelcimCustomer(string $customer_id): array |
| | | { |
| | | $cached = $this->cache->get('helcim_customer_' . $customer_id); |
| | | |
| | | if ($cached !== false) { |
| | | return $cached; |
| | | } |
| | | |
| | | $response = $this->getRequest('customer/' . $customer_id); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return []; |
| | | } |
| | | |
| | | // Get saved cards |
| | | $cards_response = $this->getRequest('customer/' . $customer_id . '/cards'); |
| | | $cards = []; |
| | | |
| | | if (!is_wp_error($cards_response) && isset($cards_response['cards'])) { |
| | | foreach ($cards_response['cards'] as $card) { |
| | | $cards[] = [ |
| | | 'id' => $card['cardToken'], |
| | | 'last_4' => $card['cardLast4'], |
| | | 'card_brand' => $card['cardBrand'], |
| | | 'exp_month' => $card['expiryMonth'], |
| | | 'exp_year' => $card['expiryYear'] |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | $customer_data = [ |
| | | 'customer' => $response, |
| | | 'cards' => $cards |
| | | ]; |
| | | |
| | | $this->cache->set('helcim_customer_' . $customer_id, $customer_data, HOUR_IN_SECONDS); |
| | | |
| | | return $customer_data; |
| | | } |
| | | |
| | | /** |
| | | * Search for Helcim customer by email |
| | | */ |
| | | private function searchHelcimCustomer(string $email): ?array |
| | | { |
| | | $response = $this->getRequest('customer/search', [ |
| | | 'email' => $email |
| | | ]); |
| | | |
| | | if (!is_wp_error($response) && isset($response['customers'][0])) { |
| | | return $response['customers'][0]; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Get user's order history |
| | | */ |
| | | private function getUserOrders(int $user_id): array |
| | | { |
| | | $orders = get_user_meta($user_id, BASE . '_helcim_orders', true) ?: []; |
| | | |
| | | // Get last 10 orders |
| | | return array_slice($orders, -10); |
| | | } |
| | | |
| | | /** |
| | | * Handle account creation |
| | | */ |
| | | public function handleAccountCreation(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $email = sanitize_email($request->get_param('email')); |
| | | $name = sanitize_text_field($request->get_param('name')); |
| | | |
| | | if (!$email || !is_email($email)) { |
| | | return new WP_REST_Response(['error' => 'Valid email required'], 400); |
| | | } |
| | | |
| | | // Check if user already exists |
| | | if (email_exists($email)) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'exists' => true, |
| | | 'message' => 'An account with this email already exists. Please log in instead.' |
| | | ], 409); |
| | | } |
| | | |
| | | // Generate username from email |
| | | $username = sanitize_user(current(explode('@', $email))); |
| | | $username = $this->generateUniqueUsername($username); |
| | | |
| | | // Create user account |
| | | $user_id = wp_create_user( |
| | | $username, |
| | | wp_generate_password(20, true, true), // Temporary password |
| | | $email |
| | | ); |
| | | |
| | | if (is_wp_error($user_id)) { |
| | | $this->logError('Failed to create customer account', [ |
| | | 'email' => $email, |
| | | 'error' => $user_id->get_error_message() |
| | | ]); |
| | | return new WP_REST_Response(['error' => 'Failed to create account'], 500); |
| | | } |
| | | |
| | | // Set user role |
| | | $user = new \WP_User($user_id); |
| | | $user->set_role(BASE.'foodie'); // Or appropriate role from JVB_USER |
| | | |
| | | // Update display name |
| | | if ($name) { |
| | | wp_update_user([ |
| | | 'ID' => $user_id, |
| | | 'display_name' => $name |
| | | ]); |
| | | } |
| | | |
| | | // Generate password reset key |
| | | $reset_key = get_password_reset_key($user); |
| | | |
| | | if (!is_wp_error($reset_key)) { |
| | | $this->sendWelcomeEmail($user, $reset_key); |
| | | } |
| | | |
| | | // Link to Helcim customer if exists |
| | | $helcim_customer = $this->searchHelcimCustomer($email); |
| | | |
| | | if ($helcim_customer) { |
| | | update_user_meta($user_id, BASE . '_helcim_customer_id', $helcim_customer['customerId']); |
| | | } else { |
| | | // Create new Helcim customer |
| | | $customer_response = $this->postRequest('customer', [ |
| | | 'customerCode' => 'WP-' . $user_id, |
| | | 'contactName' => $name ?: $username, |
| | | 'email' => $email |
| | | ]); |
| | | |
| | | if (!is_wp_error($customer_response) && isset($customer_response['customerId'])) { |
| | | update_user_meta($user_id, BASE . '_helcim_customer_id', $customer_response['customerId']); |
| | | } |
| | | } |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => 'Account created! Check your email to set your password.', |
| | | 'user_id' => $user_id |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Generate unique username |
| | | */ |
| | | private function generateUniqueUsername(string $base): string |
| | | { |
| | | $username = $base; |
| | | $counter = 1; |
| | | |
| | | while (username_exists($username)) { |
| | | $username = $base . $counter; |
| | | $counter++; |
| | | } |
| | | |
| | | return $username; |
| | | } |
| | | |
| | | /** |
| | | * Send welcome email |
| | | */ |
| | | private function sendWelcomeEmail(\WP_User $user, string $reset_key): void |
| | | { |
| | | $site_name = get_bloginfo('name'); |
| | | $reset_url = get_home_url(null, "login?action=rp&key=$reset_key&login=" . rawurlencode($user->user_login), 'login'); |
| | | |
| | | $message = sprintf( |
| | | "Welcome to %s!\n\n" . |
| | | "Your account has been created. Please click the button below to set your password:\n\n" . |
| | | "%s\n\n" . |
| | | "Or, copy and paste the link below:\n\n". |
| | | "%s\n\n" . |
| | | "Once you've set your password, you can:\n" . |
| | | "- View your order history\n" . |
| | | "- Save your favorite items\n" . |
| | | "- Speed up checkout with saved payment methods\n\n" . |
| | | "If you didn't create this account, please ignore this email.\n\n" . |
| | | "Thanks,\n", |
| | | $site_name, |
| | | JVB()->email()->button('Reset Password', $reset_url), |
| | | JVB()->email()->link($reset_url), |
| | | ); |
| | | |
| | | JVB()->email()->sendEmail( |
| | | $user->user_email, |
| | | sprintf('[%s] Welcome! Set Your Password', $site_name), |
| | | $message |
| | | ); |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * ORDER PROCESSING |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Handle checkout |
| | | */ |
| | | public function handleCheckout(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $cart_items = $request->get_param('items'); |
| | | $customer_info = $request->get_param('customer'); |
| | | $payment_token = $request->get_param('payment_token'); |
| | | |
| | | if (empty($cart_items) || empty($payment_token)) { |
| | | return new WP_REST_Response(['error' => 'Invalid order data'], 400); |
| | | } |
| | | |
| | | // Calculate order total |
| | | $order_total = $this->calculateOrderTotal($cart_items); |
| | | |
| | | // Create Helcim invoice |
| | | $invoice_response = $this->createHelcimInvoice($cart_items, $customer_info, $order_total); |
| | | |
| | | if (is_wp_error($invoice_response)) { |
| | | return new WP_REST_Response(['error' => $invoice_response->get_error_message()], 500); |
| | | } |
| | | |
| | | // Process payment |
| | | $payment_response = $this->processHelcimPayment($payment_token, $invoice_response['invoiceId'], $order_total); |
| | | |
| | | if (is_wp_error($payment_response)) { |
| | | return new WP_REST_Response(['error' => $payment_response->get_error_message()], 500); |
| | | } |
| | | |
| | | // Save order to user if logged in |
| | | if (is_user_logged_in()) { |
| | | $this->saveOrderToUser(get_current_user_id(), $invoice_response['invoiceId']); |
| | | } |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'order_id' => $invoice_response['invoiceId'], |
| | | 'receipt_url' => $payment_response['receiptUrl'] ?? '', |
| | | 'message' => 'Order placed successfully!' |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Calculate order total |
| | | */ |
| | | private function calculateOrderTotal(array $cart_items): int |
| | | { |
| | | $total = 0; |
| | | |
| | | foreach ($cart_items as $item) { |
| | | $post_id = intval($item['id'] ?? 0); |
| | | if (!$post_id) continue; |
| | | |
| | | $meta = Meta::forPost($post_id); |
| | | $price = floatval($meta->get('price')); |
| | | $quantity = intval($item['quantity'] ?? 1); |
| | | |
| | | $total += ($price * $quantity * 100); // Convert to cents |
| | | } |
| | | |
| | | // Add tax |
| | | $tax_rate = floatval(get_option(BASE . 'helcim_tax_rate', 0.05)); |
| | | $tax = intval($total * $tax_rate); |
| | | |
| | | return $total + $tax; |
| | | } |
| | | |
| | | /** |
| | | * Create Helcim invoice |
| | | */ |
| | | private function createHelcimInvoice(array $cart_items, array $customer_info, int $total): array|WP_Error |
| | | { |
| | | $line_items = []; |
| | | |
| | | foreach ($cart_items as $item) { |
| | | $post_id = intval($item['id'] ?? 0); |
| | | if (!$post_id) continue; |
| | | |
| | | $post = get_post($post_id); |
| | | $meta = Meta::forPost($post_id); |
| | | |
| | | $line_items[] = [ |
| | | 'description' => $post->post_title, |
| | | 'quantity' => intval($item['quantity'] ?? 1), |
| | | 'price' => floatval($meta->get('price')) * 100, |
| | | 'productCode' => get_post_meta($post_id, BASE . '_helcim_product_code', true) ?: 'WP-' . $post_id |
| | | ]; |
| | | } |
| | | |
| | | // Get or create customer |
| | | $customer_id = $this->getOrCreateHelcimCustomer($customer_info); |
| | | |
| | | return $this->postRequest('commerce/invoice', [ |
| | | 'customerId' => $customer_id, |
| | | 'invoiceNumber' => 'INV-' . time(), |
| | | 'tipAmount' => 0, |
| | | 'depositAmount' => 0, |
| | | 'notes' => $customer_info['notes'] ?? '', |
| | | 'lineItems' => $line_items |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Process Helcim payment |
| | | */ |
| | | private function processHelcimPayment(string $payment_token, string $invoice_id, int $amount): array|WP_Error |
| | | { |
| | | return $this->postRequest('payment/purchase', [ |
| | | 'paymentToken' => $payment_token, |
| | | 'amount' => $amount, |
| | | 'currency' => 'CAD', |
| | | 'invoiceId' => $invoice_id |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Get or create Helcim customer |
| | | */ |
| | | private function getOrCreateHelcimCustomer(array $customer_info): ?string |
| | | { |
| | | if (empty($customer_info['email'])) { |
| | | $user = get_userdata($userId); |
| | | if (!$user || empty($user->user_email)) { |
| | | return null; |
| | | } |
| | | |
| | | // Search for existing customer |
| | | $existing = $this->searchHelcimCustomer($customer_info['email']); |
| | | |
| | | if ($existing) { |
| | | return $existing['customerId']; |
| | | $id = $this->getCustomerIdByEmail($user->user_email); |
| | | if ($id) { |
| | | $this->linkUserToCustomer($userId, $id); |
| | | } |
| | | |
| | | // Create new customer |
| | | $response = $this->postRequest('customer', [ |
| | | 'customerCode' => 'GUEST-' . time(), |
| | | 'contactName' => $customer_info['name'] ?? '', |
| | | 'email' => $customer_info['email'], |
| | | 'phone' => $customer_info['phone'] ?? '' |
| | | ]); |
| | | |
| | | if (!is_wp_error($response) && isset($response['customerId'])) { |
| | | return $response['customerId']; |
| | | } |
| | | |
| | | return null; |
| | | return $id; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * VALIDATION |
| | | *****************************************************************/ |
| | | |
| | | /** |
| | | * Save order to user meta |
| | | * Validate a HelcimPay.js transaction using the secret token. |
| | | * |
| | | * After the frontend receives a SUCCESS message event, call this |
| | | * server-side to verify the transaction hash. |
| | | */ |
| | | private function saveOrderToUser(int $user_id, string $order_id): void |
| | | public function validateTransaction(string $secretToken, array $transactionData): bool |
| | | { |
| | | $orders = get_user_meta($user_id, BASE . '_helcim_orders', true) ?: []; |
| | | $orders[] = [ |
| | | 'order_id' => $order_id, |
| | | 'date' => current_time('mysql') |
| | | ]; |
| | | |
| | | // Keep only last 50 orders |
| | | if (count($orders) > 50) { |
| | | $orders = array_slice($orders, -50); |
| | | } |
| | | |
| | | update_user_meta($user_id, BASE . '_helcim_orders', $orders); |
| | | $hash = hash('sha256', $secretToken . json_encode($transactionData)); |
| | | return hash_equals($hash, $transactionData['hash'] ?? ''); |
| | | } |
| | | |
| | | /******************************************************************* |
| | | * WEBHOOKS |
| | | *******************************************************************/ |
| | | /** |
| | | * Handle order status |
| | | */ |
| | | public function handleOrderStatus(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $order_id = $request->get_param('order_id'); |
| | | |
| | | if (!$order_id) { |
| | | return new WP_REST_Response(['error' => 'Order ID required'], 400); |
| | | } |
| | | |
| | | // Check cache first |
| | | $cached_status = get_transient(BASE . 'helcim_order_' . $order_id); |
| | | |
| | | if ($cached_status !== false) { |
| | | return new WP_REST_Response($cached_status); |
| | | } |
| | | |
| | | // Fetch from Helcim |
| | | $response = $this->getRequest('commerce/invoice/' . $order_id); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return new WP_REST_Response(['error' => 'Could not fetch order status'], 500); |
| | | } |
| | | |
| | | $status = [ |
| | | 'status' => $response['status'] ?? 'unknown', |
| | | 'eta' => $response['estimatedTime'] ?? null, |
| | | 'items' => $response['lineItems'] ?? [] |
| | | ]; |
| | | |
| | | // Cache for 1 minute |
| | | set_transient(BASE . 'helcim_order_' . $order_id, $status, MINUTE_IN_SECONDS); |
| | | |
| | | return new WP_REST_Response($status); |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * WEBHOOK HANDLING |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Validate webhook signature |
| | | * Validate Helcim webhook signature. |
| | | * |
| | | * Helcim signs webhooks with HMAC-SHA256 using: |
| | | * signedContent = "{webhook-id}.{webhook-timestamp}.{body}" |
| | | * key = base64_decode(verifierToken) |
| | | * |
| | | * Headers: webhook-id, webhook-timestamp, webhook-signature (v1,{base64hash}) |
| | | * |
| | | * @see https://devdocs.helcim.com/docs/enabling-webhooks-for-transactions |
| | | */ |
| | | protected function validateWebhook(array $payload): bool |
| | | { |
| | | $signature = $_SERVER['HTTP_HELCIM_SIGNATURE'] ?? ''; |
| | | $headers = $payload['_headers'] ?? []; |
| | | |
| | | if (!$signature || !$this->webhook_secret) { |
| | | $webhookId = $headers['webhook_id'][0] ?? $headers['webhook-id'] ?? ''; |
| | | $webhookTimestamp = $headers['webhook_timestamp'][0] ?? $headers['webhook-timestamp'] ?? ''; |
| | | $webhookSignature = $headers['webhook_signature'][0] ?? $headers['webhook-signature'] ?? ''; |
| | | |
| | | if (empty($webhookId) || empty($webhookTimestamp) || empty($webhookSignature)) { |
| | | $this->logError('Webhook missing required headers', [], 'warning'); |
| | | return false; |
| | | } |
| | | |
| | | $body = file_get_contents('php://input'); |
| | | $expected = hash_hmac('sha256', $body, $this->webhook_secret); |
| | | // Verify timestamp is within 5 minutes (prevent replay attacks) |
| | | $now = time(); |
| | | if (abs($now - (int)$webhookTimestamp) > 300) { |
| | | $this->logError('Webhook timestamp too old', [ |
| | | 'webhook_timestamp' => $webhookTimestamp, |
| | | 'server_time' => $now, |
| | | ], 'warning'); |
| | | return false; |
| | | } |
| | | |
| | | return hash_equals($expected, $signature); |
| | | $secret = $this->getAdvancedSetting('webhook_signature_key'); |
| | | if (empty($secret)) { |
| | | // If no signature key configured, allow webhook but log warning |
| | | $this->logDebug('No webhook signature key configured — skipping verification'); |
| | | return true; |
| | | } |
| | | |
| | | // Reconstruct the raw body from the payload (minus our injected _headers) |
| | | $body = $payload['_raw_body'] ?? json_encode( |
| | | array_diff_key($payload, ['_headers' => 1, '_raw_body' => 1]) |
| | | ); |
| | | |
| | | $signedContent = "{$webhookId}.{$webhookTimestamp}.{$body}"; |
| | | $secretBytes = base64_decode($secret); |
| | | $expectedHash = base64_encode( |
| | | hash_hmac('sha256', $signedContent, $secretBytes, true) |
| | | ); |
| | | |
| | | // webhook-signature may contain multiple signatures: "v1,hash1 v2,hash2" |
| | | $signatures = explode(' ', $webhookSignature); |
| | | foreach ($signatures as $sig) { |
| | | $parts = explode(',', $sig, 2); |
| | | if (count($parts) === 2 && hash_equals($expectedHash, $parts[1])) { |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | $this->logError('Webhook signature mismatch', [ |
| | | 'webhook_id' => $webhookId, |
| | | ], 'warning'); |
| | | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Process webhook event |
| | | * Process a validated Helcim webhook event. |
| | | * |
| | | * Helcim sends minimal payloads: {"id": "12345", "type": "cardTransaction"} |
| | | * We fetch the full transaction details from the API. |
| | | */ |
| | | protected function processWebhook(array $payload): bool |
| | | { |
| | | $event_type = $payload['eventType'] ?? ''; |
| | | $data = $payload['data'] ?? []; |
| | | $type = $payload['type'] ?? ''; |
| | | $id = $payload['id'] ?? ''; |
| | | |
| | | switch ($event_type) { |
| | | case 'transaction.success': |
| | | case 'transaction.declined': |
| | | return $this->handleTransactionWebhook($data); |
| | | |
| | | case 'invoice.paid': |
| | | case 'invoice.updated': |
| | | return $this->handleInvoiceWebhook($data); |
| | | |
| | | case 'customer.created': |
| | | case 'customer.updated': |
| | | return $this->handleCustomerWebhook($data); |
| | | |
| | | default: |
| | | $this->logDebug('Unhandled webhook type', ['type' => $event_type]); |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle transaction webhook |
| | | */ |
| | | private function handleTransactionWebhook(array $data): bool |
| | | { |
| | | $transaction_id = $data['transactionId'] ?? ''; |
| | | $status = $data['status'] ?? ''; |
| | | |
| | | if (!$transaction_id) { |
| | | if (empty($type) || empty($id)) { |
| | | $this->logError('Webhook missing type or id', $payload, 'warning'); |
| | | return false; |
| | | } |
| | | |
| | | // Update cached transaction status |
| | | set_transient(BASE . 'helcim_transaction_' . $transaction_id, $status, HOUR_IN_SECONDS); |
| | | |
| | | // Trigger action for other integrations |
| | | do_action(BASE . 'helcim_transaction_updated', $transaction_id, $status, $data); |
| | | |
| | | return true; |
| | | return match ($type) { |
| | | 'cardTransaction' => $this->handleTransactionWebhook($id), |
| | | 'terminalCancel' => $this->handleTerminalCancelWebhook($payload), |
| | | default => $this->handleUnknownWebhook($type, $id), |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * Handle invoice webhook |
| | | * Handle a cardTransaction webhook — fetch full transaction, update records. |
| | | */ |
| | | private function handleInvoiceWebhook(array $data): bool |
| | | protected function handleTransactionWebhook(string $transactionId): bool |
| | | { |
| | | $invoice_id = $data['invoiceId'] ?? ''; |
| | | $status = $data['status'] ?? ''; |
| | | // Fetch full transaction from Helcim API |
| | | $transaction = $this->getRequest("card-transactions/{$transactionId}"); |
| | | |
| | | if (!$invoice_id) { |
| | | if (is_wp_error($transaction) || empty($transaction)) { |
| | | $this->logError('Failed to fetch transaction for webhook', [ |
| | | 'transaction_id' => $transactionId, |
| | | ]); |
| | | return false; |
| | | } |
| | | |
| | | // Update cached order status |
| | | set_transient(BASE . 'helcim_order_' . $invoice_id, $status, HOUR_IN_SECONDS); |
| | | $status = $transaction['status'] ?? ''; |
| | | |
| | | // Trigger action for other integrations |
| | | do_action(BASE . 'helcim_order_updated', $invoice_id, $status, $data); |
| | | // Fire action for other parts of the system to react |
| | | do_action('jvb_helcim_transaction', $transaction, $status); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Handle customer webhook |
| | | */ |
| | | private function handleCustomerWebhook(array $data): bool |
| | | { |
| | | $customer_id = $data['customerId'] ?? ''; |
| | | $email = $data['email'] ?? ''; |
| | | |
| | | if (!$customer_id || !$email) { |
| | | return false; |
| | | // If linked to an invoice, update invoice cache |
| | | $invoiceNumber = $transaction['invoiceNumber'] ?? ''; |
| | | if (!empty($invoiceNumber)) { |
| | | $this->cache->delete("invoice_{$invoiceNumber}"); |
| | | do_action('jvb_helcim_invoice_updated', $invoiceNumber, $transaction); |
| | | } |
| | | |
| | | // Find WordPress user with this Helcim customer ID |
| | | $users = get_users([ |
| | | 'meta_key' => BASE . '_helcim_customer_id', |
| | | 'meta_value' => $customer_id, |
| | | 'number' => 1 |
| | | // Log for debugging |
| | | $this->logDebug('Transaction webhook processed', [ |
| | | 'transaction_id' => $transactionId, |
| | | 'status' => $status, |
| | | 'amount' => $transaction['amount'] ?? 0, |
| | | 'type' => $transaction['type'] ?? '', |
| | | ]); |
| | | |
| | | if (!empty($users)) { |
| | | $user = $users[0]; |
| | | update_user_meta($user->ID, BASE . '_helcim_customer_updated', current_time('mysql')); |
| | | return true; |
| | | } |
| | | |
| | | // Clear cached customer data |
| | | $this->cache->forget('helcim_customer_' . $user->ID); |
| | | } |
| | | /** |
| | | * Handle Smart Terminal cancel webhook |
| | | */ |
| | | protected function handleTerminalCancelWebhook(array $payload): bool |
| | | { |
| | | do_action('jvb_helcim_terminal_cancel', $payload); |
| | | |
| | | $this->logDebug('Terminal cancel webhook processed', [ |
| | | 'payload' => $payload, |
| | | ]); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * CONNECTION TESTING |
| | | ******************************************************************/ |
| | | /** |
| | | * Handle unknown webhook types (future-proofing) |
| | | */ |
| | | protected function handleUnknownWebhook(string $type, string $id): bool |
| | | { |
| | | $this->logDebug('Unknown webhook type received', [ |
| | | 'type' => $type, |
| | | 'id' => $id, |
| | | ]); |
| | | |
| | | do_action("jvb_helcim_webhook_{$type}", $id); |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Perform connection test |
| | | * Extract unique webhook ID for deduplication |
| | | */ |
| | | protected function performConnectionTest(): bool |
| | | protected function extractWebhookId(array $payload): ?string |
| | | { |
| | | if (empty($this->api_token) || empty($this->account_id)) { |
| | | throw new Exception('Missing required credentials'); |
| | | $headers = $payload['_headers'] ?? []; |
| | | return $headers['webhook_id'][0] ?? $headers['webhook-id'] ?? $payload['id'] ?? null; |
| | | } |
| | | |
| | | /** |
| | | * Override the webhook request handler to capture raw body for signature verification |
| | | */ |
| | | public function handleWebhookRequest(\WP_REST_Request $request): \WP_REST_Response |
| | | { |
| | | $payload = $request->get_params(); |
| | | $payload['_headers'] = $request->get_headers(); |
| | | $payload['_raw_body'] = $request->get_body(); |
| | | |
| | | $success = $this->handleWebhook($payload); |
| | | |
| | | return new \WP_REST_Response([ |
| | | 'success' => $success, |
| | | ], $success ? 200 : 400); |
| | | } |
| | | /*********************************************************************** |
| | | * POST HOOKS |
| | | ***********************************************************************/ |
| | | /** |
| | | * Sync a WordPress post to Helcim as a product. |
| | | * Called by IntegrationExecutor::processSyncTo() |
| | | */ |
| | | public function syncPostToService(int $postID): array|\WP_Error |
| | | { |
| | | $post = get_post($postID); |
| | | if (!$post) { |
| | | return new \WP_Error('not_found', "Post {$postID} not found"); |
| | | } |
| | | |
| | | $response = $this->getRequest('account'); |
| | | $helcimProductId = get_post_meta($postID, BASE . '_helcim_item_id', true); |
| | | $productData = $this->buildProductPayload($postID); |
| | | |
| | | if (is_wp_error($productData)) { |
| | | return $productData; |
| | | } |
| | | |
| | | if ($helcimProductId) { |
| | | // Update existing |
| | | $response = $this->patchRequest("products/{$helcimProductId}", $productData); |
| | | } else { |
| | | // Create new |
| | | $response = $this->postRequest('products', $productData); |
| | | } |
| | | |
| | | if (is_wp_error($response)) { |
| | | throw new Exception($response->get_error_message()); |
| | | update_post_meta($postID, BASE . '_helcim_sync_status', 'error'); |
| | | return $response; |
| | | } |
| | | |
| | | return isset($response['accountId']); |
| | | // Store Helcim product ID |
| | | $newId = $response['id'] ?? $response['productId'] ?? $helcimProductId; |
| | | update_post_meta($postID, BASE . '_helcim_item_id', $newId); |
| | | update_post_meta($postID, BASE . '_helcim_sync_status', 'synced'); |
| | | update_post_meta($postID, BASE . '_helcim_last_sync', current_time('mysql')); |
| | | |
| | | return ['success' => true, 'helcim_id' => $newId]; |
| | | } |
| | | |
| | | /** |
| | | * Get request headers |
| | | * Delete a product from Helcim. |
| | | * Called by IntegrationExecutor::processDeleteFrom() |
| | | */ |
| | | protected function getRequestHeaders(): array |
| | | public function deleteFromService(string $externalId): array|\WP_Error |
| | | { |
| | | $headers = [ |
| | | 'Content-Type' => 'application/json', |
| | | 'Accept' => 'application/json' |
| | | ]; |
| | | $response = $this->deleteRequest("products/{$externalId}"); |
| | | |
| | | // Add authorization header |
| | | if (!empty($this->api_token)) { |
| | | $headers['api-token'] = $this->api_token; |
| | | if (is_wp_error($response)) { |
| | | return $response; |
| | | } |
| | | |
| | | return $headers; |
| | | return ['success' => true, 'deleted' => $externalId]; |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * INTEGRATION ACTIONS |
| | | ******************************************************************/ |
| | | |
| | | protected function handleImportFromHelcim() |
| | | { |
| | | $this->queueOperation('import_catalog', [ |
| | | 'user_id' => $this->userID |
| | | ], [ |
| | | 'priority' => 'normal' |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Import synced' |
| | | ]; |
| | | } |
| | | |
| | | protected function handleSyncToHelcim() |
| | | { |
| | | $post_types = array_map(function ($type) { |
| | | return jvbCheckBase($type); |
| | | }, $this->syncPostTypes); |
| | | |
| | | // Get all posts to sync |
| | | $posts = get_posts([ |
| | | 'post_type' => $post_types, |
| | | 'posts_per_page' => -1, |
| | | 'post_status' => 'publish' |
| | | ]); |
| | | |
| | | $post_ids = wp_list_pluck($posts, 'ID'); |
| | | |
| | | // Queue sync operation |
| | | $this->queueOperation('sync_to_helcim', [ |
| | | 'items' => $post_ids, |
| | | 'user_id' => $this->userID |
| | | ], [ |
| | | 'priority' => 'normal' |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => sprintf('Queued %d items for sync to Helcim', count($post_ids)) |
| | | ]; |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * ADMIN UI |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Process sync customer operation |
| | | * Build Helcim product payload from a WordPress post. |
| | | */ |
| | | private function processSyncCustomer(array $data): array |
| | | protected function buildProductPayload(int $postID): array|\WP_Error |
| | | { |
| | | $user_id = $data['user_id'] ?? 0; |
| | | $meta = \JVBase\meta\Meta::forPost($postID); |
| | | $post = get_post($postID); |
| | | |
| | | if (!$user_id) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => 'No user ID provided'] |
| | | ]; |
| | | } |
| | | |
| | | $user = get_user_by('ID', $user_id); |
| | | if (!$user) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => 'User not found'] |
| | | ]; |
| | | } |
| | | |
| | | // Get or create Helcim customer |
| | | $helcim_customer_id = $this->getOrCreateHelcimCustomer([ |
| | | 'email' => $user->user_email, |
| | | 'name' => $user->display_name |
| | | ]); |
| | | |
| | | if ($helcim_customer_id) { |
| | | update_user_meta($user_id, BASE . '_helcim_customer_id', $helcim_customer_id); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => ['customer_id' => $helcim_customer_id] |
| | | ]; |
| | | $price = $meta->get('price'); |
| | | if (empty($price) || !is_numeric($price)) { |
| | | return new \WP_Error('invalid_price', "Post {$postID} has no valid price"); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => 'Could not sync customer'] |
| | | 'name' => $post->post_title, |
| | | 'description' => wp_strip_all_tags($post->post_content), |
| | | 'sku' => $meta->get('sku') ?: "wp-{$postID}", |
| | | 'price' => (float) $price, |
| | | 'taxExempt' => (bool) $meta->get('tax_exempt'), |
| | | ]; |
| | | } |
| | | } |
| | |
| | | $config['value'] = $credentials[$name]??''; |
| | | $config['autocomplete'] = 'off'; |
| | | $config['base'] = $this->service_name.'_'; |
| | | Form::render($name, null, $config); |
| | | echo Form::render($name, '', $config); |
| | | } |
| | | } |
| | | if ($this->handleWebhooks) { |
| | |
| | | use Exception; |
| | | use JVBase\registry\PostTypeRegistrar; |
| | | use WP_Error; |
| | | use JVBase\ui\Checkout; |
| | | use JVBase\managers\queue\TypeConfig; |
| | | use JVBase\managers\queue\executors\IntegrationExecutor; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | |
| | | if (!$this->isSetUp()) { |
| | | return; |
| | | } |
| | | // User login tracking for security |
| | | add_action('wp_login', [$this, 'trackUserLogin'], 10, 2); |
| | | |
| | | // Enqueue checkout scripts |
| | | add_action('wp_login', [$this, 'trackUserLogin'], 10, 2); |
| | | add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']); |
| | | |
| | | add_filter('jvbAdditionalActions', [$this, 'outputCheckout']); |
| | | } |
| | | // Shared checkout UI (replaces outputCheckout) |
| | | add_filter('jvbAdditionalActions', [Checkout::class, 'render']); |
| | | |
| | | |
| | | public function outputCheckout(array $actions):array { |
| | | if (is_singular(BASE.'dash') || is_post_type_archive(BASE.'dash')) { |
| | | return $actions; |
| | | } |
| | | $form = '<aside id="cart" class="right main"> |
| | | <form id="checkout" data-form-id="checkout" data-save="checkout">'; |
| | | |
| | | $tabs = [ |
| | | 'cartItems' => [ |
| | | 'title' => 'Your Order', |
| | | 'icon' => 'cart', |
| | | 'description' => 'Here\'s your order. You can change quantities, remove items, or clear your cart.', |
| | | 'content' => $this->cartContent() |
| | | ], |
| | | 'checkout' => [ |
| | | 'title' => 'Checkout', |
| | | 'icon' => 'checkout', |
| | | 'description' => 'Securely checkout with your name, email, and payments processed by Square.', |
| | | 'content' => '<div class="checkout-section"> |
| | | <h3>Customer Information</h3> |
| | | '.Form::render('cart_name', null, [ |
| | | 'type' => 'text', |
| | | 'label' => 'Your Name', |
| | | 'required' => true, |
| | | 'autocomplete' => 'name' |
| | | ]). |
| | | Form::render('cart_email', null, [ |
| | | 'type' => 'email', |
| | | 'label' => 'Your Email', |
| | | 'required' => true, |
| | | 'autocomplete'=> 'email', |
| | | ]). |
| | | Form::render('cart_phone', null, [ |
| | | 'type' => 'tel', |
| | | 'label' => 'Your Phone', |
| | | 'required' => true, |
| | | 'autocomplete'=> 'phone' |
| | | ]).' |
| | | <h3>Pickup Details</h3>'. |
| | | Form::render('pickup_time', null, [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Pickup Type', |
| | | 'min' => '11:00', |
| | | 'max' => '20:00', |
| | | 'required' => true, |
| | | ]). |
| | | Form::render('special_instructions', null, [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Special Instructions', |
| | | 'quill' => true, |
| | | ]).' |
| | | <textarea name="special_instructions" placeholder="Special instructions or dietary notes"></textarea> |
| | | </div> |
| | | |
| | | <div class="checkout-section"> |
| | | <h3>Payment Information</h3> |
| | | <div id="saved-cards"></div> |
| | | <div id="square-card-container"></div> |
| | | </div>' |
| | | ], |
| | | 'order' => [ |
| | | 'title' => 'Your Order', |
| | | 'icon' => 'truck', |
| | | 'hidden' => true, |
| | | 'description' => '', |
| | | 'content' => $this->renderOrderStatus() |
| | | ] |
| | | ]; |
| | | $form .= jvbRenderTabs($tabs, true); |
| | | |
| | | $form .= '<div class="cart-total row end"><p class="tax">Tax: <span></span></p><p class="total">GRAND TOTAL: <span></span></p></div> |
| | | </form> |
| | | </aside> |
| | | <template class="restoredCart"> |
| | | <div class="restored"> |
| | | <h3>Looks like we left things hanging</h3> |
| | | <p>We\'ve restored your cart from your last session below.</p> |
| | | <p>If you\'d rather start over, click the button below.</p> |
| | | <div class="row btw"> |
| | | <button type="button" onclick="window.squareCheckout.clearCart();this.closest(\'.restored\').remove()">'.jvbIcon('trash').'Clear Cart</button> |
| | | <button type="button" onclick="this.closest(\'.restored\').remove()">'.jvbIcon('x').'Dismiss</button> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | <template class="cartItem"> |
| | | <tr class="item"> |
| | | <td class="item"> |
| | | <label for="quantity"></label> |
| | | <div class="quantity field" data-min="0" data-max="50" data-step="1" data-price="17" data-id=""> |
| | | |
| | | <button type="button" class="decrease"aria-label="Decrease Add to Order">'.jvbIcon('minus-square').'</button> |
| | | |
| | | <input type="number" id="quantity" name="quantity" value="0" min="0" max="50" step="1" class="quantity-input"> |
| | | |
| | | <button type="button" class="increase" aria-label="Increase Add to Order">'.jvbIcon('plus-square').'</button> |
| | | </div> |
| | | </td> |
| | | <td class="price"> |
| | | <span class="price"></span> |
| | | </td> |
| | | <td class="total"> |
| | | <span class="total"></span> |
| | | </td> |
| | | <td> |
| | | <button type="button" data-remove-from-cart>'.jvbIcon('trash').'</button> |
| | | </td> |
| | | </tr> |
| | | </template> |
| | | <template class="emptyCart"> |
| | | <div class="empty"> |
| | | <p><i><b>No items in cart.</b></i></p> |
| | | <p>You can <a href="'.get_post_type_archive_link(BASE.'menu_item').'" title="Browse our menu">browse our menu</a> to order.</p> |
| | | </div> |
| | | </template>'; |
| | | |
| | | |
| | | $actions[] = [ |
| | | 'button' => '<button type="button" class="toggle-cart row" title="Your Cart" data-action="toggle-cart" aria-label="Open Cart" aria-controls="checkout" aria-expanded="false"> |
| | | '.jvbIcon('shopping-cart').'<span class="abs"></span><span class="abs count"></span> |
| | | </button>', |
| | | 'content' => $form |
| | | ]; |
| | | return $actions; |
| | | } |
| | | |
| | | private function cartContent():string |
| | | { |
| | | ob_start(); |
| | | ?> |
| | | <div class="cart-items"> |
| | | <table> |
| | | <thead> |
| | | <tr> |
| | | <th scope="col">Item</th> |
| | | <th scope="col">Price</th> |
| | | <th scope="col">Total</th> |
| | | </tr> |
| | | </thead> |
| | | <tbody> |
| | | |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | |
| | | <details class="account"> |
| | | <summary> |
| | | <?php |
| | | if (is_user_logged_in()) { |
| | | echo 'Your Favourites and Order History'; |
| | | } else { |
| | | echo '<a href="'.wp_login_url(get_the_permalink()).'">Log in</a> to save your favourites and view order history.'; |
| | | } |
| | | ?> |
| | | </summary> |
| | | <?php |
| | | if (is_user_logged_in()) { |
| | | $tabs = [ |
| | | 'history' => [ |
| | | 'title' => 'Order History', |
| | | 'icon' => 'checkout', |
| | | 'description' => 'View your past orders and quickly reorder', |
| | | 'content' => $this->renderOrderHistory() |
| | | ], |
| | | 'favourites' => [ |
| | | 'title' => 'Favourites', |
| | | 'icon' => 'heart', |
| | | 'description' => 'View your favourites from our menu', |
| | | 'content' => $this->renderFavourites() |
| | | ] |
| | | ]; |
| | | jvbRenderTabs($tabs); |
| | | // Square-specific checkout description |
| | | add_filter('jvb_checkout_description', function (string $desc, string $provider) { |
| | | if ($provider === 'square') { |
| | | return 'Securely checkout with your name, email, and payments processed by Square.'; |
| | | } |
| | | return $desc; |
| | | }, 10, 2); |
| | | |
| | | ?> |
| | | </details> |
| | | // Square-specific pickup fields (extracted from old outputCheckout) |
| | | add_filter('jvb_checkout_fields', [$this, 'addPickupFields'], 10, 2); |
| | | |
| | | <?php |
| | | return ob_get_clean(); |
| | | // Browse URL for this client (restaurant menu) |
| | | add_filter('jvb_checkout_browse_url', function () { |
| | | return get_post_type_archive_link(BASE . 'menu_item'); |
| | | }); |
| | | add_filter('jvb_checkout_browse_text', function () { |
| | | return 'browse our menu'; |
| | | }); |
| | | |
| | | // Register queue executor types |
| | | $this->registerQueueTypes(); |
| | | } |
| | | |
| | | private function renderOrderHistory():string |
| | | /** |
| | | * Pickup/ordering fields for the shared checkout form. |
| | | * Specific to this Square client's food ordering use case. |
| | | */ |
| | | public function addPickupFields(string $html, string $provider): string |
| | | { |
| | | ob_start(); |
| | | //TODO: getRequest, cache for 1 day |
| | | return ob_get_clean(); |
| | | } |
| | | private function renderFavourites():string |
| | | { |
| | | ob_start(); |
| | | //TODO: get user's favourites and list them |
| | | return ob_get_clean(); |
| | | if ($provider !== 'square') { |
| | | return $html; |
| | | } |
| | | |
| | | return $html |
| | | . '<h3>Pickup Details</h3>' |
| | | . Form::render('pickup_time', null, [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Pickup Time', |
| | | 'min' => '11:00', |
| | | 'max' => '20:00', |
| | | 'required' => true, |
| | | ]) |
| | | . Form::render('special_instructions', null, [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Special Instructions', |
| | | 'quill' => true, |
| | | ]); |
| | | } |
| | | |
| | | protected function renderOrderStatus():string |
| | | protected function registerQueueTypes(): void |
| | | { |
| | | ob_start(); |
| | | ?> |
| | | <div class="order-confirmation"> |
| | | <h2>Order Confirmed!</h2> |
| | | <div id="order-status" data-order=""> |
| | | <p>Order #<span class="order-num"></span></p> |
| | | <div class="status-timeline"> |
| | | <div class="status-item active" data-status="received">Order Received</div> |
| | | <div class="status-item" data-status="preparing">Preparing</div> |
| | | <div class="status-item" data-status="ready">Ready for Pickup</div> |
| | | </div> |
| | | <div class="pickup-time"> |
| | | Estimated pickup: <span id="eta">Calculating...</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <?php |
| | | return ob_get_clean(); |
| | | $queue = JVB()->queue(); |
| | | $executor = new IntegrationExecutor(); |
| | | |
| | | $queue->registry()->register('square_sync_to', new TypeConfig( |
| | | executor: $executor, |
| | | chunkKey: 'items', |
| | | chunkSize: 50, |
| | | maxRetries: 3 |
| | | )); |
| | | |
| | | $queue->registry()->register('square_delete_from', new TypeConfig( |
| | | executor: $executor, |
| | | chunkKey: 'external_ids', |
| | | chunkSize: 200, |
| | | maxRetries: 2 |
| | | )); |
| | | |
| | | $queue->registry()->register('square_sync_from', new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 3 |
| | | )); |
| | | |
| | | $queue->registry()->register('square_sync_customer', new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 2 |
| | | )); |
| | | |
| | | $queue->registry()->register('square_import', new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 3 |
| | | )); |
| | | } |
| | | |
| | | /****************************************************************** |
| | |
| | | */ |
| | | protected function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings): void |
| | | { |
| | | // Queue the sync operation |
| | | $this->queueOperation('sync_to_square', [ |
| | | 'items' => [$postID], |
| | | 'user_id' => $this->userID |
| | | $this->queueOperation('sync_to', [ |
| | | 'items' => [$postID], |
| | | 'user_id' => $this->userID, |
| | | ], [ |
| | | 'priority' => 'high', |
| | | 'delay' => 30, // Small delay to batch multiple saves |
| | | 'delay' => 30, |
| | | ]); |
| | | |
| | | update_post_meta($postID, BASE . '_square_sync_status', 'queued'); |
| | |
| | | $square_id = get_post_meta($postID, BASE . '_square_catalog_id', true); |
| | | |
| | | if ($square_id) { |
| | | $this->queueOperation('delete_from_square', [ |
| | | 'square_ids' => [$square_id], |
| | | 'post_id' => $postID |
| | | $this->queueOperation('delete_from', [ |
| | | 'external_ids' => [$square_id], |
| | | 'post_id' => $postID, |
| | | ], [ |
| | | 'priority' => 'high' |
| | | 'priority' => 'high', |
| | | ]); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Process queued operations |
| | | * @deprecated IntegrationExecutor handles new operations via registerQueueTypes(). |
| | | * Kept for legacy-typed operations ('square_sync_to_square') already queued. |
| | | * Safe to remove once all legacy operations have been processed. |
| | | */ |
| | | public function processOperation(WP_Error|array $result, object $operation, array $data): WP_Error|array |
| | | { |
| | | $base = strtolower($this->service_name).'_'; |
| | | $square = (array_key_exists('user', $data)) ? new self((int)$data['user']) : $this; |
| | | switch ($operation->type) { |
| | | case $base.'sync_to_square': |
| | | return $square->processSyncToSquare($data); |
| | | $base = strtolower($this->service_name) . '_'; |
| | | $square = array_key_exists('user', $data) ? new self((int) $data['user']) : $this; |
| | | |
| | | case $base.'delete_from_square': |
| | | return $square->processDeleteFromSquare($data); |
| | | |
| | | case $base.'sync_from_square': |
| | | return $square->processSyncFromSquare($data); |
| | | |
| | | case $base.'sync_customer': |
| | | return $square->processSyncCustomer($data); |
| | | |
| | | default: |
| | | return $result; |
| | | } |
| | | return match ($operation->type) { |
| | | $base . 'sync_to_square' => $square->processSyncToSquare($data), |
| | | $base . 'delete_from_square' => $square->processDeleteFromSquare($data), |
| | | $base . 'sync_from_square' => $square->processSyncFromSquare($data), |
| | | $base . 'sync_customer' => $square->processSyncCustomer($data), |
| | | default => $result, |
| | | }; |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Process sync to Square |
| | | */ |
| | |
| | | /** |
| | | * Enqueue checkout scripts with Square configuration |
| | | */ |
| | | public function enqueueScripts():void |
| | | public function enqueueScripts(): void |
| | | { |
| | | $this->loadCredentials(); |
| | | $sdk_url = $this->environment === 'production' |
| | |
| | | $sdk_url, |
| | | [], |
| | | null, |
| | | [ |
| | | 'strategy' => 'defer', |
| | | 'in_footer' => true |
| | | ] |
| | | ['strategy' => 'defer', 'in_footer' => true] |
| | | ); |
| | | |
| | | // Register your custom checkout script |
| | | // Shared cart checkout base class |
| | | wp_register_script( |
| | | 'jvb-checkout', |
| | | JVB_URL . 'assets/js/min/checkout.min.js', |
| | | ['jvb-utility', 'jvb-queue', 'jvb-a11y', 'jvb-cache', 'jvb-tabs', 'jvb-popup'], |
| | | '1.1.31', |
| | | ['strategy' => 'defer', 'in_footer' => true] |
| | | ); |
| | | |
| | | // Square checkout extends CartCheckout |
| | | wp_register_script( |
| | | 'jvb-square-checkout', |
| | | JVB_URL . 'assets/js/min/square.min.js', |
| | | [ |
| | | // 'square-payments-sdk', |
| | | 'jvb-utility', |
| | | 'jvb-queue', |
| | | 'jvb-a11y', |
| | | 'jvb-cache', |
| | | 'jvb-tabs', |
| | | 'jvb-popup' |
| | | ], |
| | | '1.0.0', |
| | | [ |
| | | 'strategy' => 'defer', |
| | | 'in_footer' => true |
| | | ] |
| | | ['jvb-checkout', 'square-payments-sdk'], |
| | | '1.1.31', |
| | | ['strategy' => 'defer', 'in_footer' => true] |
| | | ); |
| | | |
| | | wp_enqueue_script('jvb-square-checkout'); |
| | | |
| | | // Localize the checkout script with Square config |
| | | wp_localize_script( |
| | | 'jvb-square-checkout', |
| | | 'squareConfig', |
| | | [ |
| | | 'isOpen' => jvbIsOpen(), |
| | | 'application_id' => $this->credentials['client_id'] ?? '', |
| | | 'location_id' => $this->locationId, |
| | | 'environment' => $this->environment, |
| | | 'api_url' => rest_url('jvb/v1/square/'), |
| | | 'nonce' => wp_create_nonce('wp_rest'), |
| | | 'currency' => get_option(BASE . 'currency', 'CAD'), |
| | | 'is_logged_in' => is_user_logged_in(), |
| | | 'user_email' => is_user_logged_in() ? wp_get_current_user()->user_email : '' // NEW |
| | | ] |
| | | ); |
| | | wp_localize_script('jvb-square-checkout', 'squareConfig', [ |
| | | 'isOpen' => jvbIsOpen(), |
| | | 'application_id' => $this->credentials['client_id'] ?? '', |
| | | 'location_id' => $this->locationId, |
| | | 'environment' => $this->environment, |
| | | 'api_url' => rest_url('jvb/v1/square/'), |
| | | 'nonce' => wp_create_nonce('wp_rest'), |
| | | 'currency' => get_option(BASE . 'currency', 'CAD'), |
| | | 'is_logged_in' => is_user_logged_in(), |
| | | 'user_email' => is_user_logged_in() ? wp_get_current_user()->user_email : '', |
| | | ]); |
| | | } |
| | | |
| | | /****************************************************************** |
| | |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Single-item sync. Called by IntegrationExecutor::processSyncTo(). |
| | | * Delegates to syncBatchToService since Square uses batch-upsert. |
| | | */ |
| | | public function syncPostToService(int $postID): array|WP_Error |
| | | { |
| | | return $this->syncBatchToService(['items' => [$postID]]); |
| | | } |
| | | |
| | | /** |
| | | * Batch sync — preferred by IntegrationExecutor when available. |
| | | * Wraps existing processSyncToSquare which already handles batches. |
| | | */ |
| | | public function syncBatchToService(array $data): array|WP_Error |
| | | { |
| | | $result = $this->processSyncToSquare($data); |
| | | |
| | | if (empty($result['success'])) { |
| | | $errors = implode(', ', $result['result']['errors'] ?? ['Sync failed']); |
| | | return new WP_Error('square_sync_failed', $errors); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * Delete catalog object from Square. |
| | | * Called by IntegrationExecutor::processDeleteFrom(). |
| | | */ |
| | | public function deleteFromService(string $externalId): array|WP_Error |
| | | { |
| | | $result = $this->processDeleteFromSquare(['square_ids' => [$externalId]]); |
| | | |
| | | if (empty($result['success'])) { |
| | | return new WP_Error('square_delete_failed', $result['result']['error'] ?? 'Delete failed'); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * Import from Square catalog → WordPress. |
| | | * Called by IntegrationExecutor::processImport(). |
| | | */ |
| | | public function importFromService(array $data): array|WP_Error |
| | | { |
| | | $result = $this->processSyncFromSquare($data); |
| | | |
| | | if (empty($result['success'])) { |
| | | return new WP_Error('square_import_failed', $result['result']['error'] ?? 'Import failed'); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * Sync customer to Square. |
| | | * Called by IntegrationExecutor::processSyncCustomer(). |
| | | */ |
| | | public function syncCustomer(array $data): array|WP_Error |
| | | { |
| | | $result = $this->processSyncCustomer($data); |
| | | |
| | | if (empty($result['success'])) { |
| | | return new WP_Error('square_customer_sync_failed', $result['result']['error'] ?? 'Customer sync failed'); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | } |
| | |
| | | namespace JVBase\managers; |
| | | |
| | | use JVBase\utility\Features; |
| | | use JVBase\rest\Route; |
| | | use JVBase\rest\PermissionHandler; |
| | | use WP_REST_Response; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | |
| | | |
| | | add_filter(BASE.'admin_action_filter', [$this, 'handleCacheActions'], 10, 3); |
| | | |
| | | add_action('rest_api_init', [$this, 'registerRestRoutes']); |
| | | // Handle form submissions |
| | | add_action('admin_init', [$this, 'handleAdminPageSubmission']); |
| | | add_action('admin_notices', [$this, 'displayAdminNotices']); |
| | | } |
| | | |
| | | /** |
| | | * Register REST API routes for admin actions |
| | | */ |
| | | public function registerRestRoutes(): void |
| | | { |
| | | register_rest_route('jvb/v1', '/admin-cache', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleCacheAction'], |
| | | 'permission_callback' => [$this, 'checkAdminPermission'] |
| | | ]); |
| | | |
| | | register_rest_route('jvb/v1', '/admin-icons', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleIconAction'], |
| | | 'permission_callback' => [$this, 'checkAdminPermission'] |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Check if user has admin permissions |
| | | */ |
| | | public function checkAdminPermission(\WP_REST_Request $request): bool |
| | | { |
| | | if (!current_user_can('manage_options')) { |
| | | return false; |
| | | } |
| | | |
| | | // Verify nonce |
| | | $nonce = $request->get_header('X-WP-Nonce'); |
| | | if (!wp_verify_nonce($nonce, 'wp_rest')) { |
| | | return false; |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Handle cache-related actions |
| | | */ |
| | | public function handleCacheAction(\WP_REST_Request $request): \WP_REST_Response |
| | | { |
| | | $action = sanitize_text_field($request->get_param('action')); |
| | | |
| | | switch ($action) { |
| | | case 'flush-all': |
| | | $total = Cache::flushAll(); |
| | | return new \WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => $total.' caches flushed successfully' |
| | | ]); |
| | | |
| | | case 'flush-cache': |
| | | $group = sanitize_text_field($request->get_param('group')); |
| | | if (empty($group)) { |
| | | return new \WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'No cache group specified' |
| | | ], 400); |
| | | } |
| | | |
| | | Cache::for($group)?->flush(); |
| | | |
| | | return new \WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => "Cache group '{$group}' flushed successfully" |
| | | ]); |
| | | |
| | | default: |
| | | return new \WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Invalid action' |
| | | ], 400); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle icon-related actions |
| | | */ |
| | | public function handleIconAction(\WP_REST_Request $request): \WP_REST_Response |
| | | { |
| | | $action = sanitize_text_field($request->get_param('action')); |
| | | $source = sanitize_text_field($request->get_param('source') ?? 'icons'); |
| | | $icons = IconsManager::for($source); |
| | | |
| | | switch ($action) { |
| | | case 'refresh-icons': |
| | | // Force regenerate CSS immediately |
| | | $icons->forceRefresh(); |
| | | IconsManager::regenerateAllCSS([$source => true]); |
| | | |
| | | return new \WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => "Icon CSS regenerated successfully for '{$source}'" |
| | | ]); |
| | | |
| | | case 'refresh-all-icons': |
| | | // Regenerate all icon sources |
| | | foreach (['icons', 'forms', 'dash'] as $src) { |
| | | IconsManager::for($src)->forceRefresh(); |
| | | } |
| | | IconsManager::regenerateAllCSS(); |
| | | |
| | | return new \WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => 'All icon CSS files regenerated successfully' |
| | | ]); |
| | | |
| | | case 'restore-icon-version': |
| | | $timestamp = (int)$request->get_param('timestamp'); |
| | | if (empty($timestamp)) { |
| | | return new \WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'No timestamp provided' |
| | | ], 400); |
| | | } |
| | | |
| | | if ($icons->restoreVersion($timestamp)) { |
| | | return new \WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => 'Icon version restored successfully' |
| | | ]); |
| | | } |
| | | |
| | | return new \WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Failed to restore icon version' |
| | | ], 500); |
| | | |
| | | case 'merge-icon-versions': |
| | | $timestamps = $request->get_param('timestamps'); |
| | | |
| | | if (empty($timestamps) || !is_array($timestamps)) { |
| | | return new \WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'No versions selected for merging' |
| | | ], 400); |
| | | } |
| | | |
| | | $timestamps = array_map('intval', $timestamps); |
| | | |
| | | if (count($timestamps) < 2) { |
| | | return new \WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Please select at least 2 versions to merge' |
| | | ], 400); |
| | | } |
| | | |
| | | if ($icons->mergeVersions($timestamps)) { |
| | | // Regenerate CSS after merge |
| | | IconsManager::regenerateAllCSS([$source => true]); |
| | | |
| | | return new \WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => 'Icon versions merged successfully' |
| | | ]); |
| | | } |
| | | |
| | | return new \WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Failed to merge icon versions' |
| | | ], 500); |
| | | |
| | | default: |
| | | return new \WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Invalid action' |
| | | ], 400); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Register a subpage to appear under the main settings page |
| | | * |
| | |
| | | * |
| | | * @param string $hook Current admin page |
| | | */ |
| | | public function enqueueAdminAssets(string $hook):void |
| | | { |
| | | // Check if we're on an Edmonton Ink admin page |
| | | if (strpos($hook, BASE) === false) { |
| | | return; |
| | | } |
| | | public function enqueueAdminAssets(string $hook):void |
| | | { |
| | | // More robust check for JVB admin pages |
| | | if (strpos($hook, BASE) === false) { |
| | | return; |
| | | } |
| | | |
| | | // Enqueue admin styles |
| | | wp_enqueue_style( |
| | | 'jvb-admin-styles', |
| | | JVB_URL . 'assets/css/admin.css', |
| | | [], |
| | | '1.0.0' |
| | | ); |
| | | // Enqueue admin styles |
| | | wp_enqueue_style( |
| | | 'jvb-admin-styles', |
| | | JVB_URL . 'assets/css/admin.css', |
| | | [], |
| | | '1.1' |
| | | ); |
| | | |
| | | // Enqueue admin scripts |
| | | wp_enqueue_script( |
| | | 'jvb-admin-scripts', |
| | | JVB_URL . 'assets/js/admin.js', |
| | | [], |
| | | '1.0.0', |
| | | true |
| | | ); |
| | | // Enqueue admin scripts - make sure jvb-auth is loaded first |
| | | wp_enqueue_script( |
| | | 'jvb-admin-scripts', |
| | | JVB_URL . 'assets/js/admin.js', |
| | | ['jvb-auth'], |
| | | '1.1', |
| | | ['strategy' => 'defer', 'in_footer' => true] |
| | | ); |
| | | |
| | | wp_localize_script( |
| | | 'jvb-admin-scripts', |
| | | 'jvbSettings', |
| | | [ |
| | | 'api' => rest_url('jvb/v1/admin-action'), |
| | | 'nonce' => wp_create_nonce('wp_rest'), |
| | | 'action' => wp_create_nonce('itsme'), |
| | | ] |
| | | ); |
| | | } |
| | | // Localize to jvb-admin-scripts as well for redundancy |
| | | wp_localize_script( |
| | | 'jvb-auth', |
| | | 'jvbSettings', |
| | | [ |
| | | 'api' => rest_url('jvb/v1/'), |
| | | 'nonce' => wp_create_nonce('wp_rest'), |
| | | 'action' => wp_create_nonce('itsme'), |
| | | ] |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Create a custom SVG icon for the admin menu |
| | |
| | | { |
| | | $groups = Cache::getAllGroups(); |
| | | |
| | | // Separate generic vs. specific caches |
| | | $generic_groups = []; |
| | | $content_specific = []; |
| | | $nonce = wp_create_nonce('wp_rest'); |
| | | |
| | | // Separate by type |
| | | $generic = []; |
| | | $specific = []; |
| | |
| | | <h1>Cache Management</h1> |
| | | |
| | | <div class="jvb-cache-actions"> |
| | | <button type="button" class="button button-primary" data-action="flush-all"> |
| | | <button type="button" |
| | | class="button button-primary" |
| | | data-cache-action="flush-all"> |
| | | <?= jvbDashIcon('arrows-clockwise'); ?> |
| | | Flush All Caches |
| | | </button> |
| | |
| | | </tr> |
| | | </thead> |
| | | <tbody> |
| | | <?php if (empty($generic_groups)): ?> |
| | | <?php if (empty($generic)): ?> |
| | | <tr><td colspan="3">No generic caches registered</td></tr> |
| | | <?php else: ?> |
| | | <?php foreach ($generic_groups as $group => $configs): ?> |
| | | <?php foreach ($generic as $group => $data): ?> |
| | | <tr> |
| | | <td><strong><?= esc_html($group); ?></strong></td> |
| | | <td><?= $this->formatConnections($configs); ?></td> |
| | | <td><?= $this->formatConnections($data); ?></td> |
| | | <td> |
| | | <button type="button" class="button" data-action="flush-cache" data-group="<?= esc_attr($group); ?>"> |
| | | <button type="button" |
| | | class="button" |
| | | data-cache-action="flush-cache" |
| | | data-group="<?= esc_attr($group); ?>"> |
| | | <?= jvbDashIcon('trash'); ?> Flush |
| | | </button> |
| | | </td> |
| | |
| | | </tr> |
| | | </thead> |
| | | <tbody> |
| | | <?php foreach ($specific as $group => $configs): ?> |
| | | <tr> |
| | | <td><strong><?= esc_html($group); ?></strong></td> |
| | | <td><?= $this->formatConnections($configs); ?></td> |
| | | <td> |
| | | <button type="button" class="button" data-action="flush-cache" data-group="<?= esc_attr($group); ?>"> |
| | | <?= jvbDashIcon('trash'); ?> Flush |
| | | </button> |
| | | </td> |
| | | </tr> |
| | | <?php endforeach; ?> |
| | | <?php foreach ($generic as $group => $data): ?> |
| | | <?php foreach ($specific as $group => $data): ?> |
| | | <tr> |
| | | <td><strong><?= esc_html($group); ?></strong></td> |
| | | <td><?= $this->formatConnections($data); ?></td> |
| | | <td> |
| | | <button type="button" class="button" |
| | | data-action="flush-cache" |
| | | <button type="button" |
| | | class="button" |
| | | data-cache-action="flush-cache" |
| | | data-group="<?= esc_attr($group); ?>"> |
| | | <?= jvbDashIcon('trash'); ?> Flush |
| | | </button> |
| | |
| | | </table> |
| | | </details> |
| | | </div> |
| | | <script> |
| | | (function() { |
| | | const apiUrl = '<?= esc_js(rest_url('jvb/v1/admin-cache')); ?>'; |
| | | const nonce = '<?= esc_js($nonce); ?>'; |
| | | |
| | | function callCacheAction(action, data = {}) { |
| | | const body = { action, ...data }; |
| | | |
| | | return fetch(apiUrl, { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/json', |
| | | 'X-WP-Nonce': nonce |
| | | }, |
| | | body: JSON.stringify(body) |
| | | }) |
| | | .then(response => response.json()) |
| | | .then(data => { |
| | | if (data.success) { |
| | | alert(data.message || 'Success!'); |
| | | location.reload(); |
| | | } else { |
| | | alert('Error: ' + (data.message || 'Unknown error')); |
| | | } |
| | | }) |
| | | .catch(error => { |
| | | alert('Network error: ' + error.message); |
| | | console.error('Error:', error); |
| | | }); |
| | | } |
| | | |
| | | // Flush all caches |
| | | document.querySelector('[data-action="flush-all"]')?.addEventListener('click', function() { |
| | | if (confirm('Flush all caches? This may temporarily slow down your site.')) { |
| | | this.disabled = true; |
| | | callCacheAction('flush-all'); |
| | | } |
| | | }); |
| | | |
| | | // Flush individual cache groups |
| | | document.querySelectorAll('[data-action="flush-cache"]').forEach(btn => { |
| | | btn.addEventListener('click', function() { |
| | | const group = this.getAttribute('data-group'); |
| | | if (confirm(`Flush cache group "${group}"?`)) { |
| | | this.disabled = true; |
| | | callCacheAction('flush-cache', { group: group }); |
| | | } |
| | | }); |
| | | }); |
| | | })(); |
| | | </script> |
| | | <?php |
| | | } |
| | | |
| | |
| | | $current_source = sanitize_text_field($current_source); |
| | | |
| | | // Get all registered icon sources |
| | | $all_sources = ['icons', 'forms', 'dash']; // You could get this dynamically if needed |
| | | $all_sources = ['icons', 'forms', 'dash']; |
| | | |
| | | $icons = IconsManager::for($current_source); |
| | | $versions = $icons->getVersionHistory(); |
| | | $nonce = wp_create_nonce('wp_rest'); |
| | | |
| | | ?> |
| | | <div class="wrap jvb-admin-wrap"> |
| | |
| | | <!-- Source Selector --> |
| | | <div class="jvb-icon-source-selector"> |
| | | <label for="icon-source-select">Icon Source:</label> |
| | | <select id="icon-source-select" onchange="window.location.href='<?= admin_url('admin.php?page=' . BASE . 'icons&icon_source='); ?>' + this.value"> |
| | | <select id="icon-source-select" |
| | | onchange="window.location.href='<?= admin_url('admin.php?page=' . BASE . 'icons&icon_source='); ?>' + this.value"> |
| | | <?php foreach ($all_sources as $source): ?> |
| | | <option value="<?= esc_attr($source); ?>" <?= selected($current_source, $source, false); ?>> |
| | | <option value="<?= esc_attr($source); ?>" |
| | | <?= selected($current_source, $source, false); ?>> |
| | | <?= esc_html(ucfirst($source)); ?> |
| | | </option> |
| | | <?php endforeach; ?> |
| | |
| | | </div> |
| | | |
| | | <div class="jvb-icon-actions"> |
| | | <button type="button" class="button button-primary" data-action="refresh-icons" data-source="<?= esc_attr($current_source); ?>"> |
| | | <button type="button" |
| | | class="button button-primary" |
| | | data-icon-action="refresh-icons" |
| | | data-source="<?= esc_attr($current_source); ?>"> |
| | | <?= jvbDashIcon('arrows-clockwise'); ?> |
| | | Force Refresh CSS |
| | | </button> |
| | | <button type="button" class="button" data-action="merge-icon-versions" data-source="<?= esc_attr($current_source); ?>" id="merge-versions-btn" disabled> |
| | | <button type="button" |
| | | class="button" |
| | | data-icon-action="merge-icon-versions" |
| | | data-source="<?= esc_attr($current_source); ?>" |
| | | id="merge-versions-btn" |
| | | disabled> |
| | | <?= jvbDashIcon('git-merge'); ?> |
| | | Merge Selected Versions |
| | | </button> |
| | |
| | | </td> |
| | | <td><?= esc_html($version['size_formatted']); ?></td> |
| | | <td> |
| | | <button type="button" class="button restore-version-btn" |
| | | data-action="restore-icon-version" |
| | | <button type="button" |
| | | class="button restore-version-btn" |
| | | data-icon-action="restore-icon-version" |
| | | data-source="<?= esc_attr($current_source); ?>" |
| | | data-timestamp="<?= esc_attr($version['timestamp']); ?>"> |
| | | <?= jvbDashIcon('arrow-counter-clockwise'); ?> Restore |
| | | </button> |
| | | </td> |
| | | </tr> |
| | | <tr id="icon-list-<?= esc_attr($version['timestamp']); ?>" class="icon-list-row" style="display: none;"> |
| | | <tr id="icon-list-<?= esc_attr($version['timestamp']); ?>" |
| | | class="icon-list-row" |
| | | style="display: none;"> |
| | | <td colspan="5"> |
| | | <div class="icon-list-content"> |
| | | <?php foreach ($version['iconList'] as $style => $icons): ?> |
| | |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | |
| | | <script> |
| | | (function() { |
| | | const apiUrl = '<?= esc_js(rest_url('jvb/v1/admin-icons')); ?>'; |
| | | const nonce = '<?= esc_js($nonce); ?>'; |
| | | const currentSource = '<?= esc_js($current_source); ?>'; |
| | | |
| | | // Helper function for API calls |
| | | function callIconAction(action, data = {}) { |
| | | const body = { action, source: currentSource, ...data }; |
| | | |
| | | return fetch(apiUrl, { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/json', |
| | | 'X-WP-Nonce': nonce |
| | | }, |
| | | body: JSON.stringify(body) |
| | | }) |
| | | .then(response => response.json()) |
| | | .then(data => { |
| | | if (data.success) { |
| | | alert(data.message || 'Success!'); |
| | | location.reload(); |
| | | } else { |
| | | alert('Error: ' + (data.message || 'Unknown error')); |
| | | } |
| | | return data; |
| | | }) |
| | | .catch(error => { |
| | | alert('Network error: ' + error.message); |
| | | console.error('Error:', error); |
| | | }); |
| | | } |
| | | |
| | | // Enable/disable merge button based on selection |
| | | document.querySelectorAll('.version-checkbox').forEach(checkbox => { |
| | | checkbox.addEventListener('change', function() { |
| | | const checkedCount = document.querySelectorAll('.version-checkbox:checked').length; |
| | | document.getElementById('merge-versions-btn').disabled = checkedCount < 2; |
| | | }); |
| | | }); |
| | | |
| | | // Select all functionality |
| | | const selectAll = document.getElementById('select-all-versions'); |
| | | if (selectAll) { |
| | | selectAll.addEventListener('change', function() { |
| | | document.querySelectorAll('.version-checkbox').forEach(checkbox => { |
| | | checkbox.checked = this.checked; |
| | | checkbox.dispatchEvent(new Event('change')); |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | // Toggle icon list view |
| | | document.querySelectorAll('.view-icon-list-btn').forEach(btn => { |
| | | btn.addEventListener('click', function() { |
| | | const timestamp = this.getAttribute('data-timestamp'); |
| | | const row = document.getElementById('icon-list-' + timestamp); |
| | | if (row) { |
| | | row.style.display = row.style.display === 'none' ? '' : 'none'; |
| | | } |
| | | }); |
| | | }); |
| | | |
| | | // Force refresh button |
| | | document.querySelector('[data-action="refresh-icons"]')?.addEventListener('click', function() { |
| | | if (confirm('Force regenerate icon CSS? This will reload the page.')) { |
| | | this.disabled = true; |
| | | callIconAction('refresh-icons'); |
| | | } |
| | | }); |
| | | |
| | | // Merge versions button |
| | | document.getElementById('merge-versions-btn')?.addEventListener('click', function() { |
| | | const checkboxes = document.querySelectorAll('.version-checkbox:checked'); |
| | | const timestamps = Array.from(checkboxes).map(cb => parseInt(cb.value)); |
| | | |
| | | if (timestamps.length < 2) { |
| | | alert('Please select at least 2 versions to merge'); |
| | | return; |
| | | } |
| | | |
| | | if (confirm(`Merge ${timestamps.length} versions? This will create a new CSS file with all unique icons.`)) { |
| | | this.disabled = true; |
| | | callIconAction('merge-icon-versions', { timestamps: timestamps }); |
| | | } |
| | | }); |
| | | |
| | | // Restore version buttons |
| | | document.querySelectorAll('.restore-version-btn').forEach(btn => { |
| | | btn.addEventListener('click', function() { |
| | | const timestamp = parseInt(this.getAttribute('data-timestamp')); |
| | | |
| | | if (confirm('Restore this icon version? This will reload the page.')) { |
| | | this.disabled = true; |
| | | callIconAction('restore-icon-version', { timestamp: timestamp }); |
| | | } |
| | | }); |
| | | }); |
| | | })(); |
| | | </script> |
| | | <?php |
| | | } |
| | | |
| | |
| | | add_action('init', 'jvbRegisterScripts', 5); |
| | | |
| | | function jvbRegisterScripts() { |
| | | $version = '1.1.31'; |
| | | $version = '1.1.33'; |
| | | $strategy = [ |
| | | 'strategy' => 'defer', |
| | | 'in_footer' => true |
| | |
| | | |
| | | Cache::registerHooks(); |
| | | // Initialize base sources (this registers hooks and includes defaults) |
| | | IconsManager::for('icons'); |
| | | IconsManager::for(); |
| | | IconsManager::for('forms'); |
| | | |
| | | // Only initialize dash if feature is enabled |
| | |
| | | return false; |
| | | } |
| | | |
| | | Cache::invalidateGroup('queue'); |
| | | // $this->invalidateUser($op->userId); |
| | | |
| | | $this->invalidateUser($op->userId); |
| | | |
| | | return true; |
| | | } |
| | |
| | | error_log('[Storage::saveFinal] DB error: ' . $wpdb->last_error); |
| | | return false; |
| | | } |
| | | Cache::invalidateGroup('queue'); |
| | | // $this->invalidateQueueCache(); |
| | | // $this->invalidateUser($op->userId); |
| | | $this->invalidateQueueCache(); |
| | | $this->invalidateUser($op->userId); |
| | | |
| | | return true; |
| | | } |
| | |
| | | ]); |
| | | |
| | | if ($result) { |
| | | // $this->invalidateUser($op->userId); |
| | | Cache::invalidateGroup('queue'); |
| | | $this->invalidateUser($op->userId); |
| | | } |
| | | |
| | | return $result !== false; |
| | |
| | | |
| | | private function invalidateUser(int $userId): void |
| | | { |
| | | $this->cache->forget($userId); |
| | | Cache::touch($userId); |
| | | Cache::for($userId.'_queue')->flush(); |
| | | } |
| | | public function getLastError(): string |
| | | { |
| | |
| | | require_once JVB_DIR . '/inc/managers/queue/executors/ContentExecutor.php'; |
| | | require_once JVB_DIR . '/inc/managers/queue/executors/ContentTermExecutor.php'; |
| | | require_once JVB_DIR . '/inc/managers/queue/executors/InvitationExecutor.php'; |
| | | require_once JVB_DIR . '/inc/managers/queue/executors/IntegrationExecutor.php'; |
| | | |
| | | // Facade |
| | | require_once JVB_DIR . '/inc/managers/queue/Queue.php'; |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\managers\queue\executors; |
| | | |
| | | use JVBase\managers\queue\{Executor, Operation, Progress, Result}; |
| | | use Exception; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Executor for integration-related queue operations. |
| | | * |
| | | * Routes operations to the correct integration instance based on the |
| | | * operation type prefix (e.g. 'helcim_sync_product' → Helcim class). |
| | | * |
| | | * Handles: {integration}_sync_to, {integration}_delete_from, |
| | | * {integration}_sync_from, {integration}_sync_customer, etc. |
| | | * |
| | | * Registration (in Integrations::registerAdditionalHooks): |
| | | * $queue = JVB()->queue(); |
| | | * $queue->registry()->register('helcim_sync_to', new TypeConfig( |
| | | * executor: new IntegrationExecutor(), |
| | | * chunkKey: 'items', |
| | | * chunkSize: 10, |
| | | * maxRetries: 3 |
| | | * )); |
| | | */ |
| | | final class IntegrationExecutor implements Executor |
| | | { |
| | | /** |
| | | * Map of integration service names to class identifiers. |
| | | * Populated on first use from JVB()->connect(). |
| | | */ |
| | | private array $integrationCache = []; |
| | | |
| | | public function execute(Operation $operation, Progress $progress): Result |
| | | { |
| | | try { |
| | | [$serviceName, $action] = $this->parseOperationType($operation->type); |
| | | |
| | | $integration = $this->resolveIntegration($serviceName, $operation->userId); |
| | | |
| | | if (!$integration || !$integration->isSetUp()) { |
| | | return new Result( |
| | | outcome: 'failed', |
| | | result: ['error' => "Integration '{$serviceName}' not available or not configured"] |
| | | ); |
| | | } |
| | | |
| | | $data = $operation->requestData; |
| | | |
| | | return match ($action) { |
| | | 'sync_to' => $this->processSyncTo($integration, $data, $progress), |
| | | 'sync_from' => $this->processSyncFrom($integration, $data, $progress), |
| | | 'delete_from' => $this->processDeleteFrom($integration, $data, $progress), |
| | | 'sync_customer' => $this->processSyncCustomer($integration, $data, $progress), |
| | | 'import' => $this->processImport($integration, $data, $progress), |
| | | default => $this->processDynamic($integration, $action, $data, $progress), |
| | | }; |
| | | |
| | | } catch (Exception $e) { |
| | | JVB()->error()->log( |
| | | '[IntegrationExecutor]:execute', |
| | | $e->getMessage(), |
| | | [ |
| | | 'operation_id' => $operation->id, |
| | | 'operation_type' => $operation->type, |
| | | 'user_id' => $operation->userId, |
| | | ] |
| | | ); |
| | | |
| | | return new Result( |
| | | outcome: 'failed', |
| | | result: ['error' => $e->getMessage()] |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * TYPE PARSING |
| | | *****************************************************************/ |
| | | |
| | | /** |
| | | * Parse 'helcim_sync_to' → ['helcim', 'sync_to'] |
| | | */ |
| | | private function parseOperationType(string $type): array |
| | | { |
| | | // Remove BASE prefix if present (e.g. 'jvb_helcim_sync_to' → 'helcim_sync_to') |
| | | $type = str_replace(BASE, '', $type); |
| | | |
| | | $pos = strpos($type, '_'); |
| | | if ($pos === false) { |
| | | throw new Exception("Invalid integration operation type: {$type}"); |
| | | } |
| | | |
| | | $serviceName = substr($type, 0, $pos); |
| | | $action = substr($type, $pos + 1); |
| | | |
| | | return [$serviceName, $action]; |
| | | } |
| | | |
| | | /** |
| | | * Resolve integration instance, optionally for a specific user |
| | | */ |
| | | private function resolveIntegration(string $serviceName, int $userId): ?object |
| | | { |
| | | if (!isset($this->integrationCache[$serviceName])) { |
| | | $this->integrationCache[$serviceName] = JVB()->connect($serviceName); |
| | | } |
| | | |
| | | $integration = $this->integrationCache[$serviceName]; |
| | | |
| | | // If operation has a user context, re-instantiate for that user |
| | | if ($integration && $userId) { |
| | | $class = get_class($integration); |
| | | return new $class($userId); |
| | | } |
| | | |
| | | return $integration; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * OPERATION HANDLERS |
| | | *****************************************************************/ |
| | | |
| | | /** |
| | | * Sync WordPress posts → external service |
| | | */ |
| | | private function processSyncTo(object $integration, array $data, Progress $progress): Result |
| | | { |
| | | $items = $data['items'] ?? []; |
| | | $success = []; |
| | | $errors = []; |
| | | |
| | | if (empty($items)) { |
| | | return new Result(outcome: 'success', result: ['synced' => [], 'message' => 'No items to sync']); |
| | | } |
| | | |
| | | foreach ($items as $postID) { |
| | | try { |
| | | $result = $integration->syncPostToService((int)$postID); |
| | | if (is_wp_error($result)) { |
| | | $errors[$postID] = $result->get_error_message(); |
| | | $progress->failItem($postID, $result->get_error_message()); |
| | | } else { |
| | | $success[] = $postID; |
| | | $progress->advance(); |
| | | } |
| | | } catch (Exception $e) { |
| | | $errors[$postID] = $e->getMessage(); |
| | | $progress->failItem($postID, $e->getMessage()); |
| | | } |
| | | } |
| | | |
| | | $outcome = empty($errors) ? 'success' : (empty($success) ? 'failed' : 'partial'); |
| | | |
| | | return new Result( |
| | | outcome: $outcome, |
| | | result: [ |
| | | 'synced' => $success, |
| | | 'errors' => $errors, |
| | | 'synced_count' => count($success), |
| | | 'failed_count' => count($errors), |
| | | ] |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Sync external service → WordPress posts |
| | | */ |
| | | private function processSyncFrom(object $integration, array $data, Progress $progress): Result |
| | | { |
| | | $items = $data['items'] ?? $data['external_ids'] ?? []; |
| | | $success = []; |
| | | $errors = []; |
| | | |
| | | foreach ($items as $externalId) { |
| | | try { |
| | | $result = $integration->syncFromService($externalId); |
| | | if (is_wp_error($result)) { |
| | | $errors[$externalId] = $result->get_error_message(); |
| | | $progress->failItem($externalId, $result->get_error_message()); |
| | | } else { |
| | | $success[] = $externalId; |
| | | $progress->advance(); |
| | | } |
| | | } catch (Exception $e) { |
| | | $errors[$externalId] = $e->getMessage(); |
| | | $progress->failItem($externalId, $e->getMessage()); |
| | | } |
| | | } |
| | | |
| | | $outcome = empty($errors) ? 'success' : (empty($success) ? 'failed' : 'partial'); |
| | | |
| | | return new Result( |
| | | outcome: $outcome, |
| | | result: ['imported' => $success, 'errors' => $errors] |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Delete items from external service |
| | | */ |
| | | private function processDeleteFrom(object $integration, array $data, Progress $progress): Result |
| | | { |
| | | $externalIds = $data['external_ids'] ?? []; |
| | | $success = []; |
| | | $errors = []; |
| | | |
| | | foreach ($externalIds as $externalId) { |
| | | try { |
| | | $result = $integration->deleteFromService($externalId); |
| | | if (is_wp_error($result)) { |
| | | $errors[$externalId] = $result->get_error_message(); |
| | | } else { |
| | | $success[] = $externalId; |
| | | } |
| | | $progress->advance(); |
| | | } catch (Exception $e) { |
| | | $errors[$externalId] = $e->getMessage(); |
| | | $progress->failItem($externalId, $e->getMessage()); |
| | | } |
| | | } |
| | | |
| | | $outcome = empty($errors) ? 'success' : (empty($success) ? 'failed' : 'partial'); |
| | | return new Result(outcome: $outcome, result: ['deleted' => $success, 'errors' => $errors]); |
| | | } |
| | | |
| | | /** |
| | | * Sync a customer record |
| | | */ |
| | | private function processSyncCustomer(object $integration, array $data, Progress $progress): Result |
| | | { |
| | | try { |
| | | $result = $integration->syncCustomer($data); |
| | | $progress->advance(); |
| | | |
| | | if (is_wp_error($result)) { |
| | | return new Result(outcome: 'failed', result: ['error' => $result->get_error_message()]); |
| | | } |
| | | return new Result(outcome: 'success', result: $result); |
| | | } catch (Exception $e) { |
| | | return new Result(outcome: 'failed', result: ['error' => $e->getMessage()]); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Bulk import from external service |
| | | */ |
| | | private function processImport(object $integration, array $data, Progress $progress): Result |
| | | { |
| | | try { |
| | | $result = $integration->importFromService($data); |
| | | $progress->advance($data['count'] ?? 1); |
| | | |
| | | if (is_wp_error($result)) { |
| | | return new Result(outcome: 'failed', result: ['error' => $result->get_error_message()]); |
| | | } |
| | | return new Result(outcome: 'success', result: $result); |
| | | } catch (Exception $e) { |
| | | return new Result(outcome: 'failed', result: ['error' => $e->getMessage()]); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Fallback: call processAction on the integration for non-standard actions |
| | | */ |
| | | private function processDynamic(object $integration, string $action, array $data, Progress $progress): Result |
| | | { |
| | | try { |
| | | $result = $integration->processAction($action, $data); |
| | | $progress->advance(); |
| | | |
| | | if (is_wp_error($result)) { |
| | | return new Result(outcome: 'failed', result: ['error' => $result->get_error_message()]); |
| | | } |
| | | |
| | | return new Result( |
| | | outcome: 'success', |
| | | result: is_array($result) ? $result : ['result' => $result] |
| | | ); |
| | | } catch (Exception $e) { |
| | | return new Result(outcome: 'failed', result: ['error' => $e->getMessage()]); |
| | | } |
| | | } |
| | | } |
| | |
| | | '<div class="%s" data-field="%s" data-field-type="%s"%s>', |
| | | $classes, |
| | | $name, |
| | | $config['type'], |
| | | str_replace('_', '-', $config['type']), |
| | | $datasets |
| | | ); |
| | | |
| | |
| | | |
| | | protected static function renderText(string $name, mixed $value, array $config): string |
| | | { |
| | | $value = ($value === '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $value = ($value !== '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $input = sprintf( |
| | | '<input type="%s"%s%s />', |
| | | $config['subtype']??'text', |
| | |
| | | { |
| | | $attrs = static::inputAttrs($name, $config); |
| | | |
| | | $value = ($value === '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $value = ($value !== '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $input = sprintf( |
| | | '<input type="number"%s%s />', |
| | | $value, |
| | |
| | | |
| | | $attrs = static::inputAttrs($name, $config); |
| | | |
| | | $value = ($value === '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $value = ($value !== '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $input = sprintf( |
| | | '<div class="quantity"> |
| | | <button type="button" class="decrease" title="%s" aria-label="Decrease %s">%s</button> |
| | |
| | | protected static function renderEmail(string $name, mixed $value, array $config): string |
| | | { |
| | | $config['validate'] = 'email'; |
| | | $value = ($value === '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $value = ($value !== '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $input = sprintf( |
| | | '<input type="email"%s%s />', |
| | | $value, |
| | |
| | | protected static function renderUrl(string $name, mixed $value, array $config): string |
| | | { |
| | | $config['validate'] = 'url'; |
| | | $value = ($value === '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $value = ($value !== '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $input = sprintf( |
| | | '<input type="url"%s%s />', |
| | | $value, |
| | |
| | | protected static function renderTel(string $name, mixed $value, array $config): string |
| | | { |
| | | $config['validate'] = 'phone'; |
| | | $value = ($value === '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $value = ($value !== '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $input = sprintf( |
| | | '<input type="tel"%s%s />', |
| | | $value, |
| | |
| | | } |
| | | } |
| | | |
| | | $value = ($value === '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $value = ($value !== '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $input = sprintf( |
| | | '<input type="date"%s%s />', |
| | | $value, |
| | |
| | | |
| | | protected static function renderTime(string $name, mixed $value, array $config): string |
| | | { |
| | | $value = ($value === '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $value = ($value !== '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $input = sprintf( |
| | | '<input type="time"%s%s />', |
| | | $value, |
| | |
| | | |
| | | protected static function renderDatetime(string $name, mixed $value, array $config): string |
| | | { |
| | | $value = ($value === '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $value = ($value !== '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $input = sprintf( |
| | | '<input type="datetime-local"%s%s />', |
| | | $value, |
| | |
| | | $optionsHtml = ''; |
| | | if (empty($config['required'])) { |
| | | $optionsHtml .= '<option value="">— Select —</option>'; |
| | | } else { |
| | | $optionsHtml .= '<option value="" disabled selected hidden>— Select —</option>'; |
| | | } |
| | | |
| | | foreach ($options as $optValue => $optLabel) { |
| | |
| | | $optValue, |
| | | esc_attr($optValue), |
| | | $checked, |
| | | $name, |
| | | esc_attr($name), |
| | | $optValue, |
| | | esc_html($optLabel) |
| | | ); |
| | |
| | | foreach ($options as $optValue => $optLabel) { |
| | | $radios .= sprintf( |
| | | ' |
| | | <input type="radio" name="%s" value="%s"%s /> |
| | | <input type="radio" name="%s" id="%s-%s" value="%s"%s /> |
| | | <label class="radio-option" for="%s-%s"> |
| | | <span>%s</span> |
| | | </label>', |
| | | esc_attr($name), |
| | | esc_attr($name), |
| | | $optValue, |
| | | esc_attr($optValue), |
| | | checked($value, $optValue), |
| | | $name, |
| | | esc_attr($name), |
| | | $optValue, |
| | | esc_html($optLabel) |
| | | ); |
| | |
| | | $input .= sprintf( |
| | | '<button type="button" class="button add-tag">%s<span>%s</span></button></div>', |
| | | jvbIcon('plus'), |
| | | $field['add_label']??'Add' |
| | | $config['add_label']??'Add' |
| | | ); |
| | | |
| | | //Tag Display |
| | |
| | | } |
| | | protected static function renderTagItems(array $fields, mixed $value, string $name, string $tagFormat):string |
| | | { |
| | | if ($value === '') { |
| | | if (!$value || $value === '') { |
| | | return ''; |
| | | } |
| | | if (is_string($value)) { |
| | |
| | | |
| | | $out = sprintf( |
| | | '<div class="tag-item"%s><span class="tag-label">%s</span>', |
| | | ($index) ? ' data-index="'.$index.'"' : '', |
| | | ($index !== null) ? ' data-index="'.$index.'"' : '', |
| | | $tagText |
| | | ); |
| | | |
| | | foreach ($fields as $fieldName => $fieldConfig) { |
| | | $value = $values[$fieldName]??''; |
| | | $fullName = (!$index) ? $fieldName : sprintf('%s:%s:%s', $name, $index, $fieldName); |
| | | $fullName = ($index === null) ? $fieldName : sprintf('%s:%s:%s', $name, $index, $fieldName); |
| | | |
| | | $out .= sprintf( |
| | | '<input type="hidden" |
| | | name="%s" |
| | | value="%s" |
| | | data-field="%s" |
| | | data-field-type="%s" />', |
| | | name="%s" |
| | | value="%s" |
| | | data-field="%s" |
| | | data-field-type="%s" |
| | | id="%s" />', |
| | | esc_attr($fullName), |
| | | esc_attr($value), |
| | | esc_attr($fieldName), |
| | | esc_attr($fieldConfig['type']) |
| | | ); |
| | | |
| | | $out .= sprintf( |
| | | '<button type="button" class="remove-tag" aria-label="Remove">%s</button>', |
| | | jvbIcon('x') |
| | | esc_attr($fieldConfig['type']), |
| | | esc_attr($fullName) |
| | | ); |
| | | } |
| | | |
| | | $out .= sprintf( |
| | | '<button type="button" class="remove-tag" aria-label="Remove">%s</button>', |
| | | jvbIcon('x') |
| | | ); |
| | | $out .='</div>'; |
| | | return $out; |
| | | } |
| | | protected static function getTagDisplayText(array $fields, mixed $values, string $tagFormat):string |
| | | { |
| | | if (empty($data)) { |
| | | if (empty($values)) { |
| | | return 'New Item'; |
| | | } |
| | | |
| | |
| | | $firstKey = array_key_first($fields); |
| | | return $values[$firstKey] ?? 'New Item'; |
| | | case 'all_fields': |
| | | $values = array_filter(array_values($data)); |
| | | $values = array_filter(array_values($values)); |
| | | return implode(', ', $values) ?: 'New Item'; |
| | | default: |
| | | if (strpos($tagFormat, '{') !== false) { |
| | |
| | | use JVBase\registry\providers\CalendarFieldProvider; |
| | | use JVBase\registry\providers\CommonFieldProvider; |
| | | use JVBase\registry\providers\FieldProviderInterface; |
| | | use JVBase\registry\providers\HelcimFieldProvider; |
| | | use JVBase\utility\Features; |
| | | use JVBase\registry\providers\IntegrationFieldProvider; |
| | | |
| | | class FieldRegistry |
| | |
| | | $this->addFieldProvider('common', new CommonFieldProvider()); |
| | | $this->addFieldProvider('calendar', new CalendarFieldProvider()); |
| | | $this->addFieldProvider('integration', new IntegrationFieldProvider()); |
| | | // if (jvbSiteUsesHelcim()) { |
| | | // $this->addFieldProvider('helcim', new HelcimFieldProvider()); |
| | | // } |
| | | |
| | | |
| | | |
| | | // Allow extensions to add providers |
| | | do_action(BASE . 'register_field_providers', $this); |
| | |
| | | } |
| | | } |
| | | |
| | | if (Features::hasIntegration('helcim') && jvbCheck('use_helcim', $config)) { |
| | | $helcim = JVB()->connect('helcim'); |
| | | if ($helcim) { |
| | | $contentType = $config['integrations']['helcim']['content_type'] ?? $helcim->getDefaultContentType(); |
| | | $fields = array_merge($fields, $helcim->getHelcimMeta($contentType)); |
| | | } |
| | | } |
| | | |
| | | return $fields; |
| | | } |
| | | |
| | |
| | | 'email' => 'sanitize_email', |
| | | 'url' => 'esc_url_raw', |
| | | 'boolean', 'bool' => 'rest_sanitize_boolean', |
| | | 'array' => function($value) { |
| | | if (is_array($value)) { |
| | | return $value; |
| | | } |
| | | return []; |
| | | }, |
| | | 'object' => function($value) { |
| | | if (is_array($value) || is_object($value)) { |
| | | return (array) $value; |
| | | } |
| | | return []; |
| | | }, |
| | | default => null, |
| | | }; |
| | | |
| | | // Add validate callback for array/object types |
| | | if (in_array($type, ['array', 'object'])) { |
| | | $arg['validate_callback'] = function($value, $request, $param) { |
| | | // Allow empty arrays/objects |
| | | if (empty($value)) { |
| | | return true; |
| | | } |
| | | // Ensure it's an array or object |
| | | return is_array($value) || is_object($value); |
| | | }; |
| | | } |
| | | |
| | | // Parse modifiers |
| | | foreach (array_slice($parts, 1) as $part) { |
| | | $part = trim($part); |
| | |
| | | require(JVB_DIR . '/inc/rest/routes/UploadRoutes.php'); |
| | | require(JVB_DIR . '/inc/rest/routes/SettingsRoutes.php'); |
| | | if (Features::forSite()->has('dashboard')) { |
| | | // require(JVB_DIR . '/inc/rest/routes/AdminRoutes.php'); |
| | | require(JVB_DIR . '/inc/rest/routes/AdminRoutes.php'); |
| | | require(JVB_DIR . '/inc/rest/routes/ContentRoutes.php'); |
| | | require(JVB_DIR . '/inc/rest/routes/ContentTermsRoutes.php'); |
| | | // require(JVB_DIR . '/inc/rest/routes/BioRoutes.php'); |
| | |
| | | require(JVB_DIR . '/inc/rest/routes/IntegrationsSquareRoutes.php'); |
| | | } |
| | | |
| | | if (Features::hasIntegration('helcim')) { |
| | | require(JVB_DIR . '/inc/rest/routes/IntegrationsHelcimRoutes.php'); |
| | | } |
| | | |
| | | require(JVB_DIR .'/inc/rest/routes/OptionsRoutes.php'); |
| | | require(JVB_DIR .'/inc/rest/routes/FormRoutes.php'); |
| | | require(JVB_DIR .'/inc/rest/routes/IntegrationsRoutes.php'); |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\rest\routes; |
| | | |
| | | use JVBase\JVB; |
| | | use JVBase\managers\Cache; |
| | | use JVBase\managers\IconsManager; |
| | | use JVBase\rest\Rest; |
| | | use JVBase\rest\Route; |
| | | use JVBase\rest\PermissionHandler; |
| | | use WP_Query; |
| | | use WP_Error; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; // Exit if accessed directly |
| | | } |
| | | |
| | | class AdminRoutes extends Rest |
| | | { |
| | | protected array $fields; |
| | | protected $meta; |
| | | protected string $metaType; |
| | | protected string $content; |
| | | |
| | | public function __construct() |
| | | { |
| | | $this->cacheName = 'itsme'; |
| | | parent::__construct(); |
| | | } |
| | | public function registerRoutes():void |
| | | { |
| | | Route::for('admin-cache') |
| | | ->post([$this, 'handleCacheAction']) |
| | | ->auth('admin') |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | |
| | | Route::for('admin-icons') |
| | | ->post([$this, 'handleIconAction']) |
| | | ->auth('admin') |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | |
| | | Route::for('admin-action') |
| | | ->post([$this, 'adminAction']) |
| | | ->auth('admin') |
| | | ->rateLimit() |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | | * Handles admin actions from the custom WP Admin pages. |
| | | * Extended by other managers that register admin subpages |
| | | * @param WP_REST_Request $request |
| | | * |
| | | * @return WP_REST_Response |
| | | */ |
| | | public function adminAction(WP_REST_Request $request):WP_REST_Response |
| | | { |
| | | error_log('Request Params: '.print_r($request->get_param('action'), true)); |
| | | return apply_filters( |
| | | BASE.'admin_action_filter', |
| | | new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'No filters found' |
| | | ]), |
| | | $request, |
| | | sanitize_text_field($request->get_param('action')) |
| | | ); |
| | | } |
| | | |
| | | |
| | | /** |
| | | * @param array $filters |
| | | * |
| | | * @return array |
| | | */ |
| | | protected function checkFilters(array $filters) |
| | | { |
| | | global $karma; |
| | | $out = []; |
| | | foreach ($filters as $type => $value) { |
| | | if (!array_key_exists($type, $karma)) { |
| | | continue; |
| | | } |
| | | $out[$type] = jvbSanitizeIDList($value); |
| | | } |
| | | return $out; |
| | | } |
| | | |
| | | /** |
| | | * Handle cache-related actions |
| | | */ |
| | | public function handleCacheAction(\WP_REST_Request $request): \WP_REST_Response |
| | | { |
| | | $action = sanitize_text_field($request->get_param('action')); |
| | | |
| | | switch ($action) { |
| | | case 'flush-all': |
| | | $total = Cache::flushAll(); |
| | | return new \WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => $total.' caches flushed successfully' |
| | | ]); |
| | | |
| | | case 'flush-cache': |
| | | $group = sanitize_text_field($request->get_param('group')); |
| | | if (empty($group)) { |
| | | return new \WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'No cache group specified' |
| | | ], 400); |
| | | } |
| | | |
| | | Cache::for($group)?->flush(); |
| | | |
| | | return new \WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => "Cache group '{$group}' flushed successfully" |
| | | ]); |
| | | |
| | | default: |
| | | return new \WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Invalid action' |
| | | ], 400); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle icon-related actions |
| | | */ |
| | | public function handleIconAction(\WP_REST_Request $request): \WP_REST_Response |
| | | { |
| | | $action = sanitize_text_field($request->get_param('action')); |
| | | $source = sanitize_text_field($request->get_param('source') ?? 'icons'); |
| | | $icons = IconsManager::for($source); |
| | | |
| | | switch ($action) { |
| | | case 'refresh-icons': |
| | | // Force regenerate CSS immediately |
| | | $icons->forceRefresh(); |
| | | IconsManager::regenerateAllCSS([$source => true]); |
| | | |
| | | return new \WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => "Icon CSS regenerated successfully for '{$source}'" |
| | | ]); |
| | | |
| | | case 'refresh-all-icons': |
| | | // Regenerate all icon sources |
| | | foreach (['icons', 'forms', 'dash'] as $src) { |
| | | IconsManager::for($src)->forceRefresh(); |
| | | } |
| | | IconsManager::regenerateAllCSS(); |
| | | |
| | | return new \WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => 'All icon CSS files regenerated successfully' |
| | | ]); |
| | | |
| | | case 'restore-icon-version': |
| | | $timestamp = (int)$request->get_param('timestamp'); |
| | | if (empty($timestamp)) { |
| | | return new \WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'No timestamp provided' |
| | | ], 400); |
| | | } |
| | | |
| | | if ($icons->restoreVersion($timestamp)) { |
| | | return new \WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => 'Icon version restored successfully' |
| | | ]); |
| | | } |
| | | |
| | | return new \WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Failed to restore icon version' |
| | | ], 500); |
| | | |
| | | case 'merge-icon-versions': |
| | | $timestamps = $request->get_param('timestamps'); |
| | | |
| | | if (empty($timestamps) || !is_array($timestamps)) { |
| | | return new \WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'No versions selected for merging' |
| | | ], 400); |
| | | } |
| | | |
| | | $timestamps = array_map('intval', $timestamps); |
| | | |
| | | if (count($timestamps) < 2) { |
| | | return new \WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Please select at least 2 versions to merge' |
| | | ], 400); |
| | | } |
| | | |
| | | if ($icons->mergeVersions($timestamps)) { |
| | | // Regenerate CSS after merge |
| | | IconsManager::regenerateAllCSS([$source => true]); |
| | | |
| | | return new \WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => 'Icon versions merged successfully' |
| | | ]); |
| | | } |
| | | |
| | | return new \WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Failed to merge icon versions' |
| | | ], 500); |
| | | |
| | | default: |
| | | return new \WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Invalid action' |
| | | ], 400); |
| | | } |
| | | } |
| | | } |
| | |
| | | 'notes' => 'string', |
| | | ]) |
| | | ->auth(PermissionHandler::combine(['user', 'verified'])) |
| | | ->rateLimit(3); |
| | | ->rateLimit(3) |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | |
| | | 'user' => 'int|required', |
| | | 'posts' => 'required', |
| | | 'content' => 'string', |
| | | ]); |
| | | ]) |
| | | ->register(); |
| | | } |
| | | |
| | | protected function initTimelineFields(string $content): void |
| | |
| | | 'user' => 'int|required', |
| | | 'term_id' => 'int|required' |
| | | ]) |
| | | ->auth(PermissionHandler::custom([$this, 'checkTermPermission'])) |
| | | ->rateLimit(10); |
| | | ->auth(PermissionHandler::combine([[$this, 'checkTermPermission']])) |
| | | ->rateLimit(10) |
| | | ->register(); |
| | | |
| | | // Member management (if track_changes enabled) |
| | | if (Features::forTaxonomy($this->taxonomy)->has('track_changes')) { |
| | |
| | | 'target_user' => 'int|required', |
| | | 'action' => 'string|enum:add,remove|required' |
| | | ]) |
| | | ->auth(PermissionHandler::custom([$this, 'checkTermPermission'])) |
| | | ->rateLimit(5); |
| | | ->auth(PermissionHandler::combine([[$this, 'checkTermPermission']])) |
| | | ->rateLimit(5) |
| | | ->register(); |
| | | } |
| | | |
| | | // Membership requests (if verify_entry enabled) |
| | |
| | | 'status' => 'string|enum:requested,accepted,rejected,all|default:requested', |
| | | 'page' => 'int|default:1|min:1' |
| | | ]) |
| | | ->auth(PermissionHandler::custom([$this, 'checkTermPermission'])) |
| | | ->rateLimit(20); |
| | | ->auth(PermissionHandler::combine([[$this, 'checkTermPermission']])) |
| | | ->rateLimit(20) |
| | | ->register(); |
| | | |
| | | Route::for("{$base}/request") |
| | | ->post([$this, 'handleRequest']) |
| | |
| | | 'notes' => 'string' |
| | | ]) |
| | | ->auth('verified') |
| | | ->rateLimit(5); |
| | | ->rateLimit(5) |
| | | ->register(); |
| | | } |
| | | |
| | | // Ownership/management (if is_ownable enabled) |
| | |
| | | 'role' => 'string|enum:owner,manager|required', |
| | | 'grant' => 'bool|required' |
| | | ]) |
| | | ->auth(PermissionHandler::custom([$this, 'checkOwnerPermission'])) |
| | | ->rateLimit(5); |
| | | ->auth(PermissionHandler::combine([[$this, 'checkTermPermission']])) |
| | | ->rateLimit(5) |
| | | ->register(); |
| | | } |
| | | } |
| | | |
| | |
| | | 'context' => 'string', |
| | | ]) |
| | | ->auth('public') |
| | | ->rateLimit(10); |
| | | ->rateLimit(10) |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | |
| | | 'notes' => 'string', |
| | | ]) |
| | | ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']])) |
| | | ->rateLimit(30); |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | |
| | | // Lists endpoints |
| | | Route::for('favourites/lists') |
| | |
| | | 'items' => 'array', |
| | | ]) |
| | | ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']])) |
| | | ->rateLimit(20); |
| | | ->rateLimit(20) |
| | | ->register(); |
| | | |
| | | // Favourite counts |
| | | Route::for('favourites/counts') |
| | | ->get([$this, 'getFavouriteCounts']) |
| | | ->args(['user' => 'integer|required']) |
| | | ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']])); |
| | | ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']])) |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | |
| | | 'highlight' => 'string', |
| | | ]) |
| | | ->auth('public') |
| | | ->rateLimit(30, 60); |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | |
| | | // Feed types endpoint |
| | | Route::for('feed/types') |
| | | ->get([$this, 'getFeedTypes']) |
| | | ->auth('public') |
| | | ->rateLimit(60, 60); |
| | | ->rateLimit() |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | |
| | | ->rateLimit(5) // 5 submissions per minute |
| | | ->get([$this, 'getForms']) |
| | | ->auth(PermissionHandler::combine(['logged_in', ['actionNonce'=>'dash-']])) |
| | | ->rateLimit(30); |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | |
| | | // Get specific form configuration |
| | | Route::for(Route::pattern('forms/{form_type}')) |
| | | ->get([$this, 'getForm']) |
| | | ->arg('form_type', 'string|required') |
| | | ->auth('logged_in') |
| | | ->rateLimit(30); |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | |
| | | 'options' => 'string', // JSON string of options |
| | | ]) |
| | | ->auth('admin') |
| | | ->rateLimit(3, 300); // 3 imports per 5 minutes |
| | | ->rateLimit(3, 300) |
| | | ->register(); // 3 imports per 5 minutes |
| | | |
| | | // Sales import endpoint |
| | | Route::for('jane/import-sales') |
| | |
| | | 'options' => 'string', // JSON string of options |
| | | ]) |
| | | ->auth('admin') |
| | | ->rateLimit(3, 300); // 3 imports per 5 minutes |
| | | ->rateLimit(3, 300) |
| | | ->register(); // 3 imports per 5 minutes |
| | | |
| | | // Get import status |
| | | Route::for(Route::pattern('jane/import-status/{id}')) |
| | | ->get([$this, 'getImportStatus']) |
| | | ->arg('id', 'string|required') |
| | | ->auth('admin') |
| | | ->rateLimit(30, 60); |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\rest\routes; |
| | | |
| | | use JVBase\rest\Rest; |
| | | use JVBase\rest\Route; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | use Exception; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | class IntegrationsHelcimRoutes extends Rest |
| | | { |
| | | public function registerRoutes(): void |
| | | { |
| | | Route::for('helcim/initialize-checkout') |
| | | ->post([$this, 'handleInitializeCheckout']) |
| | | ->auth('user') |
| | | ->rateLimit(5) |
| | | ->register(); |
| | | |
| | | Route::for('helcim/invoices') |
| | | ->get([$this, 'getInvoices']) |
| | | ->auth('user') |
| | | ->rateLimit(10) |
| | | ->register(); |
| | | |
| | | Route::for(Route::pattern('helcim/invoices/{invoice_id}')) |
| | | ->get([$this, 'getInvoice']) |
| | | ->auth('user') |
| | | ->rateLimit(10) |
| | | ->register(); |
| | | |
| | | Route::for('helcim/saved-cards') |
| | | ->get([$this, 'getSavedCards']) |
| | | ->auth('user') |
| | | ->rateLimit(5) |
| | | ->register(); |
| | | |
| | | Route::for('helcim/validate-transaction') |
| | | ->post([$this, 'validateTransaction']) |
| | | ->auth('user') |
| | | ->rateLimit(10) |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | | * Initialize a HelcimPay.js checkout session. |
| | | * |
| | | * Returns checkoutToken for the frontend to call |
| | | * appendHelcimPayIframe(checkoutToken). |
| | | */ |
| | | public function handleInitializeCheckout(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $data = $request->get_json_params(); |
| | | $user_id = absint($data['user'] ?? get_current_user_id()); |
| | | |
| | | if (empty($data['amount'])) { |
| | | return $this->validationError(['message' => 'Amount is required']); |
| | | } |
| | | |
| | | try { |
| | | $helcim = JVB()->connect('helcim'); |
| | | |
| | | // Auto-resolve customer ID from logged-in user |
| | | if (empty($data['customerId']) && $user_id) { |
| | | $data['customerId'] = $helcim->resolveCustomerId($user_id); |
| | | } |
| | | |
| | | $result = $helcim->initializeCheckout($data); |
| | | |
| | | if (!$result['success']) { |
| | | return $this->error($result['message'] ?? 'Checkout initialization failed'); |
| | | } |
| | | |
| | | return $this->success($result); |
| | | |
| | | } catch (Exception $e) { |
| | | $this->logError('Helcim checkout init failed', ['error' => $e->getMessage()]); |
| | | return $this->error($e->getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get invoices for the current user. |
| | | */ |
| | | public function getInvoices(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $user_id = absint($request->get_param('user') ?? get_current_user_id()); |
| | | |
| | | if (!$user_id) { |
| | | return $this->validationError(['message' => 'Not logged in']); |
| | | } |
| | | |
| | | try { |
| | | $helcim = JVB()->connect('helcim'); |
| | | $user = get_userdata($user_id); |
| | | $result = $helcim->handleGetInvoices([ |
| | | 'email' => $user->user_email, |
| | | ]); |
| | | |
| | | return $this->success($result); |
| | | |
| | | } catch (Exception $e) { |
| | | return $this->error($e->getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get a single invoice by Helcim invoice ID. |
| | | */ |
| | | public function getInvoice(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $invoiceId = $request->get_param('invoice_id'); |
| | | |
| | | if (!$invoiceId) { |
| | | return $this->validationError(['message' => 'Invoice ID required']); |
| | | } |
| | | |
| | | try { |
| | | $helcim = JVB()->connect('helcim'); |
| | | $result = $helcim->handleGetInvoice(['invoiceId' => $invoiceId]); |
| | | |
| | | return $this->success($result); |
| | | |
| | | } catch (Exception $e) { |
| | | return $this->error($e->getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get saved cards for the current user. |
| | | */ |
| | | public function getSavedCards(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $user_id = absint($request->get_param('user') ?? get_current_user_id()); |
| | | |
| | | if (!$user_id) { |
| | | return $this->validationError(['message' => 'Not logged in']); |
| | | } |
| | | |
| | | try { |
| | | $helcim = JVB()->connect('helcim'); |
| | | $result = $helcim->handleGetCustomerCards([ |
| | | 'email' => get_userdata($user_id)->user_email, |
| | | ]); |
| | | |
| | | return $this->success($result); |
| | | |
| | | } catch (Exception $e) { |
| | | return $this->error($e->getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Validate a HelcimPay.js transaction server-side. |
| | | * |
| | | * Called after the frontend receives a SUCCESS message event. |
| | | * Verifies the transaction hash using the secretToken stored |
| | | * in the user's session/transient. |
| | | */ |
| | | public function validateTransaction(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $data = $request->get_json_params(); |
| | | |
| | | if (empty($data['secretToken']) || empty($data['transactionData'])) { |
| | | return $this->validationError(['message' => 'Missing secretToken or transactionData']); |
| | | } |
| | | |
| | | try { |
| | | $helcim = JVB()->connect('helcim'); |
| | | $valid = $helcim->validateTransaction( |
| | | $data['secretToken'], |
| | | $data['transactionData'] |
| | | ); |
| | | |
| | | return $this->success([ |
| | | 'valid' => $valid, |
| | | ]); |
| | | |
| | | } catch (Exception $e) { |
| | | return $this->error($e->getMessage()); |
| | | } |
| | | } |
| | | } |
| | |
| | | 'data' => 'array' |
| | | ]) |
| | | ->auth('user') |
| | | ->rateLimit(20); |
| | | ->rateLimit(20) |
| | | ->register(); |
| | | Route::for('oath/connect') |
| | | ->post([$this, 'initiateOAuth']) |
| | | ->auth('user') |
| | |
| | | 'service' => 'string|required', |
| | | 'user_id' => 'int', |
| | | 'return_url'=> 'url' |
| | | ]); |
| | | ]) |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | |
| | | Route::for('square/process-payment') |
| | | ->post([$this, 'handlePaymentProcessing']) |
| | | ->auth('public') |
| | | ->rateLimit(2); |
| | | ->rateLimit(2) |
| | | ->register(); |
| | | |
| | | Route::for('square/saved-cards') |
| | | ->post([$this, 'getSavedCards']) |
| | | ->auth('user') |
| | | ->rateLimit(5); |
| | | ->rateLimit(5) |
| | | ->register(); |
| | | |
| | | Route::for('square/order-history') |
| | | ->get([$this, 'getOrderHistory']) |
| | | ->auth('user') |
| | | ->rateLimit(5); |
| | | ->rateLimit(5) |
| | | ->register(); |
| | | |
| | | Route::for(Route::pattern('square/order-status/{order_id}')) |
| | | ->get([$this, 'getOrderStatus']) |
| | | ->auth('public') |
| | | ->rateLimit(20); |
| | | ->rateLimit(20) |
| | | ->register(); |
| | | } |
| | | |
| | | //TODO: Are we processing this through our server at all? Or is it in the javascript going straight to square? |
| | |
| | | 'invitation_id' => 'int' |
| | | ]) |
| | | ->auth('verified') |
| | | ->rateLimit(10, 300); |
| | | ->rateLimit(10, 300) |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | |
| | | Route::for('auth/status') |
| | | ->get([$this, 'getAuthStatus']) |
| | | ->auth('public') |
| | | ->rateLimit(); |
| | | ->rateLimit() |
| | | ->register(); |
| | | |
| | | // Standard login |
| | | Route::for('auth/login') |
| | |
| | | 'redirect_to' => 'string', |
| | | ]) |
| | | ->auth('public') |
| | | ->rateLimit(5, 300); |
| | | ->rateLimit(5, 300) |
| | | ->register(); |
| | | |
| | | // User registration |
| | | Route::for('auth/register') |
| | |
| | | 'redirect_to' => 'string', |
| | | ]) |
| | | ->auth('public') |
| | | ->rateLimit(3, 3600); |
| | | ->rateLimit(3, 3600) |
| | | ->register(); |
| | | |
| | | // Request password reset |
| | | Route::for('auth/lostpassword') |
| | |
| | | 'user_email' => 'email|required', |
| | | ]) |
| | | ->auth('public') |
| | | ->rateLimit(3, 3600); |
| | | ->rateLimit(3, 3600) |
| | | ->register(); |
| | | |
| | | // Reset password with token |
| | | Route::for('auth/resetpass') |
| | |
| | | 'pass2' => 'string|required', |
| | | ]) |
| | | ->auth('public') |
| | | ->rateLimit(5, 300); |
| | | ->rateLimit(5, 300) |
| | | ->register(); |
| | | |
| | | // Magic link endpoint |
| | | if ($this->hasMagicLink) { |
| | |
| | | 'redirect_to' => 'string', |
| | | ]) |
| | | ->auth('public') |
| | | ->rateLimit(5, 3600); |
| | | ->rateLimit(5, 3600) |
| | | ->register(); |
| | | } |
| | | |
| | | // Logout endpoint |
| | | Route::for('auth/logout') |
| | | ->post([$this, 'handleLogout']) |
| | | ->auth('logged_in') |
| | | ->rateLimit(10, 60); |
| | | |
| | | error_log('=================== LOGIN ROUTES REGISTERED ==================='); |
| | | ->rateLimit(10) |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | |
| | | 'type' => 'integer', |
| | | ]) |
| | | ->auth(PermissionHandler::combine(['user','nonce',['actionNonce'=>'dash-']])) |
| | | ->rateLimit(30); |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | |
| | | Route::for(Route::pattern('news/{id}')) |
| | | ->get([$this, 'getNewsItem']) |
| | | ->arg('id', 'integer|required') |
| | | ->auth(PermissionHandler::combine(['user','nonce', ['actionNonce'=>'dash-']])) |
| | | ->rateLimit(30); |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | |
| | | 'offset' => 'integer|default:0', |
| | | ]) |
| | | ->auth('user') |
| | | ->rateLimit(30); |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | |
| | | // Mark as read |
| | | Route::for('notifications/read') |
| | |
| | | 'notification_id' => 'integer|required', |
| | | ]) |
| | | ->auth('user') |
| | | ->rateLimit(30); |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | |
| | | // Mark all as read |
| | | Route::for('notifications/read-all') |
| | |
| | | 'type' => 'string', |
| | | ]) |
| | | ->auth('user') |
| | | ->rateLimit(10); |
| | | ->rateLimit(10) |
| | | ->register(); |
| | | |
| | | // Mark as actioned |
| | | Route::for('notifications/action') |
| | |
| | | 'notification_id' => 'integer|required', |
| | | ]) |
| | | ->auth('user') |
| | | ->rateLimit(30); |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | |
| | | // Dismiss notification |
| | | Route::for('notifications/dismiss') |
| | |
| | | 'notification_id' => 'integer|required', |
| | | ]) |
| | | ->auth('user') |
| | | ->rateLimit(30); |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | |
| | | // Get unread count |
| | | Route::for('notifications/count') |
| | |
| | | 'type' => 'string', |
| | | ]) |
| | | ->auth('user') |
| | | ->rateLimit(60); |
| | | ->rateLimit() |
| | | ->register(); |
| | | } |
| | | |
| | | // ========================================================================= |
| | |
| | | ->args([ |
| | | 'user' => 'int|required', |
| | | 'id' => 'string|required', |
| | | ]); |
| | | ]) |
| | | ->register(); |
| | | } |
| | | |
| | | public function saveOptions(WP_REST_Request $request):WP_REST_Response |
| | |
| | | 'action' => 'string|required|enum:dismiss,retry,cancel', |
| | | ]) |
| | | ->auth('user') |
| | | ->rateLimit(30); |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | |
| | | // Poll endpoint |
| | | Route::for('queue/poll') |
| | |
| | | 'ids' => 'string', |
| | | ]) |
| | | ->auth('user') |
| | | ->rateLimit(15); |
| | | ->rateLimit(15) |
| | | ->register(); |
| | | |
| | | // Errors endpoint |
| | | Route::for('queue/errors') |
| | | ->get([$this, 'getOperationErrors']) |
| | | ->auth('user') |
| | | ->rateLimit(15); |
| | | ->rateLimit(15) |
| | | ->register(); |
| | | |
| | | // Single operation with dynamic ID |
| | | Route::for(Route::pattern('queue/{id}')) |
| | | ->get([$this, 'getOperation']) |
| | | ->arg('id', 'string|required') |
| | | ->auth('user') |
| | | ->rateLimit(15); |
| | | ->rateLimit(15) |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | |
| | | { |
| | | $params = $request->get_params(); |
| | | $user_id = absint($params['user']); |
| | | $this->cache = Cache::for($user_id.'_queue'); |
| | | $status = sanitize_text_field($params['status']); |
| | | $ids = !empty($params['ids']) |
| | | ? array_map('trim', array_map('sanitize_text_field', explode(',', $params['ids']))) |
| | |
| | | $action = sanitize_text_field($data['action'] ?? ''); |
| | | $user_id = absint($data['user']); |
| | | |
| | | $this->cache = Cache::for($user_id.'_queue'); |
| | | |
| | | // Validate input |
| | | if (empty($ids)) { |
| | | return Response::validationError(['ids' => 'Missing or invalid operation IDs']); |
| | |
| | | public function pollQueue(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $userId = $request->get_param('user'); |
| | | $this->cache = Cache::for($userId.'_queue'); |
| | | $since = $request->get_param('since'); |
| | | $ids = $request->get_param('ids'); |
| | | |
| | |
| | | public function getOperationErrors(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $user_id = absint($request->get_param('user')); |
| | | $this->cache = Cache::for($user_id.'_queue'); |
| | | $operations = JVB()->queue()->getUserOperations($user_id, [ |
| | | 'state' => 'completed', |
| | | 'outcome' => ['failed', 'failed_permanent', 'partial'], |
| | |
| | | { |
| | | $id = $request->get_param('id'); |
| | | $userId = $request->get_param('user'); |
| | | $this->cache = Cache::for($userId.'_queue'); |
| | | |
| | | $op = JVB()->queue()->get($id); |
| | | |
| | |
| | | 'action' => 'string|required|enum:invite,consulted,treated,remove,resend' |
| | | ]) |
| | | ->auth('user') |
| | | ->rateLimit(10); |
| | | ->rateLimit(10) |
| | | ->register(); |
| | | |
| | | // Referral code endpoint |
| | | Route::for('referrals/code') |
| | |
| | | ->post([$this, 'validateCode']) |
| | | ->args(['code' => 'string|required']) |
| | | ->auth('public') |
| | | ->rateLimit(10); |
| | | ->rateLimit(10) |
| | | ->register(); |
| | | |
| | | // Stats endpoint |
| | | Route::for('referrals/stats') |
| | | ->get([$this, 'getStats']) |
| | | ->args(['user' => 'integer']) |
| | | ->auth('user') |
| | | ->rateLimit(30); |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | |
| | | // Settings endpoint (admin only) |
| | | Route::for('referrals/settings') |
| | | ->get([$this, 'getSettings']) |
| | | ->post([$this, 'updateSettings']) |
| | | ->auth('admin') |
| | | ->rateLimit(10); |
| | | ->rateLimit(10) |
| | | ->register(); |
| | | |
| | | // CSV Upload endpoints (admin only) |
| | | Route::for('referrals/upload-clients') |
| | | ->post([$this, 'handleClientUpload']) |
| | | ->auth('admin') |
| | | ->rateLimit(3); |
| | | ->rateLimit(3) |
| | | ->register(); |
| | | |
| | | Route::for('referrals/upload-sales') |
| | | ->post([$this, 'handleSalesUpload']) |
| | | ->auth('admin') |
| | | ->rateLimit(3); |
| | | ->rateLimit(3) |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | |
| | | 'action' => 'string|required|enum:save,reset,preview', |
| | | 'context'=> 'string|required' |
| | | ]) |
| | | ->rateLimit(30); |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | |
| | | Route::for('seo/fields') |
| | | ->get([$this, 'getFields']) |
| | | ->auth('admin') |
| | | ->args([ |
| | | 'type'=>'string|required' |
| | | ]); |
| | | ]) |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | |
| | | Route::for('settings') |
| | | ->post([$this, 'saveSettings']) |
| | | ->auth(PermissionHandler::combine(['admin', ['actionNonce' => 'dash-']])) |
| | | ->rateLimit(20); |
| | | ->rateLimit(20) |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | |
| | | 'taxonomy' => 'string|required', |
| | | 'name' => 'string|required', |
| | | 'parent' => 'int|default:0', |
| | | ]); |
| | | ]) |
| | | ->register(); |
| | | |
| | | Route::for('terms/check') |
| | | ->get([$this,'getTermDetails']) |
| | | ->auth('public') |
| | | ->rateLimit(); |
| | | ->rateLimit() |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | |
| | | 'id' => 'string|required', |
| | | 'content' => 'string|required', |
| | | 'user' => 'int|required' |
| | | ]); |
| | | ]) |
| | | ->register(); |
| | | |
| | | Route::for('uploads/meta') |
| | | ->post([$this, 'handleMetadataUpdate']) |
| | |
| | | ->args([ |
| | | 'user' => 'int|required', |
| | | 'items' => 'array|required' |
| | | ]); |
| | | ]) |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | |
| | | 'user' => 'integer', |
| | | ]) |
| | | ->auth('user') |
| | | ->rateLimit(30); |
| | | ->rateLimit(30) |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | |
| | | $tabs = false; |
| | | } |
| | | |
| | | |
| | | $fields = $this->fields; |
| | | if (!$this->isTimeline) { |
| | | $first = ['post_thumbnail', 'post_title', 'price']; |
| | |
| | | if ($tabs) { |
| | | $tabs['basic']['content'] .= Form::render($f, '', $fields[$f]); |
| | | } else { |
| | | Form::render($f, '', $fields[$f]); |
| | | echo Form::render($f, '', $fields[$f]); |
| | | } |
| | | |
| | | unset($fields[$f]); |
| | | } |
| | | } |
| | |
| | | if (in_array($config['type'], ['taxonomy', 'selector'])) { |
| | | $config = array_merge($config, $this->taxConfig($config['taxonomy'], $config['label'])); |
| | | } |
| | | Form::render($n, '', $config); |
| | | echo Form::render($n, '', $config); |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\ui; |
| | | |
| | | use JVBase\meta\Form; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Shared Checkout UI |
| | | * |
| | | * Provider-agnostic checkout markup used by any payment integration |
| | | * (Square, Helcim, etc). Integrations hook into this via: |
| | | * - add_filter('jvbAdditionalActions', [Checkout::class, 'render']) |
| | | * |
| | | * The active provider is determined by `jvbGetPaymentProvider()`, |
| | | * which returns the integration instance (Square or Helcim). |
| | | * |
| | | * Provider-specific differences are handled by: |
| | | * - data-provider attribute on the form (for JS to detect) |
| | | * - #payment-container (provider JS attaches its own UI here) |
| | | * - Filterable sections for provider-specific content |
| | | * |
| | | * @since 1.0.0 |
| | | */ |
| | | class Checkout |
| | | { |
| | | /** |
| | | * Render the checkout aside and append to actions. |
| | | * |
| | | * Hooked via: add_filter('jvbAdditionalActions', [Checkout::class, 'render']) |
| | | */ |
| | | public static function render(array $actions): array |
| | | { |
| | | if (is_singular(BASE . 'dash') || is_post_type_archive(BASE . 'dash')) { |
| | | return $actions; |
| | | } |
| | | |
| | | $provider = jvbGetPaymentProvider(); |
| | | if (!$provider || !$provider->isSetUp()) { |
| | | return $actions; |
| | | } |
| | | |
| | | $providerName = strtolower($provider->getServiceName()); |
| | | |
| | | $form = '<aside id="cart" class="right main"> |
| | | <form id="checkout" data-form-id="checkout" data-save="checkout" data-provider="' . esc_attr($providerName) . '">'; |
| | | |
| | | $tabs = [ |
| | | 'cartItems' => [ |
| | | 'title' => 'Your Order', |
| | | 'icon' => 'cart', |
| | | 'description' => 'Here\'s your order. You can change quantities, remove items, or clear your cart.', |
| | | 'content' => self::cartContent(), |
| | | ], |
| | | 'checkout' => [ |
| | | 'title' => 'Checkout', |
| | | 'icon' => 'checkout', |
| | | 'description' => apply_filters('jvb_checkout_description', |
| | | 'Securely checkout with your name, email, and payment.', |
| | | $providerName |
| | | ), |
| | | 'content' => self::checkoutContent($providerName), |
| | | ], |
| | | 'order' => [ |
| | | 'title' => 'Your Order', |
| | | 'icon' => 'truck', |
| | | 'hidden' => true, |
| | | 'description' => '', |
| | | 'content' => self::orderStatus(), |
| | | ], |
| | | ]; |
| | | |
| | | $form .= jvbRenderTabs($tabs, true); |
| | | |
| | | $form .= '<div class="cart-total row end"> |
| | | <p class="tax">Tax: <span></span></p> |
| | | <p class="total">GRAND TOTAL: <span></span></p> |
| | | </div> |
| | | </form> |
| | | </aside>'; |
| | | |
| | | $form .= self::templates($providerName); |
| | | |
| | | $actions[] = [ |
| | | 'button' => self::toggleButton(), |
| | | 'content' => $form, |
| | | ]; |
| | | |
| | | return $actions; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * SECTIONS |
| | | *****************************************************************/ |
| | | |
| | | /** |
| | | * Checkout tab content: customer info + payment container |
| | | */ |
| | | private static function checkoutContent(string $provider): string |
| | | { |
| | | $fields = '<div class="checkout-section"> |
| | | <h3>Customer Information</h3>' |
| | | . Form::render('cart_name', null, [ |
| | | 'type' => 'text', |
| | | 'label' => 'Your Name', |
| | | 'required' => true, |
| | | 'autocomplete' => 'name', |
| | | ]) |
| | | . Form::render('cart_email', null, [ |
| | | 'type' => 'email', |
| | | 'label' => 'Your Email', |
| | | 'required' => true, |
| | | 'autocomplete' => 'email', |
| | | ]) |
| | | . Form::render('cart_phone', null, [ |
| | | 'type' => 'tel', |
| | | 'label' => 'Your Phone', |
| | | 'required' => true, |
| | | 'autocomplete' => 'phone', |
| | | ]); |
| | | |
| | | // Optional sections — integrations can add pickup, scheduling, etc. |
| | | $fields .= apply_filters('jvb_checkout_fields', '', $provider); |
| | | |
| | | $fields .= '</div>'; |
| | | |
| | | // Payment section — provider JS mounts its own UI inside #payment-container |
| | | $fields .= '<div class="checkout-section"> |
| | | <h3>Payment Information</h3> |
| | | <div id="saved-cards"></div> |
| | | <div id="payment-container" data-provider="' . esc_attr($provider) . '"></div> |
| | | </div>'; |
| | | |
| | | return $fields; |
| | | } |
| | | |
| | | /** |
| | | * Cart items tab: table + account details |
| | | */ |
| | | private static function cartContent(): string |
| | | { |
| | | ob_start(); |
| | | ?> |
| | | <div class="cart-items"> |
| | | <table> |
| | | <thead> |
| | | <tr> |
| | | <th scope="col">Item</th> |
| | | <th scope="col">Price</th> |
| | | <th scope="col">Total</th> |
| | | </tr> |
| | | </thead> |
| | | <tbody></tbody> |
| | | </table> |
| | | </div> |
| | | |
| | | <details class="account"> |
| | | <summary> |
| | | <?php |
| | | if (is_user_logged_in()) { |
| | | echo 'Your Favourites and Order History'; |
| | | } else { |
| | | echo '<a href="' . wp_login_url(get_the_permalink()) . '">Log in</a> to save your favourites and view order history.'; |
| | | } |
| | | ?> |
| | | </summary> |
| | | <?php |
| | | if (is_user_logged_in()) { |
| | | $tabs = [ |
| | | 'history' => [ |
| | | 'title' => 'Order History', |
| | | 'icon' => 'checkout', |
| | | 'description' => 'View your past orders and quickly reorder', |
| | | 'content' => apply_filters('jvb_checkout_order_history', ''), |
| | | ], |
| | | 'favourites' => [ |
| | | 'title' => 'Favourites', |
| | | 'icon' => 'heart', |
| | | 'description' => 'View your favourites', |
| | | 'content' => apply_filters('jvb_checkout_favourites', ''), |
| | | ], |
| | | ]; |
| | | jvbRenderTabs($tabs); |
| | | } |
| | | ?> |
| | | </details> |
| | | <?php |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | | /** |
| | | * Order confirmation / status tracking |
| | | */ |
| | | private static function orderStatus(): string |
| | | { |
| | | $statuses = apply_filters('jvb_checkout_order_statuses', [ |
| | | 'received' => 'Order Received', |
| | | 'preparing' => 'Preparing', |
| | | 'ready' => 'Ready for Pickup', |
| | | ]); |
| | | |
| | | ob_start(); |
| | | ?> |
| | | <div class="order-confirmation"> |
| | | <h2>Order Confirmed!</h2> |
| | | <div id="order-status" data-order=""> |
| | | <p>Order #<span class="order-num"></span></p> |
| | | <div class="status-timeline"> |
| | | <?php foreach ($statuses as $key => $label): ?> |
| | | <div class="status-item<?php echo $key === array_key_first($statuses) ? ' active' : ''; ?>" |
| | | data-status="<?php echo esc_attr($key); ?>"> |
| | | <?php echo esc_html($label); ?> |
| | | </div> |
| | | <?php endforeach; ?> |
| | | </div> |
| | | <div class="pickup-time"> |
| | | Estimated pickup: <span id="eta">Calculating...</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <?php |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * TEMPLATES — cloned by JS at runtime |
| | | *****************************************************************/ |
| | | |
| | | private static function templates(string $provider): string |
| | | { |
| | | // Browse link is filterable per-site |
| | | $browseUrl = apply_filters('jvb_checkout_browse_url', '#'); |
| | | $browseText = apply_filters('jvb_checkout_browse_text', 'browse our products'); |
| | | |
| | | return '<template class="restoredCart"> |
| | | <div class="restored"> |
| | | <h3>Looks like we left things hanging</h3> |
| | | <p>We\'ve restored your cart from your last session below.</p> |
| | | <p>If you\'d rather start over, click the button below.</p> |
| | | <div class="row btw"> |
| | | <button type="button" data-clear-cart>' . jvbIcon('trash') . 'Clear Cart</button> |
| | | <button type="button" data-dismiss>' . jvbIcon('x') . 'Dismiss</button> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | <template class="cartItem"> |
| | | <tr class="item"> |
| | | <td class="item"> |
| | | <label for="quantity"></label> |
| | | <div class="quantity field" data-min="0" data-max="50" data-step="1" data-price="" data-id="" data-catalog-id=""> |
| | | <button type="button" class="decrease" aria-label="Decrease quantity">' . jvbIcon('minus-square') . '</button> |
| | | <input type="number" id="quantity" name="quantity" value="0" min="0" max="50" step="1" class="quantity-input"> |
| | | <button type="button" class="increase" aria-label="Increase quantity">' . jvbIcon('plus-square') . '</button> |
| | | </div> |
| | | </td> |
| | | <td class="price"><span class="price"></span></td> |
| | | <td class="total"><span class="total"></span></td> |
| | | <td> |
| | | <button type="button" data-remove-from-cart>' . jvbIcon('trash') . '</button> |
| | | </td> |
| | | </tr> |
| | | </template> |
| | | <template class="emptyCart"> |
| | | <div class="empty"> |
| | | <p><i><b>No items in cart.</b></i></p> |
| | | <p>You can <a href="' . esc_url($browseUrl) . '" title="' . esc_attr($browseText) . '">' . $browseText . '</a> to order.</p> |
| | | </div> |
| | | </template>'; |
| | | } |
| | | |
| | | /** |
| | | * Cart toggle button |
| | | */ |
| | | private static function toggleButton(): string |
| | | { |
| | | return '<button type="button" class="toggle-cart row" title="Your Cart" |
| | | data-action="toggle-cart" aria-label="Open Cart" |
| | | aria-controls="checkout" aria-expanded="false">' |
| | | . jvbIcon('shopping-cart') |
| | | . '<span class="abs"></span><span class="abs count"></span> |
| | | </button>'; |
| | | } |
| | | } |
| | |
| | | require(JVB_DIR.'/inc/ui/Navigation.php'); |
| | | require(JVB_DIR.'/inc/ui/Tabs.php'); |
| | | require(JVB_DIR.'/inc/ui/CRUDSkeleton.php'); |
| | | require(JVB_DIR.'/inc/ui/Checkout.php'); |
| | |
| | | 'a11y': './assets/js/concise/A11yHelper.js', |
| | | 'auth': './assets/js/concise/AuthManager.js', |
| | | // 'admin': './assets/js/dash/Admin.js', |
| | | 'bioManager': './assets/js/concise/BioManager.js', |
| | | // 'bioManager': './assets/js/concise/BioManager.js', |
| | | 'ContentManager': './assets/js/concise/ContentManager.js', |
| | | 'hours': './assets/js/concise/CopyHours.js', |
| | | 'crud': './assets/js/concise/CRUD.js', |
| | |
| | | 'shopManager': './assets/js/concise/ShopManager.js', |
| | | 'cache': './assets/js/concise/SimpleCache.js', |
| | | 'schema': './assets/js/concise/SchemaManager.js', |
| | | 'square': './assets/js/concise/SquareCheckout.js', |
| | | 'square': './assets/js/concise/CheckoutSquare.js', |
| | | 'helcim': './assets/js/concise/CheckoutHelcim.js', |
| | | 'checkout': './assets/js/concise/Checkout.js', |
| | | 'tabs': './assets/js/concise/Tabs.js', |
| | | 'creator': './assets/js/concise/TaxonomyCreator.js', |
| | | 'selector': './assets/js/concise/TaxonomySelector.js', |