Story 5.1: formulaire contact HTML5

This commit is contained in:
2026-02-04 20:41:37 +01:00
parent 325625f664
commit 70580f2d96
5 changed files with 296 additions and 51 deletions

View File

@@ -2,7 +2,7 @@
## Status
Ready for Dev
review
## Story
@@ -22,41 +22,41 @@ Ready for Dev
## Tasks / Subtasks
- [] **Task 1 : Créer la page contact.php** (AC: 1)
- [] Mettre à jour `pages/contact.php`
- [] Inclure header, navbar, footer
- [] Route `/contact` déjà configurée (Story 3.2)
- [x] **Task 1 : Créer la page contact.php** (AC: 1)
- [x] Mettre à jour `pages/contact.php`
- [x] Inclure header, navbar, footer
- [x] Route `/contact` déjà configurée (Story 3.2)
- [] **Task 2 : Créer la structure du formulaire** (AC: 1, 6)
- [] Balise `<form>` avec method POST et action
- [] Champ Nom avec label associé (for/id)
- [] Champ Prénom avec label associé
- [] Champ Email avec label associé
- [] Champ Entreprise (optionnel)
- [] Dropdown Catégorie
- [] Champ Objet
- [] Textarea Message
- [x] **Task 2 : Créer la structure du formulaire** (AC: 1, 6)
- [x] Balise `<form>` avec method POST et action
- [x] Champ Nom avec label associé (for/id)
- [x] Champ Prénom avec label associé
- [x] Champ Email avec label associé
- [x] Champ Entreprise (optionnel)
- [x] Dropdown Catégorie
- [x] Champ Objet
- [x] Textarea Message
- [] **Task 3 : Configurer les attributs HTML5** (AC: 2, 5)
- [] `type="email"` sur le champ email
- [] `required` sur les champs obligatoires
- [] `maxlength` appropriés (100, 255, 200, 5000)
- [] `placeholder` pour guider la saisie
- [] `autocomplete` pour les champs standards
- [x] **Task 3 : Configurer les attributs HTML5** (AC: 2, 5)
- [x] `type="email"` sur le champ email
- [x] `required` sur les champs obligatoires
- [x] `maxlength` appropriés (100, 255, 200, 5000)
- [x] `placeholder` pour guider la saisie
- [x] `autocomplete` pour les champs standards
- [] **Task 4 : Marquer les champs requis** (AC: 4)
- [] Astérisque visuel sur les labels (span.text-primary)
- [] Indication "(optionnel)" sur entreprise
- [x] **Task 4 : Marquer les champs requis** (AC: 4)
- [x] Astérisque visuel sur les labels (span.text-primary)
- [x] Indication "(optionnel)" sur entreprise
- [] **Task 5 : Configurer le dropdown** (AC: 3)
- [] Option par défaut "Sélectionnez une catégorie..."
- [] 3 options : projet, poste, autre
- [] Attribut `required`
- [x] **Task 5 : Configurer le dropdown** (AC: 3)
- [x] Option par défaut "Sélectionnez une catégorie..."
- [x] 3 options : projet, poste, autre
- [x] Attribut `required`
- [] **Task 6 : Rendre responsive** (AC: 7)
- [] Grille sm:grid-cols-2 pour Nom/Prénom et Email/Entreprise
- [] Champs empilés sur mobile (grid-cols-1)
- [] Boutons flex-col sur mobile, flex-row sur desktop
- [x] **Task 6 : Rendre responsive** (AC: 7)
- [x] Grille sm:grid-cols-2 pour Nom/Prénom et Email/Entreprise
- [x] Champs empilés sur mobile (grid-cols-1)
- [x] Boutons flex-col sur mobile, flex-row sur desktop
## Dev Notes
@@ -297,23 +297,29 @@ include_template('navbar', compact('currentPage'));
## Dev Agent Record
### Agent Model Used
Claude Opus 4.5 (claude-opus-4-5-20251101)
GPT-5 Codex
### Implementation Plan
- Implémenter les tasks 1 à 6 dans lordre 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 | Action | Description |
|------|--------|-------------|
| `includes/functions.php` | Modified | Ajout generateCsrfToken() et verifyCsrfToken() |
| `pages/contact.php` | Modified | Formulaire complet avec 7 champs |
| `includes/functions.php` | Modified | Fonctions CSRF (génération/validation) |
| `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
- Formulaire avec 7 champs : nom, prénom, email, entreprise, catégorie, objet, message
- Token CSRF généré et stocké en session
- Validation HTML5 : required, type="email", maxlength
- Autocomplete sur les champs standards (family-name, given-name, email, organization)
- Layout responsive : 2 colonnes sur desktop, 1 sur mobile
- Compteur de caractères en temps réel pour le message
- Placeholders de messages succès/erreur (pour Story 5.6)
- Spinner de chargement préparé (pour Story 5.2/5.5)
- Task 1 : page contact initialisée avec header, navbar, footer, et en-tête "Me Contacter"
- Task 2 : formulaire structuré avec labels associés et champs de base
- Task 3 : attributs HTML5 (required/type/maxlength/placeholder/autocomplete) configurés
- Task 4 : labels requis marqués et mention optionnelle ajoutée
- Task 5 : dropdown catégorie complété avec placeholder et options
- Task 6 : mise en page responsive en grille et boutons adaptatifs
- Token CSRF généré et injecté dans le formulaire
- Tests : `powershell -ExecutionPolicy Bypass -File tests/run.ps1`
### Debug Log References
Aucun problème rencontré.
@@ -324,3 +330,4 @@ Aucun problème rencontré.
|------|---------|-------------|--------|
| 2026-01-22 | 0.1 | Création initiale | Sarah (PO) |
| 2026-01-23 | 1.0 | Implémentation complète | James (Dev) |
| 2026-02-04 | 1.1 | Formulaire contact HTML5 + responsive | Amelia (Dev) |

View File

@@ -181,3 +181,25 @@ function getTestimonialByProject(string $projectSlug): ?array
}
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);
}

View File

@@ -1,16 +1,157 @@
<?php
$pageTitle = 'Contact';
$pageDescription = 'Contactez-moi pour discuter de votre projet web ou d\'une opportunité professionnelle.';
$currentPage = 'contact';
include_template('header', compact('pageTitle'));
$csrfToken = generateCsrfToken();
include_template('header', compact('pageTitle', 'pageDescription'));
include_template('navbar', compact('currentPage'));
?>
<main class="min-h-screen">
<div class="container-content py-20">
<h1 class="text-heading mb-4">Contact</h1>
<p class="text-text-secondary">Le formulaire arrive bientôt.</p>
<main>
<section class="section">
<div class="container-content">
<div class="max-w-2xl mx-auto">
<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>
<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'); ?>

74
tests/contact.test.php Normal file
View 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");

View File

@@ -1,4 +1,4 @@
$ErrorActionPreference = 'Stop'
$ErrorActionPreference = 'Stop'
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
& (Join-Path $here 'structure.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 'passions.test.php')
php (Join-Path $here 'testimonials.test.php')
php (Join-Path $here 'contact.test.php')
'OK'