Skip to main content

Vue d’ensemble

Le système de quiz permet aux associations d’évaluer leur maturité numérique via un questionnaire interactif de 7 questions. Le moteur V2 utilise un scoring pondéré pour recommander les outils les plus pertinents, déterminer un profil utilisateur (Débutant, Transition, Connecté) et générer un plan d’action personnalisé. Caractéristiques principales :
  • Moteur de scoring V2 avec pondération par réponse
  • 3 profils : Débutant, Transition, Connecté
  • Top 6 outils recommandés avec scores et raisons
  • Plan d’action en 3 étapes
  • Branchement conditionnel entre questions
  • Résultats partageables via URL encodée
  • Persistence de progression (localStorage, 24h TTL)
  • Capture d’email + envoi de rapport par email (Netlify Function + Resend)
  • Interface publique + gestion admin complète

Architecture V2 — Scoring pondéré

Principe

Chaque réponse du quiz porte des scoring_weights (JSONB) qui attribuent des points à des outils, catégories ou tiers de pricing. En fin de quiz, les points sont agrégés par outil pour produire un classement.
Réponse "Email & Newsletter" → scoring_weights:
{
  "tool:uuid-mailchimp": 5,
  "tool:uuid-brevo": 4,
  "category:communication": 2,
  "pricing:gratuit": 1
}
3 types de clés supportés :
  • tool:<id> — Points directs pour un outil spécifique
  • category:<name> — Points pour tous les outils d’une catégorie
  • pricing:<tier> — Points pour tous les outils d’un tier de pricing

Flux de scoring


Tables impliquées

Le système quiz utilise 5 tables PostgreSQL gérées via Hasura GraphQL : 1. quizzes — Définitions des quiz
interface Quiz {
  id: string;
  title: string;
  description: string | null;
  slug: string;         // Pour routing (ex: /quiz/diagnostic-digital)
  is_active: boolean;
  created_at: string;
  updated_at: string;
  questions: QuizQuestion[];
}
2. quiz_questions — Questions du quiz
interface QuizQuestion {
  id: string;
  quiz_id: string;
  question_text: string;
  question_type: 'single' | 'multiple' | 'scale';
  order_index: number;
  is_required: boolean;
  help_text: string | null;
  next_question_rules?: NextQuestionRule[] | null;  // V2 : branchement conditionnel
  answers: QuizAnswer[];
}
3. quiz_answers — Options de réponse avec scoring
interface QuizAnswer {
  id: string;
  question_id: string;
  answer_text: string;
  answer_value: string;
  order_index: number;
  scoring_weights?: Record<string, number>;  // V2 : pondération par réponse
}
4. quiz_recommendations — Règles de recommandation (legacy V1)
interface QuizRecommendation {
  id: string;
  quiz_id: string;
  condition_logic: Record<string, any>;  // JSONB — utilisé par l'ancien moteur V1
  recommended_pack_ids: string[];
  recommended_tool_ids: string[];
  recommendation_text: string | null;
  priority: number;
}
La table quiz_recommendations et son condition_logic sont le système V1 (règles conditionnelles). Le moteur V2 utilise les scoring_weights sur quiz_answers à la place. Les deux systèmes coexistent dans le code.
5. quiz_responses — Soumissions utilisateurs
interface QuizResponse {
  id: string;
  quiz_id: string;
  answers: Record<string, any>;
  recommended_pack_ids: string[];
  recommended_tool_ids: string[];
  email: string | null;
  created_at: string;
}

Moteur de scoring V2

calculateScores()

