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>
16 KiB
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
- 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é
- And un challenge optionnel bonus est proposé
- And le challenge est différent du challenge principal (mini-jeu, quiz, exploration)
- And le visiteur peut fermer et quitter à tout moment
- And la participation est totalement optionnelle
- And le résultat n'impacte rien (juste pour le fun)
- 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
- Créer
-
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 |