From df6c00db050e188a6bd5707e72c4f1f331ced923 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 08 Feb 2026 20:46:43 +0000
Subject: [PATCH] =Port over to jakevan 2
---
assets/js/concise/CheckoutSquare.js | 244 ++
inc/rest/routes/NotificationsRoutes.php | 18
assets/js/concise/CheckoutHelcim.js | 274 ++
assets/js/min/square.min.js | 2
inc/rest/routes/TermRoutes.php | 6
inc/meta/Form.php | 64
assets/js/min/form.min.js | 2
inc/rest/routes/ContentRoutes.php | 3
inc/rest/routes/FeedRoutes.php | 6
inc/rest/routes/OptionsRoutes.php | 3
assets/js/concise/Checkout.js | 542 ++++
inc/rest/routes/ApprovalRoutes.php | 3
inc/ui/Checkout.php | 285 ++
inc/managers/_setup.php | 2
inc/rest/routes/ImporterRoutes.php | 9
JVBase.php | 8
inc/rest/routes/IntegrationsSquareRoutes.php | 12
inc/integrations/Square.php | 469 +--
inc/ui/CRUDSkeleton.php | 6
inc/integrations/Integrations.php | 2
inc/importers/JaneAppSalesImporter.php | 1
inc/rest/routes/SEORoutes.php | 6
inc/rest/routes/NewsRoutes.php | 6
inc/managers/queue/executors/IntegrationExecutor.php | 287 ++
assets/js/concise/FormController.js | 217
inc/rest/routes/IntegrationsHelcimRoutes.php | 187 +
inc/rest/routes/FormRoutes.php | 6
assets/js/concise/PopulateForm.js | 57
inc/ui/_setup.php | 1
assets/js/min/helcim.min.js | 1
inc/registry/FieldRegistry.php | 15
inc/rest/Route.php | 24
inc/rest/routes/ErrorRoutes.php | 3
inc/rest/routes/FavouritesRoutes.php | 9
inc/rest/routes/ReferralRoutes.php | 18
inc/rest/routes/ContentTermsRoutes.php | 23
inc/managers/queue/Storage.php | 15
inc/rest/_setup.php | 6
inc/rest/routes/UploadRoutes.php | 6
assets/js/min/populate.min.js | 2
assets/js/min/auth.min.js | 2
inc/rest/routes/QueueRoutes.php | 18
inc/rest/routes/IntegrationsRoutes.php | 6
inc/managers/AdminPages.php | 457 ---
assets/js/admin.js | 394 ++
inc/managers/ScriptLoader.php | 2
inc/rest/routes/Invitations.php | 3
inc/rest/routes/SettingsRoutes.php | 3
webpack.jvb.js | 6
inc/admin/Integrations.php | 6
/dev/null | 498 ----
assets/js/min/checkout.min.js | 1
inc/rest/routes/LoginRoutes.php | 23
inc/integrations/Helcim.php | 2634 +++++++++------------
inc/rest/routes/AdminRoutes.php | 225 +
inc/managers/queue/_setup.php | 1
inc/rest/routes/VoteRoutes.php | 3
57 files changed, 4,192 insertions(+), 2,940 deletions(-)
diff --git a/JVBase.php b/JVBase.php
index 6eba3a6..3de959a 100644
--- a/JVBase.php
+++ b/JVBase.php
@@ -23,6 +23,7 @@
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;
@@ -44,7 +45,7 @@
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;
@@ -128,6 +129,9 @@
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();
@@ -146,7 +150,7 @@
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();
diff --git a/assets/js/admin.js b/assets/js/admin.js
index 75bbd7f..7230dc8 100644
--- a/assets/js/admin.js
+++ b/assets/js/admin.js
@@ -1,102 +1,320 @@
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);
+ }
}
diff --git a/assets/js/concise/Checkout.js b/assets/js/concise/Checkout.js
new file mode 100644
index 0000000..ad0b11e
--- /dev/null
+++ b/assets/js/concise/Checkout.js
@@ -0,0 +1,542 @@
+/**
+ * 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;
diff --git a/assets/js/concise/CheckoutHelcim.js b/assets/js/concise/CheckoutHelcim.js
new file mode 100644
index 0000000..1cfd2c3
--- /dev/null
+++ b/assets/js/concise/CheckoutHelcim.js
@@ -0,0 +1,274 @@
+/**
+ * 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();
+ }
+});
diff --git a/assets/js/concise/CheckoutSquare.js b/assets/js/concise/CheckoutSquare.js
new file mode 100644
index 0000000..f1fb448
--- /dev/null
+++ b/assets/js/concise/CheckoutSquare.js
@@ -0,0 +1,244 @@
+/**
+ * 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();
+ }
+});
diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 1286355..8996cbf 100644
--- a/assets/js/concise/FormController.js
+++ b/assets/js/concise/FormController.js
@@ -59,7 +59,7 @@
field: '.field', //querySelectorAll
label: 'label',
success: '.success',
- error: '.success',
+ error: '.error',
message: '.validation-message',
},
repeater: {
@@ -78,6 +78,7 @@
remove: '.remove-tag',
label: '.tag-label',
items: '.tag-items',
+ item: '.tag-item',
inputs: this.inputSelectors, //querySelectorAll
value: 'input[type="hidden"]' //querySelectorAll
},
@@ -334,7 +335,7 @@
});
}
- 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;
}
@@ -763,14 +764,14 @@
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;
@@ -779,20 +780,20 @@
} 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) {
@@ -824,7 +825,7 @@
manyRefs.inputs?.forEach(input => {
- window.prefixInput(input, `${data.repeater.dataset.fieldName}:${index}:`, el);
+ window.prefixInput(input, `${data.repeater.dataset.field}:${index}:`, el);
});
}
},
@@ -853,10 +854,8 @@
}
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]'));
}
}
@@ -882,10 +881,10 @@
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(
@@ -902,7 +901,7 @@
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) {
@@ -914,7 +913,6 @@
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);
});
@@ -931,82 +929,114 @@
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]');
@@ -1397,28 +1427,20 @@
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');
@@ -1426,12 +1448,9 @@
}
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}`
@@ -1439,7 +1458,6 @@
window.jvbA11y.announce(announcement);
}
- // Trigger custom event
form.dispatchEvent(new CustomEvent('jvb-form-error', {
detail: data
}));
@@ -1502,6 +1520,7 @@
**********************************************************************/
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);
diff --git a/assets/js/concise/PopulateForm.js b/assets/js/concise/PopulateForm.js
index 64b3f65..2bd02b8 100644
--- a/assets/js/concise/PopulateForm.js
+++ b/assets/js/concise/PopulateForm.js
@@ -91,7 +91,7 @@
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);
@@ -99,8 +99,16 @@
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);
});
}
@@ -108,19 +116,52 @@
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 => {
diff --git a/assets/js/concise/SquareCheckout.js b/assets/js/concise/SquareCheckout.js
deleted file mode 100644
index 72d033e..0000000
--- a/assets/js/concise/SquareCheckout.js
+++ /dev/null
@@ -1,739 +0,0 @@
-class SquareCheckout {
- constructor(config = {}) {
- this.config = {
- ...squareConfig,
- ...config
- };
-
- this.payments = null;
- this.card = null;
- this.isInitialized = false;
- this.cartItems = new Map();
- this.checkout = document.querySelector('aside#cart');
-
- this.isOpen = this.config.isOpen !== '1' || false;
- //User Context
- this.isLoggedIn = this.config.is_logged_in|| false;
- this.userEmail = this.config.user_email || '';
- this.savedCards = [];
- this.selectedCardId = null;
- this.cartId = null;
-
- this.cache = new window.jvbCache('cart', {TTL: 8.64e+7});
- this.a11y = window.jvbA11y;
- this.initCart();
-
- if (this.checkout) {
- this.initElements();
- this.init();
- this.initListeners();
-
- if (this.isLoggedIn) {
- this.loadSavedCards();
- }
- }
-
- this.stepMultiplier = 1;
-
- //Handle the opening and closing of the checkout window
- this.popup = new window.jvbPopup({
- popup: this.checkout,
- toggle: this.toggle,
- name: 'Cart',
- onOpen: this.maybeAddEmptyState.bind(this),
- });
-
- console.log(this.popup);
- // this.toggle.hidden = false;
- }
-
- async initCart() {
- this.cartItems = await this.cache.get('cart') ?? new Map();
- console.log('cart',this.cartItems);
- if (this.cartItems.size > 0) {
- this.notifyRestoredCart();
- }
- }
-
- handleClick(e) {
- if (window.targetCheck(e, 'button') && window.targetCheck(e, 'div.quantity')) {
- let quantity = window.targetCheck(e, 'div.quantity');
- this.handleNumberClick(e, quantity);
- }else if (window.targetCheck(e, '[data-add-to-cart]')) {
- let add = window.targetCheck(e, '[data-add-to-cart]');
- this.handleAddToCart(add);
- } else if (window.targetCheck(e, '[data-remove-from-cart]')) {
- let remove = window.targetCheck(e, '[data-remove-from-cart]');
- this.handleRemoveFromCart(remove);
- } else if (window.targetCheck(e, '[data-clear-cart]')) {
- this.clearCart();
- }
- }
-
- handleChange(e, container) {
- console.log('Checkout change');
- let input = window.targetCheck(e, '.quantity-input');
- if (input) {
-
- let item = e.target.closest('.quantity');
- let value = input.value;
-
- if (window.targetCheck(e, '.cart-items')) {
- let main = document.querySelector(`.menu-section [data-id="${item.dataset.id}"] input`);
- if (main) {
- main.value = input.value;
- }
- }
-
- if (value > 0) {
- this.handleAddToCart(item);
- } else {
- this.handleRemoveFromCart(item);
- }
- }
- }
-
- handleNumberClick(e, container) {
- console.log(container);
- e.preventDefault();
- let change = 0;
- let action = '';
- if (e.target.closest('.increase')) {
- change += 1;
- } else if (e.target.closest('.decrease')) {
- change -=1;
- }
- if (change !== 0) {
- let [
- step,
- input
- ] = [
- parseInt(container.dataset.step),
- container.querySelector('input'),
- ];
-
- let value = (input.value === '') ? 0 : parseInt(input.value);
-
- input.value = (value + (step * change * this.stepMultiplier));
-
- input.dispatchEvent(new Event('change', {bubbles: true}));
- this.handleNumberLimits(container);
- }
- }
-
- handleNumberLimits(container) {
- let [
- min,
- max,
- input,
- increase,
- decrease
- ] = [
- container.dataset.min,
- container.dataset.max,
- container.querySelector('input'),
- container.querySelector('.increase'),
- container.querySelector('.decrease')
- ];
- let value = parseInt(input.value);
- if (value < min) {
- input.value = min;
- decrease.disabled = true;
- } else if (value > max) {
- input.value = max;
- increase.disabled = false;
- } else if (increase.disabled) {
- increase.disabled = false;
- } else if (decrease.disabled) {
- decrease.disabled = false;
- }
- }
-
- maybeAddEmptyState() {
- let empty = this.itemsList.querySelector('.empty');
- if(empty) {
- empty.remove();
- }
-
- if (this.cartItems.size === 0) {
- this.checkoutPanel.disabled = true;
- this.checkoutPanel.title = 'Add some things to your cart first!';
- let empty = window.getTemplate('emptyCart');
- this.itemsList.append(empty);
- this.table.closest('table').hidden = true;
- this.total.hidden = true;
- this.a11y.announce('Nothing in Cart');
- } else {
- this.checkoutPanel.disabled = false;
- this.table.closest('table').hidden = false;
- this.total.hidden = false;
- this.checkoutPanel.title = 'Checkout';
- }
- }
- handleEscape(e) {
- if (e.key === 'Escape') {
- this.stepMultiplier = 1;
- } else if (e.ctrlKey && e.shiftKey) {
- this.stepMultiplier = Math.max(parseInt(this.stepMultiplier) * 100, 1000);
- } else if (e.shiftKey) {
- this.stepMultiplier = Math.max(parseInt(this.stepMultiplier) * 10, 1000);
- }
- }
-
- handleAddToCart(item) {
- let id = item.dataset.id;
- this.createItemElement(item);
- let price = parseFloat(item.dataset.price);
- let quantity = parseInt(item.querySelector('.quantity-input')?.value)??1;
- let total = parseFloat(price * quantity);
- this.cartItems.set(id, {
- post_id: id,
- name: item.dataset.name,
- price: price,
- quantity: quantity,
- total: total,
- square_catalog_id: item.dataset.squareCatalogId
- });
- this.saveCart();
- }
-
- notifyRestoredCart() {
- let restored = window.getTemplate('restoredCart');
- this.checkout.querySelector('.tab-content[data-tab=cartItems]').insertBefore(restored, this.itemsList);
-
- this.cartItems.forEach(item => {
- console.log(item);
- let element = window.getTemplate('cartItem');
- let field = element.querySelector('.quantity');
- let price = item.price;
- let quantity = item.quantity;
- [
- field.dataset.id,
- element.querySelector('label').textContent,
- element.querySelector('.price').textContent,
- field.dataset.price,
- field.dataset.squareCatalogId,
- element.querySelector('[name="quantity"]').value,
- element.querySelector('.total').textContent
- ] = [
- item['post_id'],
- item.name,
- window.formatPrice(price),
- price,
- item['square_catalog_id'],
- quantity,
- window.formatPrice(quantity * price)
- ];
- this.table.append(element);
- });
- this.updateTotal();
- }
-
- handleRemoveFromCart(item) {
- if (confirm('This will remove this item from the cart. Continue?')) {
- if (!item.querySelector('[data-id]')) {
- //it's a remove button
- item = item.closest('.item')?.querySelector('.quantity.field');
- }
- let id = item.dataset.id;
- this.cartItems.delete(id);
- this.table.querySelector(`[data-id="${id}"]`)?.closest('tr').remove();
- //Reset the field value to 0 too
- let input = document.querySelector(`[data-id="${id}"] input`);
- if (input){
- input.value = 0;
- }
- this.maybeAddEmptyState();
- this.saveCart();
- }
- }
-
- clearCart() {
- this.cartItems.clear();
- window.removeChildren(this.table);
- this.saveCart();
- }
-
- saveCart() {
- this.updateTotal();
- this.cache.set('cart', this.cartItems);
- }
-
- updateTotal() {
- let total = 0;
- this.cartItems.forEach(item => {
- console.log(item);
- total += item.total;
- });
- let tax = total * .05;
- total = window.formatPrice(total + tax);
- tax = window.formatPrice(tax);
- window.eraseText(this.totalTax);
- window.eraseText(this.grandTotal);
- window.typeText(this.totalTax, tax);
- window.typeText(this.grandTotal, total);
- this.totalTax.classList.remove('typeText');
- }
-
- createItemElement(item) {
- let element = this.itemsList.querySelector(`[data-id="${item.dataset.id}"]`);
- let add = false;
- let price = item.dataset.price;
- let quantity = item.querySelector('[name="quantity"]')?.value??1;
- if (!element) {
- add = true;
- element = window.getTemplate('cartItem');
- let field = element.querySelector('.quantity');
- [
- field.dataset.id,
- element.querySelector('label').textContent,
- element.querySelector('.price').textContent,
- field.dataset.price,
- field.dataset.squareCatalogId
- ] = [
- item.dataset.id,
- item.dataset.name,
- window.formatPrice(price),
- price,
- item.dataset.squareCatalogId
- ];
- }else {
- element = element.closest('tr');
- }
-
- [
- element.querySelector('[name="quantity"]').value,
- element.querySelector('.total').textContent
- ] = [
- quantity,
- window.formatPrice(quantity * price)
- ];
- if (add) {
- element.classList.add('adding');
- this.table.append(element);
- setTimeout(()=> {
- element.classList.remove('adding');
- }, 500);
- }
-
- }
-
- async init() {
- if (!window.Square) {
- console.error('Square Web Payments SDK not loaded');
- return;
- }
-
- try {
- this.payments = window.Square.payments(
- this.config.application_id,
- this.config.location_id
- );
-
- await this.initializePaymentMethods();
-
- this.isInitialized = true;
-
- // Trigger ready event
- document.dispatchEvent(new CustomEvent('squareCheckoutReady', {
- detail: { checkout: this }
- }));
-
- } catch (error) {
- console.error('Failed to initialize Square payments:', error);
- this.handleError(error);
- }
- }
-
- initElements() {
- this.toggle = document.querySelector('.toggle-cart');
-
- if (!this.isOpen) {
- this.toggle.disabled = true;
- this.toggle.title = 'Currently closed for online ordering';
- }
- this.checkoutPanel = this.checkout.querySelector('button[data-tab="checkout"]');
- this.itemsList = this.checkout.querySelector('.cart-items');
- this.table = this.checkout.querySelector('.cart-items tbody');
- this.total = this.checkout.querySelector('.cart-total');
- this.totalTax = this.total.querySelector('.tax span');
- this.grandTotal = this.total.querySelector('.total span');
- this.checkoutForm = this.checkout.querySelector('form');
-
- this.tabs = new window.jvbTabs(this.checkoutForm, {
- updateURL: false
- });
-
- console.log('Initialized Checkout');
- }
-
- initListeners() {
- this.clickHandler = this.handleClick.bind(this);
- this.keyHandler = this.handleEscape.bind(this);
- this.changeHandler = this.handleChange.bind(this);
-
- this.checkoutForm.addEventListener('submit', (e) => this.handleFormSubmit(e));
- document.addEventListener('click', this.clickHandler);
- document.addEventListener('change', this.changeHandler);
- }
-
- async initializePaymentMethods() {
- const cardContainer = document.getElementById('square-card-container');
- if (!cardContainer) return;
-
- try {
- this.card = await this.payments.card({
- style: this.getCardStyle()
- });
- await this.card.attach('#square-card-container');
- this.card.addEventListener('cardBrandChanged', (event) => {
- console.log('Card brand:', event.detail.cardBrand);
- // You could show card brand icon here
- });
- } catch (error) {
- console.error('Failed to initialize card:', error);
- throw error;
- }
- }
-
-
-
- getCardStyle() {
- return {
- input: {
- fontSize: '16px',
- fontFamily: 'inherit',
- color: '#333',
- backgroundColor: '#fff'
- },
- '.input-container': {
- borderColor: '#ccc',
- borderRadius: '4px'
- },
- '.input-container.is-focus': {
- borderColor: '#006AFF',
- borderWidth: '2px',
- outline: '2px solid #006AFF',
- outlineOffset: '2px'
- },
- '.input-container.is-error': {
- borderColor: '#d63638'
- }
- };
- }
-
- async handleFormSubmit(event) {
- if (!this.isOpen) {
- return;
- }
- event.preventDefault();
-
- if (!this.isInitialized) {
- this.handleError('Checkout not initialized');
- return;
- }
-
- const form = event.target;
- const orderData = this.extractOrderData(form);
-
- try {
- window.jvbLoading.showLoading('Processing payment...');
- const result = await this.processPayment(orderData);
- this.handleSuccess(result, form);
- } catch (error) {
- this.handleError(error);
- } finally {
- window.jvbLoading.hideLoading();
- }
- }
-
- extractOrderData(form) {
- const items = Array.from(this.cartItems.values()).map(item => ({
- catalog_object_id: item.square_catalog_id,
- quantity: String(item.quantity),
- price: item.price,
- note: item.note || ''
- }));
-
- const total = items.reduce((sum, item) =>
- sum + (item.price * item.quantity), 0
- );
-
- // Pre-fill customer info if logged in
- return {
- total: Math.round(total * 100),
- items: items,
- customer: {
- email: this.isLoggedIn ? this.userEmail : (form.querySelector('[name="email"]')?.value || ''),
- name: form.querySelector('[name="name"]')?.value || '',
- phone: form.querySelector('[name="phone"]')?.value || ''
- },
- note: form.querySelector('[name="special_instructions"]')?.value || '',
- pickup_time: form.querySelector('[name="pickup_time"]')?.value || ''
- };
- }
-
- async processPayment(orderData) {
- try {
- let sourceToken = null;
-
- // Check if using saved card or new card
- if (this.selectedCardId) {
- // Use saved card
- sourceToken = this.selectedCardId;
- } else {
- // Tokenize new card
- const tokenResult = await this.card.tokenize({
- verificationDetails: {
- amount: String(orderData.total),
- currencyCode: this.config.currency || 'CAD',
- intent: 'CHARGE',
- customerInitiated: true,
- billingContact: {
- givenName: orderData.customer.name.split(' ')[0],
- familyName: orderData.customer.name.split(' ').slice(1).join(' '),
- email: orderData.customer.email,
- phone: orderData.customer.phone,
- addressLines: [form.querySelector('[name="address"]')?.value || ''],
- city: form.querySelector('[name="city"]')?.value || '',
- state: form.querySelector('[name="state"]')?.value || '',
- postalCode: form.querySelector('[name="postal_code"]')?.value || '',
- countryCode: 'CA' // or 'US'
- }
- }
- });
-
- if (tokenResult.status !== 'OK') {
- const errors = tokenResult.errors?.map(e => e.message).join(', ') || 'Unknown error';
- throw new Error(`Card tokenization failed: ${errors}`);
- }
-
- sourceToken = tokenResult.token;
- if (tokenResult.details?.userChallenged) {
- console.log('3D Secure verification completed');
- }
- }
-
- // Send to server
- return await this.submitToServer(sourceToken, orderData, !!this.selectedCardId);
-
- } catch (error) {
- console.error('Payment processing failed:', error);
- throw error;
- }
- }
-
- async submitToServer(sourceToken, orderData, isSavedCard = false) {
- if (!this.isOpen) {
- throw new Error('Store is currently closed');
- }
-
- const response = await fetch(this.config.api_url + 'process-payment', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-WP-Nonce': this.config.nonce
- },
- body: JSON.stringify({
- source_id: sourceToken,
- is_saved_card: isSavedCard,
- cart_id: this.getCartId(),
- amount: orderData.total,
- items: orderData.items,
- customer: {
- email: this.isLoggedIn ? this.userEmail : orderData.customer.email,
- name: orderData.customer.name,
- phone: orderData.customer.phone
- },
- note: orderData.note,
- pickup_time: orderData.pickup_time
- })
- });
-
- const result = await response.json();
-
- if (!response.ok) {
- throw new Error(result.message || 'Payment processing failed');
- }
-
- this.clearCart();
-
- return result;
- }
-
- getCartId() {
- // Generate once per cart session
- if (!this.cartId) {
- this.cartId = crypto.randomUUID();
- this.cache.set('cart_id', this.cartId);
- }
- return this.cartId;
- }
-
- trackOrder(orderNum) {
- this.orderId = orderNum;
- this.scheduleOrderCheck();
- this.checkout.querySelector('button[data-tab=order]').hidden = false;
- }
-
- scheduleOrderCheck(){
- window.debouncer.schedule(
- 'order',
- () => { this.checkOrderStatus()},
- 30000 //30seconds
- )
- }
-
- async checkOrderStatus() {
- const response = await fetch(`/wp-json/jvb/v1/square/order-status/${this.orderId}`);
- const data = await response.json();
- if (data.status !== 'ready') {
- this.scheduleOrderCheck();
- }
- this.updateOrderStatus(data);
- }
-
- updateOrderStatus(data) {
- // Update status timeline
- this.checkout.querySelectorAll('.status-item').forEach(item => {
- if(item.dataset.status === data.status) {
- item.classList.add('active');
- }
- });
-
- // Update ETA
- this.checkout.querySelector('#eta').textContent = data.eta || 'In progress';
- }
- /**************************************************************
- * Customer Data
- **************************************************************/
- /**
- * Load saved cards for logged-in user
- */
- async loadSavedCards() {
- try {
- const response = await fetch(this.config.api_url + 'saved-cards', {
- method: 'GET',
- headers: {
- 'X-WP-Nonce': this.config.nonce
- }
- });
-
- const result = await response.json();
-
- if (result.success && result.cards) {
- this.savedCards = result.cards;
- this.renderSavedCards();
- }
- } catch (error) {
- console.error('Failed to load saved cards:', error);
- }
- }
-
- /**
- * Render saved cards in the checkout form
- */
- renderSavedCards() {
- const container = document.getElementById('saved-cards');
- if (!container || this.savedCards.length === 0) {
- return;
- }
-
- const html = `
- <div class="saved-cards-section">
- <h4>Saved Payment Methods</h4>
- ${this.savedCards.map(card => `
- <label class="saved-card">
- <input type="radio" name="payment-method" value="saved" data-card-id="${card.id}">
- <span class="card-info">
- <strong>${card.card_brand}</strong> ending in ${card.last_4}
- <small>Exp: ${card.exp_month}/${card.exp_year}</small>
- </span>
- </label>
- `).join('')}
- <label class="saved-card">
- <input type="radio" name="payment-method" value="new" checked>
- <span>Use a new card</span>
- </label>
- </div>
- `;
-
- container.innerHTML = html;
-
- // Listen for payment method selection
- container.querySelectorAll('input[name="payment-method"]').forEach(radio => {
- radio.addEventListener('change', (e) => {
- const useNewCard = e.target.value === 'new';
- const cardContainer = document.getElementById('square-card-container');
-
- if (cardContainer) {
- cardContainer.style.display = useNewCard ? 'block' : 'none';
- }
-
- this.selectedCardId = useNewCard ? null : e.target.dataset.cardId;
- });
- });
- }
-
-
- handleSuccess(result, form) {
- // Trigger success event
- document.dispatchEvent(new CustomEvent('squareCheckoutSuccess', {
- detail: { result, form }
- }));
-
- // Default redirect behavior
- const successUrl = form.dataset.successUrl ||
- `/order-confirmation/?order=${result.wp_order_id}`;
- window.location.href = successUrl;
- }
-
- handleError(error) {
- console.error('Square checkout error:', error);
-
- // Trigger error event
- document.dispatchEvent(new CustomEvent('squareCheckoutError', {
- detail: { error }
- }));
-
- // Default error display
- window.jvbNotifications?.show?.(error.message || 'Payment failed', 'error');
- }
-}
-
-document.addEventListener('DOMContentLoaded', () =>{
- window.squareCheckout = new SquareCheckout();
-});
-
-
-// Usage example
-// document.addEventListener('DOMContentLoaded', async function() {
-// // Get config from WordPress
-// const configResponse = await fetch('/wp-json/jvb/v1/square/payment-form');
-// const config = await configResponse.json();
-//
-// const checkout = new SquareCheckout(config);
-//
-// // Handle form submission
-// document.getElementById('checkout-form').addEventListener('submit', async function(e) {
-// e.preventDefault();
-//
-// const orderData = {
-// total: parseFloat(this.dataset.total),
-// items: JSON.parse(this.dataset.items),
-// customer: {
-// email: this.querySelector('[name="email"]').value,
-// name: this.querySelector('[name="name"]').value
-// }
-// };
-//
-// try {
-// const result = await checkout.processPayment(orderData);
-// // Handle success - redirect or show confirmation
-// window.location.href = '/order-confirmation/?order=' + result.wp_order_id;
-// } catch (error) {
-// // Handle error
-// alert('Payment failed: ' + error.message);
-// }
-// });
-// });
diff --git a/assets/js/min/auth.min.js b/assets/js/min/auth.min.js
index 4c588df..9317888 100644
--- a/assets/js/min/auth.min.js
+++ b/assets/js/min/auth.min.js
@@ -1 +1 @@
-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())}))}))}};
\ No newline at end of file
+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())}))}))}};
\ No newline at end of file
diff --git a/assets/js/min/checkout.min.js b/assets/js/min/checkout.min.js
new file mode 100644
index 0000000..071bdc0
--- /dev/null
+++ b/assets/js/min/checkout.min.js
@@ -0,0 +1 @@
+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")}};
\ No newline at end of file
diff --git a/assets/js/min/form.min.js b/assets/js/min/form.min.js
index 4c61125..e36ea87 100644
--- a/assets/js/min/form.min.js
+++ b/assets/js/min/form.min.js
@@ -1 +1 @@
-(()=>{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)}))}))})();
\ No newline at end of file
+(()=>{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)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/helcim.min.js b/assets/js/min/helcim.min.js
new file mode 100644
index 0000000..27ef5ba
--- /dev/null
+++ b/assets/js/min/helcim.min.js
@@ -0,0 +1 @@
+(()=>{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)}))})();
\ No newline at end of file
diff --git a/assets/js/min/populate.min.js b/assets/js/min/populate.min.js
index 250edba..5cc75f4 100644
--- a/assets/js/min/populate.min.js
+++ b/assets/js/min/populate.min.js
@@ -1 +1 @@
-(()=>{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)}))}))})();
\ No newline at end of file
+(()=>{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)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/square.min.js b/assets/js/min/square.min.js
index 3c662a4..df7dd32 100644
--- a/assets/js/min/square.min.js
+++ b/assets/js/min/square.min.js
@@ -1 +1 @@
-(()=>{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}))})();
\ No newline at end of file
+(()=>{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)}))})();
\ No newline at end of file
diff --git a/inc/admin/Integrations.php b/inc/admin/Integrations.php
index 4f47744..cc11446 100644
--- a/inc/admin/Integrations.php
+++ b/inc/admin/Integrations.php
@@ -1,6 +1,8 @@
<?php
namespace JVBase\admin;
+use JVBase\managers\IconsManager;
+
if (!defined('ABSPATH')) {
exit;
}
@@ -69,6 +71,10 @@
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))),
diff --git a/inc/importers/JaneAppSalesImporter.php b/inc/importers/JaneAppSalesImporter.php
index 53c54a1..a071406 100644
--- a/inc/importers/JaneAppSalesImporter.php
+++ b/inc/importers/JaneAppSalesImporter.php
@@ -2,6 +2,7 @@
namespace JVBase\importers;
use WP_Error;
+use JVBase\managers\ReferralManager;
if (!defined('ABSPATH')) {
exit;
diff --git a/inc/integrations/Helcim.php b/inc/integrations/Helcim.php
index a70ac16..d9e4e97 100644
--- a/inc/integrations/Helcim.php
+++ b/inc/integrations/Helcim.php
@@ -1,1633 +1,1345 @@
<?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'),
];
}
}
diff --git a/inc/integrations/Integrations.php b/inc/integrations/Integrations.php
index e059b5e..a89e56c 100644
--- a/inc/integrations/Integrations.php
+++ b/inc/integrations/Integrations.php
@@ -2998,7 +2998,7 @@
$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) {
diff --git a/inc/integrations/Square.php b/inc/integrations/Square.php
index e0c73c5..8c0f234 100644
--- a/inc/integrations/Square.php
+++ b/inc/integrations/Square.php
@@ -6,6 +6,9 @@
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;
@@ -843,229 +846,95 @@
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
+ ));
}
/******************************************************************
@@ -1077,13 +946,12 @@
*/
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');
@@ -1097,40 +965,35 @@
$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
*/
@@ -2097,7 +1960,7 @@
/**
* Enqueue checkout scripts with Square configuration
*/
- public function enqueueScripts():void
+ public function enqueueScripts(): void
{
$this->loadCredentials();
$sdk_url = $this->environment === 'production'
@@ -2109,50 +1972,40 @@
$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 : '',
+ ]);
}
/******************************************************************
@@ -3683,4 +3536,74 @@
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;
+ }
}
diff --git a/inc/managers/AdminPages.php b/inc/managers/AdminPages.php
index cf42870..866893c 100644
--- a/inc/managers/AdminPages.php
+++ b/inc/managers/AdminPages.php
@@ -2,6 +2,8 @@
namespace JVBase\managers;
use JVBase\utility\Features;
+use JVBase\rest\Route;
+use JVBase\rest\PermissionHandler;
use WP_REST_Response;
if (!defined('ABSPATH')) {
@@ -47,182 +49,11 @@
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
*
@@ -615,40 +446,41 @@
*
* @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
@@ -679,11 +511,6 @@
{
$groups = Cache::getAllGroups();
- // Separate generic vs. specific caches
- $generic_groups = [];
- $content_specific = [];
- $nonce = wp_create_nonce('wp_rest');
-
// Separate by type
$generic = [];
$specific = [];
@@ -701,7 +528,9 @@
<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>
@@ -718,15 +547,18 @@
</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>
@@ -748,24 +580,14 @@
</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>
@@ -776,57 +598,6 @@
</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
}
@@ -915,11 +686,10 @@
$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">
@@ -928,9 +698,11 @@
<!-- 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; ?>
@@ -938,11 +710,19 @@
</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>
@@ -985,15 +765,18 @@
</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): ?>
@@ -1008,108 +791,6 @@
</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
}
diff --git a/inc/managers/ScriptLoader.php b/inc/managers/ScriptLoader.php
index d953e55..623eb88 100644
--- a/inc/managers/ScriptLoader.php
+++ b/inc/managers/ScriptLoader.php
@@ -2,7 +2,7 @@
add_action('init', 'jvbRegisterScripts', 5);
function jvbRegisterScripts() {
- $version = '1.1.31';
+ $version = '1.1.33';
$strategy = [
'strategy' => 'defer',
'in_footer' => true
diff --git a/inc/managers/_setup.php b/inc/managers/_setup.php
index 880d9b2..50f6299 100644
--- a/inc/managers/_setup.php
+++ b/inc/managers/_setup.php
@@ -18,7 +18,7 @@
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
diff --git a/inc/managers/queue/Storage.php b/inc/managers/queue/Storage.php
index e4a5397..add78a5 100644
--- a/inc/managers/queue/Storage.php
+++ b/inc/managers/queue/Storage.php
@@ -138,8 +138,8 @@
return false;
}
- Cache::invalidateGroup('queue');
-// $this->invalidateUser($op->userId);
+
+ $this->invalidateUser($op->userId);
return true;
}
@@ -184,9 +184,8 @@
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;
}
@@ -219,8 +218,7 @@
]);
if ($result) {
-// $this->invalidateUser($op->userId);
- Cache::invalidateGroup('queue');
+ $this->invalidateUser($op->userId);
}
return $result !== false;
@@ -450,8 +448,7 @@
private function invalidateUser(int $userId): void
{
- $this->cache->forget($userId);
- Cache::touch($userId);
+ Cache::for($userId.'_queue')->flush();
}
public function getLastError(): string
{
diff --git a/inc/managers/queue/_setup.php b/inc/managers/queue/_setup.php
index f7cbbba..37de16b 100644
--- a/inc/managers/queue/_setup.php
+++ b/inc/managers/queue/_setup.php
@@ -30,6 +30,7 @@
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';
diff --git a/inc/managers/queue/executors/IntegrationExecutor.php b/inc/managers/queue/executors/IntegrationExecutor.php
new file mode 100644
index 0000000..ea79960
--- /dev/null
+++ b/inc/managers/queue/executors/IntegrationExecutor.php
@@ -0,0 +1,287 @@
+<?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()]);
+ }
+ }
+}
diff --git a/inc/meta/Form.php b/inc/meta/Form.php
index 4a515bd..f6de609 100644
--- a/inc/meta/Form.php
+++ b/inc/meta/Form.php
@@ -99,7 +99,7 @@
'<div class="%s" data-field="%s" data-field-type="%s"%s>',
$classes,
$name,
- $config['type'],
+ str_replace('_', '-', $config['type']),
$datasets
);
@@ -276,7 +276,7 @@
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',
@@ -312,7 +312,7 @@
{
$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,
@@ -331,7 +331,7 @@
$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>
@@ -354,7 +354,7 @@
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,
@@ -367,7 +367,7 @@
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,
@@ -380,7 +380,7 @@
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,
@@ -401,7 +401,7 @@
}
}
- $value = ($value === '') ? ' value="'.esc_attr($value).'"' : '';
+ $value = ($value !== '') ? ' value="'.esc_attr($value).'"' : '';
$input = sprintf(
'<input type="date"%s%s />',
$value,
@@ -413,7 +413,7 @@
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,
@@ -425,7 +425,7 @@
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,
@@ -492,6 +492,8 @@
$optionsHtml = '';
if (empty($config['required'])) {
$optionsHtml .= '<option value="">— Select —</option>';
+ } else {
+ $optionsHtml .= '<option value="" disabled selected hidden>— Select —</option>';
}
foreach ($options as $optValue => $optLabel) {
@@ -538,7 +540,7 @@
$optValue,
esc_attr($optValue),
$checked,
- $name,
+ esc_attr($name),
$optValue,
esc_html($optLabel)
);
@@ -564,14 +566,16 @@
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)
);
@@ -1443,7 +1447,7 @@
$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
@@ -1463,7 +1467,7 @@
}
protected static function renderTagItems(array $fields, mixed $value, string $name, string $tagFormat):string
{
- if ($value === '') {
+ if (!$value || $value === '') {
return '';
}
if (is_string($value)) {
@@ -1485,37 +1489,39 @@
$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';
}
@@ -1524,7 +1530,7 @@
$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) {
diff --git a/inc/registry/FieldRegistry.php b/inc/registry/FieldRegistry.php
index 372a1f9..4de8e10 100644
--- a/inc/registry/FieldRegistry.php
+++ b/inc/registry/FieldRegistry.php
@@ -8,7 +8,7 @@
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
@@ -42,11 +42,6 @@
$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);
@@ -165,6 +160,14 @@
}
}
+ 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;
}
diff --git a/inc/registry/providers/HelcimFieldProvider.php b/inc/registry/providers/HelcimFieldProvider.php
deleted file mode 100644
index dcc73af..0000000
--- a/inc/registry/providers/HelcimFieldProvider.php
+++ /dev/null
@@ -1,498 +0,0 @@
-<?php
-namespace JVBase\registry\providers;
-
-if (!defined('ABSPATH')) {
- exit;
-}
-
-class HelcimFieldProvider implements FieldProviderInterface
-{
- public function getFields(...$args): array
- {
- $config = $args[0] ?? [];
-
- $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' => $config['integrations']['helcim']['content_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->getVariationFields($config)
- ],
-
- // 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;
- }
-
- /**
- * Get variation fields
- */
- private function getVariationFields(array $config): array
- {
- $fields = [
- '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
- ]
- ]
- ]
- ]
- ]
- ];
-
- // Add service-specific variation fields
- $product_type = $_POST['product_type'] ?? $config['integrations']['helcim']['content_type'] ?? 'REGULAR';
-
- if ($product_type === 'SERVICE') {
- $fields['service_duration'] = [
- 'type' => 'number',
- 'label' => 'Duration (minutes)',
- 'description' => 'Duration for this service variation'
- ];
-
- $fields['available_for_booking'] = [
- 'type' => 'true_false',
- 'label' => 'Available for Booking'
- ];
- }
-
- if ($product_type === 'FOOD_AND_BEV') {
- $fields['portion_size'] = [
- 'type' => 'select',
- 'label' => 'Portion Size',
- 'options' => [
- 'small' => 'Small',
- 'regular' => 'Regular',
- 'large' => 'Large',
- 'family' => 'Family Size'
- ]
- ];
-
- $fields['calories'] = [
- 'type' => 'number',
- 'label' => 'Calories',
- 'description' => 'Calorie count for this variation'
- ];
- }
-
- if ($product_type === 'DIGITAL') {
- $fields['download_limit'] = [
- 'type' => 'number',
- 'label' => 'Download Limit',
- 'description' => 'Maximum number of downloads',
- 'default' => -1
- ];
-
- $fields['expiry_days'] = [
- 'type' => 'number',
- 'label' => 'Access Duration (days)',
- 'description' => 'Days until download expires',
- 'default' => 0
- ];
- }
-
- return $fields;
- }
-}
diff --git a/inc/rest/Route.php b/inc/rest/Route.php
index 4deb44a..dd356a9 100644
--- a/inc/rest/Route.php
+++ b/inc/rest/Route.php
@@ -334,9 +334,33 @@
'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);
diff --git a/inc/rest/_setup.php b/inc/rest/_setup.php
index 26cbb13..7ba414a 100644
--- a/inc/rest/_setup.php
+++ b/inc/rest/_setup.php
@@ -37,7 +37,7 @@
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');
@@ -68,6 +68,10 @@
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');
diff --git a/inc/rest/routes/AdminRoutes.php b/inc/rest/routes/AdminRoutes.php
new file mode 100644
index 0000000..4f89e76
--- /dev/null
+++ b/inc/rest/routes/AdminRoutes.php
@@ -0,0 +1,225 @@
+<?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);
+ }
+ }
+}
diff --git a/inc/rest/routes/ApprovalRoutes.php b/inc/rest/routes/ApprovalRoutes.php
index e01ffd5..6f9d590 100644
--- a/inc/rest/routes/ApprovalRoutes.php
+++ b/inc/rest/routes/ApprovalRoutes.php
@@ -80,7 +80,8 @@
'notes' => 'string',
])
->auth(PermissionHandler::combine(['user', 'verified']))
- ->rateLimit(3);
+ ->rateLimit(3)
+ ->register();
}
/**
diff --git a/inc/rest/routes/ContentRoutes.php b/inc/rest/routes/ContentRoutes.php
index bbeefce..1c90f30 100644
--- a/inc/rest/routes/ContentRoutes.php
+++ b/inc/rest/routes/ContentRoutes.php
@@ -96,7 +96,8 @@
'user' => 'int|required',
'posts' => 'required',
'content' => 'string',
- ]);
+ ])
+ ->register();
}
protected function initTimelineFields(string $content): void
diff --git a/inc/rest/routes/ContentTermsRoutes.php b/inc/rest/routes/ContentTermsRoutes.php
index aa863e0..eee6b6b 100644
--- a/inc/rest/routes/ContentTermsRoutes.php
+++ b/inc/rest/routes/ContentTermsRoutes.php
@@ -117,8 +117,9 @@
'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')) {
@@ -139,8 +140,9 @@
'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)
@@ -153,8 +155,9 @@
'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'])
@@ -166,7 +169,8 @@
'notes' => 'string'
])
->auth('verified')
- ->rateLimit(5);
+ ->rateLimit(5)
+ ->register();
}
// Ownership/management (if is_ownable enabled)
@@ -180,8 +184,9 @@
'role' => 'string|enum:owner,manager|required',
'grant' => 'bool|required'
])
- ->auth(PermissionHandler::custom([$this, 'checkOwnerPermission']))
- ->rateLimit(5);
+ ->auth(PermissionHandler::combine([[$this, 'checkTermPermission']]))
+ ->rateLimit(5)
+ ->register();
}
}
diff --git a/inc/rest/routes/ErrorRoutes.php b/inc/rest/routes/ErrorRoutes.php
index 0a4737e..aab3658 100644
--- a/inc/rest/routes/ErrorRoutes.php
+++ b/inc/rest/routes/ErrorRoutes.php
@@ -26,7 +26,8 @@
'context' => 'string',
])
->auth('public')
- ->rateLimit(10);
+ ->rateLimit(10)
+ ->register();
}
/**
diff --git a/inc/rest/routes/FavouritesRoutes.php b/inc/rest/routes/FavouritesRoutes.php
index f127aae..9690a53 100644
--- a/inc/rest/routes/FavouritesRoutes.php
+++ b/inc/rest/routes/FavouritesRoutes.php
@@ -80,7 +80,8 @@
'notes' => 'string',
])
->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']]))
- ->rateLimit(30);
+ ->rateLimit(30)
+ ->register();
// Lists endpoints
Route::for('favourites/lists')
@@ -98,13 +99,15 @@
'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();
}
/**
diff --git a/inc/rest/routes/FeedRoutes.php b/inc/rest/routes/FeedRoutes.php
index af6c2c1..0368b21 100644
--- a/inc/rest/routes/FeedRoutes.php
+++ b/inc/rest/routes/FeedRoutes.php
@@ -95,13 +95,15 @@
'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();
}
/**
diff --git a/inc/rest/routes/FormRoutes.php b/inc/rest/routes/FormRoutes.php
index 9b136e2..e6be516 100644
--- a/inc/rest/routes/FormRoutes.php
+++ b/inc/rest/routes/FormRoutes.php
@@ -57,14 +57,16 @@
->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();
}
/**
diff --git a/inc/rest/routes/ImporterRoutes.php b/inc/rest/routes/ImporterRoutes.php
index 2e4bdaf..b5debfa 100644
--- a/inc/rest/routes/ImporterRoutes.php
+++ b/inc/rest/routes/ImporterRoutes.php
@@ -33,7 +33,8 @@
'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')
@@ -42,14 +43,16 @@
'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();
}
/**
diff --git a/inc/rest/routes/IntegrationsHelcimRoutes.php b/inc/rest/routes/IntegrationsHelcimRoutes.php
new file mode 100644
index 0000000..6476e16
--- /dev/null
+++ b/inc/rest/routes/IntegrationsHelcimRoutes.php
@@ -0,0 +1,187 @@
+<?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());
+ }
+ }
+}
diff --git a/inc/rest/routes/IntegrationsRoutes.php b/inc/rest/routes/IntegrationsRoutes.php
index 73112de..ce909bf 100644
--- a/inc/rest/routes/IntegrationsRoutes.php
+++ b/inc/rest/routes/IntegrationsRoutes.php
@@ -29,7 +29,8 @@
'data' => 'array'
])
->auth('user')
- ->rateLimit(20);
+ ->rateLimit(20)
+ ->register();
Route::for('oath/connect')
->post([$this, 'initiateOAuth'])
->auth('user')
@@ -38,7 +39,8 @@
'service' => 'string|required',
'user_id' => 'int',
'return_url'=> 'url'
- ]);
+ ])
+ ->register();
}
/**
diff --git a/inc/rest/routes/IntegrationsSquareRoutes.php b/inc/rest/routes/IntegrationsSquareRoutes.php
index 596be1e..4734a65 100644
--- a/inc/rest/routes/IntegrationsSquareRoutes.php
+++ b/inc/rest/routes/IntegrationsSquareRoutes.php
@@ -20,22 +20,26 @@
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?
diff --git a/inc/rest/routes/Invitations.php b/inc/rest/routes/Invitations.php
index 7cccb3e..1839f44 100644
--- a/inc/rest/routes/Invitations.php
+++ b/inc/rest/routes/Invitations.php
@@ -60,7 +60,8 @@
'invitation_id' => 'int'
])
->auth('verified')
- ->rateLimit(10, 300);
+ ->rateLimit(10, 300)
+ ->register();
}
/**
diff --git a/inc/rest/routes/LoginRoutes.php b/inc/rest/routes/LoginRoutes.php
index 2898b23..dc8d528 100644
--- a/inc/rest/routes/LoginRoutes.php
+++ b/inc/rest/routes/LoginRoutes.php
@@ -39,7 +39,8 @@
Route::for('auth/status')
->get([$this, 'getAuthStatus'])
->auth('public')
- ->rateLimit();
+ ->rateLimit()
+ ->register();
// Standard login
Route::for('auth/login')
@@ -51,7 +52,8 @@
'redirect_to' => 'string',
])
->auth('public')
- ->rateLimit(5, 300);
+ ->rateLimit(5, 300)
+ ->register();
// User registration
Route::for('auth/register')
@@ -64,7 +66,8 @@
'redirect_to' => 'string',
])
->auth('public')
- ->rateLimit(3, 3600);
+ ->rateLimit(3, 3600)
+ ->register();
// Request password reset
Route::for('auth/lostpassword')
@@ -73,7 +76,8 @@
'user_email' => 'email|required',
])
->auth('public')
- ->rateLimit(3, 3600);
+ ->rateLimit(3, 3600)
+ ->register();
// Reset password with token
Route::for('auth/resetpass')
@@ -85,7 +89,8 @@
'pass2' => 'string|required',
])
->auth('public')
- ->rateLimit(5, 300);
+ ->rateLimit(5, 300)
+ ->register();
// Magic link endpoint
if ($this->hasMagicLink) {
@@ -97,16 +102,16 @@
'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();
}
/**
diff --git a/inc/rest/routes/NewsRoutes.php b/inc/rest/routes/NewsRoutes.php
index bb9caae..b0aedfc 100644
--- a/inc/rest/routes/NewsRoutes.php
+++ b/inc/rest/routes/NewsRoutes.php
@@ -60,13 +60,15 @@
'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();
}
/**
diff --git a/inc/rest/routes/NotificationsRoutes.php b/inc/rest/routes/NotificationsRoutes.php
index f523b62..ee85aeb 100644
--- a/inc/rest/routes/NotificationsRoutes.php
+++ b/inc/rest/routes/NotificationsRoutes.php
@@ -152,7 +152,8 @@
'offset' => 'integer|default:0',
])
->auth('user')
- ->rateLimit(30);
+ ->rateLimit(30)
+ ->register();
// Mark as read
Route::for('notifications/read')
@@ -162,7 +163,8 @@
'notification_id' => 'integer|required',
])
->auth('user')
- ->rateLimit(30);
+ ->rateLimit(30)
+ ->register();
// Mark all as read
Route::for('notifications/read-all')
@@ -172,7 +174,8 @@
'type' => 'string',
])
->auth('user')
- ->rateLimit(10);
+ ->rateLimit(10)
+ ->register();
// Mark as actioned
Route::for('notifications/action')
@@ -182,7 +185,8 @@
'notification_id' => 'integer|required',
])
->auth('user')
- ->rateLimit(30);
+ ->rateLimit(30)
+ ->register();
// Dismiss notification
Route::for('notifications/dismiss')
@@ -192,7 +196,8 @@
'notification_id' => 'integer|required',
])
->auth('user')
- ->rateLimit(30);
+ ->rateLimit(30)
+ ->register();
// Get unread count
Route::for('notifications/count')
@@ -202,7 +207,8 @@
'type' => 'string',
])
->auth('user')
- ->rateLimit(60);
+ ->rateLimit()
+ ->register();
}
// =========================================================================
diff --git a/inc/rest/routes/OptionsRoutes.php b/inc/rest/routes/OptionsRoutes.php
index 2bb2c2c..355db8c 100644
--- a/inc/rest/routes/OptionsRoutes.php
+++ b/inc/rest/routes/OptionsRoutes.php
@@ -32,7 +32,8 @@
->args([
'user' => 'int|required',
'id' => 'string|required',
- ]);
+ ])
+ ->register();
}
public function saveOptions(WP_REST_Request $request):WP_REST_Response
diff --git a/inc/rest/routes/QueueRoutes.php b/inc/rest/routes/QueueRoutes.php
index 3c27a3a..ad4507f 100644
--- a/inc/rest/routes/QueueRoutes.php
+++ b/inc/rest/routes/QueueRoutes.php
@@ -48,7 +48,8 @@
'action' => 'string|required|enum:dismiss,retry,cancel',
])
->auth('user')
- ->rateLimit(30);
+ ->rateLimit(30)
+ ->register();
// Poll endpoint
Route::for('queue/poll')
@@ -58,20 +59,23 @@
'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();
}
/**
@@ -84,6 +88,7 @@
{
$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'])))
@@ -145,6 +150,8 @@
$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']);
@@ -176,6 +183,7 @@
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');
@@ -214,6 +222,7 @@
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'],
@@ -239,6 +248,7 @@
{
$id = $request->get_param('id');
$userId = $request->get_param('user');
+ $this->cache = Cache::for($userId.'_queue');
$op = JVB()->queue()->get($id);
diff --git a/inc/rest/routes/ReferralRoutes.php b/inc/rest/routes/ReferralRoutes.php
index 787f22f..bd8ab1d 100644
--- a/inc/rest/routes/ReferralRoutes.php
+++ b/inc/rest/routes/ReferralRoutes.php
@@ -56,7 +56,8 @@
'action' => 'string|required|enum:invite,consulted,treated,remove,resend'
])
->auth('user')
- ->rateLimit(10);
+ ->rateLimit(10)
+ ->register();
// Referral code endpoint
Route::for('referrals/code')
@@ -67,32 +68,37 @@
->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();
}
/**
diff --git a/inc/rest/routes/SEORoutes.php b/inc/rest/routes/SEORoutes.php
index e5154ed..52602a6 100644
--- a/inc/rest/routes/SEORoutes.php
+++ b/inc/rest/routes/SEORoutes.php
@@ -43,14 +43,16 @@
'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();
}
/**
diff --git a/inc/rest/routes/SettingsRoutes.php b/inc/rest/routes/SettingsRoutes.php
index f49c2bd..26b77ea 100644
--- a/inc/rest/routes/SettingsRoutes.php
+++ b/inc/rest/routes/SettingsRoutes.php
@@ -38,7 +38,8 @@
Route::for('settings')
->post([$this, 'saveSettings'])
->auth(PermissionHandler::combine(['admin', ['actionNonce' => 'dash-']]))
- ->rateLimit(20);
+ ->rateLimit(20)
+ ->register();
}
/**
diff --git a/inc/rest/routes/TermRoutes.php b/inc/rest/routes/TermRoutes.php
index 7f03d49..431c7b5 100644
--- a/inc/rest/routes/TermRoutes.php
+++ b/inc/rest/routes/TermRoutes.php
@@ -56,12 +56,14 @@
'taxonomy' => 'string|required',
'name' => 'string|required',
'parent' => 'int|default:0',
- ]);
+ ])
+ ->register();
Route::for('terms/check')
->get([$this,'getTermDetails'])
->auth('public')
- ->rateLimit();
+ ->rateLimit()
+ ->register();
}
/**
diff --git a/inc/rest/routes/UploadRoutes.php b/inc/rest/routes/UploadRoutes.php
index 04e5488..39518ae 100644
--- a/inc/rest/routes/UploadRoutes.php
+++ b/inc/rest/routes/UploadRoutes.php
@@ -99,7 +99,8 @@
'id' => 'string|required',
'content' => 'string|required',
'user' => 'int|required'
- ]);
+ ])
+ ->register();
Route::for('uploads/meta')
->post([$this, 'handleMetadataUpdate'])
@@ -108,7 +109,8 @@
->args([
'user' => 'int|required',
'items' => 'array|required'
- ]);
+ ])
+ ->register();
}
/**
diff --git a/inc/rest/routes/VoteRoutes.php b/inc/rest/routes/VoteRoutes.php
index 1ddf49f..b79ec08 100644
--- a/inc/rest/routes/VoteRoutes.php
+++ b/inc/rest/routes/VoteRoutes.php
@@ -46,7 +46,8 @@
'user' => 'integer',
])
->auth('user')
- ->rateLimit(30);
+ ->rateLimit(30)
+ ->register();
}
/**
diff --git a/inc/ui/CRUDSkeleton.php b/inc/ui/CRUDSkeleton.php
index 3fa2dc6..481f0e1 100644
--- a/inc/ui/CRUDSkeleton.php
+++ b/inc/ui/CRUDSkeleton.php
@@ -1576,7 +1576,6 @@
$tabs = false;
}
-
$fields = $this->fields;
if (!$this->isTimeline) {
$first = ['post_thumbnail', 'post_title', 'price'];
@@ -1586,9 +1585,8 @@
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]);
}
}
@@ -1629,7 +1627,7 @@
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);
}
}
diff --git a/inc/ui/Checkout.php b/inc/ui/Checkout.php
new file mode 100644
index 0000000..974d09f
--- /dev/null
+++ b/inc/ui/Checkout.php
@@ -0,0 +1,285 @@
+<?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>';
+ }
+}
diff --git a/inc/ui/_setup.php b/inc/ui/_setup.php
index 29cfcc0..5e9a8bf 100644
--- a/inc/ui/_setup.php
+++ b/inc/ui/_setup.php
@@ -3,3 +3,4 @@
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');
diff --git a/webpack.jvb.js b/webpack.jvb.js
index 7983537..7995231 100644
--- a/webpack.jvb.js
+++ b/webpack.jvb.js
@@ -7,7 +7,7 @@
'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',
@@ -40,7 +40,9 @@
'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',
--
Gitblit v1.10.0