feat(historique): refonte pixel-perfect avec stats + filtres + tendance 30j (Sprint 4.7)

Inclut le retrait du padding de AppLayout et le wrapper standardisé
(mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9) ajouté sur
11 pages (Dashboard, Progression, 9 pages Simulation EE/EO/T1) pour
laisser chaque page gérer son max-width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-26 00:04:12 +03:00
parent d8bae9520c
commit 3ce91aaa7b
20 changed files with 1417 additions and 874 deletions

View file

@ -91,81 +91,85 @@ export function EnregistrementEOPage() {
const lockControls = submitting || isCorrecting
return (
<main className="mx-auto max-w-3xl px-4 py-6">
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold text-ink-primary">{formatTache(production.tache)}</h2>
<Badge variant="neutral" className="inline-flex items-center gap-1.5">
<Timer className="size-3.5" aria-hidden="true" />
Durée recommandée : {formatTimer(dureeRecommandee)}
</Badge>
</div>
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-3xl px-4 py-6">
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold text-ink-primary">
{formatTache(production.tache)}
</h2>
<Badge variant="neutral" className="inline-flex items-center gap-1.5">
<Timer className="size-3.5" aria-hidden="true" />
Durée recommandée : {formatTimer(dureeRecommandee)}
</Badge>
</div>
{/* T1 affiche la présentation générée comme texte de référence à lire.
{/* T1 affiche la présentation générée comme texte de référence à lire.
T3 affiche le sujet sélectionné. Les deux sont mutuellement exclusifs. */}
{production.tache === 'EO_T1' && presentationT1 && (
<section
className="mb-6 rounded-lg border border-border bg-surface-solid p-4"
aria-label="Texte de présentation de référence"
>
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Ta présentation (référence)
</p>
<div className="max-h-48 overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed text-ink-secondary">
{presentationT1}
</div>
</section>
)}
{production.tache !== 'EO_T1' && sujet && (
<div className="mb-6">
<SujetDisplay sujet={sujet} />
</div>
)}
<AudioRecorder
minSeconds={minSeconds}
maxSeconds={dureeRecommandee || undefined}
downloadFilename={`expria-${production.tache.toLowerCase()}-${production.id.slice(0, 8)}`}
onSubmit={handleSubmit}
onCancel={handleCancel}
autoStart
disabled={lockControls}
/>
{lockControls && (
<div
role="status"
aria-live="polite"
className="mt-4 flex items-start gap-2 rounded-md border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand-text"
>
<Loader2 className="mt-0.5 size-4 shrink-0 animate-spin" aria-hidden="true" />
<div>
<p className="font-medium">Transcription et correction en cours</p>
<p className="mt-0.5 text-xs">
Cela peut prendre jusqu'à 60 secondes. Tu seras redirigé vers le rapport
automatiquement.
{production.tache === 'EO_T1' && presentationT1 && (
<section
className="mb-6 rounded-lg border border-border bg-surface-solid p-4"
aria-label="Texte de présentation de référence"
>
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Ta présentation (référence)
</p>
<div className="max-h-48 overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed text-ink-secondary">
{presentationT1}
</div>
</section>
)}
{production.tache !== 'EO_T1' && sujet && (
<div className="mb-6">
<SujetDisplay sujet={sujet} />
</div>
</div>
)}
)}
{encodingError && (
<div
role="alert"
className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
{encodingError}
</div>
)}
<AudioRecorder
minSeconds={minSeconds}
maxSeconds={dureeRecommandee || undefined}
downloadFilename={`expria-${production.tache.toLowerCase()}-${production.id.slice(0, 8)}`}
onSubmit={handleSubmit}
onCancel={handleCancel}
autoStart
disabled={lockControls}
/>
{correctError && !lockControls && (
<div
role="alert"
className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
La correction a échoué. Réessayez dans quelques instants.
</div>
)}
</main>
{lockControls && (
<div
role="status"
aria-live="polite"
className="mt-4 flex items-start gap-2 rounded-md border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand-text"
>
<Loader2 className="mt-0.5 size-4 shrink-0 animate-spin" aria-hidden="true" />
<div>
<p className="font-medium">Transcription et correction en cours</p>
<p className="mt-0.5 text-xs">
Cela peut prendre jusqu'à 60 secondes. Tu seras redirigé vers le rapport
automatiquement.
</p>
</div>
</div>
)}
{encodingError && (
<div
role="alert"
className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
{encodingError}
</div>
)}
{correctError && !lockControls && (
<div
role="alert"
className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
La correction a échoué. Réessayez dans quelques instants.
</div>
)}
</main>
</div>
)
}

View file

@ -29,50 +29,54 @@ export function ModeChoixT1Page() {
if (shouldRedirect) return null
return (
<main className="mx-auto max-w-3xl px-4 py-6">
<div className="mb-2 flex items-center gap-3">
<button
type="button"
onClick={() => {
reset()
navigate('/simulation/eo')
}}
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
>
Retour
</button>
</div>
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-3xl px-4 py-6">
<div className="mb-2 flex items-center gap-3">
<button
type="button"
onClick={() => {
reset()
navigate('/simulation/eo')
}}
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
>
Retour
</button>
</div>
<h2 className="text-lg font-semibold text-ink-primary">Tâche 1 Présentation personnelle</h2>
<p className="mt-1 mb-6 text-sm text-ink-secondary">Choisis ton mode d'entraînement.</p>
<h2 className="text-lg font-semibold text-ink-primary">
Tâche 1 Présentation personnelle
</h2>
<p className="mt-1 mb-6 text-sm text-ink-secondary">Choisis ton mode d'entraînement.</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Card
variant="interactive"
className="flex flex-col gap-3 p-5"
onClick={() => navigate('/simulation/eo/t1/questionnaire')}
>
<Sparkles className="size-6 text-brand-text" aria-hidden="true" />
<h3 className="text-base font-semibold text-ink-primary">Générer ma présentation</h3>
<p className="text-sm leading-relaxed text-ink-secondary">
Réponds à 5 questions Expria génère ton texte personnalisé que tu lis avant
d'enregistrer.
</p>
</Card>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Card
variant="interactive"
className="flex flex-col gap-3 p-5"
onClick={() => navigate('/simulation/eo/t1/questionnaire')}
>
<Sparkles className="size-6 text-brand-text" aria-hidden="true" />
<h3 className="text-base font-semibold text-ink-primary">Générer ma présentation</h3>
<p className="text-sm leading-relaxed text-ink-secondary">
Réponds à 5 questions Expria génère ton texte personnalisé que tu lis avant
d'enregistrer.
</p>
</Card>
<Card
variant="interactive"
className="flex flex-col gap-3 p-5"
onClick={() => navigate('/simulation/eo/pre-enregistrement')}
>
<Mic className="size-6 text-brand-text" aria-hidden="true" />
<h3 className="text-base font-semibold text-ink-primary">Enregistrer directement</h3>
<p className="text-sm leading-relaxed text-ink-secondary">
Tu as déjà préparé ta présentation enregistre-toi directement sans passer par le
formulaire.
</p>
</Card>
</div>
</main>
<Card
variant="interactive"
className="flex flex-col gap-3 p-5"
onClick={() => navigate('/simulation/eo/pre-enregistrement')}
>
<Mic className="size-6 text-brand-text" aria-hidden="true" />
<h3 className="text-base font-semibold text-ink-primary">Enregistrer directement</h3>
<p className="text-sm leading-relaxed text-ink-secondary">
Tu as déjà préparé ta présentation enregistre-toi directement sans passer par le
formulaire.
</p>
</Card>
</div>
</main>
</div>
)
}

View file

