style: prettier format
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
79bbbdc4e8
commit
99617f117c
45 changed files with 229 additions and 302 deletions
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,5 @@ if (!container) {
|
|||
}
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
{isMaintenanceMode ? <MaintenancePage /> : <Providers />}
|
||||
</StrictMode>,
|
||||
<StrictMode>{isMaintenanceMode ? <MaintenancePage /> : <Providers />}</StrictMode>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────── */}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
} {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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"', () => {
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -13,8 +13,36 @@
|
|||
*/
|
||||
|
||||
const SPECIAL_CHARS = [
|
||||
'à', 'â', 'é', 'è', 'ê', 'ë', 'î', 'ï', 'ô', 'ù', 'û', 'ü', 'ç', 'œ', 'æ',
|
||||
'À', 'Â', 'É', 'È', 'Ê', 'Ë', 'Î', 'Ï', 'Ô', 'Ù', 'Û', 'Ü', 'Ç', 'Œ', 'Æ',
|
||||
'à',
|
||||
'â',
|
||||
'é',
|
||||
'è',
|
||||
'ê',
|
||||
'ë',
|
||||
'î',
|
||||
'ï',
|
||||
'ô',
|
||||
'ù',
|
||||
'û',
|
||||
'ü',
|
||||
'ç',
|
||||
'œ',
|
||||
'æ',
|
||||
'À',
|
||||
'Â',
|
||||
'É',
|
||||
'È',
|
||||
'Ê',
|
||||
'Ë',
|
||||
'Î',
|
||||
'Ï',
|
||||
'Ô',
|
||||
'Ù',
|
||||
'Û',
|
||||
'Ü',
|
||||
'Ç',
|
||||
'Œ',
|
||||
'Æ',
|
||||
] as const
|
||||
|
||||
interface Props {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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: '' } },
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue