Compare commits

..

92 commits

Author SHA1 Message Date
3016d909a6 feat(t1-live): T1 Live frontend — Sprint 7b
Some checks failed
CI / quality (push) Has been cancelled
- Add T1 state machine (8 states, presenting ⇄ interrupted)
- Add useT1LiveSession (WS /t1/live, uplink gate by ref, no context msg)
- Add T1PreparationPage, T1DialoguePage, T1SpeakingIndicator
- Add EO_T1_LIVE card in TaskSelector gated via oral_t2_live
- Extract shared t1Questionnaire.ts for batch/live DRY
- Remove T1LiveQuestionnairePage + T1LiveContext (post patch 7a)
- Simplified flow: card → preparation → dialogue
- FTD-44 frozen (cross-feature audio hooks, Sprint 7.5)
- FTD-45/46 frozen (Gemini relance quality + transcription)
- Tests: 301/301 green
2026-06-30 22:53:57 +03:00
eb8987ddb3 chore(roadmap): script de synchro frontend -> backend (sens unique, garde durcie)
Some checks are pending
CI / quality (push) Waiting to run
- scripts/sync-roadmap.mjs : copie ROADMAP frontend (source de verite) vers backend (copie generee).
- Garde sens-unique : identite de chemins resolus + refus si cible dans le repo source + casse win32.
- Validation source (existe, non vide, header attendu) ; diff + confirmation y/N ; flag --check (dry-run).
- Banniere auto-generee en tete de la copie backend (NE PAS EDITER A LA MAIN), ignoree au diff (idempotent).
- Pas de commit auto : rappelle la marche a suivre manuelle.
- alias npm sync:roadmap.
2026-06-29 23:10:43 +03:00
713d830be0 docs(roadmap): reconciliation + Sprint 7a livre
- Reconciliation des deux copies divergentes (frontend/backend) en version canonique.
- Sprint 6 marque complet (6b/6c/6d backend/6e), note 6d backend reinjectee (commits 94387a7/5f7e52d).
- Sprint 7a (T1 Live backend) marque livre : commits 868bd09/3722e2a, dettes tracees TD-23/24/25.
- Sprint 7e (transcription live a l'ecran) reinjecte + annote post-7a (caveat TD-23 : incremental cote candidat a reconcevoir, flush a activityEnd en VAD manuel).
2026-06-29 22:41:49 +03:00
044a305019 docs(t2-live): cloture documentaire Sprint 6e
Some checks are pending
CI / quality (push) Waiting to run
- CHANGELOG : bloc Sprint 6e (Voie A, Bugs 4/5/6, indicateur, cleanup, removed).
- PARCOURS : section 4 T2 Live (notes + gating 30 mots, candidat initie, timers prepa/dialogue).
- ROADMAP : Sprint 6 marque livre (6b/6c/6e).
- GOLDEN_DATASET : D3 corrige (candidat en premier) + annotations Groupe D (D6 partiel, D7-D11 sprints futurs).
2026-06-29 14:35:43 +03:00
72795e924e feat(t2-live): archi audio Voie A + Bugs 4/5/6 + indicateur de prise de parole (Sprint 6e)
- Voie A WAV : AudioContext unique au rate natif, tap AudioWorklet sur mixGain, uplink rate-aware 16k, alignement par horloge unique (fin offset/resample/concat). Anti-echo candidat. Cycle start=ws.onopen / stop=Terminer / cancel=aucun WAV.
- Bug 4 : 'Voir le rapport' route vers le rapport (navigatingAwayRef).
- Bug 5 : 'Annuler' (cancelDialogue) - arret sans evaluation, sans WAV, sans production.
- Bug 6 : 'Nouvelle simulation' route selon le type via champ tache propage (Report).
- Indicateur de prise de parole : state machine USER_SPEAKING/USER_SILENT (RMS + hysteresis).
- Cleanup : retrait instrumentation [BISECT] ; ref VAD renomme lastAiChunkTsRef.
- Removed : code mort mixTracksToInt16, resample16kTo24k + tests.
2026-06-29 14:31:38 +03:00
9bf95f5c05 fix(t2-live): exiger 30 mots de notes avant suggestions d'idées (parité EE)
handleIdees envoyait la consigne du sujet comme contenu_partiel, déclenchant
l'anti-blanc sans effort du candidat et provoquant un 400 sur les sujets à
consigne courte (< 30 mots). Envoie désormais les notes réelles du candidat
et désactive le bouton sous 30 mots (tooltip), aligné sur le comportement EE.
Le modal IdeesSuggestions reste le filet VALIDATION_ERROR.

Test: T2PreparationPage.test.tsx (bouton désactivé < 30 mots ; fetchIdees
reçoit les notes ≥ 30 mots).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-29 00:07:14 +03:00
b8eed80708 fix(useT2LiveSession): stabilize cleanup and connection
Some checks failed
CI / quality (push) Has been cancelled
fix: cancelTokenRef prevents double WS connections (StrictMode)
fix: closeAllRef ensures cleanup only runs on unmount
  259/259 frontend tests green
2026-04-27 02:26:13 +03:00
1d95166611 Sprint 6c — Frontend T2 Live UI + state machine + integration
feat(t2-live): state machine pure 9 states x 8 events (resolves FTD-09)
feat(t2-live): useT2LiveSession WS orchestrator + audio hooks + close codes
feat(t2-live): T2SujetsPage, T2PreparationPage (2min timer + notes + ideas),
  T2DialoguePage (3:30 dialogue + terminal screen with WAV download)
feat(t2-live): T2LiveContext provider for sujet sharing between pages
fix(TaskSelector): unlock EO_T2_LIVE card via hasAccess (resolves FTD-33)
chore: Tache type + labels + config extended with EO_T2_LIVE
test: 21 t2-machine tests — 259/259 green (+21)
2026-04-26 20:32:02 +03:00
7862f7c9f3 Sprint 6b — Frontend audio capture + playback hooks
feat(audio): pcm-capture-processor.js AudioWorklet (16kHz resample, Int16 LE)
feat(hooks): useAudioCapture (getUserMedia + worklet + onChunk base64)
feat(hooks): useAudioPlayback (24kHz sequential scheduling, gap-free)
feat(hooks): useAudioRecording (chronological buffer, resample 16→24k, WAV export)
feat(lib): audio-utils (base64, int16/float32, resample, WAV header)
test: 12 audio-utils + 7 useAudioRecording = 238/238 green (+19)
2026-04-26 20:12:36 +03:00
5a31819bca Sprint 5.5 Clean FTD — triage 21→14 actives (cap 15)
fix(StatCards): replace plan === 'free' with !hasAccess(plan, 'dashboard') (FTD-39)
refactor(useAudioRecorder): move optionsRef assignment to useEffect (FTD-38)
docs(TECH_DEBT): v1.27 — freeze FTD-09/33/42, close FTD-14/35/38/39
2026-04-26 19:04:40 +03:00
3a3fa6272d docs(sprint-5): CHANGELOG + ROADMAP + TECH_DEBT (Sprint 5e clean)
- CHANGELOG: entrées Sprint 5b/5c/5d (198 → 219 tests)
- ROADMAP: Sprint 5 Billing marqué 
- TECH_DEBT: FTD-42 (modal prorata) + FTD-43 (race webhook) ouvertes. v1.26. 21 FTD actives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 05:57:15 +03:00
de16deede3 feat(billing): Customer Portal + page Paramètres + Standard→Premium via portal
- useCustomerPortal hook (mutation + redirect full-page)
- AccountBillingSection: badge plan + bouton Gérer mon abonnement (Standard/Premium)
- ParametresPage: page conteneur /parametres avec section billing
- PricingPage: Standard→Premium redirige vers Customer Portal (prorata natif Stripe)
- Tests: 212 → 219 verts (+7)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 05:43:06 +03:00
bda7feb196 feat(billing): useStripeCheckout hook + post-redirect upgrade success
- useStripeCheckout: mutation + redirect full-page, pendingPriceType exposed
- PricingPage migré vers useStripeCheckout (suppression useMutation inline)
- useUpgradeSuccessHandler: détecte ?upgrade=success, invalide plan cache, clean URL
- UpgradeSuccessBanner: callout success dans DashboardPage
- Tests: 203 → 212 verts (+9)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 05:19:18 +03:00
9edfbb3c95 feat(billing): page tarifaire /plan + uniformisation CTA "Voir les plans" (Sprint 5b)
- features/billing/{api,components/PlanCard,pages/PricingPage} + 5 tests
- 3 colonnes Free/Standard/Premium avec gating dynamique selon usePlan()
- POST /stripe/checkout avec redirect full-page Stripe Checkout
- env: VITE_STRIPE_PRICE_STANDARD/_PREMIUM (optionnels)
- router: /plan → PricingPage (sous PrivateLayout)
- CTA renommés "Voir les plans" : SimulationsList, RapportPage, TaskSelector,
  DashboardFreeView, PaywallBanner — au lieu de CTA orientés un seul plan
- Tests: 198 → 203 verts (+5)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 04:52:13 +03:00
04019f8348 feat(rapport/eo): support 5 critères × /4 — Phonologie (Sprint 4.8 frontend)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:30:49 +03:00
3ce91aaa7b feat(historique): refonte pixel-perfect avec stats + filtres + tendance 30j (Sprint 4.7)
Inclut le retrait du padding de AppLayout et le wrapper standardisé
(mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9) ajouté sur
11 pages (Dashboard, Progression, 9 pages Simulation EE/EO/T1) pour
laisser chaque page gérer son max-width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:04:12 +03:00
d8bae9520c feat(simulations/eo): waveform + timeline colorée pendant l'enregistrement (Sprint 4.6)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:04:03 +03:00
9614f9de14 docs(changelog): session 2026-04-25 — Sprint 4.5 Clean + fixes Golden Dataset 2026-04-25 21:19:37 +03:00
06fbfe3f9b fix(rapport): patch temporaire conclusion conseil_nclc quand NCLC dépassé (FTD-40, FTD-41) 2026-04-25 21:16:00 +03:00
822b02a2d1 fix(rapport,eo): conclusion ScoreHero 3 états + persistance simulation_id pour resume EO 2026-04-25 21:10:39 +03:00
5188714235 docs(architecture): refléter l'arborescence réelle + documenter convention shared/ui (FTD-25, FTD-26) 2026-04-25 17:59:10 +03:00
8175438eea docs: add sprints 4.6/4.7/4.8 to ROADMAP (EO UI, historique, phonologie)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:03:05 +03:00
9ddb3dc24a docs: update CHANGELOG and ROADMAP for Sprint 4
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 08:54:47 +03:00
d1c8b548bb feat(eo): complete EO simulation flow (T1 + T3) with Gemini transcription
- Gemini batch transcription (no Deepgram live)
- blobToBase64 helper (shared/lib/audio.ts)
- AudioRecorder: remove onChunk, add maxSeconds/onMaxReached auto-submit
- Timer stops at maxSeconds and triggers auto-submission
- EnregistrementEOPage: audioBase64 to backend, fix race condition step=done
- SimulationFlowProvider: submitEoAudio(audioBase64, mimeType, nclcCible)
- MIME normalization (strip codec params)
- Split CORRECTION_EE_TIMEOUT_MS (60s) / CORRECTION_EO_TIMEOUT_MS (120s)
- PresentationGenereeT1Page: localStorage persistence

Typecheck: OK · Tests: 159/159 

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 08:28:51 +03:00
71c1ad3018 docs(changelog): add api-client timeout fix entry
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 02:28:04 +03:00
944d2803a2 fix(api-client): increase default timeout 5s → 15s
Render Starter tier has occasional >5s latency on first authenticated
requests after DB idle period. 15s absorbs cold-path latency without
masking real failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:47:26 +03:00
4005673ae8 feat(ui-polish): sidebar icons + topbar + dashboard redesign
- Sidebar: lucide-react icons, lock on gated items, upgrade badge on "Mon plan", user footer with avatar initials + plan label, "EX|PRIA" logo header
- Topbar: sticky with backdrop-blur, breadcrumb via centralized route-titles.ts, search placeholder, keyboard shortcuts + notifications icons
- Dashboard: split into Free/Standard/Premium views (ARCHITECTURE.md §3 aligned)
- NclcHero: NCLC display + gauge 5→10 + SVG score ring
- StatCards: simulations remaining + NCLC estimé + dernier score with delta
- RecentSimulations: 3 latest with NCLC badge + chevron nav
- NextStepCard: static recommendation per plan
- PaywallBanner: full-width redesign + fixed dead Boréal tokens
- Removed orphan MobileHeader.tsx (0 consumers)

Typecheck: OK · Tests: 122/122 

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:52:02 +03:00
b68f160bce feat(design-system): reskin Charcoal — tokens dark-default + sidebar navy permanent
- Remplacement intégral index.css par palette Charcoal (DESIGN_SYSTEM.md v2.0)
- Dark = thème par défaut, .light = override via @custom-variant light
- Sidebar navy #0C1528 permanent (identique dark+light)
- Script anti-FOUC inline dans index.html
- Layout : radial-gradient sur <main>, sidebar 230px, max-w-[1100px]
- Renommage tokens Boréal→Charcoal sur ~45 composants
- Inversion dark: → baseline + light: sur primitives shadcn
- Fix logo blanc forcé dans sidebar
- ADR 006 mis à jour

Typecheck: OK · Tests: 122/122 

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:09:15 +03:00
407d1bd134 style: format useRapport.test.tsx (fix CI)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:35:49 +03:00
cab9c8c92b fix(simulations): resolve FTD-23 autosave after correction + FTD-24 auto-polling pending jobs
- FTD-23: propagate enabled=false to useAutosave when step is done/correcting, preventing 400 PATCH after correction
- FTD-24: add conditional refetchInterval (3s) in useRapport for pending exercices/modele, 2min timeout with retry UI
- 7 new tests (2 regression + 5 polling), 122/122 green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 03:56:57 +03:00
bc2a1174d1 docs: fermeture FTD-28 (Semgrep CI), TECH_DEBT v1.18, CI verte
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 03:26:52 +03:00
e72d68513a ci: env vars factices pour tests CI
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 03:23:23 +03:00
99617f117c style: prettier format
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 03:17:16 +03:00
79bbbdc4e8 fix(lint): 4 erreurs ESLint corrigées — split SimulationFlowProvider, hook conditionnel, ref render, setState effect
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 03:05:14 +03:00
de69b3ff16 ci(semgrep): scan SAST --severity=ERROR (FTD-28)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:46:19 +03:00
39b633d1e3 docs: fermeture FTD-27 (CI backend), TECH_DEBT v1.17
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:39:29 +03:00
d7321c868e docs: fermeture FTD-29, TECH_DEBT v1.16
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:32:33 +03:00
1c84844108 ci(dependabot): config version updates weekly (FTD-29)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:30:42 +03:00
5fdc4ee0ef docs(changelog): réorg sécurité v1.15
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:23:32 +03:00
04dfbe2731 docs(tech-debt): réorg sécurité v1.15 — FTD-06/08/15 gelées, FTD-27/28/29 ajoutées
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:21:27 +03:00
2a6ea10978 docs(changelog): triage FTD v1.14
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:44:59 +03:00
a0352457dc docs(tech-debt): triage FTD v1.14 — 17→15 actives
Fermées : FTD-04 (miroir docs, accepté ADR 004), FTD-05 (scaffold
caduc, audit clean), FTD-20 (GET /simulations/:id livré Sprint 3.6a),
FTD-22 (code orphelin /sujets, résolution complète).

Ajoutées : FTD-25 (ARCHITECTURE.md §3 désaligné), FTD-26
(cohabitation shared/ui vs shared/components/ui).

Cap de 15 FTD actives respecté.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:42:11 +03:00
a60c298605 feat(progression): page /progression + section Dashboard Premium — patterns, exercices long terme, indice de préparation (Sprint 3.6c)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:12:23 +03:00
a752029c19 feat(historique): page /historique — liste paginée des productions + gating plan (Sprint 3.7)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:29:32 +03:00
da4e465125 fix(navigation): correctifs flux retour post-rapport et post-sujets (reset sticky useEffect)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:15:53 +03:00
f51caa1b75 feat(rapport): Sprint 3.6b — RapportPage enrichie, exercices dynamiques, production modèle, sélecteur NCLC
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:14:38 +03:00
8390e8b873 feat(corrections): Sprint 3.6a — nouveaux prompts + taxonomie erreurs + génération parallèle
Côté frontend : timeout corrections 30→60s (aligné avec backend 55s),
FTD-23 documentée, TAXONOMIE_ERREURS.md ajouté.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:28:02 +03:00
18f92098cb refactor(simulation-ee): Sprint 3.5 clean — FTD-17/18/19 résolus, factorisation SimulationForm
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:03:46 +03:00
385b29679e docs: Session Clean + ROADMAP sprints X.5 (refactoring obligatoire) 2026-04-21 05:14:57 +03:00
656b42e6c4 docs: FTD-21 résolu partiellement pour /simulation/ee 2026-04-21 04:54:09 +03:00
ae8d8af1df feat(simulations): resume session + RapportPage tolère rapport=null (FTD-21) 2026-04-21 04:52:51 +03:00
aaecc3f804 feat(simulations): resume session depuis localStorage (FTD-21) 2026-04-21 04:02:15 +03:00
549e5f698f feat(simulations): autosave 30s + localStorage + reprise contenu (FTD-21) 2026-04-21 03:57:10 +03:00
d395a04193 feat(production): types SimulationState + API autosave/updateSujet (FTD-21) 2026-04-21 03:51:16 +03:00
95711a7c44 docs(tech-debt): FTD-21 documentation complète persistance session
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 03:34:49 +03:00
886ecbb433 docs(changelog): tâche G5 — suggestions d'idées DeepSeek
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 03:24:39 +03:00
dee3c181f6 feat(simulations): bouton Suggestions d'idées + modal DeepSeek (G5)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 03:24:17 +03:00
67eb3411c5 feat(simulations): getIdees API + hook useIdees (G5)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 03:09:01 +03:00
555dac17e2 docs(changelog): G4 + refonte page sujets + fix quota simulations
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 02:56:45 +03:00
4712a3a16e refactor(simulations): supprimer SujetSelector + selectSujet orphelins (FTD-22)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 02:56:02 +03:00
6bfdf15db9 feat(simulations): finaliser flux /sujets — SimulationForm + SujetDisplay + TaskSelector type prop
- SimulationForm : bouton "Changer de sujet" → /sujets (étape 3 refonte)
- SujetDisplay : redevient présentationnel (plus de dropdown)
- TaskSelector : prop type 'EE' | 'EO' (EO_CARDS réservé usage futur — non routé)
- SimulationPage : type='EE' hardcodé (EO restera ComingSoon jusqu'au Sprint EO)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 02:48:48 +03:00
43f3ce2c6c fix(simulations): TaskSelector EE — retirer cartes EO 2026-04-21 02:36:28 +03:00
a6f95c2093 feat(simulations): router /sujets + SimulationFlowProvider wiring + useSimulation refacto 2026-04-21 02:19:43 +03:00
782439b309 feat(simulations): SimulationFlowProvider + SujetsPage + SujetCard 2026-04-21 02:14:19 +03:00
7902eec042 feat(simulations): choix du sujet — dropdown intégré + bouton aléatoire 2026-04-21 02:06:08 +03:00
477477b6a6 feat(simulations): hook useSujets + composant SujetSelector 2026-04-21 01:09:27 +03:00
4245d0bcf1 feat(production): getSujets() — GET /sujets?mode=&tache= 2026-04-21 01:07:30 +03:00
021b9d35ea docs(changelog): tâches G2+G3 — clavier + minuteur 2026-04-21 00:47:24 +03:00
c5b433749d feat(simulations): minuteur + limites mots + clavier sticky + bouton Soumettre 2026-04-21 00:40:13 +03:00
41d2eec3f7 feat(simulations): composants TimerDisplay + WordCountBar 2026-04-21 00:18:33 +03:00
24968f542d feat(simulations): config par tâche + hook useTimer + 7 tests 2026-04-21 00:16:38 +03:00
869668a1ba feat(simulations): clavier caractères spéciaux sticky + flex-wrap + auto-resize textarea 2026-04-20 23:54:42 +03:00
4f786dd44b feat(simulations): composant clavier caractères spéciaux français 2026-04-20 23:37:58 +03:00
6a40e9a4c0 docs(changelog): tâche G1 — affichage consigne 2026-04-20 23:19:36 +03:00
e449661ee0 feat(simulations): afficher la consigne du sujet au-dessus de la textarea 2026-04-20 23:15:13 +03:00
b356bc7109 feat(simulations): exposer sujet dans useSimulation + composant SujetDisplay 2026-04-20 23:09:35 +03:00
e130d3792e feat(production): exposer SujetData dans Production + FTD-21 persistance session 2026-04-20 23:05:01 +03:00
b16dbfa1c8 docs(changelog): session alignement types Report ↔ backend 2026-04-20 06:05:20 +03:00
fb3de2865f fix(report): aligner types sur backend — note→score, exercices string[], supprimer Exercice 2026-04-20 06:01:29 +03:00
ef86da85d7 docs(tech-debt): FTD-20 🔴 — GET /simulations/:id manquant backend 2026-04-20 03:46:36 +03:00
47d5ec9524 feat(simulations): RapportPage avec floutage conditionnel — Sprint 3 étape 15 2026-04-20 03:46:18 +03:00
1dbca24c35 fix(report): aligner payloads corrections/ee et eo sur contrat backend réel 2026-04-20 03:45:52 +03:00
d7b084d05a docs(sprint-0.5-bis): TECH_DEBT v1.6 (FTD-18/19) + CHANGELOG 2026-04-20 02:37:46 +03:00
8450265449 feat(sprint-0.5-bis): AppLayout + primitives UI + refonte pages
- AppLayout (sidebar fixe, drawer mobile, BottomNav)
- MobileHeader sticky + Sidebar avec verrouillage hasAccess()
- Primitives src/shared/ui/ : Button, Card, Badge
- SimulationPage + DashboardPage : suppression headers internes
- TaskSelector : Card interactive + Badge EE/EO + eyebrow
- router.tsx : layout routes + ComingSoon inline
2026-04-20 02:37:19 +03:00
997f39bd33 feat(simulations): useSimulation hook + TaskSelector + SimulationForm + SimulationPage + route (Sprint 3 étape 14)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 00:08:34 +03:00
b31e8666a5 feat(entities): production + report — types, lib, api, tests floutage (Sprint 3 étape 13) 2026-04-19 03:37:41 +03:00
ca4291d7eb feat(app): implement MAINTENANCE_MODE + configure Claude Code security hooks
- Add VITE_MAINTENANCE_MODE guard in main.tsx (no providers mount when true)
- Create static MaintenancePage.tsx (Direction H tokens, zero dependencies)
- Register VITE_MAINTENANCE_MODE in env.ts (optional, defaults to false)
- Add PreToolUse security hook (9 patterns from SECURITY.md §2)
- Add Stop hook for file size check (>200 lines warning)
- Register Semgrep MCP server
- Update ARCHITECTURE.md §7 (new env var)
- Resolve FTD-16 in TECH_DEBT.md
2026-04-19 02:31:32 +03:00
bf778a5a4d feat(dashboard): PaywallBanner + DashboardPage conditionnel (Sprint 1 étape 5) 2026-04-18 02:50:34 +03:00
d0f77e04f9 feat(app): câbler router Sprint 1 — login/register/dashboard + redirect / (étape 4) 2026-04-18 02:23:05 +03:00
464eb27f1e feat(auth): LoginPage + RegisterPage (Sprint 1 étape 3) 2026-04-18 02:13:08 +03:00
38777796aa feat(auth): useAuth + ProtectedRoute + signUp dans auth-client (Sprint 1 étape 2) 2026-04-18 02:09:46 +03:00
107a37d197 feat(entities/user): PlanStatus + getPlanStatus + hook usePlan (Sprint 1 étape 1)
Fondations data plan utilisateur pour le dashboard conditionnel :
- entities/user/types.ts : interface PlanStatus (plan, permissions, simulations_used/remaining, plan_expires_at)
- entities/user/api.ts : getPlanStatus() via apiFetch<PlanStatus>('/plans/status')
- features/dashboard/hooks/usePlan.ts : useQuery + PLAN_QUERY_KEY + staleTime 5 min

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:00:12 +03:00
201 changed files with 24394 additions and 619 deletions

View file

@ -0,0 +1,42 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.

View file

@ -7,3 +7,8 @@ VITE_ENABLE_T2_LIVE=false
# Optionnel — DSN Sentry pour monitoring prod (laisser commenté en dev local)
# VITE_SENTRY_DSN=https://xxxxxx@o000000.ingest.sentry.io/0000000
# Sprint 5b — price_ids Stripe publics (Dashboard Stripe → Produits → Plan → Tarif).
# Requis en dev/prod ; absents en CI tests (tests mockent features/billing/api.ts).
VITE_STRIPE_PRICE_STANDARD=price_xxx
VITE_STRIPE_PRICE_PREMIUM=price_xxx

7
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,7 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10

View file

@ -9,6 +9,10 @@ on:
jobs:
quality:
runs-on: ubuntu-latest
env:
VITE_API_URL: "http://localhost:3000"
VITE_SUPABASE_URL: "https://fake.supabase.co"
VITE_SUPABASE_ANON_KEY: "fake-anon-key-for-ci"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@ -21,3 +25,7 @@ jobs:
- run: npm run typecheck
- run: npm run test
- run: npm audit --audit-level=high
- name: Install Semgrep
run: python3 -m pip install --user semgrep
- name: Semgrep scan
run: semgrep scan --config=auto --error --severity=ERROR

3
.gitignore vendored
View file

@ -30,3 +30,6 @@ dist-ssr
# Claude Code local config
.claude/
# Exploration DA temporaire — supprimer une fois la direction choisie
design-exploration/

View file

@ -0,0 +1,927 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Expria — Tableau de bord · Direction H : Mode sombre</title>
<style>
/* =========================================================
DIRECTION H — MODE SOMBRE
Inversion réfléchie de la palette claire :
- Fond bleu-nuit désaturé (pas noir pur = fatigue visuelle)
- Cards en bleu-slate légèrement plus clair pour ressortir
- Bleu Expria remonté en luminance (lisibilité sur fond sombre)
- Hairlines plus visibles qu'en clair pour structurer
- Titres en blanc cassé (jamais pur blanc = trop agressif)
========================================================= */
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
/* Fonds — hiérarchie 3 niveaux pour créer la profondeur */
--canvas: #0D1220; /* fond global, bleu-nuit désaturé */
--canvas-2: #121A2D; /* séparations, fond hover sidebar */
--surface: #182238; /* cards — ressortent nettement */
--surface-hover: #1E2A42; /* cards au survol */
--surface-raised: #1F2B45; /* cards surélevées (plan premium) */
/* Hairlines — plus visibles qu'en clair, structure l'œil */
--line: #27324B;
--line-strong: #364363;
/* Encres — blanc cassé pour confort visuel, jamais pur blanc */
--ink-1: #F1F4FA; /* titres forts — très blanc mais chaud */
--ink-2: #DDE3EF; /* corps principal */
--ink-3: #A8B2C7; /* secondaire */
--ink-4: #7A8499; /* tertiaire, labels */
--ink-5: #525C73; /* désactivé, hints */
/* Brand Expria — remonté en luminance pour rester lisible sur sombre */
--expria: #5B7FFF; /* #1B4FD8 brightened pour dark mode */
--expria-hover: #6F8EFF;
--expria-dim: #3A5DD9; /* variante pour fonds/backgrounds */
--expria-bg: rgba(91, 127, 255, 0.12); /* wash très léger */
--expria-bg-2: rgba(91, 127, 255, 0.18);
/* Bleu nuit — utilisé inversement en clair : ici en accent très foncé */
--deep: #060B1A; /* plus profond que canvas, pour contraster */
/* Sémantiques — versions dark mode (fonds sombres transparents) */
--success: #3DD68C;
--success-bg: rgba(61, 214, 140, 0.12);
--warning: #F5B849;
--warning-bg: rgba(245, 184, 73, 0.12);
--danger: #F06B6B;
--danger-bg: rgba(240, 107, 107, 0.12);
/* Élévations — jouer sur les surfaces plus que les ombres */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5);
/* Rayons — identiques à light */
--r-sm: 6px;
--r-md: 10px;
--r-lg: 14px;
--r-xl: 18px;
--r-full: 999px;
}
html, body {
font-family: "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
background: var(--canvas);
color: var(--ink-2);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* =========================================================
HEADER (bandeau de présentation de la direction)
========================================================= */
.da-header {
background: var(--surface);
border-bottom: 0.5px solid var(--line);
padding: 20px 28px;
}
.da-header-inner {
max-width: 1400px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.da-header-title {
font-size: 14px;
font-weight: 600;
color: var(--ink-1);
letter-spacing: -0.01em;
}
.da-header-subtitle {
font-size: 12px;
color: var(--ink-4);
margin-top: 2px;
}
.da-header-tag {
font-size: 11px;
color: var(--expria);
background: var(--expria-bg);
padding: 5px 12px;
border-radius: var(--r-full);
font-weight: 600;
letter-spacing: 0.02em;
border: 0.5px solid rgba(91, 127, 255, 0.3);
}
/* =========================================================
LAYOUT APP
========================================================= */
.app {
max-width: 1400px;
margin: 0 auto;
display: grid;
grid-template-columns: 240px 1fr;
gap: 0;
min-height: 100vh;
}
/* =========================================================
SIDEBAR
========================================================= */
.sidebar {
background: transparent;
border-right: 0.5px solid var(--line);
padding: 24px 20px;
display: flex;
flex-direction: column;
gap: 32px;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 700;
color: var(--ink-1);
letter-spacing: -0.02em;
padding: 0 8px;
}
.logo-mark {
width: 28px;
height: 28px;
background: var(--expria);
border-radius: var(--r-sm);
display: flex;
align-items: center;
justify-content: center;
color: var(--canvas);
font-size: 13px;
font-weight: 700;
letter-spacing: -0.02em;
}
.nav-group {
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-group-label {
font-size: 10px;
color: var(--ink-5);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 0 10px 6px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: var(--r-md);
font-size: 13.5px;
color: var(--ink-3);
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
}
.nav-item:hover {
background: var(--canvas-2);
color: var(--ink-1);
}
.nav-item.active {
background: var(--surface);
color: var(--ink-1);
font-weight: 600;
box-shadow: var(--shadow-sm);
border: 0.5px solid var(--line);
}
.nav-item svg {
width: 16px;
height: 16px;
flex-shrink: 0;
stroke: currentColor;
fill: none;
stroke-width: 1.75;
stroke-linecap: round;
stroke-linejoin: round;
}
.user-card {
margin-top: auto;
padding: 12px;
background: var(--surface);
border: 0.5px solid var(--line);
border-radius: var(--r-md);
display: flex;
align-items: center;
gap: 10px;
box-shadow: var(--shadow-sm);
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: var(--r-full);
background: var(--expria);
color: var(--canvas);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.user-meta { flex: 1; min-width: 0; }
.user-name {
font-size: 13px;
font-weight: 600;
color: var(--ink-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-plan {
font-size: 11px;
color: var(--ink-4);
margin-top: 1px;
}
/* =========================================================
MAIN
========================================================= */
.main {
padding: 32px 36px 56px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 28px;
}
.page-title-block { flex: 1; }
.page-eyebrow {
font-size: 11px;
color: var(--ink-4);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
}
.page-title {
font-size: 26px;
font-weight: 700;
color: var(--ink-1);
letter-spacing: -0.02em;
line-height: 1.2;
}
.page-sub {
font-size: 14px;
color: var(--ink-3);
margin-top: 6px;
line-height: 1.6;
max-width: 640px;
}
/* =========================================================
BOUTONS
========================================================= */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
border: none;
border-radius: var(--r-md);
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
white-space: nowrap;
}
.btn-primary {
background: var(--expria);
color: var(--canvas);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.btn-primary:hover {
background: var(--expria-hover);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(91, 127, 255, 0.35);
}
.btn-ghost {
background: transparent;
color: var(--ink-2);
border: 0.5px solid var(--line-strong);
}
.btn-ghost:hover {
background: var(--surface);
border-color: var(--ink-5);
color: var(--ink-1);
}
.btn-arrow {
font-size: 16px;
line-height: 1;
}
/* =========================================================
GRID & CARDS
========================================================= */
.metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.card {
background: var(--surface);
border: 0.5px solid var(--line);
border-radius: var(--r-lg);
padding: 22px;
box-shadow: var(--shadow-sm);
transition: box-shadow 0.2s, transform 0.2s, background 0.2s;
}
.card-raised {
box-shadow: var(--shadow-md);
}
.card:hover {
box-shadow: var(--shadow-md);
background: var(--surface-hover);
}
.card-label {
font-size: 11px;
color: var(--ink-4);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 10px;
}
/* Metric cards */
.metric-card .metric-value {
font-size: 32px;
font-weight: 700;
color: var(--ink-1);
letter-spacing: -0.03em;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.metric-value-unit {
font-size: 14px;
color: var(--ink-4);
font-weight: 500;
}
.metric-sub {
font-size: 12px;
color: var(--ink-4);
margin-top: 8px;
}
.progress {
height: 5px;
background: var(--canvas-2);
border-radius: var(--r-full);
margin-top: 12px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--expria);
border-radius: var(--r-full);
transition: width 0.4s;
}
.progress-fill.success { background: var(--success); }
/* Metric card highlight — plan
Inversion du light mode : ici on montre un fond bleu saturé
avec le brand, au lieu du bleu nuit/deep */
.metric-card-plan {
background:
radial-gradient(circle at 80% -30%, rgba(91, 127, 255, 0.4), transparent 60%),
linear-gradient(135deg, #1E2A52 0%, #2C3E7A 100%);
border: 0.5px solid rgba(91, 127, 255, 0.4);
color: var(--ink-1);
position: relative;
overflow: hidden;
}
.metric-card-plan .card-label {
color: rgba(255, 255, 255, 0.55);
}
.metric-card-plan .plan-name {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 4px;
color: #FFFFFF;
}
.metric-card-plan .plan-desc {
font-size: 12px;
color: rgba(255, 255, 255, 0.75);
line-height: 1.5;
margin-bottom: 16px;
}
.metric-card-plan .btn-upgrade {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--expria);
color: var(--canvas);
border-radius: var(--r-md);
font-size: 12.5px;
font-weight: 600;
text-decoration: none;
transition: background 0.15s;
position: relative;
z-index: 1;
}
.metric-card-plan .btn-upgrade:hover {
background: var(--expria-hover);
}
/* =========================================================
ROW — 2 colonnes pour historique + prochaine étape
========================================================= */
.row-2 {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
/* =========================================================
HISTORIQUE
========================================================= */
.history-title {
font-size: 15px;
font-weight: 600;
color: var(--ink-1);
margin-bottom: 4px;
letter-spacing: -0.01em;
}
.history-sub {
font-size: 12.5px;
color: var(--ink-4);
margin-bottom: 18px;
}
.history-list {
display: flex;
flex-direction: column;
}
.history-item {
display: grid;
grid-template-columns: 40px 1fr auto auto;
align-items: center;
gap: 14px;
padding: 14px 0;
border-bottom: 0.5px solid var(--line);
}
.history-item:last-child { border-bottom: none; }
.history-icon {
width: 36px;
height: 36px;
border-radius: var(--r-md);
background: var(--expria-bg);
display: flex;
align-items: center;
justify-content: center;
color: var(--expria);
flex-shrink: 0;
border: 0.5px solid rgba(91, 127, 255, 0.25);
}
.history-icon svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 1.75;
stroke-linecap: round;
stroke-linejoin: round;
}
.history-meta { min-width: 0; }
.history-type {
font-size: 13.5px;
font-weight: 600;
color: var(--ink-1);
margin-bottom: 2px;
}
.history-date {
font-size: 11.5px;
color: var(--ink-4);
}
.history-nclc-badge {
font-size: 11px;
font-weight: 600;
padding: 4px 10px;
border-radius: var(--r-full);
background: var(--success-bg);
color: var(--success);
letter-spacing: 0.02em;
border: 0.5px solid rgba(61, 214, 140, 0.25);
}
.history-nclc-badge.warn {
background: var(--warning-bg);
color: var(--warning);
border-color: rgba(245, 184, 73, 0.25);
}
.history-score {
font-size: 16px;
font-weight: 700;
color: var(--ink-1);
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
min-width: 50px;
text-align: right;
}
.history-score-max {
font-size: 11px;
color: var(--ink-5);
font-weight: 500;
}
/* =========================================================
PROCHAINE ÉTAPE
========================================================= */
.next-step {
background: linear-gradient(135deg, rgba(91, 127, 255, 0.08) 0%, rgba(24, 34, 56, 1) 100%);
border: 0.5px solid rgba(91, 127, 255, 0.25);
}
.next-step:hover {
background: linear-gradient(135deg, rgba(91, 127, 255, 0.12) 0%, rgba(30, 42, 66, 1) 100%);
}
.next-step .card-label {
color: var(--expria);
}
.next-step-title {
font-size: 16px;
font-weight: 700;
color: var(--ink-1);
margin-bottom: 8px;
line-height: 1.35;
letter-spacing: -0.01em;
}
.next-step-desc {
font-size: 13px;
color: var(--ink-3);
line-height: 1.6;
margin-bottom: 18px;
}
.next-step-stats {
display: flex;
gap: 20px;
margin-bottom: 18px;
padding: 12px 14px;
background: rgba(13, 18, 32, 0.5);
border-radius: var(--r-md);
border: 0.5px solid var(--line);
}
.next-step-stat {
flex: 1;
min-width: 0;
}
.next-step-stat-label {
font-size: 10px;
color: var(--ink-4);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 3px;
}
.next-step-stat-value {
font-size: 16px;
font-weight: 700;
color: var(--ink-1);
letter-spacing: -0.01em;
}
/* =========================================================
CTA BOTTOM
========================================================= */
.cta-block {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
/* =========================================================
PALETTE DISPLAY (bas de page)
========================================================= */
.palette-row {
margin-top: 40px;
padding: 20px 24px;
background: var(--surface);
border: 0.5px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-sm);
}
.palette-title {
font-size: 11px;
color: var(--ink-4);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 14px;
}
.palette-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 10px;
}
.palette-swatch {
text-align: left;
}
.palette-color {
height: 40px;
border-radius: var(--r-sm);
border: 0.5px solid var(--line);
margin-bottom: 6px;
}
.palette-name {
font-size: 11px;
color: var(--ink-2);
font-weight: 600;
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
}
.palette-role {
font-size: 10px;
color: var(--ink-4);
margin-top: 1px;
}
/* =========================================================
RESPONSIVE
========================================================= */
@media (max-width: 960px) {
.app { grid-template-columns: 1fr; }
.sidebar { display: none; }
.main { padding: 24px; }
.metrics { grid-template-columns: 1fr; }
.row-2 { grid-template-columns: 1fr; }
.page-header { flex-direction: column; }
}
@media (max-width: 480px) {
.da-header-inner { flex-direction: column; align-items: flex-start; gap: 10px; }
.page-title { font-size: 22px; }
.metric-card .metric-value { font-size: 28px; }
.cta-block { flex-direction: column; }
.cta-block .btn { width: 100%; }
.history-item { grid-template-columns: 36px 1fr auto; }
.history-nclc-badge { display: none; }
}
</style>
</head>
<body>
<!-- Bandeau de présentation de la direction -->
<div class="da-header">
<div class="da-header-inner">
<div>
<div class="da-header-title">Direction H — Mode sombre</div>
<div class="da-header-subtitle">Inversion réfléchie de la version claire. Fond bleu-nuit désaturé, cards qui ressortent, bleu Expria remonté en luminance pour rester lisible.</div>
</div>
<div class="da-header-tag">Mode sombre</div>
</div>
</div>
<div class="app">
<!-- =====================================================
SIDEBAR
====================================================== -->
<aside class="sidebar">
<div class="logo">
<div class="logo-mark">EX</div>
<span>Expria</span>
</div>
<nav class="nav-group">
<div class="nav-group-label">Espace de travail</div>
<a class="nav-item active" href="#">
<svg viewBox="0 0 24 24"><path d="M3 12l9-9 9 9"/><path d="M5 10v10a1 1 0 001 1h4v-6h4v6h4a1 1 0 001-1V10"/></svg>
Tableau de bord
</a>
<a class="nav-item" href="#">
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8M8 17h8M8 9h2"/></svg>
Simulations
</a>
<a class="nav-item" href="#">
<svg viewBox="0 0 24 24"><path d="M12 2a10 10 0 100 20 10 10 0 000-20z"/><path d="M12 6v6l4 2"/></svg>
Historique
</a>
<a class="nav-item" href="#">
<svg viewBox="0 0 24 24"><path d="M12 15l-3-3 3-3M3 12h12a6 6 0 010 12"/></svg>
Rapports
</a>
</nav>
<nav class="nav-group">
<div class="nav-group-label">Préparation</div>
<a class="nav-item" href="#">
<svg viewBox="0 0 24 24"><path d="M21 5l-9-3-9 3v6a12 12 0 009 11 12 12 0 009-11V5z"/></svg>
Mode examen
</a>
<a class="nav-item" href="#">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>
Guide TCF
</a>
</nav>
<div class="user-card">
<div class="user-avatar">HM</div>
<div class="user-meta">
<div class="user-name">Hermann Mbanga</div>
<div class="user-plan">Plan Découverte</div>
</div>
</div>
</aside>
<!-- =====================================================
MAIN
====================================================== -->
<main class="main">
<div class="page-header">
<div class="page-title-block">
<div class="page-eyebrow">Mercredi 17 avril</div>
<h1 class="page-title">Bonjour Hermann</h1>
<p class="page-sub">Vous progressez bien. Plus que deux simulations pour atteindre votre objectif NCLC 9.</p>
</div>
<div class="cta-block">
<button class="btn btn-ghost">Voir mon profil</button>
<button class="btn btn-primary">
Nouvelle simulation
<span class="btn-arrow"></span>
</button>
</div>
</div>
<!-- Métriques -->
<div class="metrics">
<div class="card metric-card card-raised">
<div class="card-label">Simulations restantes</div>
<div class="metric-value">3<span class="metric-value-unit"> / 5</span></div>
<div class="progress"><div class="progress-fill" style="width: 60%"></div></div>
<div class="metric-sub">Plan Découverte — renouvellement à chaque upgrade</div>
</div>
<div class="card metric-card card-raised">
<div class="card-label">Niveau estimé</div>
<div class="metric-value">NCLC 8</div>
<div class="progress"><div class="progress-fill" style="width: 75%"></div></div>
<div class="metric-sub">Moyenne des 3 dernières simulations · Objectif NCLC 9</div>
</div>
<div class="card metric-card-plan">
<div class="card-label">Plan actuel</div>
<div class="plan-name">Découverte</div>
<div class="plan-desc">Débloquez des simulations illimitées, le Mode Examen et le T2 Live avec Standard ou Premium.</div>
<a href="#" class="btn-upgrade">
Passer à Standard
<span style="font-size: 14px; line-height: 1;"></span>
</a>
</div>
</div>
<!-- Row 2 colonnes -->
<div class="row-2">
<!-- Historique -->
<div class="card">
<div class="history-title">Simulations récentes</div>
<div class="history-sub">Vos 3 dernières corrections</div>
<div class="history-list">
<div class="history-item">
<div class="history-icon">
<svg viewBox="0 0 24 24"><path d="M17 3a2.85 2.83 0 114 4L7.5 20.5 2 22l1.5-5.5z"/></svg>
</div>
<div class="history-meta">
<div class="history-type">Expression écrite — Tâche 2</div>
<div class="history-date">Aujourd'hui · 09:42</div>
</div>
<div class="history-nclc-badge">NCLC 9</div>
<div class="history-score">16<span class="history-score-max">/20</span></div>
</div>
<div class="history-item">
<div class="history-icon">
<svg viewBox="0 0 24 24"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 11-14 0v-2M12 19v4M8 23h8"/></svg>
</div>
<div class="history-meta">
<div class="history-type">Expression orale — Tâche 1</div>
<div class="history-date">Il y a 2 jours</div>
</div>
<div class="history-nclc-badge warn">NCLC 8</div>
<div class="history-score">14<span class="history-score-max">/20</span></div>
</div>
<div class="history-item">
<div class="history-icon">
<svg viewBox="0 0 24 24"><path d="M17 3a2.85 2.83 0 114 4L7.5 20.5 2 22l1.5-5.5z"/></svg>
</div>
<div class="history-meta">
<div class="history-type">Expression écrite — Tâche 3</div>
<div class="history-date">Il y a 5 jours</div>
</div>
<div class="history-nclc-badge">NCLC 9</div>
<div class="history-score">15<span class="history-score-max">/20</span></div>
</div>
</div>
</div>
<!-- Prochaine étape -->
<div class="card next-step">
<div class="card-label">Prochaine étape recommandée</div>
<div class="next-step-title">Travaillez la tâche 2 à l'oral</div>
<div class="next-step-desc">
Votre dernier score à l'EO T2 (14/20) est en dessous de votre moyenne. Une simulation de 10 minutes suffit pour consolider.
</div>
<div class="next-step-stats">
<div class="next-step-stat">
<div class="next-step-stat-label">Durée</div>
<div class="next-step-stat-value">10 min</div>
</div>
<div class="next-step-stat">
<div class="next-step-stat-label">Difficulté</div>
<div class="next-step-stat-value">Modérée</div>
</div>
</div>
<button class="btn btn-primary" style="width: 100%;">
Commencer maintenant
<span class="btn-arrow"></span>
</button>
</div>
</div>
<!-- Palette -->
<div class="palette-row">
<div class="palette-title">Palette de la direction H — mode sombre</div>
<div class="palette-grid">
<div class="palette-swatch">
<div class="palette-color" style="background: #0D1220;"></div>
<div class="palette-name">#0D1220</div>
<div class="palette-role">Fond principal</div>
</div>
<div class="palette-swatch">
<div class="palette-color" style="background: #182238;"></div>
<div class="palette-name">#182238</div>
<div class="palette-role">Cards surface</div>
</div>
<div class="palette-swatch">
<div class="palette-color" style="background: #5B7FFF;"></div>
<div class="palette-name">#5B7FFF</div>
<div class="palette-role">Bleu Expria — remonté</div>
</div>
<div class="palette-swatch">
<div class="palette-color" style="background: #27324B; border-color: #5B7FFF;"></div>
<div class="palette-name">#27324B</div>
<div class="palette-role">Hairlines</div>
</div>
<div class="palette-swatch">
<div class="palette-color" style="background: #F1F4FA;"></div>
<div class="palette-name">#F1F4FA</div>
<div class="palette-role">Titres</div>
</div>
<div class="palette-swatch">
<div class="palette-color" style="background: #A8B2C7;"></div>
<div class="palette-name">#A8B2C7</div>
<div class="palette-role">Corps secondaire</div>
</div>
<div class="palette-swatch">
<div class="palette-color" style="background: #3DD68C;"></div>
<div class="palette-name">#3DD68C</div>
<div class="palette-role">Succès</div>
</div>
<div class="palette-swatch">
<div class="palette-color" style="background: #F5B849;"></div>
<div class="palette-name">#F5B849</div>
<div class="palette-role">Attention</div>
</div>
</div>
</div>
</main>
</div>
</body>
</html>

View file

@ -0,0 +1,921 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Expria — Tableau de bord · Direction H : Juste milieu</title>
<style>
/* =========================================================
DIRECTION H — "JUSTE MILIEU"
Entre Boréal (A) trop blanc et Cadence (B) trop sombre.
Inspirations : Revolut (mode clair), Notion, Linear,
N26, Anthropic Console.
Parti pris : fond gris-bleuté calme, cards blanches qui
ressortent avec profondeur, bleu Expria pivot, accents
bleu-nuit pour les éléments premium/structurants.
========================================================= */
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
/* Fonds — gris bleutés, jamais pur blanc pour éviter l'effet clinique */
--canvas: #EEF2F8; /* fond global, bleuté très léger */
--canvas-2: #E6EBF4; /* fond plus marqué, séparations */
--surface: #FFFFFF; /* cards — blanc franc qui ressort */
--surface-hover: #F8FAFD; /* cards au survol */
/* Hairlines — fines, bleutées */
--line: #DDE3ED;
--line-strong: #C7D0E0;
/* Encres — ardoise, jamais noir pur (plus doux, plus premium) */
--ink-1: #0F172A; /* titres forts */
--ink-2: #1E293B; /* corps principal */
--ink-3: #475569; /* secondaire */
--ink-4: #64748B; /* tertiaire, labels */
--ink-5: #94A3B8; /* désactivé, hints */
/* Brand Expria */
--expria: #1B4FD8;
--expria-hover: #1741B8;
--expria-50: #EEF3FF;
--expria-100: #DCE6FF;
--expria-200: #B8CDFF;
/* Bleu nuit — accent secondaire pour éléments structurants/premium */
--deep: #0B1F5C;
--deep-2: #142B6E;
/* Sémantiques — pastel sur blanc */
--success: #0E9F6E;
--success-bg: #E6F6F0;
--warning: #C77A00;
--warning-bg: #FEF3E2;
--danger: #C53030;
--danger-bg: #FDECEC;
/* Élévations — subtiles, crée la profondeur sans alourdir */
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.04);
--shadow-lg: 0 12px 28px rgba(15, 23, 42, 0.08), 0 2px 6px rgba(15, 23, 42, 0.04);
/* Rayons */
--r-sm: 6px;
--r-md: 10px;
--r-lg: 14px;
--r-xl: 18px;
--r-full: 999px;
}
html, body {
font-family: "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
background: var(--canvas);
color: var(--ink-2);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* =========================================================
HEADER (bandeau de présentation de la direction)
========================================================= */
.da-header {
background: var(--surface);
border-bottom: 0.5px solid var(--line);
padding: 20px 28px;
}
.da-header-inner {
max-width: 1400px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.da-header-title {
font-size: 14px;
font-weight: 600;
color: var(--ink-1);
letter-spacing: -0.01em;
}
.da-header-subtitle {
font-size: 12px;
color: var(--ink-4);
margin-top: 2px;
}
.da-header-tag {
font-size: 11px;
color: var(--expria);
background: var(--expria-50);
padding: 5px 12px;
border-radius: var(--r-full);
font-weight: 600;
letter-spacing: 0.02em;
}
/* =========================================================
LAYOUT APP
========================================================= */
.app {
max-width: 1400px;
margin: 0 auto;
display: grid;
grid-template-columns: 240px 1fr;
gap: 0;
min-height: 100vh;
}
/* =========================================================
SIDEBAR
========================================================= */
.sidebar {
background: transparent;
border-right: 0.5px solid var(--line);
padding: 24px 20px;
display: flex;
flex-direction: column;
gap: 32px;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 700;
color: var(--ink-1);
letter-spacing: -0.02em;
padding: 0 8px;
}
.logo-mark {
width: 28px;
height: 28px;
background: var(--expria);
border-radius: var(--r-sm);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 13px;
font-weight: 700;
letter-spacing: -0.02em;
}
.nav-group {
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-group-label {
font-size: 10px;
color: var(--ink-5);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 0 10px 6px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: var(--r-md);
font-size: 13.5px;
color: var(--ink-3);
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
}
.nav-item:hover {
background: var(--canvas-2);
color: var(--ink-1);
}
.nav-item.active {
background: var(--surface);
color: var(--ink-1);
font-weight: 600;
box-shadow: var(--shadow-sm);
border: 0.5px solid var(--line);
}
.nav-item svg {
width: 16px;
height: 16px;
flex-shrink: 0;
stroke: currentColor;
fill: none;
stroke-width: 1.75;
stroke-linecap: round;
stroke-linejoin: round;
}
.user-card {
margin-top: auto;
padding: 12px;
background: var(--surface);
border: 0.5px solid var(--line);
border-radius: var(--r-md);
display: flex;
align-items: center;
gap: 10px;
box-shadow: var(--shadow-sm);
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: var(--r-full);
background: var(--deep);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
}
.user-meta { flex: 1; min-width: 0; }
.user-name {
font-size: 13px;
font-weight: 600;
color: var(--ink-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-plan {
font-size: 11px;
color: var(--ink-4);
margin-top: 1px;
}
/* =========================================================
MAIN
========================================================= */
.main {
padding: 32px 36px 56px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 28px;
}
.page-title-block { flex: 1; }
.page-eyebrow {
font-size: 11px;
color: var(--ink-4);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
}
.page-title {
font-size: 26px;
font-weight: 700;
color: var(--ink-1);
letter-spacing: -0.02em;
line-height: 1.2;
}
.page-sub {
font-size: 14px;
color: var(--ink-3);
margin-top: 6px;
line-height: 1.6;
max-width: 640px;
}
/* =========================================================
BOUTONS
========================================================= */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
border: none;
border-radius: var(--r-md);
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
white-space: nowrap;
}
.btn-primary {
background: var(--expria);
color: white;
box-shadow: 0 1px 2px rgba(27, 79, 216, 0.24);
}
.btn-primary:hover {
background: var(--expria-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(27, 79, 216, 0.2);
}
.btn-ghost {
background: transparent;
color: var(--ink-2);
border: 0.5px solid var(--line-strong);
}
.btn-ghost:hover {
background: var(--surface);
border-color: var(--ink-5);
}
.btn-arrow {
font-size: 16px;
line-height: 1;
}
/* =========================================================
GRID & CARDS
========================================================= */
.metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.card {
background: var(--surface);
border: 0.5px solid var(--line);
border-radius: var(--r-lg);
padding: 22px;
box-shadow: var(--shadow-sm);
transition: box-shadow 0.2s, transform 0.2s;
}
.card-raised {
box-shadow: var(--shadow-md);
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card-label {
font-size: 11px;
color: var(--ink-4);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 10px;
}
/* Metric cards */
.metric-card .metric-value {
font-size: 32px;
font-weight: 700;
color: var(--ink-1);
letter-spacing: -0.03em;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.metric-value-unit {
font-size: 14px;
color: var(--ink-4);
font-weight: 500;
}
.metric-sub {
font-size: 12px;
color: var(--ink-4);
margin-top: 8px;
}
.progress {
height: 5px;
background: var(--canvas-2);
border-radius: var(--r-full);
margin-top: 12px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--expria);
border-radius: var(--r-full);
transition: width 0.4s;
}
.progress-fill.success { background: var(--success); }
/* Metric card highlight — plan */
.metric-card-plan {
background: linear-gradient(135deg, var(--deep) 0%, var(--deep-2) 100%);
color: white;
border-color: transparent;
position: relative;
overflow: hidden;
}
.metric-card-plan::after {
content: '';
position: absolute;
top: -50%;
right: -30%;
width: 180px;
height: 180px;
background: radial-gradient(circle, rgba(27, 79, 216, 0.4), transparent 60%);
pointer-events: none;
}
.metric-card-plan .card-label { color: rgba(255, 255, 255, 0.6); }
.metric-card-plan .plan-name {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 4px;
}
.metric-card-plan .plan-desc {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
line-height: 1.5;
margin-bottom: 16px;
}
.metric-card-plan .btn-upgrade {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--expria);
color: white;
border-radius: var(--r-md);
font-size: 12.5px;
font-weight: 600;
text-decoration: none;
transition: background 0.15s;
position: relative;
z-index: 1;
}
.metric-card-plan .btn-upgrade:hover {
background: #2A60E8;
}
/* =========================================================
ROW — 2 colonnes pour historique + prochaine étape
========================================================= */
.row-2 {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
/* =========================================================
HISTORIQUE
========================================================= */
.history-title {
font-size: 15px;
font-weight: 600;
color: var(--ink-1);
margin-bottom: 4px;
letter-spacing: -0.01em;
}
.history-sub {
font-size: 12.5px;
color: var(--ink-4);
margin-bottom: 18px;
}
.history-list {
display: flex;
flex-direction: column;
}
.history-item {
display: grid;
grid-template-columns: 40px 1fr auto auto;
align-items: center;
gap: 14px;
padding: 14px 0;
border-bottom: 0.5px solid var(--line);
}
.history-item:last-child { border-bottom: none; }
.history-icon {
width: 36px;
height: 36px;
border-radius: var(--r-md);
background: var(--expria-50);
display: flex;
align-items: center;
justify-content: center;
color: var(--expria);
flex-shrink: 0;
}
.history-icon svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 1.75;
stroke-linecap: round;
stroke-linejoin: round;
}
.history-meta { min-width: 0; }
.history-type {
font-size: 13.5px;
font-weight: 600;
color: var(--ink-1);
margin-bottom: 2px;
}
.history-date {
font-size: 11.5px;
color: var(--ink-4);
}
.history-nclc-badge {
font-size: 11px;
font-weight: 600;
padding: 4px 10px;
border-radius: var(--r-full);
background: var(--success-bg);
color: var(--success);
letter-spacing: 0.02em;
}
.history-nclc-badge.warn {
background: var(--warning-bg);
color: var(--warning);
}
.history-score {
font-size: 16px;
font-weight: 700;
color: var(--ink-1);
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
min-width: 50px;
text-align: right;
}
.history-score-max {
font-size: 11px;
color: var(--ink-5);
font-weight: 500;
}
/* =========================================================
PROCHAINE ÉTAPE
========================================================= */
.next-step {
background: linear-gradient(135deg, var(--expria-50) 0%, var(--canvas-2) 100%);
border: 0.5px solid var(--expria-100);
}
.next-step .card-label {
color: var(--expria);
}
.next-step-title {
font-size: 16px;
font-weight: 700;
color: var(--ink-1);
margin-bottom: 8px;
line-height: 1.35;
letter-spacing: -0.01em;
}
.next-step-desc {
font-size: 13px;
color: var(--ink-3);
line-height: 1.6;
margin-bottom: 18px;
}
.next-step-stats {
display: flex;
gap: 20px;
margin-bottom: 18px;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.6);
border-radius: var(--r-md);
border: 0.5px solid var(--line);
}
.next-step-stat {
flex: 1;
min-width: 0;
}
.next-step-stat-label {
font-size: 10px;
color: var(--ink-4);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 3px;
}
.next-step-stat-value {
font-size: 16px;
font-weight: 700;
color: var(--ink-1);
letter-spacing: -0.01em;
}
/* =========================================================
CTA BOTTOM
========================================================= */
.cta-block {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
/* =========================================================
PALETTE DISPLAY (bas de page)
========================================================= */
.palette-row {
margin-top: 40px;
padding: 20px 24px;
background: var(--surface);
border: 0.5px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-sm);
}
.palette-title {
font-size: 11px;
color: var(--ink-4);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 14px;
}
.palette-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 10px;
}
.palette-swatch {
text-align: left;
}
.palette-color {
height: 40px;
border-radius: var(--r-sm);
border: 0.5px solid var(--line);
margin-bottom: 6px;
}
.palette-name {
font-size: 11px;
color: var(--ink-2);
font-weight: 600;
font-family: "SF Mono", monospace;
}
.palette-role {
font-size: 10px;
color: var(--ink-4);
margin-top: 1px;
}
/* =========================================================
RESPONSIVE
========================================================= */
@media (max-width: 960px) {
.app { grid-template-columns: 1fr; }
.sidebar { display: none; }
.main { padding: 24px; }
.metrics { grid-template-columns: 1fr; }
.row-2 { grid-template-columns: 1fr; }
.page-header { flex-direction: column; }
}
@media (max-width: 480px) {
.da-header-inner { flex-direction: column; align-items: flex-start; gap: 10px; }
.page-title { font-size: 22px; }
.metric-card .metric-value { font-size: 28px; }
.cta-block { flex-direction: column; }
.cta-block .btn { width: 100%; }
.history-item { grid-template-columns: 36px 1fr auto; }
.history-nclc-badge { display: none; }
}
</style>
</head>
<body>
<!-- Bandeau de présentation de la direction -->
<div class="da-header">
<div class="da-header-inner">
<div>
<div class="da-header-title">Direction H — Juste milieu</div>
<div class="da-header-subtitle">Entre Boréal (trop blanc) et Cadence (trop sombre). Fond gris-bleuté, cards blanches en relief, bleu Expria pivot, accents bleu-nuit.</div>
</div>
<div class="da-header-tag">Version recommandée</div>
</div>
</div>
<div class="app">
<!-- =====================================================
SIDEBAR
====================================================== -->
<aside class="sidebar">
<div class="logo">
<div class="logo-mark">EX</div>
<span>Expria</span>
</div>
<nav class="nav-group">
<div class="nav-group-label">Espace de travail</div>
<a class="nav-item active" href="#">
<svg viewBox="0 0 24 24"><path d="M3 12l9-9 9 9"/><path d="M5 10v10a1 1 0 001 1h4v-6h4v6h4a1 1 0 001-1V10"/></svg>
Tableau de bord
</a>
<a class="nav-item" href="#">
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8M8 17h8M8 9h2"/></svg>
Simulations
</a>
<a class="nav-item" href="#">
<svg viewBox="0 0 24 24"><path d="M12 2a10 10 0 100 20 10 10 0 000-20z"/><path d="M12 6v6l4 2"/></svg>
Historique
</a>
<a class="nav-item" href="#">
<svg viewBox="0 0 24 24"><path d="M12 15l-3-3 3-3M3 12h12a6 6 0 010 12"/></svg>
Rapports
</a>
</nav>
<nav class="nav-group">
<div class="nav-group-label">Préparation</div>
<a class="nav-item" href="#">
<svg viewBox="0 0 24 24"><path d="M21 5l-9-3-9 3v6a12 12 0 009 11 12 12 0 009-11V5z"/></svg>
Mode examen
</a>
<a class="nav-item" href="#">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>
Guide TCF
</a>
</nav>
<div class="user-card">
<div class="user-avatar">HM</div>
<div class="user-meta">
<div class="user-name">Hermann Mbanga</div>
<div class="user-plan">Plan Découverte</div>
</div>
</div>
</aside>
<!-- =====================================================
MAIN
====================================================== -->
<main class="main">
<div class="page-header">
<div class="page-title-block">
<div class="page-eyebrow">Mercredi 17 avril</div>
<h1 class="page-title">Bonjour Hermann</h1>
<p class="page-sub">Vous progressez bien. Plus que deux simulations pour atteindre votre objectif NCLC 9.</p>
</div>
<div class="cta-block">
<button class="btn btn-ghost">Voir mon profil</button>
<button class="btn btn-primary">
Nouvelle simulation
<span class="btn-arrow"></span>
</button>
</div>
</div>
<!-- Métriques -->
<div class="metrics">
<div class="card metric-card card-raised">
<div class="card-label">Simulations restantes</div>
<div class="metric-value">3<span class="metric-value-unit"> / 5</span></div>
<div class="progress"><div class="progress-fill" style="width: 60%"></div></div>
<div class="metric-sub">Plan Découverte — renouvellement à chaque upgrade</div>
</div>
<div class="card metric-card card-raised">
<div class="card-label">Niveau estimé</div>
<div class="metric-value">NCLC 8</div>
<div class="progress"><div class="progress-fill" style="width: 75%"></div></div>
<div class="metric-sub">Moyenne des 3 dernières simulations · Objectif NCLC 9</div>
</div>
<div class="card metric-card-plan">
<div class="card-label">Plan actuel</div>
<div class="plan-name">Découverte</div>
<div class="plan-desc">Débloquez des simulations illimitées, le Mode Examen et le T2 Live avec Standard ou Premium.</div>
<a href="#" class="btn-upgrade">
Passer à Standard
<span style="font-size: 14px; line-height: 1;"></span>
</a>
</div>
</div>
<!-- Row 2 colonnes -->
<div class="row-2">
<!-- Historique -->
<div class="card">
<div class="history-title">Simulations récentes</div>
<div class="history-sub">Vos 3 dernières corrections</div>
<div class="history-list">
<div class="history-item">
<div class="history-icon">
<svg viewBox="0 0 24 24"><path d="M17 3a2.85 2.83 0 114 4L7.5 20.5 2 22l1.5-5.5z"/></svg>
</div>
<div class="history-meta">
<div class="history-type">Expression écrite — Tâche 2</div>
<div class="history-date">Aujourd'hui · 09:42</div>
</div>
<div class="history-nclc-badge">NCLC 9</div>
<div class="history-score">16<span class="history-score-max">/20</span></div>
</div>
<div class="history-item">
<div class="history-icon">
<svg viewBox="0 0 24 24"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 11-14 0v-2M12 19v4M8 23h8"/></svg>
</div>
<div class="history-meta">
<div class="history-type">Expression orale — Tâche 1</div>
<div class="history-date">Il y a 2 jours</div>
</div>
<div class="history-nclc-badge warn">NCLC 8</div>
<div class="history-score">14<span class="history-score-max">/20</span></div>
</div>
<div class="history-item">
<div class="history-icon">
<svg viewBox="0 0 24 24"><path d="M17 3a2.85 2.83 0 114 4L7.5 20.5 2 22l1.5-5.5z"/></svg>
</div>
<div class="history-meta">
<div class="history-type">Expression écrite — Tâche 3</div>
<div class="history-date">Il y a 5 jours</div>
</div>
<div class="history-nclc-badge">NCLC 9</div>
<div class="history-score">15<span class="history-score-max">/20</span></div>
</div>
</div>
</div>
<!-- Prochaine étape -->
<div class="card next-step">
<div class="card-label">Prochaine étape recommandée</div>
<div class="next-step-title">Travaillez la tâche 2 à l'oral</div>
<div class="next-step-desc">
Votre dernier score à l'EO T2 (14/20) est en dessous de votre moyenne. Une simulation de 10 minutes suffit pour consolider.
</div>
<div class="next-step-stats">
<div class="next-step-stat">
<div class="next-step-stat-label">Durée</div>
<div class="next-step-stat-value">10 min</div>
</div>
<div class="next-step-stat">
<div class="next-step-stat-label">Difficulté</div>
<div class="next-step-stat-value">Modérée</div>
</div>
</div>
<button class="btn btn-primary" style="width: 100%;">
Commencer maintenant
<span class="btn-arrow"></span>
</button>
</div>
</div>
<!-- Palette -->
<div class="palette-row">
<div class="palette-title">Palette de la direction H</div>
<div class="palette-grid">
<div class="palette-swatch">
<div class="palette-color" style="background: #EEF2F8;"></div>
<div class="palette-name">#EEF2F8</div>
<div class="palette-role">Fond principal</div>
</div>
<div class="palette-swatch">
<div class="palette-color" style="background: #FFFFFF; border-color: #C7D0E0;"></div>
<div class="palette-name">#FFFFFF</div>
<div class="palette-role">Cards (blanc franc)</div>
</div>
<div class="palette-swatch">
<div class="palette-color" style="background: #1B4FD8;"></div>
<div class="palette-name">#1B4FD8</div>
<div class="palette-role">Bleu Expria — pivot</div>
</div>
<div class="palette-swatch">
<div class="palette-color" style="background: #0B1F5C;"></div>
<div class="palette-name">#0B1F5C</div>
<div class="palette-role">Bleu nuit — premium</div>
</div>
<div class="palette-swatch">
<div class="palette-color" style="background: #0F172A;"></div>
<div class="palette-name">#0F172A</div>
<div class="palette-role">Titres</div>
</div>
<div class="palette-swatch">
<div class="palette-color" style="background: #475569;"></div>
<div class="palette-name">#475569</div>
<div class="palette-role">Corps</div>
</div>
<div class="palette-swatch">
<div class="palette-color" style="background: #0E9F6E;"></div>
<div class="palette-name">#0E9F6E</div>
<div class="palette-role">Succès</div>
</div>
<div class="palette-swatch">
<div class="palette-color" style="background: #C77A00;"></div>
<div class="palette-name">#C77A00</div>
<div class="palette-role">Attention</div>
</div>
</div>
</div>
</main>
</div>
</body>
</html>

View file

@ -47,24 +47,25 @@ Le frontend communique avec Supabase **uniquement pour l'authentification** (log
Versions officielles au 2026-04-17 (cf. ADR 006 pour la justification) :
| Domaine | Choix | Version | Justification |
|---|---|---|---|
| Framework UI | React | 19.2.x | Compilateur React, Actions, useOptimistic |
| Build tool | Vite | 8.0.x | HMR rapide, moteur Rolldown, config minimale |
| Langage | TypeScript (strict mode) | 6.0.x | Typage fort obligatoire pour détecter les bugs de permissions à la compilation |
| Styling | Tailwind CSS | 4.2.x | Configuration CSS-first via `@theme`, moteur Oxide (builds microseconde) |
| UI components | shadcn/ui | CLI latest | Copy-paste, total contrôle, supporte Tailwind 4 + React 19 depuis 2025 |
| Routing | React Router | v7.14.x | Compatible API v6, data loaders disponibles |
| État serveur | TanStack Query | 5.x | Cache, refetch, invalidation, remplace Redux/SWR |
| État local | `useState` / `useReducer` | React 19 built-in | Pas de store global pour la V2 (voir ADR 003) |
| Auth | Supabase JS | 2.103.x | Côté frontend : auth uniquement. Cf. `ARCHITECTURE.md` backend §2 |
| Validation | Zod | latest | Validation des inputs formulaires (cf. SECURITY.md SEC-04) |
| Rendu Markdown | react-markdown | latest | Rendu sécurisé des rapports IA (cf. SECURITY.md SEC-05) |
| Tests | Vitest + React Testing Library | latest | Parité avec backend (qui utilise Vitest) |
| Lint + Format | ESLint + Prettier | 9.x + latest | Standard |
| CI | GitHub Actions | — | Typecheck + tests + `npm audit` |
| Domaine | Choix | Version | Justification |
| -------------- | ------------------------------ | ----------------- | ------------------------------------------------------------------------------ |
| Framework UI | React | 19.2.x | Compilateur React, Actions, useOptimistic |
| Build tool | Vite | 8.0.x | HMR rapide, moteur Rolldown, config minimale |
| Langage | TypeScript (strict mode) | 6.0.x | Typage fort obligatoire pour détecter les bugs de permissions à la compilation |
| Styling | Tailwind CSS | 4.2.x | Configuration CSS-first via `@theme`, moteur Oxide (builds microseconde) |
| UI components | shadcn/ui | CLI latest | Copy-paste, total contrôle, supporte Tailwind 4 + React 19 depuis 2025 |
| Routing | React Router | v7.14.x | Compatible API v6, data loaders disponibles |
| État serveur | TanStack Query | 5.x | Cache, refetch, invalidation, remplace Redux/SWR |
| État local | `useState` / `useReducer` | React 19 built-in | Pas de store global pour la V2 (voir ADR 003) |
| Auth | Supabase JS | 2.103.x | Côté frontend : auth uniquement. Cf. `ARCHITECTURE.md` backend §2 |
| Validation | Zod | latest | Validation des inputs formulaires (cf. SECURITY.md SEC-04) |
| Rendu Markdown | react-markdown | latest | Rendu sécurisé des rapports IA (cf. SECURITY.md SEC-05) |
| Tests | Vitest + React Testing Library | latest | Parité avec backend (qui utilise Vitest) |
| Lint + Format | ESLint + Prettier | 9.x + latest | Standard |
| CI | GitHub Actions | — | Typecheck + tests + `npm audit` |
**Choix motivés par ADR :**
- ADR 001 : Cloudflare Pages (hébergement)
- ADR 002 : Découplage `auth-client` / `api-client`
- ADR 003 : Pas de Zustand pour la V2
@ -95,10 +96,16 @@ expria-frontend/
│ └── 005-has-access-typed-strict.md
├── src/
│ ├── app/ # CONFIGURATION ET ENTRY POINTS
│ │ ├── providers.tsx # QueryClientProvider + AuthProvider + Router
│ ├── app/ # ENTRY POINTS + LAYOUT DE LA COQUILLE
│ │ ├── main.tsx # Entry point React (montage DOM)
│ │ ├── providers.tsx # QueryClientProvider + ThemeProvider + Router
│ │ ├── router.tsx # Routes déclaratives
│ │ └── main.tsx # Entry point React
│ │ ├── route-titles.ts # Mapping route → titre (breadcrumb Topbar)
│ │ ├── AppLayout.tsx # Coquille app (sidebar + topbar + outlet)
│ │ ├── Sidebar.tsx # Navigation desktop (navy permanent)
│ │ ├── Topbar.tsx # Topbar sticky (breadcrumb, recherche, theme toggle)
│ │ ├── BottomNav.tsx # Navigation mobile (< 1024px)
│ │ └── MaintenancePage.tsx # Page affichée si VITE_MAINTENANCE_MODE=true
│ │
│ ├── entities/ # OBJETS MÉTIER (indépendants de l'UI)
│ │ ├── user/
@ -106,67 +113,109 @@ expria-frontend/
│ │ │ ├── lib.ts # hasAccess(), canSimulate()
│ │ │ ├── access.ts # IDENTIQUE à expria-backend/src/lib/access.ts
│ │ │ ├── api.ts # GET /plans/status, POST /auth/verify-token
│ │ │ ├── query-keys.ts # Constantes de clés TanStack (PLAN_QUERY_KEY)
│ │ │ └── __tests__/
│ │ │ ├── hasAccess.test.ts
│ │ │ └── canSimulate.test.ts
│ │ │
│ │ ├── production/
│ │ │ ├── types.ts # Production, Tache, Mode
│ │ │ ├── lib.ts # helpers (format tache, etc.)
│ │ │ └── api.ts # POST /simulations, GET /simulations/:id
│ │ │ └── api.ts # POST /simulations, GET /simulations/:id, PATCH /:id/contenu
│ │ │
│ │ └── report/
│ │ ├── types.ts # Report, Critere
│ │ ├── lib.ts # Logique de floutage selon plan
│ │ ├── api.ts # POST /corrections/ee, POST /corrections/eo
│ │ └── __tests__/
│ │ └── floutage.test.ts
│ │ ├── report/
│ │ │ ├── types.ts # Report, Critere, Revelation, Diagnostic
│ │ │ ├── lib.ts # Logique de floutage selon plan
│ │ │ ├── api.ts # POST /corrections/ee, POST /corrections/eo
│ │ │ └── __tests__/
│ │ │
│ │ ├── patterns/ # Sprint 3.6c — analyse patterns Premium
│ │ │ ├── types.ts # Pattern, PatternAnalysis, PreparationIndex
│ │ │ └── api.ts # GET /users/patterns
│ │ │
│ │ ├── presentation/ # Sprint 4c-2 — présentation T1 EO
│ │ │ ├── types.ts
│ │ │ └── api.ts # POST /presentations/generate
│ │ │
│ │ └── transcription/ # Sprint 4c — code Deepgram dormant (cf. FTD-37)
│ │ ├── types.ts
│ │ └── api.ts
│ │
│ ├── features/ # UI (composants + pages + hooks)
│ │ ├── auth/
│ │ │ ├── components/ # LoginForm, RegisterForm, ProtectedRoute
│ │ │ ├── components/ # ProtectedRoute
│ │ │ ├── pages/ # LoginPage, RegisterPage
│ │ │ └── hooks/ # useAuth
│ │ │
│ │ ├── dashboard/
│ │ │ ├── components/ # DashboardFreeView, DashboardStandardView, DashboardPremiumView
│ │ │ ├── components/ # DashboardFreeView/StandardView/PremiumView,
│ │ │ │ # NclcHero, StatCards, RecentSimulations,
│ │ │ │ # NextStepCard, PaywallBanner, MonProfilPreparation
│ │ │ ├── pages/ # DashboardPage (orchestre les vues selon le plan)
│ │ │ └── hooks/ # usePlan
│ │ │
│ │ ├── simulations/
│ │ │ ├── components/ # SimulationForm, AudioRecorder, TimerExam
│ │ │ ├── pages/ # SimulationPage, RapportPage
│ │ │ └── hooks/ # useSimulation, useExamMode
│ │ │ ├── components/ # SimulationForm, AudioRecorder, TimerDisplay,
│ │ │ │ │ # TaskSelector, SujetCard/Display, IdeesSuggestions,
│ │ │ │ │ # NclcCibleSelector, SpecialCharsKeyboard,
│ │ │ │ │ # WordCountBar, TranscriptionDisplay
│ │ │ │ └── rapport/ # ScoreHero, RevelationCards, CritereCard,
│ │ │ │ # DiagnosticCallout, ConseilNclcCallout,
│ │ │ │ # ExerciceInteractive, ProductionModeleSection,
│ │ │ │ # JobStatusFallback
│ │ │ ├── pages/ # EE : SimulationPage, SujetsPage, RapportPage
│ │ │ │ # EO : SujetsEOPage, PreEnregistrementEOPage,
│ │ │ │ # EnregistrementEOPage, SimulationEOPage,
│ │ │ │ # ModeChoixT1Page, QuestionnaireT1Page,
│ │ │ │ # PresentationGenereeT1Page
│ │ │ ├── hooks/ # useSimulation, useSujets, useRapport, useTimer,
│ │ │ │ # useAutosave, useIdees, useAudioRecorder,
│ │ │ │ # useDeepgramLive (dormant — FTD-37)
│ │ │ ├── lib/ # simulationConfig.ts (durées, mots cibles, etc.)
│ │ │ └── state/ # SimulationFlowProvider + simulationFlow (machine d'état)
│ │ │
│ │ ├── t2-live/
│ │ │ ├── components/ # DialogueView, AudioVisualizer
│ │ │ ├── pages/ # T2LivePage
│ │ │ ├── hooks/ # useT2LiveSession
│ │ │ ├── lib/
│ │ │ │ ├── ws-client.ts # WebSocket + reconnexion
│ │ │ │ └── audio.ts # Capture PCM + lecture réponse
│ │ │ └── state/
│ │ │ └── t2-machine.ts # State machine (idle → connecting → listening → ...)
│ │ ├── historique/ # Sprint 3.7 — liste des productions
│ │ │ ├── components/ # SimulationsList, SimulationListItem
│ │ │ ├── pages/ # HistoriquePage
│ │ │ └── hooks/ # useSimulationsList
│ │ │
│ │ └── billing/
│ │ ├── components/ # PaymentSummary
│ │ ├── pages/ # PricingPage, CheckoutPage, UpgradePage
│ │ └── hooks/ # useStripeCheckout
│ │ ├── progression/ # Sprint 3.6c — analyse patterns Premium
│ │ │ ├── components/ # PreparationIndexHero, PatternsList,
│ │ │ │ # PatternExerciceCard, ProgressionPremium,
│ │ │ │ # BlurredProgression, NotReadyState
│ │ │ ├── pages/ # ProgressionPage
│ │ │ └── hooks/ # usePatterns
│ │ │
│ │ └── design-system/ # Page interne de référence visuelle (DA Charcoal)
│ │ └── DesignSystemPage.tsx
│ │
│ ├── shared/ # CODE RÉUTILISABLE NON MÉTIER
│ │ ├── ui/ # PRIMITIVES EXPRIA (PascalCase) — voir note ci-dessous
│ │ │ ├── Button.tsx
│ │ │ ├── Card.tsx
│ │ │ └── Badge.tsx
│ │ ├── components/
│ │ │ ├── ui/ # Button, Modal, Badge (shadcn/ui)
│ │ │ ├── PaywallModal.tsx # Blocage + boutons upgrade
│ │ │ └── Spinner.tsx
│ │ ├── hooks/ # useDebounce, useLocalStorage
│ │ │ ├── ui/ # PRIMITIVES SHADCN BRUTES (kebab-case) — voir note
│ │ │ │ ├── avatar.tsx
│ │ │ │ ├── badge.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── dialog.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── label.tsx
│ │ │ │ ├── progress.tsx
│ │ │ │ └── separator.tsx
│ │ │ ├── Logo.tsx
│ │ │ └── ThemeToggle.tsx
│ │ ├── hooks/ # useTheme
│ │ ├── lib/
│ │ │ ├── auth-client.ts # Supabase Auth uniquement (ADR 002)
│ │ │ ├── api-client.ts # Fetch + retry + timeout + logging (ADR 002)
│ │ │ ├── query-client.ts # Configuration TanStack Query
│ │ │ └── logger.ts # Logging structuré frontend
│ │ │ ├── logger.ts # Logging structuré frontend
│ │ │ ├── theme.ts # getInitialTheme / applyTheme / persistTheme
│ │ │ ├── audio.ts # Helpers MediaRecorder + mime detection
│ │ │ ├── date.ts # formatRelativeDate (Intl.RelativeTimeFormat)
│ │ │ └── utils.ts # cn() — clsx + tailwind-merge
│ │ ├── types/
│ │ │ ├── api.ts # ApiResponse<T>, ApiError
│ │ │ └── common.ts # Types utilitaires
│ │ │ └── api.ts # ApiError, ApiErrorCode, FrontendErrorCode
│ │ └── config/
│ │ └── env.ts # Validation des variables d'environnement au démarrage
│ │
@ -186,6 +235,29 @@ expria-frontend/
└── README.md
```
### Note sur `app/` — entry points + layout
Le dossier `app/` contient les entry points React (`main.tsx`, `providers.tsx`, `router.tsx`) **ET** les composants layout de la coquille applicative (`AppLayout`, `Sidebar`, `Topbar`, `BottomNav`, `MaintenancePage`). Ces composants ne sont rattachés à aucune feature : ils définissent la structure visuelle globale de l'app et orchestrent l'affichage des routes. Leur rôle structurel justifie leur place dans `app/` plutôt que dans `shared/components/` ou dans une feature dédiée.
> Note : `t2-live/` (Sprint 6) et `billing/` (Sprint 5) ne sont pas encore implémentés et n'apparaissent volontairement pas dans cette arborescence. Voir `ROADMAP.md` pour le calendrier.
### Convention `shared/ui/` vs `shared/components/ui/`
Deux dossiers UI cohabitent dans `shared/`. **La distinction est volontaire :**
| Dossier | Convention | Contenu | Usage |
| ----------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `shared/ui/` | PascalCase (`Button.tsx`) | **Wrappers Expria maison** : tokens DA Charcoal appliqués, API simplifiée, variants métier (`primary`, `secondary`, `ghost`, `upgrade`). | **À utiliser par défaut dans toutes les features.** |
| `shared/components/ui/` | kebab-case (`button.tsx`) | **Primitives shadcn/ui brutes** générées par la CLI shadcn. | À utiliser **uniquement** comme base interne pour construire un wrapper Expria, ou quand une primitive Radix (Dialog, Popover) est nécessaire directement. |
**Règle d'évolution :**
- Toute nouvelle primitive Expria va dans `shared/ui/`.
- Aucune nouvelle primitive ne doit être ajoutée manuellement dans `shared/components/ui/` — ce dossier est réservé aux fichiers générés par la CLI shadcn.
- Si un wrapper Expria s'appuie sur une primitive shadcn, il l'importe depuis `shared/components/ui/<name>` et l'expose sous une API simplifiée dans `shared/ui/<Name>.tsx`.
Cette dualité est tracée dans `TECH_DEBT.md` (FTD-26) — documentée, pas à fusionner.
### Règles de dépendance entre dossiers
```
@ -197,6 +269,10 @@ shared/ ne doit RIEN importer des autres dossiers
Cette hiérarchie garantit que la logique métier (`entities/`) ne dépend jamais de l'UI (`features/`), et que les briques réutilisables (`shared/`) restent portables.
**Exception documentée — cross-entity report → user :**
`entities/report/lib.ts` importe `hasAccess` et `Plan` depuis `entities/user/lib`.
Justification : la logique de floutage des rapports dépend intrinsèquement des permissions utilisateur. Utiliser `hasAccess()` est obligatoire (Règle D) ; le déplacer vers `shared/` briserait la cohésion du domaine `user`. Cette exception est volontaire et ne doit pas être généralisée à d'autres paires d'entities.
---
## 4. Flux de données
@ -260,12 +336,12 @@ Composant React
**Gestion des close codes côté frontend :**
| Close code | Cause | Action côté frontend |
|---|---|---|
| 1000 | Fermeture normale | State → 'ended', afficher le rapport |
| 4001 | AUTH_REQUIRED | State → 'error', redirect `/login` |
| 4003 | PLAN_INSUFFICIENT | State → 'error', afficher PaywallModal Premium |
| Autre | Erreur réseau ou serveur | State → 'error', message générique + bouton "Réessayer" |
| Close code | Cause | Action côté frontend |
| ---------- | ------------------------ | ------------------------------------------------------- |
| 1000 | Fermeture normale | State → 'ended', afficher le rapport |
| 4001 | AUTH_REQUIRED | State → 'error', redirect `/login` |
| 4003 | PLAN_INSUFFICIENT | State → 'error', afficher PaywallModal Premium |
| Autre | Erreur réseau ou serveur | State → 'error', message générique + bouton "Réessayer" |
---
@ -345,44 +421,44 @@ export interface ApiError {
error: true
code: ApiErrorCode
message: string
status?: number // quirk backend (simulations, corrections)
status?: number // quirk backend (simulations, corrections)
}
export type ApiErrorCode =
| 'AUTH_REQUIRED' // 401 — JWT absent, invalide ou expiré
| 'PLAN_INSUFFICIENT' // 403 — feature non disponible pour ce plan
| 'QUOTA_REACHED' // 403 — quota de simulations Free épuisé
| 'VALIDATION_ERROR' // 400 — corps de requête invalide (simulations, corrections)
| 'INVALID_BODY' // 400 — corps de requête invalide (plans, stripe) — voir note
| 'INVALID_PLAN' // 400 — valeur de plan inconnue
| 'NO_ACTIVE_SUBSCRIPTION' // 400 — tentative d'upgrade sans abonnement actif
| 'SIMULATION_NOT_FOUND' // 404 — simulation inexistante ou non accessible
| 'STRIPE_WEBHOOK_INVALID' // 400 — signature webhook invalide
| 'INTERNAL_ERROR' // 500 — erreur serveur inattendue
| 'AUTH_REQUIRED' // 401 — JWT absent, invalide ou expiré
| 'PLAN_INSUFFICIENT' // 403 — feature non disponible pour ce plan
| 'QUOTA_REACHED' // 403 — quota de simulations Free épuisé
| 'VALIDATION_ERROR' // 400 — corps de requête invalide (simulations, corrections)
| 'INVALID_BODY' // 400 — corps de requête invalide (plans, stripe) — voir note
| 'INVALID_PLAN' // 400 — valeur de plan inconnue
| 'NO_ACTIVE_SUBSCRIPTION' // 400 — tentative d'upgrade sans abonnement actif
| 'SIMULATION_NOT_FOUND' // 404 — simulation inexistante ou non accessible
| 'STRIPE_WEBHOOK_INVALID' // 400 — signature webhook invalide
| 'INTERNAL_ERROR' // 500 — erreur serveur inattendue
// Erreurs générées côté frontend uniquement (pas envoyées par le backend)
export type FrontendErrorCode =
| 'TIMEOUT' // timeout côté client (AbortController)
| 'NETWORK_ERROR' // pas de réponse réseau
| 'PARSE_ERROR' // réponse non-JSON
| 'TIMEOUT' // timeout côté client (AbortController)
| 'NETWORK_ERROR' // pas de réponse réseau
| 'PARSE_ERROR' // réponse non-JSON
```
> **Note sur `VALIDATION_ERROR` vs `INVALID_BODY`** : le backend utilise deux codes pour la même classe d'erreur (corps invalide). `VALIDATION_ERROR` dans les routes simulations/corrections, `INVALID_BODY` dans les routes plans/stripe. Cette inconsistance est documentée dans `TECH_DEBT.md` backend (TD-15 à créer). Côté frontend, les deux codes sont gérés de la même manière dans l'UI.
### Codes d'erreur — mapping HTTP
| Code backend | HTTP | Signification | Routes émettrices |
|---|---|---|---|
| `AUTH_REQUIRED` | 401 | JWT absent, invalide, expiré, ou profil introuvable | middleware, corrections |
| `PLAN_INSUFFICIENT` | 403 | Feature réservée à un plan supérieur | middleware, simulations |
| `QUOTA_REACHED` | 403 | 5/5 simulations utilisées (plan Free) | simulations |
| `VALIDATION_ERROR` | 400 | Corps de requête invalide (simulations, corrections) | simulations, corrections |
| `INVALID_BODY` | 400 | Corps de requête invalide (plans, stripe) | plans, stripe |
| `INVALID_PLAN` | 400 | Valeur de plan inconnue dans le payload | plans, stripe |
| `NO_ACTIVE_SUBSCRIPTION` | 400 | Upgrade prorata sans abonnement actif | plans |
| `SIMULATION_NOT_FOUND` | 404 | Simulation inexistante ou non accessible | corrections |
| `STRIPE_WEBHOOK_INVALID` | 400 | Signature webhook invalide | stripe |
| `INTERNAL_ERROR` | 500 | Erreur serveur inattendue | plans, stripe, corrections, simulations |
| Code backend | HTTP | Signification | Routes émettrices |
| ------------------------ | ---- | ---------------------------------------------------- | --------------------------------------- |
| `AUTH_REQUIRED` | 401 | JWT absent, invalide, expiré, ou profil introuvable | middleware, corrections |
| `PLAN_INSUFFICIENT` | 403 | Feature réservée à un plan supérieur | middleware, simulations |
| `QUOTA_REACHED` | 403 | 5/5 simulations utilisées (plan Free) | simulations |
| `VALIDATION_ERROR` | 400 | Corps de requête invalide (simulations, corrections) | simulations, corrections |
| `INVALID_BODY` | 400 | Corps de requête invalide (plans, stripe) | plans, stripe |
| `INVALID_PLAN` | 400 | Valeur de plan inconnue dans le payload | plans, stripe |
| `NO_ACTIVE_SUBSCRIPTION` | 400 | Upgrade prorata sans abonnement actif | plans |
| `SIMULATION_NOT_FOUND` | 404 | Simulation inexistante ou non accessible | corrections |
| `STRIPE_WEBHOOK_INVALID` | 400 | Signature webhook invalide | stripe |
| `INTERNAL_ERROR` | 500 | Erreur serveur inattendue | plans, stripe, corrections, simulations |
### Pattern `apiFetch<T>`
@ -398,9 +474,14 @@ const { data, error, isLoading } = useQuery({
// error est de type ApiError | null
if (error) {
switch (error.code) {
case 'AUTH_REQUIRED': redirectToLogin(); break
case 'QUOTA_REACHED': openUpgradeModal(); break
default: showGenericErrorToast()
case 'AUTH_REQUIRED':
redirectToLogin()
break
case 'QUOTA_REACHED':
openUpgradeModal()
break
default:
showGenericErrorToast()
}
}
```
@ -484,9 +565,18 @@ export const PLANS = {
},
}
export function getPlanPermissions(plan: Plan) { /* ... */ }
export function canUserSimulate(user: { plan: string; simulations_used: number }): { allowed, reason? } { /* ... */ }
export function checkFeatureAccess(plan: Plan, feature: Feature): boolean { /* ... */ }
export function getPlanPermissions(plan: Plan) {
/* ... */
}
export function canUserSimulate(user: { plan: string; simulations_used: number }): {
allowed
reason?
} {
/* ... */
}
export function checkFeatureAccess(plan: Plan, feature: Feature): boolean {
/* ... */
}
```
### Alias frontend-idiomatiques
@ -495,11 +585,7 @@ Le fichier `src/entities/user/lib.ts` ré-exporte ces fonctions sous des noms st
```typescript
// src/entities/user/lib.ts
import {
canUserSimulate,
checkFeatureAccess,
getPlanPermissions,
} from './access'
import { canUserSimulate, checkFeatureAccess, getPlanPermissions } from './access'
/**
* Alias frontend-idiomatique de checkFeatureAccess.
@ -550,6 +636,7 @@ VITE_SUPABASE_URL=https://xxx.supabase.co
VITE_SUPABASE_ANON_KEY=xxx
VITE_ENABLE_T2_LIVE=false # flag pour cacher T2 en prod tant que pas prêt
VITE_SENTRY_DSN=xxx # optionnel, pour monitoring
VITE_MAINTENANCE_MODE=false # true = affiche MaintenancePage avant tout provider
```
### Règle absolue
@ -563,6 +650,7 @@ VITE_SENTRY_DSN=xxx # optionnel, pour monitoring
- `STRIPE_WEBHOOK_SECRET`
Cette règle est vérifiée par :
- Le plugin Security Guidance de Claude Code (voir `SECURITY.md`)
- Une règle Semgrep dans la CI
- Le scan de secrets GitHub (Dependabot)
@ -577,12 +665,12 @@ Cette règle est vérifiée par :
### Infrastructure cible (cf. ADR 001)
| Composant | Plateforme | URL |
|---|---|---|
| Frontend | Cloudflare Pages | `https://expria.app` |
| Backend API | Render (Frankfurt) | `https://api.expria.app` |
| DNS | Vercel (actuellement) | — |
| Base de données | Supabase (Frankfurt) | — |
| Composant | Plateforme | URL |
| --------------- | --------------------- | ------------------------ |
| Frontend | Cloudflare Pages | `https://expria.app` |
| Backend API | Render (Frankfurt) | `https://api.expria.app` |
| DNS | Vercel (actuellement) | — |
| Base de données | Supabase (Frankfurt) | — |
### Workflow de déploiement
@ -617,13 +705,13 @@ Tests ciblés sur la logique critique, pas exhaustifs. On copie la stratégie ba
### Fichiers obligatoirement couverts
| Fichier | Nombre de tests minimum |
|---|---|
| `src/entities/user/__tests__/hasAccess.test.ts` | 14+ |
| `src/entities/user/__tests__/canSimulate.test.ts` | 7 |
| `src/entities/report/__tests__/floutage.test.ts` | 8+ (un par critère à flouter × 3 plans) |
| `src/features/t2-live/state/__tests__/t2-machine.test.ts` | 6+ (transitions d'états) |
| `src/features/dashboard/hooks/__tests__/usePlan.test.ts` | 3+ (cache, refetch, invalidation) |
| Fichier | Nombre de tests minimum |
| --------------------------------------------------------- | --------------------------------------- |
| `src/entities/user/__tests__/hasAccess.test.ts` | 14+ |
| `src/entities/user/__tests__/canSimulate.test.ts` | 7 |
| `src/entities/report/__tests__/floutage.test.ts` | 8+ (un par critère à flouter × 3 plans) |
| `src/features/t2-live/state/__tests__/t2-machine.test.ts` | 6+ (transitions d'états) |
| `src/features/dashboard/hooks/__tests__/usePlan.test.ts` | 3+ (cache, refetch, invalidation) |
### Fichiers non testés (par design)
@ -650,39 +738,50 @@ Un échec sur l'un de ces jobs bloque le merge.
Ces règles sont héritées de `DEVELOPMENT_PRINCIPLES.md` backend et adaptées au frontend.
### Règle 1 — Séparation stricte
Le frontend affiche des données et relaie des actions. Aucune logique métier.
### Règle 2 — Source de vérité unique pour les plans
`src/entities/user/access.ts` est identique à `expria-backend/src/lib/access.ts`. Toute modification se fait dans les deux dépôts, dans le même commit logique.
### Règle 3 — Maximum 3 fichiers par étape
Hérité du backend. Si une tâche nécessite plus de 3 fichiers, elle est découpée.
### Règle 4 — Plan avant code
Aucune session Claude Code ne commence à coder sans plan validé.
### Règle 5 — Tests verts avant de continuer
`npm run test` et `npm run typecheck` doivent passer après chaque étape.
### Règle 6 — Jamais de clé privée dans le frontend
Variables `VITE_*` uniquement. Cf. section 7.
### Règle 7 — Jamais de `if (plan === 'xxx')`
Toute vérification de permission passe par `hasAccess()` ou `canSimulate()`. Cf. ADR 005.
### Règle 8 — Jamais de logique métier dans `features/`
Les règles de floutage, de quotas, de permissions vivent dans `entities/*/lib.ts`. Les composants de `features/` appellent ces fonctions.
### Règle 9 — Jamais d'appel direct à Supabase pour les données métier
Supabase côté frontend est **uniquement** pour l'authentification. Toute lecture/écriture passe par le backend Hono.
### Règle 10 — Signaler tout écart par rapport au plan
Identique à la Règle H backend.
---
## 11. Historique des versions de ce document
| Version | Date | Auteur | Changements |
|---|---|---|---|
| 1.0 | 2026-04-17 | Hermann (avec assistance Claude) | Création initiale |
| Version | Date | Auteur | Changements |
| ------- | ---------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1.0 | 2026-04-17 | Hermann (avec assistance Claude) | Création initiale |
| 1.1 | 2026-04-25 | Hermann (avec assistance Claude) | FTD-25 + FTD-26 — §3 reflète l'arborescence réelle ; ajout note `app/` (entry points + layout) ; ajout convention `shared/ui/` vs `shared/components/ui/` |

View file

@ -29,9 +29,902 @@ Chaque entrée suit ce format :
---
## [Unreleased] — 2026-06-30 — Sprint 7b — Frontend T1 Live (monologue + interruption non déterministe)
### Added
- Machine d'état T1 (`features/t1-live/state/t1-machine.ts`) — 8 états purs (`idle`, `preparing`, `connecting`, `presenting`, `interrupted`, `processing`, `ended`, `error`). Le cœur est la transition `interrupted ⇄ presenting` (interruption examinateur puis reprise candidat). +23 tests.
- `useT1LiveSession` (`features/t1-live/hooks/useT1LiveSession.ts`) — orchestrateur du dialogue T1, calqué sur `useT2LiveSession` (discipline « Voie A »). WS `wss://${API_URL}/t1/live?token=<jwt>` (PAS de `&sujet=` — T1 n'est pas subject-based). Aucun VAD micro (T1 = monologue) ; l'uplink micro est coupé/rétabli pendant une interruption via un **ref** (`uplinkMutedRef`), jamais via `setState` (leçon Voie A). Réagit aux signaux applicatifs `{type:'interruption_start'}` / `{type:'interruption_end'}`. Timer dur 180 s. Close codes 1000/4001/4003/4005/4006.
- `T1PreparationPage` + `T1DialoguePage` (`features/t1-live/pages/`) — parcours préparation → dialogue (3:00) ; écran terminal « Télécharger l'audio » + « Voir le rapport » (`/rapport/:id`). L'UI ne suppose JAMAIS qu'une relance suit (interruption non déterministe).
- `T1SpeakingIndicator` (`features/t1-live/components/`) — indicateur de prise de parole (amplitude micro réelle en `presenting`, animation décorative en `interrupted`).
- Carte `EO_T1_LIVE` dans `TaskSelector` (discriminateur `live?: 'T1' | 'T2'`, label « Tâche 1 — Live ») gatée Premium via `hasAccess(plan, 'oral_t2_live')` (TD-24 — pas de nouvelle permission, le gate couvre T1 et T2 Live) + prop `onT1LiveSelect`. `SimulationEOPage` câble `onT1LiveSelect → /simulation/eo/t1/live/preparation`.
- `features/simulations/lib/t1Questionnaire.ts` — définition partagée du questionnaire T1 (FIELDS + schéma zod + `EMPTY_REPONSES`), réutilisée par le batch `QuestionnaireT1Page`.
### Changed
- `useT1LiveSession` aligné sur le **Patch 7a backend** : plus d'envoi du message `{type:'context'}`, plus d'option `reponses`, la session audio démarre directement sur `ws.onopen` (WS_OPENED → presenting).
- Parcours T1 Live simplifié : carte `EO_T1_LIVE` → préparation → dialogue (plus d'étape questionnaire intermédiaire).
- `t1-machine` : commentaire et test nettoyés (mapping close **4004** retiré → 4006), cohérent avec la suppression du contexte côté backend.
### Removed
- `T1LiveQuestionnairePage` et `T1LiveContext` (post-Patch 7a) — le backend n'exige plus de message `context` ni de réponses pré-remplies ; ces écrans/état deviennent sans objet.
### Notes
- **FTD-44 gelée** (§3bis TECH_DEBT) — les trois hooks audio génériques sont empruntés à `features/t2-live/hooks/` (violation FSD inter-features assumée et tracée, sites marqués `// TODO(FTD-44)`), réactivée au Sprint 7.5 (factorisation Sprint 7).
- WebSocket / AudioContext non matérialisables en jsdom → validation manuelle ; la logique pure de transition est couverte par `t1-machine.test.ts`.
- Bugs amont observés au test manuel, hors contrôle frontend : **FTD-45** (relances Gemini hors-sujet, extension TD-23) et **FTD-46** (transcription Gemini Live hasardeuse).
---
## [Unreleased] — 2026-06-29 — Sprint 6e — T2 Live « Voie A » (mix audio temps réel)
### Added
- `public/pcm-record-processor.js` — AudioWorklet « tap » branché sur le mix du contexte partagé : prélève le mix (micro candidat + voix IA) en temps réel et émet des chunks Int16 vers le hook d'enregistrement. Permet un WAV aligné temporellement sur une horloge unique.
- `useAudioCapture` expose désormais `contextRef` (AudioContext partagé) + `mixNodeRef` (GainNode point de convergence) pour partager une horloge unique entre capture, playback et enregistrement.
- Indicateur de prise de parole du candidat : VAD par RMS sur le flux micro Int16 (seuils `SPEAK_RMS=500` / `SILENCE_RMS=250`, debounce 700 ms) pilotant les transitions `speaking``listening`.
- Détection `newTurn` : un chunk audio IA reçu après > 800 ms de silence IA marque la reprise de parole de l'examinateur → réalignement de l'edge-tracking micro + `USER_SILENT`.
### Changed
- **Architecture audio « Voie A »** : passage à **UN SEUL AudioContext au rate NATIF** (≈ 48 kHz), partagé par capture / playback / enregistrement. Suppression du forçage `{ sampleRate: 16000 }` et des deux contextes séparés 16 k / 24 k.
- `useAudioPlayback({ contextRef, mixNodeRef })` ne crée plus son propre contexte : la source IA est routée vers `ctx.destination` (audible) ET vers `mixGain` (captée par le tap). Buffer créé à 24 k, rééchantillonné automatiquement par le contexte natif.
- `useAudioRecording` : enregistrement via tap worklet sur le mix (`mixGain → recordNode → gain(0) → destination`, sink muet pour pull cross-navigateur). Buffer Int16 hors cycle de vie du contexte (`exportWAV()` survit à la fermeture). **WAV mono au rate natif, single-track, zéro resample** (remplace l'ancien WAV 24 k multi-piste).
- `useT2LiveSession` : cycle de vie audio aligné sur la « Voie A » — start sur `ws.onopen` après `capture.start()` résolu ; stop sur `endDialogue` (débranche le tap, buffer conservé) ; cancel ferme le contexte (buffer abandonné, aucun export).
- **Bug 6 — « Nouvelle simulation »** : le routage vers la bonne tâche s'appuie désormais sur le champ `tache` propagé dans le rapport (`report/api.ts`, `types.ts`, `RapportPage.tsx`), sans query param.
### Fixed
- **Anti-blanc EO** : suppression des silences/blancs en début de dialogue grâce à l'horloge unique et au scheduling continu de la voix IA.
- Correction de l'écho de la voix candidat (`mixGain` jamais connecté à `destination`).
- **Bug 4 — « Voir le rapport »** : la navigation vers `/rapport/:id` aboutit bien (garde `navigatingAwayRef` empêchant le cleanup/teardown d'avorter la redirection).
- **Bug 5 — « Annuler » (`cancelDialogue`)** : arrête l'enregistrement, ne déclenche aucune évaluation, ne produit aucun WAV et ne persiste aucune production (WS fermée sans message de fin).
- Stabilité de l'uplink micro : l'architecture « Voie A » supprime l'état React réactif sur la `MediaStream` (source du _starving_ d'uplink), au profit de refs stables sur le contexte/mix partagés.
### Removed
- Helpers `resample16kTo24k` et `mixTracksToInt16` de `audio-utils.ts` (rendus inutiles par l'horloge unique et le single-track). Helpers purs conservés : `arrayBufferToBase64`, `base64ToArrayBuffer`, `int16ToFloat32`, `float32ToInt16`, `concatInt16`, `buildWavHeader`.
- Instrumentation de diagnostic `[BISECT]` retirée des hooks T2 Live (logique runtime VAD / garde-fous / routage des messages conservée).
### Notes
- Tous les bugs ciblés (anti-blanc, Voie A, bugs 4/5/6, indicateur de parole) validés **à l'oreille en navigation privée** — console sans `[BISECT]`.
- Tests frontend : 259 → **269 verts (37 fichiers)**.
- AudioContext / AudioWorklet / WebSocket non matérialisables en jsdom → validation audio à l'oreille (objectif de la session). `useAudioRecording` couvert sur sa surface pure (export WAV, reset).
---
## [Unreleased] — 2026-04-26 — Sprint 6c — Frontend T2 Live UI
### Added
- `t2-machine.ts` — state machine pure T2 Live : 9 états (`idle``preparing``connecting``ready``speaking``listening``processing``ended` / `error`), 8 events. 21 tests. Résout FTD-09.
- `useT2LiveSession.ts` — hook orchestrateur : WebSocket + state machine + hooks audio (capture/playback/recording). Parse format Gemini natif (`serverContent.modelTurn`) + messages applicatifs backend (`warning`/`report`/`error`). Close codes 1000/4001/4003/4004. Timer dialogue 210 s. Ping 30 s keep-alive.
- `T2LiveContext.tsx` — Provider léger pour partager le sujet sélectionné entre les pages T2.
- `T2SujetsPage.tsx` — grille de sélection des sujets T2 (`GET /sujets?mode=EO&tache=2`).
- `T2PreparationPage.tsx` — timer 2 min, consigne affichée, zone de notes locale, bouton « Suggestions d'idées » (DeepSeek, actif immédiatement), bouton « Je suis prêt », pré-warm micro via `getUserMedia`. Transition auto vers dialogue à 0:00.
- `T2DialoguePage.tsx` — timer 3:30, indicateur d'état IA, waveform, bouton « Terminer ». Écran terminal (state `ended`) : bouton « Télécharger l'audio » (WAV mono 24 kHz) + bouton « Voir le rapport » (→ `/rapport/:id`).
- 3 routes : `/simulation/eo/t2`, `/simulation/eo/t2/preparation`, `/simulation/eo/t2/dialogue` sous `T2LiveLayout`.
### Changed
- `TaskSelector.tsx` — carte EO T2 Live déverrouillée via `hasAccess(plan, 'oral_t2_live')` + prop `onT2LiveSelect`. Résout FTD-33.
- `SimulationEOPage.tsx` — branche `onT2LiveSelect` vers `/simulation/eo/t2`.
- `entities/production/``Tache` type, labels, `mapTacheToSujetParams`, config étendus avec `EO_T2_LIVE`.
- `features/historique/``TACHE_NUMBER` étendu.
### Notes
- Tests frontend : 238 → 259 verts (+21 — tous sur t2-machine).
- FTD-09 résolue (state machine testée).
- FTD-33 résolue (carte déverrouillée via hasAccess).
- `useT2LiveSession` non testé en unit (WebSocket non supporté jsdom) — validation manuelle prévue.
---
## [Unreleased] — 2026-04-26 — Sprint 6b — Frontend audio (T2 Live)
### Added
- `public/pcm-capture-processor.js` — AudioWorklet processor : capture micro, rééchantillonnage vers 16 kHz si `sampleRate` natif différent, conversion Float32 → Int16 LE, chunks de 4096 samples (~256 ms).
- `src/shared/lib/audio-utils.ts` — 6 helpers purs : `arrayBufferToBase64`, `base64ToArrayBuffer`, `int16ToFloat32`, `float32ToInt16`, `resample16kTo24k`, `buildWavHeader`.
- `src/features/t2-live/hooks/useAudioCapture.ts` — hook capture : `getUserMedia` (mono, echoCancellation, noiseSuppression) → AudioContext 16 kHz → AudioWorklet → callback `onChunk(base64)`. Cleanup au stop/unmount.
- `src/features/t2-live/hooks/useAudioPlayback.ts` — hook playback : AudioContext 24 kHz lazy-init, scheduling séquentiel via `start(max(currentTime, lastEndTime))` pour lecture sans gaps. Cleanup au stop/unmount.
- `src/features/t2-live/hooks/useAudioRecording.ts` — hook recording : buffer chronologique unique normalisé 24 kHz (chunks candidat rééchantillonnés 16→24k), `addAIChunk(base64)` décode en interne, `exportWAV()` → Blob `audio/wav` mono 24 kHz.
- 12 tests `audio-utils.test.ts` (round-trips base64/ArrayBuffer, clamping int16/float32, interpolation resample, header WAV).
- 7 tests `useAudioRecording.test.ts` (add candidat resample, add IA, alternance, header WAV, reset, export vide, chunks vides ignorés).
### Notes
- Tests frontend : 219 → 238 verts (+19).
- `useAudioCapture` et `useAudioPlayback` dépendent de AudioContext (API navigateur) — validation manuelle au Sprint 6c.
- AudioWorklet utilisé directement (pas ScriptProcessorNode) — FTD-06 ne s'applique plus pour T2 Live.
---
## [Unreleased] — 2026-04-26 — Sprint 5.5 Clean FTD
### Changed
- `StatCards.tsx:90``plan === 'free'` remplacé par `!hasAccess(plan, 'dashboard')` (FTD-39, Règle D).
- `useAudioRecorder.ts:80` — assignation `optionsRef` pendant render refactorée en `useEffect` sans deps, eslint-disable retiré (FTD-38).
### Docs
- `TECH_DEBT.md` v1.26 → v1.27 — triage dette technique :
- Gelées : FTD-09 (state machine T2 Live), FTD-33 (carte T2 Live en dur), FTD-42 (modal prorata — Customer Portal suffit).
- Fermée : FTD-35 (subsumée par FTD-41).
- Résolues : FTD-14 (anti-FOUC déjà en place, conforme DESIGN_SYSTEM v2.0), FTD-38, FTD-39.
- 21 → 14 FTD actives (cap 15 respecté).
### Notes
- FTD-14 : le script inline `.light` était déjà présent dans `index.html` (lignes 14-20), conforme à DESIGN_SYSTEM v2.0 (dark = défaut, `.light` = override). L'exemple `.dark` documenté dans la fiche FTD-14 datait de la DA Boréal v1.0.
- Tests frontend : 219/219 verts (inchangé).
---
## [Unreleased] — 2026-04-26 — Sprint 5d — Customer Portal + page Paramètres
### Added
- `src/features/billing/hooks/useCustomerPortal.ts` — hook `{ openPortal, isLoading, error }` autour de `createCustomerPortalSession` + redirect full-page vers Stripe Customer Portal. Message d'erreur backend (`NO_ACTIVE_SUBSCRIPTION`) propagé tel quel.
- `src/features/billing/components/AccountBillingSection.tsx` — section UI `Card` : badge plan + CTA contextuel (Free → lien « Voir les plans » vers `/plan` ; Standard/Premium → bouton « Gérer mon abonnement » → Customer Portal).
- `src/features/account/pages/ParametresPage.tsx` — page conteneur `/parametres` avec section Abonnement + section Session (bouton « Se déconnecter » → `signOut()` + `queryClient.clear()` + `navigate('/login')`).
- 6 tests (3 useCustomerPortal + 3 AccountBillingSection).
### Changed
- `src/features/billing/pages/PricingPage.tsx` — branche **Standard→Premium** routée vers `useCustomerPortal.openPortal()` au lieu de Stripe Checkout direct (le Customer Portal Stripe affiche le montant prorata + confirmation native). `buildCtaConfigs` refactor : signature `(plan, isStandardPending, isPremiumPending, onUpgrade)` ; loading state combiné selon source ; erreur unifiée `checkoutError ?? portalError`.
- `src/features/billing/__tests__/PricingPage.test.tsx` — 6e test : Standard click « Passer en Premium » → `createCustomerPortalSession` appelé (et `createCheckoutSession` non appelé).
- `src/app/router.tsx``/parametres``<ParametresPage />` (sous PrivateLayout).
### Notes
- Tests : 212 → 219 verts (+7).
- Customer Portal Stripe doit être configuré côté Dashboard Stripe (hors code) pour fonctionner en prod.
---
## [Unreleased] — 2026-04-26 — Sprint 5c — Flow Checkout post-redirect
### Added
- `src/features/billing/hooks/useStripeCheckout.ts` — hook `{ checkout, isLoading, pendingPriceType, error }` autour de la mutation Stripe Checkout + redirect full-page sur succès. `pendingPriceType` permet l'affichage loading par carte sans state local.
- `src/features/dashboard/hooks/useUpgradeSuccessHandler.ts` — détecte `?upgrade=success` au mount du Dashboard, invalide le cache `PLAN_QUERY_KEY` (refetch automatique du plan), nettoie l'URL via `history.replaceState` (préserve les autres params utm\_\*, etc.).
- `src/features/dashboard/components/UpgradeSuccessBanner.tsx` — callout success-soft « Bienvenue ! Votre plan a été mis à jour. » + bouton dismiss.
- 9 tests (4 useStripeCheckout + 5 useUpgradeSuccessHandler).
### Changed
- `src/features/billing/pages/PricingPage.tsx` — migration vers `useStripeCheckout` (suppression `useMutation` inline + `pendingType` state local).
- `src/features/dashboard/pages/DashboardPage.tsx` — branche `useUpgradeSuccessHandler` + rend `<UpgradeSuccessBanner>` au-dessus de `<DashboardContent>` quand `showSuccess`.
### Cross-repo
- `expria-backend@28f8373``fix(stripe): cancel_url /tarifs → /plan`. Bug détecté lors de cette session : la route `/tarifs` n'existe pas côté frontend, les checkouts annulés aboutissaient sur un 404. Corrigé en commit séparé sur le backend.
### Notes
- Tests : 203 → 212 verts (+9).
- Race condition connue (FTD-43) : le webhook Stripe peut arriver après le redirect frontend ; `usePlan()` peut retourner l'ancien plan brièvement. Le banner indique « rafraîchissez dans quelques secondes » pour gérer ce cas.
---
## [Unreleased] — 2026-04-26 — Sprint 5b — Page tarifaire `/plan`
### Added
- `src/features/billing/api.ts``createCheckoutSession(priceType)` + `createCustomerPortalSession()` (utilisée Sprint 5d).
- `src/features/billing/components/PlanCard.tsx` — carte plan présentationnelle pure : props `cta`, `currentBadge`, `highlighted`, `ctaHint`, `errorMessage`.
- `src/features/billing/pages/PricingPage.tsx` — orchestration 3 colonnes (Découverte / Standard / Premium) avec gating dynamique selon `usePlan()`. CTA payant → Stripe Checkout (full-page redirect). Callout d'erreur sous la carte cliquée.
- 5 tests PricingPage (rendu Free/Standard/Premium + click + erreur).
### Changed
- `src/shared/config/env.ts` + `.env.example` — ajout `VITE_STRIPE_PRICE_STANDARD` + `VITE_STRIPE_PRICE_PREMIUM` (optionnels — public price_ids Stripe).
- `src/app/router.tsx``/plan``<PricingPage />` (sous PrivateLayout, donc ProtectedRoute).
- **Uniformisation CTA upgrade** : `SimulationsList`, `RapportPage`, `TaskSelector`, `DashboardFreeView`, `PaywallBanner` → libellé « Voir les plans » (au lieu de « Passer en Standard » / « Passer en Premium → » / « Voir les offres »). Cibles navigation inchangées (`/plan`).
- `SimulationsList.test.tsx` — assertion adaptée au nouveau libellé.
### Notes
- Tests : 198 → 203 verts (+5).
- `DashboardStandardView` et `BlurredProgression` conservent leurs CTA orientés (« Passer en Premium ») — sémantiquement corrects (Standard a un seul upgrade possible ; pattern_analysis est Premium-only).
---
## [Unreleased] — 2026-04-26 — Sprint 4.8 — Phonologie EO (frontend)
### Added
- `src/entities/report/__tests__/getMaxScorePerCritere.test.ts` — 7 tests (détection maxScore + mapping libellés EO).
### Changed
- `src/entities/report/lib.ts` — nouveau helper `getMaxScorePerCritere(rapport): 4 | 5` (détection sur criteres.length === 5). `CRITERE_NOM_TO_CODE` étendu avec les 4 libellés EO Sprint 4.8.
- `src/features/simulations/components/rapport/CritereCard.tsx` — nouvelle prop `maxScore` : affiche `X/4` (EO Sprint 4.8) ou `X/5` (EE, EO legacy).
- `src/features/simulations/pages/RapportPage.tsx` — calcul maxScore propagé aux CritereCard.
- `src/entities/report/types.ts` — commentaire Critere.score clarifié.
### Notes
- Rétrocompatibilité : rapports EO legacy (4 critères × /5) et EE (4 × /5) inchangés.
- Tests : 191 → 198 verts (+7).
---
## [Unreleased] — 2026-04-26 — Sprint 4.6 — UI EO (waveform + timeline)
### Added
- `RecordingWaveform.tsx` — visualiseur audio animé (AnalyserNode, fftSize 256,
smoothing 0.7, 32 barres). Visible uniquement pendant `isRecording`. Respecte
`prefers-reduced-motion` (frame statique). AudioContext fermé au cleanup.
- `RecordingTimeline.tsx` — barre de progression colorée avec seuils fixes :
vert (0 → maxSeconds-30s), orange (maxSeconds-30s → maxSeconds-15s),
rouge (maxSeconds-15s → fin). Applicable T1 et T3.
- `RecordingTimeline.test.tsx` — 7 tests (logique seuils + rendu + clamp).
### Changed
- `useAudioRecorder.ts` — expose `mediaStream: MediaStream | null` (set au
start, reset au cleanup).
- `AudioRecorder.tsx` — intègre Waveform + Timeline dans l'UI d'enregistrement.
### Notes
- Aucun changement backend.
- Tests : 166 → 173 verts (+7).
---
## [Unreleased] — 2026-04-25 — Sprint 4.5 Clean + fixes Golden Dataset
### Added
- `features/simulations/components/rapport/__tests__/ScoreHero.test.tsx` — 3 tests (un par état : depasse / atteint / !atteint)
- `features/simulations/components/rapport/__tests__/ConseilNclcCallout.test.tsx` — 3 tests (patch FTD-40)
- Test d'hydratation EO_T1 dans `simulationFlowT1.test.tsx` (resume au refresh : production + présentation)
### Changed
- `ARCHITECTURE.md` §3 — arborescence réelle reflétée (FTD-25) : note `app/` documente entry points + composants layout (AppLayout, Sidebar, Topbar, BottomNav, MaintenancePage). `t2-live/` et `billing/` retirés (non implémentés). Ajout `entities/{patterns,presentation,transcription}` et `features/{historique,progression,design-system}`. Bump v1.1.
- `ARCHITECTURE.md` §3 — convention `shared/ui/` (wrappers Expria PascalCase) vs `shared/components/ui/` (primitives shadcn kebab-case) documentée (FTD-26).
- `ConseilNclcCallout.tsx` — props `nclc` + `nclcCible` ajoutées ; patch temporaire FTD-40 (texte fixe « Excellent travail — vous avez dépassé votre objectif. Continuez sur cette lancée pour viser NCLC {nclc+1} ! » quand `nclc > nclcCible`).
- `RapportPage.tsx` — passe `nclc` + `nclcCible` à `ConseilNclcCallout`.
- `ScoreHero.tsx` — encart de conclusion à 3 états (depasse / atteint / !atteint).
- `SimulationFlowProvider.tsx``useEffect` persiste `production.id` dans `localStorage.expria_simulation_id` pour TOUS les flows (EE + EO_T1 + EO_T3) → resume au refresh fonctionnel pour EO.
### Fixed
- Sprint 4.5 Clean — 3 erreurs lint Sprint 4c corrigées :
- `useDeepgramLive.ts:152` — directive `eslint-disable-next-line` orpheline retirée
- `useAudioRecorder.test.ts:77,81` — params `_t`/`_timeslice` neutralisés via `void` (signature mock préservée)
- `useAudioRecorder.ts:73``eslint-disable-next-line react-hooks/refs` + commentaire renvoyant à FTD-38
- `QuestionnaireT1Page.test.tsx:10` — import `React` inutilisé supprimé (TS6133).
### Notes
- **TECH_DEBT.md bumps** : v1.23 (FTD-25/26 fermées) → v1.24 (FTD-38/39 ouvertes) → v1.25 (FTD-40/41 ouvertes).
- **FTD ouvertes Sprint 4.5** :
- FTD-38 🟢 — `useAudioRecorder` ref mise à jour pendant render (eslint-disable local en place)
- FTD-39 🟡 — Règle D violée dans `StatCards.tsx:90` (préexistant Sprint UI Polish)
- FTD-40 🟡 — Conclusion `conseil_nclc` backend incohérente quand NCLC atteint > cible (patch frontend en place, fix backend prompt à venir)
- FTD-41 🔴 — Persistance présentation EO T1 en BDD (résout FTD-35 ; localStorage instable)
- **FTD fermées Sprint 4.5** : FTD-25 🟢, FTD-26 🟡.
- **Cap FTD : 19/15 — dépassé de 4. Résorption obligatoire au Sprint 5.5 avant toute nouvelle FTD.**
- Tests : 159 → 166 verts (+7). Typecheck + lint : 0 erreur.
---
## [Unreleased] — 2026-04-25 — Sprint 4c — Frontend EO (T1 + T3)
### Added
- `features/simulations/pages/SimulationEOPage.tsx` — TaskSelector EO (T1, T3, T2 cadenas Premium)
- `features/simulations/pages/SujetsEOPage.tsx` — grille sujets EO_T3 + bouton aléatoire
- `features/simulations/pages/PreEnregistrementEOPage.tsx` — consigne + durée recommandée
- `features/simulations/pages/EnregistrementEOPage.tsx` — enregistrement audio + auto-submit à expiration
- `features/simulations/pages/ModeChoixT1Page.tsx` — choix Générer / Enregistrer directement
- `features/simulations/pages/QuestionnaireT1Page.tsx` — 5 champs + validation Zod + génération IA
- `features/simulations/pages/PresentationGenereeT1Page.tsx` — texte généré, modifier, copier, .txt, refaire, localStorage
- `features/simulations/hooks/useAudioRecorder.ts` — MediaRecorder, timer, maxSeconds, auto-stop, download
- `features/simulations/hooks/useDeepgramLive.ts` — conservé dormant (FTD-37)
- `features/simulations/components/AudioRecorder.tsx` — UI enregistrement, maxSeconds/onMaxReached
- `features/simulations/components/TranscriptionDisplay.tsx` — conservé dormant
- `entities/transcription/` — token Deepgram (dormant, FTD-37)
- `entities/presentation/` — generatePresentation (POST /presentations/generate)
- `shared/lib/audio.ts` — blobToBase64 helper
### Changed
- `SimulationFlowProvider` — étendu EO : submitEoAudio, presentationT1, résolution race condition step=done
- `entities/report/api.ts` — CORRECTION_EE_TIMEOUT_MS=60s / CORRECTION_EO_TIMEOUT_MS=120s
- `entities/report/types.ts` — CorrectEoPayload étendu audioBase64/mimeType
- MIME normalisé côté frontend (strip codec params)
- router.tsx — 7 nouvelles routes EO sous SimulationFlowLayout
- FTD-30 à 37 tracées dans TECH_DEBT.md
### Notes
- Transcription live Deepgram abandonnée pour le MVP — Gemini batch côté backend
- Audio non stocké côté serveur — bouton télécharger local
- Tests : 122 → 159 verts (+37)
---
## [Unreleased] — 2026-04-25 — Fix timeout API
### Fixed
- `DEFAULT_TIMEOUT_MS` augmenté de 5s à 15s dans `api-client.ts`. Le backend Render Starter a des latences occasionnelles >5s sur les premières requêtes authentifiées.
### Notes
- UptimeRobot configuré pour pinger `https://api.expria.app/` toutes les 5 minutes (keepalive serveur).
---
## [Unreleased] — 2026-04-25 — Sprint UI Polish — Sidebar + Topbar + Dashboard
### Added
- `src/app/Topbar.tsx` — topbar sticky avec backdrop-blur, breadcrumb "Expria {page}", barre de recherche (placeholder), icônes raccourcis clavier et notifications.
- `src/app/route-titles.ts` — mapping centralisé pathname → titre de page, consommé par Topbar.
- `src/features/dashboard/components/NclcHero.tsx` — carte hero NCLC avec jauge horizontale 5→10 + anneau SVG score circulaire. Supporte état placeholder (Free/vide).
- `src/features/dashboard/components/StatCards.tsx` — 3 cartes métriques (simulations restantes, NCLC estimé, dernier score avec delta coloré).
- `src/features/dashboard/components/RecentSimulations.tsx` — liste 3 dernières simulations avec badge NCLC coloré + navigation vers `/rapport/:id`.
- `src/features/dashboard/components/NextStepCard.tsx` — carte "Prochaine étape" recommandée, contenu statique par plan.
- `src/features/dashboard/components/DashboardFreeView.tsx` — vue dashboard Free (hero placeholder, stat cards, premiers pas, PaywallBanner).
- `src/features/dashboard/components/DashboardStandardView.tsx` — vue dashboard Standard (hero NCLC dernière simu, simulations récentes, NextStepCard).
- `src/features/dashboard/components/DashboardPremiumView.tsx` — vue dashboard Premium (tout Standard + MonProfilPreparation).
### Changed
- `src/app/Sidebar.tsx` — icônes lucide-react (LayoutGrid, Pencil, Mic, etc.), cadenas Lock sur items verrouillés, badge upgrade ArrowUpCircle sur "Mon plan", user footer (avatar initiales + nom + plan + ThemeToggle), logo header "EX|PRIA" avec séparateur et sous-titre.
- `src/app/AppLayout.tsx` — intégration Topbar sticky, padding reporté sur wrapper contenu.
- `src/features/dashboard/pages/DashboardPage.tsx` — refactoré en orchestrateur : routing vers Free/Standard/Premium via `hasAccess`. Aucun `plan === 'xxx'` (Règle D).
- `src/features/dashboard/components/PaywallBanner.tsx` — refonte full-width + correction tokens morts Boréal (`border-brand-100`, `dark:border-brand/20`).
### Removed
- `src/app/MobileHeader.tsx` — fonctionnalité reprise par Topbar + Sidebar (0 consommateur confirmé par grep).
### Notes
- Tests : 122/122 verts. Typecheck : 0 erreur.
- Contenu NextStepCard statique par plan (pas d'endpoint backend dédié).
- Hero NCLC : Premium → usePatterns, Standard → NCLC dernière simulation, Free → état placeholder.
- Timeout API intermittent (cold start Render) préexistant — cause le fallback temporaire plan=free au chargement initial.
---
## [Unreleased] — 2026-04-24 — Sprint DA Charcoal — Reskin complet
### Changed
- Remplacement intégral `src/index.css` par palette Charcoal (DESIGN_SYSTEM.md v2.0). Dark = thème par défaut, `.light` = override via `@custom-variant light`.
- Sidebar navy `#0C1528` permanent (identique dark et light) avec tokens `--color-sidebar-*`.
- Layout `AppLayout` : radial-gradient sur `<main>`, sidebar 230px, `max-w-[1100px]`.
- Script anti-FOUC inline dans `index.html` (détection `prefers-color-scheme` + `localStorage`).
- Renommage tokens Boréal→Charcoal sur ~45 composants (ink-1→ink-primary, expria→brand, line→border, deep→sidebar-bg, \*-bg→\*-soft, etc.).
- Inversion `dark:` → baseline + `light:` sur 5 primitives shadcn (button, badge, input, dialog, avatar).
- `DesignSystemPage` réécrite avec palette Charcoal complète.
- `docs/adr/006-stack-versions-2026.md` mis à jour : tokens Charcoal, suppression `@variant dark` et `.dark {}`.
### Fixed
- Logo Expria : wordmark forcé en `text-white` dans la sidebar (invisible en light mode sur fond navy).
### Notes
- 59 fichiers modifiés, +1173/-727 lignes.
- Tests : 122/122 verts. Typecheck : 0 erreur.
- Timeout API intermittent observé (cold start Render) — préexistant, non lié au reskin.
---
## [Unreleased] — 2026-04-23 — Clean FTD-23 + FTD-24
### Fixed
- **FTD-23 résolu** : `useAutosave` ne fire plus après correction — `enabled` propagé avec `step !== 'done' && step !== 'correcting'` depuis `SimulationForm`. 2 tests de régression ajoutés.
- **FTD-24 résolu** : polling automatique 3s dans `useRapport` quand `exercices_status` ou `modele_status === 'pending'`. Arrêt auto dès ready/error. Timeout 2 min avec message + bouton Réessayer dans `JobStatusFallback`. 5 tests ajoutés.
### Notes
- Tests frontend : 122/122 verts (+7 vs baseline 115).
- TECH_DEBT.md → v1.19. 10 FTD actives (cap 15).
---
## [Unreleased] — 2026-04-23 — FTD-28 — Semgrep CI + CI verte
### Added
- Semgrep scan (`--severity=ERROR`) dans les CI frontend et backend (FTD-28).
- Variables d'env factices dans CI frontend pour les tests.
### Fixed
- 4 erreurs ESLint corrigées : split SimulationFlowProvider (react-refresh), hook conditionnel MonProfilPreparation, ref render useTimer, setState effect AppLayout.
- Prettier format sur 7 fichiers.
- CI frontend verte pour la première fois depuis le 18 avril.
---
## [Unreleased] — 2026-04-23 — FTD-27 — CI backend
### Added
- `expria-backend/.github/workflows/ci.yml` — CI GitHub Actions (test + audit, Node 22). CI verte au premier run.
- FTD-27 fermée dans TECH_DEBT.md (v1.17).
---
## [Unreleased] — 2026-04-23 — FTD-29 — Dependabot config
### Added
- `.github/dependabot.yml` créé dans les 2 dépôts (npm, weekly, limit 10 PRs).
- FTD-29 fermée dans TECH_DEBT.md (v1.16).
---
## [Unreleased] — 2026-04-23 — Réorg sécurité TECH_DEBT v1.15
### Changed
- `TECH_DEBT.md` v1.14 → v1.15 — réorganisation sécurité.
- Gelées (backlog post-MVP) : FTD-06 (AudioWorklet), FTD-08 (Tests E2E), FTD-15 (option 'system' thème).
- Ajoutées : FTD-27 🔴 (CI backend), FTD-28 🔴 (Semgrep CI), FTD-29 🟡 (Dependabot config).
- GitHub : Dependabot alerts + security updates activés sur les deux dépôts (UI GitHub).
---
## [Unreleased] — 2026-04-23 — Triage FTD v1.14
### Changed
- `TECH_DEBT.md` v1.13 → v1.14 — triage dette technique : 17 → 15 FTD actives (cap respecté).
- Fermées : FTD-04 (miroir docs, accepté ADR 004), FTD-05 (scaffold caduc, audit clean), FTD-20 (GET /simulations/:id livré Sprint 3.6a), FTD-22 (code orphelin /sujets, résolution complète).
- Ajoutées : FTD-25 🟢 (ARCHITECTURE.md §3 désaligné), FTD-26 🟡 (cohabitation shared/ui vs shared/components/ui).
---
## [Unreleased]
### Added
- Documentation initiale du projet (ARCHITECTURE, ONBOARDING, SECURITY, etc.)
- 5 ADRs pour les décisions architecturales majeures
- Code source de `src/entities/user/access.ts` et `lib.ts` avec tests
## [Unreleased] — 2026-04-22 — Sprint 3.5 — Clean post-Sprint 3
### Changed
- **FTD-17 résolu** : `PLAN_QUERY_KEY` centralisé dans `src/entities/user/query-keys.ts` (constantes pures, aucun import runtime). `usePlan` le ré-exporte ; `SimulationPage` et `RapportPage` remplacent leur `useQuery` inline par le hook `usePlan()` — déduplication totale de la clé et de la config `staleTime`.
- **FTD-18 résolu** : `SimulationForm` migré de `@/shared/components/ui/button` (shadcn) vers la primitive canonique `@/shared/ui/Button`. Aucun variant à adapter (usage sans prop `variant`).
- **FTD-19 résolu** : token `--shadow-focus` ajouté dans `@theme {}` (`0 0 0 3px rgba(27, 79, 216, 0.18)` — conforme `DESIGN_SYSTEM.md §2`) et dans `.dark {}` (recalculé sur la teinte expria dark). Migration de 5 occurrences `ring-2 ring-expria/20` → utility `shadow-focus` dans `Button`, `Card`, `SimulationForm` (×3), `SpecialCharsKeyboard`.
- Factorisation `SimulationForm` : className dupliquée des deux boutons secondaires (« Suggestions d'idées » / « Changer de sujet ») extraite en const locale `secondaryActionBtn`.
- `TECH_DEBT.md` → v1.11. 15 FTD actives (cap de 15 respecté).
### Notes
- Timeouts DeepSeek intermittents observés pendant les tests manuels Groupe B + C — cause externe (API tierce), hors périmètre refactor Sprint 3.5.
- B8 : comportement actuel diffère du spec `PARCOURS_UTILISATEURS.md §2 "Quota atteint"` — affichage d'une bannière inline au lieu du modal de blocage attendu. À corriger dans un sprint dédié (non inclus dans ce clean, qui n'introduit aucune nouvelle fonctionnalité).
## [Unreleased] — 2026-04-22 — Sprint 3.6c — Analyse patterns (Backend + Frontend)
### Added (backend)
- `GET /users/patterns` — analyse des patterns récurrents pour utilisateur Premium.
- Auth : `authMiddleware` + `planMiddleware('pattern_analysis')` (403 `PLAN_INSUFFICIENT` si Free/Standard).
- < 5 productions corrigées `200 { ready: false, minimum: 5, current: N }`.
- ≥ 5 → `200 { ready: true, patterns, exercises, preparation_index, analyzed_productions, last_analysis }`.
- `patternsController.aggregatePatterns` (pure) : agrège les `erreurs_codes` sur N productions, seuil 3/5, dédoublonnage intra-prod (un même code dans un rapport ne compte qu'une fois), codes `autre` distingués par description, tri par fréquence DESC.
- `patternsController.computePreparationIndex` (pure) : 60 % score moyen normalisé + 20 % régularité (médiane des intervalles entre prod) + 20 % tendance (pente linéaire sur les 5 scores). Clamp `[0, 100]`, messages figés selon les seuils `<40` / `40-70` / `>70`.
- `patternsController.list` — orchestre fetch productions + cache `pattern_analyses` + recompute + DeepSeek + INSERT. Stratégie d'invalidation : `MAX(productions.created_at) > lastAnalysis.created_at` → recompute, sinon cache hit.
- `generatePatternExercices` dans `src/lib/deepseek.ts` — prompt système validé par Hermann avec format `{ consigne, exemple, correction, astuce }`, température 0.4, `AbortSignal.timeout(20_000)`, validation runtime des critères via `isValidCritere`.
- Table `pattern_analyses` — migration `005_sprint_3_6c_pattern_analyses.sql` : UUID PK + FK cascade user_id + `productions_ids UUID[]` + patterns/exercises JSONB + preparation_index (CHECK `[0, 100]`) + preparation_message + analyzed_count + RLS SELECT par user_id + index `(user_id, created_at DESC)`.
- 19 nouveaux tests (`patternsController.test.ts`) : 7 sur `aggregatePatterns`, 4 sur `computePreparationIndex`, 8 sur route (401, 403 free/standard, <5 prod, cache hit, cache miss + insert, no patterns, DeepSeek fail gracieux). **205 tests backend verts** (+19 vs baseline 186).
### Added (frontend)
- Page `/progression` — route sous `AppLayout` + `ProtectedRoute`, remplace le placeholder `ComingSoon`.
- `ProgressionPage` — orchestre `usePlan` + `usePatterns`, gate plan via `hasAccess('pattern_analysis')`.
- `ProgressionPremium` — orchestrateur : si not-ready → `NotReadyState` ; sinon Hero indice + patterns + exercices long terme + footer « Analyse basée sur vos N dernières productions — il y a X ».
- `PreparationIndexHero` — score /100 + jauge horizontale colorée (rouge <40 / ambre 40-70 / vert >70) + message.
- `PatternsList` — liste des patterns avec libellé via nouveau `CRITERE_LABELS` + badge fréquence (3/5, 4/5, 5/5).
- **`PatternExerciceCard`** — _nouveau composant lesson-style_, non interactif (contrairement à `ExerciceInteractive` du rapport individuel) : critère + diagnostic + consigne + bloc incorrect (barré rouge) côte à côte avec bloc correct (vert) + **encart astuce proéminent** (icône ampoule + fond warning).
- `NotReadyState` — barre de progression N/5 + CTA `Démarrer une simulation`.
- `BlurredProgression` — aperçu flouté pour Free/Standard + bouton upgrade Premium.
- Section Dashboard Premium `MonProfilPreparation` — MetricCard indice (score + jauge compacte + message) + nombre d'erreurs récurrentes + CTA « Voir mon profil de préparation » vers `/progression`. Garde explicite `hasAccess('pattern_analysis')` → composant retourne `null` pour Free/Standard (pas rendu dans le DOM).
- `usePatterns(plan)` — hook TanStack Query partagé entre `/progression` et dashboard ; clé `['users', 'patterns']`, `staleTime: 60s`, `enabled` conditionné par `hasAccess` pour éviter un 403 parasite.
- `entities/patterns/types.ts` + `entities/patterns/api.ts` — types miroirs du backend (`Pattern`, `PatternExercice`, `PreparationIndex`, `PatternsReady`, `PatternsNotReady`) + `getPatterns()` avec timeout 25 s.
- `CRITERE_LABELS` exporté depuis `entities/report/lib.ts` — miroir du backend pour affichage du libellé humain à partir du code taxonomie.
- 13 nouveaux tests : 6 sur `ProgressionPremium` (not-ready, ready avec indice/patterns/exercices, footer, 0 pattern) + 7 sur `MonProfilPreparation` (gating Free/Standard, Premium ready/not-ready, loading, error, 0 pattern). **115 tests frontend verts** (+13 vs baseline 102).
### Notes
- **Formule indice** arbitraire (60/20/20) — à affiner après observation prod si besoin.
- **Dégradation gracieuse DeepSeek** : si `generatePatternExercices` throw, le backend persiste quand même l'analyse avec `exercises: []` et logue l'erreur. Le frontend affiche alors la liste des patterns sans section exercices (pas de message d'erreur explicite côté UI — l'utilisateur ne sait pas qu'il manque quelque chose).
- **`ExerciceInteractive` NON réutilisé** pour les exercices long terme : les shapes et UX sont différents (lesson vs tentative). Deux composants distincts cohabitent.
- **Migration SQL à exécuter manuellement** : `cd expria-backend && supabase db push` avant les tests end-to-end Premium.
## [Unreleased] — 2026-04-22 — Sprint 3.7 — Historique (Backend + Frontend)
### Added (backend)
- `GET /simulations` — liste paginée des productions de l'utilisateur connecté.
- Query params : `page` (défaut 1, entier ≥ 1), `limit` (défaut 20, entier entre 1 et 50).
- Tri : `created_at DESC` côté Supabase.
- Filtre : `user_id = profile.id` (double-protection avec RLS).
- Projection : `id, tache, mode, score, nclc, nclc_cible, created_at` — champs lourds (`contenu`, `rapport`, `exercices`, `modele`) **exclus**.
- Réponse : `{ data: ListItem[], pagination: { page, limit, total } }`.
- Erreurs : `400 VALIDATION_ERROR` si `page`/`limit` invalide, `401 AUTH_REQUIRED` si JWT absent, `500 INTERNAL_ERROR` si DB down.
- `simulationController.list(options, profile)` + interfaces `ListOptions`, `ListItem`, `ListResult`.
- 12 nouveaux tests sur la route `GET /simulations` (186 tests backend verts, +12 vs baseline 174).
### Added (frontend)
- Page `/historique` (route sous `AppLayout` + `ProtectedRoute`, remplace le placeholder `ComingSoon`).
- `HistoriquePage` — orchestre `usePlan` + `useSimulationsList`, state local de pagination, gating plan Free via `hasAccess('dashboard')`.
- `SimulationsList` — composant liste avec :
- Empty state + CTA « Démarrer une simulation » → `/simulation/ee`
- Loading skeleton (5 barres animées)
- Error state (callout discret `border-l-danger`)
- Aperçu flouté Free + bouton `variant="upgrade"` « Passer en Standard »
- Pagination Précédent / Suivant (masquée si une seule page)
- Affichage « Page X sur Y — Z simulations »
- `SimulationListItem` — carte item : date relative, libellé de tâche (`formatTache`), score `/20`, `NCLC atteint / cible`, badges « Examen » et « En cours » (rapport non prêt). Clic → `/rapport/:id`.
- `useSimulationsList(page, limit)` — hook TanStack Query, clé `['simulations', 'list', page, limit]`, `staleTime: 30s`, `placeholderData: keepPreviousData` pour éviter le flash de squelette au changement de page.
- `listSimulations(page, limit)` dans `entities/production/api.ts` — wrap `apiFetch` + `URLSearchParams`.
- Types `SimulationListItem` et `SimulationsListResponse` dans `entities/production/types.ts`.
- `src/shared/lib/date.ts` — helper `formatRelativeDate(iso, now?)` basé sur `Intl.RelativeTimeFormat('fr', { numeric: 'auto' })`. Seuils : secondes → minutes → heures → jours → semaines → mois → années. Zéro dépendance.
- 18 nouveaux tests frontend (7 `date.test.ts` + 11 `SimulationsList.test.tsx`).
### Notes
- Les simulations avec `score === null` (en cours ou correction échouée) sont **affichées** avec un badge « En cours ». Clic → `/rapport/:id``RapportPage` gère le cas `REPORT_NOT_READY` (FTD-21) en redirigeant vers `/simulation/ee`.
- `BlurredPreview` dupliqué localement dans `SimulationsList` (pattern équivalent à `BlurredSection` de `RapportPage`). À extraire en `shared/` si le pattern se répète dans un 3ᵉ endroit — pas fait dans ce sprint.
- Pagination : Précédent/Suivant (MVP) retenu contre scroll infini. Le choix sera revu si l'historique dépasse 100 items en prod.
- Tests frontend : **102/102 verts** (+18 vs baseline 84).
## [Unreleased] — 2026-04-22 — Sprint 3.6b — Qualité correction — Frontend
### Added
- `NclcCibleSelector` (segmented control NCLC 9 / NCLC 10) dans `SimulationForm` — valeur propagée au payload `POST /corrections/ee` via `SimulationFlowProvider.submitText(texte, nclcCible)`.
- Composants `rapport/` dans `features/simulations/components/` :
- `ScoreHero` — score /20, jauge avec marqueur du seuil NCLC cible, écart vs objectif (« X points avant NCLC 9 »), badges NCLC atteint / cible.
- `RevelationCards` — 3 colonnes : ce que le candidat croit / ce que le correcteur observe / conséquence.
- `DiagnosticCallout` — callout « Ce qui freine votre progression ».
- `CritereCard` — carte enrichie par critère (exemple / suggestion / astuce + badges codes taxonomie).
- `ConseilNclcCallout` — plan d'action NCLC (objectif, écart, action prioritaire).
- `ExerciceInteractive` — carte exercice avec zone texte, bouton Indice (révélé une fois), bouton « Voir la correction » (activé après saisie), explication.
- `ProductionModeleSection` — texte final + notes pédagogiques + transformations original/amélioré + message encourageant.
- `JobStatusFallback` — fallback pour `exercices_status` / `modele_status` en `'pending'` ou `'error'`.
- Helpers dans `entities/report/lib.ts` : `groupErreursByCritere`, `ecartVsCible`, `critereCodeFromNom`.
- Tests `ExerciceInteractive.test.tsx` (6 tests) — couvre état interne : révélation unique indice, activation bouton correction, affichage correction + explication.
- FTD-24 🟡 dans `TECH_DEBT.md` — polling automatique pour exercices/modèle `pending` (refresh manuel en MVP).
### Changed
- `entities/report/types.ts` — refonte complète alignée sur le backend Sprint 3.6a : `Report` remplace l'ancien (revelation, diagnostic, criteres enrichis, conseil_nclc, erreurs_codes top-level, exercices dynamiques, modele structuré, statuts pending/ready/error). Suppression de `feedback_court`, `erreurs[]`, `modele:string`, `idees[]` (obsolètes).
- `entities/report/lib.ts``BlurableSection` réduite à `'criteres' | 'exercices' | 'modele'` : `revelation`, `diagnostic`, `conseil_nclc` deviennent visibles pour tous les plans conformément à PLANS_TARIFAIRES.md §2.
- `entities/production/types.ts``SimulationState` étendu avec `nclc_cible`, `exercices`, `exercices_status`, `modele`, `modele_status` ; `SimulationRapport` aligné sur `CorrectionRapport` backend.
- `entities/report/api.ts``getReport` recombine `SimulationState.rapport` + `exercices` + `modele` + statuts en un `Report` unifié pour `useRapport`.
- `RapportPage.tsx` — réécriture complète : câble tous les nouveaux composants, branche le gating plan via `isSectionVisible`, affiche `JobStatusFallback` pour les jobs asynchrones. Résout l'écran blanc post-Sprint 3.6a.
- `floutage.test.ts` réécrit (17 tests — matrice de visibilité + helpers lib).
### Fixed
- **Race condition `modele_status`** (backend) : l'update principal de correction écrasait `modele_status='ready'` déjà posé par `runModeleJob` (lancé en parallèle option b). `correctionController.correctEE` ne touche plus aux colonnes `*_status` — pilotées exclusivement par les jobs asynchrones.
- **Boucle infinie retour rapport → SimulationPage** : le useEffect sticky `step === 'done' → navigate('/rapport/:id')` renvoyait l'utilisateur sur le rapport à chaque tentative de retour vers `/simulation/ee`. Supprimé ; la navigation initiale vers `/rapport/:id` est déclenchée une seule fois dans `correctMutation.onSuccess` du provider.
- **Boucle retour /sujets → SimulationPage** : même pattern sticky pour `step === 'choosing-subject' → navigate('/sujets')`. Supprimé ; navigation initiale vers `/sujets` déplacée dans `createMutation.onSuccess`.
- **RapportPage hors SimulationFlowProvider** : la route `/rapport/:id` n'était pas sous `SimulationFlowLayout` — l'appel à `useSimulation()` depuis RapportPage throw. Route déplacée sous le layout, l'instance du provider est partagée avec `/simulation/ee` et `/sujets`.
### Added
- Bouton « Nouvelle simulation » en bas de `RapportPage` qui `reset()` + `navigate('/simulation/ee')`.
- `reset()` explicite dans le bouton « ← Retour » de `SujetsPage` avant la navigation, pour empêcher tout re-déclenchement de la garde sticky.
### Changed
- Navigations post-mutation déplacées dans `onSuccess` du provider (pattern cohérent pour `createMutation``/sujets` et `correctMutation``/rapport/:id`). Plus de useEffect réactif aux changements de `step` côté SimulationPage.
- `SujetsPage` : garde étendue de `!production` à `!production \|\| step === 'idle' \|\| step === 'done'` pour couvrir le cas post-rapport (évite le 400 VALIDATION_ERROR sur `PATCH /simulations/:id/sujet` d'une simulation déjà corrigée).
- `RapportPage` breadcrumb : `<Link>` remplacé par `<button>` qui `reset()` avant navigate.
### Notes
- **Option β retenue** : frontend aligné sur la structure backend réelle du Sprint 3.6a. Aucun aller-retour backend.
- `feedback_court` supprimé de l'UI ; `diagnostic` remplace la section « Retour général ».
- Polling automatique non implémenté (FTD-24) : refresh manuel de la page si `exercices_status` / `modele_status` = `'pending'`.
- Tests : **84/84 verts** (+8 vs baseline 76).
## [Unreleased] — 2026-04-22 — Sprint 3.6a — Qualité correction — Backend
### Added (backend)
- `src/lib/taxonomieErreurs.ts` : constantes des 63 codes TCF Canada + 4 codes `autre` par critère, validation runtime `isValidCode` / `isValidCritere`, et injection au prompt via `buildTaxonomyPromptSection`.
- Prompts dynamiques dans `src/lib/deepseek.ts` : `buildCorrectionPrompt` (prompt maître avec `nclc_cible` 9 ou 10, sujet, documents T3), `buildModelPrompt` (production modèle cible NCLC 9 fixe), `buildExercicesPrompt` (3 exercices ciblés sur `erreurs_codes` + extraits `exemple`, format `{difficulte, theme, diagnostic, consigne, extrait, indice, correction, explication}`).
- Post-traitement production modèle : `wordCountTCF`, `stripModelAnnotations`, `truncateToMaxWords`.
- Route `POST /corrections/ee` accepte le paramètre `nclc_cible` (optionnel, défaut 9, valeurs acceptées : 9 ou 10 ; sinon 400 VALIDATION_ERROR).
- Migration SQL `supabase/migrations/004_sprint_3_6a_qualite_correction.sql` — colonnes : `revelation`, `diagnostic`, `conseil_nclc`, `erreurs_codes`, `exercices`, `modele`, `nclc_cible`, `exercices_status`, `modele_status` + index GIN sur `erreurs_codes` (pour Sprint 3.6c).
- `controllers/__tests__/correctionController.test.ts` (7 tests) : parallélisme, statuts ready/error, `nclc_cible=10` propagé, simulation introuvable/autre user.
- `docs/TECH_DEBT.md` TD-15 🟡 : jobs fire-and-forget peuvent rester `pending` si redémarrage process.
### Changed (backend)
- `correctEE` dans `deepseek.ts` — nouvelle signature `correctEE(CorrectionInput)` + nouvelle forme `CorrectionRapport` (revelation, diagnostic, criteres[{exemple,suggestion,astuce}], conseil_nclc, erreurs_codes). `EERapport` devient alias de `CorrectionRapport`.
- `correctionController.correctEE` : lance 3 appels DeepSeek en parallèle ; await uniquement sur la correction pour répondre 200 ; modèle et exercices s'exécutent en fire-and-forget et mettent à jour `{exercices,exercices_status}` et `{modele,modele_status}` en base (pending → ready/error).
- `simulationController.getById` retourne les nouveaux champs : `nclc_cible`, `exercices`, `exercices_status`, `modele`, `modele_status` en plus du `rapport` enrichi.
- `deepseek.test.ts` réécrit — 25 tests (ancien pipeline supprimé, nouveaux tests sur correctEE/generateProductionModele/generateExercices/helpers + EO inchangé).
### Notes
- **Option A retenue** : backend renvoie uniquement la nouvelle forme. Frontend (Sprint 3.6b) casse tant que non livré — livraison groupée sans déploiement intermédiaire.
- Prompt exercices rédigé côté backend (option b), basé sur les codes taxonomie + extraits `exemple` des critères. Format aligné sur captures d'écran demandées.
- Migration SQL à exécuter manuellement via `supabase db push` — Hermann avant le premier test end-to-end.
- Tests backend : 173/173 verts (+18 vs baseline de 155).
## [Unreleased] — 2026-04-22 — Planification Sprint 3.6a/3.6b/3.6c
### Added
- Sprints 3.6a (backend prompts + taxonomie), 3.6b (frontend rapport enrichi), 3.6c (analyse patterns Premium) ajoutés à la ROADMAP entre Sprint 3.5 et Sprint 4.
- `TAXONOMIE_ERREURS.md` — 63 codes d'erreurs TCF Canada sur 4 critères + 4 codes « autre » + procédure d'enrichissement.
## 2026-04-21 — FTD-21 — Persistance session `/simulation/ee`
### Added
- `useAutosave(simulationId, contenu, enabled)` : autosave debounce 30 s + flush sur `beforeunload`, dedup par dernier contenu sauvegardé (6 tests).
- `SimulationFlowProvider` hydrate la session au montage depuis `localStorage` (`expria_simulation_id`) → `GET /simulations/:id` → restaure `step='task-selected'` + `production` + `sujet` si `rapport=null` ; nettoie la clé sinon (3 tests resume).
- Types `SimulationState`, `SimulationRapport` + API `getSimulationState`, `autosaveContenu`, `updateSujet` dans `entities/production`.
- Indicateur "Sauvegardé à HH:MM" sous la textarea `SimulationForm` (text-xs, `aria-live="polite"`).
### Changed
- `getReport` délègue désormais à `getSimulationState` et lève `REPORT_NOT_READY` si `rapport=null`. `RapportPage` catche cette erreur et redirige vers `/simulation/ee` avec message discret "Votre simulation est en cours.".
- `SimulationForm` accepte `simulationId`, `initialContenu`, `step` et persiste `expria_simulation_id` dans `localStorage` tant que la simulation est active ; nettoie la clé quand `step='done'`.
- `changeSubject` persiste le changement côté backend via `PATCH /simulations/:id/sujet` (best-effort, silencieux si échec).
### Security
- localStorage ne stocke que `simulation_id` (UUID non-sensible) — conforme SECURITY.md §2.6.
### Notes
- FTD-21 reste ouvert pour `/simulation/eo` (Sprint 4) et `/examen` (Sprint 7).
---
## 2026-04-21 — Tâche G5 — Suggestions d'idées DeepSeek
### Ajouté
- **Backend**`POST /sujets/idees` : génère 5 suggestions
d'idées via DeepSeek pour aider l'étudiant à prolonger sa
rédaction (prompt coach TCF Canada, temperature 0.5,
timeout 15 s via AbortSignal, JSON strict
`{ idees: string[] }`)
- `generateIdees(consigne, contenu)` dans `src/lib/deepseek.ts`
(validation tableau non vide)
- 5 tests route `POST /sujets/idees` : 401 sans auth,
400 sujet_consigne manquant, 400 contenu < 30 mots,
200 succès avec idees[], 500 DeepSeek throw
- **Frontend**`getIdees(consigne, contenu)` dans
`entities/report/api.ts` (POST `/sujets/idees`,
timeoutMs 15 000)
- Hook `useIdees``useMutation` exposant
`{ idees, isLoading, error, fetchIdees, reset }`
- Composant `IdeesSuggestions` — modal shadcn Dialog avec
liste à puces, états loading/erreur/succès,
`reset()` automatique à la fermeture
- Bouton "Suggestions d'idées" (icône Lightbulb) dans
`SimulationForm` à côté de "Changer de sujet"
- Prop `plan: Plan` ajouté à `SimulationForm` (wiring
`planData.plan` depuis `SimulationPage`)
### Règles d'accès
- Règle D respectée : `hasAccess(plan, 'tips')` obligatoire
- Plan Free : bouton visible mais désactivé avec tooltip
"Disponible en Standard" (tips=false pour Free)
- Standard + Premium : bouton actif dès 30 mots écrits
- Désactivé également si `!sujet`, `isSubmitting`, ou
`idees.isLoading`
### Tests
- Backend — Typecheck : 0 erreur, Vitest : 144/144 passés
(+5 tests POST /sujets/idees)
- Frontend — Typecheck : 0 erreur, Vitest : 67/67 passés
- Test manuel : validé avec compte Standard (bouton actif
à 30+ mots, modal affiche 5 idées) et Free (bouton
verrouillé avec tooltip)
## 2026-04-21 — Tâche G4 + Refonte page /sujets + Fix quota simulations
### Ajouté
- **Tâche G4** — choix du sujet avec dropdown intégré et bouton
aléatoire dans SimulationForm (hook `useSujets`, composant
`SujetSelector`, `getSujets()` sur `GET /sujets?mode=&tache=`)
- **Refonte UX `/sujets`** (Option A) — page dédiée avec grille
de cartes `SujetCard` (responsive 1/2/3 colonnes), état partagé
via `SimulationFlowProvider` pour survivre aux navigations entre
`/simulation/ee` et `/sujets`. MVP : refresh sur `/sujets`
redirige vers `/simulation/ee`.
- Bouton "Changer de sujet" dans `SimulationForm` — retour à
`/sujets` via `goToSubjectPicker`
- Prop `type: 'EE' | 'EO'` sur `TaskSelector` (EO_CARDS réservé
usage futur — non routé, `/simulation/eo` reste `ComingSoon`
jusqu'au Sprint EO)
### Modifié
- `useSimulation` refacto en consommateur de
`SimulationFlowProvider` (source de vérité déplacée hors du hook)
- `SujetDisplay` redevient présentationnel (dropdown retiré)
- `TaskSelector` : retrait des cartes EO de la page
Expression Écrite (affiche uniquement EE T1/T2/T3)
### Corrigé
- **Quota simulations (backend — commit `ecb478e`, expria-backend)** :
incrément `simulations_used` déplacé de
`simulationController.create()` vers `correctionController.correctEE/EO`
(Option B). Une simulation créée mais jamais corrigée ne consomme
plus le quota utilisateur.
### Supprimé
- `SujetSelector.tsx` — orphelin après refonte `/sujets`
- Helper `selectSujet` de `useSimulation` — orphelin
- FTD-22 tracée résolue partiellement (step `'choosing-subject'`
- `goToSubjectPicker` conservés intentionnellement)
### Tests
- Typecheck : 0 erreur
- Vitest : 67/67 passés
- Test manuel : flux complet EE T1 avec choix de sujet
(carte + aléatoire + changement de sujet) validé
## 2026-04-21 — Tâches G2+G3 — Clavier + Minuteur
### Ajouté
- Composant SpecialCharsKeyboard — 30 caractères spéciaux
français en flex-wrap, sticky au scroll
- Bloc "Temps restant" sticky avec TimerDisplay MM:SS
(critique < 2min : rouge + pulse, expiré : rouge bold)
- Composant WordCountBar — barre de progression colorée
(orange < cible, vert dans cible, rouge > cible)
- Hook useTimer avec 7 tests unitaires
- Config par tâche dans simulationConfig.ts
(EE T1: 10min/60-120 mots, T2: 20min/120-150,
T3: 30min/120-180)
- Auto-submit à l'expiration si ≥ 30 mots
- Bouton "Soumettre ma production" (était "Envoyer")
- Textarea auto-resize sans scroll interne
### Changed
- Compteur de caractères remplacé par WordCountBar
- Bouton soumission bloqué si < 30 mots
### Tests
- Typecheck : 0 erreur
- Vitest : 66/66 passés (+7 tests useTimer)
- Test manuel : minuteur + clavier validés sur mobile
et desktop
## 2026-04-21 — Tâche G1 — Affichage de la consigne
### Ajouté
- Interface SujetData dans entities/production/types.ts
- Production enrichie avec sujet: SujetData | null
- Composant SujetDisplay — affiche consigne, rôle, contexte, doc1, doc2 selon le sujet retourné
- useSimulation expose sujet dans son retour
- SimulationForm intègre SujetDisplay au-dessus de la textarea
- FTD-21 tracée (persistance session simulation)
### Tests
- Typecheck : 0 erreur
- Vitest : 59/59 passés
- Test manuel : consigne affichée sur /simulation/ee
## 2026-04-20 — Audit frontend ↔ backend — alignement types Report
### Modifié
- `src/entities/report/types.ts``Critere.note``Critere.score`, `Report.exercices: Exercice[]``Report.exercices: string[]`, JSDoc ajusté
- `src/features/simulations/pages/RapportPage.tsx` — import `Exercice` retiré, `critere.note``critere.score`, `ExerciceCard` refactoré pour consommer une `string` rendue en Markdown, clé d'itération par index
### Supprimé
- Interface `Exercice { titre, contenu }` de `entities/report/types.ts` — remplacée par `string[]` pour coller au contrat backend
### Contexte (backend associé, expria-backend)
Quatre commits côté backend finalisent l'alignement du contrat `Report` :
- `feat(corrections)`: renommages `production_modele``modele`, `suggestions_idees``idees`, ajout `feedback_court` + prompts DeepSeek mis à jour + validations runtime
- `feat(corrections)`: réponse enrichie avec `simulation_id` côté `correctionController`
- `feat(simulations)`: nouvelle route `GET /simulations/:id` (auth owner, gestion `SIMULATION_NOT_FOUND`/`AUTH_REQUIRED`/`REPORT_NOT_READY`) + 4 tests
- `feat(simulations)`: sujet aléatoire (table `sujets`) retourné avec chaque production créée (EO_T2_LIVE exclu, non bloquant si aucun sujet actif)
### Tests
- Typecheck : 0 erreur
- Vitest : 59/59 passés
### À faire (hors scope — session frontend dédiée ultérieurement)
- Ajouter `sujet: SujetData | null` dans `entities/production/types.ts`
- Consommer le sujet retourné dans `SimulationPage` (affichage consigne + docs)
- Consommer `feedback_court` dans `RapportPage` (rendu toujours visible — cf. PLANS_TARIFAIRES §2 — déjà supporté par le type `Report`, reste à brancher dans l'UI si ce n'est pas déjà le cas)
## 2026-04-20 — Sprint 0.5 bis — AppLayout + primitives UI + refonte visuelle
### Ajouté
- `src/app/AppLayout.tsx` — layout applicatif desktop/mobile (sidebar fixe 240px, drawer mobile, BottomNav)
- `src/app/Sidebar.tsx` — navigation latérale avec verrouillage `hasAccess()` (Progression, Examen blanc, Historique)
- `src/app/MobileHeader.tsx` — header mobile sticky (Logo, ThemeToggle, bouton menu hamburger)
- `src/app/BottomNav.tsx` — navigation mobile fixe (4 items, bottom sheet "Simuler", tap target min 44px)
- `src/shared/ui/Button.tsx` — primitive Button (variants: primary/secondary/ghost/upgrade ; sizes: sm/md/lg ; loading Loader2)
- `src/shared/ui/Card.tsx` — primitive Card (variants: default/raised/interactive ; rendu `<button>` si `onClick` fourni)
- `src/shared/ui/Badge.tsx` — primitive Badge (variants: plan/nclc/neutral ; couleur selon `planValue` pour variant plan)
### Modifié
- `src/app/router.tsx` — layout routes via `PrivateLayout` (`ProtectedRoute` + `AppLayout` + `Outlet`) ; `ComingSoon` inline ; redirect `/simulation``/simulation/ee`
- `src/features/simulations/components/TaskSelector.tsx` — refonte avec `Card interactive` / `Card default opacity-60`, `Badge` "EE"/"EO", eyebrow `tracking-widest`, icône verrou
- `src/features/simulations/pages/SimulationPage.tsx` — suppression header interne (Logo + ThemeToggle) ; root `<main>` ; `Button` migré vers `@/shared/ui/Button` `variant="secondary"`
- `src/features/dashboard/pages/DashboardPage.tsx` — suppression header interne ; `Button` `variant="primary"` avec `navigate('/simulation/ee')` ; `Badge` `variant="plan" planValue={data.plan}` ; tout migré vers `@/shared/ui/`
### Documentation
- `docs/TECH_DEBT.md` v1.6 — ajout FTD-18 (SimulationForm migration Button), FTD-19 (token `--shadow-focus` manquant)
### Tests
- Typecheck : 0 erreur
- Vitest : 59/59 passés
- Tests manuels : à valider par Hermann
---
## 2026-04-19 — Sprint 1 / Étape 6 — Maintenance mode + outillage sécurité
### Ajouté
- Page de maintenance statique (`src/app/MaintenancePage.tsx`) — logo + message, tokens Direction H, zéro dépendance
- Guard `VITE_MAINTENANCE_MODE` dans `main.tsx` — si `true`, aucun provider ne se monte, aucun appel réseau
- Variable `VITE_MAINTENANCE_MODE` dans `env.ts` (optionnelle, défaut `false`)
- Hook PreToolUse Claude Code (`security-check.sh`) — 9 patterns SECURITY.md §2
- Hook Stop Claude Code (`check-file-size.sh`) — alerte fichiers > 200 lignes
- MCP server Semgrep enregistré dans Claude Code
### Documentation
- `ARCHITECTURE.md` §7 — ajout `VITE_MAINTENANCE_MODE` dans la liste des variables
- `TECH_DEBT.md` — FTD-16 résolu (maintenance mode implémenté)
### Tests
- Typecheck : 0 erreur
- Vitest : 37/37 passés
- Test manuel : maintenance mode vérifié (page affichée, aucun appel réseau, routing bloqué)

148
docs/DEPLOYMENT.md Normal file
View file

@ -0,0 +1,148 @@
# DEPLOYMENT.md — Expria V2
> Version 1.0 — Rédigé avant lancement
> Procédure officielle de bascule V1 → V2 sur expria.app
> À lire intégralement avant toute action de déploiement.
---
## 1. Architecture cible
| Composant | V1 (actuel) | V2 (cible) |
|---|---|---|
| Frontend | Next.js sur Render | React/Vite sur Cloudflare Pages |
| Backend | Next.js API routes sur Render | Hono.js sur Render (déjà live) |
| DNS | Vercel | Vercel (inchangé) |
| Domaine | expria.app | expria.app (inchangé) |
| Auth | Supabase | Supabase (inchangé) |
| Paiement | Stripe | Stripe (inchangé) |
---
## 2. Prérequis — ne pas lancer la bascule sans cocher tout
### Code
- [ ] Tous les tests Vitest backend passent (0 échec)
- [ ] Tous les tests Vitest frontend passent (0 échec)
- [ ] npm run typecheck frontend → 0 erreur
- [ ] Smoke test complet (Groupe Z du GOLDEN_DATASET.md) validé en local
### Infrastructure
- [ ] Backend V2 stable sur api.expria.app depuis au moins 48h sans erreur critique Sentry
- [ ] Sentry configuré et actif sur le frontend V2
- [ ] Variables d'environnement Cloudflare Pages configurées :
- VITE_API_URL=https://api.expria.app
- VITE_SUPABASE_URL=...
- VITE_SUPABASE_ANON_KEY=...
- VITE_ENABLE_T2_LIVE=false
- [ ] CNAME Cloudflare Pages créé et testé sur une URL de preview
### Stripe
- [ ] Webhooks Stripe pointent vers api.expria.app (backend V2)
- [ ] Test de paiement réel effectué sur l'URL de preview Cloudflare Pages
### Rollback DNS — valeurs de référence (ne pas supprimer)
- @ → A → 216.24.57.1 (frontend V1 Render)
- www → CNAME → expria.onrender.com (frontend V1 Render)
- api → CNAME → expria-backend.onrender.com (backend V2 — inchangé)
---
## 3. Procédure de bascule — dans l'ordre exact
### Étape 1 — Mettre V1 en mode maintenance (2 min)
Dans Render, sur le service frontend V1 :
- Modifier la variable d'environnement `MAINTENANCE_MODE=true`
- Redéployer le service V1
- Vérifier que expria.app affiche la page de maintenance
> ⚠️ À partir de ce moment, expria.app est inaccessible pour les utilisateurs.
> Faire cette étape à une heure creuse (nuit, week-end).
### Étape 2 — Configurer le CNAME dans Vercel (5 min)
Dans le dashboard Vercel → Domains → expria.app :
- Supprimer ou modifier l'enregistrement A/CNAME actuel qui pointe vers Render
- Ajouter un CNAME : `expria.app``<projet>.pages.dev` (URL Cloudflare Pages)
- Sauvegarder
### Étape 3 — Configurer le domaine dans Cloudflare Pages (5 min)
Dans Cloudflare Pages → projet expria-frontend → Custom domains :
- Ajouter `expria.app`
- Cloudflare Pages vérifie le CNAME automatiquement
- Attendre la validation (peut prendre 1-5 min)
### Étape 4 — Vérifier la propagation DNS (5-15 min)
Vérifier sur https://dnschecker.org que `expria.app` pointe vers Cloudflare Pages.
Ne pas continuer avant que la propagation soit visible depuis au moins 3 régions.
### Étape 5 — Smoke test en production (15 min)
Rejouer le Groupe Z du GOLDEN_DATASET.md sur expria.app :
- [ ] Z1 — Inscription + première simulation Free
- [ ] Z2 — Blocage quota Free
- [ ] Z3 — Simulation Standard complète
- [ ] Z4 — Mode examen bloqué en Standard
- [ ] Z5 — T2 live Premium
- [ ] Z6 — Mode examen EE complet
- [ ] Z7 — Paiement Free → Standard
- [ ] Z8 — Prorata Standard → Premium
- [ ] Z9 — Déconnexion + accès protégé
- [ ] Z10 — Responsive mobile Home + Login
### Étape 6 — Vérifier Sentry (5 min)
- Ouvrir le dashboard Sentry projet expria-frontend
- Vérifier qu'aucune erreur critique n'apparaît dans les 5 premières minutes
- Si erreur critique → déclencher le rollback immédiatement
### Étape 7 — Déclarer la bascule réussie
- Noter la date et l'heure dans ce fichier (section 6)
- Désactiver MAINTENANCE_MODE sur V1 (optionnel — V1 reste sur Render comme fallback 30 jours)
---
## 4. Rollback — si quelque chose casse
**Objectif : revenir sur V1 en moins de 10 minutes.**
### Étape 1 — Désactiver V2 (2 min)
Dans Cloudflare Pages → projet expria-frontend :
- Désactiver le domaine personnalisé expria.app
### Étape 2 — Remettre V1 en ligne (3 min)
Dans Vercel → Domains → expria.app :
- Remettre le CNAME/A record vers Render (valeur originale)
Dans Render → service frontend V1 :
- Modifier `MAINTENANCE_MODE=false`
- Redéployer
### Étape 3 — Vérifier (5 min)
- Vérifier que expria.app affiche à nouveau V1
- Vérifier que la connexion et une simulation fonctionnent
### Étape 4 — Diagnostiquer
- Ouvrir Sentry V2 — identifier l'erreur critique
- Ne pas retenter la bascule avant d'avoir corrigé et rejoué le Groupe Z complet
---
## 5. Post-bascule — checks 24h après
- [ ] Sentry : aucune erreur critique nouvelle
- [ ] Stripe : webhooks reçus et traités correctement
- [ ] Supabase : aucune erreur d'auth dans les logs
- [ ] Au moins 1 simulation complète effectuée par un vrai utilisateur
- [ ] V1 sur Render toujours en ligne comme fallback (désactiver après 30 jours)
---
## 6. Historique des déploiements
| Date | Version | Résultat | Notes |
|---|---|---|---|
| — | — | — | — |
---
## 7. Historique du document
| Version | Date | Changements |
|---|---|---|
| 1.0 | 2026-04-19 | Création initiale |

507
docs/DESIGN_SYSTEM.md Normal file
View file

@ -0,0 +1,507 @@
# DESIGN_SYSTEM.md — Expria Frontend
> **Document de référence — Version 2.0 — 24 avril 2026**
> Source de vérité unique pour l'identité visuelle, les tokens de design et les primitives UI.
> Toute décision de DA doit être consignée ici avant d'être implémentée.
> **Remplace intégralement la v1.0 (Direction Boréal) du 17 avril 2026.**
---
## 1. Direction artistique — verrouillée
**Nom :** Charcoal
**Positionnement :** outil pro sérieux, premium sans scolaire, immersif sans austère.
**Référence mentale :** Linear, Notion Desktop, Primo TCF — sidebar sombre permanente, contenu aéré.
### Parti pris fondateurs
| Principe | Décision |
|---|---|
| Mode par défaut | **Dark** (charcoal chaud `#111111`) |
| Mode clair | Activé — fond gris froid `#F3F4F6`, cartes blanches |
| Détection thème | `prefers-color-scheme` au chargement, toggle manuel, persistance `localStorage` |
| Sidebar | **Navy `#0C1528` permanent** — identique dark et light. C'est l'ancre visuelle de la marque. |
| Fond principal (dark) | `#111111` avec deux halos bleus subtils (`radial-gradient` à 45% opacité) |
| Fond principal (light) | `#F3F4F6` avec deux halos bleus très discrets (23% opacité) |
| Bleu de marque | `#1B4FD8` **sacro-saint** — invariant entre les modes |
| Bleu texte accent | `#7da4f0` en dark, `#1B4FD8` en light (lisibilité adaptée au fond) |
| Surfaces (dark) | Semi-transparentes `rgba(255,255,255,0.035)` — jamais de gris opaque |
| Surfaces (light) | Blanc pur `#FFFFFF` avec ombre subtile `shadow-card` |
| Angles | Rayons généreux mais retenus : 8 / 12 / 16 px |
| Ombres (dark) | Aucune — la bordure 1px et la transparence suffisent |
| Ombres (light) | Minimales. `shadow-card` subtile sur les surfaces élevées |
| Animations | 150200 ms, `ease-out`, respect de `prefers-reduced-motion` |
| Icônes | `lucide-react` pour les icônes standard. SVG inline dans `shared/ui/icons/` pour les icônes custom |
| Typographie | Plus Jakarta Sans exclusivement (via Google Fonts, fallback système) |
| Approche responsive | **Desktop-first** pour l'app (usage quotidien sur ordinateur). Mobile-first uniquement pour le funnel d'acquisition (landing, pricing, inscription) |
### Ce qu'on refuse explicitement
- Gradients criards — le seul acceptable est le dégradé `accent → accent-dark` sur le CTA primaire.
- Glassmorphism ou `backdrop-filter` généralisé — réservé à la topbar et à la bottom nav mobile.
- Emojis dans les éléments interactifs ou les labels fonctionnels.
- Ombres lourdes, "drop shadows" style Material Design 2.
- Plus de 3 niveaux d'élévation visuelle (fond → surface → surface-raised → modal).
- Toute police autre que Plus Jakarta Sans.
- Les motifs SaaS génériques : illustrations 3D, dégradés violet-rose, glass blobs.
- Fond blanc pur (`#FFFFFF`) en tant que fond de page — toujours `--color-canvas`.
- Couleurs hexadécimales en dur dans les composants — toujours via token.
---
## 2. Tokens — `src/index.css`
Remplacer intégralement le contenu actuel. Tailwind 4 lit automatiquement les tokens déclarés dans `@theme`. Les deux thèmes sont actifs dès maintenant.
```css
@import 'tailwindcss';
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
@theme {
/* ══════════════════════════════════════════════════════════════════════
INVARIANTS — identiques dark et light
══════════════════════════════════════════════════════════════════════ */
/* ── Sidebar (navy permanent) ── */
--color-sidebar-bg: #0C1528;
--color-sidebar-border: rgba(255, 255, 255, 0.07);
--color-sidebar-text: rgba(255, 255, 255, 0.6);
--color-sidebar-text-hover: rgba(255, 255, 255, 0.9);
--color-sidebar-text-active: #ffffff;
--color-sidebar-nav-hover: rgba(255, 255, 255, 0.07);
--color-sidebar-nav-active: rgba(255, 255, 255, 0.1);
--color-sidebar-section-label: rgba(255, 255, 255, 0.3);
/* ── Brand ── */
--color-brand: #1B4FD8;
--color-brand-hover: #1744B8;
--color-brand-active: #13379C;
--color-brand-dark: #1740b0;
--color-brand-ink: #FFFFFF;
/* ── Semantic ── */
--color-warning: #f59e0b;
--color-warning-soft: rgba(245, 158, 11, 0.12);
--color-danger: #ef4444;
--color-danger-soft: rgba(239, 68, 68, 0.12);
/* ── Typography ── */
--font-sans: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
--text-xs: 11px;
--text-sm: 13px;
--text-base: 14px;
--text-md: 15px;
--text-lg: 17px;
--text-xl: 20px;
--text-2xl: 24px;
--text-3xl: 32px;
--text-display: 40px;
/* ── Rayons ── */
--radius-xs: 6px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-pill: 999px;
/* ── Focus ── */
--shadow-focus: 0 0 0 3px rgba(27, 79, 216, 0.18);
/* ══════════════════════════════════════════════════════════════════════
DARK MODE (default) — tokens de contenu
══════════════════════════════════════════════════════════════════════ */
--color-canvas: #111111;
--color-surface: rgba(255, 255, 255, 0.035);
--color-surface-hover: rgba(255, 255, 255, 0.055);
--color-surface-solid: #1e1e1e;
--color-surface-raised: #222222;
--color-border: rgba(255, 255, 255, 0.06);
--color-border-strong: rgba(255, 255, 255, 0.12);
--color-ink-primary: #e5e5e5;
--color-ink-secondary: rgba(255, 255, 255, 0.55);
--color-ink-tertiary: rgba(255, 255, 255, 0.3);
--color-ink-inverse: #111111;
--color-brand-soft: rgba(27, 79, 216, 0.1);
--color-brand-text: #7da4f0;
--color-success: #4ade80;
--color-success-soft: rgba(74, 222, 128, 0.12);
--color-topbar-bg: rgba(17, 17, 17, 0.88);
--color-gradient-a: rgba(27, 79, 216, 0.05);
--color-gradient-b: rgba(27, 79, 216, 0.03);
--shadow-card: none;
--shadow-raised: none;
}
/* ══════════════════════════════════════════════════════════════════════
LIGHT MODE — override .light sur <body>
══════════════════════════════════════════════════════════════════════ */
.light {
--color-canvas: #F3F4F6;
--color-surface: #ffffff;
--color-surface-hover: #f8f9fb;
--color-surface-solid: #ffffff;
--color-surface-raised: #ffffff;
--color-border: rgba(0, 0, 0, 0.07);
--color-border-strong: rgba(0, 0, 0, 0.14);
--color-ink-primary: #0f0f1a;
--color-ink-secondary: rgba(0, 0, 0, 0.55);
--color-ink-tertiary: rgba(0, 0, 0, 0.3);
--color-ink-inverse: #ffffff;
--color-brand-soft: rgba(27, 79, 216, 0.06);
--color-brand-text: #1B4FD8;
--color-success: #16a34a;
--color-success-soft: rgba(22, 163, 74, 0.1);
--color-topbar-bg: rgba(243, 244, 246, 0.88);
--color-gradient-a: rgba(27, 79, 216, 0.025);
--color-gradient-b: rgba(27, 79, 216, 0.01);
--shadow-card: 0 1px 2px rgba(15, 18, 32, 0.04), 0 1px 8px rgba(15, 18, 32, 0.03);
--shadow-raised: 0 4px 16px rgba(15, 18, 32, 0.06), 0 1px 2px rgba(15, 18, 32, 0.04);
}
/* ── Globals ── */
html, body {
background: var(--color-canvas);
color: var(--color-ink-primary);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-variant-numeric: tabular-nums;
}
*:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
border-radius: var(--radius-xs);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0ms !important;
transition-duration: 0ms !important;
}
}
```
### Règles d'usage des tokens
1. **Aucune valeur hexadécimale en dur** dans les composants. Toute couleur passe par un token `var(--color-*)`.
2. **Nommage sémantique obligatoire.** On écrit `bg-[var(--color-surface)]`, pas `bg-white` ni `bg-gray-50`.
3. **Ne jamais utiliser** `bg-white`, `bg-gray-*`, `text-gray-*` — ces classes Tailwind cassent le dual-theme.
4. Si un cas d'usage exige une teinte hors charte, **le documenter ici avant de l'ajouter**. Pas de token orphelin.
5. La sidebar utilise ses propres tokens `--color-sidebar-*` — ils ne changent **jamais** entre les modes.
6. Le fond principal utilise toujours les deux `radial-gradient` subtils — jamais un aplat uni.
---
## 3. Gestion du thème — `src/shared/lib/theme.ts`
```typescript
export type Theme = 'dark' | 'light';
export function getInitialTheme(): Theme {
const stored = localStorage.getItem('expria-theme') as Theme | null;
if (stored === 'dark' || stored === 'light') return stored;
if (window.matchMedia('(prefers-color-scheme: light)').matches) return 'light';
return 'dark';
}
export function applyTheme(theme: Theme): void {
document.documentElement.classList.toggle('light', theme === 'light');
}
export function persistTheme(theme: Theme): void {
localStorage.setItem('expria-theme', theme);
}
```
**Script anti-FOUC** — à insérer inline dans `<head>` de `index.html` :
```html
<script>
(function(){
var t = localStorage.getItem('expria-theme');
if (!t) t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
if (t === 'light') document.documentElement.classList.add('light');
})();
</script>
```
---
## 4. Typographie
| Usage | Taille | Poids | Tracking | Ligne | Token |
|---|---|---|---|---|---|
| Display (NCLC hero) | 40px | 800 | -0.02em | 1.0 | `text-display` |
| H1 page | 32px | 700 | -0.02em | 1.1 | `text-3xl` |
| H2 section | 24px | 700 | -0.015em | 1.2 | `text-2xl` |
| H3 card title | 20px | 700 | -0.01em | 1.3 | `text-xl` |
| Lead / intro | 17px | 500 | -0.005em | 1.5 | `text-lg` |
| Body | 14px | 400 | 0 | 1.6 | `text-base` |
| Body renforcé | 15px | 500 | 0 | 1.55 | `text-md` |
| Small / meta | 13px | 500 | 0 | 1.5 | `text-sm` |
| Eyebrow / label | 11px | 600 | 0.1em (uppercase) | 1.4 | `text-xs` |
**Règles :**
- Tout nombre métier (score 16/20, NCLC 7,5, compteur 3/5) est rendu en `font-variant-numeric: tabular-nums`.
- `Plus Jakarta Sans` chargée via Google Fonts CDN avec fallback système.
- Les chiffres français utilisent la **virgule** comme séparateur décimal (`7,5`, jamais `7.5`).
---
## 5. Primitives UI
À créer dans `src/shared/ui/` en FSD, une primitive par dossier (`button/`, `card/`, etc.) avec `index.ts` pour l'export.
### Inventaire
| Composant | Variants | Usage |
|---|---|---|
| `Button` | `primary` / `secondary` / `ghost` / `upgrade` | CTA, actions tertiaires |
| `Card` | `default` / `raised` / `interactive` | Cadre métriques, item simulation |
| `MetricCard` | `default` / `hero` | Bloc NCLC, compteur simulations |
| `ProgressBar` | `default` | Progression vers NCLC 9 |
| `Badge` | `plan` / `nclc` / `brand` / `success` / `warning` / `danger` | Plan, niveau, chips sémantiques |
| `Sidebar` | — | Nav desktop (≥ 1024px), navy permanent |
| `BottomNav` | — | Nav mobile (< 1024px), 45 items max |
| `ThemeToggle` | — | Bouton soleil/lune dans le footer sidebar |
| `PageHeader` | — | Greeting + plan badge |
| `SectionHeader` | — | Titre de section + action optionnelle |
### Patterns de référence — copier, ne pas réinterpréter
**Sidebar NavItem actif :**
```tsx
<Link
className={cn(
'relative flex items-center gap-2.5 px-2.5 py-2 rounded-lg',
'text-[13px] font-medium transition-colors',
isActive
? 'bg-[var(--color-sidebar-nav-active)] text-white font-semibold'
: 'text-[var(--color-sidebar-text)] hover:bg-[var(--color-sidebar-nav-hover)] hover:text-[var(--color-sidebar-text-hover)]'
)}
>
{isActive && (
<span className="absolute left-0 top-[20%] bottom-[20%] w-[3px]
rounded-r bg-[var(--color-brand)]" />
)}
<Icon className={cn('w-4 h-4 shrink-0', isActive ? 'opacity-100' : 'opacity-60')} />
{label}
</Link>
```
**Card :**
```tsx
<div className={cn(
'rounded-[var(--radius-md)] border border-[var(--color-border)]',
'bg-[var(--color-surface)] p-[18px] transition-colors',
'shadow-[var(--shadow-card)]',
)}>
{children}
</div>
```
**Bouton CTA primaire :**
```tsx
<button className="w-full py-3.5 rounded-[var(--radius-md)]
bg-gradient-to-br from-[var(--color-brand)] to-[var(--color-brand-dark)]
text-white font-bold text-sm
shadow-[0_4px_20px_rgba(27,79,216,0.15)]
hover:translate-y-[-1px] hover:shadow-[0_6px_28px_rgba(27,79,216,0.25)]
transition-all">
Nouvelle simulation
</button>
```
**Bouton secondaire :**
```tsx
<button className="w-full py-2.5 rounded-[var(--radius-sm)]
border border-[var(--color-border)] bg-transparent
text-[var(--color-ink-secondary)] text-[13px] font-semibold
hover:border-[var(--color-brand)] hover:text-[var(--color-brand-text)]
hover:bg-[var(--color-brand-soft)]
transition-all">
Voir mon profil →
</button>
```
**Badge sémantique :**
```tsx
<span className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full',
'text-[11px] font-semibold border',
variant === 'brand' && 'bg-[var(--color-brand-soft)] text-[var(--color-brand-text)] border-[rgba(27,79,216,0.22)]',
variant === 'success' && 'bg-[var(--color-success-soft)] text-[var(--color-success)] border-[rgba(74,222,128,0.22)]',
)}>
{children}
</span>
```
### Règles d'implémentation
- Chaque primitive **accepte `className`** en plus de ses props typées, pour overrides ponctuels.
- Chaque primitive **expose ses props via un type exporté** (`ButtonProps`, `CardProps`, etc.).
- Aucune primitive ne contient de logique métier ou d'appel API. Elles reçoivent tout par props.
- Les icônes sont importées de `lucide-react` et passées comme composant, jamais par nom de string.
---
## 6. Layout principal — `AppLayout`
```tsx
<div className="flex min-h-screen">
<Sidebar /> {/* fixed, w-[230px], bg sidebar navy */}
<main
className="flex-1 ml-[230px] min-h-screen p-9"
style={{
background: `
radial-gradient(ellipse at 35% 0%, var(--color-gradient-a), transparent 55%),
radial-gradient(ellipse at 80% 100%, var(--color-gradient-b), transparent 50%),
var(--color-canvas)
`,
}}
>
<div className="max-w-[1100px] mx-auto">
{children}
</div>
</main>
</div>
```
### Breakpoints
| Breakpoint | Comportement |
|---|---|
| `< 1024px` | Sidebar masquée, `BottomNav` fixe en bas, padding horizontal 20px |
| `≥ 1024px` | Sidebar 230px + contenu centré 1100px max, padding 36px |
| `≥ 1440px` | Contenu centré 1100px max (pas d'élargissement) |
### Densité verticale
- Padding vertical section : 24px mobile, 32px desktop.
- Gap inter-cards : 12px mobile, 16px desktop.
- Marge sous `PageHeader` : 20px mobile, 28px desktop.
---
## 7. Données mock
Avant branchement API, fournir les données via `src/shared/api/mock/dashboard.ts`. Données crédibles, françaises, alignées sur l'audience réelle.
```typescript
export const mockDashboard = {
user: {
firstName: 'Yacine',
plan: 'decouverte' as const,
planLabel: 'Plan Découverte',
},
metrics: {
nclcEstimated: 7.5,
nclcTarget: 9,
simulationsUsed: 2,
simulationsQuota: 5,
lastScore: { value: 16, max: 20, type: 'ecrit' as const },
},
recentSimulations: [
{ id: 's-001', type: 'ecrit', relativeDate: 'il y a 2 jours', score: 16, max: 20, nclc: 8 },
{ id: 's-002', type: 'oral', relativeDate: 'il y a 5 jours', score: 14, max: 20, nclc: 7 },
{ id: 's-003', type: 'ecrit', relativeDate: 'il y a 1 semaine', score: 15, max: 20, nclc: 7 },
],
nextStep: {
title: 'Cible une simulation orale cette semaine',
body: 'Ton écrit est solide (NCLC 8). L\'oral reste à consolider pour sécuriser ton NCLC 9.',
action: { label: 'Démarrer Expression Orale', to: '/simulation/orale' },
},
} as const;
```
**Règles contenu :**
- Aucun "Lorem ipsum", aucune date absolue — relatif uniquement.
- Les prénoms mocks reflètent l'audience : Yacine, Aminata, Kenza, Bilal, Fatou, Kévin.
- Les scores suivent une progression crédible (pas de 20/20 ni de 5/20).
---
## 8. Accessibilité — plancher
- Contraste minimum **WCAG AA** sur tous les couples texte/fond (vérifié dark ET light).
- Tous les éléments interactifs ont un `:focus-visible` avec `--shadow-focus` (halo bleu 3px).
- Les icônes décoratives portent `aria-hidden="true"`.
- Les icônes fonctionnelles (sans label visible) portent `aria-label`.
- Les landmarks sémantiques : `<header>`, `<nav>`, `<main>`, `<section>`.
- Le `BottomNav` mobile respecte la hauteur minimale tap target : **44×44 px** par item.
- Le `ThemeToggle` a un `aria-label` dynamique : "Passer en mode clair" / "Passer en mode sombre".
---
## 9. Dépendances externes
| Dépendance | Statut | Justification |
|---|---|---|
| `lucide-react` | ✅ Autorisée | Icônes cohérentes, tree-shakeable, aucun CSS importé |
| `clsx` + `tailwind-merge` | ✅ Autorisées | Utilitaire `cn()` pour merge de classes |
| `shadcn/ui` | ⛔ Interdit | Overrides Tailwind trop complexes pour le volume actuel |
| `radix-ui` | 🔒 Reporté | Utilisable si besoin justifié par ADR (Dialog, Popover) |
---
## 10. Règles impératives pour Claude Code
1. **Ne jamais utiliser de couleurs en dur** — toujours `var(--color-*)`.
2. **Ne jamais utiliser `bg-white`, `bg-gray-*`, `text-gray-*`** — utiliser les tokens sémantiques.
3. **La sidebar est toujours navy** — ses tokens ne changent jamais entre dark et light.
4. **Le fond principal utilise deux `radial-gradient` subtils** — jamais un aplat uni.
5. **Typographie : Plus Jakarta Sans uniquement** — jamais Inter, Roboto, ou system seul.
6. **Les cartes** utilisent `var(--color-surface)` + `var(--color-border)` — en dark c'est semi-transparent, en light c'est blanc avec shadow.
7. **Les hover states** utilisent `var(--color-surface-hover)` — jamais de `rgba` en dur.
8. **Copier les patterns de la section 5** — ne pas réinterpréter, ne pas "améliorer".
9. **Tester visuellement en dark ET en light** avant de valider un composant.
---
## 11. Journal des décisions DA
| Date | Décision | Contexte |
|---|---|---|
| 2026-04-17 | Direction A (Boréal) validée comme base | 5 directions explorées, A choisie |
| 2026-04-17 | Fond `#F4F2EC`, light-only, dark reporté Sprint 2+ | Première itération |
| 2026-04-24 | **Direction Charcoal adoptée — remplace Boréal** | Analyse concurrentielle Primo TCF, 4 directions testées (Deep Navy, Royal Blue, Gradient Mesh, Charcoal), Charcoal retenu avec touch de Gradient Mesh |
| 2026-04-24 | Sidebar navy `#0C1528` permanent dark+light | Cohérence Slack/Discord/Linear, ancre visuelle de marque |
| 2026-04-24 | Dark mode activé par défaut (`#111111`) | Usage quotidien desktop, cible intérieur, cohérent avec le positionnement premium |
| 2026-04-24 | Light mode activé avec fond `#F3F4F6` | Sidebar navy maintenue, topbar claire, cartes blanches avec shadow |
| 2026-04-24 | `prefers-color-scheme` respecté au chargement | Fallback dark si pas de préférence système |
| 2026-04-24 | Desktop-first pour l'app | Analytics V1 : 60% desktop après 1 semaine d'usage. Mobile = acquisition (Facebook/WhatsApp), desktop = usage quotidien |
| 2026-04-24 | Plus Jakarta Sans via Google Fonts CDN | Chargement explicite, pas de fallback-only |
| 2026-04-24 | `lucide-react` autorisée | Remplace les SVG inline manuels |
| 2026-04-24 | Tokens dual-theme actifs dès maintenant | Plus de dark reporté — les deux modes sont livrés ensemble |
---
## 12. Hors périmètre actuel
Éléments explicitement **reportés** :
- Thème haut-contraste (WCAG AAA).
- Internationalisation (i18n) — monolingue FR.
- Animations avancées (scroll-linked, shared element transitions).
- Illustrations personnalisées / iconographie signature.
- Self-hosting de la font Plus Jakarta Sans.
- Troisième thème (ex: "mode examen" épuré).
Chacun de ces points mérite un ADR dédié quand il sera abordé.

View file

@ -480,3 +480,56 @@ Avant chaque session Claude Code, vérifier :
|---|---|---|
| 1.0 | 2026-04-17 | Création, adaptée de la version backend |
| 1.1 | 2026-04-18 | Ajout Règle L — tokens du design system (Sprint 0.5) |
| 1.2 | 2026-04-21 | Ajout section 10 — Session Clean obligatoire après chaque sprint |
---
## 10. Session Clean (obligatoire après chaque sprint)
> Session séparée du sprint de dev — jamais en cours d'implémentation.
### Déclenchement
- Le sprint est terminé
- Tous les tests automatisés sont verts
- Un commit propre existe (point de retour sûr)
### Prompt standard à donner à Claude Code
Lis dans l'ordre :
1. docs/ARCHITECTURE.md
2. docs/DEVELOPMENT_PRINCIPLES.md
3. docs/DESIGN_SYSTEM.md
Sprint [X] terminé, tests au vert, commit propre effectué.
Agis comme un ingénieur senior.
Analyse uniquement les fichiers modifiés ce sprint.
Objectif : réduire la complexité sans changer aucune fonctionnalité.
Règles :
- 1 fichier modifié à la fois
- npm run typecheck + npm run test après chaque fichier
- Si un test échoue : annuler la modification, passer au suivant
- Ne pas toucher aux fichiers non modifiés ce sprint
- Ne pas supprimer de code sans vérifier au préalable
qu'il n'est pas référencé ailleurs dans le projet
(grep obligatoire avant toute suppression)
- Aucune décision architecturale — si un doute,
signaler et attendre
Produis un plan (liste des fichiers à nettoyer, ordre)
et attends le GO.
### Séquence obligatoire
1. Claude Code propose le plan (fichiers + ordre)
2. Validation dans le Project avant GO
3. Claude Code factorise — 1 fichier à la fois
4. npm run typecheck + npm run test verts après chaque fichier
5. Tests manuels Golden Dataset — groupes concernés
6. Si tout vert → commit : refactor(<scope>): nettoyage Sprint [X]
7. CHANGELOG.md mis à jour
### Règle absolue
Un test manuel qui échoue après refactor = annuler toute
la session Clean, revenir au commit du sprint,
diagnostiquer avant de retenter.

View file

@ -16,120 +16,126 @@
4. En cas de doute : rejouer le groupe Z (smoke test complet)
**Environnement de test :**
- URL frontend local : `http://localhost:5173`
- URL backend : `https://api.expria.app` (ou local si dev simultané)
- Navigateurs à couvrir : Chrome + Firefox + Safari mobile (via DevTools mobile emulation minimum)
**Comptes de test (identiques au backend) :**
| Compte | Plan | Mot de passe |
|---|---|---|
| test.free@gmail.com | free | Expria2025!test |
| test.standard@gmail.com | standard | Expria2025!test |
| test.premium@gmail.com | premium | Expria2025!test |
| test.quota@gmail.com | free (5/5 utilisées) | Expria2025!test |
| Compte | Plan | Mot de passe |
| ----------------------- | -------------------- | --------------- |
| test.free@gmail.com | free | Expria2025!test |
| test.standard@gmail.com | standard | Expria2025!test |
| test.premium@gmail.com | premium | Expria2025!test |
| test.quota@gmail.com | free (5/5 utilisées) | Expria2025!test |
---
## Groupe A — Authentification et routing
| # | Test | Compte | Résultat attendu | ✅/❌ |
|---|---|---|---|---|
| A1 | Arriver sur `/` sans être connecté | — | Page Home publique affichée | |
| A2 | Cliquer "Se connecter" depuis Home | — | Redirection `/login`, formulaire visible | |
| A3 | Inscription avec email + mot de passe valides | nouveau | Compte créé, plan=free, redirection `/dashboard` | |
| A4 | Connexion avec identifiants corrects | test.free | Redirection `/dashboard`, plan Free affiché | |
| A5 | Connexion avec mot de passe incorrect | test.free | Message d'erreur en français, pas de redirection | |
| A6 | Déconnexion depuis le menu utilisateur | test.free | Redirection `/`, session invalidée | |
| A7 | Accès direct à `/dashboard` sans auth | — | Redirection `/login` (ProtectedRoute) | |
| A8 | Accès direct à `/t2-live` en tant que Free | test.free | Redirection ou PaywallModal "Exclusivité Premium" | |
| A9 | Session JWT expirée pendant navigation | test.free | Message "Session expirée", redirection `/login` | |
| A10 | Rafraîchir la page après login | test.free | Reste connecté, dashboard réaffiché | |
| # | Test | Compte | Résultat attendu | ✅/❌ |
| --- | --------------------------------------------- | --------- | ------------------------------------------------- | ----- |
| A1 | Arriver sur `/` sans être connecté | — | Page Home publique affichée | |
| A2 | Cliquer "Se connecter" depuis Home | — | Redirection `/login`, formulaire visible | |
| A3 | Inscription avec email + mot de passe valides | nouveau | Compte créé, plan=free, redirection `/dashboard` | |
| A4 | Connexion avec identifiants corrects | test.free | Redirection `/dashboard`, plan Free affiché | |
| A5 | Connexion avec mot de passe incorrect | test.free | Message d'erreur en français, pas de redirection | |
| A6 | Déconnexion depuis le menu utilisateur | test.free | Redirection `/`, session invalidée | |
| A7 | Accès direct à `/dashboard` sans auth | — | Redirection `/login` (ProtectedRoute) | |
| A8 | Accès direct à `/t2-live` en tant que Free | test.free | Redirection ou PaywallModal "Exclusivité Premium" | |
| A9 | Session JWT expirée pendant navigation | test.free | Message "Session expirée", redirection `/login` | |
| A10 | Rafraîchir la page après login | test.free | Reste connecté, dashboard réaffiché | |
---
## Groupe B — Plan Free (parcours complet)
| # | Test | Compte | Résultat attendu | ✅/❌ |
|---|---|---|---|---|
| B1 | Dashboard Free après connexion | test.free | Compteur "X/5 simulations", aperçu flouté du dashboard Premium visible | |
| B2 | Badge compteur simulations affiché | test.free | Visible en permanence dans le header du dashboard | |
| B3 | Lancer une simulation EE T1 | test.free (quota < 5) | Interface de production affichée, pas de tips visibles | |
| B4 | Soumettre une production EE | test.free | Rapport affiché : score + NCLC visibles, critères floutés avec cadenas | |
| B5 | Rapport flouté avec mentions correctes | test.free | "Disponible en Standard" + bouton upgrade visible | |
| B6 | Lancer une simulation EO T1 | test.free | Interface d'enregistrement audio, pas d'erreur microphone | |
| B7 | Tenter EO T2 live depuis le sélecteur de tâches | test.free | Cadenas + message "Exclusivité Premium" | |
| B8 | Atteindre la 6e simulation | test.quota | Modal de blocage : "5/5 utilisées" + 2 boutons (Standard/Premium) + "Plus tard" | |
| B9 | Cliquer "Plus tard" dans le modal | test.quota | Modal fermé, dashboard visible, pas de redirection | |
| B10 | Cliquer "Mode Examen" | test.free | Cadenas + message "Exclusivité Premium" | |
| B11 | Tenter accès URL direct `/exam-mode` | test.free | Redirection ou PaywallModal | |
| # | Test | Compte | Résultat attendu | ✅/❌ |
| --- | ----------------------------------------------- | --------------------- | ------------------------------------------------------------------------------- | ----- |
| B1 | Dashboard Free après connexion | test.free | Compteur "X/5 simulations", aperçu flouté du dashboard Premium visible | |
| B2 | Badge compteur simulations affiché | test.free | Visible en permanence dans le header du dashboard | |
| B3 | Lancer une simulation EE T1 | test.free (quota < 5) | Interface de production affichée, pas de tips visibles | |
| B4 | Soumettre une production EE | test.free | Rapport affiché : score + NCLC visibles, critères floutés avec cadenas | |
| B5 | Rapport flouté avec mentions correctes | test.free | "Disponible en Standard" + bouton upgrade visible | |
| B6 | Lancer une simulation EO T1 | test.free | Interface d'enregistrement audio, pas d'erreur microphone | |
| B7 | Tenter EO T2 live depuis le sélecteur de tâches | test.free | Cadenas + message "Exclusivité Premium" | |
| B8 | Atteindre la 6e simulation | test.quota | Modal de blocage : "5/5 utilisées" + 2 boutons (Standard/Premium) + "Plus tard" | |
| B9 | Cliquer "Plus tard" dans le modal | test.quota | Modal fermé, dashboard visible, pas de redirection | |
| B10 | Cliquer "Mode Examen" | test.free | Cadenas + message "Exclusivité Premium" | |
| B11 | Tenter accès URL direct `/exam-mode` | test.free | Redirection ou PaywallModal | |
---
## Groupe C — Plan Standard
| # | Test | Compte | Résultat attendu | ✅/❌ |
|---|---|---|---|---|
| C1 | Dashboard Standard après connexion | test.standard | Historique visible, pas de compteur simulations, bouton "Choisir une tâche" actif | |
| C2 | Lancer simulation EE sans limite | test.standard | Accès direct, aucune vérification de quota visible | |
| C3 | Toggle "Suggestions d'idées" activé | test.standard | Suggestions visibles pendant la simulation | |
| C4 | Toggle "Mode focus" activé | test.standard | Tips masqués pendant la simulation | |
| C5 | Rapport complet après soumission EE | test.standard | Score, critères détaillés, erreurs expliquées, modèle, exercices — rien flouté | |
| C6 | Production apparaît dans le dashboard | test.standard | Date, tâche, score affichés dans la liste | |
| C7 | Cliquer une production dans l'historique | test.standard | Rapport complet de cette production réaffiché | |
| C8 | Cliquer "Mode Examen" | test.standard | Message "Réservé au plan Premium" + bouton upgrade | |
| C9 | Cliquer "EO Tâche 2 live" | test.standard | Cadenas + message "Exclusivité Premium" | |
| C10 | Après 5 productions : indice de préparation | test.standard | Section indice visible avec score et message interprétatif | |
| C11 | Upgrade Standard → Premium : prorata affiché | test.standard | Avant confirmation, montant prorata visible (ex : "~10€ aujourd'hui") | |
| # | Test | Compte | Résultat attendu | ✅/❌ |
| --- | -------------------------------------------- | ------------- | --------------------------------------------------------------------------------- | ----- |
| C1 | Dashboard Standard après connexion | test.standard | Historique visible, pas de compteur simulations, bouton "Choisir une tâche" actif | |
| C2 | Lancer simulation EE sans limite | test.standard | Accès direct, aucune vérification de quota visible | |
| C3 | Toggle "Suggestions d'idées" activé | test.standard | Suggestions visibles pendant la simulation | |
| C4 | Toggle "Mode focus" activé | test.standard | Tips masqués pendant la simulation | |
| C5 | Rapport complet après soumission EE | test.standard | Score, critères détaillés, erreurs expliquées, modèle, exercices — rien flouté | |
| C6 | Production apparaît dans le dashboard | test.standard | Date, tâche, score affichés dans la liste | |
| C7 | Cliquer une production dans l'historique | test.standard | Rapport complet de cette production réaffiché | |
| C8 | Cliquer "Mode Examen" | test.standard | Message "Réservé au plan Premium" + bouton upgrade | |
| C9 | Cliquer "EO Tâche 2 live" | test.standard | Cadenas + message "Exclusivité Premium" | |
| C10 | Après 5 productions : indice de préparation | test.standard | Section indice visible avec score et message interprétatif | |
| C11 | Upgrade Standard → Premium : prorata affiché | test.standard | Avant confirmation, montant prorata visible (ex : "~10€ aujourd'hui") | |
---
## Groupe D — Plan Premium
| # | Test | Compte | Résultat attendu | ✅/❌ |
|---|---|---|---|---|
| D1 | Dashboard Premium après connexion | test.premium | Historique, indice, patterns, bouton examen actif, T2 live accessible | |
| D2 | Accéder à EO T2 live | test.premium | Page préparation T2, bouton "Démarrer le dialogue" actif | |
| D3 | Démarrer le dialogue T2 | test.premium | État "Connecting" puis "Listening", l'IA prend la parole en premier | |
| D4 | Répondre en audio à l'IA | test.premium | L'IA réagit après la réponse du candidat, état oscille listening/speaking | |
| D5 | Fin de dialogue T2 | test.premium | Rapport complet affiché, production enregistrée avec tag "T2 Live" | |
| D6 | Déconnexion WebSocket en cours de T2 | test.premium | État "Error" affiché, message utilisateur clair, option de reprise | |
| D7 | Lancer mode Examen EE | test.premium | Page d'avertissement affichée avant démarrage | |
| D8 | Confirmer Examen EE | test.premium | 3 tâches visibles, timer 60:00 démarré, inarrêtable | |
| D9 | Blocage à T=0 (Examen EE) | test.premium | Zone de texte figée, message "Temps écoulé", envoi auto | |
| D10 | Lancer mode Examen EO | test.premium | Timer 12:00, enregistrement actif, tâches enchaînées | |
| D11 | Analyse patterns (5+ productions) | test.premium | Section "Mon profil" avec erreurs récurrentes classées | |
> ⚠️ Certains items décrivent un état cible (sprints futurs), pas l'état implémenté actuel — voir marqueurs par ligne.
| # | Test | Compte | Résultat attendu | ✅/❌ |
| --- | ------------------------------------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
| D1 | Dashboard Premium après connexion | test.premium | Historique, bouton examen actif, T2 live accessible ; indice / patterns / profil ⏳ non implémenté (sprint ultérieur) | |
| D2 | Accéder à EO T2 live | test.premium | Page préparation T2, bouton "Démarrer le dialogue" actif | |
| D3 | Démarrer le dialogue T2 | test.premium | État "Connecting" puis "Listening" ; le candidat prend la parole en premier (le candidat initie l'interaction de service), l'IA répond ensuite | |
| D4 | Répondre en audio à l'IA | test.premium | L'IA réagit après la réponse du candidat, état oscille listening/speaking | |
| D5 | Fin de dialogue T2 | test.premium | Rapport complet affiché, production enregistrée avec tag "T2 Live" | |
| D6 | Déconnexion WebSocket en cours de T2 | test.premium | État "Error" affiché, message utilisateur clair, option de reprise — ⚠️ partiel (cf. note D6) | |
| D7 | Lancer mode Examen EE | test.premium | Page d'avertissement affichée avant démarrage — ⏳ Sprint 8 — non implémenté | |
| D8 | Confirmer Examen EE | test.premium | 3 tâches visibles, timer 60:00 démarré, inarrêtable — ⏳ Sprint 8 — non implémenté | |
| D9 | Blocage à T=0 (Examen EE) | test.premium | Zone de texte figée, message "Temps écoulé", envoi auto — ⏳ Sprint 8 — non implémenté | |
| D10 | Lancer mode Examen EO | test.premium | Timer 12:00, enregistrement actif, tâches enchaînées — ⏳ Sprint 8 — non implémenté | |
| D11 | Analyse patterns (5+ productions) | test.premium | Section "Mon profil" avec erreurs récurrentes classées — ⏳ non implémenté (sprint ultérieur) | |
> **Note D6 (partiel)** : un drop WebSocket subi mène bien à l'état `error` avec un message utilisateur clair (`T2DialoguePage.tsx:165-184`, `useT2LiveSession.ts:336-380`). Mais l'« option de reprise » du critère cible n'est PAS implémentée : l'écran d'erreur n'offre qu'un bouton « Retour aux sujets » (`T2DialoguePage.tsx:176-180`), pas de bouton « Réessayer » / reconnexion. Item à reclasser ✅ une fois la reprise ajoutée.
---
## Groupe E — Paiements Stripe
> ⚠️ Utiliser les cartes de test Stripe :
>
> - Carte valide : `4242 4242 4242 4242` (date future, CVC libre)
> - Carte refusée : `4000 0000 0000 0002`
| # | Test | Compte | Résultat attendu | ✅/❌ |
|---|---|---|---|---|
| E1 | Upgrade Free → Standard (Stripe Checkout) | test.free | Redirection full page vers Stripe, paiement, retour dashboard Standard | |
| E2 | Invalidation du cache plan après paiement | test.free → standard | usePlan() refetch automatiquement, dashboard bascule sans recharger la page | |
| E3 | Upgrade Free → Premium | test.free | Même flux que E1, plan=premium après retour | |
| E4 | Upgrade Standard → Premium avec prorata | test.standard | Montant prorata affiché avant confirmation, accès Premium immédiat | |
| E5 | Paiement refusé (carte 4000 0000 0000 0002) | test.free | Message d'erreur Stripe clair, plan inchangé | |
| E6 | Annuler au milieu du Checkout | test.free | Retour sur `/billing` ou `/pricing`, plan inchangé | |
| # | Test | Compte | Résultat attendu | ✅/❌ |
| --- | ------------------------------------------- | -------------------- | --------------------------------------------------------------------------- | ----- |
| E1 | Upgrade Free → Standard (Stripe Checkout) | test.free | Redirection full page vers Stripe, paiement, retour dashboard Standard | |
| E2 | Invalidation du cache plan après paiement | test.free → standard | usePlan() refetch automatiquement, dashboard bascule sans recharger la page | |
| E3 | Upgrade Free → Premium | test.free | Même flux que E1, plan=premium après retour | |
| E4 | Upgrade Standard → Premium avec prorata | test.standard | Montant prorata affiché avant confirmation, accès Premium immédiat | |
| E5 | Paiement refusé (carte 4000 0000 0000 0002) | test.free | Message d'erreur Stripe clair, plan inchangé | |
| E6 | Annuler au milieu du Checkout | test.free | Retour sur `/billing` ou `/pricing`, plan inchangé | |
---
## Groupe F — Sécurité et permissions
| # | Test | Compte | Résultat attendu | ✅/❌ |
|---|---|---|---|---|
| F1 | URL directe `/t2-live` en Standard | test.standard | Redirection ou PaywallModal, pas d'accès à la page | |
| F2 | Inspecter DevTools → clés privées | — | Aucune clé `SERVICE_ROLE`, `GEMINI`, `STRIPE_SECRET` visible | |
| F3 | Inspecter DevTools → JWT en clair dans localStorage | test.free | JWT Supabase visible (normal, c'est un access token) mais pas de refresh token exposé | |
| F4 | Modifier le plan dans DevTools via Redux/state | test.free | La modification locale n'a aucun effet — le backend reste l'autorité | |
| F5 | Rapport contenant des caractères HTML potentiellement malicieux | test.standard | Rendu comme texte, pas comme HTML (aucune exécution) | |
| F6 | CSP header présent dans la réponse HTTP | — | `Content-Security-Policy` défini dans les headers Cloudflare Pages | |
| F7 | Console navigateur : pas de log de JWT ou données perso | test.free | Aucun `console.log` contenant email, token, payload API | |
| # | Test | Compte | Résultat attendu | ✅/❌ |
| --- | --------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------- | ----- |
| F1 | URL directe `/t2-live` en Standard | test.standard | Redirection ou PaywallModal, pas d'accès à la page | |
| F2 | Inspecter DevTools → clés privées | — | Aucune clé `SERVICE_ROLE`, `GEMINI`, `STRIPE_SECRET` visible | |
| F3 | Inspecter DevTools → JWT en clair dans localStorage | test.free | JWT Supabase visible (normal, c'est un access token) mais pas de refresh token exposé | |
| F4 | Modifier le plan dans DevTools via Redux/state | test.free | La modification locale n'a aucun effet — le backend reste l'autorité | |
| F5 | Rapport contenant des caractères HTML potentiellement malicieux | test.standard | Rendu comme texte, pas comme HTML (aucune exécution) | |
| F6 | CSP header présent dans la réponse HTTP | — | `Content-Security-Policy` défini dans les headers Cloudflare Pages | |
| F7 | Console navigateur : pas de log de JWT ou données perso | test.free | Aucun `console.log` contenant email, token, payload API | |
---
@ -137,15 +143,15 @@
Tests à rejouer sur DevTools mobile emulation (iPhone SE, iPhone 12, Samsung Galaxy) ET sur vrai mobile si possible.
| # | Test | Résultat attendu | ✅/❌ |
|---|---|---|---|
| G1 | Page Home lisible sur écran 375px | Pas de débordement horizontal, CTA accessible | |
| G2 | Formulaire de login sur mobile | Champs bien dimensionnés, clavier virtuel ne cache pas le bouton | |
| G3 | Dashboard Free sur mobile | Compteur visible, aperçu flouté lisible | |
| G4 | Simulation EE sur mobile | Zone de texte utilisable, pas de zoom intempestif | |
| G5 | Enregistrement audio EO sur mobile | Permission microphone demandée, enregistrement fonctionnel | |
| G6 | T2 live sur mobile (Premium) | WebSocket fonctionne, audio bidirectionnel OK | |
| G7 | Modal PaywallModal sur mobile | Scrollable si contenu déborde, bouton fermeture accessible | |
| # | Test | Résultat attendu | ✅/❌ |
| --- | ---------------------------------- | ---------------------------------------------------------------- | ----- |
| G1 | Page Home lisible sur écran 375px | Pas de débordement horizontal, CTA accessible | |
| G2 | Formulaire de login sur mobile | Champs bien dimensionnés, clavier virtuel ne cache pas le bouton | |
| G3 | Dashboard Free sur mobile | Compteur visible, aperçu flouté lisible | |
| G4 | Simulation EE sur mobile | Zone de texte utilisable, pas de zoom intempestif | |
| G5 | Enregistrement audio EO sur mobile | Permission microphone demandée, enregistrement fonctionnel | |
| G6 | T2 live sur mobile (Premium) | WebSocket fonctionne, audio bidirectionnel OK | |
| G7 | Modal PaywallModal sur mobile | Scrollable si contenu déborde, bouton fermeture accessible | |
---
@ -153,18 +159,18 @@ Tests à rejouer sur DevTools mobile emulation (iPhone SE, iPhone 12, Samsung Ga
Les 10 scénarios les plus critiques, à rejouer dans l'ordre avant chaque déploiement production.
| # | Test | Description rapide |
|---|---|---|
| Z1 | Inscription + première simulation Free | Compte créé → simulation → rapport flouté visible |
| Z2 | Blocage quota Free | 6e simulation → modal de blocage |
| Z3 | Simulation Standard complète | Login → simulation → rapport complet → dashboard |
| Z4 | Mode examen bloqué en Standard | Bouton mode examen → message upgrade |
| Z5 | T2 live Premium | Login → T2 live → dialogue → rapport |
| Z6 | Mode examen EE complet | Lancement → timer → T=0 → envoi auto → rapport |
| Z7 | Paiement Free → Standard | Stripe Checkout → retour dashboard Standard sans rechargement |
| Z8 | Prorata Standard → Premium | Montant affiché → confirmation → accès Premium immédiat |
| Z9 | Déconnexion + accès protégé | Logout → accès `/dashboard` → redirection `/login` |
| Z10 | Responsive mobile Home + Login | Affichage correct sur iPhone SE |
| # | Test | Description rapide |
| --- | -------------------------------------- | ------------------------------------------------------------- |
| Z1 | Inscription + première simulation Free | Compte créé → simulation → rapport flouté visible |
| Z2 | Blocage quota Free | 6e simulation → modal de blocage |
| Z3 | Simulation Standard complète | Login → simulation → rapport complet → dashboard |
| Z4 | Mode examen bloqué en Standard | Bouton mode examen → message upgrade |
| Z5 | T2 live Premium | Login → T2 live → dialogue → rapport |
| Z6 | Mode examen EE complet | Lancement → timer → T=0 → envoi auto → rapport |
| Z7 | Paiement Free → Standard | Stripe Checkout → retour dashboard Standard sans rechargement |
| Z8 | Prorata Standard → Premium | Montant affiché → confirmation → accès Premium immédiat |
| Z9 | Déconnexion + accès protégé | Logout → accès `/dashboard` → redirection `/login` |
| Z10 | Responsive mobile Home + Login | Affichage correct sur iPhone SE |
---
@ -190,13 +196,13 @@ Test échoue
> Remplir après chaque session Claude Code frontend.
| Date | Session | Tests rejoués | Résultat | Notes |
|---|---|---|---|---|
| — | — | — | — | — |
| ---- | ------- | ------------- | -------- | ----- |
| — | — | — | — | — |
---
## Historique de ce document
| Version | Date | Changements |
|---|---|---|
| 1.0 | 2026-04-17 | Création initiale, 55 tests frontend |
| Version | Date | Changements |
| ------- | ---------- | ------------------------------------ |
| 1.0 | 2026-04-17 | Création initiale, 55 tests frontend |

View file

@ -299,18 +299,41 @@ Page de préparation :
— Explication du déroulé
(l'IA joue le rôle de l'examinateur)
— Consigne de la tâche affichée
— Zone de notes personnelles (brouillon local du candidat)
— Bouton "Suggestions d'idées"
→ propose des pistes pour nourrir la préparation
→ débloqué seulement quand les notes atteignent ~30 mots
(évite une demande d'idées "à vide")
— Bouton "Démarrer le dialogue"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SIMULATION LIVE — T2 Expression Orale
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
— L'IA ouvre le dialogue (première prise de parole de l'examinateur)
— Le candidat répond en audio en temps réel
— Le candidat ouvre l'interaction de service (il a besoin d'une
information et initie la conversation — format réel TCF Canada)
— L'examinateur (IA) répond ensuite et relance le dialogue
— Le candidat poursuit en audio en temps réel
— La voix de l'IA est jouée sans blanc ni coupure ;
voix de l'examinateur et voix du candidat partagent la même
horloge audio (dialogue fluide, sans décalage)
— Un indicateur signale qui a la parole
(le candidat parle / il écoute l'examinateur)
— L'IA adapte ses relances selon les réponses du candidat
— Durée libre en mode entraînement (pas de timer sur cette tâche)
— Timer de préparation 2:00 (transition automatique vers le dialogue à 0:00)
— Timer de dialogue 3:30 (210 s)
Pendant le dialogue, le candidat peut :
— "Annuler" → quitte la simulation sans évaluation,
aucun rapport généré, aucun enregistrement conservé
Fin du dialogue (candidat ou IA clôture)
Écran terminal :
— Bouton "Télécharger l'audio" (enregistrement WAV du dialogue complet,
voix candidat + examinateur mixées sur une seule piste)
— Bouton "Voir le rapport" → /rapport/:id
— Bouton "Nouvelle simulation" → relance le parcours T2 Live
Rapport complet généré (même structure que les autres tâches) ✅
Production enregistrée dans le dashboard avec tag "T2 Live"
@ -424,17 +447,18 @@ Webhook Stripe : customer.subscription.deleted
## 5. Matrice des upgrades / downgrades
| Depuis → Vers | Action | Montant facturé | Délai | Données |
|---|---|---|---|---|
| Free → Standard | Stripe Checkout | 19,90€ | Immédiat après webhook | Conservées |
| Free → Premium | Stripe Checkout | 39,90€ | Immédiat après webhook | Conservées |
| Standard → Premium | Prorata Stripe | Différence au prorata | Immédiat après webhook | Conservées |
| Premium → Standard | Résiliation + nouvel abonnement | 19,90€ | Immédiat après webhook | Conservées |
| Premium → Free | Résiliation | — | Immédiat après webhook | Conservées |
| Standard → Free | Résiliation | — | Immédiat après webhook | Conservées |
| Depuis → Vers | Action | Montant facturé | Délai | Données |
| ------------------ | ------------------------------- | --------------------- | ---------------------- | ---------- |
| Free → Standard | Stripe Checkout | 19,90€ | Immédiat après webhook | Conservées |
| Free → Premium | Stripe Checkout | 39,90€ | Immédiat après webhook | Conservées |
| Standard → Premium | Prorata Stripe | Différence au prorata | Immédiat après webhook | Conservées |
| Premium → Standard | Résiliation + nouvel abonnement | 19,90€ | Immédiat après webhook | Conservées |
| Premium → Free | Résiliation | — | Immédiat après webhook | Conservées |
| Standard → Free | Résiliation | — | Immédiat après webhook | Conservées |
> **Règle absolue :** les productions ne sont jamais supprimées, quel que soit le changement de plan.
> L'accès aux features change. Les données restent.
### Détail du prorata Standard → Premium
Stripe crédite automatiquement les jours non consommés du plan Standard et facture les jours restants au tarif Premium. L'utilisateur voit le montant exact avant de confirmer. Aucun calcul manuel requis côté code — comportement natif de Stripe via `subscription.update()` avec `proration_behavior: 'always_invoice'`.

225
docs/ROADMAP.md Normal file
View file

@ -0,0 +1,225 @@
# ROADMAP.md — Expria Frontend
> Source de vérité de l'ordre d'implémentation des sprints.
> Ne pas modifier sans validation de Hermann.
---
## Sprint 0 — Fondations ✅
1. Scaffold Vite + TypeScript + Tailwind + shadcn/ui
2. Structure de dossiers complète
3. docs/ copiés depuis backend + adaptations
4. ONBOARDING.md rédigé
## Sprint 0.5 — Design System ✅
- Direction artistique Boréal validée
- Tokens CSS dans index.css
- DESIGN_SYSTEM.md rédigé
## Sprint 1 — Auth + API layer ✅
5. auth-client.ts
6. api-client.ts
7. query-client.ts
8. entities/user/\*
9. features/auth (Login, Register, ProtectedRoute)
## Sprint 2 — Dashboard conditionnel ✅
10. usePlan hook
11. shared/components/PaywallModal
12. features/dashboard (Free / Standard / Premium)
## Sprint 3 — Simulations EE ✅
13. entities/production/_ + entities/report/_
14. features/simulations (EE T1/T2/T3)
15. Affichage rapport avec floutage conditionnel
## Sprint 3.5 — Clean
- Factorisation des fichiers modifiés Sprint 3
- Tests manuels Groupe B + C rejoués
- Commit refactor(simulation-ee)
## Sprint 3.6a — Qualité correction — Backend ✅
- Remplacement prompt maître (docs/Prompt_maître.md) + intégration taxonomie erreurs (docs/TAXONOMIE_ERREURS.md)
- Remplacement prompt production modèle (docs/Prompt_production_modèle.md) — cible fixe NCLC 9
- Génération parallèle correction + exercices + modèle (await correction, fire-and-forget sur les deux autres)
- Nouveaux champs DB : revelation, diagnostic, conseil_nclc, erreurs_codes, exercices_status, modele_status, nclc_cible
- Mise à jour GET /simulations/:id
- Migration SQL : `supabase/migrations/004_sprint_3_6a_qualite_correction.sql` (à exécuter manuellement)
- Tests : 173 tests verts (+18 vs baseline)
## Sprint 3.6b — Qualité correction — Frontend ✅
- Sélecteur NCLC cible dans SimulationForm (9 ou 10, défaut 9) — NclcCibleSelector
- RapportPage réécrite : ScoreHero (jauge + seuil NCLC cible + écart), RevelationCards, DiagnosticCallout, CritereCard enrichie (exemple/suggestion/astuce + codes taxonomie), ConseilNclcCallout
- ExerciceInteractive : badge difficulté, zone texte, bouton Indice (une fois), bouton Voir la correction (activé après saisie), explication
- ProductionModeleSection : texte final + notes pédagogiques + transformations original/amélioré + message
- JobStatusFallback : gère exercices_status / modele_status (pending / error) — refresh manuel, polling tracé en FTD-24
- Gating plan conforme PLANS_TARIFAIRES.md : revelation/diagnostic/conseil_nclc tous plans ; criteres/exercices/modele Standard+
- Tests : 84 verts (+8 vs baseline — floutage + helpers lib + ExerciceInteractive)
## Sprint 3.7 — Historique ✅
- Backend : `GET /simulations` — liste paginée des productions de l'utilisateur connecté (page/limit, tri `created_at DESC`, projection légère). 186 tests backend verts.
- Frontend : page `/historique` (route sous AppLayout), liste d'items (date relative, tâche, score /20, NCLC, badge Examen / En cours), pagination Précédent/Suivant, clic → `/rapport/:id`.
- Gating plan : Free → aperçu flouté + CTA « Passer en Standard » (`hasAccess(plan, 'dashboard')`) ; Standard + Premium → liste complète.
- État vide : CTA « Démarrer une simulation ».
- Hook `useSimulationsList(page, limit)` — TanStack Query, `staleTime: 30s`, `keepPreviousData` pour transitions fluides.
- Helper `formatRelativeDate` (Intl.RelativeTimeFormat, zéro dépendance).
- 102 tests frontend verts (+18 vs baseline 84).
## Sprint 3.6c — Analyse patterns (Premium) ✅
- Backend : `GET /users/patterns` — agrégation des `erreurs_codes` sur les 5 dernières productions corrigées, seuil 3/5, tri DESC, cache `pattern_analyses` avec invalidation si nouvelle production plus récente que la dernière analyse.
- Backend : exercices long terme générés par DeepSeek sur patterns confirmés — format `{ consigne, exemple, correction, astuce }` avec prompt dédié (température 0.4, timeout 20 s). Dégradation gracieuse si DeepSeek échoue.
- Backend : indice de préparation 0→100 — formule 60 % score moyen + 20 % régularité + 20 % tendance, messages figés (`<40`, `40-70`, `>70`).
- Backend : migration SQL `005_sprint_3_6c_pattern_analyses.sql` (RLS SELECT par user_id, index composite, CHECK constraints).
- Backend : 205 tests verts (+19 vs baseline 186).
- Frontend : page `/progression` — orchestration hero (indice + jauge), liste patterns, cartes exercices long terme, footer « il y a X » ; gate plan via `hasAccess('pattern_analysis')` (Free/Standard → aperçu flouté + upgrade).
- Frontend : `PatternExerciceCard` — composant lesson-style dédié (non interactif, UX distincte de `ExerciceInteractive`) avec encart astuce proéminent.
- Frontend : Dashboard Premium — section compacte `MonProfilPreparation` (MetricCard indice + nb patterns + CTA vers `/progression`). Absente pour Free/Standard.
- Frontend : hook `usePatterns` (staleTime 60 s, cache partagé entre page et dashboard, `enabled` conditionné par feature).
- Frontend : 115 tests verts (+13 vs baseline 102).
## Sprint DA Charcoal — Reskin ✅
- Remplacement palette Boréal par Charcoal (dark default, light override)
- Sidebar navy permanent, layout radial-gradient, anti-FOUC
- Renommage tokens sur ~45 composants + inversion dark:/light: shadcn
- ADR 006 mis à jour
## Sprint UI Polish — Sidebar + Topbar + Dashboard ✅
- Sidebar : icônes lucide, cadenas gating, badge upgrade, user footer, logo "EX|PRIA"
- Topbar : sticky backdrop-blur, breadcrumb centralisé, recherche placeholder
- Dashboard : split Free/Standard/Premium, NclcHero + StatCards + RecentSimulations + NextStepCard + PaywallBanner refonte
- MobileHeader supprimé (remplacé par Topbar)
## Sprint 4 — Simulations EO (audio) ✅
- MediaRecorder + Gemini batch transcription (EO T1/T3)
- Questionnaire T1 + génération présentation IA (POST /presentations/generate)
- Auto-submit à expiration de la durée recommandée
- Rapport EO format 3.6a (4 critères officiels TCF Canada)
## Sprint 4.5 — Clean
- Factorisation des fichiers modifiés Sprint 4
- Tests manuels Groupe B + D rejoués
- Commit refactor(simulation-eo)
## Sprint 4.6 — UI EO (waveform + timeline)
- Waveform visualizer pendant l'enregistrement (barres audio animées)
- Barre timeline colorée : verte → orange (75%) → rouge (dernières 15s)
- Applicable à toutes les tâches EO (T1 et T3)
## Sprint 4.7 — Historique refonte
- Stats en haut : Total simulations, Score moyen, Meilleur score
- Filtre par tâche (EE T1/T2/T3, EO T1/T3, Examen blanc)
- Filtre par période (Ce mois, 3 mois, Tout)
- Design conforme aux captures de référence
## Sprint 4.8 — Phonologie EO
- Affichage note_phonologie dans RapportPage (déjà stocké en base)
- Analyse phonologique réelle via Gemini audio (TD-08 backend)
- Score phonologie dans les 4 critères EO (actuellement fixé à 0)
## Sprint 5 — Billing ✅
- **5a (backend)** : TD-13 webhook idempotency (table `stripe_webhook_events` + helpers + 10 tests) ; route `POST /stripe/customer-portal` + `createBillingPortalSession` ; doc cleanup `ARCHITECTURE-backend.md` (`POST /plans/upgrade` retiré, duplication doc) ; tests backend 261 → 278.
- **5b (frontend)** : `PricingPage` `/plan` (3 colonnes Découverte/Standard/Premium) + `useStripeCheckout` initial + uniformisation CTA upgrade « Voir les plans » sur 5 emplacements ; env vars `VITE_STRIPE_PRICE_*` ; tests 198 → 203.
- **5c (frontend + cross-repo backend fix)** : `useStripeCheckout` hook isolé + `useUpgradeSuccessHandler` (détection `?upgrade=success` + invalidation cache plan + URL clean) + `UpgradeSuccessBanner` ; migration `PricingPage` + injection banner `DashboardPage` ; fix backend `cancel_url /tarifs → /plan` ; tests 203 → 212.
- **5d (frontend)** : `useCustomerPortal` hook + `AccountBillingSection` + `ParametresPage` `/parametres` (Abonnement + Session/déconnexion) ; **Standard→Premium routé via Customer Portal** (prorata natif Stripe) ; tests 212 → 219.
## Sprint 5.5 — Clean
- Factorisation des fichiers modifiés Sprint 5
- Tests manuels Groupe E rejoués
- Commit refactor(billing)
## Sprint 6 — T2 Live ✅
18. features/t2-live (ws-client + audio worklet + state machine)
- **6b (frontend)** : capture micro (AudioWorklet 16 kHz uplink) + playback IA + helpers audio purs.
- **6c (frontend)** : state machine T2 (9 états), `useT2LiveSession` (WebSocket + audio + format Gemini natif), pages Sujets / Préparation / Dialogue + routes ; carte EO T2 Live déverrouillée Premium.
- **6d (backend)** : prompt T2 durci (anti-relance, interdiction du `?`, règles dures Gemini — TD-22), VAD `realtimeInputConfig` réintégré, `@google/genai` retiré. Validé Groupe D en conditions réelles. Commits `94387a7` (code) + `5f7e52d` (docs), poussés sur `forgejo`.
- **6e (frontend)** : architecture audio « Voie A » — un seul AudioContext au rate natif partagé (capture + playback + enregistrement), mix temps réel via tap worklet, WAV mono single-track aligné, indicateur de prise de parole (VAD), correction des blancs EO, nettoyage `[BISECT]`. Tests 269/37 ; validation audio à l'oreille.
## Sprint 6.5 — Clean
- Factorisation des fichiers modifiés Sprint 6
- Tests manuels Groupe D rejoués
- Commit refactor(t2-live)
## Sprint 7 — T1 Live (interruption aléatoire)
- **7a (backend) ✅** : extension du proxy WebSocket Gemini Live (`gemini-3.1-flash-live-preview`, ws brut, pas de SDK) au mode T1 — system prompt « examinateur », décision d'interruption probabiliste, génération de la question de relance sur transcription partielle (DeepSeek). Réutilise l'infra T2 Live. Scoring EO 5 critères × /4. Phonologie live = 0 (TD-08, gelé). Contraintes héritées : pas de `speechConfig`. Livré : commits `868bd09` (code) + `3722e2a` (docs) ; dettes tracées TD-23/24/25 (cf. `TECH_DEBT-backend.md`).
- **7b (frontend) ✅** : UI T1 Live — machine d'état T1 (8 états, `interrupted ⇄ presenting`), `useT1LiveSession` (WS `/t1/live`, sans message `context` post-Patch 7a, uplink coupé par ref pendant interruption), `T1PreparationPage` / `T1DialoguePage` / `T1SpeakingIndicator`, carte `EO_T1_LIVE` gatée Premium (`oral_t2_live`). Parcours simplifié carte → prépa → dialogue. `T1LiveQuestionnairePage` + `T1LiveContext` retirés. Réutilise les hooks audio T2 (FTD-44 gelée). **Bugs amont observés au test manuel** (hors contrôle frontend) : **FTD-45** (relances Gemini hors-sujet, extension TD-23) et **FTD-46** (transcription Gemini Live hasardeuse).
## Sprint 7.5 — Clean
- Factorisation des fichiers modifiés Sprint 7
- Tests manuels Groupe D étendu (T1 Live) rejoués
- Commit refactor(t1-live)
## Sprint 7e — Transcription live à l'écran (T2 + T1)
- Affichage incrémental temps réel des prises de parole pendant le dialogue : router `inputTranscription` + `outputTranscription` (déjà produits côté backend pour l'évaluation) jusqu'au frontend via le WebSocket, puis rendu progressif à l'écran.
- Placé après le T1 Live pour couvrir **les deux modes live** d'un seul chantier.
- **Chantier non trivial** (flux WS + affichage incrémental) — à décomposer en sous-étapes ; pas « cosmétique ».
- **MAJ post-7a** : source backend de la transcription déjà disponible (confirmé par 7a).
- **Caveat TD-23** : en VAD manuel, `inputTranscription` candidat n'est flushé qu'à `activityEnd` (pas token par token) → l'affichage incrémental temps réel n'est possible que pour `outputTranscription` (examinateur) ; l'incrémental côté candidat est à reconcevoir.
## Sprint 8 — Mode Examen
- Timer inarrêtable + readOnly à T=0
## Sprint 8.5 — Clean
- Factorisation des fichiers modifiés Sprint 8
- Tests manuels Groupe D rejoués
- Commit refactor(exam-mode)
## Sprint 9 — Page Admin (outillage opérationnel)
- **9a (backend)** : middleware auth admin (modèle de sécurité à trancher — cf. SECURITY.md) ; endpoint agrégation chiffres clés (inscrits, corrections jour/mois, abonnements actifs, waitlist) ; endpoint waitlist (liste + export CSV).
- **9b (backend)** : CRUD sujets (liste + filtres mode·tâche·statut, create, update, toggle actif, delete) — réutilise le modèle de sujets existant, service role.
- **9c (frontend)** : route admin protégée (hors navigation publique) + Dashboard chiffres clés (compteurs cliquables, refresh périodique).
- **9d (frontend)** : module Gestion des sujets + module Waitlist (tableau + bouton Export CSV).
## Sprint 9.5 — Clean
- Factorisation des fichiers modifiés Sprint 9
- Tests manuels Groupe H (admin) joués
- Commit refactor(admin)
## Sprint 10 — Paiement Orange Money (semi-manuel)
- **10a (backend)** : migration Supabase `commandes_om` (RLS, accès service role) ; endpoint création de commande (code unique + insertion) ; job d'expiration via scheduler Render (pas de cron Vercel).
- **10b (backend)** : endpoint d'activation → écrit le plan via le même chemin que le webhook Stripe (planController / source de vérité unique, ADR 005) — jamais d'écriture SQL directe du plan ; email de confirmation client.
- **10c (frontend)** : page client `/paiement-om` (depuis `/plan`, lien WhatsApp pré-rempli) + ajout de l'option « Payer via Orange Money » sur la page plans.
- **10d (frontend)** : module Commandes OM dans l'admin (onglets en attente / activées / expirées, bouton Activer, countdown, note interne).
## Sprint 10.5 — Clean
- Factorisation des fichiers modifiés Sprint 10
- Tests manuels Groupe H étendu (flux OM complet) joués
- Commit refactor(paiement-om)
## Sprint 11 — Pré-lancement
- MAINTENANCE_MODE implémenté ✅ (2026-04-19)
- Sentry configuré
- /ultrareview avant bascule
- Smoke test Groupe Z complet
- Procédure DEPLOYMENT.md exécutée

309
docs/TAXONOMIE_ERREURS.md Normal file
View file

@ -0,0 +1,309 @@
# TAXONOMIE_ERREURS.md — Expria V2
> **Document de référence — Version 1.0**
> Taxonomie fermée des erreurs détectables en expression écrite TCF Canada.
> Utilisée par le prompt maître pour nommer les erreurs de façon stable et agrégeable.
> Mise à jour après observation de nouveaux patterns en production.
>
> **Principe :** DeepSeek doit obligatoirement choisir un code dans cette liste pour chaque erreur identifiée.
> Si l'erreur ne correspond à aucun code existant, DeepSeek utilise le code `autre` du critère concerné
> et fournit une description textuelle. Ces occurrences sont remontées pour enrichissement de la taxonomie.
---
## Structure d'une erreur dans le rapport
Chaque erreur retournée par le prompt maître doit respecter ce format :
```json
{
"code": "virgule_exces",
"critere": "competence_grammaticale",
"description": "description libre si code=autre, null sinon"
}
```
---
## Critère 1 — Adéquation à la tâche et au registre
`critere: "adequation_tache"`
### Contenu
| Code | Description |
|---|---|
| `hors_sujet_total` | La production ne répond pas à la consigne |
| `hors_sujet_partiel` | Un ou plusieurs points de la consigne sont ignorés |
| `information_manquante` | Une information demandée explicitement dans la consigne est absente |
| `enonce_copie` | Le candidat recopie l'énoncé au lieu de le reformuler |
### Longueur
| Code | Description |
|---|---|
| `longueur_insuffisante` | Sous le minimum de mots requis (score plafonné automatiquement) |
| `longueur_excessive` | Au-dessus du maximum de mots requis |
### Format
| Code | Description |
|---|---|
| `format_non_respecte` | Type de texte non respecté (mail sans objet, blog sans accroche) |
| `salutation_absente` | Pas de formule d'appel |
| `cloture_absente` | Pas de formule de clôture ou de signature |
| `structure_absente` | Texte bloc sans paragraphes |
### Registre
| Code | Description |
|---|---|
| `registre_trop_formel` | Registre soutenu alors que familier requis |
| `registre_trop_familier` | Registre familier alors que formel requis |
| `abreviations_sms` | Usage de "bjr", "svp", "stp" hors contexte très informel |
| `tutoiement_inadequat` | Tutoiement quand vouvoiement requis, ou inversement |
### Non couvert
| Code | Description |
|---|---|
| `autre` | Erreur d'adéquation non couverte par la taxonomie — **description obligatoire** |
---
## Critère 2 — Cohérence et cohésion du discours
`critere: "coherence_cohesion"`
### Structure
| Code | Description |
|---|---|
| `introduction_absente` | Pas d'entrée en matière ou d'accroche |
| `conclusion_absente` | Pas de clôture ou de phrase de synthèse |
| `paragraphes_absents` | Texte bloc sans découpage en paragraphes |
| `progression_illogique` | Les idées ne s'enchaînent pas dans un ordre logique |
### Connecteurs
| Code | Description |
|---|---|
| `connecteurs_absents` | Phrases juxtaposées sans lien logique |
| `connecteurs_repetes` | Même connecteur utilisé en boucle (ex : "et" x5) |
| `connecteurs_inadequats` | Connecteur utilisé à contresens (ex : "donc" pour introduire une cause) |
| `connecteurs_insuffisants` | Connecteurs trop simples pour le niveau visé (uniquement "mais", "et", "car") |
### Cohérence thématique
| Code | Description |
|---|---|
| `idee_non_developpee` | Idée introduite puis abandonnée sans explication |
| `repetition_idee` | Même idée reformulée plusieurs fois sans apport nouveau |
| `contradiction_interne` | Deux affirmations contradictoires dans le même texte |
| `hors_propos` | Phrase ou paragraphe sans lien avec le reste du texte |
### Cohésion référentielle
| Code | Description |
|---|---|
| `pronoms_ambigus` | "il", "elle", "ils" sans antécédent clair |
| `substitution_absente` | Même mot répété au lieu d'utiliser un pronom ou un synonyme |
| `rupture_temporelle` | Mélange incohérent des temps dans le récit |
### Non couvert
| Code | Description |
|---|---|
| `autre` | Erreur de cohérence/cohésion non couverte par la taxonomie — **description obligatoire** |
---
## Critère 3 — Compétence lexicale
`critere: "competence_lexicale"`
### Étendue du vocabulaire
| Code | Description |
|---|---|
| `vocabulaire_basique` | Mots trop simples pour le niveau visé (ex : "bien" au lieu de "remarquable") |
| `vocabulaire_insuffisant` | Manque de mots pour exprimer une idée, recours à des périphrases maladroites |
| `registre_lexical_inadequat` | Mots familiers dans un contexte formel, ou inversement |
### Précision
| Code | Description |
|---|---|
| `mot_imprecis` | Mot approximatif (ex : "faire" au lieu de "effectuer", "réaliser", "accomplir") |
| `contresens_lexical` | Mot utilisé dans un sens erroné |
| `anglicisme` | Mot anglais utilisé à la place du mot français (ex : "checker" au lieu de "vérifier") |
| `calque_syntaxique` | Construction calquée sur une autre langue |
### Variété
| Code | Description |
|---|---|
| `repetition_lexicale` | Même mot répété excessivement dans le texte |
| `synonymes_absents` | Absence de variation lexicale sur un même champ sémantique |
| `expressions_figees_absentes` | Absence d'expressions idiomatiques attendues au niveau visé |
### Orthographe lexicale
| Code | Description |
|---|---|
| `faute_orthographe_courante` | Erreur sur un mot courant (ex : "apelle" au lieu de "appelle") |
| `confusion_homophones` | "sa"/"ça", "a"/"à", "ou"/"où", "ce"/"se", "on"/"ont" |
| `majuscules_incorrectes` | Majuscule absente ou mal placée |
### Non couvert
| Code | Description |
|---|---|
| `autre` | Erreur lexicale non couverte par la taxonomie — **description obligatoire** |
---
## Critère 4 — Compétence grammaticale
`critere: "competence_grammaticale"`
### Accords
| Code | Description |
|---|---|
| `accord_sujet_verbe` | "les enfants joue" au lieu de "jouent" |
| `accord_adjectif_nom` | "une révolution positif" au lieu de "positive" |
| `accord_participe_passe` | "elle est parti" au lieu de "partie" |
| `accord_determinant_nom` | "un table" au lieu de "une table" |
### Conjugaison
| Code | Description |
|---|---|
| `temps_verbal_inadequat` | Présent au lieu de passé composé, futur au lieu de conditionnel |
| `subjonctif_absent` | Indicatif utilisé là où le subjonctif est requis |
| `subjonctif_incorrect` | Subjonctif utilisé mais mal formé |
| `conditionnel_absent` | Conditionnel requis mais absent (politesse, hypothèse) |
| `concordance_temps` | Incohérence des temps dans un même passage |
### Syntaxe
| Code | Description |
|---|---|
| `phrase_incomplete` | Phrase sans verbe conjugué ou sans sujet |
| `phrase_trop_longue` | Phrase surchargée, incompréhensible |
| `ordre_mots_incorrect` | "je ne sais pas où est-il" au lieu de "où il est" |
| `subordination_absente` | Phrases simples juxtaposées là où une subordonnée est attendue |
| `subordination_incorrecte` | Connecteur de subordination mal utilisé |
### Ponctuation
| Code | Description |
|---|---|
| `virgule_exces` | Virgules à outrance qui coupent le flux naturel |
| `virgule_absence` | Absence de virgule là où elle est requise |
| `point_absent` | Phrases non délimitées, texte continu sans point |
| `ponctuation_incorrecte` | Usage erroné de ";" ":" "!" "?" |
### Prépositions
| Code | Description |
|---|---|
| `preposition_absente` | "je pense que c'est important aller" au lieu de "d'aller" |
| `preposition_incorrecte` | "je rêve à partir" au lieu de "de partir" |
| `preposition_superflue` | Préposition ajoutée inutilement |
### Morphologie
| Code | Description |
|---|---|
| `genre_incorrect` | "la problème" au lieu de "le problème" |
| `nombre_incorrect` | Pluriel absent ou mal formé |
| `negation_incomplete` | "je sais pas" au lieu de "je ne sais pas" |
### Non couvert
| Code | Description |
|---|---|
| `autre` | Erreur grammaticale non couverte par la taxonomie — **description obligatoire** |
---
## Règles d'utilisation pour DeepSeek
1. **Chaque erreur identifiée dans un rapport doit avoir un code de cette liste.**
2. **Un seul code par erreur** — choisir le plus précis.
3. **Le code `autre` est autorisé** mais exige une `description` textuelle non nulle.
4. **Les codes `autre` observés en production** sont remontés à Hermann pour décision d'intégration.
5. **La détection de patterns** (analyse multi-productions) agrège les codes — un pattern est confirmé si le même code apparaît dans ≥ 3 productions sur les 5 dernières.
---
## Procédure d'enrichissement
Quand un code `autre` revient ≥ 3 fois en production :
1. Hermann identifie le pattern dans les logs
2. Un nouveau code est proposé et validé
3. `TAXONOMIE_ERREURS.md` est mis à jour (bump de version)
4. Le prompt maître est mis à jour dans le même commit
5. Les anciennes entrées `autre` concernées sont reclassifiées si possible
---
## Index des codes (référence rapide)
| Code | Critère |
|---|---|
| `hors_sujet_total` | adequation_tache |
| `hors_sujet_partiel` | adequation_tache |
| `information_manquante` | adequation_tache |
| `enonce_copie` | adequation_tache |
| `longueur_insuffisante` | adequation_tache |
| `longueur_excessive` | adequation_tache |
| `format_non_respecte` | adequation_tache |
| `salutation_absente` | adequation_tache |
| `cloture_absente` | adequation_tache |
| `structure_absente` | adequation_tache |
| `registre_trop_formel` | adequation_tache |
| `registre_trop_familier` | adequation_tache |
| `abreviations_sms` | adequation_tache |
| `tutoiement_inadequat` | adequation_tache |
| `introduction_absente` | coherence_cohesion |
| `conclusion_absente` | coherence_cohesion |
| `paragraphes_absents` | coherence_cohesion |
| `progression_illogique` | coherence_cohesion |
| `connecteurs_absents` | coherence_cohesion |
| `connecteurs_repetes` | coherence_cohesion |
| `connecteurs_inadequats` | coherence_cohesion |
| `connecteurs_insuffisants` | coherence_cohesion |
| `idee_non_developpee` | coherence_cohesion |
| `repetition_idee` | coherence_cohesion |
| `contradiction_interne` | coherence_cohesion |
| `hors_propos` | coherence_cohesion |
| `pronoms_ambigus` | coherence_cohesion |
| `substitution_absente` | coherence_cohesion |
| `rupture_temporelle` | coherence_cohesion |
| `vocabulaire_basique` | competence_lexicale |
| `vocabulaire_insuffisant` | competence_lexicale |
| `registre_lexical_inadequat` | competence_lexicale |
| `mot_imprecis` | competence_lexicale |
| `contresens_lexical` | competence_lexicale |
| `anglicisme` | competence_lexicale |
| `calque_syntaxique` | competence_lexicale |
| `repetition_lexicale` | competence_lexicale |
| `synonymes_absents` | competence_lexicale |
| `expressions_figees_absentes` | competence_lexicale |
| `faute_orthographe_courante` | competence_lexicale |
| `confusion_homophones` | competence_lexicale |
| `majuscules_incorrectes` | competence_lexicale |
| `accord_sujet_verbe` | competence_grammaticale |
| `accord_adjectif_nom` | competence_grammaticale |
| `accord_participe_passe` | competence_grammaticale |
| `accord_determinant_nom` | competence_grammaticale |
| `temps_verbal_inadequat` | competence_grammaticale |
| `subjonctif_absent` | competence_grammaticale |
| `subjonctif_incorrect` | competence_grammaticale |
| `conditionnel_absent` | competence_grammaticale |
| `concordance_temps` | competence_grammaticale |
| `phrase_incomplete` | competence_grammaticale |
| `phrase_trop_longue` | competence_grammaticale |
| `ordre_mots_incorrect` | competence_grammaticale |
| `subordination_absente` | competence_grammaticale |
| `subordination_incorrecte` | competence_grammaticale |
| `virgule_exces` | competence_grammaticale |
| `virgule_absence` | competence_grammaticale |
| `point_absent` | competence_grammaticale |
| `ponctuation_incorrecte` | competence_grammaticale |
| `preposition_absente` | competence_grammaticale |
| `preposition_incorrecte` | competence_grammaticale |
| `preposition_superflue` | competence_grammaticale |
| `genre_incorrect` | competence_grammaticale |
| `nombre_incorrect` | competence_grammaticale |
| `negation_incomplete` | competence_grammaticale |
---
## Historique de ce document
| Version | Date | Changements |
|---|---|---|
| 1.0 | 2026-04-22 | Création initiale — 4 critères, 63 codes + 4 codes `autre` |

View file

@ -1,6 +1,6 @@
# TECH_DEBT.md — Expria Frontend
> **Document de référence — Version 1.2**
> **Document de référence — Version 1.30**
> Ce document recense les décisions techniques prises par pragmatisme qui devront être revisitées, les stubs temporaires, et les fonctionnalités reportées.
> À mettre à jour après chaque session de développement.
>
@ -24,10 +24,12 @@ Pour éviter que ce document devienne un cimetière de dette ignorée (le piège
## 1. Dettes héritées de l'audit backend (2026-04-17)
### FTD-01 — Inconsistance des codes de validation côté backend
**Priorité :** 🟡 Important
**Statut :** Ouvert — dépend du backend
**Estimation de session :** 2h (backend uniquement)
**Description :** Le backend utilise deux codes d'erreur pour la même classe (corps de requête invalide) :
- `VALIDATION_ERROR` dans `routes/simulations.ts` et `routes/corrections.ts`
- `INVALID_BODY` dans `routes/plans.ts` et `routes/stripe.ts`
@ -41,6 +43,7 @@ Le frontend gère les deux de la même manière (voir `ARCHITECTURE.md` §5), ma
---
### FTD-02 — Header `X-API-Version` envoyé mais non vérifié
**Priorité :** 🟡 Important
**Statut :** Ouvert
**Estimation de session :** 1h (backend) + 30min (frontend)
@ -49,6 +52,7 @@ Le frontend gère les deux de la même manière (voir `ARCHITECTURE.md` §5), ma
**Impact :** si le backend évolue de façon breaking (ex : format de réponse de `/plans/status` modifié), le frontend peut recevoir un payload incompatible sans message d'erreur clair. Symptôme : bugs silencieux en production après un déploiement backend.
**À faire :**
- Backend : ajouter un middleware qui lit `X-API-Version`, le log, et retourne `HTTP 426 Upgrade Required` avec code `API_VERSION_MISMATCH` si breaking change
- Frontend : gérer `API_VERSION_MISMATCH` dans `api-client.ts` → afficher un message "Une nouvelle version est disponible, veuillez rafraîchir la page"
@ -57,13 +61,16 @@ Le frontend gère les deux de la même manière (voir `ARCHITECTURE.md` §5), ma
---
### FTD-03 — Quirk `status` dans le body des erreurs de simulations/corrections
**Priorité :** 🟢 Mineur
**Statut :** Ouvert — dépend du backend
**Estimation de session :** 1h (backend uniquement)
**Description :** Les routes `POST /simulations` et `POST /corrections/ee,eo` renvoient un champ `status` dans le body JSON d'erreur, qui duplique le code HTTP :
```json
{ "error": true, "code": "QUOTA_REACHED", "message": "...", "status": 403 }
```
Vient du pattern `c.json(result, result.status)``result` contient déjà `status`. C'est ignorable côté frontend (on ne lit pas ce champ), mais c'est du bruit.
**À faire côté backend :** nettoyer les objets d'erreur retournés par `simulationController` et `correctionController` pour ne pas contenir de champ `status`. Tracé dans **TD-16** à créer dans `expria-backend/docs/TECH_DEBT.md`.
@ -74,38 +81,8 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
## 2. Dettes frontend propres
### FTD-04 — Documents miroir sans automatisation de synchronisation
**Priorité :** 🟢 Mineur
**Statut :** Ouvert — accepté par design (voir ADR 004)
**Estimation de session :** 1 jour (mise en place monorepo)
**Description :** Les documents `PLANS_TARIFAIRES.md`, `PARCOURS_UTILISATEURS.md`, et le fichier de code `src/entities/user/access.ts` existent à l'identique dans les deux dépôts (frontend et backend). La synchronisation est manuelle — si un changement est fait dans un dépôt sans être répercuté dans l'autre, divergence silencieuse.
**Mitigation actuelle :**
- Règle G de `DEVELOPMENT_PRINCIPLES.md` (modifications simultanées dans le même commit logique)
- Commentaire `// SOURCE OF TRUTH:` en tête de `access.ts`
- Tests de parité dans `src/entities/user/__tests__/access.test.ts` (calqués sur les tests backend)
**À faire si la dette devient trop coûteuse :**
- Migrer vers un monorepo pnpm workspaces avec package partagé `@expria/types-and-access`
- OU ajouter un script CI qui vérifie que le hash SHA-256 de `access.ts` matche entre les deux dépôts
**Condition de résolution :** après 3+ mois de production stable, ou si une divergence silencieuse cause un bug.
---
### FTD-05 — Ancien scaffold frontend possiblement caduc
**Priorité :** 🟡 Important
**Statut :** Ouvert — diagnostic en cours (session Claude Code)
**Estimation de session :** variable selon diagnostic
**Description :** Un scaffold frontend a été créé au démarrage du projet (fin mars 2026 ou début avril 2026), avant que les décisions architecturales récentes (entities/features/shared, auth-client/api-client découplés, pas de Zustand, etc.) ne soient prises. Le contenu actuel de `D:\expria-frontend\` peut donc contenir des fichiers qui ne matchent plus l'architecture cible.
**À faire :** session Claude Code (première session frontend de la V2) qui fait un état des lieux complet et propose une stratégie (clean slate / refactor progressif / adaptation en place). Cf. prompt de session dans l'historique de conversation Claude AI du 2026-04-17.
**Condition de résolution :** fin du Sprint 0 (scaffold conforme à `ARCHITECTURE.md`).
---
### FTD-10 — Semgrep non intégré en CI
**Priorité :** 🟢 Mineur
**Statut :** Reporté — après MVP
**Estimation de session :** 2h
@ -114,6 +91,7 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
**Impact actuel :** `npm audit` couvre les vulnérabilités des dépendances npm, mais aucune analyse statique de sécurité (SAST) n'est faite sur le code custom du projet. Des patterns dangereux (`eval`, `innerHTML` sans DOMPurify, secrets en dur, etc.) passeraient inaperçus en CI.
**À faire :**
- Ajouter un step Semgrep au workflow `.github/workflows/ci.yml`
- Utiliser les rulesets `auto` + `r2c-security-audit` + `r2c-ci`
- Configurer la sortie pour bloquer sur sévérité ERROR uniquement
@ -123,67 +101,230 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
---
### FTD-14 — Anti-FOUC thème : script inline manquant dans `<head>`
**Priorité :** 🟡 Important
**Statut :** Ouvert — à faire avant déploiement production
**Estimation de session :** 30 min
**Description :** Le `ThemeProvider` applique la classe `.dark` sur `<html>` après l'hydratation React (`useEffect`). Entre le premier paint du navigateur et l'exécution de React, la page s'affiche brièvement en mode clair même si l'utilisateur a choisi le mode sombre — c'est le FOUC (Flash Of Unstyled Content).
**Fix :** ajouter un script inline bloquant dans le `<head>` de `index.html` qui lit `localStorage.getItem('expria-theme')` (et `prefers-color-scheme` en fallback) et applique `.dark` sur `document.documentElement` avant le premier paint. Ce script doit être minifié et inliné (non-async, non-defer) pour garantir l'exécution avant le CSS.
```html
<script>
(function(){var t=localStorage.getItem('expria-theme');
if(t==='dark'||(t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches))
document.documentElement.classList.add('dark')})()
</script>
```
**Impact actuel :** visible uniquement pour les utilisateurs en mode sombre — bref flash de fond clair au chargement. Acceptable en dev, indésirable en production.
**Condition de résolution :** avant la première mise en production (Sprint 1 ou avant).
> FTD-14 résolu au Sprint 5.5 (2026-04-26) — voir §5 Historique des résolutions.
> FTD-17, FTD-18, FTD-19 résolus au Sprint 3.5 (2026-04-22) — voir §5 Historique des résolutions.
---
### FTD-15 — Option `'system'` manquante dans ThemeProvider
### FTD-30 — Rotation token Deepgram sans grace period
**Priorité :** 🟢 Mineur
**Statut :** Reporté — après MVP
**Statut :** Gelé — Sprint 4c-3 (Deepgram live mis en pause au profit de Gemini batch backend)
**Estimation de session :** 0,5 jour
**Description :** `useDeepgramLive` redemande un token à T-60 s avant expiration et hot-swap la WebSocket. Si la nouvelle échoue à s'ouvrir avant l'expiration, des chunks peuvent être perdus. **Code dormant depuis le Sprint 4c-3** — à ré-évaluer si Deepgram live est réactivé (cf. FTD-37).
**À faire :** retry policy explicite + maintien de l'ancienne connexion tant que la nouvelle n'a pas reçu son premier message. Hors scope tant que le hook reste dormant.
---
### FTD-31 — Page `EnregistrementEOPage` non resumable au refresh
**Priorité :** 🟢 Mineur
**Statut :** Ouvert — introduit au Sprint 4c-1
**Estimation de session :** 0,5 jour
**Description :** Si l'utilisateur ferme l'onglet ou recharge la page pendant l'enregistrement, le transcript live et l'audio sont perdus. La simulation côté backend reste avec `rapport=null` mais sans contenu textuel : au resume, le provider redirige vers `/simulation/eo/pre-enregistrement` et l'utilisateur doit recommencer.
**À faire :** persister un buffer du transcript final dans `localStorage` à chaque `is_final=true`, restaurer au resume comme point de départ. Décider si on autorise la reprise « par-dessus » ou si on impose un nouveau départ.
**Condition de résolution :** session dédiée autosave EO post-MVP.
---
### FTD-32 — `useAudioRecorder` non testé sur Safari iOS
**Priorité :** 🟢 Mineur
**Statut :** Ouvert — introduit au Sprint 4c-1
**Estimation de session :** 0,5 jour
**Description :** `pickMimeType()` propose un fallback `audio/mp4` pour Safari, mais aucun test manuel n'a été réalisé. Le bouton « Télécharger l'audio » nomme toujours le fichier `.webm` même quand le mime réel est `audio/mp4`.
**À faire :** validation manuelle iOS, adapter l'extension du fichier téléchargé au mime réel via `audioMimeType`.
**Condition de résolution :** une fois la version iPhone validée par un testeur réel.
---
### FTD-34 — Présentation T1 stockée en clair dans `localStorage`
**Priorité :** 🟢 Mineur
**Statut :** Ouvert — introduit au Sprint 4c-2
**Estimation de session :** 0,5 jour
**Description :** `expria_eo_t1_presentation` contient le texte de la présentation personnelle de l'utilisateur (prénom, âge, ville, parcours, situation familiale, projet d'immigration). Stocké en clair, accessible à tout script tiers exécuté dans le contexte du domaine. Acceptable au MVP : aucune donnée sensible au sens RGPD strict (pas de mot de passe ni numéro fiscal), mais le contenu reste personnel.
**À faire :** chiffrement AES-GCM avec clé dérivée du JWT Supabase, ou bascule vers IndexedDB chiffré (libs : `idb-keyval` + `Web Crypto API`). Étendre à toute persistance sensible si on en ajoute (transcripts, audio, etc.).
**Condition de résolution :** quand on stocke un jour des contenus plus sensibles via le même mécanisme.
---
> FTD-35 fermée au Sprint 5.5 (2026-04-26) — subsumée par FTD-41. Voir §5 Historique.
---
### FTD-36 — Upload audio base64 in-memory sans indicateur de progression
**Priorité :** 🟡 Important
**Statut :** Ouvert — introduit au Sprint 4c-3
**Estimation de session :** 1 jour
**Description :** `EnregistrementEOPage` encode le Blob audio en base64 via `FileReader.readAsDataURL` puis envoie le résultat dans le body JSON de `POST /corrections/eo`. Pour 6 minutes d'audio webm/Opus à 32 kbps ≈ 1,5 Mo binaire ≈ 2 Mo base64. Reste sous le cap 14 Mo backend, mais : (a) tout est chargé en mémoire navigateur, (b) aucun indicateur de progression d'upload (le banner « Transcription et correction en cours » couvre les ~30-60 s totales sans distinguer upload/Gemini/DeepSeek), (c) retry impossible côté navigateur si la connexion mobile coupe en cours d'upload.
**À faire :** passer à `multipart/form-data` avec `XMLHttpRequest.upload.onprogress` ou `fetch` + `ReadableStream` ; afficher une barre de progression upload distincte de l'état serveur.
**Condition de résolution :** observer un cas réel de plantage mobile/edge OU avant ouverture publique.
---
### FTD-37 — Code Deepgram live dormant à trancher
**Priorité :** 🟢 Mineur
**Statut :** Ouvert — introduit au Sprint 4c-3
**Estimation de session :** 1 jour (réactivation) ou 0,5 jour (suppression)
**Description :** Sprint 4c-3 a basculé la transcription EO sur Gemini batch côté backend. Les artefacts Deepgram live restent en place mais sans consommateur :
- Frontend : `useDeepgramLive`, `TranscriptionDisplay`, `entities/transcription/api.ts` + tests associés
- Backend : route `POST /transcriptions/token`, `lib/deepgram.ts` + tests associés
**Décision de garde :** conservés 30 jours après la mise en prod du Sprint 4c-3 puis on tranche. Soit (a) réactivation pour réduire la latence perçue (transcription live pendant l'enregistrement vs attente serveur après stop), soit (b) suppression définitive si le retour utilisateur sur la latence Gemini est acceptable.
**À faire :** trancher au plus tard 30 jours après la première mise en prod de cette session.
---
> FTD-38, FTD-39 résolus au Sprint 5.5 (2026-04-26) — voir §5 Historique des résolutions.
---
### FTD-40 — Conclusion rapport incohérente quand NCLC atteint > cible (backend)
**Priorité :** 🟡 Important
**Statut :** Ouvert — patch frontend temporaire en place (Sprint 4.5)
**Estimation de session :** 1h (session backend)
**Description :** Le prompt maître DeepSeek génère toujours un message d'encouragement vers l'objectif cible, même quand `nclcObtenu > nclcCible`. Le champ `conseil_nclc.action_prioritaire` contient alors un texte incohérent (« tu atteindras facilement le niveau 9 » pour un candidat NCLC 10). Patch frontend en place dans [ConseilNclcCallout.tsx](../src/features/simulations/components/rapport/ConseilNclcCallout.tsx) (condition `depasse` → texte générique). Fix robuste : modifier le prompt maître backend pour détecter `nclcObtenu > nclcCible` et générer un message de maintien/progression vers NCLC suivant.
**Condition de résolution :** prompt backend mis à jour + patch frontend retiré.
---
### FTD-41 — Persistance présentation EO T1 en base de données
**Priorité :** 🔴 Critique
**Statut :** Ouvert — localStorage instable (FTD-35 partiellement liée)
**Estimation de session :** 1 jour (session fullstack)
**Description :** La présentation générée pour EO T1 est stockée uniquement en localStorage (`expria_eo_t1_presentation`). Au refresh, une redirect prématurée dans `PresentationGenereeT1Page` (`shouldRedirect` déclenché avant hydratation async complète) efface la session. Solution retenue : nouvelle colonne `presentation_t1` (TEXT, nullable) sur la table `productions` + `PATCH /simulations/:id/presentation` + bouton « Sauvegarder » explicite dans `PresentationGenereeT1Page`. Le localStorage devient brouillon temporaire uniquement. Résout FTD-35.
**Condition de résolution :** migration DB + endpoint backend + bouton frontend implémentés et testés.
---
> FTD-42 gelée au Sprint 5.5 (2026-04-26) — voir §3bis Backlog gelé.
---
### FTD-43 — Race condition webhook post-redirect Stripe Checkout
**Priorité :** 🟢 Mineur
**Statut :** Ouvert — introduit Sprint 5c (2026-04-26)
**Estimation de session :** 0,5 jour
**Description :** Après un Stripe Checkout réussi, le frontend revient sur `/dashboard?upgrade=success`. `useUpgradeSuccessHandler` invalide `PLAN_QUERY_KEY` immédiatement. Mais le webhook `checkout.session.completed` peut arriver côté backend **après** le redirect frontend (latence réseau Stripe → Render typiquement 1-3 s). Si l'invalidation refetch trop tôt, `usePlan()` retourne encore l'ancien plan. L'utilisateur voit son ancien plan dans le dashboard pendant quelques secondes.
**Mitigation actuelle :** `UpgradeSuccessBanner` affiche explicitement « Si certaines features semblent manquer, rafraîchissez la page dans quelques secondes ». Acceptable au MVP.
**À faire si remonté en prod :**
- Polling court : refetch automatique de `usePlan()` toutes les 2 s pendant 30 s tant que le plan reçu === ancien plan.
- OU : ajouter un endpoint `GET /plans/status?wait_for_change=true` côté backend qui long-poll jusqu'au changement (max 10 s) et retourne le nouveau plan.
- OU : Stripe envoie un event WebSocket via Pusher / SSE — out of scope MVP.
**Condition de résolution :** observer en production. Si > 5 % des upgrades produisent un message support, prioriser.
---
> FTD-33 gelée au Sprint 5.5 (2026-04-26) — voir §3bis Backlog gelé.
---
### FTD-24 — Pas de polling automatique pour exercices / modèle `pending`
**Priorité :** 🟡 Important
**Statut :** Résolu — 2026-04-23
**Estimation de session :** 2h
**Description :** Le `ThemeProvider` est bi-state (`'light' | 'dark'`). L'option `'system'` (qui suit `prefers-color-scheme` en temps réel via `MediaQueryList.addEventListener`) a été volontairement différée (décision Sprint 0.5).
**Description :** Après soumission d'une correction EE, le backend génère la correction en bloquant (jusqu'à 45 s), puis retourne 200 dès que la correction est prête. Les jobs `modele` et `exercices` (fire-and-forget côté backend) peuvent mettre 10-30 s supplémentaires après la réponse HTTP. Pendant ce temps, `exercices_status` et `modele_status` valent `'pending'` côté `GET /simulations/:id`. Côté frontend, `RapportPage` affiche un `JobStatusFallback` invitant l'utilisateur à **rafraîchir manuellement** la page pour voir les résultats.
**Impact UX :** l'utilisateur voit le rapport principal immédiatement, mais doit recharger pour voir ses exercices + production modèle. Expérience acceptable en MVP mais sous-optimale.
**À faire :**
- Étendre le type `Theme` à `'light' | 'dark' | 'system'`
- Dans `ThemeProvider`, si `theme === 'system'` : écouter `matchMedia('(prefers-color-scheme: dark)')` et appliquer/retirer `.dark` dynamiquement
- `ThemeToggle` : cycle light → dark → system (ou un sélecteur 3 états)
- Mettre à jour `getInitialTheme()` pour retourner `'system'` si aucune préférence stockée
**Condition de résolution :** après MVP — confort utilisateur, pas bloquant.
- Hook `useRapport` : déclencher un polling automatique via TanStack Query `refetchInterval: 3000` si `exercices_status === 'pending' || modele_status === 'pending'`.
- Arrêt du polling dès que les deux statuts sortent de `'pending'` (ready ou error).
- Afficher un indicateur visuel discret pendant le polling actif (petit spinner dans JobStatusFallback).
- Timeout de polling : max 2 minutes → message "La génération prend plus de temps que prévu" + bouton Réessayer.
**Lien avec TD-15 backend :** si le process backend redémarre pendant un job, le statut reste indéfiniment `'pending'`. Le timeout frontend atténue ce problème côté UX (on arrête de poller après 2 min).
**Condition de résolution :** après Sprint 3.6c (patterns) si la patience utilisateur devient un frein.
---
### FTD-23 — `useAutosave` continue après correction → 400 VALIDATION_ERROR
**Priorité :** 🟡 Important
**Statut :** Résolu — 2026-04-23
**Estimation de session :** 30 min
**Description :** Le hook `useAutosave` (cf. `src/features/simulations/hooks/useAutosave.ts`) peut déclencher un `PATCH /simulations/:id/contenu` après que la correction a été persistée (colonne `rapport !== null`). Le backend refuse alors avec `400 VALIDATION_ERROR` message « Cette simulation a déjà été corrigée. » (cf. `simulationController.autosaveContenu` backend lignes 248-255).
**Scénario déclencheur :**
1. L'utilisateur soumet sa production → `rapport` persisté côté backend.
2. `SimulationForm` passe `step` à `'done'`, mais :
- Le timer d'autosave debouncé (30 s) peut encore fire après cette transition si le debounce n'est pas clear.
- Un `beforeunload` handler peut déclencher un `flush()` final même une fois la correction reçue.
3. `useAutosave.enabled` est calculé comme `!isSubmitting` dans `SimulationForm` — il redevient `true` après la correction (quand `isSubmitting` repasse à `false`).
**À faire :**
- Propager `enabled = !isSubmitting && step !== 'done' && step !== 'correcting'` depuis `SimulationForm`
- OU : au montage, quand `rapport` devient non null après correction, clear le timeout debouncé et retirer le handler `beforeunload` immédiatement.
- Ajouter un test regression dans `useAutosave.test.ts` qui vérifie qu'aucun `autosaveContenu` n'est appelé après `step='done'`.
**Impact actuel :** erreur 400 dans les DevTools Network uniquement (pas d'impact UX — le texte est déjà corrigé, la sauvegarde n'est plus nécessaire). Pollue les logs frontend et backend.
**Condition de résolution :** session dédiée — ne bloque pas le Sprint 3.6b.
---
### FTD-25 — Mise à jour ARCHITECTURE.md §3 (arborescence réelle)
**Priorité :** 🟢 Mineur
**Statut :** Résolu — 2026-04-25
**Estimation de session :** 1h
**Description :** ARCHITECTURE.md §3 ne liste pas `entities/patterns`, `features/historique`, `features/progression`, `features/design-system` (ajoutés aux Sprints 3.6c et 3.7). Les composants layout (`AppLayout`, `Sidebar`, `MobileHeader`, `BottomNav`, `MaintenancePage`) sont dans `app/` alors que §3 ne prévoit que `providers`, `router`, `main` dans ce dossier.
**À faire :**
- Mettre à jour ARCHITECTURE.md §3 pour refléter l'arborescence réelle.
- Formaliser `app/` comme contenant entry points + composants layout de la coquille OU déplacer vers `shared/components/layout/`.
**Condition de résolution :** ARCHITECTURE.md §3 reflète l'arborescence réelle.
---
### FTD-26 — Clarifier cohabitation `shared/ui/` vs `shared/components/ui/`
**Priorité :** 🟡 Important
**Statut :** Résolu — 2026-04-25
**Estimation de session :** 2h
**Description :** Deux conventions UI cohabitent sans documentation :
- `src/shared/ui/{Button,Card,Badge}.tsx` (PascalCase) — wrappers Expria, 40+ imports dans les features.
- `src/shared/components/ui/{button,dialog,input,…}.tsx` (kebab-case) — primitives shadcn/ui, 7 fichiers consommateurs.
Risque : confusion pour un futur dev sur quel composant utiliser.
**À faire :** documenter la convention dans ARCHITECTURE.md (distinction wrappers Expria / primitives shadcn) **OU** regrouper sous un seul dossier (ex. `shared/components/ui/primitives/` + `shared/components/ui/expria/`).
**Condition de résolution :** un seul pattern documenté et appliqué.
---
## 3. Fonctionnalités reportées
### FTD-06 — AudioWorklet au lieu de ScriptProcessorNode (T2 Live)
**Priorité :** 🟢 Mineur
**Statut :** Reporté à après le lancement MVP
**Estimation de session :** 1 jour
**Description :** Hérité du backend (TD-09). Côté frontend, le traitement audio pour la T2 Live (capture PCM 16kHz) devra probablement utiliser `AudioWorklet` au lieu de `ScriptProcessorNode` qui est déprécié.
**Impact actuel :** fonctionne avec warnings dans la console. Peut poser problème sur certains navigateurs futurs.
**À faire :** session dédiée après le lancement MVP, pour migrer le pipeline audio vers AudioWorklet.
**Condition de résolution :** après 30 jours de production stable.
---
### FTD-07 — Sentry non intégré
**Priorité :** 🟡 Important
**Statut :** Planifié — après MVP
**Estimation de session :** 3h
**Description :** Le monitoring frontend (erreurs JS, performances, sessions) n'est pas encore en place. Sans Sentry (ou équivalent), les bugs en production ne remontent pas — on les découvre uniquement si un utilisateur prend la peine de les signaler.
**À faire :**
- Créer un compte Sentry (tier gratuit suffit pour démarrer)
- Ajouter `@sentry/react` au projet
- Intégrer dans `src/app/providers.tsx`
@ -195,9 +336,82 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
---
### FTD-08 — Tests E2E non implémentés
### FTD-21 — Persistance session simulation
**Priorité :** 🔴 Critique
**Statut :** Partiellement résolu — `/simulation/ee` ✅ (2026-04-21)
**Pages concernées par ordre de priorité :**
**`/simulation/ee`** (résolu 2026-04-21)
- Autosave contenu toutes les 30 s (`useAutosave`)
- Save on `beforeunload`
- Reprise au refresh via `localStorage` (`expria_simulation_id`) + `GET /simulations/:id`
- `PATCH /simulations/:id/contenu` + `PATCH /simulations/:id/sujet` (Option C)
- `getById` tolère `rapport=null` (Option A)
- `RapportPage` redirige vers `/simulation/ee` si simulation en cours
🟡 **`/simulation/eo`** (Sprint 4 — ouvert)
- Identique EE + état audio/enregistrement
🟡 **`/examen`** (Sprint 7 — ouvert)
- Autosave critique — timer inarrêtable + 3 tâches
- Crash pendant examen = perte totale
🟢 **`/sujets`** (inclus dans la résolution EE)
- `localStorage simulation_id` suffit
- Pas d'autosave (pas de données saisies)
**Pas nécessaire :** `/dashboard`, `/rapport/:id`, `/historique`, `/progression`
**Résolution EE livrée (2026-04-21) :**
Backend :
- `simulationController.create` persiste `sujet_id` à la création
- `getById` retourne `SimulationState` (tolère `rapport=null` pour resume)
- `autosaveContenu` + `updateSujet` controllers (refuse si `rapport !== null`)
- Routes `PATCH /simulations/:id/contenu` + `PATCH /simulations/:id/sujet`
- CORS : `allowMethods` étendu à PATCH/PUT/DELETE
Frontend :
- `useAutosave` : debounce 30 s + `beforeunload` flush + dedup par contenu
- `SimulationForm` : hydrate `initialContenu`, affiche "Sauvegardé à HH:MM"
- `SimulationFlowProvider` : hydratation au montage depuis `localStorage` → restaure step `task-selected` si rapport null, nettoie sinon
- `getReport` délègue à `getSimulationState` et throw `REPORT_NOT_READY` si rapport null
**Condition de résolution complète :** intégration EO (Sprint 4) + examen (Sprint 7).
---
## 3bis. Backlog gelé — post-MVP
> Ces FTDs sont volontairement gelées : elles concernent des fonctionnalités non encore livrées (T2 Live, tests E2E) ou du confort utilisateur non bloquant (option `'system'` thème). Elles **ne comptent pas dans le cap de 15 FTD actives** et seront réactivées quand leur sprint arrive ou quand la condition de déblocage (post-MVP) est atteinte.
### FTD-06 — AudioWorklet au lieu de ScriptProcessorNode (T2 Live)
**Priorité :** 🟢 Mineur
**Statut :** Reporté — accepté par design
**Statut :** Gelé — post-MVP (T2 Live non encore implémenté)
**Estimation de session :** 1 jour
**Description :** Hérité du backend (TD-09). Côté frontend, le traitement audio pour la T2 Live (capture PCM 16kHz) devra probablement utiliser `AudioWorklet` au lieu de `ScriptProcessorNode` qui est déprécié.
**Impact actuel :** fonctionne avec warnings dans la console. Peut poser problème sur certains navigateurs futurs.
**À faire :** session dédiée après le lancement MVP, pour migrer le pipeline audio vers AudioWorklet.
**Condition de résolution :** après 30 jours de production stable.
---
### FTD-08 — Tests E2E non implémentés
**Priorité :** 🟢 Mineur
**Statut :** Gelé — post-MVP (accepté par design)
**Estimation de session :** 2 jours (Playwright setup)
**Description :** Actuellement, les tests de bout en bout sont manuels (via `GOLDEN_DATASET.md`). Une automatisation avec Playwright permettrait de détecter les régressions UI sans effort humain.
@ -207,23 +421,92 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
---
> FTD-09 et FTD-33 résolues au Sprint 6c (2026-04-26) — voir §5 Historique.
---
### FTD-42 — Modal prorata Standard→Premium avec montant exact
**Priorité :** 🟡 Important
**Statut :** Gelé — Sprint 5.5 (2026-04-26)
**Estimation de session :** 1 jour
**Description :** Le flux Standard→Premium passe actuellement par le **Stripe Customer Portal** (Sprint 5d). Le portal natif Stripe affiche le montant prorata + confirmation hors de l'app. Divergence avec PARCOURS_UTILISATEURS.md §3 qui prévoit une modal in-app.
**Motif de gel :** Gelé — Customer Portal Stripe gère nativement le prorata. Modal in-app = confort post-MVP, pas MVP-bloquant.
**Condition de résolution :** post-MVP, si retour utilisateur fait remonter la friction du redirect vers Customer Portal.
---
### FTD-15 — Option `'system'` manquante dans ThemeProvider
**Priorité :** 🟢 Mineur
**Statut :** Gelé — post-MVP
**Estimation de session :** 2h
**Description :** Le `ThemeProvider` est bi-state (`'light' | 'dark'`). L'option `'system'` (qui suit `prefers-color-scheme` en temps réel via `MediaQueryList.addEventListener`) a été volontairement différée (décision Sprint 0.5).
**À faire :**
- Étendre le type `Theme` à `'light' | 'dark' | 'system'`
- Dans `ThemeProvider`, si `theme === 'system'` : écouter `matchMedia('(prefers-color-scheme: dark)')` et appliquer/retirer `.dark` dynamiquement
- `ThemeToggle` : cycle light → dark → system (ou un sélecteur 3 états)
- Mettre à jour `getInitialTheme()` pour retourner `'system'` si aucune préférence stockée
**Condition de résolution :** après MVP — confort utilisateur, pas bloquant.
---
### FTD-44 — Hooks audio génériques empruntés à `features/t2-live/` (T1 Live)
**Priorité :** 🟡 Important
**Statut :** Gelé — réactivé au Sprint 7.5 (« factorisation Sprint 7 »)
**Estimation de session :** 0,5 jour
**Description :** Le flux T1 Live (Sprint 7b) importe directement les trois hooks audio génériques de `features/t2-live/hooks/` (`useAudioCapture`, `useAudioPlayback`, `useAudioRecording`) — une violation assumée de la frontière inter-features FSD (un feature ne devrait pas importer un sibling). Décision prise pour NE PAS toucher aux fichiers T2 (pipeline audio validé à l'oreille, intouchable jusqu'à factorisation). Sites d'import marqués `// TODO(FTD-44)` dans `features/t1-live/hooks/useT1LiveSession.ts`.
**À faire :** relocaliser les trois hooks (génériques par nature : aucune logique T2 spécifique) vers `shared/lib/audio/`, puis migrer les imports T2 ET T1 vers ce chemin partagé. Validation à l'oreille obligatoire après déplacement (T2 + T1).
**Condition de résolution :** Sprint 7.5 (factorisation Sprint 7), une fois les flux T1 et T2 Live stabilisés.
---
### FTD-45 — Relances Gemini T1 Live hors-sujet (extension TD-23)
**Priorité :** 🟡 Important
**Statut :** Gelé — dépend de l'amont (Gemini / backend TD-23), hors contrôle frontend
**Estimation de session :** à évaluer (chantier backend/prompt)
**Description :** En T1 Live, l'examinateur (Gemini) formule ses relances à partir de son contexte audio interne. Au test manuel, certaines relances partent **hors-sujet** (sans rapport avec ce que le candidat vient de dire). Extension de la dette backend **TD-23** : en VAD manuel, `inputTranscription` candidat n'est flushé qu'à `activityEnd`, donc le modèle relance sans transcription token-par-token fiable.
**Impact actuel :** dégrade le réalisme de l'entretien T1 ; non bloquant pour la livraison 7b (le flux fonctionne, l'évaluation finale reste correcte).
**À faire :** ré-évaluer côté backend/prompt (formulation de la consigne de relance, fenêtre de contexte) une fois la transcription incrémentale repensée (Sprint 7e / TD-23).
**Condition de résolution :** après traitement de TD-23 (transcription live) — non actionnable côté frontend seul.
---
### FTD-46 — Transcription Gemini Live hasardeuse (qualité audio→texte)
**Priorité :** 🟡 Important
**Statut :** Gelé — dépend de l'amont (qualité Gemini Live), hors contrôle frontend
**Estimation de session :** à évaluer
**Description :** La transcription produite par Gemini Live (`input/outputTranscription`) est de qualité **inégale** : mots manqués, segments approximatifs. Observé au test manuel T1 Live (et applicable au T2 Live). Affecte la fidélité du transcript utilisé pour l'évaluation et bloquera l'affichage live (Sprint 7e).
**Impact actuel :** qualité du transcript variable ; non bloquant pour 7b (l'évaluation 5 critères reste exploitable).
**À faire :** suivre l'évolution du modèle Gemini Live ; évaluer un post-traitement ou une source de transcription alternative si la qualité reste insuffisante au Sprint 7e.
**Condition de résolution :** amélioration amont (modèle) ou décision d'architecture transcription au Sprint 7e.
---
## 4. Tests à renforcer
### FTD-09 — Tests de la state machine T2 Live non implémentés
**Priorité :** 🟡 Important
**Statut :** Planifié — à créer au Sprint 2.5
**Estimation de session :** 3h
**Description :** La state machine T2 Live (`src/features/t2-live/state/t2-machine.ts`) n'existe pas encore. Quand elle sera créée, elle devra être testée de manière exhaustive (6+ tests couvrant les transitions d'états et les cas d'erreur).
**À faire au Sprint 2.5 (spike T2 Live) :**
- Créer `t2-machine.test.ts` avec tests des transitions : idle → connecting, connecting → listening, listening ↔ speaking, * → error, * → ended
- Tests des messages d'erreur (close code 4001, 4003, autre)
**Condition de résolution :** fin Sprint 2.5.
> FTD-09 gelée au Sprint 5.5 (2026-04-26) — voir §3bis Backlog gelé.
---
### FTD-12 — Tests automatisés manquants pour `api-client.ts`
**Priorité :** 🟡 Important
**Statut :** Ouvert — à faire avant intégration des features critiques
**Estimation de session :** 3h
@ -232,6 +515,7 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
**Impact actuel :** toute régression sur ce fichier (oubli d'un header, mauvais parsing d'une erreur, boucle de retry infinie sur un edge case) passera inaperçue jusqu'aux tests manuels ou à un bug en production.
**À faire :**
- Créer `src/shared/lib/__tests__/api-client.test.ts`
- Mocker globalement `fetch` via `vi.fn()`
- Couvrir :
@ -253,18 +537,66 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
## 5. Historique des résolutions
| ID | Description | Résolu le | Comment |
|---|---|---|---|
| FTD-11 | `@theme` Tailwind 4 non défini — palette et typographie absentes | 2026-04-18 | Résolu au Sprint 0.5 (design system). Palette Direction H complète (canvas/surface/ink/expria/deep/semantic) + typo Plus Jakarta Sans définis dans `src/index.css` via `@theme {}` et `.dark {}`. shadcn/ui remappé sur ces tokens. Règle L ajoutée dans `DEVELOPMENT_PRINCIPLES.md` pour garantir l'usage exclusif des tokens. |
| FTD-13 | Incompatibilité Vitest 3 / Vite 8 (conflit de types `Plugin<any>` entre le Vite 8 top-level avec Rolldown et le Vite 7 pinné de Vitest 3.2.4 ; `npm run build` cassé) | 2026-04-17 | Résolu par upgrade Vitest `3.2.4 → 4.1.4` (et `@vitest/coverage-v8` idem) à l'étape 12-bis du Sprint 0. Vitest 4.x supporte nativement Vite 8 Rolldown. Correctif complémentaire : script `typecheck` passé de `tsc --noEmit -p tsconfig.app.json` à `tsc -b --noEmit` pour couvrir aussi `tsconfig.node.json` (d'où `vite.config.ts`) et éviter qu'un bug similaire échappe à la CI. |
| ID | Description | Résolu le | Comment |
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FTD-11 | `@theme` Tailwind 4 non défini — palette et typographie absentes | 2026-04-18 | Résolu au Sprint 0.5 (design system). Palette Direction H complète (canvas/surface/ink/expria/deep/semantic) + typo Plus Jakarta Sans définis dans `src/index.css` via `@theme {}` et `.dark {}`. shadcn/ui remappé sur ces tokens. Règle L ajoutée dans `DEVELOPMENT_PRINCIPLES.md` pour garantir l'usage exclusif des tokens. |
| FTD-13 | Incompatibilité Vitest 3 / Vite 8 (conflit de types `Plugin<any>` entre le Vite 8 top-level avec Rolldown et le Vite 7 pinné de Vitest 3.2.4 ; `npm run build` cassé) | 2026-04-17 | Résolu par upgrade Vitest `3.2.4 → 4.1.4` (et `@vitest/coverage-v8` idem) à l'étape 12-bis du Sprint 0. Vitest 4.x supporte nativement Vite 8 Rolldown. Correctif complémentaire : script `typecheck` passé de `tsc --noEmit -p tsconfig.app.json` à `tsc -b --noEmit` pour couvrir aussi `tsconfig.node.json` (d'où `vite.config.ts`) et éviter qu'un bug similaire échappe à la CI. |
| FTD-16 | `VITE_MAINTENANCE_MODE` non lu dans le code — la variable d'env était dans `env.ts` mais jamais consommée | 2026-04-18 | Résolu au Sprint 1 étape 6. Ajout de `isMaintenanceMode` dans `src/shared/config/env.ts` et garde dans `src/app/main.tsx` : `isMaintenanceMode ? <MaintenancePage /> : <Providers />`. `MaintenancePage` est statique (aucun provider requis), tokens Direction H exclusivement. |
| FTD-22 | Code orphelin suite à la refonte UX `/sujets` (2026-04-21) — composant `SujetSelector` et helper `selectSujet` plus référencés après bascule dropdown → page dédiée | 2026-04-23 | Résolution complète. `SujetSelector` + `selectSujet` supprimés. Éléments conservés (`choosing-subject`, `goToSubjectPicker`) sont activement utilisés par `SimulationFlowProvider` et `SimulationForm` — ce n'est plus de la dette. |
| FTD-20 | `GET /simulations/:id` manquant dans le backend | 2026-04-22 | Implémenté au Sprint 3.6a (backend) — route complète avec auth, owner check, `REPORT_NOT_READY`. Consommé par `RapportPage` et `useAutosave`. |
| FTD-04 | Documents miroir sans automatisation de synchronisation | 2026-04-23 | Risque accepté par design (ADR 004). Mitigation en place (Règle G, commentaire `SOURCE OF TRUTH`, tests de parité). Condition de ré-ouverture : si une divergence silencieuse cause 2+ bugs en production. |
| FTD-05 | Ancien scaffold frontend possiblement caduc | 2026-04-23 | Audit Claude Code complet — aucun résidu scaffold Vite, aucun fichier orphelin, règles critiques (D, E, F, G, J + ADR 003/005) respectées. Désalignements documentaires traités via FTD-25 et FTD-26. |
| FTD-29 | `.github/dependabot.yml` dans les 2 dépôts | 2026-04-23 | Fichier créé dans expria-frontend et expria-backend. Ecosystem npm, weekly, limit 10 PRs. Dependabot alerts + security updates activés via UI GitHub. |
| FTD-27 | CI GitHub Actions pour expria-backend | 2026-04-23 | Workflow créé : npm ci → test → audit. Node 22, trigger push/PR sur main. CI verte au premier run (21s). Observations : typecheck absent (O1), ESLint absent (O2), engines.node absent (O3) — à traiter en FTDs séparées. |
| FTD-28 | Semgrep dans CI frontend + backend | 2026-04-23 | Step `semgrep scan --config=auto --error --severity=ERROR` ajouté aux deux workflows CI. Backend vert au 1er run. Frontend vert après correction de 4 erreurs ESLint préexistantes + fix Prettier + ajout env vars CI. |
| FTD-17 | Clé `['plan']` dupliquée entre features (`usePlan`, `SimulationPage`, `RapportPage`) | 2026-04-22 | Résolu au Sprint 3.5. Création de `src/entities/user/query-keys.ts` (constantes pures, aucun import runtime) exportant `PLAN_QUERY_KEY = ['plan'] as const`. `features/dashboard/hooks/usePlan.ts` l'importe et le re-exporte pour conserver la rétro-compatibilité de l'import `PLAN_QUERY_KEY`. `SimulationPage.tsx` et `RapportPage.tsx` remplacent leur `useQuery` inline par le hook `usePlan()` — dédup totale de la clé et de la config staleTime. |
| FTD-18 | SimulationForm utilise encore le shadcn Button au lieu de la primitive `@/shared/ui/Button` | 2026-04-22 | Résolu au Sprint 3.5. Remplacement de l'import `@/shared/components/ui/button` par `@/shared/ui/Button` dans `SimulationForm.tsx`. Aucun variant à adapter (usage du Button sans prop `variant``primary` par défaut dans les deux implémentations). Les 7 autres consommateurs shadcn (`Login/RegisterPage`, `PaywallBanner`, `DesignSystemPage`, `ThemeToggle`, `dialog.tsx`) restent hors scope de cette FTD. |
| FTD-23 | `useAutosave` continue après correction → 400 VALIDATION_ERROR | 2026-04-23 | `enabled` corrigé dans `SimulationForm` (`!isSubmitting && step !== 'done' && step !== 'correcting'`). Le `beforeunload` handler et le debounce lisent `enabled` via `latestRef` — tous deux neutralisés dès que `step` transite. 2 tests de régression ajoutés dans `useAutosave.test.ts` : (a) `enabled` true→false annule le debounce en cours, (b) `enabled=false` + `beforeunload` = aucun appel. |
| FTD-24 | Pas de polling automatique pour exercices / modèle `pending` | 2026-04-23 | Polling conditionnel dans `useRapport` via `refetchInterval: 3000` tant que `exercices_status === 'pending' \|\| modele_status === 'pending'`. Arrêt automatique dès que les deux sortent de pending (ready ou error). Timeout global 2 min → `hasTimedOut = true` + bouton « Réessayer » dans `JobStatusFallback` (primitive `@/shared/ui/Button`). `refetch()` réinitialise le flag et relance le polling. `staleTime: Infinity` conservé. 5 tests nouveaux dans `useRapport.test.tsx`. |
| FTD-25 | Mise à jour ARCHITECTURE.md §3 (arborescence réelle) | 2026-04-25 | §3 réécrite : `app/` documenté avec entry points + layout (AppLayout, Sidebar, Topbar, BottomNav, MaintenancePage) ; ajout `entities/{patterns,presentation,transcription}` ; ajout `features/{historique,progression,design-system}` ; extension `simulations/` (pages EO, components/rapport/, lib/, state/) ; mise à jour `shared/`. `t2-live/` et `billing/` retirés (non implémentés — voir ROADMAP). Note explicative ajoutée sous `app/`. Bump doc v1.1. |
| FTD-26 | Clarifier cohabitation `shared/ui/` vs `shared/components/ui/` | 2026-04-25 | Section dédiée ajoutée dans ARCHITECTURE.md §3 : tableau de distinction (PascalCase wrappers Expria vs kebab-case primitives shadcn) + règle d'évolution (toute nouvelle primitive Expria va dans `shared/ui/`, `shared/components/ui/` réservé à la CLI shadcn). Aucun fichier déplacé — documentation uniquement. |
| FTD-09 | Tests de la state machine T2 Live non implémentés | 2026-04-26 | Sprint 6c — State machine pure créée (`src/features/t2-live/state/t2-machine.ts`, 9 états × 8 events) + 21 tests Vitest couvrant transitions nominales, END_REQUESTED depuis tout état actif, ERROR terminal, événements invalides ignorés. Dégelée et fermée. |
| FTD-33 | Carte EO_T2_LIVE verrouillée en dur (pas via `hasAccess`) | 2026-04-26 | Sprint 6c — Carte EO_T2_LIVE déverrouillée via `hasAccess(plan, 'oral_t2_live')` + nouvelle prop `onT2LiveSelect` dans `TaskSelector`. Si plan donne accès, clic navigue vers `/simulation/eo/t2` (la production est créée par le backend en fin de session, pas au clic). Sinon, carte reste verrouillée avec lockLabel « Exclusivité Premium ». Dégelée et fermée. |
| FTD-14 | Anti-FOUC thème : script inline manquant dans `<head>` | 2026-04-26 | Sprint 5.5 — Script `.light` déjà en place dans `index.html` (lignes 14-20), conforme DESIGN_SYSTEM v2.0. L'exemple `.dark` documenté dans la fiche FTD-14 datait de la DA Boréal v1.0 (obsolète). Aucune action code requise — FTD fermée comme déjà résolue. |
| FTD-35 | `PresentationGenereeT1Page` : refresh sans simulation active | 2026-04-26 | Sprint 5.5 — Subsumée par FTD-41 : la résolution de FTD-41 (persistance T1 en BDD) élimine le problème de FTD-35 (localStorage instable). Aucune action propre. |
| FTD-38 | `useAudioRecorder` : mise à jour de ref pendant le render | 2026-04-26 | Sprint 5.5 — Refactor `optionsRef.current = options` (assignation pendant render + eslint-disable) en `useEffect(() => { optionsRef.current = options })`. Sémantique préservée : effet sans deps run après chaque commit, donc avant le prochain render qui lit la ref. eslint-disable retiré. 195 lignes de tests `useAudioRecorder.test.ts` toujours vertes (219/219). |
| FTD-39 | Règle D violée dans `StatCards.tsx` (`plan === 'free'` en dur) | 2026-04-26 | Sprint 5.5 — Remplacement de `{plan === 'free' && ...}` (ligne 90) par `{!hasAccess(plan, 'dashboard') && ...}`. Sémantique du gating : afficher « Renouvellement offert à l'upgrade » uniquement aux utilisateurs sans accès au dashboard complet (= Free). Import `hasAccess` ajouté depuis `@/entities/user/lib`. Tests Dashboard verts. |
| FTD-19 | Token `--shadow-focus` absent de `src/index.css` | 2026-04-22 | Résolu au Sprint 3.5. Ajout de `--shadow-focus: 0 0 0 3px rgba(27, 79, 216, 0.18)` dans `@theme {}` (valeur conforme à `DESIGN_SYSTEM.md §2`) et `--shadow-focus: 0 0 0 3px rgba(91, 127, 255, 0.32)` dans `.dark {}` (recalculé sur la teinte expria dark `#5B7FFF`). Tailwind 4 génère automatiquement l'utility `shadow-focus`. Migration de 5 occurrences `ring-2 ring-expria/20``shadow-focus` dans `Button.tsx`, `Card.tsx`, `SimulationForm.tsx` (×3), `SpecialCharsKeyboard.tsx`. Factorisation bonus : className dupliquée des boutons secondaires de `SimulationForm` extraite en const `secondaryActionBtn`. |
---
## 6. Historique de ce document
| Version | Date | Changements |
|---|---|---|
| 1.0 | 2026-04-17 | Création initiale avec 9 FTD identifiées depuis l'audit backend et les décisions d'architecture |
| 1.1 | 2026-04-17 | Ajout FTD-10 (Semgrep CI), FTD-11 (`@theme` Tailwind 4), FTD-12 (tests `api-client`) suite à l'étape 11 du Sprint 0 |
| 1.2 | 2026-04-17 | Ajout FTD-13 résolu (incompatibilité Vitest 3 / Vite 8) suite à l'étape 12-bis du Sprint 0 |
| 1.3 | 2026-04-18 | FTD-11 résolu (design system Sprint 0.5) ; ajout FTD-14 (anti-FOUC), FTD-15 (option 'system' thème) |
| Version | Date | Changements |
| ------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1.0 | 2026-04-17 | Création initiale avec 9 FTD identifiées depuis l'audit backend et les décisions d'architecture |
| 1.1 | 2026-04-17 | Ajout FTD-10 (Semgrep CI), FTD-11 (`@theme` Tailwind 4), FTD-12 (tests `api-client`) suite à l'étape 11 du Sprint 0 |
| 1.2 | 2026-04-17 | Ajout FTD-13 résolu (incompatibilité Vitest 3 / Vite 8) suite à l'étape 12-bis du Sprint 0 |
| 1.3 | 2026-04-18 | FTD-11 résolu (design system Sprint 0.5) ; ajout FTD-14 (anti-FOUC), FTD-15 (option 'system' thème) |
| 1.4 | 2026-04-18 | FTD-16 résolu (VITE_MAINTENANCE_MODE implémenté — Sprint 1 étape 6) |
| 1.5 | 2026-04-19 | Ajout FTD-17 (clé ['plan'] dupliquée entre features — Sprint 3 étape 14) |
| 1.6 | 2026-04-20 | Ajout FTD-18 (SimulationForm shadcn Button — Sprint 0.5 bis D2) ; ajout FTD-19 (token --shadow-focus manquant — Sprint 0.5 bis D2) |
| 1.7 | 2026-04-20 | Ajout FTD-20 🔴 (GET /simulations/:id manquant backend — bloque RapportPage Sprint 3 étape 15) |
| 1.8 | 2026-04-20 | Ajout FTD-21 🔴 (persistance session simulation — prod + sujet perdus au refresh, session dédiée après G1-G5) |
| 1.9 | 2026-04-21 | FTD-22 résolu partiellement (nettoyage code orphelin refonte `/sujets``SujetSelector` + `selectSujet` supprimés ; `choosing-subject` + `goToSubjectPicker` conservés) |
| 1.10 | 2026-04-21 | FTD-21 résolu partiellement pour `/simulation/ee` (autosave 30 s + `beforeunload` + reprise via `localStorage` + `PATCH /:id/contenu` + `PATCH /:id/sujet` + `getById` tolère `rapport=null`) ; EO + examen restent ouverts |
| 1.11 | 2026-04-22 | Sprint 3.5 Clean — FTD-17, FTD-18, FTD-19 résolus. 15 FTD actives restantes (cap de 15 respecté) |
| 1.12 | 2026-04-22 | Sprint 3.6a — Ajout FTD-23 🟡 (useAutosave fire après correction). 16 FTD actives → cap de 15 dépassé temporairement, à revoir au prochain clean. |
| 1.13 | 2026-04-22 | Sprint 3.6b — Ajout FTD-24 🟡 (polling auto exercices/modèle pending). 17 FTD actives → cap dépassé, un clean 3.6.5 devra résoudre FTD-23/24 ensemble. |
| 1.14 | 2026-04-23 | Triage : FTD-04, FTD-05, FTD-20, FTD-22 fermées. FTD-25, FTD-26 ajoutées. 15 FTD actives (cap respecté). |
| 1.15 | 2026-04-23 | Réorg sécurité : FTD-06, FTD-08, FTD-15 gelées (backlog post-MVP). FTD-27 🔴, FTD-28 🔴, FTD-29 🟡 ajoutées (sécurité). 15 FTD actives (cap respecté). |
| 1.16 | 2026-04-23 | FTD-29 fermée (Dependabot config). 14 FTD actives. |
| 1.17 | 2026-04-23 | FTD-27 fermée (CI backend). 13 FTD actives. |
| 1.18 | 2026-04-23 | FTD-28 fermée (Semgrep CI). CI frontend verte pour la première fois. 12 FTD actives. |
| 1.19 | 2026-04-23 | FTD-23 et FTD-24 fermées (clean useAutosave après correction + polling automatique jobs pending dans useRapport). 10 FTD actives (cap 15). |
| 1.20 | 2026-04-25 | Sprint 4c-1 — Ajout FTD-30 🟡 (rotation token Deepgram sans grace period), FTD-31 🟢 (page enregistrement EO non resumable), FTD-32 🟢 (Safari iOS non testé), FTD-33 🟢 (EO_T2_LIVE verrouillé en dur). 14 FTD actives (cap 15 respecté). |
| 1.21 | 2026-04-25 | Sprint 4c-2 — Ajout FTD-34 🟢 (présentation T1 en localStorage clair), FTD-35 🟡 (refresh sans simulation active sur PresentationGenereeT1Page). **16 FTD actives — cap dépassé temporairement, accepté par Hermann pour cette session ; clean à planifier au prochain Sprint.** |
| 1.22 | 2026-04-25 | Sprint 4c-3 — Ajout FTD-36 🟡 (upload audio base64 sans progression), FTD-37 🟢 (code Deepgram live dormant à trancher). FTD-30 dégradée 🟡→🟢 et passée en « gelé » (Deepgram live mis en pause). **17 FTD actives — cap toujours dépassé, clean prioritaire au Sprint suivant.** |
| 1.23 | 2026-04-25 | FTD-25 et FTD-26 fermées (ARCHITECTURE.md §3 reflète l'arborescence réelle + convention `shared/ui/` vs `shared/components/ui/` documentée). 15 FTD actives (cap respecté). |
| 1.24 | 2026-04-25 | Sprint 4.5 Clean — Ajout FTD-38 🟢 (`useAudioRecorder` ref mise à jour pendant render — eslint-disable local en place) et FTD-39 🟡 (Règle D violée dans `StatCards.tsx` — préexistant Sprint UI Polish). 17 FTD actives — cap dépassé temporairement, à résorber au Sprint 5.5. |
| 1.25 | 2026-04-25 | Sprint 4.5 — Ajout FTD-40 🟡 (conclusion `conseil_nclc` backend incohérente quand NCLC atteint > cible — patch frontend en place dans `ConseilNclcCallout`) et FTD-41 🔴 (persistance présentation EO T1 en BDD — résout FTD-35). **19 FTD actives — cap 15 dépassé de 4. Résorption obligatoire au Sprint 5.5 avant toute nouvelle FTD.** |
| 1.26 | 2026-04-26 | Sprint 5e (clean Sprint 5 Billing) — Ajout FTD-42 🟡 (modal prorata Standard→Premium avec montant exact — divergence PARCOURS_UTILISATEURS §3, actuellement Customer Portal natif sans preview in-app) et FTD-43 🟢 (race condition webhook post-redirect Stripe — `usePlan()` peut retourner ancien plan brièvement). **21 FTD actives — cap 15 dépassé de 6. Résorption FTD critique au Sprint 5.5 avant Sprint 6.** |
| 1.27 | 2026-04-26 | Sprint 5.5 Clean — FTD-09, FTD-33, FTD-42 gelées. FTD-35 fermée (subsumée par FTD-41). FTD-14, FTD-38, FTD-39 résolues. **14 FTD actives** (cap 15 respecté). |
| 1.28 | 2026-04-26 | Sprint 6c — FTD-09 et FTD-33 résolues (dégelées → fermées). **14 FTD actives** (inchangé — les gelées ne comptaient pas dans le cap). |
| 1.29 | 2026-06-30 | Sprint 7b (T1 Live) — Ajout FTD-44 🟡 **gelée** (hooks audio génériques empruntés à `features/t2-live/`, réactivée au Sprint 7.5). **14 FTD actives** (inchangé — entrée gelée, ne compte pas dans le cap, même mécanique que FTD-06). |
| 1.30 | 2026-06-30 | Sprint 7b (T1 Live, finalisation) — Ajout FTD-45 🟡 **gelée** (relances Gemini hors-sujet, extension TD-23) et FTD-46 🟡 **gelée** (transcription Gemini Live hasardeuse). Bugs amont observés au test manuel, hors contrôle frontend. **14 FTD actives** (inchangé — entrées gelées, ne comptent pas dans le cap). |

View file

@ -1,6 +1,6 @@
# ADR 006 — Stack frontend : versions 2026 (React 19, Vite 8, TypeScript 6, Tailwind 4, RR7)
**Statut :** Accepté
**Statut :** Accepté — mis à jour Sprint 0.5
**Date :** 2026-04-17
**Décideur :** Hermann
**Contexte :** Révélé par l'état des lieux Claude Code au démarrage du Sprint 0 frontend
@ -105,18 +105,150 @@ Garder React 19, Vite 8, TypeScript 6 mais downgrader Tailwind 4 → 3 pour "com
### Configuration Tailwind 4
Pas de `tailwind.config.ts`. La configuration se fait exclusivement dans `src/index.css` via les directives :
Pas de `tailwind.config.ts`. La configuration se fait exclusivement dans `src/index.css`.
#### Mode thème (mis à jour Sprint DA Charcoal — 2026-04-24)
**Dark est le thème par défaut.** Les tokens de contenu (`--color-canvas`, `--color-ink-*`, etc.) sont déclarés en mode dark dans `@theme`. Une classe `.light` sur `<html>` active le mode clair en override. Configuré via :
```css
@import "tailwindcss";
@custom-variant light (&:where(.light, .light *));
```
Ce variant permet d'écrire `light:bg-surface` dans les composants quand un comportement spécifique au mode clair est requis (ex. primitives shadcn où l'opacité doit être adaptée).
#### Tokens @theme (DA Charcoal — validée Sprint DA Charcoal 2026-04-24)
```css
@import 'tailwindcss';
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
@custom-variant light (&:where(.light, .light *));
@theme {
--color-primary: #1B4FD8; /* Couleur brand Expria */
--font-sans: "Plus Jakarta Sans", system-ui, sans-serif;
/* Autres variables de thème */
/* ── Invariants (identiques dark + light) ── */
/* Sidebar navy permanent */
--color-sidebar-bg: #0C1528;
--color-sidebar-border: rgba(255, 255, 255, 0.07);
--color-sidebar-text: rgba(255, 255, 255, 0.6);
--color-sidebar-text-hover: rgba(255, 255, 255, 0.9);
--color-sidebar-text-active: #FFFFFF;
--color-sidebar-nav-hover: rgba(255, 255, 255, 0.07);
--color-sidebar-nav-active: rgba(255, 255, 255, 0.1);
--color-sidebar-section-label: rgba(255, 255, 255, 0.3);
/* Brand */
--color-brand: #1B4FD8;
--color-brand-hover: #1744B8;
--color-brand-active: #13379C;
--color-brand-dark: #1740B0;
--color-brand-ink: #FFFFFF;
/* Semantic (invariants) */
--color-warning: #F59E0B;
--color-warning-soft: rgba(245, 158, 11, 0.12);
--color-danger: #EF4444;
--color-danger-soft: rgba(239, 68, 68, 0.12);
/* Typographie */
--font-sans: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
/* Rayons (override Tailwind) */
--radius-xs: 6px; --radius-sm: 8px; --radius-md: 12px;
--radius-lg: 16px; --radius-xl: 20px; --radius-pill: 999px;
/* Focus */
--shadow-focus: 0 0 0 3px rgba(27, 79, 216, 0.18);
/* ── Dark mode (défaut) ── */
--color-canvas: #111111;
--color-surface: rgba(255, 255, 255, 0.035);
--color-surface-hover: rgba(255, 255, 255, 0.055);
--color-surface-solid: #1E1E1E;
--color-surface-raised: #222222;
--color-border: rgba(255, 255, 255, 0.06);
--color-border-strong: rgba(255, 255, 255, 0.12);
--color-ink-primary: #E5E5E5;
--color-ink-secondary: rgba(255, 255, 255, 0.55);
--color-ink-tertiary: rgba(255, 255, 255, 0.3);
--color-ink-inverse: #111111;
--color-brand-soft: rgba(27, 79, 216, 0.1);
--color-brand-text: #7DA4F0;
--color-success: #4ADE80;
--color-success-soft: rgba(74, 222, 128, 0.12);
--color-topbar-bg: rgba(17, 17, 17, 0.88);
--color-gradient-a: rgba(27, 79, 216, 0.05);
--color-gradient-b: rgba(27, 79, 216, 0.03);
--shadow-card: none;
--shadow-raised: none;
}
/* Light mode — override sur <html class="light"> */
.light {
--color-canvas: #F3F4F6;
--color-surface: #FFFFFF;
--color-surface-hover: #F8F9FB;
--color-surface-solid: #FFFFFF;
--color-surface-raised: #FFFFFF;
--color-border: rgba(0, 0, 0, 0.07);
--color-border-strong: rgba(0, 0, 0, 0.14);
--color-ink-primary: #0F0F1A;
--color-ink-secondary: rgba(0, 0, 0, 0.55);
--color-ink-tertiary: rgba(0, 0, 0, 0.3);
--color-ink-inverse: #FFFFFF;
--color-brand-soft: rgba(27, 79, 216, 0.06);
--color-brand-text: #1B4FD8;
--color-success: #16A34A;
--color-success-soft: rgba(22, 163, 74, 0.1);
--color-topbar-bg: rgba(243, 244, 246, 0.88);
--color-gradient-a: rgba(27, 79, 216, 0.025);
--color-gradient-b: rgba(27, 79, 216, 0.01);
--shadow-card: 0 1px 2px rgba(15, 18, 32, 0.04), 0 1px 8px rgba(15, 18, 32, 0.03);
--shadow-raised: 0 4px 16px rgba(15, 18, 32, 0.06), 0 1px 2px rgba(15, 18, 32, 0.04);
}
```
#### Classes Tailwind générées
Les tokens `@theme` créent des classes utilitaires directement utilisables :
| Token | Classes Tailwind |
|---|---|
| `--color-canvas` | `bg-canvas`, `text-canvas`, `border-canvas` |
| `--color-surface` | `bg-surface`, `border-surface` |
| `--color-surface-hover` | `bg-surface-hover` |
| `--color-sidebar-bg` | `bg-sidebar-bg` (navy permanent, identique dark+light) |
| `--color-ink-primary` | `text-ink-primary` |
| `--color-ink-secondary` | `text-ink-secondary` |
| `--color-ink-tertiary` | `text-ink-tertiary` |
| `--color-brand` | `bg-brand`, `border-brand`, `ring-brand` |
| `--color-brand-text` | `text-brand-text` (bleu adapté au fond — `#7DA4F0` dark, `#1B4FD8` light) |
| `--color-brand-soft` | `bg-brand-soft` (teinte chip / highlight discret) |
| `--color-success-soft`, `-warning-soft`, `-danger-soft` | `bg-success-soft`, etc. |
| `--shadow-card`, `--shadow-raised` | `shadow-card`, `shadow-raised` (auto dual-theme : `none` en dark, ombre en light) |
| `--shadow-focus` | `shadow-focus` (halo bleu 3px sur `:focus-visible`) |
**Conventions critiques :**
- `bg-surface` = cards / modals / panels. `bg-canvas` = fond de page. Ne jamais inverser.
- `bg-sidebar-bg` = navy permanent — ne change jamais entre dark et light (ancre visuelle de marque).
- Utiliser le préfixe `light:` uniquement quand un override spécifique au mode clair est strictement nécessaire (ex. primitives shadcn où l'opacité d'une couleur sémantique diffère).
#### Typographie
Plus Jakarta Sans chargée via Google Fonts dans `index.html` (preconnect + stylesheet, weights 400/500/600/700). Migration vers auto-hébergement (`@fontsource/plus-jakarta-sans`) après MVP si les performances réseau deviennent un enjeu.
### shadcn/ui avec Tailwind 4
La CLI shadcn/ui supporte Tailwind 4 depuis début 2025 :

View file

@ -5,6 +5,19 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Expria</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<script>
;(function () {
var t = localStorage.getItem('expria-theme')
if (!t) t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
if (t === 'light') document.documentElement.classList.add('light')
})()
</script>
</head>
<body>
<div id="root"></div>

1172
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,8 @@
"test:watch": "vitest",
"format": "prettier --write \"src/**/*.{ts,tsx,css,md}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,md}\"",
"preview": "vite preview"
"preview": "vite preview",
"sync:roadmap": "node scripts/sync-roadmap.mjs"
},
"dependencies": {
"@supabase/supabase-js": "^2.103.2",
@ -23,6 +24,7 @@
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.14.1",
"tailwind-merge": "^2.5.5",
"zod": "^3.24.1"

View file

@ -0,0 +1,80 @@
/**
* pcm-capture-processor.js AudioWorklet processor pour T2 Live (Sprint 6b).
*
* Capture du micro à `sampleRate` natif du navigateur (typiquement 48 kHz),
* rééchantillonnage vers 16 kHz si nécessaire, conversion Float32 Int16
* little-endian, envoi par chunks de ~4096 samples ( 256 ms à 16 kHz).
*
* Format de sortie attendu par Gemini Live API :
* PCM brut, 16 kHz, 16 bits, little-endian, mono.
*
* Le rééchantillonnage utilise une interpolation linéaire équivalent
* à `resample16kTo24k` côté audio-utils.ts mais en sens inverse.
*
* Vanille JS (pas TS) : les AudioWorklet processors s'exécutent dans un
* scope global isolé qui ne peut pas importer depuis le bundle TS.
*/
const TARGET_SAMPLE_RATE = 16000
const CHUNK_SIZE_16K = 4096 // ≈ 256 ms à 16 kHz
class PcmCaptureProcessor extends AudioWorkletProcessor {
constructor() {
super()
this.buffer16k = new Float32Array(0)
}
/**
* Rééchantillonne un Float32 du sample rate source vers 16 kHz par
* interpolation linéaire. Si srcRate === 16000, no-op.
*/
resampleTo16k(input, srcRate) {
if (srcRate === TARGET_SAMPLE_RATE) return input
const ratio = TARGET_SAMPLE_RATE / srcRate
const outLength = Math.floor(input.length * ratio)
const out = new Float32Array(outLength)
for (let i = 0; i < outLength; i++) {
const srcIndex = i / ratio
const srcFloor = Math.floor(srcIndex)
const srcCeil = Math.min(srcFloor + 1, input.length - 1)
const frac = srcIndex - srcFloor
out[i] = input[srcFloor] * (1 - frac) + input[srcCeil] * frac
}
return out
}
process(inputs) {
const input = inputs[0]
if (!input || !input[0]) return true
const channelData = input[0] // mono
// Rééchantillonner d'abord vers 16 kHz puis accumuler.
// `sampleRate` est une variable globale du scope AudioWorklet (Web Audio spec).
const resampled = this.resampleTo16k(channelData, sampleRate)
const newBuffer = new Float32Array(this.buffer16k.length + resampled.length)
newBuffer.set(this.buffer16k)
newBuffer.set(resampled, this.buffer16k.length)
this.buffer16k = newBuffer
while (this.buffer16k.length >= CHUNK_SIZE_16K) {
const chunk = this.buffer16k.slice(0, CHUNK_SIZE_16K)
this.buffer16k = this.buffer16k.slice(CHUNK_SIZE_16K)
// Float32 [-1, 1] → Int16 PCM little-endian
const pcm = new ArrayBuffer(chunk.length * 2)
const view = new DataView(pcm)
for (let i = 0; i < chunk.length; i++) {
const s = Math.max(-1, Math.min(1, chunk[i]))
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true)
}
this.port.postMessage(pcm, [pcm])
}
return true
}
}
registerProcessor('pcm-capture-processor', PcmCaptureProcessor)

View file

@ -0,0 +1,61 @@
/**
* pcm-record-processor.js AudioWorklet processor d'ENREGISTREMENT T2 Live
* (Sprint 6e, Voie A tap temps réel).
*
* Branché en dérivation sur le `mixGain` de capture (point de convergence
* micro + voix IA dans le contexte PARTAGÉ). Il LIT le mix au rate NATIF du
* contexte (typiquement 48 kHz), convertit Float32 Int16 little-endian, et
* envoie des chunks (~4096 samples) au thread principal via `port.postMessage`.
*
* Aucun rééchantillonnage : on enregistre au rate natif (le WAV est écrit à ce
* même rate côté useAudioRecording). L'alignement temporel micro/IA est natif
* les deux voix partagent l'horloge unique du contexte (plus de réassemblage
* offline à base d'offsets).
*
* Le node est tiré par le graphe via mixGain recordNode gain(0)
* destination (sink muet) ; ce processor n'écrit rien sur ses sorties (silence),
* il ne fait que prélever l'entrée. Le gain(0) garantit zéro résidu audible.
*
* Vanille JS (pas TS) : les AudioWorklet processors s'exécutent dans un scope
* global isolé qui ne peut pas importer depuis le bundle TS.
*/
const RECORD_CHUNK_SIZE = 4096
class PcmRecordProcessor extends AudioWorkletProcessor {
constructor() {
super()
this.buffer = new Float32Array(0)
}
process(inputs) {
const input = inputs[0]
if (!input || !input[0]) return true
const channelData = input[0] // mono (mix micro + IA)
const merged = new Float32Array(this.buffer.length + channelData.length)
merged.set(this.buffer)
merged.set(channelData, this.buffer.length)
this.buffer = merged
while (this.buffer.length >= RECORD_CHUNK_SIZE) {
const chunk = this.buffer.slice(0, RECORD_CHUNK_SIZE)
this.buffer = this.buffer.slice(RECORD_CHUNK_SIZE)
// Float32 [-1, 1] → Int16 PCM little-endian
const pcm = new ArrayBuffer(chunk.length * 2)
const view = new DataView(pcm)
for (let i = 0; i < chunk.length; i++) {
const s = Math.max(-1, Math.min(1, chunk[i]))
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true)
}
this.port.postMessage(pcm, [pcm])
}
return true
}
}
registerProcessor('pcm-record-processor', PcmRecordProcessor)

195
scripts/sync-roadmap.mjs Normal file
View file

@ -0,0 +1,195 @@
#!/usr/bin/env node
// @ts-check
/**
* sync-roadmap.mjs synchronise la ROADMAP frontend backend (sens UNIQUE).
*
* SOURCE DE VÉRITÉ : expria-frontend/docs/ROADMAP.md (versionnée).
* CIBLE (copie générée) : expria-backend/docs/ROADMAP.md.
*
* HYPOTHÈSE D'ARBORESCENCE : les deux repos sont FRÈRES sous un parent commun,
* p.ex. D:\expria-v2\{expria-frontend, expria-backend}. Ce script ne fonctionne
* QUE dans cette disposition. Override possible via la variable d'environnement
* EXPRIA_BACKEND_DIR (chemin absolu vers le repo expria-backend).
*
* SENS UNIQUE STRICT : jamais backend frontend. Aucun paramètre n'inverse le sens ;
* une vérification défensive refuse d'écrire vers un chemin contenant 'expria-frontend'.
*
* DÉCISION (B) bannière auto-générée : la cible reçoit, EN TÊTE, un commentaire HTML
* (invisible au rendu Markdown) avertissant que le fichier est généré. Le diff IGNORE
* cette bannière (comparaison corps source vs corps cible), pour rester idempotent.
*
* PAS DE COMMIT AUTO : le script écrit le fichier uniquement ; le commit backend reste
* une action manuelle validée.
*
* USAGE (depuis expria-frontend/) :
* npm run sync:roadmap # interactif : montre le diff, demande confirmation y/N
* node scripts/sync-roadmap.mjs --check # dry-run : exit 1 si désynchro, n'écrit rien
* node scripts/sync-roadmap.mjs --yes # non-interactif : écrit sans confirmation
*/
import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join, resolve, sep } from 'node:path'
import { createInterface } from 'node:readline'
const BANNER =
'<!-- AUTO-GÉNÉRÉ depuis expria-frontend/docs/ROADMAP.md — NE PAS ÉDITER À LA MAIN.\n' +
' Toute modification passe par le frontend, puis : npm run sync:roadmap -->\n\n'
const EXPECTED_HEADER = '# ROADMAP.md'
/** Affiche un message d'erreur et termine en échec. */
function fail(message) {
console.error(`\n[sync-roadmap] ERREUR : ${message}\n`)
process.exit(1)
}
// --- Résolution des chemins (dérivés de la position du script) -------------
const scriptDir = dirname(fileURLToPath(import.meta.url))
const frontendRoot = resolve(scriptDir, '..')
const sourcePath = join(frontendRoot, 'docs', 'ROADMAP.md')
const backendRoot = process.env.EXPRIA_BACKEND_DIR
? resolve(process.env.EXPRIA_BACKEND_DIR)
: resolve(frontendRoot, '..', 'expria-backend')
const backendDocsDir = join(backendRoot, 'docs')
const targetPath = join(backendDocsDir, 'ROADMAP.md')
// --- Garde-fou : sens unique strict ----------------------------------------
// La cible ne doit JAMAIS être (ni vivre dans) le repo source. La garde qui fait
// foi est l'identité de chemins RÉSOLUS ; le test de sous-chaîne ne reste qu'un
// signal secondaire. Sur Windows les chemins sont insensibles à la casse.
const norm = (p) => (process.platform === 'win32' ? p.toLowerCase() : p)
const resolvedTarget = norm(resolve(targetPath))
const resolvedSource = norm(resolve(sourcePath))
const resolvedFrontend = norm(resolve(frontendRoot))
// 1. Garde PRIMAIRE : cible == source (identité de chemin résolu).
if (resolvedTarget === resolvedSource) {
fail(`refus : la cible EST la source (sens unique). Chemin = ${targetPath}`)
}
// 2. Garde ÉLARGIE : cible à l'intérieur du repo source (tout fichier du frontend).
if (resolvedTarget.startsWith(resolvedFrontend + sep)) {
fail(`refus : la cible est à l'intérieur du repo source (sens unique). Cible = ${targetPath}`)
}
// 3. Garde SECONDAIRE (signal de bon sens) : nom contenant 'expria-frontend'.
if (resolvedTarget.includes('expria-frontend')) {
fail(`refus : la cible référence le repo source (sens unique). Cible = ${targetPath}`)
}
// --- Garde-fou : arborescence backend --------------------------------------
if (!existsSync(backendDocsDir)) {
fail(
`repo backend introuvable comme frère.\n` +
` Attendu : ${backendDocsDir}\n` +
` Astuce : place expria-backend à côté de expria-frontend, ` +
`ou exporte EXPRIA_BACKEND_DIR vers le repo backend.`,
)
}
// --- Garde-fou : source valide ---------------------------------------------
if (!existsSync(sourcePath)) {
fail(`source introuvable : ${sourcePath}`)
}
if (statSync(sourcePath).size === 0) {
fail(`source vide : ${sourcePath} (on ne remplace jamais la cible par du vide)`)
}
const sourceBody = readFileSync(sourcePath, 'utf8')
if (!sourceBody.trimStart().startsWith(EXPECTED_HEADER)) {
fail(
`la source ne commence pas par « ${EXPECTED_HEADER} » — ` +
`format inattendu, abandon par sécurité.`,
)
}
// --- Calcul du contenu cible (bannière + corps source) ---------------------
const desiredTarget = BANNER + sourceBody
/** Retire la bannière auto-générée d'un contenu cible, pour comparer le corps seul. */
function stripBanner(content) {
if (content.startsWith('<!-- AUTO-GÉNÉRÉ')) {
const end = content.indexOf('-->')
if (end !== -1) {
// Saute le '-->' puis les sauts de ligne qui le suivent.
return content.slice(end + 3).replace(/^\r?\n+/, '')
}
}
return content
}
const currentTargetRaw = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : null
const currentBody = currentTargetRaw === null ? null : stripBanner(currentTargetRaw)
// --- Idempotence : déjà synchro ? ------------------------------------------
// On compare les CORPS (bannière ignorée).
if (currentBody === sourceBody) {
console.log('[sync-roadmap] déjà synchro — rien à faire.')
process.exit(0)
}
// --- Diff (corps source vs corps cible, bannière exclue) -------------------
function printDiff(oldBody, newBody) {
const oldLines = (oldBody ?? '').split('\n')
const newLines = newBody.split('\n')
const max = Math.max(oldLines.length, newLines.length)
console.log('\n[sync-roadmap] différences (corps, bannière exclue) :')
console.log(` source : ${sourcePath}`)
console.log(` cible : ${targetPath}\n`)
let shown = 0
for (let i = 0; i < max; i++) {
if (oldLines[i] !== newLines[i]) {
if (oldLines[i] !== undefined) console.log(` - ${oldLines[i]}`)
if (newLines[i] !== undefined) console.log(` + ${newLines[i]}`)
shown++
}
}
if (currentTargetRaw === null) {
console.log(" (la cible n'existe pas encore — création complète)")
}
console.log(`\n ${shown} ligne(s) divergente(s).`)
}
printDiff(currentBody, sourceBody)
const args = new Set(process.argv.slice(2))
// --- Mode --check : dry-run, signale la désynchro, n'écrit rien ------------
if (args.has('--check')) {
console.error('\n[sync-roadmap] --check : cible DÉSYNCHRONISÉE (aucune écriture).\n')
process.exit(1)
}
/** Demande une confirmation y/N (refus par défaut). */
function confirm(question) {
return new Promise((res) => {
const rl = createInterface({ input: process.stdin, output: process.stdout })
rl.question(question, (answer) => {
rl.close()
res(/^y(es)?$/i.test(answer.trim()))
})
})
}
/** Écrit la cible et rappelle la marche à suivre manuelle. */
function writeTarget() {
writeFileSync(targetPath, desiredTarget, 'utf8')
console.log(`\n[sync-roadmap] écrit : ${targetPath}`)
console.log('\n[sync-roadmap] PAS de commit automatique. Marche à suivre manuelle :')
console.log(' cd ../expria-backend')
console.log(' git add docs/ROADMAP.md')
console.log(' git commit -m "docs(roadmap): sync depuis frontend"\n')
}
if (args.has('--yes')) {
writeTarget()
process.exit(0)
}
const ok = await confirm('\n[sync-roadmap] Écrire la cible backend ? (y/N) ')
if (!ok) {
console.log('[sync-roadmap] annulé — aucune écriture.')
process.exit(0)
}
writeTarget()

89
src/app/AppLayout.tsx Normal file
View file

@ -0,0 +1,89 @@
/**
* Layout applicatif enveloppe toutes les routes privées.
*
* Desktop ( 1024px) : Sidebar fixe 230px + Topbar sticky + zone contenu.
* Mobile (< 1024px) : Topbar avec hamburger + drawer slide-in + BottomNav fixe.
*
* Le drawer mobile se ferme automatiquement à chaque changement de route
* (useEffect sur location.pathname).
*
* Règle L : tokens du design system exclusivement.
* Règle H : aucune logique métier plan lu depuis le cache TanStack Query.
*/
import { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { Sidebar } from './Sidebar'
import { Topbar } from './Topbar'
import { BottomNav } from './BottomNav'
import { usePlan } from '@/features/dashboard/hooks/usePlan'
import { cn } from '@/shared/lib/utils'
import type { Plan } from '@/entities/user/lib'
interface AppLayoutProps {
children: React.ReactNode
}
export function AppLayout({ children }: AppLayoutProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const location = useLocation()
const { data } = usePlan()
const plan: Plan = data?.plan ?? 'free'
// Ferme le drawer à chaque changement de route.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsMobileMenuOpen(false)
}, [location.pathname])
const mainBackground = `
radial-gradient(ellipse at 35% 0%, var(--color-gradient-a), transparent 55%),
radial-gradient(ellipse at 80% 100%, var(--color-gradient-b), transparent 50%),
var(--color-canvas)
`
return (
<div className="min-h-screen">
{/* ── DESKTOP — Sidebar fixe 230px ───────────────────────────── */}
<aside className="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:flex lg:w-[230px] lg:flex-col">
<Sidebar plan={plan} />
</aside>
{/* ── MOBILE — Drawer overlay ────────────────────────────────── */}
<div
aria-hidden="true"
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'fixed inset-0 z-40 bg-black/40 transition-opacity duration-200 ease-out lg:hidden',
isMobileMenuOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
/>
{/* ── MOBILE — Drawer panel ──────────────────────────────────── */}
<div
className={cn(
'fixed inset-y-0 left-0 z-50 flex w-[230px] flex-col transition-transform duration-200 ease-out lg:hidden',
isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full',
)}
aria-hidden={!isMobileMenuOpen}
>
<Sidebar plan={plan} />
</div>
{/* ── Zone de contenu principale ─────────────────────────────── */}
{/* pb-16 sur mobile pour ne pas être masqué par le BottomNav fixe */}
<main
className="min-h-screen pb-16 lg:pb-0 lg:pl-[230px]"
style={{ background: mainBackground }}
>
<Topbar onMobileMenuOpen={() => setIsMobileMenuOpen(true)} />
{/* Pas de padding ni de max-width ici : chaque page gère sa propre
largeur de contenu et son propre padding (cf. HistoriquePage). */}
{children}
</main>
{/* ── MOBILE — BottomNav fixe ────────────────────────────────── */}
<BottomNav />
</div>
)
}

139
src/app/BottomNav.tsx Normal file
View file

@ -0,0 +1,139 @@
/**
* Navigation mobile fixe affichée uniquement en dessous de 1024px.
*
* 4 items : Accueil / Simuler / Progression / Compte.
* "Simuler" ouvre une bottom sheet (EE / EO / Examen blanc).
* Tap target 44×44px minimum (DESIGN_SYSTEM.md §7).
*
* Règle L : tokens du design system exclusivement.
* Règle H : aucune logique métier navigation uniquement.
*/
import { useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { Home, BookOpen, TrendingUp, User } from 'lucide-react'
import { cn } from '@/shared/lib/utils'
const SHEET_ITEMS = [
{ label: 'Expression Écrite', to: '/simulation/ee' },
{ label: 'Expression Orale', to: '/simulation/eo' },
{ label: 'Examen blanc', to: '/examen' },
] as const
export function BottomNav() {
const [isSheetOpen, setIsSheetOpen] = useState(false)
const location = useLocation()
const navigate = useNavigate()
const isActive = (prefix: string) => location.pathname.startsWith(prefix)
function handleSheetNavigate(to: string) {
setIsSheetOpen(false)
navigate(to)
}
const navItemClasses = (active: boolean) =>
cn(
'flex min-h-[44px] flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors duration-150',
active ? 'text-brand-text' : 'text-ink-tertiary hover:text-ink-primary',
)
return (
<>
{/* Bottom sheet overlay */}
{isSheetOpen && (
<div
className="fixed inset-0 z-40 lg:hidden"
aria-hidden="true"
onClick={() => setIsSheetOpen(false)}
/>
)}
{/* Bottom sheet */}
{isSheetOpen && (
<div
role="dialog"
aria-label="Choisir une simulation"
className="fixed bottom-16 left-0 right-0 z-50 rounded-t-xl border-t border-border bg-surface px-2 py-2 shadow-raised lg:hidden"
>
<p className="px-3 pb-2 pt-1 text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Simuler
</p>
<ul role="list">
{SHEET_ITEMS.map((item) => (
<li key={item.to}>
<button
type="button"
onClick={() => handleSheetNavigate(item.to)}
className="flex min-h-[44px] w-full items-center rounded-md px-3 text-sm text-ink-primary transition-colors duration-150 hover:bg-surface-hover"
>
{item.label}
</button>
</li>
))}
</ul>
</div>
)}
{/* Bottom nav bar */}
<nav
aria-label="Navigation mobile"
className="fixed bottom-0 left-0 right-0 z-30 flex h-16 items-center border-t border-border bg-surface lg:hidden"
>
{/* Accueil */}
<Link
to="/dashboard"
aria-label="Accueil"
className={navItemClasses(isActive('/dashboard'))}
>
<Home
className={cn('size-5', isActive('/dashboard') && 'text-brand-text')}
aria-hidden="true"
/>
Accueil
</Link>
{/* Simuler */}
<button
type="button"
aria-label="Simuler"
aria-expanded={isSheetOpen}
onClick={() => setIsSheetOpen((v) => !v)}
className={navItemClasses(isActive('/simulation') || isSheetOpen)}
>
<BookOpen
className={cn('size-5', (isActive('/simulation') || isSheetOpen) && 'text-brand-text')}
aria-hidden="true"
/>
Simuler
</button>
{/* Progression */}
<Link
to="/progression"
aria-label="Progression"
className={navItemClasses(isActive('/progression'))}
>
<TrendingUp
className={cn('size-5', isActive('/progression') && 'text-brand-text')}
aria-hidden="true"
/>
Progression
</Link>
{/* Compte */}
<Link
to="/parametres"
aria-label="Compte"
className={navItemClasses(isActive('/parametres'))}
>
<User
className={cn('size-5', isActive('/parametres') && 'text-brand-text')}
aria-hidden="true"
/>
Compte
</Link>
</nav>
</>
)
}

View file

@ -0,0 +1,24 @@
import { Logo } from '@/shared/components/Logo'
export function MaintenancePage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 bg-canvas px-4 text-center">
<Logo size="md" />
<div className="space-y-2">
<h1 className="text-2xl font-semibold text-ink-primary">Maintenance en cours</h1>
<p className="text-sm text-ink-secondary">
Expria est temporairement indisponible. Revenez dans quelques instants.
</p>
</div>
<p className="text-xs text-ink-tertiary">
Des questions ?{' '}
<a
href="mailto:support@expria.ca"
className="text-brand-text underline-offset-4 hover:underline"
>
support@expria.ca
</a>
</p>
</div>
)
}

233
src/app/Sidebar.tsx Normal file
View file

@ -0,0 +1,233 @@
/**
* Sidebar desktop navigation principale ( 1024px).
*
* DA Charcoal : navy permanent (#0C1528), identique dark et light.
* Règle D : le verrouillage des items passe par hasAccess(),
* jamais par if (plan === '...').
* Règle L : tokens du design system exclusivement.
*/
import { NavLink } from 'react-router-dom'
import {
Activity,
ArrowUpCircle,
BookOpen,
Clock,
FileText,
LayoutGrid,
Lock,
Mic,
Pencil,
Settings,
User as UserIcon,
type LucideIcon,
} from 'lucide-react'
import { hasAccess } from '@/entities/user/lib'
import { Logo } from '@/shared/components/Logo'
import { ThemeToggle } from '@/shared/components/ThemeToggle'
import { useAuth } from '@/features/auth/hooks/useAuth'
import { cn } from '@/shared/lib/utils'
import type { Feature, Plan } from '@/entities/user/lib'
import type { User } from '@/shared/lib/auth-client'
interface NavItem {
label: string
to: string
feature: Feature | null
Icon: LucideIcon
/**
* Affiche un badge upgrade (flèche bleue) à droite du label quand
* l'utilisateur peut encore passer à un plan supérieur. Aujourd'hui
* utilisé uniquement sur "Mon plan".
*/
showUpgradeWhenUpgradable?: boolean
}
const PREPARE_ITEMS: readonly NavItem[] = [
{ label: 'Tableau de bord', to: '/dashboard', feature: null, Icon: LayoutGrid },
{ label: 'Expression Écrite', to: '/simulation/ee', feature: null, Icon: Pencil },
{ label: 'Expression Orale', to: '/simulation/eo', feature: null, Icon: Mic },
{ label: 'Examen blanc', to: '/examen', feature: 'exam_mode', Icon: FileText },
{ label: 'Progression', to: '/progression', feature: 'pattern_analysis', Icon: Activity },
{ label: 'Méthodologie', to: '/methodologie', feature: null, Icon: BookOpen },
{ label: 'Historique', to: '/historique', feature: 'dashboard', Icon: Clock },
]
const ACCOUNT_ITEMS: readonly NavItem[] = [
{
label: 'Mon plan',
to: '/plan',
feature: null,
Icon: UserIcon,
showUpgradeWhenUpgradable: true,
},
{ label: 'Paramètres', to: '/parametres', feature: null, Icon: Settings },
]
const PLAN_LABELS: Record<Plan, string> = {
free: 'Plan Découverte',
standard: 'Plan Standard',
premium: 'Plan Premium',
}
/**
* Proxy "peut encore upgrader". Examen blanc est exclusif Premium
* (PLANS_TARIFAIRES.md §4), donc son absence d'accès = plan en dessous
* du top-tier. Utilisé uniquement pour affichage UX, pas un check de
* permission (Règle D respectée on passe par hasAccess).
*/
function isUpgradable(plan: Plan): boolean {
return !hasAccess(plan, 'exam_mode')
}
function getInitials(user: User | null): string {
if (!user) return '··'
const fullName = user.user_metadata?.full_name as string | undefined
if (fullName) {
const parts = fullName.trim().split(/\s+/)
if (parts.length >= 2 && parts[0] && parts[parts.length - 1]) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
return fullName.slice(0, 2).toUpperCase()
}
const local = user.email?.split('@')[0] ?? ''
return local.slice(0, 2).toUpperCase() || '··'
}
function getDisplayName(user: User | null): string {
if (!user) return 'Invité'
const fullName = user.user_metadata?.full_name as string | undefined
if (fullName) return fullName
return user.email?.split('@')[0] ?? 'Invité'
}
function SidebarItem({ item, plan }: { item: NavItem; plan: Plan }) {
const locked = item.feature !== null && !hasAccess(plan, item.feature)
const showUpgrade = item.showUpgradeWhenUpgradable === true && isUpgradable(plan)
const { Icon } = item
return (
<NavLink
to={item.to}
aria-disabled={locked}
className={({ isActive }) =>
cn(
'relative flex items-center gap-2.5 rounded-lg px-2.5 py-2',
'text-[13px] font-medium transition-colors',
isActive && !locked
? 'bg-[var(--color-sidebar-nav-active)] font-semibold text-[var(--color-sidebar-text-active)]'
: locked
? 'cursor-default text-[var(--color-sidebar-text)] opacity-40'
: 'text-[var(--color-sidebar-text)] hover:bg-[var(--color-sidebar-nav-hover)] hover:text-[var(--color-sidebar-text-hover)]',
)
}
>
{({ isActive }) => (
<>
{isActive && !locked && (
<span
aria-hidden="true"
className="absolute bottom-[20%] left-0 top-[20%] w-[3px] rounded-r bg-[var(--color-brand)]"
/>
)}
<Icon
className={cn('size-4 shrink-0', isActive && !locked ? 'opacity-100' : 'opacity-60')}
aria-hidden="true"
/>
<span className="flex-1">{item.label}</span>
{locked && <Lock className="size-3 shrink-0 opacity-40" aria-hidden="true" />}
{showUpgrade && !locked && (
<ArrowUpCircle
className="size-3.5 shrink-0 text-[var(--color-brand-text)]"
aria-hidden="true"
/>
)}
</>
)}
</NavLink>
)
}
function SidebarSection({
label,
items,
plan,
className,
}: {
label: string
items: readonly NavItem[]
plan: Plan
className?: string
}) {
return (
<div className={className}>
<p className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-widest text-[var(--color-sidebar-section-label)]">
{label}
</p>
<ul role="list" className="space-y-0.5">
{items.map((item) => (
<li key={item.to}>
<SidebarItem item={item} plan={plan} />
</li>
))}
</ul>
</div>
)
}
function UserFooter({ plan }: { plan: Plan }) {
const { user } = useAuth()
const initials = getInitials(user)
const displayName = getDisplayName(user)
return (
<div className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="flex size-8 shrink-0 items-center justify-center rounded-full border border-white/15 bg-white/10 text-xs font-bold text-white"
>
{initials}
</span>
<div className="min-w-0 flex-1 leading-tight">
<p className="truncate text-[12.5px] font-semibold text-white">{displayName}</p>
<p className="text-[10.5px] text-white/40">{PLAN_LABELS[plan]}</p>
</div>
<ThemeToggle className="shrink-0 text-white/60 hover:text-white" />
</div>
)
}
interface SidebarProps {
plan: Plan
}
export function Sidebar({ plan }: SidebarProps) {
return (
<div className="flex h-full w-full flex-col border-r border-[var(--color-sidebar-border)] bg-[var(--color-sidebar-bg)]">
{/* Logo header */}
<div className="flex h-14 shrink-0 items-center gap-2.5 border-b border-[var(--color-sidebar-border)] px-4">
<Logo variant="icon" size="sm" className="text-white" />
<div className="flex flex-col leading-none">
<span className="text-lg font-extrabold tracking-wide text-white">
EX<span className="opacity-30">|</span>PRIA
</span>
<span className="mt-0.5 text-[10px] text-white/35">Préparation TCF Canada</span>
</div>
</div>
{/* Navigation */}
<nav
className="flex flex-1 flex-col gap-6 overflow-y-auto px-3 py-4"
aria-label="Navigation principale"
>
<SidebarSection label="Préparer" items={PREPARE_ITEMS} plan={plan} />
<SidebarSection label="Compte" items={ACCOUNT_ITEMS} plan={plan} />
</nav>
{/* Footer — avatar + user info + ThemeToggle */}
<div className="shrink-0 border-t border-[var(--color-sidebar-border)] px-3 py-3">
<UserFooter plan={plan} />
</div>
</div>
)
}

86
src/app/Topbar.tsx Normal file
View file

@ -0,0 +1,86 @@
/**
* Topbar sticky au-dessus du contenu principal.
*
* - Breadcrumb "Expria <pageTitle>" (gauche).
* - Hamburger (mobile uniquement) qui ouvre le drawer Sidebar.
* - Barre de recherche placeholder (non fonctionnelle visuel only).
* - Icônes Command (raccourcis clavier) et Bell (notifications)
* non fonctionnelles, décoratives dans ce sprint.
*
* Règle L : tokens du design system exclusivement.
* Règle H : aucune logique métier navigation uniquement.
*/
import { useLocation } from 'react-router-dom'
import { Bell, Command, Menu, Search } from 'lucide-react'
import { getRouteTitle } from './route-titles'
interface TopbarProps {
onMobileMenuOpen: () => void
}
export function Topbar({ onMobileMenuOpen }: TopbarProps) {
const { pathname } = useLocation()
const title = getRouteTitle(pathname)
return (
<header
role="banner"
className="sticky top-0 z-10 flex h-14 items-center gap-3 border-b border-border bg-[var(--color-topbar-bg)] px-5 backdrop-blur-md lg:px-9"
>
{/* Hamburger (mobile only) */}
<button
type="button"
aria-label="Ouvrir le menu de navigation"
onClick={onMobileMenuOpen}
className="flex size-9 shrink-0 items-center justify-center rounded-md text-ink-secondary transition-colors hover:bg-surface-hover hover:text-ink-primary focus-visible:outline-none focus-visible:shadow-focus lg:hidden"
>
<Menu className="size-5" aria-hidden="true" />
</button>
{/* Breadcrumb */}
<nav aria-label="Fil d'Ariane" className="min-w-0 flex-1">
<ol className="flex items-center gap-1.5 text-sm">
<li className="text-ink-secondary">Expria</li>
<li aria-hidden="true" className="text-ink-tertiary">
</li>
<li className="truncate font-semibold text-ink-primary">{title}</li>
</ol>
</nav>
{/* Right cluster: search + command + bell */}
<div className="flex shrink-0 items-center gap-2">
<div className="relative hidden sm:block">
<Search
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-ink-tertiary"
aria-hidden="true"
/>
<input
type="search"
disabled
placeholder="Rechercher…"
aria-label="Rechercher"
className="h-8 w-[200px] rounded-[var(--radius-sm)] border border-border bg-surface pl-8 pr-3 text-sm text-ink-primary placeholder:text-ink-tertiary disabled:cursor-not-allowed"
/>
</div>
<button
type="button"
aria-label="Raccourcis clavier"
disabled
className="hidden size-8 items-center justify-center rounded-md text-ink-tertiary opacity-60 lg:flex"
>
<Command className="size-4" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Notifications"
disabled
className="flex size-8 items-center justify-center rounded-md text-ink-tertiary opacity-60"
>
<Bell className="size-4" aria-hidden="true" />
</button>
</div>
</header>
)
}

View file

@ -1,6 +1,8 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Providers } from './providers'
import { MaintenancePage } from './MaintenancePage'
import { isMaintenanceMode } from '@/shared/config/env'
import '../index.css'
const container = document.getElementById('root')
@ -9,7 +11,5 @@ if (!container) {
}
createRoot(container).render(
<StrictMode>
<Providers />
</StrictMode>,
<StrictMode>{isMaintenanceMode ? <MaintenancePage /> : <Providers />}</StrictMode>,
)

31
src/app/route-titles.ts Normal file
View file

@ -0,0 +1,31 @@
/**
* Mapping centralisé pathname titre de page.
*
* Consommé par la Topbar (breadcrumb "Expria <titre>") et tout composant
* qui a besoin du libellé humain d'une route. Maintient la source unique
* pas de duplication dans Sidebar/Topbar/helmet.
*/
const STATIC_ROUTES: Readonly<Record<string, string>> = {
'/dashboard': 'Tableau de bord',
'/simulation/ee': 'Expression Écrite',
'/simulation/eo': 'Expression Orale',
'/sujets': 'Choisir un sujet',
'/examen': 'Examen blanc',
'/progression': 'Progression',
'/methodologie': 'Méthodologie',
'/historique': 'Historique',
'/plan': 'Mon plan',
'/parametres': 'Paramètres',
'/login': 'Connexion',
'/register': 'Inscription',
'/design-system': 'Design System',
}
export function getRouteTitle(pathname: string): string {
const exact = STATIC_ROUTES[pathname]
if (exact) return exact
if (pathname.startsWith('/rapport/')) return 'Rapport'
if (pathname === '/' || pathname === '') return 'Tableau de bord'
return 'Expria'
}

View file

@ -1,19 +1,133 @@
import React, { Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'
import { Navigate, Outlet, Routes, Route } from 'react-router-dom'
import { LoginPage } from '@/features/auth/pages/LoginPage'
import { RegisterPage } from '@/features/auth/pages/RegisterPage'
import { ProtectedRoute } from '@/features/auth/components/ProtectedRoute'
import { DashboardPage } from '@/features/dashboard/pages/DashboardPage'
import { SimulationPage } from '@/features/simulations/pages/SimulationPage'
import { SujetsPage } from '@/features/simulations/pages/SujetsPage'
import { RapportPage } from '@/features/simulations/pages/RapportPage'
import { SimulationEOPage } from '@/features/simulations/pages/SimulationEOPage'
import { SujetsEOPage } from '@/features/simulations/pages/SujetsEOPage'
import { PreEnregistrementEOPage } from '@/features/simulations/pages/PreEnregistrementEOPage'
import { EnregistrementEOPage } from '@/features/simulations/pages/EnregistrementEOPage'
import { ModeChoixT1Page } from '@/features/simulations/pages/ModeChoixT1Page'
import { QuestionnaireT1Page } from '@/features/simulations/pages/QuestionnaireT1Page'
import { PresentationGenereeT1Page } from '@/features/simulations/pages/PresentationGenereeT1Page'
import { HistoriquePage } from '@/features/historique/pages/HistoriquePage'
import { ProgressionPage } from '@/features/progression/pages/ProgressionPage'
import { PricingPage } from '@/features/billing/pages/PricingPage'
import { ParametresPage } from '@/features/account/pages/ParametresPage'
import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider'
import { T2LiveProvider } from '@/features/t2-live/state/T2LiveContext'
import { T2SujetsPage } from '@/features/t2-live/pages/T2SujetsPage'
import { T2PreparationPage } from '@/features/t2-live/pages/T2PreparationPage'
import { T2DialoguePage } from '@/features/t2-live/pages/T2DialoguePage'
import { T1PreparationPage } from '@/features/t1-live/pages/T1PreparationPage'
import { T1DialoguePage } from '@/features/t1-live/pages/T1DialoguePage'
import { AppLayout } from './AppLayout'
const DesignSystemPage = import.meta.env.DEV
? React.lazy(() => import('@/features/design-system/DesignSystemPage'))
: () => null
function ComingSoon() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-2 px-4 text-center">
<p className="text-sm font-medium text-ink-primary">Page en cours de développement</p>
<p className="text-xs text-ink-secondary">Disponible dans une prochaine version.</p>
</div>
)
}
function PrivateLayout() {
return (
<ProtectedRoute>
<AppLayout>
<Outlet />
</AppLayout>
</ProtectedRoute>
)
}
function SimulationFlowLayout() {
return (
<SimulationFlowProvider>
<Outlet />
</SimulationFlowProvider>
)
}
function T2LiveLayout() {
return (
<T2LiveProvider>
<Outlet />
</T2LiveProvider>
)
}
export function AppRouter() {
return (
<Routes>
<Route path="/" element={<ScaffoldPlaceholder />} />
{/* ── Routes publiques ─────────────────────────────────────── */}
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
{/* ── Routes privées — ProtectedRoute + AppLayout ──────────── */}
<Route element={<PrivateLayout />}>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
{/* Simulation /simulation/ee, /sujets et /rapport/:id partagent le
SimulationFlowProvider. L'instance est préservée entre ces routes
par React Router tant que le layout parent reste monté, ce qui
permet à RapportPage.reset() d'agir sur le même state que
SimulationPage (bouton « Nouvelle simulation » + breadcrumb). */}
<Route path="/simulation" element={<Navigate to="/simulation/ee" replace />} />
<Route element={<SimulationFlowLayout />}>
<Route path="/simulation/ee" element={<SimulationPage />} />
<Route path="/sujets" element={<SujetsPage />} />
{/* Sprint 4c-1 — flow EO */}
<Route path="/simulation/eo" element={<SimulationEOPage />} />
<Route path="/simulation/eo/sujets" element={<SujetsEOPage />} />
<Route path="/simulation/eo/pre-enregistrement" element={<PreEnregistrementEOPage />} />
<Route path="/simulation/eo/enregistrement" element={<EnregistrementEOPage />} />
{/* Sprint 4c-2 — flux T1 (questionnaire + génération + présentation) */}
<Route path="/simulation/eo/t1/mode" element={<ModeChoixT1Page />} />
<Route path="/simulation/eo/t1/questionnaire" element={<QuestionnaireT1Page />} />
<Route path="/simulation/eo/t1/presentation" element={<PresentationGenereeT1Page />} />
<Route path="/rapport/:id" element={<RapportPage />} />
</Route>
{/* Sprint 6c — T2 Live (Premium) : sélection sujet → prépa 2min → dialogue 3:30 */}
<Route element={<T2LiveLayout />}>
<Route path="/simulation/eo/t2" element={<T2SujetsPage />} />
<Route path="/simulation/eo/t2/preparation" element={<T2PreparationPage />} />
<Route path="/simulation/eo/t2/dialogue" element={<T2DialoguePage />} />
</Route>
{/* Sprint 7b T1 Live (Premium) : prépa 2min dialogue 3:00 avec
interruption non déterministe de l'examinateur. Entrée directe depuis
la carte TaskSelector (plus de questionnaire Patch 7a backend). */}
<Route path="/simulation/eo/t1/live/preparation" element={<T1PreparationPage />} />
<Route path="/simulation/eo/t1/live/dialogue" element={<T1DialoguePage />} />
{/* Autres sections — Sprint 4+ */}
<Route path="/examen" element={<ComingSoon />} />
<Route path="/progression" element={<ProgressionPage />} />
<Route path="/methodologie" element={<ComingSoon />} />
<Route path="/historique" element={<HistoriquePage />} />
<Route path="/plan" element={<PricingPage />} />
<Route path="/parametres" element={<ParametresPage />} />
</Route>
{/* ── Dev only ─────────────────────────────────────────────── */}
{import.meta.env.DEV && (
<Route
path="/design-system"
element={
<Suspense fallback={<div className="p-6 text-ink-4">Loading</div>}>
<Suspense fallback={<div className="p-6 text-ink-secondary">Loading</div>}>
<DesignSystemPage />
</Suspense>
}
@ -22,12 +136,3 @@ export function AppRouter() {
</Routes>
)
}
function ScaffoldPlaceholder() {
return (
<main className="p-6">
<h1 className="text-2xl font-semibold">Expria scaffold Sprint 0</h1>
<p>Aucune feature n'est encore branchée. Les routes seront ajoutées au fil des sprints.</p>
</main>
)
}

View file

@ -0,0 +1,23 @@
/**
* Appels API du domaine `patterns`.
*
* Endpoint unique : `GET /users/patterns`.
* - Plan non-Premium 403 PLAN_INSUFFICIENT (géré côté hook via `enabled`).
* - < 5 productions corrigées 200 { ready: false, minimum, current }.
* - 5 productions 200 { ready: true, patterns, exercises, preparation_index, ... }.
*
* Timeout par défaut : 10 s (largement suffisant le backend retourne sur cache
* sauf recompute + DeepSeek qui peut prendre jusqu'à 20 s côté serveur, mais
* reste sous le timeout HTTP côté proxy). Retry activé par défaut sur GET.
*/
import { apiFetch } from '@/shared/lib/api-client'
import type { PatternsResponse } from './types'
const PATTERNS_TIMEOUT_MS = 25_000
export function getPatterns(): Promise<PatternsResponse> {
return apiFetch<PatternsResponse>('/users/patterns', {
timeoutMs: PATTERNS_TIMEOUT_MS,
})
}

View file

@ -0,0 +1,52 @@
/**
* Types publics du domaine `patterns` Sprint 3.6c.
*
* Miroir de la réponse backend `GET /users/patterns` (cf. expria-backend
* Sprint 3.6c, commit c48ae8d). Feature Premium uniquement le gating se
* fait via `hasAccess(plan, 'pattern_analysis')` côté frontend ; la route
* backend renvoie 403 `PLAN_INSUFFICIENT` si plan non Premium (fallback
* défensif).
*/
import type { CritereCode } from '@/entities/report/types'
export interface Pattern {
code: string
critere: CritereCode
frequency: number // 3, 4 ou 5 (seuil d'agrégation : ≥ 3)
description: string | null // non-null uniquement pour code === 'autre'
}
export interface PatternExercice {
code: string
critere: CritereCode
diagnostic: string
exercice: {
consigne: string
exemple: string // phrase incorrecte générique (pas du candidat)
correction: string // version correcte
astuce: string // procédé mnémotechnique / réflexe de relecture
}
}
export interface PreparationIndex {
score: number // 0-100 entier
message: string // interprétation textuelle fixée par le backend
}
export interface PatternsReady {
ready: true
patterns: Pattern[]
exercises: PatternExercice[]
preparation_index: PreparationIndex
analyzed_productions: number
last_analysis: string // ISO timestamp
}
export interface PatternsNotReady {
ready: false
minimum: number // toujours 5 côté backend actuel
current: number // nb de productions corrigées déjà réalisées
}
export type PatternsResponse = PatternsReady | PatternsNotReady

View file

@ -0,0 +1,59 @@
/**
* Tests du domaine `presentation` Sprint 4c-2.
*
* Valide :
* - succès : retourne { presentation }
* - erreur : ApiError propagée par apiFetch
*/
import { describe, expect, it, vi, beforeEach } from 'vitest'
vi.mock('@/shared/lib/api-client', () => ({
apiFetch: vi.fn(),
}))
import { apiFetch } from '@/shared/lib/api-client'
import { generatePresentation } from '../api'
import type { PresentationReponses } from '../types'
const mocked = vi.mocked(apiFetch)
const VALID_REPONSES: PresentationReponses = {
prenom_age_ville: 'Marie, 32 ans, Douala',
formation_metier: 'Master en gestion, comptable',
situation_familiale: 'Mariée, deux enfants',
loisirs: 'Lecture, cuisine',
motivation_canada: 'Opportunités, départ 2025',
}
describe('generatePresentation', () => {
beforeEach(() => {
mocked.mockReset()
})
it('retourne la présentation générée et appelle le bon endpoint', async () => {
mocked.mockResolvedValueOnce({ presentation: 'Bonjour, je m appelle Marie...' })
const result = await generatePresentation(VALID_REPONSES)
expect(result.presentation).toContain('Marie')
expect(mocked).toHaveBeenCalledWith('/presentations/generate', {
method: 'POST',
body: { reponses: VALID_REPONSES },
timeoutMs: 25_000,
retry: { max: 0, baseDelayMs: 0 },
})
})
it('propage les ApiError du backend', async () => {
mocked.mockRejectedValueOnce({
error: true,
code: 'INTERNAL_ERROR',
message: 'DeepSeek down',
})
await expect(generatePresentation(VALID_REPONSES)).rejects.toMatchObject({
code: 'INTERNAL_ERROR',
})
})
})

View file

@ -0,0 +1,23 @@
/**
* Appels API du domaine `presentation` Sprint 4c-2.
*
* `POST /presentations/generate` : timeout 25 s (DeepSeek peut mettre 10-20 s),
* retry désactivé volontairement un POST non-idempotent qui consomme un
* appel DeepSeek ne doit pas être rejoué silencieusement sur erreur réseau.
*/
import { apiFetch } from '@/shared/lib/api-client'
import type { PresentationGenerated, PresentationReponses } from './types'
const GENERATE_TIMEOUT_MS = 25_000
export function generatePresentation(
reponses: PresentationReponses,
): Promise<PresentationGenerated> {
return apiFetch<PresentationGenerated>('/presentations/generate', {
method: 'POST',
body: { reponses },
timeoutMs: GENERATE_TIMEOUT_MS,
retry: { max: 0, baseDelayMs: 0 },
})
}

View file

@ -0,0 +1,23 @@
/**
* Types publics du domaine `presentation` Sprint 4c-2.
*
* Le domaine couvre la génération assistée d'un texte de présentation
* personnelle (Tâche 1 EO). Aucune persistance backend : le texte généré
* est mirroré côté client (localStorage `expria_eo_t1_presentation`) et
* porté dans le state du `SimulationFlowProvider` pour servir de
* référence pendant l'enregistrement.
*/
/** Réponses au questionnaire — alignées sur le body du backend. */
export interface PresentationReponses {
prenom_age_ville: string
formation_metier: string
situation_familiale: string
loisirs: string
motivation_canada: string
}
/** Réponse de `POST /presentations/generate`. */
export interface PresentationGenerated {
presentation: string
}

View file

@ -0,0 +1,114 @@
/**
* Appels API du domaine `production`.
*
* Toutes les requêtes passent par `apiFetch` (Règle J / Règle F).
* - `POST /simulations` : timeout 5s (défaut), retry désactivé (POST non-idempotent).
* - `GET /simulations/:id` : timeout 5s, retry activé (GET idempotent).
*
* Erreurs notables : `QUOTA_REACHED` (Free 5/5), `PLAN_INSUFFICIENT` (exam_mode).
*/
import { apiFetch } from '@/shared/lib/api-client'
import type {
CreateSimulationPayload,
Production,
SimulationState,
SimulationsListResponse,
SujetData,
Tache,
} from './types'
/** Crée une nouvelle simulation. Endpoint : `POST /simulations` (HTTP 201). */
export function createSimulation(payload: CreateSimulationPayload): Promise<Production> {
return apiFetch<Production>('/simulations', { method: 'POST', body: payload })
}
/** Récupère une simulation existante. Endpoint : `GET /simulations/:id`. */
export function getSimulation(id: string): Promise<Production> {
return apiFetch<Production>(`/simulations/${id}`)
}
/**
* Sprint 3.7 liste paginée des simulations de l'utilisateur connecté.
* Endpoint : `GET /simulations?page=X&limit=Y`. Tri `created_at DESC` côté backend.
* Champs lourds exclus (contenu, rapport, exercices, modele) cf. SimulationListItem.
*/
export function listSimulations(page: number, limit: number): Promise<SimulationsListResponse> {
const qs = new URLSearchParams({ page: String(page), limit: String(limit) })
return apiFetch<SimulationsListResponse>(`/simulations?${qs.toString()}`)
}
/**
* FTD-21 récupère l'état complet d'une simulation (contenu + sujet + rapport).
* Utilisé par `SimulationFlowProvider` pour restaurer une session depuis
* `localStorage.expria_simulation_id` au mount.
*
* Si `rapport === null` simulation en cours, restaurer `/simulation/ee`.
* Sinon simulation terminée, rediriger vers `/rapport/:id`.
*/
export function getSimulationState(id: string): Promise<SimulationState> {
return apiFetch<SimulationState>(`/simulations/${id}`)
}
/**
* FTD-21 autosave du contenu (debounce 30 s + beforeunload).
* Endpoint : `PATCH /simulations/:id/contenu`.
* Ne retourne rien : le client conserve déjà le texte localement.
*/
export async function autosaveContenu(id: string, contenu: string): Promise<void> {
await apiFetch<{ ok: true }>(`/simulations/${id}/contenu`, {
method: 'PATCH',
body: { contenu },
})
}
/**
* FTD-21 persiste un changement de sujet côté backend.
* Endpoint : `PATCH /simulations/:id/sujet`.
* Appelé depuis `SimulationFlowProvider.changeSubject` quand l'utilisateur
* choisit un autre sujet via `/sujets`.
*/
export async function updateSujet(id: string, sujetId: string): Promise<void> {
await apiFetch<{ sujet: SujetData }>(`/simulations/${id}/sujet`, {
method: 'PATCH',
body: { sujet_id: sujetId },
})
}
/**
* Mappe une Tache vers les paramètres de la route `GET /sujets`.
* Retourne `null` pour les tâches sans catalogue de sujets côté base
* (EO_T1 : sujet fixe connu).
*
* Sprint 6c : `EO_T2_LIVE` mappe vers (mode='EO', tache=2) pour récupérer
* la grille de sujets T2 (rôle + contexte alimentés en BDD).
*/
function mapTacheToSujetParams(tache: Tache): { mode: 'EE' | 'EO'; tacheNumber: 1 | 2 | 3 } | null {
switch (tache) {
case 'EE_T1':
return { mode: 'EE', tacheNumber: 1 }
case 'EE_T2':
return { mode: 'EE', tacheNumber: 2 }
case 'EE_T3':
return { mode: 'EE', tacheNumber: 3 }
case 'EO_T2_LIVE':
return { mode: 'EO', tacheNumber: 2 }
case 'EO_T3':
return { mode: 'EO', tacheNumber: 3 }
case 'EO_T1':
return null
}
}
/**
* Récupère la liste des sujets actifs disponibles pour une tâche.
* Endpoint : `GET /sujets?mode=XX&tache=N`.
*
* Retourne `[]` immédiatement pour les tâches sans catalogue (EO_T1).
*/
export function getSujets(tache: Tache): Promise<SujetData[]> {
const params = mapTacheToSujetParams(tache)
if (!params) return Promise.resolve([])
const qs = new URLSearchParams({ mode: params.mode, tache: String(params.tacheNumber) })
return apiFetch<{ sujets: SujetData[] }>(`/sujets?${qs.toString()}`).then((r) => r.sujets)
}

View file

@ -0,0 +1,30 @@
/**
* Helpers purs du domaine `production`.
* Aucune logique de plan en dur les règles d'accès passent par entities/user/lib.
*/
import type { Tache } from './types'
const TACHE_LABELS: Record<Tache, string> = {
EE_T1: 'Expression Écrite — Tâche 1',
EE_T2: 'Expression Écrite — Tâche 2',
EE_T3: 'Expression Écrite — Tâche 3',
EO_T1: 'Expression Orale — Tâche 1',
EO_T2_LIVE: 'Expression Orale — Tâche 2 Live',
EO_T3: 'Expression Orale — Tâche 3',
}
/** Libellé long d'une tâche, à afficher dans l'UI. */
export function formatTache(tache: Tache): string {
return TACHE_LABELS[tache]
}
/** Vrai si la tâche est une tâche d'Expression Écrite. */
export function isEcrit(tache: Tache): boolean {
return tache.startsWith('EE_')
}
/** Vrai si la tâche est une tâche d'Expression Orale (T1 ou T3 — hors T2 Live). */
export function isOral(tache: Tache): boolean {
return tache.startsWith('EO_')
}

View file

@ -0,0 +1,166 @@
/**
* Types publics du domaine `production`.
*
* Une `Production` correspond à une simulation créée via `POST /simulations`.
* Le backend renvoie directement l'objet métier (pas d'enveloppe cf. ARCHITECTURE.md §5).
*
* EO_T2 (T2 Live) est exclu de ce domaine : il passe par WebSocket et est géré
* dans `features/t2-live` (Sprint 6).
*
* SEC-05 : les payloads de correction contiennent du texte utilisateur brut.
* Ne jamais les injecter comme HTML passer par react-markdown dans les composants.
*/
/**
* Identifiants des tâches disponibles en mode simulation.
* `EO_T2_LIVE` désigne la T2 EO en dialogue live (Sprint 6).
*/
export type Tache = 'EE_T1' | 'EE_T2' | 'EE_T3' | 'EO_T1' | 'EO_T2_LIVE' | 'EO_T3'
/** Mode de la simulation — examen uniquement accessible au plan Premium. */
export type Mode = 'entrainement' | 'examen'
/**
* Sujet d'examen (consigne + documents) associé à une production.
*
* Retourné par `POST /simulations` depuis la table `sujets` (filtrage actif + tâche).
* `null` lorsque la table ne contient aucun sujet actif pour la tâche, ou pour `EO_T2_LIVE`
* (interaction live sans sujet pré-défini). Voir TECH_DEBT FTD-21 pour la persistance
* via `GET /simulations/:id` (pas encore branchée).
*/
export interface SujetData {
id: string
consigne: string
role: string | null
contexte: string | null
doc1_titre: string | null
doc1_texte: string | null
doc2_titre: string | null
doc2_texte: string | null
}
/**
* Réponse du backend pour `POST /simulations` (HTTP 201).
* Format confirmé par l'audit backend 2026-04-17 (cf. ARCHITECTURE.md §5).
*
* FTD-21 : `contenu` et `sujet_id` sont persistés côté backend pour permettre
* la restauration de session. Ils ne sont pas retournés par `POST /simulations`
* mais peuvent être hydratés via `getSimulationState(id)` pour le resume.
*/
export interface Production {
id: string
tache: Tache
mode: Mode
created_at: string
sujet: SujetData | null
contenu?: string
sujet_id?: string
}
/** Corps de la requête `POST /simulations`. */
export interface CreateSimulationPayload {
tache: Tache
mode: Mode
}
/**
* Réponse du backend pour `GET /simulations/:id` (FTD-21).
*
* Contrairement au `Report` pur, cette réponse porte :
* - le contenu textuel en cours (pour restaurer la textarea)
* - le sujet joint (pour restaurer `SujetDisplay`)
* - le rapport si disponible (sinon `null` simulation en cours)
*
* Si `rapport === null`, le frontend restaure la session `/simulation/ee`.
* Sinon, il redirige vers `/rapport/:id`.
*/
export interface SimulationState {
simulation_id: string
tache: Tache
mode: Mode
created_at: string
contenu: string | null
sujet: SujetData | null
rapport: SimulationRapport | null
// Sprint 3.6a — nouveaux champs backend (null/pending si rapport non encore corrigé)
nclc_cible: 9 | 10 | null
exercices: SimulationExercice[] | null
exercices_status: SimulationJobStatus
modele: SimulationProductionModele | null
modele_status: SimulationJobStatus
}
export type SimulationJobStatus = 'pending' | 'ready' | 'error'
/**
* Sprint 3.7 Item léger pour la liste /historique.
* Miroir de `ListItem` côté backend (GET /simulations pagination).
* Les champs lourds (contenu, rapport, exercices, modele) sont **exclus**.
*/
export interface SimulationListItem {
id: string
tache: Tache
mode: Mode
score: number | null
nclc: number | null
nclc_cible: 9 | 10 | null
created_at: string
}
export interface SimulationsListResponse {
data: SimulationListItem[]
pagination: {
page: number
limit: number
total: number
}
}
/**
* Rapport tel que stocké par le backend (sans `simulation_id`, `exercices`, `modele`
* qui sont portés par SimulationState). Miroir de `CorrectionRapport` côté backend
* (Sprint 3.6a). -exposé ici pour éviter l'import circulaire avec
* `entities/report/types.ts`.
*/
export interface SimulationRapport {
score: number
nclc: number
nclc_cible: 9 | 10
revelation: { croyance: string; realite: string; consequence: string }
diagnostic: string
criteres: {
nom: string
score: number
commentaire: string
exemple: string
suggestion: string
astuce: string
}[]
conseil_nclc: { nclc_cible: string; ecart: string; action_prioritaire: string }
erreurs_codes: { code: string; critere: string; description: string | null }[]
}
export interface SimulationExercice {
difficulte: 'facile' | 'intermediaire' | 'difficile'
theme: string
diagnostic: string
consigne: string
extrait: string
indice: string
correction: string
explication: string
}
export interface SimulationProductionModele {
production_modele_propre: string
notes_pedagogiques: { passage: string; explication: string }[]
transformations: { original: string; ameliore: string; explication: string }[]
message: string
nclc_modele?: number
nclc_obtenu?: number
score_cible?: number
tcf_word_count?: number
tcf_word_min?: number
tcf_word_max?: number
tcf_truncated?: boolean
}

View file

@ -0,0 +1,107 @@
/**
* Tests matrice de floutage Sprint 3.6b.
*
* Nouvelle matrice :
* - Floutables : criteres (detailed_report), exercices + modele (tips)
* - Toujours visibles : score, nclc, revelation, diagnostic, conseil_nclc
*
* Source de vérité : PLANS_TARIFAIRES.md §2.
*/
import { describe, it, expect } from 'vitest'
import { isSectionVisible, groupErreursByCritere, ecartVsCible, critereCodeFromNom } from '../lib'
import type { ErreurCode } from '../types'
describe('isSectionVisible — plan free', () => {
it('criteres : non visible (detailed_report = false)', () => {
expect(isSectionVisible('free', 'criteres')).toBe(false)
})
it('exercices : non visible (tips = false)', () => {
expect(isSectionVisible('free', 'exercices')).toBe(false)
})
it('modele : non visible (tips = false)', () => {
expect(isSectionVisible('free', 'modele')).toBe(false)
})
})
describe('isSectionVisible — plan standard', () => {
it('criteres : visible (detailed_report = true)', () => {
expect(isSectionVisible('standard', 'criteres')).toBe(true)
})
it('exercices : visible (tips = true)', () => {
expect(isSectionVisible('standard', 'exercices')).toBe(true)
})
it('modele : visible (tips = true)', () => {
expect(isSectionVisible('standard', 'modele')).toBe(true)
})
})
describe('isSectionVisible — plan premium', () => {
it('criteres : visible', () => {
expect(isSectionVisible('premium', 'criteres')).toBe(true)
})
it('exercices : visible', () => {
expect(isSectionVisible('premium', 'exercices')).toBe(true)
})
it('modele : visible', () => {
expect(isSectionVisible('premium', 'modele')).toBe(true)
})
})
describe('groupErreursByCritere', () => {
it('regroupe les erreurs par code critère', () => {
const erreurs: ErreurCode[] = [
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
{ code: 'connecteurs_repetes', critere: 'coherence_cohesion', description: null },
{ code: 'virgule_exces', critere: 'competence_grammaticale', description: null },
]
const grouped = groupErreursByCritere(erreurs)
expect(grouped.competence_grammaticale).toHaveLength(2)
expect(grouped.coherence_cohesion).toHaveLength(1)
expect(grouped.competence_lexicale).toHaveLength(0)
expect(grouped.adequation_tache).toHaveLength(0)
})
it('retourne les 4 critères même sur liste vide', () => {
const grouped = groupErreursByCritere([])
expect(Object.keys(grouped)).toHaveLength(4)
expect(grouped.competence_grammaticale).toEqual([])
})
})
describe('ecartVsCible', () => {
it('NCLC 9 atteint (score = 14)', () => {
expect(ecartVsCible(14, 9)).toEqual({ points: 0, atteint: true })
})
it('NCLC 9 non atteint (score = 12)', () => {
expect(ecartVsCible(12, 9)).toEqual({ points: 2, atteint: false })
})
it('NCLC 10 atteint (score = 18)', () => {
expect(ecartVsCible(18, 10)).toEqual({ points: 0, atteint: true })
})
it('score supérieur à cible : atteint + points=0', () => {
expect(ecartVsCible(20, 9)).toEqual({ points: 0, atteint: true })
})
})
describe('critereCodeFromNom', () => {
it('mappe les 4 libellés officiels', () => {
expect(critereCodeFromNom('Adéquation à la tâche et au registre')).toBe('adequation_tache')
expect(critereCodeFromNom('Cohérence et cohésion du discours')).toBe('coherence_cohesion')
expect(critereCodeFromNom('Compétence lexicale')).toBe('competence_lexicale')
expect(critereCodeFromNom('Compétence grammaticale')).toBe('competence_grammaticale')
})
it('retourne null sur un libellé inconnu', () => {
expect(critereCodeFromNom('Autre chose')).toBeNull()
})
})

View file

@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest'
import { getMaxScorePerCritere, critereCodeFromNom } from '../lib'
import type { Critere } from '../types'
function critere(nom: string, score: number): Critere {
return { nom, score, commentaire: '', exemple: '', suggestion: '', astuce: '' }
}
describe('getMaxScorePerCritere — Sprint 4.8', () => {
it('5 critères (EO Sprint 4.8) → maxScore = 4', () => {
const rapport = {
criteres: [
critere('Adéquation à la tâche', 3),
critere('Cohérence et cohésion', 3),
critere('Étendue et maîtrise du lexique', 2),
critere('Maîtrise morphosyntaxique', 3),
critere('Phonologie', 3),
],
}
expect(getMaxScorePerCritere(rapport)).toBe(4)
})
it('4 critères (EE / EO legacy) → maxScore = 5', () => {
const rapport = {
criteres: [
critere('Adéquation à la tâche et au registre', 4),
critere('Cohérence et cohésion du discours', 3),
critere('Compétence lexicale', 3),
critere('Compétence grammaticale', 4),
],
}
expect(getMaxScorePerCritere(rapport)).toBe(5)
})
it('0 critère (cas limite) → maxScore = 5 (défaut sécurité)', () => {
expect(getMaxScorePerCritere({ criteres: [] })).toBe(5)
})
it('6+ critères (cas hypothétique) → maxScore = 5 (défaut sécurité)', () => {
const rapport = {
criteres: Array.from({ length: 6 }, (_, i) => critere(`c${i}`, 0)),
}
expect(getMaxScorePerCritere(rapport)).toBe(5)
})
})
describe('critereCodeFromNom — extension Sprint 4.8 EO', () => {
it('mappe les libellés EO Sprint 4.8 vers les codes taxonomie', () => {
expect(critereCodeFromNom('Adéquation à la tâche')).toBe('adequation_tache')
expect(critereCodeFromNom('Cohérence et cohésion')).toBe('coherence_cohesion')
expect(critereCodeFromNom('Étendue et maîtrise du lexique')).toBe('competence_lexicale')
expect(critereCodeFromNom('Maîtrise morphosyntaxique')).toBe('competence_grammaticale')
})
it("Phonologie n'a pas de code taxonomie → null", () => {
expect(critereCodeFromNom('Phonologie')).toBeNull()
})
it('libellés EE legacy toujours mappés (rétrocompat)', () => {
expect(critereCodeFromNom('Adéquation à la tâche et au registre')).toBe('adequation_tache')
expect(critereCodeFromNom('Compétence lexicale')).toBe('competence_lexicale')
})
})

View file

@ -0,0 +1,97 @@
/**
* Appels API du domaine `report`.
*
* Toutes les requêtes passent par `apiFetch` (Règle J / Règle F).
* Timeout 30 s : DeepSeek (EE) et Gemini (EO) peuvent mettre jusqu'à 20-25 s.
* Retry désactivé : POST non-idempotent une double soumission créerait
* deux corrections facturées sur le quota Free.
*
* Erreurs notables : SIMULATION_NOT_FOUND (404), AUTH_REQUIRED (401),
* QUOTA_REACHED (403 côté simulation, pas correction).
*/
import { apiFetch } from '@/shared/lib/api-client'
import { getSimulationState } from '@/entities/production/api'
import type { CorrectEePayload, CorrectEoPayload, Report } from './types'
/**
* Récupère un rapport existant. Endpoint : `GET /simulations/:id`.
*
* FTD-21 : depuis l'assouplissement de `getById` côté backend (tolère `rapport=null`
* pour permettre le resume), on unwrap le champ `rapport` du `SimulationState`.
* Si la simulation est encore en cours (`rapport === null`), on lève une erreur
* typée `REPORT_NOT_READY` que RapportPage catche pour rediriger vers /simulation/ee.
*/
export function getReport(id: string): Promise<Report> {
return getSimulationState(id).then((state) => {
if (state.rapport === null) {
throw {
error: true,
code: 'REPORT_NOT_READY',
message: 'Simulation en cours — rédaction pas encore corrigée.',
}
}
// Sprint 3.6b : reconstruit un Report en combinant rapport (correction) +
// exercices / modele (jobs fire-and-forget, portés par SimulationState).
return {
...state.rapport,
simulation_id: state.simulation_id,
tache: state.tache,
erreurs_codes: state.rapport.erreurs_codes as Report['erreurs_codes'],
exercices: state.exercices as Report['exercices'],
exercices_status: state.exercices_status,
modele: state.modele as Report['modele'],
modele_status: state.modele_status,
}
})
}
// Sprint 3.6a — le nouveau prompt maître (taxonomie + revelation + diagnostic +
// criteres×6 champs + conseil_nclc + erreurs_codes) produit un JSON long ;
// DeepSeek met typiquement 25-45 s pour répondre. Backend abort à 55 s.
const CORRECTION_EE_TIMEOUT_MS = 60_000
// Sprint 4b.3 — EO en mode audio enchaîne Gemini transcribe (jusqu'à 60 s,
// 30 s + 1 retry de 30 s) puis DeepSeek correction (55 s côté backend).
// Pire cas serveur ≈ 115 s : on alloue 120 s côté client pour ne pas couper
// avant que la mutation aboutisse (le rapport apparaissait sinon dans
// l'historique sans navigation vers /rapport/:id).
const CORRECTION_EO_TIMEOUT_MS = 120_000
/** Soumet une production écrite pour correction. Endpoint : `POST /corrections/ee`.
* Payload : { simulationId, contenu, tache }
*/
export function correctEe(payload: CorrectEePayload): Promise<Report> {
return apiFetch<Report>('/corrections/ee', {
method: 'POST',
body: payload,
timeoutMs: CORRECTION_EE_TIMEOUT_MS,
})
}
/**
* Soumet une production orale pour correction. Endpoint : `POST /corrections/eo`.
* Payload : { simulationId, transcript, tache } transcript implémenté Sprint 4.
*/
export function correctEo(payload: CorrectEoPayload): Promise<Report> {
return apiFetch<Report>('/corrections/eo', {
method: 'POST',
body: payload,
timeoutMs: CORRECTION_EO_TIMEOUT_MS,
})
}
const IDEES_TIMEOUT_MS = 15_000
/**
* Récupère 5 suggestions d'idées DeepSeek pour prolonger la rédaction en cours.
* Endpoint : `POST /sujets/idees`. Tâche G5.
* Contraintes backend : sujet_consigne non vide + contenu_partiel 30 mots.
*/
export function getIdees(consigne: string, contenu: string): Promise<string[]> {
return apiFetch<{ idees: string[] }>('/sujets/idees', {
method: 'POST',
body: { sujet_consigne: consigne, contenu_partiel: contenu },
timeoutMs: IDEES_TIMEOUT_MS,
}).then((res) => res.idees)
}

128
src/entities/report/lib.ts Normal file
View file

@ -0,0 +1,128 @@
/**
* Logique de floutage du domaine `report`.
*
* Import cross-entity volontaire et documenté :
* entities/report entities/user/lib (hasAccess, Plan)
* Justification : la logique de floutage dépend intrinsèquement des permissions
* utilisateur. Exception validée cf. ARCHITECTURE.md §3.
*
* Règle D : aucun `if (plan === 'xxx')` tout passe par hasAccess().
* Règle H : cette logique vit ici, jamais dans les composants features/.
*
* Sprint 3.6b : nouvelle matrice `revelation`, `diagnostic`, `conseil_nclc`
* sont visibles tous plans (pas listés ici). Seuls `criteres`, `exercices`,
* `modele` sont floutés selon le plan (cf. types.ts BlurableSection).
*/
import { hasAccess } from '@/entities/user/lib'
import type { Plan } from '@/entities/user/lib'
import type { BlurableSection, Critere, ErreurCode, CritereCode, Report } from './types'
const SECTION_FEATURE: Record<BlurableSection, 'detailed_report' | 'tips'> = {
criteres: 'detailed_report',
exercices: 'tips',
modele: 'tips',
}
/**
* Indique si une section du rapport est visible pour un plan donné.
*
* @example
* isSectionVisible('free', 'criteres') // false
* isSectionVisible('standard', 'modele') // true
*/
export function isSectionVisible(plan: Plan, section: BlurableSection): boolean {
return hasAccess(plan, SECTION_FEATURE[section])
}
/**
* Regroupe les codes d'erreurs par critère pour affichage dans les cartes critère.
*
* Le backend retourne `erreurs_codes` en top-level ; l'UI les affiche
* contextualisés dans chaque carte critère correspondante.
*/
export function groupErreursByCritere(
erreursCodes: ErreurCode[],
): Record<CritereCode, ErreurCode[]> {
const acc: Record<CritereCode, ErreurCode[]> = {
adequation_tache: [],
coherence_cohesion: [],
competence_lexicale: [],
competence_grammaticale: [],
}
for (const err of erreursCodes) {
acc[err.critere].push(err)
}
return acc
}
/**
* Mappe le nom d'un critère (libellé humain backend) vers son code taxonomie,
* pour rattacher `erreurs_codes` à la bonne carte critère côté UI.
*/
const CRITERE_NOM_TO_CODE: Record<string, CritereCode> = {
// Libellés EE (CRITERE_LABELS backend)
'Adéquation à la tâche et au registre': 'adequation_tache',
'Cohérence et cohésion du discours': 'coherence_cohesion',
'Compétence lexicale': 'competence_lexicale',
'Compétence grammaticale': 'competence_grammaticale',
// Libellés EO Sprint 4.8 (CRITERE_LABELS_EO backend) — mappés vers les
// mêmes codes taxonomie pour rattacher les `erreurs_codes`. La 5e dimension
// « Phonologie » n'a pas de CritereCode (aucune erreur taxonomie associée
// côté backend) : `critereCodeFromNom('Phonologie')` retourne donc `null`.
'Adéquation à la tâche': 'adequation_tache',
'Cohérence et cohésion': 'coherence_cohesion',
'Étendue et maîtrise du lexique': 'competence_lexicale',
'Maîtrise morphosyntaxique': 'competence_grammaticale',
}
export function critereCodeFromNom(nom: string): CritereCode | null {
return CRITERE_NOM_TO_CODE[nom] ?? null
}
/**
* Sprint 4.8 Détecte le score maximum par critère selon le format du rapport.
*
* - Rapports EO Sprint 4.8 : 5 critères × /4 (4 textuels DeepSeek + Phonologie Gemini).
* - Rapports EE et EO legacy : 4 critères × /5.
*
* Détection sur la donnée elle-même (pas sur la tâche) pour rester rétrocompatible
* avec les rapports EO en base d'avant Sprint 4.8.
*
* Défaut sécurité : tout autre nombre de critères 5.
*/
export function getMaxScorePerCritere(rapport: Pick<Report, 'criteres'>): 4 | 5 {
return rapport.criteres.length === 5 ? 4 : 5
}
/**
* Calcule l'écart en points /20 entre le score obtenu et l'objectif NCLC cible.
* Barème TCF Canada (cf. Prompt_maître.md §Barème) : NCLC 9 14/20, NCLC 10 16/20.
*/
const NCLC_MIN_SCORE: Record<number, number> = { 7: 10, 8: 12, 9: 14, 10: 16 }
export function ecartVsCible(
score: number,
nclcCible: number,
): {
points: number
atteint: boolean
} {
const minScore = NCLC_MIN_SCORE[nclcCible] ?? NCLC_MIN_SCORE[9]!
const points = Math.max(0, minScore - score)
return { points, atteint: score >= minScore }
}
export type { Critere }
/**
* Libellés officiels des 4 critères TCF Canada miroir de backend
* `src/lib/taxonomieErreurs.ts` CRITERE_LABELS. Utilisé par les listes de
* patterns et tout affichage nécessitant le libellé humain à partir du code.
*/
export const CRITERE_LABELS: Record<CritereCode, string> = {
adequation_tache: 'Adéquation à la tâche et au registre',
coherence_cohesion: 'Cohérence et cohésion du discours',
competence_lexicale: 'Compétence lexicale',
competence_grammaticale: 'Compétence grammaticale',
}

View file

@ -0,0 +1,167 @@
/**
* Types publics du domaine `report` Sprint 3.6b.
*
* Structure alignée sur le backend Sprint 3.6a (prompt maître + production
* modèle + exercices fire-and-forget). Aucune enveloppe : le payload est
* retourné directement (cf. ARCHITECTURE.md §5).
*
* Visibilité par section selon le plan (cf. PLANS_TARIFAIRES.md §2) :
* - score, nclc, nclc_cible, revelation, diagnostic, conseil_nclc tous plans
* - criteres (exemple/suggestion/astuce) detailed_report (Standard+)
* - exercices, modele tips (Standard+)
*
* SEC-05 : textes IA rendus via react-markdown, jamais dangerouslySetInnerHTML.
*/
import type { Tache } from '@/entities/production/types'
/** Codes taxonomie d'erreurs — valeurs exhaustives dans `TAXONOMIE_ERREURS.md` v1.0. */
export type CritereCode =
| 'adequation_tache'
| 'coherence_cohesion'
| 'competence_lexicale'
| 'competence_grammaticale'
export interface ErreurCode {
code: string
critere: CritereCode
description: string | null
}
export interface Critere {
nom: string
score: number // 0-4 (EO Sprint 4.8 — 5 critères) | 0-5 (EE, EO legacy 4 critères)
commentaire: string
exemple: string
suggestion: string
astuce: string
}
export interface Revelation {
croyance: string
realite: string
consequence: string
}
export interface ConseilNclc {
nclc_cible: string // ex. "NCLC 9"
ecart: string
action_prioritaire: string
}
export type Difficulte = 'facile' | 'intermediaire' | 'difficile'
/** Libellés affichés pour chaque niveau de difficulté. */
export const DIFFICULTE_LABEL: Record<Difficulte, string> = {
facile: 'Facile',
intermediaire: 'Moyen',
difficile: 'Difficile',
}
export interface Exercice {
difficulte: Difficulte
theme: string
diagnostic: string
consigne: string
extrait: string
indice: string
correction: string
explication: string
}
export interface NotePedagogique {
passage: string
explication: string
}
export interface Transformation {
original: string
ameliore: string
explication: string
}
export interface ProductionModele {
production_modele_propre: string
notes_pedagogiques: NotePedagogique[]
transformations: Transformation[]
message: string
// Métadonnées backend — non affichées côté UI mais exposées pour complétude.
nclc_modele?: number
nclc_obtenu?: number
score_cible?: number
tcf_word_count?: number
tcf_word_min?: number
tcf_word_max?: number
tcf_truncated?: boolean
}
export type JobStatus = 'pending' | 'ready' | 'error'
export type NclcCible = 9 | 10
/**
* Rapport de correction renvoyé par `GET /simulations/:id` (et ressources dérivées).
*/
export interface Report {
simulation_id: string
/**
* Tâche d'origine (propagée depuis `SimulationState`). Permet de router le
* retour « Nouvelle simulation » vers la bonne sélection (EO vs EE) sans
* plomberie de query param. Optionnel : absent des payloads `POST /corrections/*`.
*/
tache?: Tache
score: number // /20
nclc: number // NCLC atteint — ex. 8
nclc_cible: NclcCible
revelation: Revelation
diagnostic: string
criteres: Critere[]
conseil_nclc: ConseilNclc
erreurs_codes: ErreurCode[] // top-level — regroupés par critère côté UI
exercices: Exercice[] | null
exercices_status: JobStatus
modele: ProductionModele | null
modele_status: JobStatus
}
/** Corps de `POST /corrections/ee`. */
export interface CorrectEePayload {
simulationId: string
contenu: string
tache: string
nclc_cible?: NclcCible // défaut backend : 9
}
/**
* Corps de `POST /corrections/eo`.
* transcript : transcription audio envoyée au backend (implémenté Sprint 4).
*/
/**
* Corps de `POST /corrections/eo`.
*
* Modes (XOR exactement un des deux) :
* - `transcript` (Sprint 4) : transcription texte fournie directement par le client.
* - `audioBase64` + `mimeType` (Sprint 4b.2) : audio brut, le backend transcrit
* via Gemini batch puis poursuit le pipeline correction.
*/
export interface CorrectEoPayload {
simulationId: string
tache: string
/** Sprint 4a backend — cible NCLC (9 par défaut, 10 pour viser plus haut). */
nclc_cible?: 9 | 10
transcript?: string
audioBase64?: string
/** MIME du payload audio (audio/webm | audio/mp4 | audio/wav). */
mimeType?: string
}
/**
* Sections du rapport dont la visibilité dépend du plan (Sprint 3.6b).
*
* - `criteres` gate `detailed_report` : floute les champs exemple/suggestion/astuce
* + codes d'erreurs pour les utilisateurs Free.
* - `exercices` / `modele` gate `tips`.
*
* `revelation`, `diagnostic`, `conseil_nclc`, `score`, `nclc` ne sont **pas**
* des `BlurableSection` : elles sont visibles pour tous les plans (cf. PLANS_TARIFAIRES.md §2).
*/
export type BlurableSection = 'criteres' | 'exercices' | 'modele'

View file

@ -0,0 +1,49 @@
/**
* Tests du domaine `transcription` Sprint 4c-1.
*
* Valide :
* - succès : retourne le token et expires_in
* - erreur : ApiError propagée par apiFetch
*/
import { describe, expect, it, vi, beforeEach } from 'vitest'
vi.mock('@/shared/lib/api-client', () => ({
apiFetch: vi.fn(),
}))
import { apiFetch } from '@/shared/lib/api-client'
import { requestDeepgramToken } from '../api'
const mocked = vi.mocked(apiFetch)
describe('requestDeepgramToken', () => {
beforeEach(() => {
mocked.mockReset()
})
it('retourne le token et expires_in en cas de succès', async () => {
mocked.mockResolvedValueOnce({ token: 'dg-temp-abc', expires_in: 600 })
const result = await requestDeepgramToken()
expect(result).toEqual({ token: 'dg-temp-abc', expires_in: 600 })
expect(mocked).toHaveBeenCalledWith('/transcriptions/token', {
method: 'POST',
timeoutMs: 10_000,
retry: { max: 0, baseDelayMs: 0 },
})
})
it('propage les ApiError du backend', async () => {
mocked.mockRejectedValueOnce({
error: true,
code: 'AUTH_REQUIRED',
message: 'Auth required',
})
await expect(requestDeepgramToken()).rejects.toMatchObject({
code: 'AUTH_REQUIRED',
})
})
})

View file

@ -0,0 +1,21 @@
/**
* Appels API du domaine `transcription`.
*
* `POST /transcriptions/token` : timeout 10 s, retry désactivé.
* Le retry est désactivé volontairement : un POST non-idempotent qui
* consomme un crédit Deepgram à chaque appel ne doit pas être rejoué
* silencieusement en cas d'erreur réseau transitoire.
*/
import { apiFetch } from '@/shared/lib/api-client'
import type { TranscriptionToken } from './types'
const TOKEN_TIMEOUT_MS = 10_000
export function requestDeepgramToken(): Promise<TranscriptionToken> {
return apiFetch<TranscriptionToken>('/transcriptions/token', {
method: 'POST',
timeoutMs: TOKEN_TIMEOUT_MS,
retry: { max: 0, baseDelayMs: 0 },
})
}

View file

@ -0,0 +1,15 @@
/**
* Types publics du domaine `transcription`.
*
* Le frontend obtient un token Deepgram éphémère via le backend
* (`POST /transcriptions/token`) puis ouvre une connexion WebSocket
* directe vers Deepgram pour la transcription live. La clé maître
* Deepgram reste côté backend (cf. SECURITY.md).
*/
export interface TranscriptionToken {
/** JWT éphémère Deepgram (durée de vie ~10 min). */
token: string
/** Durée de validité du token, en secondes. */
expires_in: number
}

22
src/entities/user/api.ts Normal file
View file

@ -0,0 +1,22 @@
/**
* Appels API du domaine `user`.
*
* Toutes les requêtes passent par `apiFetch` (Règle J) qui gère auth, retry,
* timeout et erreurs typées. Les consommateurs consomment ces fonctions via
* TanStack Query (cf. `features/dashboard/hooks/usePlan`).
*/
import { apiFetch } from '@/shared/lib/api-client'
import type { PlanStatus } from './types'
/**
* Récupère le statut courant du plan de l'utilisateur connecté.
*
* Endpoint : `GET /plans/status`
* Auth : JWT Bearer requis (ajouté automatiquement par `apiFetch`).
*
* @throws ApiError notamment `AUTH_REQUIRED` si le JWT est absent/expiré.
*/
export function getPlanStatus(): Promise<PlanStatus> {
return apiFetch<PlanStatus>('/plans/status')
}

View file

@ -0,0 +1,13 @@
/**
* Clés TanStack Query partagées pour le domaine `user`.
*
* Source unique importée par `features/dashboard/hooks/usePlan`,
* `features/simulations/pages/SimulationPage`, `features/simulations/pages/RapportPage`,
* et tout futur consommateur du statut de plan. Une clé locale inline briserait
* silencieusement le cache partagé de TanStack Query à la moindre faute de frappe.
*
* Ce module ne contient que des constantes pures aucun import React, TanStack
* ou autre dépendance runtime.
*/
export const PLAN_QUERY_KEY = ['plan'] as const

View file

@ -0,0 +1,32 @@
/**
* Types publics du domaine `user`.
*
* Porte d'entrée unique pour les consommateurs frontend : toute UI ou hook
* qui manipule un plan ou une permission importe depuis ce fichier (ou `./lib`
* pour les fonctions), jamais directement depuis `./access` (cf. ADR 005).
*/
import type { Feature, Plan } from './access'
/**
* Réponse du backend pour `GET /plans/status`.
*
* Format confirmé par l'audit backend 2026-04-17 (cf. ARCHITECTURE.md §5).
*
* - `permissions` : dictionnaire booléen par feature. Les consommateurs
* doivent passer par `hasAccess(plan, feature)` plutôt que lire ce champ
* directement (Règle D / ADR 005). Il est exposé ici uniquement parce que
* le backend le renvoie et qu'il peut servir à du debug côté DevTools.
* - `simulations_remaining` : `null` si le plan est illimité (standard/premium),
* sinon nombre de simulations restantes sur le quota à vie (Free : 5).
* - `plan_expires_at` : ISO 8601 pour un plan payant actif, `null` pour Free.
*/
export interface PlanStatus {
plan: Plan
permissions: Record<Feature, boolean>
simulations_used: number
simulations_remaining: number | null
plan_expires_at: string | null
}
export type { Feature, Plan }

View file

@ -0,0 +1,67 @@
/**
* Page Paramètres Sprint 5d.
*
* Page minimale conteneur pour les sections de gestion du compte :
* - Abonnement (`AccountBillingSection`) Stripe.
* - Session bouton de déconnexion.
* Future : préférences langue, sécurité (changement mot de passe),
* suppression compte, etc.
*
* Wrapper layout standard 1100px (cohérent avec convention Sprint 4.7).
*/
import { useNavigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { LogOut } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { Card } from '@/shared/ui/Card'
import { signOut } from '@/shared/lib/auth-client'
import { AccountBillingSection } from '@/features/billing/components/AccountBillingSection'
export function ParametresPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
async function handleSignOut() {
await signOut()
queryClient.clear()
navigate('/login', { replace: true })
}
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<header className="mb-8">
<p className="text-[11px] font-semibold uppercase tracking-[0.073em] text-ink-tertiary">
Paramètres
</p>
<h1 className="mt-1 text-[32px] font-bold tracking-[-0.02em] text-ink-primary">
Mon compte
</h1>
<p className="mt-2 text-sm text-ink-secondary">
Gérez votre abonnement, vos préférences et la sécurité de votre compte.
</p>
</header>
<div className="space-y-6">
<AccountBillingSection />
<Card variant="default" className="space-y-4 p-6">
<header>
<h2 className="text-lg font-semibold text-ink-primary">Session</h2>
<p className="mt-1 text-sm text-ink-secondary">
Terminer votre session sur cet appareil.
</p>
</header>
<Button
variant="secondary"
size="md"
icon={<LogOut className="size-4" aria-hidden="true" />}
onClick={handleSignOut}
>
Se déconnecter
</Button>
</Card>
</div>
</div>
)
}

View file

@ -0,0 +1,42 @@
/**
* Wrapper de route qui exige un utilisateur authentifié.
*
* - Pendant le chargement de la session : affiche un spinner centré.
* - Si non authentifié : redirige vers `/login` avec replace (pas d'entrée
* parasite dans l'historique navigateur).
* - Si authentifié : rend les `children`.
*
* Le backend reste l'autorité finale : cette garde est de l'UX. Les routes
* sensibles sont protégées par les middlewares Hono côté API (ADR 002).
*/
import { Navigate } from 'react-router-dom'
import { Loader2 } from 'lucide-react'
import { useAuth } from '../hooks/useAuth'
interface ProtectedRouteProps {
children: React.ReactNode
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isLoading, isAuthenticated } = useAuth()
if (isLoading) {
return (
<div
className="flex min-h-screen items-center justify-center bg-canvas text-ink-secondary"
role="status"
aria-live="polite"
aria-label="Chargement de la session"
>
<Loader2 className="size-6 animate-spin" aria-hidden="true" />
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}

View file

@ -0,0 +1,50 @@
/**
* Hook source de vérité pour l'état d'authentification dans toute l'app.
*
* Au mount : récupère la session courante depuis Supabase (cookie + localStorage).
* S'abonne ensuite à `onAuthStateChange` pour réagir aux login/logout/refresh
* token ; se désabonne au unmount.
*
* Consommé par `ProtectedRoute` (redirect si non authentifié) et par toute page
* qui a besoin du profil Supabase (ex. prénom affiché dans le header).
*/
import { useEffect, useState } from 'react'
import { getCurrentSession, subscribeToAuthChanges, type User } from '@/shared/lib/auth-client'
interface UseAuthResult {
user: User | null
isLoading: boolean
isAuthenticated: boolean
}
export function useAuth(): UseAuthResult {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
let cancelled = false
getCurrentSession().then((session) => {
if (cancelled) return
setUser(session?.user ?? null)
setIsLoading(false)
})
const unsubscribe = subscribeToAuthChanges((session) => {
setUser(session?.user ?? null)
setIsLoading(false)
})
return () => {
cancelled = true
unsubscribe()
}
}, [])
return {
user,
isLoading,
isAuthenticated: user !== null,
}
}

View file

@ -0,0 +1,135 @@
/**
* Page de connexion.
*
* Formulaire email + mot de passe, appel de `signIn` (Supabase via auth-client).
* Si la session devient active (arrivée déjà connecté OU succès de signIn),
* `useAuth` propage l'état et le useEffect redirige vers /dashboard. On évite
* ainsi un double `navigate` concurrent depuis le handler de submit.
*/
import { useEffect, useState, type FormEvent } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Loader2 } from 'lucide-react'
import { Button } from '@/shared/components/ui/button'
import { Input } from '@/shared/components/ui/input'
import { Label } from '@/shared/components/ui/label'
import { signIn } from '@/shared/lib/auth-client'
import { useAuth } from '../hooks/useAuth'
function mapSignInError(message: string | undefined): string {
if (!message) return 'Connexion impossible. Réessayez dans quelques instants.'
if (message === 'Invalid login credentials') {
return 'Email ou mot de passe incorrect.'
}
if (message.includes('Email not confirmed')) {
return 'Email non confirmé. Vérifiez votre boîte mail.'
}
return 'Connexion impossible. Réessayez dans quelques instants.'
}
export function LoginPage() {
const navigate = useNavigate()
const { isAuthenticated, isLoading } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!isLoading && isAuthenticated) {
navigate('/dashboard', { replace: true })
}
}, [isLoading, isAuthenticated, navigate])
if (isLoading || isAuthenticated) {
return (
<div
className="flex min-h-screen items-center justify-center bg-canvas text-ink-secondary"
role="status"
aria-live="polite"
aria-label="Chargement de la session"
>
<Loader2 className="size-6 animate-spin" aria-hidden="true" />
</div>
)
}
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
setError(null)
setIsSubmitting(true)
try {
const { error: signInError } = await signIn(email, password)
if (signInError) {
setError(mapSignInError(signInError.message))
}
} finally {
setIsSubmitting(false)
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-canvas px-4 py-8">
<section className="w-full max-w-sm rounded-lg border border-border bg-surface p-6 shadow-card">
<h1 className="text-2xl font-semibold text-ink-primary">Se connecter</h1>
<p className="mt-1 text-sm text-ink-secondary">Accédez à votre espace Expria.</p>
{error && (
<div
role="alert"
className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="mt-6 space-y-4" noValidate>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Mot de passe</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isSubmitting}
/>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : (
'Se connecter'
)}
</Button>
</form>
<p className="mt-6 text-center text-sm text-ink-secondary">
Pas encore de compte ?{' '}
<Link to="/register" className="text-brand-text underline-offset-4 hover:underline">
Créer un compte
</Link>
</p>
</section>
</main>
)
}
export default LoginPage

View file

@ -0,0 +1,201 @@
/**
* Page d'inscription.
*
* Formulaire email + mot de passe + confirmation, validation Zod côté client,
* appel de `signUp` (Supabase via auth-client). Supabase envoie un email de
* confirmation par défaut : on n'a donc pas de session active après succès.
* On affiche un message de confirmation invitant à vérifier la boîte mail,
* puis à revenir sur /login.
*/
import { useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import { Loader2 } from 'lucide-react'
import { z } from 'zod'
import { Button } from '@/shared/components/ui/button'
import { Input } from '@/shared/components/ui/input'
import { Label } from '@/shared/components/ui/label'
import { signUp } from '@/shared/lib/auth-client'
const registerSchema = z
.object({
email: z.string().email('Email invalide'),
password: z.string().min(8, 'Le mot de passe doit contenir au moins 8 caractères'),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
path: ['confirmPassword'],
message: 'Les mots de passe ne correspondent pas',
})
type FieldErrors = Partial<Record<'email' | 'password' | 'confirmPassword', string>>
function mapSignUpError(message: string | undefined): string {
if (!message) return 'Inscription impossible. Réessayez dans quelques instants.'
if (message.toLowerCase().includes('already registered')) {
return 'Un compte existe déjà avec cet email.'
}
if (/password/i.test(message)) {
return 'Mot de passe refusé par le serveur. Choisissez un mot de passe plus robuste.'
}
return 'Inscription impossible. Réessayez dans quelques instants.'
}
export function RegisterPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
const [formError, setFormError] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
setFieldErrors({})
setFormError(null)
const parsed = registerSchema.safeParse({ email, password, confirmPassword })
if (!parsed.success) {
const flat = parsed.error.flatten().fieldErrors
setFieldErrors({
email: flat.email?.[0],
password: flat.password?.[0],
confirmPassword: flat.confirmPassword?.[0],
})
return
}
setIsSubmitting(true)
try {
const { error: signUpError } = await signUp(parsed.data.email, parsed.data.password)
if (signUpError) {
setFormError(mapSignUpError(signUpError.message))
return
}
setSuccessMessage(
'Compte créé. Vérifiez votre email pour confirmer votre inscription, puis connectez-vous.',
)
} finally {
setIsSubmitting(false)
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-canvas px-4 py-8">
<section className="w-full max-w-sm rounded-lg border border-border bg-surface p-6 shadow-card">
<h1 className="text-2xl font-semibold text-ink-primary">Créer un compte</h1>
<p className="mt-1 text-sm text-ink-secondary">Commencez votre préparation TCF Canada.</p>
{successMessage ? (
<>
<div
role="status"
className="mt-6 rounded-md border border-success/40 bg-success-soft px-3 py-3 text-sm text-success"
>
{successMessage}
</div>
<p className="mt-6 text-center text-sm text-ink-secondary">
<Link to="/login" className="text-brand-text underline-offset-4 hover:underline">
Retour à la connexion
</Link>
</p>
</>
) : (
<>
{formError && (
<div
role="alert"
className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
{formError}
</div>
)}
<form onSubmit={handleSubmit} className="mt-6 space-y-4" noValidate>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isSubmitting}
aria-invalid={!!fieldErrors.email}
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
/>
{fieldErrors.email && (
<p id="email-error" className="text-sm text-danger">
{fieldErrors.email}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Mot de passe</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isSubmitting}
aria-invalid={!!fieldErrors.password}
aria-describedby={fieldErrors.password ? 'password-error' : undefined}
/>
{fieldErrors.password && (
<p id="password-error" className="text-sm text-danger">
{fieldErrors.password}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirmer le mot de passe</Label>
<Input
id="confirmPassword"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isSubmitting}
aria-invalid={!!fieldErrors.confirmPassword}
aria-describedby={
fieldErrors.confirmPassword ? 'confirmPassword-error' : undefined
}
/>
{fieldErrors.confirmPassword && (
<p id="confirmPassword-error" className="text-sm text-danger">
{fieldErrors.confirmPassword}
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : (
'Créer mon compte'
)}
</Button>
</form>
<p className="mt-6 text-center text-sm text-ink-secondary">
Déjà un compte ?{' '}
<Link to="/login" className="text-brand-text underline-offset-4 hover:underline">
Se connecter
</Link>
</p>
</>
)}
</section>
</main>
)
}
export default RegisterPage

View file

@ -0,0 +1,99 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
// ─── Mocks ───────────────────────────────────────────────────────────────────
const { usePlanMock, useCustomerPortalMock } = vi.hoisted(() => ({
usePlanMock: vi.fn(),
useCustomerPortalMock: vi.fn(),
}))
vi.mock('@/features/dashboard/hooks/usePlan', () => ({
usePlan: usePlanMock,
}))
vi.mock('../hooks/useCustomerPortal', () => ({
useCustomerPortal: useCustomerPortalMock,
}))
import { AccountBillingSection } from '../components/AccountBillingSection'
afterEach(() => {
cleanup()
usePlanMock.mockReset()
useCustomerPortalMock.mockReset()
})
function renderSection() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<AccountBillingSection />
</MemoryRouter>
</QueryClientProvider>,
)
}
function mockPlan(plan: 'free' | 'standard' | 'premium') {
usePlanMock.mockReturnValue({
data: { plan, permissions: {}, simulations_used: 0, simulations_remaining: null },
isLoading: false,
})
}
function mockPortal(overrides: Partial<ReturnType<typeof useCustomerPortalMock>> = {}) {
const openPortal = vi.fn()
useCustomerPortalMock.mockReturnValue({
openPortal,
isLoading: false,
error: null,
...overrides,
})
return openPortal
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('AccountBillingSection — plan free', () => {
it('affiche le badge Découverte + lien "Voir les plans" → /plan', () => {
mockPlan('free')
mockPortal()
renderSection()
expect(screen.getByText('Plan Découverte')).toBeInTheDocument()
const link = screen.getByRole('link', { name: /voir les plans/i })
expect(link).toHaveAttribute('href', '/plan')
expect(screen.queryByRole('button', { name: /gérer mon abonnement/i })).not.toBeInTheDocument()
})
})
describe('AccountBillingSection — plan standard', () => {
it('clic sur "Gérer mon abonnement" appelle openPortal', async () => {
const user = userEvent.setup()
mockPlan('standard')
const openPortal = mockPortal()
renderSection()
expect(screen.getByText('Plan Standard')).toBeInTheDocument()
const btn = screen.getByRole('button', { name: /gérer mon abonnement/i })
expect(btn).toBeEnabled()
await user.click(btn)
expect(openPortal).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('link', { name: /voir les plans/i })).not.toBeInTheDocument()
})
it("affiche un callout d'erreur quand le hook expose error", () => {
mockPlan('premium')
mockPortal({ error: 'Aucun abonnement actif trouvé.' })
renderSection()
expect(screen.getByRole('alert')).toHaveTextContent(/Aucun abonnement actif trouvé/i)
})
})

View file

@ -0,0 +1,154 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, cleanup, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
// ─── Mocks ───────────────────────────────────────────────────────────────────
const { usePlanMock, createCheckoutSessionMock, createCustomerPortalSessionMock } = vi.hoisted(
() => ({
usePlanMock: vi.fn(),
createCheckoutSessionMock: vi.fn(),
createCustomerPortalSessionMock: vi.fn(),
}),
)
vi.mock('@/features/dashboard/hooks/usePlan', () => ({
usePlan: usePlanMock,
}))
vi.mock('../api', () => ({
createCheckoutSession: createCheckoutSessionMock,
createCustomerPortalSession: createCustomerPortalSessionMock,
}))
import { PricingPage } from '../pages/PricingPage'
afterEach(() => {
cleanup()
usePlanMock.mockReset()
createCheckoutSessionMock.mockReset()
createCustomerPortalSessionMock.mockReset()
})
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<PricingPage />
</MemoryRouter>
</QueryClientProvider>,
)
}
function mockPlan(plan: 'free' | 'standard' | 'premium') {
usePlanMock.mockReturnValue({
data: { plan, permissions: {}, simulations_used: 0, simulations_remaining: null },
isLoading: false,
isError: false,
})
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('PricingPage — plan free', () => {
it('CTA Standard et Premium actifs (plein tarif), Découverte = "Plan actuel" disabled', () => {
mockPlan('free')
renderPage()
expect(
screen.getByRole('button', { name: /Choisir Standard — 19,90 €\/4 sem\./ }),
).toBeEnabled()
expect(screen.getByRole('button', { name: /Choisir Premium — 39,90 €\/4 sem\./ })).toBeEnabled()
const planActuelButtons = screen.getAllByRole('button', { name: /Plan actuel/i })
expect(planActuelButtons).toHaveLength(1)
expect(planActuelButtons[0]).toBeDisabled()
})
})
describe('PricingPage — plan standard', () => {
it('Standard désactivé "Plan actuel" ; Premium actif "Passer en Premium" + hint prorata', () => {
mockPlan('standard')
renderPage()
expect(screen.getByRole('button', { name: /Passer en Premium$/ })).toBeEnabled()
expect(
screen.queryByRole('button', { name: /Choisir Premium — 39,90 €/ }),
).not.toBeInTheDocument()
expect(screen.getByText(/Stripe calculera automatiquement le prorata/i)).toBeInTheDocument()
const planActuelButtons = screen.getAllByRole('button', { name: /Plan actuel/i })
expect(planActuelButtons).toHaveLength(1)
expect(planActuelButtons[0]).toBeDisabled()
})
})
describe('PricingPage — plan premium', () => {
it('tous les CTA payants désactivés', () => {
mockPlan('premium')
renderPage()
const planActuelButtons = screen.getAllByRole('button', { name: /Plan actuel/i })
expect(planActuelButtons).toHaveLength(1)
expect(planActuelButtons[0]).toBeDisabled()
expect(
screen.queryByRole('button', { name: /Choisir Standard|Passer en Premium|Choisir Premium/ }),
).not.toBeInTheDocument()
const inferieurButtons = screen.getAllByRole('button', { name: /Inférieur à votre plan/i })
expect(inferieurButtons.length).toBeGreaterThanOrEqual(1)
inferieurButtons.forEach((btn) => expect(btn).toBeDisabled())
})
})
describe('PricingPage — interaction', () => {
it('clic sur "Choisir Standard" appelle createCheckoutSession("standard")', async () => {
const user = userEvent.setup()
mockPlan('free')
// Promesse non résolue : on veut juste vérifier l'appel, pas la redirection.
createCheckoutSessionMock.mockReturnValue(new Promise(() => {}))
renderPage()
await user.click(screen.getByRole('button', { name: /Choisir Standard — 19,90 €\/4 sem\./ }))
await waitFor(() => {
expect(createCheckoutSessionMock).toHaveBeenCalledTimes(1)
})
// TanStack Query injecte un 2e arg (mutationContext) → on vérifie uniquement le 1er.
expect(createCheckoutSessionMock.mock.calls[0]?.[0]).toBe('standard')
})
it('Standard user clique "Passer en Premium" → createCustomerPortalSession (PAS createCheckoutSession)', async () => {
const user = userEvent.setup()
mockPlan('standard')
createCustomerPortalSessionMock.mockReturnValue(new Promise(() => {}))
renderPage()
await user.click(screen.getByRole('button', { name: /Passer en Premium$/ }))
await waitFor(() => {
expect(createCustomerPortalSessionMock).toHaveBeenCalledTimes(1)
})
expect(createCheckoutSessionMock).not.toHaveBeenCalled()
})
it("erreur de mutation → callout d'erreur affiché", async () => {
const user = userEvent.setup()
mockPlan('free')
createCheckoutSessionMock.mockRejectedValue(new Error('Configuration Stripe manquante.'))
renderPage()
await user.click(screen.getByRole('button', { name: /Choisir Standard — 19,90 €\/4 sem\./ }))
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/Configuration Stripe manquante/i)
})
})
})

View file

@ -0,0 +1,97 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { ReactNode } from 'react'
// ─── Mocks ───────────────────────────────────────────────────────────────────
const { createCustomerPortalSessionMock } = vi.hoisted(() => ({
createCustomerPortalSessionMock: vi.fn(),
}))
vi.mock('../api', () => ({
createCustomerPortalSession: createCustomerPortalSessionMock,
}))
import { useCustomerPortal } from '../hooks/useCustomerPortal'
afterEach(() => {
createCustomerPortalSessionMock.mockReset()
})
function wrapper({ children }: { children: ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('useCustomerPortal', () => {
it("openPortal() succès → window.location.href set sur l'URL portal", async () => {
const originalLocation = window.location
const hrefSetter = vi.fn()
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: {
...originalLocation,
get href() {
return originalLocation.href
},
set href(v: string) {
hrefSetter(v)
},
},
})
createCustomerPortalSessionMock.mockResolvedValue({
url: 'https://billing.stripe.com/p/session/abc',
})
const { result } = renderHook(() => useCustomerPortal(), { wrapper })
act(() => {
result.current.openPortal()
})
await waitFor(() => {
expect(hrefSetter).toHaveBeenCalledWith('https://billing.stripe.com/p/session/abc')
})
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: originalLocation,
})
})
it('erreur backend → message backend propagé dans `error`', async () => {
createCustomerPortalSessionMock.mockRejectedValue(
new Error('Aucun abonnement actif trouvé. Souscrivez dabord à un plan.'),
)
const { result } = renderHook(() => useCustomerPortal(), { wrapper })
act(() => {
result.current.openPortal()
})
await waitFor(() => {
expect(result.current.error).toMatch(/Aucun abonnement actif trouvé/)
})
})
it('isLoading vrai pendant la mutation pending', async () => {
createCustomerPortalSessionMock.mockReturnValue(new Promise(() => {}))
const { result } = renderHook(() => useCustomerPortal(), { wrapper })
expect(result.current.isLoading).toBe(false)
act(() => {
result.current.openPortal()
})
await waitFor(() => {
expect(result.current.isLoading).toBe(true)
})
})
})

View file

@ -0,0 +1,107 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { ReactNode } from 'react'
// ─── Mocks ───────────────────────────────────────────────────────────────────
const { createCheckoutSessionMock } = vi.hoisted(() => ({
createCheckoutSessionMock: vi.fn(),
}))
vi.mock('../api', () => ({
createCheckoutSession: createCheckoutSessionMock,
}))
import { useStripeCheckout } from '../hooks/useStripeCheckout'
afterEach(() => {
createCheckoutSessionMock.mockReset()
})
function wrapper({ children }: { children: ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('useStripeCheckout', () => {
it('checkout(priceType) appelle createCheckoutSession avec le bon argument', async () => {
createCheckoutSessionMock.mockReturnValue(new Promise(() => {}))
const { result } = renderHook(() => useStripeCheckout(), { wrapper })
act(() => {
result.current.checkout('standard')
})
await waitFor(() => {
expect(createCheckoutSessionMock).toHaveBeenCalledTimes(1)
})
expect(createCheckoutSessionMock.mock.calls[0]?.[0]).toBe('standard')
})
it('expose pendingPriceType pendant la mutation', async () => {
createCheckoutSessionMock.mockReturnValue(new Promise(() => {}))
const { result } = renderHook(() => useStripeCheckout(), { wrapper })
expect(result.current.pendingPriceType).toBeNull()
act(() => {
result.current.checkout('premium')
})
expect(result.current.pendingPriceType).toBe('premium')
})
it("redirige window.location.href vers l'URL Stripe au succès", async () => {
const originalLocation = window.location
const hrefSetter = vi.fn()
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: {
...originalLocation,
get href() {
return originalLocation.href
},
set href(v: string) {
hrefSetter(v)
},
},
})
createCheckoutSessionMock.mockResolvedValue({
url: 'https://checkout.stripe.com/pay/cs_xyz',
})
const { result } = renderHook(() => useStripeCheckout(), { wrapper })
act(() => {
result.current.checkout('standard')
})
await waitFor(() => {
expect(hrefSetter).toHaveBeenCalledWith('https://checkout.stripe.com/pay/cs_xyz')
})
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: originalLocation,
})
})
it("expose error et reset pendingPriceType en cas d'échec", async () => {
createCheckoutSessionMock.mockRejectedValue(new Error('Configuration Stripe manquante.'))
const { result } = renderHook(() => useStripeCheckout(), { wrapper })
act(() => {
result.current.checkout('standard')
})
await waitFor(() => {
expect(result.current.error).toMatch(/Configuration Stripe manquante/)
})
expect(result.current.pendingPriceType).toBeNull()
})
})

View file

@ -0,0 +1,51 @@
/**
* Sprint 5b API client billing.
*
* Wrappers TanStack-Query-friendly autour des endpoints Stripe :
* - `POST /stripe/checkout` (création session paiement plein tarif)
* - `POST /stripe/customer-portal` (Sprint 5d Customer Portal Stripe)
*
* Le frontend ne stocke jamais de clé Stripe privée. Les `price_id` (publics
* par nature, comme la clé Supabase anon) sont injectés via les variables
* d'env `VITE_STRIPE_PRICE_*` leur absence au runtime déclenche une erreur
* explicite côté CTA, pas un crash silencieux.
*/
import { apiFetch } from '@/shared/lib/api-client'
import { env } from '@/shared/config/env'
export type PriceType = 'standard' | 'premium'
interface CheckoutResponse {
url: string
}
interface CustomerPortalResponse {
url: string
}
function resolvePriceId(priceType: PriceType): string {
const id =
priceType === 'standard' ? env.VITE_STRIPE_PRICE_STANDARD : env.VITE_STRIPE_PRICE_PREMIUM
if (!id) {
throw new Error(
'Configuration Stripe manquante. Veuillez réessayer plus tard ou contacter le support.',
)
}
return id
}
export async function createCheckoutSession(priceType: PriceType): Promise<CheckoutResponse> {
return apiFetch<CheckoutResponse>('/stripe/checkout', {
method: 'POST',
body: { priceId: resolvePriceId(priceType), planName: priceType },
timeoutMs: 30_000,
})
}
export async function createCustomerPortalSession(): Promise<CustomerPortalResponse> {
return apiFetch<CustomerPortalResponse>('/stripe/customer-portal', {
method: 'POST',
timeoutMs: 15_000,
})
}

View file

@ -0,0 +1,86 @@
/**
* Sprint 5d Section Abonnement de la page Paramètres.
*
* Affiche le plan actuel + un CTA contextuel :
* - Plan free lien « Voir les plans » vers `/plan`.
* - Plan payant bouton « Gérer mon abonnement » Stripe Customer Portal.
*
* Règle D : aucune comparaison `plan === 'xxx'` exposée hors d'un mapping
* explicite (ici la branche est binaire free vs payant).
* Règle L : tokens DA Charcoal exclusivement.
*/
import { Link } from 'react-router-dom'
import { Button } from '@/shared/ui/Button'
import { Card } from '@/shared/ui/Card'
import { Badge } from '@/shared/ui/Badge'
import { usePlan } from '@/features/dashboard/hooks/usePlan'
import { useCustomerPortal } from '../hooks/useCustomerPortal'
const PLAN_LABEL: Record<'free' | 'standard' | 'premium', string> = {
free: 'Plan Découverte',
standard: 'Plan Standard',
premium: 'Plan Premium',
}
export function AccountBillingSection() {
const { data: planData, isLoading: isPlanLoading } = usePlan()
const { openPortal, isLoading: isPortalLoading, error: portalError } = useCustomerPortal()
if (isPlanLoading || !planData) {
return (
<Card variant="default" className="p-6">
<div
className="h-24 animate-pulse rounded bg-surface-hover"
aria-busy="true"
aria-label="Chargement du plan…"
/>
</Card>
)
}
const plan = planData.plan as 'free' | 'standard' | 'premium'
const isSubscribed = plan !== 'free'
return (
<Card variant="default" className="space-y-4 p-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-ink-primary">Abonnement</h2>
<Badge variant="plan" planValue={plan}>
{PLAN_LABEL[plan]}
</Badge>
</header>
{isSubscribed ? (
<>
<p className="text-sm text-ink-secondary">
Modifier votre plan, mettre à jour votre moyen de paiement, ou consulter vos factures.
</p>
<Button variant="primary" size="md" onClick={openPortal} loading={isPortalLoading}>
Gérer mon abonnement
</Button>
{portalError && (
<p
role="alert"
className="rounded-md border border-danger/30 bg-danger-soft px-3 py-2 text-sm text-danger"
>
{portalError}
</p>
)}
</>
) : (
<>
<p className="text-sm text-ink-secondary">
Vous utilisez actuellement le plan gratuit (5 simulations à vie). Découvrez les plans
payants pour un entraînement illimité avec correction détaillée.
</p>
<Button variant="primary" size="md">
<Link to="/plan" className="-m-1 p-1">
Voir les plans
</Link>
</Button>
</>
)}
</Card>
)
}

View file

@ -0,0 +1,105 @@
/**
* Carte plan tarifaire Sprint 5b.
*
* Présentationnel pur (Règle H). Tokens DA Charcoal exclusivement (Règle L).
* La logique CTA (qui est désactivé, qui est "Plan actuel", etc.) vit dans
* `PricingPage.tsx`.
*/
import { Check } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
export interface PlanCardCta {
label: string
variant: 'primary' | 'secondary'
disabled?: boolean
loading?: boolean
onClick?: () => void
}
interface Props {
title: string
price: string
priceCadence?: string
description?: string
features: string[]
highlighted?: boolean
currentBadge?: boolean
cta: PlanCardCta
/** Texte additionnel sous le bouton (ex. info prorata). */
ctaHint?: string
/** Message d'erreur affiché en bas de carte (ex. erreur mutation Stripe). */
errorMessage?: string
}
export function PlanCard({
title,
price,
priceCadence,
description,
features,
highlighted = false,
currentBadge = false,
cta,
ctaHint,
errorMessage,
}: Props) {
const borderClass = highlighted
? 'border-brand shadow-[0_0_0_1px_var(--color-brand)]'
: 'border-border'
return (
<div
className={`relative flex flex-col gap-4 rounded-[var(--radius-lg)] border bg-surface p-6 shadow-card ${borderClass}`}
>
{currentBadge && (
<span className="absolute -top-3 left-6 inline-flex items-center rounded-full border border-brand/30 bg-brand-soft px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-brand-text">
Plan actuel
</span>
)}
<div>
<h2 className="text-xl font-bold text-ink-primary">{title}</h2>
{description && <p className="mt-1 text-sm text-ink-secondary">{description}</p>}
</div>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold tracking-tight tabular-nums text-ink-primary">
{price}
</span>
{priceCadence && <span className="text-sm text-ink-tertiary">{priceCadence}</span>}
</div>
<ul className="flex-1 space-y-2">
{features.map((feature) => (
<li key={feature} className="flex items-start gap-2 text-sm text-ink-primary">
<Check className="mt-0.5 size-4 shrink-0 text-brand-text" aria-hidden="true" />
<span>{feature}</span>
</li>
))}
</ul>
<div className="space-y-2">
<Button
variant={cta.variant}
size="md"
className="w-full"
disabled={cta.disabled}
loading={cta.loading}
onClick={cta.onClick}
>
{cta.label}
</Button>
{ctaHint && <p className="text-xs text-ink-tertiary">{ctaHint}</p>}
{errorMessage && (
<p
role="alert"
className="rounded-md border border-danger/30 bg-danger-soft px-2.5 py-1.5 text-xs text-danger"
>
{errorMessage}
</p>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,55 @@
/**
* Sprint 5d Hook Stripe Customer Portal.
*
* Wrap la mutation `createCustomerPortalSession` + redirect full-page vers
* la session Customer Portal Stripe. Utilisé par :
* - `AccountBillingSection` (page Paramètres) : bouton « Gérer mon abonnement ».
* - `PricingPage` (StandardPremium) : redirige vers le portal qui gère
* nativement l'upgrade prorata + confirmation du montant.
*
* Erreur 400 `NO_ACTIVE_SUBSCRIPTION` propagée telle quelle (le backend
* fournit déjà un message FR exploitable directement).
*/
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { createCustomerPortalSession } from '../api'
export interface UseCustomerPortalResult {
openPortal: () => void
isLoading: boolean
error: string | null
}
const FALLBACK_ERROR_MESSAGE =
'Impossible douvrir lespace abonnement. Réessayez dans quelques instants.'
export function useCustomerPortal(): UseCustomerPortalResult {
const [error, setError] = useState<string | null>(null)
const mutation = useMutation({
mutationFn: createCustomerPortalSession,
onSuccess: (data) => {
// Redirect full-page : l'utilisateur reviendra sur ${APP_URL}/dashboard
// (cf. backend `return_url`). Le query param `?upgrade=success` n'est PAS
// ajouté par le portal — pas de banner de bienvenue dans ce flow,
// seulement une éventuelle invalidation au refresh manuel.
window.location.href = data.url
},
onError: (err: unknown) => {
const message = err instanceof Error && err.message ? err.message : FALLBACK_ERROR_MESSAGE
setError(message)
},
})
function openPortal(): void {
setError(null)
mutation.mutate()
}
return {
openPortal,
isLoading: mutation.isPending,
error,
}
}

View file

@ -0,0 +1,59 @@
/**
* Sprint 5c Hook checkout Stripe.
*
* Encapsule la mutation `createCheckoutSession` + la redirection full-page
* vers Stripe Checkout. Expose `pendingPriceType` pour permettre aux pages
* (ex. PricingPage) d'afficher un loading par carte sans state local.
*
* Usage typique :
* const { checkout, pendingPriceType, error } = useStripeCheckout()
* <Button loading={pendingPriceType === 'standard'} onClick={() => checkout('standard')}>
* Choisir Standard
* </Button>
*/
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { createCheckoutSession, type PriceType } from '../api'
export interface UseStripeCheckoutResult {
checkout: (priceType: PriceType) => void
isLoading: boolean
pendingPriceType: PriceType | null
error: string | null
}
const FALLBACK_ERROR_MESSAGE =
'Impossible de démarrer le paiement. Réessayez dans quelques instants.'
export function useStripeCheckout(): UseStripeCheckoutResult {
const [pendingPriceType, setPendingPriceType] = useState<PriceType | null>(null)
const [error, setError] = useState<string | null>(null)
const mutation = useMutation({
mutationFn: createCheckoutSession,
onSuccess: (data) => {
// Redirection full-page vers Stripe Checkout. L'utilisateur reviendra
// sur /dashboard?upgrade=success après paiement réussi (cf. backend
// success_url) ou /plan?upgrade=cancelled en cas d'annulation.
window.location.href = data.url
},
onError: (err: Error) => {
setError(err.message || FALLBACK_ERROR_MESSAGE)
setPendingPriceType(null)
},
})
function checkout(priceType: PriceType): void {
setError(null)
setPendingPriceType(priceType)
mutation.mutate(priceType)
}
return {
checkout,
isLoading: mutation.isPending,
pendingPriceType,
error,
}
}

View file

@ -0,0 +1,240 @@
/**
* Page tarifaire `/plan` Sprint 5b.
*
* 3 colonnes (Découverte / Standard / Premium). Le CTA de chaque carte dépend
* du plan actuel de l'utilisateur :
* - free CTA Standard et Premium actifs (plein tarif).
* - standard Standard désactivé "Plan actuel" ; Premium = "Passer en Premium"
* (sans prix affiché Stripe calcule le prorata côté serveur).
* - premium Tous désactivés ; Premium marqué "Plan actuel".
*
* Le clic sur un CTA payant déclenche `createCheckoutSession(priceType)` puis
* redirige le navigateur en full-page vers l'URL Stripe Checkout retournée.
*
* Règle D : aucun `plan === 'xxx'` exposé la sélection du CTA passe par
* une fonction `getCtaConfig(plan)` qui mappe explicitement chaque plan vers
* ses CTA, sans `if/else` éparpillés.
*/
import { useState } from 'react'
import { usePlan } from '@/features/dashboard/hooks/usePlan'
import { type PriceType } from '../api'
import { useStripeCheckout } from '../hooks/useStripeCheckout'
import { useCustomerPortal } from '../hooks/useCustomerPortal'
import { PlanCard, type PlanCardCta } from '../components/PlanCard'
type Plan = 'free' | 'standard' | 'premium'
interface PlanColumn {
key: 'free' | 'standard' | 'premium'
title: string
price: string
priceCadence?: string
description: string
features: string[]
highlighted: boolean
}
const COLUMNS: PlanColumn[] = [
{
key: 'free',
title: 'Découverte',
price: 'Gratuit',
description: 'Goûter le produit, voir comment ça marche.',
features: [
'5 simulations à vie',
'Score global et niveau NCLC',
'Feedback court (2-3 lignes)',
'Accès EE T1, T2, T3 et EO T1, T3',
],
highlighted: false,
},
{
key: 'standard',
title: 'Standard',
price: '19,90 €',
priceCadence: '/ 4 semaines',
description: 'Progression sérieuse — toutes les corrections détaillées.',
features: [
'Simulations illimitées',
'Rapport détaillé par critère',
'Suggestions, exercices, production modèle',
'Historique complet et dashboard',
'Indice de préparation après 5 productions',
],
highlighted: true,
},
{
key: 'premium',
title: 'Premium',
price: '39,90 €',
priceCadence: '/ 4 semaines',
description: 'Tout Standard, plus les outils de simulation réelle.',
features: [
'Tout le plan Standard',
'Mode Examen (60 min EE / 12 min EO)',
'EO Tâche 2 — dialogue live avec lexaminateur IA',
'Analyse des patterns sur 5 dernières productions',
'Exercices long terme personnalisés',
],
highlighted: false,
},
]
interface CtaConfigs {
standard: { cta: PlanCardCta; hint?: string }
premium: { cta: PlanCardCta; hint?: string }
}
function buildCtaConfigs(
plan: Plan,
isStandardPending: boolean,
isPremiumPending: boolean,
onUpgrade: (priceType: PriceType) => void,
): CtaConfigs {
const anyPending = isStandardPending || isPremiumPending
if (plan === 'free') {
return {
standard: {
cta: {
label: 'Choisir Standard — 19,90 €/4 sem.',
variant: 'primary',
loading: isStandardPending,
disabled: anyPending,
onClick: () => onUpgrade('standard'),
},
},
premium: {
cta: {
label: 'Choisir Premium — 39,90 €/4 sem.',
variant: 'primary',
loading: isPremiumPending,
disabled: anyPending,
onClick: () => onUpgrade('premium'),
},
},
}
}
if (plan === 'standard') {
return {
standard: {
cta: { label: 'Plan actuel', variant: 'secondary', disabled: true },
},
premium: {
cta: {
label: 'Passer en Premium',
variant: 'primary',
loading: isPremiumPending,
disabled: anyPending,
onClick: () => onUpgrade('premium'),
},
hint: 'Stripe calculera automatiquement le prorata sur votre abonnement en cours.',
},
}
}
// premium
return {
standard: {
cta: { label: 'Inférieur à votre plan', variant: 'secondary', disabled: true },
},
premium: {
cta: { label: 'Plan actuel', variant: 'secondary', disabled: true },
},
}
}
export function PricingPage() {
const { data: planData, isLoading } = usePlan()
const { checkout, pendingPriceType, error: checkoutError } = useStripeCheckout()
const { openPortal, isLoading: isPortalLoading, error: portalError } = useCustomerPortal()
// Mémorise le dernier priceType cliqué pour rattacher l'erreur (checkout OU
// portal) à la bonne carte. L'utilisateur ne clique qu'un CTA à la fois.
const [lastClicked, setLastClicked] = useState<PriceType | null>(null)
const plan = (planData?.plan as Plan | undefined) ?? 'free'
// Sprint 5d — branche Standard→Premium via Customer Portal (Stripe affiche
// le montant prorata + confirmation native). Free→* reste sur Checkout direct.
function handleUpgrade(priceType: PriceType) {
setLastClicked(priceType)
if (plan === 'standard') {
openPortal()
} else {
checkout(priceType)
}
}
// Loading par carte : combine la source pertinente selon le plan utilisateur.
const isStandardPending = pendingPriceType === 'standard'
const isPremiumPending = plan === 'standard' ? isPortalLoading : pendingPriceType === 'premium'
const ctaConfigs = buildCtaConfigs(plan, isStandardPending, isPremiumPending, handleUpgrade)
const effectiveError = checkoutError ?? portalError
const errorByType: Partial<Record<PriceType, string>> =
effectiveError && lastClicked ? { [lastClicked]: effectiveError } : {}
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<header className="mb-8">
<p className="text-[11px] font-semibold uppercase tracking-[0.073em] text-ink-tertiary">
Tarifs
</p>
<h1 className="mt-1 text-[32px] font-bold tracking-[-0.02em] text-ink-primary">
Choisissez votre plan
</h1>
<p className="mt-2 text-sm text-ink-secondary">
Toutes les offres incluent laccès aux 5 tâches du TCF Canada (EE T1/T2/T3, EO T1/T3).
Annulation libre à tout moment depuis votre espace abonnement.
</p>
</header>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{COLUMNS.map((col) => {
if (col.key === 'free') {
return (
<PlanCard
key={col.key}
title={col.title}
price={col.price}
description={col.description}
features={col.features}
highlighted={col.highlighted}
currentBadge={plan === 'free'}
cta={{
label: plan === 'free' ? 'Plan actuel' : 'Inférieur à votre plan',
variant: 'secondary',
disabled: true,
}}
/>
)
}
const config = ctaConfigs[col.key]
return (
<PlanCard
key={col.key}
title={col.title}
price={col.price}
priceCadence={col.priceCadence}
description={col.description}
features={col.features}
highlighted={col.highlighted}
currentBadge={plan === col.key}
cta={config.cta}
ctaHint={config.hint}
errorMessage={errorByType[col.key]}
/>
)
})}
</div>
{isLoading && (
<p aria-live="polite" className="mt-6 text-center text-xs text-ink-tertiary">
Chargement de votre plan
</p>
)}
</div>
)
}

View file

@ -0,0 +1,111 @@
/**
* DashboardFreeView vue Dashboard pour le plan Découverte.
*
* Spécificités Free :
* - Pas d'appel `useSimulationsList` (gate 'dashboard' à false côté backend).
* - Hero NCLC en état placeholder (pas d'historique lisible).
* - Stat cards avec "NCLC estimé —" et "Dernier score —".
* - Recommandation statique vers la première simulation EE T2.
* - Bannière upsell Standard en bas.
*
* Règle D : aucun `plan === 'free'` c'est le parent (DashboardPage) qui
* route vers cette vue via hasAccess.
* Règle H : aucune logique métier les données viennent des props.
* Règle L : tokens du design system exclusivement.
*/
import { useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { Badge } from '@/shared/ui/Badge'
import { NclcHero } from './NclcHero'
import { StatCards } from './StatCards'
import { NextStepCard } from './NextStepCard'
import { PaywallBanner } from './PaywallBanner'
interface DashboardFreeViewProps {
displayName: string
simulationsUsed: number
simulationsRemaining: number
canStartSimulation: boolean
}
const FREE_CONSEIL =
"Commencez par une simulation d'Expression Écrite pour découvrir votre niveau. " +
'Le rapport détaillé et le suivi NCLC se débloquent avec le plan Standard.'
export function DashboardFreeView({
displayName,
simulationsUsed,
simulationsRemaining,
canStartSimulation,
}: DashboardFreeViewProps) {
const navigate = useNavigate()
return (
<div className="space-y-6">
{/* Header */}
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-ink-primary">Bonjour, {displayName}</h1>
<Badge variant="plan" planValue="free">
Plan Découverte
</Badge>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => navigate('/plan')}>
Voir les plans
</Button>
<Button
variant="primary"
size="sm"
icon={<Plus className="size-4" />}
disabled={!canStartSimulation}
onClick={() => navigate('/simulation/ee')}
>
Nouvelle simulation
</Button>
</div>
</header>
{/* Hero NCLC — placeholder en Free */}
<NclcHero currentNclc={null} conseil={FREE_CONSEIL} lastScore={null} />
{/* Stat cards — NCLC et dernier score vides */}
<StatCards
plan="free"
simulationsUsed={simulationsUsed}
simulationsRemaining={simulationsRemaining}
recentSimulations={[]}
/>
{/* Prochaine étape + (pas de simulations récentes en Free) */}
<div className="grid gap-4 lg:grid-cols-[1fr_360px]">
<section
aria-label="Premiers pas"
className="rounded-[var(--radius-md)] border border-border bg-surface p-6"
>
<p className="text-[11px] font-semibold uppercase tracking-wider text-ink-tertiary">
Pour bien démarrer
</p>
<h2 className="mt-2 text-lg font-semibold text-ink-primary">Votre première simulation</h2>
<p className="mt-2 text-sm text-ink-secondary">
Choisissez une tâche d'Expression Écrite pour obtenir un premier score et une estimation
NCLC. Vos 5 simulations gratuites vous attendent.
</p>
</section>
<NextStepCard
title="Démarrez par l'Écrit T2"
conseil="Article d'opinion — le format le plus représentatif du TCF Canada."
tags={['20 min', '120-150 mots']}
ctaLabel="Commencer"
ctaTo="/simulation/ee"
/>
</div>
{/* Bannière upsell */}
<PaywallBanner />
</div>
)
}

View file

@ -0,0 +1,96 @@
/**
* DashboardPremiumView vue Dashboard pour le plan Premium.
*
* Spécificités Premium :
* - Historique via `useSimulationsList`.
* - NCLC = dernière simulation (comme Standard).
* - Indice de préparation 0100 via `MonProfilPreparation` (patterns).
* - Pas de CTA "Passer en Premium" déjà au top-tier.
*
* Règle D : aucun `plan === 'premium'` routing via hasAccess côté parent.
* Règle H : logique d'affichage uniquement.
* Règle L : tokens du design system exclusivement.
*/
import { useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { Badge } from '@/shared/ui/Badge'
import { useSimulationsList } from '@/features/historique/hooks/useSimulationsList'
import { NclcHero } from './NclcHero'
import { StatCards } from './StatCards'
import { RecentSimulations } from './RecentSimulations'
import { NextStepCard } from './NextStepCard'
import { MonProfilPreparation } from './MonProfilPreparation'
interface DashboardPremiumViewProps {
displayName: string
simulationsUsed: number
}
const PREMIUM_CONSEIL =
'Votre préparation est avancée. Enchaînez un Examen blanc chaque semaine pour verrouiller votre NCLC cible.'
export function DashboardPremiumView({ displayName, simulationsUsed }: DashboardPremiumViewProps) {
const navigate = useNavigate()
const { data } = useSimulationsList(1, 5)
const recent = data?.data ?? []
const totalCount = data?.pagination.total ?? 0
const firstWithNclc = recent.find((s) => s.nclc !== null) ?? null
const lastNclc = firstWithNclc?.nclc ?? null
const firstWithScore = recent.find((s) => s.score !== null) ?? null
const lastScore =
firstWithScore && firstWithScore.score !== null
? { value: firstWithScore.score, max: 20 }
: null
return (
<div className="space-y-6">
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-ink-primary">Bonjour, {displayName}</h1>
<Badge variant="plan" planValue="premium">
Plan Premium
</Badge>
</div>
<Button
variant="primary"
size="sm"
icon={<Plus className="size-4" />}
onClick={() => navigate('/simulation/ee')}
>
Nouvelle simulation
</Button>
</header>
<NclcHero
currentNclc={lastNclc}
nclcLabel="NCLC dernière simulation"
conseil={PREMIUM_CONSEIL}
lastScore={lastScore}
/>
<StatCards
plan="premium"
simulationsUsed={simulationsUsed}
simulationsRemaining={null}
recentSimulations={recent}
/>
<div className="grid gap-4 lg:grid-cols-[1fr_360px]">
<RecentSimulations items={recent} totalCount={totalCount} />
<NextStepCard
title="Lancez un Examen blanc"
conseil="Conditions réelles : 60 min, 3 tâches, envoi automatique. Reproduisez la pression du jour J."
tags={['60 min', 'Examen']}
ctaLabel="Démarrer"
ctaTo="/examen"
/>
</div>
<MonProfilPreparation plan="premium" />
</div>
)
}

View file

@ -0,0 +1,102 @@
/**
* DashboardStandardView vue Dashboard pour le plan Standard.
*
* Spécificités Standard :
* - Historique lisible via `useSimulationsList`.
* - NCLC estimé = NCLC de la dernière simulation (premier item avec nclc non-null).
* - Pas de `MonProfilPreparation` (pattern_analysis gated Premium).
* - CTA "Passer en Premium →" + "+ Nouvelle simulation".
*
* Règle D : aucun `plan === 'standard'` c'est le parent (DashboardPage) qui
* route vers cette vue via hasAccess.
* Règle H : logique d'affichage uniquement.
* Règle L : tokens du design system exclusivement.
*/
import { useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { Badge } from '@/shared/ui/Badge'
import { useSimulationsList } from '@/features/historique/hooks/useSimulationsList'
import { NclcHero } from './NclcHero'
import { StatCards } from './StatCards'
import { RecentSimulations } from './RecentSimulations'
import { NextStepCard } from './NextStepCard'
interface DashboardStandardViewProps {
displayName: string
simulationsUsed: number
}
const STD_CONSEIL =
'Votre préparation avance. Continuez la régularité — visez une simulation tous les deux jours pour sécuriser votre NCLC cible.'
export function DashboardStandardView({
displayName,
simulationsUsed,
}: DashboardStandardViewProps) {
const navigate = useNavigate()
const { data } = useSimulationsList(1, 5)
const recent = data?.data ?? []
const totalCount = data?.pagination.total ?? 0
const firstWithNclc = recent.find((s) => s.nclc !== null) ?? null
const lastNclc = firstWithNclc?.nclc ?? null
const firstWithScore = recent.find((s) => s.score !== null) ?? null
const lastScore =
firstWithScore && firstWithScore.score !== null
? { value: firstWithScore.score, max: 20 }
: null
return (
<div className="space-y-6">
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-ink-primary">Bonjour, {displayName}</h1>
<Badge variant="plan" planValue="standard">
Plan Standard
</Badge>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => navigate('/plan')}>
Passer en Premium
</Button>
<Button
variant="primary"
size="sm"
icon={<Plus className="size-4" />}
onClick={() => navigate('/simulation/ee')}
>
Nouvelle simulation
</Button>
</div>
</header>
<NclcHero
currentNclc={lastNclc}
nclcLabel="NCLC dernière simulation"
conseil={STD_CONSEIL}
lastScore={lastScore}
/>
<StatCards
plan="standard"
simulationsUsed={simulationsUsed}
simulationsRemaining={null}
recentSimulations={recent}
/>
<div className="grid gap-4 lg:grid-cols-[1fr_360px]">
<RecentSimulations items={recent} totalCount={totalCount} />
<NextStepCard
title="Travaillez l'Oral T1"
conseil="La présentation personnelle est souvent négligée. 10 minutes suffisent pour progresser."
tags={['10 min', 'Oral T1']}
ctaLabel="Commencer"
ctaTo="/simulation/eo"
/>
</div>
</div>
)
}

View file

@ -0,0 +1,117 @@
/**
* MonProfilPreparation Sprint 3.6c.
*
* Section compacte du Dashboard Premium qui résume l'analyse des patterns :
* - Premium + ready indice de préparation + « N erreurs récurrentes » + CTA
* - Premium + not-ready message compact « Encore X simulations »
* - Free + Standard ne rend rien (composant court-circuite)
*
* Le hook `usePatterns` court-circuite déjà la requête côté client si
* !hasAccess(plan, 'pattern_analysis'), donc aucun appel backend parasite
* pour Free/Standard. La garde locale ici empêche aussi un flash de contenu
* si le composant est monté par erreur.
*
* Règle D : gating via hasAccess, jamais `plan === 'premium'`.
* Règle L : tokens Direction H exclusivement.
*/
import { Link } from 'react-router-dom'
import { ArrowRight } from 'lucide-react'
import { Card } from '@/shared/ui/Card'
import { Button } from '@/shared/ui/Button'
import { hasAccess, type Plan } from '@/entities/user/lib'
import { usePatterns } from '@/features/progression/hooks/usePatterns'
interface Props {
plan: Plan
}
function gaugeColor(score: number): string {
if (score < 40) return 'bg-danger'
if (score <= 70) return 'bg-warning'
return 'bg-success'
}
export function MonProfilPreparation({ plan }: Props) {
// Hook appelé inconditionnellement (règle React). Il court-circuite la
// requête backend via `enabled: hasAccess(plan, 'pattern_analysis')`,
// donc aucun appel parasite pour Free/Standard.
const { data, isLoading, isError } = usePatterns(plan)
// Garde explicite après le hook pour éviter un flash de contenu.
if (!hasAccess(plan, 'pattern_analysis')) return null
if (isLoading || isError || !data) {
return (
<Card variant="default" className="p-4">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Mon profil de préparation
</p>
<p className="mt-2 text-sm text-ink-secondary">
{isError ? 'Profil temporairement indisponible.' : 'Chargement…'}
</p>
</Card>
)
}
if (!data.ready) {
const remaining = Math.max(0, data.minimum - data.current)
return (
<Card variant="default" className="space-y-2 p-4">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Mon profil de préparation
</p>
<p className="text-sm text-ink-primary">
Encore <span className="font-semibold tabular-nums">{remaining}</span>{' '}
{remaining > 1 ? 'simulations' : 'simulation'} pour débloquer votre profil.
</p>
<p className="text-xs text-ink-secondary tabular-nums">
{data.current}/{data.minimum} simulations corrigées
</p>
</Card>
)
}
const patternsCount = data.patterns.length
const pct = Math.max(0, Math.min(100, data.preparation_index.score))
const color = gaugeColor(pct)
return (
<Card variant="raised" className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Indice de préparation
</p>
<p className="tabular-nums text-ink-primary">
<span className="text-3xl font-bold">{data.preparation_index.score}</span>
<span className="text-lg font-medium text-ink-secondary">/100</span>
</p>
</div>
<p className="max-w-[180px] text-right text-xs text-ink-secondary">
{data.preparation_index.message}
</p>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-surface">
<div
className={`h-full transition-all duration-300 ${color}`}
style={{ width: `${pct}%` }}
/>
</div>
<p className="text-sm text-ink-primary">
{patternsCount === 0
? 'Aucune erreur récurrente identifiée — continuez !'
: `${patternsCount} ${patternsCount > 1 ? 'erreurs récurrentes identifiées' : 'erreur récurrente identifiée'}.`}
</p>
<Button variant="secondary" size="sm" className="w-full">
<Link to="/progression" className="-m-1 flex items-center justify-center gap-1.5 p-1">
Voir mon profil de préparation
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
</Button>
</Card>
)
}

View file

@ -0,0 +1,159 @@
/**
* NclcHero carte principale du Dashboard.
*
* Affiche :
* - l'indice NCLC courant (via valeur passée par le parent usePatterns
* en Premium, dernière simu en Standard, null en Free) ;
* - l'objectif NCLC (défaut 9) et le conseil personnalisé ;
* - la jauge horizontale 5 10 avec position actuelle + marqueur cible ;
* - le dernier score dans un anneau SVG (facultatif).
*
* Règle H : aucune logique métier les valeurs sont calculées par le parent.
* Règle L : tokens du design system exclusivement.
*/
import { Card } from '@/shared/ui/Card'
interface LastScore {
value: number
max: number
}
interface NclcHeroProps {
/** NCLC actuel (510). `null` = pas de donnée (Free ou historique vide). */
currentNclc: number | null
/** Libellé du NCLC (ex. "NCLC estimé", "NCLC dernière simulation"). */
nclcLabel?: string
/** NCLC cible (défaut 9). */
targetNclc?: number
/** Texte conseil affiché sous le NCLC. */
conseil: string
/** Dernier score pour l'anneau SVG (optionnel). */
lastScore?: LastScore | null
}
const NCLC_MIN = 5
const NCLC_MAX = 10
const CIRCLE_RADIUS = 44
const CIRCLE_CIRCUMFERENCE = 2 * Math.PI * CIRCLE_RADIUS
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
function formatNclc(n: number): string {
return n.toLocaleString('fr-FR', { maximumFractionDigits: 1 })
}
function nclcToPct(n: number): number {
const clamped = clamp(n, NCLC_MIN, NCLC_MAX)
return ((clamped - NCLC_MIN) / (NCLC_MAX - NCLC_MIN)) * 100
}
function ScoreRing({ score }: { score: LastScore }) {
const pct = clamp((score.value / score.max) * 100, 0, 100)
const offset = CIRCLE_CIRCUMFERENCE * (1 - pct / 100)
return (
<div className="relative size-[140px] shrink-0">
<svg className="size-full -rotate-90" viewBox="0 0 100 100" aria-hidden="true">
<circle
cx="50"
cy="50"
r={CIRCLE_RADIUS}
fill="none"
stroke="var(--color-border)"
strokeWidth="6"
/>
<circle
cx="50"
cy="50"
r={CIRCLE_RADIUS}
fill="none"
stroke="var(--color-success)"
strokeWidth="6"
strokeLinecap="round"
strokeDasharray={CIRCLE_CIRCUMFERENCE}
strokeDashoffset={offset}
className="transition-[stroke-dashoffset] duration-500"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-extrabold tabular-nums text-ink-primary">{score.value}</span>
<span className="text-xs text-ink-tertiary tabular-nums">/{score.max}</span>
<span className="mt-1 text-[10px] font-semibold uppercase tracking-wider text-ink-tertiary">
Dernier score
</span>
</div>
</div>
)
}
export function NclcHero({
currentNclc,
nclcLabel = 'NCLC estimé',
targetNclc = 9,
conseil,
lastScore = null,
}: NclcHeroProps) {
const hasNclc = currentNclc !== null
const currentPct = hasNclc ? nclcToPct(currentNclc) : 0
const targetPct = nclcToPct(targetNclc)
return (
<Card variant="raised" className="p-6 lg:p-8">
<div className="flex flex-col gap-8 lg:flex-row lg:items-start lg:justify-between">
{/* Left block */}
<div className="flex-1 space-y-4">
<p className="text-[11px] font-semibold uppercase tracking-wider text-ink-tertiary">
Indice de préparation TCF Canada
</p>
<div className="flex flex-wrap items-center gap-3">
<p className="text-display font-extrabold tabular-nums leading-none text-ink-primary">
{hasNclc ? `NCLC ${formatNclc(currentNclc)}` : 'NCLC —'}
</p>
<span className="inline-flex items-center rounded-full bg-success-soft px-2.5 py-0.5 text-xs font-semibold text-success">
Objectif NCLC {targetNclc}+
</span>
</div>
<p className="max-w-prose text-sm text-ink-secondary">{conseil}</p>
{/* Jauge 5 → 10 */}
<div className="space-y-1.5 pt-2">
<div className="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-ink-tertiary">
<span>NCLC {NCLC_MIN}</span>
<span>NCLC {NCLC_MAX}</span>
</div>
<div
className="relative h-2 rounded-full bg-surface-hover"
role="progressbar"
aria-valuemin={NCLC_MIN}
aria-valuemax={NCLC_MAX}
aria-valuenow={currentNclc ?? undefined}
aria-label={nclcLabel}
>
{hasNclc && (
<div
className="absolute inset-y-0 left-0 rounded-full bg-brand transition-[width] duration-500"
style={{ width: `${currentPct}%` }}
/>
)}
{/* Marqueur cible */}
<span
aria-hidden="true"
className="absolute -top-1 h-4 w-0.5 rounded-full bg-ink-primary"
style={{ left: `${targetPct}%` }}
title={`Cible NCLC ${targetNclc}`}
/>
</div>
</div>
</div>
{/* Right block — score ring */}
{lastScore && <ScoreRing score={lastScore} />}
</div>
</Card>
)
}

View file

@ -0,0 +1,65 @@
/**
* NextStepCard encart "Prochaine étape" affiché à droite des simulations.
*
* Contenu statique par plan pour ce sprint (pas d'endpoint "recommandation"
* en V1). Le parent construit le texte, les tags et la route CTA.
*
* Règle H : aucune logique métier affichage pur.
* Règle L : tokens du design system exclusivement.
*/
import { Link } from 'react-router-dom'
import { ArrowRight, Sparkles } from 'lucide-react'
import { Card } from '@/shared/ui/Card'
interface NextStepCardProps {
title: string
conseil: string
tags: readonly string[]
ctaLabel: string
ctaTo: string
}
export function NextStepCard({ title, conseil, tags, ctaLabel, ctaTo }: NextStepCardProps) {
return (
<Card variant="raised" className="flex h-full flex-col gap-4 p-5">
<p className="text-[11px] font-semibold uppercase tracking-wider text-brand-text">
Recommandé
</p>
<div className="flex items-start gap-2.5">
<span
aria-hidden="true"
className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand-text"
>
<Sparkles className="size-4" />
</span>
<div className="space-y-1">
<h3 className="text-base font-semibold text-ink-primary">{title}</h3>
<p className="text-sm text-ink-secondary">{conseil}</p>
</div>
</div>
{tags.length > 0 && (
<ul role="list" className="flex flex-wrap gap-1.5">
{tags.map((tag) => (
<li
key={tag}
className="inline-flex items-center rounded-full bg-surface-hover px-2 py-0.5 text-[11px] font-medium text-ink-secondary"
>
{tag}
</li>
))}
</ul>
)}
<Link
to={ctaTo}
className="mt-auto inline-flex h-9 items-center justify-center gap-1.5 rounded-md bg-brand px-4 text-sm font-semibold text-white transition-colors hover:bg-brand-hover focus-visible:outline-none focus-visible:shadow-focus"
>
{ctaLabel}
<ArrowRight className="size-4" aria-hidden="true" />
</Link>
</Card>
)
}

View file

@ -0,0 +1,42 @@
/**
* Bannière inline affichée en bas du dashboard pour les utilisateurs Free.
* Présente les features débloquées par Standard et oriente vers /plan.
*
* DA Charcoal : surface-solid + border-border, icône + dans cercle brand.
* Intégrée dans le flux de la page (pas de modale) cf. PARCOURS_UTILISATEURS §2.
*/
import { Link } from 'react-router-dom'
import { Plus } from 'lucide-react'
export function PaywallBanner() {
return (
<section
aria-label="Proposition d'upgrade"
className="flex flex-col items-start gap-4 rounded-[var(--radius-md)] border border-border bg-surface-solid p-5 sm:flex-row sm:items-center"
>
<span
aria-hidden="true"
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-brand text-white"
>
<Plus className="size-5" />
</span>
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-semibold text-ink-primary">
Débloque le rapport complet et l'IA de correction détaillée
</p>
<p className="text-xs text-ink-secondary">
Plan Standard · simulations illimitées · suivi NCLC dans le temps · 19,90 / 4 semaines
</p>
</div>
<Link
to="/plan"
className="inline-flex h-9 shrink-0 items-center justify-center rounded-md border border-border bg-surface px-4 text-sm font-semibold text-ink-primary transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus"
>
Voir les plans
</Link>
</section>
)
}

View file

@ -0,0 +1,97 @@
/**
* RecentSimulations liste des 3 dernières simulations sur le Dashboard.
*
* Chaque item est cliquable ( /rapport/:id). Badge NCLC coloré selon le niveau,
* score /20, date relative, type court (EE · T2 / EO · T1).
*
* Règle H : aucune logique métier les données viennent du parent.
* Règle L : tokens du design system exclusivement.
*/
import { Link } from 'react-router-dom'
import { ChevronRight } from 'lucide-react'
import { Card } from '@/shared/ui/Card'
import { formatRelativeDate } from '@/shared/lib/date'
import { isEcrit } from '@/entities/production/lib'
import type { SimulationListItem, Tache } from '@/entities/production/types'
interface RecentSimulationsProps {
/** Items récents (max 3 affichés). */
items: readonly SimulationListItem[]
/** Total historique — affiché en badge à droite du titre. */
totalCount: number
}
function shortTacheLabel(tache: Tache): string {
const [prefix, num] = tache.split('_')
return `${prefix} · ${num}`
}
function nclcBadgeClasses(nclc: number | null): string {
if (nclc === null) return 'bg-surface-hover text-ink-tertiary'
if (nclc >= 9) return 'bg-success-soft text-success'
if (nclc >= 7) return 'bg-brand-soft text-brand-text'
return 'bg-warning-soft text-warning'
}
function formatNclc(n: number): string {
return n.toLocaleString('fr-FR', { maximumFractionDigits: 1 })
}
export function RecentSimulations({ items, totalCount }: RecentSimulationsProps) {
const visible = items.slice(0, 3)
return (
<section aria-label="Simulations récentes" className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-ink-primary">3 dernières simulations</h2>
{totalCount > 0 && (
<span className="inline-flex items-center rounded-full bg-surface-hover px-2.5 py-0.5 text-[11px] font-semibold text-ink-secondary">
{totalCount} au total
</span>
)}
</div>
{visible.length === 0 ? (
<Card variant="default" className="p-6 text-center">
<p className="text-sm text-ink-secondary">Aucune simulation pour l'instant.</p>
</Card>
) : (
<Card variant="default" className="divide-y divide-[var(--color-border)] p-0">
{visible.map((item) => {
const type = isEcrit(item.tache) ? 'EE' : 'EO'
return (
<Link
key={item.id}
to={`/rapport/${item.id}`}
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus"
>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="truncate text-sm font-medium text-ink-primary">
<span className="font-semibold">{shortTacheLabel(item.tache)}</span>
<span className="text-ink-tertiary"> · {type}</span>
</p>
<p className="text-xs text-ink-tertiary">{formatRelativeDate(item.created_at)}</p>
</div>
{item.nclc !== null && (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold tabular-nums ${nclcBadgeClasses(item.nclc)}`}
>
NCLC {formatNclc(item.nclc)}
</span>
)}
<span className="hidden tabular-nums text-sm font-semibold text-ink-primary sm:inline">
{item.score === null ? '—' : `${item.score}/20`}
</span>
<ChevronRight className="size-4 shrink-0 text-ink-tertiary" aria-hidden="true" />
</Link>
)
})}
</Card>
)}
</section>
)
}

View file

@ -0,0 +1,190 @@
/**
* StatCards trois cartes synthétiques affichées sur le Dashboard.
*
* - Simulations restantes (barre de progression pour Free, "Illimitées" ailleurs)
* - NCLC estimé (dernière simulation)
* - Dernier score (+ delta vs précédent)
*
* Règle H : aucune logique métier de gating ici le parent décide du rendu
* global via hasAccess. Ce composant ne fait que formater les
* valeurs déjà fournies.
* Règle L : tokens du design system exclusivement.
*/
import { Card } from '@/shared/ui/Card'
import { formatRelativeDate } from '@/shared/lib/date'
import { isEcrit } from '@/entities/production/lib'
import type { SimulationListItem } from '@/entities/production/types'
import { hasAccess, type Plan } from '@/entities/user/lib'
interface StatCardsProps {
plan: Plan
simulationsUsed: number
/** null = illimité (Standard/Premium), number = reste (Free). */
simulationsRemaining: number | null
/** Liste des dernières simulations (index 0 = la plus récente). */
recentSimulations: readonly SimulationListItem[]
}
function formatNclc(n: number): string {
return n.toLocaleString('fr-FR', { maximumFractionDigits: 1 })
}
function formatScore(value: number): string {
return value.toLocaleString('fr-FR', { maximumFractionDigits: 1 })
}
function StatShell({ label, children }: { label: string; children: React.ReactNode }) {
return (
<Card variant="default" className="p-4">
<p className="text-[11px] font-semibold uppercase tracking-wider text-ink-tertiary">
{label}
</p>
<div className="mt-2 space-y-2">{children}</div>
</Card>
)
}
function SimulationsRestantesCard({
plan,
simulationsUsed,
simulationsRemaining,
}: {
plan: Plan
simulationsUsed: number
simulationsRemaining: number | null
}) {
if (simulationsRemaining === null) {
return (
<StatShell label="Simulations">
<p className="text-2xl font-extrabold tabular-nums text-ink-primary">Illimitées</p>
<p className="text-xs text-ink-secondary">
{simulationsUsed} effectuée{simulationsUsed > 1 ? 's' : ''}
</p>
</StatShell>
)
}
const total = simulationsUsed + simulationsRemaining
const pct = total > 0 ? (simulationsUsed / total) * 100 : 0
return (
<StatShell label="Simulations restantes">
<p className="tabular-nums text-ink-primary">
<span className="text-2xl font-extrabold">{simulationsRemaining}</span>
<span className="text-lg font-medium text-ink-secondary">/{total}</span>
</p>
<div
className="h-1.5 overflow-hidden rounded-full bg-surface-hover"
role="progressbar"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={simulationsUsed}
aria-label="Simulations utilisées"
>
<div
className="h-full bg-brand transition-[width] duration-500"
style={{ width: `${pct}%` }}
/>
</div>
{!hasAccess(plan, 'dashboard') && (
<p className="text-xs text-ink-tertiary">Renouvellement offert à l'upgrade</p>
)}
</StatShell>
)
}
function NclcCard({ lastSim }: { lastSim: SimulationListItem | null }) {
if (!lastSim || lastSim.nclc === null) {
return (
<StatShell label="NCLC estimé">
<p className="text-2xl font-extrabold tabular-nums text-ink-primary"></p>
<p className="text-xs text-ink-tertiary">
Démarrez une simulation pour estimer votre niveau.
</p>
</StatShell>
)
}
const nclc = lastSim.nclc
const inTarget = nclc >= 7
return (
<StatShell label="NCLC estimé">
<p className="text-2xl font-extrabold tabular-nums text-ink-primary">{formatNclc(nclc)}</p>
<span className="inline-flex items-center rounded-full bg-brand-soft px-2.5 py-0.5 text-[11px] font-semibold text-brand-text">
{inTarget ? 'Dans la cible CLB 7+' : 'Visez la cible CLB 7+'}
</span>
</StatShell>
)
}
function DernierScoreCard({
recentSimulations,
}: {
recentSimulations: readonly SimulationListItem[]
}) {
const lastWithScore = recentSimulations.find((s) => s.score !== null) ?? null
if (!lastWithScore || lastWithScore.score === null) {
return (
<StatShell label="Dernier score">
<p className="text-2xl font-extrabold tabular-nums text-ink-primary"></p>
<p className="text-xs text-ink-tertiary">Aucun score enregistré.</p>
</StatShell>
)
}
// Précédente simulation avec score, pour calculer le delta.
const previous =
recentSimulations.filter((s) => s.id !== lastWithScore.id && s.score !== null).at(0) ?? null
const delta = previous && previous.score !== null ? lastWithScore.score - previous.score : null
const type = isEcrit(lastWithScore.tache) ? 'Écrit' : 'Oral'
const relative = formatRelativeDate(lastWithScore.created_at)
return (
<StatShell label="Dernier score">
<p className="tabular-nums text-ink-primary">
<span className="text-2xl font-extrabold">{formatScore(lastWithScore.score)}</span>
<span className="text-lg font-medium text-ink-secondary">/20</span>
</p>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-secondary">
<span>{type}</span>
<span aria-hidden="true" className="text-ink-tertiary">
·
</span>
<span>{relative}</span>
{delta !== null && delta !== 0 && (
<span className={delta > 0 ? 'font-semibold text-success' : 'font-semibold text-warning'}>
{delta > 0 ? '+' : ''}
{formatScore(delta)} vs précédent
</span>
)}
</div>
</StatShell>
)
}
export function StatCards({
plan,
simulationsUsed,
simulationsRemaining,
recentSimulations,
}: StatCardsProps) {
const lastSim = recentSimulations.at(0) ?? null
return (
<section
aria-label="Indicateurs de préparation"
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
<SimulationsRestantesCard
plan={plan}
simulationsUsed={simulationsUsed}
simulationsRemaining={simulationsRemaining}
/>
<NclcCard lastSim={lastSim} />
<DernierScoreCard recentSimulations={recentSimulations} />
</section>
)
}

View file

@ -0,0 +1,41 @@
/**
* Sprint 5c Banner affiché au retour de Stripe Checkout réussi.
*
* Présentationnel pur (Règle H). Le déclenchement et le nettoyage URL sont
* gérés par `useUpgradeSuccessHandler` côté DashboardPage.
*
* Tokens DA Charcoal exclusivement (Règle L).
*/
import { CheckCircle2, X } from 'lucide-react'
interface Props {
onDismiss: () => void
}
export function UpgradeSuccessBanner({ onDismiss }: Props) {
return (
<div
role="status"
aria-live="polite"
className="mb-6 flex items-start gap-3 rounded-[var(--radius-md)] border border-success/30 bg-success-soft p-4 text-sm"
>
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-success" aria-hidden="true" />
<div className="flex-1">
<p className="font-semibold text-ink-primary">Bienvenue ! Votre plan a é mis à jour.</p>
<p className="mt-0.5 text-xs text-ink-secondary">
Si certaines features semblent manquer, rafraîchissez la page dans quelques secondes la
confirmation Stripe peut prendre un instant.
</p>
</div>
<button
type="button"
onClick={onDismiss}
aria-label="Fermer le message"
className="shrink-0 rounded-md p-1 text-ink-tertiary transition-colors hover:bg-surface-hover hover:text-ink-secondary focus-visible:outline-none focus-visible:shadow-focus"
>
<X className="size-4" aria-hidden="true" />
</button>
</div>
)
}

View file

@ -0,0 +1,151 @@
/**
* Tests MonProfilPreparation (Sprint 3.6c).
*
* Couvre le gating plan : absent Free/Standard, visible Premium (ready + not-ready).
* Le hook `usePatterns` est mocké pour isoler la présentation.
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
vi.mock('@/features/progression/hooks/usePatterns', () => ({
usePatterns: vi.fn(),
}))
import { usePatterns } from '@/features/progression/hooks/usePatterns'
import { MonProfilPreparation } from '../MonProfilPreparation'
beforeEach(() => {
// Mock par défaut — usePatterns est appelé inconditionnellement depuis le
// composant (Règle des hooks). Les tests Premium surchargent ce mock.
vi.mocked(usePatterns).mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
} as unknown as ReturnType<typeof usePatterns>)
})
afterEach(cleanup)
function renderWithRouter(ui: React.ReactNode) {
return render(<MemoryRouter>{ui}</MemoryRouter>)
}
describe('MonProfilPreparation — gating plan', () => {
it('plan free → ne rend rien', () => {
const { container } = renderWithRouter(<MonProfilPreparation plan="free" />)
expect(container).toBeEmptyDOMElement()
})
it('plan standard → ne rend rien', () => {
const { container } = renderWithRouter(<MonProfilPreparation plan="standard" />)
expect(container).toBeEmptyDOMElement()
})
})
describe('MonProfilPreparation — plan premium', () => {
it('ready: true → affiche score, message, nb patterns, CTA /progression', () => {
vi.mocked(usePatterns).mockReturnValue({
data: {
ready: true,
patterns: [
{
code: 'accord_sujet_verbe',
critere: 'competence_grammaticale',
frequency: 4,
description: null,
},
{
code: 'connecteurs_repetes',
critere: 'coherence_cohesion',
frequency: 3,
description: null,
},
{
code: 'repetition_lexicale',
critere: 'competence_lexicale',
frequency: 3,
description: null,
},
],
exercises: [],
preparation_index: { score: 72, message: 'Vous êtes en bonne voie pour NCLC 9+' },
analyzed_productions: 5,
last_analysis: '2026-04-22T12:00:00Z',
},
isLoading: false,
isError: false,
} as unknown as ReturnType<typeof usePatterns>)
renderWithRouter(<MonProfilPreparation plan="premium" />)
expect(screen.getByText('72')).toBeInTheDocument()
expect(screen.getByText(/NCLC 9/)).toBeInTheDocument()
expect(screen.getByText(/3 erreurs récurrentes identifiées/i)).toBeInTheDocument()
expect(screen.getByRole('link', { name: /voir mon profil de préparation/i })).toHaveAttribute(
'href',
'/progression',
)
})
it('ready: false → message compact "Encore X simulations"', () => {
vi.mocked(usePatterns).mockReturnValue({
data: { ready: false, minimum: 5, current: 2 },
isLoading: false,
isError: false,
} as unknown as ReturnType<typeof usePatterns>)
renderWithRouter(<MonProfilPreparation plan="premium" />)
expect(screen.getByText(/encore/i)).toBeInTheDocument()
// Le nombre restant (3) est dans un span séparé du mot "simulations"
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.getByText(/pour débloquer votre profil/i)).toBeInTheDocument()
expect(screen.getByText(/2\/5 simulations corrigées/i)).toBeInTheDocument()
})
it('isLoading → placeholder "Chargement"', () => {
vi.mocked(usePatterns).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
} as unknown as ReturnType<typeof usePatterns>)
renderWithRouter(<MonProfilPreparation plan="premium" />)
expect(screen.getByText(/chargement/i)).toBeInTheDocument()
})
it('isError → message "temporairement indisponible"', () => {
vi.mocked(usePatterns).mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
} as unknown as ReturnType<typeof usePatterns>)
renderWithRouter(<MonProfilPreparation plan="premium" />)
expect(screen.getByText(/temporairement indisponible/i)).toBeInTheDocument()
})
it('ready: true avec 0 pattern → message "Aucune erreur récurrente"', () => {
vi.mocked(usePatterns).mockReturnValue({
data: {
ready: true,
patterns: [],
exercises: [],
preparation_index: { score: 85, message: 'Vous êtes en bonne voie pour NCLC 9+' },
analyzed_productions: 5,
last_analysis: '2026-04-22T12:00:00Z',
},
isLoading: false,
isError: false,
} as unknown as ReturnType<typeof usePatterns>)
renderWithRouter(<MonProfilPreparation plan="premium" />)
expect(screen.getByText(/aucune erreur récurrente/i)).toBeInTheDocument()
expect(screen.getByText('85')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { ReactNode } from 'react'
import { PLAN_QUERY_KEY } from '@/entities/user/query-keys'
import { useUpgradeSuccessHandler } from '../useUpgradeSuccessHandler'
let invalidateSpy: ReturnType<typeof vi.fn>
let queryClient: QueryClient
function wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
function setLocation(search: string) {
window.history.replaceState(null, '', `/dashboard${search}`)
}
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
invalidateSpy = vi.fn().mockResolvedValue(undefined)
// Spy sur la méthode invalidateQueries pour vérifier la clé exacte.
queryClient.invalidateQueries = invalidateSpy as unknown as typeof queryClient.invalidateQueries
})
afterEach(() => {
setLocation('')
})
describe('useUpgradeSuccessHandler', () => {
it('?upgrade=success → showSuccess=true, invalidate(PLAN_QUERY_KEY) appelé, URL nettoyée', () => {
setLocation('?upgrade=success')
const { result } = renderHook(() => useUpgradeSuccessHandler(), { wrapper })
expect(result.current.showSuccess).toBe(true)
expect(invalidateSpy).toHaveBeenCalledTimes(1)
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: PLAN_QUERY_KEY })
// URL nettoyée : plus de `upgrade` dans la query string.
expect(window.location.search).toBe('')
})
it('absence de query param → showSuccess=false, invalidate non appelé', () => {
setLocation('')
const { result } = renderHook(() => useUpgradeSuccessHandler(), { wrapper })
expect(result.current.showSuccess).toBe(false)
expect(invalidateSpy).not.toHaveBeenCalled()
})
it("?upgrade=cancelled (autre valeur) → showSuccess=false, pas d'action", () => {
setLocation('?upgrade=cancelled')
const { result } = renderHook(() => useUpgradeSuccessHandler(), { wrapper })
expect(result.current.showSuccess).toBe(false)
expect(invalidateSpy).not.toHaveBeenCalled()
// URL conservée intacte (autre valeur, hors scope du nettoyage).
expect(window.location.search).toBe('?upgrade=cancelled')
})
it('dismiss() bascule showSuccess à false', () => {
setLocation('?upgrade=success')
const { result } = renderHook(() => useUpgradeSuccessHandler(), { wrapper })
expect(result.current.showSuccess).toBe(true)
act(() => {
result.current.dismiss()
})
expect(result.current.showSuccess).toBe(false)
})
it('conserve les autres query params (utm_*, etc.) lors du nettoyage', () => {
setLocation('?upgrade=success&utm_source=email&ref=abc')
renderHook(() => useUpgradeSuccessHandler(), { wrapper })
const params = new URLSearchParams(window.location.search)
expect(params.has('upgrade')).toBe(false)
expect(params.get('utm_source')).toBe('email')
expect(params.get('ref')).toBe('abc')
})
})

View file

@ -0,0 +1,25 @@
/**
* Hook TanStack Query sur le statut du plan utilisateur.
*
* Source unique de vérité côté frontend pour `plan`, `permissions`, et
* compteurs de simulations. Consommé par `DashboardPage`, les gardes de
* permission dans les pages simulations/t2-live, et le router.
*
* `staleTime: 5 min` le plan change peu (upgrade Stripe, expiration). Les
* flux d'upgrade appellent `queryClient.invalidateQueries(['plan'])` pour
* forcer un refetch immédiat après webhook.
*/
import { useQuery } from '@tanstack/react-query'
import { getPlanStatus } from '@/entities/user/api'
import { PLAN_QUERY_KEY } from '@/entities/user/query-keys'
export { PLAN_QUERY_KEY }
export function usePlan() {
return useQuery({
queryKey: PLAN_QUERY_KEY,
queryFn: getPlanStatus,
staleTime: 5 * 60 * 1000,
})
}

View file

@ -0,0 +1,60 @@
/**
* Sprint 5c Détection retour Stripe Checkout réussi.
*
* Lit `?upgrade=success` au mount de la page Dashboard, déclenche :
* 1. invalidation du cache plan (`PLAN_QUERY_KEY`) refetch automatique
* du plan mis à jour par le webhook backend `checkout.session.completed`,
* 2. affichage d'un banner de succès (consommé par DashboardPage),
* 3. nettoyage du query param via `history.replaceState` (un refresh ne
* doit pas re-déclencher le banner).
*
* Indépendant de react-router (lit `window.location.search` directement)
* pour faciliter les tests sans MemoryRouter.
*
* Race connue (Sprint 5c) : le webhook Stripe peut arriver après le
* redirect frontend (latence ~1-3 s). Si l'invalidation refetch trop tôt,
* `usePlan()` retourne encore l'ancien plan. Mitigation MVP : message
* neutre + refresh manuel résoud. Polling/retry à tracer en FTD si
* problème observé en production.
*/
import { useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { PLAN_QUERY_KEY } from '@/entities/user/query-keys'
export interface UseUpgradeSuccessHandlerResult {
showSuccess: boolean
dismiss: () => void
}
const QUERY_PARAM = 'upgrade'
const SUCCESS_VALUE = 'success'
export function useUpgradeSuccessHandler(): UseUpgradeSuccessHandlerResult {
const [showSuccess, setShowSuccess] = useState(false)
const queryClient = useQueryClient()
useEffect(() => {
if (typeof window === 'undefined') return
const params = new URLSearchParams(window.location.search)
if (params.get(QUERY_PARAM) !== SUCCESS_VALUE) return
setShowSuccess(true)
void queryClient.invalidateQueries({ queryKey: PLAN_QUERY_KEY })
// Nettoyage URL : retire UNIQUEMENT le param `upgrade`, conserve les autres
// (utm_*, etc.). `replaceState` ne déclenche pas de remount React Router.
params.delete(QUERY_PARAM)
const remaining = params.toString()
const newSearch = remaining.length > 0 ? `?${remaining}` : ''
const newUrl = window.location.pathname + newSearch + window.location.hash
window.history.replaceState(null, '', newUrl)
}, [queryClient])
function dismiss(): void {
setShowSuccess(false)
}
return { showSuccess, dismiss }
}

View file

@ -0,0 +1,102 @@
/**
* DashboardPage orchestrateur minimal : charge le plan et route vers
* la vue appropriée (Free / Standard / Premium).
*
* Le routing par plan passe exclusivement par `hasAccess()` jamais de
* `plan === '...'` (Règles D et H).
*/
import { useQueryClient } from '@tanstack/react-query'
import { Button } from '@/shared/ui/Button'
import { hasAccess, canSimulate } from '@/entities/user/lib'
import { useAuth } from '@/features/auth/hooks/useAuth'
import { usePlan, PLAN_QUERY_KEY } from '../hooks/usePlan'
import { useUpgradeSuccessHandler } from '../hooks/useUpgradeSuccessHandler'
import { DashboardFreeView } from '../components/DashboardFreeView'
import { DashboardStandardView } from '../components/DashboardStandardView'
import { DashboardPremiumView } from '../components/DashboardPremiumView'
import { UpgradeSuccessBanner } from '../components/UpgradeSuccessBanner'
function getDisplayName(
user: { user_metadata?: { full_name?: string }; email?: string } | null,
): string {
const fullName = user?.user_metadata?.full_name
if (fullName) return fullName.split(' ')[0]
const email = user?.email
if (email) return email.split('@')[0]
return 'vous'
}
function DashboardSkeleton() {
return (
<div className="space-y-6" aria-busy="true" aria-label="Chargement du tableau de bord">
<div className="h-9 w-64 animate-pulse rounded-md bg-surface" />
<div className="h-48 animate-pulse rounded-lg bg-surface" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="h-24 animate-pulse rounded-lg bg-surface" />
<div className="h-24 animate-pulse rounded-lg bg-surface" />
<div className="h-24 animate-pulse rounded-lg bg-surface" />
</div>
</div>
)
}
function DashboardContent() {
const { user } = useAuth()
const { data, isLoading, isError } = usePlan()
const queryClient = useQueryClient()
if (isLoading) return <DashboardSkeleton />
if (isError || !data) {
return (
<div className="space-y-3 text-center">
<p className="text-sm text-danger">
Impossible de charger votre tableau de bord. Réessayez dans quelques instants.
</p>
<Button
variant="secondary"
size="sm"
onClick={() => queryClient.refetchQueries({ queryKey: PLAN_QUERY_KEY })}
>
Réessayer
</Button>
</div>
)
}
const displayName = getDisplayName(user)
const plan = data.plan
// Route : Free → preview ; Premium (pattern_analysis) → full ; sinon Standard.
if (!hasAccess(plan, 'dashboard')) {
const simulationsRemaining = data.simulations_remaining ?? 0
const canStart = canSimulate(plan, data.simulations_used).allowed
return (
<DashboardFreeView
displayName={displayName}
simulationsUsed={data.simulations_used}
simulationsRemaining={simulationsRemaining}
canStartSimulation={canStart}
/>
)
}
if (hasAccess(plan, 'pattern_analysis')) {
return (
<DashboardPremiumView displayName={displayName} simulationsUsed={data.simulations_used} />
)
}
return <DashboardStandardView displayName={displayName} simulationsUsed={data.simulations_used} />
}
export function DashboardPage() {
const { showSuccess, dismiss } = useUpgradeSuccessHandler()
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
{showSuccess && <UpgradeSuccessBanner onDismiss={dismiss} />}
<DashboardContent />
</div>
)
}

View file

@ -23,32 +23,173 @@ import {
DialogTrigger,
} from '@/shared/components/ui/dialog'
// ─── palette data ────────────────────────────────────────────────────────────
// ─── palette data — DA Charcoal ──────────────────────────────────────────────
const PALETTE: { token: string; var: string; light: string; dark: string }[] = [
{ token: 'canvas', var: '--color-canvas', light: '#EEF2F8', dark: '#0D1220' },
{ token: 'canvas-2', var: '--color-canvas-2', light: '#E6EBF4', dark: '#121A2D' },
{ token: 'surface', var: '--color-surface', light: '#FFFFFF', dark: '#182238' },
{ token: 'surface-hover',var: '--color-surface-hover',light: '#F8FAFD', dark: '#1E2A42' },
{ token: 'line', var: '--color-line', light: '#DDE3ED', dark: '#27324B' },
{ token: 'line-strong', var: '--color-line-strong', light: '#C7D0E0', dark: '#364363' },
{ token: 'ink-1', var: '--color-ink-1', light: '#0F172A', dark: '#F1F4FA' },
{ token: 'ink-2', var: '--color-ink-2', light: '#1E293B', dark: '#DDE3EF' },
{ token: 'ink-3', var: '--color-ink-3', light: '#475569', dark: '#A8B2C7' },
{ token: 'ink-4', var: '--color-ink-4', light: '#64748B', dark: '#7A8499' },
{ token: 'ink-5', var: '--color-ink-5', light: '#94A3B8', dark: '#525C73' },
{ token: 'expria', var: '--color-expria', light: '#1B4FD8', dark: '#5B7FFF' },
{ token: 'expria-hover', var: '--color-expria-hover', light: '#1741B8', dark: '#6F8EFF' },
{ token: 'expria-50', var: '--color-expria-50', light: '#EEF3FF', dark: 'rgba(91,127,255,.12)' },
{ token: 'expria-100', var: '--color-expria-100', light: '#DCE6FF', dark: '—' },
{ token: 'expria-200', var: '--color-expria-200', light: '#B8CDFF', dark: '—' },
{ token: 'deep', var: '--color-deep', light: '#0B1F5C', dark: '#060B1A' },
{ token: 'success', var: '--color-success', light: '#0E9F6E', dark: '#3DD68C' },
{ token: 'success-bg', var: '--color-success-bg', light: '#E6F6F0', dark: 'rgba(61,214,140,.12)' },
{ token: 'warning', var: '--color-warning', light: '#C77A00', dark: '#F5B849' },
{ token: 'warning-bg', var: '--color-warning-bg', light: '#FEF3E2', dark: 'rgba(245,184,73,.12)' },
{ token: 'danger', var: '--color-danger', light: '#C53030', dark: '#F06B6B' },
{ token: 'danger-bg', var: '--color-danger-bg', light: '#FDECEC', dark: 'rgba(240,107,107,.12)' },
interface PaletteEntry {
token: string
cssVar: string
dark: string
light: string
group: 'Invariants' | 'Dark default' | 'Light override'
}
const PALETTE: PaletteEntry[] = [
// Invariants
{
token: 'sidebar-bg',
cssVar: '--color-sidebar-bg',
dark: '#0C1528',
light: '#0C1528',
group: 'Invariants',
},
{
token: 'brand',
cssVar: '--color-brand',
dark: '#1B4FD8',
light: '#1B4FD8',
group: 'Invariants',
},
{
token: 'brand-hover',
cssVar: '--color-brand-hover',
dark: '#1744B8',
light: '#1744B8',
group: 'Invariants',
},
{
token: 'brand-active',
cssVar: '--color-brand-active',
dark: '#13379C',
light: '#13379C',
group: 'Invariants',
},
{
token: 'warning',
cssVar: '--color-warning',
dark: '#F59E0B',
light: '#F59E0B',
group: 'Invariants',
},
{
token: 'danger',
cssVar: '--color-danger',
dark: '#EF4444',
light: '#EF4444',
group: 'Invariants',
},
// Dual-theme (valeurs différentes dark/light)
{
token: 'canvas',
cssVar: '--color-canvas',
dark: '#111111',
light: '#F3F4F6',
group: 'Dark default',
},
{
token: 'surface',
cssVar: '--color-surface',
dark: 'rgba(255,255,255,.035)',
light: '#FFFFFF',
group: 'Dark default',
},
{
token: 'surface-hover',
cssVar: '--color-surface-hover',
dark: 'rgba(255,255,255,.055)',
light: '#F8F9FB',
group: 'Dark default',
},
{
token: 'surface-solid',
cssVar: '--color-surface-solid',
dark: '#1E1E1E',
light: '#FFFFFF',
group: 'Dark default',
},
{
token: 'surface-raised',
cssVar: '--color-surface-raised',
dark: '#222222',
light: '#FFFFFF',
group: 'Dark default',
},
{
token: 'border',
cssVar: '--color-border',
dark: 'rgba(255,255,255,.06)',
light: 'rgba(0,0,0,.07)',
group: 'Dark default',
},
{
token: 'border-strong',
cssVar: '--color-border-strong',
dark: 'rgba(255,255,255,.12)',
light: 'rgba(0,0,0,.14)',
group: 'Dark default',
},
{
token: 'ink-primary',
cssVar: '--color-ink-primary',
dark: '#E5E5E5',
light: '#0F0F1A',
group: 'Dark default',
},
{
token: 'ink-secondary',
cssVar: '--color-ink-secondary',
dark: 'rgba(255,255,255,.55)',
light: 'rgba(0,0,0,.55)',
group: 'Dark default',
},
{
token: 'ink-tertiary',
cssVar: '--color-ink-tertiary',
dark: 'rgba(255,255,255,.3)',
light: 'rgba(0,0,0,.3)',
group: 'Dark default',
},
{
token: 'brand-soft',
cssVar: '--color-brand-soft',
dark: 'rgba(27,79,216,.1)',
light: 'rgba(27,79,216,.06)',
group: 'Dark default',
},
{
token: 'brand-text',
cssVar: '--color-brand-text',
dark: '#7DA4F0',
light: '#1B4FD8',
group: 'Dark default',
},
{
token: 'success',
cssVar: '--color-success',
dark: '#4ADE80',
light: '#16A34A',
group: 'Dark default',
},
{
token: 'success-soft',
cssVar: '--color-success-soft',
dark: 'rgba(74,222,128,.12)',
light: 'rgba(22,163,74,.1)',
group: 'Dark default',
},
{
token: 'warning-soft',
cssVar: '--color-warning-soft',
dark: 'rgba(245,158,11,.12)',
light: 'rgba(245,158,11,.12)',
group: 'Dark default',
},
{
token: 'danger-soft',
cssVar: '--color-danger-soft',
dark: 'rgba(239,68,68,.12)',
light: 'rgba(239,68,68,.12)',
group: 'Dark default',
},
]
// ─── section wrapper ─────────────────────────────────────────────────────────
@ -56,7 +197,9 @@ const PALETTE: { token: string; var: string; light: string; dark: string }[] = [
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="space-y-4">
<h2 className="text-base font-semibold text-ink-3 uppercase tracking-wider">{title}</h2>
<h2 className="text-base font-semibold uppercase tracking-wider text-ink-secondary">
{title}
</h2>
<Separator />
{children}
</section>
@ -70,13 +213,14 @@ export default function DesignSystemPage() {
const [dialogOpen, setDialogOpen] = useState(false)
return (
<div className="min-h-screen bg-canvas px-6 py-10 space-y-14">
<div className="min-h-screen space-y-14 bg-canvas px-6 py-10">
{/* ── header ── */}
<header className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-ink-1">Design System</h1>
<p className="text-sm text-ink-4 mt-0.5">Expria Direction H palette · Sprint 0.5</p>
<h1 className="text-2xl font-bold text-ink-primary">Design System</h1>
<p className="mt-0.5 text-sm text-ink-secondary">
Expria DA Charcoal · dark-default + light override
</p>
</div>
<Button
variant="outline"
@ -89,21 +233,17 @@ export default function DesignSystemPage() {
{/* ── palette ── */}
<Section title="Palette">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{PALETTE.map(({ token, var: cssVar, light, dark }) => (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{PALETTE.map(({ token, cssVar, light, dark }) => (
<div key={token} className="flex flex-col gap-1.5">
<div
className="h-12 w-full rounded-md border border-line shadow-sm"
className="h-12 w-full rounded-md border border-border shadow-card"
style={{ background: `var(${cssVar})` }}
/>
<div className="space-y-0.5">
<p className="text-xs font-mono font-medium text-ink-2">{token}</p>
<p className="text-xs font-mono text-ink-4 leading-tight">
{light}
</p>
<p className="text-xs font-mono text-ink-4 leading-tight">
{dark}
</p>
<p className="font-mono text-xs font-medium text-ink-primary">{token}</p>
<p className="font-mono text-xs leading-tight text-ink-secondary"> {dark}</p>
<p className="font-mono text-xs leading-tight text-ink-secondary"> {light}</p>
</div>
</div>
))}
@ -112,22 +252,22 @@ export default function DesignSystemPage() {
{/* ── typography ── */}
<Section title="Typography">
<div className="space-y-3 bg-surface rounded-lg p-6 border border-line">
<p className="text-4xl font-bold text-ink-1">Display / 36px Bold</p>
<p className="text-2xl font-semibold text-ink-1">Heading 1 / 24px Semibold</p>
<p className="text-xl font-semibold text-ink-1">Heading 2 / 20px Semibold</p>
<p className="text-lg font-medium text-ink-2">Heading 3 / 18px Medium</p>
<p className="text-base text-ink-2">Body / 16px Regular Plus Jakarta Sans</p>
<p className="text-sm text-ink-3">Small / 14px Regular secondary copy</p>
<p className="text-xs text-ink-4">Caption / 12px Regular labels, metadata</p>
<p className="text-xs font-mono text-ink-3">Mono / 12px token names, code</p>
<div className="space-y-3 rounded-lg border border-border bg-surface p-6">
<p className="text-4xl font-bold text-ink-primary">Display / 40px Bold</p>
<p className="text-2xl font-semibold text-ink-primary">Heading 1 / 24px Semibold</p>
<p className="text-xl font-semibold text-ink-primary">Heading 2 / 20px Semibold</p>
<p className="text-lg font-medium text-ink-primary">Heading 3 / 17px Medium</p>
<p className="text-base text-ink-primary">Body / 14px Regular Plus Jakarta Sans</p>
<p className="text-sm text-ink-secondary">Small / 13px Regular secondary copy</p>
<p className="text-xs text-ink-tertiary">Caption / 11px Regular labels, metadata</p>
<p className="font-mono text-xs text-ink-secondary">Mono / 11px token names, code</p>
</div>
</Section>
{/* ── buttons ── */}
<Section title="Button">
<div className="space-y-4 bg-surface rounded-lg p-6 border border-line">
<div className="flex flex-wrap gap-2 items-center">
<div className="space-y-4 rounded-lg border border-border bg-surface p-6">
<div className="flex flex-wrap items-center gap-2">
<Button>Default</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
@ -135,22 +275,24 @@ export default function DesignSystemPage() {
<Button variant="destructive">Destructive</Button>
<Button variant="link">Link</Button>
</div>
<div className="flex flex-wrap gap-2 items-center">
<div className="flex flex-wrap items-center gap-2">
<Button size="lg">Large</Button>
<Button>Default</Button>
<Button size="sm">Small</Button>
<Button size="icon">+</Button>
</div>
<div className="flex flex-wrap gap-2 items-center">
<div className="flex flex-wrap items-center gap-2">
<Button disabled>Disabled</Button>
<Button variant="outline" disabled>Outline disabled</Button>
<Button variant="outline" disabled>
Outline disabled
</Button>
</div>
</div>
</Section>
{/* ── badges ── */}
<Section title="Badge">
<div className="flex flex-wrap gap-2 bg-surface rounded-lg p-6 border border-line">
<div className="flex flex-wrap gap-2 rounded-lg border border-border bg-surface p-6">
<Badge>Default</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="outline">Outline</Badge>
@ -160,7 +302,7 @@ export default function DesignSystemPage() {
{/* ── inputs / forms ── */}
<Section title="Input · Label · Progress · Separator">
<div className="space-y-5 bg-surface rounded-lg p-6 border border-line max-w-md">
<div className="max-w-md space-y-5 rounded-lg border border-border bg-surface p-6">
<div className="space-y-1.5">
<Label htmlFor="ds-email">Email</Label>
<Input id="ds-email" type="email" placeholder="you@expria.io" />
@ -178,51 +320,51 @@ export default function DesignSystemPage() {
<Progress value={65} />
</div>
<Separator />
<p className="text-sm text-ink-4">Content below separator</p>
<p className="text-sm text-ink-secondary">Content below separator</p>
</div>
</Section>
{/* ── avatar ── */}
<Section title="Avatar">
<div className="flex flex-wrap items-end gap-6 bg-surface rounded-lg p-6 border border-line">
<div className="flex flex-wrap items-end gap-6 rounded-lg border border-border bg-surface p-6">
<div className="flex flex-col items-center gap-2">
<Avatar size="sm">
<AvatarImage src="" />
<AvatarFallback>HK</AvatarFallback>
</Avatar>
<span className="text-xs text-ink-4">sm</span>
<span className="text-xs text-ink-secondary">sm</span>
</div>
<div className="flex flex-col items-center gap-2">
<Avatar>
<AvatarImage src="" />
<AvatarFallback>HK</AvatarFallback>
</Avatar>
<span className="text-xs text-ink-4">default</span>
<span className="text-xs text-ink-secondary">default</span>
</div>
<div className="flex flex-col items-center gap-2">
<Avatar size="lg">
<AvatarImage src="" />
<AvatarFallback>HK</AvatarFallback>
</Avatar>
<span className="text-xs text-ink-4">lg</span>
<span className="text-xs text-ink-secondary">lg</span>
</div>
<div className="flex flex-col items-center gap-2">
<AvatarGroup>
{['AB', 'CD', 'EF'].map(initials => (
{['AB', 'CD', 'EF'].map((initials) => (
<Avatar key={initials}>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
))}
<AvatarGroupCount>+5</AvatarGroupCount>
</AvatarGroup>
<span className="text-xs text-ink-4">group</span>
<span className="text-xs text-ink-secondary">group</span>
</div>
</div>
</Section>
{/* ── dialog ── */}
<Section title="Dialog">
<div className="bg-surface rounded-lg p-6 border border-line">
<div className="rounded-lg border border-border bg-surface p-6">
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline">Open dialog</Button>
@ -231,8 +373,8 @@ export default function DesignSystemPage() {
<DialogHeader>
<DialogTitle>Example dialog</DialogTitle>
<DialogDescription>
This dialog uses Direction H tokens bg-surface, border-line, text-ink-4.
Toggle the theme to see it adapt.
This dialog uses DA Charcoal tokens bg-surface-solid, border-border,
text-ink-secondary. Toggle the theme to see it adapt.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
@ -242,7 +384,6 @@ export default function DesignSystemPage() {
</Dialog>
</div>
</Section>
</div>
)
}

View file

@ -0,0 +1,189 @@
import { describe, it, expect } from 'vitest'
import {
applyFilters,
computeStats,
computeTrend,
formatShortDate,
formatTaskLabel,
nclcChipVariant,
} from '../lib/historique'
import type { SimulationListItem } from '@/entities/production/types'
const NOW = new Date('2026-04-25T12:00:00Z')
function item(
overrides: Partial<SimulationListItem> & { id: string; created_at: string },
): SimulationListItem {
return {
id: overrides.id,
tache: overrides.tache ?? 'EE_T1',
mode: overrides.mode ?? 'entrainement',
score: overrides.score ?? null,
nclc: overrides.nclc ?? null,
nclc_cible: overrides.nclc_cible ?? null,
created_at: overrides.created_at,
}
}
describe('applyFilters', () => {
const items: SimulationListItem[] = [
item({ id: 'a', tache: 'EE_T1', created_at: '2026-04-22T10:00:00Z', score: 14 }),
item({ id: 'b', tache: 'EE_T2', created_at: '2026-04-10T10:00:00Z', score: 12 }),
item({ id: 'c', tache: 'EO_T1', created_at: '2026-02-15T10:00:00Z', score: 16 }),
item({ id: 'd', tache: 'EO_T3', created_at: '2025-12-01T10:00:00Z', score: 10 }),
]
it('task=all + period=all → tous les items', () => {
expect(applyFilters(items, { task: 'all', period: 'all' }, NOW).map((i) => i.id)).toEqual([
'a',
'b',
'c',
'd',
])
})
it('filtre par tâche', () => {
expect(applyFilters(items, { task: 'EE_T1', period: 'all' }, NOW).map((i) => i.id)).toEqual([
'a',
])
})
it("period=this-month garde uniquement les items d'avril 2026", () => {
expect(
applyFilters(items, { task: 'all', period: 'this-month' }, NOW).map((i) => i.id),
).toEqual(['a', 'b'])
})
it('period=3-months exclut les items > 90 jours', () => {
expect(applyFilters(items, { task: 'all', period: '3-months' }, NOW).map((i) => i.id)).toEqual([
'a',
'b',
'c',
])
})
it('combine tâche + période', () => {
expect(
applyFilters(items, { task: 'EE_T2', period: 'this-month' }, NOW).map((i) => i.id),
).toEqual(['b'])
})
})
describe('computeStats', () => {
it('dataset vide → all null', () => {
expect(computeStats([], NOW)).toEqual({
total: 0,
thisMonth: 0,
average: null,
best: null,
})
})
it('ignore les scores null pour average + best', () => {
const items = [
item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: 12 }),
item({ id: 'b', created_at: '2026-04-21T10:00:00Z', score: null }),
item({ id: 'c', created_at: '2026-04-22T10:00:00Z', score: 18 }),
]
const s = computeStats(items, NOW)
expect(s.total).toBe(3)
expect(s.thisMonth).toBe(3)
expect(s.average).toBe(15)
expect(s.best?.score).toBe(18)
expect(s.best?.created_at).toBe('2026-04-22T10:00:00Z')
})
it('thisMonth ne compte que le mois courant', () => {
const items = [
item({ id: 'a', created_at: '2026-04-22T10:00:00Z', score: 14 }),
item({ id: 'b', created_at: '2026-03-22T10:00:00Z', score: 14 }),
]
expect(computeStats(items, NOW).thisMonth).toBe(1)
})
})
describe('computeTrend', () => {
it('retourne null si fenêtre récente vide', () => {
const items = [item({ id: 'a', created_at: '2026-02-15T10:00:00Z', score: 10 })]
expect(computeTrend(items, NOW)).toBeNull()
})
it('retourne null si fenêtre précédente vide', () => {
const items = [item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: 14 })]
expect(computeTrend(items, NOW)).toBeNull()
})
it('détecte une tendance up', () => {
const items = [
// récents (030j) : moyenne 15
item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: 16 }),
item({ id: 'b', created_at: '2026-04-10T10:00:00Z', score: 14 }),
// précédents (3060j) : moyenne 12
item({ id: 'c', created_at: '2026-03-20T10:00:00Z', score: 12 }),
item({ id: 'd', created_at: '2026-03-10T10:00:00Z', score: 12 }),
]
const t = computeTrend(items, NOW)
expect(t?.direction).toBe('up')
expect(t?.delta).toBeCloseTo(3, 5)
})
it('détecte une tendance down', () => {
const items = [
item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: 10 }),
item({ id: 'b', created_at: '2026-03-15T10:00:00Z', score: 14 }),
]
const t = computeTrend(items, NOW)
expect(t?.direction).toBe('down')
expect(t?.delta).toBeCloseTo(4, 5)
})
it('ignore les scores null', () => {
const items = [
item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: null }),
item({ id: 'b', created_at: '2026-04-10T10:00:00Z', score: 14 }),
item({ id: 'c', created_at: '2026-03-15T10:00:00Z', score: 12 }),
]
const t = computeTrend(items, NOW)
expect(t?.direction).toBe('up')
expect(t?.delta).toBeCloseTo(2, 5)
})
})
describe('formatShortDate', () => {
it('formate "22 avr." en fr-FR', () => {
expect(formatShortDate('2026-04-22T10:00:00Z')).toMatch(/22 avr/)
})
it('retourne chaîne vide pour ISO invalide', () => {
expect(formatShortDate('not-a-date')).toBe('')
})
})
describe('formatTaskLabel', () => {
it('entraînement → "EE · Tâche 3"', () => {
expect(formatTaskLabel({ tache: 'EE_T3', mode: 'entrainement' })).toBe('EE · Tâche 3')
})
it('examen EE → "Examen blanc EE"', () => {
expect(formatTaskLabel({ tache: 'EE_T1', mode: 'examen' })).toBe('Examen blanc EE')
})
it('examen EO → "Examen blanc EO"', () => {
expect(formatTaskLabel({ tache: 'EO_T3', mode: 'examen' })).toBe('Examen blanc EO')
})
})
describe('nclcChipVariant', () => {
it('≥ 9 → ok', () => {
expect(nclcChipVariant(9)).toBe('ok')
expect(nclcChipVariant(12)).toBe('ok')
})
it('7-8 → warn', () => {
expect(nclcChipVariant(7)).toBe('warn')
expect(nclcChipVariant(8)).toBe('warn')
})
it('≤ 6 → err', () => {
expect(nclcChipVariant(6)).toBe('err')
expect(nclcChipVariant(0)).toBe('err')
})
})

View file

@ -0,0 +1,129 @@
/**
* Filtres de la page /historique refonte Sprint 4.7 + correction theming.
*
* Dropdowns custom (div + état ouvert/fermé) zéro lib externe pour
* garantir la lisibilité en dark/light. Tokens DA Charcoal (Règle L).
*
* Accessibilité minimale : button aria-haspopup, fermeture sur clic
* extérieur ou Escape, options atteignables au clic.
*/
import { useEffect, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import type { PeriodFilter, TaskFilter } from '../lib/historique'
interface Props {
task: TaskFilter
period: PeriodFilter
onTaskChange: (task: TaskFilter) => void
onPeriodChange: (period: PeriodFilter) => void
}
const TASK_OPTIONS: { value: TaskFilter; label: string }[] = [
{ value: 'all', label: 'Toutes les tâches' },
{ value: 'EE_T1', label: 'EE T1' },
{ value: 'EE_T2', label: 'EE T2' },
{ value: 'EE_T3', label: 'EE T3' },
{ value: 'EO_T1', label: 'EO T1' },
{ value: 'EO_T3', label: 'EO T3' },
]
const PERIOD_OPTIONS: { value: PeriodFilter; label: string }[] = [
{ value: 'this-month', label: 'Ce mois' },
{ value: '3-months', label: '3 mois' },
{ value: 'all', label: 'Tout' },
]
interface DropdownProps<T extends string> {
value: T
options: { value: T; label: string }[]
onChange: (value: T) => void
ariaLabel: string
}
function Dropdown<T extends string>({ value, options, onChange, ariaLabel }: DropdownProps<T>) {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function onDocClick(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onDocClick)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDocClick)
document.removeEventListener('keydown', onKey)
}
}, [open])
const selected = options.find((o) => o.value === value) ?? options[0]
return (
<div ref={rootRef} className="relative">
<button
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
onClick={() => setOpen((o) => !o)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface px-[14px] py-[7px] text-[12.5px] font-semibold text-ink-primary hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus"
>
<span>{selected.label}</span>
<ChevronDown className="size-3.5 text-ink-tertiary" aria-hidden="true" />
</button>
{open && (
<ul
role="listbox"
className="absolute right-0 z-10 mt-1 min-w-full overflow-hidden rounded-lg border border-border bg-surface-solid shadow-card"
>
{options.map((opt) => {
const isActive = opt.value === value
return (
<li key={opt.value}>
<button
type="button"
role="option"
aria-selected={isActive}
onClick={() => {
onChange(opt.value)
setOpen(false)
}}
className={`block w-full whitespace-nowrap px-[14px] py-2 text-left text-[12.5px] font-medium hover:bg-surface-hover focus-visible:bg-surface-hover focus-visible:outline-none ${
isActive ? 'text-brand-text' : 'text-ink-primary'
}`}
>
{opt.label}
</button>
</li>
)
})}
</ul>
)}
</div>
)
}
export function HistoriqueFilters({ task, period, onTaskChange, onPeriodChange }: Props) {
return (
<div className="flex flex-wrap items-center gap-2">
<Dropdown
value={task}
options={TASK_OPTIONS}
onChange={onTaskChange}
ariaLabel="Filtrer par tâche"
/>
<Dropdown
value={period}
options={PERIOD_OPTIONS}
onChange={onPeriodChange}
ariaLabel="Filtrer par période"
/>
</div>
)
}

View file

@ -0,0 +1,118 @@
/**
* 3 cartes métriques en haut de /historique Sprint 4.7.
*
* Total simulations / Score moyen / Meilleur score. Recalculées à chaque
* changement de filtres (les filtres sont appliqués en amont par la page).
*
* Règle L : tokens DA Charcoal exclusivement.
* Règle H : purement présentationnel.
*/
import {
formatShortDate,
formatTaskLabel,
type HistoriqueStats,
type Trend,
} from '../lib/historique'
interface Props {
stats: HistoriqueStats
trend: Trend | null
}
const NUMBER_FR = new Intl.NumberFormat('fr-FR', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
function Label({ children }: { children: React.ReactNode }) {
return (
<p className="text-[11.5px] font-medium uppercase tracking-[0.04em] text-ink-secondary">
{children}
</p>
)
}
function Value({ children }: { children: React.ReactNode }) {
return (
<span className="text-[34px] font-bold leading-none tracking-[-0.03em] tabular-nums text-ink-primary">
{children}
</span>
)
}
function Unit({ children }: { children: React.ReactNode }) {
return <span className="ml-[3px] text-[15px] font-medium text-ink-tertiary">{children}</span>
}
function Footer({ children }: { children: React.ReactNode }) {
return <p className="mt-2 text-[11.5px] text-ink-tertiary">{children}</p>
}
function TrendChip({ trend }: { trend: Trend }) {
const isUp = trend.direction === 'up'
const sign = isUp ? '+' : '-'
const label = `${sign}${NUMBER_FR.format(trend.delta)} en 30j`
const colorClasses = isUp
? 'bg-success-soft text-success border-success/30'
: 'bg-danger-soft text-danger border-danger/30'
return (
<span
className={`mt-2 inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11.5px] font-semibold ${colorClasses}`}
>
<span aria-hidden="true">{isUp ? '↑' : '↓'}</span>
{label}
</span>
)
}
export function HistoriqueStatsCards({ stats, trend }: Props) {
return (
<div className="grid w-full grid-cols-3 gap-3">
<div className="rounded-[12px] border border-border bg-surface p-4 px-[18px] shadow-card">
<Label>Total simulations</Label>
<div className="mt-2 flex items-baseline">
<Value>{stats.total}</Value>
</div>
<Footer>dont {stats.thisMonth} ce mois</Footer>
</div>
<div className="rounded-[12px] border border-border bg-surface p-4 px-[18px] shadow-card">
<Label>Score moyen</Label>
<div className="mt-2 flex items-baseline">
{stats.average !== null ? (
<>
<Value>{NUMBER_FR.format(stats.average)}</Value>
<Unit>/20</Unit>
</>
) : (
<Value></Value>
)}
</div>
{trend ? <TrendChip trend={trend} /> : <Footer></Footer>}
</div>
<div className="rounded-[12px] border border-border bg-surface p-4 px-[18px] shadow-card">
<Label>Meilleur score</Label>
<div className="mt-2 flex items-baseline">
{stats.best !== null ? (
<>
<Value>{stats.best.score}</Value>
<Unit>/20</Unit>
</>
) : (
<Value></Value>
)}
</div>
{stats.best !== null ? (
<Footer>
{formatTaskLabel({ tache: stats.best.tache, mode: 'entrainement' })} ·{' '}
{formatShortDate(stats.best.created_at)}
</Footer>
) : (
<Footer></Footer>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,65 @@
/**
* Item d'une ligne de la liste /historique réécrit Sprint 4.7 selon maquette.
*
* Layout flex : Date · Libellé · Badge NCLC · Score · Chevron.
* Couleur du badge NCLC selon seuil (cf. `nclcChipVariant`).
*
* Règle L : tokens DA Charcoal exclusivement.
* Règle H : purement présentationnel.
*/
import { ChevronRight } from 'lucide-react'
import { Link } from 'react-router-dom'
import type { SimulationListItem as Item } from '@/entities/production/types'
import { formatShortDate, formatTaskLabel, nclcChipVariant } from '../lib/historique'
interface Props {
item: Item
isLast: boolean
}
const CHIP_BASE =
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide'
const CHIP_OK = 'bg-success-soft text-success border-success/30'
const CHIP_WARN = 'bg-warning-soft text-warning border-warning/30'
const CHIP_ERR = 'bg-danger-soft text-danger border-danger/30'
const CHIP_NEUTRAL = 'bg-surface text-ink-secondary border-border'
function NclcBadge({ nclc }: { nclc: number }) {
const variant = nclcChipVariant(nclc)
const cls = variant === 'ok' ? CHIP_OK : variant === 'warn' ? CHIP_WARN : CHIP_ERR
return <span className={`${CHIP_BASE} ${cls}`}>NCLC {nclc}</span>
}
export function SimulationListItem({ item, isLast }: Props) {
const hasScore = item.score !== null && item.nclc !== null
const borderClass = isLast ? '' : 'border-b border-border'
return (
<Link
to={`/rapport/${item.id}`}
className={`flex items-center gap-[14px] px-4 py-[14px] transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus ${borderClass}`}
>
<span className="w-[68px] shrink-0 text-[11.5px] tabular-nums text-ink-tertiary">
{formatShortDate(item.created_at)}
</span>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-ink-primary">
{formatTaskLabel(item)}
</span>
{hasScore && item.nclc !== null ? (
<NclcBadge nclc={item.nclc} />
) : (
<span className={`${CHIP_BASE} ${CHIP_NEUTRAL}`}>En cours</span>
)}
<span className="min-w-[56px] text-right text-[16px] font-semibold tracking-[-0.02em] tabular-nums text-ink-primary">
{hasScore ? `${item.score}/20` : '—/20'}
</span>
<ChevronRight className="size-[14px] shrink-0 text-ink-tertiary" aria-hidden="true" />
</Link>
)
}

View file

@ -0,0 +1,125 @@
/**
* SimulationsList refonte Sprint 4.7.
*
* - Reçoit directement `items: SimulationListItem[]` (filtrés en amont par la
* page) au lieu d'une réponse paginée. La pagination Précédent/Suivant a
* é supprimée au profit du filtrage local (cf. HistoriquePage).
* - Conserve le gating Free (aperçu flouté + CTA upgrade Règle D).
* - Distingue état vide global (« aucune simulation ») vs filtré
* (« aucun résultat pour ces filtres »).
*
* Règle L : tokens DA Charcoal exclusivement.
*/
import { Lock } from 'lucide-react'
import { Link } from 'react-router-dom'
import { Card } from '@/shared/ui/Card'
import { Button } from '@/shared/ui/Button'
import { hasAccess, type Plan } from '@/entities/user/lib'
import type { SimulationListItem as Item } from '@/entities/production/types'
import { SimulationListItem } from './SimulationListItem'
interface Props {
plan: Plan
items: Item[]
isLoading: boolean
isError: boolean
/** True si au moins un filtre non-`all` est actif — distingue empty filtré vs global. */
isFiltered: boolean
onUpgrade: () => void
}
const PLACEHOLDER_WIDTHS = ['w-3/4', 'w-full', 'w-3/5', 'w-4/5'] as const
function BlurredPreview({ onUpgrade }: { onUpgrade: () => void }) {
return (
<div className="relative min-h-[240px] overflow-hidden rounded-[12px] border border-border bg-surface">
<div className="space-y-3 p-4 opacity-25 blur-sm" aria-hidden="true">
{PLACEHOLDER_WIDTHS.map((w, i) => (
<div key={i} className={`h-16 rounded bg-surface-hover ${w}`} />
))}
</div>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4">
<Lock className="size-5 text-ink-secondary" aria-hidden="true" />
<p className="text-sm font-medium text-ink-primary">Historique disponible en Standard</p>
<Button variant="upgrade" size="sm" onClick={onUpgrade}>
Voir les plans
</Button>
</div>
</div>
)
}
function ListSkeleton() {
return (
<div className="space-y-3" aria-busy="true" aria-label="Chargement de l'historique…">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-14 animate-pulse rounded-lg bg-surface" />
))}
</div>
)
}
function EmptyState() {
return (
<Card variant="default" className="space-y-3 p-6 text-center">
<p className="text-sm text-ink-primary">Aucune simulation pour le moment.</p>
<p className="text-xs text-ink-secondary">
Lancez votre première simulation pour commencer à construire votre historique.
</p>
<div className="flex justify-center">
<Button variant="primary" size="sm">
<Link to="/simulation/ee" className="-m-1 p-1">
Démarrer une simulation
</Link>
</Button>
</div>
</Card>
)
}
function EmptyFilteredState() {
return (
<Card variant="default" className="p-6 text-center">
<p className="text-sm text-ink-primary">Aucune simulation ne correspond à ces filtres.</p>
<p className="mt-1 text-xs text-ink-secondary">
Essayez d'élargir la période ou de changer la tâche sélectionnée.
</p>
</Card>
)
}
function ErrorState() {
return (
<Card variant="default" className="border-l-4 border-l-danger p-4">
<p className="text-sm text-danger" role="alert">
Impossible de charger l'historique. Réessayez dans quelques instants.
</p>
</Card>
)
}
export function SimulationsList({ plan, items, isLoading, isError, isFiltered, onUpgrade }: Props) {
if (!hasAccess(plan, 'dashboard')) {
return <BlurredPreview onUpgrade={onUpgrade} />
}
if (isError) return <ErrorState />
if (isLoading) return <ListSkeleton />
if (items.length === 0) {
return isFiltered ? <EmptyFilteredState /> : <EmptyState />
}
return (
<div className="overflow-hidden rounded-[12px] border border-border bg-surface shadow-card">
<ul>
{items.map((it, i) => (
<li key={it.id}>
<SimulationListItem item={it} isLast={i === items.length - 1} />
</li>
))}
</ul>
</div>
)
}

View file

@ -0,0 +1,186 @@
/**
* Tests SimulationsList refonte Sprint 4.7.
*
* Couvre : gating Free, état vide global, état vide filtré, items rendus,
* isError, isLoading. La pagination a é retirée au Sprint 4.7.
*/
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { SimulationsList } from '../SimulationsList'
import type { SimulationListItem } from '@/entities/production/types'
afterEach(cleanup)
function renderWithRouter(ui: React.ReactNode) {
return render(<MemoryRouter>{ui}</MemoryRouter>)
}
const ITEMS: SimulationListItem[] = [
{
id: 'p1',
tache: 'EE_T1',
mode: 'entrainement',
score: 14,
nclc: 9,
nclc_cible: 9,
created_at: '2026-04-22T10:00:00Z',
},
{
id: 'p2',
tache: 'EE_T2',
mode: 'examen',
score: 16,
nclc: 10,
nclc_cible: 10,
created_at: '2026-04-22T09:00:00Z',
},
{
id: 'p3',
tache: 'EE_T3',
mode: 'entrainement',
score: null,
nclc: null,
nclc_cible: null,
created_at: '2026-04-22T08:00:00Z',
},
]
const NOOP = () => {}
describe('SimulationsList — gating Free', () => {
it("affiche l'aperçu flouté pour le plan Free", () => {
renderWithRouter(
<SimulationsList
plan="free"
items={ITEMS}
isLoading={false}
isError={false}
isFiltered={false}
onUpgrade={NOOP}
/>,
)
expect(screen.getByText(/historique disponible en standard/i)).toBeInTheDocument()
expect(screen.queryByText(/EE · Tâche 1/i)).not.toBeInTheDocument()
})
it('clic sur "Voir les plans" appelle onUpgrade', async () => {
const user = userEvent.setup()
const onUpgrade = vi.fn()
renderWithRouter(
<SimulationsList
plan="free"
items={ITEMS}
isLoading={false}
isError={false}
isFiltered={false}
onUpgrade={onUpgrade}
/>,
)
await user.click(screen.getByRole('button', { name: /voir les plans/i }))
expect(onUpgrade).toHaveBeenCalledTimes(1)
})
})
describe('SimulationsList — plan Standard', () => {
it('affiche l\'état vide global avec CTA "Démarrer une simulation"', () => {
renderWithRouter(
<SimulationsList
plan="standard"
items={[]}
isLoading={false}
isError={false}
isFiltered={false}
onUpgrade={NOOP}
/>,
)
expect(screen.getByText(/aucune simulation pour le moment/i)).toBeInTheDocument()
expect(screen.getByRole('link', { name: /démarrer une simulation/i })).toHaveAttribute(
'href',
'/simulation/ee',
)
})
it('affiche un état vide spécifique quand des filtres sont actifs', () => {
renderWithRouter(
<SimulationsList
plan="standard"
items={[]}
isLoading={false}
isError={false}
isFiltered={true}
onUpgrade={NOOP}
/>,
)
expect(screen.getByText(/aucune simulation ne correspond à ces filtres/i)).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /démarrer une simulation/i })).not.toBeInTheDocument()
})
it('rend les items avec libellé, score et badges', () => {
renderWithRouter(
<SimulationsList
plan="standard"
items={ITEMS}
isLoading={false}
isError={false}
isFiltered={false}
onUpgrade={NOOP}
/>,
)
const links = screen.getAllByRole('link')
expect(links).toHaveLength(3)
expect(screen.getByText('EE · Tâche 1')).toBeInTheDocument()
expect(screen.getByText('Examen blanc EE')).toBeInTheDocument()
expect(screen.getByText('14/20')).toBeInTheDocument()
expect(screen.getByText('NCLC 9')).toBeInTheDocument()
expect(screen.getByText(/En cours/i)).toBeInTheDocument()
expect(screen.getByText('—/20')).toBeInTheDocument()
})
it('chaque item pointe vers /rapport/:id', () => {
renderWithRouter(
<SimulationsList
plan="standard"
items={ITEMS}
isLoading={false}
isError={false}
isFiltered={false}
onUpgrade={NOOP}
/>,
)
const link = screen.getAllByRole('link')[0]
expect(link).toHaveAttribute('href', '/rapport/p1')
})
})
describe('SimulationsList — états transverses', () => {
it("isError → callout d'erreur", () => {
renderWithRouter(
<SimulationsList
plan="standard"
items={[]}
isLoading={false}
isError={true}
isFiltered={false}
onUpgrade={NOOP}
/>,
)
expect(screen.getByRole('alert')).toHaveTextContent(/impossible de charger/i)
})
it('isLoading → squelettes', () => {
renderWithRouter(
<SimulationsList
plan="standard"
items={[]}
isLoading={true}
isError={false}
isFiltered={false}
onUpgrade={NOOP}
/>,
)
expect(screen.getByLabelText(/chargement de l'historique/i)).toBeInTheDocument()
})
})

View file

@ -0,0 +1,27 @@
/**
* Hook TanStack Query liste paginée des simulations.
*
* Clé de cache : `['simulations', 'list', page, limit]`. `staleTime: 30 s`
* l'historique change peu entre deux requêtes utilisateur, 30 s évite les
* rafraîchissements inutiles tout en gardant les données fraîches.
*
* `placeholderData: keepPreviousData` (TanStack v5) permet un changement de
* page sans flash de squelette les items précédents restent affichés
* pendant le fetch.
*
* Règle H : aucune logique métier ici le hook ne fait qu'envelopper l'API.
*/
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { listSimulations } from '@/entities/production/api'
const DEFAULT_LIMIT = 20
export function useSimulationsList(page: number, limit: number = DEFAULT_LIMIT) {
return useQuery({
queryKey: ['simulations', 'list', page, limit] as const,
queryFn: () => listSimulations(page, limit),
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}

View file

@ -0,0 +1,159 @@
/**
* Logique pure de la page /historique Sprint 4.7.
*
* Toutes les fonctions ici sont déterministes et acceptent `now` injecté
* pour permettre des tests reproductibles. Aucune dépendance React, aucune
* I/O Règle H respectée (ces helpers pourraient vivre en `entities/`,
* mais ils sont 100 % spécifiques à la page historique restent ici).
*
* Filtrage côté frontend uniquement : les 50 simulations les plus récentes
* sont chargées en une fois (cf. HistoriquePage), puis filtrées localement.
*/
import type { SimulationListItem, Tache } from '@/entities/production/types'
export type TaskFilter = 'all' | Tache
export type PeriodFilter = 'all' | 'this-month' | '3-months'
export interface FiltersState {
task: TaskFilter
period: PeriodFilter
}
const DAY_MS = 24 * 60 * 60 * 1000
function isInPeriod(iso: string, period: PeriodFilter, now: Date): boolean {
if (period === 'all') return true
const d = new Date(iso)
if (!Number.isFinite(d.getTime())) return false
if (period === 'this-month') {
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()
}
// 3-months : 90 jours glissants
return now.getTime() - d.getTime() <= 90 * DAY_MS
}
export function applyFilters(
items: SimulationListItem[],
{ task, period }: FiltersState,
now: Date,
): SimulationListItem[] {
return items.filter((it) => {
if (task !== 'all' && it.tache !== task) return false
if (!isInPeriod(it.created_at, period, now)) return false
return true
})
}
export interface HistoriqueStats {
total: number
thisMonth: number
average: number | null
best: { score: number; tache: Tache; created_at: string } | null
}
export function computeStats(items: SimulationListItem[], now: Date): HistoriqueStats {
const total = items.length
const thisMonth = items.filter((it) => isInPeriod(it.created_at, 'this-month', now)).length
const scored = items.filter(
(it): it is SimulationListItem & { score: number } => typeof it.score === 'number',
)
if (scored.length === 0) {
return { total, thisMonth, average: null, best: null }
}
const sum = scored.reduce((acc, it) => acc + it.score, 0)
const average = sum / scored.length
const bestItem = scored.reduce((acc, it) => (it.score > acc.score ? it : acc), scored[0])
return {
total,
thisMonth,
average,
best: { score: bestItem.score, tache: bestItem.tache, created_at: bestItem.created_at },
}
}
export interface Trend {
direction: 'up' | 'down'
delta: number
}
/**
* Tendance 30 j : moyenne des 30 derniers jours vs 30 j précédents (j-60 j-30).
* Ignore les items sans score. Retourne `null` si l'une des deux fenêtres est vide.
*/
export function computeTrend(items: SimulationListItem[], now: Date): Trend | null {
const t = now.getTime()
const within = (iso: string, fromDays: number, toDays: number) => {
const ts = new Date(iso).getTime()
if (!Number.isFinite(ts)) return false
const ageMs = t - ts
return ageMs >= fromDays * DAY_MS && ageMs < toDays * DAY_MS
}
const recent = items.filter(
(it): it is SimulationListItem & { score: number } =>
typeof it.score === 'number' && within(it.created_at, 0, 30),
)
const previous = items.filter(
(it): it is SimulationListItem & { score: number } =>
typeof it.score === 'number' && within(it.created_at, 30, 60),
)
if (recent.length === 0 || previous.length === 0) return null
const avg = (xs: { score: number }[]) => xs.reduce((s, it) => s + it.score, 0) / xs.length
const delta = avg(recent) - avg(previous)
if (delta === 0) return { direction: 'up', delta: 0 }
return { direction: delta > 0 ? 'up' : 'down', delta: Math.abs(delta) }
}
const SHORT_DATE = new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' })
/** Date courte type "22 avr." (locale fr-FR). */
export function formatShortDate(iso: string): string {
const d = new Date(iso)
if (!Number.isFinite(d.getTime())) return ''
return SHORT_DATE.format(d)
}
const TACHE_NUMBER: Record<Tache, string> = {
EE_T1: 'EE · Tâche 1',
EE_T2: 'EE · Tâche 2',
EE_T3: 'EE · Tâche 3',
EO_T1: 'EO · Tâche 1',
EO_T2_LIVE: 'EO · Tâche 2 Live',
EO_T3: 'EO · Tâche 3',
}
/**
* Libellé court d'un item d'historique selon la maquette Sprint 4.7.
*
* Mode entraînement "EE · Tâche 3"
* Mode examen "Examen blanc EE" / "Examen blanc EO"
*/
export function formatTaskLabel(item: Pick<SimulationListItem, 'tache' | 'mode'>): string {
if (item.mode === 'examen') {
return item.tache.startsWith('EE_') ? 'Examen blanc EE' : 'Examen blanc EO'
}
return TACHE_NUMBER[item.tache]
}
export type NclcChip = 'ok' | 'warn' | 'err'
/**
* Variante visuelle du badge NCLC selon le seuil :
* - 9 ok (success)
* - 7-8 warn (warning/gold)
* - 6 err (danger)
*/
export function nclcChipVariant(nclc: number): NclcChip {
if (nclc >= 9) return 'ok'
if (nclc >= 7) return 'warn'
return 'err'
}

View file

@ -0,0 +1,103 @@
/**
* Page /historique refonte Sprint 4.7.
*
* Charge en une fois les 50 simulations les plus récentes via
* `useSimulationsList(1, 50)` puis applique les filtres (tâche + période)
* côté frontend. Cette limite est volontaire : un MVP avec un volume modeste
* d'utilisateurs ne nécessite pas de filtrage backend. Au-delà de 50, les
* simulations plus anciennes ne sont pas accessibles tant que les filtres
* ne sont pas reportés côté backend (`GET /simulations?tache=&since=`).
*
* Règle D : gating via `hasAccess(plan, 'dashboard')` dans `SimulationsList`.
* Règle L : tokens DA Charcoal exclusivement.
*/
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { usePlan } from '@/features/dashboard/hooks/usePlan'
import { useSimulationsList } from '../hooks/useSimulationsList'
import { SimulationsList } from '../components/SimulationsList'
import { HistoriqueStatsCards } from '../components/HistoriqueStats'
import { HistoriqueFilters } from '../components/HistoriqueFilters'
import {
applyFilters,
computeStats,
computeTrend,
type PeriodFilter,
type TaskFilter,
} from '../lib/historique'
const LIMIT = 50
export function HistoriquePage() {
const navigate = useNavigate()
const [task, setTask] = useState<TaskFilter>('all')
const [period, setPeriod] = useState<PeriodFilter>('this-month')
const { data: planData, isLoading: isPlanLoading } = usePlan()
const { data, isLoading, isError } = useSimulationsList(1, LIMIT)
const now = useMemo(() => new Date(), [])
const allItems = data?.data ?? []
const filtered = useMemo(
() => applyFilters(allItems, { task, period }, now),
[allItems, task, period, now],
)
const stats = useMemo(() => computeStats(filtered, now), [filtered, now])
const trend = useMemo(() => computeTrend(filtered, now), [filtered, now])
const isFiltered = task !== 'all' || period !== 'all'
const showStats = !isPlanLoading && planData && !isError
const canSeeContent = planData && planData.plan !== 'free'
// AppLayout fournit déjà mx-auto max-w-[1100px] + lg:px-9 lg:py-9 (cf.
// AppLayout.tsx) — on limite ici à 860 px sans réintroduire de padding
// pour éviter le double margin.
return (
<main className="mx-auto w-full max-w-[860px]">
<header className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.073em] text-ink-tertiary">
Historique
</p>
<h1 className="mt-1 text-[24px] font-bold tracking-[-0.02em] text-ink-primary">
Mes simulations
</h1>
<p className="mt-1 text-[13.5px] text-ink-secondary">
Retrouve toutes tes simulations avec leur score et leur rapport.
</p>
</div>
{canSeeContent && (
<HistoriqueFilters
task={task}
period={period}
onTaskChange={setTask}
onPeriodChange={setPeriod}
/>
)}
</header>
{showStats && canSeeContent && (
<div className="mb-6">
<HistoriqueStatsCards stats={stats} trend={trend} />
</div>
)}
{isPlanLoading || !planData ? (
<div className="space-y-3" aria-busy="true">
<div className="h-20 animate-pulse rounded-lg bg-surface" />
<div className="h-20 animate-pulse rounded-lg bg-surface" />
</div>
) : (
<SimulationsList
plan={planData.plan}
items={filtered}
isLoading={isLoading}
isError={isError}
isFiltered={isFiltered}
onUpgrade={() => navigate('/plan')}
/>
)}
</main>
)
}

View file

@ -0,0 +1,45 @@
/**
* BlurredProgression Sprint 3.6c.
*
* Aperçu flouté de la page /progression pour Free/Standard + CTA upgrade
* vers Premium. Ce composant n'est JAMAIS rendu pour Premium (cf.
* ProgressionPage) le gating est fait en amont via hasAccess.
*
* Règle L : tokens Direction H exclusivement.
*/
import { Lock } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
interface Props {
onUpgrade: () => void
}
const PLACEHOLDER_HEIGHTS = ['h-24', 'h-16', 'h-16', 'h-20'] as const
export function BlurredProgression({ onUpgrade }: Props) {
return (
<div className="relative min-h-[320px] overflow-hidden rounded-lg border border-border bg-surface">
<div className="space-y-3 p-4 opacity-25 blur-sm" aria-hidden="true">
{PLACEHOLDER_HEIGHTS.map((h, i) => (
<div key={i} className={`${h} rounded bg-surface-hover`} />
))}
</div>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4 text-center">
<Lock className="size-6 text-ink-secondary" aria-hidden="true" />
<div className="space-y-1">
<p className="text-sm font-semibold text-ink-primary">
Profil de préparation Exclusivité Premium
</p>
<p className="max-w-sm text-xs text-ink-secondary">
Analysez vos erreurs récurrentes, recevez des exercices ciblés long terme, et suivez
votre indice de préparation au TCF Canada.
</p>
</div>
<Button variant="upgrade" size="sm" onClick={onUpgrade}>
Passer en Premium
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,59 @@
/**
* NotReadyState Sprint 3.6c.
*
* Affiché quand l'utilisateur Premium a moins de 5 productions corrigées.
* Barre de progression N/5 + CTA pour démarrer une simulation.
*
* Règle L : tokens Direction H exclusivement.
*/
import { Link } from 'react-router-dom'
import { Card } from '@/shared/ui/Card'
import { Button } from '@/shared/ui/Button'
interface Props {
current: number
minimum: number
}
export function NotReadyState({ current, minimum }: Props) {
const remaining = Math.max(0, minimum - current)
const pct = Math.max(0, Math.min(100, (current / minimum) * 100))
return (
<Card variant="raised" className="space-y-4 p-6 text-center">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-ink-primary">Profil de préparation</h2>
<p className="text-sm leading-relaxed text-ink-secondary">
Vous avez réalisé{' '}
<span className="font-semibold text-ink-primary tabular-nums">
{current}/{minimum}
</span>{' '}
simulations corrigées.{' '}
{remaining > 0
? `Encore ${remaining} pour débloquer votre profil.`
: 'Votre profil va être généré à la prochaine correction.'}
</p>
</div>
<div
className="relative h-2 overflow-hidden rounded-full bg-surface"
role="progressbar"
aria-valuenow={current}
aria-valuemin={0}
aria-valuemax={minimum}
aria-label={`Progression : ${current} sur ${minimum}`}
>
<div className="h-full bg-brand transition-all duration-300" style={{ width: `${pct}%` }} />
</div>
<div className="flex justify-center">
<Button variant="primary" size="sm">
<Link to="/simulation/ee" className="-m-1 p-1">
Démarrer une simulation
</Link>
</Button>
</div>
</Card>
)
}

View file

@ -0,0 +1,91 @@
/**
* PatternExerciceCard Sprint 3.6c.
*
* Carte d'exercice long terme : UX **leçon** (pas interactive, contrairement à
* `ExerciceInteractive` du rapport individuel). Le candidat a déjà répété
* cette erreur 3+ fois l'intention est de montrer directement le bon usage
* + l'astuce mnémotechnique pour réflexe de relecture.
*
* Structure :
* - En-tête : critère + badge taxonomie + diagnostic
* - Bloc consigne (fond neutre)
* - Exemple incorrect (barré rouge) Correction (fond vert)
* - Encart astuce avec icône ampoule + fond chaud
*
* Règle L : tokens Direction H exclusivement.
* Règle H : présentation pure contenu fourni par DeepSeek via backend.
*/
import { Lightbulb } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import { Card } from '@/shared/ui/Card'
import { Badge } from '@/shared/ui/Badge'
import { CRITERE_LABELS } from '@/entities/report/lib'
import type { PatternExercice } from '@/entities/patterns/types'
interface Props {
exercice: PatternExercice
}
export function PatternExerciceCard({ exercice }: Props) {
const critereLabel = CRITERE_LABELS[exercice.critere]
return (
<Card variant="default" className="space-y-4 p-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="neutral">{critereLabel}</Badge>
<span className="text-xs font-medium text-ink-secondary">
{exercice.code.replace(/_/g, ' ')}
</span>
</div>
{exercice.diagnostic && (
<p className="text-sm leading-relaxed text-ink-primary">
<ReactMarkdown
disallowedElements={['script', 'iframe']}
components={{ p: ({ children }) => <span>{children}</span> }}
>
{exercice.diagnostic}
</ReactMarkdown>
</p>
)}
</div>
{exercice.exercice.consigne && (
<div className="space-y-1.5 rounded-md border border-border bg-surface p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Consigne
</p>
<p className="text-sm leading-relaxed text-ink-primary">{exercice.exercice.consigne}</p>
</div>
)}
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1.5 rounded-md border border-danger/30 bg-danger-soft p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-danger">
Incorrect
</p>
<p className="text-sm leading-relaxed text-ink-primary line-through decoration-danger decoration-1">
{exercice.exercice.exemple}
</p>
</div>
<div className="space-y-1.5 rounded-md border border-success/30 bg-success-soft p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-success">
Correct
</p>
<p className="text-sm leading-relaxed text-ink-primary">{exercice.exercice.correction}</p>
</div>
</div>
<div className="flex gap-3 rounded-md border border-warning/30 bg-warning-soft p-3">
<Lightbulb className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden="true" />
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-widest text-warning">
Astuce de relecture
</p>
<p className="text-sm leading-relaxed text-ink-primary">{exercice.exercice.astuce}</p>
</div>
</div>
</Card>
)
}

View file

@ -0,0 +1,53 @@
/**
* PatternsList Sprint 3.6c.
*
* Liste les erreurs récurrentes détectées, groupées par critère et triées par
* fréquence DESC (déjà fait côté backend).
*
* Règle L : tokens Direction H exclusivement.
*/
import { Card } from '@/shared/ui/Card'
import { Badge } from '@/shared/ui/Badge'
import { CRITERE_LABELS } from '@/entities/report/lib'
import type { Pattern } from '@/entities/patterns/types'
interface Props {
patterns: Pattern[]
}
function humanizeCode(code: string): string {
return code.replace(/_/g, ' ')
}
export function PatternsList({ patterns }: Props) {
if (patterns.length === 0) {
return (
<Card variant="default" className="p-4">
<p className="text-sm text-ink-secondary">
Aucune erreur récurrente détectée sur vos 5 dernières productions. Continuez ainsi !
</p>
</Card>
)
}
return (
<ul className="space-y-2">
{patterns.map((p) => (
<li key={`${p.critere}-${p.code}-${p.description ?? ''}`}>
<Card variant="default" className="flex items-start justify-between gap-3 p-4">
<div className="min-w-0 space-y-1">
<p className="text-sm font-semibold text-ink-primary">
{p.description ?? humanizeCode(p.code)}
</p>
<p className="text-xs text-ink-secondary">{CRITERE_LABELS[p.critere]}</p>
</div>
<Badge variant="nclc" className="shrink-0 tabular-nums">
{p.frequency}/5
</Badge>
</Card>
</li>
))}
</ul>
)
}

View file

@ -0,0 +1,64 @@
/**
* PreparationIndexHero Sprint 3.6c.
*
* Affiche l'indice de préparation (0-100) en gros, jauge horizontale et
* message interprétatif (<40 / 40-70 / >70).
*
* Règle L : tokens Direction H exclusivement.
* Règle H : présentation pure le message vient du backend.
*/
import { Card } from '@/shared/ui/Card'
import type { PreparationIndex } from '@/entities/patterns/types'
interface Props {
index: PreparationIndex
}
function gaugeColor(score: number): string {
if (score < 40) return 'bg-danger'
if (score <= 70) return 'bg-warning'
return 'bg-success'
}
export function PreparationIndexHero({ index }: Props) {
const pct = Math.max(0, Math.min(100, index.score))
const color = gaugeColor(pct)
return (
<Card variant="raised" className="space-y-4 p-6">
<div className="flex flex-wrap items-baseline justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Indice de préparation
</p>
<p className="mt-1 tabular-nums text-ink-primary">
<span className="text-5xl font-bold">{index.score}</span>
<span className="text-2xl font-medium text-ink-secondary">/100</span>
</p>
</div>
<p className="max-w-xs text-sm leading-relaxed text-ink-primary">{index.message}</p>
</div>
<div
className="relative h-2 overflow-hidden rounded-full bg-surface"
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Indice de préparation : ${pct} sur 100`}
>
<div
className={`h-full transition-all duration-300 ${color}`}
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex justify-between text-xs text-ink-secondary tabular-nums">
<span>0</span>
<span>40</span>
<span>70</span>
<span>100</span>
</div>
</Card>
)
}

View file

@ -0,0 +1,57 @@
/**
* ProgressionPremium Sprint 3.6c.
*
* Orchestre le contenu de /progression pour un utilisateur Premium :
* - not-ready NotReadyState
* - ready Hero (indice) + PatternsList + PatternExerciceCard[] + footer
*
* Règle L : tokens Direction H exclusivement.
* Règle H : purement présentationnel data vient du parent via props.
*/
import { Card } from '@/shared/ui/Card'
import { formatRelativeDate } from '@/shared/lib/date'
import type { PatternsResponse } from '@/entities/patterns/types'
import { PreparationIndexHero } from './PreparationIndexHero'
import { PatternsList } from './PatternsList'
import { PatternExerciceCard } from './PatternExerciceCard'
import { NotReadyState } from './NotReadyState'
interface Props {
data: PatternsResponse
}
export function ProgressionPremium({ data }: Props) {
if (!data.ready) {
return <NotReadyState current={data.current} minimum={data.minimum} />
}
return (
<div className="space-y-6">
<PreparationIndexHero index={data.preparation_index} />
<section aria-label="Erreurs récurrentes">
<h2 className="mb-3 text-base font-semibold text-ink-primary">Erreurs récurrentes</h2>
<PatternsList patterns={data.patterns} />
</section>
{data.exercises.length > 0 && (
<section aria-label="Exercices long terme">
<h2 className="mb-3 text-base font-semibold text-ink-primary">Exercices long terme</h2>
<div className="space-y-3">
{data.exercises.map((ex, i) => (
<PatternExerciceCard key={`${ex.code}-${i}`} exercice={ex} />
))}
</div>
</section>
)}
<Card variant="default" className="p-3">
<p className="text-center text-xs text-ink-secondary">
Analyse basée sur vos {data.analyzed_productions} dernières productions {' '}
{formatRelativeDate(data.last_analysis)}
</p>
</Card>
</div>
)
}

View file

@ -0,0 +1,115 @@
/**
* Tests ProgressionPremium (Sprint 3.6c).
*
* Couvre les 3 états principaux du composant (le gating plan lui-même est
* géré en amont par ProgressionPage via hasAccess).
*/
import { describe, it, expect, afterEach } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { ProgressionPremium } from '../ProgressionPremium'
import type { PatternsReady, PatternsNotReady, PatternExercice } from '@/entities/patterns/types'
afterEach(cleanup)
function renderWithRouter(ui: React.ReactNode) {
return render(<MemoryRouter>{ui}</MemoryRouter>)
}
const EXERCICE: PatternExercice = {
code: 'accord_sujet_verbe',
critere: 'competence_grammaticale',
diagnostic: 'Accords fragiles sur vos 5 dernières productions.',
exercice: {
consigne: 'Corrigez la phrase suivante.',
exemple: 'les enfants joue dans le parc',
correction: 'les enfants jouent dans le parc',
astuce: 'Pointez du doigt le sujet avant de lire le verbe.',
},
}
const READY_DATA: PatternsReady = {
ready: true,
patterns: [
{
code: 'accord_sujet_verbe',
critere: 'competence_grammaticale',
frequency: 4,
description: null,
},
{ code: 'connecteurs_repetes', critere: 'coherence_cohesion', frequency: 3, description: null },
],
exercises: [EXERCICE],
preparation_index: { score: 72, message: 'Vous êtes en bonne voie pour NCLC 9+' },
analyzed_productions: 5,
last_analysis: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
}
const NOT_READY: PatternsNotReady = {
ready: false,
minimum: 5,
current: 3,
}
describe('ProgressionPremium — état not-ready', () => {
it('affiche le compteur N/5 et le CTA "Démarrer une simulation"', () => {
renderWithRouter(<ProgressionPremium data={NOT_READY} />)
expect(screen.getByText(/3\/5/)).toBeInTheDocument()
expect(screen.getByText(/encore 2 pour débloquer votre profil/i)).toBeInTheDocument()
expect(screen.getByRole('link', { name: /démarrer une simulation/i })).toHaveAttribute(
'href',
'/simulation/ee',
)
})
})
describe('ProgressionPremium — état ready', () => {
it("affiche l'indice de préparation (score + message)", () => {
renderWithRouter(<ProgressionPremium data={READY_DATA} />)
expect(screen.getByText('72')).toBeInTheDocument()
expect(screen.getByText(/NCLC 9/)).toBeInTheDocument()
})
it('affiche les 2 patterns avec leur fréquence', () => {
renderWithRouter(<ProgressionPremium data={READY_DATA} />)
expect(screen.getByText('4/5')).toBeInTheDocument()
expect(screen.getByText('3/5')).toBeInTheDocument()
// Libellés critères — chacun apparaît au moins une fois (pattern + exercice
// réutilisent le même label, donc getAllByText)
expect(screen.getAllByText(/Compétence grammaticale/i).length).toBeGreaterThan(0)
expect(screen.getByText(/Cohérence et cohésion/i)).toBeInTheDocument()
})
it("rend l'exercice avec consigne, exemple incorrect, correction et astuce", () => {
renderWithRouter(<ProgressionPremium data={READY_DATA} />)
expect(screen.getByText(EXERCICE.exercice.consigne)).toBeInTheDocument()
expect(screen.getByText(EXERCICE.exercice.exemple)).toBeInTheDocument()
expect(screen.getByText(EXERCICE.exercice.correction)).toBeInTheDocument()
expect(screen.getByText(EXERCICE.exercice.astuce)).toBeInTheDocument()
expect(screen.getByText(/astuce de relecture/i)).toBeInTheDocument()
})
it('affiche le footer "Analyse basée sur vos 5 dernières productions"', () => {
renderWithRouter(<ProgressionPremium data={READY_DATA} />)
expect(screen.getByText(/analyse basée sur vos 5 dernières productions/i)).toBeInTheDocument()
})
it('ready sans pattern : affiche le message "Aucune erreur récurrente"', () => {
const noPatterns: PatternsReady = {
...READY_DATA,
patterns: [],
exercises: [],
}
renderWithRouter(<ProgressionPremium data={noPatterns} />)
expect(screen.getByText(/aucune erreur récurrente détectée/i)).toBeInTheDocument()
// Pas de section "Exercices long terme" si exercises=[]
expect(screen.queryByText(/exercices long terme/i)).not.toBeInTheDocument()
})
})

View file

@ -0,0 +1,31 @@
/**
* Hook TanStack Query analyse des patterns (Premium).
*
* Clé `['users', 'patterns']` partagée entre `/progression` et la section
* dashboard Premium un seul appel backend pour les deux affichages.
*
* `staleTime: 60 s` l'analyse ne change que quand une nouvelle production est
* corrigée ; 60 s évite les rafraîchissements inutiles.
*
* `enabled` : ne lance la requête QUE si l'utilisateur a la feature. Évite un
* 403 parasite pour Free/Standard (la route backend refuse avec
* PLAN_INSUFFICIENT on court-circuite côté client).
*
* Règle H : aucune logique métier ici wrap pur autour de `getPatterns`.
* Règle D : le check feature utilise `hasAccess`, jamais `plan === 'premium'`.
*/
import { useQuery } from '@tanstack/react-query'
import { getPatterns } from '@/entities/patterns/api'
import { hasAccess, type Plan } from '@/entities/user/lib'
export function usePatterns(plan: Plan | undefined) {
const enabled = plan !== undefined && hasAccess(plan, 'pattern_analysis')
return useQuery({
queryKey: ['users', 'patterns'] as const,
queryFn: getPatterns,
staleTime: 60 * 1000,
enabled,
})
}

View file

@ -0,0 +1,76 @@
/**
* Page /progression Sprint 3.6c.
*
* Gating plan via `hasAccess(plan, 'pattern_analysis')` :
* - Free + Standard `BlurredProgression` (aperçu flouté + CTA upgrade)
* - Premium `ProgressionPremium` (NotReady ou contenu complet)
*
* Règle D : aucun `plan === 'xxx'` tout passe par hasAccess().
* Règle L : tokens Direction H exclusivement.
*/
import { useNavigate } from 'react-router-dom'
import { Card } from '@/shared/ui/Card'
import { Button } from '@/shared/ui/Button'
import { hasAccess } from '@/entities/user/lib'
import { usePlan } from '@/features/dashboard/hooks/usePlan'
import { usePatterns } from '../hooks/usePatterns'
import { BlurredProgression } from '../components/BlurredProgression'
import { ProgressionPremium } from '../components/ProgressionPremium'
function Skeleton() {
return (
<div className="space-y-4" aria-busy="true" aria-label="Chargement de votre profil…">
<div className="h-32 animate-pulse rounded-lg bg-surface" />
<div className="h-24 animate-pulse rounded-lg bg-surface" />
<div className="h-48 animate-pulse rounded-lg bg-surface" />
</div>
)
}
export function ProgressionPage() {
const navigate = useNavigate()
const { data: planData, isLoading: isPlanLoading } = usePlan()
const { data: patternsData, isLoading: isPatternsLoading, isError } = usePatterns(planData?.plan)
const isPremium = planData ? hasAccess(planData.plan, 'pattern_analysis') : false
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-3xl space-y-6 px-4 py-6">
<header className="space-y-1">
<h1 className="text-lg font-semibold text-ink-primary">Profil de préparation</h1>
<p className="text-sm text-ink-secondary">
Repérez vos erreurs récurrentes et travaillez-les avec des exercices ciblés.
</p>
</header>
{isPlanLoading && <Skeleton />}
{!isPlanLoading && planData && !isPremium && (
<BlurredProgression onUpgrade={() => navigate('/plan')} />
)}
{!isPlanLoading && planData && isPremium && (
<>
{isPatternsLoading && <Skeleton />}
{isError && (
<Card variant="default" className="border-l-4 border-l-danger p-4">
<p className="text-sm text-danger" role="alert">
Impossible de charger votre profil de préparation. Réessayez dans quelques
instants.
</p>
<div className="mt-3">
<Button variant="secondary" size="sm" onClick={() => navigate(0)}>
Rafraîchir
</Button>
</div>
</Card>
)}
{patternsData && <ProgressionPremium data={patternsData} />}
</>
)}
</main>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show more