625 lines
20 KiB
TypeScript
625 lines
20 KiB
TypeScript
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' }}>cá 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>
|
||
)
|
||
}
|