diff --git a/assets/js/contact-form.js b/assets/js/contact-form.js
index 22a168d..8d2071c 100644
--- a/assets/js/contact-form.js
+++ b/assets/js/contact-form.js
@@ -1,6 +1,6 @@
-/**
+/**
* Validation du formulaire de contact
- * JavaScript vanilla - pas de dépendances
+ * JavaScript vanilla - pas de dépendances
*/
class FormValidator {
@@ -21,13 +21,13 @@ class FormValidator {
required: true,
minLength: 2,
maxLength: 100,
- message: 'Veuillez entrer votre nom (2 caractčres minimum)'
+ 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)'
+ message: 'Veuillez entrer votre prénom (2 caractères minimum)'
},
email: {
required: true,
@@ -36,19 +36,19 @@ class FormValidator {
},
categorie: {
required: true,
- message: 'Veuillez sélectionner une catégorie'
+ message: 'Veuillez sélectionner une catégorie'
},
objet: {
required: true,
minLength: 5,
maxLength: 200,
- message: 'Veuillez entrer un objet (5 caractčres minimum)'
+ 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)'
+ message: 'Veuillez entrer votre message (20 caractères minimum)'
}
};
@@ -98,7 +98,7 @@ class FormValidator {
if (isValid && rule.maxLength && value.length > rule.maxLength) {
isValid = false;
- errorMessage = `Maximum ${rule.maxLength} caractčres`;
+ errorMessage = `Maximum ${rule.maxLength} caractères`;
}
if (isValid && rule.email && value) {
@@ -212,6 +212,89 @@ class FormValidator {
}
}
+class ContactFormPersistence {
+ constructor(formId) {
+ this.form = document.getElementById(formId);
+ if (!this.form) return;
+
+ this.debounceTimer = null;
+ this.init();
+ }
+
+ init() {
+ this.loadSavedData();
+ this.bindEvents();
+ }
+
+ bindEvents() {
+ this.form.addEventListener('input', () => {
+ clearTimeout(this.debounceTimer);
+ this.debounceTimer = setTimeout(() => this.saveData(), 500);
+ });
+
+ const clearBtn = document.getElementById('clear-form-btn');
+ if (clearBtn) {
+ clearBtn.addEventListener('click', () => this.clearForm());
+ }
+
+ 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];
+ }
+ });
+
+ const messageField = this.form.querySelector('[name="message"]');
+ const countEl = document.getElementById('message-count');
+ if (messageField && countEl) {
+ countEl.textContent = messageField.value.length;
+ }
+ }
+
+ clearForm() {
+ if (!confirm('Êtes-vous sûr de vouloir effacer le formulaire ?')) {
+ return;
+ }
+
+ AppState.clearFormData();
+ this.form.reset();
+
+ const countEl = document.getElementById('message-count');
+ if (countEl) {
+ countEl.textContent = '0';
+ }
+
+ this.form.querySelectorAll('.input-error').forEach((el) => {
+ el.classList.remove('input-error');
+ });
+ this.form.querySelectorAll('[data-error]').forEach((el) => {
+ el.classList.add('hidden');
+ el.textContent = '';
+ });
+ }
+}
+
document.addEventListener('DOMContentLoaded', () => {
window.contactFormValidator = new FormValidator('contact-form');
+ window.contactFormPersistence = new ContactFormPersistence('contact-form');
});
diff --git a/assets/js/state.js b/assets/js/state.js
new file mode 100644
index 0000000..0317b20
--- /dev/null
+++ b/assets/js/state.js
@@ -0,0 +1,57 @@
+/**
+ * Gestionnaire d'état pour le localStorage
+ */
+const AppState = {
+ STORAGE_KEY: 'portfolio_contact_form',
+ EXCLUDED_FIELDS: ['csrf_token', 'password', 'recaptcha_token'],
+
+ isStorageAvailable() {
+ try {
+ const test = '__storage_test__';
+ localStorage.setItem(test, test);
+ localStorage.removeItem(test);
+ return true;
+ } catch (e) {
+ return false;
+ }
+ },
+
+ saveFormData(data) {
+ if (!this.isStorageAvailable()) return;
+
+ try {
+ const filteredData = {};
+ Object.keys(data).forEach((key) => {
+ if (!this.EXCLUDED_FIELDS.includes(key)) {
+ filteredData[key] = data[key];
+ }
+ });
+
+ localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filteredData));
+ } catch (e) {
+ console.warn('Impossible de sauvegarder dans localStorage:', e);
+ }
+ },
+
+ getFormData() {
+ if (!this.isStorageAvailable()) return null;
+
+ try {
+ const data = localStorage.getItem(this.STORAGE_KEY);
+ return data ? JSON.parse(data) : null;
+ } catch (e) {
+ console.warn('Impossible de charger depuis localStorage:', e);
+ return null;
+ }
+ },
+
+ clearFormData() {
+ if (!this.isStorageAvailable()) return;
+
+ try {
+ localStorage.removeItem(this.STORAGE_KEY);
+ } catch (e) {
+ // Silencieux
+ }
+ }
+};
diff --git a/docs/stories/5.3.persistance-localstorage.md b/docs/stories/5.3.persistance-localstorage.md
index 63e097f..a6b4a32 100644
--- a/docs/stories/5.3.persistance-localstorage.md
+++ b/docs/stories/5.3.persistance-localstorage.md
@@ -2,7 +2,7 @@
## Status
-Ready for Dev
+review
## Story
@@ -21,34 +21,34 @@ Ready for Dev
## Tasks / Subtasks
-- [] **Task 1 : Créer le gestionnaire de stockage** (AC: 6)
- - [] Clé unique `portfolio_contact_form`
- - [] Méthodes save, load, clear
- - [] Gestion des erreurs (localStorage indisponible)
+- [x] **Task 1 : Créer le gestionnaire de stockage** (AC: 6)
+ - [x] Clé unique `portfolio_contact_form`
+ - [x] Méthodes save, load, clear
+ - [x] Gestion des erreurs (localStorage indisponible)
-- [] **Task 2 : Sauvegarder automatiquement** (AC: 1)
- - [] Écouter l'événement `input` sur chaque champ
- - [] Debounce pour éviter trop d'écritures (500ms)
- - [] Sauvegarder l'état complet
+- [x] **Task 2 : Sauvegarder automatiquement** (AC: 1)
+ - [x] Écouter l'événement `input` sur chaque champ
+ - [x] Debounce pour éviter trop d'écritures (500ms)
+ - [x] Sauvegarder l'état complet
-- [] **Task 3 : Restaurer au chargement** (AC: 2)
- - [] Charger les données au DOMContentLoaded
- - [] Pré-remplir chaque champ
- - [] Mettre à jour le compteur de caractères
+- [x] **Task 3 : Restaurer au chargement** (AC: 2)
+ - [x] Charger les données au DOMContentLoaded
+ - [x] Pré-remplir chaque champ
+ - [x] Mettre à jour le compteur de caractères
-- [] **Task 4 : Vider après envoi réussi** (AC: 3)
- - [] Appeler clear() après succès (événement formSuccess)
- - [] Réinitialiser le formulaire
+- [x] **Task 4 : Vider après envoi réussi** (AC: 3)
+ - [x] Appeler clear() après succès (événement formSuccess)
+ - [x] Réinitialiser le formulaire
-- [] **Task 5 : Bouton "Effacer"** (AC: 4)
- - [] Écouter le clic sur le bouton (id="clear-form-btn")
- - [] Vider le localStorage
- - [] Réinitialiser le formulaire
- - [] Confirmation avec confirm()
+- [x] **Task 5 : Bouton "Effacer"** (AC: 4)
+ - [x] Écouter le clic sur le bouton (id="clear-form-btn")
+ - [x] Vider le localStorage
+ - [x] Réinitialiser le formulaire
+ - [x] Confirmation avec confirm()
-- [] **Task 6 : Exclure les données sensibles** (AC: 5)
- - [] Ne pas stocker csrf_token, password, recaptcha_token
- - [] Documenté dans EXCLUDED_FIELDS
+- [x] **Task 6 : Exclure les données sensibles** (AC: 5)
+ - [x] Ne pas stocker csrf_token, password, recaptcha_token
+ - [x] Documenté dans EXCLUDED_FIELDS
## Dev Notes
@@ -270,7 +270,11 @@ Exemple de données stockées:
## Dev Agent Record
### Agent Model Used
-Claude Opus 4.5 (claude-opus-4-5-20251101)
+GPT-5 Codex
+
+### Implementation Plan
+- Implémenter les tâches 1 à 6 dans l’ordre avec tests à chaque étape.
+- Intégrer le stockage dans `contact-form.js` et scripts dans la page.
### File List
| File | Action | Description |
@@ -278,16 +282,16 @@ Claude Opus 4.5 (claude-opus-4-5-20251101)
| `assets/js/state.js` | Created | Objet AppState pour gestion localStorage |
| `assets/js/contact-form.js` | Modified | Ajout classe ContactFormPersistence |
| `pages/contact.php` | Modified | Bouton Effacer + script state.js |
+| `tests/contact-state.test.php` | Added | Tests persistance localStorage |
+| `tests/contact.test.php` | Modified | Vérification clear button + scripts |
+| `tests/run.ps1` | Modified | Ajout du test contact-state |
### Completion Notes
-- AppState avec clé unique `portfolio_contact_form`
-- Vérification disponibilité localStorage (try/catch)
-- Filtrage des champs sensibles (csrf_token, password, recaptcha_token)
-- Debounce de 500ms sur la sauvegarde
-- Restauration automatique au chargement de la page
-- Bouton "Effacer" avec confirmation et reset complet
-- Événement `formSuccess` pour vider après envoi réussi
-- Scripts chargés avec defer pour ne pas bloquer le rendu
+- AppState avec clé unique `portfolio_contact_form` et exclusions sensibles
+- Sauvegarde avec debounce 500ms + restauration au chargement
+- Bouton Effacer avec confirm + reset complet
+- Listener `formSuccess` pour purge post-envoi
+- Tests : `powershell -ExecutionPolicy Bypass -File tests/run.ps1`
### Debug Log References
Aucun problème rencontré.
@@ -298,3 +302,4 @@ Aucun problème rencontré.
|------|---------|-------------|--------|
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
| 2026-01-24 | 1.0 | Implémentation complète | James (Dev) |
+| 2026-02-04 | 1.1 | Persistance localStorage | Amelia (Dev) |
diff --git a/pages/contact.php b/pages/contact.php
index 88de4c7..6a77d27 100644
--- a/pages/contact.php
+++ b/pages/contact.php
@@ -136,7 +136,7 @@ include_template('navbar', compact('currentPage'));
-