IndexBook a call

Formation DELF

June 2026 – Present

From a WordPress and LearnDash quiz site to a complete DELF B2 exam-preparation platform built on Next.js, with AI correction.

Client projectFull-stackNext.jsAI correctionRealtime voicePWAPayments

Project overviewFull-stack product development

From a WordPress and LearnDash quiz site to a complete DELF B2 exam-preparation platform built on Next.js, with AI correction.

Years of content, live customers, and one gap: production écrite and orale still needed a human examiner.

Now all four DELF B2 épreuves and a full examen blanc run in one app, desktop and phone, installable as a PWA: AI correction, a live oral examiner, payments, a back office, and the legacy library migrated intact.

Compréhension oraleTwo listenings and the official pauses, timed.
Compréhension écrite20 questions in 60 minutes, scored per question.
Production écriteLive word count and clock, then AI graded /25.
Production oraleThe exposé recording, then a live AI examiner.
Examen blancThe four papers chained in exam order, /100.
épreuves, plus a full examen blanc /100
4 + 1
exam questions migrated, 9 wrong keys caught
680
realtime voice engines kept for production, after evaluation
1 of 4
automated test files: 342 unit, 106 end-to-end
448
DELF-DALF centres mapped in 167 countries
1,092

The problemProduct discovery

From a quiz site that could score two papers to an app that trains all four.

Live customers, real content, real revenue. Replace the platform without losing any of it.

01 · Preserve

What already worked

Questions, answer keys, exam subjects, audio: years of content. Nothing could be lost.

02 · Build

What the old architecture could not support

Listening and reading score themselves. Writing and speaking need an examiner.

Before and after

Compréhension orale
BeforeQuiz attached to long audio files.
AfterDocument-based audio, timed listening rules and exam-specific playback.
Compréhension écrite
BeforeStandard quiz access.
After20-question timed examination flow with per-question scoring.
Production écrite
BeforeRequires a human examiner.
AfterAI correction against the official DELF grille.
Production orale
BeforeRequires a human examiner.
AfterA live AI examiner, then grading on the oral grille.

Architecture and stackSystem architecture and stack

From a WordPress theme and its plugins to six domains on Next.js and Postgres, each one replaceable.

No tangled monolith. Six clear domains, each free to change on its own.

Content

  • Questions
  • Séries
  • Exam subjects
  • Documents and audio
  • Transcripts
  • Grading criteria

Assessment

  • Exam sessions
  • Candidate answers
  • Timers
  • Question state
  • Scoring
  • Submission rules

AI

  • Written correction
  • Prompt versions
  • Oral examination
  • Transcription
  • Pronunciation
  • Evaluation harness

Commerce

  • Stripe Checkout
  • Passes
  • Credits
  • Refunds
  • Entitlements

Candidate

  • Progress
  • Results
  • Attempts
  • Access
  • Performance history

Operations

  • Content admin
  • AI test bench
  • Examiner tools
  • User management
  • Business metrics

My scope, end to end

Architecture principle

Swap the interface, the AI provider or the pricing: grading, exams and progress stay untouched.

The stack, layer by layer

Application
  • Next.js16.2 · App Router
  • React19.2
  • TypeScript6 · strict
  • Tailwind CSS4
  • shadcn/uidesign system
  • Vercelhosting · Fluid compute
AI
  • AI SDK + AI Gatewaystructured output, one key
  • gpt-5.6-lunawritten grading
  • Gemini Livevoice examiner · in production
  • Gemini 2.5 Prophonology fallback, from audio
  • ElevenLabsevaluated, not kept
  • GPT Realtimeevaluated, not kept
  • Grok Voiceevaluated, not kept
Data
  • Neon Postgres57 tables
  • Drizzle ORM56 migrations
  • Vercel Blobaudio and recordings
Auth and payments
  • Clerkauth
  • Stripepasses and refunds
Product signals
  • PostHoganalytics, after consent
  • Resendtransactional email
Quality
  • Playwright106 specs
  • node:test342 unit files
  • Huskypre-commit · pre-push

Mobile-first PWAProgressive web app · mobile first

From a desktop quiz site to a mobile-first PWA, installed on the home screen like a native app.

Candidates revise on the bus, not at a desk. So Formation DELF ships as a PWA, and every paper, timer and correction is drawn for a phone screen first.

phone design decisions, written down
16
commits shaping the phone layout
155
landing JavaScript after the mobile speed pass
-28%

Thumbs first

A tab bar on every browsable surface, the account in the top bar, the start button on the first screen.

Drawers, not columns

Questions and corrections rise from the bottom as drawers, so the text being read keeps the screen.

Fast on 4G

Below-the-fold code split, the auth origin preconnected, pricing streamed before the account loads.

Four screens, one thumb: the candidate home with its tab bar, the DELF projection, the examen blanc started from the first screen, and a reading paper with its question drawer. All on a phone.

Legacy migrationData migration

From a LearnDash library to Postgres, every question, key and audio clip intact.

First problem, no AI involved: no LearnDash export held everything. So I built an authenticated migration pipeline around a versioned snapshot.

The migration loop · every pass replays from the snapshot
  1. 1
    Extractlogged-in scraper
  2. 2
    Inspectdiff vs last run
  3. 3
    Correctfix the transform
  4. 4
    Re-importidempotent
  5. 5
    Verifycounts, spot checks
  6. Versioned JSON
LearnDash stays legacy and read only; Postgres receives the content only once it verifies.

Why an intermediate dataset

Nothing wrote straight to production. Versioned JSON made every pass reviewable, comparable and safe to rerun, with a source-level backup.

Idempotent imports

Run it ten times, get one copy. Extract, inspect, correct, rerun, verify.

Migration was only the beginning

Imported is not correct. Listening content audited against transcripts, long audio cut into clips with ffmpeg. 17 listening and 17 reading séries came across.

questions migrated
680
audio clips separated by document
85
listening questions re-reviewed against transcripts
340
wrong answer keys found in the legacy content
9

Every key fix is guarded: it writes only if the row still holds the audited value, otherwise the transaction rolls back. JSON backup first.

Guarded UPDATE: expected old key or abortscripts/fix-co-answer-keys.ts:122
    await client.query("begin");    for (const f of FIXES) {      const row = qs.rows.find((r) => r.id === f.qid)!;      const res = await client.query(        `update questions set correct_answer_index = $1, correct_answer = $2, updated_at = now()          where id = $3 and correct_answer_index = $4`,        [f.to, row.options[f.to], f.qid, f.from],      );      if (res.rowCount !== 1) throw new Error(`update guard failed for question ${f.qid}`);    }

The WHERE clause carries the expected old key: if the row changed since the audit, nothing is written.

The listening paper those corrected keys now score, mid-exam. Desktop and phone.
Principle

Migration is not copying rows. It is preserving meaning.


AI written correctionAI grading pipeline

From essays waiting for a human examiner to a structured /25 correction on the official DELF grille.

Feedback from an LLM is easy. A score that behaves like an exam is not. The model judges; the code enforces every rule.

Written production, end to end

  1. Pick a sujet
  2. Write under exam conditions
  3. Structured AI correction
  4. Progress across copies
Writing under exam conditions: the official consigne in view, a French keyboard row, the 60-minute clock and a live count against the 250-word floor. Desktop and phone.

How a correction works

  1. GuardA blank copy never reaches the model: zero words returns a deterministic 0/25 with no inference and no cost.
  2. Build the contextLevel, consigne, response, official criteria, bands and the expected output shape.
  3. GradeThe model scores the five criteria one by one and returns structure, not a paragraph.
  4. ValidateThe output schema accepts only the official bands 0, 1, 3 or 5, never a 2 or a 4.
  5. Apply the grille in codeLength is counted, not judged: under half the words, all five criteria are capped to 0, whatever the model says. Off-topic caps follow the grille.
  6. ReportCriterion scores, quoted evidence, the annotated text, strengths, priority fixes and a model answer.
