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>
19 KiB
19 KiB
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
- 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é)
- And un formulaire de contact s'affiche : nom (requis), email (requis), message (requis)
- And la validation temps réel est effectuée côté frontend (champs requis, format email)
- And les erreurs sont communiquées par le narrateur (pas de messages d'erreur classiques)
- And un champ honeypot invisible est présent (anti-spam)
- And reCAPTCHA v3 est intégré de manière invisible
- And le bouton d'envoi utilise la couleur accent (
sky-accent) - 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
- And le rate limiting (5 req/min par IP) est appliqué
- And l'email est envoyé via Laravel Mail
- And une animation de célébration s'affiche (confettis ou similaire)
- And le narrateur confirme l'envoi avec un message personnalisé
- 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
- Créer
-
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
- Créer
-
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
<!-- 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
// 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
// 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
// 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
// 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',
);
}
}
<!-- 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 :
{
"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 :
{
"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 |