# 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

