✉️ Feature: Epic 5 - Formulaire de Contact (Stories 5.1-5.7)
- Formulaire HTML5 avec validation (nom, prénom, email, entreprise, catégorie, objet, message) - Validation JavaScript côté client (FormValidator) - Persistance localStorage des données (AppState) - Intégration reCAPTCHA v3 avec dégradation gracieuse - Traitement PHP sécurisé (CSRF, validation, envoi email) - Feedback utilisateur AJAX (succès/erreur) - Liens contact secondaires (LinkedIn, GitHub, Email protégé) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
561
assets/js/contact-form.js
Normal file
561
assets/js/contact-form.js
Normal file
@@ -0,0 +1,561 @@
|
||||
/**
|
||||
* 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<string>} - 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');
|
||||
});
|
||||
Reference in New Issue
Block a user