Jake Vanderwerf
2026-01-20 7a9054bb3f033c98067b3196378311dae54c5fbf
=OperationQueue refactor to the JVBase/managers/queue namespace
36 files modified
16 files added
3828 ■■■■ changed files
JVBase.php 6 ●●●● patch | view | raw | blame | history
assets/js/concise/FormController.js 183 ●●●●● patch | view | raw | blame | history
assets/js/concise/Queue.js 16 ●●●●● patch | view | raw | blame | history
assets/js/concise/Referral.js 3 ●●●● patch | view | raw | blame | history
assets/js/min/form.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/queue.min.js 2 ●●● patch | view | raw | blame | history
assets/js/min/referral.min.js 2 ●●● patch | view | raw | blame | history
inc/EmbedGenerator.php 15 ●●●● patch | view | raw | blame | history
inc/blocks/CustomBlocks.php 8 ●●●●● patch | view | raw | blame | history
inc/blocks/FormBlock.php 4 ●●● patch | view | raw | blame | history
inc/integrations/GoogleMyBusiness.php 22 ●●●●● patch | view | raw | blame | history
inc/integrations/Integrations.php 5 ●●●● patch | view | raw | blame | history
inc/integrations/PostMark.php 4 ●●● patch | view | raw | blame | history
inc/managers/AdminPages.php 2 ●●● patch | view | raw | blame | history
inc/managers/DashboardManager.php 1 ●●●● patch | view | raw | blame | history
inc/managers/IconsManager.php 4 ●●● patch | view | raw | blame | history
inc/managers/LoginManager.php 186 ●●●● patch | view | raw | blame | history
inc/managers/SEO/BreadcrumbManager.php 4 ●●● patch | view | raw | blame | history
inc/managers/SEOMetaManager.php 3 ●●●● patch | view | raw | blame | history
inc/managers/SchemaManager.php 44 ●●●● patch | view | raw | blame | history
inc/managers/UmamiMetrics.php 98 ●●●● patch | view | raw | blame | history
inc/managers/_setup.php 3 ●●●● patch | view | raw | blame | history
inc/managers/queue/Executor.php 14 ●●●●● patch | view | raw | blame | history
inc/managers/queue/FilteredExecutor.php 154 ●●●●● patch | view | raw | blame | history
inc/managers/queue/Locker.php 65 ●●●●● patch | view | raw | blame | history
inc/managers/queue/Mergeable.php 11 ●●●●● patch | view | raw | blame | history
inc/managers/queue/Metrics.php 210 ●●●●● patch | view | raw | blame | history
inc/managers/queue/Operation.php 36 ●●●●● patch | view | raw | blame | history
inc/managers/queue/Processor.php 91 ●●●●● patch | view | raw | blame | history
inc/managers/queue/Progress.php 31 ●●●●● patch | view | raw | blame | history
inc/managers/queue/Queue.php 390 ●●●●● patch | view | raw | blame | history
inc/managers/queue/Result.php 13 ●●●●● patch | view | raw | blame | history
inc/managers/queue/Storage.php 372 ●●●●● patch | view | raw | blame | history
inc/managers/queue/TypeConfig.php 16 ●●●●● patch | view | raw | blame | history
inc/managers/queue/TypeRegistry.php 52 ●●●●● patch | view | raw | blame | history
inc/managers/queue/_setup.php 30 ●●●●● patch | view | raw | blame | history
inc/managers/queue/executors/ContentExecutor.php 580 ●●●●● patch | view | raw | blame | history
inc/managers/queue/executors/UploadExecutor.php 628 ●●●●● patch | view | raw | blame | history
inc/meta/MetaForm.php 4 ●●●● patch | view | raw | blame | history
inc/meta/MetaManager.php 4 ●●● patch | view | raw | blame | history
inc/registry/CheckCustomTables.php 54 ●●●●● patch | view | raw | blame | history
inc/registry/FieldRegistry.php 4 ●●● patch | view | raw | blame | history
inc/rest/routes/AdminRoutes.php 13 ●●●● patch | view | raw | blame | history
inc/rest/routes/ContentRoutes.php 28 ●●●● patch | view | raw | blame | history
inc/rest/routes/FavouritesRoutes.php 4 ●●● patch | view | raw | blame | history
inc/rest/routes/FeedRoutes.php 3 ●●●● patch | view | raw | blame | history
inc/rest/routes/FormRoutes.php 6 ●●●●● patch | view | raw | blame | history
inc/rest/routes/Invitations.php 18 ●●●●● patch | view | raw | blame | history
inc/rest/routes/LoginRoutes.php 23 ●●●● patch | view | raw | blame | history
inc/rest/routes/NewsRoutes.php 4 ●●● patch | view | raw | blame | history
inc/rest/routes/QueueRoutes.php 296 ●●●●● patch | view | raw | blame | history
inc/rest/routes/UploadRoutes.php 57 ●●●●● patch | view | raw | blame | history
JVBase.php
@@ -8,7 +8,7 @@
use JVBase\managers\ErrorHandler;
use JVBase\managers\LoginManager;
use JVBase\managers\MagicLinkManager;
use JVBase\managers\OperationQueue;
use JVBase\managers\queue\Queue;
use JVBase\managers\DashboardManager;
use JVBase\managers\DirectoryManager;
use JVBase\managers\ReferralManager;
@@ -88,7 +88,7 @@
        $this->customBlocks = new CustomBlocks();
        $this->managers = [
            'errors'        => new ErrorHandler(),
            'queue'         => new OperationQueue(),
            'queue'         => new Queue(),
//            'dash'          => new DashboardManager(),
            'roles'         => new RoleManager(),
//            'forms'         => new FormManager(),
@@ -222,7 +222,7 @@
    {
        return $this->managers['cache'];
    }
    public function queue():OperationQueue
    public function queue():Queue
    {
        return $this->managers['queue'];
    }
assets/js/concise/FormController.js
@@ -320,8 +320,6 @@
        }
        let form = this.getForm(e.target);
        //Autosave
        if (!form || !form.options.cache) return;
        this.updateItem(field.dataset.field, this.getFieldValue(e.target), form);
    }
@@ -335,9 +333,7 @@
        window.debouncer.cancel(`form:${form.id}:validate:${fieldName}`);
        this.validateField(e.target);
        if (form.options.cache) {
            this.updateItem(fieldName, this.getFieldValue(e.target), form);
        }
        this.updateItem(fieldName, this.getFieldValue(e.target), form);
    }
    handleInput(e){
@@ -363,10 +359,18 @@
            e.preventDefault();
            const storedData = await this.store.get(form.id);
            this.notify('form-submit', {
                config: form,
                data: storedData?.changes || {}
            });
            if (form.options.cache) {
                this.notify('form-submit', {
                    config: form,
                    data: storedData.changes
                });
            } else {
                this.notify('form-submit', {
                    config: form,
                    data: this.changes.get(form.id)?.changes??{},
                });
            }
        }
        if (form.options.showSummary) {
@@ -396,7 +400,9 @@
        let changes = this.changes.get(form.id);
        changes.changes[name] = value;
        this.changes.set(form.id, changes);
        this.scheduleBackup();
        if (form.options.cache) {
            this.scheduleBackup();
        }
    }
    scheduleBackup()  {
@@ -1194,6 +1200,146 @@
        }
    }
    handleFormSuccess(form, data) {
        // Clear previous errors
        form.querySelectorAll('.error-message').forEach(el => el.remove());
        form.querySelectorAll('.field-error').forEach(el =>
            el.classList.remove('field-error')
        );
        // Add success class to form
        form.classList.add('form-success');
        // Show success message if provided
        if (data.message) {
            const success = document.createElement('div');
            success.className = 'form-success-message success-message';
            success.textContent = data.message;
            form.insertBefore(success, form.firstChild);
            const icon = window.getIcon?.('check-circle');
            if (icon) {
                icon.classList.add('success-icon');
                success.prepend(icon);
            }
        }
        // If there's a title/description (for registration success)
        if (data.title || data.description) {
            const successBox = document.createElement('div');
            successBox.className = 'success-box';
            if (data.title) {
                const title = document.createElement('h3');
                title.textContent = data.title;
                successBox.appendChild(title);
            }
            if (data.description) {
                const descriptions = Array.isArray(data.description)
                    ? data.description
                    : [data.description];
                descriptions.forEach(desc => {
                    const p = document.createElement('p');
                    p.textContent = desc;
                    successBox.appendChild(p);
                });
            }
            form.insertBefore(successBox, form.firstChild);
        }
        //  DELETE CACHED FORM DATA ON SUCCESS
        if (form.dataset.formId) {
            this.store.delete(form.dataset.formId).catch(err => {
                console.warn('Failed to clear form cache:', err);
            });
            // Clear form config dirty state
            const formConfig = this.forms.get(form.dataset.formId);
            if (formConfig) {
                formConfig.isDirty = false;
                formConfig.lastSaved = Date.now();
                formConfig.data = {}; // Clear cached data
            }
        }
        // Announce success for accessibility
        if (window.jvbA11y) {
            window.jvbA11y.announce(data.message || 'Form submitted successfully');
        }
        // Trigger custom event
        form.dispatchEvent(new CustomEvent('jvb-form-success', {
            detail: data
        }));
    }
    handleFormError(form, data) {
        // Clear all previous errors
        form.querySelectorAll('.error-message').forEach(el => el.remove());
        form.querySelectorAll('.field-error, .has-error').forEach(el => {
            el.classList.remove('field-error', 'has-error');
        });
        // Clear validation states using existing method
        form.querySelectorAll('.field').forEach(fieldWrapper => {
            this.clearValidation(fieldWrapper);
        });
        // Handle field-specific errors
        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');
                error.prepend(icon);
            }
            form.insertBefore(error, form.firstChild);
            // Scroll to top to show the error
            form.scrollIntoView({ behavior: 'smooth', block: 'start' });
        }
        // Announce error for accessibility
        if (window.jvbA11y) {
            const announcement = data.field
                ? `Error in ${data.field}: ${data.message}`
                : `Form error: ${data.message}`;
            window.jvbA11y.announce(announcement);
        }
        // Trigger custom event
        form.dispatchEvent(new CustomEvent('jvb-form-error', {
            detail: data
        }));
    }
    /**********************************************************************
    STATUS
     **********************************************************************/
@@ -1375,6 +1521,23 @@
        this.inputs.set(config.id, config);
    }
    /**********************************************************************
     Subscription
    **********************************************************************/
    subscribe(callback) {
        this.subscribers.add(callback);
        return () => this.subscribers.delete(callback);
    }
    notify(event, data) {
        this.subscribers.forEach(cb => {
            try {
                cb(event, data);
            } catch (e) {
                console.error('HandleSelection subscriber error:', e);
            }
        });
    }
    /**********************************************************************
     Cleanup
    **********************************************************************/
    destroy() {
assets/js/concise/Queue.js
@@ -5,6 +5,7 @@
        this.user = window.auth.getUser();
        this.canUpdateUI = true;
        this.isProcessing = false;
        this.isPolling = false;
@@ -26,7 +27,7 @@
        this.initElements();
        this.initListeners();
        this.initStore();
        if (this.canUpdateUI) {
        if (this.canUpdateUI && this.ui.panel) {
            this.popup = new window.jvbPopup({
                popup: this.ui.panel,
                toggle: this.ui.toggle.button,
@@ -175,6 +176,7 @@
            this.updatePanel('offline');
        }
    handleBeforeUnload(e) {
        if (!this.ui.panel) return;
        const total = this.getQueueByStatus(this.pendingStatuses).length;
        if (total > 0) {
            // Modern browsers ignore custom messages, but this triggers the native dialog
@@ -283,6 +285,9 @@
                    {name: 'status', keyPath: 'status'},
                    {name: 'type', keyPath: 'type'},
                ],
                filters: {
                    user: window.auth.getUser()
                },
                showLoading: false,
            }
        )
@@ -486,13 +491,13 @@
            let requestBody;
            if (operation.data instanceof FormData) {
                operation.data.append('id', operation.id);
                operation.data.append('user', this.user);
                operation.data.append('user', window.auth.getUser());
                requestBody = operation.data;
            } else {
                requestBody = JSON.stringify({
                    ...operation.data,
                    id: operation.id,
                    user: this.user
                    user: window.auth.getUser()
                });
                operation.headers['Content-Type'] = 'application/json';
            }
@@ -861,7 +866,7 @@
    }
    updatePanel(status = 'syncing') {
        if (!this.panelStatuses.includes(status)) return;
        if (!this.ui.panel || !this.panelStatuses.includes(status)) return;
        this.ui.panel.classList.remove(...this.panelStatuses);
        this.ui.panel.classList.add(status);
    }
@@ -898,7 +903,7 @@
            case 'completed':
                return 'Successfully completed';
            case 'failed':
                return `Failed: ${item.lastError || 'Unknown error'} (Retry ${item.retries}/${this.config.maxRetries})`;
                return `Failed: ${item.lastError || 'Unknown error'} (Retry ${item.retries}/${2})`;
            case 'failed_permanent':
                return `Failed: ${item.lastError || 'Unknown error'}`;
            default:
@@ -906,6 +911,7 @@
        }
    }
    toggleQueue(on = true) {
        if (!this.ui.panel) return;
        this.ui.panel.hidden = !on;
        this.ui.toggle.button.hidden = !on;
    }
assets/js/concise/Referral.js
@@ -50,7 +50,7 @@
        this.tabs = null;
        if (this.container.querySelector('nav.tabs')) {
            this.tabs = new window.jvbTabs(this.container, {updateURL: false});
            this.tabs = window.jvbTabs.registerTab(this.container, {updateURL: false});
        }
