feat: certificate celebration page

This commit is contained in:
electron-rare
2026-06-11 01:43:03 +02:00
parent d9bd5e0815
commit 8b0a135e0a
4 changed files with 304 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import { courseComplete } from './completion';
import type { ReadMark } from './grist';
import type { Quiz, QuizAttempt } from './quiz';
// --- helpers -------------------------------------------------------------
const mod = (chapterCount: number) =>
({ chapters: Array.from({ length: chapterCount }, (_, i) => ({ title: `Ch ${i + 1}`, fileUrl: '' })) });
const read = (moduleN: number, chapitreN: number, id = 0): ReadMark =>
({ id, course_slug: 'kicad-makers', module_n: moduleN, chapitre_n: chapitreN, moodle_synced: true });
const allReads = (modules: { chapters: unknown[] }[]): ReadMark[] =>
modules.flatMap((m, mi) => m.chapters.map((_, ci) => read(mi + 1, ci + 1, mi * 100 + ci)));
const quiz = (quizN: number, gradepass = 60): Quiz =>
({ quizN, cmid: 50 + quizN, name: `Quiz ${quizN}`, gradepass, questions: [] });
const attempt = (quizN: number, score: number, total = 5, id = 0): QuizAttempt =>
({ id, quiz_n: quizN, score, total, reponses: '{}', date: 1700000000 + id, moodle_synced: true });
// --- courseComplete ------------------------------------------------------
describe('courseComplete', () => {
const modules = [mod(3), mod(2)]; // 5 chapters total
const quizzes = [quiz(1), quiz(2), quiz(3), quiz(4)];
it('is complete when every chapter is read and every quiz is passed', () => {
const attempts = [attempt(1, 5), attempt(2, 4), attempt(3, 3, 5, 3), attempt(4, 5)];
const r = courseComplete(modules, allReads(modules), quizzes, attempts);
expect(r).toEqual({ complete: true, chaptersRead: 5, chaptersTotal: 5, quizzesPassed: 4, quizzesTotal: 4 });
});
it('is incomplete when a chapter is missing, even with all quizzes passed', () => {
const reads = allReads(modules).filter((m) => !(m.module_n === 2 && m.chapitre_n === 2));
const attempts = [attempt(1, 5), attempt(2, 5), attempt(3, 5), attempt(4, 5)];
const r = courseComplete(modules, reads, quizzes, attempts);
expect(r.complete).toBe(false);
expect(r.chaptersRead).toBe(4);
expect(r.chaptersTotal).toBe(5);
expect(r.quizzesPassed).toBe(4);
});
it('is incomplete when a quiz is failed or never attempted', () => {
// Quiz 2 best attempt below gradepass (60 % of 5 → needs ≥3), quiz 4 never attempted.
const attempts = [attempt(1, 5), attempt(2, 2), attempt(2, 1, 5, 9), attempt(3, 4)];
const r = courseComplete(modules, allReads(modules), quizzes, attempts);
expect(r.complete).toBe(false);
expect(r.quizzesPassed).toBe(2);
expect(r.quizzesTotal).toBe(4);
expect(r.chaptersRead).toBe(5);
});
it('treats an empty quiz list as trivially complete (0/0) when all chapters are read', () => {
const r = courseComplete(modules, allReads(modules), [], []);
expect(r).toEqual({ complete: true, chaptersRead: 5, chaptersTotal: 5, quizzesPassed: 0, quizzesTotal: 0 });
});
it('never grants completion on an empty course (0 chapters)', () => {
const r = courseComplete([], [], [], []);
expect(r.complete).toBe(false);
expect(r.chaptersTotal).toBe(0);
});
it('counts only distinct read pairs that exist in the modules', () => {
// Duplicate read of 1-1 + a stale read pointing outside the course (module 9).
const reads = [read(1, 1, 1), read(1, 1, 2), read(9, 1, 3), read(1, 7, 4)];
const r = courseComplete(modules, reads, quizzes, []);
expect(r.chaptersRead).toBe(1);
expect(r.complete).toBe(false);
});
it('uses the best attempt per quiz (a later fail does not undo a pass)', () => {
const attempts = [attempt(1, 5, 5, 1), attempt(1, 0, 5, 2)];
const r = courseComplete(modules, allReads(modules), [quiz(1)], attempts);
expect(r.quizzesPassed).toBe(1);
expect(r.complete).toBe(true);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { isBookComplete, type ReadMark } from './grist';
import { bestByQuiz, isPassed, type Quiz, type QuizAttempt } from './quiz';
export interface CourseCompletion {
complete: boolean;
chaptersRead: number;
chaptersTotal: number;
quizzesPassed: number;
quizzesTotal: number;
}
/** Pure completion predicate of a course: every module fully read AND every
* quiz passed (best attempt). An empty quiz list is trivially complete
* (0/0); an empty course (0 chapters) never is. */
export function courseComplete(
modules: { chapters: unknown[] }[],
reads: ReadMark[],
quizzes: Quiz[],
attempts: QuizAttempt[],
): CourseCompletion {
const chaptersTotal = modules.reduce((s, m) => s + m.chapters.length, 0);
// Distinct (module, chapitre) pairs that actually exist in the modules —
// duplicates and stale marks (renumbered course) must not inflate the count.
const seen = new Set<string>();
let chaptersRead = 0;
for (const r of reads) {
const key = `${r.module_n}-${r.chapitre_n}`;
if (seen.has(key)) continue;
seen.add(key);
const m = modules[r.module_n - 1];
if (m && r.chapitre_n >= 1 && r.chapitre_n <= m.chapters.length) chaptersRead++;
}
const chaptersComplete =
chaptersTotal > 0 && modules.every((m, i) => isBookComplete(reads, i + 1, m.chapters.length));
const best = bestByQuiz(attempts);
let quizzesPassed = 0;
for (const q of quizzes) {
const a = best.get(q.quizN);
if (a && isPassed(q, a.score, a.total)) quizzesPassed++;
}
return {
complete: chaptersComplete && quizzesPassed === quizzes.length,
chaptersRead,
chaptersTotal,
quizzesPassed,
quizzesTotal: quizzes.length,
};
}
+160
View File
@@ -0,0 +1,160 @@
---
import Base from '../../../layouts/Base.astro';
import Nav from '../../../components/Nav.astro';
import { bySlug } from '../../../config/courses';
import { getCourseBooks, type BookModule } from '../../../lib/moodle';
import { verifySession, SESSION_COOKIE } from '../../../lib/session';
import { getReads, type ReadMark } from '../../../lib/grist';
import { getQuizzes, getAttempts, bestByQuiz, isPassed, type Quiz, type QuizAttempt } from '../../../lib/quiz';
import { courseComplete } from '../../../lib/completion';
const course = bySlug(Astro.params.slug ?? '');
if (!course || !course.published) return Astro.rewrite('/404');
const user = verifySession(Astro.cookies.get(SESSION_COOKIE)?.value);
if (!user) return Astro.redirect('/connexion?next=' + encodeURIComponent(Astro.url.pathname));
let modules: BookModule[];
let reads: ReadMark[];
let quizzes: Quiz[];
let attempts: QuizAttempt[];
try {
[modules, reads, quizzes, attempts] = await Promise.all([
getCourseBooks(course.id),
getReads(user.sub, course.slug),
getQuizzes(course.slug),
getAttempts(user.sub, course.slug),
]);
} catch {
return new Response('Certificat momentanément indisponible — réessayez dans quelques minutes.', { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}
const completion = courseComplete(modules, reads, quizzes, attempts);
// First unread chapter (incomplete view).
const readSet = new Set(reads.map((r) => `${r.module_n}-${r.chapitre_n}`));
let firstMissing: { url: string; label: string } | null = null;
for (let mi = 0; mi < modules.length && !firstMissing; mi++) {
for (let ci = 0; ci < modules[mi].chapters.length; ci++) {
if (!readSet.has(`${mi + 1}-${ci + 1}`)) {
firstMissing = {
url: `/cours/${course.slug}/module-${mi + 1}/chapitre-${ci + 1}`,
label: modules[mi].chapters[ci].title,
};
break;
}
}
}
// First unpassed quiz (incomplete view).
const best = bestByQuiz(attempts);
const firstUnpassed = quizzes.find((q) => {
const a = best.get(q.quizN);
return !(a && isPassed(q, a.score, a.total));
}) ?? null;
const chPct = completion.chaptersTotal ? Math.round((completion.chaptersRead / completion.chaptersTotal) * 100) : 0;
const qzPct = completion.quizzesTotal ? Math.round((completion.quizzesPassed / completion.quizzesTotal) * 100) : 0;
---
<Base title={`Certificat — ${course.title}`} description={`Certificat de la formation ${course.title}`} noindex>
<Nav user={{ name: user.name }} currentPath={Astro.url.pathname} />
<main class="pt-24 pb-20 px-6">
<div class="max-w-prose mx-auto">
<nav class="text-sm text-slate-500 mb-8 flex flex-wrap gap-2">
<a href="/" class="hover:text-white">Formations</a><span>/</span>
<a href={`/cours/${course.slug}`} class="hover:text-white">{course.title}</a><span>/</span>
<span class="text-slate-300">Certificat</span>
</nav>
{completion.complete ? (
<section class="text-center">
<div class="nucleus mx-auto mb-10" aria-hidden="true"></div>
<h1 class="text-4xl md:text-5xl font-semibold tracking-tight text-white">Félicitations !</h1>
<p class="mt-3 text-xl text-copper-soft">{course.title}</p>
<p class="mt-2 text-slate-400">{completion.chaptersTotal} chapitres · {completion.quizzesTotal} quiz réussis</p>
<div class="mt-10 rounded-2xl border border-copper/30 bg-copper-dim px-6 py-6 text-left">
<p class="text-slate-200">
Votre certificat officiel est envoyé par email à
<strong class="text-white">{user.email}</strong>
(pensez à vérifier vos indésirables). Il est généré et signé par notre
plateforme de formation.
</p>
<p class="mt-4 text-sm text-slate-400">
Un souci avec votre certificat ?
<a href="mailto:contact@lelectronrare.fr" class="text-copper hover:underline">contact@lelectronrare.fr</a>
</p>
</div>
<p class="mt-10">
<a href={`/cours/${course.slug}`} class="text-copper hover:underline">← Retour au cours</a>
</p>
</section>
) : (
<section>
<h1 class="text-3xl md:text-4xl font-semibold tracking-tight text-white mb-3">Certificat</h1>
<p class="text-slate-400 mb-10">Terminez tous les chapitres et réussissez les {completion.quizzesTotal} quiz pour recevoir votre certificat par email.</p>
<div class="space-y-8">
<div>
<p class="text-sm text-slate-300 mb-2">Chapitres lus : {completion.chaptersRead} / {completion.chaptersTotal}</p>
<div class="h-2 rounded-full bg-white/10 overflow-hidden">
<div class="h-full rounded-full bg-copper" style={`width:${chPct}%`}></div>
</div>
</div>
<div>
<p class="text-sm text-slate-300 mb-2">Quiz réussis : {completion.quizzesPassed} / {completion.quizzesTotal}</p>
<div class="h-2 rounded-full bg-white/10 overflow-hidden">
<div class="h-full rounded-full bg-copper-soft" style={`width:${qzPct}%`}></div>
</div>
</div>
</div>
<ul class="mt-10 space-y-3 text-sm">
{firstMissing && (
<li>
<a href={firstMissing.url} class="text-copper hover:underline">Reprendre la lecture → {firstMissing.label}</a>
</li>
)}
{firstUnpassed && (
<li>
<a href={`/cours/${course.slug}/quiz-${firstUnpassed.quizN}`} class="text-copper hover:underline">Passer le quiz → {firstUnpassed.name}</a>
</li>
)}
<li>
<a href={`/cours/${course.slug}`} class="text-slate-400 hover:text-white">← Retour au cours</a>
</li>
</ul>
</section>
)}
</div>
</main>
</Base>
<style>
/* Copper nucleus glow — pure CSS celebration, no WebGL. */
.nucleus {
width: 11rem;
height: 11rem;
border-radius: 9999px;
background:
radial-gradient(circle at 38% 35%, rgba(255, 237, 213, 0.95) 0%, rgba(253, 186, 116, 0.9) 18%, rgba(249, 115, 22, 0.85) 45%, rgba(154, 52, 18, 0.35) 70%, transparent 78%);
box-shadow:
0 0 40px 8px rgba(249, 115, 22, 0.45),
0 0 120px 30px rgba(249, 115, 22, 0.18);
animation: nucleus-pulse 3.2s ease-in-out infinite;
}
@keyframes nucleus-pulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 40px 8px rgba(249, 115, 22, 0.45), 0 0 120px 30px rgba(249, 115, 22, 0.18);
}
50% {
transform: scale(1.05);
box-shadow: 0 0 56px 14px rgba(249, 115, 22, 0.6), 0 0 160px 44px rgba(249, 115, 22, 0.26);
}
}
@media (prefers-reduced-motion: reduce) {
.nucleus { animation: none; }
}
</style>
+13
View File
@@ -7,6 +7,7 @@ import { getCourseBooks } from '../../../lib/moodle';
import { verifySession, SESSION_COOKIE } from '../../../lib/session';
import { getReads, type ReadMark } from '../../../lib/grist';
import { getQuizzes, getAttempts, isPassed, type Quiz, type QuizAttempt } from '../../../lib/quiz';
import { courseComplete } from '../../../lib/completion';
import { syncToMoodle, syncQuizToMoodle } from '../../../lib/moodle-sync';
const user = verifySession(Astro.cookies.get(SESSION_COOKIE)?.value);
@@ -45,6 +46,9 @@ for (const qz of quizzes) {
syncQuizToMoodle({ sub: user.sub, email: user.email, name: user.name }, course.slug, qz.quizN).catch(() => {});
}
// Certificate eligibility — single computation from the already-loaded data.
const completion = user ? courseComplete(modules, reads, quizzes, attempts) : null;
const hubData = {
slug: course.slug,
modules: modules.map((m, mi) => ({
@@ -69,6 +73,15 @@ const pct = total ? Math.round((nbLus / total) * 100) : 0;
<p class="text-lg text-slate-400 mt-3 max-w-2xl">{course.blurb}</p>
{user && <p class="mt-4 text-copper-soft text-sm">{nbLus} / {total} chapitres lus · {pct} %</p>}
{user && quizzes.length > 0 && <p class="mt-1 text-copper-soft text-sm">Quiz : {quizPassed.size}/{quizzes.length} réussis</p>}
{user && completion?.complete && (
<a href={`/cours/${course.slug}/certificat`}
class="mt-5 inline-block rounded-xl border border-copper/40 bg-copper-dim px-5 py-3 text-copper-soft font-medium hover:border-copper hover:text-white transition-colors">
🎓 Certificat disponible →
</a>
)}
{user && completion && !completion.complete && (
<p class="mt-2 text-xs text-slate-500">Certificat : terminez les chapitres et les 4 quiz</p>
)}
</header>
<div id="atom-mount" class="hidden max-w-content mx-auto" data-hub={JSON.stringify(hubData)}></div>
<Sommaire slug={course.slug} modules={modules} readSet={readSet} quizPassed={quizPassed} />