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", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint ." "lint": "eslint .",
"import:tests": "node scripts/import-tests.mjs"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",

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

View File

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

View File

@@ -64,7 +64,7 @@ export function Dashboard() {
if (!user) { if (!user) {
return ( 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> <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)' }}> <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. Đă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 ( return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter"> <div className="px-6 lg:px-10 py-10 page-enter">
{/* Editorial head */} {/* Editorial head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10"> <div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div> <div>

View File

@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth-store' import { useAuthStore } from '@/store/auth-store'
import { useAuthModalStore } from '@/store/auth-modal-store'
import { import {
fetchFlashcardTerms, fetchFlashcardTerms,
fetchUserProgress, fetchUserProgress,
@@ -34,7 +35,9 @@ function speak(word: string) {
export function FlashCardLearnPage({ listId }: Props) { export function FlashCardLearnPage({ listId }: Props) {
const navigate = useNavigate() const navigate = useNavigate()
const user = useAuthStore(s => s.user) const user = useAuthStore(s => s.user)
const openAuthModal = useAuthModalStore(s => s.open)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const isGuest = !user
const [isFlipped, setIsFlipped] = useState(false) const [isFlipped, setIsFlipped] = useState(false)
const [currentIdx, setCurrentIdx] = useState(0) const [currentIdx, setCurrentIdx] = useState(0)
@@ -213,6 +216,12 @@ export function FlashCardLearnPage({ listId }: Props) {
setTimeout(advance, 450) setTimeout(advance, 450)
}, [currentIdx, sessionTerms, user, saveAnswer, progressMap, advance]) }, [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 // Keyboard shortcuts
useEffect(() => { useEffect(() => {
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
@@ -222,14 +231,26 @@ export function FlashCardLearnPage({ listId }: Props) {
setIsFlipped(v => !v) setIsFlipped(v => !v)
return 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') if (e.key.toLowerCase() === 'j') handleAnswer('known')
else if (e.key.toLowerCase() === 'k') handleAnswer('hard') else if (e.key.toLowerCase() === 'k') handleAnswer('hard')
else if (e.key.toLowerCase() === 'i') handleAnswer('ignored') else if (e.key.toLowerCase() === 'i') handleAnswer('ignored')
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [isDone, isFlipped, currentIdx, sessionTerms, handleAnswer]) }, [isDone, isFlipped, currentIdx, sessionTerms, handleAnswer, isGuest, jumpTo])
const total = sessionTerms.length const total = sessionTerms.length
const progressPct = total > 0 ? Math.round((currentIdx / total) * 100) : 0 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 ( return (
<div <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" 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> </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="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')}> {isGuest ? (
<button onClick={() => handleAnswer('ignored')} disabled={!isFlipped} className="at-action" style={{ padding: '11px 14px', fontSize: 13 }}> <div className="flex flex-col gap-2.5">
Bỏ qua <span className="at-kbd">I</span> <div className="flex items-stretch gap-2.5 w-full">
</button> <button
<button onClick={() => handleAnswer('hard')} disabled={!isFlipped} className="at-action at-action-review" style={{ padding: '11px 14px', fontSize: 13 }}> onClick={() => currentIdx > 0 && jumpTo(currentIdx - 1)}
Cần ôn <span className="at-kbd">K</span> disabled={currentIdx === 0}
</button> className="at-action"
<button onClick={() => handleAnswer('known')} disabled={!isFlipped} className="at-action at-action-known" style={{ padding: '11px 14px', fontSize: 13 }}> 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> <span className="at-kbd"></span> Trước
</button> </button>
</div> <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> </div>
{/* Progress */} {/* Progress */}
<div className="mt-3 w-full" style={{ maxWidth: 420 }}> <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)]"> <div className="flex items-baseline justify-between mb-1.5 text-[12px] text-[var(--at-mute)]">
<span> <span>
<b className="text-[var(--at-ink)] tabular-nums">{currentIdx + 1}</b> / {total} ·{' '} <b className="text-[var(--at-ink)] tabular-nums">{currentIdx + 1}</b> / {total}
{sessionStats.known} biết · {sessionStats.learning} học · {sessionStats.ignored} bỏ {!isGuest && (
<> · {sessionStats.known} biết · {sessionStats.learning} học · {sessionStats.ignored} bỏ</>
)}
</span> </span>
<span className="at-pct" style={{ fontSize: 18 }}>{progressPct}%</span> <span className="at-pct" style={{ fontSize: 18 }}>{progressPct}%</span>
</div> </div>
@@ -501,37 +548,59 @@ export function FlashCardLearnPage({ listId }: Props) {
{/* Right sidebar */} {/* Right sidebar */}
<aside className="hidden lg:flex flex-col gap-3 min-h-0"> <aside className="hidden lg:flex flex-col gap-3 min-h-0">
{/* Today stats */} {/* Today stats — auth users; guests see a login nudge */}
<div {isGuest ? (
className="rounded-2xl p-4 flex-shrink-0" <div
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }} 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-2" style={{ fontSize: 11 }}>Hôm nay</div> >
<div className="grid grid-cols-2 gap-3 mt-1"> <div className="at-eyebrow mb-1" style={{ fontSize: 11, color: 'var(--at-brand-ink)' }}>Chế đ khách</div>
<div> <div
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}> className="at-serif"
Đã học style={{ fontSize: 18, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.2, color: 'var(--at-brand-ink)' }}
</div> >
<div Đăng nhập đ <i>ghi nhớ</i> tiến đ
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>
<div> <button
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}> onClick={() => openAuthModal('register')}
Đúng 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>
<div <div>
className="at-serif" <div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}>
style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-good)' }} Đúng
> </div>
{sessionStats.known} <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>
</div> </div>
</div> )}
{/* Cards in deck — compact rows (word only) */} {/* Cards in deck — compact rows (word only) */}
<div <div

