/** * 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 { useEffect, useRef, 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 { SujetData, Tache } from '@/entities/production/types' import type { ApiError } from '@/shared/types/api' import { SujetDisplay } from './SujetDisplay' import { SpecialCharsKeyboard } from './SpecialCharsKeyboard' 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 sujet: SujetData | null isSubmitting: boolean error: ApiError | null onSubmit: (texte: string) => void onBack: () => void } export function SimulationForm({ tache, sujet, isSubmitting, error, onSubmit, onBack }: Props) { const textareaRef = useRef(null) const [texte, setTexte] = useState('') const [fieldError, setFieldError] = useState(null) useEffect(() => { const el = textareaRef.current if (!el) return el.style.height = 'auto' el.style.height = `${el.scrollHeight}px` }, [texte]) function handleInsert(char: string) { const el = textareaRef.current if (!el) { setTexte((prev) => prev + char) return } const start = el.selectionStart ?? texte.length const end = el.selectionEnd ?? texte.length const next = texte.slice(0, start) + char + texte.slice(end) setTexte(next) const caret = start + char.length requestAnimationFrame(() => { el.focus() el.setSelectionRange(caret, caret) }) } function handleSubmit(e: FormEvent) { 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 (

{formatTache(tache)}

{apiError && (
{apiError}
)}