From 7a9054bb3f033c98067b3196378311dae54c5fbf Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 20 Jan 2026 01:31:53 +0000
Subject: [PATCH] =OperationQueue refactor to the JVBase/managers/queue namespace

---
 inc/managers/SchemaManager.php                   |   44 
 inc/managers/queue/Locker.php                    |   65 +
 inc/managers/queue/Operation.php                 |   36 
 inc/managers/queue/Mergeable.php                 |   11 
 inc/managers/queue/FilteredExecutor.php          |  154 ++
 assets/js/min/form.min.js                        |    2 
 inc/managers/DashboardManager.php                |    1 
 inc/rest/routes/ContentRoutes.php                |   28 
 assets/js/min/referral.min.js                    |    2 
 assets/js/min/queue.min.js                       |    2 
 inc/blocks/CustomBlocks.php                      |    8 
 inc/rest/routes/FeedRoutes.php                   |    3 
 inc/managers/_setup.php                          |    3 
 JVBase.php                                       |    6 
 inc/managers/LoginManager.php                    |  186 +-
 inc/meta/MetaManager.php                         |    4 
 inc/managers/queue/executors/ContentExecutor.php |  580 +++++++++
 assets/js/concise/Queue.js                       |   16 
 inc/integrations/Integrations.php                |    5 
 inc/managers/IconsManager.php                    |    4 
 inc/rest/routes/NewsRoutes.php                   |    4 
 assets/js/concise/Referral.js                    |    3 
 assets/js/concise/FormController.js              |  183 ++
 inc/managers/queue/Executor.php                  |   14 
 inc/registry/CheckCustomTables.php               |   54 
 inc/rest/routes/FormRoutes.php                   |    6 
 inc/managers/SEO/BreadcrumbManager.php           |    4 
 inc/managers/queue/Processor.php                 |   91 +
 inc/EmbedGenerator.php                           |   15 
 inc/registry/FieldRegistry.php                   |    4 
 inc/managers/queue/Progress.php                  |   31 
 inc/rest/routes/FavouritesRoutes.php             |    4 
 inc/managers/queue/Storage.php                   |  372 +++++
 inc/meta/MetaForm.php                            |    4 
 inc/rest/routes/UploadRoutes.php                 |   57 
 inc/managers/queue/Queue.php                     |  390 ++++++
 inc/integrations/GoogleMyBusiness.php            |   22 
 inc/managers/queue/Result.php                    |   13 
 inc/rest/routes/QueueRoutes.php                  |  296 +--
 inc/managers/queue/TypeRegistry.php              |   52 
 inc/managers/AdminPages.php                      |    2 
 inc/managers/SEOMetaManager.php                  |    3 
 inc/managers/queue/executors/UploadExecutor.php  |  628 +++++++++
 inc/rest/routes/Invitations.php                  |   18 
 inc/integrations/PostMark.php                    |    4 
 inc/managers/queue/TypeConfig.php                |   16 
 inc/managers/UmamiMetrics.php                    |   98 
 inc/rest/routes/LoginRoutes.php                  |   23 
 inc/rest/routes/AdminRoutes.php                  |   13 
 inc/blocks/FormBlock.php                         |    4 
 inc/managers/queue/Metrics.php                   |  210 +++
 inc/managers/queue/_setup.php                    |   30 
 52 files changed, 3,317 insertions(+), 511 deletions(-)

diff --git a/JVBase.php b/JVBase.php
index 0b63b08..c7a20d9 100644
--- a/JVBase.php
+++ b/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'];
     }
diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 76ab6c3..894e58b 100644
--- a/assets/js/concise/FormController.js
+++ b/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() {
diff --git a/assets/js/concise/Queue.js b/assets/js/concise/Queue.js
index a44d3a1..4c9adbc 100644
--- a/assets/js/concise/Queue.js
+++ b/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;
 	}