View File

@@ -125,7 +125,7 @@ export function FlashCardListPage() {
}) })
return ( return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter"> <div className="px-6 lg:px-10 py-10 page-enter">
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10"> <div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div> <div>
<div className="at-eyebrow mb-3">Từ vựng TOEIC</div> <div className="at-eyebrow mb-3">Từ vựng TOEIC</div>

View File

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

View File

@@ -71,7 +71,7 @@ export function Vocabulary() {
.reverse() .reverse()
return ( 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 */} {/* Mobile topic chips */}
<div className="lg:hidden mb-4 overflow-x-auto pb-1"> <div className="lg:hidden mb-4 overflow-x-auto pb-1">
<div className="flex gap-2 w-max"> <div className="flex gap-2 w-max">

View File

@@ -35,7 +35,7 @@ export function Home() {
const firstName = user?.name ?? 'bạn' const firstName = user?.name ?? 'bạn'
return ( return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter"> <div className="px-6 lg:px-10 py-10 page-enter">
{/* Page head — editorial */} {/* Page head — editorial */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10"> <div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div> <div>

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -99,7 +99,7 @@ export function WritingChecker() {
const wordCount = text.split(/\s+/).filter(Boolean).length const wordCount = text.split(/\s+/).filter(Boolean).length
return ( return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter"> <div className="px-6 lg:px-10 py-10 page-enter">
{/* Editorial page head */} {/* Editorial page head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10"> <div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div className="min-w-0"> <div className="min-w-0">

View File

@@ -107,7 +107,7 @@ export function WritingHistory() {
if (!user) return null if (!user) return null
return ( 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> <h2 className="text-lg font-bold text-slate-800 mb-4">Lịch sử chấm bài</h2>
{isLoading && ( {isLoading && (

View File

@@ -1,6 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
@import "./styles/mobile.css";
@custom-variant dark (&:is(.dark *)); @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 { useEffect } from 'react'
import { Sidebar } from '@/components/layout/Sidebar' import { Sidebar } from '@/components/layout/Sidebar'
import { AppHeader } from '@/components/layout/AppHeader' import { AppHeader } from '@/components/layout/AppHeader'
@@ -10,18 +10,34 @@ export const Route = createRootRoute({
component: RootLayout, component: RootLayout,
}) })
// Routes that own the full viewport — hide sidebar, top header, and mobile nav.
const FULLSCREEN_ROUTES = new Set(['/toeic/session'])
function RootLayout() { function RootLayout() {
const initialize = useAuthStore((s) => s.initialize) const initialize = useAuthStore((s) => s.initialize)
const pathname = useRouterState({ select: (s) => s.location.pathname })
const isFullscreen = FULLSCREEN_ROUTES.has(pathname)
useEffect(() => { useEffect(() => {
initialize() initialize()
}, [initialize]) }, [initialize])
if (isFullscreen) {
return (
<div className="min-h-screen" style={{ background: 'var(--at-paper-2)' }}>
<Outlet />
<AuthModal />
</div>
)
}
return ( return (
<div className="min-h-screen bg-slate-50"> <div className="min-h-screen" style={{ background: 'var(--at-paper)' }}>
<Sidebar /> <Sidebar />
<AppHeader /> <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 /> <Outlet />
</main> </main>
<MobileNav /> <MobileNav />

View File

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

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

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