347 lines
11 KiB
TypeScript
347 lines
11 KiB
TypeScript
/**
|
|
* 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<HTMLTextAreaElement>(null)
|
|
const hasAutoSubmittedRef = useRef(false)
|
|
const [texte, setTexte] = useState(() => initialContenu ?? '')
|
|
const [fieldError, setFieldError] = useState<string | null>(null)
|
|
const [isIdeesOpen, setIsIdeesOpen] = useState(false)
|
|
const [nclcCible, setNclcCible] = useState<NclcCible>(9)
|
|
|
|
const config = getSimulationConfig(tache)
|
|
const wordCount = countWords(texte)
|
|
const canSubmit = wordCount >= config.motsMin
|
|
|
|
const timer = useTimer(config.dureeMinutes, !isSubmitting)
|
|
const idees = useIdees()
|
|
const autosave = useAutosave(simulationId, texte, !isSubmitting)
|
|
|
|
// 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<HTMLFormElement>) {
|
|
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 (
|
|
<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="flex-1 text-lg font-semibold text-ink-1">{formatTache(tache)}</h2>
|
|
</div>
|
|
|
|
<SujetDisplay sujet={sujet} />
|
|
|
|
{sujet && (
|
|
<div className="flex flex-wrap justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={handleIdeesClick}
|
|
disabled={ideesDisabled}
|
|
title={ideesTitle}
|
|
className={secondaryActionBtn}
|
|
aria-label="Obtenir des suggestions d'idées"
|
|
>
|
|
<Lightbulb className="size-4" aria-hidden="true" />
|
|
Suggestions d'idées
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onChangeSujet}
|
|
disabled={isSubmitting}
|
|
className={secondaryActionBtn}
|
|
aria-label="Changer de sujet"
|
|
>
|
|
<Shuffle className="size-4" aria-hidden="true" />
|
|
Changer de sujet
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<IdeesSuggestions
|
|
idees={idees.idees}
|
|
isLoading={idees.isLoading}
|
|
error={idees.error}
|
|
isOpen={isIdeesOpen}
|
|
onClose={handleIdeesClose}
|
|
/>
|
|
|
|
{apiError && (
|
|
<div
|
|
role="alert"
|
|
className="rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-sm text-danger"
|
|
>
|
|
{apiError}
|
|
</div>
|
|
)}
|
|
|
|
{expiredBelowMin && (
|
|
<div
|
|
role="alert"
|
|
className="rounded-md border border-warning/40 bg-warning-bg px-3 py-2 text-sm text-warning"
|
|
>
|
|
Temps écoulé. Écrivez au moins {config.motsMin} mots pour soumettre.
|
|
</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>
|
|
<div className="sticky top-14 z-20 bg-canvas pb-1 lg:top-0">
|
|
<div
|
|
className={`mb-2 flex items-center gap-2 rounded-md border px-3 py-2 ${
|
|
timer.isExpired || timer.secondesRestantes < 120
|
|
? 'border-danger bg-danger-bg'
|
|
: 'border-line bg-surface'
|
|
}`}
|
|
>
|
|
<Clock
|
|
className={`size-4 ${
|
|
timer.isExpired || timer.secondesRestantes < 120 ? 'text-danger' : 'text-ink-3'
|
|
}`}
|
|
aria-hidden="true"
|
|
/>
|
|
<span
|
|
className={`text-xs font-medium uppercase tracking-wide ${
|
|
timer.isExpired || timer.secondesRestantes < 120 ? 'text-danger' : 'text-ink-3'
|
|
}`}
|
|
>
|
|
Temps restant
|
|
</span>
|
|
<span className="ml-auto">
|
|
<TimerDisplay
|
|
secondesRestantes={timer.secondesRestantes}
|
|
isExpired={timer.isExpired}
|
|
/>
|
|
</span>
|
|
</div>
|
|
<SpecialCharsKeyboard onInsert={handleInsert} disabled={isSubmitting} />
|
|
</div>
|
|
<textarea
|
|
ref={textareaRef}
|
|
id="texte"
|
|
rows={8}
|
|
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 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} />
|
|
|
|
{autosave.savedAt && !fieldError && (
|
|
<p className="text-xs text-ink-4" aria-live="polite">
|
|
Sauvegardé à{' '}
|
|
{autosave.savedAt.toLocaleTimeString('fr-FR', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})}
|
|
</p>
|
|
)}
|
|
{fieldError && (
|
|
<p id="texte-error" className="text-sm text-danger">
|
|
{fieldError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<Button type="submit" className="w-full" disabled={submitDisabled}>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader2 className="animate-spin" aria-hidden="true" />
|
|
Correction en cours…
|
|
</>
|
|
) : (
|
|
'Soumettre ma production'
|
|
)}
|
|
</Button>
|
|
|
|
{isSubmitting && (
|
|
<p className="text-center text-xs text-ink-4">
|
|
La correction peut prendre jusqu'à 30 secondes.
|
|
</p>
|
|
)}
|
|
</form>
|
|
</div>
|
|
)
|
|
}
|