- 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>
559 lines
16 KiB
Markdown
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
|
|
|