🎉 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:
2026-02-05 02:08:56 +01:00
commit ec1ae92799
116 changed files with 55669 additions and 0 deletions

View File

@@ -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