Files
english/src/features/toeic/components/ToeicTestDetail.tsx
2026-04-24 14:41:41 +07:00

625 lines
20 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.

import { useState, useMemo } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ArrowRight, Check, Clock, Sparkles, Target } from 'lucide-react'
import { fetchTestWithParts } from '@/features/toeic/api/test-list-api'
import { fetchQuestionsForTest } from '@/hooks/use-questions'
import { useTestStore } from '@/store/test-store'
import { useRequireAuth } from '@/hooks/use-require-auth'
import type { PartRecord } from '@/types'
interface Props { testId: number }
// TOEIC part metadata (stable across all tests)
const PART_META: Record<number, { subtitle: string; desc: string; skill: 'listening' | 'reading' }> = {
1: { subtitle: 'Photographs', desc: 'Mô tả hình ảnh', skill: 'listening' },
2: { subtitle: 'Question-Response', desc: 'Hỏi đáp', skill: 'listening' },
3: { subtitle: 'Conversations', desc: 'Hội thoại ngắn', skill: 'listening' },
4: { subtitle: 'Short Talks', desc: 'Bài nói ngắn', skill: 'listening' },
5: { subtitle: 'Incomplete Sentences', desc: 'Ngữ pháp câu', skill: 'reading' },
6: { subtitle: 'Text Completion', desc: 'Điền vào đoạn văn', skill: 'reading' },
7: { subtitle: 'Reading Comprehension', desc: 'Đọc hiểu', skill: 'reading' },
}
const TABS = ['Tất cả', 'Listening', 'Reading', 'Chưa làm', 'Cần ôn'] as const
type Tab = (typeof TABS)[number]
function Ring({ percent, size = 56, stroke = 5, color }: {
percent: number; size?: number; stroke?: number; color: string
}) {
const cx = size / 2
const r = cx - stroke
const c = 2 * Math.PI * r
const offset = c - (Math.min(percent, 100) / 100) * c
return (
<div className="relative grid place-items-center flex-shrink-0" style={{ width: size, height: size }}>
<svg className="-rotate-90" width={size} height={size}>
<circle cx={cx} cy={cx} r={r} fill="none" stroke="var(--at-line)" strokeWidth={stroke} />
<circle
cx={cx} cy={cx} r={r}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset 0.5s ease' }}
/>
</svg>
<span
className="absolute tabular-nums"
style={{ fontSize: 13, fontWeight: 700, color: 'var(--at-ink)' }}
>
{percent}
<span style={{ fontSize: 9, fontWeight: 500, color: 'var(--at-mute)' }}>%</span>
</span>
</div>
)
}
function StatusChip({ done, fresh }: { done: boolean; fresh: boolean }) {
if (done) {
return (
<span
className="inline-flex items-center gap-1.5"
style={{
padding: '5px 10px',
borderRadius: 999,
background: 'var(--at-good-soft)',
color: 'var(--at-good-ink)',
fontSize: 11,
fontWeight: 600,
letterSpacing: '0.02em',
}}
>
<Check size={11} strokeWidth={3} />
Hoàn thành
</span>
)
}
if (fresh) {
return (
<span
style={{
padding: '5px 10px',
borderRadius: 999,
background: 'var(--at-line-2)',
color: 'var(--at-ink-3)',
fontSize: 11,
fontWeight: 600,
letterSpacing: '0.02em',
}}
>
Mới
</span>
)
}
return (
<span
style={{
padding: '5px 10px',
borderRadius: 999,
background: 'var(--at-warm-soft)',
color: 'var(--at-warm)',
fontSize: 11,
fontWeight: 600,
letterSpacing: '0.02em',
}}
>
Đang làm
</span>
)
}
function PartCard({ part, selected, onToggle, disabled }: {
part: PartRecord; selected: boolean; onToggle: () => void; disabled: boolean
}) {
const meta = PART_META[part.partNumber] ?? { subtitle: part.title, desc: '', skill: 'reading' as const }
// Progress data not yet available in API — render as fresh.
const done = 0
const score = 0
const pct = part.questionCount > 0 ? Math.round((done / part.questionCount) * 100) : 0
const isDone = done === part.questionCount && part.questionCount > 0
const isFresh = done === 0
const ringColor = isDone
? 'var(--at-good)'
: isFresh
? 'var(--at-mute-2)'
: 'var(--at-brand)'
return (
<button
onClick={onToggle}
disabled={disabled}
aria-pressed={selected}
className="text-left transition-all hover:-translate-y-0.5 disabled:opacity-60 relative"
style={{
background: 'var(--at-surface)',
border: `1px solid ${selected ? 'var(--at-brand)' : 'var(--at-line)'}`,
borderRadius: 18,
padding: 20,
boxShadow: selected
? '0 0 0 1px var(--at-brand), 0 20px 40px -16px rgba(61,75,215,0.25)'
: '0 1px 2px rgba(15,17,20,0.04)',
}}
>
{/* Checker — top-right corner */}
<span
className="absolute grid place-items-center"
style={{
top: 14,
right: 14,
width: 22,
height: 22,
borderRadius: 6,
background: selected ? 'var(--at-brand)' : 'var(--at-surface)',
border: `1.5px solid ${selected ? 'var(--at-brand)' : 'var(--at-line)'}`,
transition: 'all 0.15s',
}}
>
{selected && <Check size={13} strokeWidth={3} color="white" />}
</span>
<div className="flex items-start justify-between mb-4" style={{ paddingRight: 30 }}>
<div>
<div
className="uppercase"
style={{
fontSize: 10.5,
fontWeight: 700,
color: 'var(--at-brand)',
letterSpacing: '0.14em',
marginBottom: 6,
}}
>
Part {part.partNumber}
</div>
<div
style={{
fontFamily: 'var(--at-serif)',
fontSize: 22,
fontWeight: 400,
letterSpacing: '-0.02em',
lineHeight: 1.1,
color: 'var(--at-ink)',
}}
>
{meta.subtitle}
</div>
<div style={{ fontSize: 12.5, color: 'var(--at-mute)', marginTop: 6 }}>
{meta.desc}
</div>
</div>
<Ring percent={pct} color={ringColor} />
</div>
<div style={{ height: 1, background: 'var(--at-line)', margin: '12px 0' }} />
<div className="flex items-center justify-between mb-2.5">
<div className="flex items-center gap-4">
<div>
<div
className="uppercase"
style={{
fontSize: 10, color: 'var(--at-mute)',
letterSpacing: '0.1em', fontWeight: 600,
}}
>
Câu hỏi
</div>
<div className="tabular-nums" style={{ fontSize: 16, fontWeight: 700, color: 'var(--at-ink)' }}>
{done}/{part.questionCount}
</div>
</div>
<div>
<div
className="uppercase"
style={{
fontSize: 10, color: 'var(--at-mute)',
letterSpacing: '0.1em', fontWeight: 600,
}}
>
Điểm
</div>
<div
className="tabular-nums"
style={{
fontSize: 16, fontWeight: 700,
color: isFresh
? 'var(--at-mute)'
: score >= 80
? 'var(--at-good)'
: score >= 60
? 'var(--at-ink)'
: 'var(--at-bad)',
}}
>
{isFresh ? '—' : score}
</div>
</div>
</div>
<StatusChip done={isDone} fresh={isFresh} />
</div>
<div
style={{
height: 6, background: 'var(--at-line-2)',
borderRadius: 999, overflow: 'hidden', position: 'relative',
}}
>
<span
style={{
position: 'absolute', inset: '0 auto 0 0',
background: 'var(--at-brand)',
borderRadius: 999,
width: `${pct}%`,
transition: 'width 0.5s cubic-bezier(0.2,0.7,0.2,1)',
}}
/>
</div>
</button>
)
}
function AiGeneratedCard({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
className="text-left relative overflow-hidden"
style={{
background: 'var(--at-ink)',
color: 'var(--at-paper)',
border: 'none',
borderRadius: 18,
padding: 20,
cursor: 'pointer',
}}
>
<div
style={{
position: 'absolute', top: -30, right: -30,
width: 140, height: 140, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(124,139,250,0.2), transparent 60%)',
}}
/>
<div className="relative">
<div
className="inline-flex items-center gap-1.5 uppercase"
style={{
fontSize: 10.5, fontWeight: 700,
color: '#A9B3FA', letterSpacing: '0.14em', marginBottom: 6,
}}
>
<Sparkles size={12} />
AI Generated
</div>
<div
style={{
fontFamily: 'var(--at-serif)',
fontSize: 22, fontWeight: 400,
letterSpacing: '-0.02em', lineHeight: 1.1,
}}
>
Đ thi <i style={{ color: '#A9B3FA' }}> nhân hóa</i>
</div>
<div
style={{
fontSize: 12.5,
color: 'rgba(250,248,243,0.65)',
marginTop: 6, marginBottom: 22,
}}
>
AI tạo đ dựa trên điểm yếu của bạn. Mỗi lần mỗi khác.
</div>
<div className="flex items-center gap-2">
<span
className="inline-flex items-center"
style={{
padding: '4px 10px', borderRadius: 999,
background: 'rgba(255,255,255,0.1)',
color: 'var(--at-paper)', fontSize: 11, fontWeight: 600,
}}
>
20 câu · 15 phút
</span>
<span style={{ fontSize: 12, color: 'rgba(250,248,243,0.5)' }}>Miễn phí</span>
</div>
<div className="absolute right-0 bottom-0">
<ArrowRight size={20} style={{ color: 'var(--at-paper)', opacity: 0.8 }} />
</div>
</div>
</button>
)
}
export function ToeicTestDetail({ testId }: Props) {
const navigate = useNavigate()
const { startExam } = useTestStore()
const { requireAuth } = useRequireAuth()
const [loading, setLoading] = useState(false)
const [activeTab, setActiveTab] = useState<Tab>('Tất cả')
const [selectedParts, setSelectedParts] = useState<number[]>([])
const [durationMinutes, setDurationMinutes] = useState(30)
const { data, isLoading } = useQuery({
queryKey: ['test-detail', testId],
queryFn: () => fetchTestWithParts(testId),
})
function togglePart(partNumber: number) {
setSelectedParts(prev =>
prev.includes(partNumber) ? prev.filter(p => p !== partNumber) : [...prev, partNumber],
)
}
function clearSelection() {
setSelectedParts([])
}
async function handleStart(
mode: 'full' | 'short' | 'custom',
partNumbers?: number[],
minutes?: number,
) {
if (!requireAuth()) return
if (!data) return
setLoading(true)
try {
const parts = await fetchQuestionsForTest(testId, partNumbers)
const totalSeconds = mode === 'full'
? data.test.durationMinutes * 60
: mode === 'short'
? 20 * 60
: (minutes ?? 30) * 60
startExam({ testId, testName: data.test.title, parts, totalSeconds })
navigate({ to: '/toeic/session' })
} finally {
setLoading(false)
}
}
const filteredParts = useMemo(() => {
if (!data) return []
const all = data.parts
if (activeTab === 'Tất cả') return all
if (activeTab === 'Listening') return all.filter(p => PART_META[p.partNumber]?.skill === 'listening')
if (activeTab === 'Reading') return all.filter(p => PART_META[p.partNumber]?.skill === 'reading')
// "Chưa làm" / "Cần ôn" — no progress data yet, show all
return all
}, [data, activeTab])
if (isLoading) {
return (
<div className="px-6 lg:px-10 py-10">
<div
className="animate-pulse rounded h-10 mb-6"
style={{ background: 'var(--at-line-2)', width: 280 }}
/>
<div className="grid gap-5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))' }}>
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="animate-pulse"
style={{ height: 220, borderRadius: 18, background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
/>
))}
</div>
</div>
)
}
if (!data) return null
const { test, parts } = data
return (
<div className="px-6 lg:px-10 py-10 page-enter">
{/* Editorial head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-8">
<div>
<h1
style={{
fontFamily: 'var(--at-serif)',
fontSize: 40,
fontWeight: 400,
letterSpacing: '-0.025em',
lineHeight: 1.05,
color: 'var(--at-ink)',
}}
>
Chọn <i style={{ color: 'var(--at-brand)', fontWeight: 400 }}>phần</i> bạn muốn luyện
</h1>
<p style={{ marginTop: 12, fontSize: 13, color: 'var(--at-mute)' }}>
{parts.length} phần thi · {test.totalQuestions} câu hỏi · đy đ Listening + Reading
</p>
</div>
<div className="flex gap-2 flex-shrink-0">
<button
onClick={() => handleStart('short')}
disabled={loading}
className="inline-flex items-center gap-2 transition-colors hover:bg-[var(--at-line-2)] disabled:opacity-50"
style={{
padding: '10px 18px',
borderRadius: 10,
border: '1px solid var(--at-line)',
background: 'var(--at-surface)',
color: 'var(--at-ink-2)',
fontSize: 13.5,
fontWeight: 600,
}}
>
<Clock size={14} /> Đ ngắn 20 phút
</button>
<button
onClick={() => handleStart('full')}
disabled={loading}
className="inline-flex items-center gap-2 transition-all hover:brightness-110 disabled:opacity-50"
style={{
padding: '10px 18px',
borderRadius: 10,
background: 'var(--at-ink)',
color: 'var(--at-paper)',
border: '1px solid var(--at-ink)',
fontSize: 13.5,
fontWeight: 600,
}}
>
<Target size={14} /> Thi thử đy đ
</button>
</div>
</div>
{/* Tabs */}
<div
className="flex gap-2 mb-6"
style={{ borderBottom: '1px solid var(--at-line)' }}
>
{TABS.map(t => {
const active = activeTab === t
return (
<button
key={t}
onClick={() => setActiveTab(t)}
style={{
padding: '10px 16px',
fontWeight: 600,
fontSize: 13,
color: active ? 'var(--at-brand)' : 'var(--at-mute)',
borderBottom: active ? '2px solid var(--at-brand)' : '2px solid transparent',
marginBottom: -1,
}}
>
{t}
</button>
)
})}
</div>
{/* Part grid */}
<div
className="grid gap-5"
style={{
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
paddingBottom: selectedParts.length > 0 ? 96 : 0,
}}
>
{filteredParts.map(part => (
<PartCard
key={part.partNumber}
part={part}
selected={selectedParts.includes(part.partNumber)}
onToggle={() => togglePart(part.partNumber)}
disabled={loading}
/>
))}
{activeTab === 'Tất cả' && (
<AiGeneratedCard onClick={() => handleStart('short')} />
)}
</div>
{/* Sticky selection bar — full viewport width */}
{selectedParts.length > 0 && (
<div
className="fixed bottom-0 right-0 left-0 z-30 flex items-center justify-between gap-4 px-6 lg:px-10 py-4"
style={{
background: 'color-mix(in oklab, var(--at-paper) 92%, transparent)',
borderTop: '1px solid var(--at-line)',
backdropFilter: 'blur(8px)',
}}
>
<div className="flex items-center gap-3">
<span
className="grid place-items-center tabular-nums"
style={{
width: 34, height: 34, borderRadius: 10,
background: 'var(--at-brand-soft)',
color: 'var(--at-brand)',
fontWeight: 700, fontSize: 14,
}}
>
{selectedParts.length}
</span>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--at-ink)' }}>
Đã chọn {selectedParts.length} phần
</div>
<div style={{ fontSize: 12, color: 'var(--at-mute)' }}>
{data?.parts
.filter(p => selectedParts.includes(p.partNumber))
.reduce((sum, p) => sum + p.questionCount, 0)}{' '}
câu hỏi
</div>
</div>
</div>
<div className="flex items-center gap-2">
<label
className="inline-flex items-center gap-2"
style={{
padding: '8px 12px',
borderRadius: 10,
border: '1px solid var(--at-line)',
background: 'var(--at-surface)',
color: 'var(--at-ink-2)',
fontSize: 13,
fontWeight: 500,
}}
>
<Clock size={14} style={{ color: 'var(--at-mute)' }} />
<span style={{ color: 'var(--at-mute)' }}>Thời gian</span>
<select
value={durationMinutes}
onChange={(e) => setDurationMinutes(Number(e.target.value))}
className="tabular-nums outline-none"
style={{
background: 'transparent',
color: 'var(--at-ink)',
fontWeight: 700,
fontSize: 13,
cursor: 'pointer',
}}
>
{Array.from({ length: 19 }, (_, i) => 20 + i * 10).map(m => (
<option key={m} value={m}>{m} phút</option>
))}
</select>
</label>
<button
onClick={clearSelection}
disabled={loading}
className="transition-colors hover:bg-[var(--at-line-2)]"
style={{
padding: '10px 16px',
borderRadius: 10,
border: '1px solid var(--at-line)',
background: 'var(--at-surface)',
color: 'var(--at-ink-2)',
fontSize: 13,
fontWeight: 600,
}}
>
Bỏ chọn
</button>
<button
onClick={() => handleStart('custom', [...selectedParts].sort((a, b) => a - b), durationMinutes)}
disabled={loading}
className="inline-flex items-center gap-2 transition-[filter] hover:brightness-110 disabled:opacity-60"
style={{
padding: '10px 20px',
borderRadius: 10,
background: 'var(--at-brand)',
color: 'white',
fontSize: 13.5,
fontWeight: 600,
}}
>
<Target size={14} />
Bắt đu luyện
</button>
</div>
</div>
)}
</div>
)
}