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>
This commit is contained in:
2026-02-08 13:42:30 +01:00
parent 7e87a341a2
commit 065e7a0b6a
7 changed files with 520 additions and 37 deletions

View File

@@ -1,6 +1,6 @@
# Story 4.9: Challenge post-formulaire
Status: ready-for-dev
Status: done
## Story

View File

@@ -43,54 +43,54 @@ development_status:
# ═══════════════════════════════════════════════════════════════════════════
# EPIC 1: Fondations & Double Entrée
# ═══════════════════════════════════════════════════════════════════════════
epic-1: in-progress
1-1-initialisation-monorepo-infrastructure: review
1-2-base-donnees-migrations-initiales: review
1-3-systeme-i18n-frontend-api-bilingue: review
1-4-layouts-routing-transitions-page: review
1-5-landing-page-choix-heros: review
1-6-store-pinia-progression-bandeau-rgpd: review
1-7-page-resume-express-mode-presse: review
epic-1: done
1-1-initialisation-monorepo-infrastructure: done
1-2-base-donnees-migrations-initiales: done
1-3-systeme-i18n-frontend-api-bilingue: done
1-4-layouts-routing-transitions-page: done
1-5-landing-page-choix-heros: done
1-6-store-pinia-progression-bandeau-rgpd: done
1-7-page-resume-express-mode-presse: done
epic-1-retrospective: optional
# ═══════════════════════════════════════════════════════════════════════════
# EPIC 2: Contenu & Découverte
# ═══════════════════════════════════════════════════════════════════════════
epic-2: in-progress
2-1-composant-projectcard: review
2-2-page-projets-galerie: review
2-3-page-projet-detail: review
2-4-page-competences-affichage-categories: review
2-5-competences-cliquables-projets-lies: review
2-6-page-temoignages-migrations-bdd: review
2-7-composant-dialogue-pnj: review
2-8-page-parcours-timeline-narrative: review
epic-2: done
2-1-composant-projectcard: done
2-2-page-projets-galerie: done
2-3-page-projet-detail: done
2-4-page-competences-affichage-categories: done
2-5-competences-cliquables-projets-lies: done
2-6-page-temoignages-migrations-bdd: done
2-7-composant-dialogue-pnj: done
2-8-page-parcours-timeline-narrative: done
epic-2-retrospective: optional
# ═══════════════════════════════════════════════════════════════════════════
# EPIC 3: Navigation Gamifiée & Progression
# ═══════════════════════════════════════════════════════════════════════════
epic-3: in-progress
3-1-table-narrator-texts-api-narrateur: review
3-2-composant-narratorbubble-le-bug: review
3-3-textes-narrateur-contextuels-arc-revelation: review
3-4-barre-progression-globale-xp-bar: review
3-5-logique-progression-deblocage-contact: review
3-6-carte-interactive-desktop-konvajs: review
3-7-navigation-mobile-chemin-libre-bottom-bar: review
epic-3: done
3-1-table-narrator-texts-api-narrateur: done
3-2-composant-narratorbubble-le-bug: done
3-3-textes-narrateur-contextuels-arc-revelation: done
3-4-barre-progression-globale-xp-bar: done
3-5-logique-progression-deblocage-contact: done
3-6-carte-interactive-desktop-konvajs: done
3-7-navigation-mobile-chemin-libre-bottom-bar: done
epic-3-retrospective: optional
# ═══════════════════════════════════════════════════════════════════════════
# EPIC 4: Chemins Narratifs, Challenge & Contact
# ═══════════════════════════════════════════════════════════════════════════
epic-4: in-progress
4-1-composant-choicecards-choix-narratifs: ready-for-dev
4-2-intro-narrative-premier-choix: ready-for-dev
4-3-chemins-narratifs-differencies: ready-for-dev
4-4-table-easter-eggs-systeme-detection: ready-for-dev
4-5-easter-eggs-implementation-ui-collection: ready-for-dev
4-6-page-challenge-structure-puzzle: ready-for-dev
4-7-revelation-monde-de-code: ready-for-dev
4-8-page-contact-formulaire-celebration: ready-for-dev
4-9-challenge-post-formulaire: ready-for-dev
epic-4: done
4-1-composant-choicecards-choix-narratifs: done
4-2-intro-narrative-premier-choix: done
4-3-chemins-narratifs-differencies: done
4-4-table-easter-eggs-systeme-detection: done
4-5-easter-eggs-implementation-ui-collection: done
4-6-page-challenge-structure-puzzle: done
4-7-revelation-monde-de-code: done
4-8-page-contact-formulaire-celebration: done
4-9-challenge-post-formulaire: done
epic-4-retrospective: optional

View File

