3 Commits

Author SHA1 Message Date
54324e45d4 fix test 2026-05-02 00:46:09 +07:00
dcbce863de update 2026-05-02 00:20:44 +07:00
36b8ee9ec2 update UI 2026-04-24 14:41:41 +07:00
42 changed files with 100051 additions and 421 deletions

View File

@@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint ."
"lint": "eslint .",
"import:tests": "node scripts/import-tests.mjs"
},
"dependencies": {
"@base-ui/react": "^1.3.0",

145
scripts/import-tests.mjs Normal file
View 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)
})

View File

@@ -1,6 +1,8 @@
import { useRouterState } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTestStore } from '@/store/test-store'
import { UserMenu } from '@/components/UserMenu'
import { fetchTestWithParts } from '@/features/toeic/api/test-list-api'
const ROUTE_TITLES: Record<string, { eyebrow: string; title: string; accent?: string }> = {
'/': { eyebrow: 'Học TOEIC cùng AI', title: 'Trang chủ' },
@@ -11,6 +13,9 @@ const ROUTE_TITLES: Record<string, { eyebrow: string; title: string; accent?: st
'/settings': { eyebrow: 'Tuỳ chỉnh', title: 'Cài đặt' },
}
// Match `/toeic/<numeric-id>` but not `/toeic/session`, `/toeic/result`, `/toeic/part/...`
const TEST_DETAIL_RE = /^\/toeic\/(\d+)$/
function matchRouteLabel(pathname: string) {
if (ROUTE_TITLES[pathname]) return ROUTE_TITLES[pathname]
const keys = Object.keys(ROUTE_TITLES).sort((a, b) => b.length - a.length)
@@ -25,6 +30,15 @@ export function AppHeader() {
const { testName, parts, answers } = useTestStore()
const pathname = location.pathname
// Show test title in header when viewing a specific test's part-selection page.
const testDetailMatch = pathname.match(TEST_DETAIL_RE)
const testId = testDetailMatch ? Number(testDetailMatch[1]) : null
const { data: testDetail } = useQuery({
queryKey: ['test-detail', testId],
queryFn: () => fetchTestWithParts(testId!),
enabled: testId !== null,
})
// In-session mode: show test progress instead of route title
if (pathname === '/toeic/session') {
const totalQuestions = parts.reduce((sum, p) => sum + p.questions.length, 0)
@@ -48,7 +62,12 @@ export function AppHeader() {
)
}
const { eyebrow, title, accent } = matchRouteLabel(pathname)
// Test-detail page: show test title in header
const routeLabel = testDetail
? { eyebrow: 'Luyện đề TOEIC', title: testDetail.test.title }
: matchRouteLabel(pathname)
const { eyebrow, title } = routeLabel
const accent = 'accent' in routeLabel ? routeLabel.accent : undefined
const renderTitle = () => {
if (!accent || !title.includes(accent)) return title
const [before, after] = title.split(accent)

View File

@@ -1,12 +1,15 @@
import { Link, useRouterState } from '@tanstack/react-router'
import { Home, ClipboardList, Layers, Trophy, User } from 'lucide-react'
import { cn } from '@/lib/utils'
const NAV_ITEMS = [
{ to: '/', label: 'Home', icon: 'home', matchPrefix: '/', exact: true },
{ to: '/archivement', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/archivement', exact: false },
{ to: '/toeic', label: 'Luyện đề', icon: 'assignment', matchPrefix: '/toeic', exact: false },
{ to: '/writing', label: 'Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false },
{ to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false },
// Atelier Mobile tab bar — 5 tabs matching the mobile design.
// Labels keep the mobile design's playful brevity: Hôm nay / Luyện / Thẻ / Thành tích / Tôi
const TABS = [
{ to: '/', label: 'Hôm nay', icon: Home, matchPrefix: '/', exact: true },
{ to: '/toeic', label: 'Luyện', icon: ClipboardList, matchPrefix: '/toeic', exact: false },
{ to: '/flash-card', label: 'Thẻ', icon: Layers, matchPrefix: '/flash-card', exact: false },
{ to: '/archivement', label: 'Thành tích', icon: Trophy, matchPrefix: '/archivement', exact: false },
{ to: '/settings', label: 'Tôi', icon: User, matchPrefix: '/settings', exact: false },
]
function isActive(pathname: string, prefix: string, exact: boolean) {
@@ -18,22 +21,19 @@ export function MobileNav() {
const pathname = location.pathname
return (
<nav className="fixed bottom-0 inset-x-0 lg:hidden bg-white border-t border-slate-200 z-50 flex safe-area-inset-bottom">
{NAV_ITEMS.map((item) => {
const active = isActive(pathname, item.matchPrefix, item.exact)
<nav className="m-tabbar lg:hidden" aria-label="Chuyển màn hình">
{TABS.map((tab) => {
const active = isActive(pathname, tab.matchPrefix, tab.exact)
const Icon = tab.icon
return (
<Link
key={item.to}
to={item.to}
className={cn(
'flex-1 flex flex-col items-center justify-center gap-0.5 py-2 min-h-[56px] text-[11px] font-medium transition-colors',
active ? 'text-blue-600' : 'text-slate-400',
)}
key={tab.to}
to={tab.to}
className={cn('m-tab', active && 'is-active')}
aria-current={active ? 'page' : undefined}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
{item.icon}
</span>
{item.label}
<Icon width={22} height={22} strokeWidth={active ? 2.25 : 1.75} />
<span>{tab.label}</span>
</Link>
)
})}

View File

@@ -64,7 +64,7 @@ export function Dashboard() {
if (!user) {
return (
<div className="px-4 lg:px-6 py-20 max-w-6xl mx-auto flex flex-col items-center text-center gap-4">
<div className="px-4 lg:px-6 py-20 flex flex-col items-center text-center gap-4">
<div className="at-serif italic text-5xl" style={{ color: 'var(--at-mute-2)' }}>Thành tích</div>
<p className="max-w-sm" style={{ color: 'var(--at-mute)' }}>
Đăng nhập đ xem streak, XP, Xu bảng xếp hạng của bạn.
@@ -114,7 +114,7 @@ export function Dashboard() {
}))
return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
<div className="px-6 lg:px-10 py-10 page-enter">
{/* Editorial head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div>

View File

@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth-store'
import { useAuthModalStore } from '@/store/auth-modal-store'
import {
fetchFlashcardTerms,
fetchUserProgress,
@@ -34,7 +35,9 @@ function speak(word: string) {
export function FlashCardLearnPage({ listId }: Props) {
const navigate = useNavigate()
const user = useAuthStore(s => s.user)
const openAuthModal = useAuthModalStore(s => s.open)
const queryClient = useQueryClient()
const isGuest = !user
const [isFlipped, setIsFlipped] = useState(false)
const [currentIdx, setCurrentIdx] = useState(0)
@@ -213,6 +216,12 @@ export function FlashCardLearnPage({ listId }: Props) {
setTimeout(advance, 450)
}, [currentIdx, sessionTerms, user, saveAnswer, progressMap, advance])
// Jump to a specific card in the deck (no progress write — just navigate)
const jumpTo = useCallback((idx: number) => {
setCurrentIdx(idx)
setIsFlipped(false)
}, [])
// Keyboard shortcuts
useEffect(() => {
function onKey(e: KeyboardEvent) {
@@ -222,14 +231,26 @@ export function FlashCardLearnPage({ listId }: Props) {
setIsFlipped(v => !v)
return
}
if (!isFlipped) return
// Arrow nav — works for everyone, no progress write
if (e.key === 'ArrowLeft') {
e.preventDefault()
if (currentIdx > 0) jumpTo(currentIdx - 1)
return
}
if (e.key === 'ArrowRight') {
e.preventDefault()
if (currentIdx < sessionTerms.length - 1) jumpTo(currentIdx + 1)
return
}
// SRS keys — auth users only
if (isGuest || !isFlipped) return
if (e.key.toLowerCase() === 'j') handleAnswer('known')
else if (e.key.toLowerCase() === 'k') handleAnswer('hard')
else if (e.key.toLowerCase() === 'i') handleAnswer('ignored')
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [isDone, isFlipped, currentIdx, sessionTerms, handleAnswer])
}, [isDone, isFlipped, currentIdx, sessionTerms, handleAnswer, isGuest, jumpTo])
const total = sessionTerms.length
const progressPct = total > 0 ? Math.round((currentIdx / total) * 100) : 0
@@ -309,12 +330,6 @@ export function FlashCardLearnPage({ listId }: Props) {
)
}
// Jump to a specific card in the deck (no progress write — just navigate)
const jumpTo = (idx: number) => {
setCurrentIdx(idx)
setIsFlipped(false)
}
return (
<div
className="atelier fixed top-16 right-0 left-0 lg:left-60 bottom-20 lg:bottom-0 flex flex-col px-4 lg:px-6 py-3 overflow-hidden"
@@ -468,28 +483,60 @@ export function FlashCardLearnPage({ listId }: Props) {
</div>
)}
{/* Actions */}
{/* Actions — auth users get SRS buttons, guests get prev/next + login CTA */}
<div className="mt-4 w-full" style={{ maxWidth: 420 }}>
<div className={cn('flex items-stretch gap-2.5 w-full transition-opacity duration-300', !isFlipped && 'opacity-40 pointer-events-none')}>
<button onClick={() => handleAnswer('ignored')} disabled={!isFlipped} className="at-action" style={{ padding: '11px 14px', fontSize: 13 }}>
Bỏ qua <span className="at-kbd">I</span>
</button>
<button onClick={() => handleAnswer('hard')} disabled={!isFlipped} className="at-action at-action-review" style={{ padding: '11px 14px', fontSize: 13 }}>
Cần ôn <span className="at-kbd">K</span>
</button>
<button onClick={() => handleAnswer('known')} disabled={!isFlipped} className="at-action at-action-known" style={{ padding: '11px 14px', fontSize: 13 }}>
Đã thuộc
<span className="at-kbd" style={{ background: 'rgba(255,255,255,0.16)', color: 'rgba(255,255,255,0.9)', border: 'none' }}>J</span>
</button>
</div>
{isGuest ? (
<div className="flex flex-col gap-2.5">
<div className="flex items-stretch gap-2.5 w-full">
<button
onClick={() => currentIdx > 0 && jumpTo(currentIdx - 1)}
disabled={currentIdx === 0}
className="at-action"
style={{ padding: '11px 14px', fontSize: 13 }}
>
<span className="at-kbd"></span> Trước
</button>
<button
onClick={() => currentIdx < sessionTerms.length - 1 && jumpTo(currentIdx + 1)}
disabled={currentIdx >= sessionTerms.length - 1}
className="at-action"
style={{ padding: '11px 14px', fontSize: 13 }}
>
Sau <span className="at-kbd"></span>
</button>
</div>
<button
onClick={() => openAuthModal('register')}
className="at-action at-action-known"
style={{ padding: '11px 14px', fontSize: 13 }}
>
Đăng nhập đ theo dõi tiến đ
</button>
</div>
) : (
<div className={cn('flex items-stretch gap-2.5 w-full transition-opacity duration-300', !isFlipped && 'opacity-40 pointer-events-none')}>
<button onClick={() => handleAnswer('ignored')} disabled={!isFlipped} className="at-action" style={{ padding: '11px 14px', fontSize: 13 }}>
Bỏ qua <span className="at-kbd">I</span>
</button>
<button onClick={() => handleAnswer('hard')} disabled={!isFlipped} className="at-action at-action-review" style={{ padding: '11px 14px', fontSize: 13 }}>
Cần ôn <span className="at-kbd">K</span>
</button>
<button onClick={() => handleAnswer('known')} disabled={!isFlipped} className="at-action at-action-known" style={{ padding: '11px 14px', fontSize: 13 }}>
Đã thuộc
<span className="at-kbd" style={{ background: 'rgba(255,255,255,0.16)', color: 'rgba(255,255,255,0.9)', border: 'none' }}>J</span>
</button>
</div>
)}
</div>
{/* Progress */}
<div className="mt-3 w-full" style={{ maxWidth: 420 }}>
<div className="flex items-baseline justify-between mb-1.5 text-[12px] text-[var(--at-mute)]">
<span>
<b className="text-[var(--at-ink)] tabular-nums">{currentIdx + 1}</b> / {total} ·{' '}
{sessionStats.known} biết · {sessionStats.learning} học · {sessionStats.ignored} bỏ
<b className="text-[var(--at-ink)] tabular-nums">{currentIdx + 1}</b> / {total}
{!isGuest && (
<> · {sessionStats.known} biết · {sessionStats.learning} học · {sessionStats.ignored} bỏ</>
)}
</span>
<span className="at-pct" style={{ fontSize: 18 }}>{progressPct}%</span>
</div>
@@ -501,37 +548,59 @@ export function FlashCardLearnPage({ listId }: Props) {
{/* Right sidebar */}
<aside className="hidden lg:flex flex-col gap-3 min-h-0">
{/* Today stats */}
<div
className="rounded-2xl p-4 flex-shrink-0"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div className="at-eyebrow mb-2" style={{ fontSize: 11 }}>Hôm nay</div>
<div className="grid grid-cols-2 gap-3 mt-1">
<div>
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}>
Đã học
</div>
<div
className="at-serif"
style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-ink)' }}
>
{sessionStats.known + sessionStats.learning + sessionStats.ignored}
</div>
{/* Today stats — auth users; guests see a login nudge */}
{isGuest ? (
<div
className="rounded-2xl p-4 flex-shrink-0"
style={{ background: 'var(--at-brand-soft)', border: '1px solid var(--at-line)' }}
>
<div className="at-eyebrow mb-1" style={{ fontSize: 11, color: 'var(--at-brand-ink)' }}>Chế đ khách</div>
<div
className="at-serif"
style={{ fontSize: 18, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.2, color: 'var(--at-brand-ink)' }}
>
Đăng nhập đ <i>ghi nhớ</i> tiến đ
</div>
<div>
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}>
Đúng
<button
onClick={() => openAuthModal('register')}
className="mt-3 w-full text-[12px] font-semibold py-2 rounded-lg transition-opacity hover:opacity-90"
style={{ background: 'var(--at-brand)', color: '#fff' }}
>
Đăng nhập / Đăng
</button>
</div>
) : (
<div
className="rounded-2xl p-4 flex-shrink-0"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div className="at-eyebrow mb-2" style={{ fontSize: 11 }}>Hôm nay</div>
<div className="grid grid-cols-2 gap-3 mt-1">
<div>
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}>
Đã học
</div>
<div
className="at-serif"
style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-ink)' }}
>
{sessionStats.known + sessionStats.learning + sessionStats.ignored}
</div>
</div>
<div
className="at-serif"
style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-good)' }}
>
{sessionStats.known}
<div>
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}>
Đúng
</div>
<div
className="at-serif"
style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-good)' }}
>
{sessionStats.known}
</div>
</div>
</div>
</div>
</div>
)}
{/* Cards in deck — compact rows (word only) */}
<div

View File

@@ -125,7 +125,7 @@ export function FlashCardListPage() {
})
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">
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div>
<div className="at-eyebrow mb-3">Từ vựng TOEIC</div>

View File

@@ -68,7 +68,7 @@ export function FlashCardTermsPage({ listId }: Props) {
})
return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
<div className="px-6 lg:px-10 py-10 page-enter">
{/* Editorial head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div className="flex items-start gap-4 min-w-0">

View File

@@ -71,7 +71,7 @@ export function Vocabulary() {
.reverse()
return (
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
<div className="px-4 lg:px-6 py-6 page-enter">
{/* Mobile topic chips */}
<div className="lg:hidden mb-4 overflow-x-auto pb-1">
<div className="flex gap-2 w-max">

View File

@@ -35,7 +35,7 @@ export function Home() {
const firstName = user?.name ?? 'bạn'
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">
{/* Page head — editorial */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div>

View File

@@ -13,7 +13,7 @@ export function Settings() {
if (!user) {
return (
<div className="px-4 lg:px-6 py-12 max-w-6xl mx-auto flex flex-col items-center justify-center gap-4 text-center">
<div className="px-4 lg:px-6 py-12 flex flex-col items-center justify-center gap-4 text-center">
<span className="material-symbols-outlined text-slate-300" style={{ fontSize: 64 }}>settings</span>
<h1 className="text-xl font-bold text-slate-700">Cài đt</h1>
<p className="text-slate-400 text-sm max-w-xs">
@@ -30,7 +30,7 @@ export function Settings() {
}
return (
<div className="px-4 lg:px-6 py-6 max-w-5xl mx-auto">
<div className="px-4 lg:px-6 py-6">
<div className="mb-6">
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">Cài đt</h1>
<p className="text-slate-400 text-sm">Quản hồ , mục tiêu học tập thông báo.</p>

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import { Check, Home, Minus, RotateCcw, Sparkles, X } from 'lucide-react'
import { useTestStore } from '@/store/test-store'
import { useRequireAuth } from '@/hooks/use-require-auth'
import { useAuthStore } from '@/store/auth-store'
@@ -10,11 +10,107 @@ import { XP_REWARDS } from '@/lib/gamification-service'
const ANSWER_LABELS = ['A', 'B', 'C', 'D']
function formatTime(s: number) {
const m = Math.floor(s / 60)
const sec = s % 60
if (m === 0) return `${sec}s`
return `${m}m ${sec}s`
function formatMinSec(s: number) {
const mm = String(Math.floor(s / 60)).padStart(2, '0')
const ss = String(s % 60).padStart(2, '0')
return `${mm}:${ss}`
}
function Ring({ percent, size = 110, stroke = 7, color, bg, children }: {
percent: number; size?: number; stroke?: number; color: string; bg: string; children: React.ReactNode
}) {
const cx = size / 2
const r = cx - stroke
const c = 2 * Math.PI * r
const offset = c - (Math.min(percent, 100) / 100) * c
return (
<div className="relative grid place-items-center flex-shrink-0" style={{ width: size, height: size }}>
<svg className="-rotate-90" width={size} height={size}>
<circle cx={cx} cy={cx} r={r} fill="none" stroke={bg} strokeWidth={stroke} />
<circle
cx={cx} cy={cx} r={r}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset 0.7s ease' }}
/>
</svg>
<div className="absolute inset-0 grid place-items-center">{children}</div>
</div>
)
}
function HeadlineByPercent({ pct }: { pct: number }) {
if (pct >= 80) return <>Xuất <i>sắc!</i></>
if (pct >= 60) return <>Khá <i>tốt</i></>
return <>Cần <i>ôn thêm</i></>
}
// Hero theme by estimated TOEIC score band — aligned to ETS proficiency levels.
// <225 Novice · 225549 Elementary→Basic · 550749 Working · ≥750 Professional
// Light backgrounds use ink text; dark use paper text.
function heroThemeByScore(score: number) {
if (score < 225) {
return {
bg: 'var(--at-warm)',
text: 'var(--at-paper)',
muted: 'rgba(250,248,243,0.6)',
labelMuted: 'rgba(250,248,243,0.5)',
ringBg: 'rgba(255,255,255,0.15)',
ringColor: 'var(--at-paper)',
glow: 'rgba(255,255,255,0.18)',
btnSolidBg: 'var(--at-paper)',
btnSolidText: 'var(--at-ink)',
btnGhostBorder: 'rgba(255,255,255,0.25)',
btnGhostHover: 'rgba(255,255,255,0.1)',
}
}
if (score < 550) {
return {
bg: 'var(--at-warm-soft)',
text: 'var(--at-ink)',
muted: 'var(--at-mute)',
labelMuted: 'var(--at-mute)',
ringBg: 'rgba(15,17,20,0.08)',
ringColor: 'var(--at-warm)',
glow: 'rgba(210,106,59,0.18)',
btnSolidBg: 'var(--at-ink)',
btnSolidText: 'var(--at-paper)',
btnGhostBorder: 'var(--at-line)',
btnGhostHover: 'rgba(15,17,20,0.05)',
}
}
if (score < 750) {
return {
bg: 'var(--at-good-soft)',
text: 'var(--at-ink)',
muted: 'var(--at-mute)',
labelMuted: 'var(--at-mute)',
ringBg: 'rgba(15,17,20,0.08)',
ringColor: 'var(--at-good)',
glow: 'rgba(47,125,74,0.18)',
btnSolidBg: 'var(--at-ink)',
btnSolidText: 'var(--at-paper)',
btnGhostBorder: 'var(--at-line)',
btnGhostHover: 'rgba(15,17,20,0.05)',
}
}
return {
bg: 'var(--at-good)',
text: 'var(--at-paper)',
muted: 'rgba(250,248,243,0.65)',
labelMuted: 'rgba(250,248,243,0.55)',
ringBg: 'rgba(255,255,255,0.15)',
ringColor: 'var(--at-paper)',
glow: 'rgba(255,255,255,0.18)',
btnSolidBg: 'var(--at-paper)',
btnSolidText: 'var(--at-ink)',
btnGhostBorder: 'rgba(255,255,255,0.25)',
btnGhostHover: 'rgba(255,255,255,0.1)',
}
}
export function TestResult() {
@@ -25,7 +121,6 @@ export function TestResult() {
const savedRef = useRef(false)
const { mutate: awardActivity } = useAwardActivity()
// Flatten all questions across parts
const allQuestions = parts.flatMap(p => p.questions)
useEffect(() => {
@@ -36,12 +131,12 @@ export function TestResult() {
useEffect(() => {
if (!user || savedRef.current || allQuestions.length === 0) return
savedRef.current = true
const correct = allQuestions.filter(q => answers[q.id] === q.correctAnswer).length
const correctCount = allQuestions.filter(q => answers[q.id] === q.correctAnswer).length
awardActivity({ xp: XP_REWARDS.test })
saveTestResult(user.id, {
testId,
selectedParts: parts.map(p => p.partNumber),
score: correct,
score: correctCount,
total: allQuestions.length,
timeUsed,
answers: allQuestions.map(q => ({
@@ -54,10 +149,15 @@ export function TestResult() {
if (allQuestions.length === 0) {
return (
<div className="px-6 py-8 max-w-6xl mx-auto text-center">
<p className="text-slate-500 mb-4">Không dữ liệu bài thi.</p>
<button onClick={() => navigate({ to: '/toeic' })}
className="bg-blue-600 text-white px-6 py-2.5 rounded-xl font-semibold text-sm hover:bg-blue-700 transition-colors">
<div className="px-6 py-10 text-center">
<p style={{ color: 'var(--at-mute)' }} className="mb-4">Không dữ liệu bài thi.</p>
<button
onClick={() => navigate({ to: '/toeic' })}
style={{
background: 'var(--at-ink)', color: 'var(--at-paper)',
padding: '10px 20px', borderRadius: 10, fontWeight: 600, fontSize: 14,
}}
>
Chọn đ thi
</button>
</div>
@@ -68,109 +168,367 @@ export function TestResult() {
const wrong = allQuestions.filter(q => answers[q.id] !== null && answers[q.id] !== undefined && answers[q.id] !== q.correctAnswer).length
const skipped = allQuestions.filter(q => answers[q.id] === null || answers[q.id] === undefined).length
const total = allQuestions.length
const percent = total > 0 ? Math.round((correct / total) * 100) : 0
const circumference = 2 * Math.PI * 52
const offset = circumference - (percent / 100) * circumference
const pct = total > 0 ? Math.round((correct / total) * 100) : 0
// TOEIC scaled score 10990. Linear approximation from raw accuracy;
// real ETS scaling is non-linear, but good enough as a practice estimate.
const toeicEstimate = Math.round(10 + pct * 9.8)
const theme = heroThemeByScore(toeicEstimate)
return (
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
{/* Score header */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 mb-5">
<div className="flex flex-col lg:flex-row items-center gap-6">
<div className="flex-shrink-0 relative w-32 h-32">
<svg className="w-full h-full -rotate-90" viewBox="0 0 120 120">
<circle cx="60" cy="60" r="52" fill="none" stroke="#e2e8f0" strokeWidth="8" />
<circle cx="60" cy="60" r="52" fill="none"
stroke={percent >= 70 ? '#16a34a' : percent >= 50 ? '#2563eb' : '#dc2626'}
strokeWidth="8" strokeLinecap="round"
strokeDasharray={circumference} strokeDashoffset={offset}
className="transition-all duration-700" />
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-extrabold text-slate-800">{correct}/{total}</span>
<span className="text-xs text-slate-400 font-medium">điểm</span>
<div className="px-6 lg:px-10 py-10 page-enter">
{/* Top row — headline card (dark) + TOEIC estimate card */}
<div className="grid gap-5 mb-5" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(380px, 1fr))' }}>
{/* Hero result card — themed by score */}
<div
className="relative overflow-hidden"
style={{
background: theme.bg,
color: theme.text,
borderRadius: 24,
padding: 32,
boxShadow: 'var(--shadow-card)',
transition: 'background 0.3s ease',
}}
>
<div
style={{
position: 'absolute', top: -40, right: -40, width: 200, height: 200,
borderRadius: '50%',
background: `radial-gradient(circle, ${theme.glow}, transparent 60%)`,
}}
/>
<div className="relative">
<div
className="uppercase"
style={{
fontSize: 10.5, fontWeight: 700, letterSpacing: '0.16em',
color: theme.labelMuted, marginBottom: 10,
}}
>
Kết quả
</div>
</div>
<div className="flex-1 text-center lg:text-left">
<div className="text-2xl font-extrabold text-slate-800 mb-1">
{percent >= 80 ? 'Xuất sắc!' : percent >= 60 ? 'Hoàn thành!' : 'Cố gắng hơn nhé!'}
<div
style={{
fontFamily: 'var(--at-serif)', fontSize: 40, fontWeight: 400,
letterSpacing: '-0.025em', lineHeight: 1.05, marginBottom: 6,
}}
>
<HeadlineByPercent pct={pct} />
</div>
<div className="text-sm text-slate-400 mb-4">{testName}</div>
<div className="flex flex-wrap gap-3 justify-center lg:justify-start">
{[
{ label: 'Đúng', value: correct, cls: 'bg-green-50 border-green-100 text-green-600' },
{ label: 'Sai', value: wrong, cls: 'bg-red-50 border-red-100 text-red-600' },
{ label: 'Bỏ qua', value: skipped, cls: 'bg-slate-50 border-slate-200 text-slate-500' },
{ label: 'Thời gian', value: formatTime(timeUsed), cls: 'bg-blue-50 border-blue-100 text-blue-600' },
].map(({ label, value, cls }) => (
<div key={label} className={cn('border rounded-xl px-4 py-2 text-center', cls)}>
<div className="text-xl font-extrabold">{value}</div>
<div className="text-xs text-slate-400">{label}</div>
<div style={{ fontSize: 13, color: theme.muted, marginBottom: 24 }}>
{testName}
</div>
<div className="flex items-center gap-7 flex-wrap">
<Ring percent={pct} color={theme.ringColor} bg={theme.ringBg}>
<div style={{ color: theme.text, textAlign: 'center' }}>
<div
style={{
fontFamily: 'var(--at-serif)', fontSize: 30, fontWeight: 400,
letterSpacing: '-0.02em', lineHeight: 1,
}}
>
{pct}
</div>
<div
className="uppercase"
style={{
fontSize: 9.5, opacity: 0.6, letterSpacing: '0.14em',
marginTop: 4, fontWeight: 600,
}}
>
điểm
</div>
</div>
))}
</Ring>
<div>
<div
className="uppercase"
style={{
fontSize: 10.5, color: theme.labelMuted,
letterSpacing: '0.14em', fontWeight: 600, marginBottom: 2,
}}
>
Đúng
</div>
<div
className="tabular-nums"
style={{
fontFamily: 'var(--at-serif)', fontSize: 28, fontWeight: 400,
letterSpacing: '-0.02em',
}}
>
{correct}
<span style={{ opacity: 0.5, fontSize: 18, fontStyle: 'italic' }}>
/{total}
</span>
</div>
<div
className="uppercase"
style={{
fontSize: 10.5, color: theme.labelMuted,
letterSpacing: '0.14em', fontWeight: 600, marginTop: 14, marginBottom: 2,
}}
>
Thời gian
</div>
<div
className="tabular-nums"
style={{
fontFamily: 'var(--at-mono)', fontSize: 24, fontWeight: 500,
letterSpacing: '0.02em',
}}
>
{formatMinSec(timeUsed)}
</div>
</div>
<div className="ml-auto flex flex-col gap-2">
<button
onClick={() => navigate({ to: '/toeic/session' })}
className="inline-flex items-center gap-2 transition-[filter] hover:brightness-110"
style={{
padding: '10px 18px', borderRadius: 10,
background: theme.btnSolidBg, color: theme.btnSolidText,
fontSize: 13, fontWeight: 600,
}}
>
<RotateCcw size={14} />
Làm lại
</button>
<button
onClick={() => { reset(); navigate({ to: '/toeic' }) }}
className="inline-flex items-center gap-2 transition-colors"
style={{
padding: '10px 18px', borderRadius: 10,
background: 'transparent', color: theme.text,
border: `1px solid ${theme.btnGhostBorder}`,
fontSize: 13, fontWeight: 600,
}}
onMouseEnter={(e) => (e.currentTarget.style.background = theme.btnGhostHover)}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Home size={14} />
Trang chủ
</button>
</div>
</div>
</div>
</div>
<div className="flex lg:flex-col gap-3 flex-shrink-0">
<button onClick={() => navigate({ to: '/toeic/session' })}
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-semibold hover:bg-blue-700 transition-colors">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>replay</span>Làm lại
</button>
<button onClick={() => { reset(); navigate({ to: '/toeic' }) }}
className="flex items-center gap-2 px-5 py-2.5 border border-slate-200 text-slate-600 rounded-xl text-sm font-semibold hover:bg-slate-50 transition-colors">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>home</span>Về trang chủ
</button>
{/* TOEIC estimate card */}
<div
style={{
background: 'var(--at-surface)',
border: '1px solid var(--at-line)',
borderRadius: 24,
padding: 32,
boxShadow: 'var(--shadow-sm)',
}}
>
<div
className="uppercase"
style={{
fontFamily: 'var(--at-serif)', fontStyle: 'italic', fontWeight: 500,
fontSize: 12, letterSpacing: '0.08em',
color: 'var(--at-mute)', marginBottom: 8,
}}
>
Dự kiến TOEIC
</div>
<div
style={{
fontFamily: 'var(--at-serif)', fontSize: 24, fontWeight: 500,
letterSpacing: '-0.015em', marginBottom: 4, color: 'var(--at-ink)',
}}
>
Điểm <i style={{ color: 'var(--at-brand)' }}>ưc tính</i>
</div>
<div style={{ fontSize: 12.5, color: 'var(--at-mute)', marginBottom: 18 }}>
Dựa trên bài thi hôm nay
</div>
<div className="flex items-baseline gap-2 mb-4">
<div
className="tabular-nums"
style={{
fontFamily: 'var(--at-serif)', fontSize: 56, fontWeight: 400,
letterSpacing: '-0.035em', color: 'var(--at-brand)', lineHeight: 1,
}}
>
{toeicEstimate}
</div>
<div
style={{
fontSize: 14, color: 'var(--at-mute)',
fontFamily: 'var(--at-serif)', fontStyle: 'italic',
}}
>
/ 990
</div>
</div>
<div
style={{
height: 6, background: 'var(--at-line-2)',
borderRadius: 999, overflow: 'hidden', position: 'relative',
marginBottom: 6,
}}
>
<span
style={{
position: 'absolute', inset: '0 auto 0 0',
background: 'var(--at-brand)',
width: `${(toeicEstimate / 990) * 100}%`,
borderRadius: 999,
transition: 'width 0.7s cubic-bezier(0.2,0.7,0.2,1)',
}}
/>
</div>
<div className="flex justify-between" style={{ fontSize: 11, color: 'var(--at-mute)' }}>
<span>0</span>
<span>Mục tiêu: 850</span>
<span>990</span>
</div>
<div style={{ height: 1, background: 'var(--at-line)', margin: '18px 0' }} />
<div className="grid grid-cols-3 gap-4">
<Stat label="Đúng" value={correct} color="var(--at-good)" />
<Stat label="Sai" value={wrong} color="var(--at-bad)" />
<Stat label="Bỏ qua" value={skipped} color="var(--at-mute)" />
</div>
</div>
</div>
{/* Answer review grouped by part */}
{/* Per-part review */}
{parts.map(part => (
<div key={part.partNumber} className="bg-white rounded-2xl border border-slate-200 p-6 mb-4">
<h2 className="text-base font-bold text-slate-800 mb-4">Part {part.partNumber} {part.partName}</h2>
<div className="space-y-4">
<div
key={part.partNumber}
style={{
background: 'var(--at-surface)',
border: '1px solid var(--at-line)',
borderRadius: 18,
padding: 24,
marginBottom: 16,
boxShadow: 'var(--shadow-sm)',
}}
>
<div className="flex items-center justify-between mb-4">
<div>
<div
className="uppercase"
style={{
fontFamily: 'var(--at-serif)', fontStyle: 'italic', fontWeight: 500,
fontSize: 12, letterSpacing: '0.08em', color: 'var(--at-brand)',
marginBottom: 4,
}}
>
Part {part.partNumber}
</div>
<div
style={{
fontFamily: 'var(--at-serif)', fontSize: 22, fontWeight: 400,
letterSpacing: '-0.015em', color: 'var(--at-ink)',
}}
>
{part.partName}
</div>
</div>
<div className="flex items-center gap-2">
<Chip kind="good" icon={<Check size={10} strokeWidth={3} />}>
{part.questions.filter(q => answers[q.id] === q.correctAnswer).length} đúng
</Chip>
<Chip kind="bad" icon={<X size={10} strokeWidth={3} />}>
{part.questions.filter(q => answers[q.id] !== null && answers[q.id] !== undefined && answers[q.id] !== q.correctAnswer).length} sai
</Chip>
</div>
</div>
<div>
{part.questions.map((q, i) => {
const userAnswer = answers[q.id] ?? null
const isCorrect = userAnswer === q.correctAnswer
const isSkipped = userAnswer === null || userAnswer === undefined
const statusColor = isCorrect
? 'var(--at-good)'
: isSkipped
? 'var(--at-mute)'
: 'var(--at-bad)'
const statusBg = isCorrect
? 'var(--at-good-soft)'
: isSkipped
? 'var(--at-line-2)'
: 'var(--at-bad-soft)'
return (
<div key={q.id} className={cn(
'rounded-xl border p-4',
isCorrect ? 'border-green-100 bg-green-50/50' : isSkipped ? 'border-slate-100 bg-slate-50/50' : 'border-red-100 bg-red-50/50',
)}>
<div className="flex items-start gap-3">
<span className={cn(
'w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5',
isCorrect ? 'bg-green-600 text-white' : isSkipped ? 'bg-slate-400 text-white' : 'bg-red-600 text-white',
)}>{i + 1}</span>
<div className="flex-1 min-w-0">
{q.text && <p className="text-sm font-medium text-slate-800 mb-2">{q.text}</p>}
<div className="flex flex-wrap gap-2 mb-2">
{q.options.map((opt, j) => (
<span key={j} className={cn(
'text-xs px-2.5 py-1 rounded-lg font-medium',
j === q.correctAnswer ? 'bg-green-100 text-green-700 border border-green-200'
: j === userAnswer && !isCorrect ? 'bg-red-100 text-red-700 border border-red-200 line-through'
: 'bg-slate-100 text-slate-500',
)}>
{ANSWER_LABELS[j]}. {opt}
</span>
))}
</div>
{q.explanation && (
<p className="text-xs text-slate-500 bg-white rounded-lg px-3 py-2 border border-slate-100">
<span className="font-semibold text-slate-600">Giải thích: </span>{q.explanation}
</p>
<div
key={q.id}
className="flex items-start gap-3"
style={{
padding: '14px 0',
borderTop: i === 0 ? 'none' : '1px solid var(--at-line)',
}}
>
<div
className="grid place-items-center flex-shrink-0"
style={{
width: 28, height: 28, borderRadius: 8,
background: statusBg, color: statusColor,
}}
>
{isCorrect
? <Check size={16} strokeWidth={2.5} />
: isSkipped
? <Minus size={16} strokeWidth={2.5} />
: <X size={16} strokeWidth={2.5} />}
</div>
<div className="flex-1 min-w-0">
<div
className="mb-1.5"
style={{
fontSize: 13.5, fontWeight: 500, color: 'var(--at-ink)',
lineHeight: 1.5,
}}
>
<span style={{ color: 'var(--at-mute)', fontFamily: 'var(--at-serif)', fontStyle: 'italic' }}>
Câu {i + 1}.{' '}
</span>
{q.text || <span style={{ color: 'var(--at-mute-2)' }}> (nghe/nhìn)</span>}
</div>
<div style={{ fontSize: 12.5, color: 'var(--at-mute)', lineHeight: 1.5 }}>
Đáp án đúng:{' '}
<b style={{ color: 'var(--at-good)' }}>
{ANSWER_LABELS[q.correctAnswer]}. {q.options[q.correctAnswer]}
</b>
{!isCorrect && !isSkipped && userAnswer !== null && (
<>
{' · '}Bạn chọn:{' '}
<b style={{ color: 'var(--at-bad)' }}>
{ANSWER_LABELS[userAnswer]}. {q.options[userAnswer]}
</b>
</>
)}
</div>
<span className="flex-shrink-0">
{isCorrect
? <span className="material-symbols-outlined text-green-600" style={{ fontSize: 20 }}>check_circle</span>
: isSkipped
? <span className="material-symbols-outlined text-slate-400" style={{ fontSize: 20 }}>remove_circle</span>
: <span className="material-symbols-outlined text-red-500" style={{ fontSize: 20 }}>cancel</span>}
</span>
{q.explanation && (
<div
className="mt-2"
style={{
fontSize: 12.5,
color: 'var(--at-ink-2)',
background: 'var(--at-paper-2)',
border: '1px solid var(--at-line)',
borderRadius: 8,
padding: '10px 12px',
lineHeight: 1.55,
}}
>
<span
style={{
fontFamily: 'var(--at-serif)', fontStyle: 'italic',
color: 'var(--at-brand)', marginRight: 6,
}}
>
<Sparkles size={10} className="inline -mt-0.5 mr-1" />
Giải thích
</span>
{q.explanation}
</div>
)}
</div>
</div>
)
@@ -181,3 +539,48 @@ export function TestResult() {
</div>
)
}
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div>
<div
className="uppercase"
style={{
fontSize: 10.5, color: 'var(--at-mute)',
letterSpacing: '0.1em', fontWeight: 600, marginBottom: 4,
}}
>
{label}
</div>
<div
className="tabular-nums"
style={{
fontFamily: 'var(--at-serif)', fontSize: 22, fontWeight: 400,
letterSpacing: '-0.02em', color,
}}
>
{value}
</div>
</div>
)
}
function Chip({ kind, icon, children }: {
kind: 'good' | 'bad'; icon: React.ReactNode; children: React.ReactNode
}) {
const bg = kind === 'good' ? 'var(--at-good-soft)' : 'var(--at-bad-soft)'
const fg = kind === 'good' ? 'var(--at-good-ink)' : 'var(--at-bad)'
return (
<span
className="inline-flex items-center gap-1.5"
style={{
padding: '5px 10px', borderRadius: 999,
background: bg, color: fg,
fontSize: 11, fontWeight: 600, letterSpacing: '0.02em',
}}
>
{icon}
{children}
</span>
)
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { Play } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useTestStore } from '@/store/test-store'
import { useRequireAuth } from '@/hooks/use-require-auth'
@@ -9,82 +10,372 @@ import { TestSessionFooter } from './TestSessionFooter'
import type { Question } from '@/types'
const ANSWER_LABELS = ['A', 'B', 'C', 'D']
const LETTER_PLACEHOLDER_RE =
/^(Statement|Response|Choice)\s+[A-D]$/i
function QuestionCard({
question, globalNum, answer, onSelect,
interface QuestionGroup {
groupId: number
questions: Question[]
passageText?: string
audioUrl?: string
imageUrl?: string
}
function groupByGroupId(questions: Question[]): QuestionGroup[] {
const groups: QuestionGroup[] = []
for (const q of questions) {
const last = groups[groups.length - 1]
if (last && last.groupId === q.groupId) {
last.questions.push(q)
} else {
groups.push({
groupId: q.groupId,
questions: [q],
passageText: q.passageText,
audioUrl: q.audioUrl,
imageUrl: q.imageUrl,
})
}
}
return groups
}
function PassageBlock({ group }: { group: QuestionGroup }) {
const { audioUrl, imageUrl, passageText } = group
if (!audioUrl && !imageUrl && !passageText) return null
return (
<div
style={{
background: 'var(--at-paper-2)',
borderRadius: 10,
padding: '18px 20px',
fontFamily: 'var(--at-mono)',
fontSize: 12.5,
lineHeight: 1.7,
color: 'var(--at-ink-2)',
marginBottom: 24,
whiteSpace: 'pre-wrap',
}}
>
{audioUrl && (
<div className="flex items-center gap-3" style={{ marginBottom: passageText || imageUrl ? 12 : 0 }}>
<audio
controls
src={audioUrl}
className="w-full"
style={{ height: 32 }}
/>
</div>
)}
{imageUrl && (
<div
style={{
marginTop: audioUrl ? 12 : 0,
borderRadius: 8,
overflow: 'hidden',
border: '1px solid var(--at-line)',
aspectRatio: '16 / 9',
background: '#d8d4c9',
}}
>
<img src={imageUrl} alt="" className="w-full h-full object-contain" />
</div>
)}
{!audioUrl && !imageUrl && (
// No audio/image → text-only passage (Part 6/7)
<div>{passageText}</div>
)}
{(audioUrl || imageUrl) && passageText && (
<div style={{ marginTop: 10, fontSize: 12, color: 'var(--at-mute)' }}>{passageText}</div>
)}
</div>
)
}
function AudioPlaceholder({ label }: { label: string }) {
return (
<div
className="flex items-center gap-3"
style={{
background: 'var(--at-surface)',
border: '1px solid var(--at-line)',
borderRadius: 10,
padding: '14px 16px',
marginBottom: 24,
}}
>
<button
className="grid place-items-center"
style={{
width: 34,
height: 34,
borderRadius: 999,
background: 'var(--at-brand)',
color: 'white',
}}
>
<Play size={14} fill="white" />
</button>
<div className="flex-1">
<div
style={{
fontFamily: 'var(--at-serif)',
fontStyle: 'italic',
fontSize: 12,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--at-mute)',
marginBottom: 2,
}}
>
{label}
</div>
<div
style={{
height: 4,
background: 'var(--at-line)',
borderRadius: 2,
overflow: 'hidden',
}}
>
<span style={{ display: 'block', width: 0, height: '100%' }} />
</div>
</div>
</div>
)
}
function QuestionBlock({
question, globalNum, answer, onSelect, isFirst, registerRef,
}: {
question: Question
globalNum: number
answer: number | null
onSelect: (idx: number) => void
isFirst: boolean
registerRef: (el: HTMLDivElement | null) => void
}) {
return (
<div className="bg-white rounded-2xl border border-slate-200 p-6 mb-4">
<span className="inline-block bg-blue-600 text-white text-xs font-bold px-3 py-1 rounded-full mb-4">
Câu {globalNum}
</span>
{question.passageText && (
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4 mb-4 text-sm text-slate-700 leading-relaxed whitespace-pre-wrap">
{question.passageText}
</div>
)}
{question.audioUrl && (
<audio controls src={question.audioUrl} className="w-full mb-4 rounded-lg" />
)}
{question.imageUrl && (
<img src={question.imageUrl} alt="" className="max-h-64 rounded-xl mb-4 object-contain" />
)}
{question.text && (
<p className="text-base font-medium text-slate-800 leading-relaxed mb-5">{question.text}</p>
)}
<div className="space-y-2.5">
{question.options.map((opt, i) => (
<button
key={i}
onClick={() => onSelect(i)}
className={cn(
'w-full flex items-center gap-3 p-3.5 border-2 rounded-xl text-sm font-medium text-left transition-all',
answer === i
? 'border-blue-600 bg-blue-50 text-blue-700'
: 'border-slate-200 hover:border-blue-300 hover:bg-blue-50/50 text-slate-700',
)}
>
<span className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0',
answer === i ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-500',
)}>
{ANSWER_LABELS[i]}
</span>
{opt}
</button>
))}
<div
ref={registerRef}
data-qid={question.id}
style={{
padding: '16px 0',
borderTop: isFirst ? 'none' : '1px solid var(--at-line)',
paddingTop: isFirst ? 4 : 16,
scrollMarginTop: 24,
}}
>
<div style={{ marginBottom: 10 }}>
<span
className="inline-block"
style={{
background: 'var(--at-brand-soft)',
color: 'var(--at-brand)',
padding: '4px 10px',
borderRadius: 6,
fontSize: 12,
fontWeight: 600,
}}
>
Câu {globalNum}
</span>
</div>
{question.text && (
<p
style={{
fontSize: 15,
lineHeight: 1.5,
color: 'var(--at-ink)',
marginBottom: 14,
fontWeight: 500,
}}
>
{question.text}
</p>
)}
<div className="grid gap-2">
{question.options.map((opt, i) => {
const letter = ANSWER_LABELS[i]
const isSelected = answer === i
const hideText = !question.text && LETTER_PLACEHOLDER_RE.test(opt)
return (
<button
key={i}
onClick={() => onSelect(i)}
className={cn('flex items-center gap-3 text-left transition-all')}
style={{
padding: hideText ? '14px 18px' : '12px 16px',
minHeight: hideText ? 44 : undefined,
background: isSelected ? 'var(--at-brand-soft)' : 'var(--at-surface)',
border: `1px solid ${isSelected ? 'var(--at-brand)' : 'var(--at-line)'}`,
borderRadius: 10,
fontSize: 14,
color: 'var(--at-ink)',
}}
>
<span
className="grid place-items-center flex-shrink-0"
style={{
width: 28,
height: 28,
borderRadius: 999,
background: isSelected ? 'var(--at-brand)' : 'var(--at-surface)',
border: `1px solid ${isSelected ? 'var(--at-brand)' : 'var(--at-line)'}`,
color: isSelected ? '#fff' : 'var(--at-ink-2)',
fontWeight: 600,
fontSize: 12,
}}
>
{letter}
</span>
{!hideText && <span>{opt}</span>}
</button>
)
})}
</div>
</div>
)
}
function GroupCard({
group, globalOffset, startIndex, answers, onSelect, registerQuestionRef,
}: {
group: QuestionGroup
globalOffset: number
startIndex: number
answers: Record<number, number | null>
onSelect: (qid: number, idx: number) => void
registerQuestionRef: (qid: number, el: HTMLDivElement | null) => void
}) {
const hasAudio = !!group.audioUrl
return (
<div
style={{
background: 'var(--at-surface)',
border: '1px solid var(--at-line)',
borderRadius: 12,
padding: '24px 28px',
marginBottom: 20,
}}
>
{hasAudio && !group.imageUrl && !group.passageText && (
<AudioPlaceholder label={`Audio · Câu ${globalOffset + startIndex + 1}`} />
)}
<PassageBlock group={group} />
{group.questions.map((q, i) => (
<QuestionBlock
key={q.id}
question={q}
globalNum={globalOffset + startIndex + i + 1}
answer={answers[q.id] ?? null}
onSelect={(idx) => onSelect(q.id, idx)}
isFirst={i === 0 && !hasAudio && !group.imageUrl && !group.passageText}
registerRef={(el) => registerQuestionRef(q.id, el)}
/>
))}
</div>
)
}
export function TestSession() {
const navigate = useNavigate()
const { testName, parts, currentPartIndex, answers, totalSeconds, setAnswer, setCurrentPart, submitExam } = useTestStore()
const {
testName, parts, currentPartIndex, answers, totalSeconds,
setAnswer, setCurrentPart, submitExam,
} = useTestStore()
const { isAuthenticated, isLoading } = useRequireAuth()
const [timeLeft, setTimeLeft] = useState(() => totalSeconds > 0 ? totalSeconds : -1)
const [timeLeft, setTimeLeft] = useState(() => (totalSeconds > 0 ? totalSeconds : -1))
const [timeUsed, setTimeUsed] = useState(0)
// Map of questionId → DOM node, so sidebar clicks can scroll to a specific question.
const questionRefs = useRef<Map<number, HTMLDivElement>>(new Map())
// When jumping to a question in another part, we switch part first, then scroll
// after the new part renders. `pendingScrollQid` holds the target until mount.
const [pendingScrollQid, setPendingScrollQid] = useState<number | null>(null)
const registerQuestionRef = useCallback((qid: number, el: HTMLDivElement | null) => {
if (el) questionRefs.current.set(qid, el)
else questionRefs.current.delete(qid)
}, [])
function scrollToQuestion(qid: number) {
const el = questionRefs.current.get(qid)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function jumpToQuestion(qid: number) {
// Find part that owns this question.
const targetPartIndex = parts.findIndex(p => p.questions.some(q => q.id === qid))
if (targetPartIndex === -1) return
if (targetPartIndex !== currentPartIndex) {
setCurrentPart(targetPartIndex)
setPendingScrollQid(qid)
} else {
scrollToQuestion(qid)
}
}
// After a part change, if we have a pending scroll target, run it.
useEffect(() => {
if (pendingScrollQid === null) return
// Wait one frame so refs are registered for the newly mounted part.
const raf = requestAnimationFrame(() => {
scrollToQuestion(pendingScrollQid)
setPendingScrollQid(null)
})
return () => cancelAnimationFrame(raf)
}, [currentPartIndex, pendingScrollQid])
const totalQuestions = useMemo(
() => parts.reduce((sum, p) => sum + p.questions.length, 0),
[parts],
)
const answeredCount = useMemo(
() => Object.values(answers).filter((v) => v !== null && v !== undefined).length,
[answers],
)
const [showSubmitConfirm, setShowSubmitConfirm] = useState(false)
const handleSubmit = useCallback(() => {
submitExam(totalSeconds > 0 ? totalSeconds - timeLeft : timeUsed)
navigate({ to: '/toeic/result' })
}, [submitExam, navigate, totalSeconds, timeLeft, timeUsed])
// Timer
// Manual click → confirm if any unanswered; auto (timer expire) skips confirm.
const requestSubmit = useCallback(() => {
if (totalQuestions > 0 && answeredCount < totalQuestions) {
setShowSubmitConfirm(true)
} else {
handleSubmit()
}
}, [totalQuestions, answeredCount, handleSubmit])
useEffect(() => {
if (parts.length === 0) return
const id = setInterval(() => {
if (timeLeft > 0) {
setTimeLeft(t => { if (t <= 1) { clearInterval(id); handleSubmit(); return 0 } return t - 1 })
setTimeLeft((t) => {
if (t <= 1) {
clearInterval(id)
handleSubmit()
return 0
}
return t - 1
})
} else {
setTimeUsed(t => t + 1)
setTimeUsed((t) => t + 1)
}
}, 1000)
return () => clearInterval(id)
@@ -99,17 +390,26 @@ export function TestSession() {
const currentPart = parts[currentPartIndex]
// Compute global question offset for current part
let globalOffset = 0
for (let i = 0; i < currentPartIndex; i++) globalOffset += parts[i].questions.length
const groups = groupByGroupId(currentPart.questions)
return (
<div className="flex flex-col" style={{ height: 'calc(100vh - var(--app-header-height, 0px))' }}>
<div
className="flex flex-col"
style={{
height: 'calc(100vh - var(--app-header-height, 0px))',
background: 'var(--at-paper-2)',
}}
>
<TestSessionHeader
testName={testName}
timeLeft={timeLeft}
timeUsed={timeUsed}
onSubmit={handleSubmit}
totalQuestions={totalQuestions}
answeredCount={answeredCount}
onSubmit={requestSubmit}
/>
<div className="flex flex-1 overflow-hidden">
@@ -118,23 +418,85 @@ export function TestSession() {
currentPartIndex={currentPartIndex}
answers={answers}
onSelectPart={setCurrentPart}
onSelectQuestion={jumpToQuestion}
/>
{/* Main scrollable content */}
<main className="flex-1 overflow-y-auto bg-[#F8FAFC] px-6 py-6">
<div className="max-w-3xl mx-auto">
<h2 className="text-lg font-extrabold text-slate-700 mb-5">
Part {currentPart.partNumber}: {currentPart.partName}
</h2>
{currentPart.questions.map((q, idx) => (
<QuestionCard
key={q.id}
question={q}
globalNum={globalOffset + idx + 1}
answer={answers[q.id] ?? null}
onSelect={(i) => setAnswer(q.id, i)}
/>
))}
<main
className="flex-1 overflow-y-auto"
style={{ padding: '24px 32px 80px' }}
>
<div className="mx-auto w-full" style={{ maxWidth: 880 }}>
<div style={{ marginBottom: 18 }}>
<div
style={{
fontFamily: 'var(--at-serif)',
fontStyle: 'italic',
fontSize: 12,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--at-mute)',
marginBottom: 8,
}}
>
Đang làm
</div>
<div
style={{
fontFamily: 'var(--at-serif)',
fontSize: 22,
fontWeight: 400,
letterSpacing: '-0.015em',
color: 'var(--at-ink)',
}}
>
Part {currentPart.partNumber}:{' '}
<i style={{ fontStyle: 'italic', color: 'var(--at-ink-2)' }}>
{currentPart.partName}
</i>
</div>
</div>
{groups.length === 0 && (
<div
style={{
background: 'var(--at-surface)',
border: '1px dashed var(--at-line)',
borderRadius: 12,
padding: 40,
textAlign: 'center',
}}
>
<div
style={{
fontFamily: 'var(--at-serif)',
fontSize: 18,
fontStyle: 'italic',
color: 'var(--at-mute)',
}}
>
Part này chưa dữ liệu.
</div>
</div>
)}
{groups.map((group) => {
let startIndex = 0
for (const g of groups) {
if (g === group) break
startIndex += g.questions.length
}
return (
<GroupCard
key={group.groupId}
group={group}
globalOffset={globalOffset}
startIndex={startIndex}
answers={answers}
onSelect={setAnswer}
registerQuestionRef={registerQuestionRef}
/>
)
})}
</div>
</main>
</div>
@@ -146,6 +508,49 @@ export function TestSession() {
onPrev={() => setCurrentPart(currentPartIndex - 1)}
onNext={() => setCurrentPart(currentPartIndex + 1)}
/>
{showSubmitConfirm && (
<div
className="fixed inset-0 z-50 flex items-center justify-center px-4"
style={{ background: 'rgba(15, 17, 20, 0.5)' }}
onClick={() => setShowSubmitConfirm(false)}
>
<div
className="w-full max-w-md rounded-2xl p-6 shadow-2xl"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
onClick={(e) => e.stopPropagation()}
>
<div
className="at-serif"
style={{ fontSize: 22, fontWeight: 500, letterSpacing: '-0.02em', color: 'var(--at-ink)', marginBottom: 8 }}
>
Nộp bài <i style={{ fontStyle: 'italic', color: 'var(--at-brand)' }}>ngay?</i>
</div>
<p style={{ fontSize: 14, color: 'var(--at-mute)', lineHeight: 1.55, marginBottom: 20 }}>
Bạn còn{' '}
<b style={{ color: 'var(--at-bad)' }}>{totalQuestions - answeredCount}</b>/{totalQuestions} câu
chưa trả lời. Các câu chưa trả lời sẽ tính {' '}
<b>bỏ qua</b> không điểm.
</p>
<div className="flex gap-2 justify-end">
<button
onClick={() => setShowSubmitConfirm(false)}
className="px-4 py-2 rounded-lg text-sm font-semibold transition-colors hover:bg-[var(--at-line-2)]"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
>
Tiếp tục làm
</button>
<button
onClick={() => { setShowSubmitConfirm(false); handleSubmit() }}
className="px-4 py-2 rounded-lg text-sm font-semibold text-white transition-[filter] hover:brightness-110"
style={{ background: '#e53935' }}
>
Nộp bài
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,3 +1,5 @@
import { ArrowLeft, ArrowRight } from 'lucide-react'
interface Props {
currentPartIndex: number
totalParts: number
@@ -6,30 +8,86 @@ interface Props {
onNext: () => void
}
export function TestSessionFooter({ currentPartIndex, totalParts, currentPartName, onPrev, onNext }: Props) {
export function TestSessionFooter({
currentPartIndex, totalParts, currentPartName, onPrev, onNext,
}: Props) {
const isFirst = currentPartIndex === 0
const isLast = currentPartIndex === totalParts - 1
return (
<footer className="h-14 flex items-center justify-between px-5 bg-white border-t border-slate-200 flex-shrink-0 z-10">
<footer
className="grid items-center flex-shrink-0"
style={{
gridTemplateColumns: '1fr auto 1fr',
padding: '12px 24px',
gap: 16,
background: 'var(--at-surface)',
borderTop: '1px solid var(--at-line)',
}}
>
<button
onClick={onPrev}
disabled={currentPartIndex === 0}
className="flex items-center gap-1.5 px-4 py-2 border border-slate-200 rounded-xl text-sm font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
disabled={isFirst}
className="inline-flex items-center gap-1.5 transition-colors disabled:cursor-not-allowed"
style={{
justifySelf: 'start',
padding: '8px 14px',
borderRadius: 8,
background: 'var(--at-surface)',
border: '1px solid var(--at-line)',
color: 'var(--at-ink-2)',
fontSize: 13,
fontWeight: 500,
opacity: isFirst ? 0.4 : 1,
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>arrow_back</span>
<ArrowLeft size={14} />
Part trước
</button>
<span className="text-sm font-bold text-slate-700">
Part {currentPartIndex + 1} / {totalParts}
<span className="text-slate-400 font-normal ml-1.5"> {currentPartName}</span>
</span>
<div
className="flex items-center gap-2"
style={{
fontFamily: 'var(--at-serif)',
fontStyle: 'italic',
fontSize: 13,
color: 'var(--at-ink-2)',
}}
>
<span
style={{
background: 'var(--at-paper-2)',
padding: '4px 10px',
borderRadius: 6,
fontWeight: 600,
fontStyle: 'normal',
fontFamily: 'var(--at-sans)',
color: 'var(--at-ink)',
}}
>
Part {currentPartIndex + 1} / {totalParts}
</span>
<span style={{ color: 'var(--at-mute)' }}> {currentPartName}</span>
</div>
<button
onClick={onNext}
disabled={currentPartIndex === totalParts - 1}
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 text-white rounded-xl text-sm font-semibold hover:bg-blue-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
disabled={isLast}
className="inline-flex items-center gap-1.5 transition-[filter] hover:brightness-110 disabled:cursor-not-allowed"
style={{
justifySelf: 'end',
padding: '8px 14px',
borderRadius: 8,
background: 'var(--at-brand)',
border: '1px solid var(--at-brand)',
color: 'white',
fontSize: 13,
fontWeight: 500,
opacity: isLast ? 0.4 : 1,
}}
>
Part tiếp theo
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>arrow_forward</span>
<ArrowRight size={14} />
</button>
</footer>
)

View File

@@ -1,9 +1,11 @@
import { cn } from '@/lib/utils'
import { Check } from 'lucide-react'
interface Props {
testName: string
timeLeft: number // seconds remaining; -1 = no limit (count-up mode)
timeUsed: number // seconds elapsed (used when no limit)
timeLeft: number // seconds remaining; -1 = no limit (count-up mode)
timeUsed: number // seconds elapsed (used when no limit)
totalQuestions: number
answeredCount: number
onSubmit: () => void
}
@@ -11,31 +13,89 @@ function formatTime(s: number): string {
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = s % 60
if (h > 0) return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
}
export function TestSessionHeader({ testName, timeLeft, timeUsed, onSubmit }: Props) {
export function TestSessionHeader({
testName, timeLeft, timeUsed, totalQuestions, answeredCount, onSubmit,
}: Props) {
const isUnlimited = timeLeft === -1
const displaySeconds = isUnlimited ? timeUsed : timeLeft
const isUrgent = !isUnlimited && timeLeft < 300 // last 5 min
const isUrgent = !isUnlimited && timeLeft < 300
return (
<header className="h-14 flex items-center justify-between px-5 bg-white border-b border-slate-200 shadow-sm flex-shrink-0 z-10">
<span className="font-bold text-slate-800 text-sm truncate max-w-xs">{testName}</span>
<header
className="grid items-center flex-shrink-0"
style={{
gridTemplateColumns: '1fr auto 1fr',
padding: '12px 24px',
gap: 16,
background: 'var(--at-surface)',
borderBottom: '1px solid var(--at-line)',
}}
>
<div className="flex flex-col gap-0.5" style={{ justifySelf: 'start' }}>
<span
style={{
fontFamily: 'var(--at-serif)',
fontStyle: 'italic',
fontWeight: 400,
fontSize: 11,
letterSpacing: '0.04em',
color: 'var(--at-mute)',
textTransform: 'uppercase',
}}
>
Phiên thi
</span>
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--at-ink)' }}>
{testName} ·{' '}
<span
style={{
color: 'var(--at-brand)',
fontStyle: 'italic',
fontFamily: 'var(--at-serif)',
fontWeight: 400,
}}
>
{answeredCount}/{totalQuestions}
</span>{' '}
câu
</span>
</div>
<span className={cn(
'text-2xl font-extrabold tabular-nums',
isUrgent ? 'text-red-600 timer-urgent' : 'text-blue-600',
)}>
{isUnlimited ? <span className="text-slate-400 text-base"></span> : formatTime(displaySeconds)}
<span
className="tabular-nums text-center"
style={{
fontFamily: 'var(--at-mono)',
fontSize: 22,
fontWeight: 600,
letterSpacing: '0.02em',
color: isUrgent ? 'var(--at-bad)' : 'var(--at-brand)',
animation: isUrgent ? 'pulse 1s infinite' : undefined,
}}
>
{isUnlimited ? (
<span style={{ color: 'var(--at-mute-2)', fontSize: 16 }}></span>
) : (
formatTime(displaySeconds)
)}
</span>
<button
onClick={onSubmit}
className="flex items-center gap-1.5 px-4 py-2 bg-red-600 text-white rounded-xl text-sm font-bold hover:bg-red-700 transition-colors"
className="inline-flex items-center gap-1.5 transition-[filter] hover:brightness-110"
style={{
justifySelf: 'end',
padding: '8px 16px',
borderRadius: 8,
background: '#e53935',
color: 'white',
fontWeight: 600,
fontSize: 13,
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>send</span>
<Check size={14} strokeWidth={2.5} />
Nộp bài
</button>
</header>

View File

@@ -6,60 +6,89 @@ interface Props {
currentPartIndex: number
answers: Record<number, number | null>
onSelectPart: (index: number) => void
onSelectQuestion: (questionId: number) => void
}
export function TestSessionSidebar({ parts, currentPartIndex, answers, onSelectPart }: Props) {
export function TestSessionSidebar({
parts, currentPartIndex, answers, onSelectPart, onSelectQuestion,
}: Props) {
// Global question offset per part for sequential numbering
let offset = 0
const partOffsets: number[] = parts.map(p => {
const partOffsets: number[] = parts.map((p) => {
const o = offset
offset += p.questions.length
return o
})
return (
<aside className="w-60 flex-shrink-0 bg-white border-r border-slate-200 overflow-y-auto">
<div className="p-3 border-b border-slate-100">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Question Map</span>
<aside
className="overflow-y-auto"
style={{
width: 240,
flexShrink: 0,
background: 'var(--at-surface)',
borderRight: '1px solid var(--at-line)',
padding: '18px 16px 40px',
}}
>
<div
className="uppercase"
style={{
fontSize: 11,
letterSpacing: '0.08em',
color: 'var(--at-mute)',
marginBottom: 12,
}}
>
Question Map
</div>
{parts.map((part, partIdx) => {
const isCurrent = partIdx === currentPartIndex
return (
<div key={part.partNumber} className="px-3 pt-3 pb-1">
{/* Part label — click to switch */}
<div key={part.partNumber} style={{ marginBottom: 18 }}>
<button
onClick={() => onSelectPart(partIdx)}
className={cn(
'w-full text-left text-[10px] font-bold uppercase tracking-widest mb-2 px-1 py-0.5 rounded transition-colors',
isCurrent ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600',
)}
className="text-left w-full"
style={{
fontFamily: 'var(--at-serif)',
fontStyle: 'italic',
fontWeight: 400,
fontSize: 13,
color: isCurrent ? 'var(--at-brand)' : 'var(--at-ink-2)',
marginBottom: 8,
letterSpacing: '0.04em',
}}
>
Part {part.partNumber}
</button>
{/* Question number grid */}
<div className="grid grid-cols-5 gap-1.5 mb-2">
<div className="grid grid-cols-5 gap-1">
{part.questions.map((q, qIdx) => {
const globalNum = partOffsets[partIdx] + qIdx + 1
const answered = answers[q.id] !== null && answers[q.id] !== undefined
// Soft brand-tinted border for all unanswered cells.
const unansweredBorder = 'rgba(84, 114, 158, 0.35)'
return (
<button
key={q.id}
onClick={() => onSelectPart(partIdx)}
onClick={() => onSelectQuestion(q.id)}
title={`Câu ${globalNum}`}
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center text-[11px] font-semibold transition-all',
isCurrent && answered
? 'bg-blue-600 text-white'
: !isCurrent && answered
? 'bg-blue-400 text-white'
: isCurrent
? 'border-2 border-blue-600 text-blue-600'
: 'border-2 border-slate-200 text-slate-400 hover:border-slate-300',
'tabular-nums transition-all aspect-square',
'hover:border-[var(--at-ink-2)]',
)}
style={{
borderRadius: 6,
border: `1px solid ${answered ? 'var(--at-brand)' : unansweredBorder}`,
background: answered ? 'var(--at-brand)' : 'var(--at-surface)',
color: answered ? '#fff' : 'var(--at-ink-2)',
fontSize: 11,
fontWeight: 500,
padding: 0,
}}
>
{globalNum}
</button>
@@ -69,18 +98,6 @@ export function TestSessionSidebar({ parts, currentPartIndex, answers, onSelectP
</div>
)
})}
{/* Legend */}
<div className="px-4 py-3 border-t border-slate-100 mt-1">
<div className="flex flex-col gap-1.5 text-[10px] text-slate-400">
<span className="flex items-center gap-2">
<span className="w-4 h-4 rounded bg-blue-600 inline-block" />Đã trả lời
</span>
<span className="flex items-center gap-2">
<span className="w-4 h-4 rounded border-2 border-slate-200 inline-block" />Chưa làm
</span>
</div>
</div>
</aside>
)
}

View File

@@ -28,7 +28,7 @@ export function ToeicPractice() {
}
return (
<div className="px-6 py-8 max-w-6xl mx-auto page-enter">
<div className="px-6 py-8 page-enter">
<div className="mb-8">
<h1 className="text-3xl font-extrabold text-slate-800 mb-2">Chọn Part TOEIC</h1>
<p className="text-slate-500">

View File

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

View File

@@ -10,7 +10,7 @@ export function ToeicTestList() {
})
return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
<div className="px-6 lg:px-10 py-10 page-enter">
{/* Editorial head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div>
@@ -25,11 +25,11 @@ export function ToeicTestList() {
</div>
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{Array.from({ length: 6 }).map((_, i) => (
<div className="grid gap-6" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))' }}>
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="rounded-2xl h-44 animate-pulse"
className="rounded-2xl h-64 animate-pulse"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
/>
))}
@@ -59,47 +59,80 @@ export function ToeicTestList() {
)}
{tests.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<div className="grid gap-6" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))' }}>
{tests.map((test) => (
<div
key={test.id}
className="rounded-2xl p-6 flex flex-col transition-all hover:-translate-y-1"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
className="rounded-3xl flex flex-col transition-all hover:-translate-y-1 hover:shadow-[0_20px_40px_-16px_rgba(15,17,20,0.12)]"
style={{
background: 'var(--at-surface)',
border: '1px solid var(--at-line)',
padding: 32,
}}
>
{test.categoryName && (
<span className="at-chip at-chip-brand self-start mb-3">
<span className="at-chip at-chip-brand self-start mb-5">
<span className="at-chip-dot" />
{test.categoryName}
</span>
)}
<h3
className="at-serif text-[20px] leading-[1.2] tracking-tight mb-2"
style={{ color: 'var(--at-ink)', fontWeight: 500 }}
className="at-serif tracking-tight mb-3"
style={{
color: 'var(--at-ink)',
fontWeight: 500,
fontSize: 28,
lineHeight: 1.15,
letterSpacing: '-0.02em',
}}
>
{test.title}
</h3>
{test.description && (
<p className="text-xs leading-[1.5] mb-3 line-clamp-2" style={{ color: 'var(--at-mute)' }}>
<p
className="line-clamp-2 mb-6"
style={{ color: 'var(--at-mute)', fontSize: 14, lineHeight: 1.55 }}
>
{test.description}
</p>
)}
<div className="flex items-center gap-4 text-xs mt-auto mb-4" style={{ color: 'var(--at-mute)' }}>
<span className="flex items-center gap-1">
<span className="material-symbols-outlined" style={{ fontSize: 13 }}>list_alt</span>
<b className="tabular-nums" style={{ color: 'var(--at-ink)' }}>{test.totalQuestions}</b> câu
<div
className="flex items-center gap-6 mt-auto mb-6"
style={{ color: 'var(--at-mute)', fontSize: 13 }}
>
<span className="flex items-center gap-1.5">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>list_alt</span>
<b
className="tabular-nums"
style={{ color: 'var(--at-ink)', fontSize: 16, fontWeight: 700 }}
>
{test.totalQuestions}
</b>
câu
</span>
<span className="flex items-center gap-1">
<span className="material-symbols-outlined" style={{ fontSize: 13 }}>timer</span>
<b className="tabular-nums" style={{ color: 'var(--at-ink)' }}>{test.durationMinutes}</b> phút
<span className="flex items-center gap-1.5">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>timer</span>
<b
className="tabular-nums"
style={{ color: 'var(--at-ink)', fontSize: 16, fontWeight: 700 }}
>
{test.durationMinutes}
</b>
phút
</span>
</div>
<button
onClick={() => navigate({ to: '/toeic/$testId', params: { testId: String(test.id) } })}
className="w-full py-2.5 rounded-xl text-[13px] font-semibold transition-opacity hover:opacity-90"
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
className="w-full rounded-xl font-semibold transition-opacity hover:opacity-90"
style={{
background: 'var(--at-ink)',
color: 'var(--at-paper)',
padding: '14px 20px',
fontSize: 14,
}}
>
Bắt đu
</button>

View File

@@ -99,7 +99,7 @@ export function WritingChecker() {
const wordCount = text.split(/\s+/).filter(Boolean).length
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 page head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div className="min-w-0">

View File

@@ -107,7 +107,7 @@ export function WritingHistory() {
if (!user) return null
return (
<section className="px-4 lg:px-6 pb-10 max-w-6xl mx-auto">
<section className="px-4 lg:px-6 pb-10">
<h2 className="text-lg font-bold text-slate-800 mb-4">Lịch sử chấm bài</h2>
{isLoading && (

View File

@@ -1,6 +1,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "./styles/mobile.css";
@custom-variant dark (&:is(.dark *));

View File

@@ -1,4 +1,4 @@
import { createRootRoute, Outlet } from '@tanstack/react-router'
import { createRootRoute, Outlet, useRouterState } from '@tanstack/react-router'
import { useEffect } from 'react'
import { Sidebar } from '@/components/layout/Sidebar'
import { AppHeader } from '@/components/layout/AppHeader'
@@ -10,18 +10,34 @@ export const Route = createRootRoute({
component: RootLayout,
})
// Routes that own the full viewport — hide sidebar, top header, and mobile nav.
const FULLSCREEN_ROUTES = new Set(['/toeic/session'])
function RootLayout() {
const initialize = useAuthStore((s) => s.initialize)
const pathname = useRouterState({ select: (s) => s.location.pathname })
const isFullscreen = FULLSCREEN_ROUTES.has(pathname)
useEffect(() => {
initialize()
}, [initialize])
if (isFullscreen) {
return (
<div className="min-h-screen" style={{ background: 'var(--at-paper-2)' }}>
<Outlet />
<AuthModal />
</div>
)
}
return (
<div className="min-h-screen bg-slate-50">
<div className="min-h-screen" style={{ background: 'var(--at-paper)' }}>
<Sidebar />
<AppHeader />
<main className="lg:ml-60 pt-16 pb-20 lg:pb-0 min-h-screen">
{/* Extra bottom padding on mobile to clear the floating tab bar
(68px pill + 10px margin + safe-area). */}
<main className="lg:ml-60 pt-16 pb-28 lg:pb-0 min-h-screen">
<Outlet />
</main>
<MobileNav />

View File

@@ -62,6 +62,6 @@ export const useTestStore = create<TestStore>()(
reset: () => set(INITIAL_STATE),
}),
{ name: 'test-store' },
{ name: 'test-store', version: 2 },
),
)

295
src/styles/mobile.css Normal file
View File

@@ -0,0 +1,295 @@
/* Mobile UI primitives — Atelier Mobile design.
Only kicks in on narrow viewports via `@media (max-width: 880px)`.
Uses the existing `--at-*` design tokens; does NOT touch desktop layouts. */
@media (max-width: 880px) {
/* Mobile page background gradient */
body {
background:
radial-gradient(60% 60% at 50% 0%, color-mix(in oklab, var(--at-brand) 6%, transparent) 0%, transparent 70%),
var(--at-paper);
}
}
/* -------- Mobile primitives (usable on any viewport) -------- */
/* Card — soft surface with hairline border and small shadow */
.m-card {
background: var(--at-surface);
border: 1px solid var(--at-line);
border-radius: 20px;
padding: 18px;
box-shadow: 0 1px 2px rgba(15, 17, 20, 0.04);
}
.m-card + .m-card {
margin-top: 12px;
}
.m-row {
display: flex;
align-items: center;
gap: 12px;
}
/* Big serif display numbers */
.m-num {
font-family: var(--at-serif);
font-weight: 400;
letter-spacing: -0.03em;
line-height: 0.95;
}
.m-num.xl {
font-size: 72px;
}
.m-num.lg {
font-size: 48px;
}
.m-num.md {
font-size: 32px;
}
/* Eyebrow */
.m-eyebrow {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--at-mute);
}
/* Chips */
.m-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
background: var(--at-paper-2);
color: var(--at-ink-2);
border: 1px solid var(--at-line);
}
.m-chip.brand {
background: var(--at-brand-soft);
color: var(--at-brand);
border-color: color-mix(in oklab, var(--at-brand) 18%, transparent);
}
.m-chip.good {
background: var(--at-good-soft);
color: var(--at-good);
border-color: color-mix(in oklab, var(--at-good) 20%, transparent);
}
.m-chip.streak {
background: var(--at-streak-soft);
color: var(--at-streak);
border-color: color-mix(in oklab, var(--at-streak) 22%, transparent);
}
/* Progress bar */
.m-bar {
width: 100%;
height: 8px;
border-radius: 99px;
background: var(--at-line-2);
overflow: hidden;
}
.m-bar > span {
display: block;
height: 100%;
background: var(--at-brand);
border-radius: 99px;
transition: width 0.6s cubic-bezier(0.2, 0.7, 0.2, 1);
}
/* Buttons */
.m-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 44px;
padding: 0 18px;
border-radius: 14px;
font-size: 14px;
font-weight: 600;
background: var(--at-surface);
color: var(--at-ink);
border: 1px solid var(--at-line);
transition: transform 0.08s ease;
}
.m-btn:active {
transform: scale(0.97);
}
.m-btn.primary {
background: var(--at-ink);
color: var(--at-paper);
border-color: var(--at-ink);
}
.m-btn.brand {
background: var(--at-brand);
color: white;
border-color: var(--at-brand);
box-shadow: 0 4px 14px -4px color-mix(in oklab, var(--at-brand) 60%, transparent);
}
.m-btn.block {
width: 100%;
}
.m-btn.sm {
height: 34px;
padding: 0 12px;
font-size: 12.5px;
}
/* Section header */
.m-section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 20px 4px 12px;
}
.m-section-title {
font-family: var(--at-serif);
font-weight: 400;
font-size: 22px;
letter-spacing: -0.02em;
line-height: 1.1;
}
.m-section-title i {
font-style: italic;
color: var(--at-brand);
}
.m-section-link {
font-size: 12.5px;
color: var(--at-brand);
font-weight: 600;
}
/* Topic tile */
.m-topic-tile {
border-radius: 18px;
padding: 14px;
background: var(--at-surface);
border: 1px solid var(--at-line);
text-align: left;
position: relative;
overflow: hidden;
min-height: 100px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
transition: transform 0.1s;
}
.m-topic-tile:active {
transform: scale(0.98);
}
/* Grids */
.m-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.m-grid-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
}
/* Screen transition */
.m-fade {
animation: mFade 0.35s cubic-bezier(0.2, 0.7, 0.2, 1);
}
@keyframes mFade {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* -------- Mobile-only responsive tweaks (only < 880px) -------- */
@media (max-width: 880px) {
/* Tab bar — floating glass pill */
.m-tabbar {
position: fixed;
left: 10px;
right: 10px;
bottom: calc(10px + env(safe-area-inset-bottom));
height: 68px;
border-radius: 28px;
background: color-mix(in oklab, var(--at-surface) 88%, transparent);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--at-line);
box-shadow:
0 -2px 14px rgba(15, 17, 20, 0.04),
0 14px 32px -12px rgba(15, 17, 20, 0.16);
display: grid;
grid-template-columns: repeat(5, 1fr);
align-items: stretch;
z-index: 30;
padding: 6px;
gap: 2px;
}
.m-tab {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
border-radius: 20px;
color: var(--at-mute);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 6px 4px;
transition: color 0.15s, background 0.15s;
}
.m-tab.is-active {
color: var(--at-brand);
background: var(--at-brand-softer);
}
.m-tab > svg {
transition: transform 0.15s;
}
.m-tab.is-active > svg {
transform: translateY(-1px) scale(1.08);
}
}
/* -------- Mobile top bar (floating title) -------- */
@media (max-width: 880px) {
.m-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 16px 16px 12px;
background: color-mix(in oklab, var(--at-paper) 92%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid var(--at-line);
position: sticky;
top: 0;
z-index: 20;
}
.m-topbar-title {
font-family: var(--at-serif);
font-weight: 400;
font-size: 26px;
letter-spacing: -0.02em;
line-height: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m-topbar-title i {
font-style: italic;
color: var(--at-brand);
}
}

View File

@@ -1,6 +1,7 @@
CREATE TABLE test (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
total_questions INT DEFAULT 0,
duration_minutes INT DEFAULT 120,
@@ -34,6 +35,7 @@ CREATE TABLE question_group (
audio_url VARCHAR(500),
image_url VARCHAR(500),
passage_text TEXT,
transcript TEXT,
display_order INT DEFAULT 0
);

View File

@@ -0,0 +1,102 @@
-- Migration 007: Fix gamification trigger that blocks auth signup
--
-- Problem: trigger `on_auth_user_created_gamification` fires after every
-- auth.users INSERT and writes to user_gamification + xu_transactions. If those
-- tables are missing or schema-drifted, the trigger raises and the entire
-- signup transaction rolls back → POST /auth/v1/signup returns 500.
--
-- Fix:
-- 1. Re-create gamification tables idempotently so the trigger has a target.
-- 2. Wrap inserts in BEGIN/EXCEPTION block so any future schema break logs a
-- warning instead of breaking auth signup.
-- 3. Replace the trigger function in place (DROP not needed because of CREATE
-- OR REPLACE).
--
-- Run in Supabase Dashboard → SQL Editor.
-- ============================================================
-- 1. Ensure tables exist (idempotent — same shape as 002_gamification.sql)
-- ============================================================
CREATE TABLE IF NOT EXISTS user_gamification (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
xp INT NOT NULL DEFAULT 0,
level TEXT NOT NULL DEFAULT 'beginner'
CHECK (level IN ('beginner', 'bronze', 'silver', 'gold', 'master')),
streak INT NOT NULL DEFAULT 0,
longest_streak INT NOT NULL DEFAULT 0,
last_active DATE,
xu INT NOT NULL DEFAULT 50,
freeze_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS xu_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
type TEXT NOT NULL
CHECK (type IN (
'earn_welcome', 'earn_daily', 'earn_streak', 'earn_ads',
'spend_freeze', 'spend_writing', 'spend_test'
)),
amount INT NOT NULL,
balance INT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_xu_transactions_user_date
ON xu_transactions(user_id, created_at DESC);
-- Re-apply RLS (idempotent — OR REPLACE not supported on policies, so drop-then-create)
ALTER TABLE user_gamification ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Users can read own gamification" ON user_gamification;
DROP POLICY IF EXISTS "Users can insert own gamification" ON user_gamification;
DROP POLICY IF EXISTS "Users can update own gamification" ON user_gamification;
CREATE POLICY "Users can read own gamification"
ON user_gamification FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own gamification"
ON user_gamification FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own gamification"
ON user_gamification FOR UPDATE USING (auth.uid() = user_id);
ALTER TABLE xu_transactions ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Users can read own xu transactions" ON xu_transactions;
DROP POLICY IF EXISTS "Users can insert own xu transactions" ON xu_transactions;
CREATE POLICY "Users can read own xu transactions"
ON xu_transactions FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own xu transactions"
ON xu_transactions FOR INSERT WITH CHECK (auth.uid() = user_id);
-- ============================================================
-- 2. Replace trigger function with one that NEVER blocks signup
-- ============================================================
-- Why the EXCEPTION wrapper:
-- A trigger error inside auth.users INSERT rolls back the whole transaction,
-- meaning a broken gamification side-effect kills the user's ability to sign
-- up. We swallow gamification failures and log a warning so signup always
-- succeeds; missing rows can be backfilled later.
CREATE OR REPLACE FUNCTION handle_new_user_gamification()
RETURNS TRIGGER AS $$
BEGIN
BEGIN
INSERT INTO user_gamification (user_id, xu)
VALUES (NEW.id, 50)
ON CONFLICT (user_id) DO NOTHING;
INSERT INTO xu_transactions (user_id, type, amount, balance, description)
VALUES (NEW.id, 'earn_welcome', 50, 50, 'Welcome bonus');
EXCEPTION WHEN OTHERS THEN
RAISE WARNING 'gamification side-effect failed for user %: %', NEW.id, SQLERRM;
END;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================
-- 3. Re-attach the trigger (idempotent)
-- ============================================================
DROP TRIGGER IF EXISTS on_auth_user_created_gamification ON auth.users;
CREATE TRIGGER on_auth_user_created_gamification
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION handle_new_user_gamification();

6466
test/test_1209.json Normal file

File diff suppressed because it is too large Load Diff

6466
test/test_1211.json Normal file

File diff suppressed because it is too large Load Diff

6466
test/test_1212.json Normal file

File diff suppressed because it is too large Load Diff

6466
test/test_1213.json Normal file

File diff suppressed because it is too large Load Diff

6466
test/test_1214.json Normal file

File diff suppressed because it is too large Load Diff

6466
test/test_224.json Normal file

File diff suppressed because it is too large Load Diff

6466
test/test_225.json Normal file

File diff suppressed because it is too large Load Diff

6466
test/test_226.json Normal file

File diff suppressed because it is too large Load Diff

6466
test/test_227.json Normal file

File diff suppressed because it is too large Load Diff

6466
test/test_228.json Normal file

File diff suppressed because it is too large Load Diff

6567
test/test_229.json Normal file

File diff suppressed because it is too large Load Diff

6576
test/test_231.json Normal file

File diff suppressed because it is too large Load Diff

6567
test/test_232.json Normal file

File diff suppressed because it is too large Load Diff

6585
test/test_266.json Normal file

File diff suppressed because it is too large Load Diff

6576
test/test_267.json Normal file

File diff suppressed because it is too large Load Diff