Jake Vanderwerf
2026-02-08 df6c00db050e188a6bd5707e72c4f1f331ced923
=Port over to jakevan 2
2 files deleted
47 files modified
9 files added
7871 ■■■■ changed files
JVBase.php 8 ●●●● patch | view | raw | blame | history
assets/js/admin.js 394 ●●●● patch | view | raw | blame | history
assets/js/concise/Checkout.js 542 ●●●●● patch | view | raw | blame | history
assets/js/concise/CheckoutHelcim.js 274 ●●●●● patch | view | raw | blame | history
assets/js/concise/CheckoutSquare.js 244 ●●●●● patch | view | raw | blame | history
assets/js/concise/FormController.js 217 ●●●● patch | view | raw | blame | history
assets/js/concise/PopulateForm.js 57 ●●●● patch | view | raw | blame | history
assets/js/concise/SquareCheckout.js 739 ●●●●● patch | view | raw | blame | history
assets/js/min/auth.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/checkout.min.js 1 ●●●● patch | view | raw | blame | history
assets/js/min/form.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/helcim.min.js 1 ●●●● patch | view | raw | blame | history
assets/js/min/populate.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/square.min.js 2 ●●● patch | view | raw | blame | history
inc/admin/Integrations.php 6 ●●●●● patch | view | raw | blame | history
inc/importers/JaneAppSalesImporter.php 1 ●●●● patch | view | raw | blame | history
inc/integrations/Helcim.php 2634 ●●●●● patch | view | raw | blame | history
inc/integrations/Integrations.php 2 ●●● patch | view | raw | blame | history
inc/integrations/Square.php 469 ●●●●● patch | view | raw | blame | history
inc/managers/AdminPages.php 457 ●●●● patch | view | raw | blame | history
inc/managers/ScriptLoader.php 2 ●●● patch | view | raw | blame | history
inc/managers/_setup.php 2 ●●● patch | view | raw | blame | history
inc/managers/queue/Storage.php 15 ●●●●● patch | view | raw | blame | history
inc/managers/queue/_setup.php 1 ●●●● patch | view | raw | blame | history
inc/managers/queue/executors/IntegrationExecutor.php 287 ●●●●● patch | view | raw | blame | history
inc/meta/Form.php 64 ●●●● patch | view | raw | blame | history
inc/registry/FieldRegistry.php 15 ●●●●● patch | view | raw | blame | history
inc/registry/providers/HelcimFieldProvider.php 498 ●●●●● patch | view | raw | blame | history
inc/rest/Route.php 24 ●●●●● patch | view | raw | blame | history
inc/rest/_setup.php 6 ●●●● patch | view | raw | blame | history
inc/rest/routes/AdminRoutes.php 225 ●●●●● patch | view | raw | blame | history
inc/rest/routes/ApprovalRoutes.php 3 ●●●● patch | view | raw | blame | history
inc/rest/routes/ContentRoutes.php 3 ●●●● patch | view | raw | blame | history
inc/rest/routes/ContentTermsRoutes.php 23 ●●●●● patch | view | raw | blame | history
inc/rest/routes/ErrorRoutes.php 3 ●●●● patch | view | raw | blame | history
inc/rest/routes/FavouritesRoutes.php 9 ●●●●● patch | view | raw | blame | history
inc/rest/routes/FeedRoutes.php 6 ●●●●● patch | view | raw | blame | history
inc/rest/routes/FormRoutes.php 6 ●●●●● patch | view | raw | blame | history
inc/rest/routes/ImporterRoutes.php 9 ●●●●● patch | view | raw | blame | history
inc/rest/routes/IntegrationsHelcimRoutes.php 187 ●●●●● patch | view | raw | blame | history
inc/rest/routes/IntegrationsRoutes.php 6 ●●●●● patch | view | raw | blame | history
inc/rest/routes/IntegrationsSquareRoutes.php 12 ●●●●● patch | view | raw | blame | history
inc/rest/routes/Invitations.php 3 ●●●● patch | view | raw | blame | history
inc/rest/routes/LoginRoutes.php 23 ●●●●● patch | view | raw | blame | history
inc/rest/routes/NewsRoutes.php 6 ●●●●● patch | view | raw | blame | history
inc/rest/routes/NotificationsRoutes.php 18 ●●●●● patch | view | raw | blame | history
inc/rest/routes/OptionsRoutes.php 3 ●●●● patch | view | raw | blame | history
inc/rest/routes/QueueRoutes.php 18 ●●●● patch | view | raw | blame | history
inc/rest/routes/ReferralRoutes.php 18 ●●●●● patch | view | raw | blame | history
inc/rest/routes/SEORoutes.php 6 ●●●●● patch | view | raw | blame | history
inc/rest/routes/SettingsRoutes.php 3 ●●●● patch | view | raw | blame | history
inc/rest/routes/TermRoutes.php 6 ●●●●● patch | view | raw | blame | history
inc/rest/routes/UploadRoutes.php 6 ●●●●● patch | view | raw | blame | history
inc/rest/routes/VoteRoutes.php 3 ●●●● patch | view | raw | blame | history
inc/ui/CRUDSkeleton.php 6 ●●●●● patch | view | raw | blame | history
inc/ui/Checkout.php 285 ●●●●● patch | view | raw | blame | history
inc/ui/_setup.php 1 ●●●● patch | view | raw | blame | history
webpack.jvb.js 6 ●●●●● patch | view | raw | blame | history
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();
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);
    }
}
assets/js/concise/Checkout.js
New file
@@ -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;
assets/js/concise/CheckoutHelcim.js
New file
@@ -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();
    }
});
assets/js/concise/CheckoutSquare.js
New file
@@ -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();
    }
});
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);
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 => {
assets/js/concise/SquareCheckout.js
File was deleted
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())}))}))}};
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())}))}))}};
assets/js/min/checkout.min.js
New file
@@ -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")}};
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)}))}))})();
(()=>{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)}))}))})();
assets/js/min/helcim.min.js
New file
@@ -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)}))})();
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)}))}))})();
(()=>{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)}))}))})();
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}))})();
(()=>{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)}))})();
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))),
inc/importers/JaneAppSalesImporter.php
@@ -2,6 +2,7 @@
namespace JVBase\importers;
use WP_Error;
use JVBase\managers\ReferralManager;
if (!defined('ABSPATH')) {
    exit;
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'),
        ];
    }
}
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) {
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;
    }
}
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
    }
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
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
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
    {
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';
inc/managers/queue/executors/IntegrationExecutor.php
New file
@@ -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()]);
        }
    }
}
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) {
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;
    }
inc/registry/providers/HelcimFieldProvider.php
File was deleted
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);
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');
inc/rest/routes/AdminRoutes.php
New file
@@ -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);
        }
    }
}
inc/rest/routes/ApprovalRoutes.php
@@ -80,7 +80,8 @@
                'notes' => 'string',
            ])
            ->auth(PermissionHandler::combine(['user', 'verified']))
            ->rateLimit(3);
            ->rateLimit(3)
            ->register();
    }
    /**
inc/rest/routes/ContentRoutes.php
@@ -96,7 +96,8 @@
                'user'  => 'int|required',
                'posts' => 'required',
                'content' => 'string',
            ]);
            ])
            ->register();
    }
    protected function initTimelineFields(string $content): void
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();
        }
    }
inc/rest/routes/ErrorRoutes.php
@@ -26,7 +26,8 @@
                'context' => 'string',
            ])
            ->auth('public')
            ->rateLimit(10);
            ->rateLimit(10)
            ->register();
    }
    /**
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();
    }
    /**
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();
    }
    /**
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();
    }
    /**
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();
    }
    /**
inc/rest/routes/IntegrationsHelcimRoutes.php
New file
@@ -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());
        }
    }
}
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();
    }
    /**
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?
inc/rest/routes/Invitations.php
@@ -60,7 +60,8 @@
                'invitation_id' => 'int'
            ])
            ->auth('verified')
            ->rateLimit(10, 300);
            ->rateLimit(10, 300)
            ->register();
    }
    /**
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();
    }
    /**
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();
    }
    /**
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();
    }
    // =========================================================================
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
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);
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();
    }
    /**
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();
    }
    /**
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();
    }
    /**
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();
    }
    /**
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();
    }
    /**
inc/rest/routes/VoteRoutes.php
@@ -46,7 +46,8 @@
                'user' => 'integer',
            ])
            ->auth('user')
            ->rateLimit(30);
            ->rateLimit(30)
            ->register();
    }
    /**
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);
                    }
                }
inc/ui/Checkout.php
New file
@@ -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>';
    }
}
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');
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',