🎉 Init monorepo Nuxt 4 + Laravel 12 (Story 1.1)
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>
This commit is contained in:
@@ -0,0 +1,654 @@
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user