Créer un écran de résultats attrayant et interactif qui s'affiche après chaque quiz avec animations, feedback visuel et options pour continuer.
- Confetti Effect: OUI, mais seulement si score > moyenne
- Badges Débloqués: OUI, afficher les nouveaux badges
- Animation Score: OUI, compteur animé (0% → score%)
- Messages Personnalisés: OUI, messages différents selon le score
Ajouter les computed et fonctions nécessaires au store pour supporter l'écran de fin.
src/stores/useQuizStore.ts
const isQuizFinished = computed(() =>
activeSession.value?.dateFin !== null && activeSession.value !== null
)Lieu: Après les autres computed (après isLastQuestion)
function getReplayParams() {
if (!activeSession.value) return null
return {
categories: [...activeSession.value.categories],
difficulty: activeSession.value.difficulteChoisie,
count: activeSession.value.nbQuestions
}
}Lieu: Avec les autres fonctions de gestion (après selectDifficulty)
return {
// ... autres exports
isQuizFinished, // ← NOUVEAU
getReplayParams, // ← NOUVEAU
}- ✅
isQuizFinishedest true quanddateFin !== null - ✅
isQuizFinishedest false quandactiveSession === null - ✅
getReplayParams()retourne les bons paramètres - ✅ Pas d'erreur TypeScript
// Après finishQuiz()
console.log(quizStore.isQuizFinished) // true
console.log(quizStore.getReplayParams()) // {categories, difficulty, count}Modifier Active.vue pour naviguer vers Summary quand le quiz est terminé.
src/views/quiz/Active.vue
Avant:
if (!quizStore.activeSession) {
router.push({ name: 'summary' })
}Après:
if (quizStore.isQuizFinished) {
await router.push({ name: 'summary' })
}Enlever les logs "Quiz still active, staying on active page"
- ✅ Clicker "Terminer le Quiz" navigue vers
/quiz/summary - ✅ La session reste accessible sur Summary (pas vide)
- ✅ Pas de boucle infinie de navigation
- Finir un quiz
- Clicker "Terminer le Quiz"
- Être redirigé vers Summary page
- Vérifier que
quizStore.activeSessionn'est pas null
Créer la structure HTML et CSS de base pour Summary.vue, sans animations.
src/views/quiz/Summary.vue (RECONSTRUCTION COMPLÈTE)
<template>
<div v-if="!quizStore.isQuizFinished" class="flex items-center justify-center h-full">
<p>Quiz non trouvé.</p>
</div>
<div v-else class="min-h-screen bg-gradient-to-br from-indigo-50 to-blue-50 p-4 flex flex-col">
<!-- Container principal -->
<div class="max-w-2xl mx-auto w-full space-y-8 py-8">
<!-- 1. Header Ludique -->
<div class="text-center space-y-2">
<div class="text-6xl mb-4">🎉</div>
<h1 class="text-4xl font-bold text-slate-900">Quiz Terminé!</h1>
<p class="text-xl text-slate-600">{{ congratulationsMessage }}</p>
</div>
<!-- 2. Section Score -->
<div class="bg-white rounded-2xl shadow-xl p-8 space-y-6">
<!-- Score Circle SVG -->
<div class="flex justify-center">
<svg width="200" height="200" class="transform -rotate-90">
<!-- Background circle -->
<circle cx="100" cy="100" r="90" fill="none" stroke="#e2e8f0" stroke-width="8"/>
<!-- Progress circle animé -->
<circle
cx="100"
cy="100"
r="90"
fill="none"
:stroke="scoreColor"
stroke-width="8"
class="transition-all duration-1000 ease-out"
:style="{ strokeDasharray: `${scoreDasharray}, ${2 * Math.PI * 90}` }"
/>
</svg>
<div class="absolute flex flex-col items-center justify-center">
<div class="text-5xl font-bold" :class="scoreColorClass">{{ score }}%</div>
<div class="text-sm text-slate-500">Score</div>
</div>
</div>
<!-- Résumé -->
<div class="text-center space-y-2">
<p class="text-2xl font-semibold text-slate-800">
{{ correctAnswers }} / {{ totalQuestions }} réponses correctes
</p>
<p class="text-slate-600">Excellente tentative!</p>
</div>
</div>
<!-- 3. Comparaison Moyenne -->
<div class="bg-white rounded-2xl shadow-xl p-8 space-y-4">
<h2 class="text-lg font-semibold text-slate-900">Comparaison vs Moyenne</h2>
<div class="grid grid-cols-2 gap-4">
<!-- Score actuel -->
<div class="text-center p-4 bg-indigo-50 rounded-lg">
<div class="text-3xl font-bold text-indigo-600">{{ score }}%</div>
<div class="text-sm text-slate-600">Votre score</div>
</div>
<!-- Moyenne -->
<div class="text-center p-4 bg-slate-50 rounded-lg">
<div class="text-3xl font-bold text-slate-600">{{ averageScore }}%</div>
<div class="text-sm text-slate-600">Moyenne</div>
</div>
</div>
<!-- Message comparaison -->
<div class="text-center p-4 rounded-lg" :class="comparisonClass">
<p class="font-semibold" :class="comparisonTextColor">
{{ comparisonSymbol }} {{ comparisonMessage }}
</p>
</div>
</div>
<!-- 4. Section Streak (conditionnel) -->
<div v-if="isPrimaryQuizOfDay" class="bg-orange-50 rounded-2xl shadow-xl p-8 space-y-4 border-2 border-orange-200">
<div class="text-center space-y-2">
<div class="text-6xl">🔥</div>
<h2 class="text-2xl font-bold text-orange-700">STREAK: {{ currentStreak }} jours</h2>
<p class="text-orange-600">Continuez demain pour garder votre streak! 💪</p>
</div>
</div>
<!-- 5. Section Badges Débloqués -->
<div v-if="newBadges.length > 0" class="bg-yellow-50 rounded-2xl shadow-xl p-8 space-y-6 border-2 border-yellow-200">
<div class="text-center">
<div class="text-5xl mb-2">🏆</div>
<h2 class="text-2xl font-bold text-yellow-700">Nouveaux Badges!</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="badge in newBadges" :key="badge.id" class="bg-white rounded-lg p-4 text-center">
<div class="text-4xl mb-2">{{ badge.icon }}</div>
<p class="font-semibold text-slate-900">{{ badge.nom }}</p>
<p class="text-sm text-slate-600">{{ badge.description }}</p>
</div>
</div>
</div>
<!-- 6. Actions -->
<div class="flex gap-4 pt-4">
<button
@click="goHome"
class="flex-1 px-6 py-3 bg-slate-200 text-slate-800 rounded-lg font-semibold hover:bg-slate-300 transition"
>
Accueil
</button>
<button
@click="replayQuiz"
class="flex-1 px-6 py-3 bg-indigo-600 text-white rounded-lg font-semibold hover:bg-indigo-700 transition flex items-center justify-center gap-2"
>
<span>Rejouer</span>
<span>🔄</span>
</button>
</div>
</div>
<!-- Confetti Container (si score > moyenne) -->
<div v-if="shouldShowConfetti" id="confetti-container" class="fixed inset-0 pointer-events-none"></div>
</div>
</template><script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useQuizStore } from '@/stores/useQuizStore'
import { useStatsStore } from '@/stores/useStatsStore'
const router = useRouter()
const quizStore = useQuizStore()
const statsStore = useStatsStore()
// ============ COMPUTED VALUES ============
const session = computed(() => quizStore.activeSession)
const score = computed(() => Math.round(session.value?.notePourcentage || 0))
const correctAnswers = computed(() => {
if (!session.value) return 0
return session.value.questions.filter(q => q.estCorrecte).length
})
const totalQuestions = computed(() => session.value?.nbQuestions || 0)
const averageScore = computed(() => Math.round(statsStore.averageScore || 0))
const currentStreak = computed(() => statsStore.currentStreak || 0)
// Score vs Moyenne
const isAboveAverage = computed(() => score.value > averageScore.value)
const scoreDifference = computed(() => Math.abs(score.value - averageScore.value))
const comparisonSymbol = computed(() => (isAboveAverage.value ? '▲' : (score.value === averageScore.value ? '=' : '▼')))
// Couleurs basées sur score
const scoreColorClass = computed(() => {
if (score.value >= 80) return 'text-green-600'
if (score.value >= 50) return 'text-amber-600'
return 'text-red-600'
})
const scoreColor = computed(() => {
if (score.value >= 80) return '#10b981'
if (score.value >= 50) return '#f59e0b'
return '#ef4444'
})
const scoreDasharray = computed(() => {
const circumference = 2 * Math.PI * 90
return (score.value / 100) * circumference
})
// Messages personnalisés
const congratulationsMessage = computed(() => {
if (score.value >= 90) return '🚀 Excellent! Vous êtes une superstar!'
if (score.value >= 80) return '👏 Très bien! Continuez!'
if (score.value >= 60) return '💪 Pas mal! Il y a du potentiel!'
if (score.value >= 40) return '📚 Continuez à pratiquer!'
return '🎯 Gardez la tête haute et essayez encore!'
})
const comparisonMessage = computed(() => {
if (isAboveAverage.value) {
return `Vous êtes ${scoreDifference.value}% au-dessus de la moyenne! 📈`
} else if (score.value === averageScore.value) {
return 'Vous êtes à la moyenne! 📊'
} else {
return `Vous êtes ${scoreDifference.value}% en dessous de la moyenne. Continuez! 📖`
}
})
const comparisonClass = computed(() => {
if (isAboveAverage.value) return 'bg-green-50'
if (score.value === averageScore.value) return 'bg-blue-50'
return 'bg-blue-50'
})
const comparisonTextColor = computed(() => {
if (isAboveAverage.value) return 'text-green-700'
if (score.value === averageScore.value) return 'text-blue-700'
return 'text-blue-700'
})
// Badges
const newBadges = computed(() => statsStore.newBadgesThisSession || [])
// Streak
const isPrimaryQuizOfDay = computed(() => {
if (!session.value) return false
const today = new Date().toISOString().split('T')[0]
return session.value.dateJour === today && currentStreak.value > 0
})
// Confetti
const shouldShowConfetti = computed(() => isAboveAverage.value)
// ============ LIFECYCLE ============
onMounted(async () => {
await statsStore.loadStats()
})
// ============ METHODS ============
async function goHome() {
quizStore.clearActiveSession()
await router.push({ name: 'home' })
}
async function replayQuiz() {
const params = quizStore.getReplayParams()
if (!params) return
quizStore.clearActiveSession()
await quizStore.createQuizSession(params.categories, params.difficulty, params.count)
await router.push({ name: 'quiz-active' })
}
</script>- ✅ Page affiche "Quiz non trouvé" si pas de session active
- ✅ Header avec emoji et message
- ✅ Section score affiche % et compteur (X/Y)
- ✅ Section comparaison affiche score vs moyenne
- ✅ Section streak affichée si c'est le 1er quiz du jour
- ✅ Section badges affichée si nouveaux badges
- ✅ Boutons Accueil et Rejouer présents
- ✅ Couleurs cohérentes (vert ≥80%, amber 50-80%, rouge <50%)
- ✅ Responsive design (mobile/tablet/desktop)
- Finir un quiz
- Vérifier affichage du header
- Vérifier calculs du score
- Vérifier comparaison vs moyenne
- Vérifier présence streak/badges selon conditions
- Vérifier responsive sur mobile
Ajouter les animations CSS pour le compteur de score et le pulse du streak.
src/style.css(ajouter keyframes)src/views/quiz/Summary.vue(ajouter classes)
/* Score counter animation */
@keyframes scoreCounter {
from {
stroke-dashoffset: 471; /* Full circle */
opacity: 0;
}
to {
stroke-dashoffset: 0;
opacity: 1;
}
}
/* Streak fire pulse */
@keyframes fireGlowPulse {
0%, 100% {
text-shadow: 0 0 10px rgba(249, 115, 22, 0.8);
transform: scale(1);
}
50% {
text-shadow: 0 0 30px rgba(249, 115, 22, 1);
transform: scale(1.1);
}
}
/* Page entrance */
@keyframes pageEnter {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Badge pop animation */
@keyframes badgePop {
0% {
opacity: 0;
transform: scale(0.5) rotateZ(-180deg);
}
50% {
transform: scale(1.1) rotateZ(10deg);
}
100% {
opacity: 1;
transform: scale(1) rotateZ(0deg);
}
}<circle
cx="100"
cy="100"
r="90"
fill="none"
:stroke="scoreColor"
stroke-width="8"
class="animate-[scoreCounter_2s_ease-out_forwards]"
:style="{ strokeDasharray: `${scoreDasharray}, ${2 * Math.PI * 90}` }"
/><div class="min-h-screen ... animate-[pageEnter_0.5s_ease-out_forwards]"><div v-if="isPrimaryQuizOfDay" class="... animate-[pageEnter_0.7s_ease-out_forwards]">
<div class="text-6xl animate-[fireGlowPulse_2s_ease-in-out_infinite]">🔥</div>
</div><div
v-for="(badge, index) in newBadges"
:key="badge.id"
:style="{ animationDelay: `${index * 150}ms` }"
class="animate-[badgePop_0.6s_cubic-bezier(0.34,1.56,0.64,1)_forwards]"
>- ✅ Score circle s'anime de 0→score% sur 2 secondes
- ✅ Page entrée en douceur (slideUp)
- ✅ Section streak pulse en rouge-orange
- ✅ Badges pop-in avec délai en cascade
- ✅ Toutes les animations sont fluides (60fps)
- Vérifier animation du cercle de score (2s)
- Vérifier pulse du feu (infini)
- Vérifier pop des badges (cascade)
- Vérifier aucun lag ou stutter
Ajouter une animation de confetti seulement si score > moyenne.
Créer une fonction CSS pure avec des carrés animés.
@keyframes confetti {
0% {
opacity: 1;
transform: translateY(0) rotateX(0) rotateZ(0);
}
100% {
opacity: 0;
transform: translateY(-400px) rotateX(720deg) rotateZ(360deg);
}
}
.confetti-piece {
position: fixed;
width: 10px;
height: 10px;
pointer-events: none;
animation: confetti 3s ease-out forwards;
}function createConfetti() {
if (!shouldShowConfetti.value) return
const container = document.getElementById('confetti-container')
if (!container) return
const colors = ['#FFD700', '#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8']
const pieces = 50
for (let i = 0; i < pieces; i++) {
const piece = document.createElement('div')
piece.className = 'confetti-piece'
piece.style.left = Math.random() * 100 + '%'
piece.style.top = Math.random() * 50 + '%'
piece.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)]
piece.style.animationDelay = Math.random() * 0.5 + 's'
piece.style.animationDuration = (Math.random() * 2 + 2) + 's'
container.appendChild(piece)
// Cleanup
setTimeout(() => piece.remove(), 5000)
}
}onMounted(async () => {
await statsStore.loadStats()
// Déclencher confetti après 500ms (laisse les animations se charger)
setTimeout(() => {
createConfetti()
}, 500)
})- ✅ Confetti n'apparaît QUE si score > moyenne
- ✅ 50 pièces de confetti tombent
- ✅ Couleurs variées et aléatoires
- ✅ Dur 3 secondes max
- ✅ Pas de lag (50 éléments est acceptable)
- ✅ Éléments supprimés du DOM après animation
- Finir un quiz avec score > moyenne → confetti doit tomber
- Finir un quiz avec score < moyenne → PAS de confetti
- Vérifier fluidité avec 50 éléments
Tester tous les cas et affiner l'expérience utilisateur.
- Message encourageant
- Couleur verte
- Confetti tombe
- Badges si débloqués
- Message motivant
- Couleur ambrée
- PAS de confetti
- Badges si débloqués
- Message encourageant
- Couleur rouge
- PAS de confetti
- Badges si débloqués
- Section Streak affichée
- Pulse du feu animé
- Correct currentStreak value
- Clicker "Rejouer" utilise les mêmes paramètres
- Navigue vers /quiz/active
- Nouvelle session créée
- Ancien score et session clearés
- Mobile (375px): Stack vertical, texte lisible
- Tablet (768px): Grille correcte
- Desktop (1024px+): Layout optimal
- Pas de session (redirection)
- Première session ever (pas de moyenne)
- Score exact = moyenne (affichage "=")
- Multiple quizzes même jour (streakanimation pas 2x)
- Vérifier typos/grammaire français
- Cohérence couleurs avec design system
- Shadows et spacing consistants
- Temps d'animation cohérents
- Pas de console errors
| Étape | Objectif | Fichiers | Durée | Test |
|---|---|---|---|---|
| 1 | Store foundation | useQuizStore.ts | 10min | Computed/function work |
| 2 | Routing | Active.vue | 5min | Navigation works |
| 3 | HTML/CSS | Summary.vue | 45min | Layout renders |
| 4 | Animations | style.css + Summary.vue | 30min | Animations smooth |
| 5 | Confetti | Summary.vue | 15min | Confetti > avg only |
| 6 | Tests & Polish | All files | 30min | All scenarios pass |
TOTAL: ~2h30
npm run build
# Vérifier pas d'erreur TypeScriptnpm run dev
# Finir un quiz, vérifier navigation vers /quiz/summarynpm run dev
# Vérifier layout et contenu affichent correctement
# Tester responsive avec DevToolsnpm run dev
# Vérifier animations fluides (onglet Performance dans DevTools)
# Vérifier pas de janky/lagnpm run dev
# Finir quiz avec score > moyenne, voir confetti
# Finir quiz avec score < moyenne, voir PAS de confettinpm run build
npm run preview
# Tester dans production build
# Tous les tests manuels pass- ✅ Summary.vue reste "dumb", reçoit données via computed
- ✅ useQuizStore gère la logique de fin
- ✅ useStatsStore fournit comparaison/streak
- ✅ Pas de dépendances externes (confetti en CSS pur)
- ✅ SVG circle optimisé (pas d'animation JS lourde)
- ✅ Confetti limité à 50 pièces max
- ✅ CSS animations seulement (GPU-accelerated)
- ✅ Animations cleanup après completion
- ✅ Messages positifs même pour bas scores
- ✅ Confetti récompense accomplissement (score > moyenne)
- ✅ Streak motivation pour retention
- ✅ Rejouer quick-action pour engagement