feat(simulations): bouton Suggestions d'idées + modal DeepSeek (G5)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
67eb3411c5
commit
dee3c181f6
3 changed files with 146 additions and 2 deletions
91
src/features/simulations/components/IdeesSuggestions.tsx
Normal file
91
src/features/simulations/components/IdeesSuggestions.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
/**
|
||||||
|
* Modal — suggestions d'idées DeepSeek (tâche G5).
|
||||||
|
*
|
||||||
|
* Présentationnel pur. La fermeture déclenche `onClose` qui doit appeler
|
||||||
|
* `reset()` du hook useIdees côté parent pour vider le cache de mutation.
|
||||||
|
*
|
||||||
|
* Règle H : aucune logique métier. Règle L : tokens Direction H uniquement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Loader2, Lightbulb } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/shared/components/ui/dialog'
|
||||||
|
import type { ApiError } from '@/shared/types/api'
|
||||||
|
|
||||||
|
function mapIdeesError(err: ApiError | null): string | null {
|
||||||
|
if (!err) return null
|
||||||
|
switch (err.code) {
|
||||||
|
case 'AUTH_REQUIRED':
|
||||||
|
return 'Votre session a expiré. Reconnectez-vous.'
|
||||||
|
case 'VALIDATION_ERROR':
|
||||||
|
case 'INVALID_BODY':
|
||||||
|
return 'Écrivez au moins 30 mots avant de demander des suggestions.'
|
||||||
|
default:
|
||||||
|
return 'Suggestions indisponibles. Réessayez dans quelques instants.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
idees: string[] | null
|
||||||
|
isLoading: boolean
|
||||||
|
error: ApiError | null
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IdeesSuggestions({ idees, isLoading, error, isOpen, onClose }: Props) {
|
||||||
|
const message = mapIdeesError(error)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-ink-1">
|
||||||
|
<Lightbulb className="size-5 text-expria" aria-hidden="true" />
|
||||||
|
Suggestions d'idées
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Pour prolonger votre rédaction, inspirez-vous de ces pistes.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-ink-3" aria-busy="true">
|
||||||
|
<Loader2 className="size-4 animate-spin text-expria" aria-hidden="true" />
|
||||||
|
Génération des idées…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && message && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-sm text-danger"
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !message && idees && idees.length > 0 && (
|
||||||
|
<ul className="space-y-2 text-sm text-ink-2">
|
||||||
|
{idees.map((idee, i) => (
|
||||||
|
<li key={i} className="flex gap-2">
|
||||||
|
<span className="mt-[0.4em] size-1.5 shrink-0 rounded-full bg-expria" aria-hidden="true" />
|
||||||
|
<span>{idee}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -11,18 +11,23 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||||
import { Clock, Loader2, Shuffle } from 'lucide-react'
|
import { Clock, Lightbulb, Loader2, Shuffle } from 'lucide-react'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { Button } from '@/shared/components/ui/button'
|
import { Button } from '@/shared/components/ui/button'
|
||||||
import { formatTache } from '@/entities/production/lib'
|
import { formatTache } from '@/entities/production/lib'
|
||||||
|
import { hasAccess, type Plan } from '@/entities/user/lib'
|
||||||
import type { SujetData, Tache } from '@/entities/production/types'
|
import type { SujetData, Tache } from '@/entities/production/types'
|
||||||
import type { ApiError } from '@/shared/types/api'
|
import type { ApiError } from '@/shared/types/api'
|
||||||
import { countWords, getSimulationConfig } from '../lib/simulationConfig'
|
import { countWords, getSimulationConfig } from '../lib/simulationConfig'
|
||||||
import { useTimer } from '../hooks/useTimer'
|
import { useTimer } from '../hooks/useTimer'
|
||||||
|
import { useIdees } from '../hooks/useIdees'
|
||||||
import { SujetDisplay } from './SujetDisplay'
|
import { SujetDisplay } from './SujetDisplay'
|
||||||
import { SpecialCharsKeyboard } from './SpecialCharsKeyboard'
|
import { SpecialCharsKeyboard } from './SpecialCharsKeyboard'
|
||||||
import { TimerDisplay } from './TimerDisplay'
|
import { TimerDisplay } from './TimerDisplay'
|
||||||
import { WordCountBar } from './WordCountBar'
|
import { WordCountBar } from './WordCountBar'
|
||||||
|
import { IdeesSuggestions } from './IdeesSuggestions'
|
||||||
|
|
||||||
|
const MIN_WORDS_IDEES = 30
|
||||||
|
|
||||||
const textSchema = z.object({
|
const textSchema = z.object({
|
||||||
texte: z
|
texte: z
|
||||||
|
|
@ -49,6 +54,7 @@ function mapCorrectError(err: ApiError | null): string | null {
|
||||||
interface Props {
|
interface Props {
|
||||||
tache: Tache
|
tache: Tache
|
||||||
sujet: SujetData | null
|
sujet: SujetData | null
|
||||||
|
plan: Plan
|
||||||
isSubmitting: boolean
|
isSubmitting: boolean
|
||||||
error: ApiError | null
|
error: ApiError | null
|
||||||
onSubmit: (texte: string) => void
|
onSubmit: (texte: string) => void
|
||||||
|
|
@ -59,6 +65,7 @@ interface Props {
|
||||||
export function SimulationForm({
|
export function SimulationForm({
|
||||||
tache,
|
tache,
|
||||||
sujet,
|
sujet,
|
||||||
|
plan,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
error,
|
error,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
|
@ -69,12 +76,38 @@ export function SimulationForm({
|
||||||
const hasAutoSubmittedRef = useRef(false)
|
const hasAutoSubmittedRef = useRef(false)
|
||||||
const [texte, setTexte] = useState('')
|
const [texte, setTexte] = useState('')
|
||||||
const [fieldError, setFieldError] = useState<string | null>(null)
|
const [fieldError, setFieldError] = useState<string | null>(null)
|
||||||
|
const [isIdeesOpen, setIsIdeesOpen] = useState(false)
|
||||||
|
|
||||||
const config = getSimulationConfig(tache)
|
const config = getSimulationConfig(tache)
|
||||||
const wordCount = countWords(texte)
|
const wordCount = countWords(texte)
|
||||||
const canSubmit = wordCount >= config.motsMin
|
const canSubmit = wordCount >= config.motsMin
|
||||||
|
|
||||||
const timer = useTimer(config.dureeMinutes, !isSubmitting)
|
const timer = useTimer(config.dureeMinutes, !isSubmitting)
|
||||||
|
const idees = useIdees()
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const el = textareaRef.current
|
const el = textareaRef.current
|
||||||
|
|
@ -149,7 +182,18 @@ export function SimulationForm({
|
||||||
<SujetDisplay sujet={sujet} />
|
<SujetDisplay sujet={sujet} />
|
||||||
|
|
||||||
{sujet && (
|
{sujet && (
|
||||||
<div className="flex justify-end">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleIdeesClick}
|
||||||
|
disabled={ideesDisabled}
|
||||||
|
title={ideesTitle}
|
||||||
|
className="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:ring-2 focus:ring-expria/20 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
aria-label="Obtenir des suggestions d'idées"
|
||||||
|
>
|
||||||
|
<Lightbulb className="size-4" aria-hidden="true" />
|
||||||
|
Suggestions d'idées
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onChangeSujet}
|
onClick={onChangeSujet}
|
||||||
|
|
@ -163,6 +207,14 @@ export function SimulationForm({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<IdeesSuggestions
|
||||||
|
idees={idees.idees}
|
||||||
|
isLoading={idees.isLoading}
|
||||||
|
error={idees.error}
|
||||||
|
isOpen={isIdeesOpen}
|
||||||
|
onClose={handleIdeesClose}
|
||||||
|
/>
|
||||||
|
|
||||||
{apiError && (
|
{apiError && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ export function SimulationPage() {
|
||||||
<SimulationForm
|
<SimulationForm
|
||||||
tache={production.tache}
|
tache={production.tache}
|
||||||
sujet={sujet}
|
sujet={sujet}
|
||||||
|
plan={planData.plan}
|
||||||
isSubmitting={isCorrecting}
|
isSubmitting={isCorrecting}
|
||||||
error={correctError}
|
error={correctError}
|
||||||
onSubmit={submitText}
|
onSubmit={submitText}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue