feat(simulations): useSimulation hook + TaskSelector + SimulationForm + SimulationPage + route (Sprint 3 étape 14)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-20 00:08:34 +03:00
parent b31e8666a5
commit 997f39bd33
7 changed files with 621 additions and 0 deletions

View file

@ -0,0 +1,139 @@
/**
* Formulaire de saisie pour une simulation Expression Écrite.
*
* SEC-04 : validation Zod avant envoi (texte non vide, max 5 000 caractères).
* SEC-05 : aucun dangerouslySetInnerHTML le texte utilisateur est rendu comme texte.
* Règle H : aucune logique métier le composant reçoit tache, handlers et états par props.
*/
import { useState, type FormEvent } from 'react'
import { Loader2 } from 'lucide-react'
import { z } from 'zod'
import { Button } from '@/shared/components/ui/button'
import { formatTache } from '@/entities/production/lib'
import type { Tache } from '@/entities/production/types'
import type { ApiError } from '@/shared/types/api'
const textSchema = z.object({
texte: z
.string()
.min(1, 'Le texte ne peut pas être vide.')
.max(5000, 'Le texte ne doit pas dépasser 5 000 caractères.'),
})
function mapCorrectError(err: ApiError | null): string | null {
if (!err) return null
switch (err.code) {
case 'SIMULATION_NOT_FOUND':
return 'Simulation introuvable. Revenez en arrière et recommencez.'
case 'AUTH_REQUIRED':
return 'Votre session a expiré. Reconnectez-vous.'
case 'VALIDATION_ERROR':
case 'INVALID_BODY':
return 'Le texte soumis est invalide. Vérifiez votre saisie.'
default:
return 'Correction impossible. Réessayez dans quelques instants.'
}
}
interface Props {
tache: Tache
isSubmitting: boolean
error: ApiError | null
onSubmit: (texte: string) => void
onBack: () => void
}
export function SimulationForm({ tache, isSubmitting, error, onSubmit, onBack }: Props) {
const [texte, setTexte] = useState('')
const [fieldError, setFieldError] = useState<string | null>(null)
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
setFieldError(null)
const parsed = textSchema.safeParse({ texte })
if (!parsed.success) {
setFieldError(parsed.error.flatten().fieldErrors.texte?.[0] ?? null)
return
}
onSubmit(parsed.data.texte)
}
const apiError = mapCorrectError(error)
const charCount = texte.length
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<button
type="button"
onClick={onBack}
disabled={isSubmitting}
className="text-sm text-ink-4 underline-offset-4 hover:text-ink-2 hover:underline disabled:pointer-events-none"
>
Retour
</button>
<h2 className="text-lg font-semibold text-ink-1">{formatTache(tache)}</h2>
</div>
{apiError && (
<div
role="alert"
className="rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-sm text-danger"
>
{apiError}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
<div className="space-y-1.5">
<label htmlFor="texte" className="text-sm font-medium text-ink-2">
Votre production
</label>
<textarea
id="texte"
rows={12}
value={texte}
onChange={(e) => setTexte(e.target.value)}
disabled={isSubmitting}
placeholder="Rédigez votre texte ici…"
aria-invalid={!!fieldError}
aria-describedby={fieldError ? 'texte-error' : undefined}
className="w-full resize-none 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:ring-2 focus:ring-expria/20 disabled:cursor-not-allowed disabled:opacity-50"
/>
<div className="flex items-start justify-between gap-2">
{fieldError ? (
<p id="texte-error" className="text-sm text-danger">
{fieldError}
</p>
) : (
<span />
)}
<span className={`text-xs tabular-nums ${charCount > 5000 ? 'text-danger' : 'text-ink-5'}`}>
{charCount.toLocaleString('fr-FR')}&thinsp;/&thinsp;5 000
</span>
</div>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Correction en cours
</>
) : (
'Envoyer pour correction'
)}
</Button>
{isSubmitting && (
<p className="text-center text-xs text-ink-4">
La correction peut prendre jusqu'à 30 secondes.
</p>
)}
</form>
</div>
)
}