@@ -0,0 +1,268 @@
<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>
<!-- Compteur -->
<p class="text-sm text-sky-text/60 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="getOptionClass(index)"
: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="getLetterClass(index)"
>
{{ ['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>
<script setup lang="ts">
const emit = defineEmits<{
complete: [score: number]
}>()
const { 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 Celian pour le frontend ?',
en: 'What JavaScript framework does Celian 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 prefere de Celian ?',
en: 'What is the name of Celian\'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 utilise 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 annee Skycel a ete cree ?',
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 framework CSS utilitaire est utilise dans ce portfolio ?',
en: 'Which utility CSS framework is used in this portfolio?',
},
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
}
function getOptionClass(index: number): string[] {
const classes: string[] = []
if (selectedOption.value === null) {
classes.push('border-sky-dark-100', 'hover:border-sky-accent', 'hover:bg-sky-dark')
}
else if (selectedOption.value === index) {
if (index === currentQuestion.value.correctIndex) {
classes.push('border-green-500', 'bg-green-500/20')
}
else {
classes.push('border-red-500', 'bg-red-500/20')
}
}
else if (index === currentQuestion.value.correctIndex && showFeedback.value) {
classes.push('border-green-500', 'bg-green-500/10')
}
else {
classes.push('border-sky-dark-100', 'opacity-50')
}
return classes
}
function getLetterClass(index: number): string[] {
const classes: string[] = []
if (selectedOption.value === index && index === currentQuestion.value.correctIndex) {
classes.push('bg-green-500', 'text-white')
}
else if (selectedOption.value === index && index !== currentQuestion.value.correctIndex) {
classes.push('bg-red-500', 'text-white')
}
else {
classes.push('bg-sky-dark-100', 'text-sky-text')
}
return classes
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,175 @@
<template>
<div class="bonus-page min-h-screen bg-sky-dark flex flex-col items-center justify-center p-8 relative">
<!-- Bouton quitter (toujours visible) -->
<button
type="button"
class="absolute top-4 right-4 text-sky-text/60 hover:text-sky-text text-sm font-ui flex items-center gap-2 z-10"
@click="goHome"
>
<span>{{ $t('bonus.exit') }}</span>
<span aria-hidden="true"></span>
</button>
<!-- Intro -->
<Transition name="fade" mode="out-in">
<div
v-if="showIntro"
key="intro"
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/60 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"
key="quiz"
class="w-full max-w-2xl"
>
<FeatureBonusQuiz @complete="handleQuizComplete" />
</div>
<!-- Résultat -->
<div
v-else-if="showResult"
key="result"
class="max-w-lg text-center"
>
<div class="text-6xl mb-4" aria-hidden="true">
{{ getResultEmoji }}
</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/60 mb-8">
{{ getResultMessage }}
</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="playAgain"
>
{{ $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/60">
{{ $t('bonus.messageConfirm') }}
</p>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'adventure',
})
const { t } = useI18n()
const router = useRouter()
const { setPageMeta } = useSeo()
setPageMeta({
title: t('bonus.pageTitle'),
description: t('bonus.pageDescription'),
})
// États
const showIntro = ref(true)
const showQuiz = ref(false)
const showResult = ref(false)
const score = ref(0)
function startQuiz() {
showIntro.value = false
showQuiz.value = true
}
function handleQuizComplete(finalScore: number) {
score.value = finalScore
showQuiz.value = false
showResult.value = true
}
function playAgain() {
showResult.value = false
showIntro.value = true
score.value = 0
}
function goHome() {
router.push('/')
}
const getResultEmoji = computed(() => {
if (score.value === 5) return '🏆'
if (score.value >= 3) return '🎉'
return '💪'
})
const getResultMessage = computed(() => {
if (score.value === 5) return t('bonus.perfectMessage')
if (score.value >= 3) return t('bonus.goodMessage')
return t('bonus.tryMessage')
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -136,6 +136,7 @@ definePageMeta({
})
const { t } = useI18n()
const router = useRouter()
const { setPageMeta } = useSeo()
const config = useRuntimeConfig()
const progressionStore = useProgressionStore()
@@ -194,7 +195,8 @@ async function handleSubmit() {
},
})
isSubmitted.value = true
// Rediriger vers le quiz bonus
router.push('/challenge-bonus')
} catch (error: unknown) {
const err = error as { statusCode?: number }
if (err.statusCode === 429) {

View File

@@ -320,5 +320,24 @@
"successMessage": "Thanks for your message. I'll get back to you as soon as possible.",
"error": "An error occurred. Please try again later.",
"rateLimitError": "Too many attempts. Please wait a moment before trying again."
},
"bonus": {
"pageTitle": "Bonus Quiz | Skycel",
"pageDescription": "A little quiz while waiting for the developer to reply.",
"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 Celian!",
"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. Celian will reply soon!"
}
}

View File

@@ -320,5 +320,24 @@
"successMessage": "Merci pour ton message. Je te repondrai dans les plus brefs delais.",
"error": "Une erreur s'est produite. Reessaie plus tard.",
"rateLimitError": "Trop de tentatives. Patiente un moment avant de reessayer."
},
"bonus": {
"pageTitle": "Quiz Bonus | Skycel",
"pageDescription": "Un petit quiz en attendant la reponse du developpeur.",
"exit": "Quitter",
"waitingTitle": "Message envoye !",
"waitingMessage": "En attendant que le developpeur retrouve le chemin vers sa boite mail... un petit quiz pour passer le temps ?",
"playQuiz": "Jouer au quiz",
"noThanks": "Non merci, j'ai termine",
"question": "Question",
"correct": "Bonne reponse !",
"incorrect": "Pas tout a fait...",
"resultTitle": "Quiz termine !",
"perfectMessage": "Score parfait ! Tu connais vraiment bien le developpement web... et Celian !",
"goodMessage": "Bien joue ! Tu as de bonnes bases en developpement web.",
"tryMessage": "Continue d'apprendre ! Le developpement web est un voyage sans fin.",
"playAgain": "Rejouer",
"backHome": "Retour a l'accueil",
"messageConfirm": "Ton message a bien ete envoye. Celian te repondra bientot !"
}
}