@ -51,55 +51,59 @@ export function PreEnregistrementEOPage() {
}
return (
<main className="mx-auto max-w-3xl px-4 py-6">
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold text-ink-primary">{heading}</h2>
<Badge variant="neutral" className="inline-flex items-center gap-1.5">
<Timer className="size-3.5" aria-hidden="true" />
Durée recommandée : {formatTimer(dureeRecommandee)}
</Badge>
</div>
{sujet && !isT1 && (
<div className="mb-6">
<SujetDisplay sujet={sujet} />
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-3xl px-4 py-6">
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold text-ink-primary">{heading}</h2>
<Badge variant="neutral" className="inline-flex items-center gap-1.5">
<Timer className="size-3.5" aria-hidden="true" />
Durée recommandée : {formatTimer(dureeRecommandee)}
</Badge>
</div>
)}
<div className="mb-6 rounded-lg border border-border bg-surface p-4 text-sm text-ink-secondary">
<p className="font-medium text-ink-primary">Avant de commencer</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Trouvez un endroit calme : la transcription est plus précise sans bruit de fond.</li>
<li>Autorisez l'accès au micro dans votre navigateur lorsque demandé.</li>
<li>Parlez de manière naturelle. La durée est indicative, pas un cap.</li>
{isT1 && (
<li>
Présentez-vous à l'examinateur : prénom, parcours, situation familiale, loisirs,
projet d'immigration au Canada.
</li>
)}
<li>
Vous pourrez télécharger votre enregistrement à la fin il n'est pas conservé sur nos
serveurs.
</li>
</ul>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="primary"
size="lg"
icon={<Mic className="size-4" aria-hidden="true" />}
onClick={handleStart}
>
Démarrer l'enregistrement
</Button>
{isT3 && (
<Button variant="secondary" size="md" onClick={handleChangeSujet}>
Changer de sujet
</Button>
{sujet && !isT1 && (
<div className="mb-6">
<SujetDisplay sujet={sujet} />
</div>
)}
</div>
</main>
<div className="mb-6 rounded-lg border border-border bg-surface p-4 text-sm text-ink-secondary">
<p className="font-medium text-ink-primary">Avant de commencer</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>
Trouvez un endroit calme : la transcription est plus précise sans bruit de fond.
</li>
<li>Autorisez l'accès au micro dans votre navigateur lorsque demandé.</li>
<li>Parlez de manière naturelle. La durée est indicative, pas un cap.</li>
{isT1 && (
<li>
Présentez-vous à l'examinateur : prénom, parcours, situation familiale, loisirs,
projet d'immigration au Canada.
</li>
)}
<li>
Vous pourrez télécharger votre enregistrement à la fin il n'est pas conservé sur nos
serveurs.
</li>
</ul>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="primary"
size="lg"
icon={<Mic className="size-4" aria-hidden="true" />}
onClick={handleStart}
>
Démarrer l'enregistrement
</Button>
{isT3 && (
<Button variant="secondary" size="md" onClick={handleChangeSujet}>
Changer de sujet
</Button>
)}
</div>
</main>
</div>
)
}

View file

@ -108,91 +108,93 @@ export function PresentationGenereeT1Page() {
}
return (
<main className="mx-auto max-w-3xl px-4 py-6">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-ink-primary">Ta présentation générée</h2>
<p className="mt-1 text-sm text-ink-secondary">
Lis-la, modifie-la si nécessaire, puis enregistre-toi.
</p>
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-3xl px-4 py-6">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-ink-primary">Ta présentation générée</h2>
<p className="mt-1 text-sm text-ink-secondary">
Lis-la, modifie-la si nécessaire, puis enregistre-toi.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
icon={
copied ? (
<Check className="size-4" aria-hidden="true" />
) : (
<Copy className="size-4" aria-hidden="true" />
)
}
onClick={handleCopy}
>
{copied ? 'Copié' : 'Copier'}
</Button>
<Button
variant="secondary"
size="sm"
icon={<Download className="size-4" aria-hidden="true" />}
onClick={handleDownload}
>
.txt
</Button>
<Button
variant="secondary"
size="sm"
icon={
isEditing ? (
<Save className="size-4" aria-hidden="true" />
) : (
<Pencil className="size-4" aria-hidden="true" />
)
}
onClick={handleToggleEdit}
>
{isEditing ? 'Enregistrer les modifications' : 'Modifier'}
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
icon={
copied ? (
<Check className="size-4" aria-hidden="true" />
) : (
<Copy className="size-4" aria-hidden="true" />
)
}
onClick={handleCopy}
>
{copied ? 'Copié' : 'Copier'}
</Button>
<Button
variant="secondary"
size="sm"
icon={<Download className="size-4" aria-hidden="true" />}
onClick={handleDownload}
>
.txt
</Button>
<Button
variant="secondary"
size="sm"
icon={
isEditing ? (
<Save className="size-4" aria-hidden="true" />
) : (
<Pencil className="size-4" aria-hidden="true" />
)
}
onClick={handleToggleEdit}
>
{isEditing ? 'Enregistrer les modifications' : 'Modifier'}
</Button>
</div>
</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
readOnly={!isEditing}
rows={12}
className="w-full rounded-lg border border-border bg-surface p-4 text-sm leading-relaxed text-ink-primary focus:border-brand focus:outline-none focus:shadow-focus disabled:cursor-not-allowed read-only:bg-surface-solid"
/>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
readOnly={!isEditing}
rows={12}
className="w-full rounded-lg border border-border bg-surface p-4 text-sm leading-relaxed text-ink-primary focus:border-brand focus:outline-none focus:shadow-focus disabled:cursor-not-allowed read-only:bg-surface-solid"
/>
<div
role="note"
className="mt-4 rounded-md border border-warning/40 bg-warning-soft px-4 py-3 text-sm text-ink-primary"
>
<strong className="text-warning">Conseil :</strong> Lis ce texte à voix haute 2-3 fois avant
d'enregistrer. Vise un débit naturel, ni trop rapide ni trop lent.
</div>
<div className="mt-3 flex flex-col gap-2 rounded-md border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand-text sm:flex-row sm:items-center sm:justify-between">
<span>Présentation sauvegardée retrouvée automatiquement à ta prochaine visite.</span>
<button
type="button"
onClick={handleRefaire}
className="inline-flex items-center gap-1.5 text-sm font-medium underline underline-offset-4 hover:text-brand-text"
<div
role="note"
className="mt-4 rounded-md border border-warning/40 bg-warning-soft px-4 py-3 text-sm text-ink-primary"
>
<RotateCcw className="size-3.5" aria-hidden="true" />
Refaire
</button>
</div>
<strong className="text-warning">Conseil :</strong> Lis ce texte à voix haute 2-3 fois
avant d'enregistrer. Vise un débit naturel, ni trop rapide ni trop lent.
</div>
<Button
variant="primary"
size="lg"
icon={<Mic className="size-4" aria-hidden="true" />}
onClick={handleStartRecording}
className="mt-6 w-full"
>
Je suis prêt Enregistrer
</Button>
</main>
<div className="mt-3 flex flex-col gap-2 rounded-md border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand-text sm:flex-row sm:items-center sm:justify-between">
<span>Présentation sauvegardée retrouvée automatiquement à ta prochaine visite.</span>
<button
type="button"
onClick={handleRefaire}
className="inline-flex items-center gap-1.5 text-sm font-medium underline underline-offset-4 hover:text-brand-text"
>
<RotateCcw className="size-3.5" aria-hidden="true" />
Refaire
</button>
</div>
<Button
variant="primary"
size="lg"
icon={<Mic className="size-4" aria-hidden="true" />}
onClick={handleStartRecording}
className="mt-6 w-full"
>
Je suis prêt Enregistrer
</Button>
</main>
</div>
)
}