Fichier : src/api/quiz/scoring.ts
export function calculateScores(
  userAnswers: QuizUserAnswers,
  answersData: AnswerScoringData[],
  tools: ToolForScoring[],
  maxTools: number   // Défaut : 6
): ScoredTool[]
Algorithme :
  1. Initialise un score de 0 pour chaque outil du catalogue
  2. Pour chaque réponse de l’utilisateur, trouve les scoring_weights correspondants
  3. Pour chaque clé de poids :
    • tool:<id> → ajoute les points directement à l’outil
    • category:<name> → ajoute les points à tous les outils de la catégorie
    • pricing:<tier> → ajoute les points à tous les outils du tier
  4. Accumule les “raisons” (texte de la question + réponse) pour expliquer le score
  5. Filtre les outils avec score > 0, trie par score décroissant
  6. Retourne les maxTools meilleurs résultats

determineProfile()

Classifie l’utilisateur en 3 profils basés sur ses réponses :
export function determineProfile(
  answers: QuizUserAnswers,
  budgetQuestionId: string,
  maturityQuestionId: string,
  prioritiesQuestionId: string
): 'beginner' | 'transition' | 'connected'
ProfilConditions
connectedMaturité “avancé” ET 3+ priorités
beginnerBudget “0” ET maturité “aucun” ET 2 priorités max
transitionTous les autres cas

generateSummary()

Génère un résumé personnalisé basé sur les réponses (taille d’équipe, budget, priorités).

Types de retour

interface QuizReport {
  profile: 'beginner' | 'transition' | 'connected';
  profileLabel: string;      // Ex: "Explorateur Digital"
  profileDescription: string;
  summary: string;           // Résumé personnalisé
  scoredTools: ScoredTool[]; // Top 6 outils avec scores
  actionPlan: ActionStep[];  // 3 étapes concrètes
  answers: QuizUserAnswers;
  quiz_id: string;
}

interface ScoredTool {
  tool: {
    id: string;
    name: string;
    description: string | null;
    pricing_tier: string;
    logo_url: string | null;
    website_url: string | null;
    category_name: string;
  };
  score: number;
  reasons: string[];  // Max 2 raisons uniques
}

interface ActionStep {
  step: number;        // 1, 2, 3
  toolName: string;
  toolId: string;
  action: string;      // Instruction concrète
  pricingInfo: string;
}

Branchement conditionnel

Principe

Les questions peuvent rediriger vers d’autres questions selon la réponse choisie, via le champ next_question_rules :
[
  { "if_answer_value": "aucun",  "go_to_question_id": "uuid-question-3" },
  { "if_answer_value": "avance", "go_to_question_id": "uuid-question-5" }
]
Si aucune règle ne correspond ou si next_question_rules est null, le quiz avance séquentiellement (par order_index). Fichier : src/features/quiz/hooks/useQuiz.ts
// Détermine la prochaine question selon les règles de branchement
function getNextQuestionIndex(
  currentQuestion: QuizQuestion,
  answer: string | string[] | undefined,
  allQuestions: QuizQuestion[]
): number | 'end'

// Calcule le chemin complet pour la barre de progression
function calculateQuestionPath(
  questions: QuizQuestion[],
  answers: QuizUserAnswers,
  startIndex: number
): string[]
Retour arrière : Le hook maintient un questionHistory (pile d’indices) pour naviguer correctement en arrière même avec le branchement.

Résultats partageables

Les résultats du quiz sont encodés en base64 dans l’URL pour permettre le partage :
https://kit.lucdidion.lu/quiz/diagnostic-digital?r=eyJxdWl6SWQiOiJ...
// Encoder les réponses dans l'URL
function encodeAnswersToUrl(quizId: string, answers: QuizUserAnswers): string {
  const data = { quizId, answers };
  return btoa(encodeURIComponent(JSON.stringify(data)));
}

// Décoder depuis l'URL
function decodeAnswersFromUrl(encoded: string): { quizId: string; answers: QuizUserAnswers } | null
Quand un lien partagé est ouvert, le quiz charge directement les résultats sans repasser par les questions.

Persistence de progression

La progression du quiz est sauvegardée dans localStorage pour permettre la reprise :
const QUIZ_STORAGE_KEY = 'kitasso_quiz_progress';
const QUIZ_PROGRESS_MAX_AGE = 24 * 60 * 60 * 1000; // 24h

