Skip to content

Latest commit

 

History

History
722 lines (594 loc) · 20.1 KB

File metadata and controls

722 lines (594 loc) · 20.1 KB

Plan: Écran de Fin de Quiz Ludique

📋 Vue d'ensemble

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.


Décisions Finalisées ✅

  1. Confetti Effect: OUI, mais seulement si score > moyenne
  2. Badges Débloqués: OUI, afficher les nouveaux badges
  3. Animation Score: OUI, compteur animé (0% → score%)
  4. Messages Personnalisés: OUI, messages différents selon le score

ÉTAPE 1: Préparation du Store (Fondations)

1.1 Objectif

Ajouter les computed et fonctions nécessaires au store pour supporter l'écran de fin.

1.2 Fichier à modifier

src/stores/useQuizStore.ts

1.3 Changements détaillés

1.3.1 Ajouter isQuizFinished computed

const isQuizFinished = computed(() =>
  activeSession.value?.dateFin !== null && activeSession.value !== null
)

Lieu: Après les autres computed (après isLastQuestion)

1.3.2 Ajouter getReplayParams() fonction

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)

1.3.3 Exporter les nouveaux éléments

return {
  // ... autres exports
  isQuizFinished,        // ← NOUVEAU
  getReplayParams,       // ← NOUVEAU
}

1.4 Acceptance Criteria

  • isQuizFinished est true quand dateFin !== null
  • isQuizFinished est false quand activeSession === null
  • getReplayParams() retourne les bons paramètres
  • ✅ Pas d'erreur TypeScript

1.5 Test

// Après finishQuiz()
console.log(quizStore.isQuizFinished)  // true
console.log(quizStore.getReplayParams()) // {categories, difficulty, count}

ÉTAPE 2: Routing (Navigation vers Summary)

2.1 Objectif

Modifier Active.vue pour naviguer vers Summary quand le quiz est terminé.

2.2 Fichier à modifier

src/views/quiz/Active.vue

2.3 Changements détaillés

2.3.1 Mettre à jour la condition de vérification

Avant:

if (!quizStore.activeSession) {
  router.push({ name: 'summary' })
}

Après:

if (quizStore.isQuizFinished) {
  await router.push({ name: 'summary' })
}

2.3.2 Nettoyer les logs inutiles (optionnel)

Enlever les logs "Quiz still active, staying on active page"

2.4 Acceptance Criteria

  • ✅ Clicker "Terminer le Quiz" navigue vers /quiz/summary
  • ✅ La session reste accessible sur Summary (pas vide)
  • ✅ Pas de boucle infinie de navigation

2.5 Test

  1. Finir un quiz
  2. Clicker "Terminer le Quiz"
  3. Être redirigé vers Summary page
  4. Vérifier que quizStore.activeSession n'est pas null

ÉTAPE 3: Infrastructure Summary.vue

3.1 Objectif

Créer la structure HTML et CSS de base pour Summary.vue, sans animations.

3.2 Fichier à modifier

src/views/quiz/Summary.vue (RECONSTRUCTION COMPLÈTE)

3.3 Structure HTML

<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>

3.4 Script Setup (Logique de base)

<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>

3.5 Acceptance Criteria

  • ✅ 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)

3.6 Test

  1. Finir un quiz
  2. Vérifier affichage du header
  3. Vérifier calculs du score
  4. Vérifier comparaison vs moyenne
  5. Vérifier présence streak/badges selon conditions
  6. Vérifier responsive sur mobile

ÉTAPE 4: Animations Score & Streak

4.1 Objectif

Ajouter les animations CSS pour le compteur de score et le pulse du streak.

4.2 Fichiers à modifier

  • src/style.css (ajouter keyframes)
  • src/views/quiz/Summary.vue (ajouter classes)

4.3 CSS Keyframes à ajouter dans src/style.css

