diff --git a/package.json b/package.json index c8028b5..c62cc8d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "lint": "eslint ." + "lint": "eslint .", + "import:tests": "node scripts/import-tests.mjs" }, "dependencies": { "@base-ui/react": "^1.3.0", diff --git a/scripts/import-tests.mjs b/scripts/import-tests.mjs new file mode 100644 index 0000000..2808945 --- /dev/null +++ b/scripts/import-tests.mjs @@ -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) +}) diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx index a45455a..df1435f 100644 --- a/src/components/layout/AppHeader.tsx +++ b/src/components/layout/AppHeader.tsx @@ -1,6 +1,8 @@ import { useRouterState } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' import { useTestStore } from '@/store/test-store' import { UserMenu } from '@/components/UserMenu' +import { fetchTestWithParts } from '@/features/toeic/api/test-list-api' const ROUTE_TITLES: Record = { '/': { eyebrow: 'Học TOEIC cùng AI', title: 'Trang chủ' }, @@ -11,6 +13,9 @@ const ROUTE_TITLES: Record` but not `/toeic/session`, `/toeic/result`, `/toeic/part/...` +const TEST_DETAIL_RE = /^\/toeic\/(\d+)$/ + function matchRouteLabel(pathname: string) { if (ROUTE_TITLES[pathname]) return ROUTE_TITLES[pathname] const keys = Object.keys(ROUTE_TITLES).sort((a, b) => b.length - a.length) @@ -25,6 +30,15 @@ export function AppHeader() { const { testName, parts, answers } = useTestStore() const pathname = location.pathname + // Show test title in header when viewing a specific test's part-selection page. + const testDetailMatch = pathname.match(TEST_DETAIL_RE) + const testId = testDetailMatch ? Number(testDetailMatch[1]) : null + const { data: testDetail } = useQuery({ + queryKey: ['test-detail', testId], + queryFn: () => fetchTestWithParts(testId!), + enabled: testId !== null, + }) + // In-session mode: show test progress instead of route title if (pathname === '/toeic/session') { const totalQuestions = parts.reduce((sum, p) => sum + p.questions.length, 0) @@ -48,7 +62,12 @@ export function AppHeader() { ) } - const { eyebrow, title, accent } = matchRouteLabel(pathname) + // Test-detail page: show test title in header + const routeLabel = testDetail + ? { eyebrow: 'Luyện đề TOEIC', title: testDetail.test.title } + : matchRouteLabel(pathname) + const { eyebrow, title } = routeLabel + const accent = 'accent' in routeLabel ? routeLabel.accent : undefined const renderTitle = () => { if (!accent || !title.includes(accent)) return title const [before, after] = title.split(accent) diff --git a/src/features/toeic/components/TestResult.tsx b/src/features/toeic/components/TestResult.tsx index da151bd..4d9c953 100644 --- a/src/features/toeic/components/TestResult.tsx +++ b/src/features/toeic/components/TestResult.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' import { useNavigate } from '@tanstack/react-router' -import { cn } from '@/lib/utils' +import { Check, Home, Minus, RotateCcw, Sparkles, X } from 'lucide-react' import { useTestStore } from '@/store/test-store' import { useRequireAuth } from '@/hooks/use-require-auth' import { useAuthStore } from '@/store/auth-store' @@ -10,11 +10,107 @@ import { XP_REWARDS } from '@/lib/gamification-service' const ANSWER_LABELS = ['A', 'B', 'C', 'D'] -function formatTime(s: number) { - const m = Math.floor(s / 60) - const sec = s % 60 - if (m === 0) return `${sec}s` - return `${m}m ${sec}s` +function formatMinSec(s: number) { + const mm = String(Math.floor(s / 60)).padStart(2, '0') + const ss = String(s % 60).padStart(2, '0') + return `${mm}:${ss}` +} + +function Ring({ percent, size = 110, stroke = 7, color, bg, children }: { + percent: number; size?: number; stroke?: number; color: string; bg: string; children: React.ReactNode +}) { + const cx = size / 2 + const r = cx - stroke + const c = 2 * Math.PI * r + const offset = c - (Math.min(percent, 100) / 100) * c + return ( +
+ + + + +
{children}
+
+ ) +} + +function HeadlineByPercent({ pct }: { pct: number }) { + if (pct >= 80) return <>Xuất sắc! + if (pct >= 60) return <>Khá tốt + return <>Cần ôn thêm +} + +// Hero theme by estimated TOEIC score band — aligned to ETS proficiency levels. +// <225 Novice · 225–549 Elementary→Basic · 550–749 Working · ≥750 Professional +// Light backgrounds use ink text; dark use paper text. +function heroThemeByScore(score: number) { + if (score < 225) { + return { + bg: 'var(--at-warm)', + text: 'var(--at-paper)', + muted: 'rgba(250,248,243,0.6)', + labelMuted: 'rgba(250,248,243,0.5)', + ringBg: 'rgba(255,255,255,0.15)', + ringColor: 'var(--at-paper)', + glow: 'rgba(255,255,255,0.18)', + btnSolidBg: 'var(--at-paper)', + btnSolidText: 'var(--at-ink)', + btnGhostBorder: 'rgba(255,255,255,0.25)', + btnGhostHover: 'rgba(255,255,255,0.1)', + } + } + if (score < 550) { + return { + bg: 'var(--at-warm-soft)', + text: 'var(--at-ink)', + muted: 'var(--at-mute)', + labelMuted: 'var(--at-mute)', + ringBg: 'rgba(15,17,20,0.08)', + ringColor: 'var(--at-warm)', + glow: 'rgba(210,106,59,0.18)', + btnSolidBg: 'var(--at-ink)', + btnSolidText: 'var(--at-paper)', + btnGhostBorder: 'var(--at-line)', + btnGhostHover: 'rgba(15,17,20,0.05)', + } + } + if (score < 750) { + return { + bg: 'var(--at-good-soft)', + text: 'var(--at-ink)', + muted: 'var(--at-mute)', + labelMuted: 'var(--at-mute)', + ringBg: 'rgba(15,17,20,0.08)', + ringColor: 'var(--at-good)', + glow: 'rgba(47,125,74,0.18)', + btnSolidBg: 'var(--at-ink)', + btnSolidText: 'var(--at-paper)', + btnGhostBorder: 'var(--at-line)', + btnGhostHover: 'rgba(15,17,20,0.05)', + } + } + return { + bg: 'var(--at-good)', + text: 'var(--at-paper)', + muted: 'rgba(250,248,243,0.65)', + labelMuted: 'rgba(250,248,243,0.55)', + ringBg: 'rgba(255,255,255,0.15)', + ringColor: 'var(--at-paper)', + glow: 'rgba(255,255,255,0.18)', + btnSolidBg: 'var(--at-paper)', + btnSolidText: 'var(--at-ink)', + btnGhostBorder: 'rgba(255,255,255,0.25)', + btnGhostHover: 'rgba(255,255,255,0.1)', + } } export function TestResult() { @@ -25,7 +121,6 @@ export function TestResult() { const savedRef = useRef(false) const { mutate: awardActivity } = useAwardActivity() - // Flatten all questions across parts const allQuestions = parts.flatMap(p => p.questions) useEffect(() => { @@ -36,12 +131,12 @@ export function TestResult() { useEffect(() => { if (!user || savedRef.current || allQuestions.length === 0) return savedRef.current = true - const correct = allQuestions.filter(q => answers[q.id] === q.correctAnswer).length + const correctCount = allQuestions.filter(q => answers[q.id] === q.correctAnswer).length awardActivity({ xp: XP_REWARDS.test }) saveTestResult(user.id, { testId, selectedParts: parts.map(p => p.partNumber), - score: correct, + score: correctCount, total: allQuestions.length, timeUsed, answers: allQuestions.map(q => ({ @@ -54,10 +149,15 @@ export function TestResult() { if (allQuestions.length === 0) { return ( -
-