// Données sauvegardées
{ slug, answers, currentStep, timestamp }
  • Sauvegarde automatique à chaque réponse et changement d’étape
  • Restauration au rechargement de la page (même slug, < 24h)
  • Nettoyage automatique après complétion du quiz ou expiration

Composants UI

Architecture des composants

src/features/quiz/
├── pages/
│   └── Quiz.tsx                  # Route wrapper (/quiz/:slug?)
├── components/
│   ├── QuizContainer.tsx         # Orchestrateur principal
│   ├── QuizStart.tsx             # Ecran d'introduction
│   ├── QuizQuestion.tsx          # Rendu de question (3 types)
│   ├── QuizProgress.tsx          # Barre de progression dynamique
│   ├── QuizResults.tsx           # Affichage des résultats
│   └── QuizResultsSkeleton.tsx   # Skeleton pendant le calcul
├── hooks/
│   ├── useQuiz.ts                # Hook principal (440 lignes)
│   └── useQuizAnalytics.ts      # Analytics (placeholder)
├── admin/                        # CRUD admin (6 composants)
│   ├── QuizList.tsx
│   ├── QuizForm.tsx
│   ├── QuizQuestionManager.tsx
│   ├── QuestionForm.tsx          # Editeur avec scoring weights
│   ├── QuizRecommendationManager.tsx
│   └── RecommendationForm.tsx
└── constants.ts

QuizContainer — Orchestrateur

Gère le flux : Start -> Questions -> Results
// Rendu conditionnel basé sur l'état du quiz
if (isLoading) return <QuizSkeleton />;
if (isSubmitting) return <QuizResultsSkeleton />;
if (report) return <QuizResults report={report} />;
if (isIntroScreen) return <QuizStart onStart={startQuiz} />;
return <QuizQuestion question={currentQuestion} onAnswer={answerQuestion} />;

QuizResults — Affichage des résultats

Affiche le rapport complet :
  • Badge du profil (Débutant / Transition / Connecté)
  • Résumé personnalisé
  • Cartes des outils recommandés (logo, pricing, catégorie, raisons)
  • Plan d’action en 3 étapes
  • Formulaire de capture d’email
  • Bouton de partage (copie l’URL)
  • Bouton de réinitialisation

Hook useQuiz

Fichier : src/features/quiz/hooks/useQuiz.ts (440 lignes) Hook principal qui orchestre toute la logique du quiz :
const {
  quiz,              // Données du quiz chargé
  currentStep,       // Index de la question courante (-1 = intro)
  currentQuestion,   // Question courante ou null
  answers,           // Réponses de l'utilisateur
  isLoading,         // Chargement initial
  error,             // Message d'erreur
  report,            // QuizReport (null tant que non terminé)
  isSubmitting,      // Calcul des résultats en cours
  email,             // Email capturé
  setEmail,          // Setter pour l'email
  progress,          // 0-1, progression dynamique avec branchement
  isIntroScreen,     // true si step === -1
  isLastStep,        // true si dernière question du chemin
  shareableUrl,      // URL de partage (disponible après complétion)
  isFromSharedLink,  // true si résultats chargés depuis un lien partagé
  // Actions
  loadQuiz,          // Recharger le quiz
  answerQuestion,    // (questionId, answer) => void
  startQuiz,         // Passer de l'intro aux questions
  nextStep,          // Question suivante (avec branchement)
  prevStep,          // Question précédente (via historique)
  resetQuiz,         // Réinitialiser tout
} = useQuiz({ slug: 'diagnostic-digital' });
Flux de completeQuiz() :
  1. Appelle quizApi.getRecommendationsByScoring() avec les réponses
  2. Stocke le QuizReport dans le state
  3. Met à jour l’URL avec le lien partageable
  4. Soumet la réponse pour analytics via quizApi.submitResponse()

Envoi de rapport par email

Netlify Function

Fichier : netlify/functions/send-report.ts Une Netlify Function envoie le rapport par email via l’API Resend :
  • Endpoint : POST /.netlify/functions/send-report
  • Rate limiting : 3 emails par IP par heure
  • Validation : Email requis et valide, outils requis
  • CORS : Restreint à https://kit.lucdidion.lu
  • Template : Email HTML avec profil, résumé, outils recommandés, plan d’action, lien vers résultats
// Données envoyées à la fonction
interface ReportData {
  email: string;
  profileLabel: string;
  profileDescription: string;
  summary: string;
  tools: ReportTool[];
  actionPlan: ActionStep[];
  resultsUrl: string;
}
La variable d’environnement RESEND_API_KEY doit être configurée dans les settings Netlify pour que l’envoi d’email fonctionne.

API Layer (GraphQL)

Fichier : src/api/quiz/index.ts
export const quizApi = {
  // Public
  getActiveQuiz(slug: string): Promise<Quiz>,
  submitResponse(data: QuizResponseInsert): Promise<void>,

  // V2 Scoring
  getRecommendationsByScoring(
    quiz: Quiz,
    answers: QuizUserAnswers,
    questionIdMap: { teamSize, budget, maturity, priorities }
  ): Promise<QuizReport>,

  // V1 Legacy
  getRecommendations(quizId: string, answers: QuizUserAnswers): Promise<QuizRecommendation[]>,

  // Admin CRUD
  createQuiz(data: QuizFormData): Promise<Quiz>,
  updateQuiz(id: string, data: Partial<QuizFormData>): Promise<Quiz>,
  deleteQuiz(id: string): Promise<void>,

  // Questions
  createQuestion(quizId: string, data: QuestionFormData): Promise<void>,
  updateQuestion(id: string, data: QuestionFormData): Promise<void>,
  deleteQuestion(id: string): Promise<void>,

  // Recommendations (V1)
  createRecommendation(quizId: string, data: RecommendationFormData): Promise<void>,
  updateRecommendation(id: string, data: RecommendationFormData): Promise<void>,
  deleteRecommendation(id: string): Promise<void>,

  // Analytics
  getQuizResponses(quizId: string): Promise<QuizResponse[]>,
  getResponseStats(quizId: string): Promise<QuizResponseStats>,
};
Query GraphQL pour charger un quiz :
query GetQuizBySlug($slug: String!) {
  quizzes(where: { slug: { _eq: $slug }, is_active: { _eq: true } }) {
    id title description slug
    quiz_questions(order_by: { order_index: asc }) {
      id question_text question_type is_required help_text
      order_index next_question_rules
      quiz_answers(order_by: { order_index: asc }) {
        id answer_text answer_value scoring_weights
      }
    }
  }
}

Permissions Hasura

  • public : SELECT sur quizzes (actifs), quiz_questions, quiz_answers
  • public : INSERT sur quiz_responses (soumission des réponses)
  • admin : CRUD complet sur toutes les tables quiz

Bonnes pratiques

A faire

  • Toujours utiliser getRecommendationsByScoring() (V2) pour les nouveaux quiz
  • Configurer les scoring_weights sur chaque réponse dans l’admin
  • Tester le branchement conditionnel en vérifiant que tous les chemins mènent à une fin
  • Valider les questions requises avant de passer à la suivante
  • Charger uniquement le quiz demandé par slug

A éviter

  • Utiliser l’ancien moteur V1 (getRecommendations) pour de nouveaux quiz
  • Stocker des données sensibles dans l’URL partageable (les réponses y sont encodées)
  • Oublier de configurer RESEND_API_KEY en production pour l’envoi d’email

Ressources

Admin Dashboard

Gestion complète des quiz et scoring weights

Database Schema

Structure des tables quiz avec scoring_weights

API Layer

Utilisation de l’API GraphQL

Custom Hooks

Documentation du hook useQuiz