@@ -63,7 +63,6 @@
                btn.remove();
            });
        }
        this.formController = null;
    }
    initStore() {
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.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:".tag-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-loaded"===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.remove(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" data-form-id="${e}">Restore</button>\n        <button class="discard" data-form-id="${e}">Discard</button>`,i.insertBefore(a,s.ui.status.status),a.querySelector(".restore").addEventListener("click",(async()=>{this.isRestoring=!0,new this.populate(i,t),this.a11y.announce("Previous changes restored"),this.isRestoring=!1,a.remove()})),a.querySelector(".discard").addEventListener("click",(async()=>{await this.store.remove(e),this.a11y.announce("Previous changes discared"),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.getFieldValue(e);if(!s&&!e.required)return{isValid:!0,message:""};if(e.required&&!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.pattern&&!i.pattern.test(s))return{isValid:!1,message:i.message};if(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)}))}let s=this.getForm(e.target);s&&s.options.cache&&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),t.options.cache&&this.updateItem(s,this.getFieldValue(e.target),t)}handleInput(e){let t=this.getForm(e.target);if(!t||!t.options.cache)return;let s=this.getField(e.target);s&&(this.showFormStatus(t,"pending"),window.debouncer.schedule(`form:${t.id}:validate:${s.dataset.field}`,(()=>this.validateField.bind(this)),500))}async handleSubmit(e){let t=this.getForm(e.target);if(t){if(this.subscribers.size>0){e.preventDefault();const s=await this.store.get(t.id);this.notify("form-submit",{config:t,data:s?.changes||{}})}if(t.options.showSummary){const e=await this.store.get(t.id);this.showSummary(t.id,{config:t,data: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),this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule("form_changes",(async()=>{if(this.changes.size>0){await this.store.saveMany(this.changes);for(let e of this.changes.keys())this.showFormStatus(e,"autosaved");this.changes.clear()}}),2e3)}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:!1,delay:t.delay??1500,endpoint:t.save??e.dataset.save??"",formStatus:t.showStatus??!0,showSummary:!1,cache:t.cache??!0},ui:window.uiFromSelectors(this.selectors.forms,e)};return i.showSummary&&!this.summaryTemplate&&this.defineSummaryTemplate(),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.delete(item.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",...e.ignore];for(let[e,i]of Object.entries(a.changes)){if(r.includes(e)||this.isEmptyValue(i))continue;let a=Array.from(this.inputs.values()).find((t=>t.field?.dataset.field===e));if(!a)continue;let n=s.result.cloneNode(!0),l=n.querySelector("h3"),o=n.querySelector("p");l.textContent=a.label.textContent,"string"==typeof i?o.textContent=i:Array.isArray(i)||"object"==typeof i&&(o.textContent=`${i.address}`),t.append(n)}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");if(a){let e=s.result.cloneNode(!0),r=field.querySelector("h3"),n=field.querySelector("p");n?.remove(),r&&(r.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]":()=>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.addButton)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,`${e.dataset.fieldName}:${r}:`)}))}}),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)}addRepeaterRow(e){e.append(this.templates.create(e.dataset.repeaterId)),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;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=>{window.prefixInput(t,`${e.dataset.fieldName}:${r}:`)})),t.label&&(t.label.textContent=a.label)}}),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){e.target.matches(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(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.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.getFieldValue(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.autoUpload)}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){Array.from(e.children).forEach(((t,s)=>{t.dataset.index=`${s}`,Array.from(t.children).forEach((t=>{"hidden"===t.type&&window.prefixInput(t,`${e.dataset.field}:${s}:${t.dataset.field}`)}))}))}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))}showFormStatus(e,t,s=""){let i=this.forms.get(e);i&&i.options.showStatus&&i.ui?.status?.status&&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){this.templates.create("formSummary",e)}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;default:return e.value}}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}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)}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.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:".tag-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-loaded"===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.remove(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" data-form-id="${e}">Restore</button>\n        <button class="discard" data-form-id="${e}">Discard</button>`,i.insertBefore(a,s.ui.status.status),a.querySelector(".restore").addEventListener("click",(async()=>{this.isRestoring=!0,new this.populate(i,t),this.a11y.announce("Previous changes restored"),this.isRestoring=!1,a.remove()})),a.querySelector(".discard").addEventListener("click",(async()=>{await this.store.remove(e),this.a11y.announce("Previous changes discared"),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.getFieldValue(e);if(!s&&!e.required)return{isValid:!0,message:""};if(e.required&&!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.pattern&&!i.pattern.test(s))return{isValid:!1,message:i.message};if(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)}))}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||!t.options.cache)return;let s=this.getField(e.target);s&&(this.showFormStatus(t,"pending"),window.debouncer.schedule(`form:${t.id}:validate:${s.dataset.field}`,(()=>this.validateField.bind(this)),500))}async handleSubmit(e){let t=this.getForm(e.target);if(t){if(this.subscribers.size>0){e.preventDefault();const s=await this.store.get(t.id);t.options.cache?this.notify("form-submit",{config:t,data:s.changes}):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(t.id,{config:t,data: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()=>{if(this.changes.size>0){await this.store.saveMany(this.changes);for(let e of this.changes.keys())this.showFormStatus(e,"autosaved");this.changes.clear()}}),2e3)}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:!1,delay:t.delay??1500,endpoint:t.save??e.dataset.save??"",formStatus:t.showStatus??!0,showSummary:!1,cache:t.cache??!0},ui:window.uiFromSelectors(this.selectors.forms,e)};return i.showSummary&&!this.summaryTemplate&&this.defineSummaryTemplate(),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.delete(item.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",...e.ignore];for(let[e,i]of Object.entries(a.changes)){if(r.includes(e)||this.isEmptyValue(i))continue;let a=Array.from(this.inputs.values()).find((t=>t.field?.dataset.field===e));if(!a)continue;let n=s.result.cloneNode(!0),l=n.querySelector("h3"),o=n.querySelector("p");l.textContent=a.label.textContent,"string"==typeof i?o.textContent=i:Array.isArray(i)||"object"==typeof i&&(o.textContent=`${i.address}`),t.append(n)}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");if(a){let e=s.result.cloneNode(!0),r=field.querySelector("h3"),n=field.querySelector("p");n?.remove(),r&&(r.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]":()=>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.addButton)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,`${e.dataset.fieldName}:${r}:`)}))}}),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)}addRepeaterRow(e){e.append(this.templates.create(e.dataset.repeaterId)),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;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=>{window.prefixInput(t,`${e.dataset.fieldName}:${r}:`)})),t.label&&(t.label.textContent=a.label)}}),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){e.target.matches(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(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.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.getFieldValue(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.autoUpload)}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){Array.from(e.children).forEach(((t,s)=>{t.dataset.index=`${s}`,Array.from(t.children).forEach((t=>{"hidden"===t.type&&window.prefixInput(t,`${e.dataset.field}:${s}:${t.dataset.field}`)}))}))}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"),e.dispatchEvent(new CustomEvent("jvb-form-success",{detail:t}))}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.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){this.templates.create("formSummary",e)}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;default:return e.value}}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}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/queue.min.js
@@ -1 +1 @@
(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.user=window.auth.getUser(),this.canUpdateUI=!0,this.isProcessing=!1,this.isPolling=!1,this.queue=new Map,this.items=new Map,this.subscribers=new Set,this.api=jvbSettings.api,this.endpoint="queue",this.queueItems=new Map,this.init()}init(){this.headers={"X-WP-Nonce":window.auth.getNonce()},this.initElements(),this.initListeners(),this.initStore(),this.canUpdateUI&&(this.popup=new window.jvbPopup({popup:this.ui.panel,toggle:this.ui.toggle.button,name:"Queue Panel"})),this.defineTemplates()}initElements(){this.panelStatuses=["syncing","synced","pending","offline"],this.statuses=["queued","localProcessing","uploading","pending","processing","completed","failed","failed_permanent"],this.pendingStatuses=["queued","localProcessing","uploading"],this.workingStatuses=["pending","processing"],this.completedStatuses=["completed","failed","failed_permanent"],this.icons={queued:"arrows-clockwise",localProcessing:"arrows-clockwise",uploading:"syncing",pending:"cloud",processing:"syncing",completed:"cloud-check",failed:"cloud-warning",failed_permanent:"cloud-warning"},this.selectors={panel:"aside#queue",toggle:{button:"button.qtoggle",indicator:".qtoggle .indicator",count:".qtoggle .count"},refresh:{button:"#queue .refresh .refreshNow",countdown:"#queue .refresh .countdown"},popup:{popup:"#queue .popup",message:"#queue .popup span"},items:{container:"#queue .qitems"},actions:{retry:"#queue .retry-all",clear:"#queue .dismiss-all"},filters:{filter:"#queue [data-filter]",all:{label:'#queue [for="qfilter-all"]',radio:'#queue [data-filter="all"]',count:'#queue [data-filter="all"] .count'},queued:{label:'#queue [for="qfilter-queued"]',input:'#queue [data-filter="queued"]',count:'#queue [for="qfilter-queued"] .count'},localProcessing:{label:'#queue [for="qfilter-localProcessing"]',input:'#queue [data-filter="localProcessing"]',count:'#queue [for="qfilter-localProcessing"] .count'},uploading:{label:'#queue [for="qfilter-uploading"]',input:'#queue [data-filter="uploading"]',count:'#queue [for="qfilter-uploading"] .count'},pending:{label:'#queue [for="qfilter-pending"]',input:'#queue [data-filter="pending"]',count:'#queue [for="qfilter-pending"] .count'},processing:{label:'#queue [for="qfilter-processing"]',input:'#queue [data-filter="processing"]',count:'#queue [for="qfilter-processing"] .count'},completed:{label:'#queue [for="qfilter-completed"]',input:'#queue [data-filter="completed"]',count:'#queue [for="qfilter-completed"] .count'},failed:{label:'#queue [for="qfilter-failed"]',input:'#queue [data-filter="failed"]',count:'#queue [for="qfilter-failed"] .count'}},item:{type:".type",status:".status",details:".info .details",icon:".status .icon",startedAt:".started time",completed:{wrap:".completed",label:".completed span",time:".completed time"},progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},actions:{cancel:"button.cancel",retry:"button.retry",dismiss:"button.dismiss"}}},this.ui=window.uiFromSelectors(this.selectors),this.ui.panel||(this.canUpdateUI=!1)}defineTemplates(){const e=window.jvbTemplates;e.define("emptyState"),e.define("queueItem",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id}})}initListeners(){this.activityListeners=null,this.clickHandler=this.handleClick.bind(this),this.onlineHandler=this.handleOnline.bind(this),this.offlineHandler=this.handleOffline.bind(this),this.unloadHandler=this.handleBeforeUnload.bind(this),document.addEventListener("click",this.clickHandler),window.addEventListener("online",this.onlineHandler),window.addEventListener("offline",this.offlineHandler),window.addEventListener("beforeunload",this.unloadHandler)}handleOnline(){this.updatePanel("synced"),this.getQueueByStatus(this.pendingStatuses).length>0&&this.processQueue()}handleOffline(){this.updatePanel("offline")}handleBeforeUnload(e){if(this.getQueueByStatus(this.pendingStatuses).length>0)return e.preventDefault(),e.returnValue="",""}handleClick(e){if(!window.targetCheck(e,this.selectors.panel+", "+this.selectors.toggle.button))return;if(window.targetCheck(e,this.selectors.refresh.button))return this.ui.refresh.button.classList.add("fetching"),this.store.clearCache(),this.store.clearFilters(),void this.store.fetch().finally((()=>{this.ui.refresh.button.classList.remove("fetching")}));if(window.targetCheck(e,this.selectors.actions.clear))return void this.opActions("completed","dismiss").then((()=>{}));if(window.targetCheck(e,this.selectors.actions.retry))return void this.opActions("failed","retry").then((()=>{}));const t=window.targetCheck(e,"[data-action]");if(t){const e=t.closest("[data-id]")?.dataset.id;return void(e&&this.opActions(e,t.dataset.action))}const s=window.targetCheck(e,this.selectors.filters.filter);s&&this.setFilter(s.dataset.filter)}setFilter(e){Object.values(this.ui.filters).forEach((t=>{t.input?.dataset.filter===e&&(t.input.checked=!0)})),"all"===e?this.store.clearFilters():this.store.setFilter("status",e)}trackActivity(){if(!this.activityListeners){const e=["mousedown","mousemove","keypress","scroll","touchstart"];this.activityListeners=e.map((e=>{const t=()=>this.resetActivityTimer();return document.addEventListener(e,t,{passive:!0}),{event:e,handler:t}}))}this.resetActivityTimer()}resetActivityTimer(){this.activityTimer&&clearTimeout(this.activityTimer),this.activityTimer=setTimeout((()=>{this.processQueue()}),1750)}stopActivityTracking(){this.activityTimer&&(clearTimeout(this.activityTimer),this.activityTimer=null),this.activityListeners&&(this.activityListeners.forEach((({event:e,handler:t})=>{document.removeEventListener(e,t)})),this.activityListeners=null)}initStore(){if(!this.user)return;const e=window.jvbStore.register("queue",{storeName:"queue",keyPath:"id",endpoint:this.endpoint,TTL:1/0,indexes:[{name:"status",keyPath:"status"},{name:"type",keyPath:"type"}],showLoading:!1});this.store=e.queue,this.store.subscribe(((e,t)=>{switch(e){case"data-loaded":case"items-save":this.maybeStartPolling(),this.updateUI();break;case"item-saved":t.previousItem&&t.previousItem.status!==t.item.status&&this.updateOperationStatus(t.item.id,t.item.status),this.maybeStartPolling()}}))}addToQueue(e){const t={id:`u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2,9)}`,endpoint:null,method:"POST",headers:{},data:{},delay:!1,canMerge:!0,popup:"Saving changes...",title:"Operation",status:"queued",timestamp:Date.now(),created_at:(new Date).toISOString(),retries:0,user:this.user,...e};if(t.headers={...this.headers,...t.headers},!t.endpoint||!t.data)return null;if(t.popup&&this.ui.popup?.message&&(this.ui.popup.message.textContent=t.popup,this.ui.popup.popup.hidden=!1,setTimeout((()=>this.ui.popup.popup.hidden=!0),2e3)),!t.delay)return this.queue.set(t.id,t),this.processOperation(t).then((()=>{})),this.store.clearCache(),this.maybeStartPolling(),this.toggleQueue(),t.id;const s=Array.from(this.getAllQueue()).filter((e=>"queued"===e.status&&e.endpoint===t.endpoint&&e.canMerge));if(s.length>0){const e=s[0];return e.data=window.deepMerge(e.data,t.data),e.timestamp=Date.now(),this.updateOperationStatus(e.id,e.status),this.updateUI(),this.trackActivity(),e.id}return this.store.clearCache(),this.setQueue(t),this.updateOperationStatus(t.id,t.status),this.updateUI(),this.trackActivity(),t.id}async opActions(e,t){if(this.statuses.includes(e)?e=this.getQueueByStatus(e).map((e=>e.id)):"string"==typeof e&&(e=[e]),0===e.length)return;if(!["cancel","dismiss","retry"].includes(t))return;const s=["cancel","dismiss"].includes(t);s&&e.forEach((e=>{this.removeOperationUI(e)}));try{const i=await fetch(`${this.api}${this.endpoint}`,{method:"POST",headers:{"Content-Type":"application/json",...this.headers},body:JSON.stringify({action:t,ids:e,user:this.user})});if(!i.ok)throw new Error(`${t} failed: ${i.status}`);const n=await i.json();if(!n.success)throw new Error(n.message||`${t} operation failed`);return e.forEach((e=>{let i=this.getQueue(e);if(i&&this.notify(`${t}-operation`,i),s)this.clearQueue(e);else{let t=this.getQueue(e);t.status="queued",this.setQueue(t),this.updateOperationStatus(t.id,t.status)}})),"retry"===t&&this.trackActivity(),this.updateUI(),n}catch(s){return await window.jvbError.log(s,{component:"Queue",operation:"performQueueAction",action:t,operationIds:e,itemCount:e.length},(()=>this.opActions(e,t))),{success:!1,error:s.message}}}async processQueue(){if(this.isProcessing)return;const e=this.getQueueByStatus("queued");if(0!==e.length){this.setProcessing();for(const t of e)await this.processOperation(t);this.setProcessing(!1),this.stopActivityTracking(),this.toggleQueue(this.maybeStartPolling())}else this.stopActivityTracking()}async processOperation(e){try{this.queue.has(e.id)||this.queue.set(e.id,e);let t,s=!1;if(e.data?._isFormData&&!e.data instanceof FormData&&(s=!0,e.data=await this.store.objectToFormData(e.data)),this.updateOperationStatus(e.id,"uploading"),e.data instanceof FormData?(e.data.append("id",e.id),e.data.append("user",this.user),t=e.data):(t=JSON.stringify({...e.data,id:e.id,user:this.user}),e.headers["Content-Type"]="application/json"),null==t)return;const i=await fetch(`${this.api}${e.endpoint}`,{method:e.method,headers:e.headers,body:t}),n=await i.json();if(s&&(e.data={}),!i.ok||!n.success)throw new Error(n.message||`HTTP ${i.status}`);n.id&&e.id!==n.id?e=await this.handleServerMerge(e,n):(e.status=n.status??"pending",e.serverData=n,this.updateOperationStatus(e.id,e.status)),this.a11y.announce(`${e.title} sent to server for processing`),this.setQueue(e)}catch(t){console.error("Operation failed: ",t),e.retries++,e.lastError=t.message,e.retries>=3?e.status="failed_permanent":e.status="failed",this.updateOperationStatus(e.id,e.status),this.setQueue(e)}}async handleServerMerge(e,t){const s=this.getQueue(t.id);return s?(e.status=t.status||"pending",e.serverData=t,this.mergeOp(s,e)):(this.clearQueue(e.id),this.setQueue(t),t)}mergeOp(e,t){return e.data=window.deepMerge(e.data,t.data),e.status=t.status,Object.hasOwn(t,"serverData")&&(e.serverData=t.serverData),this.updateOperationStatus(e.id,e.status),this.removeOperationUI(t.id),this.clearQueue(t.id),e}sortByDate(e){return e.sort(((e,t)=>(e.updated_at??e.timestamp??0)-(t.updated_at??t.timestamp??0)))}sortOperations(e){const t={processing:0,uploading:1,pending:2,queued:3,localProcessing:4,failed:5,completed:6,failed_permanent:7};return e.sort(((e,s)=>{const i=(t[e.status]??99)-(t[s.status]??99);if(0!==i)return i;const n=e.updated_at??e.timestamp??0,a=s.updated_at??s.timestamp??0;return new Date(a)-new Date(n)}))}getAllQueue(){let e=[...new Set([...Array.from(this.store.data.values()),...Array.from(this.queue.values())])];return this.sortOperations(e)}getQueueByStatus(e){"string"==typeof e&&(e=[e]);let t=[...new Set([...Array.from(this.store.filterByIndex({status:e})),...Array.from(this.queue.values()).filter((t=>e.includes(t.status)))])];return this.sortOperations(t)}updateOperationStatus(e,t){let s=this.getQueue(e);s&&this.statuses.includes(t)&&(s.status=t,this.notify("operation-status",s),this.setQueue(s))}setQueue(e){this.store.save(e),this.queue.set(e.id,e)}getQueue(e){return this.queue.has(e)?this.queue.get(e):this.store.get(e)}clearQueue(e){this.queue.delete(e),this.store.delete(e)}maybeStartPolling(){return this.getQueueByStatus([...this.pendingStatuses,...this.workingStatuses]).length>0?(this.startPolling(),!0):(this.updatePanel("synced"),!1)}startPolling(){this.isPolling||(this.isPolling=!0,this.updatePanel("pending"),this.runPollCycle())}async runPollCycle(){if(this.isPolling){try{if(this.ui.refresh.button.classList.add("fetching"),this.store.clearCache(),await this.store.fetch(),this.ui.refresh.button.classList.remove("fetching"),!this.maybeStartPolling())return this.stopPolling(),void this.updatePanel("synced")}catch(e){console.error("Polling error:",e)}this.startCountdown(5,(()=>this.runPollCycle()))}}startCountdown(e,t){this.ui.refresh.countdown?(this.ui.refresh.countdown.classList.add("counting"),this.ui.refresh.countdown.textContent=e,this.countdownTimer=setInterval((()=>{--e>0?this.ui.refresh.countdown.textContent=e:(this.stopCountdown(),t&&t())}),1e3)):console.warn("Countdown element not found")}stopPolling(){this.isPolling&&(this.isPolling=!1,this.pollTimer&&(clearInterval(this.pollTimer),this.pollTimer=null),this.stopCountdown())}stopCountdown(){this.countdownTimer&&(clearInterval(this.countdownTimer),this.countdownTimer=null),this.ui.refresh.countdown.classList.remove("counting"),this.ui.refresh.countdown.textContent=""}updateUI(){this.canUpdateUI&&window.debouncer.schedule("queue-ui",this.handleUpdateUI.bind(this))}handleUpdateUI(){const e=this.getAllQueue();this.ui.actions.retry.disabled=0===e.filter((e=>"failed"===e.status)).length,this.ui.actions.clear.disabled=0===e.filter((e=>"completed"===e.status)).length;const t=e.filter((e=>[...this.pendingStatuses,...this.workingStatuses].includes(e.status))).length;this.ui.toggle.count.hidden=0===t,this.ui.toggle.count.textContent=t;for(let t of this.statuses){if("failed_permanent"===t)continue;let s=e.filter((e=>e.status===t)).length;this.ui.filters[t].label.hidden=0===s,this.ui.filters[t].input.dataset.count=`${s}`,this.ui.filters[t].count.textContent=s>0?s:""}this.renderOperations()}renderOperations(){if(!this.ui.items.container)return;const e=this.store.filters?.status??"all",t="all"===e?this.getAllQueue():this.getQueueByStatus(e),s=this.sortOperations(t);if(0===s.length){window.removeChildren(this.ui.items.container);const e=window.jvbTemplates.create("emptyQueue");return this.ui.items.container.append(e),void this.a11y.announce("No items in queue")}this.ui.items.container.querySelector(".empty-group")?.remove();const i=new Set(s.map((e=>e.id)));this.items.forEach(((e,t)=>{i.has(t)||(e.element?.remove(),this.items.delete(t))})),s.forEach(((e,t)=>{let s=this.items.get(e.id);s||(s=this.createOperationElement(e)),s?.element&&(this.updateOperationUI(e.id),this.ui.items.container.append(s.element))}))}createOperationElement(e){const t=window.jvbTemplates.create("queueItem",e),s={element:t,ui:window.uiFromSelectors(this.selectors.item,t)};return this.items.set(e.id,s),s}updateOperationUI(e){let t=this.items.has(e)?this.items.get(e):this.createOperationElement(e);if(!t)return;let s=this.getQueue(e),i=t.element;i.classList.remove(this.statuses),i.classList.add(s.status);let n=this.getProgress(s);t.ui.type&&t.ui.type.textContent!==s.title&&(t.ui.type.textContent=s.title),t.ui.status&&(t.ui.status.title=this.statusLabel(s.status)),t.ui.icon&&(t.ui.icon.className=`icon icon-${this.icons[s.status]}`),t.ui.details&&(t.ui.details.textContent=this.itemMessage(s)),t.ui.startedAt&&(t.ui.startedAt.setAttribute("datetime",s.created_at),t.ui.startedAt.textContent=window.formatTimeAgo(s.created_at));s.status;const a="completed"===s.status&&(s.completed_at||s.updated_at);if(t.ui.completed.wrap.hidden=!a,a){const e=s.completed_at??s.updated_at;t.ui.completed.label.textContent="Completed: ",t.ui.completed.time.setAttribute("datetime",e),t.ui.completed.time.textContent=window.formatTimeAgo(e)}window.showProgress(t.ui.progress,n,100,this.statusLabel(s.status)),t.ui.actions.cancel&&(t.ui.actions.cancel.hidden=this.completedStatuses.includes(s.status)),t.ui.actions.retry&&(s.retries>=3&&(t.ui.actions.retry.disabled=!0),t.ui.actions.retry.hidden="failed"!==s.status),t.ui.actions.dismiss&&(t.ui.actions.dismiss.hidden=this.pendingStatuses.includes(s.status))}getProgress(e){if(e.progress)return e.progress;if(!this.statuses.includes(e.status))return 0;return{queued:10,uploading:25,pending:40,processing:70,completed:100,failed:0,failed_permanent:0}[e.status]??0}removeOperationUI(e){let t=this.items.get(e);t&&window.fade(t.element,!1)}updatePanel(e="syncing"){this.panelStatuses.includes(e)&&(this.ui.panel.classList.remove(...this.panelStatuses),this.ui.panel.classList.add(e))}statusLabel(e){if(!this.statuses.includes(e))return"";return{queued:"Queued",localProcessing:"Processing locally",uploading:"Uploading",pending:"Waiting on server",processing:"Processing",completed:"Completed",failed:"Failed",failed_permanent:"Failed permanently"}[e]}itemMessage(e){if(Object.hasOwn(e,"message")&&""!==e.message)return e.message;if(Object.hasOwn(e,"error_message")&&e.error_message)return e.error_message;switch(e.status){case"queued":return"Waiting to send...";case"uploading":return"Sending to server...";case"pending":return e.position?`Position ${e.position} in queue`:"In server queue";case"processing":return e.progress?`${e.progress}% complete`:"Processing...";case"completed":return"Successfully completed";case"failed":return`Failed: ${e.lastError||"Unknown error"} (Retry ${e.retries}/${this.config.maxRetries})`;case"failed_permanent":return`Failed: ${e.lastError||"Unknown error"}`;default:return""}}toggleQueue(e=!0){this.ui.panel.hidden=!e,this.ui.toggle.button.hidden=!e}setProcessing(e=!0){this.isProcessing=e,this.ui.toggle.button.classList.toggle("saving",e)}subscribe(e){if(this.subscribers)return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){this.isPolling&&this.stopPolling(),this.stopActivityTracking(),document.removeEventListener("click",this.clickHandler),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbQueue=new e)}))}))})();
(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.user=window.auth.getUser(),this.canUpdateUI=!0,this.isProcessing=!1,this.isPolling=!1,this.queue=new Map,this.items=new Map,this.subscribers=new Set,this.api=jvbSettings.api,this.endpoint="queue",this.queueItems=new Map,this.init()}init(){this.headers={"X-WP-Nonce":window.auth.getNonce()},this.initElements(),this.initListeners(),this.initStore(),this.canUpdateUI&&this.ui.panel&&(this.popup=new window.jvbPopup({popup:this.ui.panel,toggle:this.ui.toggle.button,name:"Queue Panel"})),this.defineTemplates()}initElements(){this.panelStatuses=["syncing","synced","pending","offline"],this.statuses=["queued","localProcessing","uploading","pending","processing","completed","failed","failed_permanent"],this.pendingStatuses=["queued","localProcessing","uploading"],this.workingStatuses=["pending","processing"],this.completedStatuses=["completed","failed","failed_permanent"],this.icons={queued:"arrows-clockwise",localProcessing:"arrows-clockwise",uploading:"syncing",pending:"cloud",processing:"syncing",completed:"cloud-check",failed:"cloud-warning",failed_permanent:"cloud-warning"},this.selectors={panel:"aside#queue",toggle:{button:"button.qtoggle",indicator:".qtoggle .indicator",count:".qtoggle .count"},refresh:{button:"#queue .refresh .refreshNow",countdown:"#queue .refresh .countdown"},popup:{popup:"#queue .popup",message:"#queue .popup span"},items:{container:"#queue .qitems"},actions:{retry:"#queue .retry-all",clear:"#queue .dismiss-all"},filters:{filter:"#queue [data-filter]",all:{label:'#queue [for="qfilter-all"]',radio:'#queue [data-filter="all"]',count:'#queue [data-filter="all"] .count'},queued:{label:'#queue [for="qfilter-queued"]',input:'#queue [data-filter="queued"]',count:'#queue [for="qfilter-queued"] .count'},localProcessing:{label:'#queue [for="qfilter-localProcessing"]',input:'#queue [data-filter="localProcessing"]',count:'#queue [for="qfilter-localProcessing"] .count'},uploading:{label:'#queue [for="qfilter-uploading"]',input:'#queue [data-filter="uploading"]',count:'#queue [for="qfilter-uploading"] .count'},pending:{label:'#queue [for="qfilter-pending"]',input:'#queue [data-filter="pending"]',count:'#queue [for="qfilter-pending"] .count'},processing:{label:'#queue [for="qfilter-processing"]',input:'#queue [data-filter="processing"]',count:'#queue [for="qfilter-processing"] .count'},completed:{label:'#queue [for="qfilter-completed"]',input:'#queue [data-filter="completed"]',count:'#queue [for="qfilter-completed"] .count'},failed:{label:'#queue [for="qfilter-failed"]',input:'#queue [data-filter="failed"]',count:'#queue [for="qfilter-failed"] .count'}},item:{type:".type",status:".status",details:".info .details",icon:".status .icon",startedAt:".started time",completed:{wrap:".completed",label:".completed span",time:".completed time"},progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},actions:{cancel:"button.cancel",retry:"button.retry",dismiss:"button.dismiss"}}},this.ui=window.uiFromSelectors(this.selectors),this.ui.panel||(this.canUpdateUI=!1)}defineTemplates(){const e=window.jvbTemplates;e.define("emptyState"),e.define("queueItem",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id}})}initListeners(){this.activityListeners=null,this.clickHandler=this.handleClick.bind(this),this.onlineHandler=this.handleOnline.bind(this),this.offlineHandler=this.handleOffline.bind(this),this.unloadHandler=this.handleBeforeUnload.bind(this),document.addEventListener("click",this.clickHandler),window.addEventListener("online",this.onlineHandler),window.addEventListener("offline",this.offlineHandler),window.addEventListener("beforeunload",this.unloadHandler)}handleOnline(){this.updatePanel("synced"),this.getQueueByStatus(this.pendingStatuses).length>0&&this.processQueue()}handleOffline(){this.updatePanel("offline")}handleBeforeUnload(e){if(!this.ui.panel)return;return this.getQueueByStatus(this.pendingStatuses).length>0?(e.preventDefault(),e.returnValue="",""):void 0}handleClick(e){if(!window.targetCheck(e,this.selectors.panel+", "+this.selectors.toggle.button))return;if(window.targetCheck(e,this.selectors.refresh.button))return this.ui.refresh.button.classList.add("fetching"),this.store.clearCache(),this.store.clearFilters(),void this.store.fetch().finally((()=>{this.ui.refresh.button.classList.remove("fetching")}));if(window.targetCheck(e,this.selectors.actions.clear))return void this.opActions("completed","dismiss").then((()=>{}));if(window.targetCheck(e,this.selectors.actions.retry))return void this.opActions("failed","retry").then((()=>{}));const t=window.targetCheck(e,"[data-action]");if(t){const e=t.closest("[data-id]")?.dataset.id;return void(e&&this.opActions(e,t.dataset.action))}const s=window.targetCheck(e,this.selectors.filters.filter);s&&this.setFilter(s.dataset.filter)}setFilter(e){Object.values(this.ui.filters).forEach((t=>{t.input?.dataset.filter===e&&(t.input.checked=!0)})),"all"===e?this.store.clearFilters():this.store.setFilter("status",e)}trackActivity(){if(!this.activityListeners){const e=["mousedown","mousemove","keypress","scroll","touchstart"];this.activityListeners=e.map((e=>{const t=()=>this.resetActivityTimer();return document.addEventListener(e,t,{passive:!0}),{event:e,handler:t}}))}this.resetActivityTimer()}resetActivityTimer(){this.activityTimer&&clearTimeout(this.activityTimer),this.activityTimer=setTimeout((()=>{this.processQueue()}),1750)}stopActivityTracking(){this.activityTimer&&(clearTimeout(this.activityTimer),this.activityTimer=null),this.activityListeners&&(this.activityListeners.forEach((({event:e,handler:t})=>{document.removeEventListener(e,t)})),this.activityListeners=null)}initStore(){if(!this.user)return;const e=window.jvbStore.register("queue",{storeName:"queue",keyPath:"id",endpoint:this.endpoint,TTL:1/0,indexes:[{name:"status",keyPath:"status"},{name:"type",keyPath:"type"}],filters:{user:window.auth.getUser()},showLoading:!1});this.store=e.queue,this.store.subscribe(((e,t)=>{switch(e){case"data-loaded":case"items-save":this.maybeStartPolling(),this.updateUI();break;case"item-saved":t.previousItem&&t.previousItem.status!==t.item.status&&this.updateOperationStatus(t.item.id,t.item.status),this.maybeStartPolling()}}))}addToQueue(e){const t={id:`u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2,9)}`,endpoint:null,method:"POST",headers:{},data:{},delay:!1,canMerge:!0,popup:"Saving changes...",title:"Operation",status:"queued",timestamp:Date.now(),created_at:(new Date).toISOString(),retries:0,user:this.user,...e};if(t.headers={...this.headers,...t.headers},!t.endpoint||!t.data)return null;if(t.popup&&this.ui.popup?.message&&(this.ui.popup.message.textContent=t.popup,this.ui.popup.popup.hidden=!1,setTimeout((()=>this.ui.popup.popup.hidden=!0),2e3)),!t.delay)return this.queue.set(t.id,t),this.processOperation(t).then((()=>{})),this.store.clearCache(),this.maybeStartPolling(),this.toggleQueue(),t.id;const s=Array.from(this.getAllQueue()).filter((e=>"queued"===e.status&&e.endpoint===t.endpoint&&e.canMerge));if(s.length>0){const e=s[0];return e.data=window.deepMerge(e.data,t.data),e.timestamp=Date.now(),this.updateOperationStatus(e.id,e.status),this.updateUI(),this.trackActivity(),e.id}return this.store.clearCache(),this.setQueue(t),this.updateOperationStatus(t.id,t.status),this.updateUI(),this.trackActivity(),t.id}async opActions(e,t){if(this.statuses.includes(e)?e=this.getQueueByStatus(e).map((e=>e.id)):"string"==typeof e&&(e=[e]),0===e.length)return;if(!["cancel","dismiss","retry"].includes(t))return;const s=["cancel","dismiss"].includes(t);s&&e.forEach((e=>{this.removeOperationUI(e)}));try{const i=await fetch(`${this.api}${this.endpoint}`,{method:"POST",headers:{"Content-Type":"application/json",...this.headers},body:JSON.stringify({action:t,ids:e,user:this.user})});if(!i.ok)throw new Error(`${t} failed: ${i.status}`);const n=await i.json();if(!n.success)throw new Error(n.message||`${t} operation failed`);return e.forEach((e=>{let i=this.getQueue(e);if(i&&this.notify(`${t}-operation`,i),s)this.clearQueue(e);else{let t=this.getQueue(e);t.status="queued",this.setQueue(t),this.updateOperationStatus(t.id,t.status)}})),"retry"===t&&this.trackActivity(),this.updateUI(),n}catch(s){return await window.jvbError.log(s,{component:"Queue",operation:"performQueueAction",action:t,operationIds:e,itemCount:e.length},(()=>this.opActions(e,t))),{success:!1,error:s.message}}}async processQueue(){if(this.isProcessing)return;const e=this.getQueueByStatus("queued");if(0!==e.length){this.setProcessing();for(const t of e)await this.processOperation(t);this.setProcessing(!1),this.stopActivityTracking(),this.toggleQueue(this.maybeStartPolling())}else this.stopActivityTracking()}async processOperation(e){try{this.queue.has(e.id)||this.queue.set(e.id,e);let t,s=!1;if(e.data?._isFormData&&!e.data instanceof FormData&&(s=!0,e.data=await this.store.objectToFormData(e.data)),this.updateOperationStatus(e.id,"uploading"),e.data instanceof FormData?(e.data.append("id",e.id),e.data.append("user",window.auth.getUser()),t=e.data):(t=JSON.stringify({...e.data,id:e.id,user:window.auth.getUser()}),e.headers["Content-Type"]="application/json"),null==t)return;const i=await fetch(`${this.api}${e.endpoint}`,{method:e.method,headers:e.headers,body:t}),n=await i.json();if(s&&(e.data={}),!i.ok||!n.success)throw new Error(n.message||`HTTP ${i.status}`);n.id&&e.id!==n.id?e=await this.handleServerMerge(e,n):(e.status=n.status??"pending",e.serverData=n,this.updateOperationStatus(e.id,e.status)),this.a11y.announce(`${e.title} sent to server for processing`),this.setQueue(e)}catch(t){console.error("Operation failed: ",t),e.retries++,e.lastError=t.message,e.retries>=3?e.status="failed_permanent":e.status="failed",this.updateOperationStatus(e.id,e.status),this.setQueue(e)}}async handleServerMerge(e,t){const s=this.getQueue(t.id);return s?(e.status=t.status||"pending",e.serverData=t,this.mergeOp(s,e)):(this.clearQueue(e.id),this.setQueue(t),t)}mergeOp(e,t){return e.data=window.deepMerge(e.data,t.data),e.status=t.status,Object.hasOwn(t,"serverData")&&(e.serverData=t.serverData),this.updateOperationStatus(e.id,e.status),this.removeOperationUI(t.id),this.clearQueue(t.id),e}sortByDate(e){return e.sort(((e,t)=>(e.updated_at??e.timestamp??0)-(t.updated_at??t.timestamp??0)))}sortOperations(e){const t={processing:0,uploading:1,pending:2,queued:3,localProcessing:4,failed:5,completed:6,failed_permanent:7};return e.sort(((e,s)=>{const i=(t[e.status]??99)-(t[s.status]??99);if(0!==i)return i;const n=e.updated_at??e.timestamp??0,a=s.updated_at??s.timestamp??0;return new Date(a)-new Date(n)}))}getAllQueue(){let e=[...new Set([...Array.from(this.store.data.values()),...Array.from(this.queue.values())])];return this.sortOperations(e)}getQueueByStatus(e){"string"==typeof e&&(e=[e]);let t=[...new Set([...Array.from(this.store.filterByIndex({status:e})),...Array.from(this.queue.values()).filter((t=>e.includes(t.status)))])];return this.sortOperations(t)}updateOperationStatus(e,t){let s=this.getQueue(e);s&&this.statuses.includes(t)&&(s.status=t,this.notify("operation-status",s),this.setQueue(s))}setQueue(e){this.store.save(e),this.queue.set(e.id,e)}getQueue(e){return this.queue.has(e)?this.queue.get(e):this.store.get(e)}clearQueue(e){this.queue.delete(e),this.store.delete(e)}maybeStartPolling(){return this.getQueueByStatus([...this.pendingStatuses,...this.workingStatuses]).length>0?(this.startPolling(),!0):(this.updatePanel("synced"),!1)}startPolling(){this.isPolling||(this.isPolling=!0,this.updatePanel("pending"),this.runPollCycle())}async runPollCycle(){if(this.isPolling){try{if(this.ui.refresh.button.classList.add("fetching"),this.store.clearCache(),await this.store.fetch(),this.ui.refresh.button.classList.remove("fetching"),!this.maybeStartPolling())return this.stopPolling(),void this.updatePanel("synced")}catch(e){console.error("Polling error:",e)}this.startCountdown(5,(()=>this.runPollCycle()))}}startCountdown(e,t){this.ui.refresh.countdown?(this.ui.refresh.countdown.classList.add("counting"),this.ui.refresh.countdown.textContent=e,this.countdownTimer=setInterval((()=>{--e>0?this.ui.refresh.countdown.textContent=e:(this.stopCountdown(),t&&t())}),1e3)):console.warn("Countdown element not found")}stopPolling(){this.isPolling&&(this.isPolling=!1,this.pollTimer&&(clearInterval(this.pollTimer),this.pollTimer=null),this.stopCountdown())}stopCountdown(){this.countdownTimer&&(clearInterval(this.countdownTimer),this.countdownTimer=null),this.ui.refresh.countdown.classList.remove("counting"),this.ui.refresh.countdown.textContent=""}updateUI(){this.canUpdateUI&&window.debouncer.schedule("queue-ui",this.handleUpdateUI.bind(this))}handleUpdateUI(){const e=this.getAllQueue();this.ui.actions.retry.disabled=0===e.filter((e=>"failed"===e.status)).length,this.ui.actions.clear.disabled=0===e.filter((e=>"completed"===e.status)).length;const t=e.filter((e=>[...this.pendingStatuses,...this.workingStatuses].includes(e.status))).length;this.ui.toggle.count.hidden=0===t,this.ui.toggle.count.textContent=t;for(let t of this.statuses){if("failed_permanent"===t)continue;let s=e.filter((e=>e.status===t)).length;this.ui.filters[t].label.hidden=0===s,this.ui.filters[t].input.dataset.count=`${s}`,this.ui.filters[t].count.textContent=s>0?s:""}this.renderOperations()}renderOperations(){if(!this.ui.items.container)return;const e=this.store.filters?.status??"all",t="all"===e?this.getAllQueue():this.getQueueByStatus(e),s=this.sortOperations(t);if(0===s.length){window.removeChildren(this.ui.items.container);const e=window.jvbTemplates.create("emptyQueue");return this.ui.items.container.append(e),void this.a11y.announce("No items in queue")}this.ui.items.container.querySelector(".empty-group")?.remove();const i=new Set(s.map((e=>e.id)));this.items.forEach(((e,t)=>{i.has(t)||(e.element?.remove(),this.items.delete(t))})),s.forEach(((e,t)=>{let s=this.items.get(e.id);s||(s=this.createOperationElement(e)),s?.element&&(this.updateOperationUI(e.id),this.ui.items.container.append(s.element))}))}createOperationElement(e){const t=window.jvbTemplates.create("queueItem",e),s={element:t,ui:window.uiFromSelectors(this.selectors.item,t)};return this.items.set(e.id,s),s}updateOperationUI(e){let t=this.items.has(e)?this.items.get(e):this.createOperationElement(e);if(!t)return;let s=this.getQueue(e),i=t.element;i.classList.remove(this.statuses),i.classList.add(s.status);let n=this.getProgress(s);t.ui.type&&t.ui.type.textContent!==s.title&&(t.ui.type.textContent=s.title),t.ui.status&&(t.ui.status.title=this.statusLabel(s.status)),t.ui.icon&&(t.ui.icon.className=`icon icon-${this.icons[s.status]}`),t.ui.details&&(t.ui.details.textContent=this.itemMessage(s)),t.ui.startedAt&&(t.ui.startedAt.setAttribute("datetime",s.created_at),t.ui.startedAt.textContent=window.formatTimeAgo(s.created_at));s.status;const a="completed"===s.status&&(s.completed_at||s.updated_at);if(t.ui.completed.wrap.hidden=!a,a){const e=s.completed_at??s.updated_at;t.ui.completed.label.textContent="Completed: ",t.ui.completed.time.setAttribute("datetime",e),t.ui.completed.time.textContent=window.formatTimeAgo(e)}window.showProgress(t.ui.progress,n,100,this.statusLabel(s.status)),t.ui.actions.cancel&&(t.ui.actions.cancel.hidden=this.completedStatuses.includes(s.status)),t.ui.actions.retry&&(s.retries>=3&&(t.ui.actions.retry.disabled=!0),t.ui.actions.retry.hidden="failed"!==s.status),t.ui.actions.dismiss&&(t.ui.actions.dismiss.hidden=this.pendingStatuses.includes(s.status))}getProgress(e){if(e.progress)return e.progress;if(!this.statuses.includes(e.status))return 0;return{queued:10,uploading:25,pending:40,processing:70,completed:100,failed:0,failed_permanent:0}[e.status]??0}removeOperationUI(e){let t=this.items.get(e);t&&window.fade(t.element,!1)}updatePanel(e="syncing"){this.ui.panel&&this.panelStatuses.includes(e)&&(this.ui.panel.classList.remove(...this.panelStatuses),this.ui.panel.classList.add(e))}statusLabel(e){if(!this.statuses.includes(e))return"";return{queued:"Queued",localProcessing:"Processing locally",uploading:"Uploading",pending:"Waiting on server",processing:"Processing",completed:"Completed",failed:"Failed",failed_permanent:"Failed permanently"}[e]}itemMessage(e){if(Object.hasOwn(e,"message")&&""!==e.message)return e.message;if(Object.hasOwn(e,"error_message")&&e.error_message)return e.error_message;switch(e.status){case"queued":return"Waiting to send...";case"uploading":return"Sending to server...";case"pending":return e.position?`Position ${e.position} in queue`:"In server queue";case"processing":return e.progress?`${e.progress}% complete`:"Processing...";case"completed":return"Successfully completed";case"failed":return`Failed: ${e.lastError||"Unknown error"} (Retry ${e.retries}/2)`;case"failed_permanent":return`Failed: ${e.lastError||"Unknown error"}`;default:return""}}toggleQueue(e=!0){this.ui.panel&&(this.ui.panel.hidden=!e,this.ui.toggle.button.hidden=!e)}setProcessing(e=!0){this.isProcessing=e,this.ui.toggle.button.classList.toggle("saving",e)}subscribe(e){if(this.subscribers)return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){this.isPolling&&this.stopPolling(),this.stopActivityTracking(),document.removeEventListener("click",this.clickHandler),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbQueue=new e)}))}))})();
assets/js/min/referral.min.js
@@ -1 +1 @@
(()=>{class e{constructor(){this.container=document.querySelector("aside.referral"),this.container&&(this.a11y=window.jvbA11y,this.toggle=document.querySelector('button[data-action="toggle-referral"]'),this.hasCopy=navigator.clipboard&&navigator.clipboard.writeText,this.initElements(),this.initStore(),this.initListeners(),this.checkForReferral())}initElements(){this.selectors={copyBtn:".copy-btn",checkCode:".check-code-btn",submit:"[type=submit]",recentList:".recent-referrals-list",stats:{codeUsed:'[data-stat="code_used"]',consultations:'[data-stat="consultations"]',treatments:'[data-stat="treatments"]',rewards:'[data-stat="total_rewards"]'}},this.forms=this.container.querySelectorAll("form"),this.popup=new window.jvbPopup({toggle:this.toggle,popup:this.container,name:"Referral Box",onOpen:()=>{this.bindEventListeners(!0)},onClose:()=>{this.bindEventListeners(!1)}}),this.tabs=null,this.container.querySelector("nav.tabs")&&(this.tabs=new window.jvbTabs(this.container,{updateURL:!1})),this.ui=window.uiFromSelectors(this.selectors),this.hasCopy||document.querySelectorAll(this.selectors.copyBtn).forEach((e=>{e.remove()})),this.formController=null}initStore(){if(!this.isLoggedIn())return;const e=window.jvbStore.register("referrals",[{storeName:"stats",keyPath:"user_id",endpoint:"referrals/stats",TTL:3e5,showLoading:!1,delayFetch:!1,filters:{type:"dashboard",user:window.auth.getUser()}},{storeName:"list",keyPath:"id",endpoint:"referrals",TTL:6e5,showLoading:!1,delayFetch:!1,filters:{user:window.auth.getUser(),status:"all",limit:50,offset:0}}]);this.statsStore=e.stats,this.listStore=e.list,this.statsStore&&this.statsStore.subscribe(this.handleStatsEvent.bind(this)),this.listStore&&this.listStore.subscribe(this.handleListEvent.bind(this))}initListeners(){this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleFormSubmit.bind(this)}bindEventListeners(e){const t=e?"addEventListener":"removeEventListener";this.forms.forEach((e=>{e[t]("submit",this.submitHandler)})),this.container[t]("click",this.clickHandler),this.container[t]("input",this.inputHandler)}isLoggedIn(){return Boolean(window.auth.getUser())}handleStatsEvent(e,t){switch(e){case"data-loaded":t.items&&t.items.length>0&&this.updateStatsDisplay();break;case"fetch-error":console.error("Error loading stats:",t.error)}}handleListEvent(e,t){switch(e){case"data-loaded":this.ui.recentList&&this.renderRecentReferrals();break;case"fetch-error":console.error("Error loading referrals:",t.error)}}updateStatsDisplay(){if(0===!this.statsStore.data.size)return;let e=this.statsStore.data.get(parseInt(window.auth.getUser()));const t={total:e.code_used||0,treated:e.treatments||0,pending:e.pending||0,rewards:"$"+parseFloat(e.total_rewards||0).toFixed(2)};Object.entries(t).forEach((([e,t])=>{const r=this.container.querySelector(`[data-stat="${e}"]`);r&&(r.textContent=t)}));const r=this.container.querySelectorAll(".stats .card");r.length>=4&&(r[0].querySelector(".stat-number").textContent=t.code_used,r[1].querySelector(".stat-number").textContent=t.consultations,r[2].querySelector(".stat-number").textContent=t.treatments,r[3].querySelector(".stat-number").textContent=t.total_rewards)}handleClick(e){const t=e.target.closest(".copy-btn, .check-code-btn, .attn");t&&(t.classList.contains("copy-btn")?this.handleCopyClick(t):t.classList.contains("check-code-btn")?this.handleCheckCode(e):t.classList.contains("attn")&&t.classList.remove("attn"))}handleCopyClick(e){const t=e.dataset.target,r=this.container.querySelector(`#${t}`);if(!r)return;const s=r.textContent.trim();this.hasCopy&&navigator.clipboard.writeText(s).then((()=>{e.classList.toggle("success"),setTimeout((()=>{e.classList.remove("success")}),1500)}))}handleError(e,t){const{message:r,code:s,field:a}=t;switch(a?this.showFieldError(e,a,r):this.showFormStatus(e,"error",r||"Something went wrong. Please try again."),s){case"duplicate_email":break;case"invalid_code":const t=e.querySelector('[name="referral_code"]');t&&(t.readOnly=!1,t.focus());break;case"turnstile_failed":window.turnstile&&e.querySelector(".cf-turnstile")&&window.turnstile.reset()}}showFieldError(e,t,r){let s=e.querySelector(`.field[data-field="${t}"]`);if(s||(s=e.querySelector(`.field[data-field="referral_${t}"]`)),!s)return void this.showFormStatus(e,"error",r);const a=s.querySelector("input, textarea, select"),n=s.querySelector(".validation-message"),i=s.querySelector(".validation-icon.error"),o=s.querySelector(".validation-icon.success");a?(s.classList.remove("has-success"),s.classList.add("has-error"),a.classList.add("error"),a.setAttribute("aria-invalid","true"),i&&(i.hidden=!1),o&&(o.hidden=!0),n&&(n.textContent=r,n.hidden=!1),a.focus(),this.a11y?.announce(`Error in ${t}: ${r}`)):this.showFormStatus(e,"error",r)}showFormStatus(e,t,r=""){const s=e.querySelector(".fstatus");if(!s)return void console.warn("No .fstatus element found in form");s.hidden=!1;const a=s.querySelector(".message");s.querySelector(".icon")?.remove(),s.querySelector(".actions")?.remove();const n={saving:"Sending...",submitted:"Sent successfully!",error:"Something went wrong",checking:"Checking code..."},i={submitted:"check-circle",error:"close-circle",checking:"loading"};if(i[t]&&window.getIcon){const e=window.getIcon(i[t]);e&&s.prepend(e)}a&&(a.textContent=r||n[t]||t),s.classList.toggle("loading",["saving","checking"].includes(t)),"submitted"===t&&setTimeout((()=>s.hidden=!0),3e3),this.a11y&&this.a11y.announce(r||n[t]||t)}clearFormErrors(e){e.querySelectorAll(".field.has-error, .field.has-success").forEach((e=>{this.clearFieldValidation(e)}));const t=e.querySelector(".fstatus");t&&(t.hidden=!0)}clearFieldValidation(e){if(!e)return;const t=e.querySelector("input, textarea, select"),r=e.querySelector(".validation-message"),s=e.querySelectorAll(".validation-icon");e.classList.remove("has-error","has-success"),t&&(t.classList.remove("error"),t.removeAttribute("aria-invalid")),s.forEach((e=>e.hidden=!0)),r&&(r.hidden=!0,r.textContent="")}handleInput(e){"referral_code"!==e.target.id&&"referral_code"!==e.target.name||(e.target.value=e.target.value.toUpperCase());const t=e.target.closest(".field");t&&t.classList.contains("has-error")&&this.clearFieldValidation(t)}async handleCheckCode(e){e.preventDefault();const t=e.target.closest("form"),r=t.querySelector('[name="referral_code"]'),s=t.querySelector(".code-status");if(!r||!s)return;const a=r.value.trim();if(a){s.hidden=!1,s.className="code-status loading",s.innerHTML='<span class="spinner"></span> Checking...';try{const e=await this.validateCodeOnly(a);e.success?this.showCodeStatus(s,`✓ Valid! Referred by ${e.referrer_name}`,"success"):this.showCodeStatus(s,e.message||"Invalid code","error")}catch(e){console.error("Error checking code:",e),this.showCodeStatus(s,"Error checking code","error")}}else this.showCodeStatus(s,"Please enter a code","error")}showCodeStatus(e,t,r){e.hidden=!1,e.className=`code-status ${r}`,e.textContent=t,"error"===r&&setTimeout((()=>{e.hidden=!0}),5e3)}async checkForReferral(){const e=this.getUrlParameter("ref"),t=this.getUrlParameter("rname"),r=this.getUrlParameter("remail"),s=this.getUrlParameter("seeReferral");if(!e&&!s)return;if(s&&!e)return this.popup.openPopup(),void this.removeUrlParameter("seeReferral");const a=this.container.querySelector('[name="referral_code"]');if(!a)return;const n=e.toUpperCase();if(a.value=n,a.readOnly=!0,t||r){const e=this.container.querySelector('[name="referral_name"]');e&&(e.value=t);const s=this.container.querySelector('[name="referral_email"]');s&&(s.value=r)}this.popup.openPopup();try{const e=await this.validateCodeOnly(n);if(e.success){const t=a.closest("form").querySelector(".code-status");t&&this.showCodeStatus(t,`✓ ${e.referrer_name} invited you!`,"success");const r=this.container.querySelector('[name="referral_name"]');r&&!r.value&&r.focus()}else a.readOnly=!1,this.showMessage("This referral link is invalid. Please enter a valid code.","error")}catch(e){console.error("Error validating code:",e),a.readOnly=!1}this.removeUrlParameter("ref"),this.removeUrlParameter("rname"),this.removeUrlParameter("remail")}getUrlParameter(e){return new URLSearchParams(window.location.search).get(e)}removeUrlParameter(e){const t=new URL(window.location);t.searchParams.delete(e),window.history.replaceState({},document.title,t.toString())}async validateCodeOnly(e){const t=await fetch(`${jvbSettings.api}referrals/code`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify({code:e})});return await t.json()}renderRecentReferrals(){let e=this.ui.recentList,t=Array.from(this.listStore.data.values());t&&0!==t.length?e.innerHTML=t.map((e=>`\n\t\t\t<div class="referral-item">\n\t\t\t\t<div class="referral-info">\n\t\t\t\t\t<strong>${window.escapeHtml(e.referee_name)}</strong>\n\t\t\t\t\t<span class="status-badge">${e.referral_status}</span>\n\t\t\t\t</div>\n\t\t\t\t<div class="referral-date">${window.formatTimeAgo(e.referred_at)}</div>\n\t\t\t</div>\n\t\t`)).join(""):e.innerHTML='<p class="no-referrals">Share your code to get started!</p>'}async handleFormSubmit(e){e.preventDefault();const t=e.target,r=new FormData(t);this.clearFormErrors(t),this.setFormLoading(!0,t);try{let e={success:!1,message:""};if("referral-code-form"===t.id){let t={name:r.get("referral_name"),email:r.get("referral_email"),referral_code:r.get("referral_code")};r.get("cf-turnstile-response")&&(t["cf-turnstile-response"]=r.get("cf-turnstile-response")),t.name&&t.email&&t.referral_code?e=await this.makeRequest("auth/register",t):e.message="Please fill in all fields"}else if("login-form"===t.id){let t={type:"login",email:r.get("login_email"),context:{redirect_to:window.location.href+"?seeReferral=1"}};r.get("cf-turnstile-response")&&(t["cf-turnstile-response"]=r.get("cf-turnstile-response")),t.email?e=await this.makeRequest("magic",t):e.message="Please fill in your email"}e.success?this.handleSuccess(t,e):this.handleError(t,e)}catch(e){console.error("Error submitting form:",e),this.showFormMessage(t,"Something went wrong. Please try again.","error")}finally{this.setFormLoading(!1,t)}}async makeRequest(e,t){if(!["magic","auth/register"].includes(e))return{success:!1,message:"Invalid endpoint"};const r=await fetch(`${jvbSettings.api}${e}`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify(t)});if(!r.ok){const e=await r.text();console.error("Error response:",r.status,e);try{return JSON.parse(e)}catch{return{success:!1,message:"Server error"}}}return await r.json()}handleSuccess(e,t){e.style.display="none";const r=e.nextElementSibling;r&&r.classList.contains("success-content")&&(r.hidden=!1,r.scrollIntoView({behavior:"smooth",block:"center"})),this.dispatchEvent("emailSent",{email:t.email})}showFormMessage(e,t,r="error"){const s=e.querySelector(".status");if(!s)return;const a=s.querySelector(".message");a&&(a.textContent=t),s.hidden=!1,s.className=`status ${r}`,"error"===r&&setTimeout((()=>{s.hidden=!0}),5e3)}setFormLoading(e,t){t.querySelectorAll("input, button, textarea, select").forEach((t=>t.disabled=e)),e&&this.showFormStatus(t,"saving")}dispatchEvent(e,t){const r=new CustomEvent("referralWidget:"+e,{detail:t,bubbles:!0});this.container.dispatchEvent(r)}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbReferral=new e)}))}))})();
(()=>{class e{constructor(){this.container=document.querySelector("aside.referral"),this.container&&(this.a11y=window.jvbA11y,this.toggle=document.querySelector('button[data-action="toggle-referral"]'),this.hasCopy=navigator.clipboard&&navigator.clipboard.writeText,this.initElements(),this.initStore(),this.initListeners(),this.checkForReferral())}initElements(){this.selectors={copyBtn:".copy-btn",checkCode:".check-code-btn",submit:"[type=submit]",recentList:".recent-referrals-list",stats:{codeUsed:'[data-stat="code_used"]',consultations:'[data-stat="consultations"]',treatments:'[data-stat="treatments"]',rewards:'[data-stat="total_rewards"]'}},this.forms=this.container.querySelectorAll("form"),this.popup=new window.jvbPopup({toggle:this.toggle,popup:this.container,name:"Referral Box",onOpen:()=>{this.bindEventListeners(!0)},onClose:()=>{this.bindEventListeners(!1)}}),this.tabs=null,this.container.querySelector("nav.tabs")&&(this.tabs=window.jvbTabs.registerTab(this.container,{updateURL:!1})),this.ui=window.uiFromSelectors(this.selectors),this.hasCopy||document.querySelectorAll(this.selectors.copyBtn).forEach((e=>{e.remove()}))}initStore(){if(!this.isLoggedIn())return;const e=window.jvbStore.register("referrals",[{storeName:"stats",keyPath:"user_id",endpoint:"referrals/stats",TTL:3e5,showLoading:!1,delayFetch:!1,filters:{type:"dashboard",user:window.auth.getUser()}},{storeName:"list",keyPath:"id",endpoint:"referrals",TTL:6e5,showLoading:!1,delayFetch:!1,filters:{user:window.auth.getUser(),status:"all",limit:50,offset:0}}]);this.statsStore=e.stats,this.listStore=e.list,this.statsStore&&this.statsStore.subscribe(this.handleStatsEvent.bind(this)),this.listStore&&this.listStore.subscribe(this.handleListEvent.bind(this))}initListeners(){this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleFormSubmit.bind(this)}bindEventListeners(e){const t=e?"addEventListener":"removeEventListener";this.forms.forEach((e=>{e[t]("submit",this.submitHandler)})),this.container[t]("click",this.clickHandler),this.container[t]("input",this.inputHandler)}isLoggedIn(){return Boolean(window.auth.getUser())}handleStatsEvent(e,t){switch(e){case"data-loaded":t.items&&t.items.length>0&&this.updateStatsDisplay();break;case"fetch-error":console.error("Error loading stats:",t.error)}}handleListEvent(e,t){switch(e){case"data-loaded":this.ui.recentList&&this.renderRecentReferrals();break;case"fetch-error":console.error("Error loading referrals:",t.error)}}updateStatsDisplay(){if(0===!this.statsStore.data.size)return;let e=this.statsStore.data.get(parseInt(window.auth.getUser()));const t={total:e.code_used||0,treated:e.treatments||0,pending:e.pending||0,rewards:"$"+parseFloat(e.total_rewards||0).toFixed(2)};Object.entries(t).forEach((([e,t])=>{const r=this.container.querySelector(`[data-stat="${e}"]`);r&&(r.textContent=t)}));const r=this.container.querySelectorAll(".stats .card");r.length>=4&&(r[0].querySelector(".stat-number").textContent=t.code_used,r[1].querySelector(".stat-number").textContent=t.consultations,r[2].querySelector(".stat-number").textContent=t.treatments,r[3].querySelector(".stat-number").textContent=t.total_rewards)}handleClick(e){const t=e.target.closest(".copy-btn, .check-code-btn, .attn");t&&(t.classList.contains("copy-btn")?this.handleCopyClick(t):t.classList.contains("check-code-btn")?this.handleCheckCode(e):t.classList.contains("attn")&&t.classList.remove("attn"))}handleCopyClick(e){const t=e.dataset.target,r=this.container.querySelector(`#${t}`);if(!r)return;const s=r.textContent.trim();this.hasCopy&&navigator.clipboard.writeText(s).then((()=>{e.classList.toggle("success"),setTimeout((()=>{e.classList.remove("success")}),1500)}))}handleError(e,t){const{message:r,code:s,field:a}=t;switch(a?this.showFieldError(e,a,r):this.showFormStatus(e,"error",r||"Something went wrong. Please try again."),s){case"duplicate_email":break;case"invalid_code":const t=e.querySelector('[name="referral_code"]');t&&(t.readOnly=!1,t.focus());break;case"turnstile_failed":window.turnstile&&e.querySelector(".cf-turnstile")&&window.turnstile.reset()}}showFieldError(e,t,r){let s=e.querySelector(`.field[data-field="${t}"]`);if(s||(s=e.querySelector(`.field[data-field="referral_${t}"]`)),!s)return void this.showFormStatus(e,"error",r);const a=s.querySelector("input, textarea, select"),i=s.querySelector(".validation-message"),n=s.querySelector(".validation-icon.error"),o=s.querySelector(".validation-icon.success");a?(s.classList.remove("has-success"),s.classList.add("has-error"),a.classList.add("error"),a.setAttribute("aria-invalid","true"),n&&(n.hidden=!1),o&&(o.hidden=!0),i&&(i.textContent=r,i.hidden=!1),a.focus(),this.a11y?.announce(`Error in ${t}: ${r}`)):this.showFormStatus(e,"error",r)}showFormStatus(e,t,r=""){const s=e.querySelector(".fstatus");if(!s)return void console.warn("No .fstatus element found in form");s.hidden=!1;const a=s.querySelector(".message");s.querySelector(".icon")?.remove(),s.querySelector(".actions")?.remove();const i={saving:"Sending...",submitted:"Sent successfully!",error:"Something went wrong",checking:"Checking code..."},n={submitted:"check-circle",error:"close-circle",checking:"loading"};if(n[t]&&window.getIcon){const e=window.getIcon(n[t]);e&&s.prepend(e)}a&&(a.textContent=r||i[t]||t),s.classList.toggle("loading",["saving","checking"].includes(t)),"submitted"===t&&setTimeout((()=>s.hidden=!0),3e3),this.a11y&&this.a11y.announce(r||i[t]||t)}clearFormErrors(e){e.querySelectorAll(".field.has-error, .field.has-success").forEach((e=>{this.clearFieldValidation(e)}));const t=e.querySelector(".fstatus");t&&(t.hidden=!0)}clearFieldValidation(e){if(!e)return;const t=e.querySelector("input, textarea, select"),r=e.querySelector(".validation-message"),s=e.querySelectorAll(".validation-icon");e.classList.remove("has-error","has-success"),t&&(t.classList.remove("error"),t.removeAttribute("aria-invalid")),s.forEach((e=>e.hidden=!0)),r&&(r.hidden=!0,r.textContent="")}handleInput(e){"referral_code"!==e.target.id&&"referral_code"!==e.target.name||(e.target.value=e.target.value.toUpperCase());const t=e.target.closest(".field");t&&t.classList.contains("has-error")&&this.clearFieldValidation(t)}async handleCheckCode(e){e.preventDefault();const t=e.target.closest("form"),r=t.querySelector('[name="referral_code"]'),s=t.querySelector(".code-status");if(!r||!s)return;const a=r.value.trim();if(a){s.hidden=!1,s.className="code-status loading",s.innerHTML='<span class="spinner"></span> Checking...';try{const e=await this.validateCodeOnly(a);e.success?this.showCodeStatus(s,`✓ Valid! Referred by ${e.referrer_name}`,"success"):this.showCodeStatus(s,e.message||"Invalid code","error")}catch(e){console.error("Error checking code:",e),this.showCodeStatus(s,"Error checking code","error")}}else this.showCodeStatus(s,"Please enter a code","error")}showCodeStatus(e,t,r){e.hidden=!1,e.className=`code-status ${r}`,e.textContent=t,"error"===r&&setTimeout((()=>{e.hidden=!0}),5e3)}async checkForReferral(){const e=this.getUrlParameter("ref"),t=this.getUrlParameter("rname"),r=this.getUrlParameter("remail"),s=this.getUrlParameter("seeReferral");if(!e&&!s)return;if(s&&!e)return this.popup.openPopup(),void this.removeUrlParameter("seeReferral");const a=this.container.querySelector('[name="referral_code"]');if(!a)return;const i=e.toUpperCase();if(a.value=i,a.readOnly=!0,t||r){const e=this.container.querySelector('[name="referral_name"]');e&&(e.value=t);const s=this.container.querySelector('[name="referral_email"]');s&&(s.value=r)}this.popup.openPopup();try{const e=await this.validateCodeOnly(i);if(e.success){const t=a.closest("form").querySelector(".code-status");t&&this.showCodeStatus(t,`✓ ${e.referrer_name} invited you!`,"success");const r=this.container.querySelector('[name="referral_name"]');r&&!r.value&&r.focus()}else a.readOnly=!1,this.showMessage("This referral link is invalid. Please enter a valid code.","error")}catch(e){console.error("Error validating code:",e),a.readOnly=!1}this.removeUrlParameter("ref"),this.removeUrlParameter("rname"),this.removeUrlParameter("remail")}getUrlParameter(e){return new URLSearchParams(window.location.search).get(e)}removeUrlParameter(e){const t=new URL(window.location);t.searchParams.delete(e),window.history.replaceState({},document.title,t.toString())}async validateCodeOnly(e){const t=await fetch(`${jvbSettings.api}referrals/code`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify({code:e})});return await t.json()}renderRecentReferrals(){let e=this.ui.recentList,t=Array.from(this.listStore.data.values());t&&0!==t.length?e.innerHTML=t.map((e=>`\n\t\t\t<div class="referral-item">\n\t\t\t\t<div class="referral-info">\n\t\t\t\t\t<strong>${window.escapeHtml(e.referee_name)}</strong>\n\t\t\t\t\t<span class="status-badge">${e.referral_status}</span>\n\t\t\t\t</div>\n\t\t\t\t<div class="referral-date">${window.formatTimeAgo(e.referred_at)}</div>\n\t\t\t</div>\n\t\t`)).join(""):e.innerHTML='<p class="no-referrals">Share your code to get started!</p>'}async handleFormSubmit(e){e.preventDefault();const t=e.target,r=new FormData(t);this.clearFormErrors(t),this.setFormLoading(!0,t);try{let e={success:!1,message:""};if("referral-code-form"===t.id){let t={name:r.get("referral_name"),email:r.get("referral_email"),referral_code:r.get("referral_code")};r.get("cf-turnstile-response")&&(t["cf-turnstile-response"]=r.get("cf-turnstile-response")),t.name&&t.email&&t.referral_code?e=await this.makeRequest("auth/register",t):e.message="Please fill in all fields"}else if("login-form"===t.id){let t={type:"login",email:r.get("login_email"),context:{redirect_to:window.location.href+"?seeReferral=1"}};r.get("cf-turnstile-response")&&(t["cf-turnstile-response"]=r.get("cf-turnstile-response")),t.email?e=await this.makeRequest("magic",t):e.message="Please fill in your email"}e.success?this.handleSuccess(t,e):this.handleError(t,e)}catch(e){console.error("Error submitting form:",e),this.showFormMessage(t,"Something went wrong. Please try again.","error")}finally{this.setFormLoading(!1,t)}}async makeRequest(e,t){if(!["magic","auth/register"].includes(e))return{success:!1,message:"Invalid endpoint"};const r=await fetch(`${jvbSettings.api}${e}`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify(t)});if(!r.ok){const e=await r.text();console.error("Error response:",r.status,e);try{return JSON.parse(e)}catch{return{success:!1,message:"Server error"}}}return await r.json()}handleSuccess(e,t){e.style.display="none";const r=e.nextElementSibling;r&&r.classList.contains("success-content")&&(r.hidden=!1,r.scrollIntoView({behavior:"smooth",block:"center"})),this.dispatchEvent("emailSent",{email:t.email})}showFormMessage(e,t,r="error"){const s=e.querySelector(".status");if(!s)return;const a=s.querySelector(".message");a&&(a.textContent=t),s.hidden=!1,s.className=`status ${r}`,"error"===r&&setTimeout((()=>{s.hidden=!0}),5e3)}setFormLoading(e,t){t.querySelectorAll("input, button, textarea, select").forEach((t=>t.disabled=e)),e&&this.showFormStatus(t,"saving")}dispatchEvent(e,t){const r=new CustomEvent("referralWidget:"+e,{detail:t,bubbles:!0});this.container.dispatchEvent(r)}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbReferral=new e)}))}))})();
inc/EmbedGenerator.php
@@ -67,7 +67,7 @@
        $legacy_url = 'https://legacytattooremoval.ca';
        // Build HTML with structured data for SEO
        $html = <<<HTML
        return <<<HTML
