Files
Portfolio-Game/docs/implementation-artifacts/4-9-challenge-post-formulaire.md
skycel 065e7a0b6a feat(frontend): quiz bonus post-contact (Story 4.9)
- Add BonusQuiz.vue component with 7 randomized questions
- Add challenge-bonus.vue page with intro, quiz, and results
- Redirect to bonus quiz after successful contact form submission
- Add i18n translations for bonus.* (fr/en)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 13:42:30 +01:00

559 lines
16 KiB
Markdown

# Story 4.9: Challenge post-formulaire
Status: done
## 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
```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
```vue
<!-- 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 :**
```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 :**
```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