/** * MorphGuard Smart Form Validator * * An AI-assisted form validation system that provides intelligent feedback, * contextual suggestions, and adaptive validation based on user input patterns. * * Features: * - Real-time validation with adaptive error messages * - Smart suggestions for common input errors * - Context-aware validation rules * - Machine learning for improved suggestions over time * - Automatic error recovery suggestions * - User behavior analysis for validation adjustment */ class SmartValidator { constructor(options = {}) { // Default options this.options = { enableSuggestions: true, realTimeValidation: true, adaptiveMessages: true, learnFromCorrections: true, contextualHelp: true, validationDelay: 500, // ms delay for real-time validation showValidFeedback: true, errorClass: 'smart-error', validClass: 'smart-valid', suggestionsClass: 'smart-suggestions', ...options }; // Store elements being validated this.elements = new Map(); // Store validation state this.validationState = new Map(); // Store context information this.context = { currentView: null, lastOperation: null, previousInputs: {}, deviceInfo: this.getDeviceInfo(), sessionData: {} }; // Learning data for suggestions this.learningData = { corrections: {}, patterns: {}, successRates: {} }; // Custom validators this.customValidators = {}; // Smart suggestion banks this.suggestionBank = { fileUpload: { wrongFormat: [ "This file format isn't supported. Please upload a {acceptedFormats} file.", "We need a {acceptedFormats} file for this operation.", "Please select a {acceptedFormats} file instead." ], tooLarge: [ "This file is too large ({size}). Maximum size is {maxSize}.", "Please upload a smaller file (under {maxSize}).", "The file size ({size}) exceeds our limit of {maxSize}." ], lowQuality: [ "This image may be too low quality for accurate processing.", "A higher resolution image would improve results.", "Consider uploading a clearer image for better accuracy." ], noFaceDetected: [ "No face was detected in this image.", "Please upload an image with a clearly visible face.", "We couldn't find a face in this image. Try another photo." ], multipleFaces: [ "Multiple faces detected. Please upload an image with just one face.", "We found {count} faces in this image. Please use a photo with only one face.", "For best results, use an image with a single face." ] }, text: { tooShort: [ "This input is too short. Minimum length is {minLength} characters.", "Please provide at least {minLength} characters.", "A bit more information is needed (at least {minLength} characters)." ], tooLong: [ "This input is too long. Maximum length is {maxLength} characters.", "Please shorten your input to {maxLength} characters or less.", "We need a shorter input (maximum {maxLength} characters)." ], invalidFormat: [ "This doesn't match the required format: {format}", "Please follow this format: {format}", "The input needs to match: {format}" ], invalidCharacters: [ "There are invalid characters in your input.", "Please remove special characters like: {invalidChars}", "Only {validChars} are allowed in this field." ] }, mfa: { invalidCode: [ "This doesn't look like a valid MFA code.", "MFA codes are typically 6 digits without spaces.", "Please check your MFA code and try again." ], expired: [ "This MFA code may have expired.", "MFA codes expire after a few minutes. Please request a new one.", "Try requesting a fresh MFA code." ] }, general: { required: [ "This field is required.", "Please fill out this field.", "This information is needed to continue." ], suggestion: [ "Did you mean: {suggestion}?", "{suggestion} might be what you're looking for.", "Consider using {suggestion} instead." ] } }; // Initialize this.init(); } /** * Initialize the validation system */ init() { // Load any saved learning data from localStorage this.loadLearningData(); // Detect current view/context this.detectContext(); // Set up global handlers this.setupGlobalHandlers(); // Expose API on window for easy access window.morphGuardValidator = this; console.log('MorphGuard Smart Validator initialized'); } /** * Set up global handlers */ setupGlobalHandlers() { // Listen for form submissions to validate before submitting document.addEventListener('submit', (e) => { const form = e.target; // Skip if form has data-no-validate attribute if (form.hasAttribute('data-no-validate')) return; // Validate all form fields const isValid = this.validateForm(form); // Prevent submission if not valid if (!isValid) { e.preventDefault(); e.stopPropagation(); this.focusFirstInvalid(form); } }); // Listen for dynamic content changes const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type === 'childList') { // Check if new forms or fields were added const newForms = Array.from(mutation.addedNodes) .filter(node => node.tagName === 'FORM'); const newInputs = Array.from(mutation.addedNodes) .filter(node => node.tagName === 'INPUT' || node.tagName === 'SELECT' || node.tagName === 'TEXTAREA'); // Initialize validation for new forms newForms.forEach(form => { this.initForm(form); }); // Initialize validation for new inputs newInputs.forEach(input => { this.initElement(input); }); } } }); // Start observing document for dynamic changes observer.observe(document.body, { childList: true, subtree: true }); } /** * Initialize validation for a form * @param {HTMLFormElement} form - The form element to initialize */ initForm(form) { // Skip if already initialized or has data-no-validate attribute if (form.hasAttribute('data-smart-validator-init') || form.hasAttribute('data-no-validate')) return; // Mark as initialized form.setAttribute('data-smart-validator-init', 'true'); // Find all form elements const inputs = form.querySelectorAll('input, select, textarea'); // Initialize each element inputs.forEach(input => { this.initElement(input); }); // Add custom submit handling form.addEventListener('submit', (e) => { const formId = form.id || form.getAttribute('name') || 'unknown_form'; this.logEvent('form_submit', { formId }); }); } /** * Initialize validation for an element * @param {HTMLElement} element - The element to initialize */ initElement(element) { // Skip if already initialized or has data-no-validate attribute if (element.hasAttribute('data-smart-validator-init') || element.hasAttribute('data-no-validate')) return; // Mark as initialized element.setAttribute('data-smart-validator-init', 'true'); // Add to elements map const elementId = element.id || element.getAttribute('name') || `element_${this.elements.size}`; this.elements.set(elementId, element); // Create validation state entry this.validationState.set(elementId, { valid: null, // null = not validated yet errors: [], suggestions: [], attempts: 0, lastValue: element.value, rules: this.determineValidationRules(element) }); // Determine element type and attach appropriate event listeners if (element.tagName === 'INPUT' && element.type === 'file') { // File input element.addEventListener('change', (e) => { if (this.options.realTimeValidation) { this.validateElement(element); } }); } else if (element.tagName === 'SELECT') { // Select dropdown element.addEventListener('change', (e) => { if (this.options.realTimeValidation) { this.validateElement(element); } }); } else { // Text inputs, textareas, etc. let timeoutId = null; element.addEventListener('input', (e) => { if (this.options.realTimeValidation) { // Clear previous timeout if (timeoutId) clearTimeout(timeoutId); // Set new timeout for validation with delay timeoutId = setTimeout(() => { this.validateElement(element); }, this.options.validationDelay); } }); element.addEventListener('blur', (e) => { // Validate immediately on blur if (timeoutId) clearTimeout(timeoutId); this.validateElement(element); }); } // Add container for error/suggestion messages if needed if (!element.nextElementSibling || !element.nextElementSibling.classList.contains('smart-feedback')) { const feedbackContainer = document.createElement('div'); feedbackContainer.className = 'smart-feedback'; // Create error message element const errorElement = document.createElement('div'); errorElement.className = 'smart-error-message hidden'; feedbackContainer.appendChild(errorElement); // Create suggestions element const suggestionsElement = document.createElement('div'); suggestionsElement.className = 'smart-suggestions-list hidden'; feedbackContainer.appendChild(suggestionsElement); // Insert after the element if (element.nextElementSibling) { element.parentNode.insertBefore(feedbackContainer, element.nextElementSibling); } else { element.parentNode.appendChild(feedbackContainer); } } } /** * Determine validation rules for an element based on its attributes and type * @param {HTMLElement} element - The element to analyze * @returns {Object} - Validation rules for the element */ determineValidationRules(element) { const rules = {}; // Common rules from HTML attributes if (element.hasAttribute('required')) { rules.required = true; } if (element.hasAttribute('minlength')) { rules.minLength = parseInt(element.getAttribute('minlength'), 10); } if (element.hasAttribute('maxlength')) { rules.maxLength = parseInt(element.getAttribute('maxlength'), 10); } if (element.hasAttribute('min')) { rules.min = parseFloat(element.getAttribute('min')); } if (element.hasAttribute('max')) { rules.max = parseFloat(element.getAttribute('max')); } if (element.hasAttribute('pattern')) { rules.pattern = new RegExp(element.getAttribute('pattern')); } // Type-specific rules if (element.tagName === 'INPUT') { switch (element.type) { case 'email': rules.email = true; break; case 'url': rules.url = true; break; case 'tel': rules.tel = true; break; case 'number': rules.number = true; break; case 'file': // File type validation if (element.hasAttribute('accept')) { rules.accept = element.getAttribute('accept').split(',').map(type => type.trim()); } // Check for custom data attributes if (element.hasAttribute('data-max-size')) { rules.maxSize = parseInt(element.getAttribute('data-max-size'), 10); } if (element.hasAttribute('data-validate-image')) { rules.validateImage = true; } if (element.hasAttribute('data-require-face')) { rules.requireFace = true; } if (element.hasAttribute('data-require-document')) { rules.requireDocument = true; } break; } } // Get custom rules from data attributes const dataAttributes = Array.from(element.attributes) .filter(attr => attr.name.startsWith('data-rule-')) .forEach(attr => { const ruleName = attr.name.replace('data-rule-', ''); const ruleValue = attr.value; // Parse boolean values if (ruleValue === 'true') { rules[ruleName] = true; } else if (ruleValue === 'false') { rules[ruleName] = false; } else if (!isNaN(parseFloat(ruleValue))) { // Parse numeric values rules[ruleName] = parseFloat(ruleValue); } else { // Keep as string rules[ruleName] = ruleValue; } }); // Context-based rules for specific elements this.addContextBasedRules(element, rules); return rules; } /** * Add context-based validation rules for specific elements * @param {HTMLElement} element - The element to add rules for * @param {Object} rules - The current rules object to modify */ addContextBasedRules(element, rules) { // Check element ID to add specific rules const id = element.id || element.getAttribute('name'); if (!id) return rules; // MFA code validation if (id === 'mfa-code') { rules.pattern = /^\d{6}$/; rules.minLength = 6; rules.maxLength = 6; } // File type validation for specific inputs if (id.includes('image') || ['detect-image', 'demorph-image', 'reference-image', 'selfie-image', 'id-image', 'liveness-image', 'enroll-image', 'verify-image'].includes(id)) { rules.accept = rules.accept || ['.jpg', '.jpeg', '.png', '.webp']; rules.validateImage = true; rules.maxSize = rules.maxSize || 10 * 1024 * 1024; // 10MB default } // Video validation if (id === 'liveness-video') { rules.accept = rules.accept || ['.mp4', '.webm', '.mov']; rules.maxSize = rules.maxSize || 50 * 1024 * 1024; // 50MB default rules.maxDuration = rules.maxDuration || 30; // 30 seconds default } // Face detection requirement if (['selfie-image', 'liveness-image', 'enroll-image', 'verify-image'].includes(id)) { rules.requireFace = true; } // ID document validation if (id === 'id-image') { rules.requireDocument = true; } // User ID validation if (id === 'enroll-user-id' || id === 'verify-user-id') { rules.minLength = 3; rules.maxLength = 50; rules.pattern = /^[a-zA-Z0-9_.-]+$/; } return rules; } /** * Validate a specific element * @param {HTMLElement} element - The element to validate * @returns {boolean} - Whether the element is valid */ validateElement(element) { const elementId = element.id || element.getAttribute('name') || 'unknown_element'; const state = this.validationState.get(elementId); if (!state) { console.error(`Element not found in validation state: ${elementId}`); return false; } // Clear previous errors and suggestions state.errors = []; state.suggestions = []; // Get current value const value = this.getElementValue(element); state.lastValue = value; // Increment attempt counter if value changed if (state.lastValue !== value) { state.attempts++; } // Check required if (state.rules.required && this.isEmpty(value)) { state.errors.push('required'); } // If not required and empty, mark as valid and skip other checks if (!state.rules.required && this.isEmpty(value)) { state.valid = true; this.updateElementFeedback(element, state); return true; } // Run all applicable validations this.validateElementByType(element, state); // Set overall validity state.valid = state.errors.length === 0; // Update element UI this.updateElementFeedback(element, state); // Log validation event this.logEvent('validation', { elementId, valid: state.valid, errors: state.errors, attempts: state.attempts }); return state.valid; } /** * Validate an element based on its type and rules * @param {HTMLElement} element - The element to validate * @param {Object} state - The validation state for the element */ validateElementByType(element, state) { const value = this.getElementValue(element); if (element.tagName === 'INPUT') { switch (element.type) { case 'text': case 'password': case 'textarea': case 'search': // Text validation this.validateTextInput(value, state); break; case 'email': // Email validation if (!this.isValidEmail(value)) { state.errors.push('email'); // Suggest corrections for common email mistakes if (this.options.enableSuggestions) { const suggestion = this.suggestEmailCorrection(value); if (suggestion) { state.suggestions.push({ type: 'email', value: suggestion }); } } } break; case 'url': // URL validation if (!this.isValidUrl(value)) { state.errors.push('url'); // Suggest corrections for common URL mistakes if (this.options.enableSuggestions) { const suggestion = this.suggestUrlCorrection(value); if (suggestion) { state.suggestions.push({ type: 'url', value: suggestion }); } } } break; case 'tel': // Phone validation if (!this.isValidPhone(value)) { state.errors.push('tel'); // Suggest formatting for phone numbers if (this.options.enableSuggestions) { const suggestion = this.formatPhoneNumber(value); if (suggestion !== value) { state.suggestions.push({ type: 'tel', value: suggestion }); } } } break; case 'number': // Number validation if (!this.isValidNumber(value, state.rules)) { state.errors.push('number'); } break; case 'file': // File validation this.validateFileInput(element, state); break; } } else if (element.tagName === 'SELECT') { // Select validation if (state.rules.required && (value === '' || value === null)) { state.errors.push('required'); } } // Check pattern if specified if (state.rules.pattern && value !== '' && !state.rules.pattern.test(value)) { state.errors.push('pattern'); } // Run custom validators if any if (element.hasAttribute('data-custom-validator')) { const validatorName = element.getAttribute('data-custom-validator'); if (this.customValidators[validatorName]) { const customResult = this.customValidators[validatorName](value, element, state.rules); if (customResult !== true) { state.errors.push(customResult || 'custom'); } } } } /** * Validate text input against rules * @param {string} value - The input value * @param {Object} state - The validation state */ validateTextInput(value, state) { // Check min length if (state.rules.minLength && value.length < state.rules.minLength) { state.errors.push('minLength'); } // Check max length if (state.rules.maxLength && value.length > state.rules.maxLength) { state.errors.push('maxLength'); } // Check pattern if (state.rules.pattern && !state.rules.pattern.test(value)) { state.errors.push('pattern'); } // Check for invalid characters if specified if (state.rules.allowedChars) { const allowedCharsRegex = new RegExp(`^[${state.rules.allowedChars}]*$`); if (!allowedCharsRegex.test(value)) { state.errors.push('invalidCharacters'); // Find the invalid characters const invalidChars = Array.from(value) .filter(char => !state.rules.allowedChars.includes(char)) .filter((char, index, self) => self.indexOf(char) === index) // Unique only .join(''); // Store for error message state.invalidChars = invalidChars; } } // MFA specific validation if (state.rules.mfaCode) { // Check if it's the right format (typically 6 digits) if (!/^\d{6}$/.test(value)) { state.errors.push('mfaFormat'); } } } /** * Validate file input * @param {HTMLElement} element - The file input element * @param {Object} state - Validation state */ validateFileInput(element, state) { const files = element.files; // Check if any file is selected if (state.rules.required && (!files || files.length === 0)) { state.errors.push('required'); return; } // Skip other validations if no file selected if (!files || files.length === 0) return; // Validate each file for (let i = 0; i < files.length; i++) { const file = files[i]; // Check file type if (state.rules.accept && state.rules.accept.length > 0) { const fileType = file.type; const fileExtension = '.' + file.name.split('.').pop().toLowerCase(); const acceptedTypes = state.rules.accept.map(type => type.trim().toLowerCase()); const isAcceptedType = acceptedTypes.some(type => { if (type.startsWith('.')) { // Extension check return type === fileExtension; } else if (type.includes('/*')) { // MIME type with wildcard const typeGroup = type.split('/')[0]; return fileType.startsWith(typeGroup + '/'); } else { // Exact MIME type return type === fileType; } }); if (!isAcceptedType) { state.errors.push('fileType'); state.acceptedFormats = acceptedTypes.join(', '); } } // Check file size if (state.rules.maxSize && file.size > state.rules.maxSize) { state.errors.push('fileSize'); state.fileSize = this.formatFileSize(file.size); state.maxFileSize = this.formatFileSize(state.rules.maxSize); } // Image-specific validation if (state.rules.validateImage && file.type.startsWith('image/')) { this.validateImageFile(file, element, state); } // Video-specific validation if (state.rules.validateVideo && file.type.startsWith('video/')) { this.validateVideoFile(file, element, state); } } } /** * Validate an image file * @param {File} file - The image file * @param {HTMLElement} element - The file input element * @param {Object} state - Validation state */ validateImageFile(file, element, state) { // Check dimensions and quality asynchronously const img = new Image(); const objectUrl = URL.createObjectURL(file); img.onload = () => { // Revoke the object URL to free memory URL.revokeObjectURL(objectUrl); // Check minimum dimensions if specified if (state.rules.minWidth && img.width < state.rules.minWidth) { state.errors.push('imageTooSmall'); this.updateElementFeedback(element, state); } if (state.rules.minHeight && img.height < state.rules.minHeight) { state.errors.push('imageTooSmall'); this.updateElementFeedback(element, state); } // Check maximum dimensions if specified if (state.rules.maxWidth && img.width > state.rules.maxWidth) { state.errors.push('imageTooLarge'); this.updateElementFeedback(element, state); } if (state.rules.maxHeight && img.height > state.rules.maxHeight) { state.errors.push('imageTooLarge'); this.updateElementFeedback(element, state); } // Check aspect ratio if specified if (state.rules.aspectRatio) { const ratio = img.width / img.height; const targetRatio = state.rules.aspectRatio; const tolerance = state.rules.aspectRatioTolerance || 0.1; if (Math.abs(ratio - targetRatio) > tolerance) { state.errors.push('aspectRatio'); this.updateElementFeedback(element, state); } } // Face detection if required and the browser supports it if (state.rules.requireFace && window.faceapi) { this.detectFace(img, element, state); } // Update UI with any new errors this.updateElementFeedback(element, state); }; img.onerror = () => { URL.revokeObjectURL(objectUrl); state.errors.push('invalidImage'); this.updateElementFeedback(element, state); }; img.src = objectUrl; } /** * Validate a video file * @param {File} file - The video file * @param {HTMLElement} element - The file input element * @param {Object} state - Validation state */ validateVideoFile(file, element, state) { // Create a video element to check properties const video = document.createElement('video'); const objectUrl = URL.createObjectURL(file); video.addEventListener('loadedmetadata', () => { // Revoke the object URL to free memory URL.revokeObjectURL(objectUrl); // Check duration if specified if (state.rules.maxDuration && video.duration > state.rules.maxDuration) { state.errors.push('videoTooLong'); state.videoDuration = Math.round(video.duration); state.maxVideoDuration = state.rules.maxDuration; this.updateElementFeedback(element, state); } if (state.rules.minDuration && video.duration < state.rules.minDuration) { state.errors.push('videoTooShort'); state.videoDuration = Math.round(video.duration); state.minVideoDuration = state.rules.minDuration; this.updateElementFeedback(element, state); } // Check resolution if specified if (state.rules.minWidth && video.videoWidth < state.rules.minWidth) { state.errors.push('videoTooSmall'); this.updateElementFeedback(element, state); } if (state.rules.minHeight && video.videoHeight < state.rules.minHeight) { state.errors.push('videoTooSmall'); this.updateElementFeedback(element, state); } }); video.addEventListener('error', () => { URL.revokeObjectURL(objectUrl); state.errors.push('invalidVideo'); this.updateElementFeedback(element, state); }); // Set the source to trigger loading video.src = objectUrl; video.load(); } /** * Detect faces in an image * @param {HTMLImageElement} img - The image element * @param {HTMLElement} element - The file input element * @param {Object} state - Validation state */ async detectFace(img, element, state) { try { // Check if faceapi is loaded if (!window.faceapi) { console.warn('Face API not available for face detection'); return; } // Make sure models are loaded if (!window.faceApiModelsLoaded) { await Promise.all([ faceapi.nets.tinyFaceDetector.loadFromUri('/static/models/face-api'), faceapi.nets.faceLandmark68Net.loadFromUri('/static/models/face-api'), faceapi.nets.faceRecognitionNet.loadFromUri('/static/models/face-api') ]); window.faceApiModelsLoaded = true; } // Detect faces const detections = await faceapi.detectAllFaces( img, new faceapi.TinyFaceDetectorOptions() ).withFaceLandmarks().withFaceDescriptors(); // Check detection results if (detections.length === 0) { state.errors.push('noFaceDetected'); this.updateElementFeedback(element, state); } else if (detections.length > 1 && !state.rules.allowMultipleFaces) { state.errors.push('multipleFaces'); state.faceCount = detections.length; this.updateElementFeedback(element, state); } } catch (error) { console.error('Face detection error:', error); // Don't add validation error as this is an enhancement } } /** * Update element feedback (errors, suggestions, validation classes) * @param {HTMLElement} element - The element to update * @param {Object} state - The validation state */ updateElementFeedback(element, state) { // Find or create feedback container let feedbackContainer = element.nextElementSibling; if (!feedbackContainer || !feedbackContainer.classList.contains('smart-feedback')) { feedbackContainer = document.createElement('div'); feedbackContainer.className = 'smart-feedback'; // Create error message element const errorElement = document.createElement('div'); errorElement.className = 'smart-error-message hidden'; feedbackContainer.appendChild(errorElement); // Create suggestions element const suggestionsElement = document.createElement('div'); suggestionsElement.className = 'smart-suggestions-list hidden'; feedbackContainer.appendChild(suggestionsElement); // Insert after the element if (element.nextElementSibling) { element.parentNode.insertBefore(feedbackContainer, element.nextElementSibling); } else { element.parentNode.appendChild(feedbackContainer); } } // Get error and suggestions elements const errorElement = feedbackContainer.querySelector('.smart-error-message'); const suggestionsElement = feedbackContainer.querySelector('.smart-suggestions-list'); // Update error classes on the element if (state.valid === false) { // Invalid element.classList.add(this.options.errorClass); element.classList.remove(this.options.validClass); element.setAttribute('aria-invalid', 'true'); // Show error message errorElement.textContent = this.getErrorMessage(element, state); errorElement.classList.remove('hidden'); } else if (state.valid === true) { // Valid element.classList.remove(this.options.errorClass); if (this.options.showValidFeedback) { element.classList.add(this.options.validClass); } element.setAttribute('aria-invalid', 'false'); // Hide error message errorElement.classList.add('hidden'); } else { // Not validated yet element.classList.remove(this.options.errorClass); element.classList.remove(this.options.validClass); element.removeAttribute('aria-invalid'); // Hide error message errorElement.classList.add('hidden'); } // Update suggestions if (state.suggestions.length > 0 && this.options.enableSuggestions) { suggestionsElement.innerHTML = ''; // Create suggestions UI state.suggestions.forEach(suggestion => { const suggestionItem = document.createElement('div'); suggestionItem.className = 'smart-suggestion-item'; // Create suggestion content const message = this.getSuggestionMessage(suggestion.type, suggestion.value); suggestionItem.innerHTML = message; // Add click handler to apply suggestion suggestionItem.addEventListener('click', () => { this.applySuggestion(element, suggestion.value); }); suggestionsElement.appendChild(suggestionItem); }); suggestionsElement.classList.remove('hidden'); } else { suggestionsElement.classList.add('hidden'); } } /** * Get appropriate error message for the current validation state * @param {HTMLElement} element - The element * @param {Object} state - The validation state * @returns {string} - The error message */ getErrorMessage(element, state) { if (state.errors.length === 0) return ''; // Get the first error const errorType = state.errors[0]; // Check for custom error message on the element const customMessage = element.getAttribute(`data-error-${errorType}`); if (customMessage) return customMessage; // Get appropriate message from suggestion bank switch (errorType) { case 'required': return this.getRandomMessage(this.suggestionBank.general.required); case 'minLength': return this.getRandomMessage(this.suggestionBank.text.tooShort) .replace('{minLength}', state.rules.minLength); case 'maxLength': return this.getRandomMessage(this.suggestionBank.text.tooLong) .replace('{maxLength}', state.rules.maxLength); case 'pattern': return this.getRandomMessage(this.suggestionBank.text.invalidFormat) .replace('{format}', element.getAttribute('data-format-description') || 'the required format'); case 'invalidCharacters': return this.getRandomMessage(this.suggestionBank.text.invalidCharacters) .replace('{invalidChars}', state.invalidChars) .replace('{validChars}', state.rules.allowedChars); case 'email': return 'Please enter a valid email address.'; case 'url': return 'Please enter a valid URL.'; case 'tel': return 'Please enter a valid phone number.'; case 'number': if (state.rules.min !== undefined && state.rules.max !== undefined) { return `Please enter a number between ${state.rules.min} and ${state.rules.max}.`; } else if (state.rules.min !== undefined) { return `Please enter a number greater than or equal to ${state.rules.min}.`; } else if (state.rules.max !== undefined) { return `Please enter a number less than or equal to ${state.rules.max}.`; } return 'Please enter a valid number.'; case 'fileType': return this.getRandomMessage(this.suggestionBank.fileUpload.wrongFormat) .replace('{acceptedFormats}', state.acceptedFormats); case 'fileSize': return this.getRandomMessage(this.suggestionBank.fileUpload.tooLarge) .replace('{size}', state.fileSize) .replace('{maxSize}', state.maxFileSize); case 'invalidImage': return 'The selected file is not a valid image.'; case 'imageTooSmall': const minWidth = state.rules.minWidth || 0; const minHeight = state.rules.minHeight || 0; return `Image dimensions are too small. Minimum size is ${minWidth}×${minHeight} pixels.`; case 'imageTooLarge': const maxWidth = state.rules.maxWidth || Infinity; const maxHeight = state.rules.maxHeight || Infinity; return `Image dimensions are too large. Maximum size is ${maxWidth}×${maxHeight} pixels.`; case 'aspectRatio': return `Image must have an aspect ratio of ${state.rules.aspectRatio}.`; case 'noFaceDetected': return this.getRandomMessage(this.suggestionBank.fileUpload.noFaceDetected); case 'multipleFaces': return this.getRandomMessage(this.suggestionBank.fileUpload.multipleFaces) .replace('{count}', state.faceCount); case 'mfaFormat': return this.getRandomMessage(this.suggestionBank.mfa.invalidCode); case 'videoTooLong': return `Video is too long (${state.videoDuration}s). Maximum duration is ${state.maxVideoDuration} seconds.`; case 'videoTooShort': return `Video is too short (${state.videoDuration}s). Minimum duration is ${state.minVideoDuration} seconds.`; default: return 'Please check this field.'; } } /** * Get a suggestion message * @param {string} type - The suggestion type * @param {string} value - The suggested value * @returns {string} - The suggestion message */ getSuggestionMessage(type, value) { // Get appropriate message template from suggestion bank let messageTemplate = this.getRandomMessage(this.suggestionBank.general.suggestion); // Replace suggestion placeholder return messageTemplate.replace('{suggestion}', `${value}`); } /** * Apply a suggestion to an element * @param {HTMLElement} element - The element to update * @param {string} value - The value to apply */ applySuggestion(element, value) { // Update element value if (element.tagName === 'SELECT') { // For select elements, find the matching option const option = Array.from(element.options).find(opt => opt.value === value); if (option) { element.value = value; } } else { // For text inputs, textareas, etc. element.value = value; } // Re-validate this.validateElement(element); // Focus element element.focus(); // Log the suggestion usage this.logEvent('suggestion_applied', { elementId: element.id || element.getAttribute('name') || 'unknown_element', value }); // If we're learning from corrections, store this correction if (this.options.learnFromCorrections) { const elementId = element.id || element.getAttribute('name') || 'unknown_element'; const state = this.validationState.get(elementId); if (state) { // Store the correction for this element if (!this.learningData.corrections[elementId]) { this.learningData.corrections[elementId] = []; } this.learningData.corrections[elementId].push({ original: state.lastValue, corrected: value, timestamp: Date.now() }); // Limit the array size if (this.learningData.corrections[elementId].length > 20) { this.learningData.corrections[elementId].shift(); } // Save learning data this.saveLearningData(); } } } /** * Validate all form fields * @param {HTMLFormElement} form - The form to validate * @returns {boolean} - Whether the form is valid */ validateForm(form) { // Find all form elements const inputs = form.querySelectorAll('input, select, textarea'); // Track overall form validity let isValid = true; // Validate each element inputs.forEach(input => { // Skip elements with data-no-validate attribute if (input.hasAttribute('data-no-validate')) return; // Initialize the element if not already if (!input.hasAttribute('data-smart-validator-init')) { this.initElement(input); } // Validate and update overall validity const elementValid = this.validateElement(input); if (!elementValid) { isValid = false; } }); return isValid; } /** * Focus the first invalid element in a form * @param {HTMLFormElement} form - The form to process */ focusFirstInvalid(form) { const invalidElement = form.querySelector(`.${this.options.errorClass}`); if (invalidElement) { invalidElement.focus(); // Scroll to the element if needed const rect = invalidElement.getBoundingClientRect(); const isInView = ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth) ); if (!isInView) { invalidElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } } /** * Get the value of an element regardless of type * @param {HTMLElement} element - The element to get value from * @returns {*} - The element's value */ getElementValue(element) { if (element.tagName === 'INPUT') { if (element.type === 'checkbox') { return element.checked; } else if (element.type === 'radio') { const name = element.getAttribute('name'); const checkedRadio = document.querySelector(`input[name="${name}"]:checked`); return checkedRadio ? checkedRadio.value : ''; } else if (element.type === 'file') { return element.files; } else { return element.value; } } else if (element.tagName === 'SELECT') { return element.value; } else if (element.tagName === 'TEXTAREA') { return element.value; } return ''; } /** * Check if a value is empty * @param {*} value - The value to check * @returns {boolean} - Whether the value is empty */ isEmpty(value) { if (value === null || value === undefined) return true; if (typeof value === 'string') return value.trim() === ''; if (value instanceof FileList) return value.length === 0; return false; } /** * Validate email format * @param {string} email - The email to validate * @returns {boolean} - Whether the email is valid */ isValidEmail(email) { // Basic email validation return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } /** * Validate URL format * @param {string} url - The URL to validate * @returns {boolean} - Whether the URL is valid */ isValidUrl(url) { try { new URL(url); return true; } catch (e) { return false; } } /** * Validate phone number format * @param {string} phone - The phone number to validate * @returns {boolean} - Whether the phone number is valid */ isValidPhone(phone) { // Allow for various formats return /^[+]?[(]?[0-9]{3}[)]?[-\s.]?[0-9]{3}[-\s.]?[0-9]{4,6}$/.test( phone.replace(/\s+/g, '') ); } /** * Validate number against rules * @param {string|number} value - The value to validate * @param {Object} rules - Validation rules * @returns {boolean} - Whether the number is valid */ isValidNumber(value, rules) { // Convert to number const num = parseFloat(value); // Check if it's a valid number if (isNaN(num)) return false; // Check min/max constraints if (rules.min !== undefined && num < rules.min) return false; if (rules.max !== undefined && num > rules.max) return false; return true; } /** * Format a file size in human-readable format * @param {number} bytes - The size in bytes * @returns {string} - Formatted size string */ formatFileSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB'; } /** * Suggest a correction for common email mistakes * @param {string} email - The email to correct * @returns {string|null} - Suggested correction or null */ suggestEmailCorrection(email) { if (!email || !email.includes('@')) return null; // Trim whitespace email = email.trim(); // Extract domain part const parts = email.split('@'); if (parts.length !== 2) return null; const [username, domain] = parts; // Common domain corrections const domainCorrections = { 'gmail.co': 'gmail.com', 'gmail.cm': 'gmail.com', 'gmal.com': 'gmail.com', 'gmial.com': 'gmail.com', 'gamil.com': 'gmail.com', 'gmail.cmo': 'gmail.com', 'hotmial.com': 'hotmail.com', 'hotmail.co': 'hotmail.com', 'hotmail.cm': 'hotmail.com', 'yaho.com': 'yahoo.com', 'yahooo.com': 'yahoo.com', 'yahoo.co': 'yahoo.com', 'yaho.co': 'yahoo.com', 'outloo.com': 'outlook.com', 'outlok.com': 'outlook.com', 'outlook.co': 'outlook.com' }; // Check for common domain typos if (domainCorrections[domain]) { return `${username}@${domainCorrections[domain]}`; } // Check for missing dots in common domains if (domain.includes('gmailcom')) { return `${username}@gmail.com`; } if (domain.includes('yahoocom')) { return `${username}@yahoo.com`; } if (domain.includes('hotmailcom')) { return `${username}@hotmail.com`; } if (domain.includes('outlookcom')) { return `${username}@outlook.com`; } // Check TLD const domainParts = domain.split('.'); if (domainParts.length >= 2) { const tld = domainParts[domainParts.length - 1]; // Common TLD corrections const tldCorrections = { 'con': 'com', 'comm': 'com', 'om': 'com', 'coim': 'com', 'com,': 'com', 'cmo': 'com', 'net,': 'net', 'orgg': 'org', 'ogr': 'org', 'rog': 'org' }; if (tldCorrections[tld]) { domainParts[domainParts.length - 1] = tldCorrections[tld]; return `${username}@${domainParts.join('.')}`; } } return null; } /** * Suggest a correction for common URL mistakes * @param {string} url - The URL to correct * @returns {string|null} - Suggested correction or null */ suggestUrlCorrection(url) { if (!url) return null; // Trim whitespace url = url.trim(); // Add protocol if missing if (!url.startsWith('http://') && !url.startsWith('https://')) { return `https://${url}`; } // Fix common typos if (url.includes('htpt://')) { return url.replace('htpt://', 'http://'); } if (url.includes('htps://')) { return url.replace('htps://', 'https://'); } if (url.includes('http::/')) { return url.replace('http::/', 'http://'); } if (url.includes('https::/')) { return url.replace('https::/', 'https://'); } // Fix common domain typos const domainCorrections = { 'wwww.': 'www.', 'ww.': 'www.', 'www,': 'www.' }; for (const [typo, correction] of Object.entries(domainCorrections)) { if (url.includes(typo)) { return url.replace(typo, correction); } } return null; } /** * Format a phone number for consistent display * @param {string} phone - The phone number to format * @returns {string} - Formatted phone number */ formatPhoneNumber(phone) { // Remove all non-digit characters const digits = phone.replace(/\D/g, ''); // Check for different phone number lengths if (digits.length === 10) { // US format: (123) 456-7890 return `(${digits.substring(0, 3)}) ${digits.substring(3, 6)}-${digits.substring(6)}`; } else if (digits.length === 11 && digits.startsWith('1')) { // US with country code: 1 (123) 456-7890 return `1 (${digits.substring(1, 4)}) ${digits.substring(4, 7)}-${digits.substring(7)}`; } // For other formats, just add spaces for readability if (digits.length > 4) { // Add space every 4 digits from the end let formatted = ''; for (let i = 0; i < digits.length; i++) { if (i > 0 && (digits.length - i) % 4 === 0) { formatted += ' '; } formatted += digits[i]; } return formatted; } // Return as is if we couldn't format it return phone; } /** * Get a random message from a message array * @param {string[]} messages - Array of message templates * @returns {string} - A randomly selected message */ getRandomMessage(messages) { if (!messages || messages.length === 0) return ''; const index = Math.floor(Math.random() * messages.length); return messages[index]; } /** * Detect the current context (view, device, etc.) */ detectContext() { // Detect current view based on URL const path = window.location.pathname; if (path.includes('/demo')) { this.context.currentView = 'demo'; } else if (path.includes('/setup')) { this.context.currentView = 'setup'; } else if (path.includes('/lead')) { this.context.currentView = 'lead'; } else if (path === '/') { this.context.currentView = 'landing'; } // Detect device info this.context.deviceInfo = this.getDeviceInfo(); // Load session data from sessionStorage try { const sessionData = sessionStorage.getItem('morphGuardValidatorSession'); if (sessionData) { this.context.sessionData = JSON.parse(sessionData); } } catch (e) { console.error('Error loading session data:', e); } } /** * Get device information for context-aware validation * @returns {Object} - Device information */ getDeviceInfo() { return { isMobile: /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent), screenWidth: window.innerWidth, screenHeight: window.innerHeight, devicePixelRatio: window.devicePixelRatio || 1, hasCamera: 'mediaDevices' in navigator && 'getUserMedia' in navigator.mediaDevices, supportsTouchInput: ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) }; } /** * Log validation events for analytics and learning * @param {string} eventType - Type of event * @param {Object} data - Event data */ logEvent(eventType, data) { // Create event record const event = { type: eventType, timestamp: Date.now(), data, context: { view: this.context.currentView, isMobile: this.context.deviceInfo.isMobile } }; // Store in session data if (!this.context.sessionData.events) { this.context.sessionData.events = []; } this.context.sessionData.events.push(event); // Limit array size if (this.context.sessionData.events.length > 100) { this.context.sessionData.events.shift(); } // Save to sessionStorage try { sessionStorage.setItem( 'morphGuardValidatorSession', JSON.stringify(this.context.sessionData) ); } catch (e) { console.error('Error saving session data:', e); } // Update learning data for certain events if (eventType === 'validation' && data.valid === false) { this.updateErrorPatterns(data.elementId, data.errors); } } /** * Update error patterns for learning * @param {string} elementId - The element ID * @param {string[]} errors - The errors that occurred */ updateErrorPatterns(elementId, errors) { if (!this.options.learnFromCorrections) return; // Initialize patterns for this element if needed if (!this.learningData.patterns[elementId]) { this.learningData.patterns[elementId] = {}; } // Update counts for each error type errors.forEach(error => { if (!this.learningData.patterns[elementId][error]) { this.learningData.patterns[elementId][error] = 0; } this.learningData.patterns[elementId][error]++; }); // Save learning data this.saveLearningData(); } /** * Save learning data to localStorage */ saveLearningData() { try { localStorage.setItem( 'morphGuardValidatorLearning', JSON.stringify(this.learningData) ); } catch (e) { console.error('Error saving learning data:', e); } } /** * Load learning data from localStorage */ loadLearningData() { try { const data = localStorage.getItem('morphGuardValidatorLearning'); if (data) { this.learningData = JSON.parse(data); } } catch (e) { console.error('Error loading learning data:', e); } } /** * Add a custom validator function * @param {string} name - The validator name * @param {Function} validator - The validator function */ addCustomValidator(name, validator) { if (typeof validator !== 'function') { console.error('Custom validator must be a function'); return; } this.customValidators[name] = validator; } /** * Initialize all forms and fields in the document */ initAll() { // Find all forms const forms = document.querySelectorAll('form:not([data-no-validate])'); // Initialize each form forms.forEach(form => { this.initForm(form); }); // Find standalone fields const standaloneFields = document.querySelectorAll( 'input:not([data-no-validate]):not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]), ' + 'select:not([data-no-validate]), ' + 'textarea:not([data-no-validate])' ); // Initialize each standalone field standaloneFields.forEach(field => { if (!field.form) { this.initElement(field); } }); } } // Initialize SmartValidator when DOM is loaded document.addEventListener('DOMContentLoaded', () => { // Create default styles for validation elements const style = document.createElement('style'); style.textContent = ` .smart-error { border-color: #ef4444 !important; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ef4444' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 8px center; background-size: 20px 20px; padding-right: 32px !important; } select.smart-error { background-position: right 24px center; } .smart-valid { border-color: #10b981 !important; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2310b981' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M22 11.08V12a10 10 0 1 1-5.93-9.14'%3E%3C/path%3E%3Cpolyline points='22 4 12 14.01 9 11.01'%3E%3C/polyline%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 8px center; background-size: 20px 20px; padding-right: 32px !important; } select.smart-valid { background-position: right 24px center; } .smart-feedback { margin-top: 0.25rem; font-size: 0.875rem; } .smart-error-message { color: #ef4444; animation: fadeIn 0.3s ease-in-out; } .smart-suggestions-list { margin-top: 0.5rem; padding: 0.5rem; border: 1px solid #e5e7eb; border-radius: 0.375rem; background-color: #f9fafb; } .smart-suggestion-item { padding: 0.25rem 0.5rem; cursor: pointer; border-radius: 0.25rem; color: #6366f1; } .smart-suggestion-item:hover { background-color: #eef2ff; } .hidden { display: none !important; } @keyframes fadeIn { 0% { opacity: 0; transform: translateY(-5px); } 100% { opacity: 1; transform: translateY(0); } } /* Dark mode styles */ .theme-dark .smart-suggestions-list, .theme-cyberpunk .smart-suggestions-list, .theme-matrix .smart-suggestions-list, .theme-midnight .smart-suggestions-list { background-color: #1f2937; border-color: #374151; } .theme-dark .smart-suggestion-item:hover, .theme-cyberpunk .smart-suggestion-item:hover, .theme-matrix .smart-suggestion-item:hover, .theme-midnight .smart-suggestion-item:hover { background-color: #374151; } `; document.head.appendChild(style); // Create and expose global instance window.smartValidator = new SmartValidator(); // Initialize all forms and fields in the document window.smartValidator.initAll(); }); // Export for module use if (typeof module !== 'undefined' && module.exports) { module.exports = SmartValidator; }