From 42fa8304ddb811b0f725f245130f70c0f5e86a6c Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 04 Nov 2025 06:12:02 +0000
Subject: [PATCH] =Refactored LoginManager to be more extensible and configurable, as well as an AjaxRateLimiter

---
 assets/js/concise/FormController.js | 1168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 1,129 insertions(+), 39 deletions(-)

diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 6663484..9a3b075 100644
--- a/assets/js/concise/FormController.js
+++ b/assets/js/concise/FormController.js
@@ -28,6 +28,10 @@
 		this.specialFields = new Map();
 		this.dependencies = new Map();
 
+		// Validation (YOU ARE GREAT!)
+		this.validators = this.initValidators();
+		this.touchedFields = new Set();
+
 		// Auto-save configuration
 		this.autoSaveDefaults = {
 			delay: 3000, // 3 seconds
@@ -71,15 +75,26 @@
 		switch(event) {
 			case 'item-saved':
 				if (data.item.status === 'autosave') {
-					this.showFormStatus(data.item.formId, 'autosave');
+					// this.showFormStatus(data.item.formId, 'autosave');
 				}
 				break;
 			case 'data-loaded':
-
+				this.checkPendingForms();
 				break;
 		}
 	}
 
+	async checkPendingForms() {
+		let items = await this.store.query('status', 'draft');
+		items.forEach(item => {
+			let form = this.forms.get(item.formId);
+			if (form && form.element) {
+				form.element.querySelector('.restore-form').hidden = false;
+				new this.populateForm(form.element, item.data);
+			}
+		});
+
+	}
 	/**
 	 * Check for pending operations from previous session
 	 */
@@ -160,11 +175,11 @@
 	initListeners() {
 		// Only add if not already added
 		if (!this.globalHandlersAdded) {
-			document.addEventListener('submit', this.submitHandler);
 			document.addEventListener('click', this.clickHandler);
 			document.addEventListener('change', this.changeHandler);
 			document.addEventListener('focus', this.focusHandler, true);
 			document.addEventListener('blur', this.blurHandler, true);
+			document.addEventListener('input', this.inputHandler);
 			this.globalHandlersAdded = true;
 		}
 	}
@@ -176,13 +191,15 @@
 		const formId = formElement.dataset.formId || `form_${Date.now()}`;
 		formElement.dataset.formId = formId;
 
