style: prettier format

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-23 03:17:16 +03:00
parent 79bbbdc4e8
commit 99617f117c
45 changed files with 229 additions and 302 deletions

View file

@ -71,9 +71,7 @@ export function AppLayout({ children }: AppLayoutProps) {
{/* ── Zone de contenu ────────────────────────────────────────── */} {/* ── Zone de contenu ────────────────────────────────────────── */}
{/* pb-16 sur mobile pour ne pas être masqué par le BottomNav fixe */} {/* pb-16 sur mobile pour ne pas être masqué par le BottomNav fixe */}
<div className="pb-16 lg:pl-60 lg:pb-0"> <div className="pb-16 lg:pl-60 lg:pb-0">{children}</div>
{children}
</div>
{/* ── MOBILE — BottomNav fixe ────────────────────────────────── */} {/* ── MOBILE — BottomNav fixe ────────────────────────────────── */}
<BottomNav /> <BottomNav />

View file

@ -16,8 +16,8 @@ import { cn } from '@/shared/lib/utils'
const SHEET_ITEMS = [ const SHEET_ITEMS = [
{ label: 'Expression Écrite', to: '/simulation/ee' }, { label: 'Expression Écrite', to: '/simulation/ee' },
{ label: 'Expression Orale', to: '/simulation/eo' }, { label: 'Expression Orale', to: '/simulation/eo' },
{ label: 'Examen blanc', to: '/examen' }, { label: 'Examen blanc', to: '/examen' },
] as const ] as const
export function BottomNav() { export function BottomNav() {
@ -102,10 +102,7 @@ export function BottomNav() {
)} )}
> >
<BookOpen <BookOpen
className={cn( className={cn('size-5', (isActive('/simulation') || isSheetOpen) && 'text-expria')}
'size-5',
(isActive('/simulation') || isSheetOpen) && 'text-expria',
)}
aria-hidden="true" aria-hidden="true"
/> />
Simuler Simuler

View file

@ -21,18 +21,18 @@ interface NavItem {
} }
const PREPARE_ITEMS: readonly NavItem[] = [ const PREPARE_ITEMS: readonly NavItem[] = [
{ label: 'Tableau de bord', to: '/dashboard', feature: null }, { label: 'Tableau de bord', to: '/dashboard', feature: null },
{ label: 'Expression Écrite', to: '/simulation/ee', feature: null }, { label: 'Expression Écrite', to: '/simulation/ee', feature: null },
{ label: 'Expression Orale', to: '/simulation/eo', feature: null }, { label: 'Expression Orale', to: '/simulation/eo', feature: null },
{ label: 'Examen blanc', to: '/examen', feature: 'exam_mode' }, { label: 'Examen blanc', to: '/examen', feature: 'exam_mode' },
{ label: 'Progression', to: '/progression', feature: 'pattern_analysis' }, { label: 'Progression', to: '/progression', feature: 'pattern_analysis' },
{ label: 'Méthodologie', to: '/methodologie', feature: null }, { label: 'Méthodologie', to: '/methodologie', feature: null },
{ label: 'Historique', to: '/historique', feature: 'dashboard' }, { label: 'Historique', to: '/historique', feature: 'dashboard' },
] ]
const ACCOUNT_ITEMS: readonly NavItem[] = [ const ACCOUNT_ITEMS: readonly NavItem[] = [
{ label: 'Mon plan', to: '/plan', feature: null }, { label: 'Mon plan', to: '/plan', feature: null },
{ label: 'Paramètres', to: '/parametres', feature: null }, { label: 'Paramètres', to: '/parametres', feature: null },
] ]
function SidebarItem({ item, plan }: { item: NavItem; plan: Plan }) { function SidebarItem({ item, plan }: { item: NavItem; plan: Plan }) {
@ -48,8 +48,8 @@ function SidebarItem({ item, plan }: { item: NavItem; plan: Plan }) {
isActive && !locked isActive && !locked
? 'bg-expria-50 font-medium text-expria' ? 'bg-expria-50 font-medium text-expria'
: locked : locked
? 'cursor-default text-ink-4 opacity-50' ? 'cursor-default text-ink-4 opacity-50'
: 'text-ink-3 hover:bg-canvas hover:text-ink-1', : 'text-ink-3 hover:bg-canvas hover:text-ink-1',
) )
} }
> >

View file

@ -11,7 +11,5 @@ if (!container) {
} }
createRoot(container).render( createRoot(container).render(
<StrictMode> <StrictMode>{isMaintenanceMode ? <MaintenancePage /> : <Providers />}</StrictMode>,
{isMaintenanceMode ? <MaintenancePage /> : <Providers />}
</StrictMode>,
) )

View file

@ -48,34 +48,34 @@ export function AppRouter() {
return ( return (
<Routes> <Routes>
{/* ── Routes publiques ─────────────────────────────────────── */} {/* ── Routes publiques ─────────────────────────────────────── */}
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={<RegisterPage />} />
{/* ── Routes privées — ProtectedRoute + AppLayout ──────────── */} {/* ── Routes privées — ProtectedRoute + AppLayout ──────────── */}
<Route element={<PrivateLayout />}> <Route element={<PrivateLayout />}>
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} /> <Route path="/dashboard" element={<DashboardPage />} />
{/* Simulation /simulation/ee, /sujets et /rapport/:id partagent le {/* Simulation /simulation/ee, /sujets et /rapport/:id partagent le
SimulationFlowProvider. L'instance est préservée entre ces routes SimulationFlowProvider. L'instance est préservée entre ces routes
par React Router tant que le layout parent reste monté, ce qui par React Router tant que le layout parent reste monté, ce qui
permet à RapportPage.reset() d'agir sur le même state que permet à RapportPage.reset() d'agir sur le même state que
SimulationPage (bouton « Nouvelle simulation » + breadcrumb). */} SimulationPage (bouton « Nouvelle simulation » + breadcrumb). */}
<Route path="/simulation" element={<Navigate to="/simulation/ee" replace />} /> <Route path="/simulation" element={<Navigate to="/simulation/ee" replace />} />
<Route element={<SimulationFlowLayout />}> <Route element={<SimulationFlowLayout />}>
<Route path="/simulation/ee" element={<SimulationPage />} /> <Route path="/simulation/ee" element={<SimulationPage />} />
<Route path="/sujets" element={<SujetsPage />} /> <Route path="/sujets" element={<SujetsPage />} />
<Route path="/rapport/:id" element={<RapportPage />} /> <Route path="/rapport/:id" element={<RapportPage />} />
</Route> </Route>
<Route path="/simulation/eo" element={<ComingSoon />} /> <Route path="/simulation/eo" element={<ComingSoon />} />
{/* Autres sections — Sprint 4+ */} {/* Autres sections — Sprint 4+ */}
<Route path="/examen" element={<ComingSoon />} /> <Route path="/examen" element={<ComingSoon />} />
<Route path="/progression" element={<ProgressionPage />} /> <Route path="/progression" element={<ProgressionPage />} />
<Route path="/methodologie" element={<ComingSoon />} /> <Route path="/methodologie" element={<ComingSoon />} />
<Route path="/historique" element={<HistoriquePage />} /> <Route path="/historique" element={<HistoriquePage />} />
<Route path="/plan" element={<ComingSoon />} /> <Route path="/plan" element={<ComingSoon />} />
<Route path="/parametres" element={<ComingSoon />} /> <Route path="/parametres" element={<ComingSoon />} />
</Route> </Route>
{/* ── Dev only ─────────────────────────────────────────────── */} {/* ── Dev only ─────────────────────────────────────────────── */}

View file

@ -13,7 +13,7 @@ import type { CritereCode } from '@/entities/report/types'
export interface Pattern { export interface Pattern {
code: string code: string
critere: CritereCode critere: CritereCode
frequency: number // 3, 4 ou 5 (seuil d'agrégation : ≥ 3) frequency: number // 3, 4 ou 5 (seuil d'agrégation : ≥ 3)
description: string | null // non-null uniquement pour code === 'autre' description: string | null // non-null uniquement pour code === 'autre'
} }
@ -23,14 +23,14 @@ export interface PatternExercice {
diagnostic: string diagnostic: string
exercice: { exercice: {
consigne: string consigne: string
exemple: string // phrase incorrecte générique (pas du candidat) exemple: string // phrase incorrecte générique (pas du candidat)
correction: string // version correcte correction: string // version correcte
astuce: string // procédé mnémotechnique / réflexe de relecture astuce: string // procédé mnémotechnique / réflexe de relecture
} }
} }
export interface PreparationIndex { export interface PreparationIndex {
score: number // 0-100 entier score: number // 0-100 entier
message: string // interprétation textuelle fixée par le backend message: string // interprétation textuelle fixée par le backend
} }
@ -40,13 +40,13 @@ export interface PatternsReady {
exercises: PatternExercice[] exercises: PatternExercice[]
preparation_index: PreparationIndex preparation_index: PreparationIndex
analyzed_productions: number analyzed_productions: number
last_analysis: string // ISO timestamp last_analysis: string // ISO timestamp
} }
export interface PatternsNotReady { export interface PatternsNotReady {
ready: false ready: false
minimum: number // toujours 5 côté backend actuel minimum: number // toujours 5 côté backend actuel
current: number // nb de productions corrigées déjà réalisées current: number // nb de productions corrigées déjà réalisées
} }
export type PatternsResponse = PatternsReady | PatternsNotReady export type PatternsResponse = PatternsReady | PatternsNotReady

View file

@ -33,10 +33,7 @@ export function getSimulation(id: string): Promise<Production> {
* Endpoint : `GET /simulations?page=X&limit=Y`. Tri `created_at DESC` côté backend. * Endpoint : `GET /simulations?page=X&limit=Y`. Tri `created_at DESC` côté backend.
* Champs lourds exclus (contenu, rapport, exercices, modele) cf. SimulationListItem. * Champs lourds exclus (contenu, rapport, exercices, modele) cf. SimulationListItem.
*/ */
export function listSimulations( export function listSimulations(page: number, limit: number): Promise<SimulationsListResponse> {
page: number,
limit: number,
): Promise<SimulationsListResponse> {
const qs = new URLSearchParams({ page: String(page), limit: String(limit) }) const qs = new URLSearchParams({ page: String(page), limit: String(limit) })
return apiFetch<SimulationsListResponse>(`/simulations?${qs.toString()}`) return apiFetch<SimulationsListResponse>(`/simulations?${qs.toString()}`)
} }
@ -83,9 +80,7 @@ export async function updateSujet(id: string, sujetId: string): Promise<void> {
* Retourne `null` pour les tâches sans catalogue de sujets côté base * Retourne `null` pour les tâches sans catalogue de sujets côté base
* (EO_T1 : sujet fixe connu, EO_T2_LIVE : interaction sans sujet). * (EO_T1 : sujet fixe connu, EO_T2_LIVE : interaction sans sujet).
*/ */
function mapTacheToSujetParams( function mapTacheToSujetParams(tache: Tache): { mode: 'EE' | 'EO'; tacheNumber: 1 | 2 | 3 } | null {
tache: Tache,
): { mode: 'EE' | 'EO'; tacheNumber: 1 | 2 | 3 } | null {
switch (tache) { switch (tache) {
case 'EE_T1': case 'EE_T1':
return { mode: 'EE', tacheNumber: 1 } return { mode: 'EE', tacheNumber: 1 }

View file

@ -77,7 +77,10 @@ export function critereCodeFromNom(nom: string): CritereCode | null {
*/ */
const NCLC_MIN_SCORE: Record<number, number> = { 7: 10, 8: 12, 9: 14, 10: 16 } const NCLC_MIN_SCORE: Record<number, number> = { 7: 10, 8: 12, 9: 14, 10: 16 }
export function ecartVsCible(score: number, nclcCible: number): { export function ecartVsCible(
score: number,
nclcCible: number,
): {
points: number points: number
atteint: boolean atteint: boolean
} { } {

View file

@ -28,7 +28,7 @@ export interface ErreurCode {
export interface Critere { export interface Critere {
nom: string nom: string
score: number // 0-5 score: number // 0-5
commentaire: string commentaire: string
exemple: string exemple: string
suggestion: string suggestion: string
@ -42,7 +42,7 @@ export interface Revelation {
} }
export interface ConseilNclc { export interface ConseilNclc {
nclc_cible: string // ex. "NCLC 9" nclc_cible: string // ex. "NCLC 9"
ecart: string ecart: string
action_prioritaire: string action_prioritaire: string
} }
@ -101,14 +101,14 @@ export type NclcCible = 9 | 10
*/ */
export interface Report { export interface Report {
simulation_id: string simulation_id: string
score: number // /20 score: number // /20
nclc: number // NCLC atteint — ex. 8 nclc: number // NCLC atteint — ex. 8
nclc_cible: NclcCible nclc_cible: NclcCible
revelation: Revelation revelation: Revelation
diagnostic: string diagnostic: string
criteres: Critere[] criteres: Critere[]
conseil_nclc: ConseilNclc conseil_nclc: ConseilNclc
erreurs_codes: ErreurCode[] // top-level — regroupés par critère côté UI erreurs_codes: ErreurCode[] // top-level — regroupés par critère côté UI
exercices: Exercice[] | null exercices: Exercice[] | null
exercices_status: JobStatus exercices_status: JobStatus
modele: ProductionModele | null modele: ProductionModele | null
@ -120,7 +120,7 @@ export interface CorrectEePayload {
simulationId: string simulationId: string
contenu: string contenu: string
tache: string tache: string
nclc_cible?: NclcCible // défaut backend : 9 nclc_cible?: NclcCible // défaut backend : 9
} }
/** /**

View file

@ -10,11 +10,7 @@
*/ */
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import { getCurrentSession, subscribeToAuthChanges, type User } from '@/shared/lib/auth-client'
getCurrentSession,
subscribeToAuthChanges,
type User,
} from '@/shared/lib/auth-client'
interface UseAuthResult { interface UseAuthResult {
user: User | null user: User | null

View file

@ -62,8 +62,7 @@ export function MonProfilPreparation({ plan }: Props) {
Mon profil de préparation Mon profil de préparation
</p> </p>
<p className="text-sm text-ink-2"> <p className="text-sm text-ink-2">
Encore{' '} Encore <span className="font-semibold tabular-nums">{remaining}</span>{' '}
<span className="font-semibold tabular-nums">{remaining}</span>{' '}
{remaining > 1 ? 'simulations' : 'simulation'} pour débloquer votre profil. {remaining > 1 ? 'simulations' : 'simulation'} pour débloquer votre profil.
</p> </p>
<p className="text-xs text-ink-4 tabular-nums"> <p className="text-xs text-ink-4 tabular-nums">
@ -108,10 +107,7 @@ export function MonProfilPreparation({ plan }: Props) {
</p> </p>
<Button variant="secondary" size="sm" className="w-full"> <Button variant="secondary" size="sm" className="w-full">
<Link <Link to="/progression" className="-m-1 flex items-center justify-center gap-1.5 p-1">
to="/progression"
className="-m-1 flex items-center justify-center gap-1.5 p-1"
>
Voir mon profil de préparation Voir mon profil de préparation
<ArrowRight className="size-3.5" aria-hidden="true" /> <ArrowRight className="size-3.5" aria-hidden="true" />
</Link> </Link>

View file

@ -23,7 +23,9 @@ const PLAN_LABELS: Record<Plan, string> = {
premium: 'Plan Premium', premium: 'Plan Premium',
} }
function getDisplayName(user: { user_metadata?: { full_name?: string }; email?: string } | null): string { function getDisplayName(
user: { user_metadata?: { full_name?: string }; email?: string } | null,
): string {
const fullName = user?.user_metadata?.full_name const fullName = user?.user_metadata?.full_name
if (fullName) return fullName.split(' ')[0] if (fullName) return fullName.split(' ')[0]
const email = user?.email const email = user?.email
@ -76,9 +78,7 @@ export function DashboardPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Salutation */} {/* Salutation */}
<section className="flex flex-wrap items-center gap-3"> <section className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold text-ink-1"> <h1 className="text-2xl font-semibold text-ink-1">Bonjour, {displayName}</h1>
Bonjour, {displayName}
</h1>
<Badge variant="plan" planValue={data.plan}> <Badge variant="plan" planValue={data.plan}>
{PLAN_LABELS[data.plan]} {PLAN_LABELS[data.plan]}
</Badge> </Badge>
@ -88,16 +88,11 @@ export function DashboardPage() {
{!hasAccess(data.plan, 'dashboard') && <PaywallBanner />} {!hasAccess(data.plan, 'dashboard') && <PaywallBanner />}
{/* Métriques */} {/* Métriques */}
<section <section className="grid grid-cols-2 gap-4" aria-label="Métriques de préparation">
className="grid grid-cols-2 gap-4"
aria-label="Métriques de préparation"
>
<div className="rounded-lg border border-line bg-surface p-4"> <div className="rounded-lg border border-line bg-surface p-4">
<p className="text-xs text-ink-4">Simulations restantes</p> <p className="text-xs text-ink-4">Simulations restantes</p>
<p className="mt-1 text-2xl font-semibold text-ink-1"> <p className="mt-1 text-2xl font-semibold text-ink-1">
{data.simulations_remaining === null {data.simulations_remaining === null ? 'Illimitées' : data.simulations_remaining}
? 'Illimitées'
: data.simulations_remaining}
</p> </p>
</div> </div>
<div className="rounded-lg border border-line bg-surface p-4"> <div className="rounded-lg border border-line bg-surface p-4">

View file

@ -49,9 +49,7 @@ export function SimulationListItem({ item }: Props) {
</p> </p>
</div> </div>
) : ( ) : (
<div className="shrink-0 text-right text-xs text-ink-4"> <div className="shrink-0 text-right text-xs text-ink-4">Score à venir</div>
Score à venir
</div>
)} )}
</div> </div>
</Link> </Link>

View file

@ -272,7 +272,7 @@ describe('SimulationsList — pagination', () => {
}) })
describe('SimulationsList — états transverses', () => { describe('SimulationsList — états transverses', () => {
it('isError → affiche le callout d\'erreur', () => { it("isError → affiche le callout d'erreur", () => {
renderWithRouter( renderWithRouter(
<SimulationsList <SimulationsList
plan="standard" plan="standard"

View file

@ -32,8 +32,8 @@ export function BlurredProgression({ onUpgrade }: Props) {
Profil de préparation Exclusivité Premium Profil de préparation Exclusivité Premium
</p> </p>
<p className="max-w-sm text-xs text-ink-4"> <p className="max-w-sm text-xs text-ink-4">
Analysez vos erreurs récurrentes, recevez des exercices ciblés long Analysez vos erreurs récurrentes, recevez des exercices ciblés long terme, et suivez
terme, et suivez votre indice de préparation au TCF Canada. votre indice de préparation au TCF Canada.
</p> </p>
</div> </div>
<Button variant="upgrade" size="sm" onClick={onUpgrade}> <Button variant="upgrade" size="sm" onClick={onUpgrade}>

View file

@ -35,9 +35,7 @@ export function PatternExerciceCard({ exercice }: Props) {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="neutral">{critereLabel}</Badge> <Badge variant="neutral">{critereLabel}</Badge>
<span className="text-xs font-medium text-ink-4"> <span className="text-xs font-medium text-ink-4">{exercice.code.replace(/_/g, ' ')}</span>
{exercice.code.replace(/_/g, ' ')}
</span>
</div> </div>
{exercice.diagnostic && ( {exercice.diagnostic && (
<p className="text-sm leading-relaxed text-ink-2"> <p className="text-sm leading-relaxed text-ink-2">
@ -53,12 +51,8 @@ export function PatternExerciceCard({ exercice }: Props) {
{exercice.exercice.consigne && ( {exercice.exercice.consigne && (
<div className="space-y-1.5 rounded-md border border-line bg-canvas-2 p-3"> <div className="space-y-1.5 rounded-md border border-line bg-canvas-2 p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">Consigne</p>
Consigne <p className="text-sm leading-relaxed text-ink-1">{exercice.exercice.consigne}</p>
</p>
<p className="text-sm leading-relaxed text-ink-1">
{exercice.exercice.consigne}
</p>
</div> </div>
)} )}
@ -75,9 +69,7 @@ export function PatternExerciceCard({ exercice }: Props) {
<p className="text-[11px] font-semibold uppercase tracking-widest text-success"> <p className="text-[11px] font-semibold uppercase tracking-widest text-success">
Correct Correct
</p> </p>
<p className="text-sm leading-relaxed text-ink-1"> <p className="text-sm leading-relaxed text-ink-1">{exercice.exercice.correction}</p>
{exercice.exercice.correction}
</p>
</div> </div>
</div> </div>
@ -87,9 +79,7 @@ export function PatternExerciceCard({ exercice }: Props) {
<p className="text-[11px] font-semibold uppercase tracking-widest text-warning"> <p className="text-[11px] font-semibold uppercase tracking-widest text-warning">
Astuce de relecture Astuce de relecture
</p> </p>
<p className="text-sm leading-relaxed text-ink-1"> <p className="text-sm leading-relaxed text-ink-1">{exercice.exercice.astuce}</p>
{exercice.exercice.astuce}
</p>
</div> </div>
</div> </div>
</Card> </Card>

View file

@ -25,8 +25,7 @@ export function PatternsList({ patterns }: Props) {
return ( return (
<Card variant="default" className="p-4"> <Card variant="default" className="p-4">
<p className="text-sm text-ink-3"> <p className="text-sm text-ink-3">
Aucune erreur récurrente détectée sur vos 5 dernières productions. Aucune erreur récurrente détectée sur vos 5 dernières productions. Continuez ainsi !
Continuez ainsi !
</p> </p>
</Card> </Card>
) )

View file

@ -31,17 +31,13 @@ export function ProgressionPremium({ data }: Props) {
<PreparationIndexHero index={data.preparation_index} /> <PreparationIndexHero index={data.preparation_index} />
<section aria-label="Erreurs récurrentes"> <section aria-label="Erreurs récurrentes">
<h2 className="mb-3 text-base font-semibold text-ink-1"> <h2 className="mb-3 text-base font-semibold text-ink-1">Erreurs récurrentes</h2>
Erreurs récurrentes
</h2>
<PatternsList patterns={data.patterns} /> <PatternsList patterns={data.patterns} />
</section> </section>
{data.exercises.length > 0 && ( {data.exercises.length > 0 && (
<section aria-label="Exercices long terme"> <section aria-label="Exercices long terme">
<h2 className="mb-3 text-base font-semibold text-ink-1"> <h2 className="mb-3 text-base font-semibold text-ink-1">Exercices long terme</h2>
Exercices long terme
</h2>
<div className="space-y-3"> <div className="space-y-3">
{data.exercises.map((ex, i) => ( {data.exercises.map((ex, i) => (
<PatternExerciceCard key={`${ex.code}-${i}`} exercice={ex} /> <PatternExerciceCard key={`${ex.code}-${i}`} exercice={ex} />

View file

@ -9,11 +9,7 @@ import { describe, it, expect, afterEach } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react' import { render, screen, cleanup } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
import { ProgressionPremium } from '../ProgressionPremium' import { ProgressionPremium } from '../ProgressionPremium'
import type { import type { PatternsReady, PatternsNotReady, PatternExercice } from '@/entities/patterns/types'
PatternsReady,
PatternsNotReady,
PatternExercice,
} from '@/entities/patterns/types'
afterEach(cleanup) afterEach(cleanup)
@ -36,7 +32,12 @@ const EXERCICE: PatternExercice = {
const READY_DATA: PatternsReady = { const READY_DATA: PatternsReady = {
ready: true, ready: true,
patterns: [ patterns: [
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', frequency: 4, description: null }, {
code: 'accord_sujet_verbe',
critere: 'competence_grammaticale',
frequency: 4,
description: null,
},
{ code: 'connecteurs_repetes', critere: 'coherence_cohesion', frequency: 3, description: null }, { code: 'connecteurs_repetes', critere: 'coherence_cohesion', frequency: 3, description: null },
], ],
exercises: [EXERCICE], exercises: [EXERCICE],
@ -65,7 +66,7 @@ describe('ProgressionPremium — état not-ready', () => {
}) })
describe('ProgressionPremium — état ready', () => { describe('ProgressionPremium — état ready', () => {
it('affiche l\'indice de préparation (score + message)', () => { it("affiche l'indice de préparation (score + message)", () => {
renderWithRouter(<ProgressionPremium data={READY_DATA} />) renderWithRouter(<ProgressionPremium data={READY_DATA} />)
expect(screen.getByText('72')).toBeInTheDocument() expect(screen.getByText('72')).toBeInTheDocument()
@ -83,7 +84,7 @@ describe('ProgressionPremium — état ready', () => {
expect(screen.getByText(/Cohérence et cohésion/i)).toBeInTheDocument() expect(screen.getByText(/Cohérence et cohésion/i)).toBeInTheDocument()
}) })
it('rend l\'exercice avec consigne, exemple incorrect, correction et astuce', () => { it("rend l'exercice avec consigne, exemple incorrect, correction et astuce", () => {
renderWithRouter(<ProgressionPremium data={READY_DATA} />) renderWithRouter(<ProgressionPremium data={READY_DATA} />)
expect(screen.getByText(EXERCICE.exercice.consigne)).toBeInTheDocument() expect(screen.getByText(EXERCICE.exercice.consigne)).toBeInTheDocument()
@ -96,9 +97,7 @@ describe('ProgressionPremium — état ready', () => {
it('affiche le footer "Analyse basée sur vos 5 dernières productions"', () => { it('affiche le footer "Analyse basée sur vos 5 dernières productions"', () => {
renderWithRouter(<ProgressionPremium data={READY_DATA} />) renderWithRouter(<ProgressionPremium data={READY_DATA} />)
expect( expect(screen.getByText(/analyse basée sur vos 5 dernières productions/i)).toBeInTheDocument()
screen.getByText(/analyse basée sur vos 5 dernières productions/i),
).toBeInTheDocument()
}) })
it('ready sans pattern : affiche le message "Aucune erreur récurrente"', () => { it('ready sans pattern : affiche le message "Aucune erreur récurrente"', () => {

View file

@ -31,9 +31,7 @@ function Skeleton() {
export function ProgressionPage() { export function ProgressionPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { data: planData, isLoading: isPlanLoading } = usePlan() const { data: planData, isLoading: isPlanLoading } = usePlan()
const { data: patternsData, isLoading: isPatternsLoading, isError } = usePatterns( const { data: patternsData, isLoading: isPatternsLoading, isError } = usePatterns(planData?.plan)
planData?.plan,
)
const isPremium = planData ? hasAccess(planData.plan, 'pattern_analysis') : false const isPremium = planData ? hasAccess(planData.plan, 'pattern_analysis') : false
@ -58,8 +56,7 @@ export function ProgressionPage() {
{isError && ( {isError && (
<Card variant="default" className="border-l-4 border-l-danger p-4"> <Card variant="default" className="border-l-4 border-l-danger p-4">
<p className="text-sm text-danger" role="alert"> <p className="text-sm text-danger" role="alert">
Impossible de charger votre profil de préparation. Réessayez dans Impossible de charger votre profil de préparation. Réessayez dans quelques instants.
quelques instants.
</p> </p>
<div className="mt-3"> <div className="mt-3">
<Button variant="secondary" size="sm" onClick={() => navigate(0)}> <Button variant="secondary" size="sm" onClick={() => navigate(0)}>

View file

@ -79,7 +79,10 @@ export function IdeesSuggestions({ idees, isLoading, error, isOpen, onClose }: P
<ul className="space-y-2 text-sm text-ink-2"> <ul className="space-y-2 text-sm text-ink-2">
{idees.map((idee, i) => ( {idees.map((idee, i) => (
<li key={i} className="flex gap-2"> <li key={i} className="flex gap-2">
<span className="mt-[0.4em] size-1.5 shrink-0 rounded-full bg-expria" aria-hidden="true" /> <span
className="mt-[0.4em] size-1.5 shrink-0 rounded-full bg-expria"
aria-hidden="true"
/>
<span>{idee}</span> <span>{idee}</span>
</li> </li>
))} ))}

View file

@ -61,9 +61,7 @@ export function NclcCibleSelector({ value, onChange, disabled = false }: Props)
) )
})} })}
</div> </div>
<p className="text-xs text-ink-4"> <p className="text-xs text-ink-4">{OPTIONS.find((o) => o.value === value)?.hint}</p>
{OPTIONS.find((o) => o.value === value)?.hint}
</p>
</fieldset> </fieldset>
) )
} }

View file

@ -117,11 +117,7 @@ export function SimulationForm({
const tipsAllowed = hasAccess(plan, 'tips') const tipsAllowed = hasAccess(plan, 'tips')
const ideesDisabled = const ideesDisabled =
isSubmitting || isSubmitting || idees.isLoading || !sujet || !tipsAllowed || wordCount < MIN_WORDS_IDEES
idees.isLoading ||
!sujet ||
!tipsAllowed ||
wordCount < MIN_WORDS_IDEES
const ideesTitle = !tipsAllowed const ideesTitle = !tipsAllowed
? 'Disponible en Standard' ? 'Disponible en Standard'
: wordCount < MIN_WORDS_IDEES : wordCount < MIN_WORDS_IDEES
@ -311,11 +307,7 @@ export function SimulationForm({
className="w-full resize-none overflow-y-hidden rounded-md border border-line bg-surface p-3 text-sm text-ink-1 placeholder:text-ink-5 focus:border-expria focus:outline-none focus:shadow-focus disabled:cursor-not-allowed disabled:opacity-50" className="w-full resize-none overflow-y-hidden rounded-md border border-line bg-surface p-3 text-sm text-ink-1 placeholder:text-ink-5 focus:border-expria focus:outline-none focus:shadow-focus disabled:cursor-not-allowed disabled:opacity-50"
/> />
<WordCountBar count={wordCount} config={config} /> <WordCountBar count={wordCount} config={config} />
<NclcCibleSelector <NclcCibleSelector value={nclcCible} onChange={setNclcCible} disabled={isSubmitting} />
value={nclcCible}
onChange={setNclcCible}
disabled={isSubmitting}
/>
{autosave.savedAt && !fieldError && ( {autosave.savedAt && !fieldError && (
<p className="text-xs text-ink-4" aria-live="polite"> <p className="text-xs text-ink-4" aria-live="polite">

View file

@ -13,8 +13,36 @@
*/ */
const SPECIAL_CHARS = [ const SPECIAL_CHARS = [
'à', 'â', 'é', 'è', 'ê', 'ë', 'î', 'ï', 'ô', 'ù', 'û', 'ü', 'ç', 'œ', 'æ', 'à',
'À', 'Â', 'É', 'È', 'Ê', 'Ë', 'Î', 'Ï', 'Ô', 'Ù', 'Û', 'Ü', 'Ç', 'Œ', 'Æ', 'â',
'é',
'è',
'ê',
'ë',
'î',
'ï',
'ô',
'ù',
'û',
'ü',
'ç',
'œ',
'æ',
'À',
'Â',
'É',
'È',
'Ê',
'Ë',
'Î',
'Ï',
'Ô',
'Ù',
'Û',
'Ü',
'Ç',
'Œ',
'Æ',
] as const ] as const
interface Props { interface Props {

View file

@ -24,9 +24,7 @@ function DocumentBlock({ titre, texte }: { titre: string | null; texte: string |
return ( return (
<article className="rounded-md border border-line bg-canvas-2 p-3"> <article className="rounded-md border border-line bg-canvas-2 p-3">
{titre && <h4 className="mb-2 text-sm font-semibold text-ink-1">{titre}</h4>} {titre && <h4 className="mb-2 text-sm font-semibold text-ink-1">{titre}</h4>}
{texte && ( {texte && <p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-2">{texte}</p>}
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-2">{texte}</p>
)}
</article> </article>
) )
} }
@ -45,9 +43,7 @@ export function SujetDisplay({ sujet }: Props) {
)} )}
{sujet.contexte && ( {sujet.contexte && (
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-3"> <p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-3">{sujet.contexte}</p>
{sujet.contexte}
</p>
)} )}
<div> <div>

View file

@ -44,7 +44,13 @@ const EE_CARDS: readonly TaskCard[] = [
const EO_CARDS: readonly TaskCard[] = [ const EO_CARDS: readonly TaskCard[] = [
{ key: 'EO_T1', tache: 'EO_T1', label: 'Expression Orale', sublabel: 'Entretien' }, { key: 'EO_T1', tache: 'EO_T1', label: 'Expression Orale', sublabel: 'Entretien' },
{ key: 'EO_T3', tache: 'EO_T3', label: 'Expression Orale', sublabel: 'Point de vue' }, { key: 'EO_T3', tache: 'EO_T3', label: 'Expression Orale', sublabel: 'Point de vue' },
{ key: 'EO_T2_LIVE', tache: null, label: 'Expression Orale', sublabel: 'Tâche 2 — Live', lockLabel: 'Exclusivité Premium' }, {
key: 'EO_T2_LIVE',
tache: null,
label: 'Expression Orale',
sublabel: 'Tâche 2 — Live',
lockLabel: 'Exclusivité Premium',
},
] ]
export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect }: Props) { export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect }: Props) {
@ -56,9 +62,7 @@ export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-semibold text-ink-1">Choisir une tâche</h2> <h2 className="text-lg font-semibold text-ink-1">Choisir une tâche</h2>
<p className="mt-1 text-sm text-ink-3"> <p className="mt-1 text-sm text-ink-3">Sélectionnez la tâche que vous souhaitez simuler.</p>
Sélectionnez la tâche que vous souhaitez simuler.
</p>
</div> </div>
{quotaBlocked && ( {quotaBlocked && (
@ -81,11 +85,7 @@ export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect
if (locked) { if (locked) {
return ( return (
<Card <Card key={card.key} variant="default" className="flex flex-col p-4 opacity-60">
key={card.key}
variant="default"
className="flex flex-col p-4 opacity-60"
>
{card.tache === null && ( {card.tache === null && (
<Lock className="mb-2 size-4 text-ink-4" aria-hidden="true" /> <Lock className="mb-2 size-4 text-ink-4" aria-hidden="true" />
)} )}

View file

@ -21,13 +21,9 @@ export function ConseilNclcCallout({ conseil }: Props) {
<h2 className="mb-3 text-base font-semibold text-ink-1">Plan d'action NCLC</h2> <h2 className="mb-3 text-base font-semibold text-ink-1">Plan d'action NCLC</h2>
<Card variant="raised" className="space-y-3 p-4"> <Card variant="raised" className="space-y-3 p-4">
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1"> <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">Objectif</p>
Objectif
</p>
<p className="text-sm font-semibold text-ink-1">{conseil.nclc_cible}</p> <p className="text-sm font-semibold text-ink-1">{conseil.nclc_cible}</p>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">Écart</p>
Écart
</p>
<p className="text-sm text-ink-2">{conseil.ecart}</p> <p className="text-sm text-ink-2">{conseil.ecart}</p>
</div> </div>
<div className="space-y-1.5 rounded-md border border-expria/30 bg-expria-50 p-3"> <div className="space-y-1.5 rounded-md border border-expria/30 bg-expria-50 p-3">

View file

@ -44,9 +44,7 @@ export function CritereCard({ critere, erreursCodes }: Props) {
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Exemple tiré de votre texte Exemple tiré de votre texte
</p> </p>
<p className="italic text-sm leading-relaxed text-ink-2"> <p className="italic text-sm leading-relaxed text-ink-2">« {critere.exemple} »</p>
« {critere.exemple} »
</p>
</div> </div>
)} )}
@ -61,7 +59,9 @@ export function CritereCard({ critere, erreursCodes }: Props) {
{critere.astuce && ( {critere.astuce && (
<div className="flex gap-2 text-sm text-ink-3"> <div className="flex gap-2 text-sm text-ink-3">
<span className="shrink-0 text-expria" aria-hidden="true">💡</span> <span className="shrink-0 text-expria" aria-hidden="true">
💡
</span>
<span>{critere.astuce}</span> <span>{critere.astuce}</span>
</div> </div>
)} )}

View file

@ -17,14 +17,10 @@ interface Props {
export function DiagnosticCallout({ diagnostic }: Props) { export function DiagnosticCallout({ diagnostic }: Props) {
return ( return (
<section aria-label="Frein principal"> <section aria-label="Frein principal">
<h2 className="mb-3 text-base font-semibold text-ink-1"> <h2 className="mb-3 text-base font-semibold text-ink-1">Ce qui freine votre progression</h2>
Ce qui freine votre progression
</h2>
<Card variant="default" className="border-l-4 border-l-expria p-4"> <Card variant="default" className="border-l-4 border-l-expria p-4">
<div className="text-sm leading-relaxed text-ink-1"> <div className="text-sm leading-relaxed text-ink-1">
<ReactMarkdown disallowedElements={['script', 'iframe']}> <ReactMarkdown disallowedElements={['script', 'iframe']}>{diagnostic}</ReactMarkdown>
{diagnostic}
</ReactMarkdown>
</div> </div>
</Card> </Card>
</section> </section>

View file

@ -52,9 +52,7 @@ export function ExerciceInteractive({ exercice }: Props) {
{exercice.consigne && ( {exercice.consigne && (
<div className="space-y-1.5 rounded-md border border-line bg-canvas-2 p-3"> <div className="space-y-1.5 rounded-md border border-line bg-canvas-2 p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">Consigne</p>
Consigne
</p>
<p className="text-sm leading-relaxed text-ink-1">{exercice.consigne}</p> <p className="text-sm leading-relaxed text-ink-1">{exercice.consigne}</p>
</div> </div>
)} )}
@ -95,7 +93,7 @@ export function ExerciceInteractive({ exercice }: Props) {
size="sm" size="sm"
disabled={!canRevealCorrection || correctionRevealed} disabled={!canRevealCorrection || correctionRevealed}
onClick={() => setCorrectionRevealed(true)} onClick={() => setCorrectionRevealed(true)}
title={!canRevealCorrection ? 'Écrivez d\'abord votre tentative' : undefined} title={!canRevealCorrection ? "Écrivez d'abord votre tentative" : undefined}
> >
Voir la correction Voir la correction
</Button> </Button>
@ -106,9 +104,7 @@ export function ExerciceInteractive({ exercice }: Props) {
className="space-y-1 rounded-md border border-warning/30 bg-warning-bg p-3" className="space-y-1 rounded-md border border-warning/30 bg-warning-bg p-3"
aria-live="polite" aria-live="polite"
> >
<p className="text-[11px] font-semibold uppercase tracking-widest text-warning"> <p className="text-[11px] font-semibold uppercase tracking-widest text-warning">Indice</p>
Indice
</p>
<p className="text-sm leading-relaxed text-ink-1">{exercice.indice}</p> <p className="text-sm leading-relaxed text-ink-1">{exercice.indice}</p>
</div> </div>
)} )}

View file

@ -30,9 +30,7 @@ export function ProductionModeleSection({ modele }: Props) {
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Version restructurée NCLC 9+ Version restructurée NCLC 9+
</p> </p>
<Badge variant="nclc"> <Badge variant="nclc">{modele.tcf_word_count ?? ''} mots</Badge>
{modele.tcf_word_count ?? ''} mots
</Badge>
</div> </div>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-1"> <p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-1">
{modele.production_modele_propre} {modele.production_modele_propre}

View file

@ -18,7 +18,7 @@ interface Props {
const SECTIONS: { key: keyof Revelation; titre: string; ton: 'ink' | 'warning' | 'danger' }[] = [ const SECTIONS: { key: keyof Revelation; titre: string; ton: 'ink' | 'warning' | 'danger' }[] = [
{ key: 'croyance', titre: 'Ce que vous croyez', ton: 'ink' }, { key: 'croyance', titre: 'Ce que vous croyez', ton: 'ink' },
{ key: 'realite', titre: 'Ce qu\'observe le correcteur', ton: 'warning' }, { key: 'realite', titre: "Ce qu'observe le correcteur", ton: 'warning' },
{ key: 'consequence', titre: 'Conséquence sur la note', ton: 'danger' }, { key: 'consequence', titre: 'Conséquence sur la note', ton: 'danger' },
] ]
@ -35,7 +35,9 @@ export function RevelationCards({ revelation }: Props) {
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
{SECTIONS.map(({ key, titre, ton }) => ( {SECTIONS.map(({ key, titre, ton }) => (
<Card key={key} variant="default" className="p-4"> <Card key={key} variant="default" className="p-4">
<p className={`mb-2 text-[11px] font-semibold uppercase tracking-widest ${TON_CLASS[ton]}`}> <p
className={`mb-2 text-[11px] font-semibold uppercase tracking-widest ${TON_CLASS[ton]}`}
>
{titre} {titre}
</p> </p>
<div className="text-sm leading-relaxed text-ink-2"> <div className="text-sm leading-relaxed text-ink-2">

View file

@ -13,8 +13,8 @@ import { ecartVsCible } from '@/entities/report/lib'
import type { NclcCible } from '@/entities/report/types' import type { NclcCible } from '@/entities/report/types'
interface Props { interface Props {
score: number // /20 score: number // /20
nclc: number // NCLC atteint nclc: number // NCLC atteint
nclcCible: NclcCible nclcCible: NclcCible
} }
@ -30,9 +30,7 @@ export function ScoreHero({ score, nclc, nclcCible }: Props) {
<Card variant="raised" className="space-y-4 p-6"> <Card variant="raised" className="space-y-4 p-6">
<div className="flex flex-wrap items-end gap-8"> <div className="flex flex-wrap items-end gap-8">
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">Score</p>
Score
</p>
<p className="mt-1 tabular-nums text-ink-1"> <p className="mt-1 tabular-nums text-ink-1">
<span className="text-5xl font-bold">{score}</span> <span className="text-5xl font-bold">{score}</span>
<span className="text-2xl font-medium text-ink-4">/20</span> <span className="text-2xl font-medium text-ink-4">/20</span>
@ -47,9 +45,7 @@ export function ScoreHero({ score, nclc, nclcCible }: Props) {
</Badge> </Badge>
</div> </div>
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">Objectif</p>
Objectif
</p>
<Badge variant="neutral" className="mt-2"> <Badge variant="neutral" className="mt-2">
NCLC {nclcCible} NCLC {nclcCible}
</Badge> </Badge>
@ -67,9 +63,7 @@ export function ScoreHero({ score, nclc, nclcCible }: Props) {
aria-label={`Score ${score} sur 20`} aria-label={`Score ${score} sur 20`}
> >
<div <div
className={`h-full transition-all duration-300 ${ className={`h-full transition-all duration-300 ${atteint ? 'bg-success' : 'bg-expria'}`}
atteint ? 'bg-success' : 'bg-expria'
}`}
style={{ width: `${percent}%` }} style={{ width: `${percent}%` }}
/> />
{/* Marqueur du seuil NCLC cible */} {/* Marqueur du seuil NCLC cible */}
@ -82,7 +76,9 @@ export function ScoreHero({ score, nclc, nclcCible }: Props) {
</div> </div>
<div className="flex justify-between text-xs text-ink-4 tabular-nums"> <div className="flex justify-between text-xs text-ink-4 tabular-nums">
<span>0</span> <span>0</span>
<span className="font-medium">Seuil NCLC {nclcCible} : {seuilCible}/20</span> <span className="font-medium">
Seuil NCLC {nclcCible} : {seuilCible}/20
</span>
<span>20</span> <span>20</span>
</div> </div>
</div> </div>
@ -94,9 +90,7 @@ export function ScoreHero({ score, nclc, nclcCible }: Props) {
</p> </p>
) : ( ) : (
<p className="rounded-md border border-warning/30 bg-warning-bg px-3 py-2 text-sm text-warning"> <p className="rounded-md border border-warning/30 bg-warning-bg px-3 py-2 text-sm text-warning">
{points === 1 {points === 1 ? '1 point avant NCLC ' : `${points} points avant NCLC `}
? '1 point avant NCLC '
: `${points} points avant NCLC `}
{nclcCible}+ {nclcCible}+
</p> </p>
)} )}

View file

@ -21,7 +21,7 @@ const EXERCICE: Exercice = {
extrait: 'les enfants joue', extrait: 'les enfants joue',
indice: 'Pluriel du sujet ?', indice: 'Pluriel du sujet ?',
correction: 'les enfants jouent', correction: 'les enfants jouent',
explication: 'Le verbe s\'accorde en nombre avec le sujet.', explication: "Le verbe s'accorde en nombre avec le sujet.",
} }
describe('ExerciceInteractive', () => { describe('ExerciceInteractive', () => {

View file

@ -31,7 +31,7 @@ describe('useAutosave', () => {
vi.useRealTimers() vi.useRealTimers()
}) })
it('debounce 30 s : pas d\'appel avant, appel après', async () => { it("debounce 30 s : pas d'appel avant, appel après", async () => {
const { rerender } = renderHook( const { rerender } = renderHook(
({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true), ({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true),
{ initialProps: { contenu: '' } }, { initialProps: { contenu: '' } },
@ -112,7 +112,7 @@ describe('useAutosave', () => {
expect(mocked).not.toHaveBeenCalled() expect(mocked).not.toHaveBeenCalled()
}) })
it('dédoublonnage : pas de second appel si le contenu n\'a pas changé', async () => { it("dédoublonnage : pas de second appel si le contenu n'a pas changé", async () => {
const { rerender } = renderHook( const { rerender } = renderHook(
({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true), ({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true),
{ initialProps: { contenu: '' } }, { initialProps: { contenu: '' } },

View file

@ -19,11 +19,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useSimulation } from '../useSimulation' import { useSimulation } from '../useSimulation'
import { SimulationFlowProvider } from '../../state/SimulationFlowProvider' import { SimulationFlowProvider } from '../../state/SimulationFlowProvider'
import { useSimulationFlow } from '../../state/simulationFlow' import { useSimulationFlow } from '../../state/simulationFlow'
import { import { createSimulation, getSimulationState, updateSujet } from '@/entities/production/api'
createSimulation,
getSimulationState,
updateSujet,
} from '@/entities/production/api'
import { correctEe } from '@/entities/report/api' import { correctEe } from '@/entities/report/api'
import type { Production } from '@/entities/production/types' import type { Production } from '@/entities/production/types'
import type { Report } from '@/entities/report/types' import type { Report } from '@/entities/report/types'
@ -134,7 +130,12 @@ describe('useSimulation — selectTask', () => {
it('isCreating = true pendant la mutation createSimulation', async () => { it('isCreating = true pendant la mutation createSimulation', async () => {
let resolveCreate!: (p: Production) => void let resolveCreate!: (p: Production) => void
mockCreateSimulation.mockImplementation(() => new Promise(r => { resolveCreate = r })) mockCreateSimulation.mockImplementation(
() =>
new Promise((r) => {
resolveCreate = r
}),
)
const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() }) const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
@ -154,7 +155,12 @@ describe('useSimulation — submitText', () => {
mockCreateSimulation.mockResolvedValue(mockProduction) mockCreateSimulation.mockResolvedValue(mockProduction)
let resolveCorrect!: (r: Report) => void let resolveCorrect!: (r: Report) => void
mockCorrectEe.mockImplementation(() => new Promise(r => { resolveCorrect = r })) mockCorrectEe.mockImplementation(
() =>
new Promise((r) => {
resolveCorrect = r
}),
)
const { result } = renderHook( const { result } = renderHook(
() => { () => {
@ -202,8 +208,7 @@ describe('useSimulation — submitText', () => {
}) })
await waitFor(() => expect(result.current.step).toBe('task-selected')) await waitFor(() => expect(result.current.step).toBe('task-selected'))
act(() => result.current.submitText('Mon texte.') act(() => result.current.submitText('Mon texte.'))
)
await waitFor(() => expect(result.current.step).toBe('task-selected')) await waitFor(() => expect(result.current.step).toBe('task-selected'))
expect(result.current.report).toBeNull() expect(result.current.report).toBeNull()
}) })

View file

@ -46,7 +46,9 @@ export function countWords(texte: string): number {
/** Formate un nombre de secondes en MM:SS (ex. 125 → "02:05"). */ /** Formate un nombre de secondes en MM:SS (ex. 125 → "02:05"). */
export function formatTimer(secondes: number): string { export function formatTimer(secondes: number): string {
const safe = Math.max(0, Math.floor(secondes)) const safe = Math.max(0, Math.floor(secondes))
const mm = Math.floor(safe / 60).toString().padStart(2, '0') const mm = Math.floor(safe / 60)
.toString()
.padStart(2, '0')
const ss = (safe % 60).toString().padStart(2, '0') const ss = (safe % 60).toString().padStart(2, '0')
return `${mm}:${ss}` return `${mm}:${ss}`
} }

View file

@ -20,11 +20,7 @@ import { useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Lock } from 'lucide-react' import { Lock } from 'lucide-react'
import { usePlan } from '@/features/dashboard/hooks/usePlan' import { usePlan } from '@/features/dashboard/hooks/usePlan'
import { import { isSectionVisible, groupErreursByCritere, critereCodeFromNom } from '@/entities/report/lib'
isSectionVisible,
groupErreursByCritere,
critereCodeFromNom,
} from '@/entities/report/lib'
import type { Report } from '@/entities/report/types' import type { Report } from '@/entities/report/types'
import { useRapport } from '../hooks/useRapport' import { useRapport } from '../hooks/useRapport'
import { useSimulation } from '../hooks/useSimulation' import { useSimulation } from '../hooks/useSimulation'
@ -171,11 +167,7 @@ export function RapportPage() {
const { reset } = useSimulation() const { reset } = useSimulation()
const { const { data: planData, isLoading: isPlanLoading, isError: isPlanError } = usePlan()
data: planData,
isLoading: isPlanLoading,
isError: isPlanError,
} = usePlan()
// FTD-21 — si la simulation n'est pas encore corrigée, rediriger vers /simulation/ee. // FTD-21 — si la simulation n'est pas encore corrigée, rediriger vers /simulation/ee.
useEffect(() => { useEffect(() => {
@ -206,7 +198,9 @@ export function RapportPage() {
Simulations Simulations
</button> </button>
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
<span aria-current="page" className="text-ink-2">Rapport</span> <span aria-current="page" className="text-ink-2">
Rapport
</span>
</nav> </nav>
{(isLoading || isPlanLoading) && <RapportSkeleton />} {(isLoading || isPlanLoading) && <RapportSkeleton />}
@ -232,11 +226,7 @@ export function RapportPage() {
{rapport && planData && ( {rapport && planData && (
<> <>
<ScoreHero <ScoreHero score={rapport.score} nclc={rapport.nclc} nclcCible={rapport.nclc_cible} />
score={rapport.score}
nclc={rapport.nclc}
nclcCible={rapport.nclc_cible}
/>
<RevelationCards revelation={rapport.revelation} /> <RevelationCards revelation={rapport.revelation} />
@ -258,10 +248,7 @@ export function RapportPage() {
<ExercicesSection rapport={rapport} /> <ExercicesSection rapport={rapport} />
</BlurredSection> </BlurredSection>
<BlurredSection <BlurredSection visible={isSectionVisible(planData.plan, 'modele')} onUpgrade={onUpgrade}>
visible={isSectionVisible(planData.plan, 'modele')}
onUpgrade={onUpgrade}
>
<ModeleSection rapport={rapport} /> <ModeleSection rapport={rapport} />
</BlurredSection> </BlurredSection>

View file

@ -80,23 +80,21 @@ export function SimulationPage() {
/> />
)} )}
{planData && {planData && (step === 'task-selected' || step === 'correcting') && production && (
(step === 'task-selected' || step === 'correcting') && <SimulationForm
production && ( tache={production.tache}
<SimulationForm sujet={sujet}
tache={production.tache} plan={planData.plan}
sujet={sujet} simulationId={production.id}
plan={planData.plan} initialContenu={production.contenu}
simulationId={production.id} step={step}
initialContenu={production.contenu} isSubmitting={isCorrecting}
step={step} error={correctError}
isSubmitting={isCorrecting} onSubmit={submitText}
error={correctError} onBack={reset}
onSubmit={submitText} onChangeSujet={goToSubjectPicker}
onBack={reset} />
onChangeSujet={goToSubjectPicker} )}
/>
)}
</main> </main>
) )
} }

View file

@ -46,10 +46,12 @@ export function SujetsPage() {
if (shouldRedirect) navigate('/simulation/ee', { replace: true }) if (shouldRedirect) navigate('/simulation/ee', { replace: true })
}, [shouldRedirect, navigate]) }, [shouldRedirect, navigate])
const { data: sujets, isLoading, isError, refetch } = useSujets( const {
production?.tache ?? 'EE_T1', data: sujets,
!!production && !shouldRedirect, isLoading,
) isError,
refetch,
} = useSujets(production?.tache ?? 'EE_T1', !!production && !shouldRedirect)
if (shouldRedirect || !production) return null if (shouldRedirect || !production) return null
@ -61,9 +63,7 @@ export function SujetsPage() {
function handleRandom() { function handleRandom() {
if (!sujets || sujets.length === 0) return if (!sujets || sujets.length === 0) return
const pool = production?.sujet const pool = production?.sujet ? sujets.filter((s) => s.id !== production.sujet?.id) : sujets
? sujets.filter((s) => s.id !== production.sujet?.id)
: sujets
const list = pool.length > 0 ? pool : sujets const list = pool.length > 0 ? pool : sujets
const pick = list[Math.floor(Math.random() * list.length)] const pick = list[Math.floor(Math.random() * list.length)]
if (pick) handleSelect(pick) if (pick) handleSelect(pick)
@ -117,11 +117,7 @@ export function SujetsPage() {
className="mb-4 rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-sm text-danger" className="mb-4 rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-sm text-danger"
> >
Impossible de charger les sujets.{' '} Impossible de charger les sujets.{' '}
<button <button type="button" onClick={() => refetch()} className="underline underline-offset-2">
type="button"
onClick={() => refetch()}
className="underline underline-offset-2"
>
Réessayer Réessayer
</button> </button>
</div> </div>

View file

@ -146,7 +146,5 @@ export function SimulationFlowProvider({ children }: { children: ReactNode }) {
reset, reset,
} }
return ( return <SimulationFlowContext.Provider value={value}>{children}</SimulationFlowContext.Provider>
<SimulationFlowContext.Provider value={value}>{children}</SimulationFlowContext.Provider>
)
} }

View file

@ -7,20 +7,11 @@
*/ */
import { createContext, useContext } from 'react' import { createContext, useContext } from 'react'
import type { import type { CreateSimulationPayload, Production, SujetData } from '@/entities/production/types'
CreateSimulationPayload,
Production,
SujetData,
} from '@/entities/production/types'
import type { Report } from '@/entities/report/types' import type { Report } from '@/entities/report/types'
import type { ApiError } from '@/shared/types/api' import type { ApiError } from '@/shared/types/api'
export type SimulationStep = export type SimulationStep = 'idle' | 'choosing-subject' | 'task-selected' | 'correcting' | 'done'
| 'idle'
| 'choosing-subject'
| 'task-selected'
| 'correcting'
| 'done'
export interface FlowValue { export interface FlowValue {
step: SimulationStep step: SimulationStep

View file

@ -12,25 +12,25 @@
import { cn } from '@/shared/lib/utils' import { cn } from '@/shared/lib/utils'
export type BadgeVariant = 'plan' | 'nclc' | 'neutral' export type BadgeVariant = 'plan' | 'nclc' | 'neutral'
export type BadgePlanValue = 'free' | 'standard' | 'premium' export type BadgePlanValue = 'free' | 'standard' | 'premium'
export interface BadgeProps { export interface BadgeProps {
variant : BadgeVariant variant: BadgeVariant
planValue?: BadgePlanValue planValue?: BadgePlanValue
className?: string className?: string
children : React.ReactNode children: React.ReactNode
} }
const planClasses: Record<BadgePlanValue, string> = { const planClasses: Record<BadgePlanValue, string> = {
free : 'bg-canvas-2 text-ink-4', free: 'bg-canvas-2 text-ink-4',
standard: 'bg-expria-50 text-expria', standard: 'bg-expria-50 text-expria',
premium : 'bg-deep text-white', premium: 'bg-deep text-white',
} }
const variantClasses: Record<BadgeVariant, string> = { const variantClasses: Record<BadgeVariant, string> = {
plan : '', // résolu dynamiquement via planValue plan: '', // résolu dynamiquement via planValue
nclc : 'bg-expria-50 text-expria', nclc: 'bg-expria-50 text-expria',
neutral: 'bg-canvas-2 text-ink-4', neutral: 'bg-canvas-2 text-ink-4',
} }

View file

@ -13,13 +13,13 @@ import { Loader2 } from 'lucide-react'
import { cn } from '@/shared/lib/utils' import { cn } from '@/shared/lib/utils'
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'upgrade' export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'upgrade'
export type ButtonSize = 'sm' | 'md' | 'lg' export type ButtonSize = 'sm' | 'md' | 'lg'
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant? : ButtonVariant variant?: ButtonVariant
size? : ButtonSize size?: ButtonSize
icon? : React.ReactNode icon?: React.ReactNode
loading? : boolean loading?: boolean
className?: string className?: string
} }
@ -28,10 +28,8 @@ const variantClasses: Record<ButtonVariant, string> = {
'bg-expria text-white hover:bg-expria-hover active:bg-expria-hover disabled:bg-expria/50', 'bg-expria text-white hover:bg-expria-hover active:bg-expria-hover disabled:bg-expria/50',
secondary: secondary:
'border border-line bg-surface text-ink-2 hover:bg-canvas hover:text-ink-1 disabled:text-ink-4', 'border border-line bg-surface text-ink-2 hover:bg-canvas hover:text-ink-1 disabled:text-ink-4',
ghost: ghost: 'bg-transparent text-ink-3 hover:bg-canvas hover:text-ink-1 disabled:text-ink-5',
'bg-transparent text-ink-3 hover:bg-canvas hover:text-ink-1 disabled:text-ink-5', upgrade: 'bg-deep text-white hover:bg-deep-2 active:bg-deep-2 disabled:bg-deep/50',
upgrade:
'bg-deep text-white hover:bg-deep-2 active:bg-deep-2 disabled:bg-deep/50',
} }
const sizeClasses: Record<ButtonSize, string> = { const sizeClasses: Record<ButtonSize, string> = {
@ -41,10 +39,10 @@ const sizeClasses: Record<ButtonSize, string> = {
} }
export function Button({ export function Button({
variant = 'primary', variant = 'primary',
size = 'md', size = 'md',
icon, icon,
loading = false, loading = false,
disabled, disabled,
className, className,
children, children,

View file

@ -14,9 +14,9 @@ import { cn } from '@/shared/lib/utils'
export type CardVariant = 'default' | 'raised' | 'interactive' export type CardVariant = 'default' | 'raised' | 'interactive'
interface CardBaseProps { interface CardBaseProps {
variant? : CardVariant variant?: CardVariant
className?: string className?: string
children : React.ReactNode children: React.ReactNode
} }
interface CardDivProps extends CardBaseProps { interface CardDivProps extends CardBaseProps {
@ -32,8 +32,8 @@ export type CardProps = CardDivProps | CardButtonProps
const baseClasses = 'rounded-lg border border-line bg-surface' const baseClasses = 'rounded-lg border border-line bg-surface'
const variantClasses: Record<CardVariant, string> = { const variantClasses: Record<CardVariant, string> = {
default : 'shadow-sm', default: 'shadow-sm',
raised : 'shadow-md', raised: 'shadow-md',
interactive: interactive:
'shadow-sm cursor-pointer transition-colors duration-150 hover:border-expria hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus', 'shadow-sm cursor-pointer transition-colors duration-150 hover:border-expria hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus',
} }
@ -49,9 +49,5 @@ export function Card({ variant = 'default', className, children, onClick }: Card
) )
} }
return ( return <div className={classes}>{children}</div>
<div className={classes}>
{children}
</div>
)
} }