MorphGuard / static /js /smart_validator.js
juanquy's picture
Initial clean commit of modular MorphGuard
2978bba
Raw
History Blame Contribute Delete
57.5 kB
/**
* 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;
}