diff --git a/assets/js/concise/Referral.js b/assets/js/concise/Referral.js
index 6435e98..f56d939 100644
--- a/assets/js/concise/Referral.js
+++ b/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() {
diff --git a/assets/js/min/form.min.js b/assets/js/min/form.min.js
index e487647..1ad372e 100644
--- a/assets/js/min/form.min.js
+++ b/assets/js/min/form.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.queue=window.jvbQueue,this.populate=window.jvbPopulate,this.changes=new Map,this.forms=new Map,this.inputs=new Map,this.repeaters=new Map,this.tagLists=new Map,this.charLimits=new Map,this.quantityFields=new Map,this.quillInstances=new Map,this.dependencies=new Map,this.subscribers=new Set,this.isRestoring=!1,this.hasListeners=!1,this.summaryTemplate=!1,this.init()}init(){this.templates=window.jvbTemplates,this.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)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.queue=window.jvbQueue,this.populate=window.jvbPopulate,this.changes=new Map,this.forms=new Map,this.inputs=new Map,this.repeaters=new Map,this.tagLists=new Map,this.charLimits=new Map,this.quantityFields=new Map,this.quillInstances=new Map,this.dependencies=new Map,this.subscribers=new Set,this.isRestoring=!1,this.hasListeners=!1,this.summaryTemplate=!1,this.init()}init(){this.templates=window.jvbTemplates,this.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)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/queue.min.js b/assets/js/min/queue.min.js
index fd05a2c..73c402b 100644
--- a/assets/js/min/queue.min.js
+++ b/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)}))}))})();
\ No newline at end of file
+(()=>{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)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/referral.min.js b/assets/js/min/referral.min.js
index b712ec0..5c055c3 100644
--- a/assets/js/min/referral.min.js
+++ b/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)}))}))})();
\ No newline at end of file
+(()=>{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)}))}))})();
\ No newline at end of file
diff --git a/inc/EmbedGenerator.php b/inc/EmbedGenerator.php
index 1e15dce..faac311 100644
--- a/inc/EmbedGenerator.php
+++ b/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;
     }
 }
diff --git a/inc/blocks/CustomBlocks.php b/inc/blocks/CustomBlocks.php
index f83b57d..00613e1 100644
--- a/inc/blocks/CustomBlocks.php
+++ b/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
     {
diff --git a/inc/blocks/FormBlock.php b/inc/blocks/FormBlock.php
index edffbe7..512a9c9 100644
--- a/inc/blocks/FormBlock.php
+++ b/inc/blocks/FormBlock.php
@@ -116,9 +116,7 @@
 			'sections' => []
 		];
 
