update UI
This commit is contained in:
@@ -7,7 +7,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint ."
|
"lint": "eslint .",
|
||||||
|
"import:tests": "node scripts/import-tests.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.3.0",
|
"@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 { useRouterState } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTestStore } from '@/store/test-store'
|
import { useTestStore } from '@/store/test-store'
|
||||||
import { UserMenu } from '@/components/UserMenu'
|
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 }> = {
|
const ROUTE_TITLES: Record<string, { eyebrow: string; title: string; accent?: string }> = {
|
||||||
'/': { eyebrow: 'Học TOEIC cùng AI', title: 'Trang chủ' },
|
'/': { 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' },
|
'/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) {
|
function matchRouteLabel(pathname: string) {
|
||||||
if (ROUTE_TITLES[pathname]) return ROUTE_TITLES[pathname]
|
if (ROUTE_TITLES[pathname]) return ROUTE_TITLES[pathname]
|
||||||
const keys = Object.keys(ROUTE_TITLES).sort((a, b) => b.length - a.length)
|
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 { testName, parts, answers } = useTestStore()
|
||||||
const pathname = location.pathname
|
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
|
// In-session mode: show test progress instead of route title
|
||||||
if (pathname === '/toeic/session') {
|
if (pathname === '/toeic/session') {
|
||||||
const totalQuestions = parts.reduce((sum, p) => sum + p.questions.length, 0)
|
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 = () => {
|
const renderTitle = () => {
|
||||||
if (!accent || !title.includes(accent)) return title
|
if (!accent || !title.includes(accent)) return title
|
||||||
const [before, after] = title.split(accent)
|
const [before, after] = title.split(accent)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
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 { useTestStore } from '@/store/test-store'
|
||||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||||
import { useAuthStore } from '@/store/auth-store'
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
@@ -10,11 +10,107 @@ import { XP_REWARDS } from '@/lib/gamification-service'
|
|||||||
|
|
||||||
const ANSWER_LABELS = ['A', 'B', 'C', 'D']
|
const ANSWER_LABELS = ['A', 'B', 'C', 'D']
|
||||||
|
|
||||||
function formatTime(s: number) {
|
function formatMinSec(s: number) {
|
||||||
const m = Math.floor(s / 60)
|
const mm = String(Math.floor(s / 60)).padStart(2, '0')
|
||||||
const sec = s % 60
|
const ss = String(s % 60).padStart(2, '0')
|
||||||
if (m === 0) return `${sec}s`
|
return `${mm}:${ss}`
|
||||||
return `${m}m ${sec}s`
|
}
|
||||||
|
|
||||||
|
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() {
|
export function TestResult() {
|
||||||
@@ -25,7 +121,6 @@ export function TestResult() {
|
|||||||
const savedRef = useRef(false)
|
const savedRef = useRef(false)
|
||||||
const { mutate: awardActivity } = useAwardActivity()
|
const { mutate: awardActivity } = useAwardActivity()
|
||||||
|
|
||||||
// Flatten all questions across parts
|
|
||||||
const allQuestions = parts.flatMap(p => p.questions)
|
const allQuestions = parts.flatMap(p => p.questions)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -36,12 +131,12 @@ export function TestResult() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || savedRef.current || allQuestions.length === 0) return
|
if (!user || savedRef.current || allQuestions.length === 0) return
|
||||||
savedRef.current = true
|
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 })
|
awardActivity({ xp: XP_REWARDS.test })
|
||||||
saveTestResult(user.id, {
|
saveTestResult(user.id, {
|
||||||
testId,
|
testId,
|
||||||
selectedParts: parts.map(p => p.partNumber),
|
selectedParts: parts.map(p => p.partNumber),
|
||||||
score: correct,
|
score: correctCount,
|
||||||
total: allQuestions.length,
|
total: allQuestions.length,
|
||||||
timeUsed,
|
timeUsed,
|
||||||
answers: allQuestions.map(q => ({
|
answers: allQuestions.map(q => ({
|
||||||
@@ -54,10 +149,15 @@ export function TestResult() {
|
|||||||
|
|
||||||
if (allQuestions.length === 0) {
|
if (allQuestions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="px-6 py-8 max-w-6xl mx-auto text-center">
|
<div className="px-6 py-10 text-center">
|
||||||
<p className="text-slate-500 mb-4">Không có dữ liệu bài thi.</p>
|
<p style={{ color: 'var(--at-mute)' }} className="mb-4">Không có dữ liệu bài thi.</p>
|
||||||
<button onClick={() => navigate({ to: '/toeic' })}
|
<button
|
||||||
className="bg-blue-600 text-white px-6 py-2.5 rounded-xl font-semibold text-sm hover:bg-blue-700 transition-colors">
|
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
|
Chọn đề thi
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 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 skipped = allQuestions.filter(q => answers[q.id] === null || answers[q.id] === undefined).length
|
||||||
const total = allQuestions.length
|
const total = allQuestions.length
|
||||||
const percent = total > 0 ? Math.round((correct / total) * 100) : 0
|
const pct = total > 0 ? Math.round((correct / total) * 100) : 0
|
||||||
const circumference = 2 * Math.PI * 52
|
// TOEIC scaled score 10–990. Linear approximation from raw accuracy;
|
||||||
const offset = circumference - (percent / 100) * circumference
|
// 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 (
|
return (
|
||||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
<div className="px-6 lg:px-10 py-10 page-enter">
|
||||||
{/* Score header */}
|
{/* Top row — headline card (dark) + TOEIC estimate card */}
|
||||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 mb-5">
|
<div className="grid gap-5 mb-5" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(380px, 1fr))' }}>
|
||||||
<div className="flex flex-col lg:flex-row items-center gap-6">
|
{/* Hero result card — themed by score */}
|
||||||
<div className="flex-shrink-0 relative w-32 h-32">
|
<div
|
||||||
<svg className="w-full h-full -rotate-90" viewBox="0 0 120 120">
|
className="relative overflow-hidden"
|
||||||
<circle cx="60" cy="60" r="52" fill="none" stroke="#e2e8f0" strokeWidth="8" />
|
style={{
|
||||||
<circle cx="60" cy="60" r="52" fill="none"
|
background: theme.bg,
|
||||||
stroke={percent >= 70 ? '#16a34a' : percent >= 50 ? '#2563eb' : '#dc2626'}
|
color: theme.text,
|
||||||
strokeWidth="8" strokeLinecap="round"
|
borderRadius: 24,
|
||||||
strokeDasharray={circumference} strokeDashoffset={offset}
|
padding: 32,
|
||||||
className="transition-all duration-700" />
|
boxShadow: 'var(--shadow-card)',
|
||||||
</svg>
|
transition: 'background 0.3s ease',
|
||||||
<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
|
||||||
|
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
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--at-serif)', fontSize: 40, fontWeight: 400,
|
||||||
|
letterSpacing: '-0.025em', lineHeight: 1.05, marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HeadlineByPercent pct={pct} />
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
</Ring>
|
||||||
<div className="flex-1 text-center lg:text-left">
|
<div>
|
||||||
<div className="text-2xl font-extrabold text-slate-800 mb-1">
|
<div
|
||||||
{percent >= 80 ? 'Xuất sắc!' : percent >= 60 ? 'Hoàn thành!' : 'Cố gắng hơn nhé!'}
|
className="uppercase"
|
||||||
|
style={{
|
||||||
|
fontSize: 10.5, color: theme.labelMuted,
|
||||||
|
letterSpacing: '0.14em', fontWeight: 600, marginBottom: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Đúng
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-slate-400 mb-4">{testName}</div>
|
<div
|
||||||
<div className="flex flex-wrap gap-3 justify-center lg:justify-start">
|
className="tabular-nums"
|
||||||
{[
|
style={{
|
||||||
{ label: 'Đúng', value: correct, cls: 'bg-green-50 border-green-100 text-green-600' },
|
fontFamily: 'var(--at-serif)', fontSize: 28, fontWeight: 400,
|
||||||
{ label: 'Sai', value: wrong, cls: 'bg-red-50 border-red-100 text-red-600' },
|
letterSpacing: '-0.02em',
|
||||||
{ 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 }) => (
|
{correct}
|
||||||
<div key={label} className={cn('border rounded-xl px-4 py-2 text-center', cls)}>
|
<span style={{ opacity: 0.5, fontSize: 18, fontStyle: 'italic' }}>
|
||||||
<div className="text-xl font-extrabold">{value}</div>
|
/{total}
|
||||||
<div className="text-xs text-slate-400">{label}</div>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
<div className="ml-auto flex flex-col gap-2">
|
||||||
<div className="flex lg:flex-col gap-3 flex-shrink-0">
|
<button
|
||||||
<button onClick={() => navigate({ to: '/toeic/session' })}
|
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">
|
className="inline-flex items-center gap-2 transition-[filter] hover:brightness-110"
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>replay</span>Làm lại
|
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>
|
||||||
<button onClick={() => { reset(); navigate({ to: '/toeic' }) }}
|
<button
|
||||||
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">
|
onClick={() => { reset(); navigate({ to: '/toeic' }) }}
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>home</span>Về trang chủ
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Answer review grouped by part */}
|
{/* 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>
|
||||||
|
|
||||||
|
{/* Per-part review */}
|
||||||
{parts.map(part => (
|
{parts.map(part => (
|
||||||
<div key={part.partNumber} className="bg-white rounded-2xl border border-slate-200 p-6 mb-4">
|
<div
|
||||||
<h2 className="text-base font-bold text-slate-800 mb-4">Part {part.partNumber} — {part.partName}</h2>
|
key={part.partNumber}
|
||||||
<div className="space-y-4">
|
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) => {
|
{part.questions.map((q, i) => {
|
||||||
const userAnswer = answers[q.id] ?? null
|
const userAnswer = answers[q.id] ?? null
|
||||||
const isCorrect = userAnswer === q.correctAnswer
|
const isCorrect = userAnswer === q.correctAnswer
|
||||||
const isSkipped = userAnswer === null || userAnswer === undefined
|
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 (
|
return (
|
||||||
<div key={q.id} className={cn(
|
<div
|
||||||
'rounded-xl border p-4',
|
key={q.id}
|
||||||
isCorrect ? 'border-green-100 bg-green-50/50' : isSkipped ? 'border-slate-100 bg-slate-50/50' : 'border-red-100 bg-red-50/50',
|
className="flex items-start gap-3"
|
||||||
)}>
|
style={{
|
||||||
<div className="flex items-start gap-3">
|
padding: '14px 0',
|
||||||
<span className={cn(
|
borderTop: i === 0 ? 'none' : '1px solid var(--at-line)',
|
||||||
'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
|
||||||
<div className="flex-1 min-w-0">
|
className="grid place-items-center flex-shrink-0"
|
||||||
{q.text && <p className="text-sm font-medium text-slate-800 mb-2">{q.text}</p>}
|
style={{
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
width: 28, height: 28, borderRadius: 8,
|
||||||
{q.options.map((opt, j) => (
|
background: statusBg, color: statusColor,
|
||||||
<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'
|
{isCorrect
|
||||||
: j === userAnswer && !isCorrect ? 'bg-red-100 text-red-700 border border-red-200 line-through'
|
? <Check size={16} strokeWidth={2.5} />
|
||||||
: 'bg-slate-100 text-slate-500',
|
: isSkipped
|
||||||
)}>
|
? <Minus size={16} strokeWidth={2.5} />
|
||||||
{ANSWER_LABELS[j]}. {opt}
|
: <X size={16} strokeWidth={2.5} />}
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
{q.explanation && (
|
|
||||||
<p className="text-xs text-slate-500 bg-white rounded-lg px-3 py-2 border border-slate-100">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="font-semibold text-slate-600">Giải thích: </span>{q.explanation}
|
<div
|
||||||
</p>
|
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>
|
</div>
|
||||||
<span className="flex-shrink-0">
|
{q.explanation && (
|
||||||
{isCorrect
|
<div
|
||||||
? <span className="material-symbols-outlined text-green-600" style={{ fontSize: 20 }}>check_circle</span>
|
className="mt-2"
|
||||||
: isSkipped
|
style={{
|
||||||
? <span className="material-symbols-outlined text-slate-400" style={{ fontSize: 20 }}>remove_circle</span>
|
fontSize: 12.5,
|
||||||
: <span className="material-symbols-outlined text-red-500" style={{ fontSize: 20 }}>cancel</span>}
|
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>
|
</span>
|
||||||
|
{q.explanation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -181,3 +539,48 @@ export function TestResult() {
|
|||||||
</div>
|
</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 { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { Play } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useTestStore } from '@/store/test-store'
|
import { useTestStore } from '@/store/test-store'
|
||||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||||
@@ -9,82 +10,361 @@ import { TestSessionFooter } from './TestSessionFooter'
|
|||||||
import type { Question } from '@/types'
|
import type { Question } from '@/types'
|
||||||
|
|
||||||
const ANSWER_LABELS = ['A', 'B', 'C', 'D']
|
const ANSWER_LABELS = ['A', 'B', 'C', 'D']
|
||||||
|
const LETTER_PLACEHOLDER_RE =
|
||||||
|
/^(Statement|Response|Choice)\s+[A-D]$/i
|
||||||
|
|
||||||
function QuestionCard({
|
interface QuestionGroup {
|
||||||
question, globalNum, answer, onSelect,
|
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
|
question: Question
|
||||||
globalNum: number
|
globalNum: number
|
||||||
answer: number | null
|
answer: number | null
|
||||||
onSelect: (idx: number) => void
|
onSelect: (idx: number) => void
|
||||||
|
isFirst: boolean
|
||||||
|
registerRef: (el: HTMLDivElement | null) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-slate-200 p-6 mb-4">
|
<div
|
||||||
<span className="inline-block bg-blue-600 text-white text-xs font-bold px-3 py-1 rounded-full mb-4">
|
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}
|
Câu {globalNum}
|
||||||
</span>
|
</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>
|
</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 && (
|
{question.text && (
|
||||||
<p className="text-base font-medium text-slate-800 leading-relaxed mb-5">{question.text}</p>
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: 'var(--at-ink)',
|
||||||
|
marginBottom: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{question.text}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2.5">
|
<div className="grid gap-2">
|
||||||
{question.options.map((opt, i) => (
|
{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
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => onSelect(i)}
|
onClick={() => onSelect(i)}
|
||||||
className={cn(
|
className={cn('flex items-center gap-3 text-left transition-all')}
|
||||||
'w-full flex items-center gap-3 p-3.5 border-2 rounded-xl text-sm font-medium text-left transition-all',
|
style={{
|
||||||
answer === i
|
padding: hideText ? '14px 18px' : '12px 16px',
|
||||||
? 'border-blue-600 bg-blue-50 text-blue-700'
|
minHeight: hideText ? 44 : undefined,
|
||||||
: 'border-slate-200 hover:border-blue-300 hover:bg-blue-50/50 text-slate-700',
|
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={cn(
|
<span
|
||||||
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0',
|
className="grid place-items-center flex-shrink-0"
|
||||||
answer === i ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-500',
|
style={{
|
||||||
)}>
|
width: 28,
|
||||||
{ANSWER_LABELS[i]}
|
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>
|
</span>
|
||||||
{opt}
|
{!hideText && <span>{opt}</span>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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() {
|
export function TestSession() {
|
||||||
const navigate = useNavigate()
|
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 { isAuthenticated, isLoading } = useRequireAuth()
|
||||||
const [timeLeft, setTimeLeft] = useState(() => totalSeconds > 0 ? totalSeconds : -1)
|
const [timeLeft, setTimeLeft] = useState(() => (totalSeconds > 0 ? totalSeconds : -1))
|
||||||
const [timeUsed, setTimeUsed] = useState(0)
|
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(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
submitExam(totalSeconds > 0 ? totalSeconds - timeLeft : timeUsed)
|
submitExam(totalSeconds > 0 ? totalSeconds - timeLeft : timeUsed)
|
||||||
navigate({ to: '/toeic/result' })
|
navigate({ to: '/toeic/result' })
|
||||||
}, [submitExam, navigate, totalSeconds, timeLeft, timeUsed])
|
}, [submitExam, navigate, totalSeconds, timeLeft, timeUsed])
|
||||||
|
|
||||||
// Timer
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (parts.length === 0) return
|
if (parts.length === 0) return
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
if (timeLeft > 0) {
|
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 {
|
} else {
|
||||||
setTimeUsed(t => t + 1)
|
setTimeUsed((t) => t + 1)
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
@@ -99,16 +379,25 @@ export function TestSession() {
|
|||||||
|
|
||||||
const currentPart = parts[currentPartIndex]
|
const currentPart = parts[currentPartIndex]
|
||||||
|
|
||||||
// Compute global question offset for current part
|
|
||||||
let globalOffset = 0
|
let globalOffset = 0
|
||||||
for (let i = 0; i < currentPartIndex; i++) globalOffset += parts[i].questions.length
|
for (let i = 0; i < currentPartIndex; i++) globalOffset += parts[i].questions.length
|
||||||
|
|
||||||
|
const groups = groupByGroupId(currentPart.questions)
|
||||||
|
|
||||||
return (
|
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
|
<TestSessionHeader
|
||||||
testName={testName}
|
testName={testName}
|
||||||
timeLeft={timeLeft}
|
timeLeft={timeLeft}
|
||||||
timeUsed={timeUsed}
|
timeUsed={timeUsed}
|
||||||
|
totalQuestions={totalQuestions}
|
||||||
|
answeredCount={answeredCount}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -118,23 +407,85 @@ export function TestSession() {
|
|||||||
currentPartIndex={currentPartIndex}
|
currentPartIndex={currentPartIndex}
|
||||||
answers={answers}
|
answers={answers}
|
||||||
onSelectPart={setCurrentPart}
|
onSelectPart={setCurrentPart}
|
||||||
|
onSelectQuestion={jumpToQuestion}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main scrollable content */}
|
<main
|
||||||
<main className="flex-1 overflow-y-auto bg-[#F8FAFC] px-6 py-6">
|
className="flex-1 overflow-y-auto"
|
||||||
<div className="max-w-3xl mx-auto">
|
style={{ padding: '24px 32px 80px' }}
|
||||||
<h2 className="text-lg font-extrabold text-slate-700 mb-5">
|
>
|
||||||
Part {currentPart.partNumber}: {currentPart.partName}
|
<div className="mx-auto w-full" style={{ maxWidth: 880 }}>
|
||||||
</h2>
|
<div style={{ marginBottom: 18 }}>
|
||||||
{currentPart.questions.map((q, idx) => (
|
<div
|
||||||
<QuestionCard
|
style={{
|
||||||
key={q.id}
|
fontFamily: 'var(--at-serif)',
|
||||||
question={q}
|
fontStyle: 'italic',
|
||||||
globalNum={globalOffset + idx + 1}
|
fontSize: 12,
|
||||||
answer={answers[q.id] ?? null}
|
letterSpacing: '0.08em',
|
||||||
onSelect={(i) => setAnswer(q.id, i)}
|
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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentPartIndex: number
|
currentPartIndex: number
|
||||||
totalParts: number
|
totalParts: number
|
||||||
@@ -6,30 +8,86 @@ interface Props {
|
|||||||
onNext: () => void
|
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 (
|
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
|
<button
|
||||||
onClick={onPrev}
|
onClick={onPrev}
|
||||||
disabled={currentPartIndex === 0}
|
disabled={isFirst}
|
||||||
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"
|
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
|
Part trước
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span className="text-sm font-bold text-slate-700">
|
<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}
|
Part {currentPartIndex + 1} / {totalParts}
|
||||||
<span className="text-slate-400 font-normal ml-1.5">— {currentPartName}</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
<span style={{ color: 'var(--at-mute)' }}>— {currentPartName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={currentPartIndex === totalParts - 1}
|
disabled={isLast}
|
||||||
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"
|
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
|
Part tiếp theo
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>arrow_forward</span>
|
<ArrowRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { cn } from '@/lib/utils'
|
import { Check } from 'lucide-react'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
testName: string
|
testName: string
|
||||||
timeLeft: number // seconds remaining; -1 = no limit (count-up mode)
|
timeLeft: number // seconds remaining; -1 = no limit (count-up mode)
|
||||||
timeUsed: number // seconds elapsed (used when no limit)
|
timeUsed: number // seconds elapsed (used when no limit)
|
||||||
|
totalQuestions: number
|
||||||
|
answeredCount: number
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,31 +13,89 @@ function formatTime(s: number): string {
|
|||||||
const h = Math.floor(s / 3600)
|
const h = Math.floor(s / 3600)
|
||||||
const m = Math.floor((s % 3600) / 60)
|
const m = Math.floor((s % 3600) / 60)
|
||||||
const sec = s % 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(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')}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestSessionHeader({ testName, timeLeft, timeUsed, onSubmit }: Props) {
|
export function TestSessionHeader({
|
||||||
|
testName, timeLeft, timeUsed, totalQuestions, answeredCount, onSubmit,
|
||||||
|
}: Props) {
|
||||||
const isUnlimited = timeLeft === -1
|
const isUnlimited = timeLeft === -1
|
||||||
const displaySeconds = isUnlimited ? timeUsed : timeLeft
|
const displaySeconds = isUnlimited ? timeUsed : timeLeft
|
||||||
const isUrgent = !isUnlimited && timeLeft < 300 // last 5 min
|
const isUrgent = !isUnlimited && timeLeft < 300
|
||||||
|
|
||||||
return (
|
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">
|
<header
|
||||||
<span className="font-bold text-slate-800 text-sm truncate max-w-xs">{testName}</span>
|
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(
|
<span
|
||||||
'text-2xl font-extrabold tabular-nums',
|
className="tabular-nums text-center"
|
||||||
isUrgent ? 'text-red-600 timer-urgent' : 'text-blue-600',
|
style={{
|
||||||
)}>
|
fontFamily: 'var(--at-mono)',
|
||||||
{isUnlimited ? <span className="text-slate-400 text-base">∞</span> : formatTime(displaySeconds)}
|
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>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onSubmit}
|
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
|
Nộp bài
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -6,60 +6,89 @@ interface Props {
|
|||||||
currentPartIndex: number
|
currentPartIndex: number
|
||||||
answers: Record<number, number | null>
|
answers: Record<number, number | null>
|
||||||
onSelectPart: (index: number) => void
|
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
|
// Global question offset per part for sequential numbering
|
||||||
let offset = 0
|
let offset = 0
|
||||||
const partOffsets: number[] = parts.map(p => {
|
const partOffsets: number[] = parts.map((p) => {
|
||||||
const o = offset
|
const o = offset
|
||||||
offset += p.questions.length
|
offset += p.questions.length
|
||||||
return o
|
return o
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-60 flex-shrink-0 bg-white border-r border-slate-200 overflow-y-auto">
|
<aside
|
||||||
<div className="p-3 border-b border-slate-100">
|
className="overflow-y-auto"
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Question Map</span>
|
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>
|
</div>
|
||||||
|
|
||||||
{parts.map((part, partIdx) => {
|
{parts.map((part, partIdx) => {
|
||||||
const isCurrent = partIdx === currentPartIndex
|
const isCurrent = partIdx === currentPartIndex
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={part.partNumber} className="px-3 pt-3 pb-1">
|
<div key={part.partNumber} style={{ marginBottom: 18 }}>
|
||||||
{/* Part label — click to switch */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => onSelectPart(partIdx)}
|
onClick={() => onSelectPart(partIdx)}
|
||||||
className={cn(
|
className="text-left w-full"
|
||||||
'w-full text-left text-[10px] font-bold uppercase tracking-widest mb-2 px-1 py-0.5 rounded transition-colors',
|
style={{
|
||||||
isCurrent ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600',
|
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}
|
Part {part.partNumber}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Question number grid */}
|
<div className="grid grid-cols-5 gap-1">
|
||||||
<div className="grid grid-cols-5 gap-1.5 mb-2">
|
|
||||||
{part.questions.map((q, qIdx) => {
|
{part.questions.map((q, qIdx) => {
|
||||||
const globalNum = partOffsets[partIdx] + qIdx + 1
|
const globalNum = partOffsets[partIdx] + qIdx + 1
|
||||||
const answered = answers[q.id] !== null && answers[q.id] !== undefined
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={q.id}
|
key={q.id}
|
||||||
onClick={() => onSelectPart(partIdx)}
|
onClick={() => onSelectQuestion(q.id)}
|
||||||
title={`Câu ${globalNum}`}
|
title={`Câu ${globalNum}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-8 h-8 rounded-lg flex items-center justify-center text-[11px] font-semibold transition-all',
|
'tabular-nums transition-all aspect-square',
|
||||||
isCurrent && answered
|
'hover:border-[var(--at-ink-2)]',
|
||||||
? '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',
|
|
||||||
)}
|
)}
|
||||||
|
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}
|
{globalNum}
|
||||||
</button>
|
</button>
|
||||||
@@ -69,18 +98,6 @@ export function TestSessionSidebar({ parts, currentPartIndex, answers, onSelectP
|
|||||||
</div>
|
</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>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,345 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
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 { fetchTestWithParts } from '@/features/toeic/api/test-list-api'
|
||||||
import { fetchQuestionsForTest } from '@/hooks/use-questions'
|
import { fetchQuestionsForTest } from '@/hooks/use-questions'
|
||||||
import { useTestStore } from '@/store/test-store'
|
import { useTestStore } from '@/store/test-store'
|
||||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||||
|
import type { PartRecord } from '@/types'
|
||||||
|
|
||||||
interface Props { testId: number }
|
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) {
|
export function ToeicTestDetail({ testId }: Props) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { startExam } = useTestStore()
|
const { startExam } = useTestStore()
|
||||||
const { requireAuth } = useRequireAuth()
|
const { requireAuth } = useRequireAuth()
|
||||||
const [selectedParts, setSelectedParts] = useState<number[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
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({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['test-detail', testId],
|
queryKey: ['test-detail', testId],
|
||||||
@@ -23,21 +348,29 @@ export function ToeicTestDetail({ testId }: Props) {
|
|||||||
|
|
||||||
function togglePart(partNumber: number) {
|
function togglePart(partNumber: number) {
|
||||||
setSelectedParts(prev =>
|
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 (!requireAuth()) return
|
||||||
if (mode === 'parts' && selectedParts.length === 0) return
|
|
||||||
if (!data) return
|
if (!data) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const partNumbers = mode === 'full' ? undefined : selectedParts
|
|
||||||
const parts = await fetchQuestionsForTest(testId, partNumbers)
|
const parts = await fetchQuestionsForTest(testId, partNumbers)
|
||||||
const totalSeconds = mode === 'full'
|
const totalSeconds = mode === 'full'
|
||||||
? data.test.durationMinutes * 60
|
? data.test.durationMinutes * 60
|
||||||
: selectedParts.length * 10 * 60
|
: mode === 'short'
|
||||||
|
? 20 * 60
|
||||||
|
: (minutes ?? 30) * 60
|
||||||
startExam({ testId, testName: data.test.title, parts, totalSeconds })
|
startExam({ testId, testName: data.test.title, parts, totalSeconds })
|
||||||
navigate({ to: '/toeic/session' })
|
navigate({ to: '/toeic/session' })
|
||||||
} finally {
|
} 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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="px-6 py-8 max-w-5xl mx-auto">
|
<div className="px-6 lg:px-10 py-10">
|
||||||
<div className="h-8 w-64 bg-slate-200 rounded animate-pulse mb-8" />
|
<div
|
||||||
<div className="grid grid-cols-2 gap-5">
|
className="animate-pulse rounded h-10 mb-6"
|
||||||
<div className="h-80 bg-slate-100 rounded-2xl animate-pulse" />
|
style={{ background: 'var(--at-line-2)', width: 280 }}
|
||||||
<div className="h-80 bg-slate-100 rounded-2xl animate-pulse" />
|
/>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -61,91 +412,213 @@ export function ToeicTestDetail({ testId }: Props) {
|
|||||||
const { test, parts } = data
|
const { test, parts } = data
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 py-8 max-w-5xl mx-auto page-enter">
|
<div className="px-6 lg:px-10 py-10 page-enter">
|
||||||
{/* Back + title */}
|
{/* Editorial head */}
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<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
|
<button
|
||||||
onClick={() => navigate({ to: '/toeic' })}
|
onClick={() => handleStart('short')}
|
||||||
className="w-8 h-8 rounded-full border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors"
|
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,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-slate-600" style={{ fontSize: 18 }}>arrow_back</span>
|
<Clock size={14} /> Đề ngắn 20 phút
|
||||||
</button>
|
</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>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleStart('full')}
|
onClick={() => handleStart('full')}
|
||||||
disabled={loading}
|
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" /> : (
|
<Target size={14} /> Thi thử đầy đủ
|
||||||
<><span className="material-symbols-outlined" style={{ fontSize: 18 }}>play_arrow</span>Bắt đầu thi</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Part selection card */}
|
{/* Tabs */}
|
||||||
<div className="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col">
|
<div
|
||||||
<span className="material-symbols-outlined text-blue-600 mb-4" style={{ fontSize: 32 }}>checklist</span>
|
className="flex gap-2 mb-6"
|
||||||
<h2 className="text-xl font-extrabold text-slate-800 mb-1">Chọn Part Luyện Tập</h2>
|
style={{ borderBottom: '1px solid var(--at-line)' }}
|
||||||
<p className="text-slate-400 text-sm mb-4">Chọn các part muốn luyện tập</p>
|
>
|
||||||
|
{TABS.map(t => {
|
||||||
<div className="space-y-2 flex-1">
|
const active = activeTab === t
|
||||||
{parts.map((part) => {
|
|
||||||
const checked = selectedParts.includes(part.partNumber)
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={part.partNumber}
|
key={t}
|
||||||
onClick={() => togglePart(part.partNumber)}
|
onClick={() => setActiveTab(t)}
|
||||||
className={cn(
|
style={{
|
||||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-xl border-2 transition-all text-left',
|
padding: '10px 16px',
|
||||||
checked
|
fontWeight: 600,
|
||||||
? 'border-blue-600 bg-blue-50'
|
fontSize: 13,
|
||||||
: 'border-slate-100 hover:border-slate-200 bg-slate-50/50',
|
color: active ? 'var(--at-brand)' : 'var(--at-mute)',
|
||||||
)}
|
borderBottom: active ? '2px solid var(--at-brand)' : '2px solid transparent',
|
||||||
|
marginBottom: -1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className={cn(
|
{t}
|
||||||
'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>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{/* Part grid */}
|
||||||
onClick={() => handleStart('parts')}
|
<div
|
||||||
disabled={loading || selectedParts.length === 0}
|
className="grid gap-5"
|
||||||
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"
|
style={{
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
|
paddingBottom: selectedParts.length > 0 ? 96 : 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{loading ? <span className="w-4 h-4 border-2 border-blue-200 border-t-white rounded-full animate-spin" /> : (
|
{filteredParts.map(part => (
|
||||||
<><span className="material-symbols-outlined" style={{ fontSize: 18 }}>play_arrow</span>Bắt đầu luyện tập</>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function ToeicTestList() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Editorial head */}
|
||||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
||||||
<div>
|
<div>
|
||||||
@@ -25,11 +25,11 @@ export function ToeicTestList() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<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))' }}>
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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)' }}
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -59,47 +59,80 @@ export function ToeicTestList() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tests.length > 0 && (
|
{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) => (
|
{tests.map((test) => (
|
||||||
<div
|
<div
|
||||||
key={test.id}
|
key={test.id}
|
||||||
className="rounded-2xl p-6 flex flex-col transition-all hover:-translate-y-1"
|
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)' }}
|
style={{
|
||||||
|
background: 'var(--at-surface)',
|
||||||
|
border: '1px solid var(--at-line)',
|
||||||
|
padding: 32,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{test.categoryName && (
|
{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" />
|
<span className="at-chip-dot" />
|
||||||
{test.categoryName}
|
{test.categoryName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h3
|
<h3
|
||||||
className="at-serif text-[20px] leading-[1.2] tracking-tight mb-2"
|
className="at-serif tracking-tight mb-3"
|
||||||
style={{ color: 'var(--at-ink)', fontWeight: 500 }}
|
style={{
|
||||||
|
color: 'var(--at-ink)',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 28,
|
||||||
|
lineHeight: 1.15,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{test.title}
|
{test.title}
|
||||||
</h3>
|
</h3>
|
||||||
{test.description && (
|
{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}
|
{test.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-xs mt-auto mb-4" style={{ color: 'var(--at-mute)' }}>
|
<div
|
||||||
<span className="flex items-center gap-1">
|
className="flex items-center gap-6 mt-auto mb-6"
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 13 }}>list_alt</span>
|
style={{ color: 'var(--at-mute)', fontSize: 13 }}
|
||||||
<b className="tabular-nums" style={{ color: 'var(--at-ink)' }}>{test.totalQuestions}</b> câu
|
>
|
||||||
|
<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>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 13 }}>timer</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>timer</span>
|
||||||
<b className="tabular-nums" style={{ color: 'var(--at-ink)' }}>{test.durationMinutes}</b> phút
|
<b
|
||||||
|
className="tabular-nums"
|
||||||
|
style={{ color: 'var(--at-ink)', fontSize: 16, fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
{test.durationMinutes}
|
||||||
|
</b>
|
||||||
|
phút
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: '/toeic/$testId', params: { testId: String(test.id) } })}
|
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"
|
className="w-full rounded-xl font-semibold transition-opacity hover:opacity-90"
|
||||||
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
|
style={{
|
||||||
|
background: 'var(--at-ink)',
|
||||||
|
color: 'var(--at-paper)',
|
||||||
|
padding: '14px 20px',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Bắt đầu
|
Bắt đầu
|
||||||
</button>
|
</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 { useEffect } from 'react'
|
||||||
import { Sidebar } from '@/components/layout/Sidebar'
|
import { Sidebar } from '@/components/layout/Sidebar'
|
||||||
import { AppHeader } from '@/components/layout/AppHeader'
|
import { AppHeader } from '@/components/layout/AppHeader'
|
||||||
@@ -10,13 +10,27 @@ export const Route = createRootRoute({
|
|||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Routes that own the full viewport — hide sidebar, top header, and mobile nav.
|
||||||
|
const FULLSCREEN_ROUTES = new Set(['/toeic/session'])
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const initialize = useAuthStore((s) => s.initialize)
|
const initialize = useAuthStore((s) => s.initialize)
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
const isFullscreen = FULLSCREEN_ROUTES.has(pathname)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initialize()
|
initialize()
|
||||||
}, [initialize])
|
}, [initialize])
|
||||||
|
|
||||||
|
if (isFullscreen) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen" style={{ background: 'var(--at-paper-2)' }}>
|
||||||
|
<Outlet />
|
||||||
|
<AuthModal />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50">
|
<div className="min-h-screen bg-slate-50">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|||||||
@@ -62,6 +62,6 @@ export const useTestStore = create<TestStore>()(
|
|||||||
|
|
||||||
reset: () => set(INITIAL_STATE),
|
reset: () => set(INITIAL_STATE),
|
||||||
}),
|
}),
|
||||||
{ name: 'test-store' },
|
{ name: 'test-store', version: 2 },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
CREATE TABLE test (
|
CREATE TABLE test (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
title VARCHAR(255) NOT NULL,
|
title VARCHAR(255) NOT NULL,
|
||||||
|
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
total_questions INT DEFAULT 0,
|
total_questions INT DEFAULT 0,
|
||||||
duration_minutes INT DEFAULT 120,
|
duration_minutes INT DEFAULT 120,
|
||||||
@@ -34,6 +35,7 @@ CREATE TABLE question_group (
|
|||||||
audio_url VARCHAR(500),
|
audio_url VARCHAR(500),
|
||||||
image_url VARCHAR(500),
|
image_url VARCHAR(500),
|
||||||
passage_text TEXT,
|
passage_text TEXT,
|
||||||
|
transcript TEXT,
|
||||||
display_order INT DEFAULT 0
|
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