TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Quiz adaptatif : écran de départ (concept optionnel), question (mcq / réponse3// ouverte + confiance), rétroaction riche, synthèse de fin de session.4import Link from "next/link";5import { useEffect, useState } from "react";6import { useSearchParams } from "next/navigation";7import { ArrowRight, CheckCircle2, Flag, ListChecks, NotebookPen, XCircle } from "lucide-react";8import { Badge, Button, Card, ProgressBar, Skeleton, Spinner, Textarea, cn } from "@/components/ui";9import { Markdown } from "@/components/chat/markdown";10import { DifficultyDots, ErrorBanner, fetchJson, postJson } from "./shared";1112type Question = { id: number; type: string; difficulty: number; question: string; options: string[]; conceptId: number | null };13type Feedback = { correct: boolean; expected: string; explanation: string; score: { correct: number; total: number } };14type Summary = { correct: number; total: number; byConcept: { name: string; correct: number; total: number }[] };15type ConceptOpt = { slug: string; name: string };1617const TYPE_LABELS: Record<string, string> = {18 mcq: "Choix multiple", short: "Réponse courte", calc: "Calcul", "error-detect": "Détection d'erreur",19 case: "Étude de cas", order: "Mise en ordre", match: "Association",20};2122export function QuizApp({ course }: { course: string }) {23 const searchParams = useSearchParams();24 const upper = course.toUpperCase();2526 const [phase, setPhase] = useState<"start" | "question" | "feedback" | "summary">("start");27 const [concepts, setConcepts] = useState<ConceptOpt[] | null>(null);28 const [conceptSlug, setConceptSlug] = useState<string>(searchParams.get("concept") ?? "");29 const [sessionId, setSessionId] = useState<number | null>(null);30 const [question, setQuestion] = useState<Question | null>(null);31 const [answer, setAnswer] = useState("");32 const [confidence, setConfidence] = useState(3);33 const [feedback, setFeedback] = useState<Feedback | null>(null);34 const [summary, setSummary] = useState<Summary | null>(null);35 const [answered, setAnswered] = useState(0);36 const [busy, setBusy] = useState(false);37 const [error, setError] = useState<string | null>(null);38 const [exhaustedMsg, setExhaustedMsg] = useState<string | null>(null);3940 useEffect(() => {41 fetchJson<{ nodes: ConceptOpt[] }>(`/api/learning/${course}/concepts`)42 .then((d) => setConcepts(d.nodes))43 .catch(() => setConcepts([]));44 }, [course]);4546 async function start() {47 setBusy(true);48 setError(null);49 try {50 const d = await postJson<{ sessionId: number; question: Question }>("/api/learning/quiz", {51 action: "start", course: upper, conceptSlug: conceptSlug || null,52 });53 setSessionId(d.sessionId);54 setQuestion(d.question);55 setAnswer("");56 setConfidence(3);57 setAnswered(0);58 setExhaustedMsg(null);59 setPhase("question");60 } catch (e) {61 setError(e instanceof Error ? e.message : "Impossible de démarrer le quiz.");62 } finally {63 setBusy(false);64 }65 }6667 async function submitAnswer(value: string) {68 if (!sessionId || !question || busy) return;69 setBusy(true);70 setError(null);71 try {72 const d = await postJson<Feedback>("/api/learning/quiz", {73 action: "answer",74 sessionId,75 questionId: question.id,76 answer: value,77 confidence: question.type === "mcq" ? undefined : confidence,78 hintsUsed: 0,79 });80 setFeedback(d);81 setAnswered((n) => n + 1);82 setPhase("feedback");83 } catch (e) {84 setError(e instanceof Error ? e.message : "Erreur d'envoi de la réponse.");85 } finally {86 setBusy(false);87 }88 }8990 async function nextQuestion() {91 if (!sessionId || busy) return;92 setBusy(true);93 setError(null);94 try {95 const d = await postJson<{ question: Question | null; message?: string }>("/api/learning/quiz", {96 action: "next", sessionId,97 });98 if (!d.question) {99 setExhaustedMsg(d.message ?? "Banque de questions épuisée pour ce ciblage.");100 await finish();101 return;102 }103 setQuestion(d.question);104 setAnswer("");105 setConfidence(3);106 setFeedback(null);107 setPhase("question");108 } catch (e) {109 setError(e instanceof Error ? e.message : "Erreur de chargement de la question.");110 } finally {111 setBusy(false);112 }113 }114115 async function finish() {116 if (!sessionId) return;117 setBusy(true);118 setError(null);119 try {120 const d = await postJson<{ summary: Summary }>("/api/learning/quiz", { action: "finish", sessionId });121 setSummary(d.summary);122 setPhase("summary");123 } catch (e) {124 setError(e instanceof Error ? e.message : "Erreur de clôture de la session.");125 } finally {126 setBusy(false);127 }128 }129130 // ---------- Écran de départ ----------131 if (phase === "start") {132 return (133 <main className="px-4 sm:px-6 py-6 max-w-2xl mx-auto w-full">134 <Card className="p-6 animate-fade-up">135 <div className="flex items-center gap-2.5 mb-1">136 <ListChecks size={20} className="text-brand-500" />137 <h2 className="text-lg font-bold text-fg">Quiz adaptatif</h2>138 </div>139 <p className="text-sm text-muted mb-5">140 La difficulté s'ajuste à vos réponses (deux bonnes de suite → plus dur ; une erreur → plus facile).141 Chaque erreur est ajoutée à votre cahier d'erreurs.142 </p>143 <label htmlFor="quiz-concept" className="block text-[13px] font-medium text-fg mb-1.5">144 Concept ciblé (optionnel)145 </label>146 {concepts === null ? (147 <Skeleton className="h-10 w-full" />148 ) : (149 <select150 id="quiz-concept"151 value={conceptSlug}152 onChange={(e) => setConceptSlug(e.target.value)}153 className="w-full h-10 px-3 rounded-lg bg-card border border-app text-sm text-fg outline-none focus:border-brand-400"154 >155 <option value="">Mix intelligent (faiblesses, consolidation, découverte)</option>156 {concepts.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}157 </select>158 )}159 {error && <ErrorBanner message={error} className="mt-4" />}160 <Button onClick={start} disabled={busy} className="w-full justify-center mt-5" size="lg">161 {busy ? <Spinner /> : <>Commencer le quiz <ArrowRight size={16} /></>}162 </Button>163 </Card>164 </main>165 );166 }167168 // ---------- Synthèse ----------169 if (phase === "summary") {170 const pct = summary && summary.total > 0 ? Math.round((summary.correct / summary.total) * 100) : 0;171 return (172 <main className="px-4 sm:px-6 py-6 max-w-2xl mx-auto w-full space-y-4">173 <Card className="p-6 text-center animate-fade-up">174 <h2 className="text-lg font-bold text-fg mb-1">Session terminée</h2>175 {exhaustedMsg && <p className="text-[13px] text-muted mb-2">{exhaustedMsg}</p>}176 {summary && summary.total > 0 ? (177 <>178 <p className={cn("text-5xl font-bold my-3", pct >= 70 ? "text-emerald-500" : pct >= 50 ? "text-amber-500" : "text-red-500")}>179 {pct} %180 </p>181 <p className="text-sm text-muted">{summary.correct} bonne{summary.correct > 1 ? "s" : ""} réponse{summary.correct > 1 ? "s" : ""} sur {summary.total}</p>182 </>183 ) : (184 <p className="text-sm text-muted my-3">Aucune question répondue dans cette session.</p>185 )}186 </Card>187 {summary && summary.byConcept.length > 0 && (188 <Card className="p-5">189 <h3 className="text-sm font-semibold text-fg mb-3">Résultat par concept</h3>190 <div className="space-y-3">191 {summary.byConcept.map((c) => (192 <div key={c.name}>193 <div className="flex justify-between text-[13px] mb-1">194 <span className="text-fg">{c.name}</span>195 <span className="text-muted tabular-nums">{c.correct}/{c.total}</span>196 </div>197 <ProgressBar value={c.total ? c.correct / c.total : 0} tone={c.correct === c.total ? "green" : "brand"} />198 </div>199 ))}200 </div>201 </Card>202 )}203 {error && <ErrorBanner message={error} />}204 <div className="flex flex-wrap gap-2">205 <Button onClick={() => { setPhase("start"); setSummary(null); setSessionId(null); }} variant="secondary">206 Nouveau quiz207 </Button>208 {summary && summary.correct < summary.total && (209 <Link href={`/apprendre/${course}/erreurs`}>210 <Button variant="primary"><NotebookPen size={15} /> Revoir mes erreurs</Button>211 </Link>212 )}213 </div>214 </main>215 );216 }217218 // ---------- Question / rétroaction ----------219 if (!question) return null;220 const isMcq = question.type === "mcq";221222 return (223 <main className="px-4 sm:px-6 py-6 max-w-2xl mx-auto w-full space-y-4">224 <div className="flex flex-wrap items-center gap-2">225 <Badge tone="brand">{TYPE_LABELS[question.type] ?? question.type}</Badge>226 <DifficultyDots value={question.difficulty} />227 <span className="ml-auto text-[12.5px] text-muted tabular-nums">228 Question {answered + (phase === "question" ? 1 : 0)}{feedback ? ` · ${feedback.score.correct}/${feedback.score.total} réussie${feedback.score.correct > 1 ? "s" : ""}` : ""}229 </span>230 </div>231232 <Card className="p-5 animate-fade-up">233 <Markdown content={question.question} />234 </Card>235236 {phase === "question" ? (237 <>238 {isMcq ? (239 <div className="grid gap-2" role="group" aria-label="Options de réponse">240 {question.options.map((opt, i) => (241 <button242 key={i}243 onClick={() => submitAnswer(opt)}244 disabled={busy}245 className={cn(246 "text-left border border-app bg-card rounded-xl px-4 py-3 text-sm text-fg transition-colors",247 "hover:border-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/30 disabled:opacity-60"248 )}249 >250 <span className="font-semibold text-brand-600 dark:text-brand-300 mr-2">{String.fromCharCode(65 + i)}.</span>251 <span className="[&_.prose-immbot]:inline"><Markdown content={opt} /></span>252 </button>253 ))}254 </div>255 ) : (256 <form257 onSubmit={(e) => { e.preventDefault(); if (answer.trim()) submitAnswer(answer); }}258 className="space-y-3"259 >260 <Textarea261 value={answer}262 onChange={(e) => setAnswer(e.target.value)}263 rows={4}264 placeholder={question.type === "calc" ? "Votre résultat (avec unités s'il y a lieu)…" : "Votre réponse…"}265 aria-label="Votre réponse"266 autoFocus267 />268 <div>269 <label htmlFor="quiz-conf" className="block text-[13px] font-medium text-fg mb-1">270 Confiance dans votre réponse : {["très faible", "faible", "moyenne", "élevée", "très élevée"][confidence - 1]}271 </label>272 <input273 id="quiz-conf" type="range" min={1} max={5} step={1}274 value={confidence} onChange={(e) => setConfidence(Number(e.target.value))}275 className="w-full accent-[#1d64b0]"276 />277 <p className="text-[11.5px] text-muted mt-0.5">Déclarer sa confiance améliore le calibrage de la maîtrise estimée.</p>278 </div>279 <div className="flex gap-2">280 <Button type="submit" disabled={busy || !answer.trim()} className="flex-1 justify-center">281 {busy ? <Spinner /> : "Valider ma réponse"}282 </Button>283 <Button type="button" variant="ghost" onClick={finish} disabled={busy}>284 <Flag size={14} /> Terminer285 </Button>286 </div>287 </form>288 )}289 {isMcq && (290 <div className="flex justify-end">291 <Button variant="ghost" size="sm" onClick={finish} disabled={busy}>292 <Flag size={14} /> Terminer la session293 </Button>294 </div>295 )}296 </>297 ) : feedback ? (298 <div className="space-y-3 animate-fade-up">299 <div300 className={cn(301 "rounded-xl border px-4 py-3 flex items-center gap-2.5 text-sm font-semibold",302 feedback.correct303 ? "bg-emerald-500/10 border-emerald-500/30 text-emerald-700 dark:text-emerald-400"304 : "bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400"305 )}306 role="status"307 >308 {feedback.correct ? <CheckCircle2 size={18} /> : <XCircle size={18} />}309 {feedback.correct ? "Bonne réponse !" : "Réponse incorrecte — elle a été ajoutée à votre cahier d'erreurs."}310 </div>311 <Card className="p-4">312 <p className="text-[12px] font-semibold text-muted uppercase tracking-wide mb-1.5">Réponse attendue</p>313 <Markdown content={feedback.expected} />314 {feedback.explanation && (315 <>316 <p className="text-[12px] font-semibold text-muted uppercase tracking-wide mt-4 mb-1.5">Explication</p>317 <Markdown content={feedback.explanation} />318 </>319 )}320 </Card>321 <div className="flex gap-2">322 <Button onClick={nextQuestion} disabled={busy} className="flex-1 justify-center">323 {busy ? <Spinner /> : <>Question suivante <ArrowRight size={15} /></>}324 </Button>325 <Button variant="secondary" onClick={finish} disabled={busy}>Terminer</Button>326 </div>327 </div>328 ) : null}329330 {error && <ErrorBanner message={error} />}331 </main>332 );333}334