Spaces:
Sleeping
Sleeping
| /** | |
| * 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}', `<strong>${value}</strong>`); | |
| } | |
| /** | |
| * 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; | |
| } |