/** * Validation du formulaire de contact * JavaScript vanilla - pas de dépendances */ /** * Service reCAPTCHA v3 * Gestion du token avec dégradation gracieuse */ const RecaptchaService = { siteKey: null, init() { this.siteKey = window.RECAPTCHA_SITE_KEY || null; }, isAvailable() { return this.siteKey && typeof grecaptcha !== 'undefined'; }, /** * Obtient un token reCAPTCHA * @param {string} action - Action à valider (ex: 'contact') * @returns {Promise} - Token ou chaîne vide si indisponible */ async getToken(action = 'contact') { // Dégradation gracieuse si reCAPTCHA non disponible if (!this.isAvailable()) { console.warn('reCAPTCHA non disponible, envoi sans protection'); return ''; } return new Promise((resolve) => { grecaptcha.ready(() => { grecaptcha.execute(this.siteKey, { action }) .then(token => resolve(token)) .catch(error => { console.error('Erreur reCAPTCHA:', error); resolve(''); // Permettre l'envoi quand même }); }); }); } }; class FormValidator { constructor(formId) { this.form = document.getElementById(formId); if (!this.form) return; this.submitBtn = document.getElementById('submit-btn'); this.submitText = document.getElementById('submit-text'); this.submitLoading = document.getElementById('submit-loading'); this.fields = {}; this.errors = {}; this.init(); } init() { // Définir les règles de validation this.rules = { nom: { required: true, minLength: 2, maxLength: 100, message: 'Veuillez entrer votre nom (2 caractères minimum)' }, prenom: { required: true, minLength: 2, maxLength: 100, message: 'Veuillez entrer votre prénom (2 caractères minimum)' }, email: { required: true, email: true, message: 'Veuillez entrer une adresse email valide' }, categorie: { required: true, message: 'Veuillez sélectionner une catégorie' }, objet: { required: true, minLength: 5, maxLength: 200, message: 'Veuillez entrer un objet (5 caractères minimum)' }, message: { required: true, minLength: 20, maxLength: 5000, message: 'Veuillez entrer votre message (20 caractères minimum)' } }; // Récupérer les champs Object.keys(this.rules).forEach(fieldName => { this.fields[fieldName] = this.form.querySelector(`[name="${fieldName}"]`); }); this.bindEvents(); } bindEvents() { // Validation au blur Object.keys(this.fields).forEach(fieldName => { const field = this.fields[fieldName]; if (field) { field.addEventListener('blur', () => this.validateField(fieldName)); field.addEventListener('input', () => { // Effacer l'erreur pendant la saisie if (this.errors[fieldName]) { this.clearError(fieldName); } }); } }); // Validation à la soumission this.form.addEventListener('submit', (e) => this.handleSubmit(e)); // Compteur de caractères pour le message const messageField = this.fields.message; if (messageField) { messageField.addEventListener('input', () => this.updateCharCount()); } // Bouton reset this.form.addEventListener('reset', () => { setTimeout(() => { this.clearAllErrors(); this.updateCharCount(); }, 10); }); } validateField(fieldName) { const field = this.fields[fieldName]; const rule = this.rules[fieldName]; if (!field || !rule) return true; const value = field.value.trim(); let isValid = true; let errorMessage = ''; // Required if (rule.required && !value) { isValid = false; errorMessage = rule.message; } // Min length if (isValid && rule.minLength && value.length < rule.minLength) { isValid = false; errorMessage = rule.message; } // Max length if (isValid && rule.maxLength && value.length > rule.maxLength) { isValid = false; errorMessage = `Maximum ${rule.maxLength} caractères`; } // Email format if (isValid && rule.email && value) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(value)) { isValid = false; errorMessage = rule.message; } } // Afficher ou masquer l'erreur if (isValid) { this.clearError(fieldName); } else { this.showError(fieldName, errorMessage); } this.errors[fieldName] = !isValid; this.updateSubmitButton(); return isValid; } validateAll() { let allValid = true; Object.keys(this.rules).forEach(fieldName => { if (!this.validateField(fieldName)) { allValid = false; } }); return allValid; } showError(fieldName, message) { const field = this.fields[fieldName]; const errorEl = this.form.querySelector(`[data-error="${fieldName}"]`); if (field) { field.classList.add('border-red-500', 'focus:ring-red-500/50', 'focus:border-red-500'); field.classList.remove('border-border', 'focus:ring-primary/50', 'focus:border-primary'); field.setAttribute('aria-invalid', 'true'); } if (errorEl) { errorEl.textContent = message; errorEl.classList.remove('hidden'); } } clearError(fieldName) { const field = this.fields[fieldName]; const errorEl = this.form.querySelector(`[data-error="${fieldName}"]`); if (field) { field.classList.remove('border-red-500', 'focus:ring-red-500/50', 'focus:border-red-500'); field.classList.add('border-border', 'focus:ring-primary/50', 'focus:border-primary'); field.removeAttribute('aria-invalid'); } if (errorEl) { errorEl.textContent = ''; errorEl.classList.add('hidden'); } this.errors[fieldName] = false; this.updateSubmitButton(); } clearAllErrors() { Object.keys(this.rules).forEach(fieldName => { this.clearError(fieldName); }); } updateSubmitButton() { const hasErrors = Object.values(this.errors).some(err => err); if (this.submitBtn) { this.submitBtn.disabled = hasErrors; } } updateCharCount() { const messageField = this.fields.message; const countEl = document.getElementById('message-count'); if (messageField && countEl) { countEl.textContent = messageField.value.length; } } setLoading(isLoading) { if (this.submitBtn) { this.submitBtn.disabled = isLoading; } if (this.submitText) { this.submitText.classList.toggle('hidden', isLoading); } if (this.submitLoading) { this.submitLoading.classList.toggle('hidden', !isLoading); if (isLoading) { this.submitLoading.classList.add('flex'); } else { this.submitLoading.classList.remove('flex'); } } } handleSubmit(e) { e.preventDefault(); if (!this.validateAll()) { // Focus sur le premier champ en erreur const firstError = Object.keys(this.errors).find(key => this.errors[key]); if (firstError && this.fields[firstError]) { this.fields[firstError].focus(); } return; } // Si valide, déclencher l'envoi this.form.dispatchEvent(new CustomEvent('validSubmit', { detail: this.getFormData() })); } getFormData() { const formData = {}; Object.keys(this.fields).forEach(fieldName => { if (this.fields[fieldName]) { formData[fieldName] = this.fields[fieldName].value.trim(); } }); // Ajouter le champ entreprise (optionnel) const entreprise = this.form.querySelector('[name="entreprise"]'); if (entreprise) { formData.entreprise = entreprise.value.trim(); } // Ajouter le token CSRF const csrfToken = this.form.querySelector('[name="csrf_token"]'); if (csrfToken) { formData.csrf_token = csrfToken.value; } return formData; } } /** * Gestionnaire de persistance du formulaire * Sauvegarde/restaure les données via localStorage */ class ContactFormPersistence { constructor(formId) { this.form = document.getElementById(formId); if (!this.form) return; this.debounceTimer = null; this.init(); } init() { this.loadSavedData(); this.bindEvents(); } bindEvents() { // Sauvegarder à chaque modification (avec debounce de 500ms) this.form.addEventListener('input', () => { clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(() => this.saveData(), 500); }); // Bouton effacer const clearBtn = document.getElementById('clear-form-btn'); if (clearBtn) { clearBtn.addEventListener('click', (e) => { e.preventDefault(); this.clearForm(); }); } // Écouter l'envoi réussi (dispatché par le handler AJAX) this.form.addEventListener('formSuccess', () => { AppState.clearFormData(); }); } saveData() { const formData = new FormData(this.form); const data = {}; formData.forEach((value, key) => { data[key] = value; }); AppState.saveFormData(data); } loadSavedData() { const savedData = AppState.getFormData(); if (!savedData) return; Object.keys(savedData).forEach(key => { const field = this.form.querySelector(`[name="${key}"]`); if (field && savedData[key]) { field.value = savedData[key]; } }); // Mettre à jour le compteur de caractères const messageField = this.form.querySelector('[name="message"]'); const countEl = document.getElementById('message-count'); if (messageField && countEl) { countEl.textContent = messageField.value.length; } } clearForm() { // Confirmation if (!confirm('Êtes-vous sûr de vouloir effacer le formulaire ?')) { return; } // Vider le localStorage AppState.clearFormData(); // Réinitialiser le formulaire this.form.reset(); // Réinitialiser le compteur const countEl = document.getElementById('message-count'); if (countEl) { countEl.textContent = '0'; } // Effacer les erreurs visuelles this.form.querySelectorAll('.border-red-500').forEach(el => { el.classList.remove('border-red-500', 'focus:ring-red-500/50', 'focus:border-red-500'); el.classList.add('border-border', 'focus:ring-primary/50', 'focus:border-primary'); el.removeAttribute('aria-invalid'); }); this.form.querySelectorAll('[data-error]').forEach(el => { el.classList.add('hidden'); el.textContent = ''; }); // Réactiver le bouton submit const submitBtn = document.getElementById('submit-btn'); if (submitBtn) { submitBtn.disabled = false; } } } /** * Gestionnaire d'envoi AJAX du formulaire * Gère le loading, succès et erreurs */ class ContactFormSubmit { constructor(formId) { this.form = document.getElementById(formId); if (!this.form) return; this.submitBtn = document.getElementById('submit-btn'); this.submitText = document.getElementById('submit-text'); this.submitLoading = document.getElementById('submit-loading'); this.successMessage = document.getElementById('success-message'); this.errorMessage = document.getElementById('error-message'); this.errorText = document.getElementById('error-text'); this.isSubmitting = false; this.init(); } init() { // Écouter l'événement de validation réussie this.form.addEventListener('validSubmit', (e) => this.handleSubmit(e.detail)); } async handleSubmit(formData) { if (this.isSubmitting) return; this.setLoadingState(true); this.hideMessages(); try { // Ajouter le token CSRF const csrfInput = this.form.querySelector('[name="csrf_token"]'); if (csrfInput) { formData.csrf_token = csrfInput.value; } // Obtenir le token reCAPTCHA formData.recaptcha_token = await RecaptchaService.getToken('contact'); // Envoyer la requête const response = await fetch('/api/contact.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(formData) }); const result = await response.json(); if (result.success) { this.handleSuccess(result.message); } else { this.handleError(result.error || 'Une erreur est survenue'); } } catch (error) { console.error('Erreur envoi formulaire:', error); this.handleError('Impossible de contacter le serveur. Vérifiez votre connexion.'); } finally { this.setLoadingState(false); } } setLoadingState(loading) { this.isSubmitting = loading; if (this.submitBtn) { this.submitBtn.disabled = loading; } if (this.submitText && this.submitLoading) { if (loading) { this.submitText.classList.add('hidden'); this.submitLoading.classList.remove('hidden'); this.submitLoading.classList.add('flex'); } else { this.submitText.classList.remove('hidden'); this.submitLoading.classList.add('hidden'); this.submitLoading.classList.remove('flex'); } } } handleSuccess(message) { // Masquer le formulaire this.form.classList.add('hidden'); // Afficher le message de succès if (this.successMessage) { this.successMessage.classList.remove('hidden'); // Scroll vers le message this.successMessage.scrollIntoView({ behavior: 'smooth', block: 'center' }); } // Vider le localStorage AppState.clearFormData(); // Réinitialiser le formulaire (pour un éventuel nouvel envoi) this.form.reset(); // Déclencher l'événement de succès this.form.dispatchEvent(new CustomEvent('formSuccess')); } handleError(message) { // Afficher le message d'erreur if (this.errorMessage && this.errorText) { this.errorText.textContent = message; this.errorMessage.classList.remove('hidden'); // Scroll vers le message this.errorMessage.scrollIntoView({ behavior: 'smooth', block: 'center' }); } // Les données sont conservées dans le formulaire pour réessayer } hideMessages() { if (this.successMessage) { this.successMessage.classList.add('hidden'); } if (this.errorMessage) { this.errorMessage.classList.add('hidden'); } } } // Initialisation document.addEventListener('DOMContentLoaded', () => { RecaptchaService.init(); window.contactFormValidator = new FormValidator('contact-form'); window.contactFormPersistence = new ContactFormPersistence('contact-form'); window.contactFormSubmit = new ContactFormSubmit('contact-form'); });