update UI
This commit is contained in:
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint ."
|
||||
"lint": "eslint .",
|
||||
"import:tests": "node scripts/import-tests.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
|
||||
145
scripts/import-tests.mjs
Normal file
145
scripts/import-tests.mjs
Normal file
@@ -0,0 +1,145 @@
|
||||
// Import TOEIC test JSON files into Supabase.
|
||||
// Usage: SUPABASE_URL=... SUPABASE_SERVICE_ROLE_KEY=... node scripts/import-tests.mjs
|
||||
// Requires service_role key to bypass RLS. Idempotent: skips if slug already exists.
|
||||
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const TEST_DIR = join(__dirname, '..', 'test')
|
||||
|
||||
const SUPABASE_URL = process.env.SUPABASE_URL
|
||||
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!SUPABASE_URL || !SERVICE_KEY) {
|
||||
console.error('Missing env: SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const db = createClient(SUPABASE_URL, SERVICE_KEY, {
|
||||
auth: { persistSession: false },
|
||||
})
|
||||
|
||||
async function insertReturningId(table, payload) {
|
||||
const { data, error } = await db.from(table).insert(payload).select('id').single()
|
||||
if (error) throw new Error(`${table} insert failed: ${error.message}`)
|
||||
return data.id
|
||||
}
|
||||
|
||||
async function insertMany(table, rows) {
|
||||
if (rows.length === 0) return
|
||||
const { error } = await db.from(table).insert(rows)
|
||||
if (error) throw new Error(`${table} bulk insert failed: ${error.message}`)
|
||||
}
|
||||
|
||||
async function importTest(data) {
|
||||
const { data: existing } = await db.from('test').select('id').eq('slug', data.slug).maybeSingle()
|
||||
if (existing) {
|
||||
console.log(` skip (exists): ${data.slug}`)
|
||||
return { skipped: true }
|
||||
}
|
||||
|
||||
const testId = await insertReturningId('test', {
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
total_questions: data.total_questions ?? 0,
|
||||
})
|
||||
|
||||
let totalQuestions = 0
|
||||
|
||||
for (const part of data.parts ?? []) {
|
||||
const partId = await insertReturningId('part', {
|
||||
test_id: testId,
|
||||
part_number: part.part_number,
|
||||
title: part.title,
|
||||
display_order: part.display_order ?? 0,
|
||||
})
|
||||
|
||||
let partCount = 0
|
||||
|
||||
for (const group of part.groups ?? []) {
|
||||
const groupId = await insertReturningId('question_group', {
|
||||
part_id: partId,
|
||||
audio_url: group.audio_url ?? null,
|
||||
image_url: group.image_url ?? null,
|
||||
passage_text: group.passage_text ?? null,
|
||||
transcript: group.transcript ?? null,
|
||||
display_order: group.display_order ?? 0,
|
||||
})
|
||||
|
||||
for (const q of group.questions ?? []) {
|
||||
const questionId = await insertReturningId('question', {
|
||||
group_id: groupId,
|
||||
question_number: q.question_number,
|
||||
question_text: q.question_text ?? null,
|
||||
display_order: q.display_order ?? 0,
|
||||
})
|
||||
|
||||
const choices = (q.choices ?? []).map((c) => ({
|
||||
question_id: questionId,
|
||||
value: c.value,
|
||||
label_text: c.label_text ?? null,
|
||||
is_correct: c.is_correct ?? false,
|
||||
}))
|
||||
await insertMany('answer_choice', choices)
|
||||
|
||||
partCount++
|
||||
totalQuestions++
|
||||
}
|
||||
}
|
||||
|
||||
await db.from('part').update({ question_count: partCount }).eq('id', partId)
|
||||
}
|
||||
|
||||
return { testId, totalQuestions }
|
||||
}
|
||||
|
||||
async function rollback(slug) {
|
||||
// CASCADE will clean up parts, groups, questions, choices when test row is deleted.
|
||||
await db.from('test').delete().eq('slug', slug)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const files = (await readdir(TEST_DIR))
|
||||
.filter((f) => f.startsWith('test_') && f.endsWith('.json'))
|
||||
.sort()
|
||||
|
||||
console.log(`Found ${files.length} file(s) in ${TEST_DIR}\n`)
|
||||
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
let failed = 0
|
||||
|
||||
for (const file of files) {
|
||||
const path = join(TEST_DIR, file)
|
||||
process.stdout.write(`${file}: `)
|
||||
let slug = null
|
||||
try {
|
||||
const data = JSON.parse(await readFile(path, 'utf-8'))
|
||||
slug = data.slug
|
||||
const res = await importTest(data)
|
||||
if (res.skipped) skipped++
|
||||
else {
|
||||
imported++
|
||||
console.log(` ok (${res.totalQuestions} questions)`)
|
||||
}
|
||||
} catch (err) {
|
||||
failed++
|
||||
console.error(` FAIL: ${err.message}`)
|
||||
if (slug) {
|
||||
console.error(` rolling back ${slug}...`)
|
||||
await rollback(slug).catch((e) => console.error(` rollback failed: ${e.message}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. imported=${imported} skipped=${skipped} failed=${failed}`)
|
||||
process.exit(failed > 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useRouterState } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTestStore } from '@/store/test-store'
|
||||
import { UserMenu } from '@/components/UserMenu'
|
||||
import { fetchTestWithParts } from '@/features/toeic/api/test-list-api'
|
||||
|
||||
const ROUTE_TITLES: Record<string, { eyebrow: string; title: string; accent?: string }> = {
|
||||
'/': { eyebrow: 'Học TOEIC cùng AI', title: 'Trang chủ' },
|
||||
@@ -11,6 +13,9 @@ const ROUTE_TITLES: Record<string, { eyebrow: string; title: string; accent?: st
|
||||
'/settings': { eyebrow: 'Tuỳ chỉnh', title: 'Cài đặt' },
|
||||
}
|
||||
|
||||
// Match `/toeic/<numeric-id>` but not `/toeic/session`, `/toeic/result`, `/toeic/part/...`
|
||||
const TEST_DETAIL_RE = /^\/toeic\/(\d+)$/
|
||||
|
||||
function matchRouteLabel(pathname: string) {
|
||||
if (ROUTE_TITLES[pathname]) return ROUTE_TITLES[pathname]
|
||||
const keys = Object.keys(ROUTE_TITLES).sort((a, b) => b.length - a.length)
|
||||
@@ -25,6 +30,15 @@ export function AppHeader() {
|
||||
const { testName, parts, answers } = useTestStore()
|
||||
const pathname = location.pathname
|
||||
|
||||
// Show test title in header when viewing a specific test's part-selection page.
|
||||
const testDetailMatch = pathname.match(TEST_DETAIL_RE)
|
||||
const testId = testDetailMatch ? Number(testDetailMatch[1]) : null
|
||||
const { data: testDetail } = useQuery({
|
||||
queryKey: ['test-detail', testId],
|
||||
queryFn: () => fetchTestWithParts(testId!),
|
||||
enabled: testId !== null,
|
||||
})
|
||||
|
||||
// In-session mode: show test progress instead of route title
|
||||
if (pathname === '/toeic/session') {
|
||||
const totalQuestions = parts.reduce((sum, p) => sum + p.questions.length, 0)
|
||||
@@ -48,7 +62,12 @@ export function AppHeader() {
|
||||
)
|
||||
}
|
||||
|
||||
const { eyebrow, title, accent } = matchRouteLabel(pathname)
|
||||
// Test-detail page: show test title in header
|
||||
const routeLabel = testDetail
|
||||
? { eyebrow: 'Luyện đề TOEIC', title: testDetail.test.title }
|
||||
: matchRouteLabel(pathname)
|
||||
const { eyebrow, title } = routeLabel
|
||||
const accent = 'accent' in routeLabel ? routeLabel.accent : undefined
|
||||
const renderTitle = () => {
|
||||
if (!accent || !title.includes(accent)) return title
|
||||
const [before, after] = title.split(accent)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Check, Home, Minus, RotateCcw, Sparkles, X } from 'lucide-react'
|
||||
import { useTestStore } from '@/store/test-store'
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
@@ -10,11 +10,107 @@ import { XP_REWARDS } from '@/lib/gamification-service'
|
||||
|
||||
const ANSWER_LABELS = ['A', 'B', 'C', 'D']
|
||||
|
||||
function formatTime(s: number) {
|
||||
const m = Math.floor(s / 60)
|
||||
const sec = s % 60
|
||||
if (m === 0) return `${sec}s`
|
||||
return `${m}m ${sec}s`
|
||||
function formatMinSec(s: number) {
|
||||
const mm = String(Math.floor(s / 60)).padStart(2, '0')
|
||||
const ss = String(s % 60).padStart(2, '0')
|
||||
return `${mm}:${ss}`
|
||||
}
|
||||
|
||||
function Ring({ percent, size = 110, stroke = 7, color, bg, children }: {
|
||||
percent: number; size?: number; stroke?: number; color: string; bg: string; children: React.ReactNode
|
||||
}) {
|
||||
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={bg} 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.7s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 grid place-items-center">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HeadlineByPercent({ pct }: { pct: number }) {
|
||||
if (pct >= 80) return <>Xuất <i>sắc!</i></>
|
||||
if (pct >= 60) return <>Khá <i>tốt</i></>
|
||||
return <>Cần <i>ôn thêm</i></>
|
||||
}
|
||||
|
||||
// Hero theme by estimated TOEIC score band — aligned to ETS proficiency levels.
|
||||
// <225 Novice · 225–549 Elementary→Basic · 550–749 Working · ≥750 Professional
|
||||
// Light backgrounds use ink text; dark use paper text.
|
||||
function heroThemeByScore(score: number) {
|
||||
if (score < 225) {
|
||||
return {
|
||||
bg: 'var(--at-warm)',
|
||||
text: 'var(--at-paper)',
|
||||
muted: 'rgba(250,248,243,0.6)',
|
||||
labelMuted: 'rgba(250,248,243,0.5)',
|
||||
ringBg: 'rgba(255,255,255,0.15)',
|
||||
ringColor: 'var(--at-paper)',
|
||||
glow: 'rgba(255,255,255,0.18)',
|
||||
btnSolidBg: 'var(--at-paper)',
|
||||
btnSolidText: 'var(--at-ink)',
|
||||
btnGhostBorder: 'rgba(255,255,255,0.25)',
|
||||
btnGhostHover: 'rgba(255,255,255,0.1)',
|
||||
}
|
||||
}
|
||||
if (score < 550) {
|
||||
return {
|
||||
bg: 'var(--at-warm-soft)',
|
||||
text: 'var(--at-ink)',
|
||||
muted: 'var(--at-mute)',
|
||||
labelMuted: 'var(--at-mute)',
|
||||
ringBg: 'rgba(15,17,20,0.08)',
|
||||
ringColor: 'var(--at-warm)',
|
||||
glow: 'rgba(210,106,59,0.18)',
|
||||
btnSolidBg: 'var(--at-ink)',
|
||||
btnSolidText: 'var(--at-paper)',
|
||||
btnGhostBorder: 'var(--at-line)',
|
||||
btnGhostHover: 'rgba(15,17,20,0.05)',
|
||||
}
|
||||
}
|
||||
if (score < 750) {
|
||||
return {
|
||||
bg: 'var(--at-good-soft)',
|
||||
text: 'var(--at-ink)',
|
||||
muted: 'var(--at-mute)',
|
||||
labelMuted: 'var(--at-mute)',
|
||||
ringBg: 'rgba(15,17,20,0.08)',
|
||||
ringColor: 'var(--at-good)',
|
||||
glow: 'rgba(47,125,74,0.18)',
|
||||
btnSolidBg: 'var(--at-ink)',
|
||||
btnSolidText: 'var(--at-paper)',
|
||||
btnGhostBorder: 'var(--at-line)',
|
||||
btnGhostHover: 'rgba(15,17,20,0.05)',
|
||||
}
|
||||
}
|
||||
return {
|
||||
bg: 'var(--at-good)',
|
||||
text: 'var(--at-paper)',
|
||||
muted: 'rgba(250,248,243,0.65)',
|
||||
labelMuted: 'rgba(250,248,243,0.55)',
|
||||
ringBg: 'rgba(255,255,255,0.15)',
|
||||
ringColor: 'var(--at-paper)',
|
||||
glow: 'rgba(255,255,255,0.18)',
|
||||
btnSolidBg: 'var(--at-paper)',
|
||||
btnSolidText: 'var(--at-ink)',
|
||||
btnGhostBorder: 'rgba(255,255,255,0.25)',
|
||||
btnGhostHover: 'rgba(255,255,255,0.1)',
|
||||
}
|
||||
}
|
||||
|
||||
export function TestResult() {
|
||||
@@ -25,7 +121,6 @@ export function TestResult() {
|
||||
const savedRef = useRef(false)
|
||||
const { mutate: awardActivity } = useAwardActivity()
|
||||
|
||||
// Flatten all questions across parts
|
||||
const allQuestions = parts.flatMap(p => p.questions)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -36,12 +131,12 @@ export function TestResult() {
|
||||
useEffect(() => {
|
||||
if (!user || savedRef.current || allQuestions.length === 0) return
|
||||
savedRef.current = true
|
||||
const correct = allQuestions.filter(q => answers[q.id] === q.correctAnswer).length
|
||||
const correctCount = allQuestions.filter(q => answers[q.id] === q.correctAnswer).length
|
||||
awardActivity({ xp: XP_REWARDS.test })
|
||||
saveTestResult(user.id, {
|
||||
testId,
|
||||
selectedParts: parts.map(p => p.partNumber),
|
||||
score: correct,
|
||||
score: correctCount,
|
||||
total: allQuestions.length,
|
||||
timeUsed,
|
||||
answers: allQuestions.map(q => ({
|
||||
@@ -54,10 +149,15 @@ export function TestResult() {
|
||||
|
||||
if (allQuestions.length === 0) {
|
||||
return (
|
||||
<div className="px-6 py-8 max-w-6xl mx-auto text-center">
|
||||
<p className="text-slate-500 mb-4">Không có dữ liệu bài thi.</p>
|
||||
<button onClick={() => navigate({ to: '/toeic' })}
|
||||
className="bg-blue-600 text-white px-6 py-2.5 rounded-xl font-semibold text-sm hover:bg-blue-700 transition-colors">
|
||||
<div className="px-6 py-10 text-center">
|
||||
<p style={{ color: 'var(--at-mute)' }} className="mb-4">Không có dữ liệu bài thi.</p>
|
||||
<button
|
||||
onClick={() => navigate({ to: '/toeic' })}
|
||||
style={{
|
||||
background: 'var(--at-ink)', color: 'var(--at-paper)',
|
||||
padding: '10px 20px', borderRadius: 10, fontWeight: 600, fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Chọn đề thi
|
||||
</button>
|
||||
</div>
|
||||
@@ -68,109 +168,367 @@ export function TestResult() {
|
||||
const wrong = allQuestions.filter(q => answers[q.id] !== null && answers[q.id] !== undefined && answers[q.id] !== q.correctAnswer).length
|
||||
const skipped = allQuestions.filter(q => answers[q.id] === null || answers[q.id] === undefined).length
|
||||
const total = allQuestions.length
|
||||
const percent = total > 0 ? Math.round((correct / total) * 100) : 0
|
||||
const circumference = 2 * Math.PI * 52
|
||||
const offset = circumference - (percent / 100) * circumference
|
||||
const pct = total > 0 ? Math.round((correct / total) * 100) : 0
|
||||
// TOEIC scaled score 10–990. Linear approximation from raw accuracy;
|
||||
// real ETS scaling is non-linear, but good enough as a practice estimate.
|
||||
const toeicEstimate = Math.round(10 + pct * 9.8)
|
||||
const theme = heroThemeByScore(toeicEstimate)
|
||||
|
||||
return (
|
||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
||||
{/* Score header */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 mb-5">
|
||||
<div className="flex flex-col lg:flex-row items-center gap-6">
|
||||
<div className="flex-shrink-0 relative w-32 h-32">
|
||||
<svg className="w-full h-full -rotate-90" viewBox="0 0 120 120">
|
||||
<circle cx="60" cy="60" r="52" fill="none" stroke="#e2e8f0" strokeWidth="8" />
|
||||
<circle cx="60" cy="60" r="52" fill="none"
|
||||
stroke={percent >= 70 ? '#16a34a' : percent >= 50 ? '#2563eb' : '#dc2626'}
|
||||
strokeWidth="8" strokeLinecap="round"
|
||||
strokeDasharray={circumference} strokeDashoffset={offset}
|
||||
className="transition-all duration-700" />
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-extrabold text-slate-800">{correct}/{total}</span>
|
||||
<span className="text-xs text-slate-400 font-medium">điểm</span>
|
||||
<div className="px-6 lg:px-10 py-10 page-enter">
|
||||
{/* Top row — headline card (dark) + TOEIC estimate card */}
|
||||
<div className="grid gap-5 mb-5" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(380px, 1fr))' }}>
|
||||
{/* Hero result card — themed by score */}
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
style={{
|
||||
background: theme.bg,
|
||||
color: theme.text,
|
||||
borderRadius: 24,
|
||||
padding: 32,
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
transition: 'background 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', top: -40, right: -40, width: 200, height: 200,
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${theme.glow}, transparent 60%)`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="uppercase"
|
||||
style={{
|
||||
fontSize: 10.5, fontWeight: 700, letterSpacing: '0.16em',
|
||||
color: theme.labelMuted, marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
Kết quả
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 text-center lg:text-left">
|
||||
<div className="text-2xl font-extrabold text-slate-800 mb-1">
|
||||
{percent >= 80 ? 'Xuất sắc!' : percent >= 60 ? 'Hoàn thành!' : 'Cố gắng hơn nhé!'}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--at-serif)', fontSize: 40, fontWeight: 400,
|
||||
letterSpacing: '-0.025em', lineHeight: 1.05, marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<HeadlineByPercent pct={pct} />
|
||||
</div>
|
||||
<div className="text-sm text-slate-400 mb-4">{testName}</div>
|
||||
<div className="flex flex-wrap gap-3 justify-center lg:justify-start">
|
||||
{[
|
||||
{ label: 'Đúng', value: correct, cls: 'bg-green-50 border-green-100 text-green-600' },
|
||||
{ label: 'Sai', value: wrong, cls: 'bg-red-50 border-red-100 text-red-600' },
|
||||
{ label: 'Bỏ qua', value: skipped, cls: 'bg-slate-50 border-slate-200 text-slate-500' },
|
||||
{ label: 'Thời gian', value: formatTime(timeUsed), cls: 'bg-blue-50 border-blue-100 text-blue-600' },
|
||||
].map(({ label, value, cls }) => (
|
||||
<div key={label} className={cn('border rounded-xl px-4 py-2 text-center', cls)}>
|
||||
<div className="text-xl font-extrabold">{value}</div>
|
||||
<div className="text-xs text-slate-400">{label}</div>
|
||||
<div style={{ fontSize: 13, color: theme.muted, marginBottom: 24 }}>
|
||||
{testName}
|
||||
</div>
|
||||
<div className="flex items-center gap-7 flex-wrap">
|
||||
<Ring percent={pct} color={theme.ringColor} bg={theme.ringBg}>
|
||||
<div style={{ color: theme.text, textAlign: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--at-serif)', fontSize: 30, fontWeight: 400,
|
||||
letterSpacing: '-0.02em', lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{pct}
|
||||
</div>
|
||||
<div
|
||||
className="uppercase"
|
||||
style={{
|
||||
fontSize: 9.5, opacity: 0.6, letterSpacing: '0.14em',
|
||||
marginTop: 4, fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
điểm
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Ring>
|
||||
<div>
|
||||
<div
|
||||
className="uppercase"
|
||||
style={{
|
||||
fontSize: 10.5, color: theme.labelMuted,
|
||||
letterSpacing: '0.14em', fontWeight: 600, marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
Đúng
|
||||
</div>
|
||||
<div
|
||||
className="tabular-nums"
|
||||
style={{
|
||||
fontFamily: 'var(--at-serif)', fontSize: 28, fontWeight: 400,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{correct}
|
||||
<span style={{ opacity: 0.5, fontSize: 18, fontStyle: 'italic' }}>
|
||||
/{total}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="uppercase"
|
||||
style={{
|
||||
fontSize: 10.5, color: theme.labelMuted,
|
||||
letterSpacing: '0.14em', fontWeight: 600, marginTop: 14, marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
Thời gian
|
||||
</div>
|
||||
<div
|
||||
className="tabular-nums"
|
||||
style={{
|
||||
fontFamily: 'var(--at-mono)', fontSize: 24, fontWeight: 500,
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
{formatMinSec(timeUsed)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => navigate({ to: '/toeic/session' })}
|
||||
className="inline-flex items-center gap-2 transition-[filter] hover:brightness-110"
|
||||
style={{
|
||||
padding: '10px 18px', borderRadius: 10,
|
||||
background: theme.btnSolidBg, color: theme.btnSolidText,
|
||||
fontSize: 13, fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
Làm lại
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { reset(); navigate({ to: '/toeic' }) }}
|
||||
className="inline-flex items-center gap-2 transition-colors"
|
||||
style={{
|
||||
padding: '10px 18px', borderRadius: 10,
|
||||
background: 'transparent', color: theme.text,
|
||||
border: `1px solid ${theme.btnGhostBorder}`,
|
||||
fontSize: 13, fontWeight: 600,
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = theme.btnGhostHover)}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<Home size={14} />
|
||||
Trang chủ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex lg:flex-col gap-3 flex-shrink-0">
|
||||
<button onClick={() => navigate({ to: '/toeic/session' })}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-semibold hover:bg-blue-700 transition-colors">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>replay</span>Làm lại
|
||||
</button>
|
||||
<button onClick={() => { reset(); navigate({ to: '/toeic' }) }}
|
||||
className="flex items-center gap-2 px-5 py-2.5 border border-slate-200 text-slate-600 rounded-xl text-sm font-semibold hover:bg-slate-50 transition-colors">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>home</span>Về trang chủ
|
||||
</button>
|
||||
{/* TOEIC estimate card */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--at-surface)',
|
||||
border: '1px solid var(--at-line)',
|
||||
borderRadius: 24,
|
||||
padding: 32,
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="uppercase"
|
||||
style={{
|
||||
fontFamily: 'var(--at-serif)', fontStyle: 'italic', fontWeight: 500,
|
||||
fontSize: 12, letterSpacing: '0.08em',
|
||||
color: 'var(--at-mute)', marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Dự kiến TOEIC
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--at-serif)', fontSize: 24, fontWeight: 500,
|
||||
letterSpacing: '-0.015em', marginBottom: 4, color: 'var(--at-ink)',
|
||||
}}
|
||||
>
|
||||
Điểm <i style={{ color: 'var(--at-brand)' }}>ước tính</i>
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--at-mute)', marginBottom: 18 }}>
|
||||
Dựa trên bài thi hôm nay
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2 mb-4">
|
||||
<div
|
||||
className="tabular-nums"
|
||||
style={{
|
||||
fontFamily: 'var(--at-serif)', fontSize: 56, fontWeight: 400,
|
||||
letterSpacing: '-0.035em', color: 'var(--at-brand)', lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{toeicEstimate}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14, color: 'var(--at-mute)',
|
||||
fontFamily: 'var(--at-serif)', fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
/ 990
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 6, background: 'var(--at-line-2)',
|
||||
borderRadius: 999, overflow: 'hidden', position: 'relative',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute', inset: '0 auto 0 0',
|
||||
background: 'var(--at-brand)',
|
||||
width: `${(toeicEstimate / 990) * 100}%`,
|
||||
borderRadius: 999,
|
||||
transition: 'width 0.7s cubic-bezier(0.2,0.7,0.2,1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between" style={{ fontSize: 11, color: 'var(--at-mute)' }}>
|
||||
<span>0</span>
|
||||
<span>Mục tiêu: 850</span>
|
||||
<span>990</span>
|
||||
</div>
|
||||
<div style={{ height: 1, background: 'var(--at-line)', margin: '18px 0' }} />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Stat label="Đúng" value={correct} color="var(--at-good)" />
|
||||
<Stat label="Sai" value={wrong} color="var(--at-bad)" />
|
||||
<Stat label="Bỏ qua" value={skipped} color="var(--at-mute)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Answer review grouped by part */}
|
||||
{/* Per-part review */}
|
||||
{parts.map(part => (
|
||||
<div key={part.partNumber} className="bg-white rounded-2xl border border-slate-200 p-6 mb-4">
|
||||
<h2 className="text-base font-bold text-slate-800 mb-4">Part {part.partNumber} — {part.partName}</h2>
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
key={part.partNumber}
|
||||
style={{
|
||||
background: 'var(--at-surface)',
|
||||
border: '1px solid var(--at-line)',
|
||||
borderRadius: 18,
|
||||
padding: 24,
|
||||
marginBottom: 16,
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div
|
||||
className="uppercase"
|
||||
style={{
|
||||
fontFamily: 'var(--at-serif)', fontStyle: 'italic', fontWeight: 500,
|
||||
fontSize: 12, letterSpacing: '0.08em', color: 'var(--at-brand)',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Part {part.partNumber}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--at-serif)', fontSize: 22, fontWeight: 400,
|
||||
letterSpacing: '-0.015em', color: 'var(--at-ink)',
|
||||
}}
|
||||
>
|
||||
{part.partName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip kind="good" icon={<Check size={10} strokeWidth={3} />}>
|
||||
{part.questions.filter(q => answers[q.id] === q.correctAnswer).length} đúng
|
||||
</Chip>
|
||||
<Chip kind="bad" icon={<X size={10} strokeWidth={3} />}>
|
||||
{part.questions.filter(q => answers[q.id] !== null && answers[q.id] !== undefined && answers[q.id] !== q.correctAnswer).length} sai
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{part.questions.map((q, i) => {
|
||||
const userAnswer = answers[q.id] ?? null
|
||||
const isCorrect = userAnswer === q.correctAnswer
|
||||
const isSkipped = userAnswer === null || userAnswer === undefined
|
||||
const statusColor = isCorrect
|
||||
? 'var(--at-good)'
|
||||
: isSkipped
|
||||
? 'var(--at-mute)'
|
||||
: 'var(--at-bad)'
|
||||
const statusBg = isCorrect
|
||||
? 'var(--at-good-soft)'
|
||||
: isSkipped
|
||||
? 'var(--at-line-2)'
|
||||
: 'var(--at-bad-soft)'
|
||||
return (
|
||||
<div key={q.id} className={cn(
|
||||
'rounded-xl border p-4',
|
||||
isCorrect ? 'border-green-100 bg-green-50/50' : isSkipped ? 'border-slate-100 bg-slate-50/50' : 'border-red-100 bg-red-50/50',
|
||||
)}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={cn(
|
||||
'w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5',
|
||||
isCorrect ? 'bg-green-600 text-white' : isSkipped ? 'bg-slate-400 text-white' : 'bg-red-600 text-white',
|
||||
)}>{i + 1}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{q.text && <p className="text-sm font-medium text-slate-800 mb-2">{q.text}</p>}
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{q.options.map((opt, j) => (
|
||||
<span key={j} className={cn(
|
||||
'text-xs px-2.5 py-1 rounded-lg font-medium',
|
||||
j === q.correctAnswer ? 'bg-green-100 text-green-700 border border-green-200'
|
||||
: j === userAnswer && !isCorrect ? 'bg-red-100 text-red-700 border border-red-200 line-through'
|
||||
: 'bg-slate-100 text-slate-500',
|
||||
)}>
|
||||
{ANSWER_LABELS[j]}. {opt}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{q.explanation && (
|
||||
<p className="text-xs text-slate-500 bg-white rounded-lg px-3 py-2 border border-slate-100">
|
||||
<span className="font-semibold text-slate-600">Giải thích: </span>{q.explanation}
|
||||
</p>
|
||||
<div
|
||||
key={q.id}
|
||||
className="flex items-start gap-3"
|
||||
style={{
|
||||
padding: '14px 0',
|
||||
borderTop: i === 0 ? 'none' : '1px solid var(--at-line)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="grid place-items-center flex-shrink-0"
|
||||
style={{
|
||||
width: 28, height: 28, borderRadius: 8,
|
||||
background: statusBg, color: statusColor,
|
||||
}}
|
||||
>
|
||||
{isCorrect
|
||||
? <Check size={16} strokeWidth={2.5} />
|
||||
: isSkipped
|
||||
? <Minus size={16} strokeWidth={2.5} />
|
||||
: <X size={16} strokeWidth={2.5} />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="mb-1.5"
|
||||
style={{
|
||||
fontSize: 13.5, fontWeight: 500, color: 'var(--at-ink)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--at-mute)', fontFamily: 'var(--at-serif)', fontStyle: 'italic' }}>
|
||||
Câu {i + 1}.{' '}
|
||||
</span>
|
||||
{q.text || <span style={{ color: 'var(--at-mute-2)' }}>— (nghe/nhìn)</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--at-mute)', lineHeight: 1.5 }}>
|
||||
Đáp án đúng:{' '}
|
||||
<b style={{ color: 'var(--at-good)' }}>
|
||||
{ANSWER_LABELS[q.correctAnswer]}. {q.options[q.correctAnswer]}
|
||||
</b>
|
||||
{!isCorrect && !isSkipped && userAnswer !== null && (
|
||||
<>
|
||||
{' · '}Bạn chọn:{' '}
|
||||
<b style={{ color: 'var(--at-bad)' }}>
|
||||
{ANSWER_LABELS[userAnswer]}. {q.options[userAnswer]}
|
||||
</b>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex-shrink-0">
|
||||
{isCorrect
|
||||
? <span className="material-symbols-outlined text-green-600" style={{ fontSize: 20 }}>check_circle</span>
|
||||
: isSkipped
|
||||
? <span className="material-symbols-outlined text-slate-400" style={{ fontSize: 20 }}>remove_circle</span>
|
||||
: <span className="material-symbols-outlined text-red-500" style={{ fontSize: 20 }}>cancel</span>}
|
||||
</span>
|
||||
{q.explanation && (
|
||||
<div
|
||||
className="mt-2"
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
color: 'var(--at-ink-2)',
|
||||
background: 'var(--at-paper-2)',
|
||||
border: '1px solid var(--at-line)',
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--at-serif)', fontStyle: 'italic',
|
||||
color: 'var(--at-brand)', marginRight: 6,
|
||||
}}
|
||||
>
|
||||
<Sparkles size={10} className="inline -mt-0.5 mr-1" />
|
||||
Giải thích
|
||||
</span>
|
||||
{q.explanation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -181,3 +539,48 @@ export function TestResult() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="uppercase"
|
||||
style={{
|
||||
fontSize: 10.5, color: 'var(--at-mute)',
|
||||
letterSpacing: '0.1em', fontWeight: 600, marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className="tabular-nums"
|
||||
style={{
|
||||
fontFamily: 'var(--at-serif)', fontSize: 22, fontWeight: 400,
|
||||
letterSpacing: '-0.02em', color,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({ kind, icon, children }: {
|
||||
kind: 'good' | 'bad'; icon: React.ReactNode; children: React.ReactNode
|
||||
}) {
|
||||
const bg = kind === 'good' ? 'var(--at-good-soft)' : 'var(--at-bad-soft)'
|
||||
const fg = kind === 'good' ? 'var(--at-good-ink)' : 'var(--at-bad)'
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
style={{
|
||||
padding: '5px 10px', borderRadius: 999,
|
||||
background: bg, color: fg,
|
||||
fontSize: 11, fontWeight: 600, letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Play } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTestStore } from '@/store/test-store'
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||
@@ -9,82 +10,361 @@ import { TestSessionFooter } from './TestSessionFooter'
|
||||
import type { Question } from '@/types'
|
||||
|
||||
const ANSWER_LABELS = ['A', 'B', 'C', 'D']
|
||||
const LETTER_PLACEHOLDER_RE =
|
||||
/^(Statement|Response|Choice)\s+[A-D]$/i
|
||||
|
||||
function QuestionCard({
|
||||
question, globalNum, answer, onSelect,
|
||||
interface QuestionGroup {
|
||||
groupId: number
|
||||
questions: Question[]
|
||||
passageText?: string
|
||||
audioUrl?: string
|
||||
imageUrl?: string
|
||||
}
|
||||
|
||||
function groupByGroupId(questions: Question[]): QuestionGroup[] {
|
||||
const groups: QuestionGroup[] = []
|
||||
for (const q of questions) {
|
||||
const last = groups[groups.length - 1]
|
||||
if (last && last.groupId === q.groupId) {
|
||||
last.questions.push(q)
|
||||
} else {
|
||||
groups.push({
|
||||
groupId: q.groupId,
|
||||
questions: [q],
|
||||
passageText: q.passageText,
|
||||
audioUrl: q.audioUrl,
|
||||
imageUrl: q.imageUrl,
|
||||
})
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function PassageBlock({ group }: { group: QuestionGroup }) {
|
||||
const { audioUrl, imageUrl, passageText } = group
|
||||
|
||||
if (!audioUrl && !imageUrl && !passageText) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--at-paper-2)',
|
||||
borderRadius: 10,
|
||||
padding: '18px 20px',
|
||||
fontFamily: 'var(--at-mono)',
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.7,
|
||||
color: 'var(--at-ink-2)',
|
||||
marginBottom: 24,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{audioUrl && (
|
||||
<div className="flex items-center gap-3" style={{ marginBottom: passageText || imageUrl ? 12 : 0 }}>
|
||||
<audio
|
||||
controls
|
||||
src={audioUrl}
|
||||
className="w-full"
|
||||
style={{ height: 32 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imageUrl && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: audioUrl ? 12 : 0,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid var(--at-line)',
|
||||
aspectRatio: '16 / 9',
|
||||
background: '#d8d4c9',
|
||||
}}
|
||||
>
|
||||
<img src={imageUrl} alt="" className="w-full h-full object-contain" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!audioUrl && !imageUrl && (
|
||||
// No audio/image → text-only passage (Part 6/7)
|
||||
<div>{passageText}</div>
|
||||
)}
|
||||
|
||||
{(audioUrl || imageUrl) && passageText && (
|
||||
<div style={{ marginTop: 10, fontSize: 12, color: 'var(--at-mute)' }}>{passageText}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AudioPlaceholder({ label }: { label: string }) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
style={{
|
||||
background: 'var(--at-surface)',
|
||||
border: '1px solid var(--at-line)',
|
||||
borderRadius: 10,
|
||||
padding: '14px 16px',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="grid place-items-center"
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 999,
|
||||
background: 'var(--at-brand)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Play size={14} fill="white" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--at-serif)',
|
||||
fontStyle: 'italic',
|
||||
fontSize: 12,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--at-mute)',
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 4,
|
||||
background: 'var(--at-line)',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'block', width: 0, height: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuestionBlock({
|
||||
question, globalNum, answer, onSelect, isFirst, registerRef,
|
||||
}: {
|
||||
question: Question
|
||||
globalNum: number
|
||||
answer: number | null
|
||||
onSelect: (idx: number) => void
|
||||
isFirst: boolean
|
||||
registerRef: (el: HTMLDivElement | null) => 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
|
||||
ref={registerRef}
|
||||
data-qid={question.id}
|
||||
style={{
|
||||
padding: '16px 0',
|
||||
borderTop: isFirst ? 'none' : '1px solid var(--at-line)',
|
||||
paddingTop: isFirst ? 4 : 16,
|
||||
scrollMarginTop: 24,
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<span
|
||||
className="inline-block"
|
||||
style={{
|
||||
background: 'var(--at-brand-soft)',
|
||||
color: 'var(--at-brand)',
|
||||
padding: '4px 10px',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Câu {globalNum}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{question.text && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: 15,
|
||||
lineHeight: 1.5,
|
||||
color: 'var(--at-ink)',
|
||||
marginBottom: 14,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{question.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
{question.options.map((opt, i) => {
|
||||
const letter = ANSWER_LABELS[i]
|
||||
const isSelected = answer === i
|
||||
const hideText = !question.text && LETTER_PLACEHOLDER_RE.test(opt)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onSelect(i)}
|
||||
className={cn('flex items-center gap-3 text-left transition-all')}
|
||||
style={{
|
||||
padding: hideText ? '14px 18px' : '12px 16px',
|
||||
minHeight: hideText ? 44 : undefined,
|
||||
background: isSelected ? 'var(--at-brand-soft)' : 'var(--at-surface)',
|
||||
border: `1px solid ${isSelected ? 'var(--at-brand)' : 'var(--at-line)'}`,
|
||||
borderRadius: 10,
|
||||
fontSize: 14,
|
||||
color: 'var(--at-ink)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="grid place-items-center flex-shrink-0"
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 999,
|
||||
background: isSelected ? 'var(--at-brand)' : 'var(--at-surface)',
|
||||
border: `1px solid ${isSelected ? 'var(--at-brand)' : 'var(--at-line)'}`,
|
||||
color: isSelected ? '#fff' : 'var(--at-ink-2)',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
{!hideText && <span>{opt}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupCard({
|
||||
group, globalOffset, startIndex, answers, onSelect, registerQuestionRef,
|
||||
}: {
|
||||
group: QuestionGroup
|
||||
globalOffset: number
|
||||
startIndex: number
|
||||
answers: Record<number, number | null>
|
||||
onSelect: (qid: number, idx: number) => void
|
||||
registerQuestionRef: (qid: number, el: HTMLDivElement | null) => void
|
||||
}) {
|
||||
const hasAudio = !!group.audioUrl
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--at-surface)',
|
||||
border: '1px solid var(--at-line)',
|
||||
borderRadius: 12,
|
||||
padding: '24px 28px',
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
{hasAudio && !group.imageUrl && !group.passageText && (
|
||||
<AudioPlaceholder label={`Audio · Câu ${globalOffset + startIndex + 1}`} />
|
||||
)}
|
||||
<PassageBlock group={group} />
|
||||
|
||||
{group.questions.map((q, i) => (
|
||||
<QuestionBlock
|
||||
key={q.id}
|
||||
question={q}
|
||||
globalNum={globalOffset + startIndex + i + 1}
|
||||
answer={answers[q.id] ?? null}
|
||||
onSelect={(idx) => onSelect(q.id, idx)}
|
||||
isFirst={i === 0 && !hasAudio && !group.imageUrl && !group.passageText}
|
||||
registerRef={(el) => registerQuestionRef(q.id, el)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TestSession() {
|
||||
const navigate = useNavigate()
|
||||
const { testName, parts, currentPartIndex, answers, totalSeconds, setAnswer, setCurrentPart, submitExam } = useTestStore()
|
||||
const {
|
||||
testName, parts, currentPartIndex, answers, totalSeconds,
|
||||
setAnswer, setCurrentPart, submitExam,
|
||||
} = useTestStore()
|
||||
const { isAuthenticated, isLoading } = useRequireAuth()
|
||||
const [timeLeft, setTimeLeft] = useState(() => totalSeconds > 0 ? totalSeconds : -1)
|
||||
const [timeLeft, setTimeLeft] = useState(() => (totalSeconds > 0 ? totalSeconds : -1))
|
||||
const [timeUsed, setTimeUsed] = useState(0)
|
||||
|
||||
// Map of questionId → DOM node, so sidebar clicks can scroll to a specific question.
|
||||
const questionRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||
// When jumping to a question in another part, we switch part first, then scroll
|
||||
// after the new part renders. `pendingScrollQid` holds the target until mount.
|
||||
const [pendingScrollQid, setPendingScrollQid] = useState<number | null>(null)
|
||||
|
||||
const registerQuestionRef = useCallback((qid: number, el: HTMLDivElement | null) => {
|
||||
if (el) questionRefs.current.set(qid, el)
|
||||
else questionRefs.current.delete(qid)
|
||||
}, [])
|
||||
|
||||
function scrollToQuestion(qid: number) {
|
||||
const el = questionRefs.current.get(qid)
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
function jumpToQuestion(qid: number) {
|
||||
// Find part that owns this question.
|
||||
const targetPartIndex = parts.findIndex(p => p.questions.some(q => q.id === qid))
|
||||
if (targetPartIndex === -1) return
|
||||
if (targetPartIndex !== currentPartIndex) {
|
||||
setCurrentPart(targetPartIndex)
|
||||
setPendingScrollQid(qid)
|
||||
} else {
|
||||
scrollToQuestion(qid)
|
||||
}
|
||||
}
|
||||
|
||||
// After a part change, if we have a pending scroll target, run it.
|
||||
useEffect(() => {
|
||||
if (pendingScrollQid === null) return
|
||||
// Wait one frame so refs are registered for the newly mounted part.
|
||||
const raf = requestAnimationFrame(() => {
|
||||
scrollToQuestion(pendingScrollQid)
|
||||
setPendingScrollQid(null)
|
||||
})
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [currentPartIndex, pendingScrollQid])
|
||||
|
||||
const totalQuestions = useMemo(
|
||||
() => parts.reduce((sum, p) => sum + p.questions.length, 0),
|
||||
[parts],
|
||||
)
|
||||
const answeredCount = useMemo(
|
||||
() => Object.values(answers).filter((v) => v !== null && v !== undefined).length,
|
||||
[answers],
|
||||
)
|
||||
|
||||
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 })
|
||||
setTimeLeft((t) => {
|
||||
if (t <= 1) {
|
||||
clearInterval(id)
|
||||
handleSubmit()
|
||||
return 0
|
||||
}
|
||||
return t - 1
|
||||
})
|
||||
} else {
|
||||
setTimeUsed(t => t + 1)
|
||||
setTimeUsed((t) => t + 1)
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearInterval(id)
|
||||
@@ -99,16 +379,25 @@ export function TestSession() {
|
||||
|
||||
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
|
||||
|
||||
const groups = groupByGroupId(currentPart.questions)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col" style={{ height: 'calc(100vh - var(--app-header-height, 0px))' }}>
|
||||
<div
|
||||
className="flex flex-col"
|
||||
style={{
|
||||
height: 'calc(100vh - var(--app-header-height, 0px))',
|
||||
background: 'var(--at-paper-2)',
|
||||
}}
|
||||
>
|
||||
<TestSessionHeader
|
||||
testName={testName}
|
||||
timeLeft={timeLeft}
|
||||
timeUsed={timeUsed}
|
||||
totalQuestions={totalQuestions}
|
||||
answeredCount={answeredCount}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
@@ -118,23 +407,85 @@ export function TestSession() {
|
||||
currentPartIndex={currentPartIndex}
|
||||
answers={answers}
|
||||
onSelectPart={setCurrentPart}
|
||||
onSelectQuestion={jumpToQuestion}
|
||||
/>
|
||||
|
||||
{/* 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)}
|
||||
/>
|
||||
))}
|
||||
<main
|
||||
className="flex-1 overflow-y-auto"
|
||||
style={{ padding: '24px 32px 80px' }}
|
||||
>
|
||||
<div className="mx-auto w-full" style={{ maxWidth: 880 }}>
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--at-serif)',
|
||||
fontStyle: 'italic',
|
||||
fontSize: 12,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--at-mute)',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Đang làm
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--at-serif)',
|
||||
fontSize: 22,
|
||||
fontWeight: 400,
|
||||
letterSpacing: '-0.015em',
|
||||
color: 'var(--at-ink)',
|
||||
}}
|
||||
>
|
||||
Part {currentPart.partNumber}:{' '}
|
||||
<i style={{ fontStyle: 'italic', color: 'var(--at-ink-2)' }}>
|
||||
{currentPart.partName}
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--at-surface)',
|
||||
border: '1px dashed var(--at-line)',
|
||||
borderRadius: 12,
|
||||
padding: 40,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--at-serif)',
|
||||
fontSize: 18,
|
||||
fontStyle: 'italic',
|
||||
color: 'var(--at-mute)',
|
||||
}}
|
||||
>
|
||||
Part này chưa có dữ liệu.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map((group) => {
|
||||
let startIndex = 0
|
||||
for (const g of groups) {
|
||||
if (g === group) break
|
||||
startIndex += g.questions.length
|
||||
}
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.groupId}
|
||||
group={group}
|
||||
globalOffset={globalOffset}
|
||||
startIndex={startIndex}
|
||||
answers={answers}
|
||||
onSelect={setAnswer}
|
||||
registerQuestionRef={registerQuestionRef}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
currentPartIndex: number
|
||||
totalParts: number
|
||||
@@ -6,30 +8,86 @@ interface Props {
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
export function TestSessionFooter({ currentPartIndex, totalParts, currentPartName, onPrev, onNext }: Props) {
|
||||
export function TestSessionFooter({
|
||||
currentPartIndex, totalParts, currentPartName, onPrev, onNext,
|
||||
}: Props) {
|
||||
const isFirst = currentPartIndex === 0
|
||||
const isLast = currentPartIndex === totalParts - 1
|
||||
|
||||
return (
|
||||
<footer className="h-14 flex items-center justify-between px-5 bg-white border-t border-slate-200 flex-shrink-0 z-10">
|
||||
<footer
|
||||
className="grid items-center flex-shrink-0"
|
||||
style={{
|
||||
gridTemplateColumns: '1fr auto 1fr',
|
||||
padding: '12px 24px',
|
||||
gap: 16,
|
||||
background: 'var(--at-surface)',
|
||||
borderTop: '1px solid var(--at-line)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={currentPartIndex === 0}
|
||||
className="flex items-center gap-1.5 px-4 py-2 border border-slate-200 rounded-xl text-sm font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
disabled={isFirst}
|
||||
className="inline-flex items-center gap-1.5 transition-colors disabled:cursor-not-allowed"
|
||||
style={{
|
||||
justifySelf: 'start',
|
||||
padding: '8px 14px',
|
||||
borderRadius: 8,
|
||||
background: 'var(--at-surface)',
|
||||
border: '1px solid var(--at-line)',
|
||||
color: 'var(--at-ink-2)',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
opacity: isFirst ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>arrow_back</span>
|
||||
<ArrowLeft size={14} />
|
||||
Part trước
|
||||
</button>
|
||||
|
||||
<span className="text-sm font-bold text-slate-700">
|
||||
Part {currentPartIndex + 1} / {totalParts}
|
||||
<span className="text-slate-400 font-normal ml-1.5">— {currentPartName}</span>
|
||||
</span>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
style={{
|
||||
fontFamily: 'var(--at-serif)',
|
||||
fontStyle: 'italic',
|
||||
fontSize: 13,
|
||||
color: 'var(--at-ink-2)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background: 'var(--at-paper-2)',
|
||||
padding: '4px 10px',
|
||||
borderRadius: 6,
|
||||
fontWeight: 600,
|
||||
fontStyle: 'normal',
|
||||
fontFamily: 'var(--at-sans)',
|
||||
color: 'var(--at-ink)',
|
||||
}}
|
||||
>
|
||||
Part {currentPartIndex + 1} / {totalParts}
|
||||
</span>
|
||||
<span style={{ color: 'var(--at-mute)' }}>— {currentPartName}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={currentPartIndex === totalParts - 1}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 text-white rounded-xl text-sm font-semibold hover:bg-blue-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
disabled={isLast}
|
||||
className="inline-flex items-center gap-1.5 transition-[filter] hover:brightness-110 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
justifySelf: 'end',
|
||||
padding: '8px 14px',
|
||||
borderRadius: 8,
|
||||
background: 'var(--at-brand)',
|
||||
border: '1px solid var(--at-brand)',
|
||||
color: 'white',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
opacity: isLast ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
Part tiếp theo
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>arrow_forward</span>
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
testName: string
|
||||
timeLeft: number // seconds remaining; -1 = no limit (count-up mode)
|
||||
timeUsed: number // seconds elapsed (used when no limit)
|
||||
timeLeft: number // seconds remaining; -1 = no limit (count-up mode)
|
||||
timeUsed: number // seconds elapsed (used when no limit)
|
||||
totalQuestions: number
|
||||
answeredCount: number
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
@@ -11,31 +13,89 @@ function formatTime(s: number): string {
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const sec = s % 60
|
||||
if (h > 0) return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function TestSessionHeader({ testName, timeLeft, timeUsed, onSubmit }: Props) {
|
||||
export function TestSessionHeader({
|
||||
testName, timeLeft, timeUsed, totalQuestions, answeredCount, onSubmit,
|
||||
}: Props) {
|
||||
const isUnlimited = timeLeft === -1
|
||||
const displaySeconds = isUnlimited ? timeUsed : timeLeft
|
||||
const isUrgent = !isUnlimited && timeLeft < 300 // last 5 min
|
||||
const isUrgent = !isUnlimited && timeLeft < 300
|
||||
|
||||
return (
|
||||
<header className="h-14 flex items-center justify-between px-5 bg-white border-b border-slate-200 shadow-sm flex-shrink-0 z-10">
|
||||
<span className="font-bold text-slate-800 text-sm truncate max-w-xs">{testName}</span>
|
||||
<header
|
||||
className="grid items-center flex-shrink-0"
|
||||
style={{
|
||||
gridTemplateColumns: '1fr auto 1fr',
|
||||
padding: '12px 24px',
|
||||
gap: 16,
|
||||
background: 'var(--at-surface)',
|
||||
borderBottom: '1px solid var(--at-line)',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5" style={{ justifySelf: 'start' }}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--at-serif)',
|
||||
fontStyle: 'italic',
|
||||
fontWeight: 400,
|
||||
fontSize: 11,
|
||||
letterSpacing: '0.04em',
|
||||
color: 'var(--at-mute)',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Phiên thi
|
||||
</span>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--at-ink)' }}>
|
||||
{testName} ·{' '}
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--at-brand)',
|
||||
fontStyle: 'italic',
|
||||
fontFamily: 'var(--at-serif)',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{answeredCount}/{totalQuestions}
|
||||
</span>{' '}
|
||||
câu
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className={cn(
|
||||
'text-2xl font-extrabold tabular-nums',
|
||||
isUrgent ? 'text-red-600 timer-urgent' : 'text-blue-600',
|
||||
)}>
|
||||
{isUnlimited ? <span className="text-slate-400 text-base">∞</span> : formatTime(displaySeconds)}
|
||||
<span
|
||||
className="tabular-nums text-center"
|
||||
style={{
|
||||
fontFamily: 'var(--at-mono)',
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.02em',
|
||||
color: isUrgent ? 'var(--at-bad)' : 'var(--at-brand)',
|
||||
animation: isUrgent ? 'pulse 1s infinite' : undefined,
|
||||
}}
|
||||
>
|
||||
{isUnlimited ? (
|
||||
<span style={{ color: 'var(--at-mute-2)', fontSize: 16 }}>∞</span>
|
||||
) : (
|
||||
formatTime(displaySeconds)
|
||||
)}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-red-600 text-white rounded-xl text-sm font-bold hover:bg-red-700 transition-colors"
|
||||
className="inline-flex items-center gap-1.5 transition-[filter] hover:brightness-110"
|
||||
style={{
|
||||
justifySelf: 'end',
|
||||
padding: '8px 16px',
|
||||
borderRadius: 8,
|
||||
background: '#e53935',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>send</span>
|
||||
<Check size={14} strokeWidth={2.5} />
|
||||
Nộp bài
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -6,60 +6,89 @@ interface Props {
|
||||
currentPartIndex: number
|
||||
answers: Record<number, number | null>
|
||||
onSelectPart: (index: number) => void
|
||||
onSelectQuestion: (questionId: number) => void
|
||||
}
|
||||
|
||||
export function TestSessionSidebar({ parts, currentPartIndex, answers, onSelectPart }: Props) {
|
||||
export function TestSessionSidebar({
|
||||
parts, currentPartIndex, answers, onSelectPart, onSelectQuestion,
|
||||
}: Props) {
|
||||
// Global question offset per part for sequential numbering
|
||||
let offset = 0
|
||||
const partOffsets: number[] = parts.map(p => {
|
||||
const partOffsets: number[] = parts.map((p) => {
|
||||
const o = offset
|
||||
offset += p.questions.length
|
||||
return o
|
||||
})
|
||||
|
||||
return (
|
||||
<aside className="w-60 flex-shrink-0 bg-white border-r border-slate-200 overflow-y-auto">
|
||||
<div className="p-3 border-b border-slate-100">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Question Map</span>
|
||||
<aside
|
||||
className="overflow-y-auto"
|
||||
style={{
|
||||
width: 240,
|
||||
flexShrink: 0,
|
||||
background: 'var(--at-surface)',
|
||||
borderRight: '1px solid var(--at-line)',
|
||||
padding: '18px 16px 40px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="uppercase"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
letterSpacing: '0.08em',
|
||||
color: 'var(--at-mute)',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
Question Map
|
||||
</div>
|
||||
|
||||
{parts.map((part, partIdx) => {
|
||||
const isCurrent = partIdx === currentPartIndex
|
||||
|
||||
return (
|
||||
<div key={part.partNumber} className="px-3 pt-3 pb-1">
|
||||
{/* Part label — click to switch */}
|
||||
<div key={part.partNumber} style={{ marginBottom: 18 }}>
|
||||
<button
|
||||
onClick={() => onSelectPart(partIdx)}
|
||||
className={cn(
|
||||
'w-full text-left text-[10px] font-bold uppercase tracking-widest mb-2 px-1 py-0.5 rounded transition-colors',
|
||||
isCurrent ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600',
|
||||
)}
|
||||
className="text-left w-full"
|
||||
style={{
|
||||
fontFamily: 'var(--at-serif)',
|
||||
fontStyle: 'italic',
|
||||
fontWeight: 400,
|
||||
fontSize: 13,
|
||||
color: isCurrent ? 'var(--at-brand)' : 'var(--at-ink-2)',
|
||||
marginBottom: 8,
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
Part {part.partNumber}
|
||||
</button>
|
||||
|
||||
{/* Question number grid */}
|
||||
<div className="grid grid-cols-5 gap-1.5 mb-2">
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{part.questions.map((q, qIdx) => {
|
||||
const globalNum = partOffsets[partIdx] + qIdx + 1
|
||||
const answered = answers[q.id] !== null && answers[q.id] !== undefined
|
||||
// Soft brand-tinted border for all unanswered cells.
|
||||
const unansweredBorder = 'rgba(84, 114, 158, 0.35)'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={q.id}
|
||||
onClick={() => onSelectPart(partIdx)}
|
||||
onClick={() => onSelectQuestion(q.id)}
|
||||
title={`Câu ${globalNum}`}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center text-[11px] font-semibold transition-all',
|
||||
isCurrent && answered
|
||||
? 'bg-blue-600 text-white'
|
||||
: !isCurrent && answered
|
||||
? 'bg-blue-400 text-white'
|
||||
: isCurrent
|
||||
? 'border-2 border-blue-600 text-blue-600'
|
||||
: 'border-2 border-slate-200 text-slate-400 hover:border-slate-300',
|
||||
'tabular-nums transition-all aspect-square',
|
||||
'hover:border-[var(--at-ink-2)]',
|
||||
)}
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${answered ? 'var(--at-brand)' : unansweredBorder}`,
|
||||
background: answered ? 'var(--at-brand)' : 'var(--at-surface)',
|
||||
color: answered ? '#fff' : 'var(--at-ink-2)',
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{globalNum}
|
||||
</button>
|
||||
@@ -69,18 +98,6 @@ export function TestSessionSidebar({ parts, currentPartIndex, answers, onSelectP
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="px-4 py-3 border-t border-slate-100 mt-1">
|
||||
<div className="flex flex-col gap-1.5 text-[10px] text-slate-400">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-4 h-4 rounded bg-blue-600 inline-block" />Đã trả lời
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-4 h-4 rounded border-2 border-slate-200 inline-block" />Chưa làm
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' }}>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 [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">Mô 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function ToeicTestList() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
|
||||
<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-10">
|
||||
<div>
|
||||
@@ -25,11 +25,11 @@ export function ToeicTestList() {
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div className="grid gap-6" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))' }}>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl h-44 animate-pulse"
|
||||
className="rounded-2xl h-64 animate-pulse"
|
||||
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||
/>
|
||||
))}
|
||||
@@ -59,47 +59,80 @@ export function ToeicTestList() {
|
||||
)}
|
||||
|
||||
{tests.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<div className="grid gap-6" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))' }}>
|
||||
{tests.map((test) => (
|
||||
<div
|
||||
key={test.id}
|
||||
className="rounded-2xl p-6 flex flex-col transition-all hover:-translate-y-1"
|
||||
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||
className="rounded-3xl flex flex-col transition-all hover:-translate-y-1 hover:shadow-[0_20px_40px_-16px_rgba(15,17,20,0.12)]"
|
||||
style={{
|
||||
background: 'var(--at-surface)',
|
||||
border: '1px solid var(--at-line)',
|
||||
padding: 32,
|
||||
}}
|
||||
>
|
||||
{test.categoryName && (
|
||||
<span className="at-chip at-chip-brand self-start mb-3">
|
||||
<span className="at-chip at-chip-brand self-start mb-5">
|
||||
<span className="at-chip-dot" />
|
||||
{test.categoryName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<h3
|
||||
className="at-serif text-[20px] leading-[1.2] tracking-tight mb-2"
|
||||
style={{ color: 'var(--at-ink)', fontWeight: 500 }}
|
||||
className="at-serif tracking-tight mb-3"
|
||||
style={{
|
||||
color: 'var(--at-ink)',
|
||||
fontWeight: 500,
|
||||
fontSize: 28,
|
||||
lineHeight: 1.15,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{test.title}
|
||||
</h3>
|
||||
{test.description && (
|
||||
<p className="text-xs leading-[1.5] mb-3 line-clamp-2" style={{ color: 'var(--at-mute)' }}>
|
||||
<p
|
||||
className="line-clamp-2 mb-6"
|
||||
style={{ color: 'var(--at-mute)', fontSize: 14, lineHeight: 1.55 }}
|
||||
>
|
||||
{test.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs mt-auto mb-4" style={{ color: 'var(--at-mute)' }}>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 13 }}>list_alt</span>
|
||||
<b className="tabular-nums" style={{ color: 'var(--at-ink)' }}>{test.totalQuestions}</b> câu
|
||||
<div
|
||||
className="flex items-center gap-6 mt-auto mb-6"
|
||||
style={{ color: 'var(--at-mute)', fontSize: 13 }}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>list_alt</span>
|
||||
<b
|
||||
className="tabular-nums"
|
||||
style={{ color: 'var(--at-ink)', fontSize: 16, fontWeight: 700 }}
|
||||
>
|
||||
{test.totalQuestions}
|
||||
</b>
|
||||
câu
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 13 }}>timer</span>
|
||||
<b className="tabular-nums" style={{ color: 'var(--at-ink)' }}>{test.durationMinutes}</b> phút
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>timer</span>
|
||||
<b
|
||||
className="tabular-nums"
|
||||
style={{ color: 'var(--at-ink)', fontSize: 16, fontWeight: 700 }}
|
||||
>
|
||||
{test.durationMinutes}
|
||||
</b>
|
||||
phút
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => navigate({ to: '/toeic/$testId', params: { testId: String(test.id) } })}
|
||||
className="w-full py-2.5 rounded-xl text-[13px] font-semibold transition-opacity hover:opacity-90"
|
||||
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
|
||||
className="w-full rounded-xl font-semibold transition-opacity hover:opacity-90"
|
||||
style={{
|
||||
background: 'var(--at-ink)',
|
||||
color: 'var(--at-paper)',
|
||||
padding: '14px 20px',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Bắt đầu
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createRootRoute, Outlet } from '@tanstack/react-router'
|
||||
import { createRootRoute, Outlet, useRouterState } from '@tanstack/react-router'
|
||||
import { useEffect } from 'react'
|
||||
import { Sidebar } from '@/components/layout/Sidebar'
|
||||
import { AppHeader } from '@/components/layout/AppHeader'
|
||||
@@ -10,13 +10,27 @@ export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
})
|
||||
|
||||
// Routes that own the full viewport — hide sidebar, top header, and mobile nav.
|
||||
const FULLSCREEN_ROUTES = new Set(['/toeic/session'])
|
||||
|
||||
function RootLayout() {
|
||||
const initialize = useAuthStore((s) => s.initialize)
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const isFullscreen = FULLSCREEN_ROUTES.has(pathname)
|
||||
|
||||
useEffect(() => {
|
||||
initialize()
|
||||
}, [initialize])
|
||||
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--at-paper-2)' }}>
|
||||
<Outlet />
|
||||
<AuthModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<Sidebar />
|
||||
|
||||
@@ -62,6 +62,6 @@ export const useTestStore = create<TestStore>()(
|
||||
|
||||
reset: () => set(INITIAL_STATE),
|
||||
}),
|
||||
{ name: 'test-store' },
|
||||
{ name: 'test-store', version: 2 },
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
CREATE TABLE test (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
total_questions INT DEFAULT 0,
|
||||
duration_minutes INT DEFAULT 120,
|
||||
@@ -34,6 +35,7 @@ CREATE TABLE question_group (
|
||||
audio_url VARCHAR(500),
|
||||
image_url VARCHAR(500),
|
||||
passage_text TEXT,
|
||||
transcript TEXT,
|
||||
display_order INT DEFAULT 0
|
||||
);
|
||||
|
||||
|
||||
6466
test/test_1209.json
Normal file
6466
test/test_1209.json
Normal file
File diff suppressed because it is too large
Load Diff
6466
test/test_1211.json
Normal file
6466
test/test_1211.json
Normal file
File diff suppressed because it is too large
Load Diff
6466
test/test_1212.json
Normal file
6466
test/test_1212.json
Normal file
File diff suppressed because it is too large
Load Diff
6466
test/test_1213.json
Normal file
6466
test/test_1213.json
Normal file
File diff suppressed because it is too large
Load Diff
6466
test/test_1214.json
Normal file
6466
test/test_1214.json
Normal file
File diff suppressed because it is too large
Load Diff
6466
test/test_224.json
Normal file
6466
test/test_224.json
Normal file
File diff suppressed because it is too large
Load Diff
6466
test/test_225.json
Normal file
6466
test/test_225.json
Normal file
File diff suppressed because it is too large
Load Diff
6466
test/test_226.json
Normal file
6466
test/test_226.json
Normal file
File diff suppressed because it is too large
Load Diff
6466
test/test_227.json
Normal file
6466
test/test_227.json
Normal file
File diff suppressed because it is too large
Load Diff
6466
test/test_228.json
Normal file
6466
test/test_228.json
Normal file
File diff suppressed because it is too large
Load Diff
6567
test/test_229.json
Normal file
6567
test/test_229.json
Normal file
File diff suppressed because it is too large
Load Diff
6576
test/test_231.json
Normal file
6576
test/test_231.json
Normal file
File diff suppressed because it is too large
Load Diff
6567
test/test_232.json
Normal file
6567
test/test_232.json
Normal file
File diff suppressed because it is too large
Load Diff
6585
test/test_266.json
Normal file
6585
test/test_266.json
Normal file
File diff suppressed because it is too large
Load Diff
6576
test/test_267.json
Normal file
6576
test/test_267.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user