Setup complet de l'infrastructure projet : - Frontend Nuxt 4 (SSR, TypeScript, i18n, Pinia, TailwindCSS) - Backend Laravel 12 API-only avec middleware X-API-Key et CORS - Design tokens (sky-dark, sky-accent, sky-text) et polices (Merriweather, Inter) - Documentation planning et implementation artifacts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
655 lines
19 KiB
Markdown
655 lines
19 KiB
Markdown
# Story 4.8: Page Contact - Formulaire et célébration
|
|
|
|
Status: ready-for-dev
|
|
|
|
## Story
|
|
|
|
As a visiteur ayant trouvé le développeur,
|
|
I want le contacter facilement avec une célébration,
|
|
so that l'envoi du message est une conclusion satisfaisante.
|
|
|
|
## Acceptance Criteria
|
|
|
|
1. **Given** le visiteur est sur la page Contact après la révélation **When** la page s'affiche **Then** un message de félicitations avec stats du parcours est visible (zones visitées, easter eggs trouvés, temps passé)
|
|
2. **And** un formulaire de contact s'affiche : nom (requis), email (requis), message (requis)
|
|
3. **And** la validation temps réel est effectuée côté frontend (champs requis, format email)
|
|
4. **And** les erreurs sont communiquées par le narrateur (pas de messages d'erreur classiques)
|
|
5. **And** un champ honeypot invisible est présent (anti-spam)
|
|
6. **And** reCAPTCHA v3 est intégré de manière invisible
|
|
7. **And** le bouton d'envoi utilise la couleur accent (`sky-accent`)
|
|
8. **Given** le formulaire est soumis **When** les données sont envoyées à l'API **Then** la validation backend Laravel (Form Request) vérifie les données
|
|
9. **And** le rate limiting (5 req/min par IP) est appliqué
|
|
10. **And** l'email est envoyé via Laravel Mail
|
|
11. **And** une animation de célébration s'affiche (confettis ou similaire)
|
|
12. **And** le narrateur confirme l'envoi avec un message personnalisé
|
|
13. **And** en cas d'erreur, le narrateur explique le problème avec bienveillance
|
|
|
|
## Tasks / Subtasks
|
|
|
|
- [ ] **Task 1: Créer la page contact** (AC: #1, #2)
|
|
- [ ] Créer `frontend/app/pages/contact.vue`
|
|
- [ ] Afficher les stats du parcours (zones, easter eggs, temps)
|
|
- [ ] Formulaire avec nom, email, message
|
|
|
|
- [ ] **Task 2: Implémenter la validation frontend** (AC: #3, #4)
|
|
- [ ] Validation en temps réel avec Vuelidate ou Vee-Validate
|
|
- [ ] Format email valide
|
|
- [ ] Champs requis
|
|
- [ ] Erreurs via le narrateur (pas de messages classiques)
|
|
|
|
- [ ] **Task 3: Ajouter les protections anti-spam** (AC: #5, #6)
|
|
- [ ] Champ honeypot invisible
|
|
- [ ] Intégrer reCAPTCHA v3 (invisible)
|
|
- [ ] Obtenir token reCAPTCHA avant envoi
|
|
|
|
- [ ] **Task 4: Créer l'API de contact** (AC: #8, #9, #10)
|
|
- [ ] Créer `app/Http/Controllers/Api/ContactController.php`
|
|
- [ ] Form Request pour validation backend
|
|
- [ ] Rate limiting : 5 requêtes/min par IP
|
|
- [ ] Envoi email via Laravel Mail
|
|
- [ ] Vérification reCAPTCHA côté serveur
|
|
|
|
- [ ] **Task 5: Créer le template d'email**
|
|
- [ ] Template Blade pour l'email de contact
|
|
- [ ] Inclure : nom, email, message
|
|
- [ ] Design sobre et professionnel
|
|
|
|
- [ ] **Task 6: Animation de succès** (AC: #11, #12)
|
|
- [ ] Confettis après envoi réussi
|
|
- [ ] Message du narrateur confirmant l'envoi
|
|
- [ ] Transition vers le challenge post-formulaire
|
|
|
|
- [ ] **Task 7: Gestion des erreurs** (AC: #13)
|
|
- [ ] Erreur réseau : narrateur explique
|
|
- [ ] Rate limit : narrateur demande de patienter
|
|
- [ ] reCAPTCHA : narrateur suggère de réessayer
|
|
|
|
- [ ] **Task 8: Tests et validation**
|
|
- [ ] Tester la validation frontend
|
|
- [ ] Tester l'envoi complet (API + email)
|
|
- [ ] Vérifier le rate limiting
|
|
- [ ] Tester le honeypot
|
|
- [ ] Valider reCAPTCHA
|
|
|
|
## Dev Notes
|
|
|
|
### Page contact.vue
|
|
|
|
```vue
|
|
<!-- frontend/app/pages/contact.vue -->
|
|
<script setup lang="ts">
|
|
import confetti from 'canvas-confetti'
|
|
import { useVuelidate } from '@vuelidate/core'
|
|
import { required, email as emailValidator } from '@vuelidate/validators'
|
|
|
|
const { t } = useI18n()
|
|
const config = useRuntimeConfig()
|
|
const router = useRouter()
|
|
const progressionStore = useProgressionStore()
|
|
const narrator = useNarrator()
|
|
|
|
// Stats du parcours
|
|
const stats = computed(() => ({
|
|
zonesVisited: progressionStore.visitedSections.length,
|
|
zonesTotal: 4,
|
|
easterEggsFound: progressionStore.easterEggsFoundCount,
|
|
easterEggsTotal: 8,
|
|
challengeCompleted: progressionStore.challengeCompleted,
|
|
}))
|
|
|
|
// Formulaire
|
|
const form = reactive({
|
|
name: '',
|
|
email: '',
|
|
message: '',
|
|
honeypot: '', // Champ honeypot
|
|
})
|
|
|
|
const rules = {
|
|
name: { required },
|
|
email: { required, email: emailValidator },
|
|
message: { required },
|
|
}
|
|
|
|
const v$ = useVuelidate(rules, form)
|
|
|
|
// États
|
|
const isSubmitting = ref(false)
|
|
const isSuccess = ref(false)
|
|
|
|
// Récupérer le token reCAPTCHA
|
|
async function getRecaptchaToken(): Promise<string> {
|
|
return new Promise((resolve) => {
|
|
window.grecaptcha.ready(() => {
|
|
window.grecaptcha.execute(config.public.recaptchaSiteKey, { action: 'contact' })
|
|
.then(resolve)
|
|
})
|
|
})
|
|
}
|
|
|
|
// Soumission du formulaire
|
|
async function handleSubmit() {
|
|
const isValid = await v$.value.$validate()
|
|
|
|
if (!isValid) {
|
|
// Erreurs communiquées par le narrateur
|
|
narrator.showMessage('contact_validation_error')
|
|
return
|
|
}
|
|
|
|
// Vérifier le honeypot
|
|
if (form.honeypot) {
|
|
// C'est un bot, faire semblant de réussir
|
|
fakeSuccess()
|
|
return
|
|
}
|
|
|
|
isSubmitting.value = true
|
|
|
|
try {
|
|
const recaptchaToken = await getRecaptchaToken()
|
|
|
|
await $fetch('/contact', {
|
|
method: 'POST',
|
|
baseURL: config.public.apiUrl,
|
|
headers: {
|
|
'X-API-Key': config.public.apiKey,
|
|
},
|
|
body: {
|
|
name: form.name,
|
|
email: form.email,
|
|
message: form.message,
|
|
recaptcha_token: recaptchaToken,
|
|
},
|
|
})
|
|
|
|
// Succès !
|
|
isSuccess.value = true
|
|
launchConfetti()
|
|
narrator.showMessage('contact_success')
|
|
|
|
// Naviguer vers le challenge post-formulaire après délai
|
|
setTimeout(() => {
|
|
router.push('/challenge-bonus')
|
|
}, 5000)
|
|
|
|
} catch (error: any) {
|
|
handleError(error)
|
|
} finally {
|
|
isSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
function handleError(error: any) {
|
|
const status = error.response?.status
|
|
|
|
if (status === 429) {
|
|
narrator.showMessage('contact_rate_limited')
|
|
} else if (status === 422) {
|
|
narrator.showMessage('contact_validation_error')
|
|
} else {
|
|
narrator.showMessage('contact_error')
|
|
}
|
|
}
|
|
|
|
function fakeSuccess() {
|
|
isSuccess.value = true
|
|
launchConfetti()
|
|
}
|
|
|
|
function launchConfetti() {
|
|
confetti({
|
|
particleCount: 150,
|
|
spread: 100,
|
|
origin: { y: 0.6 },
|
|
colors: ['#fa784f', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6'],
|
|
})
|
|
}
|
|
|
|
// Message du narrateur au montage
|
|
onMounted(() => {
|
|
narrator.showMessage('contact_welcome')
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="contact-page min-h-screen bg-sky-dark py-12 px-4">
|
|
<div class="max-w-2xl mx-auto">
|
|
<!-- Stats du parcours -->
|
|
<div class="bg-sky-dark-50 rounded-xl p-6 mb-8 border border-sky-dark-100">
|
|
<h2 class="text-lg font-ui font-bold text-sky-text mb-4">
|
|
{{ t('contact.yourJourney') }}
|
|
</h2>
|
|
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
|
<div>
|
|
<p class="text-2xl font-ui font-bold text-sky-accent">
|
|
{{ stats.zonesVisited }}/{{ stats.zonesTotal }}
|
|
</p>
|
|
<p class="text-sm text-sky-text-muted">{{ t('contact.zones') }}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-2xl font-ui font-bold text-sky-accent">
|
|
{{ stats.easterEggsFound }}/{{ stats.easterEggsTotal }}
|
|
</p>
|
|
<p class="text-sm text-sky-text-muted">{{ t('contact.easterEggs') }}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-2xl font-ui font-bold" :class="stats.challengeCompleted ? 'text-green-400' : 'text-sky-text-muted'">
|
|
{{ stats.challengeCompleted ? '✓' : '—' }}
|
|
</p>
|
|
<p class="text-sm text-sky-text-muted">{{ t('contact.challenge') }}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-2xl font-ui font-bold text-sky-accent">🏆</p>
|
|
<p class="text-sm text-sky-text-muted">{{ t('contact.explorer') }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Titre -->
|
|
<h1 class="text-3xl font-ui font-bold text-sky-text text-center mb-2">
|
|
{{ t('contact.title') }}
|
|
</h1>
|
|
|
|
<p class="text-sky-text-muted text-center mb-8 font-narrative">
|
|
{{ t('contact.subtitle') }}
|
|
</p>
|
|
|
|
<!-- Formulaire ou message de succès -->
|
|
<Transition name="fade" mode="out-in">
|
|
<!-- Formulaire -->
|
|
<form
|
|
v-if="!isSuccess"
|
|
class="space-y-6"
|
|
@submit.prevent="handleSubmit"
|
|
>
|
|
<!-- Honeypot (invisible) -->
|
|
<input
|
|
v-model="form.honeypot"
|
|
type="text"
|
|
name="website"
|
|
class="hidden"
|
|
tabindex="-1"
|
|
autocomplete="off"
|
|
/>
|
|
|
|
<!-- Nom -->
|
|
<div>
|
|
<label class="block text-sm font-ui font-medium text-sky-text mb-2">
|
|
{{ t('contact.name') }} *
|
|
</label>
|
|
<input
|
|
v-model="form.name"
|
|
type="text"
|
|
class="w-full px-4 py-3 bg-sky-dark border rounded-lg text-sky-text placeholder-sky-text-muted focus:outline-none focus:ring-2 focus:ring-sky-accent"
|
|
:class="v$.name.$error ? 'border-red-500' : 'border-sky-dark-100'"
|
|
:placeholder="t('contact.namePlaceholder')"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Email -->
|
|
<div>
|
|
<label class="block text-sm font-ui font-medium text-sky-text mb-2">
|
|
{{ t('contact.email') }} *
|
|
</label>
|
|
<input
|
|
v-model="form.email"
|
|
type="email"
|
|
class="w-full px-4 py-3 bg-sky-dark border rounded-lg text-sky-text placeholder-sky-text-muted focus:outline-none focus:ring-2 focus:ring-sky-accent"
|
|
:class="v$.email.$error ? 'border-red-500' : 'border-sky-dark-100'"
|
|
:placeholder="t('contact.emailPlaceholder')"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Message -->
|
|
<div>
|
|
<label class="block text-sm font-ui font-medium text-sky-text mb-2">
|
|
{{ t('contact.message') }} *
|
|
</label>
|
|
<textarea
|
|
v-model="form.message"
|
|
rows="5"
|
|
class="w-full px-4 py-3 bg-sky-dark border rounded-lg text-sky-text placeholder-sky-text-muted focus:outline-none focus:ring-2 focus:ring-sky-accent resize-none"
|
|
:class="v$.message.$error ? 'border-red-500' : 'border-sky-dark-100'"
|
|
:placeholder="t('contact.messagePlaceholder')"
|
|
></textarea>
|
|
</div>
|
|
|
|
<!-- Bouton envoi -->
|
|
<button
|
|
type="submit"
|
|
class="w-full py-4 bg-sky-accent text-white font-ui font-semibold rounded-lg hover:bg-sky-accent/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
:disabled="isSubmitting"
|
|
>
|
|
<span v-if="isSubmitting" class="flex items-center justify-center gap-2">
|
|
<svg class="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
{{ t('contact.sending') }}
|
|
</span>
|
|
<span v-else>
|
|
{{ t('contact.send') }}
|
|
</span>
|
|
</button>
|
|
|
|
<!-- Note reCAPTCHA -->
|
|
<p class="text-xs text-sky-text-muted text-center">
|
|
{{ t('contact.recaptchaNote') }}
|
|
</p>
|
|
</form>
|
|
|
|
<!-- Message de succès -->
|
|
<div
|
|
v-else
|
|
class="text-center py-12"
|
|
>
|
|
<div class="text-6xl mb-4">🎉</div>
|
|
|
|
<h2 class="text-2xl font-ui font-bold text-sky-accent mb-4">
|
|
{{ t('contact.successTitle') }}
|
|
</h2>
|
|
|
|
<p class="font-narrative text-lg text-sky-text mb-8">
|
|
{{ t('contact.successMessage') }}
|
|
</p>
|
|
|
|
<p class="text-sky-text-muted">
|
|
{{ t('contact.redirecting') }}
|
|
</p>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
### Controller API Laravel
|
|
|
|
```php
|
|
<?php
|
|
// api/app/Http/Controllers/Api/ContactController.php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Requests\ContactRequest;
|
|
use App\Mail\ContactMail;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Mail;
|
|
|
|
class ContactController extends Controller
|
|
{
|
|
public function store(ContactRequest $request)
|
|
{
|
|
// Vérifier reCAPTCHA
|
|
$recaptchaResponse = Http::asForm()->post('https://www.google.com/recaptcha/api/siteverify', [
|
|
'secret' => config('services.recaptcha.secret'),
|
|
'response' => $request->input('recaptcha_token'),
|
|
'remoteip' => $request->ip(),
|
|
]);
|
|
|
|
$recaptchaData = $recaptchaResponse->json();
|
|
|
|
if (!$recaptchaData['success'] || $recaptchaData['score'] < 0.5) {
|
|
return response()->json([
|
|
'error' => [
|
|
'code' => 'RECAPTCHA_FAILED',
|
|
'message' => 'reCAPTCHA verification failed',
|
|
],
|
|
], 422);
|
|
}
|
|
|
|
// Envoyer l'email
|
|
Mail::to(config('mail.contact_to'))
|
|
->send(new ContactMail(
|
|
$request->input('name'),
|
|
$request->input('email'),
|
|
$request->input('message')
|
|
));
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Message sent successfully',
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Form Request
|
|
|
|
```php
|
|
<?php
|
|
// api/app/Http/Requests/ContactRequest.php
|
|
|
|
namespace App\Http\Requests;
|
|
|
|
use Illuminate\Foundation\Http\FormRequest;
|
|
|
|
class ContactRequest extends FormRequest
|
|
{
|
|
public function authorize(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
public function rules(): array
|
|
{
|
|
return [
|
|
'name' => ['required', 'string', 'max:100'],
|
|
'email' => ['required', 'email', 'max:255'],
|
|
'message' => ['required', 'string', 'max:5000'],
|
|
'recaptcha_token' => ['required', 'string'],
|
|
];
|
|
}
|
|
}
|
|
```
|
|
|
|
### Rate Limiting
|
|
|
|
```php
|
|
// api/routes/api.php
|
|
Route::middleware(['throttle:contact'])->group(function () {
|
|
Route::post('/contact', [ContactController::class, 'store']);
|
|
});
|
|
|
|
// api/app/Providers/RouteServiceProvider.php
|
|
protected function configureRateLimiting(): void
|
|
{
|
|
RateLimiter::for('contact', function (Request $request) {
|
|
return Limit::perMinute(5)->by($request->ip());
|
|
});
|
|
}
|
|
```
|
|
|
|
### Mail Template
|
|
|
|
```php
|
|
<?php
|
|
// api/app/Mail/ContactMail.php
|
|
|
|
namespace App\Mail;
|
|
|
|
use Illuminate\Mail\Mailable;
|
|
use Illuminate\Mail\Mailables\Content;
|
|
use Illuminate\Mail\Mailables\Envelope;
|
|
|
|
class ContactMail extends Mailable
|
|
{
|
|
public function __construct(
|
|
public string $name,
|
|
public string $email,
|
|
public string $message
|
|
) {}
|
|
|
|
public function envelope(): Envelope
|
|
{
|
|
return new Envelope(
|
|
subject: "Nouveau message de {$this->name} via Skycel",
|
|
replyTo: [$this->email],
|
|
);
|
|
}
|
|
|
|
public function content(): Content
|
|
{
|
|
return new Content(
|
|
view: 'emails.contact',
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
```blade
|
|
<!-- api/resources/views/emails/contact.blade.php -->
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
</head>
|
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
|
<h2>Nouveau message via Skycel</h2>
|
|
|
|
<p><strong>De :</strong> {{ $name }}</p>
|
|
<p><strong>Email :</strong> {{ $email }}</p>
|
|
|
|
<hr>
|
|
|
|
<h3>Message :</h3>
|
|
<p>{!! nl2br(e($message)) !!}</p>
|
|
|
|
<hr>
|
|
<p style="color: #666; font-size: 12px;">
|
|
Ce message a été envoyé depuis le portfolio Skycel.
|
|
</p>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
### Clés i18n
|
|
|
|
**fr.json :**
|
|
```json
|
|
{
|
|
"contact": {
|
|
"yourJourney": "Ton parcours",
|
|
"zones": "Zones explorées",
|
|
"easterEggs": "Easter eggs",
|
|
"challenge": "Challenge",
|
|
"explorer": "Explorateur",
|
|
"title": "Contacte-moi",
|
|
"subtitle": "Tu m'as trouvé ! Maintenant, écris-moi. Je lis chaque message.",
|
|
"name": "Ton nom",
|
|
"namePlaceholder": "Comment dois-je t'appeler ?",
|
|
"email": "Ton email",
|
|
"emailPlaceholder": "Pour que je puisse te répondre",
|
|
"message": "Ton message",
|
|
"messagePlaceholder": "Dis-moi tout...",
|
|
"send": "Envoyer le message",
|
|
"sending": "Envoi en cours...",
|
|
"recaptchaNote": "Ce site est protégé par reCAPTCHA.",
|
|
"successTitle": "Message envoyé !",
|
|
"successMessage": "Je l'ai bien reçu et je te réponds dès que possible. En attendant, un petit défi bonus ?",
|
|
"redirecting": "Redirection vers le challenge bonus..."
|
|
}
|
|
}
|
|
```
|
|
|
|
**en.json :**
|
|
```json
|
|
{
|
|
"contact": {
|
|
"yourJourney": "Your journey",
|
|
"zones": "Zones explored",
|
|
"easterEggs": "Easter eggs",
|
|
"challenge": "Challenge",
|
|
"explorer": "Explorer",
|
|
"title": "Contact me",
|
|
"subtitle": "You found me! Now, write to me. I read every message.",
|
|
"name": "Your name",
|
|
"namePlaceholder": "What should I call you?",
|
|
"email": "Your email",
|
|
"emailPlaceholder": "So I can reply to you",
|
|
"message": "Your message",
|
|
"messagePlaceholder": "Tell me everything...",
|
|
"send": "Send message",
|
|
"sending": "Sending...",
|
|
"recaptchaNote": "This site is protected by reCAPTCHA.",
|
|
"successTitle": "Message sent!",
|
|
"successMessage": "I received it and will reply as soon as possible. In the meantime, a bonus challenge?",
|
|
"redirecting": "Redirecting to bonus challenge..."
|
|
}
|
|
}
|
|
```
|
|
|
|
### Dépendances
|
|
|
|
**Cette story nécessite :**
|
|
- Story 3.5 : Store de progression (stats)
|
|
- Story 3.3 : useNarrator (messages d'erreur)
|
|
- Story 4.7 : Révélation (page précédente)
|
|
|
|
**Cette story prépare pour :**
|
|
- Story 4.9 : Challenge post-formulaire
|
|
|
|
### Project Structure Notes
|
|
|
|
**Fichiers à créer :**
|
|
```
|
|
frontend/app/pages/
|
|
└── contact.vue # CRÉER
|
|
|
|
api/
|
|
├── app/Http/Controllers/Api/
|
|
│ └── ContactController.php # CRÉER
|
|
├── app/Http/Requests/
|
|
│ └── ContactRequest.php # CRÉER
|
|
├── app/Mail/
|
|
│ └── ContactMail.php # CRÉER
|
|
└── resources/views/emails/
|
|
└── contact.blade.php # CRÉER
|
|
```
|
|
|
|
**Fichiers à modifier :**
|
|
```
|
|
api/routes/api.php # AJOUTER route contact
|
|
api/config/services.php # AJOUTER recaptcha config
|
|
frontend/nuxt.config.ts # AJOUTER reCAPTCHA
|
|
frontend/i18n/fr.json # AJOUTER contact.*
|
|
frontend/i18n/en.json # AJOUTER contact.*
|
|
```
|
|
|
|
### References
|
|
|
|
- [Source: docs/planning-artifacts/epics.md#Story-4.8]
|
|
- [Source: docs/planning-artifacts/ux-design-specification.md#Contact-Form]
|
|
- [Source: docs/planning-artifacts/architecture.md#Security]
|
|
|
|
### Technical Requirements
|
|
|
|
| Requirement | Value | Source |
|
|
|-------------|-------|--------|
|
|
| Validation | Frontend + Backend | Epics |
|
|
| Anti-spam | Honeypot + reCAPTCHA v3 | Epics |
|
|
| Rate limiting | 5 req/min/IP | Epics |
|
|
| Envoi email | Laravel Mail | Architecture |
|
|
|
|
## Dev Agent Record
|
|
|
|
### Agent Model Used
|
|
|
|
{{agent_model_name_version}}
|
|
|
|
### Debug Log References
|
|
|
|
### Completion Notes List
|
|
|
|
### Change Log
|
|
| Date | Change | Author |
|
|
|------|--------|--------|
|
|
| 2026-02-04 | Story créée avec contexte complet | SM Agent |
|
|
|
|
### File List
|
|
|