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:
parent
b31e8666a5
commit
997f39bd33
7 changed files with 621 additions and 0 deletions
139
src/features/simulations/components/SimulationForm.tsx
Normal file
139
src/features/simulations/components/SimulationForm.tsx
Normal 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')} / 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>
|
||||
)
|
||||
}
|
||||
113
src/features/simulations/components/TaskSelector.tsx
Normal file
113
src/features/simulations/components/TaskSelector.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Sélecteur de tâche pour lancer une simulation.
|
||||
*
|
||||
* Affiche les 6 tâches TCF :
|
||||
* - EE T1/T2/T3 → sélectionnables si quota OK
|
||||
* - EO T1/T3 → verrouillées (audio — Sprint 4)
|
||||
* - EO T2 Live → verrouillée (Exclusivité Premium — Sprint 6)
|
||||
*
|
||||
* Règle D : le quota est vérifié via canSimulate(), jamais if (plan === 'free').
|
||||
* Règle H : aucune logique métier — uniquement appel de canSimulate() et affichage.
|
||||
*/
|
||||
|
||||
import { Lock, Loader2 } from 'lucide-react'
|
||||
import { canSimulate } from '@/entities/user/lib'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
import type { Plan } from '@/entities/user/lib'
|
||||
import type { CreateSimulationPayload, Tache } from '@/entities/production/types'
|
||||
|
||||
interface Props {
|
||||
plan: Plan
|
||||
simulationsUsed: number
|
||||
isLoading: boolean
|
||||
onSelect: (payload: CreateSimulationPayload) => void
|
||||
}
|
||||
|
||||
interface TaskCard {
|
||||
tache: Tache | null // null = EO T2 Live (hors Tache type)
|
||||
label: string
|
||||
sublabel: string
|
||||
sprintLocked?: boolean // audio ou premium non encore implémenté
|
||||
lockLabel?: string
|
||||
}
|
||||
|
||||
const TASK_CARDS: readonly TaskCard[] = [
|
||||
{ tache: 'EE_T1', label: 'Expression Écrite', sublabel: 'Tâche 1' },
|
||||
{ tache: 'EE_T2', label: 'Expression Écrite', sublabel: 'Tâche 2' },
|
||||
{ tache: 'EE_T3', label: 'Expression Écrite', sublabel: 'Tâche 3' },
|
||||
{ tache: 'EO_T1', label: 'Expression Orale', sublabel: 'Tâche 1', sprintLocked: true, lockLabel: 'Bientôt disponible' },
|
||||
{ tache: 'EO_T3', label: 'Expression Orale', sublabel: 'Tâche 3', sprintLocked: true, lockLabel: 'Bientôt disponible' },
|
||||
{ tache: null, label: 'Expression Orale', sublabel: 'Tâche 2 — Live', sprintLocked: true, lockLabel: 'Exclusivité Premium' },
|
||||
]
|
||||
|
||||
|
||||
export function TaskSelector({ plan, simulationsUsed, isLoading, onSelect }: Props) {
|
||||
const simulationCheck = canSimulate(plan, simulationsUsed)
|
||||
const quotaBlocked = !simulationCheck.allowed
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-ink-1">Choisir une tâche</h2>
|
||||
<p className="mt-1 text-sm text-ink-3">
|
||||
Sélectionnez la tâche que vous souhaitez simuler.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{quotaBlocked && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-danger/30 bg-danger-bg px-4 py-3 text-sm text-danger"
|
||||
>
|
||||
Vous avez utilisé vos 5 simulations gratuites.{' '}
|
||||
<a href="/pricing" className="underline underline-offset-4">
|
||||
Passer en Standard
|
||||
</a>{' '}
|
||||
pour continuer votre préparation.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{TASK_CARDS.map((card) => {
|
||||
const locked = card.sprintLocked || quotaBlocked
|
||||
const disabled = locked || isLoading || card.tache === null
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${card.tache ?? 'eo-t2'}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (card.tache && !locked) {
|
||||
onSelect({ tache: card.tache, mode: 'entrainement' })
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'group relative flex flex-col rounded-lg border p-4 text-left transition-colors',
|
||||
locked || card.tache === null
|
||||
? 'cursor-not-allowed border-line bg-canvas-2 opacity-60'
|
||||
: 'cursor-pointer border-line bg-surface hover:border-expria hover:bg-expria-50',
|
||||
isLoading && !locked && 'cursor-wait',
|
||||
)}
|
||||
>
|
||||
{(card.sprintLocked || card.tache === null) && (
|
||||
<Lock
|
||||
className="mb-2 size-4 text-ink-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs font-medium text-ink-4">{card.label}</span>
|
||||
<span className="mt-0.5 text-sm font-semibold text-ink-1">{card.sublabel}</span>
|
||||
{card.lockLabel && (
|
||||
<span className="mt-1.5 text-xs text-ink-4">{card.lockLabel}</span>
|
||||
)}
|
||||
{isLoading && !locked && card.tache && (
|
||||
<Loader2 className="absolute right-3 top-3 size-3.5 animate-spin text-expria" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue