expria-frontend/src/features/t1-live/pages/T1DialoguePage.tsx
Hermann_Kitio 3016d909a6
Some checks are pending
CI / quality (push) Waiting to run
feat(t1-live): T1 Live frontend — Sprint 7b
- Add T1 state machine (8 states, presenting ⇄ interrupted)
- Add useT1LiveSession (WS /t1/live, uplink gate by ref, no context msg)
- Add T1PreparationPage, T1DialoguePage, T1SpeakingIndicator
- Add EO_T1_LIVE card in TaskSelector gated via oral_t2_live
- Extract shared t1Questionnaire.ts for batch/live DRY
- Remove T1LiveQuestionnairePage + T1LiveContext (post patch 7a)
- Simplified flow: card → preparation → dialogue
- FTD-44 frozen (cross-feature audio hooks, Sprint 7.5)
- FTD-45/46 frozen (Gemini relance quality + transcription)
- Tests: 301/301 green
2026-06-30 22:53:57 +03:00

237 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Page /simulation/eo/t1/live/dialogue — phase de dialogue live T1 (Sprint 7b).
*
* Démarre la session WS au mount, pilote l'UI selon l'état machine T1, affiche le
* timer 3:00 et l'indicateur d'état. Spécificité T1 : l'examinateur peut
* INTERROMPRE le monologue de façon NON DÉTERMINISTE (état `interrupted`) — l'UI
* ne suppose JAMAIS qu'une relance suit. À la fin (REPORT_READY), écran terminal
* avec « Télécharger l'audio » + « Voir le rapport ».
*/
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Mic, Volume2, Download, FileText, Loader2 } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { Card } from '@/shared/ui/Card'
import { T1SpeakingIndicator } from '../components/T1SpeakingIndicator'
import { useT1LiveSession } from '../hooks/useT1LiveSession'
const DIALOGUE_SECONDS = 180 // 3:00
function formatMmSs(totalSeconds: number): string {
const remaining = Math.max(0, totalSeconds)
const m = Math.floor(remaining / 60)
const s = remaining % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
export function T1DialoguePage() {
const navigate = useNavigate()
const [autoStarted, setAutoStarted] = useState(false)
const session = useT1LiveSession()
// Démarrer le dialogue automatiquement au mount (la prépa est déjà finie).
useEffect(() => {
if (autoStarted) return
setAutoStarted(true)
void session.startDialogue()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoStarted])
const remaining = DIALOGUE_SECONDS - session.elapsedSeconds
const stateLabel = (() => {
switch (session.state) {
case 'idle':
case 'connecting':
return 'Connexion à lexaminateur…'
case 'presenting':
return 'À vous — présentez-vous.'
case 'interrupted':
return 'Lexaminateur vous interrompt — répondez-lui.'
case 'processing':
return 'Évaluation en cours…'
case 'ended':
return 'Session terminée.'
case 'error':
return 'Erreur.'
case 'preparing':
return 'Préparation…'
}
})()
function handleDownload() {
const blob = session.recording.exportWAV()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `expria-t1-${new Date().toISOString().slice(0, 10)}.wav`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
function handleViewReport() {
if (!session.simulationId) return
navigate(`/rapport/${session.simulationId}`)
}
function handleRestart() {
navigate('/simulation/eo/t1/live/preparation')
}
// Abandon : ferme la session sans évaluation (cancelDialogue ne déclenche ni
// correction ni persistance), puis sortie du flux.
function handleCancel() {
session.cancelDialogue()
navigate('/simulation/eo')
}
// « Annuler » / « Terminer » n'ont de sens que pendant la session active
// (connexion, présentation ou interruption), pas en évaluation.
const canCancel =
session.state === 'connecting' ||
session.state === 'presenting' ||
session.state === 'interrupted'
// ── État terminal : rapport prêt ─────────────────────────────────────────
if (session.state === 'ended') {
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-2xl space-y-5">
<h2 className="text-lg font-semibold text-ink-primary">Session terminée</h2>
<Card variant="default" className="space-y-3 p-5">
<p className="text-sm text-ink-primary">
Votre présentation a é évaluée. Vous pouvez télécharger l'enregistrement audio avant
de consulter le rapport.
</p>
<p className="text-xs text-ink-secondary">
Durée enregistrée :{' '}
<span className="font-semibold tabular-nums text-ink-primary">
{session.recording.durationSeconds.toFixed(1)} s
</span>
</p>
</Card>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="secondary"
icon={<Download className="size-4" aria-hidden="true" />}
onClick={handleDownload}
disabled={session.recording.durationSeconds === 0}
>
Télécharger l'audio
</Button>
<Button
variant="primary"
icon={<FileText className="size-4" aria-hidden="true" />}
onClick={handleViewReport}
disabled={!session.simulationId}
>
Voir le rapport
</Button>
</div>
</main>
</div>
)
}
// ── État erreur ──────────────────────────────────────────────────────────
if (session.state === 'error') {
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-2xl space-y-5">
<h2 className="text-lg font-semibold text-ink-primary">Erreur</h2>
<div
role="alert"
className="rounded-md border border-danger/40 bg-danger-soft px-4 py-3 text-sm text-danger"
>
{session.errorMessage ?? 'Une erreur est survenue.'}
</div>
<div className="flex gap-3">
<Button variant="secondary" onClick={handleRestart}>
Recommencer
</Button>
</div>
</main>
</div>
)
}
// ── État dialogue actif ──────────────────────────────────────────────────
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-2xl space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-ink-primary">Présentation en cours</h2>
<span
className="rounded-full border border-border bg-surface px-3 py-1 text-sm font-semibold tabular-nums text-ink-primary"
aria-label="Temps restant de présentation"
>
{formatMmSs(remaining)}
</span>
</div>
{session.warning && (
<div
role="status"
className="rounded-md border border-warning/40 bg-warning-soft px-3 py-2 text-sm text-ink-primary"
>
30 secondes restantes.
</div>
)}
<Card variant="default" className="space-y-4 p-5">
<div className="flex items-center gap-3">
{session.state === 'processing' ? (
<Loader2 className="size-5 animate-spin text-brand-text" aria-hidden="true" />
) : session.state === 'interrupted' ? (
<Volume2 className="size-5 text-brand-text" aria-hidden="true" />
) : (
<Mic
className={
session.state === 'presenting'
? 'size-5 text-success'
: 'size-5 text-ink-secondary'
}
aria-hidden="true"
/>
)}
<p className="text-sm font-semibold text-ink-primary">{stateLabel}</p>
</div>
{/* Indicateur : 'presenting' = amplitude micro réelle (analyser lu par
ref en rAF) ; 'interrupted' = animation décorative (uplink coupé). */}
{canCancel && (
<T1SpeakingIndicator analyserRef={session.analyserRef} state={session.state} />
)}
<p className="text-xs text-ink-secondary">
Présentez-vous naturellement. L'examinateur peut vous interrompre à tout moment.
</p>
</Card>
<div className="flex items-center justify-center gap-3">
{canCancel && (
<Button
variant="ghost"
className="text-danger hover:bg-danger-soft hover:text-danger"
onClick={handleCancel}
title="Abandonner sans générer de rapport"
>
Annuler
</Button>
)}
<Button
variant="secondary"
onClick={() => session.endDialogue()}
disabled={session.state === 'processing'}
>
Terminer
</Button>
</div>
<p className="text-center text-xs text-ink-tertiary">
« Annuler » abandonne la session sans rapport. « Terminer » lance l'évaluation.
</p>
</main>
</div>
)
}