View file

@ -158,86 +158,88 @@ export function QuestionnaireT1Page() {
if (shouldRedirect) return null
return (
<main className="mx-auto max-w-2xl px-4 py-6">
<div className="mb-2 flex items-center gap-3">
<button
type="button"
onClick={() => navigate('/simulation/eo/t1/mode')}
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
disabled={mutation.isPending}
>
Retour
</button>
</div>
<h2 className="text-lg font-semibold text-ink-primary">Tâche 1 Questionnaire</h2>
<p className="mt-1 mb-6 text-sm text-ink-secondary">
Réponds brièvement à ces 5 questions. Ta présentation personnalisée sera générée
automatiquement.
</p>
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
{FIELDS.map((field) => {
const value = reponses[field.key]
const showError = touched[field.key] && fieldErrors[field.key]
const id = `q-${field.key}`
return (
<div key={field.key} className="space-y-1.5">
<label htmlFor={id} className="block text-sm font-medium text-ink-primary">
{field.label}
</label>
{field.multiline ? (
<textarea
id={id}
value={value}
onChange={(e) => handleChange(field.key, e.target.value)}
onBlur={() => handleBlur(field.key)}
placeholder={field.placeholder}
maxLength={FIELD_MAX}
rows={2}
className={inputBase}
/>
) : (
<input
id={id}
type="text"
value={value}
onChange={(e) => handleChange(field.key, e.target.value)}
onBlur={() => handleBlur(field.key)}
placeholder={field.placeholder}
maxLength={FIELD_MAX}
className={inputBase}
/>
)}
{showError && (
<p className="text-xs text-danger" role="alert">
{fieldErrors[field.key]}
</p>
)}
</div>
)
})}
{apiErrorMessage && (
<div
role="alert"
className="rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-2xl px-4 py-6">
<div className="mb-2 flex items-center gap-3">
<button
type="button"
onClick={() => navigate('/simulation/eo/t1/mode')}
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
disabled={mutation.isPending}
>
{apiErrorMessage}
</div>
)}
Retour
</button>
</div>
<Button
type="submit"
variant="primary"
size="lg"
icon={<Sparkles className="size-4" aria-hidden="true" />}
loading={mutation.isPending}
disabled={!formValid || mutation.isPending}
>
Générer ma présentation
</Button>
</form>
</main>
<h2 className="text-lg font-semibold text-ink-primary">Tâche 1 Questionnaire</h2>
<p className="mt-1 mb-6 text-sm text-ink-secondary">
Réponds brièvement à ces 5 questions. Ta présentation personnalisée sera générée
automatiquement.
</p>
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
{FIELDS.map((field) => {
const value = reponses[field.key]
const showError = touched[field.key] && fieldErrors[field.key]
const id = `q-${field.key}`
return (
<div key={field.key} className="space-y-1.5">
<label htmlFor={id} className="block text-sm font-medium text-ink-primary">
{field.label}
</label>
{field.multiline ? (
<textarea
id={id}
value={value}
onChange={(e) => handleChange(field.key, e.target.value)}
onBlur={() => handleBlur(field.key)}
placeholder={field.placeholder}
maxLength={FIELD_MAX}
rows={2}
className={inputBase}
/>
) : (
<input
id={id}
type="text"
value={value}
onChange={(e) => handleChange(field.key, e.target.value)}
onBlur={() => handleBlur(field.key)}
placeholder={field.placeholder}
maxLength={FIELD_MAX}
className={inputBase}
/>
)}
{showError && (
<p className="text-xs text-danger" role="alert">
{fieldErrors[field.key]}
</p>
)}
</div>
)
})}
{apiErrorMessage && (
<div
role="alert"
className="rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
{apiErrorMessage}
</div>
)}
<Button
type="submit"
variant="primary"
size="lg"
icon={<Sparkles className="size-4" aria-hidden="true" />}
loading={mutation.isPending}
disabled={!formValid || mutation.isPending}
>
Générer ma présentation
</Button>
</form>
</main>
</div>
)
}

