feat: certificate celebration page
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user