-		$config = array_merge($defaults, $config);
-
-		return $config;
+		return array_merge($defaults, $config);
 	}
 
 	/**
diff --git a/inc/integrations/GoogleMyBusiness.php b/inc/integrations/GoogleMyBusiness.php
index e818d5b..c838f64 100644
--- a/inc/integrations/GoogleMyBusiness.php
+++ b/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'
+		);
 	}
 
 	/**
diff --git a/inc/integrations/Integrations.php b/inc/integrations/Integrations.php
index ff053aa..9f7f851 100644
--- a/inc/integrations/Integrations.php
+++ b/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);
 	}
 
 	/**
diff --git a/inc/integrations/PostMark.php b/inc/integrations/PostMark.php
index f9e154c..9882d6c 100644
--- a/inc/integrations/PostMark.php
+++ b/inc/integrations/PostMark.php
@@ -419,9 +419,7 @@
 
 		// Clean up whitespace
 		$text = preg_replace('/\s+/', ' ', $text);
-		$text = trim($text);
-
-		return $text;
+		return trim($text);
 	}
 
 	/**
diff --git a/inc/managers/AdminPages.php b/inc/managers/AdminPages.php
index a2147a0..1e42e8c 100644
--- a/inc/managers/AdminPages.php
+++ b/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
diff --git a/inc/managers/DashboardManager.php b/inc/managers/DashboardManager.php
index 67844ec..20fbfd8 100644
--- a/inc/managers/DashboardManager.php
+++ b/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;
 			}
 		}
 	}
diff --git a/inc/managers/IconsManager.php b/inc/managers/IconsManager.php
index d244340..4415bc7 100644
--- a/inc/managers/IconsManager.php
+++ b/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
diff --git a/inc/managers/LoginManager.php b/inc/managers/LoginManager.php
index 8b632ca..308a51c 100644
--- a/inc/managers/LoginManager.php
+++ b/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);
 	}
 
diff --git a/inc/managers/SEO/BreadcrumbManager.php b/inc/managers/SEO/BreadcrumbManager.php
index 32e91a6..ff9ff03 100644
--- a/inc/managers/SEO/BreadcrumbManager.php
+++ b/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));
 	}
 
 	/**
diff --git a/inc/managers/SEOMetaManager.php b/inc/managers/SEOMetaManager.php
index 2db1d41..44d1399 100644
--- a/inc/managers/SEOMetaManager.php
+++ b/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}";
     }
 
     /**
diff --git a/inc/managers/SchemaManager.php b/inc/managers/SchemaManager.php
index c659277..1ba6204 100644
--- a/inc/managers/SchemaManager.php
+++ b/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'
+				]
+			],
+		];
     }
 
     /**
diff --git a/inc/managers/UmamiMetrics.php b/inc/managers/UmamiMetrics.php
index 5330133..d88df16 100644
--- a/inc/managers/UmamiMetrics.php
+++ b/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']
+		];
     }
 
     /**
diff --git a/inc/managers/_setup.php b/inc/managers/_setup.php
index fa20385..84cad06 100644
--- a/inc/managers/_setup.php
+++ b/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')) {
diff --git a/inc/managers/queue/Executor.php b/inc/managers/queue/Executor.php
new file mode 100644
index 0000000..cb07357
--- /dev/null
+++ b/inc/managers/queue/Executor.php
@@ -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;
+}
diff --git a/inc/managers/queue/FilteredExecutor.php b/inc/managers/queue/FilteredExecutor.php
new file mode 100644
index 0000000..a2c8682
--- /dev/null
+++ b/inc/managers/queue/FilteredExecutor.php
@@ -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;
+	}
+}
diff --git a/inc/managers/queue/Locker.php b/inc/managers/queue/Locker.php
new file mode 100644
index 0000000..cd527ad
--- /dev/null
+++ b/inc/managers/queue/Locker.php
@@ -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();
+		}
+	}
+}
diff --git a/inc/managers/queue/Mergeable.php b/inc/managers/queue/Mergeable.php
new file mode 100644
index 0000000..5c7a9e0
--- /dev/null
+++ b/inc/managers/queue/Mergeable.php
@@ -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;
+}
diff --git a/inc/managers/queue/Metrics.php b/inc/managers/queue/Metrics.php
new file mode 100644
index 0000000..a3a7b7d
--- /dev/null
+++ b/inc/managers/queue/Metrics.php
@@ -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
+		);
+	}
+}
diff --git a/inc/managers/queue/Operation.php b/inc/managers/queue/Operation.php
new file mode 100644
index 0000000..0ba6e23
--- /dev/null
+++ b/inc/managers/queue/Operation.php
@@ -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;
+}
diff --git a/inc/managers/queue/Processor.php b/inc/managers/queue/Processor.php
new file mode 100644
index 0000000..0f02650
--- /dev/null
+++ b/inc/managers/queue/Processor.php
@@ -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);
+	}
+}
diff --git a/inc/managers/queue/Progress.php b/inc/managers/queue/Progress.php
new file mode 100644
index 0000000..929e6bd
--- /dev/null
+++ b/inc/managers/queue/Progress.php
@@ -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,
+		];
+	}
+}
diff --git a/inc/managers/queue/Queue.php b/inc/managers/queue/Queue.php
new file mode 100644
index 0000000..66ceed0
--- /dev/null
+++ b/inc/managers/queue/Queue.php
@@ -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();
+	}
+}
diff --git a/inc/managers/queue/Result.php b/inc/managers/queue/Result.php
new file mode 100644
index 0000000..8a78ab1
--- /dev/null
+++ b/inc/managers/queue/Result.php
@@ -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
+	) {}
+}
diff --git a/inc/managers/queue/Storage.php b/inc/managers/queue/Storage.php
new file mode 100644
index 0000000..2aa9ab6
--- /dev/null
+++ b/inc/managers/queue/Storage.php
@@ -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);
+	}
+}
diff --git a/inc/managers/queue/TypeConfig.php b/inc/managers/queue/TypeConfig.php
new file mode 100644
index 0000000..8c8dac4
--- /dev/null
+++ b/inc/managers/queue/TypeConfig.php
@@ -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
+	) {}
+}
diff --git a/inc/managers/queue/TypeRegistry.php b/inc/managers/queue/TypeRegistry.php
new file mode 100644
index 0000000..f695d75
--- /dev/null
+++ b/inc/managers/queue/TypeRegistry.php
@@ -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;
+	}
+}
diff --git a/inc/managers/queue/_setup.php b/inc/managers/queue/_setup.php
new file mode 100644
index 0000000..b19e37b
--- /dev/null
+++ b/inc/managers/queue/_setup.php
@@ -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';
diff --git a/inc/managers/queue/executors/ContentExecutor.php b/inc/managers/queue/executors/ContentExecutor.php
new file mode 100644
index 0000000..c53f4a7
--- /dev/null
+++ b/inc/managers/queue/executors/ContentExecutor.php
@@ -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;
+	}
+}
diff --git a/inc/managers/queue/executors/UploadExecutor.php b/inc/managers/queue/executors/UploadExecutor.php
new file mode 100644
index 0000000..10accf8
--- /dev/null
+++ b/inc/managers/queue/executors/UploadExecutor.php
@@ -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;
+	}
+}
diff --git a/inc/meta/MetaForm.php b/inc/meta/MetaForm.php
index 5225f9b..bfb93eb 100644
--- a/inc/meta/MetaForm.php
+++ b/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>
diff --git a/inc/meta/MetaManager.php b/inc/meta/MetaManager.php
index 89029d2..e3f5e1f 100644
--- a/inc/meta/MetaManager.php
+++ b/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 {
diff --git a/inc/registry/CheckCustomTables.php b/inc/registry/CheckCustomTables.php
index bf0f1bf..a54967e 100644
--- a/inc/registry/CheckCustomTables.php
+++ b/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)
         )"
         ];
     }
diff --git a/inc/registry/FieldRegistry.php b/inc/registry/FieldRegistry.php
index 996e99b..372a1f9 100644
--- a/inc/registry/FieldRegistry.php
+++ b/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);
 	}
 
 	/**
diff --git a/inc/rest/routes/AdminRoutes.php b/inc/rest/routes/AdminRoutes.php
index cf58a34..a5593cb 100644
--- a/inc/rest/routes/AdminRoutes.php
+++ b/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
+		];
     }
 
     /**
diff --git a/inc/rest/routes/ContentRoutes.php b/inc/rest/routes/ContentRoutes.php
index 43b1f9f..8ff8c78 100644
--- a/inc/rest/routes/ContentRoutes.php
+++ b/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
+		));
 	}
 
 	/**
diff --git a/inc/rest/routes/FavouritesRoutes.php b/inc/rest/routes/FavouritesRoutes.php
index 29112b5..a5de911 100644
--- a/inc/rest/routes/FavouritesRoutes.php
+++ b/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(),
diff --git a/inc/rest/routes/FeedRoutes.php b/inc/rest/routes/FeedRoutes.php
index de35a83..05a97a7 100644
--- a/inc/rest/routes/FeedRoutes.php
+++ b/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
diff --git a/inc/rest/routes/FormRoutes.php b/inc/rest/routes/FormRoutes.php
index f1efa39..1201a8e 100644
--- a/inc/rest/routes/FormRoutes.php
+++ b/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;
 	}
 
 	/**
diff --git a/inc/rest/routes/Invitations.php b/inc/rest/routes/Invitations.php
index 4022790..5c7638a 100644
--- a/inc/rest/routes/Invitations.php
+++ b/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
diff --git a/inc/rest/routes/LoginRoutes.php b/inc/rest/routes/LoginRoutes.php
index 226692b..9762e9f 100644
--- a/inc/rest/routes/LoginRoutes.php
+++ b/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);
diff --git a/inc/rest/routes/NewsRoutes.php b/inc/rest/routes/NewsRoutes.php
index a985b68..0811cec 100644
--- a/inc/rest/routes/NewsRoutes.php
+++ b/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);
     }
 
     /**
diff --git a/inc/rest/routes/QueueRoutes.php b/inc/rest/routes/QueueRoutes.php
index 21bce8f..824ec55 100644
--- a/inc/rest/routes/QueueRoutes.php
+++ b/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
diff --git a/inc/rest/routes/UploadRoutes.php b/inc/rest/routes/UploadRoutes.php
index 941207b..eb3c211 100644
--- a/inc/rest/routes/UploadRoutes.php
+++ b/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

--
Gitblit v1.10.0