Length anomalies decided in code from the word countsrc/lib/ecrite-rubric.ts:62
/** * The official PE anomalies, and the word-floor fraction under which a response * is treated as "manque de matière" (< 50 % of the expected words → 0 on every * criterion) per the grille. */export const MANQUE_MATIERE_FRACTION = 0.5; /** * Resolve the effective PE anomaly. The two *length-based* anomalies are decided * here from the word count — never trusted from the model (same rigor as * `total25`): a blank copy is `copie_blanche`, and a copy under half the word * floor is `manque_matiere`. A length anomaly the model emitted but the count * doesn't support is discarded (so a hallucinated `copie_blanche` can't wrongly * zero a full-length copy); content-based anomalies (`hors_sujet_*`) are kept as * the model reported them. */export function resolveEcriteAnomalie(  modelAnomalie: string | null | undefined,  wordCount: number,  wordMin: number,): string | null {  if (wordCount === 0) return "copie_blanche";  if (wordMin > 0 && wordCount < wordMin * MANQUE_MATIERE_FRACTION) return "manque_matiere";  if (modelAnomalie === "copie_blanche" || modelAnomalie === "manque_matiere") return null;  return modelAnomalie ?? null;}

Length is counted by code. Under half the required words, every criterion is capped to 0 after the model returns.

A copy written in 52 minutes, graded 23/25: criterion by criterion, with the bilan and the annotated copy. Desktop and phone.

Prompt engineering

From prompts tuned by feel to versions fingerprinted and benchmarked against blind human marking.

A prompt moves scores, so it is treated like code: every version fingerprinted, none shipped on a hunch.

Role

What kind of evaluator the model behaves as.

Assessment framework

Which evaluation criteria apply.

Score constraints

Which values are valid and how to read them.

Evidence

Every judgment is justified with the candidate's own text.

Error routing

Lexical and grammatical spelling are never counted twice.

Output contract

Structured information, not arbitrary prose.

The release gate for a grading prompt
  1. Draftnew text
  2. Fixturesgraded repeatedly
  3. Comparevs blind humans
  4. Gateno regression?
    No regressionRegression: back to draft
  5. Activehash on every run
