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 ────────────────────────────────────────── */}
{/* pb-16 sur mobile pour ne pas être masqué par le BottomNav fixe */}
<div className="pb-16 lg:pl-60 lg:pb-0">
{children}
</div>
<div className="pb-16 lg:pl-60 lg:pb-0">{children}</div>
{/* ── MOBILE — BottomNav fixe ────────────────────────────────── */}
<BottomNav />

View file

@ -16,8 +16,8 @@ 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' },
{ label: 'Expression Orale', to: '/simulation/eo' },
{ label: 'Examen blanc', to: '/examen' },
] as const
export function BottomNav() {
@ -102,10 +102,7 @@ export function BottomNav() {
)}
>
<BookOpen
className={cn(
'size-5',
(isActive('/simulation') || isSheetOpen) && 'text-expria',
)}
className={cn('size-5', (isActive('/simulation') || isSheetOpen) && 'text-expria')}
aria-hidden="true"
/>
Simuler

View file

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

View file

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

View file

@ -48,34 +48,34 @@ export function AppRouter() {
return (
<Routes>
{/* ── Routes publiques ─────────────────────────────────────── */}
<Route path="/login" element={<LoginPage />} />
<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 />} />
<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 path="/simulation" element={<Navigate to="/simulation/ee" replace />} />
<Route element={<SimulationFlowLayout />}>
<Route path="/simulation/ee" element={<SimulationPage />} />
<Route path="/sujets" element={<SujetsPage />} />
<Route path="/rapport/:id" element={<RapportPage />} />
<Route path="/sujets" element={<SujetsPage />} />
<Route path="/rapport/:id" element={<RapportPage />} />
</Route>
<Route path="/simulation/eo" element={<ComingSoon />} />
{/* 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={<ComingSoon />} />
<Route path="/parametres" element={<ComingSoon />} />
<Route path="/examen" element={<ComingSoon />} />
<Route path="/progression" element={<ProgressionPage />} />
<Route path="/methodologie" element={<ComingSoon />} />
<Route path="/historique" element={<HistoriquePage />} />
<Route path="/plan" element={<ComingSoon />} />
<Route path="/parametres" element={<ComingSoon />} />
</Route>
{/* ── Dev only ─────────────────────────────────────────────── */}

View file

@ -13,7 +13,7 @@ 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)
frequency: number // 3, 4 ou 5 (seuil d'agrégation : ≥ 3)
description: string | null // non-null uniquement pour code === 'autre'
}
@ -23,14 +23,14 @@ export interface PatternExercice {
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
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
score: number // 0-100 entier
message: string // interprétation textuelle fixée par le backend
}
@ -40,13 +40,13 @@ export interface PatternsReady {
exercises: PatternExercice[]
preparation_index: PreparationIndex
analyzed_productions: number
last_analysis: string // ISO timestamp
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
minimum: number // toujours 5 côté backend actuel
current: number // nb de productions corrigées déjà réalisées
}
export type PatternsResponse = PatternsReady | PatternsNotReady

View file