View file

@ -213,94 +213,99 @@ export function RapportPage() {
}
return (
<main className="mx-auto max-w-3xl space-y-6 px-4 py-6">
{/* Breadcrumb */}
<nav
aria-label="Fil d'Ariane"
className="flex items-center gap-1.5 text-sm text-ink-secondary"
>
<button
type="button"
onClick={goToSimulations}
className="transition-colors duration-150 hover:text-ink-primary"
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-3xl space-y-6 px-4 py-6">
{/* Breadcrumb */}
<nav
aria-label="Fil d'Ariane"
className="flex items-center gap-1.5 text-sm text-ink-secondary"
>
Simulations
</button>
<span aria-hidden="true"></span>
<span aria-current="page" className="text-ink-primary">
Rapport
</span>
</nav>
{(isLoading || isPlanLoading) && <RapportSkeleton />}
{isInProgress && (
<p className="text-center text-sm text-ink-secondary" aria-live="polite">
Votre simulation est en cours.
</p>
)}
{(isError || isPlanError) && !isInProgress && !isLoading && !isPlanLoading && (
<Card variant="default" className="border-l-4 border-l-danger p-4">
<div role="alert" className="space-y-3">
<p className="text-sm text-danger">
Impossible de charger ce rapport. Réessayez dans quelques instants.
</p>
<Button variant="secondary" size="sm" onClick={() => navigate('/simulation/ee')}>
Retour aux simulations
</Button>
</div>
</Card>
)}
{rapport && planData && (
<>
<ScoreHero score={rapport.score} nclc={rapport.nclc} nclcCible={rapport.nclc_cible} />
<RevelationCards revelation={rapport.revelation} />
<DiagnosticCallout diagnostic={rapport.diagnostic} />
<BlurredSection
visible={isSectionVisible(planData.plan, 'criteres')}
onUpgrade={onUpgrade}
<button
type="button"
onClick={goToSimulations}
className="transition-colors duration-150 hover:text-ink-primary"
>
<CriteresSection rapport={rapport} />
</BlurredSection>
Simulations
</button>
<span aria-hidden="true"></span>
<span aria-current="page" className="text-ink-primary">
Rapport
</span>
</nav>
<ConseilNclcCallout
conseil={rapport.conseil_nclc}
nclc={rapport.nclc}
nclcCible={rapport.nclc_cible}
/>
{(isLoading || isPlanLoading) && <RapportSkeleton />}
<BlurredSection
visible={isSectionVisible(planData.plan, 'exercices')}
onUpgrade={onUpgrade}
>
<ExercicesSection
rapport={rapport}
hasTimedOut={hasTimedOut}
onRetry={() => void refetch()}
{isInProgress && (
<p className="text-center text-sm text-ink-secondary" aria-live="polite">
Votre simulation est en cours.
</p>
)}
{(isError || isPlanError) && !isInProgress && !isLoading && !isPlanLoading && (
<Card variant="default" className="border-l-4 border-l-danger p-4">
<div role="alert" className="space-y-3">
<p className="text-sm text-danger">
Impossible de charger ce rapport. Réessayez dans quelques instants.
</p>
<Button variant="secondary" size="sm" onClick={() => navigate('/simulation/ee')}>
Retour aux simulations
</Button>
</div>
</Card>
)}
{rapport && planData && (
<>
<ScoreHero score={rapport.score} nclc={rapport.nclc} nclcCible={rapport.nclc_cible} />
<RevelationCards revelation={rapport.revelation} />
<DiagnosticCallout diagnostic={rapport.diagnostic} />
<BlurredSection
visible={isSectionVisible(planData.plan, 'criteres')}
onUpgrade={onUpgrade}
>
<CriteresSection rapport={rapport} />
</BlurredSection>
<ConseilNclcCallout
conseil={rapport.conseil_nclc}
nclc={rapport.nclc}
nclcCible={rapport.nclc_cible}
/>
</BlurredSection>
<BlurredSection visible={isSectionVisible(planData.plan, 'modele')} onUpgrade={onUpgrade}>
<ModeleSection
rapport={rapport}
hasTimedOut={hasTimedOut}
onRetry={() => void refetch()}
/>
</BlurredSection>
<BlurredSection
visible={isSectionVisible(planData.plan, 'exercices')}
onUpgrade={onUpgrade}
>
<ExercicesSection
rapport={rapport}
hasTimedOut={hasTimedOut}
onRetry={() => void refetch()}
/>
</BlurredSection>
{/* Action de sortie — reset + nouvelle simulation */}
<div className="flex justify-center pt-4">
<Button variant="primary" onClick={goToSimulations}>
Nouvelle simulation
</Button>
</div>
</>
)}
</main>
<BlurredSection
visible={isSectionVisible(planData.plan, 'modele')}
onUpgrade={onUpgrade}
>
<ModeleSection
rapport={rapport}
hasTimedOut={hasTimedOut}
onRetry={() => void refetch()}
/>
</BlurredSection>
{/* Action de sortie — reset + nouvelle simulation */}
<div className="flex justify-center pt-4">
<Button variant="primary" onClick={goToSimulations}>
Nouvelle simulation
</Button>
</div>
</>
)}
</main>
</div>
)
}