Không có dữ liệu bài thi.

-
@@ -68,109 +168,367 @@ export function TestResult() { const wrong = allQuestions.filter(q => answers[q.id] !== null && answers[q.id] !== undefined && answers[q.id] !== q.correctAnswer).length const skipped = allQuestions.filter(q => answers[q.id] === null || answers[q.id] === undefined).length const total = allQuestions.length - const percent = total > 0 ? Math.round((correct / total) * 100) : 0 - const circumference = 2 * Math.PI * 52 - const offset = circumference - (percent / 100) * circumference + const pct = total > 0 ? Math.round((correct / total) * 100) : 0 + // TOEIC scaled score 10–990. Linear approximation from raw accuracy; + // real ETS scaling is non-linear, but good enough as a practice estimate. + const toeicEstimate = Math.round(10 + pct * 9.8) + const theme = heroThemeByScore(toeicEstimate) return ( -
- {/* Score header */} -
-
-
- - - = 70 ? '#16a34a' : percent >= 50 ? '#2563eb' : '#dc2626'} - strokeWidth="8" strokeLinecap="round" - strokeDasharray={circumference} strokeDashoffset={offset} - className="transition-all duration-700" /> - -
- {correct}/{total} - điểm +
+ {/* Top row — headline card (dark) + TOEIC estimate card */} +
+ {/* Hero result card — themed by score */} +
+
+
+
+ Kết quả
-
- -
-
- {percent >= 80 ? 'Xuất sắc!' : percent >= 60 ? 'Hoàn thành!' : 'Cố gắng hơn nhé!'} +
+
-
{testName}
-
- {[ - { label: 'Đúng', value: correct, cls: 'bg-green-50 border-green-100 text-green-600' }, - { label: 'Sai', value: wrong, cls: 'bg-red-50 border-red-100 text-red-600' }, - { label: 'Bỏ qua', value: skipped, cls: 'bg-slate-50 border-slate-200 text-slate-500' }, - { label: 'Thời gian', value: formatTime(timeUsed), cls: 'bg-blue-50 border-blue-100 text-blue-600' }, - ].map(({ label, value, cls }) => ( -
-
{value}
-
{label}
+
+ {testName} +
+
+ +
+
+ {pct} +
+
+ điểm +
- ))} +
+
+
+ Đúng +
+
+ {correct} + + /{total} + +
+
+ Thời gian +
+
+ {formatMinSec(timeUsed)} +
+
+
+ + +
+
-
- - + {/* TOEIC estimate card */} +
+
+ Dự kiến TOEIC +
+
+ Điểm ước tính +
+
+ Dựa trên bài thi hôm nay +
+
+
+ {toeicEstimate} +
+
+ / 990 +
+
+
+ +
+
+ 0 + Mục tiêu: 850 + 990 +
+
+
+ + +
- {/* Answer review grouped by part */} + {/* Per-part review */} {parts.map(part => ( -
-

Part {part.partNumber} — {part.partName}

-
+
+
+
+
+ Part {part.partNumber} +
+
+ {part.partName} +
+
+
+ }> + {part.questions.filter(q => answers[q.id] === q.correctAnswer).length} đúng + + }> + {part.questions.filter(q => answers[q.id] !== null && answers[q.id] !== undefined && answers[q.id] !== q.correctAnswer).length} sai + +
+
+ +
{part.questions.map((q, i) => { const userAnswer = answers[q.id] ?? null const isCorrect = userAnswer === q.correctAnswer const isSkipped = userAnswer === null || userAnswer === undefined + const statusColor = isCorrect + ? 'var(--at-good)' + : isSkipped + ? 'var(--at-mute)' + : 'var(--at-bad)' + const statusBg = isCorrect + ? 'var(--at-good-soft)' + : isSkipped + ? 'var(--at-line-2)' + : 'var(--at-bad-soft)' return ( -
-
- {i + 1} -
- {q.text &&

{q.text}

} -
- {q.options.map((opt, j) => ( - - {ANSWER_LABELS[j]}. {opt} - - ))} -
- {q.explanation && ( -

- Giải thích: {q.explanation} -

+
+
+ {isCorrect + ? + : isSkipped + ? + : } +
+ +
+
+ + Câu {i + 1}.{' '} + + {q.text || — (nghe/nhìn)} +
+
+ Đáp án đúng:{' '} + + {ANSWER_LABELS[q.correctAnswer]}. {q.options[q.correctAnswer]} + + {!isCorrect && !isSkipped && userAnswer !== null && ( + <> + {' · '}Bạn chọn:{' '} + + {ANSWER_LABELS[userAnswer]}. {q.options[userAnswer]} + + )}
- - {isCorrect - ? check_circle - : isSkipped - ? remove_circle - : cancel} - + {q.explanation && ( +
+ + + Giải thích + + {q.explanation} +
+ )}
) @@ -181,3 +539,48 @@ export function TestResult() {
) } + +function Stat({ label, value, color }: { label: string; value: number; color: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ) +} + +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 ( + + {icon} + {children} + + ) +} diff --git a/src/features/toeic/components/TestSession.tsx b/src/features/toeic/components/TestSession.tsx index 32be51a..303caec 100644 --- a/src/features/toeic/components/TestSession.tsx +++ b/src/features/toeic/components/TestSession.tsx @@ -1,5 +1,6 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useNavigate } from '@tanstack/react-router' +import { Play } from 'lucide-react' import { cn } from '@/lib/utils' import { useTestStore } from '@/store/test-store' import { useRequireAuth } from '@/hooks/use-require-auth' @@ -9,82 +10,361 @@ import { TestSessionFooter } from './TestSessionFooter' import type { Question } from '@/types' const ANSWER_LABELS = ['A', 'B', 'C', 'D'] +const LETTER_PLACEHOLDER_RE = + /^(Statement|Response|Choice)\s+[A-D]$/i -function QuestionCard({ - question, globalNum, answer, onSelect, +interface QuestionGroup { + groupId: number + questions: Question[] + passageText?: string + audioUrl?: string + imageUrl?: string +} + +function groupByGroupId(questions: Question[]): QuestionGroup[] { + const groups: QuestionGroup[] = [] + for (const q of questions) { + const last = groups[groups.length - 1] + if (last && last.groupId === q.groupId) { + last.questions.push(q) + } else { + groups.push({ + groupId: q.groupId, + questions: [q], + passageText: q.passageText, + audioUrl: q.audioUrl, + imageUrl: q.imageUrl, + }) + } + } + return groups +} + +function PassageBlock({ group }: { group: QuestionGroup }) { + const { audioUrl, imageUrl, passageText } = group + + if (!audioUrl && !imageUrl && !passageText) return null + + return ( +
+ {audioUrl && ( +
+
+ )} + + {imageUrl && ( +
+ +
+ )} + + {!audioUrl && !imageUrl && ( + // No audio/image → text-only passage (Part 6/7) +
{passageText}
+ )} + + {(audioUrl || imageUrl) && passageText && ( +
{passageText}
+ )} +
+ ) +} + +function AudioPlaceholder({ label }: { label: string }) { + return ( +
+ +
+
+ {label} +
+
+ +
+
+
+ ) +} + +function QuestionBlock({ + question, globalNum, answer, onSelect, isFirst, registerRef, }: { question: Question globalNum: number answer: number | null onSelect: (idx: number) => void + isFirst: boolean + registerRef: (el: HTMLDivElement | null) => void }) { return ( -
- - Câu {globalNum} - - - {question.passageText && ( -
- {question.passageText} -
- )} - {question.audioUrl && ( -