/* 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);
  }
}

4.4 Classes à ajouter dans Summary.vue

4.4.1 SVG Circle animation

<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}` }"
/>

4.4.2 Page container

<div class="min-h-screen ... animate-[pageEnter_0.5s_ease-out_forwards]">

4.4.3 Streak section

<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>

4.4.4 Badges

<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]"
>

4.5 Acceptance Criteria

  • ✅ 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)

4.6 Test

  1. Vérifier animation du cercle de score (2s)
  2. Vérifier pulse du feu (infini)
  3. Vérifier pop des badges (cascade)
  4. Vérifier aucun lag ou stutter

ÉTAPE 5: Confetti Effect (Optionnel mais Ludique)

5.1 Objectif

Ajouter une animation de confetti seulement si score > moyenne.

5.2 Implémentation sans dépendances externes

Créer une fonction CSS pure avec des carrés animés.

5.3 CSS Confetti

@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;
}

5.4 Fonction JavaScript dans Summary.vue

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)
  }
}

5.5 Appeler dans onMounted (après que la page soit rendue)

onMounted(async () => {
  await statsStore.loadStats()

  // Déclencher confetti après 500ms (laisse les animations se charger)
  setTimeout(() => {
    createConfetti()
  }, 500)
})

5.6 Acceptance Criteria

  • ✅ 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

5.7 Test

  1. Finir un quiz avec score > moyenne → confetti doit tomber
  2. Finir un quiz avec score < moyenne → PAS de confetti
  3. Vérifier fluidité avec 50 éléments

ÉTAPE 6: Tests & Polish Final

6.1 Objectif

Tester tous les cas et affiner l'expérience utilisateur.

6.2 Scénarios de Test

6.2.1 Score Élevé (≥80%)

  • Message encourageant
  • Couleur verte
  • Confetti tombe
  • Badges si débloqués

6.2.2 Score Moyen (50-80%)

  • Message motivant
  • Couleur ambrée
  • PAS de confetti
  • Badges si débloqués

6.2.3 Score Faible (<50%)

  • Message encourageant
  • Couleur rouge
  • PAS de confetti
  • Badges si débloqués

6.2.4 Premier Quiz du Jour

  • Section Streak affichée
  • Pulse du feu animé
  • Correct currentStreak value

6.2.5 Rejouer Quiz

  • Clicker "Rejouer" utilise les mêmes paramètres
  • Navigue vers /quiz/active
  • Nouvelle session créée
  • Ancien score et session clearés

6.2.6 Responsive Design

  • Mobile (375px): Stack vertical, texte lisible
  • Tablet (768px): Grille correcte
  • Desktop (1024px+): Layout optimal

6.2.7 Edge Cases

  • Pas de session (redirection)
  • Première session ever (pas de moyenne)
  • Score exact = moyenne (affichage "=")
  • Multiple quizzes même jour (streakanimation pas 2x)

6.3 Checklist Polish

  • 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

Résumé Étapes

É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


Commandes de Déploiement

Après Étape 1

npm run build
# Vérifier pas d'erreur TypeScript

Après Étape 2

npm run dev
# Finir un quiz, vérifier navigation vers /quiz/summary

Après Étape 3

npm run dev
# Vérifier layout et contenu affichent correctement
# Tester responsive avec DevTools

Après Étape 4

npm run dev
# Vérifier animations fluides (onglet Performance dans DevTools)
# Vérifier pas de janky/lag

Après Étape 5

npm run dev
# Finir quiz avec score > moyenne, voir confetti
# Finir quiz avec score < moyenne, voir PAS de confetti

Après Étape 6

npm run build
npm run preview
# Tester dans production build
# Tous les tests manuels pass

Notes Importantes

Architecture

  • ✅ 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)

Performance

  • ✅ SVG circle optimisé (pas d'animation JS lourde)
  • ✅ Confetti limité à 50 pièces max
  • ✅ CSS animations seulement (GPU-accelerated)
  • ✅ Animations cleanup après completion

UX

  • ✅ Messages positifs même pour bas scores
  • ✅ Confetti récompense accomplissement (score > moyenne)
  • ✅ Streak motivation pour retention
  • ✅ Rejouer quick-action pour engagement