Files
Portfolio-Game/docs/implementation-artifacts/4-8-page-contact-formulaire-celebration.md
skycel ec1ae92799 🎉 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>
2026-02-05 02:08:56 +01:00

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

  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

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

File List