/** * 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 — délègue à simulationConfig + useTimer. * * Minuteur et cibles de mots : cf. getSimulationConfig(tache). * À l'expiration du timer, soumission automatique si mots ≥ motsMin, * sinon message explicite demandant d'atteindre le seuil. */ import { useEffect, useRef, useState, type FormEvent } from 'react' import { Clock, Lightbulb, Loader2, Shuffle } from 'lucide-react' import { z } from 'zod' import { Button } from '@/shared/ui/Button' import { formatTache } from '@/entities/production/lib' import { hasAccess, type Plan } from '@/entities/user/lib' import type { SujetData, Tache } from '@/entities/production/types' import type { NclcCible } from '@/entities/report/types' import type { ApiError } from '@/shared/types/api' import { countWords, getSimulationConfig } from '../lib/simulationConfig' import { useTimer } from '../hooks/useTimer' import { useIdees } from '../hooks/useIdees' import { useAutosave } from '../hooks/useAutosave' import type { SimulationStep } from '../state/simulationFlow' import { SujetDisplay } from './SujetDisplay' import { SpecialCharsKeyboard } from './SpecialCharsKeyboard' import { TimerDisplay } from './TimerDisplay' import { WordCountBar } from './WordCountBar' import { IdeesSuggestions } from './IdeesSuggestions' import { NclcCibleSelector } from './NclcCibleSelector' const MIN_WORDS_IDEES = 30 const LS_SIMULATION_ID_KEY = 'expria_simulation_id' const secondaryActionBtn = 'inline-flex items-center gap-1.5 rounded-md border border-line bg-surface px-3 py-1.5 text-sm text-ink-2 transition-colors hover:border-expria hover:text-expria focus:border-expria focus:outline-none focus:shadow-focus disabled:cursor-not-allowed disabled:opacity-50' 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 plan: Plan simulationId: string initialContenu?: string step: SimulationStep isSubmitting: boolean error: ApiError | null onSubmit: (texte: string, nclcCible: NclcCible) => void onBack: () => void onChangeSujet: () => void } export function SimulationForm({ tache, sujet, plan, simulationId, initialContenu, step, isSubmitting, error, onSubmit, onBack, onChangeSujet, }: Props) { const textareaRef = useRef(null) const hasAutoSubmittedRef = useRef(false) const [texte, setTexte] = useState(() => initialContenu ?? '') const [fieldError, setFieldError] = useState(null) const [isIdeesOpen, setIsIdeesOpen] = useState(false) const [nclcCible, setNclcCible] = useState(9) const config = getSimulationConfig(tache) const wordCount = countWords(texte) const canSubmit = wordCount >= config.motsMin const timer = useTimer(config.dureeMinutes, !isSubmitting) const idees = useIdees() const autosaveEnabled = !isSubmitting && step !== 'done' && step !== 'correcting' const autosave = useAutosave(simulationId, texte, autosaveEnabled) // FTD-21 — marquer la simulation en cours pour le resume au refresh. useEffect(() => { if (simulationId) { localStorage.setItem(LS_SIMULATION_ID_KEY, simulationId) } }, [simulationId]) // FTD-21 — nettoyer dès que la correction est terminée. useEffect(() => { if (step === 'done') { localStorage.removeItem(LS_SIMULATION_ID_KEY) } }, [step]) const tipsAllowed = hasAccess(plan, 'tips') const ideesDisabled = isSubmitting || idees.isLoading || !sujet || !tipsAllowed || wordCount < MIN_WORDS_IDEES const ideesTitle = !tipsAllowed ? 'Disponible en Standard' : wordCount < MIN_WORDS_IDEES ? `Écrivez au moins ${MIN_WORDS_IDEES} mots` : undefined function handleIdeesClick() { if (!sujet) return setIsIdeesOpen(true) idees.fetchIdees({ consigne: sujet.consigne, contenu: texte }) } function handleIdeesClose() { setIsIdeesOpen(false) idees.reset() } useEffect(() => { const el = textareaRef.current if (!el) return el.style.height = 'auto' el.style.height = `${el.scrollHeight}px` }, [texte]) useEffect(() => { if (!timer.isExpired) return if (hasAutoSubmittedRef.current) return if (isSubmitting) return if (wordCount < config.motsMin) return hasAutoSubmittedRef.current = true onSubmit(texte, nclcCible) }, [timer.isExpired, wordCount, config.motsMin, isSubmitting, texte, nclcCible, onSubmit]) 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 } if (wordCount < config.motsMin) { setFieldError(`Écrivez au moins ${config.motsMin} mots pour soumettre.`) return } onSubmit(parsed.data.texte, nclcCible) } const apiError = mapCorrectError(error) const expiredBelowMin = timer.isExpired && wordCount < config.motsMin const submitDisabled = isSubmitting || !canSubmit return (

{formatTache(tache)}

{sujet && (
)} {apiError && (
{apiError}
)} {expiredBelowMin && (
Temps écoulé. Écrivez au moins {config.motsMin} mots pour soumettre.
)}