View file

@ -38,40 +38,42 @@ export function SimulationEOPage() {
const { isCreating, taskUnavailableMessage, selectTask } = useSimulationFlow()
return (
<main className="mx-auto max-w-2xl px-4 py-6">
{isPlanLoading && <SimulationEOSkeleton />}
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-2xl px-4 py-6">
{isPlanLoading && <SimulationEOSkeleton />}
{isPlanError && (
<div className="space-y-3 text-center">
<p className="text-sm text-danger">
Impossible de charger vos informations. Réessayez dans quelques instants.
</p>
<Button variant="secondary" size="sm" onClick={() => refetchPlan()}>
Réessayer
</Button>
</div>
)}
{isPlanError && (
<div className="space-y-3 text-center">
<p className="text-sm text-danger">
Impossible de charger vos informations. Réessayez dans quelques instants.
</p>
<Button variant="secondary" size="sm" onClick={() => refetchPlan()}>
Réessayer
</Button>
</div>
)}
{planData && (
<div className="space-y-4">
<TaskSelector
type="EO"
plan={planData.plan}
simulationsUsed={planData.simulations_used}
isLoading={isCreating}
onSelect={selectTask}
/>
{planData && (
<div className="space-y-4">
<TaskSelector
type="EO"
plan={planData.plan}
simulationsUsed={planData.simulations_used}
isLoading={isCreating}
onSelect={selectTask}
/>
{taskUnavailableMessage && (
<div
role="status"
className="rounded-lg border border-border bg-surface px-4 py-3 text-sm text-ink-secondary"
>
{taskUnavailableMessage}
</div>
)}
</div>
)}
</main>
{taskUnavailableMessage && (
<div
role="status"
className="rounded-lg border border-border bg-surface px-4 py-3 text-sm text-ink-secondary"
>
{taskUnavailableMessage}
</div>
)}
</div>
)}
</main>
</div>
)
}

View file

@ -56,45 +56,47 @@ export function SimulationPage() {
// createMutation.onSuccess (idle → choosing-subject → navigate /sujets).
return (
<main className="mx-auto max-w-2xl px-4 py-6">
{isPlanLoading && <SimulationSkeleton />}
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-2xl px-4 py-6">
{isPlanLoading && <SimulationSkeleton />}
{isPlanError && (
<div className="space-y-3 text-center">
<p className="text-sm text-danger">
Impossible de charger vos informations. Réessayez dans quelques instants.
</p>
<Button variant="secondary" size="sm" onClick={() => refetchPlan()}>
Réessayer
</Button>
</div>
)}
{isPlanError && (
<div className="space-y-3 text-center">
<p className="text-sm text-danger">
Impossible de charger vos informations. Réessayez dans quelques instants.
</p>
<Button variant="secondary" size="sm" onClick={() => refetchPlan()}>
Réessayer
</Button>
</div>
)}
{planData && step === 'idle' && (
<TaskSelector
type="EE"
plan={planData.plan}
simulationsUsed={planData.simulations_used}
isLoading={isCreating}
onSelect={selectTask}
/>
)}
{planData && step === 'idle' && (
<TaskSelector
type="EE"
plan={planData.plan}
simulationsUsed={planData.simulations_used}
isLoading={isCreating}
onSelect={selectTask}
/>
)}
{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>
{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>
</div>
)
}

View file

@ -64,63 +64,69 @@ export function SujetsEOPage() {
const hasSujets = (sujets?.length ?? 0) > 0
return (
<main className="mx-auto max-w-4xl px-4 py-6">
<div className="mb-4 flex items-center gap-3">
<button
type="button"
onClick={() => {
reset()
navigate('/simulation/eo')
}}
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
>
Retour
</button>
<h2 className="flex-1 text-lg font-semibold text-ink-primary">
Choisir un sujet {formatTache(production.tache)}
</h2>
</div>
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-ink-secondary">
{isLoading
? 'Chargement des sujets…'
: hasSujets
? `${sujets!.length} sujet${sujets!.length > 1 ? 's' : ''} disponible${sujets!.length > 1 ? 's' : ''}.`
: 'Aucun sujet disponible pour cette tâche.'}
</p>
<Button
variant="secondary"
size="sm"
icon={<Shuffle className="size-4" aria-hidden="true" />}
onClick={handleRandom}
disabled={!hasSujets}
>
Sujet aléatoire
</Button>
</div>
{isError && (
<div
role="alert"
className="mb-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
Impossible de charger les sujets.{' '}
<button type="button" onClick={() => refetch()} className="underline underline-offset-2">
Réessayer
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-4xl px-4 py-6">
<div className="mb-4 flex items-center gap-3">
<button
type="button"
onClick={() => {
reset()
navigate('/simulation/eo')
}}
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
>
Retour
</button>
<h2 className="flex-1 text-lg font-semibold text-ink-primary">
Choisir un sujet {formatTache(production.tache)}
</h2>
</div>
)}
{isLoading ? (
<SujetsSkeleton />
) : hasSujets ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{sujets!.map((sujet) => (
<SujetCard key={sujet.id} sujet={sujet} onSelect={handleSelect} />
))}
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-ink-secondary">
{isLoading
? 'Chargement des sujets…'
: hasSujets
? `${sujets!.length} sujet${sujets!.length > 1 ? 's' : ''} disponible${sujets!.length > 1 ? 's' : ''}.`
: 'Aucun sujet disponible pour cette tâche.'}
</p>
<Button
variant="secondary"
size="sm"
icon={<Shuffle className="size-4" aria-hidden="true" />}
onClick={handleRandom}
disabled={!hasSujets}
>
Sujet aléatoire
</Button>
</div>
) : null}
</main>
{isError && (
<div
role="alert"
className="mb-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
>
Impossible de charger les sujets.{' '}
<button
type="button"
onClick={() => refetch()}
className="underline underline-offset-2"
>
Réessayer
</button>
</div>
)}
{isLoading ? (
<SujetsSkeleton />
) : hasSujets ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{sujets!.map((sujet) => (
<SujetCard key={sujet.id} sujet={sujet} onSelect={handleSelect} />
))}
</div>
) : null}
</main>
</div>
)
}