@ -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.
* Champs lourds exclus (contenu, rapport, exercices, modele) cf. SimulationListItem.
*/
export function listSimulations(
page: number,
limit: number,
): Promise<SimulationsListResponse> {
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()}`)
}
@ -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
* (EO_T1 : sujet fixe connu, EO_T2_LIVE : interaction sans sujet).
*/
function mapTacheToSujetParams(
tache: Tache,
): { mode: 'EE' | 'EO'; tacheNumber: 1 | 2 | 3 } | null {
function mapTacheToSujetParams(tache: Tache): { mode: 'EE' | 'EO'; tacheNumber: 1 | 2 | 3 } | null {
switch (tache) {
case 'EE_T1':
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 }
export function ecartVsCible(score: number, nclcCible: number): {
export function ecartVsCible(
score: number,
nclcCible: number,
): {
points: number
atteint: boolean
} {

View file

@ -28,7 +28,7 @@ export interface ErreurCode {
export interface Critere {
nom: string
score: number // 0-5
score: number // 0-5
commentaire: string
exemple: string
suggestion: string
@ -42,7 +42,7 @@ export interface Revelation {
}
export interface ConseilNclc {
nclc_cible: string // ex. "NCLC 9"
nclc_cible: string // ex. "NCLC 9"
ecart: string
action_prioritaire: string
}
@ -101,14 +101,14 @@ export type NclcCible = 9 | 10
*/
export interface Report {
simulation_id: string
score: number // /20
nclc: number // NCLC atteint — ex. 8
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
erreurs_codes: ErreurCode[] // top-level — regroupés par critère côté UI
exercices: Exercice[] | null
exercices_status: JobStatus
modele: ProductionModele | null
@ -120,7 +120,7 @@ export interface CorrectEePayload {
simulationId: string
contenu: 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 {
getCurrentSession,
subscribeToAuthChanges,
type User,
} from '@/shared/lib/auth-client'
import { getCurrentSession, subscribeToAuthChanges, type User } from '@/shared/lib/auth-client'
interface UseAuthResult {
user: User | null

View file

@ -62,8 +62,7 @@ export function MonProfilPreparation({ plan }: Props) {
Mon profil de préparation
</p>
<p className="text-sm text-ink-2">
Encore{' '}
<span className="font-semibold tabular-nums">{remaining}</span>{' '}
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-4 tabular-nums">
@ -108,10 +107,7 @@ export function MonProfilPreparation({ plan }: Props) {
</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"
>
<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>

View file

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

View file

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

View file

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

View file

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

View file

@ -35,9 +35,7 @@ export function PatternExerciceCard({ exercice }: Props) {
<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-4">
{exercice.code.replace(/_/g, ' ')}
</span>
<span className="text-xs font-medium text-ink-4">{exercice.code.replace(/_/g, ' ')}</span>
</div>
{exercice.diagnostic && (
<p className="text-sm leading-relaxed text-ink-2">
@ -53,12 +51,8 @@ export function PatternExerciceCard({ exercice }: Props) {
{exercice.exercice.consigne && (
<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">
Consigne
</p>
<p className="text-sm leading-relaxed text-ink-1">
{exercice.exercice.consigne}
</p>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">Consigne</p>
<p className="text-sm leading-relaxed text-ink-1">{exercice.exercice.consigne}</p>
</div>
)}
@ -75,9 +69,7 @@ export function PatternExerciceCard({ exercice }: Props) {
<p className="text-[11px] font-semibold uppercase tracking-widest text-success">
Correct
</p>
<p className="text-sm leading-relaxed text-ink-1">
{exercice.exercice.correction}
</p>
<p className="text-sm leading-relaxed text-ink-1">{exercice.exercice.correction}</p>
</div>
</div>
@ -87,9 +79,7 @@ export function PatternExerciceCard({ exercice }: Props) {
<p className="text-[11px] font-semibold uppercase tracking-widest text-warning">
Astuce de relecture
</p>
<p className="text-sm leading-relaxed text-ink-1">
{exercice.exercice.astuce}
</p>
<p className="text-sm leading-relaxed text-ink-1">{exercice.exercice.astuce}</p>
</div>
</div>
</Card>

View file

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

View file

@ -31,17 +31,13 @@ export function ProgressionPremium({ data }: Props) {
<PreparationIndexHero index={data.preparation_index} />
<section aria-label="Erreurs récurrentes">
<h2 className="mb-3 text-base font-semibold text-ink-1">
Erreurs récurrentes
</h2>
<h2 className="mb-3 text-base font-semibold text-ink-1">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-1">
Exercices long terme
</h2>
<h2 className="mb-3 text-base font-semibold text-ink-1">Exercices long terme</h2>
<div className="space-y-3">
{data.exercises.map((ex, i) => (
<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 { MemoryRouter } from 'react-router-dom'
import { ProgressionPremium } from '../ProgressionPremium'
import type {
PatternsReady,
PatternsNotReady,
PatternExercice,
} from '@/entities/patterns/types'
import type { PatternsReady, PatternsNotReady, PatternExercice } from '@/entities/patterns/types'
afterEach(cleanup)
@ -36,7 +32,12 @@ const EXERCICE: PatternExercice = {
const READY_DATA: PatternsReady = {
ready: true,
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 },
],
exercises: [EXERCICE],
@ -65,7 +66,7 @@ describe('ProgressionPremium — état not-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} />)
expect(screen.getByText('72')).toBeInTheDocument()
@ -83,7 +84,7 @@ describe('ProgressionPremium — état ready', () => {
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} />)
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"', () => {
renderWithRouter(<ProgressionPremium data={READY_DATA} />)
expect(
screen.getByText(/analyse basée sur vos 5 dernières productions/i),
).toBeInTheDocument()
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"', () => {

View file

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

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">
{idees.map((idee, i) => (
<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>
</li>
))}

View file

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

View file

@ -117,11 +117,7 @@ export function SimulationForm({
const tipsAllowed = hasAccess(plan, 'tips')
const ideesDisabled =
isSubmitting ||
idees.isLoading ||
!sujet ||
!tipsAllowed ||
wordCount < MIN_WORDS_IDEES
isSubmitting || idees.isLoading || !sujet || !tipsAllowed || wordCount < MIN_WORDS_IDEES
const ideesTitle = !tipsAllowed
? 'Disponible en Standard'
: 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"
/>
<WordCountBar count={wordCount} config={config} />
<NclcCibleSelector
value={nclcCible}
onChange={setNclcCible}
disabled={isSubmitting}
/>
<NclcCibleSelector value={nclcCible} onChange={setNclcCible} disabled={isSubmitting} />
{autosave.savedAt && !fieldError && (
<p className="text-xs text-ink-4" aria-live="polite">

View file

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

View file

@ -24,9 +24,7 @@ function DocumentBlock({ titre, texte }: { titre: string | null; texte: string |
return (
<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>}
{texte && (
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-2">{texte}</p>
)}
{texte && <p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-2">{texte}</p>}
</article>
)
}
@ -45,9 +43,7 @@ export function SujetDisplay({ sujet }: Props) {
)}
{sujet.contexte && (
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-3">
{sujet.contexte}
</p>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-3">{sujet.contexte}</p>
)}
<div>

View file

@ -44,7 +44,13 @@ const EE_CARDS: readonly TaskCard[] = [
const EO_CARDS: readonly TaskCard[] = [
{ 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_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) {
@ -56,9 +62,7 @@ export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-ink-1">Choisir une tâche</h2>
<p className="mt-1 text-sm text-ink-3">
Sélectionnez la tâche que vous souhaitez simuler.
</p>
<p className="mt-1 text-sm text-ink-3">Sélectionnez la tâche que vous souhaitez simuler.</p>
</div>
{quotaBlocked && (
@ -81,11 +85,7 @@ export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect
if (locked) {
return (
<Card
key={card.key}
variant="default"
className="flex flex-col p-4 opacity-60"
>
<Card key={card.key} variant="default" className="flex flex-col p-4 opacity-60">
{card.tache === null && (
<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>
<Card variant="raised" className="space-y-3 p-4">
<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">
Objectif
</p>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">Objectif</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">
Écart
</p>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">Écart</p>
<p className="text-sm text-ink-2">{conseil.ecart}</p>
</div>
<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">
Exemple tiré de votre texte
</p>
<p className="italic text-sm leading-relaxed text-ink-2">
« {critere.exemple} »
</p>
<p className="italic text-sm leading-relaxed text-ink-2">« {critere.exemple} »</p>
</div>
)}
@ -61,7 +59,9 @@ export function CritereCard({ critere, erreursCodes }: Props) {
{critere.astuce && (
<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>
</div>
)}

View file

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

View file

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

View file

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

View file

@ -18,7 +18,7 @@ interface Props {
const SECTIONS: { key: keyof Revelation; titre: string; ton: 'ink' | 'warning' | 'danger' }[] = [
{ 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' },
]
@ -35,7 +35,9 @@ export function RevelationCards({ revelation }: Props) {
<div className="grid gap-3 sm:grid-cols-3">
{SECTIONS.map(({ key, titre, ton }) => (
<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}
</p>
<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'
interface Props {
score: number // /20
nclc: number // NCLC atteint
score: number // /20
nclc: number // NCLC atteint
nclcCible: NclcCible
}
@ -30,9 +30,7 @@ export function ScoreHero({ score, nclc, nclcCible }: Props) {
<Card variant="raised" className="space-y-4 p-6">
<div className="flex flex-wrap items-end gap-8">
<div>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Score
</p>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">Score</p>
<p className="mt-1 tabular-nums text-ink-1">
<span className="text-5xl font-bold">{score}</span>
<span className="text-2xl font-medium text-ink-4">/20</span>
@ -47,9 +45,7 @@ export function ScoreHero({ score, nclc, nclcCible }: Props) {
</Badge>
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Objectif
</p>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">Objectif</p>
<Badge variant="neutral" className="mt-2">
NCLC {nclcCible}
</Badge>
@ -67,9 +63,7 @@ export function ScoreHero({ score, nclc, nclcCible }: Props) {
aria-label={`Score ${score} sur 20`}
>
<div
className={`h-full transition-all duration-300 ${
atteint ? 'bg-success' : 'bg-expria'
}`}
className={`h-full transition-all duration-300 ${atteint ? 'bg-success' : 'bg-expria'}`}
style={{ width: `${percent}%` }}
/>
{/* Marqueur du seuil NCLC cible */}
@ -82,7 +76,9 @@ export function ScoreHero({ score, nclc, nclcCible }: Props) {
</div>
<div className="flex justify-between text-xs text-ink-4 tabular-nums">
<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>
</div>
</div>
@ -94,9 +90,7 @@ export function ScoreHero({ score, nclc, nclcCible }: Props) {
</p>
) : (
<p className="rounded-md border border-warning/30 bg-warning-bg px-3 py-2 text-sm text-warning">
{points === 1
? '1 point avant NCLC '
: `${points} points avant NCLC `}
{points === 1 ? '1 point avant NCLC ' : `${points} points avant NCLC `}
{nclcCible}+
</p>
)}

View file

@ -21,7 +21,7 @@ const EXERCICE: Exercice = {
extrait: 'les enfants joue',
indice: 'Pluriel du sujet ?',
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', () => {

View file

@ -31,7 +31,7 @@ describe('useAutosave', () => {
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(
({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true),
{ initialProps: { contenu: '' } },
@ -112,7 +112,7 @@ describe('useAutosave', () => {
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(
({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true),
{ initialProps: { contenu: '' } },

View file

@ -19,11 +19,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useSimulation } from '../useSimulation'
import { SimulationFlowProvider } from '../../state/SimulationFlowProvider'
import { useSimulationFlow } from '../../state/simulationFlow'
import {
createSimulation,
getSimulationState,
updateSujet,
} from '@/entities/production/api'
import { createSimulation, getSimulationState, updateSujet } from '@/entities/production/api'
import { correctEe } from '@/entities/report/api'
import type { Production } from '@/entities/production/types'
import type { Report } from '@/entities/report/types'
@ -134,7 +130,12 @@ describe('useSimulation — selectTask', () => {
it('isCreating = true pendant la mutation createSimulation', async () => {
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() })
@ -154,7 +155,12 @@ describe('useSimulation — submitText', () => {
mockCreateSimulation.mockResolvedValue(mockProduction)
let resolveCorrect!: (r: Report) => void
mockCorrectEe.mockImplementation(() => new Promise(r => { resolveCorrect = r }))
mockCorrectEe.mockImplementation(
() =>
new Promise((r) => {
resolveCorrect = r
}),
)
const { result } = renderHook(
() => {
@ -202,8 +208,7 @@ describe('useSimulation — submitText', () => {
})
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'))
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"). */
export function formatTimer(secondes: number): string {
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')
return `${mm}:${ss}`
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,25 +12,25 @@
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 interface BadgeProps {
variant : BadgeVariant
variant: BadgeVariant
planValue?: BadgePlanValue
className?: string
children : React.ReactNode
children: React.ReactNode
}
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',
premium : 'bg-deep text-white',
premium: 'bg-deep text-white',
}
const variantClasses: Record<BadgeVariant, string> = {
plan : '', // résolu dynamiquement via planValue
nclc : 'bg-expria-50 text-expria',
plan: '', // résolu dynamiquement via planValue
nclc: 'bg-expria-50 text-expria',
neutral: 'bg-canvas-2 text-ink-4',
}

View file

@ -13,13 +13,13 @@ import { Loader2 } from 'lucide-react'
import { cn } from '@/shared/lib/utils'
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> {
variant? : ButtonVariant
size? : ButtonSize
icon? : React.ReactNode
loading? : boolean
variant?: ButtonVariant
size?: ButtonSize
icon?: React.ReactNode
loading?: boolean
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',
secondary:
'border border-line bg-surface text-ink-2 hover:bg-canvas hover:text-ink-1 disabled:text-ink-4',
ghost:
'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',
ghost: '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',
}
const sizeClasses: Record<ButtonSize, string> = {
@ -41,10 +39,10 @@ const sizeClasses: Record<ButtonSize, string> = {
}
export function Button({
variant = 'primary',
size = 'md',
variant = 'primary',
size = 'md',
icon,
loading = false,
loading = false,
disabled,
className,
children,

View file

@ -14,9 +14,9 @@ import { cn } from '@/shared/lib/utils'
export type CardVariant = 'default' | 'raised' | 'interactive'
interface CardBaseProps {
variant? : CardVariant
variant?: CardVariant
className?: string
children : React.ReactNode
children: React.ReactNode
}
interface CardDivProps extends CardBaseProps {
@ -32,8 +32,8 @@ export type CardProps = CardDivProps | CardButtonProps
const baseClasses = 'rounded-lg border border-line bg-surface'
const variantClasses: Record<CardVariant, string> = {
default : 'shadow-sm',
raised : 'shadow-md',
default: 'shadow-sm',
raised: 'shadow-md',
interactive:
'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 (
<div className={classes}>
{children}
</div>
)
return <div className={classes}>{children}</div>
}