Files
Portfolio-Game/docs/implementation-artifacts/4-9-challenge-post-formulaire.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

16 KiB

Story 4.9: Challenge post-formulaire

Status: ready-for-dev

Story

As a visiteur ayant envoyé un message, I want m'amuser en attendant la réponse, so that le temps d'attente est transformé en moment de jeu.

Acceptance Criteria

  1. Given le formulaire de contact a été envoyé avec succès When la confirmation s'affiche Then un message "En attendant que le développeur retrouve le chemin vers sa boîte mail..." est affiché
  2. And un challenge optionnel bonus est proposé
  3. And le challenge est différent du challenge principal (mini-jeu, quiz, exploration)
  4. And le visiteur peut fermer et quitter à tout moment
  5. And la participation est totalement optionnelle
  6. And le résultat n'impacte rien (juste pour le fun)
  7. And le narrateur commente avec humour

Tasks / Subtasks

  • Task 1: Créer la page challenge-bonus (AC: #1, #2, #4)

    • Créer frontend/app/pages/challenge-bonus.vue
    • Message d'attente humoristique
    • Présentation du mini-jeu
    • Bouton "Quitter" visible en permanence
  • Task 2: Concevoir le mini-jeu (AC: #3, #6)

    • Quiz sur le développement web (5 questions)
    • OU : Memory avec des technos (Vue, Laravel, TypeScript, etc.)
    • OU : Snake simplifié thème code
    • Résultat juste pour le fun, pas de récompense
  • Task 3: Créer le composant BonusQuiz (AC: #3)

    • 5 questions aléatoires sur le dev
    • Choix multiples (4 options)
    • Feedback immédiat (correct/incorrect)
    • Score à la fin
  • Task 4: Commentaires du narrateur (AC: #7)

    • Message d'intro humoristique
    • Réactions aux réponses
    • Message de fin selon le score
  • Task 5: Navigation de sortie (AC: #4, #5)

    • Bouton "Retour à l'accueil" visible
    • Confirmation que le message est envoyé
    • Remerciement final
  • Task 6: Tests et validation

    • Tester le quiz complet
    • Vérifier que le résultat n'impacte rien
    • Tester la sortie à tout moment

Dev Notes

Page challenge-bonus.vue

<!-- frontend/app/pages/challenge-bonus.vue -->
<script setup lang="ts">
const { t } = useI18n()
const router = useRouter()
const narrator = useNarrator()

// États
const showIntro = ref(true)
const showQuiz = ref(false)
const showResult = ref(false)
const score = ref(0)

// Afficher le message d'intro
onMounted(() => {
  narrator.showMessage('bonus_intro')
})

function startQuiz() {
  showIntro.value = false
  showQuiz.value = true
}

function handleQuizComplete(finalScore: number) {
  score.value = finalScore
  showQuiz.value = false
  showResult.value = true

  // Message du narrateur selon le score
  if (finalScore === 5) {
    narrator.showMessage('bonus_perfect')
  } else if (finalScore >= 3) {
    narrator.showMessage('bonus_good')
  } else {
    narrator.showMessage('bonus_try_again')
  }
}

function goHome() {
  router.push('/')
}
</script>

<template>
  <div class="bonus-page min-h-screen bg-sky-dark flex flex-col items-center justify-center p-8">
    <!-- Bouton quitter (toujours visible) -->
    <button
      type="button"
      class="absolute top-4 right-4 text-sky-text-muted hover:text-sky-text text-sm font-ui flex items-center gap-2"
      @click="goHome"
    >
      <span>{{ t('bonus.exit') }}</span>
      <span></span>
    </button>

    <!-- Intro -->
    <Transition name="fade" mode="out-in">
      <div
        v-if="showIntro"
        class="max-w-lg text-center"
      >
        <img
          src="/images/bug/bug-stage-5.svg"
          alt="Le Bug"
          class="w-24 h-24 mx-auto mb-6"
        />

        <h1 class="text-2xl font-ui font-bold text-sky-text mb-4">
          {{ t('bonus.waitingTitle') }}
        </h1>

        <p class="font-narrative text-lg text-sky-text-muted mb-8">
          {{ t('bonus.waitingMessage') }}
        </p>

        <div class="space-y-4">
          <button
            type="button"
            class="w-full px-8 py-4 bg-sky-accent text-white font-ui font-semibold rounded-lg hover:bg-sky-accent/90 transition-colors"
            @click="startQuiz"
          >
            {{ t('bonus.playQuiz') }}
          </button>

          <button
            type="button"
            class="w-full px-8 py-4 border border-sky-dark-100 text-sky-text font-ui rounded-lg hover:bg-sky-dark-50 transition-colors"
            @click="goHome"
          >
            {{ t('bonus.noThanks') }}
          </button>
        </div>
      </div>

      <!-- Quiz -->
      <div
        v-else-if="showQuiz"
        class="w-full max-w-2xl"
      >
        <BonusQuiz @complete="handleQuizComplete" />
      </div>

      <!-- Résultat -->
      <div
        v-else-if="showResult"
        class="max-w-lg text-center"
      >
        <div class="text-6xl mb-4">
          {{ score === 5 ? '🏆' : score >= 3 ? '🎉' : '💪' }}
        </div>

        <h2 class="text-2xl font-ui font-bold text-sky-text mb-2">
          {{ t('bonus.resultTitle') }}
        </h2>

        <p class="text-4xl font-ui font-bold text-sky-accent mb-4">
          {{ score }} / 5
        </p>

        <p class="font-narrative text-lg text-sky-text-muted mb-8">
          {{ score === 5
            ? t('bonus.perfectMessage')
            : score >= 3
              ? t('bonus.goodMessage')
              : t('bonus.tryMessage')
          }}
        </p>

        <div class="space-y-4">
          <button
            type="button"
            class="w-full px-8 py-4 bg-sky-accent text-white font-ui font-semibold rounded-lg hover:bg-sky-accent/90 transition-colors"
            @click="showIntro = true; showResult = false"
          >
            {{ t('bonus.playAgain') }}
          </button>

          <button
            type="button"
            class="w-full px-8 py-4 border border-sky-dark-100 text-sky-text font-ui rounded-lg hover:bg-sky-dark-50 transition-colors"
            @click="goHome"
          >
            {{ t('bonus.backHome') }}
          </button>
        </div>

        <!-- Confirmation message envoyé -->
        <p class="mt-8 text-sm text-sky-text-muted">
          {{ t('bonus.messageConfirm') }}
        </p>
      </div>
    </Transition>
  </div>
</template>

Composant BonusQuiz

<!-- frontend/app/components/feature/BonusQuiz.vue -->
<script setup lang="ts">
const emit = defineEmits<{
  complete: [score: number]
}>()

const { t, locale } = useI18n()

interface Question {
  question: { fr: string; en: string }
  options: { fr: string; en: string }[]
  correctIndex: number
}

// Questions du quiz
const allQuestions: Question[] = [
  {
    question: {
      fr: "Quel framework JavaScript utilise Célian pour le frontend ?",
      en: "What JavaScript framework does Célian use for the frontend?"
    },
    options: [
      { fr: "React", en: "React" },
      { fr: "Vue.js", en: "Vue.js" },
      { fr: "Angular", en: "Angular" },
      { fr: "Svelte", en: "Svelte" }
    ],
    correctIndex: 1
  },
  {
    question: {
      fr: "Quel est le nom du framework PHP backend préféré de Célian ?",
      en: "What is the name of Célian's favorite PHP backend framework?"
    },
    options: [
      { fr: "Symfony", en: "Symfony" },
      { fr: "CodeIgniter", en: "CodeIgniter" },
      { fr: "Laravel", en: "Laravel" },
      { fr: "CakePHP", en: "CakePHP" }
    ],
    correctIndex: 2
  },
  {
    question: {
      fr: "Comment s'appelle la mascotte de Skycel ?",
      en: "What is the name of Skycel's mascot?"
    },
    options: [
      { fr: "La Fourmi", en: "The Ant" },
      { fr: "Le Bug", en: "The Bug" },
      { fr: "Le Pixel", en: "The Pixel" },
      { fr: "Le Code", en: "The Code" }
    ],
    correctIndex: 1
  },
  {
    question: {
      fr: "Quelle extension de JavaScript ajoute le typage statique ?",
      en: "Which JavaScript extension adds static typing?"
    },
    options: [
      { fr: "CoffeeScript", en: "CoffeeScript" },
      { fr: "TypeScript", en: "TypeScript" },
      { fr: "Babel", en: "Babel" },
      { fr: "ESLint", en: "ESLint" }
    ],
    correctIndex: 1
  },
  {
    question: {
      fr: "Quel meta-framework Nuxt est utilisé pour ce portfolio ?",
      en: "Which Nuxt meta-framework is used for this portfolio?"
    },
    options: [
      { fr: "Nuxt 2", en: "Nuxt 2" },
      { fr: "Nuxt 3", en: "Nuxt 3" },
      { fr: "Nuxt 4", en: "Nuxt 4" },
      { fr: "Next.js", en: "Next.js" }
    ],
    correctIndex: 2
  },
  {
    question: {
      fr: "En quelle année Skycel a été créé ?",
      en: "In what year was Skycel created?"
    },
    options: [
      { fr: "2020", en: "2020" },
      { fr: "2021", en: "2021" },
      { fr: "2022", en: "2022" },
      { fr: "2023", en: "2023" }
    ],
    correctIndex: 2
  },
  {
    question: {
      fr: "Quel est l'acronyme de l'outil CSS utilitaire populaire ?",
      en: "What is the acronym of the popular utility CSS tool?"
    },
    options: [
      { fr: "Bootstrap", en: "Bootstrap" },
      { fr: "Tailwind CSS", en: "Tailwind CSS" },
      { fr: "Bulma", en: "Bulma" },
      { fr: "Foundation", en: "Foundation" }
    ],
    correctIndex: 1
  },
]

// Sélectionner 5 questions aléatoires
const questions = ref<Question[]>([])
const currentIndex = ref(0)
const score = ref(0)
const selectedOption = ref<number | null>(null)
const showFeedback = ref(false)

onMounted(() => {
  // Mélanger et prendre 5 questions
  questions.value = [...allQuestions]
    .sort(() => Math.random() - 0.5)
    .slice(0, 5)
})

const currentQuestion = computed(() => questions.value[currentIndex.value])
const progress = computed(() => ((currentIndex.value + 1) / 5) * 100)

function selectOption(index: number) {
  if (showFeedback.value) return

  selectedOption.value = index
  showFeedback.value = true

  if (index === currentQuestion.value.correctIndex) {
    score.value++
  }

  // Passer à la question suivante après délai
  setTimeout(() => {
    if (currentIndex.value < 4) {
      currentIndex.value++
      selectedOption.value = null
      showFeedback.value = false
    } else {
      emit('complete', score.value)
    }
  }, 1500)
}

function getText(obj: { fr: string; en: string }): string {
  return locale.value === 'fr' ? obj.fr : obj.en
}
</script>

<template>
  <div class="bonus-quiz">
    <!-- Barre de progression -->
    <div class="h-2 bg-sky-dark-100 rounded-full mb-8 overflow-hidden">
      <div
        class="h-full bg-sky-accent transition-all duration-300"
        :style="{ width: `${progress}%` }"
      ></div>
    </div>

    <!-- Compteur -->
    <p class="text-sm text-sky-text-muted text-center mb-4">
      {{ t('bonus.question') }} {{ currentIndex + 1 }} / 5
    </p>

    <!-- Question -->
    <div
      v-if="currentQuestion"
      class="bg-sky-dark-50 rounded-xl p-6 border border-sky-dark-100"
    >
      <h3 class="text-xl font-ui font-semibold text-sky-text mb-6">
        {{ getText(currentQuestion.question) }}
      </h3>

      <!-- Options -->
      <div class="space-y-3">
        <button
          v-for="(option, index) in currentQuestion.options"
          :key="index"
          type="button"
          class="w-full p-4 rounded-lg border text-left transition-all font-ui"
          :class="[
            selectedOption === null
              ? 'border-sky-dark-100 hover:border-sky-accent hover:bg-sky-dark'
              : selectedOption === index
                ? index === currentQuestion.correctIndex
                  ? 'border-green-500 bg-green-500/20'
                  : 'border-red-500 bg-red-500/20'
                : index === currentQuestion.correctIndex && showFeedback
                  ? 'border-green-500 bg-green-500/10'
                  : 'border-sky-dark-100 opacity-50'
          ]"
          :disabled="showFeedback"
          @click="selectOption(index)"
        >
          <span class="flex items-center gap-3">
            <span
              class="shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium"
              :class="[
                selectedOption === index && index === currentQuestion.correctIndex
                  ? 'bg-green-500 text-white'
                  : selectedOption === index && index !== currentQuestion.correctIndex
                    ? 'bg-red-500 text-white'
                    : 'bg-sky-dark-100 text-sky-text'
              ]"
            >
              {{ ['A', 'B', 'C', 'D'][index] }}
            </span>
            <span class="text-sky-text">{{ getText(option) }}</span>
          </span>
        </button>
      </div>

      <!-- Feedback -->
      <Transition name="fade">
        <p
          v-if="showFeedback"
          class="mt-4 text-center font-ui"
          :class="selectedOption === currentQuestion.correctIndex ? 'text-green-400' : 'text-red-400'"
        >
          {{ selectedOption === currentQuestion.correctIndex
            ? t('bonus.correct')
            : t('bonus.incorrect')
          }}
        </p>
      </Transition>
    </div>
  </div>
</template>

Clés i18n

fr.json :

{
  "bonus": {
    "exit": "Quitter",
    "waitingTitle": "Message envoyé !",
    "waitingMessage": "En attendant que le développeur retrouve le chemin vers sa boîte mail... un petit quiz pour passer le temps ?",
    "playQuiz": "Jouer au quiz",
    "noThanks": "Non merci, j'ai terminé",
    "question": "Question",
    "correct": "Bonne réponse ! 🎉",
    "incorrect": "Pas tout à fait... 😅",
    "resultTitle": "Quiz terminé !",
    "perfectMessage": "Score parfait ! Tu connais vraiment bien le développement web... et Célian !",
    "goodMessage": "Bien joué ! Tu as de bonnes bases en développement web.",
    "tryMessage": "Continue d'apprendre ! Le développement web est un voyage sans fin.",
    "playAgain": "Rejouer",
    "backHome": "Retour à l'accueil",
    "messageConfirm": "Ton message a bien été envoyé. Célian te répondra bientôt !"
  }
}

en.json :

{
  "bonus": {
    "exit": "Exit",
    "waitingTitle": "Message sent!",
    "waitingMessage": "While the developer finds their way to the inbox... a little quiz to pass the time?",
    "playQuiz": "Play the quiz",
    "noThanks": "No thanks, I'm done",
    "question": "Question",
    "correct": "Correct! 🎉",
    "incorrect": "Not quite... 😅",
    "resultTitle": "Quiz completed!",
    "perfectMessage": "Perfect score! You really know web development... and Célian!",
    "goodMessage": "Well done! You have solid web development basics.",
    "tryMessage": "Keep learning! Web development is an endless journey.",
    "playAgain": "Play again",
    "backHome": "Back to home",
    "messageConfirm": "Your message was sent successfully. Célian will reply soon!"
  }
}

Dépendances

Cette story nécessite :

  • Story 4.8 : Page contact (redirection après envoi)
  • Story 3.3 : useNarrator

Cette story prépare pour :

  • Aucune (dernière story de l'Epic 4)

Project Structure Notes

Fichiers à créer :

frontend/app/
├── pages/
│   └── challenge-bonus.vue              # CRÉER
└── components/feature/
    └── BonusQuiz.vue                    # CRÉER

Fichiers à modifier :

frontend/i18n/fr.json                    # AJOUTER bonus.*
frontend/i18n/en.json                    # AJOUTER bonus.*

References

  • [Source: docs/planning-artifacts/epics.md#Story-4.9]
  • [Source: docs/planning-artifacts/ux-design-specification.md#Bonus-Challenge]
  • [Source: docs/brainstorming-gamification-2026-01-26.md#Post-Contact]

Technical Requirements

Requirement Value Source
Type de mini-jeu Quiz (5 questions) Décision technique
Impact sur progression Aucun Epics
Sortie Toujours possible Epics
Ambiance Humoristique Epics

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