+		formElement.addEventListener('submit', this.submitHandler);
+
 		const formConfig = {
 			element: formElement,
 			id: formId,
 			options: {
-				autoSave: true,
+				autoSave: 'autosave' in formElement.dataset,
 				saveDelay: this.autoSaveDefaults.delay,
-				endpoint: formElement.dataset.save,
+				endpoint: formElement.dataset.save??'',
 				cache: true,
 				...options
 			},
@@ -199,7 +216,7 @@
 
 		// Check for pending data
 		if (this.store && formConfig.options.cache) {
-			const cached = this.store.getForm(formId);
+			const cached = this.store.get(formId);
 			if (cached && cached.formData) {
 				this.showPendingNotification(cached);
 			}
@@ -231,16 +248,132 @@
 
 		// Initialize tabs if present
 		if (window.jvbTabs && form.querySelector('nav.tabs')) {
-			new window.jvbTabs(form);
+			formConfig.tabs = new window.jvbTabs(form);
+			this.forms.set(formConfig.formId, formConfig);
+			this.initSteppedForm(formConfig.formId);
 		}
 
 		// Scan for existing selector fields
 		if (window.jvbSelector) {
-			window.jvbSelector.scanExistingFields();
+			window.jvbSelector.scanExistingFields(form);
 		}
 	}
 
 	/**
+	 * Initialize stepped form functionality
+	 */
+	initSteppedForm(formId) {
+		const formConfig = this.forms.get(formId);
+		const form = formConfig.element;
+		const tabsInstance = formConfig.tabs;
+
+		const sections = form.querySelectorAll('.tab-content');
+		const totalSteps = sections.length;
+		const progressBar = form.querySelector('.form-progress .fill');
+		const stepText = form.querySelector('.step-text .current');
+		const tabButtons = form.querySelectorAll('nav.tabs button');
+
+		// Update progress display
+		const updateProgress = (currentStep) => {
+			const progress = (currentStep / totalSteps) * 100;
+			if (progressBar) {
+				progressBar.style.width = progress + '%';
+			}
+			if (stepText) {
+				stepText.textContent = currentStep;
+			}
+
+			// Update tab states
+			tabButtons.forEach((btn, idx) => {
+				const stepNum = idx + 1;
+				btn.classList.remove('current', 'completed', 'pending');
+
+				if (stepNum < currentStep) {
+					btn.classList.add('completed');
+				} else if (stepNum === currentStep) {
+					btn.classList.add('current');
+				} else {
+					btn.classList.add('pending');
+				}
+			});
+		};
+
+		// Next/Previous button handling
+		form.addEventListener('click', (e) => {
+			const nextBtn = e.target.closest('[data-action="next-step"]');
+			const prevBtn = e.target.closest('[data-action="prev-step"]');
+
+			if (nextBtn) {
+				e.preventDefault();
+				const currentSection = nextBtn.closest('.tab-content');
+				const currentStep = parseInt(currentSection.dataset.step);
+				const nextSection = form.querySelector(`.tab-content[data-step="${currentStep + 1}"]`);
+
+				if (nextSection && this.validateStep(currentSection)) {
+					const nextTab = nextSection.dataset.tab;
+					tabsInstance.switchTab(nextTab, true);
+					updateProgress(currentStep + 1);
+
+					// Scroll to top of form
+					form.scrollIntoView({ behavior: 'smooth', block: 'start' });
+				}
+			}
+
+			if (prevBtn) {
+				e.preventDefault();
+				const currentSection = prevBtn.closest('.tab-content');
+				const currentStep = parseInt(currentSection.dataset.step);
+				const prevSection = form.querySelector(`.tab-content[data-step="${currentStep - 1}"]`);
+
+				if (prevSection) {
+					const prevTab = prevSection.dataset.tab;
+					tabsInstance.switchTab(prevTab, true);
+					updateProgress(currentStep - 1);
+
+					// Scroll to top of form
+					form.scrollIntoView({ behavior: 'smooth', block: 'start' });
+				}
+			}
+		});
+
+		// Update progress when tabs are clicked directly
+		const originalSwitchTab = tabsInstance.switchTab.bind(tabsInstance);
+		tabsInstance.switchTab = (tab, updateHistory) => {
+			originalSwitchTab(tab, updateHistory);
+			const activeSection = form.querySelector(`.tab-content[data-tab="${tab}"]`);
+			if (activeSection) {
+				const step = parseInt(activeSection.dataset.step);
+				updateProgress(step);
+			}
+		};
+
+		// Initialize progress
+		updateProgress(1);
+	}
+
+	/**
+	 * Validate current step before allowing progression
+	 * Can be enhanced with custom validation rules
+	 */
+	validateStep(section) {
+		const fields = section.querySelectorAll('.field');
+		let allValid = true;
+
+		fields.forEach(fieldWrapper => {
+			const input = fieldWrapper.querySelector('input, textarea, select');
+			if (input && !input.closest('[hidden]')) {
+				const isValid = this.validateField(input, fieldWrapper);
+				if (!isValid) {
+					allValid = false;
+				}
+			}
+		});
+
+		return allValid;
+	}
+
+
+	/**
 	 * Initialize Quill editors
 	 */
 	initQuillEditors(form) {
@@ -522,16 +655,12 @@
 		if (this.subscribers.size > 0 ){
 			const form = event.target;
 			if (!form.dataset.formId) return;
-			this.store.delete(form.dataset.formId);
-
 			event.preventDefault();
 
 			const formConfig = this.forms.get(form.dataset.formId);
 			if (!formConfig) return;
 
 			const formData = this.collectFormData(form);
-
-			event.preventDefault();
 			this.notify('form-submit', {
 				formId: formConfig.id,
 				data: formData,
@@ -544,9 +673,24 @@
 		if (window.targetCheck(e, 'div.quantity')) {
 			let container = window.targetCheck(e, 'div.quantity');
 			this.handleNumberClick(e, container.querySelector('input'));
+		} else if (window.targetCheck(e, '[data-action]')) {
+			let action = window.targetCheck(e, '[data-action]');
+			action = action.dataset.action;
+			switch (action) {
+				case 'clear-form':
+					let form = e.target.closest('form');
+					this.store.delete(form.dataset.formId);
+					form?.reset();
+					e.target.closest('.restore-form').hidden = true;
+					break;
+				case 'dismiss-restore':
+					e.target.closest('.restore-form').hidden = true;
+					break;
+			}
 		}
 	}
 
+
 	handleNumberClick(e, input) {
 		let change = 0;
 
@@ -602,6 +746,9 @@
 	}
 
 	handleChange(event) {
+		if (event.target.closest('[data-ignore]')) {
+			return;
+		}
 		if (this.subscribers.size > 0) {
 			const target = event.target;
 			const form = target.form || target.closest('form');
@@ -635,23 +782,458 @@
 		}
 	}
 
-	handleBlur(event) {
-		const target = event.target;
+	handleBlur(e) {
+		if (e.target.closest('[data-ignore]')) {
+			return;
+		}
+		const target = e.target;
 		const form = target.form || target.closest('form');
 
 		if (!form) return;
 
-		const formConfig = this.forms?.get(form.dataset.formId);
-		if (formConfig && formConfig.options.autoSave && !form.dataset.noautosave) {
-			// Shorter delay on blur
-			this.scheduleSave(formConfig, {
-				type: 'blur',
-				fieldName: target.name,
-				delay: 1500
-			});
+
+		const input = e.target.closest('input, textarea, select');
+		if (input) {
+			const fieldWrapper = this.findFieldWrapper(input);
+			if (fieldWrapper) {
+				// Mark as touched and validate
+				const fieldName = fieldWrapper.dataset.field;
+				if (fieldName) {
+					if (this.shouldDebounce(input)) {
+						window.debouncer.cancel(`validate_${fieldName}`);
+					}
+					this.touchedFields.add(fieldName);
+				}
+				this.validateField(input, fieldWrapper);
+			}
+			const formConfig = this.forms?.get(form.dataset.formId);
+			if (formConfig && formConfig.options.autoSave && !form.dataset.noautosave) {
+				// Shorter delay on blur
+				this.scheduleSave(formConfig, {
+					type: 'blur',
+					fieldName: target.name,
+					delay: 1500
+				});
+			}
 		}
 	}
 
+	handleInput(e) {
+		if (e.target.closest('[data-ignore]') || ! e.target.closest('form')) {
+			return;
+		}
+		const input = e.target.closest('input, textarea, select');
+		if (!input) return;
+
+		let form = input.closest('form');
+		this.showFormStatus(form.dataset.formId, 'pending');
+
+		const fieldWrapper = this.findFieldWrapper(input);
+		if (!fieldWrapper) return;
+
+		const fieldName = fieldWrapper.dataset.field;
+		if (fieldName) {
+			this.touchedFields.add(fieldName);
+		}
+
+		if (this.shouldDebounce(input)){
+			window.debouncer.schedule(
+				`validate_${fieldName}`,
+				(input, fieldWrapper) => this.validateField.bind(this),
+				500
+			)
+		}
+	}
+
+	/***************************************************************
+	 FORM VALIDATION
+	***************************************************************/
+	/**
+	 * Initialize validation rules
+	 */
+	initValidators() {
+		return {
+			email: {
+				pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
+				message: 'Please enter a valid email address'
+			},
+			url: {
+				pattern: /^https?:\/\/.+\..+/,
+				message: 'Please enter a valid URL starting with http:// or https://'
+			},
+			phone: {
+				pattern: /^[\d\s\-\+\(\)\.]+$/,
+				message: 'Please enter a valid phone number'
+			},
+			number: {
+				test: (value, fieldWrapper) => {
+					const num = parseFloat(value);
+					if (isNaN(num)) return 'Please enter a valid number';
+
+					const min = fieldWrapper.dataset.min;
+					const max = fieldWrapper.dataset.max;
+
+					if (min !== undefined && num < parseFloat(min)) {
+						return `Value must be at least ${min}`;
+					}
+					if (max !== undefined && num > parseFloat(max)) {
+						return `Value must be at most ${max}`;
+					}
+					return true;
+				}
+			},
+			text: {
+				test: (value, fieldWrapper) => {
+					const minLength = fieldWrapper.dataset.minlength;
+					const maxLength = fieldWrapper.dataset.maxlength;
+
+					if (minLength && value.length < parseInt(minLength)) {
+						return `Must be at least ${minLength} characters`;
+					}
+					if (maxLength && value.length > parseInt(maxLength)) {
+						return `Must be no more than ${maxLength} characters`;
+					}
+					return true;
+				}
+			}
+		};
+	}
+	/**
+	 * Find the field wrapper (handles both simple and complex fields)
+	 */
+	findFieldWrapper(input) {
+		// Try to find the closest .field wrapper
+		let wrapper = input.closest('.field');
+
+		// If we're in a repeater row, make sure we get the right field wrapper
+		if (!wrapper) {
+			wrapper = input.closest('[data-field]');
+		}
+
+		return wrapper;
+	}
+
+	/**
+	 * Check if input should be debounced
+	 */
+	shouldDebounce(input) {
+		const debounceTypes = ['text', 'email', 'url', 'tel', 'search'];
+		return debounceTypes.includes(input.type) || input.tagName === 'TEXTAREA';
+	}
+
+	/**
+	 * Validate a single field
+	 */
+	validateField(input, fieldWrapper) {
+		const value = this.getFieldValue(input);
+		const fieldName = fieldWrapper.dataset.field;
+
+		// Skip validation if field hasn't been touched yet (unless it's required)
+		if (!this.touchedFields.has(fieldName) && !input.required) {
+			return true;
+		}
+
+		// Skip validation if field is empty and not required
+		if (!value && !input.required) {
+			this.clearValidation(fieldWrapper);
+			return true;
+		}
+
+		// Check required
+		if (input.required && !value) {
+			this.showError(fieldWrapper, 'This field is required');
+			return false;
+		}
+
+		// Check HTML5 validity first
+		if (input.checkValidity && !input.checkValidity()) {
+			this.showError(fieldWrapper, input.validationMessage);
+			return false;
+		}
+
+		// Custom pattern validation from data attribute
+		const pattern = fieldWrapper.dataset.pattern;
+		if (pattern && value) {
+			const regex = new RegExp(pattern);
+			if (!regex.test(value)) {
+				const message = fieldWrapper.dataset.validationMessage || 'Invalid format';
+				this.showError(fieldWrapper, message);
+				return false;
+			}
+		}
+
+		// Type-specific validation
+		const validateType = fieldWrapper.dataset.validate || input.type;
+		if (validateType && this.validators[validateType]) {
+			const validator = this.validators[validateType];
+
+			if (validator.pattern && !validator.pattern.test(value)) {
+				this.showError(fieldWrapper, validator.message);
+				return false;
+			}
+
+			if (validator.test) {
+				const result = validator.test(value, fieldWrapper);
+				if (result !== true) {
+					this.showError(fieldWrapper, result);
+					return false;
+				}
+			}
+		}
+
+		// All validations passed
+		this.showSuccess(fieldWrapper);
+		return true;
+	}
+
+	/**
+	 * Get field value (handles different input types)
+	 */
+	getFieldValue(input) {
+		if (!input) return '';
+
+		if (input.type === 'checkbox') {
+			return input.checked ? input.value || '1' : '';
+		} else if (input.type === 'radio') {
+			const checked = input.form?.querySelector(`[name="${input.name}"]:checked`);
+			return checked ? checked.value : '';
+		} else if (input.type === 'select-multiple') {
+			return Array.from(input.selectedOptions).map(o => o.value);
+		}
+
+		return input.value?.trim() || '';
+	}
+
+	/**
+	 * Show success state (green checkmark)
+	 */
+	showSuccess(fieldWrapper) {
+		if (!fieldWrapper) return;
+
+		// Find validation elements (they might be in field-input-wrapper or field-content)
+		const success = fieldWrapper.querySelector('.validation-icon.success');
+		const error = fieldWrapper.querySelector('.validation-icon.error');
+		const message = fieldWrapper.querySelector('.validation-message');
+		const input = fieldWrapper.querySelector('input, textarea, select');
+
+		// Remove error state
+		fieldWrapper.classList.remove('has-error');
+		input?.classList.remove('error');
+
+		// Add success state
+		fieldWrapper.classList.add('has-success');
+
+		// Show checkmark (if element exists)
+		if (success) {
+			success.hidden = false;
+		}
+		if (error) {
+			error.hidden = true;
+		}
+
+		// Hide error message
+		if (message) {
+			message.hidden = true;
+			message.textContent = '';
+		}
+	}
+
+	/**
+	 * Show error state (red message below field)
+	 */
+	showError(fieldWrapper, errorMessage) {
+		if (!fieldWrapper) return;
+
+		const success = fieldWrapper.querySelector('.validation-icon.success');
+		const error = fieldWrapper.querySelector('.validation-icon.error');
+		const message = fieldWrapper.querySelector('.validation-message');
+		const input = fieldWrapper.querySelector('input, textarea, select');
+
+		// Remove success state
+		fieldWrapper.classList.remove('has-success');
+
+		// Add error state
+		fieldWrapper.classList.add('has-error');
+		input?.classList.add('error');
+
+		// Hide checkmark (if element exists)
+		if (success) {
+			success.hidden = true;
+		}
+		//show x
+		if (error) {
+			error.hidden = false;
+		}
+
+		// Show error message
+		if (message) {
+			message.hidden = false;
+			message.textContent = errorMessage;
+		}
+	}
+
+	/**
+	 * Clear validation state
+	 */
+	clearValidation(fieldWrapper) {
+		if (!fieldWrapper) return;
+
+		const icon = fieldWrapper.querySelector('.validation-icon');
+		const message = fieldWrapper.querySelector('.validation-message');
+		const input = fieldWrapper.querySelector('input, textarea, select');
+
+		fieldWrapper.classList.remove('has-error', 'has-success');
+		input?.classList.remove('error');
+
+		if (icon) {
+			icon.hidden = true;
+		}
+
+		if (message) {
+			message.hidden = true;
+			message.textContent = '';
+		}
+	}
+
+	/**
+	 * Validate all fields in a container (useful for step validation)
+	 */
+	validateAllFields(container) {
+		if (!container) return true;
+
+		const fields = container.querySelectorAll('.field:not([hidden])');
+		let allValid = true;
+
+		fields.forEach(fieldWrapper => {
+			// Skip complex parent wrappers (repeater, group) - validate their children
+			if (this.isComplexFieldWrapper(fieldWrapper)) {
+				return;
+			}
+
+			const input = fieldWrapper.querySelector('input:not([type="hidden"]), textarea, select');
+			if (input && !input.closest('[hidden]')) {
+				// Mark as touched so validation will run
+				const fieldName = fieldWrapper.dataset.field;
+				if (fieldName) {
+					this.touchedFields.add(fieldName);
+				}
+
+				const isValid = this.validateField(input, fieldWrapper);
+				if (!isValid) {
+					allValid = false;
+
+					// Scroll to first error
+					if (allValid === false) {
+						input.scrollIntoView({ behavior: 'smooth', block: 'center' });
+						input.focus();
+					}
+				}
+			}
+		});
+
+		return allValid;
+	}
+
+	/**
+	 * Check if field wrapper is a complex type (repeater, group, etc.)
+	 */
+	isComplexFieldWrapper(fieldWrapper) {
+		return fieldWrapper.classList.contains('repeater') ||
+			fieldWrapper.classList.contains('group') ||
+			fieldWrapper.classList.contains('upload');
+	}
+
+	/**
+	 * Special validation for repeater fields
+	 */
+	attachRepeaterValidation(form) {
+		// When a repeater row is added, attach validation to its fields
+		form.addEventListener('click', (e) => {
+			if (e.target.closest('.add-repeater-row')) {
+				// Wait for the DOM to update
+				setTimeout(() => {
+					const repeaterRows = form.querySelectorAll('.repeater-row');
+					repeaterRows.forEach(row => {
+						const inputs = row.querySelectorAll('input, textarea, select');
+						inputs.forEach(input => {
+							const fieldWrapper = this.findFieldWrapper(input);
+							if (fieldWrapper) {
+								// Validation listeners are already attached via event delegation
+								// Just clear any existing validation state for new rows
+								this.clearValidation(fieldWrapper);
+							}
+						});
+					});
+				}, 100);
+			}
+		});
+	}
+
+	/**
+	 * Special validation for group fields
+	 */
+	attachGroupValidation(form) {
+		// Group fields might have conditional fields
+		// Validate when conditions change
+		form.addEventListener('change', (e) => {
+			const changedInput = e.target.closest('input, select');
+			if (!changedInput) return;
+
+			// Check if this change affects conditional fields
+			const fieldName = changedInput.name;
+			if (!fieldName) return;
+
+			// Find any conditional fields that depend on this field
+			const conditionalFields = form.querySelectorAll(`[data-show-if*="${fieldName}"]`);
+			conditionalFields.forEach(conditionalField => {
+				// Clear validation for hidden fields
+				if (conditionalField.hidden) {
+					this.clearValidation(conditionalField);
+				}
+			});
+		});
+	}
+
+	/**
+	 * Reset validation state for a form
+	 */
+	resetForm(form) {
+		if (!form) return;
+
+		// Clear all touched fields
+		this.touchedFields.clear();
+
+		// Clear all validation states
+		const fields = form.querySelectorAll('.field');
+		fields.forEach(fieldWrapper => {
+			this.clearValidation(fieldWrapper);
+		});
+	}
+
+	/**
+	 * Get validation errors for a form
+	 */
+	getFormErrors(form) {
+		const errors = {};
+		const fields = form.querySelectorAll('.field.has-error');
+
+		fields.forEach(fieldWrapper => {
+			const fieldName = fieldWrapper.dataset.field;
+			const message = fieldWrapper.querySelector('.validation-message');
+			if (fieldName && message) {
+				errors[fieldName] = message.textContent;
+			}
+		});
+
+		return errors;
+	}
+
+	/**
+	 * Add custom validator
+	 */
+	addValidator(name, validator) {
+		this.validators[name] = validator;
+	}
 	/* ========== Auto-save functionality ========== */
 	/**
 	 * Get appropriate delay based on field type and context
@@ -671,7 +1253,7 @@
 		return this.autoSaveDefaults.delay;
 	}
 	scheduleSave(formConfig, delay = this.autoSaveDefaults.delay) {
-		document.addEventListener('input', this.handleInput, {passive: true});
+		document.addEventListener('input', this.saveCheck, {passive: true});
 		const saveKey = `autosave_${formConfig.id}`;
 
 		this.debouncer.schedule(
@@ -682,24 +1264,27 @@
 	}
 
 	//Extend delay if user is currently typing
-	handleInput(e) {
+	saveCheck(e) {
 		let form = e.target.closest('form[data-id]');
 		if (!form) {
 			return;
 		}
+
 		this.scheduleSave(this.forms.get(form.dataset.id));
 	}
 
 	async autosave(formConfig) {
 		const formData = this.collectFormData(formConfig.element);
 
+		this.showFormStatus(formConfig.id, 'saving');
 		await this.store.save({
 			formId: formConfig.id,
 			data: formData,
 			status: 'draft',
 			timestamp: Date.now()
+		}).then(()=> {
+			this.showFormStatus(formConfig.id, 'autosaved');
 		});
-		this.showFormStatus(formConfig.id, 'saved');
 
 		// Get only changed fields
 		const changes = this.getChangedFields(formConfig.data, formData);
@@ -742,30 +1327,48 @@
 		return Object.keys(changes).length > 0;
 	}
 
-	showFormStatus(form, status) {
+	showFormStatus(formID, status) {
 		// Remove existing status
-		const existingStatus = form.querySelector('.form-status');
-		if (existingStatus) {
-			existingStatus.remove();
-		}
+		let form = this.forms.get(formID);
+
+		console.log('Setting status: ', status);
 
 		// Add new status
-		const statusElement = document.createElement('div');
-		statusElement.className = `form-status status-${status}`;
+		const statusWrap = form.element.querySelector('.fstatus');
+		statusWrap.hidden = false;
+		const statusElement = statusWrap.querySelector('.message');
+		statusElement.textContent = '';
+		statusWrap.querySelector('.icon')?.remove();
 
 		const messages = {
 			'saving': 'Saving changes...',
-			'saved': 'Changes saved',
-			'error': 'Failed to save 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',
+			'error': 'Failed to save changes. Refresh and try again?',
 			'offline': 'Changes will be saved when online'
 		};
+		const icons = {
+			'autosaved': 'check',
+			'submitted': 'check',
+			'error': 'close',
+			'offline': 'cloud-slash',
+			'pending': 'exclamation-mark'
+		}
 
+		let icon = window.getIcon(icons[status]);
+		if (icon) {
+			statusWrap.prepend(icon);
+		}
+		console.log(status, messages[status]);
+		console.log(status, icons[status]);
 		statusElement.textContent = messages[status] || status;
-		form.insertBefore(statusElement, form.firstChild);
+		statusWrap.classList.toggle('loading', ['uploading', 'saving'].includes(status));
 
 		// Auto-hide success messages
-		if (status === 'saved') {
-			setTimeout(() => statusElement.remove(), 3000);
+		if (status === 'submitted') {
+			setTimeout(() => statusWrap.hidden = true, 3000);
 		}
 	}
 
@@ -810,7 +1413,7 @@
 		if (key.includes('|')) return this.processTableField;
 		if (key.includes('::')) return this.processGroupField;
 		if (key.includes(':')) return this.processRepeaterField;
-		if (key.includes('[')) return this.processLocationField;
+		if (/\[[^\]]+\]/.test(key)) return this.processLocationField;
 		return this.processRegularField;
 	}
 
@@ -944,6 +1547,7 @@
 
 	processRegularField(key, value, data, repeaterData, postData, form) {
 		//handle array values (like checkboxes/selects)
+		key = key.replace('[]','');
 		if (data[key]) {
 			if (!Array.isArray(data[key])) {
 				data[key] = [data[key]];
@@ -973,6 +1577,485 @@
 		return window.getDifferences?.map(original, current) || {};
 	}
 
+	/*******************************************************
+	 Field Summary
+	*******************************************************/
+	/**
+	 * Show a comprehensive summary of form submission
+	 */
+	showSummary(formId, clear = 'form') {
+		const formConfig = this.forms.get(formId);
+		if (!formConfig) return;
+
+		const form = formConfig.element || document.querySelector(`[data-form-id="${formId}"]`);
+		const summary = window.getTemplate('formSummary');
+
+		const [
+			title,
+			resultWrapper,
+			resultTemplate
+		] = [
+			summary.querySelector('h2'),
+			summary.querySelector('.summary'),
+			summary.querySelector('.result')
+		];
+
+		// Fields to skip in summary
+		const skipFields = ['sendAll', ...this.ignore];
+
+		// Process each field in the form data
+		for (const [key, value] of Object.entries(formConfig.data)) {
+			// Skip ignored fields and empty values
+			if (skipFields.includes(key) || this.isEmptyValue(value)) {
+				continue;
+			}
+
+			// Get field info from form
+			const fieldInfo = this.getFieldInfo(form, key);
+			if (!fieldInfo.label) continue; // Skip if no label found
+
+			// Create result element
+			const resultEl = this.createResultElement(
+				resultTemplate,
+				fieldInfo,
+				value,
+				form
+			);
+
+			if (resultEl) {
+				resultWrapper.appendChild(resultEl);
+			}
+		}
+
+		// Remove template
+		resultTemplate.remove();
+
+		// Insert summary and hide form
+		clear = (clear !== 'form') ? form.closest(clear)??form : form;
+
+		clear.after(summary);
+		window.fade(clear, false);
+	}
+
+	/**
+	 * Check if a value is empty (null, undefined, empty string, empty array, empty object)
+	 */
+	isEmptyValue(value) {
+		if (value === null || value === undefined || value === '') {
+			return true;
+		}
+		if (Array.isArray(value) && value.length === 0) {
+			return true;
+		}
+		if (typeof value === 'object' && Object.keys(value).length === 0) {
+			return true;
+		}
+		return false;
+	}
+
+	/**
+	 * Get field information (label, type, etc.) from the form
+	 * Handles special field name patterns ([], ::, :, etc.)
+	 */
+	getFieldInfo(form, fieldName) {
+		// Try to find label by 'for' attribute (exact match)
+		let label = form.querySelector(`label[for="${fieldName}"]`);
+		let input = null;
+		let fieldWrapper = null;
+
+		// Try to find the input field - check multiple patterns
+		if (!input) {
+			// Try exact match first
+			input = form.querySelector(`[name="${fieldName}"]`);
+		}
+
+		if (!input) {
+			// Try with [] suffix (for checkboxes, multi-selects)
+			input = form.querySelector(`[name="${fieldName}[]"]`);
+		}
+
+		if (!input) {
+			// Try as fieldset legend (for checkbox/radio groups)
+			const fieldset = form.querySelector(`fieldset[data-field="${fieldName}"]`);
+			if (fieldset) {
+				label = fieldset.querySelector('legend');
+				input = fieldset.querySelector('input, select, textarea');
+			}
+		}
+
+		// Get label from input if not found yet
+		if (!label && input) {
+			// Try closest field wrapper first
+			const field = input.closest('.field, fieldset');
+			if (field) {
+				label = field.querySelector('label, legend');
+			}
+		}
+
+		// Get field wrapper - always use base name (no special characters)
+		fieldWrapper = form.querySelector(`.field[data-field="${fieldName}"], fieldset[data-field="${fieldName}"]`);
+
+		// Determine field type
+		let fieldType = 'text';
+		if (fieldWrapper?.dataset.type) {
+			fieldType = fieldWrapper.dataset.type;
+		} else if (input) {
+			// Infer from input type
+			if (input.type === 'checkbox' && input.name.endsWith('[]')) {
+				fieldType = 'checkbox'; // checkbox group
+			} else if (input.type === 'checkbox') {
+				fieldType = 'true_false'; // single checkbox
+			} else if (input.tagName === 'SELECT' && input.multiple) {
+				fieldType = 'select'; // multi-select
+			} else {
+				fieldType = input.type || 'text';
+			}
+		}
+
+		return {
+			label: label?.textContent.replace('*', '').trim() || null,
+			type: fieldType,
+			wrapper: fieldWrapper,
+			input: input
+		};
+	}
+
+	/**
+	 * Create a result element for a field
+	 */
+	createResultElement(template, fieldInfo, value, form) {
+		const resultEl = template.cloneNode(true);
+		const titleEl = resultEl.querySelector('h4');
+		const valueEl = resultEl.querySelector('p');
+
+		// Set label
+		titleEl.textContent = fieldInfo.label;
+
+		// Format value based on field type
+		const formattedValue = this.formatFieldValue(value, fieldInfo.type, form);
+
+		// Determine how to set the value
+		if (this.isHtmlContent(formattedValue)) {
+			// HTML content - use innerHTML
+			valueEl.innerHTML = formattedValue;
+		} else {
+			// Plain text - use textContent for safety
+			valueEl.textContent = formattedValue;
+		}
+
+		return resultEl;
+	}
+
+	/**
+	 * Check if content should be treated as HTML
+	 */
+	isHtmlContent(content) {
+		return typeof content === 'string' && (
+			content.includes('<br>') ||
+			content.includes('<p>') ||
+			content.includes('<ul>') ||
+			content.includes('<ol>') ||
+			content.includes('<a ') ||
+			content.includes('<strong>') ||
+			content.includes('<em>') ||
+			content.includes('<div')
+		);
+	}
+
+	/**
+	 * Format field value based on type
+	 */
+	formatFieldValue(value, type, form) {
+		switch (type) {
+			case 'textarea':
+			case 'wysiwyg':
+				// Handle rich text - check if it's actual HTML content from Quill
+				return this.formatTextareaValue(value, type);
+
+			case 'true_false':
+				return (value === '1' || value === 1 || value === true) ? 'Yes' : 'No';
+			case 'checkbox':
+				// Handle both single checkbox and checkbox groups
+				if (Array.isArray(value)) {
+					return this.formatArrayValue(value);
+				}
+				return (value === '1' || value === 1 || value === true) ? 'Yes' : 'No';
+
+			case 'select':
+				// Handle both single and multi-select
+				if (Array.isArray(value)) {
+					return this.formatArrayValue(value);
+				}
+				// Get label from select option
+				return this.getSelectLabel(value, form, type);
+			case 'date':
+			case 'datetime':
+			case 'time':
+				return window.formatDate ? window.formatDate(value) : value;
+
+			case 'radio':
+				// Get label from select option or radio label
+				return this.getSelectLabel(value, form, type);
+
+			case 'repeater':
+				return this.formatRepeaterValue(value);
+
+			case 'group':
+				return this.formatGroupValue(value);
+
+			case 'location':
+				return this.formatLocationValue(value);
+
+			case 'file':
+			case 'image':
+				return this.formatFileValue(value);
+
+			case 'number':
+				return this.formatNumber(value);
+
+			case 'email':
+				return `<a href="mailto:${value}">${value}</a>`;
+
+			case 'url':
+				return `<a href="${value}" target="_blank" rel="noopener">${value}</a>`;
+
+			case 'phone':
+				return `<a href="tel:${value.replace(/\D/g, '')}">${value}</a>`;
+
+			default:
+				// Handle arrays (multi-select, checkbox group)
+				if (Array.isArray(value)) {
+					return this.formatArrayValue(value);
+				}
+				return value;
+		}
+	}
+
+	/**
+	 * Format repeater field value
+	 */
+	formatRepeaterValue(rows) {
+		if (!Array.isArray(rows) || rows.length === 0) {
+			return '<em>No entries</em>';
+		}
+
+		let html = '<div class="repeater-summary">';
+		rows.forEach((row, index) => {
+			html += `<div class="repeater-row">`;
+			html += `<strong>Entry ${index + 1}:</strong><ul>`;
+			for (const [key, value] of Object.entries(row)) {
+				if (!this.isEmptyValue(value)) {
+					const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
+					html += `<li><strong>${label}:</strong> ${value}</li>`;
+				}
+			}
+			html += `</ul></div>`;
+		});
+		html += '</div>';
+		return html;
+	}
+
+	/**
+	 * Format group field value
+	 */
+	formatGroupValue(groupData) {
+		if (typeof groupData !== 'object' || Object.keys(groupData).length === 0) {
+			return '<em>No data</em>';
+		}
+
+		let html = '<div class="group-summary"><ul>';
+		for (const [key, value] of Object.entries(groupData)) {
+			if (!this.isEmptyValue(value)) {
+				const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
+				// Handle nested groups
+				if (typeof value === 'object' && !Array.isArray(value)) {
+					html += `<li><strong>${label}:</strong> ${this.formatGroupValue(value)}</li>`;
+				} else {
+					html += `<li><strong>${label}:</strong> ${value}</li>`;
+				}
+			}
+		}
+		html += '</ul></div>';
+		return html;
+	}
+
+	/**
+	 * Format location field value
+	 */
+	formatLocationValue(location) {
+		if (typeof location !== 'object') return location;
+
+		const parts = [];
+		const fields = ['address', 'city', 'state', 'zip', 'country'];
+
+		fields.forEach(field => {
+			if (location[field]) {
+				parts.push(location[field]);
+			}
+		});
+
+		return parts.join(', ');
+	}
+
+	/**
+	 * Format file/image value
+	 */
+	formatFileValue(value) {
+		if (typeof value === 'string') {
+			// Single file - could be URL or filename
+			if (value.startsWith('http')) {
+				return `<a href="${value}" target="_blank">View file</a>`;
+			}
+			return value;
+		}
+
+		if (Array.isArray(value)) {
+			return value.map(file => {
+				if (typeof file === 'string') {
+					return `<a href="${file}" target="_blank">View file</a>`;
+				}
+				return file.name || 'File';
+			}).join(', ');
+		}
+
+		return 'File uploaded';
+	}
+
+	/**
+	 * Format number with proper locale formatting
+	 */
+	formatNumber(value) {
+		const num = parseFloat(value);
+		if (isNaN(num)) return value;
+
+		// Check if it's likely currency (has 2 decimal places)
+		if (value.toString().includes('.') && value.toString().split('.')[1].length === 2) {
+			return new Intl.NumberFormat('en-CA', {
+				style: 'currency',
+				currency: 'USD'
+			}).format(num);
+		}
+
+		return new Intl.NumberFormat('en-CA').format(num);
+	}
+
+	/**
+	 * Format array values (checkboxes, multi-select)
+	 */
+	/**
+	 * Format array values (checkboxes, multi-select)
+	 */
+	formatArrayValue(arr, form = null, fieldInfo = null) {
+		if (arr.length === 0) return '<em>None selected</em>';
+
+		// If we have field info, try to get proper labels
+		if (form && fieldInfo && fieldInfo.input) {
+			const labeled = arr.map(val => {
+				return this.getSelectLabel(val, form, fieldInfo.type);
+			});
+			return '<ul><li>' + labeled.join('</li><li>') + '</li></ul>';
+		}
+
+		// Fallback to raw values
+		return '<ul><li>' + arr.join('</li><li>') + '</li></ul>';
+	}
+
+	/**
+	 * Get label for select/radio option
+	 */
+	/**
+	 * Get label for select/radio/checkbox option
+	 */
+	getSelectLabel(value, form, type) {
+		if (type === 'select') {
+			const option = form.querySelector(`option[value="${value}"]`);
+			return option?.textContent || value;
+		}
+
+		if (type === 'radio') {
+			const radio = form.querySelector(`input[type="radio"][value="${value}"]`);
+			const label = radio?.nextElementSibling;
+			return label?.textContent || value;
+		}
+
+		if (type === 'checkbox') {
+			// Try to find the checkbox with this value
+			const checkbox = form.querySelector(`input[type="checkbox"][value="${value}"]`);
+			if (checkbox) {
+				// Look for associated label
+				const label = form.querySelector(`label[for="${checkbox.id}"]`);
+				if (label) {
+					return label.textContent.trim();
+				}
+				// Try next sibling
+				const nextLabel = checkbox.nextElementSibling;
+				if (nextLabel?.tagName === 'LABEL') {
+					return nextLabel.textContent.trim();
+				}
+			}
+		}
+
+		return value;
+	}
+
+	/**
+	 * Format textarea value - handles both rich text and plain text
+	 */
+	formatTextareaValue(value, type) {
+		if (!value) return '<em>Empty</em>';
+
+		// If it's explicitly a wysiwyg type or contains HTML tags, use as-is
+		if (type === 'wysiwyg' || this.containsHtml(value)) {
+			// Quill content already has proper HTML structure
+			return value;
+		}
+
+		// Plain textarea - preserve formatting
+		return this.formatPlainText(value);
+	}
+
+	/**
+	 * Check if string contains HTML content (more reliable than just checking for '<')
+	 */
+	containsHtml(str) {
+		// Check for common HTML tags that Quill uses
+		const htmlPattern = /<(p|strong|em|u|s|ol|ul|li|blockquote|h[1-6]|a|br|span)\b[^>]*>/i;
+		return htmlPattern.test(str);
+	}
+
+	/**
+	 * Format plain text content - preserves whitespace and converts newlines
+	 */
+	formatPlainText(text) {
+		if (!text) return '';
+
+		// First, escape any HTML entities that might be in the text
+		text = text
+			.replace(/&/g, '&amp;')
+			.replace(/</g, '&lt;')
+			.replace(/>/g, '&gt;');
+
+		// Convert double newlines to paragraphs for better readability
+		const paragraphs = text.split(/\n\n+/);
+
+		if (paragraphs.length > 1) {
+			// Multiple paragraphs
+			return paragraphs
+				.map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`)
+				.join('');
+		}
+
+		// Single paragraph - just convert newlines to breaks
+		return text.replace(/\n/g, '<br>');
+	}
+
+	/**
+	 * Convert newlines to <br> tags (kept for backwards compatibility)
+	 */
+	nl2br(text) {
+		return this.formatPlainText(text);
+	}
+
 	/**
 	 * Event system
 	 */
@@ -1010,11 +2093,17 @@
 	destroy() {
 		// Remove global handlers
 		if (this.globalHandlersAdded) {
-			document.removeEventListener('submit', this.submitHandler);
 			document.removeEventListener('change', this.changeHandler);
 			document.removeEventListener('focus', this.focusHandler, true);
 			document.removeEventListener('blur', this.blurHandler, true);
+			document.removeEventListener('input', this.inputHandler, true);
 		}
+		this.forms.forEach((formConfig) => {
+			let element = formConfig.element;
+			if (element) {
+				element.removeEventListener('submit', this.submitHandler);
+			}
+		});
 
 		// Clear maps
 		this.specialFields.clear();
@@ -1029,4 +2118,5 @@
 
 document.addEventListener('DOMContentLoaded', () => {
 	window.jvbForm = FormController;
+	console.log('FormController in window');
 });

--
Gitblit v1.10.0