<!-- edmonton.ink Artist Badge - Start -->
<div id="$container_id" class="jvb-badge" style="max-width:200px; margin:12px auto; text-align:center;">
    <a href="$profile_url" target="_blank" rel="noopener"
@@ -117,7 +117,6 @@
</div>
<!-- edmonton.ink Artist Badge - End -->
HTML;
        return $html;
    }
    /**
@@ -133,7 +132,7 @@
        $container_id = 'jvb-badge' . $this->user_id;
        $legacy_url   = 'https://legacytattooremoval.ca';
        $html = <<<HTML
        return <<<HTML
<!-- edmonton.ink Artist Badge - Start -->
<div id="$container_id" style="max-width:150px; text-align:center;">
    <a href="$profile_url" target="_blank" rel="noopener"
@@ -160,8 +159,6 @@
</div>
<!-- edmonton.ink Artist Badge - End -->
HTML;
        return $html;
    }
    /**
@@ -199,7 +196,7 @@
        $container_id = 'jvb-badge-img-' . $this->user_id;
        $legacy_url   = 'https://legacytattooremoval.ca';
        $html = <<<HTML
        return <<<HTML
<!-- edmonton.ink Artist Badge - Start -->
<div id="$container_id" style="display:inline-block;">
    <a href="$profile_url" target="_blank" rel="noopener"
@@ -229,8 +226,6 @@
</div>
<!-- edmonton.ink Artist Badge - End -->
HTML;
        return $html;
    }
    /**
@@ -245,7 +240,7 @@
        $image_only_code = htmlspecialchars($this->getImageOnlyEmbedCode(), ENT_QUOTES, 'UTF-8');
        $preview         = $this->getEmbedCode();
        $html = <<<HTML
        return <<<HTML
<div class="jvb-embed-code-container">
    <h3>Embed Code for Your Website</h3>
    <p>Copy this code to your website to show your edmonton.ink verified status and link back to your profile.</p>
@@ -303,7 +298,5 @@
    </div>
</div>
HTML;
        return $html;
    }
}
inc/blocks/CustomBlocks.php
@@ -1165,11 +1165,9 @@
                $classes[] = $class;
            }
        }
        $classes =  array_filter($classes, function ($class) {
            return $class!=='' && !str_starts_with($class, 'wp');
        });
        return $classes;
        return array_filter($classes, function ($class) {
            return $class!=='' && !str_starts_with($class, 'wp');
        });
    }
    protected function getClass(string $key, string|bool|array|int $value, array $attrs):string|array
    {
inc/blocks/FormBlock.php
@@ -116,9 +116,7 @@
            'sections' => []
        ];
        $config = array_merge($defaults, $config);
        return $config;
        return array_merge($defaults, $config);
    }
    /**
inc/integrations/GoogleMyBusiness.php
@@ -1737,9 +1737,7 @@
        $text = preg_replace('/\s+/', ' ', $text);
        // Trim
        $text = trim($text);
        return $text;
        return trim($text);
    }
    /**
     * Format date for GMB API
@@ -1780,11 +1778,10 @@
            // Validate hour and minute ranges
            if ($hour >= 0 && $hour <= 23 && $minute >= 0 && $minute <= 59) {
                $result = [
                return [
                    'hours' => $hour,
                    'minutes' => $minute
                ];
                return $result;
            }
        }
@@ -2074,9 +2071,7 @@
            [],
            'posts'
        );
        $result = $response['foodMenus'] ?? [];
        return $result;
        return $response['foodMenus'] ?? [];
    }
    /**
@@ -2351,12 +2346,11 @@
                'dailyRange.endDate.day' => date('j', strtotime($end_date))
            ];
            $response = $this->getRequest(
                "/v1/{$location_name}:fetchMultiDailyMetricsTimeSeries?" . http_build_query($params),
                $params,
                'performance'
            );
        return $response;
        return $this->getRequest(
            "/v1/{$location_name}:fetchMultiDailyMetricsTimeSeries?" . http_build_query($params),
            $params,
            'performance'
        );
    }
    /**
inc/integrations/Integrations.php
@@ -1626,10 +1626,7 @@
            $params = $this->addOAuthParams($params);
        }
        $auth_url = $this->oauth['authorize'] . '?' . http_build_query($params);
        return $auth_url;
        return $this->oauth['authorize'] . '?' . http_build_query($params);
    }
    /**
inc/integrations/PostMark.php
@@ -419,9 +419,7 @@
        // Clean up whitespace
        $text = preg_replace('/\s+/', ' ', $text);
        $text = trim($text);
        return $text;
        return trim($text);
    }
    /**
inc/managers/AdminPages.php
@@ -438,7 +438,7 @@
    protected function renderStatusItems():void
    {
        // Get queue stats
        $queue_status = JVB()->queue()->getQueueStatus();
        $queue_status = JVB()->queue()->getStatus();
        error_log('Queue Status: '.print_r($queue_status, true));
        // Other system checks
inc/managers/DashboardManager.php
@@ -210,7 +210,6 @@
            if (!in_array($page, $allowedPages)) {
                error_log("User not allowed to access page: {$page}");
                $this->redirectToDashboard();
                return;
            }
        }
    }
inc/managers/IconsManager.php
@@ -635,9 +635,7 @@
        // Clean up SVG for CSS usage
        $svg = preg_replace("/([\n\t]+)/", ' ', $svg);
        $svg = preg_replace('/>\s*</', '><', $svg);
        $svg = trim($svg);
        return $svg;
        return trim($svg);
    }
    public function registerStyle(): void
inc/managers/LoginManager.php
@@ -227,12 +227,14 @@
                        'type' => 'email',
                        'label' => __('Email Address', 'jvb'),
                        'required' => true,
                        'autocomplete'  => 'email',
                        'placeholder' => 'look@me.com',
                    ],
                    'user_password' => [
                        'type' => 'text',
                        'subtype'=> 'password',
                        'label' => __('Password', 'jvb'),
                        'autocomplete' => 'current-password',
                        'required' => true,
                    ],
                    'remember_me' => [
@@ -316,9 +318,7 @@
    }
    // Add nonce for security
    $logout_url = wp_nonce_url($logout_url, 'log-out');
    return $logout_url;
    return wp_nonce_url($logout_url, 'log-out');
}
    public function getLoginPage():int|false
    {
@@ -401,7 +401,7 @@
    protected function setup():void
    {
        $this->action = $this->getAction();
        if (in_array($this->action, ['logout']) || array_key_exists('loggedout', $_GET)) {
        if ($this->action == 'logout' || array_key_exists('loggedout', $_GET)) {
            wp_logout();
            wp_redirect(esc_attr($_GET['redirect_to'] ?? get_home_url()));
            exit;
@@ -861,115 +861,111 @@
        SCRIPTS
    ************************************************************************/
    public function enqueueScripts(): void
    {
        if (!$this->isLoginPage()) {
            return;
        }
{
    if (!$this->isLoginPage()) {
        return;
    }
        $this->maybeTurnstileScripts();
        wp_enqueue_script('jvb-form');
        $action = $this->getAction();
        ob_start();
        ?>
    $this->maybeTurnstileScripts();
    wp_enqueue_script('jvb-form');
    $action = $this->getAction();
document.addEventListener('DOMContentLoaded', async function () {
    $redirect_to = isset($_GET['redirect_to']) ? esc_url_raw($_GET['redirect_to']) : '';
    $has_turnstile = Features::hasIntegration('cloudflare');
    ob_start();
    ?>
    document.addEventListener('DOMContentLoaded', async function () {
        const hasTurnstile = <?= json_encode($has_turnstile) ?>;
        const redirectTo = <?= json_encode($redirect_to) ?>;
        window.auth.subscribe(event => {
            if (event === 'auth-loaded') {
                const form = document.querySelector('.login form');
                if (!form) return;
                if (!form || !window.jvbForm) return;
                if (!window.jvbForm) {
                    console.error('jvbForm not loaded');
                    return;
                }
                window.LoginController = new window.jvbForm();
                window.LoginController.registerForm(form, {
                window.jvbForm.registerForm(form, {
                    autosave: false,
                    endpoint: <?= "'{$action}'" ?>,
                    endpoint: '<?= $action ?>',
                    formStatus: false,
                    cache: false,
                });
                window.LoginController.subscribe((event, data) => {
                window.jvbForm.subscribe((event, data) => {
                    if (event === 'form-submit') {
                        handleFormSubmission(data);
                        const { config } = data;
                        const formElement = config.element;
                        // Collect current form data
                        const formData = new FormData(formElement);
                        const formObject = Object.fromEntries(formData.entries());
                        // Add redirect_to from URL
                        if (redirectTo) {
                            formObject.redirect_to = redirectTo;
                        }
                        const submit = formElement.querySelector('[type=submit]');
                        const oldText = submit.textContent;
                        window.jvbForm.showFormStatus(config.id, 'uploading');
                        submit.disabled = true;
                        submit.textContent = 'Loading...';
                        window.auth.fetch(`${jvbSettings.api}auth/<?= $action ?>`, {
                            method: 'POST',
                            body: JSON.stringify(formObject)
                        })
                        .then(response => response.json().then(result => ({ response, result })))
                        .then(({ response, result }) => {
                            if (!response.ok) {
                                window.jvbForm.showFormStatus(config.id, 'error');
                                window.jvbForm.handleFormError(formElement, result);
                                return;
                            }
                            window.jvbForm.showFormStatus(config.id, 'submitted');
                            if (result.message) {
                                window.jvbForm.handleFormSuccess(formElement, result);
                            }
                            if (window.auth?.handleLogin && result.auth) {
                                return window.auth.handleLogin(result.auth).then(() => {
                                    if (result.redirect) {
                                        setTimeout(() => {
                                            window.location.href = result.redirect;
                                        }, 100);
                                    }
                                });
                            } else if (result.redirect) {
                                setTimeout(() => {
                                    window.location.href = result.redirect;
                                }, 100);
                            }
                        })
                        .catch(error => {
                            console.error('Form submission error:', error);
                            window.jvbForm.showFormStatus(config.id, 'error');
                            window.jvbForm.handleFormError(formElement, {
                                message: 'Network error. Please check your connection and try again.',
                                code: 'network_error'
                            });
                        })
                        .finally(() => {
                            submit.textContent = oldText;
                            submit.disabled = false;
                        });
                    }
                });
                async function handleFormSubmission(data) {
                let realFormData = data.fullData;
                const { formId, config, data: formData } = data;
                const form = config.element;
                const submit = form.querySelector('[type=submit]');
                let oldText = submit.textContent;
                window.LoginController.showFormStatus(formId, 'uploading');
                try {
                    submit.disabled = true;
                    submit.textContent = 'Loading...';
                    const response = await window.auth.fetch(`${jvbSettings.api}auth/<?php echo $action; ?>`, {
                        method: 'POST',
                        body: JSON.stringify(realFormData)
                    });
                        const result = await response.json();
                        // Handle errors
                        if (!response.ok) {
                            window.LoginController.showFormStatus(formId, 'error');
                            window.LoginController.handleFormError(form, result);
                            return;
                        }
                        // Handle success
                        window.LoginController.showFormStatus(formId, 'submitted');
                        // Show success message briefly before redirect
                        if (result.message) {
                            window.LoginController.handleFormSuccess(form, result);
                        }
                        if (window.auth && typeof window.auth.handleLogin === 'function' && Object.hasOwn(result, 'auth')) {
                            console.log('Awaiting Auth...');
                            await window.auth.handleLogin(result.auth); // Pass the full result
                        }
                        // Handle redirect
                        if (result.redirect) {
                            setTimeout(() => {
                                window.location.href = result.redirect;
                            }, 100); // Brief delay to show success message
                        }
                    } catch (error) {
                        console.error('Form submission error:', error);
                        window.LoginController.showFormStatus(formId, 'error');
                        window.LoginController.handleFormError(form, {
                            message: 'Network error. Please check your connection and try again.',
                            code: 'network_error'
                        });
                    } finally {
                        submit.textContent = oldText;
                        submit.disabled = false;
                    }
                }
            }
        });
    });
});
        <?php
    <?php
        $script = ob_get_clean();
        wp_add_inline_script('jvb-form', $script);
    }
inc/managers/SEO/BreadcrumbManager.php
@@ -134,9 +134,7 @@
        }
        // Add term hierarchy
        $crumbs = array_merge($crumbs, $this->buildTermHierarchy($term));
        return $crumbs;
        return array_merge($crumbs, $this->buildTermHierarchy($term));
    }
    /**
inc/managers/SEOMetaManager.php
@@ -287,8 +287,7 @@
        $title_format = "{$title} | {$city}'s Best {$artist_type}";
        $title = strtok($title, ' ');
        $title_format = (strlen($title_format) <= $this->character_limits['title']) ? $title_format : "{$title} | {$city}'s Best {$artist_type}";
        return $title_format;
        return (strlen($title_format) <= $this->character_limits['title']) ? $title_format : "{$title} | {$city}'s Best {$artist_type}";
    }
    /**
inc/managers/SchemaManager.php
@@ -1090,29 +1090,27 @@
    private function getHomeSchema():array
    {
        // Main dataset schema for homepage
        $schema = [
            '@type' => 'WebPage',
            '@id' => get_home_url() . '/#webpage',
            'url' => get_home_url(),
            'name' => get_bloginfo('name') . ' | Edmonton\'s Best Tattoo Artists',
            'description' => 'Discover Edmonton\'s top tattoo artists, shops, and styles. Your comprehensive guide to Edmonton\'s tattoo scene.',
            'isPartOf' => [
                '@id' => get_home_url() . '/#website'
            ],
            'about' => [
                '@type' => 'Dataset',
                'name' => 'Edmonton Tattoo Artist Directory',
                'description' => 'Comprehensive directory of professional tattoo artists in Edmonton, Alberta',
                'creator' => [
                    '@id' => 'https://legacytattooremoval.ca/#organization'
                ],
                'publisher' => [
                    '@id' => 'https://legacytattooremoval.ca/#organization'
                ]
            ],
        ];
        return $schema;
        return [
            '@type' => 'WebPage',
            '@id' => get_home_url() . '/#webpage',
            'url' => get_home_url(),
            'name' => get_bloginfo('name') . ' | Edmonton\'s Best Tattoo Artists',
            'description' => 'Discover Edmonton\'s top tattoo artists, shops, and styles. Your comprehensive guide to Edmonton\'s tattoo scene.',
            'isPartOf' => [
                '@id' => get_home_url() . '/#website'
            ],
            'about' => [
                '@type' => 'Dataset',
                'name' => 'Edmonton Tattoo Artist Directory',
                'description' => 'Comprehensive directory of professional tattoo artists in Edmonton, Alberta',
                'creator' => [
                    '@id' => 'https://legacytattooremoval.ca/#organization'
                ],
                'publisher' => [
                    '@id' => 'https://legacytattooremoval.ca/#organization'
                ]
            ],
        ];
    }
    /**
inc/managers/UmamiMetrics.php
@@ -1516,56 +1516,54 @@
        $month_data = $this->getUserMetrics($user_id, $month_start, $today);
        // Build quick summary for dashboard
        $summary = [
            'today' => [
                'total_views' => $today_data['totals']['total_views'],
                'profile_views' => $today_data['totals']['profile_views'],
                'favourites' => $today_data['totals']['favourites'],
                'karma' => $today_data['totals']['karma']
            ],
            'yesterday' => [
                'total_views' => $yesterday_data['totals']['total_views'],
                'profile_views' => $yesterday_data['totals']['profile_views'],
                'favourites' => $yesterday_data['totals']['favourites'],
                'karma' => $yesterday_data['totals']['karma']
            ],
            'this_week' => [
                'total_views' => $week_data['totals']['total_views'],
                'profile_views' => $week_data['totals']['profile_views'],
                'favourites' => $week_data['totals']['favourites'],
                'karma' => $week_data['totals']['karma']
            ],
            'this_month' => [
                'total_views' => $month_data['totals']['total_views'],
                'profile_views' => $month_data['totals']['profile_views'],
                'favourites' => $month_data['totals']['favourites'],
                'karma' => $month_data['totals']['karma']
            ],
            'growth' => [
                'day_over_day' => [
                    'total_views' => $this->calculatePercentageChange(
                        $yesterday_data['totals']['total_views'],
                        $today_data['totals']['total_views']
                    ),
                    'profile_views' => $this->calculatePercentageChange(
                        $yesterday_data['totals']['profile_views'],
                        $today_data['totals']['profile_views']
                    ),
                    'favourites' => $this->calculatePercentageChange(
                        $yesterday_data['totals']['favourites'],
                        $today_data['totals']['favourites']
                    ),
                    'karma' => $this->calculateAbsoluteChange(
                        $yesterday_data['totals']['karma'],
                        $today_data['totals']['karma']
                    )
                ]
            ],
            'top_content' => $this->simplifyTopContent($week_data['top_content']),
            'sources' => $week_data['source_breakdown']
        ];
        return $summary;
        return [
            'today' => [
                'total_views' => $today_data['totals']['total_views'],
                'profile_views' => $today_data['totals']['profile_views'],
                'favourites' => $today_data['totals']['favourites'],
                'karma' => $today_data['totals']['karma']
            ],
            'yesterday' => [
                'total_views' => $yesterday_data['totals']['total_views'],
                'profile_views' => $yesterday_data['totals']['profile_views'],
                'favourites' => $yesterday_data['totals']['favourites'],
                'karma' => $yesterday_data['totals']['karma']
            ],
            'this_week' => [
                'total_views' => $week_data['totals']['total_views'],
                'profile_views' => $week_data['totals']['profile_views'],
                'favourites' => $week_data['totals']['favourites'],
                'karma' => $week_data['totals']['karma']
            ],
            'this_month' => [
                'total_views' => $month_data['totals']['total_views'],
                'profile_views' => $month_data['totals']['profile_views'],
                'favourites' => $month_data['totals']['favourites'],
                'karma' => $month_data['totals']['karma']
            ],
            'growth' => [
                'day_over_day' => [
                    'total_views' => $this->calculatePercentageChange(
                        $yesterday_data['totals']['total_views'],
                        $today_data['totals']['total_views']
                    ),
                    'profile_views' => $this->calculatePercentageChange(
                        $yesterday_data['totals']['profile_views'],
                        $today_data['totals']['profile_views']
                    ),
                    'favourites' => $this->calculatePercentageChange(
                        $yesterday_data['totals']['favourites'],
                        $today_data['totals']['favourites']
                    ),
                    'karma' => $this->calculateAbsoluteChange(
                        $yesterday_data['totals']['karma'],
                        $today_data['totals']['karma']
                    )
                ]
            ],
            'top_content' => $this->simplifyTopContent($week_data['top_content']),
            'sources' => $week_data['source_breakdown']
        ];
    }
    /**
inc/managers/_setup.php
@@ -20,7 +20,8 @@
    }
}
require(JVB_DIR . '/inc/managers/ErrorHandler.php');
require(JVB_DIR . '/inc/managers/OperationQueue.php');
require(JVB_DIR . '/inc/managers/queue/_setup.php');
//require(JVB_DIR . '/inc/managers/OperationQueue.php');
require(JVB_DIR . '/inc/managers/EmailManager.php');
if (Features::forSite()->has('magicLink')) {
inc/managers/queue/Executor.php
New file
@@ -0,0 +1,14 @@
<?php
namespace JVBase\managers\queue;
if (!defined('ABSPATH')) {
    exit;
}
//Executor.php
interface Executor
{
    /**
     * Execute and return result.
     * Throw exception on fatal failure.
     */
    public function execute(Operation $operation, Progress $progress): Result;
}
inc/managers/queue/FilteredExecutor.php
New file
@@ -0,0 +1,154 @@
<?php
namespace JVBase\managers\queue;
if (!defined('ABSPATH')) {
    exit;
}
final class FilteredExecutor implements Executor
{
    public function __construct(
        private Storage $storage
    ) {}
    public function execute(Operation $operation, Progress $progress): Result
    {
        $chunkKey = $operation->metadata['chunk_key'] ?? null;
        // No chunking â€” process entire request at once
        if (!$chunkKey) {
            return $this->processSingle($operation, $progress);
        }
        // Chunked processing
        return $this->processChunked($operation, $progress, $chunkKey);
    }
    private function processSingle(Operation $operation, Progress $progress): Result
    {
        $filterResult = $this->callFilter($operation, $operation->requestData);
        $progress->advance(1);
        return new Result(
            outcome: $filterResult['success'] ? 'success' : 'failed',
            result: $filterResult['result'] ?? null
        );
    }
    private function processChunked(Operation $operation, Progress $progress, string|array $chunkKey): Result
    {
        $keys = (array) $chunkKey;
        $chunkSize = $operation->metadata['chunk_size'] ?? 10;
        $chunks = $this->buildChunks($operation->requestData, $keys, $chunkSize);
        $offset = $operation->metadata['chunk_offset'] ?? 0;
        $results = [];
        foreach ($chunks as $index => $chunk) {
            if ($index < $offset) {
                continue;
            }
            $chunkData = array_merge(
            // Non-chunked data
                array_diff_key($operation->requestData, array_flip($keys)),
                // This chunk's data
                $chunk['data']
            );
            $filterResult = $this->callFilter($operation, $chunkData);
            if (!$filterResult['success']) {
                // Record failed items but continue
                foreach ($chunk['data'] as $key => $items) {
                    foreach ($items as $item) {
                        $progress->failItem($item, $filterResult['message'] ?? 'Chunk failed');
                    }
                }
            }
            $progress->advance($chunk['count']);
            $operation->metadata['chunk_offset'] = $index + 1;
            if (isset($filterResult['result'])) {
                $results = array_merge($results, (array) $filterResult['result']);
            }
            // Save progress after each chunk
            $this->storage->save($operation);
        }
        $outcome = 'success';
        if ($operation->failedItems) {
            $outcome = count($operation->failedItems) === $operation->totalItems ? 'failed' : 'partial';
        }
        return new Result(
            outcome: $outcome,
            result: [
                'processed' => $operation->processedItems,
                'failed'    => $operation->failedItems ? count($operation->failedItems) : 0,
                'chunks'    => count($chunks),
                'results'   => $results,
            ]
        );
    }
    private function callFilter(Operation $operation, array $data): array
    {
        $filterResult = apply_filters(
            BASE . 'handle_bulk_operation',
            ['success' => false, 'message' => 'No handler for: ' . $operation->type],
            (object) [
                'id'      => $operation->id,
                'type'    => $operation->type,
                'user_id' => $operation->userId,
            ],
            $data
        );
        // Normalize WP_Error
        if (is_wp_error($filterResult)) {
            return [
                'success' => false,
                'message' => $filterResult->get_error_message(),
            ];
        }
        // Ensure expected format
        if (!is_array($filterResult) || !isset($filterResult['success'])) {
            return [
                'success' => false,
                'message' => 'Invalid handler response',
            ];
        }
        return $filterResult;
    }
    private function buildChunks(array $data, array $keys, int $chunkSize): array
    {
        // Collect all items across keys
        $allItems = [];
        foreach ($keys as $key) {
            if (isset($data[$key]) && is_array($data[$key])) {
                foreach ($data[$key] as $i => $item) {
                    $allItems[] = ['key' => $key, 'index' => $i, 'item' => $item];
                }
            }
        }
        // Split into chunks
        $chunks = [];
        foreach (array_chunk($allItems, $chunkSize) as $chunkItems) {
            $chunkData = [];
            foreach ($chunkItems as $entry) {
                $chunkData[$entry['key']][] = $entry['item'];
            }
            $chunks[] = [
                'data'  => $chunkData,
                'count' => count($chunkItems),
            ];
        }
        return $chunks;
    }
}
inc/managers/queue/Locker.php
New file
@@ -0,0 +1,65 @@
<?php
namespace JVBase\managers\queue;
if (!defined('ABSPATH')) {
    exit;
}
class Locker
{
    private string $lockKey;
    private int $ttl;
    private ?string $token = null;
    public function __construct(string $key = 'queue_processor', int $ttl = 300)
    {
        $this->lockKey = BASE . '_lock_' . $key;
        $this->ttl = $ttl;
    }
    public function acquire(): bool
    {
        // Generate unique token for this process
        $this->token = uniqid(gethostname() . '_', true);
        // SET NX with TTL - atomic "set if not exists"
        $result = wp_cache_add($this->lockKey, $this->token, 'locks', $this->ttl);
        return $result === true;
    }
    public function release(): void
    {
        if (!$this->token) {
            return;
        }
        // Only release if we own the lock
        $current = wp_cache_get($this->lockKey, 'locks');
        if ($current === $this->token) {
            wp_cache_delete($this->lockKey, 'locks');
        }
        $this->token = null;
    }
    public function isLocked(): bool
    {
        return wp_cache_get($this->lockKey, 'locks') !== false;
    }
    /**
     * Execute callback with lock, auto-release after
     */
    public function withLock(callable $callback): mixed
    {
        if (!$this->acquire()) {
            return null;
        }
        try {
            return $callback();
        } finally {
            $this->release();
        }
    }
}
inc/managers/queue/Mergeable.php
New file
@@ -0,0 +1,11 @@
<?php
namespace JVBase\managers\queue;
if (!defined('ABSPATH')) {
    exit;
}
interface Mergeable
{
    public function canMerge(Operation $existing, Operation $incoming): bool;
    public function merge(Operation $existing, Operation $incoming): Operation;
}
inc/managers/queue/Metrics.php
New file
@@ -0,0 +1,210 @@
<?php
namespace JVBase\managers\queue;
if (!defined('ABSPATH')) {
    exit;
}
final class Metrics
{
    public function __construct(
        private \wpdb $db,
        private string $queueTable,
        private string $statsTable
    ) {}
    private function sinceDate(int $hours): string
    {
        return gmdate('Y-m-d H:i:s', time() - ($hours * HOUR_IN_SECONDS));
    }
    /* ---------- Live metrics (raw queue table) ---------- */
    public function liveSummary(int $hours = 24): array
    {
        $since = $this->sinceDate($hours);
        $sql = "
        SELECT
            COUNT(*) AS total,
            SUM(outcome = 'success') AS success,
            SUM(outcome = 'partial') AS partial,
            SUM(outcome IN ('failed', 'failed_permanent')) AS failed,
            SUM(state IN ('pending','scheduled','processing')) AS active
        FROM {$this->queueTable}
        WHERE created_at >= %s
    ";
        return (array) $this->db->get_row(
            $this->db->prepare($sql, $since),
            ARRAY_A
        );
    }
    public function byHour(int $hours = 24): array
    {
        $since = $this->sinceDate($hours);
        $sql = "
        SELECT
            HOUR(created_at) AS hour,
            COUNT(*) AS total,
            SUM(outcome = 'success') AS success,
            SUM(outcome IN ('failed','failed_permanent')) AS failed
        FROM {$this->queueTable}
        WHERE created_at >= %s
        GROUP BY hour
        ORDER BY hour ASC
    ";
        $rows = $this->db->get_results(
            $this->db->prepare($sql, $since),
            ARRAY_A
        );
        $result = [];
        foreach ($rows as $row) {
            $key = str_pad($row['hour'], 2, '0', STR_PAD_LEFT);
            $result[$key] = [
                'total'   => (int) $row['total'],
                'success' => (int) $row['success'],
                'failed'  => (int) $row['failed'],
            ];
        }
        return $result;
    }
    public function liveByUser(int $hours = 24, int $limit = 100): array
    {
        $since = $this->sinceDate($hours);
        $sql = "
        SELECT
            user_id,
            COUNT(*) AS total,
            SUM(outcome = 'success') AS success,
            SUM(outcome IN ('failed','failed_permanent')) AS failed,
            AVG(retries) AS avg_retries
        FROM {$this->queueTable}
        WHERE created_at >= %s
        GROUP BY user_id
        ORDER BY total DESC
        LIMIT %d
    ";
        return $this->db->get_results(
            $this->db->prepare($sql, $since, $limit),
            ARRAY_A
        );
    }
    public function liveQueueHealth(): array
    {
        $sql = "
        SELECT
            SUM(state = 'pending') AS pending,
            SUM(state = 'scheduled') AS scheduled,
            SUM(state = 'processing') AS processing,
            MIN(scheduled_at) AS oldest_scheduled,
            SUM(
                state = 'processing'
                AND started_at < DATE_SUB(NOW(), INTERVAL 15 MINUTE)
            ) AS stuck
        FROM {$this->queueTable}
    ";
        return (array) $this->db->get_row($sql, ARRAY_A);
    }
    public function liveByType(int $hours = 24): array
    {
        $since = gmdate('Y-m-d H:i:s', time() - $hours * HOUR_IN_SECONDS);
        $sql = "
        SELECT
            type,
            COUNT(*) AS total,
            SUM(outcome = 'success') AS success,
            SUM(outcome = 'partial') AS partial,
            SUM(outcome IN ('failed','failed_permanent')) AS failed,
            AVG(TIMESTAMPDIFF(SECOND, started_at, completed_at)) AS avg_duration
        FROM {$this->queueTable}
        WHERE completed_at IS NOT NULL
          AND created_at >= %s
        GROUP BY type
    ";
        return $this->db->get_results(
            $this->db->prepare($sql, $since),
            ARRAY_A
        );
    }
    /* ---------- Historical metrics (stats table) ---------- */
    public function dailySummary(
        \DateTimeInterface $from,
        \DateTimeInterface $to
    ): array {
        $sql = "
        SELECT
            date,
            SUM(total_operations) AS total,
            SUM(successful_operations) AS success,
            SUM(partial_operations) AS partial,
            SUM(failed_operations) AS failed,
            SUM(failed_permanent_operations) AS failed_permanent,
            SUM(total_items_processed) AS items,
            AVG(average_duration) AS avg_duration,
            MAX(max_duration) AS max_duration
        FROM {$this->statsTable}
        WHERE date BETWEEN %s AND %s
        GROUP BY date
        ORDER BY date
    ";
        return $this->db->get_results(
            $this->db->prepare(
                $sql,
                $from->format('Y-m-d'),
                $to->format('Y-m-d')
            ),
            ARRAY_A
        );
    }
    public function dailyByType(
        \DateTimeInterface $from,
        \DateTimeInterface $to
    ): array {
        $sql = "
        SELECT
            date,
            type,
            total_operations,
            successful_operations,
            partial_operations,
            failed_operations,
            failed_permanent_operations,
            average_duration,
            max_duration
        FROM {$this->statsTable}
        WHERE date BETWEEN %s AND %s
        ORDER BY date, type
    ";
        return $this->db->get_results(
            $this->db->prepare(
                $sql,
                $from->format('Y-m-d'),
                $to->format('Y-m-d')
            ),
            ARRAY_A
        );
    }
}
inc/managers/queue/Operation.php
New file
@@ -0,0 +1,36 @@
<?php
namespace JVBase\managers\queue;
if (!defined('ABSPATH')) {
    exit;
}
//Operation.php
final class Operation
{
    public string $id;
    public string $type;
    public int $userId;
    public array $requestData;
    public array $metadata = [];
    public array $dependencies = [];
    public int $totalItems = 1;
    public int $processedItems = 0;
    public ?array $failedItems = [];
    public string $priority = 'normal';
    public string $state = 'pending';
    public ?string $outcome = null;
    public int $retries = 0;
    public ?string $lastErrorHash = null;
    public ?string $errorMessage = null;
    public ?string $scheduledAt = null;
    public ?string $startedAt = null;
    public ?string $completedAt = null;
    public ?array $result = null;
    public bool $userDismissed = false;
}
inc/managers/queue/Processor.php
New file
@@ -0,0 +1,91 @@
<?php
namespace JVBase\managers\queue;
if (!defined('ABSPATH')) {
    exit;
}
final class Processor
{
    public function __construct(
        private Storage $storage,
        private Executor $defaultExecutor,
        private TypeRegistry $registry,
        private Locker $locker
    ) {}
    public function run(): void
    {
        $this->locker->withLock(function () {
            $ops = $this->storage->fetchRunnable(10);
            foreach ($ops as $op) {
                if (!$this->storage->markProcessing($op->id)) {
                    continue;
                }
                $this->processOne($op);
            }
            $this->storage->invalidateQueueCache();
        });
    }
    private function processOne(Operation $op): void
    {
        $progress = new Progress($op);
        $executor = $this->registry->getExecutor($op->type) ?? $this->defaultExecutor;
        try {
            $result = $executor->execute($op, $progress);
            $op->state = 'completed';
            $op->outcome = $result->outcome;
            $op->result = $result->result;
            $op->completedAt = current_time('mysql');
        } catch (\Throwable $e) {
            $this->handleFailure($op, $e);
        }
        $this->storage->save($op);
    }
    private function handleFailure(Operation $op, \Throwable $e): void
    {
        $hash = md5($e->getMessage());
        if ($op->lastErrorHash === $hash) {
            // Same error twice â†’ permanent failure
            $op->state = 'completed';
            $op->outcome = 'failed_permanent';
            $op->completedAt = current_time('mysql');
        } else {
            // New error â†’ schedule retry
            $op->retries++;
            $op->lastErrorHash = $hash;
            $op->state = 'scheduled';
            $op->scheduledAt = $this->calculateBackoff($op->retries);
        }
        $op->errorMessage = $e->getMessage();
        JVB()->error()->log(
            '[Queue]:processOne',
            $e->getMessage(),
            [
                'operation_id' => $op->id,
                'type'         => $op->type,
                'user_id'      => $op->userId,
                'retries'      => $op->retries,
            ],
            $op->outcome === 'failed_permanent' ? 'critical' : 'warning'
        );
    }
    private function calculateBackoff(int $attempt): string
    {
        $delay = min(30 * pow(2, $attempt - 1), 3600);
        $jitter = rand(0, (int)($delay * 0.1));
        return date('Y-m-d H:i:s', time() + $delay + $jitter);
    }
}
inc/managers/queue/Progress.php
New file
@@ -0,0 +1,31 @@
<?php
namespace JVBase\managers\queue;
if (!defined('ABSPATH')) {
    exit;
}
//Progress.php
final class Progress
{
    private Operation $operation;
    public function __construct(Operation $op)
    {
        $this->operation = $op;
    }
    public function advance(int $count = 1): void
    {
        $this->operation->processedItems = min(
            $this->operation->processedItems + $count,
            $this->operation->totalItems
        );
    }
    public function failItem(mixed $item, string $reason): void
    {
        $this->operation->failedItems[] = [
            'item' => $item,
            'reason' => $reason,
        ];
    }
}
inc/managers/queue/Queue.php
New file
@@ -0,0 +1,390 @@
<?php
namespace JVBase\managers\queue;
if (!defined('ABSPATH')) {
    exit;
}
use WP_Error;
class Queue
{
    private Storage $storage;
    private Processor $processor;
    private TypeRegistry $registry;
    private Locker $locker;
    public function __construct()
    {
        $this->storage = new Storage();
        $this->registry = new TypeRegistry();
        $this->locker = new Locker('queue_processor', 300);
        $executor = new FilteredExecutor($this->storage);
        $this->processor = new Processor($this->storage, $executor, $this->registry, $this->locker);
        add_action('jvb_process_queue', [$this, 'checkQueue']);
        add_action('jvb_queue_maintenance', [$this, 'maintenance']);
        if (!wp_next_scheduled('jvb_process_queue')) {
            wp_schedule_event(time(), 'every-minute', 'jvb_process_queue');
        }
        if (!wp_next_scheduled('jvb_queue_maintenance')) {
            wp_schedule_event(time(), 'hourly', 'jvb_queue_maintenance');
        }
    }
    /**
     * Access type registry for registering operation configs
     */
    public function registry(): TypeRegistry
    {
        return $this->registry;
    }
    /**
     * Queue a new operation or merge into existing
     *
     * @param string $type Operation type
     * @param int $userId User ID
     * @param array $data Request data
     * @param array $options {
     *     @type string $priority     'low', 'normal', 'high'
     *     @type int $delay           Seconds to delay processing
     *     @type string $scheduled    Specific datetime to process
     *     @type array|string $depends_on  Operation IDs this depends on
     *     @type string|array $chunk_key   Key(s) to chunk (overrides registry)
     *     @type int $chunk_size      Chunk size (overrides registry)
     * }
     */
    public function add(string $type, int $userId, array $data, array $options = []): array|WP_Error
    {
        try {
            $incoming = $this->buildOperation($type, $userId, $data, $options);
            $mergeable = $this->registry->getMergeable($type);
            if ($mergeable) {
                $existing = $this->storage->findMergeable($type, $userId);
                if ($existing && $mergeable->canMerge($existing, $incoming)) {
                    $merged = $mergeable->merge($existing, $incoming);
                    $this->storage->save($merged);
                    $this->runQueueOnShutdown();
                    return [
                        'success'          => true,
                        'operation_id'     => $merged->id,
                        'updated_existing' => true,
                    ];
                }
            }
            $this->storage->insert($incoming);
            $this->runQueueOnShutdown();
            return [
                'success'          => true,
                'operation_id'     => $incoming->id,
                'updated_existing' => false,
            ];
        } catch (\Exception $e) {
            JVB()->error()->log('queue', $e->getMessage(), $data, 'high');
            return new WP_Error('queue_failed', $e->getMessage());
        }
    }
    /**
     * Alias for add() - backwards compatibility
     */
    public function queueOperation(string $type, int $userId, array $data, array $options = []): array|WP_Error
    {
        return $this->add($type, $userId, $data, $options);
    }
    public function checkQueue(): void
    {
        if (!$this->storage->getQueueInfo()['has_items']) {
            return;
        }
        $this->processor->run();
    }
    public function maintenance(): void
    {
        if ($this->locker->isLocked()) {
            return;
        }
        $this->cleanupStuck();
    }
    private function cleanupStuck(): void
    {
        // Operations stuck in processing > 30 min â†’ reschedule
        global $wpdb;
        $table = $wpdb->prefix . BASE . '_operation_queue';
        $stuck = $wpdb->get_results($wpdb->prepare("
            SELECT id FROM {$table}
            WHERE state = 'processing'
            AND started_at < %s
        ", date('Y-m-d H:i:s', strtotime('-30 minutes'))));
        foreach ($stuck as $row) {
            $op = $this->storage->find($row->id);
            if ($op) {
                $op->state = 'scheduled';
                $op->scheduledAt = date('Y-m-d H:i:s', time() + 60);
                $op->retries++;
                $this->storage->save($op);
            }
        }
    }
    // === Public Getters ===
    public function get(string $id): ?Operation
    {
        return $this->storage->find($id);
    }
    /**
     * Alias for get() - backwards compatibility
     */
    public function getOperation(string $id): ?Operation
    {
        return $this->get($id);
    }
    /**
     * Get a specific value from an operation - backwards compatibility
     */
    public function getOperationValue(string $id, string $column, bool $decodeJson = true): mixed
    {
        $op = $this->get($id);
        if (!$op) {
            return null;
        }
        return match($column) {
            'result'       => $op->result,
            'request_data' => $op->requestData,
            'state'        => $op->state,
            'outcome'      => $op->outcome,
            'type'         => $op->type,
            'user_id'      => $op->userId,
            'metadata'     => $op->metadata,
            'dependencies' => $op->dependencies,
            default        => null,
        };
    }
    public function getUserOperations(int $userId, array $filters = []): array
    {
        return $this->storage->getUserOperations($userId, $filters);
    }
    public function getStatus(): array
    {
        return $this->storage->getQueueStatus();
    }
    public function getUserStats(int $userId): array
    {
        return $this->storage->getUserStats($userId);
    }
    public function getInfo(): array
    {
        return $this->storage->getQueueInfo();
    }
    public function dismiss(string $id): bool
    {
        return $this->storage->dismiss($id);
    }
    /**
     * Cancel a pending/scheduled operation (deletes it)
     *
     * @param string $id Operation ID
     * @param int $userId User ID (for ownership verification)
     * @return bool True if cancelled
     */
    public function cancel(string $id, int $userId): bool
    {
        $op = $this->get($id);
        if (!$op || $op->userId !== $userId) {
            return false;
        }
        // Can only cancel pending or scheduled operations
        if (!in_array($op->state, ['pending', 'scheduled'])) {
            return false;
        }
        return $this->storage->delete($id);
    }
    /**
     * Retry a failed operation
     *
     * @param string $id Operation ID
     * @param int $userId User ID (for ownership verification)
     * @return bool True if reset for retry
     */
    public function retry(string $id, int $userId): bool
    {
        $op = $this->get($id);
        if (!$op || $op->userId !== $userId) {
            return false;
        }
        // Can only retry completed operations with failed outcomes
        if ($op->state !== 'completed' || !in_array($op->outcome, ['failed', 'failed_permanent'])) {
            return false;
        }
        $op->state = 'pending';
        $op->outcome = 'pending';
        $op->errorMessage = null;
        $op->lastErrorHash = null;
        $op->scheduledAt = current_time('mysql');
        $op->retries++;
        $saved = $this->storage->save($op);
        if ($saved) {
            $this->runQueueOnShutdown();
        }
        return $saved;
    }
    /**
     * Update an operation's data or metadata
     *
     * @param string $id Operation ID
     * @param array $updates Fields to update (requestData, metadata, etc.)
     * @param int|null $userId Optional user ID for ownership verification
     * @return bool True if updated
     */
    public function update(string $id, array $updates, ?int $userId = null): bool
    {
        $op = $this->get($id);
        if (!$op) {
            return false;
        }
        if ($userId !== null && $op->userId !== $userId) {
            return false;
        }
        // Apply allowed updates
        foreach ($updates as $field => $value) {
            match($field) {
                'requestData' => $op->requestData = $value,
                'metadata'    => $op->metadata = array_merge($op->metadata, $value),
                'priority'    => $op->priority = $value,
                'state'       => $op->state = $value,
                'outcome'     => $op->outcome = $value,
                'result'      => $op->result = $value,
                default       => null,
            };
        }
        return $this->storage->save($op);
    }
    public function isLocked(): bool
    {
        return $this->locker->isLocked();
    }
    // === Private Helpers ===
    private function buildOperation(string $type, int $userId, array $data, array $options): Operation
    {
        $op = new Operation();
        // Use provided operation_id or generate one
        $op->id = !empty($options['operation_id'])
            ? $options['operation_id']
            : 'u' . $userId . '_' . uniqid('op_');
        $op->type = $type;
        $op->userId = $userId;
        $op->requestData = $data;
        $op->priority = $options['priority'] ?? 'normal';
        $op->state = !empty($options['delay']) || !empty($options['scheduled']) ? 'scheduled' : 'pending';
        $op->scheduledAt = $this->calculateScheduledTime($options);
        // Chunk config: explicit options override registry
        $chunkConfig = null;
        if (!empty($options['chunk_key'])) {
            $chunkConfig = [
                'key'  => $options['chunk_key'],
                'size' => $options['chunk_size'] ?? 10,
            ];
        } else {
            $chunkConfig = $this->registry->getChunkConfig($type);
        }
        if ($chunkConfig) {
            $op->metadata['chunk_key'] = $chunkConfig['key'];
            $op->metadata['chunk_size'] = $chunkConfig['size'];
            $op->totalItems = $this->countItems($data, $chunkConfig['key']);
        }
        // Dependencies
        if (!empty($options['depends_on'])) {
            $op->dependencies = is_string($options['depends_on'])
                ? explode(',', $options['depends_on'])
                : $options['depends_on'];
        }
        return $op;
    }
    private function countItems(array $data, string|array $keys): int
    {
        $keys = (array) $keys;
        $total = 0;
        foreach ($keys as $key) {
            if (isset($data[$key]) && is_array($data[$key])) {
                $total += count($data[$key]);
            }
        }
        return max(1, $total);
    }
    private function calculateScheduledTime(array $options): string
    {
        if (!empty($options['delay'])) {
            return date('Y-m-d H:i:s', current_time('timestamp') + (int)$options['delay']);
        }
        if (!empty($options['scheduled'])) {
            return $options['scheduled'];
        }
        return current_time('mysql');
    }
    private function runQueueOnShutdown(): void
    {
        if (!has_action('shutdown', [$this, 'processQueueOnShutdown'])) {
            add_action('shutdown', [$this, 'processQueueOnShutdown'], 100);
        }
    }
    public function processQueueOnShutdown(): void
    {
        remove_action('shutdown', [$this, 'processQueueOnShutdown']);
        if (function_exists('fastcgi_finish_request')) {
            fastcgi_finish_request();
        }
        $this->checkQueue();
    }
}
inc/managers/queue/Result.php
New file
@@ -0,0 +1,13 @@
<?php
namespace JVBase\managers\queue;
if (!defined('ABSPATH')) {
    exit;
}
//Result.php
final class Result
{
    public function __construct(
        public string $outcome, // success | partial | failed
        public ?array $result = null
    ) {}
}
inc/managers/queue/Storage.php
New file
@@ -0,0 +1,372 @@
<?php
namespace JVBase\managers\queue;
if (!defined('ABSPATH')) {
    exit;
}
use JVBase\managers\CacheManager;
class Storage
{
    private \wpdb $wpdb;
    private string $table;
    private CacheManager $cache;
    private const CACHE_USER_PREFIX = 'user_queue_';
    private const CACHE_QUEUE_INFO = 'queue_info';
    public function __construct()
    {
        global $wpdb;
        $this->wpdb = $wpdb;
        $this->table = $wpdb->prefix . BASE . '_operation_queue';
        $this->cache = CacheManager::for('queue', DAY_IN_SECONDS);
    }
    public function hasProcessingOperations(): bool
    {
        return (bool) $this->wpdb->get_var(
            "SELECT 1 FROM {$this->table} WHERE state = 'processing' LIMIT 1"
        );
    }
    public function fetchRunnable(int $limit = 10): array
    {
        $now = current_time('mysql');
        $rows = $this->wpdb->get_results($this->wpdb->prepare("
        SELECT oq.* FROM {$this->table} oq
        WHERE oq.state IN ('pending', 'scheduled')
          AND oq.scheduled_at <= %s
          AND NOT EXISTS (
              SELECT 1
              FROM JSON_TABLE(
                  COALESCE(NULLIF(oq.dependencies, 'null'), '[]'),
                  '\$[*]' COLUMNS (dep_id VARCHAR(64) PATH '\$')
              ) AS deps
              JOIN {$this->table} dep ON dep.id = deps.dep_id
              WHERE dep.state != 'completed'
                 OR dep.outcome NOT IN ('success', 'partial')
          )
        ORDER BY FIELD(oq.priority, 'high', 'normal', 'low'), oq.scheduled_at
        LIMIT %d
    ", $now, $limit));
        return array_map([$this, 'rowToOperation'], $rows ?: []);
    }
    public function markProcessing(string $id): bool
    {
        $now = current_time('mysql');
        $affected = $this->wpdb->query($this->wpdb->prepare("
            UPDATE {$this->table}
            SET state = 'processing', started_at = %s, updated_at = %s
            WHERE id = %s AND state IN ('pending', 'scheduled')
        ", $now, $now, $id));
        if ($affected > 0) {
            $op = $this->find($id);
            if ($op) {
                $this->invalidateUser($op->userId);
            }
        }
        return $affected > 0;
    }
    public function save(Operation $op): bool
    {
        $data = [
            'request_data'    => json_encode($op->requestData),
            'total_items'     => $op->totalItems,
            'processed_items' => $op->processedItems,
            'failed_items'    => $op->failedItems ? json_encode($op->failedItems) : null,
            'priority'        => $op->priority,
            'state'           => $op->state,
            'outcome'         => $op->outcome,
            'retries'         => $op->retries,
            'last_error_hash' => $op->lastErrorHash,
            'error_message'   => $op->errorMessage,
            'scheduled_at'    => $op->scheduledAt,
            'started_at'      => $op->startedAt,
            'completed_at'    => $op->completedAt,
            'metadata'        => json_encode($op->metadata),
            'result'          => $op->result ? json_encode($op->result) : null,
            'dependencies'    => json_encode($op->dependencies),
            'user_dismissed'  => $op->userDismissed ? 1 : 0,
            'updated_at'      => current_time('mysql'),
        ];
        $result = $this->wpdb->update($this->table, $data, ['id' => $op->id]);
        if ($result !== false) {
            $this->invalidateUser($op->userId);
        }
        return $result !== false;
    }
    public function insert(Operation $op): bool
    {
        $result = $this->wpdb->insert($this->table, [
            'id'              => $op->id,
            'type'            => $op->type,
            'user_id'         => $op->userId,
            'request_data'    => json_encode($op->requestData),
            'total_items'     => $op->totalItems,
            'processed_items' => $op->processedItems,
            'failed_items'    => null,
            'priority'        => $op->priority,
            'state'           => $op->state,
            'outcome'         => $op->outcome,
            'retries'         => 0,
            'last_error_hash' => null,
            'error_message'   => null,
            'scheduled_at'    => $op->scheduledAt ?? current_time('mysql'),
            'started_at'      => null,
            'completed_at'    => null,
            'metadata'        => json_encode($op->metadata),
            'result'          => null,
            'dependencies'    => json_encode($op->dependencies),
            'user_dismissed'  => 0,
            'created_at'      => current_time('mysql'),
            'updated_at'      => current_time('mysql'),
        ]);
        if ($result) {
            $this->invalidateUser($op->userId);
        }
        return $result !== false;
    }
    public function find(string $id): ?Operation
    {
        $row = $this->wpdb->get_row($this->wpdb->prepare(
            "SELECT * FROM {$this->table} WHERE id = %s",
            $id
        ));
        return $row ? $this->rowToOperation($row) : null;
    }
    public function findMergeable(string $type, int $userId): ?Operation
    {
        $row = $this->wpdb->get_row($this->wpdb->prepare(
            "SELECT * FROM {$this->table}
             WHERE type = %s AND user_id = %d AND state IN ('pending', 'scheduled')
             ORDER BY created_at DESC LIMIT 1",
            $type, $userId
        ));
        return $row ? $this->rowToOperation($row) : null;
    }
    public function getUserOperations(int $userId, array $filters = []): array
    {
        $where = ['user_id = %d'];
        $params = [$userId];
        if (!empty($filters['state'])) {
            $where[] = 'state = %s';
            $params[] = $filters['state'];
        }
        if (!empty($filters['type'])) {
            $where[] = 'type = %s';
            $params[] = $filters['type'];
        }
        if (!empty($filters['outcome'])) {
            $outcomes = (array) $filters['outcome'];
            $placeholders = implode(',', array_fill(0, count($outcomes), '%s'));
            $where[] = "outcome IN ($placeholders)";
            $params = array_merge($params, $outcomes);
        }
        if (!empty($filters['not_dismissed'])) {
            $where[] = 'user_dismissed = 0';
        }
        if (!empty($filters['active'])) {
            $where[] = "state IN ('pending', 'scheduled', 'processing')";
        }
        if (!empty($filters['has_errors'])) {
            $where[] = "(error_message IS NOT NULL OR failed_items IS NOT NULL)";
        }
        if (!empty($filters['ids'])) {
            $ids = (array) $filters['ids'];
            $placeholders = implode(',', array_fill(0, count($ids), '%s'));
            $where[] = "id IN ($placeholders)";
            $params = array_merge($params, $ids);
        }
        // Order by state priority, then created_at
        $orderBy = $filters['order_by'] ?? "FIELD(state, 'processing', 'pending', 'scheduled', 'completed'), created_at DESC";
        $limit = $filters['limit'] ?? 50;
        $params[] = $limit;
        $rows = $this->wpdb->get_results($this->wpdb->prepare(
            "SELECT * FROM {$this->table} WHERE " . implode(' AND ', $where) .
            " ORDER BY {$orderBy} LIMIT %d",
            ...$params
        ));
        return array_map([$this, 'rowToOperation'], $rows ?: []);
    }
    public function getQueueInfo(): array
    {
        $cached = $this->cache->get(self::CACHE_QUEUE_INFO);
        if ($cached !== false) {
            return $cached;
        }
        $now = current_time('mysql');
        $row = $this->wpdb->get_row($this->wpdb->prepare("
            SELECT
                COUNT(*) as total,
                SUM(IF(state IN ('pending', 'processing'), 1, 0)) as active,
                SUM(IF(state = 'scheduled' AND scheduled_at <= %s, 1, 0)) as ready_scheduled
            FROM {$this->table}
            WHERE state IN ('pending', 'processing', 'scheduled')
        ", $now));
        $info = [
            'total'     => (int) ($row->total ?? 0),
            'active'    => (int) ($row->active ?? 0),
            'ready'     => (int) ($row->active ?? 0) + (int) ($row->ready_scheduled ?? 0),
            'has_items' => ((int) ($row->active ?? 0) + (int) ($row->ready_scheduled ?? 0)) > 0,
        ];
        $this->cache->set(self::CACHE_QUEUE_INFO, $info, 30);
        return $info;
    }
    private function rowToOperation(object $row): Operation
    {
        $op = new Operation();
        $op->id             = $row->id;
        $op->type           = $row->type;
        $op->userId         = (int) $row->user_id;
        $op->requestData    = json_decode($row->request_data ?? '{}', true) ?: [];
        $op->metadata       = json_decode($row->metadata ?? '{}', true) ?: [];
        $op->dependencies   = json_decode($row->dependencies ?? '[]', true) ?: [];
        $op->totalItems     = (int) $row->total_items;
        $op->processedItems = (int) $row->processed_items;
        $op->failedItems    = $row->failed_items ? json_decode($row->failed_items, true) : null;
        $op->priority       = $row->priority;
        $op->state          = $row->state;
        $op->outcome        = $row->outcome;
        $op->retries        = (int) $row->retries;
        $op->lastErrorHash  = $row->last_error_hash;
        $op->errorMessage   = $row->error_message;
        $op->scheduledAt    = $row->scheduled_at;
        $op->startedAt      = $row->started_at;
        $op->completedAt    = $row->completed_at;
        $op->result         = $row->result ? json_decode($row->result, true) : null;
        $op->userDismissed  = (bool) $row->user_dismissed;
        return $op;
    }
    public function getQueueStatus(): array
    {
        $now = current_time('mysql');
        $rows = $this->wpdb->get_results($this->wpdb->prepare("
            SELECT
                state,
                COUNT(*) as count,
                SUM(IF(state = 'scheduled' AND scheduled_at <= %s, 1, 0)) as ready
            FROM {$this->table}
            GROUP BY state
        ", $now), OBJECT_K);
        return [
            'pending'         => (int) ($rows['pending']->count ?? 0),
            'scheduled'       => (int) ($rows['scheduled']->count ?? 0),
            'scheduled_ready' => (int) ($rows['scheduled']->ready ?? 0),
            'processing'      => (int) ($rows['processing']->count ?? 0),
            'completed'       => (int) ($rows['completed']->count ?? 0),
        ];
    }
    public function getUserStats(int $userId): array
    {
        $rows = $this->wpdb->get_results($this->wpdb->prepare("
            SELECT state, outcome, COUNT(*) as count
            FROM {$this->table}
            WHERE user_id = %d
            GROUP BY state, outcome
        ", $userId));
        $stats = [
            'pending' => 0,
            'scheduled' => 0,
            'processing' => 0,
            'completed' => 0,
            'failed' => 0,
            'failed_permanent' => 0,
        ];
        foreach ($rows as $row) {
            if ($row->state === 'completed') {
                // Map outcome to frontend status
                match($row->outcome) {
                    'failed' => $stats['failed'] += (int) $row->count,
                    'failed_permanent' => $stats['failed_permanent'] += (int) $row->count,
                    default => $stats['completed'] += (int) $row->count,
                };
            } else {
                // Combine pending/scheduled for frontend
                $key = $row->state === 'scheduled' ? 'pending' : $row->state;
                if (isset($stats[$key])) {
                    $stats[$key] += (int) $row->count;
                }
            }
        }
        return $stats;
    }
    public function dismiss(string $id): bool
    {
        $result = $this->wpdb->update(
            $this->table,
            ['user_dismissed' => 1, 'updated_at' => current_time('mysql')],
            ['id' => $id]
        );
        return $result !== false;
    }
    /**
     * Delete an operation from the queue
     */
    public function delete(string $id): bool
    {
        $op = $this->find($id);
        $userId = $op?->userId;
        $result = $this->wpdb->delete($this->table, ['id' => $id]);
        if ($result && $userId) {
            $this->invalidateUser($userId);
        }
        return $result !== false;
    }
    public function invalidateQueueCache(): void
    {
        $this->cache->delete(self::CACHE_QUEUE_INFO);
        $this->cache->touch();
    }
    private function invalidateUser(int $userId): void
    {
        CacheManager::invalidateAll("user_{$userId}");
        $this->cache->delete(self::CACHE_QUEUE_INFO);
    }
}
inc/managers/queue/TypeConfig.php
New file
@@ -0,0 +1,16 @@
<?php
namespace JVBase\managers\queue;
if (!defined('ABSPATH')) {
    exit;
}
final class TypeConfig
{
    public function __construct(
        public ?Mergeable $mergeable = null,
        public ?Executor $executor = null,
        public ?string $chunkKey = null,
        public int $chunkSize = 10,
        public int $maxRetries = 3
    ) {}
}
inc/managers/queue/TypeRegistry.php
New file
@@ -0,0 +1,52 @@
<?php
namespace JVBase\managers\queue;
if (!defined('ABSPATH')) {
    exit;
}
final class TypeRegistry
{
    private array $configs = [];
    public function register(string $type, TypeConfig $config): void
    {
        $this->configs[$type] = $config;
    }
    public function has(string $type): bool
    {
        return isset($this->configs[$type]);
    }
    public function getExecutor(string $type): ?Executor
    {
        return $this->configs[$type]?->executor;
    }
    public function getMergeable(string $type): ?Mergeable
    {
        return $this->configs[$type]?->mergeable;
    }
    public function getConfig(string $type): ?TypeConfig
    {
        return $this->configs[$type] ?? null;
    }
    public function getChunkConfig(string $type): ?array
    {
        $config = $this->configs[$type] ?? null;
        if (!$config || empty($config->chunkKey)) {
            return null;
        }
        return [
            'key'  => $config->chunkKey,
            'size' => $config->chunkSize,
        ];
    }
    public function getMaxRetries(string $type): int
    {
        return $this->configs[$type]?->maxRetries ?? 3;
    }
}
inc/managers/queue/_setup.php
New file
@@ -0,0 +1,30 @@
<?php
namespace JVBase\managers\queue;
if (!defined('ABSPATH')) {
    exit;
}
// Interfaces first
require_once JVB_DIR . '/inc/managers/queue/Executor.php';
require_once JVB_DIR . '/inc/managers/queue/Mergeable.php';
// Value objects / DTOs
require_once JVB_DIR . '/inc/managers/queue/Operation.php';
require_once JVB_DIR . '/inc/managers/queue/Progress.php';
require_once JVB_DIR . '/inc/managers/queue/Result.php';
// Infrastructure
require_once JVB_DIR . '/inc/managers/queue/Locker.php';
require_once JVB_DIR . '/inc/managers/queue/Storage.php';
require_once JVB_DIR . '/inc/managers/queue/TypeConfig.php';
require_once JVB_DIR . '/inc/managers/queue/TypeRegistry.php';
// Implementations
require_once JVB_DIR . '/inc/managers/queue/FilteredExecutor.php';
require_once JVB_DIR . '/inc/managers/queue/Processor.php';
require_once JVB_DIR . '/inc/managers/queue/executors/UploadExecutor.php';
require_once JVB_DIR . '/inc/managers/queue/executors/ContentExecutor.php';
// Facade
require_once JVB_DIR . '/inc/managers/queue/Queue.php';
inc/managers/queue/executors/ContentExecutor.php
New file
@@ -0,0 +1,580 @@
<?php
namespace JVBase\managers\queue\executors;
use JVBase\managers\CacheManager;
use JVBase\managers\queue\{Executor, Operation, Progress, Result};
use JVBase\meta\MetaManager;
use JVBase\utility\Features;
use Exception;
if (!defined('ABSPATH')) {
    exit;
}
/**
 * Executor for content-related queue operations.
 * Handles: content_update, batch_creation
 */
final class ContentExecutor implements Executor
{
    private const HANDLED_TYPES = [
        'content_update',
        'batch_creation',
    ];
    private int $userId;
    private string $postType;
    private array $fields = [];
    private array $timelineSharedFields = [];
    private array $timelineUniqueFields = [];
    public function execute(Operation $operation, Progress $progress): Result
    {
        if (!in_array($operation->type, self::HANDLED_TYPES)) {
            throw new Exception("ContentExecutor cannot handle type: {$operation->type}");
        }
        $this->userId = $operation->userId;
        try {
            $data = $operation->requestData;
            $result = match($operation->type) {
                'content_update'  => $this->processContentUpdate($operation, $data, $progress),
                'batch_creation'  => $this->processBatchCreation($operation, $data, $progress),
                default           => throw new Exception("Unknown type: {$operation->type}")
            };
            return $result;
        } catch (Exception $e) {
            JVB()->error()->log(
                '[ContentExecutor]:execute',
                $e->getMessage(),
                [
                    'operation_id'   => $operation->id,
                    'operation_type' => $operation->type,
                    'user_id'        => $operation->userId,
                ]
            );
            return new Result(
                outcome: 'failed',
                result: ['error' => $e->getMessage()]
            );
        }
    }
    // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    // Content Update
    // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    private function processContentUpdate(Operation $operation, array $data, Progress $progress): Result
    {
        $posts = $data['posts'] ?? [];
        if (empty($posts)) {
            return new Result(
                outcome: 'failed',
                result: ['message' => 'No posts to update']
            );
        }
        $results = [];
        $errors = [];
        foreach ($posts as $id => $postData) {
            try {
                $content = $postData['content'] ?? '';
                // Timeline posts
                if (Features::forContent($content)->has('is_timeline') && isset($postData['timeline'])) {
                    $parentId = (int)$id;
                    if ($parentId === 0) {
                        $progress->failItem($id, 'Invalid parent post ID for timeline');
                        continue;
                    }
                    $results[$id] = $this->processTimelinePost($parentId, $postData);
                    $progress->advance(1);
                    continue;
                }
                // New post creation
                if (str_starts_with((string)$id, 'new')) {
                    $newId = wp_insert_post([
                        'post_author' => $this->userId,
                        'post_type'   => jvbCheckBase($content),
                        'post_title'  => $postData['post_title'] ?? '',
                        'post_status' => $postData['status'] ?? 'draft',
                    ]);
                    if (!$newId || is_wp_error($newId)) {
                        $progress->failItem($id, 'Could not create post');
                        continue;
                    }
                    $this->savePostFields($newId, $postData);
                    $results[$id] = ['success' => true, 'new_id' => $newId];
                    $progress->advance(1);
                    continue;
                }
                // Existing post update
                if (!$this->verifyOwnership((int)$id)) {
                    $progress->failItem($id, 'No permission to modify this post');
                    continue;
                }
                $this->processPostUpdate((int)$id, $postData);
                $results[$id] = ['success' => true];
                $progress->advance(1);
                // Clear caches
                CacheManager::for($content)->clear();
                if (jvbSiteUsesFeedBlock()) {
                    CacheManager::for('feed')->clear();
                }
            } catch (Exception $e) {
                $progress->failItem($id, $e->getMessage());
                $errors[$id] = $e->getMessage();
            }
        }
        // Send notification
        if (jvbSiteHasNotifications()) {
            JVB()->notification()->addNotification(
                $this->userId,
                'content_update_complete',
                null,
                'Content updates completed!'
            );
        }
        $outcome = 'success';
        if (!empty($errors)) {
            $outcome = count($errors) === count($posts) ? 'failed' : 'partial';
        }
        return new Result(
            outcome: $outcome,
            result: $results
        );
    }
    private function processPostUpdate(int $postId, array $postData): void
    {
        $content = $postData['content'] ?? '';
        // Handle status changes
        if (isset($postData['post_status'])) {
            switch ($postData['post_status']) {
                case 'publish':
                    if (user_can($this->userId, 'manage_options') || user_can($this->userId, 'skip_moderation')) {
                        wp_update_post(['ID' => $postId, 'post_status' => 'publish']);
                    }
                    unset($postData['post_status']);
                    break;
                case 'draft':
                    wp_update_post(['ID' => $postId, 'post_status' => 'draft']);
                    break;
                case 'trash':
                    wp_trash_post($postId);
                    return;
                case 'delete':
                    wp_delete_post($postId, true);
                    return;
            }
        }
        $this->savePostFields($postId, $postData);
    }
    private function savePostFields(int $postId, array $postData): bool
    {
        $content = $postData['content'] ?? '';
        $fields = jvbGetFields($content);
        $allowedFields = array_filter($postData, function ($key) use ($fields) {
            return array_key_exists($key, $fields);
        }, ARRAY_FILTER_USE_KEY);
        if (empty($allowedFields)) {
            return true;
        }
        $meta = new MetaManager($postId, 'post');
        return $meta->setAll($allowedFields);
    }
    // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    // Batch Creation
    // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    private function processBatchCreation(Operation $operation, array $data, Progress $progress): Result
    {
        $this->postType = BASE . $data['content'];
        // Get upload results from dependency
        $uploadOpId = $operation->id . '_upload';
        $images = JVB()->queue()->get($uploadOpId)?->result ?? null;
        if (!$images) {
            return new Result(
                outcome: 'failed',
                result: ['message' => 'No upload results found']
            );
        }
        $results = [];
        if ($data['mode'] === 'selection') {
            $results = $this->createFromSelection($operation, $data, $images, $progress);
        } else {
            $results = $this->createFromDirect($operation, $data, $images, $progress);
        }
        // Clear caches
        CacheManager::for($data['content'])->clear();
        CacheManager::for('feed')->clear();
        return new Result(
            outcome: !empty($results) ? 'success' : 'failed',
            result: $results
        );
    }
    private function createFromSelection(Operation $operation, array $data, array $images, Progress $progress): array
    {
        $results = [];
        foreach ($images as $group => $files) {
            $settings = json_decode($data['files_data'][$group] ?? '{}');
            if (($settings->type ?? '') === 'group') {
                $postId = $this->createGroupPost($operation, $data, $files, $settings);
            } else {
                $postId = $this->createIndividualPosts($operation, $data, $files);
            }
            if ($postId) {
                $results = array_merge($results, (array)$postId);
            }
            $progress->advance(1);
        }
        return $results;
    }
    private function createFromDirect(Operation $operation, array $data, array $images, Progress $progress): array
    {
        $results = [];
        foreach ($images as $img) {
            $postId = wp_insert_post([
                'post_type'   => $this->postType,
                'post_title'  => $this->generatePostTitle($data['content']),
                'post_status' => 'draft',
                'post_author' => $operation->userId,
            ]);
            if ($postId && !is_wp_error($postId)) {
                set_post_thumbnail($postId, $img['attachment_id']);
                $results[] = $postId;
            }
            $progress->advance(1);
        }
        return $results;
    }
    private function createGroupPost(Operation $operation, array $data, array $files, object $settings): ?int
    {
        $featuredIndex = $settings->metadata->featuredFile ?? 0;
        $title = $settings->metadata->title ?? $this->generatePostTitle($data['content']);
        $postId = wp_insert_post([
            'post_type'   => $this->postType,
            'post_title'  => $title,
            'post_status' => 'draft',
            'post_author' => $operation->userId,
        ]);
        if (!$postId || is_wp_error($postId)) {
            return null;
        }
        // Set featured image
        set_post_thumbnail($postId, $files[$featuredIndex]['attachment_id']);
        // Remaining files go to gallery
        unset($files[$featuredIndex]);
        if (!empty($files)) {
            $meta = new MetaManager($postId, 'post');
            $ids = array_column($files, 'attachment_id');
            $meta->updateValue('gallery', implode(',', $ids));
        }
        return $postId;
    }
    private function createIndividualPosts(Operation $operation, array $data, array $files): array
    {
        $results = [];
        foreach ($files as $img) {
            $postId = wp_insert_post([
                'post_type'   => $this->postType,
                'post_title'  => $this->generatePostTitle($data['content']),
                'post_status' => 'draft',
                'post_author' => $operation->userId,
            ]);
            if ($postId && !is_wp_error($postId)) {
                set_post_thumbnail($postId, $img['attachment_id']);
                $results[] = $postId;
            }
        }
        return $results;
    }
    // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    // Timeline Processing
    // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    private function processTimelinePost(int $parentId, array $postData): array
    {
        if (!$this->verifyOwnership($parentId)) {
            return ['success' => false, 'message' => 'No permission'];
        }
        $content = $postData['content'];
        $this->initTimelineFields($content);
        $parentPost = get_post($parentId);
        $parentIsPublished = ($parentPost->post_status === 'publish');
        // Extract shared data (excluding post_thumbnail)
        $sharedData = array_filter($postData, function ($key) {
            return in_array($key, $this->timelineSharedFields)
                && !in_array($key, ['content', 'user'])
                && $key !== 'post_thumbnail';
        }, ARRAY_FILTER_USE_KEY);
        if (!isset($sharedData['post_title']) && isset($postData['timeline'][0]['post_title'])) {
            $sharedData['post_title'] = $postData['timeline'][0]['post_title'];
        }
        if (!isset($postData['timeline']) || !is_array($postData['timeline'])) {
            return ['success' => false, 'message' => 'No timeline data'];
        }
        // Validate parent is in timeline
        $index = array_search((string)$parentId, array_column($postData['timeline'], 'id'));
        if ($index === false) {
            return ['success' => false, 'message' => 'Missing parent id'];
        }
        // Handle parent reordering if needed
        if ($index !== 0) {
            $parentId = $this->reorderTimelineParent($parentId, $postData['timeline'], $index);
            $parentPost = get_post($parentId);
            $parentIsPublished = ($parentPost->post_status === 'publish');
        }
        // Shared taxonomies (excluding title and thumbnail)
        $sharedTaxonomies = array_filter($sharedData, function ($key) {
            return !in_array($key, ['post_title', 'post_thumbnail']);
        }, ARRAY_FILTER_USE_KEY);
        $existingChildren = get_children([
            'post_parent'  => $parentId,
            'orderby'      => 'menu_order',
            'post_status'  => ['publish', 'draft'],
            'fields'       => 'ids',
        ]);
        $errors = [];
        $success = [];
        foreach ($postData['timeline'] as $order => $timeline) {
            $result = $this->processTimelineEntry(
                $timeline,
                $order,
                $parentId,
                $parentIsPublished,
                $sharedTaxonomies,
                $existingChildren,
                $content
            );
            if ($result['success']) {
                $success[] = $result;
                if (isset($result['child_id']) && in_array($result['child_id'], $existingChildren)) {
                    unset($existingChildren[array_search($result['child_id'], $existingChildren)]);
                }
            } else {
                $errors[] = $result;
            }
        }
        // Trash orphaned children
        foreach ($existingChildren as $orphanId) {
            wp_trash_post($orphanId);
        }
        return [
            'success' => empty($errors),
            'updated' => count($success),
            'errors'  => $errors,
        ];
    }
    private function processTimelineEntry(
        array $timeline,
        int $order,
        int $parentId,
        bool $parentIsPublished,
        array $sharedTaxonomies,
        array &$existingChildren,
        string $content
    ): array {
        $isParent = ((int)($timeline['id'] ?? 0) === $parentId);
        // Get unique fields for this entry
        $allowedFields = array_filter($timeline, function ($key) {
            return in_array($key, $this->timelineUniqueFields) && !in_array($key, ['content', 'user']);
        }, ARRAY_FILTER_USE_KEY);
        // Determine title
        $providedTitle = $timeline['post_title'] ?? '';
        $autoPattern = '/^.+Treatment #?\d+$/';
        if ($isParent) {
            $allowedFields['post_title'] = $providedTitle ?: ($sharedTaxonomies['post_title'] ?? get_post($parentId)->post_title);
        } else {
            if (empty($providedTitle) || preg_match($autoPattern, $providedTitle)) {
                $allowedFields['post_title'] = 'Treatment ' . $order;
            } else {
                $allowedFields['post_title'] = $providedTitle;
            }
        }
        $allowedFields = array_merge($sharedTaxonomies, $allowedFields);
        // Create child if needed
        $childId = $timeline['id'] ?? null;
        if (!$childId || !is_numeric($childId)) {
            $childId = wp_insert_post([
                'post_author' => $this->userId,
                'post_type'   => jvbCheckBase($content),
                'post_title'  => $allowedFields['post_title'],
                'post_parent' => $parentId,
                'menu_order'  => $order,
                'post_status' => $parentIsPublished ? 'publish' : 'draft',
            ]);
            if (!$childId || is_wp_error($childId)) {
                return ['success' => false, 'message' => 'Could not create child post'];
            }
        }
        // Update post
        $postUpdates = ['ID' => $childId];
        if (!$isParent) {
            $postUpdates['menu_order'] = $order;
            if ($parentIsPublished) {
                $currentPost = get_post($childId);
                if ($currentPost && $currentPost->post_status !== 'publish') {
                    $postUpdates['post_status'] = 'publish';
                }
            }
        }
        if (isset($allowedFields['post_title'])) {
            $postUpdates['post_title'] = $allowedFields['post_title'];
            unset($allowedFields['post_title']);
        }
        wp_update_post($postUpdates);
        // Save meta fields
        if (!empty($allowedFields)) {
            $meta = new MetaManager($childId, 'post');
            $meta->setAll($allowedFields);
        }
        return ['success' => true, 'child_id' => $childId];
    }
    private function reorderTimelineParent(int $currentParentId, array $timeline, int $currentIndex): int
    {
        $newParentId = $timeline[0]['id'] ?? null;
        if (!is_numeric($newParentId) || (int)$newParentId <= 0) {
            return $currentParentId;
        }
        $newParentId = (int)$newParentId;
        // Make new parent a top-level post
        wp_update_post(['ID' => $newParentId, 'post_parent' => 0]);
        // Make old parent a child
        wp_update_post(['ID' => $currentParentId, 'post_parent' => $newParentId]);
        // Move existing children to new parent
        $existingChildren = get_children(['post_parent' => $currentParentId, 'fields' => 'ids']);
        foreach ($existingChildren as $childId) {
            if ($childId !== $newParentId) {
                wp_update_post(['ID' => $childId, 'post_parent' => $newParentId]);
            }
        }
        return $newParentId;
    }
    // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    // Helpers
    // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    private function initTimelineFields(string $content): void
    {
        $content = jvbNoBase($content);
        if (!Features::forContent($content)->has('is_timeline')) {
            return;
        }
        $config = Features::getConfig($content);
        $this->fields = $config['fields'] ?? [];
        // Shared fields (apply to all posts)
        $this->timelineSharedFields = array_keys(array_filter($this->fields, function ($field) {
            return !isset($field['for_all']) || $field['for_all'] === false;
        }));
        array_unshift($this->timelineSharedFields, 'post_thumbnail', 'post_title', 'post_status');
        // Unique fields (per-entry)
        $this->timelineUniqueFields = array_keys(array_filter($this->fields, function ($field) {
            return isset($field['for_all']) && $field['for_all'] === true;
        }));
    }
    private function verifyOwnership(int $postId): bool
    {
        $post = get_post($postId);
        return $post && (int)$post->post_author === $this->userId;
    }
    private function generatePostTitle(string $content): string
    {
        $username = get_user_meta($this->userId, 'first_name', true);
        $link = get_user_meta($this->userId, BASE . 'link', true);
        $city = function_exists('jvbArtistCity') ? jvbArtistCity($link) : '';
        return ucfirst($content) . ' by ' . ($city ? "$city artist " : '') . $username;
    }
}
inc/managers/queue/executors/UploadExecutor.php
New file
@@ -0,0 +1,628 @@
<?php
namespace JVBase\managers\queue\executors;
use JVBase\managers\queue\{Executor, Operation, Progress, Result};
use JVBase\managers\UploadManager;
use JVBase\meta\MetaManager;
use Exception;
use JVBase\utility\Features;
if (!defined('ABSPATH')) {
    exit;
}
/**
 * Executor for upload-related queue operations.
 * Handles: image_upload, video_upload, document_upload,
 *          update_metadata, temporary_cleanup, attach_upload_to_content, process_upload_groups
 */
final class UploadExecutor implements Executor
{
    private const HANDLED_TYPES = [
        'image_upload',
        'video_upload',
        'document_upload',
        'update_metadata',
        'temporary_cleanup',
        'attach_upload_to_content',
        'process_upload_groups'
    ];
    public function execute(Operation $operation, Progress $progress): Result
    {
        if (!in_array($operation->type, self::HANDLED_TYPES)) {
            throw new Exception("UploadExecutor cannot handle type: {$operation->type}");
        }
        try {
            $data = $operation->requestData;
            return match($operation->type) {
                'image_upload'            => $this->processFileUpload($operation, $data, 'image', $progress),
                'video_upload'            => $this->processFileUpload($operation, $data, 'video', $progress),
                'document_upload'         => $this->processFileUpload($operation, $data, 'document', $progress),
                'update_metadata'         => $this->processMetadataUpdate($operation, $data, $progress),
                'temporary_cleanup'       => $this->processTemporaryCleanup($operation, $data, $progress),
                'attach_upload_to_content'=> $this->processAttachToContent($operation, $data, $progress),
                'process_upload_groups'   => $this->processUploadGroups($operation, $data, $progress),
                default                   => throw new Exception("Unknown type: {$operation->type}")
            };
        } catch (Exception $e) {
            JVB()->error()->log(
                '[UploadExecutor]:execute',
                $e->getMessage(),
                [
                    'operation_id'   => $operation->id,
                    'operation_type' => $operation->type,
                    'user_id'        => $operation->userId,
                ]
            );
            return new Result(
                outcome: 'failed',
                result: ['error' => $e->getMessage()]
            );
        }
    }
    /**
     * Process file uploads (images, videos, documents)
     */
    private function processFileUpload(Operation $operation, array $data, string $fileType, Progress $progress): Result
    {
        $uploader = new UploadManager();
        $processedResults = [];
        $errors = [];
        $securedFiles = $data['secured_files'] ?? [];
        foreach ($securedFiles as $securedFile) {
            try {
                $result = $uploader->processUpload(
                    $securedFile['temp_path'],
                    [
                        'file_type'     => $fileType,
                        'user_id'       => $operation->userId,
                        'post_id'       => (int)($data['post_id'] ?? 0),
                        'term_id'       => (int)($data['term_id'] ?? 0),
                        'original_name' => $securedFile['original_name'],
                        'mime_type'     => $securedFile['mime_type'],
                        'content'       => $data['content'] ?? '',
                    ]
                );
                if (!is_wp_error($result)) {
                    $standardized = [
                        'attachment_id' => $result['attachment_id'],
                        'url'           => $result['url'],
                        'file'          => $result['file'],
                        'upload_id'     => $securedFile['upload_id'] ?? null,
                    ];
                    if ($standardized['upload_id']) {
                        $processedResults[$standardized['upload_id']] = $standardized;
                    } else {
                        $processedResults[] = $standardized;
                    }
                    // Apply frontend metadata if provided
                    if (!empty($securedFile['metadata'])) {
                        $this->applyMeta($standardized['attachment_id'], $securedFile['metadata']);
                    }
                    $progress->advance(1);
                } else {
                    $progress->failItem($securedFile, $result->get_error_message());
                }
            } catch (Exception $e) {
                $progress->failItem($securedFile, $e->getMessage());
                $errors[] = $e->getMessage();
            }
        }
        // Handle destination (meta, post, post_group)
        $this->handleUploadDestination($data, $processedResults);
        // Cleanup temp files
        $this->cleanupTempFiles($securedFiles, $operation->userId);
        $outcome = 'success';
        if (!empty($operation->failedItems)) {
            $outcome = count($operation->failedItems) === count($securedFiles) ? 'failed' : 'partial';
        }
        return new Result(
            outcome: $outcome,
            result: $processedResults
        );
    }
    /**
     * Attach upload results to content
     */
    private function processAttachToContent(Operation $operation, array $data, Progress $progress): Result
    {
        $uploadOpId = $data['upload'] ?? null;
        if (!$uploadOpId) {
            throw new Exception('No upload operation ID provided');
        }
        // Get results from the dependency
        $uploadOp = JVB()->queue()->get($uploadOpId);
        if (!$uploadOp || $uploadOp->outcome !== 'success') {
            throw new Exception("Upload operation {$uploadOpId} not completed successfully");
        }
        $uploadResults = $uploadOp->result ?? [];
        if (empty($uploadResults)) {
            throw new Exception('No upload results found');
        }
        // Attach to content via field
        if (!empty($data['field_name'])) {
            $this->updateFieldValue($data, $uploadResults);
        }
        $progress->advance(1);
        return new Result(
            outcome: 'success',
            result: ['attached' => count($uploadResults)]
        );
    }
    /**
     * Process metadata updates for attachments
     */
    private function processMetadataUpdate(Operation $operation, array $data, Progress $progress): Result
    {
        $updatedCount = 0;
        $errors = [];
        foreach ($data as $uploadId => $info) {
            if (!is_array($info) || empty($info['depends_on'])) {
                continue;
            }
            try {
                // Get the dependency operation to find attachment ID
                $depOp = JVB()->queue()->get($info['depends_on']);
                if (!$depOp || !$depOp->result) {
                    $errors[] = "Dependency {$info['depends_on']} not found or has no result";
                    continue;
                }
                $attachmentId = $this->findAttachmentByUploadId($uploadId, $depOp->result);
                if (!$attachmentId) {
                    $errors[] = "No attachment found for upload ID: {$uploadId}";
                    continue;
                }
                $this->applyMeta($attachmentId, $info);
                $updatedCount++;
                $progress->advance(1);
            } catch (Exception $e) {
                $progress->failItem($uploadId, $e->getMessage());
                $errors[] = $e->getMessage();
            }
        }
        $outcome = $updatedCount > 0 ? 'success' : 'failed';
        if ($updatedCount > 0 && !empty($errors)) {
            $outcome = 'partial';
        }
        return new Result(
            outcome: $outcome,
            result: [
                'updated' => $updatedCount,
                'errors'  => $errors,
            ]
        );
    }
    /**
     * Cleanup temporary upload files
     */
    private function processTemporaryCleanup(Operation $operation, array $data, Progress $progress): Result
    {
        $uploader = new UploadManager();
        // Cleanup secured files
        if (!empty($data['files'])) {
            foreach ($data['files'] as $file) {
                if (!empty($file['temp_path']) && file_exists($file['temp_path'])) {
                    @unlink($file['temp_path']);
                }
                $progress->advance(1);
            }
        }
        // Cleanup specific temp paths
        if (!empty($data['temp_paths']) && is_array($data['temp_paths'])) {
            foreach ($data['temp_paths'] as $tempPath) {
                if (file_exists($tempPath)) {
                    @unlink($tempPath);
                }
            }
        }
        // Cleanup empty directories
        $uploader->cleanupEmptyTempDirs($operation->userId);
        return new Result(
            outcome: 'success',
            result: ['cleaned' => true]
        );
    }
    /**
     * Process grouped uploads into posts
     */
    private function processUploadGroups(Operation $operation, array $data, Progress $progress): Result
    {
        $dependencies = $operation->dependencies;
        if (empty($dependencies)) {
            throw new Exception('No dependencies found for group uploads.');
        }
        $uploads = [];
        foreach ($dependencies as $dependency) {
            $res = JVB()->queue()->getOperationValue($dependency, 'result');
            if (empty($res)) {
                continue;
            }
            // Results are stored at root level, keyed by upload_id
            // Filter to only include actual upload results (arrays with attachment_id)
            foreach ($res as $key => $value) {
                if (is_array($value) && isset($value['attachment_id'])) {
                    $uploads[$key] = $value;
                }
            }
        }
        if (empty($uploads)) {
            throw new Exception('No uploads found for group uploads.');
        }
        $all_uploads = [];
        foreach($uploads as $upload_id => $img) {
            if (!array_key_exists('attachment_id', $img) || (int)$img['attachment_id'] === 0){
                continue;
            }
            $all_uploads[$upload_id] = [
                'upload_id' => $upload_id,
                'attachment_id'=> (int)$img['attachment_id']
            ];
        }
        $content = jvbCheckBase($data['content']);
        if (Features::forContent($data['content'])->has('is_timeline')) {
            return $this->processTimelineUploads($operation, $data, $progress, $all_uploads);
        }
        $user = (int)$operation->userId;
        $createdPosts = [];
        $usedUploads = [];
        foreach($data['posts'] as $index => $post) {
            $progress->advance();
            $post_title = array_key_exists('post_title', $post['fields'])
                ? sanitize_text_field($post['fields']['post_title'])
                : 'New '. JVB_CONTENT[$data['content']]['singular'].' '.($index + 1);
            $post_excerpt = array_key_exists('post_excerpt', $post['fields'])
                ? sanitize_textarea_field($post['fields']['post_excerpt'])
                : '';
            $args = [
                'post_type'     => $content,
                'post_author'   => $user,
                'post_status'   => 'draft',
                'post_title'    => $post_title,
                'post_excerpt'  => $post_excerpt
            ];
            $newPostID = wp_insert_post($args);
            if ($newPostID && !is_wp_error($newPostID)) {
                $createdPosts[] = $newPostID;
                $featured_upload_id = $post['fields']['featured']??null;
                $featured_attachment_id = null;
                $gallery_attachment_ids = [];
                foreach ($post['images'] as $img) {
                    $uploadId = $img['upload_id'];
                    $usedUploads[] = $uploadId;
                    if (array_key_exists($uploadId, $all_uploads)) {
                        $attachmentId = $all_uploads[$uploadId]['attachment_id'];
                        if ($uploadId === $featured_upload_id) {
                            $featured_attachment_id = $attachmentId;
                        } else {
                            $gallery_attachment_ids[] = $attachmentId;
                        }
                    }
                }
                if ($featured_attachment_id) {
                    set_post_thumbnail($newPostID, $featured_attachment_id);
                } elseif (!empty($gallery_attachment_ids)) {
                    set_post_thumbnail($newPostID, $gallery_attachment_ids[0]);
                    array_shift($gallery_attachment_ids);
                }
                if (!empty($gallery_attachment_ids)) {
                    $meta = new MetaManager($newPostID, 'post');
                    $fields = jvbGetFields($content, 'post');
                    foreach($fields as $name => $config) {
                        if ($config['type'] === 'gallery') {
                            $meta->updateValue($name, implode(',', $gallery_attachment_ids));
                            break;
                        }
                    }
                }
            }
        }
        return new Result(
            outcome: !empty($createdPosts) ? 'success' : 'failed',
            result: ['posts' => $createdPosts]
        );
    }
    private function processTimelineUploads(Operation $operation, array $data, Progress $progress, array $uploads):Result
    {
        $user = $operation->userId;
        $createdPosts = [];
        $usedUploads = [];
        $content = jvbCheckBase($data['content']);
        $config = Features::getConfig($content);
        $defaultTitle = 'New '.$config['singular']. ' ';
        foreach($data['posts'] as $index => $post) {
            $title = array_key_exists('post_title', $post['fields'])
                ? sanitize_text_field($post['fields']['post_title'])
                : $defaultTitle . ($index + 1);
            $excerpt = array_key_exists('post_excerpt', $post['fields'])
                ? sanitize_textarea_field($post['fields']['post_excerpt'])
                : '';
            $args = [
                'post_type' => $content,
                'post_author'   => $user,
                'post_status'   => 'draft',
                'post_title'    => $title,
                'post_excerpt'  => $excerpt
            ];
            $parent = wp_insert_post($args);
            $progress->advance();
            if ($parent && !is_wp_error($parent)) {
                $childPosts = [];
                $featured = $post['fields']['featured']??null;
                $featuredID = null;
                foreach ($post['images'] as $img) {
                    $uploadId = $img['upload_id'];
                    $usedUploads[] = $uploadId;
                    if (array_key_exists($uploadId, $uploads)) {
                        $attachmentId = (int)$uploads[$uploadId]['attachment_id'];
                        if ($uploadId === $featured) {
                            $featuredID = $attachmentId;;
                        } else {
                            $childPosts[] = $attachmentId;
                        }
                    }
                }
                if ($featuredID) {
                    set_post_thumbnail($parent, $featuredID);
                } elseif (!empty($childPosts)) {
                    set_post_thumbnail($parent, (int)$childPosts[0]);
                    array_shift($childPosts);
                }
                if (!empty($childPosts)) {
                    $args['post_parent'] = $parent;
                    $args['post_excerpt'] = '';
                    $createdPosts[$parent] = [];
                    foreach($childPosts as $i => $imgID) {
                        $treatment = $i + 1;
                        $args ['post_title'] = $title.' - Treatment #'.$treatment;
                        $child = wp_insert_post($args);
                        if ($child && !is_wp_error($child)) {
                            $createdPosts[$parent][] = $child;
                            set_post_thumbnail($child, $imgID);
                        }
                    }
                }
            }
        }
        return new Result(
            outcome: !empty($createdPosts) ? 'success' : 'failed',
            result: ['posts' => $createdPosts]
        );
    }
    // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    // Helper methods
    // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    private function applyMeta(int $attachmentId, array $metadata): void
    {
        if (!empty($metadata['title'])) {
            wp_update_post([
                'ID'         => $attachmentId,
                'post_title' => sanitize_text_field($metadata['title']),
            ]);
        }
        if (!empty($metadata['alt'])) {
            update_post_meta($attachmentId, '_wp_attachment_image_alt', sanitize_text_field($metadata['alt']));
        }
        if (!empty($metadata['caption'])) {
            wp_update_post([
                'ID'           => $attachmentId,
                'post_excerpt' => sanitize_textarea_field($metadata['caption']),
            ]);
        }
    }
    private function handleUploadDestination(array $data, array $results): void
    {
        $destination = $data['destination'] ?? 'meta';
        switch ($destination) {
            case 'meta':
                $this->saveToMeta($data, $results);
                break;
            case 'post':
                $this->createIndividualPosts($data, $results);
                break;
            case 'post_group':
                // Handled by process_upload_groups
                break;
        }
    }
    private function saveToMeta(array $data, array $results): void
    {
        if (empty($data['field_name'])) {
            return;
        }
        $attachmentIds = array_column($results, 'attachment_id');
        $meta = $this->getMetaManager($data);
        if (!$meta) {
            return;
        }
        $existing = $meta->getValue($data['field_name']);
        $existingIds = !empty($existing) ? explode(',', $existing) : [];
        $allIds = array_unique(array_merge($existingIds, $attachmentIds));
        $meta->updateValue($data['field_name'], implode(',', $allIds));
    }
    private function updateFieldValue(array $data, array $results): void
    {
        if (empty($data['field_name'])) {
            return;
        }
        $attachmentIds = array_column($results, 'attachment_id');
        $meta = $this->getMetaManager($data);
        if (!$meta) {
            return;
        }
        $existing = $meta->getValue($data['field_name']);
        $existingIds = !empty($existing) ? explode(',', $existing) : [];
        $allIds = array_unique(array_merge($existingIds, $attachmentIds));
        $meta->updateValue($data['field_name'], implode(',', $allIds));
    }
    private function getMetaManager(array $data): ?MetaManager
    {
        if (!empty($data['post_id'])) {
            return new MetaManager($data['post_id'], 'post');
        }
        if (!empty($data['term_id'])) {
            return new MetaManager($data['term_id'], 'term');
        }
        if (!empty($data['user'])) {
            $link = (int)get_user_meta($data['user'], BASE . 'link', true);
            if ($link) {
                return new MetaManager($link, 'post');
            }
        }
        return null;
    }
    private function createIndividualPosts(array $data, array $results): array
    {
        if (empty($data['content'])) {
            return [];
        }
        $createdPosts = [];
        foreach ($results as $result) {
            $attachmentId = $result['attachment_id'];
            $attachment = get_post($attachmentId);
            $postData = [
                'post_type'   => jvbCheckBase($data['content']),
                'post_title'  => $attachment->post_title,
                'post_status' => 'draft',
                'post_author' => $data['user'] ?? get_current_user_id(),
            ];
            $postId = wp_insert_post($postData);
            if (!is_wp_error($postId)) {
                $this->attachFileToPost($postId, $attachmentId, $data);
                $createdPosts[] = [
                    'post_id'       => $postId,
                    'attachment_id' => $attachmentId,
                ];
            }
        }
        return $createdPosts;
    }
    private function attachFileToPost(int $postId, int $attachmentId, array $data): void
    {
        $attachment = get_post($attachmentId);
        $mimeType = $attachment->post_mime_type;
        if (str_starts_with($mimeType, 'image/')) {
            set_post_thumbnail($postId, $attachmentId);
        } elseif (str_starts_with($mimeType, 'video/')) {
            $meta = new MetaManager($postId, 'post');
            $meta->updateValue('video', $attachmentId);
        } else {
            $meta = new MetaManager($postId, 'post');
            $existing = $meta->getValue('documents');
            $existingIds = !empty($existing) ? explode(',', $existing) : [];
            $existingIds[] = $attachmentId;
            $meta->updateValue('documents', implode(',', $existingIds));
        }
    }
    private function cleanupTempFiles(array $securedFiles, int $userId): void
    {
        $uploader = new UploadManager();
        foreach ($securedFiles as $secured) {
            if (!empty($secured['temp_path']) && file_exists($secured['temp_path'])) {
                @unlink($secured['temp_path']);
            }
        }
        $uploader->cleanupEmptyTempDirs($userId);
    }
    private function findAttachmentByUploadId(string $uploadId, array $results): ?int
    {
        if (isset($results[$uploadId]['attachment_id'])) {
            return (int)$results[$uploadId]['attachment_id'];
        }
        foreach ($results as $result) {
            if (isset($result['upload_id']) && $result['upload_id'] === $uploadId) {
                return (int)$result['attachment_id'];
            }
        }
        return null;
    }
}
inc/meta/MetaForm.php
@@ -1194,7 +1194,7 @@
                    <?php jvbRenderProgressBar('',true) ?>
                    <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
                    <label for="select-item<?=$addID?>" aria-label="Select image">
                        <?= ($attachment) ? $attachment : '<img>
                        <?= ($attachment) ?: '<img>
                        <video></video>
                        <span></span>'; ?>
                    </label>
@@ -1267,7 +1267,7 @@
                    <?php jvbRenderProgressBar('',true) ?>
                    <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
                    <label for="select-item<?=$addID?>" aria-label="Select image">
                        <?= ($attachment) ? $attachment : '<img>
                        <?= ($attachment) ?: '<img>
                        <video></video>
                        <span></span>'; ?>
                    </label>
inc/meta/MetaManager.php
@@ -815,9 +815,7 @@
                return '';
        }
        $out = apply_filters('jvbMetaTypeTemplate', $out, $type);
        return $out;
        return apply_filters('jvbMetaTypeTemplate', $out, $type);
    }
    public function getDefaultValue(string $type):mixed {
inc/registry/CheckCustomTables.php
@@ -445,16 +445,15 @@
            `type` varchar(50) NOT NULL,
            `user_id` {$this->userIDType} NOT NULL,
            `request_data` JSON NOT NULL,
            `request_data` JSON NOT NULL CHECK (JSON_VALID(request_data)),
            `total_items` int(11) NOT NULL DEFAULT 1,
            `processed_items` int(11) DEFAULT 0,
            `failed_items` JSON,
            `priority` enum('low', 'normal', 'high') DEFAULT 'normal',
//            `status` enum('pending', 'scheduled', 'processing','failed', 'failed_permanent', 'completed', 'completed_with_errors') DEFAULT 'pending',
            `state` enum('pending', 'scheduled', 'processing', 'completed'),
            `outcome` enum('success', 'partial', 'failed','failed_permanent'),
            `priority` ENUM('high', 'normal', 'low') DEFAULT 'normal',
            `state` enum('pending', 'scheduled', 'processing', 'completed') DEFAULT 'pending',
            `outcome` enum('pending', 'success', 'partial', 'failed','failed_permanent') DEFAULT 'pending',
            `retries` int(11) DEFAULT 0,
            `last_error_hash` CHAR(32) DEFAULT NULL,
@@ -467,7 +466,6 @@
            `metadata` JSON DEFAULT NULL,
            `result` JSON,
            `dependencies` JSON,
//            `merge` enum('merge', 'append', 'replace') DEFAULT 'merge',
            `user_dismissed` tinyint(1) DEFAULT 0,
            `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -476,26 +474,36 @@
            KEY `idx_run_queue` (state, priority, scheduled_at),
            KEY `idx_user_ops` (user_id, state),
            KEY `idx_user_type_pending` (user_id, type, state),
            KEY `idx_completed_at` (completed_at)
            KEY `idx_completed_at` (completed_at),
            KEY `idx_processing_stuck` (`state`, `started_at`)
            )",
        'stats__operation_queue' => "(
            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            `date` date NOT NULL,
            `type` varchar(50) NOT NULL,
            `total_operations` int NOT NULL DEFAULT 0,
            `successful_operations` int NOT NULL DEFAULT 0,
            `failed_operations` int NOT NULL DEFAULT 0,
            `average_duration` float DEFAULT NULL,
            `total_items_processed` int NOT NULL DEFAULT 0,
            `peak_queue_size` int NOT NULL DEFAULT 0,
            `peak_memory_usage` int DEFAULT NULL,
            `peak_cpu_usage` float DEFAULT NULL,
            `created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (`id`),
            UNIQUE KEY (`date`, `type`),
            KEY `date_idx` (`date`),
            KEY `type_idx` (`type`)
            `id` bigint unsigned AUTO_INCREMENT,
            `date` date NOT NULL,
            `type` varchar(50) NOT NULL,
            `total_operations` int NOT NULL DEFAULT 0,
            `successful_operations` int NOT NULL DEFAULT 0,
            `partial_operations` int NOT NULL DEFAULT 0,
            `failed_operations` int NOT NULL DEFAULT 0,
            `failed_permanent_operations` int NOT NULL DEFAULT 0,
            `total_items_processed` int NOT NULL DEFAULT 0,
            `average_duration` float DEFAULT NULL,
            `max_duration` int DEFAULT NULL,
            `peak_queue_size` int NOT NULL DEFAULT 0,
            `peak_memory_usage` int DEFAULT NULL,
            `peak_cpu_usage` float DEFAULT NULL,
            `created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
            UNIQUE KEY (date, type),
            KEY `date_idx` (date),
            KEY `type_idx` (type)
        )"
        ];
    }
inc/registry/FieldRegistry.php
@@ -121,9 +121,7 @@
        // Apply filters for extensibility
        $fields = apply_filters(BASE . 'fields', $fields, $type, $object_type);
        $fields = apply_filters(BASE . "{$type}_fields", $fields, $object_type);
        return $fields;
        return apply_filters(BASE . "{$type}_fields", $fields, $object_type);
    }
    /**
inc/rest/routes/AdminRoutes.php
@@ -598,13 +598,12 @@
        $query = new WP_Query($args);
        $items = array_map([$this, 'formatItem'], $query->posts);
        $results = [
            'items'         => $items,
            'has_more'      => $query->max_num_pages > $args['paged'],
            'total_items'   => $query->found_posts,
            'total_pages'   => $query->max_num_pages
        ];
        return $results;
        return [
            'items'         => $items,
            'has_more'      => $query->max_num_pages > $args['paged'],
            'total_items'   => $query->found_posts,
            'total_pages'   => $query->max_num_pages
        ];
    }
    /**
inc/rest/routes/ContentRoutes.php
@@ -3,6 +3,8 @@
namespace JVBase\rest\routes;
use JVBase\JVB;
use JVBase\managers\queue\executors\ContentExecutor;
use JVBase\managers\queue\TypeConfig;
use JVBase\rest\RestRouteManager;
use JVBase\managers\CacheManager;
use JVBase\meta\MetaManager;
@@ -30,9 +32,6 @@
    protected array $timelineSharedFields = [];
    protected array $timelineUniqueFields = [];
    //TODO: Ensure we are handling the bulk operations for all processes
    //TODO: Also invalidate feed caches on updates!!
    public function __construct()
    {
        $this->cache_name = 'user_content_' . get_current_user_id();
@@ -40,7 +39,28 @@
        $this->cache->clear();
        $this->action = 'dash-';
        $this->operation_type = 'content_update';
        add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
        add_action('init', [$this, 'registerContentExecutors'], 5);
    }
    /**
     * Register content operation types with the queue's TypeRegistry
     */
    public function registerContentExecutors(): void
    {
        $registry = JVB()->queue()->registry();
        $executor = new ContentExecutor();
        // Content updates - chunked at 10 posts
        $registry->register('content_update', new TypeConfig(
            executor: $executor,
            chunkKey: 'posts',
            chunkSize: 10
        ));
        // Batch creation (from uploads)
        $registry->register('batch_creation', new TypeConfig(
            executor: $executor
        ));
    }
    /**
inc/rest/routes/FavouritesRoutes.php
@@ -273,14 +273,12 @@
                $by_type[$type][] = (int)$fav->target_id;
            }
            $response_data = [
            return [
                'success' => true,
                'items'     => $by_type,
                'has_more'  => false,
            ];
            return $response_data;
        } catch (Exception $e) {
            $this->logError(
                $e->getMessage(),
inc/rest/routes/FeedRoutes.php
@@ -381,8 +381,7 @@
        $args = $this->applyOrderFilters($args, $data);
        $args = $this->applyDateFilters($args, $data);
        $args = $this->applyFavouritesFilter($args, $data);
        return $args;
        return $this->applyFavouritesFilter($args, $data);
    }
    protected function applyTaxonomyFilters(array $args, array $data): array
inc/rest/routes/FormRoutes.php
@@ -323,7 +323,7 @@
        $form = [];
        foreach ($form_config['fields'] as $field_name => $config) {
            // Skip file upload fields
            if (in_array($config['type'], ['upload'])) {
            if ($config['type'] == 'upload') {
                continue;
            }
@@ -354,7 +354,7 @@
        }
        // Send the email
        $email_sent = JVB()->email()->sendEmail(
        return JVB()->email()->sendEmail(
            $to,
            $subject,
            $body,
@@ -362,8 +362,6 @@
            $headers,
            $attachments
        );
        return $email_sent;
    }
    /**
inc/rest/routes/Invitations.php
@@ -86,16 +86,14 @@
        $toTerm = (array_key_exists('to_term', $data)) ? (int)$data['to_term'] : false;
        $taxonomy = (array_key_exists('taxonomy', $data) && in_array($data['taxonomy'], $this->inviteTypes[$role]['to_terms']??[])) ? $data['taxonomy'] : false;
        $args = [
            'user'        => (array_key_exists('user', $data)) ? (int)$data['user'] : false,
            'role'         => $role,
            'to_term'     => $toTerm,
            'taxonomy'     => $taxonomy,
            'status'    => array_key_exists('status', $data) && in_array($data['status'], ['all', 'pending', 'accepted', 'rejected', 'expired', 'revoked']) ? $data['status'] : 'all',
            'page'        => array_key_exists('page', $data) ? (int)$data['page'] : 1,
        ];
        return $args;
        return [
            'user'        => (array_key_exists('user', $data)) ? (int)$data['user'] : false,
            'role'         => $role,
            'to_term'     => $toTerm,
            'taxonomy'     => $taxonomy,
            'status'    => array_key_exists('status', $data) && in_array($data['status'], ['all', 'pending', 'accepted', 'rejected', 'expired', 'revoked']) ? $data['status'] : 'all',
            'page'        => array_key_exists('page', $data) ? (int)$data['page'] : 1,
        ];
    }
    /**
     * @param object $request the request object
inc/rest/routes/LoginRoutes.php
@@ -31,23 +31,7 @@
        register_rest_route($this->namespace, '/auth/login', [
            'methods' => 'POST',
            'callback' => [$this, 'handleLogin'],
            'permission_callback' => [$this, 'checkRateLimit'],
            'args' => [
                'user_email' => [
                    'required' => true,
                    'type' => 'string',
                    'sanitize_callback' => 'sanitize_email'
                ],
                'user_password' => [
                    'required' => true,
                    'type' => 'string'
                ],
                'remember_me' => [
                    'required' => false,
                    'type' => 'boolean',
                    'default' => false
                ]
            ]
            'permission_callback' => [$this, 'checkRateLimit']
        ]);
        // Logout endpoint
@@ -125,7 +109,8 @@
    public function handleLogin(WP_REST_Request $request): WP_REST_Response
    {
        $data = $request->get_json_params();
        $data = $request->get_params();
        error_log('Data: '.print_r($data, true));
        // Verify Turnstile
        if (!$this->verifyTurnstile($data['cf-turnstile-response'] ?? '')) {
            return $this->error('Security verification failed', 'turnstile_failed', 403);
@@ -584,7 +569,7 @@
        ];
    }
    protected function getRedirect(WP_User $user, string $url, string $context = 'login'):string
    protected function getRedirect(WP_User $user, ?string $url=null, string $context = 'login'):string
    {
        if (!empty($url)) {
            $url = sanitize_url($url);
inc/rest/routes/NewsRoutes.php
@@ -128,9 +128,7 @@
        $args = $this->applyTaxonomyFilters($args, $data);
        $args = $this->applyOrderFilters($args, $data);
        $args = $this->applyDateFilters($args, $data);
        $args = $this->applyWatchedFilter($args, $data);
        return $args;
        return $this->applyWatchedFilter($args, $data);
    }
    /**
inc/rest/routes/QueueRoutes.php
@@ -16,23 +16,21 @@
class QueueRoutes extends RestRouteManager
{
    protected string $table = BASE.'_operation_queue';
    protected string $metricsTable = BASE.'stats__operation_queue';
    public function __construct()
    {
        $this->cache_name = 'queue';
        $this->cache_ttl = 300;
        parent::__construct();
        $this->cache->clear();
    }
    /**
     * Registers queue routes
     * @return void
     */
    public function registerRoutes():void
    {
    public function registerRoutes():void
    {
        register_rest_route($this->namespace, '/queue', [
            [
                'methods'   => 'GET',
@@ -79,7 +77,7 @@
                ]
            ]
        ]);
    }
    }
    /**
     * Get queue operations with optional filtering
@@ -89,62 +87,45 @@
     */
    public function getQueue(WP_REST_Request $request): WP_REST_Response
    {
        $user_id = get_current_user_id();
        $status = $request->get_param('status');
        $user_id = $request->get_param('user');
        $status = sanitize_text_field($request->get_param('status'));
        $ids = $request->get_param('ids');
        $limit = intval($request->get_param('limit'));
        // Use base class user-specific header checking
        // This checks both 'queue' and 'user_{$user_id}' timestamps
        $cache_check = $this->checkUserHeaders($request, $user_id, 'queue');
        if ($cache_check) {
            return $cache_check; // Returns 304 Not Modified
            return $cache_check;
        }
        global $wpdb;
        $table = $wpdb->prefix . $this->table;
        // Build filters for getUserOperations
        $filters = [
            'not_dismissed' => true,
            'limit' => $limit ?: 50,
        ];
        $sql = "SELECT * FROM $table WHERE user_id = %d AND COALESCE(user_dismissed, 0) = 0";
        $params = [$user_id];
        if ($status !== 'all') {
            $sql .= " AND status = %s";
            $params[] = $status;
        if ($status && $status !== 'all') {
            $filters['state'] = $status;
        }
        if (!empty($ids)) {
            $id_array = array_map('trim', explode(',', $ids));
            if (!empty($id_array)) {
                $placeholders = implode(',', array_fill(0, count($id_array), '%s'));
                $sql .= " AND id IN ($placeholders)";
                $params = array_merge($params, $id_array);
            }
            $filters['ids'] = array_map('trim', explode(',', $ids));
        }
        $sql .= " ORDER BY FIELD(status, 'processing', 'pending', 'failed', 'completed'), created_at DESC";
        // Get operations via Queue
        $operations = JVB()->queue()->getUserOperations($user_id, $filters);
        if ($limit > 0) {
            $sql .= " LIMIT %d";
            $params[] = $limit;
        }
        $operations = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
        // Format operations
        foreach ($operations as &$op) {
            $op = $this->formatOperation($op);
        }
        // Format operations for API response
        $formatted = array_map([$this, 'formatOperationFromObject'], $operations);
        $response = new WP_REST_Response([
            'items' => $operations,
            'total' => count($operations),
            'items' => $formatted,
            'total' => count($formatted),
            'timestamp' => date('c'),
            'has_more' => count($operations) === $limit,
            'has_more' => count($formatted) === ($limit ?: 50),
            'queue_stats' => $this->getQueueStats($user_id),
            'server_time' => date('c')
        ]);
        // Add cache headers (ETag, Last-Modified)
        return $this->addCacheHeaders($response);
    }
@@ -154,76 +135,75 @@
     */
    protected function getQueueStats(int $user_id): array
    {
        global $wpdb;
        $table = $wpdb->prefix . $this->table;
        $stats = JVB()->queue()->getUserStats($user_id);
        $stats = $wpdb->get_results($wpdb->prepare(
            "SELECT status, COUNT(*) as count FROM $table WHERE user_id = %d GROUP BY status",
            $user_id
        ), OBJECT_K);
        $formatted_stats = [
        // Add frontend-only statuses that don't exist in backend
        return array_merge([
            'queued' => 0,
            'localProcessing' => 0,
            'uploading' => 0,
            'pending' => 0,
            'processing' => 0,
            'completed' => 0,
            'failed' => 0,
            'failed_permanent' => 0
        ];
        ], $stats);
    }
        foreach ($stats as $status => $data) {
            if (isset($formatted_stats[$status])) {
                $formatted_stats[$status] = intval($data->count);
            }
    /**
     * Map backend state/outcome to frontend status
     * Backend uses: state (pending, scheduled, processing, completed) + outcome (pending, success, partial, failed, failed_permanent)
     * Frontend expects: queued, pending, processing, completed, failed, failed_permanent
     */
    protected function mapStateToStatus(string $state, ?string $outcome): string
    {
        // If completed, check outcome for failure states
        if ($state === 'completed') {
            return match($outcome) {
                'failed' => 'failed',
                'failed_permanent' => 'failed_permanent',
                'partial' => 'completed', // or could be 'partial' if JS supports it
                default => 'completed'
            };
        }
        return $formatted_stats;
        // Map other states directly
        return match($state) {
            'scheduled' => 'pending',
            default => $state
        };
    }
    /**
     * Format operation for API response
     * Format Operation object for API response
     */
    protected function formatOperation(array $operation): array
    protected function formatOperationFromObject(\JVBase\managers\queue\Operation $op): array
    {
        $formatted = [
            'id' => $operation['id'],
            'type' => $operation['type'],
            'status' => $operation['status'],
            'progress_count' => intval($operation['progress_count'] ?? 0),
            'count' => intval($operation['count'] ?? 1),
            'retries' => intval($operation['retries'] ?? 0),
            'data' => json_decode($operation['request_data'] ?? '{}', true),
            'result'    => json_decode($operation['result']??'{}', true)
            'id' => $op->id,
            'type' => $op->type,
            'status' => $this->mapStateToStatus($op->state, $op->outcome),
            'progress_count' => $op->processedItems,
            'count' => $op->totalItems,
            'retries' => $op->retries,
            'data' => $op->requestData,
            'result' => $op->result ?? [],
        ];
        // Convert timestamps to ISO 8601 format with proper timezone
        $formatted['created_at'] = $this->formatTimestamp($operation['created_at']);
        $formatted['updated_at'] = $this->formatTimestamp($operation['updated_at']);
        $formatted['created_at'] = $this->formatTimestamp($op->scheduledAt);
        $formatted['updated_at'] = $this->formatTimestamp($op->completedAt ?? $op->startedAt ?? $op->scheduledAt);
        // Add completed_at if status is completed
        if ($operation['status'] === 'completed' && !empty($operation['completed_at'])) {
            $formatted['completed_at'] = $this->formatTimestamp($operation['completed_at']);
        if ($op->state === 'completed' && $op->completedAt) {
            $formatted['completed_at'] = $this->formatTimestamp($op->completedAt);
        }
        // Add error message if failed
        if (!empty($operation['error_message'])) {
            $formatted['error_message'] = $operation['error_message'];
        if ($op->errorMessage) {
            $formatted['error_message'] = $op->errorMessage;
        }
        // Simple progress percentage calculation
        if ($formatted['count'] > 0) {
            $formatted['progress_percentage'] = round(
                ($formatted['progress_count'] / $formatted['count']) * 100
            );
        }
        // Add human-readable title for easier frontend display
        $formatted['title'] = $this->getOperationTitle($operation['type'], $formatted['data']);
        // Add user dismissal status
        $formatted['user_dismissed'] = !empty($operation['user_dismissed']);
        $formatted['title'] = $this->getOperationTitle($op->type, $op->requestData);
        $formatted['user_dismissed'] = $op->userDismissed;
        return $formatted;
    }
@@ -270,10 +250,8 @@
        $data = $request->get_json_params();
        $ids = $data['ids'] ?? [];
        $action = $data['action'] ?? '';
        $user_id = (int)$data['user'];
        // Validate input
        if (empty($ids) || !is_array($ids)) {
            return new WP_REST_Response([
@@ -289,31 +267,22 @@
            ], 400);
        }
        global $wpdb;
        $table = $wpdb->prefix . $this->table;
        // Get operations via Queue - verifies ownership
        $operations = JVB()->queue()->getUserOperations($user_id, [
            'ids' => $ids,
            'limit' => count($ids),
        ]);
        // Verify operations exist and belong to user
        $placeholders = implode(',', array_fill(0, count($ids), '%s'));
        $valid_operations = $wpdb->get_results($wpdb->prepare(
            "SELECT id, status FROM $table WHERE id IN ($placeholders) AND user_id = %d",
            array_merge($ids, [$user_id])
        ));
        if (empty($valid_operations)) {
        if (empty($operations)) {
            return new WP_REST_Response([
                'success' => false,
                'message' => 'No valid operations found'
            ], 404);
        }
        $valid_ids = array_column($valid_operations, 'id');
        $placeholders = implode(',', array_fill(0, count($valid_ids), '%s'));
        // Process action using foreach approach as suggested
        $result = $this->processQueueAction($action, $valid_operations, $user_id);
        $result = $this->processQueueAction($action, $operations, $user_id);
        if ($result['success']) {
            // Update timestamp for this user's queue
            CacheManager::updateTimestamp("user_{$user_id}");
        }
@@ -322,82 +291,36 @@
    protected function processQueueAction(string $action, array $operations, int $user_id): array
    {
        global $wpdb;
        $table = $wpdb->prefix . $this->table;
        $queue = JVB()->queue();
        $processed_count = 0;
        $errors = [];
        $valid_ids = [];
        // Process each operation individually
        foreach ($operations as $operation) {
            $operation_id = $operation->id;
            $operation_status = $operation->status;
        foreach ($operations as $op) {
            try {
                $result = false;
                $result = match($action) {
                    'dismiss' => $queue->dismiss($op->id),
                    'retry'   => $queue->retry($op->id, $user_id),
                    'cancel'  => $queue->cancel($op->id, $user_id),
                    default   => false,
                };
                switch ($action) {
                    case 'dismiss':
                        // Can dismiss any operation
                        $result = $wpdb->update(
                            $table,
                            ['user_dismissed' => 1],
                            ['id' => $operation_id, 'user_id' => $user_id]
                        );
                        break;
                    case 'retry':
                        // Can only retry failed operations
                        if (!in_array($operation_status, ['failed', 'failed_permanent'])) {
                            $errors[] = "Operation {$operation_id} cannot be retried (status: {$operation_status})";
                            continue 2;
                        }
                        $result = $wpdb->update(
                            $table,
                            [
                                'status' => 'pending',
                                'error_message' => null,
                                'updated_at' => current_time('mysql'),
                                'retries' => $wpdb->get_var($wpdb->prepare(
                                        "SELECT retries FROM $table WHERE id = %s",
                                        $operation_id
                                    )) + 1
                            ],
                            ['id' => $operation_id, 'user_id' => $user_id]
                        );
                        break;
                    case 'cancel':
                        // Can only cancel pending/queued operations
                        if (!in_array($operation_status, ['pending', 'queued'])) {
//                          $errors[] = "Operation {$operation_id} cannot be cancelled (status: {$operation_status})";
                            continue 2;
                        }
                        $result = $wpdb->delete(
                            $table,
                            ['id' => $operation_id, 'user_id' => $user_id]
                        );
                        break;
                }
                if ($result !== false) {
                if ($result) {
                    $processed_count++;
                    $valid_ids[] = $operation_id;
                    $valid_ids[] = $op->id;
                } else {
                    $errors[] = "Failed to {$action} operation {$operation_id}";
                    // Only add errors for meaningful failures
                    if ($action === 'retry' && ($op->state !== 'completed' || !in_array($op->outcome, ['failed', 'failed_permanent']))) {
                        $errors[] = "Operation {$op->id} cannot be retried (state: {$op->state}, outcome: {$op->outcome})";
                    }
                    // Silently skip cancel failures (can't cancel processing/completed)
                }
            } catch (Exception $e) {
                $errors[] = "Error processing operation {$operation_id}: " . $e->getMessage();
                $errors[] = "Error processing operation {$op->id}: " . $e->getMessage();
            }
        }
        // Prepare response
        $message = $this->getActionMessage($action, $processed_count);
        if (!empty($errors)) {
            $message .= ". Errors: " . implode(', ', $errors);
        }
@@ -435,27 +358,30 @@
    {
        $user_id = get_current_user_id();
        global $wpdb;
        $table = $wpdb->prefix . $this->table;
        $operations = JVB()->queue()->getUserOperations($user_id, [
            'state' => 'completed',
            'outcome' => ['failed', 'failed_permanent', 'partial'],
            'has_errors' => true,
            'order_by' => 'updated_at DESC',
            'limit' => 20,
        ]);
        $failed_operations = $wpdb->get_results($wpdb->prepare("
            SELECT id, type, error_message, failed_items, retries, created_at, updated_at
            FROM $table
            WHERE user_id = %d
            AND status IN ('failed', 'completed_with_errors')
            AND (error_message IS NOT NULL OR failed_items IS NOT NULL)
            ORDER BY updated_at DESC
            LIMIT 20
        ", $user_id), ARRAY_A);
        foreach ($failed_operations as &$op) {
            $op['failed_items'] = json_decode($op['failed_items'] ?? '[]', true);
            $op['error_details'] = $this->parseErrorMessage($op['error_message']);
        }
        $formatted = array_map(function($op) {
            return [
                'id' => $op->id,
                'type' => $op->type,
                'error_message' => $op->errorMessage,
                'failed_items' => $op->failedItems ?? [],
                'retries' => $op->retries,
                'created_at' => $op->scheduledAt,
                'updated_at' => $op->completedAt,
                'error_details' => $this->parseErrorMessage($op->errorMessage ?? ''),
            ];
        }, $operations);
        return new WP_REST_Response([
            'errors' => $failed_operations,
            'total' => count($failed_operations)
            'errors' => $formatted,
            'total' => count($formatted)
        ]);
    }
    protected function parseErrorMessage(string $error_message): array
inc/rest/routes/UploadRoutes.php
@@ -2,6 +2,8 @@
namespace JVBase\rest\routes;
use JVBase\JVB;
use JVBase\managers\queue\executors\UploadExecutor;
use JVBase\managers\queue\TypeConfig;
use JVBase\rest\RestRouteManager;
use JVBase\meta\MetaManager;
use JVBase\managers\UploadManager;
@@ -21,9 +23,62 @@
    {
        $this->action = 'dash-';
        parent::__construct();
        add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
        add_action('init', [$this, 'registerUploadExecutors'], 5);
    }
    /**
     * Register upload operation types with the queue's TypeRegistry
     */
    public function registerUploadExecutors(): void
    {
        $registry = JVB()->queue()->registry();
        $executor = new UploadExecutor();
        // Image uploads - chunked at 5 files
        $registry->register('image_upload', new TypeConfig(
            executor: $executor,
            chunkKey: 'secured_files',
            chunkSize: 5
        ));
        // Video uploads - one at a time (heavy processing)
        $registry->register('video_upload', new TypeConfig(
            executor: $executor,
            chunkKey: 'secured_files',
            chunkSize: 1
        ));
        // Document uploads - chunked at 10
        $registry->register('document_upload', new TypeConfig(
            executor: $executor,
            chunkKey: 'secured_files',
            chunkSize: 10
        ));
        // Metadata updates
        $registry->register('update_metadata', new TypeConfig(
            executor: $executor
        ));
        // Cleanup - chunked at 5
        $registry->register('temporary_cleanup', new TypeConfig(
            executor: $executor,
            chunkKey: 'files',
            chunkSize: 5
        ));
        // Attach to content (depends on upload completing)
        $registry->register('attach_upload_to_content', new TypeConfig(
            executor: $executor
        ));
        // Process upload groups into posts
        $registry->register('process_upload_groups', new TypeConfig(
            executor: $executor
        ));
    }
    /**
     * Registers upload routes
     * @return void