Compare commits
95 commits
sprint-0.5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dd8df53e1 | |||
| 01f052dadc | |||
| d9160c4493 | |||
| 3016d909a6 | |||
| eb8987ddb3 | |||
| 713d830be0 | |||
| 044a305019 | |||
| 72795e924e | |||
| 9bf95f5c05 | |||
| b8eed80708 | |||
| 1d95166611 | |||
| 7862f7c9f3 | |||
| 5a31819bca | |||
| 3a3fa6272d | |||
| de16deede3 | |||
| bda7feb196 | |||
| 9edfbb3c95 | |||
| 04019f8348 | |||
| 3ce91aaa7b | |||
| d8bae9520c | |||
| 9614f9de14 | |||
| 06fbfe3f9b | |||
| 822b02a2d1 | |||
| 5188714235 | |||
| 8175438eea | |||
| 9ddb3dc24a | |||
| d1c8b548bb | |||
| 71c1ad3018 | |||
| 944d2803a2 | |||
| 4005673ae8 | |||
| b68f160bce | |||
| 407d1bd134 | |||
| cab9c8c92b | |||
| bc2a1174d1 | |||
| e72d68513a | |||
| 99617f117c | |||
| 79bbbdc4e8 | |||
| de69b3ff16 | |||
| 39b633d1e3 | |||
| d7321c868e | |||
| 1c84844108 | |||
| 5fdc4ee0ef | |||
| 04dfbe2731 | |||
| 2a6ea10978 | |||
| a0352457dc | |||
| a60c298605 | |||
| a752029c19 | |||
| da4e465125 | |||
| f51caa1b75 | |||
| 8390e8b873 | |||
| 18f92098cb | |||
| 385b29679e | |||
| 656b42e6c4 | |||
| ae8d8af1df | |||
| aaecc3f804 | |||
| 549e5f698f | |||
| d395a04193 | |||
| 95711a7c44 | |||
| 886ecbb433 | |||
| dee3c181f6 | |||
| 67eb3411c5 | |||
| 555dac17e2 | |||
| 4712a3a16e | |||
| 6bfdf15db9 | |||
| 43f3ce2c6c | |||
| a6f95c2093 | |||
| 782439b309 | |||
| 7902eec042 | |||
| 477477b6a6 | |||
| 4245d0bcf1 | |||
| 021b9d35ea | |||
| c5b433749d | |||
| 41d2eec3f7 | |||
| 24968f542d | |||
| 869668a1ba | |||
| 4f786dd44b | |||
| 6a40e9a4c0 | |||
| e449661ee0 | |||
| b356bc7109 | |||
| e130d3792e | |||
| b16dbfa1c8 | |||
| fb3de2865f | |||
| ef86da85d7 | |||
| 47d5ec9524 | |||
| 1dbca24c35 | |||
| d7b084d05a | |||
| 8450265449 | |||
| 997f39bd33 | |||
| b31e8666a5 | |||
| ca4291d7eb | |||
| bf778a5a4d | |||
| d0f77e04f9 | |||
| 464eb27f1e | |||
| 38777796aa | |||
| 107a37d197 |
202 changed files with 24510 additions and 619 deletions
42
.cursor/skills/frontend-design/SKILL.md
Normal file
42
.cursor/skills/frontend-design/SKILL.md
Normal 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.
|
||||
|
|
@ -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
7
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
|
|
@ -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
3
.gitignore
vendored
|
|
@ -30,3 +30,6 @@ dist-ssr
|
|||
|
||||
# Claude Code local config
|
||||
.claude/
|
||||
|
||||
# Exploration DA temporaire — supprimer une fois la direction choisie
|
||||
design-exploration/
|
||||
|
|
|
|||
927
design-reference/direction-h-dark.html
Normal file
927
design-reference/direction-h-dark.html
Normal 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>
|
||||
921
design-reference/direction-h-juste-milieu.html
Normal file
921
design-reference/direction-h-juste-milieu.html
Normal 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>
|
||||
|
|
@ -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/` |
|
||||
|
|
|
|||
|
|
@ -29,9 +29,923 @@ Chaque entrée suit ce format :
|
|||
|
||||
---
|
||||
|
||||
## [Unreleased] — 2026-07-02 — Sprint 7.5 — Clean FTD-44
|
||||
|
||||
### Changed
|
||||
|
||||
- Hooks audio génériques `useAudioCapture`, `useAudioPlayback`, `useAudioRecording` (+ test) déplacés de `features/t2-live/hooks/` vers `shared/lib/audio/`. Imports mis à jour dans `useT2LiveSession.ts` et `useT1LiveSession.ts`. Résout FTD-44.
|
||||
|
||||
### Removed
|
||||
|
||||
- Marqueurs `// TODO(FTD-44)` retirés de `useT1LiveSession.ts`.
|
||||
|
||||
### Notes
|
||||
|
||||
- 4 fichiers déplacés (3 hooks + 1 test) + 2 fichiers d'imports édités. Aucun changement fonctionnel (pipeline audio « Voie A » intact).
|
||||
- 301/301 tests verts, 0 erreur typecheck.
|
||||
- Validation manuelle : T2 Live D2-D5 verts ; T1 Live parcours complet vert (préparation → dialogue → présentation → interruption → fin → téléchargement audio → rapport), navigation privée. D6 non rejoué (optionnel, état pré-existant partiel, sans lien avec cette refacto).
|
||||
- Commit `d9160c4`.
|
||||
- **TECH_DEBT.md v1.30 → v1.31** : FTD-44 résolue (fermée) ; ajout FTD-47 🟢 (sessions T1 Live non taguées dans l'historique — découverte pendant cette session, racine Sprint 7a backend). **14 → 15 FTD actives — cap de 15 atteint.**
|
||||
- **GOLDEN_DATASET.md** : Groupe D étendu avec D12-D16 (T1 Live) — libellés vérifiés dans le code réel (`T1PreparationPage.tsx`, `T1DialoguePage.tsx`, `t1-machine.ts`).
|
||||
|
||||
---
|
||||
|
||||
## [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
148
docs/DEPLOYMENT.md
Normal 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
507
docs/DESIGN_SYSTEM.md
Normal 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` à 4–5% opacité) |
|
||||
| Fond principal (light) | `#F3F4F6` avec deux halos bleus très discrets (2–3% 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 | 150–200 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), 4–5 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é.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -16,120 +16,135 @@
|
|||
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) | |
|
||||
| D12 | Accès à EO T1 Live → page préparation | test.premium | Carte « Tâche 1 — Live » débloquée sur `/simulation/eo` → clic → `/simulation/eo/t1/live/preparation` (titre « Préparation — Tâche 1 Live », bouton « Je suis prêt — démarrer la présentation ») | |
|
||||
| D13 | Démarrer la présentation | test.premium | Passage à `/simulation/eo/t1/live/dialogue`, état `presenting` (« À vous — présentez-vous. ») ; le candidat parle en premier — monologue, pas de VAD micro | |
|
||||
| D14 | Interruption examinateur (non déterministe) | test.premium | Si déclenchée par le backend : état `interrupted` (« L'examinateur vous interrompt — répondez-lui. »), reprise en `presenting` — ⚠️ non garantie à chaque essai (probabiliste, cf. note D12-D16) | |
|
||||
| D15 | Fin → écran terminal | test.premium | Bouton « Terminer » → état `processing` (« Évaluation en cours… ») → écran « Session terminée » avec boutons « Télécharger l'audio » et « Voir le rapport » | |
|
||||
| D16 | Rapport après T1 Live | test.premium | Rapport complet affiché (`/rapport/:id`) — ⚠️ aucun tag « T1 Live » visible (à la différence du tag « T2 Live »), cf. note D12-D16 | |
|
||||
|
||||
> **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.
|
||||
|
||||
> **Note D12-D16 (T1 Live)** : D14 dépend d'une décision probabiliste backend (P0=0.2 aucune interruption, P1=0.6 une, P2=0.2 deux, fenêtre 25-75 s) — un essai sans interruption est un résultat nominal, pas un échec de test. Deux dettes amont affectent le réalisme du dialogue sans bloquer ces tests : **FTD-45** (gelée, relances de l'examinateur parfois hors-sujet, extension TD-23) et **FTD-46** (gelée, transcription Gemini Live parfois imprécise) — toutes deux hors contrôle frontend.
|
||||
>
|
||||
> **D16 — divergence vérifiée dans le code** : contrairement à T2 Live (`tache='EO_T2_LIVE'`, libellé « EO · Tâche 2 Live » dans l'historique), le backend T1 Live persiste `tache='EO_T1'` / `mode='entrainement'` (`t1live.ts`) — identique à une simulation T1 batch classique. Vérifié dans `historique.ts` (`TACHE_NUMBER`) et `RapportPage.tsx` : **aucun tag « Live » n'existe** côté frontend pour T1. Tracé dans `TECH_DEBT.md` (**FTD-47**, nouvelle, 🟢 Mineur).
|
||||
|
||||
---
|
||||
|
||||
## 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 +152,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 +168,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 +205,14 @@ 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 |
|
||||
| 1.1 | 2026-07-02 | Ajout Groupe D étendu T1 Live (D12-D16) — Sprint 7.5 Clean (FTD-44) |
|
||||
|
|
|
|||
|
|
@ -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'`.
|
||||
|
|
|
|||
233
docs/ROADMAP.md
Normal file
233
docs/ROADMAP.md
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
# 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 7c — Migration audio EO Live : Gemini Live → Deepgram (T1 + T2)
|
||||
|
||||
- Décision actée (**ADR-007**) de migrer l'architecture audio de Gemini Live natif vers une architecture découplée Deepgram (STT nova-3 + DeepSeek LLM + TTS Aura-2), suite à un POC concluant : transcription quasi parfaite à débit lent (vs confusions de langue/omissions Gemini), relances ancrées sur le discours réel du candidat, coût 4-5x inférieur.
|
||||
- Implémentation en deux temps : (1) construction du module Deepgram en parallèle de Gemini Live derrière un flag `EO_STT_PROVIDER=gemini|deepgram` pour comparaison en conditions réelles, (2) bascule complète + retrait du code Gemini Live (`geminiLive.ts`, usages `t1live.ts`/`t2live.ts`).
|
||||
- Résout FTD-45, FTD-46 (frontend, gelées avec renvoi vers cet ADR — cf. `TECH_DEBT.md`) et TD-22/TD-23 (backend, hors scope frontend — dépôt séparé).
|
||||
- Découpage fin (sous-sprints backend/frontend) à définir dans une session de planification dédiée avant exécution — non détaillé ici.
|
||||
|
||||
## 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.
|
||||
- **Note (ajoutée 2026-07-02)** : à réévaluer/simplifier une fois la migration **Sprint 7c** (Deepgram, ADR-007) effectuée — le caveat TD-23 ci-dessus disparaît avec un provider nativement streaming. Ce sprint pourrait devenir substantiellement plus simple, voire trivial, une fois 7c terminé.
|
||||
|
||||
## 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
309
docs/TAXONOMIE_ERREURS.md
Normal 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` |
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# TECH_DEBT.md — Expria Frontend
|
||||
|
||||
> **Document de référence — Version 1.2**
|
||||
> **Document de référence — Version 1.32**
|
||||
> 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)` où `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,249 @@ 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é.
|
||||
|
||||
---
|
||||
|
||||
### FTD-47 — Sessions T1 Live non taguées dans l'historique
|
||||
|
||||
**Priorité :** 🟢 Mineur
|
||||
**Statut :** Ouvert — introduit Sprint 7a (backend), découvert Sprint 7.5
|
||||
**Estimation de session :** à évaluer (cross-repo backend + frontend)
|
||||
**Description :** Contrairement à T2 Live (`tache='EO_T2_LIVE'`, tag « T2 Live » visible dans l'historique), le backend T1 Live persiste `tache='EO_T1'` / `mode='entrainement'` (`t1live.ts`) — identique à une simulation T1 batch classique. Aucun champ ou tag ne permet de distinguer une session dialoguée en direct avec l'examinateur IA d'un simple enregistrement offline. Vérifié dans `historique.ts` (`TACHE_NUMBER`) et `RapportPage.tsx` : aucun tag « Live » n'existe côté frontend pour T1.
|
||||
|
||||
**Impact :** l'utilisateur ne peut pas retrouver ses sessions T1 Live dans son historique — confusion possible, et T1 Live (feature Premium différenciante) perd sa visibilité propre.
|
||||
|
||||
**À faire :** décision d'architecture — nouvelle valeur `tache` (ex. `EO_T1_LIVE`, migration cross-repo comme T2) ou champ booléen dédié (`is_live`). Nécessite une session backend + frontend coordonnée. Hors scope Sprint 7.5 (doc-only).
|
||||
|
||||
**Session concernée :** découverte Sprint 7.5, racine Sprint 7a.
|
||||
|
||||
**Note :** la racine du problème est backend (`t1live.ts`). Une entrée miroir dans `expria-backend/docs/TECH_DEBT-backend.md` serait pertinente mais n'a pas été créée (hors scope repo de cette session).
|
||||
|
||||
**Condition de résolution :** décision d'architecture prise + migration cross-repo implémentée.
|
||||
|
||||
---
|
||||
|
||||
## 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 +355,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 +440,83 @@ 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 résolue au Sprint 7.5 (2026-07-02) — voir §5 Historique des résolutions.
|
||||
|
||||
---
|
||||
|
||||
### 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). **Piste de résolution actée : voir ADR-007 (migration Gemini Live → Deepgram). Sera dégelée et réévaluée au démarrage du Sprint 7c.**
|
||||
|
||||
**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. **Piste de résolution actée : voir ADR-007 (migration Gemini Live → Deepgram). Sera dégelée et réévaluée au démarrage du Sprint 7c.**
|
||||
|
||||
**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 +525,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 +547,69 @@ 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-44 | Hooks audio génériques empruntés à `features/t2-live/` (T1 Live) | 2026-07-02 | Sprint 7.5 Clean — `useAudioCapture`, `useAudioPlayback`, `useAudioRecording` (+ test) déplacés de `features/t2-live/hooks/` vers `shared/lib/audio/`. Imports mis à jour dans `useT2LiveSession.ts` et `useT1LiveSession.ts`, marqueurs `TODO(FTD-44)` retirés. Dérogation Règle B assumée sur la vague `useAudioRecording` (hook + test déplacés ensemble pour préserver le chemin d'import relatif `../useAudioRecording`, condition nécessaire au respect de Règle C — jamais rouge entre étapes). Validation à l'oreille manuelle : T2 Live D2-D5 verts, T1 Live parcours complet vert. 301/301 tests, 0 erreur typecheck. Commit `d9160c4`. |
|
||||
| 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). |
|
||||
| 1.31 | 2026-07-02 | Sprint 7.5 Clean — FTD-44 résolue (hooks audio relocalisés vers `shared/lib/audio/`, dégelée → fermée ; ne libère pas de place, une entrée gelée ne comptait pas dans le cap). Ajout FTD-47 🟢 (sessions T1 Live non taguées dans l'historique — découverte Sprint 7.5, racine Sprint 7a backend). **14 → 15 FTD actives — cap de 15 atteint.** |
|
||||
| 1.32 | 2026-07-02 | ADR-007 — décision actée de migration Gemini Live → Deepgram (T1 + T2 Live, voir Sprint 7c dans ROADMAP.md). FTD-45 et FTD-46 : champ « À faire » complété avec un renvoi vers ADR-007, restent gelées en l'état (§3bis). **15 FTD actives, inchangé — FTD-45/46 restent gelées avec renvoi ADR-007, dégel prévu à l'ouverture du Sprint 7c.** |
|
||||
|
|
|
|||
|
|
@ -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 :
|
||||
|
|
|
|||
67
docs/adr/007-deepgram-vs-gemini-eo-live.md
Normal file
67
docs/adr/007-deepgram-vs-gemini-eo-live.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# ADR 007 — Migration architecture audio EO Live : Gemini Live → Deepgram
|
||||
|
||||
**Statut :** Accepté
|
||||
**Date :** 2026-07-02
|
||||
**Décideur :** Hermann
|
||||
|
||||
---
|
||||
|
||||
## Contexte
|
||||
|
||||
L'Expression Orale Live (T1 + T2) repose depuis les Sprints 6-7 sur Gemini Live (modèle audio-à-audio natif, `geminiLive.ts` côté backend). Cette architecture présente un défaut rédhibitoire : une transcription de mauvaise qualité qui biaise systématiquement les corrections DeepSeek vers la sévérité (**FTD-46**) — un problème de fiabilité produit, pas cosmétique, sur un service qui évalue un examen officiel. Gemini pose également des questions de relance sans lien avec le discours du candidat (**FTD-45**, T1 Live). Ces deux défauts sont eux-mêmes une extension de la dette backend **TD-23** (en VAD manuel, `inputTranscription` n'est flushé qu'à `activityEnd`, donc sans transcription token-par-token fiable pour ancrer les relances).
|
||||
|
||||
Un test comparatif (POC) a été mené entre l'architecture actuelle et une architecture découplée Deepgram (STT nova-3 + DeepSeek LLM + TTS Aura-2), pour évaluer si ces défauts sont propres à Gemini ou inhérents au produit.
|
||||
|
||||
## Résultats du test comparatif (POC)
|
||||
|
||||
- **Transcription** : à débit de parole lent du candidat, Deepgram approche la perfection sur la tâche T1 Live (quasi 100 %). Gemini produit des confusions de langue et des omissions de phrases entières en conditions réelles.
|
||||
- **Relances/interruptions** : Deepgram (LLM + orchestration) pose des questions ancrées sur le discours réel du candidat, contrairement à Gemini qui relance hors-sujet.
|
||||
- **Coût** : Deepgram + DeepSeek + Aura-2 ≈ 0,015-0,020 $/session de 2 min vs Gemini Live ≈ 0,075-0,080 $/session — 4-5x moins cher. Réserve : tarifs volatils, le calcul Gemini n'inclut pas la réaccumulation de contexte par tour — à revérifier avant tout engagement budgétaire.
|
||||
- **Seul point faible identifié** : débit vocal Aura-2 FR légèrement lent et non naturel à T2, aucun paramètre de vitesse fiable trouvé sans risque de régression. Jugé préférable malgré tout à un débit naturel qui s'accompagne de confusions de langue et d'omissions de phrases côté Gemini — compromis assumé, pas un point bloquant.
|
||||
- **Détail d'implémentation validé sur le POC** (Fable 5, agent de code) : succès one-shot sur T1 (contrainte d'ancrage sur tout le monologue, point d'échec de Gemini) ; T2 a nécessité du debug itératif mais les causes étaient des artefacts d'exécution (build obsolète), pas des limites structurelles.
|
||||
|
||||
## Options envisagées
|
||||
|
||||
### Option A — Statu quo : Gemini Live
|
||||
|
||||
- Avantages : modèle unique audio-à-audio natif, pas d'orchestration multi-service, déjà en production (T1 + T2 livrés Sprints 6-7).
|
||||
- Inconvénients : transcription de qualité inégale biaisant systématiquement les corrections DeepSeek vers la sévérité (FTD-46) ; relances hors-sujet en T1 (FTD-45) ; caveat TD-23 (flush transcription à `activityEnd` seulement) bloquant l'affichage incrémental prévu au Sprint 7e.
|
||||
|
||||
### Option B — Architecture découplée Deepgram (STT nova-3 + DeepSeek LLM + TTS Aura-2)
|
||||
|
||||
- Avantages : transcription quasi parfaite à débit lent (T1), relances ancrées sur le discours réel du candidat, coût 4-5x inférieur, nativement streaming (résout TD-23 sans contournement), POC one-shot réussi sur T1.
|
||||
- Inconvénients : complexité d'orchestration accrue (3 services au lieu d'1), débit vocal Aura-2 FR légèrement lent/non naturel à T2 (aucun paramètre de vitesse fiable trouvé), T2 a nécessité du debug itératif (causes non structurelles), tarifs à revérifier avant engagement budgétaire.
|
||||
|
||||
## Décision
|
||||
|
||||
**Option B** — migration actée de Gemini Live vers Deepgram, pour **T1 ET T2 Live**.
|
||||
|
||||
## Plan d'implémentation en deux temps
|
||||
|
||||
1. **Temps 1 — Construction en parallèle** : construire le module Deepgram à côté de Gemini Live, activé par un flag `EO_STT_PROVIDER=gemini|deepgram`, pour permettre des tests comparatifs en conditions réelles avant toute bascule définitive.
|
||||
2. **Temps 2 — Bascule + retrait** : une fois la comparaison validée, bascule complète sur Deepgram et **retrait du code Gemini Live** (`geminiLive.ts` et son usage dans `t1live.ts` / `t2live.ts` côté backend).
|
||||
|
||||
**Clause de non-maintien à long terme** : le flag `EO_STT_PROVIDER` est un outil de transition de sprint, pas une fonctionnalité permanente. Aucune intention de maintenir les deux architectures en parallèle à long terme.
|
||||
|
||||
## Conséquences
|
||||
|
||||
**Positives :**
|
||||
|
||||
- Résout FTD-45 et FTD-46 à la racine.
|
||||
- Résout également le caveat TD-23 du Sprint 7e (flush `inputTranscription` à `activityEnd` seulement) — Deepgram est nativement streaming, l'affichage incrémental candidat devient possible sans contournement.
|
||||
|
||||
**Négatives :**
|
||||
|
||||
- Complexité d'orchestration accrue pendant la phase de transition (3 services au lieu d'1) — mitigée par le fait que le flag est temporaire, pas un choix d'architecture permanent à deux branches.
|
||||
- Travail de retrait à prévoir en fin de migration : suppression du code Gemini Live une fois Deepgram validé (hors scope de cette session doc-only).
|
||||
|
||||
**À revisiter si :**
|
||||
|
||||
- Les tarifs Deepgram / DeepSeek / Aura-2 évoluent défavorablement (réserve explicite sur la volatilité tarifaire et sur le calcul Gemini incomplet ci-dessus).
|
||||
- Un paramètre de vitesse Aura-2 FR fiable devient disponible (lèverait le seul point faible identifié).
|
||||
|
||||
## Références
|
||||
|
||||
- `TECH_DEBT.md` FTD-45, FTD-46 (renvoi vers cet ADR), FTD-47
|
||||
- `ROADMAP.md` Sprint 7c (migration, à planifier en détail dans une session dédiée)
|
||||
- `expria-backend/docs/TECH_DEBT-backend.md` TD-22, TD-23 (dette backend directement concernée — non modifiée dans cette session, dépôt séparé)
|
||||
13
index.html
13
index.html
|
|
@ -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
1172
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
80
public/pcm-capture-processor.js
Normal file
80
public/pcm-capture-processor.js
Normal 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)
|
||||
61
public/pcm-record-processor.js
Normal file
61
public/pcm-record-processor.js
Normal 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
195
scripts/sync-roadmap.mjs
Normal 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
89
src/app/AppLayout.tsx
Normal 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
139
src/app/BottomNav.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
24
src/app/MaintenancePage.tsx
Normal file
24
src/app/MaintenancePage.tsx
Normal 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
233
src/app/Sidebar.tsx
Normal 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
86
src/app/Topbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
31
src/app/route-titles.ts
Normal 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'
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
23
src/entities/patterns/api.ts
Normal file
23
src/entities/patterns/api.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
52
src/entities/patterns/types.ts
Normal file
52
src/entities/patterns/types.ts
Normal 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
|
||||
59
src/entities/presentation/__tests__/api.test.ts
Normal file
59
src/entities/presentation/__tests__/api.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
23
src/entities/presentation/api.ts
Normal file
23
src/entities/presentation/api.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
23
src/entities/presentation/types.ts
Normal file
23
src/entities/presentation/types.ts
Normal 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
|
||||
}
|
||||
114
src/entities/production/api.ts
Normal file
114
src/entities/production/api.ts
Normal 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)
|
||||
}
|
||||
30
src/entities/production/lib.ts
Normal file
30
src/entities/production/lib.ts
Normal 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_')
|
||||
}
|
||||
166
src/entities/production/types.ts
Normal file
166
src/entities/production/types.ts
Normal 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). Ré-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
|
||||
}
|
||||
107
src/entities/report/__tests__/floutage.test.ts
Normal file
107
src/entities/report/__tests__/floutage.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
63
src/entities/report/__tests__/getMaxScorePerCritere.test.ts
Normal file
63
src/entities/report/__tests__/getMaxScorePerCritere.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
97
src/entities/report/api.ts
Normal file
97
src/entities/report/api.ts
Normal 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
128
src/entities/report/lib.ts
Normal 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',
|
||||
}
|
||||
167
src/entities/report/types.ts
Normal file
167
src/entities/report/types.ts
Normal 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'
|
||||
49
src/entities/transcription/__tests__/api.test.ts
Normal file
49
src/entities/transcription/__tests__/api.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
21
src/entities/transcription/api.ts
Normal file
21
src/entities/transcription/api.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
15
src/entities/transcription/types.ts
Normal file
15
src/entities/transcription/types.ts
Normal 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
22
src/entities/user/api.ts
Normal 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')
|
||||
}
|
||||
13
src/entities/user/query-keys.ts
Normal file
13
src/entities/user/query-keys.ts
Normal 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
|
||||
32
src/entities/user/types.ts
Normal file
32
src/entities/user/types.ts
Normal 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 }
|
||||
67
src/features/account/pages/ParametresPage.tsx
Normal file
67
src/features/account/pages/ParametresPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
src/features/auth/components/ProtectedRoute.tsx
Normal file
42
src/features/auth/components/ProtectedRoute.tsx
Normal 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}</>
|
||||
}
|
||||
50
src/features/auth/hooks/useAuth.ts
Normal file
50
src/features/auth/hooks/useAuth.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
135
src/features/auth/pages/LoginPage.tsx
Normal file
135
src/features/auth/pages/LoginPage.tsx
Normal 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
|
||||
201
src/features/auth/pages/RegisterPage.tsx
Normal file
201
src/features/auth/pages/RegisterPage.tsx
Normal 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
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
154
src/features/billing/__tests__/PricingPage.test.tsx
Normal file
154
src/features/billing/__tests__/PricingPage.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
97
src/features/billing/__tests__/useCustomerPortal.test.tsx
Normal file
97
src/features/billing/__tests__/useCustomerPortal.test.tsx
Normal 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 d’abord à 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
107
src/features/billing/__tests__/useStripeCheckout.test.tsx
Normal file
107
src/features/billing/__tests__/useStripeCheckout.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
51
src/features/billing/api.ts
Normal file
51
src/features/billing/api.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
86
src/features/billing/components/AccountBillingSection.tsx
Normal file
86
src/features/billing/components/AccountBillingSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
105
src/features/billing/components/PlanCard.tsx
Normal file
105
src/features/billing/components/PlanCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
src/features/billing/hooks/useCustomerPortal.ts
Normal file
55
src/features/billing/hooks/useCustomerPortal.ts
Normal 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` (Standard→Premium) : 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 d’ouvrir l’espace 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,
|
||||
}
|
||||
}
|
||||
59
src/features/billing/hooks/useStripeCheckout.ts
Normal file
59
src/features/billing/hooks/useStripeCheckout.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
240
src/features/billing/pages/PricingPage.tsx
Normal file
240
src/features/billing/pages/PricingPage.tsx
Normal 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 l’examinateur 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 l’accè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>
|
||||
)
|
||||
}
|
||||
111
src/features/dashboard/components/DashboardFreeView.tsx
Normal file
111
src/features/dashboard/components/DashboardFreeView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
96
src/features/dashboard/components/DashboardPremiumView.tsx
Normal file
96
src/features/dashboard/components/DashboardPremiumView.tsx
Normal 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 0–100 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>
|
||||
)
|
||||
}
|
||||
102
src/features/dashboard/components/DashboardStandardView.tsx
Normal file
102
src/features/dashboard/components/DashboardStandardView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
src/features/dashboard/components/MonProfilPreparation.tsx
Normal file
117
src/features/dashboard/components/MonProfilPreparation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
159
src/features/dashboard/components/NclcHero.tsx
Normal file
159
src/features/dashboard/components/NclcHero.tsx
Normal 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 (5–10). `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>
|
||||
)
|
||||
}
|
||||
65
src/features/dashboard/components/NextStepCard.tsx
Normal file
65
src/features/dashboard/components/NextStepCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
src/features/dashboard/components/PaywallBanner.tsx
Normal file
42
src/features/dashboard/components/PaywallBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
src/features/dashboard/components/RecentSimulations.tsx
Normal file
97
src/features/dashboard/components/RecentSimulations.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
190
src/features/dashboard/components/StatCards.tsx
Normal file
190
src/features/dashboard/components/StatCards.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
src/features/dashboard/components/UpgradeSuccessBanner.tsx
Normal file
41
src/features/dashboard/components/UpgradeSuccessBanner.tsx
Normal 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 été 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
25
src/features/dashboard/hooks/usePlan.ts
Normal file
25
src/features/dashboard/hooks/usePlan.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
60
src/features/dashboard/hooks/useUpgradeSuccessHandler.ts
Normal file
60
src/features/dashboard/hooks/useUpgradeSuccessHandler.ts
Normal 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 }
|
||||
}
|
||||
102
src/features/dashboard/pages/DashboardPage.tsx
Normal file
102
src/features/dashboard/pages/DashboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
189
src/features/historique/__tests__/lib.test.ts
Normal file
189
src/features/historique/__tests__/lib.test.ts
Normal 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 (0–30j) : 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 (30–60j) : 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')
|
||||
})
|
||||
})
|
||||
129
src/features/historique/components/HistoriqueFilters.tsx
Normal file
129
src/features/historique/components/HistoriqueFilters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
118
src/features/historique/components/HistoriqueStats.tsx
Normal file
118
src/features/historique/components/HistoriqueStats.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
src/features/historique/components/SimulationListItem.tsx
Normal file
65
src/features/historique/components/SimulationListItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
125
src/features/historique/components/SimulationsList.tsx
Normal file
125
src/features/historique/components/SimulationsList.tsx
Normal 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
|
||||
* été 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 été 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()
|
||||
})
|
||||
})
|
||||
27
src/features/historique/hooks/useSimulationsList.ts
Normal file
27
src/features/historique/hooks/useSimulationsList.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
159
src/features/historique/lib/historique.ts
Normal file
159
src/features/historique/lib/historique.ts
Normal 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'
|
||||
}
|
||||
103
src/features/historique/pages/HistoriquePage.tsx
Normal file
103
src/features/historique/pages/HistoriquePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
src/features/progression/components/BlurredProgression.tsx
Normal file
45
src/features/progression/components/BlurredProgression.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
src/features/progression/components/NotReadyState.tsx
Normal file
59
src/features/progression/components/NotReadyState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
src/features/progression/components/PatternExerciceCard.tsx
Normal file
91
src/features/progression/components/PatternExerciceCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
src/features/progression/components/PatternsList.tsx
Normal file
53
src/features/progression/components/PatternsList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
src/features/progression/components/PreparationIndexHero.tsx
Normal file
64
src/features/progression/components/PreparationIndexHero.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
src/features/progression/components/ProgressionPremium.tsx
Normal file
57
src/features/progression/components/ProgressionPremium.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
31
src/features/progression/hooks/usePatterns.ts
Normal file
31
src/features/progression/hooks/usePatterns.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue