✨ Story 5.1: formulaire contact HTML5
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Ready for Dev
|
review
|
||||||
|
|
||||||
## Story
|
## Story
|
||||||
|
|
||||||
@@ -22,41 +22,41 @@ Ready for Dev
|
|||||||
|
|
||||||
## Tasks / Subtasks
|
## Tasks / Subtasks
|
||||||
|
|
||||||
- [] **Task 1 : Créer la page contact.php** (AC: 1)
|
- [x] **Task 1 : Créer la page contact.php** (AC: 1)
|
||||||
- [] Mettre à jour `pages/contact.php`
|
- [x] Mettre à jour `pages/contact.php`
|
||||||
- [] Inclure header, navbar, footer
|
- [x] Inclure header, navbar, footer
|
||||||
- [] Route `/contact` déjà configurée (Story 3.2)
|
- [x] Route `/contact` déjà configurée (Story 3.2)
|
||||||
|
|
||||||
- [] **Task 2 : Créer la structure du formulaire** (AC: 1, 6)
|
- [x] **Task 2 : Créer la structure du formulaire** (AC: 1, 6)
|
||||||
- [] Balise `<form>` avec method POST et action
|
- [x] Balise `<form>` avec method POST et action
|
||||||
- [] Champ Nom avec label associé (for/id)
|
- [x] Champ Nom avec label associé (for/id)
|
||||||
- [] Champ Prénom avec label associé
|
- [x] Champ Prénom avec label associé
|
||||||
- [] Champ Email avec label associé
|
- [x] Champ Email avec label associé
|
||||||
- [] Champ Entreprise (optionnel)
|
- [x] Champ Entreprise (optionnel)
|
||||||
- [] Dropdown Catégorie
|
- [x] Dropdown Catégorie
|
||||||
- [] Champ Objet
|
- [x] Champ Objet
|
||||||
- [] Textarea Message
|
- [x] Textarea Message
|
||||||
|
|
||||||
- [] **Task 3 : Configurer les attributs HTML5** (AC: 2, 5)
|
- [x] **Task 3 : Configurer les attributs HTML5** (AC: 2, 5)
|
||||||
- [] `type="email"` sur le champ email
|
- [x] `type="email"` sur le champ email
|
||||||
- [] `required` sur les champs obligatoires
|
- [x] `required` sur les champs obligatoires
|
||||||
- [] `maxlength` appropriés (100, 255, 200, 5000)
|
- [x] `maxlength` appropriés (100, 255, 200, 5000)
|
||||||
- [] `placeholder` pour guider la saisie
|
- [x] `placeholder` pour guider la saisie
|
||||||
- [] `autocomplete` pour les champs standards
|
- [x] `autocomplete` pour les champs standards
|
||||||
|
|
||||||
- [] **Task 4 : Marquer les champs requis** (AC: 4)
|
- [x] **Task 4 : Marquer les champs requis** (AC: 4)
|
||||||
- [] Astérisque visuel sur les labels (span.text-primary)
|
- [x] Astérisque visuel sur les labels (span.text-primary)
|
||||||
- [] Indication "(optionnel)" sur entreprise
|
- [x] Indication "(optionnel)" sur entreprise
|
||||||
|
|
||||||
- [] **Task 5 : Configurer le dropdown** (AC: 3)
|
- [x] **Task 5 : Configurer le dropdown** (AC: 3)
|
||||||
- [] Option par défaut "Sélectionnez une catégorie..."
|
- [x] Option par défaut "Sélectionnez une catégorie..."
|
||||||
- [] 3 options : projet, poste, autre
|
- [x] 3 options : projet, poste, autre
|
||||||
- [] Attribut `required`
|
- [x] Attribut `required`
|
||||||
|
|
||||||
- [] **Task 6 : Rendre responsive** (AC: 7)
|
- [x] **Task 6 : Rendre responsive** (AC: 7)
|
||||||
- [] Grille sm:grid-cols-2 pour Nom/Prénom et Email/Entreprise
|
- [x] Grille sm:grid-cols-2 pour Nom/Prénom et Email/Entreprise
|
||||||
- [] Champs empilés sur mobile (grid-cols-1)
|
- [x] Champs empilés sur mobile (grid-cols-1)
|
||||||
- [] Boutons flex-col sur mobile, flex-row sur desktop
|
- [x] Boutons flex-col sur mobile, flex-row sur desktop
|
||||||
|
|
||||||
## Dev Notes
|
## Dev Notes
|
||||||
|
|
||||||
@@ -297,23 +297,29 @@ include_template('navbar', compact('currentPage'));
|
|||||||
## Dev Agent Record
|
## Dev Agent Record
|
||||||
|
|
||||||
### Agent Model Used
|
### Agent Model Used
|
||||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
GPT-5 Codex
|
||||||
|
|
||||||
|
### Implementation Plan
|
||||||
|
- Implémenter les tasks 1 à 6 dans l’ordre avec tests PHP à chaque étape.
|
||||||
|
- Mettre à jour `pages/contact.php` progressivement et ajouter le JS du compteur de caractères si requis par les tests.
|
||||||
|
|
||||||
### File List
|
### File List
|
||||||
| File | Action | Description |
|
| File | Action | Description |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| `includes/functions.php` | Modified | Ajout generateCsrfToken() et verifyCsrfToken() |
|
| `includes/functions.php` | Modified | Fonctions CSRF (génération/validation) |
|
||||||
| `pages/contact.php` | Modified | Formulaire complet avec 7 champs |
|
| `pages/contact.php` | Modified | Formulaire complet avec validation HTML5 et layout responsive |
|
||||||
|
| `tests/contact.test.php` | Added | Tests formulaire contact (structure + attributs) |
|
||||||
|
| `tests/run.ps1` | Modified | Ajout du test contact |
|
||||||
|
|
||||||
### Completion Notes
|
### Completion Notes
|
||||||
- Formulaire avec 7 champs : nom, prénom, email, entreprise, catégorie, objet, message
|
- Task 1 : page contact initialisée avec header, navbar, footer, et en-tête "Me Contacter"
|
||||||
- Token CSRF généré et stocké en session
|
- Task 2 : formulaire structuré avec labels associés et champs de base
|
||||||
- Validation HTML5 : required, type="email", maxlength
|
- Task 3 : attributs HTML5 (required/type/maxlength/placeholder/autocomplete) configurés
|
||||||
- Autocomplete sur les champs standards (family-name, given-name, email, organization)
|
- Task 4 : labels requis marqués et mention optionnelle ajoutée
|
||||||
- Layout responsive : 2 colonnes sur desktop, 1 sur mobile
|
- Task 5 : dropdown catégorie complété avec placeholder et options
|
||||||
- Compteur de caractères en temps réel pour le message
|
- Task 6 : mise en page responsive en grille et boutons adaptatifs
|
||||||
- Placeholders de messages succès/erreur (pour Story 5.6)
|
- Token CSRF généré et injecté dans le formulaire
|
||||||
- Spinner de chargement préparé (pour Story 5.2/5.5)
|
- Tests : `powershell -ExecutionPolicy Bypass -File tests/run.ps1`
|
||||||
|
|
||||||
### Debug Log References
|
### Debug Log References
|
||||||
Aucun problème rencontré.
|
Aucun problème rencontré.
|
||||||
@@ -324,3 +330,4 @@ Aucun problème rencontré.
|
|||||||
|------|---------|-------------|--------|
|
|------|---------|-------------|--------|
|
||||||
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
|
||||||
| 2026-01-23 | 1.0 | Implémentation complète | James (Dev) |
|
| 2026-01-23 | 1.0 | Implémentation complète | James (Dev) |
|
||||||
|
| 2026-02-04 | 1.1 | Formulaire contact HTML5 + responsive | Amelia (Dev) |
|
||||||
|
|||||||
@@ -181,3 +181,25 @@ function getTestimonialByProject(string $projectSlug): ?array
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF token helpers
|
||||||
|
*/
|
||||||
|
function generateCsrfToken(): string
|
||||||
|
{
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
return $_SESSION['csrf_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyCsrfToken(?string $token): bool
|
||||||
|
{
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
return !empty($token) && !empty($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
|
||||||
|
}
|
||||||
@@ -1,16 +1,157 @@
|
|||||||
<?php
|
<?php
|
||||||
$pageTitle = 'Contact';
|
$pageTitle = 'Contact';
|
||||||
|
$pageDescription = 'Contactez-moi pour discuter de votre projet web ou d\'une opportunité professionnelle.';
|
||||||
$currentPage = 'contact';
|
$currentPage = 'contact';
|
||||||
|
|
||||||
include_template('header', compact('pageTitle'));
|
$csrfToken = generateCsrfToken();
|
||||||
|
|
||||||
|
include_template('header', compact('pageTitle', 'pageDescription'));
|
||||||
include_template('navbar', compact('currentPage'));
|
include_template('navbar', compact('currentPage'));
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<main class="min-h-screen">
|
<main>
|
||||||
<div class="container-content py-20">
|
<section class="section">
|
||||||
<h1 class="text-heading mb-4">Contact</h1>
|
<div class="container-content">
|
||||||
<p class="text-text-secondary">Le formulaire arrive bientôt.</p>
|
<div class="max-w-2xl mx-auto">
|
||||||
</div>
|
<div class="text-center mb-12">
|
||||||
|
<h1 class="text-display mb-4">Me Contacter</h1>
|
||||||
|
<p class="text-xl text-text-secondary">
|
||||||
|
Une question, un projet ? Parlons-en !
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
id="contact-form"
|
||||||
|
method="POST"
|
||||||
|
action="/api/contact.php"
|
||||||
|
class="space-y-6"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label for="nom" class="label">Nom <span class="text-primary">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="nom"
|
||||||
|
name="nom"
|
||||||
|
class="input"
|
||||||
|
required
|
||||||
|
maxlength="100"
|
||||||
|
autocomplete="family-name"
|
||||||
|
placeholder="Dupont"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="prenom" class="label">Prénom <span class="text-primary">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="prenom"
|
||||||
|
name="prenom"
|
||||||
|
class="input"
|
||||||
|
required
|
||||||
|
maxlength="100"
|
||||||
|
autocomplete="given-name"
|
||||||
|
placeholder="Marie"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="label">Email <span class="text-primary">*</span></label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
class="input"
|
||||||
|
required
|
||||||
|
maxlength="255"
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="marie.dupont@example.com"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="entreprise" class="label">Entreprise <span class="text-text-muted">(optionnel)</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="entreprise"
|
||||||
|
name="entreprise"
|
||||||
|
class="input"
|
||||||
|
maxlength="200"
|
||||||
|
autocomplete="organization"
|
||||||
|
placeholder="Nom de votre entreprise"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="categorie" class="label">Catégorie <span class="text-primary">*</span></label>
|
||||||
|
<select id="categorie" name="categorie" class="input" required>
|
||||||
|
<option value="" disabled selected>Sélectionnez une catégorie...</option>
|
||||||
|
<option value="projet">Je souhaite parler de mon projet</option>
|
||||||
|
<option value="poste">Je souhaite vous proposer un poste</option>
|
||||||
|
<option value="autre">Autre</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="objet" class="label">Objet <span class="text-primary">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="objet"
|
||||||
|
name="objet"
|
||||||
|
class="input"
|
||||||
|
required
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="Résumez votre demande en quelques mots"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="message" class="label">Message <span class="text-primary">*</span></label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
name="message"
|
||||||
|
class="textarea"
|
||||||
|
required
|
||||||
|
maxlength="5000"
|
||||||
|
rows="6"
|
||||||
|
placeholder="Décrivez votre projet ou votre demande..."
|
||||||
|
></textarea>
|
||||||
|
<p class="text-xs text-text-muted mt-2">
|
||||||
|
<span id="message-count">0</span> / 5000 caractères
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 pt-4">
|
||||||
|
<button type="submit" class="btn-primary flex-1 justify-center">
|
||||||
|
Envoyer le message
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-ghost">
|
||||||
|
Effacer le formulaire
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const messageField = document.getElementById('message');
|
||||||
|
const messageCount = document.getElementById('message-count');
|
||||||
|
|
||||||
|
if (messageField && messageCount) {
|
||||||
|
const updateCount = () => {
|
||||||
|
messageCount.textContent = String(messageField.value.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCount();
|
||||||
|
messageField.addEventListener('input', updateCount);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<?php include_template('footer'); ?>
|
<?php include_template('footer'); ?>
|
||||||
74
tests/contact.test.php
Normal file
74
tests/contact.test.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../includes/functions.php';
|
||||||
|
|
||||||
|
function assertTrue($cond, $msg) {
|
||||||
|
if (!$cond) {
|
||||||
|
fwrite(STDERR, $msg . PHP_EOL);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = file_get_contents(__DIR__ . '/../pages/contact.php');
|
||||||
|
|
||||||
|
assertTrue(strpos($content, "include_template('header'") !== false, 'missing header include');
|
||||||
|
assertTrue(strpos($content, "include_template('navbar'") !== false, 'missing navbar include');
|
||||||
|
assertTrue(strpos($content, "include_template('footer'") !== false, 'missing footer include');
|
||||||
|
assertTrue(strpos($content, 'Me Contacter') !== false, 'missing contact heading');
|
||||||
|
|
||||||
|
assertTrue(strpos($content, '<form') !== false, 'missing form');
|
||||||
|
assertTrue(strpos($content, 'method="POST"') !== false, 'missing form method');
|
||||||
|
assertTrue(strpos($content, 'action="/api/contact.php"') !== false, 'missing form action');
|
||||||
|
|
||||||
|
assertTrue(strpos($content, 'label for="nom"') !== false, 'missing nom label');
|
||||||
|
assertTrue(strpos($content, 'id="nom"') !== false, 'missing nom input');
|
||||||
|
assertTrue(strpos($content, 'label for="prenom"') !== false, 'missing prenom label');
|
||||||
|
assertTrue(strpos($content, 'id="prenom"') !== false, 'missing prenom input');
|
||||||
|
assertTrue(strpos($content, 'label for="email"') !== false, 'missing email label');
|
||||||
|
assertTrue(strpos($content, 'id="email"') !== false, 'missing email input');
|
||||||
|
assertTrue(strpos($content, 'label for="entreprise"') !== false, 'missing entreprise label');
|
||||||
|
assertTrue(strpos($content, 'id="entreprise"') !== false, 'missing entreprise input');
|
||||||
|
assertTrue(strpos($content, 'label for="categorie"') !== false, 'missing categorie label');
|
||||||
|
assertTrue(strpos($content, 'id="categorie"') !== false, 'missing categorie select');
|
||||||
|
assertTrue(strpos($content, 'label for="objet"') !== false, 'missing objet label');
|
||||||
|
assertTrue(strpos($content, 'id="objet"') !== false, 'missing objet input');
|
||||||
|
assertTrue(strpos($content, 'label for="message"') !== false, 'missing message label');
|
||||||
|
assertTrue(strpos($content, 'id="message"') !== false, 'missing message textarea');
|
||||||
|
|
||||||
|
assertTrue(preg_match('/id="nom"[^>]*required/', $content) === 1, 'nom missing required');
|
||||||
|
assertTrue(preg_match('/id="prenom"[^>]*required/', $content) === 1, 'prenom missing required');
|
||||||
|
assertTrue(preg_match('/id="email"[^>]*required/', $content) === 1, 'email missing required');
|
||||||
|
assertTrue(preg_match('/id="categorie"[^>]*required/', $content) === 1, 'categorie missing required');
|
||||||
|
assertTrue(preg_match('/id="objet"[^>]*required/', $content) === 1, 'objet missing required');
|
||||||
|
assertTrue(preg_match('/id="message"[^>]*required/', $content) === 1, 'message missing required');
|
||||||
|
|
||||||
|
assertTrue(preg_match('/<input[^>]*id="email"[^>]*type="email"|<input[^>]*type="email"[^>]*id="email"/', $content) === 1, 'email type not email');
|
||||||
|
|
||||||
|
assertTrue(preg_match('/id="nom"[^>]*maxlength="100"/', $content) === 1, 'nom maxlength');
|
||||||
|
assertTrue(preg_match('/id="prenom"[^>]*maxlength="100"/', $content) === 1, 'prenom maxlength');
|
||||||
|
assertTrue(preg_match('/id="email"[^>]*maxlength="255"/', $content) === 1, 'email maxlength');
|
||||||
|
assertTrue(preg_match('/id="entreprise"[^>]*maxlength="200"/', $content) === 1, 'entreprise maxlength');
|
||||||
|
assertTrue(preg_match('/id="objet"[^>]*maxlength="200"/', $content) === 1, 'objet maxlength');
|
||||||
|
assertTrue(preg_match('/id="message"[^>]*maxlength="5000"/', $content) === 1, 'message maxlength');
|
||||||
|
|
||||||
|
assertTrue(preg_match('/id="nom"[^>]*placeholder="Dupont"/', $content) === 1, 'nom placeholder');
|
||||||
|
assertTrue(preg_match('/id="prenom"[^>]*placeholder="Marie"/', $content) === 1, 'prenom placeholder');
|
||||||
|
assertTrue(preg_match('/id="email"[^>]*placeholder="marie\\.dupont@example\\.com"/', $content) === 1, 'email placeholder');
|
||||||
|
assertTrue(preg_match('/id="entreprise"[^>]*placeholder="Nom de votre entreprise"/', $content) === 1, 'entreprise placeholder');
|
||||||
|
assertTrue(preg_match('/id="objet"[^>]*placeholder="Résumez votre demande en quelques mots"/', $content) === 1, 'objet placeholder');
|
||||||
|
assertTrue(preg_match('/id="message"[^>]*placeholder="Décrivez votre projet ou votre demande\\.\\.\\."/', $content) === 1, 'message placeholder');
|
||||||
|
|
||||||
|
assertTrue(preg_match('/id="nom"[^>]*autocomplete="family-name"/', $content) === 1, 'nom autocomplete');
|
||||||
|
assertTrue(preg_match('/id="prenom"[^>]*autocomplete="given-name"/', $content) === 1, 'prenom autocomplete');
|
||||||
|
assertTrue(preg_match('/id="email"[^>]*autocomplete="email"/', $content) === 1, 'email autocomplete');
|
||||||
|
assertTrue(preg_match('/id="entreprise"[^>]*autocomplete="organization"/', $content) === 1, 'entreprise autocomplete');
|
||||||
|
|
||||||
|
assertTrue(strpos($content, 'text-primary') !== false, 'missing required marker');
|
||||||
|
assertTrue(strpos($content, '(optionnel)') !== false, 'missing optional marker');
|
||||||
|
assertTrue(strpos($content, 'Sélectionnez une catégorie...') !== false, 'missing categorie placeholder');
|
||||||
|
assertTrue(strpos($content, 'Je souhaite parler de mon projet') !== false, 'missing categorie projet');
|
||||||
|
assertTrue(strpos($content, 'Je souhaite vous proposer un poste') !== false, 'missing categorie poste');
|
||||||
|
assertTrue(strpos($content, 'Autre') !== false, 'missing categorie autre');
|
||||||
|
assertTrue(strpos($content, 'grid grid-cols-1 sm:grid-cols-2') !== false, 'missing responsive grid');
|
||||||
|
assertTrue(strpos($content, 'flex flex-col sm:flex-row') !== false, 'missing responsive buttons');
|
||||||
|
|
||||||
|
fwrite(STDOUT, "OK\n");
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
& (Join-Path $here 'structure.test.ps1')
|
& (Join-Path $here 'structure.test.ps1')
|
||||||
& (Join-Path $here 'tailwind.test.ps1')
|
& (Join-Path $here 'tailwind.test.ps1')
|
||||||
@@ -20,4 +20,5 @@ php (Join-Path $here 'tools.test.php')
|
|||||||
php (Join-Path $here 'about.test.php')
|
php (Join-Path $here 'about.test.php')
|
||||||
php (Join-Path $here 'passions.test.php')
|
php (Join-Path $here 'passions.test.php')
|
||||||
php (Join-Path $here 'testimonials.test.php')
|
php (Join-Path $here 'testimonials.test.php')
|
||||||
|
php (Join-Path $here 'contact.test.php')
|
||||||
'OK'
|
'OK'
|
||||||
Reference in New Issue
Block a user