152 lines
5.2 KiB
TypeScript
152 lines
5.2 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react'
|
|
import { useNavigate } from '@tanstack/react-router'
|
|
import { cn } from '@/lib/utils'
|
|
import { useTestStore } from '@/store/test-store'
|
|
import { useRequireAuth } from '@/hooks/use-require-auth'
|
|
import { TestSessionHeader } from './TestSessionHeader'
|
|
import { TestSessionSidebar } from './TestSessionSidebar'
|
|
import { TestSessionFooter } from './TestSessionFooter'
|
|
import type { Question } from '@/types'
|
|
|
|
const ANSWER_LABELS = ['A', 'B', 'C', 'D']
|
|
|
|
function QuestionCard({
|
|
question, globalNum, answer, onSelect,
|
|
}: {
|
|
question: Question
|
|
globalNum: number
|
|
answer: number | null
|
|
onSelect: (idx: number) => void
|
|
}) {
|
|
return (
|
|
<div className="bg-white rounded-2xl border border-slate-200 p-6 mb-4">
|
|
<span className="inline-block bg-blue-600 text-white text-xs font-bold px-3 py-1 rounded-full mb-4">
|
|
Câu {globalNum}
|
|
</span>
|
|
|
|
{question.passageText && (
|
|
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4 mb-4 text-sm text-slate-700 leading-relaxed whitespace-pre-wrap">
|
|
{question.passageText}
|
|
</div>
|
|
)}
|
|
{question.audioUrl && (
|
|
<audio controls src={question.audioUrl} className="w-full mb-4 rounded-lg" />
|
|
)}
|
|
{question.imageUrl && (
|
|
<img src={question.imageUrl} alt="" className="max-h-64 rounded-xl mb-4 object-contain" />
|
|
)}
|
|
{question.text && (
|
|
<p className="text-base font-medium text-slate-800 leading-relaxed mb-5">{question.text}</p>
|
|
)}
|
|
|
|
<div className="space-y-2.5">
|
|
{question.options.map((opt, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => onSelect(i)}
|
|
className={cn(
|
|
'w-full flex items-center gap-3 p-3.5 border-2 rounded-xl text-sm font-medium text-left transition-all',
|
|
answer === i
|
|
? 'border-blue-600 bg-blue-50 text-blue-700'
|
|
: 'border-slate-200 hover:border-blue-300 hover:bg-blue-50/50 text-slate-700',
|
|
)}
|
|
>
|
|
<span className={cn(
|
|
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0',
|
|
answer === i ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-500',
|
|
)}>
|
|
{ANSWER_LABELS[i]}
|
|
</span>
|
|
{opt}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function TestSession() {
|
|
const navigate = useNavigate()
|
|
const { testName, parts, currentPartIndex, answers, totalSeconds, setAnswer, setCurrentPart, submitExam } = useTestStore()
|
|
const { isAuthenticated, isLoading } = useRequireAuth()
|
|
const [timeLeft, setTimeLeft] = useState(() => totalSeconds > 0 ? totalSeconds : -1)
|
|
const [timeUsed, setTimeUsed] = useState(0)
|
|
|
|
const handleSubmit = useCallback(() => {
|
|
submitExam(totalSeconds > 0 ? totalSeconds - timeLeft : timeUsed)
|
|
navigate({ to: '/toeic/result' })
|
|
}, [submitExam, navigate, totalSeconds, timeLeft, timeUsed])
|
|
|
|
// Timer
|
|
useEffect(() => {
|
|
if (parts.length === 0) return
|
|
const id = setInterval(() => {
|
|
if (timeLeft > 0) {
|
|
setTimeLeft(t => { if (t <= 1) { clearInterval(id); handleSubmit(); return 0 } return t - 1 })
|
|
} else {
|
|
setTimeUsed(t => t + 1)
|
|
}
|
|
}, 1000)
|
|
return () => clearInterval(id)
|
|
}, [parts.length, timeLeft, handleSubmit])
|
|
|
|
useEffect(() => {
|
|
if (isLoading) return
|
|
if (!isAuthenticated || parts.length === 0) navigate({ to: '/toeic' })
|
|
}, [isLoading, isAuthenticated, parts.length, navigate])
|
|
|
|
if (parts.length === 0) return null
|
|
|
|
const currentPart = parts[currentPartIndex]
|
|
|
|
// Compute global question offset for current part
|
|
let globalOffset = 0
|
|
for (let i = 0; i < currentPartIndex; i++) globalOffset += parts[i].questions.length
|
|
|
|
return (
|
|
<div className="flex flex-col" style={{ height: 'calc(100vh - var(--app-header-height, 0px))' }}>
|
|
<TestSessionHeader
|
|
testName={testName}
|
|
timeLeft={timeLeft}
|
|
timeUsed={timeUsed}
|
|
onSubmit={handleSubmit}
|
|
/>
|
|
|
|
<div className="flex flex-1 overflow-hidden">
|
|
<TestSessionSidebar
|
|
parts={parts}
|
|
currentPartIndex={currentPartIndex}
|
|
answers={answers}
|
|
onSelectPart={setCurrentPart}
|
|
/>
|
|
|
|
{/* Main scrollable content */}
|
|
<main className="flex-1 overflow-y-auto bg-[#F8FAFC] px-6 py-6">
|
|
<div className="max-w-3xl mx-auto">
|
|
<h2 className="text-lg font-extrabold text-slate-700 mb-5">
|
|
Part {currentPart.partNumber}: {currentPart.partName}
|
|
</h2>
|
|
{currentPart.questions.map((q, idx) => (
|
|
<QuestionCard
|
|
key={q.id}
|
|
question={q}
|
|
globalNum={globalOffset + idx + 1}
|
|
answer={answers[q.id] ?? null}
|
|
onSelect={(i) => setAnswer(q.id, i)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<TestSessionFooter
|
|
currentPartIndex={currentPartIndex}
|
|
totalParts={parts.length}
|
|
currentPartName={currentPart.partName}
|
|
onPrev={() => setCurrentPart(currentPartIndex - 1)}
|
|
onNext={() => setCurrentPart(currentPartIndex + 1)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|