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 :
Initialise un score de 0 pour chaque outil du catalogue
Pour chaque réponse de l’utilisateur, trouve les scoring_weights correspondants
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
Accumule les “raisons” (texte de la question + réponse) pour expliquer le score
Filtre les outils avec score > 0, trie par score décroissant
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'
Profil Conditions connected Maturité “avancé” ET 3+ priorités beginner Budget “0” ET maturité “aucun” ET 2 priorités max transition Tous 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).
Navigation
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() :
Appelle quizApi.getRecommendationsByScoring() avec les réponses
Stocke le QuizReport dans le state
Met à jour l’URL avec le lien partageable
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