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