update UI

This commit is contained in:
2026-04-24 14:41:41 +07:00
parent abfaf397ee
commit 36b8ee9ec2
28 changed files with 99446 additions and 339 deletions

View File

@@ -1,20 +1,345 @@
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { cn } from '@/lib/utils'
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 [selectedParts, setSelectedParts] = useState<number[]>([])
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],
@@ -23,21 +348,29 @@ export function ToeicTestDetail({ testId }: Props) {
function togglePart(partNumber: number) {
setSelectedParts(prev =>
prev.includes(partNumber) ? prev.filter(p => p !== partNumber) : [...prev, partNumber]
prev.includes(partNumber) ? prev.filter(p => p !== partNumber) : [...prev, partNumber],
)
}
async function handleStart(mode: 'full' | 'parts') {
function clearSelection() {
setSelectedParts([])
}
async function handleStart(
mode: 'full' | 'short' | 'custom',
partNumbers?: number[],
minutes?: number,
) {
if (!requireAuth()) return
if (mode === 'parts' && selectedParts.length === 0) return
if (!data) return
setLoading(true)
try {
const partNumbers = mode === 'full' ? undefined : selectedParts
const parts = await fetchQuestionsForTest(testId, partNumbers)
const totalSeconds = mode === 'full'
? data.test.durationMinutes * 60
: selectedParts.length * 10 * 60
: mode === 'short'
? 20 * 60
: (minutes ?? 30) * 60
startExam({ testId, testName: data.test.title, parts, totalSeconds })
navigate({ to: '/toeic/session' })
} finally {
@@ -45,13 +378,31 @@ export function ToeicTestDetail({ testId }: Props) {
}
}
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 py-8 max-w-5xl mx-auto">
<div className="h-8 w-64 bg-slate-200 rounded animate-pulse mb-8" />
<div className="grid grid-cols-2 gap-5">
<div className="h-80 bg-slate-100 rounded-2xl animate-pulse" />
<div className="h-80 bg-slate-100 rounded-2xl animate-pulse" />
<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>
)
@@ -61,91 +412,213 @@ export function ToeicTestDetail({ testId }: Props) {
const { test, parts } = data
return (
<div className="px-6 py-8 max-w-5xl mx-auto page-enter">
{/* Back + title */}
<div className="flex items-center gap-3 mb-1">
<button
onClick={() => navigate({ to: '/toeic' })}
className="w-8 h-8 rounded-full border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors"
>
<span className="material-symbols-outlined text-slate-600" style={{ fontSize: 18 }}>arrow_back</span>
</button>
<h1 className="text-2xl font-extrabold text-slate-800">{test.title}</h1>
</div>
<p className="text-slate-400 text-sm ml-11 mb-8">{test.totalQuestions} câu · {test.durationMinutes} phút</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Full test card */}
<div
className="rounded-2xl p-6 flex flex-col text-white relative overflow-hidden"
style={{ background: 'linear-gradient(135deg, #2563EB, #1d4ed8)' }}
>
<div className="absolute -top-4 -right-4 opacity-10">
<span className="material-symbols-outlined" style={{ fontSize: 100 }}>military_tech</span>
</div>
<span className="material-symbols-outlined mb-4" style={{ fontSize: 32 }}>military_tech</span>
<h2 className="text-2xl font-extrabold mb-1">Thi Toàn Bộ</h2>
<p className="text-blue-100 text-sm mb-2">{test.totalQuestions} câu · {test.durationMinutes} phút · Toàn bộ {parts.length} parts</p>
<p className="text-blue-100 text-xs mb-8"> phỏng bài thi TOEIC thực tế với giới hạn thời gian.</p>
<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="mt-auto py-3 bg-white text-blue-600 rounded-xl font-bold text-sm hover:bg-blue-50 transition-colors disabled:opacity-60 flex items-center justify-center gap-2"
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,
}}
>
{loading ? <span className="w-4 h-4 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin" /> : (
<><span className="material-symbols-outlined" style={{ fontSize: 18 }}>play_arrow</span>Bắt đu thi</>
)}
</button>
</div>
{/* Part selection card */}
<div className="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col">
<span className="material-symbols-outlined text-blue-600 mb-4" style={{ fontSize: 32 }}>checklist</span>
<h2 className="text-xl font-extrabold text-slate-800 mb-1">Chọn Part Luyện Tập</h2>
<p className="text-slate-400 text-sm mb-4">Chọn các part muốn luyện tập</p>
<div className="space-y-2 flex-1">
{parts.map((part) => {
const checked = selectedParts.includes(part.partNumber)
return (
<button
key={part.partNumber}
onClick={() => togglePart(part.partNumber)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-xl border-2 transition-all text-left',
checked
? 'border-blue-600 bg-blue-50'
: 'border-slate-100 hover:border-slate-200 bg-slate-50/50',
)}
>
<span className={cn(
'w-5 h-5 rounded flex items-center justify-center border-2 flex-shrink-0',
checked ? 'bg-blue-600 border-blue-600' : 'border-slate-300',
)}>
{checked && <span className="material-symbols-outlined text-white" style={{ fontSize: 14 }}>check</span>}
</span>
<span className="flex-1 text-sm font-semibold text-slate-700">
Part {part.partNumber} {part.title}
</span>
<span className="text-xs text-slate-400 bg-slate-100 px-2 py-0.5 rounded-full flex-shrink-0">
{part.questionCount} câu
</span>
</button>
)
})}
</div>
<button
onClick={() => handleStart('parts')}
disabled={loading || selectedParts.length === 0}
className="mt-4 w-full py-3 bg-blue-600 text-white rounded-xl font-bold text-sm hover:bg-blue-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{loading ? <span className="w-4 h-4 border-2 border-blue-200 border-t-white rounded-full animate-spin" /> : (
<><span className="material-symbols-outlined" style={{ fontSize: 18 }}>play_arrow</span>Bắt đu luyện tập</>
)}
<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>
)
}