A regression on any criterion sends the prompt back to draft; the active version keeps grading.
Prompt version fingerprint (sha256, 12 hex)src/lib/ecrite-eval/prompt-hash.ts:3
import { createHash } from "node:crypto"; /** * A short fingerprint of an écrite evaluation prompt template, stored on every * prompt version and every harness run. The exact mirror of the orale-eval hash. * * Scores drifting between two runs is only actionable if you can tell *why*. * Persisting the hash alongside the model id turns "lexique dropped a band" into * "lexique dropped a band and the prompt changed" — or, more usefully, "lexique * dropped a band and the prompt did NOT change", which points at the model. * * It is also how the page knows a stored result is stale: a run whose hash no * longer matches the current draft was produced by a prompt that no longer * exists, so its scores describe the past, not the present. * * Hashes the template itself, not a rendered prompt — the template *is* the * thing being versioned, and rendering would fold in an arbitrary fixture. */export function hashPromptTemplate(template: string): string {  return createHash("sha256").update(template).digest("hex").slice(0, 12);

Twelve hex characters identify a prompt version on every evaluation run, so stale results are flagged.

The AI test bench: prompts identified by hash, stale results flagged, cost known before a run. Desktop and phone.

Criterion-level comparison

Two graders can share a total and disagree on every competency, so the harness compares criterion by criterion, not just the /25.

Blind human marking on the official grille, before the AI's correction is revealed. Desktop and phone.

AI oral examinerVoice AI and realtime integration

From speaking practice that needed a person to a live AI examiner that runs the official protocol.

A real-time examiner that follows the exam state, not an open chat.

  1. Preparation
  2. Exposé, recorded
  3. Débat with the examiner
  4. Assessment /25
The exposé, live: the candidate is speaking, the clock runs down, the plan stays in view. Desktop and phone.

Realtime architecture

ElevenLabs, GPT Realtime, Grok Voice and Gemini Live, tested behind one examiner contract on latency, French, interruptions, stability and cost. Gemini Live won. Switching took zero exam rewrites.

Realtime architecture, at a glance
CandidateMicrophone check, quiet-room guidance and recording consent before the clock starts.
Exam sessionA state machine owns timing and phase. The engine never decides when the exposé ends.
PréparationExposéDébat
Voice engine · one contract
Gemini Live · in productionThe examiner voice: hears the candidate, pushes back in the débat, and grades phonology as it listens.
Evaluated behind the same interface, not kept:
ElevenLabsGPT RealtimeGrok Voice
RecordingsExposé and débat audio stored per sitting, with the transcript, for grading and review.
Grading /25Criteria on the official oral grille. An exposé alone is marked /20; finishing the débat re-grades the sitting /25.
Phonology fallbackIf live scoring fails, Gemini 2.5 Pro listens to an audio sample of the exposé and débat. A transcript cannot judge pronunciation.
Realtime examiner personas by engine choicesrc/lib/quiz/po-player-view.ts:374
/** * The first names candidates know the live examiners by, keyed by the real * choice. The ONE place a persona is named: the candidate dropdown prints the * name alone, the admin dropdown and ledger print it after the engine * ("Gemini Live (Caroline)"), so when a candidate writes in about "Caroline" * the admin can tell which model they are talking about without a lookup. * * Insertion order is the candidate dropdown's display order, and the first * entry is the candidate's default. */export const PO_EXAMINER_PERSONAS = {  "grok-voice-2.0": "Sara",  [GEMINI_LIVE_EXAMINER_CHOICE]: "Caroline",} as const satisfies Partial<Record<PoExaminerChoice, string>>; /** The persona's first name for a choice, or null when the choice has no candidate-facing persona. */export function examinerPersonaName(choice: PoExaminerChoice): string | null {  return choice in PO_EXAMINER_PERSONAS    ? PO_EXAMINER_PERSONAS[choice as keyof typeof PO_EXAMINER_PERSONAS]    : null;}

The examiner contract keeps the catalogue of engines evaluated; production sittings run on Gemini Live.

Phonologie graded from audio with Gemini 2.5 Prosrc/lib/ai/phonologie-from-audio.ts:47
export const PHONOLOGIE_AUDIO_MODEL =  process.env.PO_PHONOLOGIE_AUDIO_MODEL || "google/gemini-2.5-pro"; const schema = z.object({  band: grilleBandSchema,  justification: z.string().min(1),  traits: z.array(z.string()).max(12),  confidence: z.enum(["haute", "moyenne", "basse"]),});

Only used when live phonology scoring fails: Gemini 2.5 Pro listens to a trimmed sample of the recordings.

One graded sitting in the back office: engine, session cost, both recordings and the phonology heard in the audio. Desktop and phone.
The same sitting, graded 19/25: criterion by criterion, with the full transcript beside it. Desktop and phone.
Principle

The examination owns the rules; the engine only carries the voice. That is what made choosing one engine a measured decision instead of a rewrite.


Exam engine

From quizzes on long audio files to the official timing, phases and listening protocol.

Not generic quizzes: the real exam timing, order, playback and listening limits, with training and examen blanc behaving differently on purpose.

Compréhension écrite

  • 20 questions, 60-minute session
  • Several exercises, per-question scoring
  • Document highlighting
  • Desktop and mobile

Compréhension orale protocol

  • Read the questions, audio signal
  • First listening, pause, second listening
  • Answering window
  • Single listening for some exercises

The clock runs on wall time, so a sleeping phone buys no extra minutes. At zero, answers save once and the session closes.

Exam countdown force-finishes at zerosrc/components/use-exam-session.ts:172
  // Global countdown. Recomputes elapsed time from a Date.now() baseline on  // each tick and on tab re-focus, so a throttled background tab can't drift  // the clock — and so a tab that was frozen PAST zero still finishes the  // moment it comes back. Hitting zero force-finishes the session: the exam is  // over, the results screen is where the candidate belongs.  useEffect(() => {    if (finished || !hasStarted) return;     if (!quizStartTimeRef.current) {      // A resumed attempt adopts its ORIGINAL start stamp, so the countdown      // continues where it left off; only a fresh attempt stamps now. Seeding      // here rather than in a separate effect is deliberate — a separate effect      // would run after this one, which would already have stamped `Date.now()`      // and silently handed the candidate a new hour.      quizStartTimeRef.current = resumeAt ?? Date.now();    }     const timer = setInterval(() => {      const remaining = calculateTimeRemaining();      setTimeRemaining(remaining);      if (remaining <= 0) {        clearInterval(timer);        setIsFinished(true);      }    }, 1000);

Remaining time comes from a wall-clock baseline, re-checked when the tab comes back into view.

The reading paper under exam conditions: the clock counts down, answers save as the candidate goes, the text can be highlighted. Desktop and phone.

What the candidate sees

A clean examination interface.

What the application manages

A state machine deciding which action is valid at every moment.


Full mock examExam orchestration

From separate quizzes to one examen blanc scored /100 on the real pass rule.

Four papers, one sitting, one score /100. Each competency keeps its own rules and feeds the same scoring everywhere.

DELF pass rule: 50/100 and 5/25 per competencysrc/lib/scoring.ts:4
 * Official DELF model (see docs/DELF_EXAM_REFERENCE.md section 2): *   - Each of the 4 competencies is marked out of 25 → total out of 100. *   - Pass = total ≥ 50/100 AND ≥ 5/25 in every competency (a score < 5 is *     éliminatoire and cannot be rescued by a strong score elsewhere). *   - The level (A1/A2/B1/B2) is the exam sat, not a derived band. */ export type DelfLevel = "A1" | "A2" | "B1" | "B2"; export const DELF_LEVELS: readonly DelfLevel[] = ["A1", "A2", "B1", "B2"] as const; export const COMPETENCY_MAX = 25;export const TOTAL_MAX = 100;export const PASS_TOTAL = 50;export const COMPETENCY_FLOOR = 5;

One rule file feeds the dashboard, the examen blanc and every result screen.

The examen blanc today: the first sitting is offered with a free account, the session timeline shows each paper in exam order. Desktop and phone.

Engineering challenge

Showing four scores is easy. Orchestrating four exams without welding them together is the work.

Principle

Each competency owns its rules. The mock exam coordinates them.


Candidate progressLearning analytics

From isolated quiz scores to a dashboard that projects the DELF result.

Candidates see progress, not just scores: every paper projected against the DELF pass line.

A candidate at 59/100, up 9 points: the DELF projection against the pass line of 50, per-competency scores and weekly goals. Desktop and phone.

Reset without destroying history

A reset archives previous data instead of deleting it, so history stays useful for analytics, support and the candidate.

Principle

A reset changes the learning state. It does not erase the past.


Pricing and paymentsMonetisation and payments

From a WordPress shop to time-limited passes on Stripe, granted only by a signed webhook.

Priced the way candidates prepare: time-limited passes with an AI-correction quota, not a subscription.

Bronze
7 days
Silver, the most popular
30 days
Gold, for a full preparation
60 days
Pricing: one-time passes, and the first série free without a card. Desktop and phone.

After the money moves

Opening Checkout is easy. What happens after the money moves is the work.

Browserthe candidate
App serverNext.js on Vercel
Stripecheckout and events
Postgresuser_plans
  1. 1Choose a pass· server action
  2. 2List recent sessions· dedupe
  3. 3Create Checkout session
  4. 4Pay on Stripe· hosted checkout
  5. 5checkout.session.completed· signed webhook
  6. 6Create the pass· lock + insert
  7. 7Pass is live· page polls
  8. 8Open a paid série· access check
  9. 9charge.refunded· signed webhook

Step 1 / 9 · Choose a passThe plan travels in session metadata; price IDs come from configuration, and a misconfigured plan shows as unavailable.

Checkout dedupe: same pack paid within 5 minutessrc/lib/checkout-dedupe.ts:52
export function decideCheckoutDedupe(input: {  sessions: readonly CheckoutSessionSummary[];  planKey: string;  isDelfPlanKey: (key: string) => boolean;  /** Unix seconds. */  now: number;}): CheckoutDedupeDecision {  const { sessions, planKey, isDelfPlanKey, now } = input;  const delf = sessions.filter((session) => {    const key = session.metadata?.planKey;    return typeof key === "string" && isDelfPlanKey(key);  });   const recentPurchase = delf.find(    (session) =>      session.status === "complete" &&      session.metadata?.planKey === planKey &&      now - session.created <= RECENT_PURCHASE_WINDOW_S,  );   return {    recentPurchaseSessionId: recentPurchase?.id ?? null,    sessionIdsToExpire: delf      .filter((session) => session.status === "open")      .map((session) => session.id),  };}

A pure function decides; the Stripe calls around it stay thin and easy to test.

Pass creation idempotent on the checkout session idsrc/lib/user-plans.ts:458
    const values = {      clerkUserId: input.clerkUserId,      planKey: plan.planKey,      kind: plan.kind,      source: input.source,      stripeCheckoutSessionId: input.stripeCheckoutSessionId ?? null,      stripePaymentIntentId: input.stripePaymentIntentId ?? null,      amountPaidCents: input.amountPaidCents ?? null,      currency: input.currency?.toLowerCase() ?? null,      startsAt,      expiresAt: expiresAtForPlan(plan, startsAt),      ecriteTriesTotal: plan.ecriteTries,      oraleTriesTotal: plan.oraleTries,      grantedBy: input.grantedBy ?? null,    };     if (values.stripeCheckoutSessionId) {      const inserted = await tx        .insert(userPlans)        .values(values)        .onConflictDoNothing({ target: userPlans.stripeCheckoutSessionId })        .returning();      return { created: inserted.length > 0, plan: inserted[0] ?? null };    }     const [created] = await tx.insert(userPlans).values(values).returning();    return { created: true, plan: created ?? null };  });

Stripe retries are harmless: the unique checkout session id makes the second insert a no-op.

Server-side lock decision for a compréhension sériesrc/app/(fullscreen)/epreuve/[category]/entrainement/[slug]/page.tsx:28
/** * A compréhension série is locked for the current user when they lack premium * access AND an admin has not unlocked it (`series.is_free`), the one rule * shared with the séries listing and the examen blanc pool (lib/free-series.ts). */async function isSeriesLockedForUser(category: string, seriesId: number, hasFullAccess: boolean) {  if (hasFullAccess) return false;  if (!isComprehensionCategory(category)) return true;  const freeIds = await getFreeSeriesIds(category);  return !freeIds.has(seriesId);}

The lock is decided on the server before the player renders, never in the browser.

Full refund revokes the passsrc/app/api/stripe/webhook/route.ts:566
  if (isFullRefund(charge)) {    if (stripeSubscriptionId) {      await cancelSubscriptionIfActive(stripeSubscriptionId);    }    if (paymentIntentId) {      await revokeUserPlansByPaymentIntent(paymentIntentId);      // A promo code used for this purchase can be used again.      await releaseCouponForRefundedPayment(paymentIntentId);    }

A full refund takes the same webhook path as the purchase, in reverse.

Idempotent

One pass per checkout session, however many times Stripe retries.

Reconciled

Access is only ever granted from the signed webhook.

Reversible

A full refund follows the same path and revokes the pass.

The entitlement, as the candidate sees it: the pass, its status and the access it opens on every épreuve. Desktop and phone.
Key principle

Checkout is synchronous for the customer. Payment state is asynchronous for the application. That difference drives the architecture.


Operations and adminBack-office tooling

From the WordPress admin to 37 back-office pages built for how the team works.

A SaaS needs a cockpit too. The team runs everything from one back office: content, subjects, AI tests, examiners, users, passes and metrics.

Content administration: every série with its publishing state, free access and exam-player conformity. Desktop and phone.

Exam-centre database

Every DELF-DALF centre on one searchable map, with upcoming sessions and CSV export.

exam centres
1,092
countries
167
Principle

The product is not finished when the interface works. The business must be able to operate it.


Quality and deliveryDelivery, testing and decision records

From a WordPress site to 2,577 commits through pull requests, 448 test files and 401 written decisions.

16 weeks from first commit to pre-launch. Every figure is read from the repository, not estimated.

commits on the staging branch since 29 June 2026
2,577
pull requests merged, each through staging first
490
lines of TypeScript across 1,831 files
289k
pages: 68 for candidates, 37 for the back office
105
React components on one design system
445
Postgres tables, evolved through 56 migrations
57
API routes, plus server actions for every form
28
unit test files on node:test
342
Playwright end-to-end specs
106
written decisions: 195 engineering, 206 design
401
voice engines evaluated, one kept for production
4 → 1
failed source pages in the legacy migration
0

Testing

Each way to fail gets its own test: rules in unit tests, journeys in a real browser, models in an evaluation harness.

Unit
342
node:test files next to the rules they guard: scoring, bands, grille caps, access, timers, data transforms.
End to end
106
Playwright specs driving a real browser against the app and its database, desktop and phone.
AI evaluation
2
Harnesses, written and oral: fixtures, prompt fingerprints, criterion-level comparison with blind human marks.
Gates
3
Lint-staged on commit, typecheck before every push, the full suite on every pull request.

106 end-to-end specs, by area · pick one

Payments and access · 11 specs

  • auth.spec.ts
  • tarification.spec.ts
  • admin-series-access.spec.ts
  • admin-codes-promo.spec.ts
  • mon-compte.spec.ts
  • admin-subscriptions.spec.ts
  • auth-redirect.spec.ts
  • recu.spec.ts
  • mon-compte-mobile-panel.spec.ts
  • packs-dialog.spec.ts
  • sign-out.spec.ts

Four journeys, tested from the first click to the database

Sit a reading exam

  1. Open a free série signed outepreuve-series
  2. Sign in from the modal, land back on the sérieseries-signin-modal
  3. Answer, highlight, move between exercisesce-exam, ce-highlighter
  4. Let the clock run out: answers saved onceexam-timeout
  5. Results, explanations, retryce-exam-completion, serie-retry

Buy a pass and unlock

  1. Choose a pass on the pricing pagetarification
  2. Packs dialog and promo codespacks-dialog, admin-codes-promo
  3. Return from checkout to the same pageauth-redirect
  4. Pass shows in mon compte, receipt printsmon-compte, recu
  5. Paid série opens; admin can grant or revokeadmin-series-access

Sit a production orale

  1. Microphone pre-flight with zero preparationpo-zero-prep-preflight
  2. Token gate before the live sessionpo-token-gate
  3. Exposé then débat, two-part sittingpo-two-part-sitting
  4. Transcript of the passationpo-passation-transcript
  5. Result stays on the last try; ungraded sittings handledpo-last-try-stays-on-result, po-ungraded-sitting

Run an examen blanc

  1. Public landing, first sitting offeredexamen-blanc-public
  2. Sign in, start, papers chained in exam orderexamen-blanc
  3. Dashboard projection updatesdashboard-v2
  4. Back office sees the sittingadmin-examens-blancs
  5. Reset archives, never deletesreset-progression
Principle

Different failure modes require different kinds of tests. A model is evaluated; a rule is asserted; a journey is clicked through.

Decisions on record

Why passes, not subscriptions?
DecisionExam preparation is temporary and intensive; pricing should follow that.
Why not let the LLM calculate everything?
DecisionDeterministic rules belong in deterministic code. The model judges where judgment is needed.
Why version prompts?
DecisionChanging a grading prompt changes the product. It must be identifiable and testable.
Why migrate through JSON?
DecisionAn intermediate format makes the migration inspectable, reproducible and recoverable.
Why evaluate four voice engines?
DecisionAI infrastructure moves fast. The exam stays the same while the engine is measured and chosen.
Why separate training and exam modes?
DecisionTraining helps the candidate improve; exam mode reproduces the constraints.
Why preserve old progress?
DecisionHistory keeps its value even when the candidate restarts.

How the platform was rebuilt

  1. MigrateAuthenticated scraper, versioned JSON, idempotent import of questions, answers, subjects and audio.
  2. RebuildExam players, written AI correction, the oral examiner, progress, payments, access and admin.
  3. Verify106 Playwright specs, 342 node:test files, AI evaluation harnesses, checks on every change.
  4. LaunchFinal pre-launch while the legacy site keeps serving users: no premature cutover.

The resultEnd-to-end delivery

From a legacy WordPress quiz site to a product ready for candidates.

A legacy quiz site turned into the full DELF B2 preparation journey, in final pre-launch while the business stays online.

Candidates can

  • Train on listening and reading
  • Take timed exam simulations
  • Submit written productions
  • Get structured AI correction
  • Practise speaking with an AI examiner
  • Track progress across competencies
  • Sit full mock exams
  • Buy access directly

The business can

  • Manage educational content
  • Test AI correction behaviour
  • Configure examiners
  • Manage paid access
  • Monitor activity
  • Run everything from one back office

What this project proves · each one links to the evidence

Every decision answered a business constraint

Customers

who could not be disrupted

Content

that could not be lost

Grading

that had to be auditable

Payments

that had to reconcile