This commit is contained in:
2026-04-18 23:16:52 +07:00
parent 3e0b3f6a6d
commit 309609fccb
32 changed files with 2261 additions and 1030 deletions

View File

@@ -10,6 +10,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght,SOFT,WONK@0,9..144,300..700,0..100,0..1;1,9..144,300..700,0..100,0..1&family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>

View File

@@ -2,30 +2,79 @@ import { useRouterState } from '@tanstack/react-router'
import { useTestStore } from '@/store/test-store'
import { UserMenu } from '@/components/UserMenu'
const ROUTE_TITLES: Record<string, string> = {
'/': 'Trang chủ',
'/writing': 'AI Chấm Writing',
'/flash-card': 'Flash Card',
'/toeic': 'Luyện đề TOEIC',
'/toeic/session': '', // dynamic — filled below
'/toeic/result': 'Kết quả bài thi',
const ROUTE_TITLES: Record<string, { eyebrow: string; title: string; accent?: string }> = {
'/': { eyebrow: 'Học TOEIC cùng AI', title: 'Trang chủ' },
'/archivement': { eyebrow: 'Thành tích của bạn', title: 'Tôi học', accent: 'học' },
'/toeic': { eyebrow: 'Luyện đề', title: 'TOEIC Mock Tests', accent: 'Mock' },
'/writing': { eyebrow: 'AI Coach', title: 'Chấm Writing', accent: 'Writing' },
'/flash-card': { eyebrow: 'Từ vựng TOEIC', title: 'Flash Card', accent: 'Card' },
'/settings': { eyebrow: 'Tuỳ chỉnh', title: 'Cài đặt' },
}
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)
for (const k of keys) {
if (k !== '/' && pathname.startsWith(k)) return ROUTE_TITLES[k]
}
return { eyebrow: 'EnglishAI', title: 'EnglishAI' }
}
export function AppHeader() {
const { location } = useRouterState()
const { partId, partName, answers, questions } = useTestStore()
const { testName, parts, answers } = useTestStore()
const pathname = location.pathname
let title = ROUTE_TITLES[pathname] ?? 'EnglishAI'
// In-session mode: show test progress instead of route title
if (pathname === '/toeic/session') {
const answered = answers.filter((a) => a !== null).length
title = `Part ${partId}${partName} · ${answered}/${questions.length} câu`
const totalQuestions = parts.reduce((sum, p) => sum + p.questions.length, 0)
const answered = Object.values(answers).filter((a) => a !== null).length
return (
<header
className="fixed top-0 right-0 left-0 lg:left-60 h-16 z-40 flex items-center justify-between px-6 backdrop-blur-md"
style={{
background: 'color-mix(in oklab, var(--at-paper) 88%, transparent)',
borderBottom: '1px solid var(--at-line)',
}}
>
<div>
<div className="at-eyebrow" style={{ fontSize: 10, marginBottom: 2 }}>Phiên thi</div>
<div className="at-serif text-[15px]" style={{ color: 'var(--at-ink)', fontWeight: 500, letterSpacing: '-0.01em' }}>
{testName} · <i className="italic" style={{ color: 'var(--at-brand)' }}>{answered}/{totalQuestions}</i> câu
</div>
</div>
<UserMenu />
</header>
)
}
const { eyebrow, title, accent } = matchRouteLabel(pathname)
const renderTitle = () => {
if (!accent || !title.includes(accent)) return title
const [before, after] = title.split(accent)
return (
<>
{before}
<i className="italic" style={{ color: 'var(--at-brand)' }}>{accent}</i>
{after}
</>
)
}
return (
<header className="fixed top-0 right-0 left-0 lg:left-60 h-16 bg-white/90 backdrop-blur-md border-b border-slate-200 z-40 flex items-center justify-between px-6">
<span className="text-sm font-semibold text-slate-700">{title}</span>
<header
className="fixed top-0 right-0 left-0 lg:left-60 h-16 z-40 flex items-center justify-between px-6 backdrop-blur-md"
style={{
background: 'color-mix(in oklab, var(--at-paper) 88%, transparent)',
borderBottom: '1px solid var(--at-line)',
}}
>
<div>
<div className="at-eyebrow" style={{ fontSize: 10, marginBottom: 2 }}>{eyebrow}</div>
<div className="at-serif text-[15px]" style={{ color: 'var(--at-ink)', fontWeight: 500, letterSpacing: '-0.01em' }}>
{renderTitle()}
</div>
</div>
<UserMenu />
</header>
)

View File

@@ -3,7 +3,7 @@ import { cn } from '@/lib/utils'
const NAV_ITEMS = [
{ to: '/', label: 'Home', icon: 'home', matchPrefix: '/', exact: true },
{ to: '/dashboard', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/dashboard', exact: false },
{ to: '/archivement', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/archivement', exact: false },
{ to: '/toeic', label: 'Luyện đề', icon: 'assignment', matchPrefix: '/toeic', exact: false },
{ to: '/writing', label: 'Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false },
{ to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false },

View File

@@ -5,7 +5,7 @@ import { useAuthModalStore } from '@/store/auth-modal-store'
const NAV_ITEMS = [
{ to: '/', label: 'Trang chủ', icon: 'home', matchPrefix: '/', exact: true },
{ to: '/dashboard', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/dashboard', exact: false },
{ to: '/archivement', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/archivement', exact: false },
{ to: '/toeic', label: 'Luyện đề TOEIC', icon: 'assignment', matchPrefix: '/toeic', exact: false },
{ to: '/writing', label: 'AI Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false },
{ to: '/flash-card', label: 'Flash Card', icon: 'menu_book', matchPrefix: '/flash-card', exact: false },
@@ -23,60 +23,111 @@ export function Sidebar() {
const openModal = useAuthModalStore((s) => s.open)
return (
<aside className="hidden lg:flex fixed inset-y-0 left-0 w-60 flex-col bg-slate-50 border-r border-slate-200 z-50">
<aside
className="hidden lg:flex fixed inset-y-0 left-0 w-60 flex-col z-50"
style={{
background: 'var(--at-paper)',
borderRight: '1px solid var(--at-line)',
}}
>
{/* Brand */}
<div className="px-6 py-5 border-b border-slate-200">
<div className="text-xl font-extrabold text-blue-600 tracking-tight">EnglishAI</div>
<div className="text-xs text-slate-400 mt-0.5">Học tập thông minh</div>
<div className="px-5 pt-7 pb-9 flex items-start gap-2.5">
<div
className="w-[34px] h-[34px] rounded-[10px] grid place-items-center flex-shrink-0 at-serif italic"
style={{
background: 'var(--at-ink)',
color: 'var(--at-paper)',
fontSize: 20,
fontWeight: 500,
letterSpacing: '-0.02em',
}}
>
E
</div>
<div>
<div className="at-serif" style={{ fontSize: 18, fontWeight: 500, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-ink)' }}>
EnglishAI
</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--at-mute)', letterSpacing: '0.14em', textTransform: 'uppercase', marginTop: 2 }}>
TOEIC Curator
</div>
</div>
</div>
{/* Nav */}
<nav className="flex-1 py-3 overflow-y-auto">
{NAV_ITEMS.map((item) => {
const active = isActive(pathname, item.matchPrefix, item.exact)
return (
<Link
key={item.to}
to={item.to}
className={cn(
'flex items-center gap-3 mx-2 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150',
active
? 'bg-white text-blue-600 font-semibold shadow-sm'
: 'text-slate-500 hover:bg-white/70 hover:text-slate-800',
)}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>
{item.icon}
</span>
{item.label}
</Link>
)
})}
<nav className="flex-1 px-4 overflow-y-auto">
<div className="px-3 pb-2" style={{ fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase', color: 'var(--at-mute-2)', fontWeight: 600 }}>
Học tập
</div>
<div className="flex flex-col gap-0.5">
{NAV_ITEMS.map((item) => {
const active = isActive(pathname, item.matchPrefix, item.exact)
return (
<Link
key={item.to}
to={item.to}
className={cn(
'relative flex items-center gap-3 px-3 py-2.5 rounded-[10px] text-[13.5px] font-medium transition-colors',
)}
style={{
background: active ? 'var(--at-line-2)' : 'transparent',
color: active ? 'var(--at-ink)' : 'var(--at-ink-2)',
}}
>
{active && (
<span
className="absolute top-2 bottom-2 rounded-full"
style={{ left: -18, width: 2, background: 'var(--at-brand)' }}
/>
)}
<span
className="material-symbols-outlined"
style={{ fontSize: 20, color: active ? 'var(--at-brand)' : 'var(--at-mute)' }}
>
{item.icon}
</span>
{item.label}
</Link>
)
})}
</div>
</nav>
{/* User */}
<div className="px-3 py-4 border-t border-slate-200">
<div className="px-3 py-4">
{user ? (
<div className="flex items-center gap-3 bg-white rounded-xl px-3 py-2.5">
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-bold flex-shrink-0">
<div
className="flex items-center gap-2.5 px-2.5 py-2.5 rounded-xl"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div
className="w-9 h-9 rounded-[10px] grid place-items-center flex-shrink-0 at-serif italic"
style={{
background: 'linear-gradient(135deg, #F0E6D8, #E5D4B7)',
color: 'var(--at-ink)',
fontSize: 16,
fontWeight: 500,
}}
>
{user.name.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{user.name}</div>
<div className="text-xs text-slate-400 truncate">{user.email}</div>
<div className="text-[13px] font-semibold truncate" style={{ color: 'var(--at-ink)' }}>{user.name}</div>
<div className="text-[11px] truncate" style={{ color: 'var(--at-mute)' }}>{user.email}</div>
</div>
</div>
) : (
<button
onClick={() => openModal('login')}
className="w-full flex items-center gap-3 bg-white rounded-xl px-3 py-2.5 hover:bg-blue-50 transition-colors group"
className="w-full flex items-center gap-2.5 px-2.5 py-2.5 rounded-xl hover:bg-[var(--at-line-2)] transition-colors"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div className="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center flex-shrink-0 group-hover:bg-blue-100 transition-colors">
<span className="material-symbols-outlined text-slate-400 group-hover:text-blue-600 transition-colors" style={{ fontSize: 18 }}>person</span>
<div className="w-9 h-9 rounded-[10px] grid place-items-center flex-shrink-0" style={{ background: 'var(--at-line-2)' }}>
<span className="material-symbols-outlined" style={{ fontSize: 18, color: 'var(--at-mute)' }}>person</span>
</div>
<div className="min-w-0 text-left">
<div className="text-sm font-semibold text-slate-600">Khách</div>
<div className="text-xs text-blue-600 font-medium">Đăng nhập </div>
<div className="text-[13px] font-semibold" style={{ color: 'var(--at-ink-2)' }}>Khách</div>
<div className="text-[11px] font-medium at-serif italic" style={{ color: 'var(--at-brand)' }}>Đăng nhập </div>
</div>
</button>
)}

View File

@@ -3,22 +3,59 @@ import { useAuthStore } from '@/store/auth-store'
import { useAuthModalStore } from '@/store/auth-modal-store'
import { useGamification, useLeaderboard } from '@/hooks/use-gamification'
import { XP_REWARDS } from '@/lib/gamification-service'
import { StatsRow } from './StatsRow'
import { XpProgressCard } from './XpProgressCard'
import { WeeklySection } from './WeeklySection'
import { XuEconomyCard } from './XuEconomyCard'
import { LeaderboardCard } from './LeaderboardCard'
// Numeric level from XP (1 per 100 XP, min 1)
export function calcNumericLevel(xp: number) {
return Math.max(1, Math.floor(xp / 100))
const LEVEL_LABEL: Record<string, string> = {
beginner: 'Beginner',
bronze: 'Bronze',
silver: 'Silver',
gold: 'Gold',
master: 'Master',
}
// XP needed for next numeric level
export function calcXpNextLevel(xp: number) {
function calcNumericLevel(xp: number) {
return Math.max(1, Math.floor(xp / 100))
}
function xpForNext(xp: number) {
return (Math.floor(xp / 100) + 1) * 100
}
const EARN_ITEMS = [
{ label: 'Hoàn thành mục tiêu ngày', amt: 10 },
{ label: 'Mốc chuỗi (Streak)', amt: 20 },
{ label: 'Xem quảng cáo', amt: 5 },
{ label: 'Chia sẻ với bạn bè', amt: 15 },
]
const SPEND_ITEMS = [
{ label: 'Streak Freeze', amt: 20, desc: 'Giữ streak 1 ngày nghỉ' },
{ label: 'AI Writing Feedback', amt: 30, desc: 'Phân tích bài viết sâu' },
{ label: 'Bộ thẻ Premium', amt: 50, desc: 'Mở khoá toàn bộ chủ đề' },
{ label: 'Đổi theme hiếm', amt: 40, desc: 'Giao diện Atelier Noir' },
]
type Badge = { id: string; name: string; desc: string; earned: boolean; progress?: number; icon: string; color: string }
const BADGES: Badge[] = [
{ id: 'b1', name: 'Khởi hành', desc: 'Học ngày đầu tiên', earned: true, icon: 'auto_awesome', color: 'var(--at-brand)' },
{ id: 'b2', name: 'Một tuần', desc: '7 ngày liên tiếp', earned: false, progress: 40, icon: 'local_fire_department', color: 'var(--at-streak)' },
{ id: 'b3', name: 'Bền bỉ', desc: '30 ngày liên tiếp', earned: false, progress: 10, icon: 'local_fire_department', color: 'var(--at-warm)' },
{ id: 'b4', name: 'Mọt sách', desc: 'Thuộc 100 từ vựng', earned: false, progress: 30, icon: 'style', color: '#8B5CF6' },
{ id: 'b5', name: 'Nhà ngôn ngữ', desc: 'Thuộc 500 từ vựng', earned: false, progress: 10, icon: 'style', color: '#8B5CF6' },
{ id: 'b6', name: 'Điểm số vàng', desc: 'Đạt 800+ TOEIC', earned: false, progress: 20, icon: 'emoji_events', color: 'var(--at-good)' },
{ id: 'b7', name: 'Thí sinh', desc: 'Hoàn thành 10 đề full', earned: false, progress: 0, icon: 'fact_check', color: 'var(--at-brand)' },
{ id: 'b8', name: 'Cây viết', desc: 'Gửi 20 bài AI Writing', earned: false, progress: 10, icon: 'edit_note', color: 'var(--at-good)' },
]
function Coin({ size = 14 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'inline-block', verticalAlign: '-2px' }}>
<circle cx="12" cy="12" r="10" fill="#F5B94A" stroke="#C9902F" strokeWidth="1.2" />
<circle cx="12" cy="12" r="7" fill="none" stroke="#C9902F" strokeWidth="0.8" opacity="0.6" />
<text x="12" y="15.5" textAnchor="middle" fontFamily="var(--at-serif)" fontSize="9.5" fontWeight="700" fill="#7B5210">XU</text>
</svg>
)
}
export function Dashboard() {
const user = useAuthStore((s) => s.user)
const openModal = useAuthModalStore((s) => s.open)
@@ -27,15 +64,15 @@ export function Dashboard() {
if (!user) {
return (
<div className="px-4 lg:px-6 py-12 max-w-6xl mx-auto flex flex-col items-center text-center gap-4">
<span className="material-symbols-outlined text-slate-300" style={{ fontSize: 64 }}>emoji_events</span>
<h1 className="text-xl font-bold text-slate-700">Bảng thành tích</h1>
<p className="text-slate-400 text-sm max-w-xs">
<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="at-serif italic text-5xl" style={{ color: 'var(--at-mute-2)' }}>Thành tích</div>
<p className="max-w-sm" style={{ color: 'var(--at-mute)' }}>
Đăng nhập đ xem streak, XP, Xu bảng xếp hạng của bạn.
</p>
<button
onClick={() => openModal('login')}
className="mt-2 px-6 py-2.5 bg-blue-600 text-white rounded-full font-bold text-sm hover:bg-blue-700 transition-colors"
className="mt-2 px-6 py-2.5 rounded-xl font-semibold text-sm hover:opacity-90 transition"
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
>
Đăng nhập
</button>
@@ -43,53 +80,524 @@ export function Dashboard() {
)
}
// Derive weekly completed from leaderboard XP
const userLbRow = leaderboard?.find((r) => r.userId === user.id)
const weeklyXp = userLbRow?.xpEarned ?? 0
const weeklyCompleted = Math.min(Math.floor(weeklyXp / XP_REWARDS.test), 5)
const xu = gam?.xu ?? 50
const streak = gam?.streak ?? 0
const xp = gam?.xp ?? 0
const level = gam?.level ?? 'beginner'
const lastActive = gam?.lastActive ?? null
const levelLabel = LEVEL_LABEL[gam?.level ?? 'beginner']
const numericLevel = calcNumericLevel(xp)
const nextLevelXp = xpForNext(xp)
const xpIntoLevel = xp - numericLevel * 100
const levelPct = Math.round((xpIntoLevel / 100) * 100)
const xpLeft = nextLevelXp - xp
// Week metrics
const userLbRow = leaderboard?.find((r) => r.userId === user.id)
const weeklyXp = userLbRow?.xpEarned ?? 0
const weeklyCompleted = Math.min(Math.floor(weeklyXp / XP_REWARDS.test), 5)
const weekGoalTotal = 5
// 7-day history mock pattern — actual tracking would need daily_activity table
const todayDayIdx = (new Date().getDay() + 6) % 7 // Mon=0..Sun=6
const history = ['T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'CN'].map((d, i) => {
if (i === todayDayIdx) return { d, state: 'today' as const }
if (i < todayDayIdx) return { d, state: i < weeklyCompleted ? 'done' as const : 'empty' as const }
return { d, state: 'future' as const }
})
// Leaderboard display (top 5, highlight self)
const board = (leaderboard ?? []).slice(0, 5).map((row, idx) => ({
rank: idx + 1,
name: row.userId === user.id ? `${user.name} (Bạn)` : `User ${row.userId.slice(0, 6)}`,
xp: row.xpEarned,
you: row.userId === user.id,
avatar: (row.userId === user.id ? user.name : 'U').charAt(0).toUpperCase(),
}))
return (
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">Bảng thành tích</h1>
<p className="text-slate-400 text-sm">
Xin chào, <span className="font-semibold text-slate-600">{user.name}</span> tiếp tục chuỗi học tập nhé!
</p>
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
{/* Editorial head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div>
<div className="at-eyebrow mb-3">Thành tích</div>
<h1 className="at-title text-4xl lg:text-[44px]">
Bảng <i>thành tích</i>
</h1>
<p className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
Xin chào, <b style={{ color: 'var(--at-ink)' }}>{user.name}</b> tiếp tục chuỗi học tập nhé!
</p>
</div>
<div className="flex gap-2.5 flex-shrink-0">
<button
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13.5px] font-semibold hover:bg-[var(--at-line-2)] transition-colors"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 15 }}>share</span>
Chia sẻ
</button>
<Link
to="/toeic"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-[13.5px] font-semibold hover:opacity-90 transition-opacity"
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 15 }}>play_arrow</span>
Học tiếp
</Link>
</div>
</div>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-5">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-xl p-6 shadow-sm h-32 animate-pulse bg-slate-100" />
<div key={i} className="rounded-2xl h-32 animate-pulse" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }} />
))}
</div>
) : (
<StatsRow xu={xu} streak={streak} xp={xp} level={level} />
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-5" style={{ gridTemplateColumns: '1fr 1.2fr 1fr' }}>
{/* XU */}
<div
className="rounded-2xl p-5 relative overflow-hidden"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div
className="absolute inset-0 pointer-events-none"
style={{ background: 'radial-gradient(120% 80% at 100% 0%, color-mix(in oklab, #F5B94A 18%, transparent) 0%, transparent 55%)' }}
/>
<div className="relative">
<div className="at-eyebrow" style={{ color: '#B88432' }}>Số Xu</div>
<div className="flex items-baseline gap-2.5 mt-2">
<div className="at-serif" style={{ fontSize: 54, fontWeight: 400, letterSpacing: '-0.03em', lineHeight: 0.95 }}>
{xu}
</div>
<Coin size={26} />
</div>
<div className="text-[12.5px] mt-2.5 max-w-[200px]" style={{ color: 'var(--at-mute)' }}>
Dùng đ mở tính năng premium, freeze streak hoặc đi giao diện.
</div>
</div>
</div>
{/* STREAK (featured, ink) */}
<div
className="rounded-2xl p-5 relative overflow-hidden"
style={{
background: 'linear-gradient(135deg, var(--at-ink) 0%, color-mix(in oklab, var(--at-ink) 88%, var(--at-brand)) 100%)',
color: 'var(--at-paper)',
}}
>
<div
className="absolute at-serif italic"
style={{ top: -20, right: -20, fontSize: 160, opacity: 0.08, lineHeight: 1 }}
>
</div>
<div className="at-eyebrow" style={{ color: 'color-mix(in oklab, var(--at-paper) 70%, transparent)' }}>
Chuỗi học tập
</div>
<div className="flex items-baseline gap-2.5 mt-2">
<div className="at-serif" style={{ fontSize: 54, fontWeight: 400, letterSpacing: '-0.03em', lineHeight: 0.95 }}>
{streak}
</div>
<div className="at-serif italic" style={{ fontSize: 26, fontWeight: 300 }}>ngày</div>
<span className="material-symbols-outlined" style={{ fontSize: 28, color: '#F5B94A', fontVariationSettings: "'FILL' 1" }}>
local_fire_department
</span>
</div>
<div className="text-[12.5px] opacity-75 mt-2.5">Giữ vững chuỗi học mỗi ngày nhé!</div>
</div>
{/* LEVEL */}
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<div className="at-eyebrow">Cấp đ</div>
<div className="flex items-baseline justify-between mt-2">
<div className="flex items-baseline gap-2.5">
<div className="at-serif" style={{ fontSize: 54, fontWeight: 400, letterSpacing: '-0.03em', lineHeight: 0.95 }}>
{numericLevel}
</div>
<div className="at-serif italic" style={{ fontSize: 20, color: 'var(--at-mute)' }}>Level</div>
</div>
<div
className="w-11 h-11 rounded-xl grid place-items-center"
style={{ background: 'var(--at-paper-2)', color: 'var(--at-brand)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>emoji_events</span>
</div>
</div>
<div className="mt-2.5">
<span className="at-chip at-chip-warm" style={{ fontSize: 10.5 }}>
<span className="at-chip-dot" />
Hạng {levelLabel}
</span>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5 mb-5">
<XpProgressCard xp={xp} />
<WeeklySection streak={streak} lastActive={lastActive} weeklyCompleted={weeklyCompleted} />
{/* Row 2 — level ring + week goal + history */}
<div className="grid grid-cols-1 gap-5 mb-5" style={{ gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1.4fr)' }}>
{/* Level progress ring */}
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<div className="flex justify-between items-baseline">
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
Tiến đ <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>cấp đ</i>
</div>
<span className="at-serif italic text-[11px]" style={{ color: 'var(--at-mute)' }}>
Lv.{numericLevel} Lv.{numericLevel + 1}
</span>
</div>
<div className="flex justify-center py-5">
<LevelRing value={levelPct} xpInto={xpIntoLevel} xpGoal={100} />
</div>
<div className="text-center text-[12.5px] mb-3" style={{ color: 'var(--at-mute)' }}>
Chỉ còn <b style={{ color: 'var(--at-brand)' }}>{xpLeft} XP</b> nữa đ đt Level {numericLevel + 1}!
</div>
<button
className="w-full py-2.5 rounded-xl text-[13px] 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)' }}
>
<span className="material-symbols-outlined inline-block align-middle mr-1" style={{ fontSize: 15 }}>target</span>
Xem nhiệm vụ XP
</button>
</div>
{/* Week goal + history */}
<div className="flex flex-col gap-5">
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<div className="flex justify-between items-start mb-3">
<div>
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
Mục tiêu <i style={{ color: 'var(--at-good)', fontStyle: 'italic' }}>tuần</i>
</div>
<div className="text-xs mt-1" style={{ color: 'var(--at-mute)' }}>
Hoàn thành {weekGoalTotal} bài học mỗi tuần
</div>
</div>
<div
className="at-serif"
style={{ fontSize: 36, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1, color: 'var(--at-good)' }}
>
{weeklyCompleted}
<span className="italic" style={{ color: 'var(--at-mute-2)' }}>/{weekGoalTotal}</span>
</div>
</div>
<div className="at-bar" style={{ height: 8 }}>
<span style={{ width: `${(weeklyCompleted / weekGoalTotal) * 100}%`, background: 'var(--at-good)' }} />
</div>
<div className="flex justify-between mt-2.5 text-[11.5px]" style={{ color: 'var(--at-mute)' }}>
<span>Đã hoàn thành</span>
{weeklyCompleted >= weekGoalTotal ? (
<span>
<b style={{ color: 'var(--at-good)' }}>Đt mục tiêu!</b> · +50 XP thưởng
</span>
) : (
<span>
Còn <b style={{ color: 'var(--at-good)' }}>{weekGoalTotal - weeklyCompleted} bài</b>
</span>
)}
</div>
</div>
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<div className="flex justify-between items-baseline mb-4">
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
Lịch sử <i style={{ color: 'var(--at-streak)', fontStyle: 'italic' }}>rèn luyện</i>
</div>
<span className="at-serif italic text-[11px]" style={{ color: 'var(--at-mute)' }}>7 ngày qua</span>
</div>
<div className="grid grid-cols-7 gap-2">
{history.map((h, i) => {
const isDone = h.state === 'done'
const isToday = h.state === 'today'
return (
<div key={i} className="flex flex-col items-center gap-2">
<div
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: isToday ? 'var(--at-brand)' : 'var(--at-mute)',
}}
>
{isToday ? 'H.NAY' : h.d}
</div>
<div
className="grid place-items-center"
style={{
width: 44,
height: 44,
borderRadius: 12,
background: isDone ? 'color-mix(in oklab, var(--at-good) 18%, transparent)' : 'transparent',
border: isToday
? '2px dashed var(--at-brand)'
: isDone
? '1px solid color-mix(in oklab, var(--at-good) 30%, transparent)'
: '1px solid var(--at-line)',
color: isDone ? 'var(--at-good)' : isToday ? 'var(--at-brand)' : 'var(--at-mute-2)',
}}
>
{isDone && <span className="material-symbols-outlined" style={{ fontSize: 18 }}>check</span>}
{isToday && <span className="material-symbols-outlined" style={{ fontSize: 14 }}>play_arrow</span>}
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5">
<XuEconomyCard />
<LeaderboardCard />
{/* Row 3 — Xu shop + leaderboard */}
<div className="grid grid-cols-1 gap-5 mb-5" style={{ gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1.4fr)' }}>
{/* Xu shop */}
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<div className="flex justify-between items-baseline mb-3.5">
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
Cửa hàng <i style={{ color: '#B88432', fontStyle: 'italic' }}>Xu</i>
</div>
<span className="at-chip" style={{ fontSize: 10.5 }}>
<Coin size={11} /> {xu} xu
</span>
</div>
<div
style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--at-good)', marginBottom: 8 }}
>
Kiếm xu
</div>
{EARN_ITEMS.map((e, i) => (
<div
key={i}
className="flex justify-between items-center py-2.5"
style={{ borderTop: i === 0 ? 'none' : '1px solid var(--at-line)' }}
>
<span className="text-[13px]" style={{ color: 'var(--at-ink-2)' }}>{e.label}</span>
<span
className="inline-flex items-center gap-1 text-xs font-bold"
style={{ color: 'var(--at-good)' }}
>
+{e.amt} <Coin size={11} />
</span>
</div>
))}
<div
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.14em',
textTransform: 'uppercase',
color: '#C8383E',
marginTop: 18,
marginBottom: 8,
}}
>
Tiêu xu
</div>
{SPEND_ITEMS.map((s, i) => (
<div
key={i}
className="flex justify-between items-center py-2.5 gap-2.5"
style={{ borderTop: i === 0 ? 'none' : '1px solid var(--at-line)' }}
>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-medium" style={{ color: 'var(--at-ink-2)' }}>{s.label}</div>
<div className="text-[11px]" style={{ color: 'var(--at-mute)' }}>{s.desc}</div>
</div>
<button
disabled={xu < s.amt}
className="px-2.5 py-1 rounded-lg text-[11.5px] flex-shrink-0 inline-flex items-center gap-1 transition-opacity disabled:opacity-50"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)', fontWeight: 600 }}
>
{s.amt} <Coin size={11} />
</button>
</div>
))}
</div>
{/* Leaderboard */}
<div
className="rounded-2xl overflow-hidden"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div className="flex justify-between items-center px-5 py-4" style={{ borderBottom: '1px solid var(--at-line)' }}>
<div>
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
Bảng xếp hạng <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>tuần</i>
</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--at-mute)' }}>Top học viên tuần này</div>
</div>
<span className="at-chip at-chip-brand" style={{ fontSize: 10.5 }}>
<span className="at-chip-dot" />
Top tuần này
</span>
</div>
<div
className="grid px-5 py-2.5 text-[10px]"
style={{
gridTemplateColumns: '60px 1fr auto',
gap: 12,
background: 'var(--at-paper-2)',
fontWeight: 700,
color: 'var(--at-mute)',
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}
>
<span>Hạng</span>
<span>Người học</span>
<span>XP tuần</span>
</div>
{board.length === 0 ? (
<div className="px-5 py-8 text-center text-sm" style={{ color: 'var(--at-mute)' }}>
Chưa ai trên bảng xếp hạng tuần này.
</div>
) : (
board.map((p) => {
const rankColors: Record<number, string> = { 1: '#F5B94A', 2: '#BFC5CC', 3: '#C8844A' }
const rc = rankColors[p.rank]
return (
<div
key={p.rank}
className="grid items-center px-5 py-3.5"
style={{
gridTemplateColumns: '60px 1fr auto',
gap: 12,
borderTop: '1px solid var(--at-line)',
background: p.you ? 'color-mix(in oklab, var(--at-brand) 6%, transparent)' : 'transparent',
}}
>
<div
className="w-7 h-7 rounded-full grid place-items-center at-serif"
style={{
background: rc ? `color-mix(in oklab, ${rc} 25%, var(--at-paper-2))` : 'var(--at-paper-2)',
border: rc ? `1px solid ${rc}` : '1px solid var(--at-line)',
color: rc ? 'var(--at-ink)' : 'var(--at-mute)',
fontSize: 14,
fontWeight: 500,
}}
>
{p.rank}
</div>
<div className="flex items-center gap-2.5 min-w-0">
<div
className="w-8 h-8 rounded-full grid place-items-center text-[13px] font-bold flex-shrink-0"
style={{ background: p.you ? 'var(--at-brand)' : 'var(--at-ink-2)', color: 'var(--at-paper)' }}
>
{p.avatar}
</div>
<div className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
<span
className="text-[13.5px]"
style={{ fontWeight: p.you ? 700 : 500, color: p.you ? 'var(--at-brand)' : 'var(--at-ink)' }}
>
{p.name}
</span>
</div>
</div>
<div className="at-serif" style={{ fontSize: 17, fontWeight: 400, letterSpacing: '-0.01em' }}>
{p.xp}
<span className="italic ml-1" style={{ fontSize: 11, color: 'var(--at-mute)', fontWeight: 400 }}>XP</span>
</div>
</div>
)
})
)}
</div>
</div>
<Link
to="/toeic"
className="fixed bottom-24 right-6 lg:bottom-8 lg:right-8 w-14 h-14 bg-blue-600 text-white rounded-2xl flex items-center justify-center shadow-2xl hover:scale-110 active:scale-95 transition-all z-40"
title="Học ngay"
>
<span className="material-symbols-outlined text-2xl">play_arrow</span>
</Link>
{/* Row 4 — Badges */}
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<div className="flex justify-between items-baseline mb-4">
<div>
<div className="at-eyebrow mb-1">Huy hiệu</div>
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
Thành tựu <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>đã mở</i>
</div>
</div>
<span className="text-xs" style={{ color: 'var(--at-mute)' }}>
<b style={{ color: 'var(--at-ink)' }}>{BADGES.filter((b) => b.earned).length}</b> / {BADGES.length} mở khoá
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3.5">
{BADGES.map((b) => (
<div
key={b.id}
className="p-4 rounded-2xl relative"
style={{
background: b.earned ? 'var(--at-surface)' : 'var(--at-paper-2)',
border: '1px solid var(--at-line)',
opacity: b.earned ? 1 : 0.72,
}}
>
<div
className="w-12 h-12 rounded-xl grid place-items-center mb-3"
style={{
background: b.earned ? `color-mix(in oklab, ${b.color} 16%, transparent)` : 'var(--at-line-2)',
color: b.earned ? b.color : 'var(--at-mute-2)',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>{b.icon}</span>
</div>
<div
className="at-serif mb-1"
style={{ fontSize: 16, fontWeight: 500, letterSpacing: '-0.01em', color: 'var(--at-ink)' }}
>
{b.name}
</div>
<div className="text-[11.5px] leading-[1.4] mb-2" style={{ color: 'var(--at-mute)' }}>{b.desc}</div>
{b.earned ? (
<span className="at-chip at-chip-good" style={{ fontSize: 10 }}>
<span className="at-chip-dot" />
Đã mở
</span>
) : (
<div>
<div className="at-bar" style={{ height: 4, marginBottom: 4 }}>
<span style={{ width: `${b.progress ?? 0}%`, background: b.color }} />
</div>
<span className="text-[10.5px] font-semibold" style={{ color: 'var(--at-mute)' }}>
{b.progress ?? 0}% tiến đ
</span>
</div>
)}
</div>
))}
</div>
</div>
</div>
)
}
function LevelRing({ value, xpInto, xpGoal }: { value: number; xpInto: number; xpGoal: number }) {
const r = 80
const c = 2 * Math.PI * r
const offset = c - (value / 100) * c
return (
<div className="relative grid place-items-center" style={{ width: 180, height: 180 }}>
<svg width="180" height="180">
<circle cx="90" cy="90" r={r} fill="none" stroke="var(--at-line-2)" strokeWidth="10" />
<circle
cx="90"
cy="90"
r={r}
fill="none"
stroke="var(--at-brand)"
strokeWidth="10"
strokeDasharray={c}
strokeDashoffset={offset}
strokeLinecap="round"
transform="rotate(-90 90 90)"
style={{ transition: 'stroke-dashoffset 0.6s cubic-bezier(0.2, 0.7, 0.2, 1)' }}
/>
</svg>
<div className="absolute text-center">
<div className="at-serif" style={{ fontSize: 40, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1, color: 'var(--at-ink)' }}>
{value}%
</div>
<div style={{ fontSize: 10.5, color: 'var(--at-mute)', marginTop: 4, fontWeight: 600, letterSpacing: '0.1em' }}>
{xpInto} / {xpGoal} XP
</div>
</div>
</div>
)
}

View File

@@ -1,102 +0,0 @@
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth-store'
import { useLeaderboard } from '@/hooks/use-gamification'
function RankBadge({ rank }: { rank: number }) {
return (
<div className={cn(
'w-8 h-8 flex items-center justify-center font-bold rounded-full text-xs',
rank === 1 ? 'bg-amber-200 text-amber-800' :
rank === 2 ? 'bg-slate-200 text-slate-700' :
rank === 3 ? 'bg-orange-200 text-orange-700' :
'bg-slate-100 text-slate-600',
)}>
{rank}
</div>
)
}
function initials(name: string) {
return name.split(' ').map((w) => w[0]).slice(-2).join('').toUpperCase()
}
export function LeaderboardCard() {
const user = useAuthStore((s) => s.user)
const { data: rows, isLoading } = useLeaderboard()
return (
<div className="lg:col-span-8 bg-white p-6 rounded-xl shadow-sm">
<div className="flex items-center justify-between mb-5">
<h3 className="text-base font-bold text-slate-800">Bảng xếp hạng tuần</h3>
<span className="px-3 py-1 bg-blue-600 text-white rounded-full text-xs font-bold">Top tuần này</span>
</div>
{isLoading && (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-12 bg-slate-100 rounded-xl animate-pulse" />
))}
</div>
)}
{!isLoading && (!rows || rows.length === 0) && (
<div className="text-center py-8 text-slate-400 text-sm">
<span className="material-symbols-outlined block mb-2 text-slate-300" style={{ fontSize: 40 }}>leaderboard</span>
Chưa dữ liệu tuần này. Hãy hoàn thành bài học đ xuất hiện trên bảng!
</div>
)}
{!isLoading && rows && rows.length > 0 && (
<table className="w-full text-left border-separate border-spacing-y-1.5">
<thead>
<tr className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
<th className="pb-2 pl-4 w-16">Hạng</th>
<th className="pb-2">Người học</th>
<th className="pb-2 text-right pr-4">XP tuần</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const isMe = row.userId === user?.id
return (
<tr
key={row.userId}
className={cn(
'transition-colors',
isMe
? 'bg-blue-50 outline outline-2 outline-blue-200 rounded-xl'
: 'bg-slate-50/60 hover:bg-slate-100',
)}
>
<td className="py-2.5 pl-4 rounded-l-xl">
<RankBadge rank={row.rank} />
</td>
<td className="py-2.5">
<div className="flex items-center gap-2.5">
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0',
isMe
? 'bg-blue-600 text-white ring-2 ring-blue-300 ring-offset-1'
: 'bg-slate-200 text-slate-600',
)}>
{initials(row.displayName)}
</div>
<span className={cn('text-sm font-bold', isMe && 'text-blue-600')}>
{isMe ? `${row.displayName} (Bạn)` : row.displayName}
</span>
</div>
</td>
<td className="py-2.5 pr-4 text-right rounded-r-xl">
<span className={cn('text-sm font-bold', isMe ? 'text-blue-600' : 'text-slate-600')}>
{row.xpEarned.toLocaleString('vi-VN')} XP
</span>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
)
}

View File

@@ -1,71 +0,0 @@
import type { UserLevel } from '@/types'
import { calcNumericLevel } from './Dashboard'
const LEVEL_NAMES: Record<UserLevel, string> = {
beginner: 'Beginner',
bronze: 'Đồng',
silver: 'Bạc',
gold: 'Vàng',
master: 'Master',
}
interface Props {
xu: number
streak: number
xp: number
level: UserLevel
}
export function StatsRow({ xu, streak, xp, level }: Props) {
const numericLevel = calcNumericLevel(xp)
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-6">
{/* Xu Balance */}
<div className="relative overflow-hidden bg-white p-6 rounded-xl shadow-sm group">
<div className="absolute -right-4 -top-4 w-24 h-24 bg-amber-100 rounded-full opacity-40 blur-2xl group-hover:opacity-60 transition-opacity" />
<span className="text-xs uppercase tracking-widest text-slate-400 font-bold">Số Xu</span>
<div className="flex items-center gap-3 mt-1">
<span className="text-4xl font-extrabold text-slate-800">{xu.toLocaleString('vi-VN')}</span>
<span className="material-symbols-outlined text-3xl text-amber-400" style={{ fontVariationSettings: "'FILL' 1" }}>
monetization_on
</span>
</div>
<p className="text-xs text-slate-400 mt-1.5 font-medium">Dùng đ mở tính năng premium</p>
</div>
{/* Streak */}
<div className="relative overflow-hidden bg-blue-600 p-6 rounded-xl shadow-sm">
<div className="absolute inset-0 bg-gradient-to-br from-blue-500 to-blue-700 opacity-90" />
<div className="relative z-10 text-white">
<span className="text-xs uppercase tracking-widest opacity-75 font-bold">Chuỗi học tập</span>
<div className="flex items-center gap-3 mt-1">
<span className="text-4xl font-extrabold">{streak} Ngày</span>
<span className="material-symbols-outlined text-3xl text-amber-300" style={{ fontVariationSettings: "'FILL' 1" }}>
local_fire_department
</span>
</div>
<p className="text-xs opacity-80 mt-1.5 font-medium">
{streak >= 7 ? 'Bạn thuộc top 5% người học!' : 'Giữ vững chuỗi học mỗi ngày nhé!'}
</p>
</div>
</div>
{/* Level */}
<div className="bg-white p-6 rounded-xl shadow-sm flex items-center justify-between">
<div>
<span className="text-xs uppercase tracking-widest text-slate-400 font-bold">Cấp đ</span>
<div className="flex items-center gap-2 mt-1">
<span className="text-4xl font-extrabold text-slate-800">Level {numericLevel}</span>
</div>
<span className="inline-block mt-2 px-3 py-1 bg-amber-50 text-amber-600 text-xs font-bold rounded-full border border-amber-200">
Hạng {LEVEL_NAMES[level]}
</span>
</div>
<div className="w-14 h-14 bg-slate-100 flex items-center justify-center rounded-2xl rotate-12 flex-shrink-0">
<span className="material-symbols-outlined text-blue-600 text-3xl">military_tech</span>
</div>
</div>
</div>
)
}

View File

@@ -1,90 +0,0 @@
import { cn } from '@/lib/utils'
interface Props {
streak: number
lastActive: string | null
weeklyCompleted: number
}
const WEEKLY_GOAL = 5
const DAY_LABELS = ['Th 2', 'Th 3', 'Th 4', 'Th 5', 'Th 6', 'Th 7', 'CN']
function getTodayIdx() {
const d = new Date().getDay() // 0=Sun
return d === 0 ? 6 : d - 1 // Mon=0 … Sun=6
}
// Derive which days were active this week from streak + lastActive
function getWeekActivity(streak: number, lastActive: string | null): boolean[] {
const todayIdx = getTodayIdx()
const activity = Array(7).fill(false)
if (!lastActive) return activity
const today = new Date().toISOString().split('T')[0]
const isActiveToday = lastActive === today
// Mark past days as done based on streak length (up to but not including today)
const doneDays = isActiveToday ? Math.min(todayIdx, streak - 1) : Math.min(todayIdx, streak)
for (let i = todayIdx - doneDays; i < todayIdx; i++) {
if (i >= 0) activity[i] = true
}
return activity
}
export function WeeklySection({ streak, lastActive, weeklyCompleted }: Props) {
const todayIdx = getTodayIdx()
const weekActivity = getWeekActivity(streak, lastActive)
const progressPct = Math.round((weeklyCompleted / WEEKLY_GOAL) * 100)
return (
<div className="lg:col-span-7 space-y-5">
{/* Weekly goal */}
<div className="bg-white p-6 rounded-xl shadow-sm">
<div className="flex justify-between items-end mb-3">
<div>
<h3 className="text-base font-bold text-slate-800">Mục tiêu tuần</h3>
<p className="text-xs text-slate-400">Hoàn thành {WEEKLY_GOAL} bài học mỗi tuần</p>
</div>
<span className="text-2xl font-black text-green-600">
{weeklyCompleted}/{WEEKLY_GOAL}
</span>
</div>
<div className="w-full h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-green-400 rounded-full transition-all duration-500"
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
{/* Weekly heatmap */}
<div className="bg-white p-6 rounded-xl shadow-sm">
<h3 className="text-base font-bold text-slate-800 mb-5">Lịch sử rèn luyện</h3>
<div className="flex justify-between items-center">
{DAY_LABELS.map((label, i) => {
const isToday = i === todayIdx
const done = weekActivity[i]
const future = i > todayIdx
return (
<div key={label} className={cn('flex flex-col items-center gap-2.5', future && 'opacity-30')}>
<span className={cn('text-[10px] font-bold uppercase', isToday ? 'text-blue-600' : 'text-slate-400')}>
{isToday ? 'H.Nay' : label}
</span>
{isToday ? (
<div className="w-10 h-10 rounded-xl border-2 border-blue-600 border-dashed flex items-center justify-center">
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 18 }}>play_arrow</span>
</div>
) : done ? (
<div className="w-10 h-10 rounded-xl bg-green-200 flex items-center justify-center">
<span className="material-symbols-outlined text-green-700" style={{ fontSize: 18 }}>check</span>
</div>
) : (
<div className="w-10 h-10 rounded-xl bg-slate-100" />
)}
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -1,56 +0,0 @@
import { calcXpNextLevel, calcNumericLevel } from './Dashboard'
interface Props { xp: number }
function ProgressRing({ percent, xp, xpNext }: { percent: number; xp: number; xpNext: number }) {
const r = 72
const circ = 2 * Math.PI * r
const offset = circ - (percent / 100) * circ
return (
<div className="relative w-44 h-44">
<svg className="w-full h-full -rotate-90" viewBox="0 0 160 160">
<circle cx="80" cy="80" r={r} fill="transparent" stroke="#e8eaed" strokeWidth="12" />
<circle
cx="80" cy="80" r={r}
fill="transparent"
stroke="#2563eb"
strokeWidth="12"
strokeDasharray={circ}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-700"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-extrabold text-slate-800">{percent}%</span>
<span className="text-[10px] text-slate-400 font-bold mt-0.5">
{xp.toLocaleString()} / {xpNext.toLocaleString()} XP
</span>
</div>
</div>
)
}
export function XpProgressCard({ xp }: Props) {
const xpNext = calcXpNextLevel(xp)
const levelXpStart = Math.floor(xp / 100) * 100
const percent = Math.round(((xp - levelXpStart) / (xpNext - levelXpStart)) * 100)
const nextLevel = calcNumericLevel(xp) + 1
return (
<div className="lg:col-span-5 bg-white p-6 rounded-xl shadow-sm flex flex-col items-center justify-center text-center">
<h3 className="text-base font-bold mb-5 self-start text-slate-800">Tiến đ Cấp đ</h3>
<ProgressRing percent={percent} xp={xp} xpNext={xpNext} />
<p className="text-sm text-slate-400 font-medium mt-4">
Chỉ còn {(xpNext - xp).toLocaleString()} XP nữa đ đt Level {nextLevel}!
</p>
<button className="mt-5 w-full py-2.5 bg-slate-100 hover:bg-slate-200 transition-colors rounded-xl font-bold text-sm text-blue-600">
Xem nhiệm vụ XP
</button>
</div>
)
}

View File

@@ -1,46 +0,0 @@
const EARN_ITEMS = [
{ label: 'Mục tiêu ngày', reward: '+10 xu' },
{ label: 'Mốc chuỗi (Streak)', reward: '+20 xu' },
{ label: 'Xem quảng cáo', reward: '+5 xu' },
]
const SPEND_ITEMS = [
{ label: 'Streak Freeze', cost: '20 xu' },
{ label: 'AI Writing Feedback', cost: '30 xu' },
]
export function XuEconomyCard() {
return (
<div className="lg:col-span-4 bg-white p-6 rounded-xl shadow-sm">
<h3 className="text-base font-bold text-slate-800 mb-5">Cửa hàng Xu</h3>
<div className="space-y-5">
{/* Earn */}
<div>
<span className="text-xs text-green-600 font-bold uppercase tracking-wider block mb-2.5">Kiếm Xu</span>
<div className="space-y-2">
{EARN_ITEMS.map((item) => (
<div key={item.label} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<span className="text-sm font-medium text-slate-700">{item.label}</span>
<span className="text-sm font-bold text-amber-600">{item.reward}</span>
</div>
))}
</div>
</div>
{/* Spend */}
<div>
<span className="text-xs text-red-500 font-bold uppercase tracking-wider block mb-2.5">Tiêu Xu</span>
<div className="space-y-2">
{SPEND_ITEMS.map((item) => (
<div key={item.label} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg opacity-80">
<span className="text-sm font-medium text-slate-700">{item.label}</span>
<span className="text-sm font-bold text-slate-400">{item.cost}</span>
</div>
))}
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { supabase } from '@/lib/supabase'
import { EASE, computeNextReview, statusFor, type EaseKey } from '../lib/srs-intervals'
export interface FlashcardList {
id: number
@@ -24,6 +25,8 @@ export interface FlashcardTerm {
definition: string | null
example: string | null
image_url: string | null
audio_tts_text: string | null
audio_lang: string | null
display_order: number
}
@@ -53,7 +56,7 @@ export async function fetchFlashcardLists(): Promise<FlashcardList[]> {
export async function fetchFlashcardTerms(listId: number): Promise<FlashcardTerm[]> {
const { data, error } = await supabase
.from('flashcard_term')
.select('id, list_id, word, part_of_speech, phonetic, definition, example, image_url, display_order')
.select('id, list_id, word, part_of_speech, phonetic, definition, example, image_url, audio_tts_text, audio_lang, display_order')
.eq('list_id', listId)
.order('display_order', { ascending: true })
if (error) throw error
@@ -71,18 +74,16 @@ export async function fetchUserProgress(userId: string, listId: number): Promise
return data ?? []
}
/** Upsert user progress for a term (SRS update) */
/** Upsert user progress for a term. Increments review_count, writes next_review_at via interval ladder. */
export async function upsertTermProgress(
userId: string,
termId: number,
listId: number,
status: UserProgress['status'],
easeFactor: number,
easeKey: EaseKey,
currentReviewCount: number,
): Promise<void> {
const now = new Date().toISOString()
// Compute next review date based on ease_factor
const intervalDays = easeFactor <= 0 ? 0 : easeFactor >= 1 ? 7 : easeFactor >= 0.65 ? 3 : 1
const nextReview = new Date(Date.now() + intervalDays * 24 * 60 * 60 * 1000).toISOString()
const nextReview = computeNextReview(easeKey, currentReviewCount)
const { error } = await supabase
.from('user_flashcard_progress')
@@ -91,12 +92,63 @@ export async function upsertTermProgress(
user_id: userId,
term_id: termId,
list_id: listId,
status,
ease_factor: easeFactor,
status: statusFor(easeKey),
ease_factor: EASE[easeKey],
review_count: currentReviewCount + 1,
last_reviewed_at: now,
next_review_at: nextReview,
},
{ onConflict: 'user_id,term_id,list_id' },
)
if (error) console.error('Failed to upsert term progress:', error.message)
if (error) console.error('upsertTermProgress failed:', error.message)
}
export interface LearnSession {
id: number
user_id: string
list_id: number
started_at: string
}
export async function startSession(userId: string, listId: number): Promise<LearnSession> {
const { data, error } = await supabase
.from('user_flashcard_session')
.insert({ user_id: userId, list_id: listId })
.select('id, user_id, list_id, started_at')
.single()
if (error) throw error
return data as LearnSession
}
export async function endSession(
sessionId: number,
termsReviewed: number,
termsNew: number,
): Promise<void> {
const { error } = await supabase
.from('user_flashcard_session')
.update({
ended_at: new Date().toISOString(),
terms_reviewed: termsReviewed,
terms_new: termsNew,
})
.eq('id', sessionId)
if (error) console.error('endSession failed:', error.message)
}
export async function logReview(
sessionId: number,
userId: string,
termId: number,
actionValue: number,
): Promise<void> {
const { error } = await supabase
.from('user_flashcard_review_log')
.insert({
session_id: sessionId,
user_id: userId,
term_id: termId,
action_value: actionValue,
})
if (error) console.error('logReview failed:', error.message)
}

View File

@@ -1,24 +1,36 @@
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth-store'
import { fetchFlashcardTerms, fetchUserProgress, upsertTermProgress } from '../api/flashcard-api'
import {
fetchFlashcardTerms,
fetchUserProgress,
upsertTermProgress,
startSession,
endSession,
logReview,
fetchFlashcardLists,
} from '../api/flashcard-api'
import type { FlashcardTerm, UserProgress } from '../api/flashcard-api'
const EASE = {
ignored: -1,
hard: 0.1,
easy: 0.65,
known: 1.0,
} as const
type EaseKey = keyof typeof EASE
import { EASE, type EaseKey } from '../lib/srs-intervals'
interface Props {
listId: number
}
type SessionStats = { known: number; learning: number; ignored: number }
function speak(word: string) {
try {
const u = new SpeechSynthesisUtterance(word)
u.lang = 'en-US'
u.rate = 0.9
speechSynthesis.cancel()
speechSynthesis.speak(u)
} catch { /* noop */ }
}
export function FlashCardLearnPage({ listId }: Props) {
const navigate = useNavigate()
const user = useAuthStore(s => s.user)
@@ -26,51 +38,165 @@ export function FlashCardLearnPage({ listId }: Props) {
const [isFlipped, setIsFlipped] = useState(false)
const [currentIdx, setCurrentIdx] = useState(0)
const [sessionStats, setSessionStats] = useState({ known: 0, learning: 0, ignored: 0 })
const [sessionStats, setSessionStats] = useState<SessionStats>({ known: 0, learning: 0, ignored: 0 })
const [isDone, setIsDone] = useState(false)
const [fx, setFx] = useState<'known' | 'review' | null>(null)
// Bookmarks — per-list, persisted in localStorage
const bookmarkKey = `flashcard-bookmarks-${listId}`
const [bookmarks, setBookmarks] = useState<Set<number>>(() => {
try {
const raw = localStorage.getItem(bookmarkKey)
return raw ? new Set<number>(JSON.parse(raw)) : new Set()
} catch { return new Set() }
})
const toggleBookmark = useCallback((termId: number) => {
setBookmarks(prev => {
const next = new Set(prev)
if (next.has(termId)) next.delete(termId)
else next.add(termId)
try { localStorage.setItem(bookmarkKey, JSON.stringify([...next])) } catch { /* noop */ }
return next
})
}, [bookmarkKey])
// Refs for unmount cleanup so effects see fresh values
const sessionIdRef = useRef<number | null>(null)
const statsRef = useRef<SessionStats>(sessionStats)
const isDoneRef = useRef(false)
const newTermIdsAtStartRef = useRef<Set<number>>(new Set())
const answeredNewIdsRef = useRef<Set<number>>(new Set())
useEffect(() => { statsRef.current = sessionStats }, [sessionStats])
useEffect(() => { isDoneRef.current = isDone }, [isDone])
const { data: terms = [], isLoading: loadingTerms } = useQuery({
queryKey: ['flashcard-terms', listId],
queryFn: () => fetchFlashcardTerms(listId),
})
const { data: lists = [] } = useQuery({
queryKey: ['flashcard-lists'],
queryFn: fetchFlashcardLists,
staleTime: 5 * 60 * 1000,
})
const currentList = lists.find(l => l.id === listId)
const { data: progress = [] } = useQuery({
queryKey: ['flashcard-progress', user?.id, listId],
queryFn: () => fetchUserProgress(user!.id, listId),
enabled: !!user,
})
const progressMap: Record<number, UserProgress> = {}
progress.forEach(p => { progressMap[p.term_id] = p })
const progressMap = useMemo(() => {
const m: Record<number, UserProgress> = {}
progress.forEach(p => { m[p.term_id] = p })
return m
}, [progress])
// Prioritize: new + learning terms first, then known
const sessionTerms: FlashcardTerm[] = [
...terms.filter(t => {
const s = progressMap[t.id]?.status ?? 'new'
return s === 'new' || s === 'learning'
}),
...terms.filter(t => progressMap[t.id]?.status === 'known'),
]
// Session term ordering: prioritise due-for-review, then new, then known
const sessionTerms: FlashcardTerm[] = useMemo(() => {
if (!terms.length) return []
const now = Date.now()
const due: FlashcardTerm[] = []
const fresh: FlashcardTerm[] = []
const known: FlashcardTerm[] = []
for (const t of terms) {
const p = progressMap[t.id]
if (p?.status === 'ignored') continue
if (!p) { fresh.push(t); continue }
if (!p.next_review_at) { fresh.push(t); continue }
if (new Date(p.next_review_at).getTime() <= now) { due.push(t); continue }
if (p.status === 'known') known.push(t)
else fresh.push(t)
}
return [...due, ...fresh, ...known]
}, [terms, progressMap])
const { mutate: saveProgress } = useMutation({
mutationFn: ({ termId, status, easeFactor }: { termId: number; status: UserProgress['status']; easeFactor: number }) =>
upsertTermProgress(user!.id, termId, listId, status, easeFactor),
// Snapshot "new" term IDs at session start (runs once when data is loaded)
useEffect(() => {
if (newTermIdsAtStartRef.current.size === 0 && terms.length > 0) {
const newIds = new Set<number>()
for (const t of terms) {
const s = progressMap[t.id]?.status ?? 'new'
if (s === 'new') newIds.add(t.id)
}
newTermIdsAtStartRef.current = newIds
}
}, [terms, progressMap])
// Start session on mount (guarded against StrictMode double-invoke)
useEffect(() => {
if (!user || sessionIdRef.current !== null) return
let cancelled = false
startSession(user.id, listId)
.then(s => { if (!cancelled) sessionIdRef.current = s.id })
.catch(err => console.error('startSession failed:', err))
return () => { cancelled = true }
}, [user, listId])
// End session on unmount (if not already ended via done-screen effect)
useEffect(() => {
return () => {
const sid = sessionIdRef.current
if (sid === null || isDoneRef.current) return
const s = statsRef.current
const reviewed = s.known + s.learning + s.ignored
endSession(sid, reviewed, answeredNewIdsRef.current.size)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// End session when reaching done screen
useEffect(() => {
if (!isDone) return
const sid = sessionIdRef.current
if (sid === null) return
const s = statsRef.current
const reviewed = s.known + s.learning + s.ignored
endSession(sid, reviewed, answeredNewIdsRef.current.size)
}, [isDone])
const { mutate: saveAnswer } = useMutation({
mutationFn: async ({ termId, easeKey, reviewCount }: {
termId: number
easeKey: EaseKey
reviewCount: number
}) => {
if (!user) return
const sid = sessionIdRef.current
await Promise.all([
upsertTermProgress(user.id, termId, listId, easeKey, reviewCount),
sid !== null ? logReview(sid, user.id, termId, EASE[easeKey]) : Promise.resolve(),
])
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['flashcard-progress', user?.id, listId] })
},
})
const advance = useCallback(() => {
if (currentIdx + 1 >= sessionTerms.length) {
setIsDone(true)
} else {
setCurrentIdx(i => i + 1)
setIsFlipped(false)
}
setFx(null)
}, [currentIdx, sessionTerms.length])
const handleAnswer = useCallback((key: EaseKey) => {
const term = sessionTerms[currentIdx]
if (!term || !user) return
const easeFactor = EASE[key]
const status: UserProgress['status'] =
key === 'known' ? 'known' :
key === 'ignored' ? 'ignored' :
'learning'
const currentProgress = progressMap[term.id]
const reviewCount = currentProgress?.review_count ?? 0
saveProgress({ termId: term.id, status, easeFactor })
saveAnswer({ termId: term.id, easeKey: key, reviewCount })
if (newTermIdsAtStartRef.current.has(term.id)) {
answeredNewIdsRef.current.add(term.id)
}
setSessionStats(prev => ({
known: prev.known + (key === 'known' ? 1 : 0),
@@ -78,13 +204,32 @@ export function FlashCardLearnPage({ listId }: Props) {
ignored: prev.ignored + (key === 'ignored' ? 1 : 0),
}))
if (currentIdx + 1 >= sessionTerms.length) {
setIsDone(true)
// Visual feedback: known swipes right, hard/ignored swipes left
if (key === 'known' || key === 'easy') {
setFx('known')
} else {
setCurrentIdx(i => i + 1)
setIsFlipped(false)
setFx('review')
}
}, [currentIdx, sessionTerms, user, saveProgress])
setTimeout(advance, 450)
}, [currentIdx, sessionTerms, user, saveAnswer, progressMap, advance])
// Keyboard shortcuts
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (isDone || !sessionTerms[currentIdx]) return
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault()
setIsFlipped(v => !v)
return
}
if (!isFlipped) return
if (e.key.toLowerCase() === 'j') handleAnswer('known')
else if (e.key.toLowerCase() === 'k') handleAnswer('hard')
else if (e.key.toLowerCase() === 'i') handleAnswer('ignored')
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [isDone, isFlipped, currentIdx, sessionTerms, handleAnswer])
const total = sessionTerms.length
const progressPct = total > 0 ? Math.round((currentIdx / total) * 100) : 0
@@ -92,23 +237,24 @@ export function FlashCardLearnPage({ listId }: Props) {
if (loadingTerms) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-8 h-8 border-2 border-blue-100 border-t-blue-600 rounded-full animate-spin" />
<div className="atelier flex items-center justify-center min-h-screen">
<div className="w-8 h-8 border-2 border-[var(--at-line)] border-t-[var(--at-accent)] rounded-full animate-spin" />
</div>
)
}
if (sessionTerms.length === 0) {
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4 px-4">
<span className="material-symbols-outlined text-slate-300" style={{ fontSize: 56, fontVariationSettings: "'FILL' 1" }}>check_circle</span>
<h2 className="text-xl font-bold text-slate-700">Không thẻ nào đ học!</h2>
<p className="text-slate-400 text-sm text-center">Bộ thẻ y chưa từ nào. Vui lòng thêm từ trước.</p>
<div className="atelier flex flex-col items-center justify-center min-h-screen gap-4 px-4">
<div className="at-serif text-5xl italic text-[var(--at-mute-2)]">All clear.</div>
<p className="text-[var(--at-mute)] text-center max-w-sm">
Không thẻ o cần học ngay bây giờ. Quay lại sau khi đến lịch ôn tập.
</p>
<button
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
className="mt-2 px-6 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-semibold hover:bg-blue-700 transition-colors"
className="mt-4 px-6 py-2.5 bg-[var(--at-ink)] text-[var(--at-paper)] rounded-xl text-sm font-semibold hover:opacity-90 transition"
>
Quay lại
Quay lại danh sách
</button>
</div>
)
@@ -116,24 +262,24 @@ export function FlashCardLearnPage({ listId }: Props) {
if (isDone) {
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-6 px-4">
<div className="atelier flex flex-col items-center justify-center min-h-screen gap-8 px-4">
<div className="text-center">
<span className="material-symbols-outlined text-emerald-500 block mb-3" style={{ fontSize: 64, fontVariationSettings: "'FILL' 1" }}>celebration</span>
<h2 className="text-2xl font-extrabold text-slate-800 mb-1">Hoàn thành phiên học!</h2>
<p className="text-slate-500">Bạn đã ôn xong {total} thẻ trong phiên này</p>
<div className="at-serif italic text-[var(--at-accent)] text-6xl mb-4">Bravo.</div>
<h2 className="at-serif text-3xl tracking-tight text-[var(--at-ink)] mb-2">Hoàn thành phiên học</h2>
<p className="text-[var(--at-mute)]">Bạn đã ôn xong {total} thẻ trong phiên này</p>
</div>
<div className="flex gap-4">
<div className="text-center px-5 py-3 bg-emerald-50 rounded-xl border border-emerald-100">
<div className="text-2xl font-extrabold text-emerald-600">{sessionStats.known}</div>
<div className="text-xs text-slate-400 mt-0.5">Đã biết</div>
<div className="flex gap-3">
<div className="px-5 py-3 rounded-2xl border border-[var(--at-line)] bg-white text-center min-w-[88px]">
<div className="at-serif text-3xl text-[var(--at-good)]">{sessionStats.known}</div>
<div className="text-[10px] uppercase tracking-widest text-[var(--at-mute)] mt-1">Đã biết</div>
</div>
<div className="text-center px-5 py-3 bg-blue-50 rounded-xl border border-blue-100">
<div className="text-2xl font-extrabold text-blue-600">{sessionStats.learning}</div>
<div className="text-xs text-slate-400 mt-0.5">Đang học</div>
<div className="px-5 py-3 rounded-2xl border border-[var(--at-line)] bg-white text-center min-w-[88px]">
<div className="at-serif text-3xl text-[var(--at-accent)]">{sessionStats.learning}</div>
<div className="text-[10px] uppercase tracking-widest text-[var(--at-mute)] mt-1">Đang học</div>
</div>
<div className="text-center px-5 py-3 bg-slate-50 rounded-xl border border-slate-200">
<div className="text-2xl font-extrabold text-slate-500">{sessionStats.ignored}</div>
<div className="text-xs text-slate-400 mt-0.5">Bỏ qua</div>
<div className="px-5 py-3 rounded-2xl border border-[var(--at-line)] bg-white text-center min-w-[88px]">
<div className="at-serif text-3xl text-[var(--at-mute-2)]">{sessionStats.ignored}</div>
<div className="text-[10px] uppercase tracking-widest text-[var(--at-mute)] mt-1">Bỏ qua</div>
</div>
</div>
<div className="flex gap-3">
@@ -143,17 +289,19 @@ export function FlashCardLearnPage({ listId }: Props) {
setIsFlipped(false)
setIsDone(false)
setSessionStats({ known: 0, learning: 0, ignored: 0 })
sessionIdRef.current = null
answeredNewIdsRef.current = new Set()
newTermIdsAtStartRef.current = new Set()
if (user) startSession(user.id, listId).then(s => { sessionIdRef.current = s.id })
}}
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-semibold hover:bg-blue-700 transition-colors"
className="px-5 py-2.5 bg-[var(--at-ink)] text-[var(--at-paper)] rounded-xl text-sm font-semibold hover:opacity-90 transition"
>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>replay</span>
Học lại
</button>
<button
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
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"
className="px-5 py-2.5 border border-[var(--at-line)] text-[var(--at-ink-2)] rounded-xl text-sm font-semibold bg-white hover:border-[var(--at-ink)] transition"
>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>list</span>
Xem danh sách
</button>
</div>
@@ -161,141 +309,296 @@ export function FlashCardLearnPage({ listId }: Props) {
)
}
// Jump to a specific card in the deck (no progress write — just navigate)
const jumpTo = (idx: number) => {
setCurrentIdx(idx)
setIsFlipped(false)
}
return (
<div className="min-h-screen flex flex-col bg-slate-50">
{/* Header */}
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-xl border-b border-slate-100">
<div className="max-w-3xl mx-auto px-6 py-3.5 flex items-center justify-between">
<span className="text-base font-bold text-slate-800">Phiên học từ vựng</span>
<div className="flex items-center gap-4">
<span className="text-sm text-slate-500 font-medium">{currentIdx + 1} / {total} từ</span>
<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"
style={{ background: 'var(--at-paper)' }}
>
{/* Header row: breadcrumb + serif title on left, actions on right */}
<div className="flex items-end justify-between gap-4 mb-4 flex-shrink-0 min-w-0">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1 text-[13px]" style={{ color: 'var(--at-mute)' }}>
<button
onClick={() => navigate({ to: '/flash-card' })}
className="hover:text-[var(--at-ink)] transition-colors"
>
Chủ đ
</button>
<span>/</span>
<button
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-slate-100 transition-colors"
className="hover:text-[var(--at-ink)] transition-colors truncate"
style={{ color: 'var(--at-ink-2)' }}
>
<span className="material-symbols-outlined text-slate-500" style={{ fontSize: 20 }}>close</span>
{currentList?.title ?? 'Bộ thẻ'}
</button>
</div>
<h1
className="at-serif tracking-tight"
style={{ fontSize: 40, fontWeight: 400, letterSpacing: '-0.025em', lineHeight: 1.05, color: 'var(--at-ink)' }}
>
Thẻ <i style={{ fontStyle: 'italic', color: 'var(--at-brand)' }}>{currentIdx + 1}</i>
<span className="at-serif italic" style={{ color: 'var(--at-mute-2)' }}> / {total}</span>
</h1>
</div>
{/* Progress bar */}
<div className="w-full bg-slate-100 h-1 overflow-hidden">
<div
className="h-full bg-blue-600 transition-all duration-500"
style={{ width: `${progressPct}%` }}
/>
</div>
</header>
<main className="flex-1 max-w-3xl mx-auto w-full px-4 py-10 flex flex-col items-center">
{/* Session stats pills */}
<div className="flex items-center gap-3 mb-10">
<div className="px-4 py-1.5 rounded-full bg-emerald-50 text-emerald-700 text-sm font-semibold flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-emerald-500" />
{sessionStats.known} biết
</div>
<div className="px-4 py-1.5 rounded-full bg-blue-50 text-blue-700 text-sm font-semibold flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-blue-500" />
{sessionStats.learning} đang học
</div>
<div className="px-4 py-1.5 rounded-full bg-slate-100 text-slate-500 text-sm font-semibold flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-slate-400" />
{sessionStats.ignored} bỏ qua
</div>
</div>
{/* Flashcard */}
{current && (
<div className="relative group w-full max-w-xl">
<div className="absolute -inset-4 bg-blue-600/5 blur-3xl rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-700" />
<div
className="relative w-full min-h-[280px] bg-white rounded-2xl shadow-lg border border-slate-200/60 flex flex-col items-center justify-between py-8 px-8 cursor-pointer hover:scale-[1.01] hover:border-blue-200 transition-all duration-300 select-none"
onClick={() => setIsFlipped(v => !v)}
role="button"
aria-label={isFlipped ? 'Nhấp để xem từ' : 'Nhấp để xem nghĩa'}
<div className="flex gap-2 flex-shrink-0">
<button
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13px] 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)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 15 }}>arrow_back</span>
Danh sách
</button>
<button
onClick={() => current && toggleBookmark(current.id)}
disabled={!current}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13px] font-semibold transition-colors hover:bg-[var(--at-line-2)]"
style={{
background: current && bookmarks.has(current.id) ? 'var(--at-warm-soft)' : 'var(--at-surface)',
border: '1px solid ' + (current && bookmarks.has(current.id) ? 'var(--at-warm)' : 'var(--at-line)'),
color: current && bookmarks.has(current.id) ? 'var(--at-warm-ink)' : 'var(--at-ink-2)',
}}
>
<span
className="material-symbols-outlined"
style={{
fontSize: 15,
fontVariationSettings: current && bookmarks.has(current.id) ? "'FILL' 1" : "'FILL' 0",
}}
>
{!isFlipped ? (
<>
<div className="text-xs font-bold tracking-widest text-slate-400 uppercase">TIẾNG ANH</div>
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-5xl font-extrabold tracking-tight text-blue-600">{current.word}</h1>
{current.phonetic && (
<span className="text-slate-500 italic font-light text-lg">{current.phonetic}</span>
)}
bookmark
</span>
Đánh dấu
</button>
</div>
</div>
{/* Body: card column + sidebar */}
<div
className="flex-1 min-h-0 lg:grid flex flex-col gap-5"
style={{ gridTemplateColumns: 'minmax(0, 1fr) 260px' }}
>
{/* Main: card + actions + progress */}
<div className="flex flex-col items-center justify-center min-h-0">
{/* Card */}
{current && (
<div className="at-card-outer" style={{ maxWidth: 420, flexShrink: 0 }}>
<div
className={cn('at-card', isFlipped && 'is-flipped', fx === 'known' && 'fx-known', fx === 'review' && 'fx-review')}
key={current.id}
onClick={() => setIsFlipped(v => !v)}
role="button"
tabIndex={0}
aria-label={isFlipped ? 'Lật để xem từ' : 'Lật để xem nghĩa'}
>
{/* FRONT */}
<div className="at-card-face" style={{ padding: '20px 24px' }}>
<div className="flex items-center justify-between">
<span className="at-chip">
<span className="at-chip-dot" />
{current.part_of_speech?.toUpperCase() ?? 'TỪ VỰNG'}
</span>
<button
onClick={(e) => { e.stopPropagation(); speak(current.audio_tts_text ?? current.word) }}
className="w-9 h-9 rounded-lg grid place-items-center text-[var(--at-mute)] hover:bg-[var(--at-accent-soft)] hover:text-[var(--at-accent)] transition"
aria-label="Phát âm"
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>volume_up</span>
</button>
</div>
<div className="flex-1 flex flex-col justify-center">
<div className="at-word" style={{ fontSize: 'clamp(40px, 5vw, 60px)' }}>{current.word}</div>
{(current.phonetic || current.part_of_speech) && (
<div className="at-mono text-sm text-[var(--at-mute)] mt-3">
{current.phonetic}
{current.part_of_speech && (
<span className="mt-3 px-3 py-1 bg-blue-50 text-blue-600 text-[11px] font-bold uppercase tracking-wider rounded-lg">
{current.part_of_speech}
</span>
<span className="at-serif italic text-[var(--at-mute-2)]"> · {current.part_of_speech}</span>
)}
</div>
<div className="flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>flip</span>
<span className="text-sm font-medium">Nhấp đ xem nghĩa</span>
)}
</div>
<div className="flex items-center justify-center gap-2 text-[11.5px] text-[var(--at-mute)]">
<span className="at-kbd">Space</span>
<span>đ lật thẻ</span>
</div>
</div>
{/* BACK */}
<div className="at-card-face at-card-back" style={{ padding: '20px 24px' }}>
<div className="flex items-center justify-between">
<span className="at-chip at-chip-mute">
<span className="at-chip-dot" />
NGHĨA
</span>
<button
onClick={(e) => { e.stopPropagation(); speak(current.audio_tts_text ?? current.word) }}
className="w-9 h-9 rounded-lg grid place-items-center text-[var(--at-mute)] hover:bg-[var(--at-accent-soft)] hover:text-[var(--at-accent)] transition"
aria-label="Phát âm"
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>volume_up</span>
</button>
</div>
<div className="flex-1 flex flex-col justify-center gap-4">
<div className="at-meaning" style={{ fontSize: 22 }}>{current.definition ?? '—'}</div>
{current.example && (
<div className="at-example">
<div className="at-serif italic text-[14px] leading-[1.45] text-[var(--at-ink-2)]">
"{current.example}"
</div>
</div>
</>
) : (
<>
<div className="text-xs font-bold tracking-widest text-slate-400 uppercase">NGHĨA</div>
<div className="flex flex-col items-center gap-3 text-center">
<p className="text-2xl font-bold text-slate-800">{current.definition ?? '—'}</p>
{current.example && (
<p className="text-sm text-slate-500 italic bg-slate-50 rounded-xl px-4 py-2.5 border border-slate-100 max-w-sm">
"{current.example}"
</p>
)}
</div>
<div className="flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>flip</span>
<span className="text-sm font-medium">Nhấp đ xem từ</span>
</div>
</>
)}
)}
</div>
<div className="flex items-center justify-center gap-2 text-[11.5px] text-[var(--at-mute)]">
<span className="at-kbd"></span>
<span>lật lại</span>
</div>
</div>
</div>
)}
{/* Action buttons */}
<div className="mt-12 flex flex-col items-center gap-4 w-full max-w-xl">
<div className={cn(
'flex items-stretch gap-3 w-full h-14 transition-all duration-300',
!isFlipped && 'opacity-40 pointer-events-none',
)}>
<button
onClick={() => handleAnswer('ignored')}
className="flex-1 flex items-center justify-center gap-2 rounded-2xl border-2 border-slate-200 text-slate-500 font-bold text-sm hover:bg-slate-100 transition-all"
>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>close</span>
Bỏ qua
</button>
<button
onClick={() => handleAnswer('hard')}
className="flex-1 flex items-center justify-center gap-2 rounded-2xl bg-amber-500 text-white font-bold text-sm shadow-md shadow-amber-500/20 hover:bg-amber-600 transition-all"
>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>warning</span>
Khó
</button>
<button
onClick={() => handleAnswer('easy')}
className="flex-1 flex items-center justify-center gap-2 rounded-2xl bg-blue-600 text-white font-bold text-sm shadow-md shadow-blue-600/20 hover:bg-blue-700 transition-all"
>
<span className="material-symbols-outlined" style={{ fontSize: 18, fontVariationSettings: "'FILL' 1" }}>thumb_up</span>
Dễ
</button>
<button
onClick={() => handleAnswer('known')}
className="flex-1 flex items-center justify-center gap-2 rounded-2xl bg-emerald-600 text-white font-bold text-sm shadow-md shadow-emerald-600/20 hover:bg-emerald-700 transition-all"
>
<span className="material-symbols-outlined" style={{ fontSize: 18, fontVariationSettings: "'FILL' 1" }}>check_circle</span>
Đã biết
</button>
</div>
{!isFlipped && (
<p className="text-sm text-slate-400 font-medium">Lật thẻ trước khi đánh giá</p>
)}
{isFlipped && (
<p className="text-sm text-slate-400 font-medium">Còn {total - currentIdx - 1} thẻ trong phiên này</p>
)}
</div>
</main>
)}
{/* Actions */}
<div className="mt-4 w-full" style={{ maxWidth: 420 }}>
<div className={cn('flex items-stretch gap-2.5 w-full transition-opacity duration-300', !isFlipped && 'opacity-40 pointer-events-none')}>
<button onClick={() => handleAnswer('ignored')} disabled={!isFlipped} className="at-action" style={{ padding: '11px 14px', fontSize: 13 }}>
Bỏ qua <span className="at-kbd">I</span>
</button>
<button onClick={() => handleAnswer('hard')} disabled={!isFlipped} className="at-action at-action-review" style={{ padding: '11px 14px', fontSize: 13 }}>
Cần ôn <span className="at-kbd">K</span>
</button>
<button onClick={() => handleAnswer('known')} disabled={!isFlipped} className="at-action at-action-known" style={{ padding: '11px 14px', fontSize: 13 }}>
Đã thuộc
<span className="at-kbd" style={{ background: 'rgba(255,255,255,0.16)', color: 'rgba(255,255,255,0.9)', border: 'none' }}>J</span>
</button>
</div>
</div>
{/* Progress */}
<div className="mt-3 w-full" style={{ maxWidth: 420 }}>
<div className="flex items-baseline justify-between mb-1.5 text-[12px] text-[var(--at-mute)]">
<span>
<b className="text-[var(--at-ink)] tabular-nums">{currentIdx + 1}</b> / {total} ·{' '}
{sessionStats.known} biết · {sessionStats.learning} học · {sessionStats.ignored} bỏ
</span>
<span className="at-pct" style={{ fontSize: 18 }}>{progressPct}%</span>
</div>
<div className="at-progress-bar">
<span style={{ width: `${progressPct}%` }} />
</div>
</div>
</div>
{/* Right sidebar */}
<aside className="hidden lg:flex flex-col gap-3 min-h-0">
{/* Today stats */}
<div
className="rounded-2xl p-4 flex-shrink-0"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div className="at-eyebrow mb-2" style={{ fontSize: 11 }}>Hôm nay</div>
<div className="grid grid-cols-2 gap-3 mt-1">
<div>
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}>
Đã học
</div>
<div
className="at-serif"
style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-ink)' }}
>
{sessionStats.known + sessionStats.learning + sessionStats.ignored}
</div>
</div>
<div>
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}>
Đúng
</div>
<div
className="at-serif"
style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-good)' }}
>
{sessionStats.known}
</div>
</div>
</div>
</div>
{/* Cards in deck — compact rows (word only) */}
<div
className="rounded-2xl p-3 flex flex-col"
style={{
background: 'var(--at-surface)',
border: '1px solid var(--at-line)',
maxHeight: 'calc((100vh - 4rem) / 2)',
}}
>
<div className="at-eyebrow mb-2 px-1" style={{ fontSize: 11 }}>Trong bộ này</div>
<div className="flex-1 min-h-0 overflow-y-auto -mx-1 px-1">
{sessionTerms.map((t, i) => {
const p = progressMap[t.id]
const isActive = i === currentIdx
const isKnown = p?.status === 'known'
const isBookmarked = bookmarks.has(t.id)
return (
<button
key={t.id}
onClick={() => jumpTo(i)}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left transition-colors"
style={{
background: isActive ? 'var(--at-brand-soft)' : 'transparent',
borderTop: i === 0 || isActive ? 'none' : '1px solid var(--at-line)',
}}
>
<span
className="at-serif italic flex-shrink-0 text-center"
style={{ fontSize: 13, color: 'var(--at-mute)', width: 20 }}
>
{i + 1}
</span>
<div className="flex-1 min-w-0">
<div
className="text-[13px] font-bold truncate"
style={{ color: isActive ? 'var(--at-brand-ink)' : 'var(--at-ink)' }}
>
{t.word}
</div>
<div
className="text-[11.5px] truncate mt-0.5"
style={{ color: 'var(--at-mute)' }}
>
{t.definition ?? '—'}
</div>
</div>
{isBookmarked && (
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: 13, color: 'var(--at-warm)', fontVariationSettings: "'FILL' 1" }}
>
bookmark
</span>
)}
{isKnown && (
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: 14, color: 'var(--at-good)' }}>
check
</span>
)}
</button>
)
})}
</div>
</div>
</aside>
</div>
</div>
)
}

View File

@@ -14,7 +14,6 @@ function ListCard({ list, userId }: { list: FlashcardList; userId: string | null
enabled: !!userId,
})
const countNew = list.total_words - progress.filter(p => p.status !== 'new').length
const countLearning = progress.filter(p => p.status === 'learning').length
const countKnown = progress.filter(p => p.status === 'known').length
const progressPct = list.total_words > 0
@@ -22,68 +21,78 @@ function ListCard({ list, userId }: { list: FlashcardList; userId: string | null
: 0
return (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 flex flex-col hover:-translate-y-1 transition-transform duration-300">
<div
className="rounded-2xl p-6 flex flex-col transition-all hover:-translate-y-1"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 24 }}>layers</span>
<div
className="w-11 h-11 rounded-xl grid place-items-center flex-shrink-0 at-serif italic"
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)', fontSize: 18, fontWeight: 500 }}
>
{list.title.charAt(0).toUpperCase()}
</div>
<div>
<h3 className="text-base font-bold text-slate-800 leading-tight">{list.title}</h3>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 14 }}>book</span>
<span className="text-xs text-slate-500 font-medium">{list.total_words} từ</span>
<div className="min-w-0">
<h3
className="at-serif text-[17px] leading-[1.2] tracking-tight line-clamp-2"
style={{ color: 'var(--at-ink)', fontWeight: 500 }}
>
{list.title}
</h3>
<div className="flex items-center gap-1.5 mt-1">
<span className="material-symbols-outlined" style={{ fontSize: 13, color: 'var(--at-mute)' }}>book</span>
<span className="text-xs font-medium" style={{ color: 'var(--at-mute)' }}>
{list.total_words} từ
</span>
</div>
</div>
</div>
<span className={`text-[11px] font-bold tracking-wide uppercase px-2.5 py-1 rounded-full ${
list.is_public ? 'bg-blue-50 text-blue-700' : 'bg-slate-100 text-slate-500'
}`}>
<span className={`at-chip ${list.is_public ? 'at-chip-brand' : ''}`}>
<span className="at-chip-dot" />
{list.is_public ? 'Công khai' : 'Riêng tư'}
</span>
</div>
{list.description && (
<p className="text-xs text-slate-500 mb-3 line-clamp-2">{list.description}</p>
<p className="text-xs leading-[1.5] mb-4 line-clamp-2" style={{ color: 'var(--at-mute)' }}>
{list.description}
</p>
)}
<div className="mt-2 mb-4">
<div className="flex justify-between items-center mb-1.5">
<span className="text-xs font-bold text-emerald-600">Tiến đ: {progressPct}%</span>
<div className="flex justify-between items-baseline mb-2">
<span className="at-eyebrow" style={{ fontSize: 10 }}>Tiến đ</span>
<span
className="at-serif italic"
style={{ fontSize: 18, color: 'var(--at-brand)', letterSpacing: '-0.02em', lineHeight: 1 }}
>
{progressPct}%
</span>
</div>
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full transition-all duration-500"
style={{ width: `${progressPct}%` }}
/>
<div className="at-bar">
<span style={{ width: `${progressPct}%` }} />
</div>
</div>
<div className="flex gap-2 mb-5">
<div className="flex-1 py-2 bg-slate-50 rounded-lg text-center">
<p className="text-[10px] uppercase font-bold text-slate-400">Mới</p>
<p className="text-sm font-bold text-slate-600">{countNew}</p>
</div>
<div className="flex-1 py-2 bg-blue-50 rounded-lg text-center">
<p className="text-[10px] uppercase font-bold text-blue-500">Học</p>
<p className="text-sm font-bold text-blue-600">{countLearning}</p>
</div>
<div className="flex-1 py-2 bg-emerald-50 rounded-lg text-center">
<p className="text-[10px] uppercase font-bold text-emerald-600">Biết</p>
<p className="text-sm font-bold text-emerald-600">{countKnown}</p>
</div>
<div className="grid grid-cols-3 gap-2 mb-5">
<Stat num={list.total_words - countLearning - countKnown} label="Mới" />
<Stat num={countLearning} label="Học" color="var(--at-brand)" />
<Stat num={countKnown} label="Biết" color="var(--at-good)" />
</div>
<div className="grid grid-cols-2 gap-3 mt-auto">
<div className="grid grid-cols-2 gap-2 mt-auto">
<button
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(list.id) } })}
className="border-2 border-blue-600 text-blue-600 font-bold py-2.5 rounded-xl text-sm hover:bg-blue-50 transition-colors"
className="py-2.5 rounded-xl text-[13px] font-semibold transition-colors"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
>
Xem thẻ
</button>
<button
onClick={() => navigate({ to: '/flash-card/$listId/learn', params: { listId: String(list.id) } })}
className="bg-gradient-to-br from-blue-600 to-blue-500 text-white font-bold py-2.5 rounded-xl text-sm shadow-md shadow-blue-500/20 hover:opacity-90 active:scale-95 transition-all"
className="py-2.5 rounded-xl text-[13px] font-semibold transition-opacity hover:opacity-90"
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
>
Học ngay
</button>
@@ -92,6 +101,22 @@ function ListCard({ list, userId }: { list: FlashcardList; userId: string | null
)
}
function Stat({ num, label, color }: { num: number; label: string; color?: string }) {
return (
<div className="text-center py-1.5 rounded-lg" style={{ background: 'var(--at-paper-2)' }}>
<div
className="at-serif"
style={{ fontSize: 20, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1, color: color ?? 'var(--at-ink)' }}
>
{num}
</div>
<div className="text-[10px] font-semibold mt-1 tracking-wider uppercase" style={{ color: 'var(--at-mute)' }}>
{label}
</div>
</div>
)
}
export function FlashCardListPage() {
const user = useAuthStore(s => s.user)
const { data: lists = [], isLoading, isError } = useQuery({
@@ -100,48 +125,49 @@ export function FlashCardListPage() {
})
return (
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
<div className="flex justify-between items-end mb-8">
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div>
<h1 className="text-2xl font-extrabold text-slate-800 tracking-tight">Bộ Thẻ Từ Vựng</h1>
<p className="text-slate-500 mt-1">Chọn bộ thẻ đ bắt đu học</p>
</div>
<div className="hidden lg:flex items-center gap-2 bg-slate-100 px-4 py-2 rounded-full text-slate-500 text-sm font-medium border border-slate-200">
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>filter_list</span>
Sắp xếp: Mới nhất
<div className="at-eyebrow mb-3">Từ vựng TOEIC</div>
<h1 className="at-title text-4xl lg:text-[44px]">
Bộ thẻ <i>ghi nhớ</i>
</h1>
<p className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
Chọn bộ thẻ đ bắt đu {lists.length} bộ sưu tầm
</p>
</div>
</div>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-white rounded-2xl border border-slate-200 p-6 h-64 animate-pulse">
<div className="flex gap-3 mb-4">
<div className="w-12 h-12 bg-slate-100 rounded-xl" />
<div className="flex-1">
<div className="h-4 bg-slate-100 rounded mb-2" />
<div className="h-3 bg-slate-100 rounded w-2/3" />
</div>
</div>
<div className="h-2 bg-slate-100 rounded-full mb-4" />
<div className="flex gap-2">
{[0, 1, 2].map(j => <div key={j} className="flex-1 h-12 bg-slate-100 rounded-lg" />)}
</div>
</div>
<div
key={i}
className="rounded-2xl p-6 h-64 animate-pulse"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
/>
))}
</div>
) : isError ? (
<div className="bg-red-50 border border-red-100 rounded-2xl p-10 text-center">
<p className="text-red-500 text-sm">Không thể tải danh sách bộ thẻ. Vui lòng thử lại.</p>
<div
className="rounded-2xl p-10 text-center"
style={{ background: 'var(--at-bad-soft)', border: '1px solid rgba(193,68,62,0.2)' }}
>
<p className="text-sm" style={{ color: 'var(--at-bad)' }}>
Không thể tải danh sách bộ thẻ. Vui lòng thử lại.
</p>
</div>
) : lists.length === 0 ? (
<div className="bg-white border border-slate-200 rounded-2xl p-16 text-center">
<span className="material-symbols-outlined text-slate-300 mb-3 block" style={{ fontSize: 48 }}>library_books</span>
<p className="text-slate-500 font-medium">Chưa bộ thẻ nào.</p>
<p className="text-slate-400 text-sm mt-1">Bộ thẻ từ vựng TOEIC sẽ đưc thêm sớm!</p>
<div
className="rounded-2xl p-16 text-center"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<span className="material-symbols-outlined mb-3 block" style={{ fontSize: 48, color: 'var(--at-mute-2)' }}>library_books</span>
<p className="at-serif text-lg" style={{ color: 'var(--at-ink)' }}>Chưa bộ thẻ nào.</p>
<p className="text-sm mt-1" style={{ color: 'var(--at-mute)' }}>Bộ thẻ từ vựng TOEIC sẽ đưc thêm sớm!</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{lists.map(list => (
<ListCard key={list.id} list={list} userId={user?.id ?? null} />
))}

View File

@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth-store'
import { fetchFlashcardTerms, fetchUserProgress } from '../api/flashcard-api'
import type { FlashcardTerm, UserProgress } from '../api/flashcard-api'
import { resolveMediaUrl } from '../lib/media-url'
type FilterStatus = 'all' | 'new' | 'learning' | 'known' | 'ignored'
@@ -15,11 +16,11 @@ const STATUS_LABEL: Record<string, string> = {
ignored: 'Bỏ qua',
}
const STATUS_STYLE: Record<string, string> = {
new: 'bg-slate-100 text-slate-500',
learning: 'bg-blue-50 text-blue-700',
known: 'bg-emerald-50 text-emerald-700',
ignored: 'bg-rose-50 text-rose-600',
const STATUS_CLASS: Record<string, string> = {
new: 'at-chip',
learning: 'at-chip at-chip-brand',
known: 'at-chip at-chip-good',
ignored: 'at-chip at-chip-warm',
}
interface Props {
@@ -67,85 +68,107 @@ export function FlashCardTermsPage({ listId }: Props) {
})
return (
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => navigate({ to: '/flash-card' })}
className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-slate-100 transition-colors"
>
<span className="material-symbols-outlined text-slate-600" style={{ fontSize: 22 }}>arrow_back</span>
</button>
<div>
<h1 className="text-xl font-bold text-slate-800 tracking-tight">Bộ thẻ từ vựng</h1>
<span className="text-sm text-slate-400 font-medium">{countAll} từ</span>
</div>
</div>
{/* Hero actions + stats */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
{/* Editorial head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div className="flex items-start gap-4 min-w-0">
<button
onClick={() => navigate({ to: '/flash-card/$listId/learn', params: { listId: String(listId) } })}
className="bg-gradient-to-br from-blue-600 to-blue-500 text-white px-6 py-3 rounded-xl flex items-center gap-2 font-bold text-sm shadow-md shadow-blue-500/20 hover:opacity-90 active:scale-95 transition-all"
onClick={() => navigate({ to: '/flash-card' })}
className="w-10 h-10 flex-shrink-0 grid place-items-center rounded-xl transition-colors hover:bg-[var(--at-line-2)]"
style={{ color: 'var(--at-mute)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 18, fontVariationSettings: "'FILL' 1" }}>play_arrow</span>
Bắt đu học
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>arrow_back</span>
</button>
<div className="min-w-0">
<div className="at-eyebrow mb-2">Bộ thẻ từ vựng</div>
<h1 className="at-title text-[32px] lg:text-4xl">
{countAll} <i>từ</i>
</h1>
</div>
</div>
<div className="flex flex-wrap gap-2">
<span className="px-3 py-1.5 bg-slate-100 text-slate-600 rounded-full text-xs font-semibold">Tổng: {countAll}</span>
<span className="px-3 py-1.5 bg-slate-100 text-slate-500 rounded-full text-xs font-semibold">Mới: {countNew}</span>
<span className="px-3 py-1.5 bg-blue-50 text-blue-600 rounded-full text-xs font-semibold">Đang học: {countLearning}</span>
<span className="px-3 py-1.5 bg-emerald-50 text-emerald-600 rounded-full text-xs font-semibold">Đã biết: {countKnown}</span>
</div>
<button
onClick={() => navigate({ to: '/flash-card/$listId/learn', params: { listId: String(listId) } })}
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl text-[13.5px] font-semibold transition-opacity hover:opacity-90"
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 16, fontVariationSettings: "'FILL' 1" }}>play_arrow</span>
Bắt đu học
</button>
</div>
{/* Filter + Search */}
<div className="bg-slate-50 rounded-2xl p-5 mb-6">
<div className="flex flex-col md:flex-row md:items-center gap-4 justify-between">
{/* Stats + filters */}
<div
className="rounded-2xl p-5 mb-6"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div className="flex flex-col md:flex-row md:items-center gap-4 justify-between mb-4">
<div className="relative flex-1 max-w-sm">
<span className="material-symbols-outlined absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400" style={{ fontSize: 18 }}>search</span>
<span
className="material-symbols-outlined absolute left-3.5 top-1/2 -translate-y-1/2"
style={{ fontSize: 18, color: 'var(--at-mute)' }}
>
search
</span>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Tìm kiếm từ..."
className="w-full pl-10 pr-4 py-2.5 bg-white rounded-xl border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 transition-all"
className="w-full pl-10 pr-4 py-2.5 rounded-full text-sm focus:outline-none"
style={{
background: 'var(--at-paper-2)',
border: '1px solid var(--at-line)',
color: 'var(--at-ink)',
}}
/>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-1 md:pb-0">
<div className="flex items-center gap-1.5 overflow-x-auto">
{(['all', 'new', 'learning', 'known', 'ignored'] as FilterStatus[]).map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={cn(
'px-4 py-2 rounded-full text-xs font-bold whitespace-nowrap transition-colors',
filter === f
? 'bg-slate-800 text-white'
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-100',
'px-3.5 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-colors',
)}
style={{
background: filter === f ? 'var(--at-ink)' : 'var(--at-paper-2)',
color: filter === f ? 'var(--at-paper)' : 'var(--at-ink-2)',
border: '1px solid ' + (filter === f ? 'var(--at-ink)' : 'var(--at-line)'),
}}
>
{f === 'all' ? 'Tất cả' : STATUS_LABEL[f]}
</button>
))}
</div>
</div>
<div className="grid grid-cols-4 gap-2">
<HeadStat num={countAll} label="Tổng" />
<HeadStat num={countNew} label="Mới" />
<HeadStat num={countLearning} label="Đang học" color="var(--at-brand)" />
<HeadStat num={countKnown} label="Đã biết" color="var(--at-good)" />
</div>
</div>
{/* Terms list */}
{loadingTerms ? (
<div className="space-y-3">
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="bg-white rounded-xl border border-slate-100 p-5 h-16 animate-pulse" />
<div
key={i}
className="rounded-xl h-20 animate-pulse"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
/>
))}
</div>
) : filtered.length === 0 ? (
<div className="bg-white border border-slate-200 rounded-2xl p-12 text-center">
<p className="text-slate-400 text-sm">Không tìm thấy từ nào phù hợp.</p>
<div
className="rounded-2xl p-12 text-center"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<p className="text-sm" style={{ color: 'var(--at-mute)' }}>Không tìm thấy từ nào phù hợp.</p>
</div>
) : (
<div className="space-y-3">
<div className="space-y-2">
{filtered.map(term => (
<TermRow key={term.id} term={term} status={getStatus(term.id)} />
))}
@@ -155,33 +178,72 @@ export function FlashCardTermsPage({ listId }: Props) {
)
}
function TermRow({ term, status }: { term: FlashcardTerm; status: UserProgress['status'] }) {
function HeadStat({ num, label, color }: { num: number; label: string; color?: string }) {
return (
<div className="bg-white rounded-xl border border-slate-100 p-5 flex items-center gap-4 hover:shadow-sm transition-shadow">
<div className="w-1/4 min-w-0">
<div className="flex items-baseline gap-2 mb-1 flex-wrap">
<h3 className="text-base font-extrabold text-blue-600 tracking-tight truncate">{term.word}</h3>
{term.phonetic && (
<span className="text-xs text-slate-400 font-medium shrink-0">{term.phonetic}</span>
)}
</div>
{term.part_of_speech && (
<span className="text-[10px] uppercase tracking-wider font-bold px-2 py-0.5 bg-slate-100 text-slate-500 rounded">
{term.part_of_speech}
</span>
)}
<div className="text-center py-2 rounded-lg" style={{ background: 'var(--at-paper-2)' }}>
<div
className="at-serif"
style={{ fontSize: 22, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1, color: color ?? 'var(--at-ink)' }}
>
{num}
</div>
<div className="flex-1 min-w-0 text-slate-700 font-medium text-sm line-clamp-2">
{term.definition ?? '—'}
</div>
<div className="flex items-center gap-4 flex-shrink-0">
<span className={cn('px-3 py-1.5 rounded-full text-xs font-bold', STATUS_STYLE[status])}>
{STATUS_LABEL[status]}
</span>
<button className="text-slate-300 hover:text-slate-500 transition-colors">
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>more_vert</span>
</button>
<div
className="mt-1"
style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 600 }}
>
{label}
</div>
</div>
)
}
function TermRow({ term, status }: { term: FlashcardTerm; status: UserProgress['status'] }) {
const imageSrc = resolveMediaUrl(term.image_url)
return (
<div
className="rounded-xl p-4 flex items-center gap-4 transition-shadow hover:shadow-sm"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
{imageSrc && (
<img
src={imageSrc}
alt={term.word}
loading="lazy"
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
style={{ background: 'var(--at-line-2)' }}
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
/>
)}
<div className="w-1/4 min-w-0">
<div className="flex items-baseline gap-2 mb-1 flex-wrap">
<h3
className="at-serif text-[17px] tracking-tight truncate"
style={{ color: 'var(--at-ink)', fontWeight: 500 }}
>
{term.word}
</h3>
{term.phonetic && (
<span className="at-mono text-[11.5px] shrink-0" style={{ color: 'var(--at-mute)' }}>
{term.phonetic}
</span>
)}
</div>
{term.part_of_speech && (
<span
className="at-serif italic"
style={{ fontSize: 11, color: 'var(--at-mute-2)' }}
>
· {term.part_of_speech}
</span>
)}
</div>
<div className="flex-1 min-w-0 text-sm line-clamp-2" style={{ color: 'var(--at-ink-2)' }}>
{term.definition ?? '—'}
</div>
<span className={STATUS_CLASS[status]}>
<span className="at-chip-dot" />
{STATUS_LABEL[status]}
</span>
</div>
)
}

View File

@@ -0,0 +1,7 @@
const MEDIA_BASE_URL = 'https://study4.com'
export function resolveMediaUrl(path: string | null | undefined): string | null {
if (!path) return null
if (path.startsWith('http://') || path.startsWith('https://')) return path
return `${MEDIA_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`
}

View File

@@ -0,0 +1,30 @@
import type { UserProgress } from '../api/flashcard-api'
export const EASE = {
ignored: -1,
hard: 0.1,
easy: 0.65,
known: 1.0,
} as const
export type EaseKey = keyof typeof EASE
const INTERVAL_LADDER: Record<Exclude<EaseKey, 'ignored'>, number[]> = {
// count: 0 1 2 3 4 5+
known: [1, 3, 7, 14, 30, 60],
easy: [1, 2, 4, 8, 14, 30],
hard: [1, 1, 1, 2, 3, 5],
}
export function computeNextReview(key: EaseKey, reviewCount: number): string | null {
if (key === 'ignored') return null
const ladder = INTERVAL_LADDER[key]
const days = ladder[Math.min(reviewCount, ladder.length - 1)]
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString()
}
export function statusFor(key: EaseKey): UserProgress['status'] {
if (key === 'known') return 'known'
if (key === 'ignored') return 'ignored'
return 'learning'
}

View File

@@ -6,199 +6,293 @@ const FEATURES = [
{
to: '/toeic',
icon: 'assignment',
iconBg: 'bg-blue-50',
iconColor: 'text-blue-600',
borderColor: 'border-l-blue-600',
title: 'Luyện đề TOEIC',
desc: 'Kho đề thi cập nhật theo cấu trúc mới nhất. Phân tích điểm yếu chi tiết từng Part.',
cta: 'Bắt đầu ngay',
ctaColor: 'text-blue-600',
title: 'Luyện đề',
accent: 'TOEIC',
desc: 'Kho đề thi cập nhật theo cấu trúc mới nhất. Phân tích điểm yếu từng Part.',
stat: '350+ câu hỏi',
},
{
to: '/writing',
icon: 'auto_fix_high',
iconBg: 'bg-green-50',
iconColor: 'text-green-600',
borderColor: 'border-l-green-600',
title: 'AI Chấm Writing',
desc: 'Phản hồi tức thì về ngữ pháp, từ vựng, cấu trúc và bài viết mẫu từ AI.',
cta: 'Thử ngay',
ctaColor: 'text-green-600',
title: 'AI chấm',
accent: 'Writing',
desc: 'Phản hồi tức thì về ngữ pháp, từ vựng, cấu trúc và bài viết mẫu.',
stat: '3 lượt / ngày',
},
{
to: '/flash-card',
icon: 'menu_book',
iconBg: 'bg-amber-50',
iconColor: 'text-amber-600',
borderColor: 'border-l-amber-600',
title: 'Từ vựng thông minh',
desc: '720 từ TOEIC theo 6 chủ đề. Flashcard với hiệu ứng lật 3D.',
cta: 'Khám phá',
ctaColor: 'text-amber-600',
stat: '720 từ vựng',
title: 'Từ vựng',
accent: 'thông minh',
desc: 'Bộ thẻ TOEIC với spaced-repetition, lật 3D, ảnh minh hoạ.',
stat: '18 000+ từ',
},
]
export function Home() {
const user = useUser()
const openModal = useAuthModalStore((s) => s.open)
const firstName = user?.name ?? 'bạn'
return (
<div className="px-6 py-8 max-w-6xl mx-auto page-enter">
{/* Hero */}
<section className="flex flex-col lg:flex-row gap-10 items-center mb-12">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center gap-2 bg-blue-50 text-blue-600 text-xs font-bold px-3 py-1.5 rounded-full mb-5 uppercase tracking-wider">
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>auto_awesome</span>
AI-Powered Learning
</div>
<h1 className="text-4xl lg:text-5xl font-extrabold leading-tight text-slate-800 mb-4" style={{ letterSpacing: '-0.02em' }}>
Luyện TOEIC<br />thông minh<br />
<span className="text-blue-600 italic">cùng AI</span>
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
{/* Page head — editorial */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div>
<div className="at-eyebrow mb-3">Học TOEIC cùng AI</div>
<h1 className="at-title text-4xl lg:text-[44px]">
Chào <i>{firstName}</i>,<br />
hôm nay học <i>15 phút</i>?
</h1>
<p className="text-slate-500 text-lg leading-relaxed mb-8 max-w-md">
nhân hóa lộ trình học tập đ bứt phá điểm số trong thời gian ngắn nhất. AI phân tích điểm yếu tối ưu bài tập cho bạn.
</p>
<div className="flex gap-3 flex-wrap">
<Link
to="/toeic"
className="bg-blue-600 text-white px-8 py-3.5 rounded-xl font-bold text-sm hover:bg-blue-700 transition-colors shadow-lg shadow-blue-600/20"
>
Bắt đu ngay
</Link>
<Link
to="/writing"
className="border border-slate-200 px-8 py-3.5 rounded-xl font-bold text-sm text-slate-500 hover:bg-white hover:border-blue-600 hover:text-blue-600 transition-all"
>
Thử AI Writing
</Link>
</div>
<div className="flex gap-6 mt-8">
<div>
<div className="text-2xl font-extrabold text-blue-600">350+</div>
<div className="text-xs text-slate-400 mt-0.5">Câu hỏi TOEIC</div>
</div>
<div className="w-px bg-slate-200" />
<div>
<div className="text-2xl font-extrabold text-green-600">720</div>
<div className="text-xs text-slate-400 mt-0.5">Từ vựng</div>
</div>
<div className="w-px bg-slate-200" />
<div>
<div className="text-2xl font-extrabold text-amber-600">AI</div>
<div className="text-xs text-slate-400 mt-0.5">Writing Checker</div>
</div>
<div className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
Mục tiêu <b style={{ color: 'var(--at-ink)' }}>850</b>
<span className="mx-2 inline-block w-[3px] h-[3px] rounded-full align-middle" style={{ background: 'var(--at-mute-2)' }} />
hiện tại <b style={{ color: 'var(--at-ink)' }}>720</b>
<span className="mx-2 inline-block w-[3px] h-[3px] rounded-full align-middle" style={{ background: 'var(--at-mute-2)' }} />
còn <b style={{ color: 'var(--at-brand)' }}>130 điểm</b> nữa
</div>
</div>
<div className="flex gap-2.5">
<Link
to="/flash-card"
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl text-[13.5px] font-semibold transition-colors"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
>
Học từ vựng
</Link>
<Link
to="/toeic"
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl text-[13.5px] font-semibold transition-colors hover:opacity-90"
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)', border: '1px solid var(--at-ink)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>play_arrow</span>
Tiếp tục học
</Link>
</div>
</div>
{/* Preview card — hidden on mobile */}
<div className="hidden lg:block flex-shrink-0 w-80">
<div className="bg-white rounded-2xl p-6 shadow-xl border border-slate-100">
<div className="flex items-center justify-between mb-5">
<div className="grid lg:grid-cols-[2fr_1fr] gap-5">
{/* MAIN COL */}
<div className="flex flex-col gap-5 min-w-0">
{/* Progress hero */}
<div className="rounded-2xl p-7 flex flex-wrap items-center gap-7" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<ProgressRing value={85} />
<div className="flex-1 min-w-[240px]">
<div className="at-eyebrow mb-1">Lộ trình</div>
<div className="at-serif text-[22px] leading-[1.2] tracking-tight mb-3" style={{ color: 'var(--at-ink)' }}>
Bạn đang đi <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>đúng hướng</i> tuần này 4/7 ngày.
</div>
<div className="flex flex-wrap gap-4 items-stretch">
<Stat num="24" label="ngày còn lại" />
<div className="w-px self-stretch" style={{ background: 'var(--at-line)' }} />
<Stat num="+46" label="điểm tháng này" />
<div className="w-px self-stretch" style={{ background: 'var(--at-line)' }} />
<Stat num="68%" label="tỷ lệ đúng" color="var(--at-good)" />
</div>
</div>
</div>
{/* Feature cards */}
<div className="rounded-2xl p-6" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<div className="at-eyebrow mb-1">Khám phá</div>
<h2 className="at-serif text-[22px] tracking-tight mb-5" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
Tính năng <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>nổi bật</i>
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{FEATURES.map((f) => (
<Link
key={f.to}
to={f.to}
className="rounded-xl p-4 transition-all hover:-translate-y-0.5"
style={{ background: 'var(--at-paper-2)', border: '1px solid var(--at-line)' }}
>
<div className="flex items-center justify-between mb-3">
<div
className="w-9 h-9 rounded-lg grid place-items-center"
style={{ background: 'var(--at-brand-soft)', color: 'var(--at-brand)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>{f.icon}</span>
</div>
<span className="at-chip at-chip-brand">
<span className="at-chip-dot" />
{f.stat}
</span>
</div>
<div className="at-serif text-[17px] leading-[1.15] tracking-tight mb-1" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
{f.title} <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>{f.accent}</i>
</div>
<div className="text-[12.5px] leading-[1.5]" style={{ color: 'var(--at-mute)' }}>{f.desc}</div>
</Link>
))}
</div>
</div>
{/* 7-day journey */}
<div className="rounded-2xl p-6" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<div className="flex justify-between items-end mb-4">
<div>
<div className="font-bold text-base text-slate-800">Tiến đ tuần này</div>
<div className="text-xs text-slate-400 mt-0.5">Bạn đang làm rất tốt!</div>
<div className="at-eyebrow mb-1">Tuần này</div>
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
Lộ trình <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>7 ngày</i>
</div>
</div>
<div className="bg-green-50 text-green-600 text-xs font-bold px-2.5 py-1 rounded-lg">+12%</div>
<span className="at-chip at-chip-good">
<span className="at-chip-dot" />
+24% so với tuần trước
</span>
</div>
<div className="mb-4">
<div className="flex justify-between text-xs font-semibold mb-1.5">
<span>Reading Score</span><span className="text-blue-600">420/495</span>
</div>
<div className="h-1.5 w-full rounded-full bg-slate-100">
<div className="h-full bg-blue-600 rounded-full" style={{ width: '85%' }} />
</div>
</div>
<div className="mb-4">
<div className="flex justify-between text-xs font-semibold mb-1.5">
<span>Listening Score</span><span className="text-green-600">380/495</span>
</div>
<div className="h-1.5 w-full rounded-full bg-slate-100">
<div className="h-full bg-green-600 rounded-full" style={{ width: '77%' }} />
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-4">
<div className="bg-blue-50 rounded-xl p-3 border-l-4 border-blue-600">
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 18 }}>local_fire_department</span>
<div className="text-xl font-extrabold text-blue-600 mt-1">14</div>
<div className="text-xs text-slate-400">Ngày Streak</div>
</div>
<div className="bg-green-50 rounded-xl p-3 border-l-4 border-green-600">
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 18, fontVariationSettings: "'FILL' 1" }}>star</span>
<div className="text-xl font-extrabold text-green-600 mt-1">1,250</div>
<div className="text-xs text-slate-400">Điểm tích lũy</div>
</div>
</div>
<div className="mt-4 pt-4 border-t border-slate-100 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 16 }}>psychology</span>
</div>
<p className="text-xs text-slate-500">
<span className="font-semibold">AI gợi ý:</span> Ôn thêm Part 5 Ngữ pháp
</p>
<div className="grid grid-cols-7 gap-2.5">
{['T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'CN'].map((d, i) => {
const h = [60, 85, 40, 90, 75, 0, 0][i]
const done = h > 0
const today = i === 4
return (
<div key={d} className="flex flex-col items-center gap-2">
<div className="w-full relative overflow-hidden rounded-[10px]" style={{ height: 96, background: 'var(--at-line-2)' }}>
<div
className="absolute left-0 right-0 bottom-0 rounded-[10px] transition-[height] duration-500"
style={{
height: `${h}%`,
background: today ? 'var(--at-brand)' : done ? 'var(--at-brand-soft)' : 'var(--at-line-2)',
}}
/>
</div>
<div
className={today ? 'at-serif italic' : ''}
style={{ fontSize: 11, color: today ? 'var(--at-brand)' : 'var(--at-mute)', fontWeight: today ? 700 : 500 }}
>
{d}
</div>
</div>
)
})}
</div>
</div>
</div>
</section>
{/* Feature cards */}
<section>
<h2 className="text-2xl font-extrabold text-slate-800 mb-1.5">Tính năng nổi bật</h2>
<p className="text-slate-500 mb-6">Hệ sinh thái học tập toàn diện đưc thiết kế đ tối ưu hoá điểm số.</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{FEATURES.map((f) => (
<Link
key={f.to}
to={f.to}
className={`bg-white rounded-2xl p-6 border border-slate-200 border-l-4 ${f.borderColor} hover:-translate-y-1 hover:shadow-md transition-all duration-200`}
>
<div className={`w-12 h-12 ${f.iconBg} rounded-xl flex items-center justify-center mb-4`}>
<span className={`material-symbols-outlined ${f.iconColor}`}>{f.icon}</span>
{/* SIDE COL */}
<div className="flex flex-col gap-5">
{/* Streak card (inky) */}
<div className="rounded-2xl p-5" style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}>
<div className="flex items-center justify-between mb-3.5">
<div style={{ fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'rgba(250,248,243,0.55)', fontWeight: 600 }}>
Streak
</div>
<h3 className="font-bold text-base text-slate-800 mb-2">{f.title}</h3>
<p className="text-slate-500 text-sm leading-relaxed mb-4">{f.desc}</p>
<div className={`flex items-center gap-1.5 text-sm font-bold ${f.ctaColor}`}>
{f.cta}
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>arrow_forward</span>
<div className="w-10 h-10 rounded-xl grid place-items-center" style={{ background: 'rgba(255,255,255,0.08)', color: '#FFC27A' }}>
<span className="material-symbols-outlined" style={{ fontSize: 20, fontVariationSettings: "'FILL' 1" }}>local_fire_department</span>
</div>
</Link>
))}
</div>
</section>
{/* CTA banner */}
<section className="mt-10">
<div className="bg-blue-600 rounded-2xl p-8 flex items-center justify-between overflow-hidden relative">
<div className="absolute right-4 top-0 bottom-0 flex items-center opacity-10">
<span className="material-symbols-outlined text-white" style={{ fontSize: 120 }}>emoji_events</span>
</div>
<div className="at-serif" style={{ fontSize: 44, fontWeight: 400, letterSpacing: '-0.03em', lineHeight: 1, marginBottom: 4 }}>
7 <span className="italic opacity-65" style={{ fontSize: 18 }}>ngày</span>
</div>
<div style={{ fontSize: 12, color: 'rgba(250,248,243,0.55)', marginBottom: 14 }}>Kỷ lục: 21 ngày</div>
<div className="flex gap-1.5">
{['T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'CN'].map((d, i) => (
<div
key={i}
className="flex-1 rounded-md grid place-items-center"
style={{
height: 24,
background: i < 5 ? '#C15A34' : 'rgba(255,255,255,0.08)',
color: i < 5 ? 'white' : 'rgba(255,255,255,0.4)',
fontSize: 9,
fontWeight: 600,
}}
>
{d}
</div>
))}
</div>
</div>
<div className="relative z-10">
<h3 className="text-2xl font-extrabold text-white mb-2">Sẵn sàng chinh phục 990 TOEIC?</h3>
<p className="text-blue-100 mb-5">
{user
? `Chào ${user.name}! Tiếp tục luyện thi hôm nay.`
: 'Đăng ký miễn phí để lưu tiến độ và luyện thi không giới hạn.'}
</p>
{user ? (
<Link
to="/toeic"
className="inline-block bg-white text-blue-600 px-6 py-3 rounded-xl font-bold text-sm hover:bg-blue-50 transition-colors"
>
Luyện thi ngay
</Link>
) : (
{/* AI nudge */}
<div className="at-pullquote">
<div className="flex items-center gap-2 mb-2.5">
<div className="w-6 h-6 rounded-lg grid place-items-center" style={{ background: 'var(--at-brand)', color: 'white' }}>
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>auto_awesome</span>
</div>
<div style={{ fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--at-brand-ink)', fontWeight: 700 }}>
AI gợi ý
</div>
</div>
<div className="at-pullquote-q">
"Bạn yếu nhất <b style={{ fontWeight: 600 }}>Part 3</b> — dành 10 phút hôm nay có thể tăng <b style={{ fontWeight: 600 }}>30+ điểm</b>."
</div>
<div className="mt-2.5 text-[11px] opacity-70" style={{ color: 'var(--at-brand-ink)' }}> EnglishAI Coach</div>
</div>
{/* Pro tip */}
<div className="at-tip">
<div className="at-tip-label">Pro tip</div>
<div className="text-[12.5px] leading-[1.55]" style={{ color: 'var(--at-ink-2)' }}>
Học theo <b style={{ color: 'var(--at-warm)', fontWeight: 700 }}>cụm từ</b> (collocations) giúp bạn ghi nhớ nhanh hơn{' '}
<b style={{ color: 'var(--at-warm)', fontWeight: 700 }}>40%</b> so với học từ đơn lẻ.
</div>
</div>
{/* Guest CTA (only if not logged in) */}
{!user && (
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<div className="at-eyebrow mb-2">Khách</div>
<div className="at-serif text-[17px] leading-[1.2] tracking-tight mb-3" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
Đăng đ <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>lưu tiến đ</i>.
</div>
<button
onClick={() => openModal('register')}
className="bg-white text-blue-600 px-6 py-3 rounded-xl font-bold text-sm hover:bg-blue-50 transition-colors"
className="w-full py-2.5 rounded-xl text-[13.5px] font-semibold transition-opacity hover:opacity-90"
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
>
Đăng miễn phí
</button>
)}
</div>
</div>
)}
</div>
</section>
</div>
</div>
)
}
function Stat({ num, label, color }: { num: string; label: string; color?: string }) {
return (
<div>
<div className="at-serif" style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1, color: color ?? 'var(--at-ink)' }}>
{num}
</div>
<div style={{ fontSize: 11, color: 'var(--at-mute)', marginTop: 4 }}>{label}</div>
</div>
)
}
function ProgressRing({ value }: { value: number }) {
const r = 58
const c = 2 * Math.PI * r
const offset = c - (value / 100) * c
return (
<div className="relative grid place-items-center" style={{ width: 132, height: 132 }}>
<svg width="132" height="132">
<circle cx="66" cy="66" r={r} fill="none" stroke="var(--at-line-2)" strokeWidth="7" />
<circle
cx="66"
cy="66"
r={r}
fill="none"
stroke="var(--at-brand)"
strokeWidth="7"
strokeDasharray={c}
strokeDashoffset={offset}
strokeLinecap="round"
transform="rotate(-90 66 66)"
style={{ transition: 'stroke-dashoffset 0.6s cubic-bezier(0.2, 0.7, 0.2, 1)' }}
/>
</svg>
<div className="absolute text-center">
<div className="at-serif" style={{ fontSize: 34, fontWeight: 400, letterSpacing: '-0.025em', lineHeight: 1, color: 'var(--at-ink)' }}>
720
</div>
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', letterSpacing: '0.12em', marginTop: 4, fontWeight: 600 }}>
/ 850
</div>
</div>
</div>
)
}

View File

@@ -3,7 +3,7 @@ import { useNavigate } from '@tanstack/react-router'
import { CircularProgress } from '@/components/CircularProgress'
import { useTestStore } from '@/store/test-store'
import { TOEIC_PARTS } from '@/temp/local-data'
import { fetchQuestions } from '@/hooks/use-questions'
import { fetchQuestionsForTest } from '@/hooks/use-questions'
import { useRequireAuth } from '@/hooks/use-require-auth'
export function ToeicPractice() {
@@ -16,8 +16,9 @@ export function ToeicPractice() {
if (!requireAuth()) return
setLoadingPartId(partId)
try {
const questions = await fetchQuestions(partId, 10)
startExam(partId, partName, questions)
// TODO: replace hardcoded testId=1 with real test selection
const parts = await fetchQuestionsForTest(1, [partId])
startExam({ testId: 1, testName: partName, parts, totalSeconds: 0 })
navigate({ to: '/toeic/session' })
} catch (err) {
console.error('Failed to load questions:', err)

View File

@@ -10,66 +10,96 @@ export function ToeicTestList() {
})
return (
<div className="px-6 py-8 max-w-6xl mx-auto page-enter">
<div className="mb-8">
<h1 className="text-3xl font-extrabold text-slate-800 mb-2">Đ Thi TOEIC</h1>
<p className="text-slate-500">Chọn đ thi đ bắt đu luyện tập hoặc thi thử toàn bộ.</p>
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
{/* Editorial head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div>
<div className="at-eyebrow mb-3">Luyện đ</div>
<h1 className="at-title text-4xl lg:text-[44px]">
TOEIC <i>Mock Tests</i>
</h1>
<p className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
Chọn đ đ bắt đu luyện tập {tests.length} đ thi
</p>
</div>
</div>
{isLoading && (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-5">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-white rounded-2xl border border-slate-200 p-5 animate-pulse h-44" />
<div
key={i}
className="rounded-2xl h-44 animate-pulse"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
/>
))}
</div>
)}
{error && (
<div className="bg-red-50 border border-red-100 rounded-2xl p-6 text-red-600 text-sm">
<div
className="rounded-2xl p-6 text-sm"
style={{ background: 'var(--at-bad-soft)', border: '1px solid rgba(193,68,62,0.2)', color: 'var(--at-bad)' }}
>
Không thể tải danh sách đ thi. Vui lòng thử lại.
</div>
)}
{!isLoading && !error && tests.length === 0 && (
<div className="text-center py-20 text-slate-400">
<span className="material-symbols-outlined" style={{ fontSize: 48 }}>library_books</span>
<p className="mt-3 font-medium">Chưa đ thi nào. Dữ liệu đang đưc cập nhật.</p>
<div
className="rounded-2xl p-16 text-center"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<span className="material-symbols-outlined mb-3 block" style={{ fontSize: 48, color: 'var(--at-mute-2)' }}>
library_books
</span>
<p className="at-serif text-lg" style={{ color: 'var(--at-ink)' }}>Chưa đ thi nào.</p>
<p className="text-sm mt-1" style={{ color: 'var(--at-mute)' }}>Dữ liệu đang đưc cập nhật.</p>
</div>
)}
{tests.length > 0 && (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-5">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{tests.map((test) => (
<div
key={test.id}
className="bg-white rounded-2xl border border-slate-200 p-5 flex flex-col shadow-sm hover:-translate-y-1 hover:shadow-md transition-all duration-200"
className="rounded-2xl p-6 flex flex-col transition-all hover:-translate-y-1"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
{/* Category badge */}
{test.categoryName && (
<span className="self-start text-xs font-bold px-2.5 py-1 rounded-full bg-blue-50 text-blue-600 border border-blue-100 mb-3">
<span className="at-chip at-chip-brand self-start mb-3">
<span className="at-chip-dot" />
{test.categoryName}
</span>
)}
<h3 className="font-extrabold text-lg text-slate-800 mb-1 leading-snug">{test.title}</h3>
<h3
className="at-serif text-[20px] leading-[1.2] tracking-tight mb-2"
style={{ color: 'var(--at-ink)', fontWeight: 500 }}
>
{test.title}
</h3>
{test.description && (
<p className="text-xs text-slate-400 mb-3 line-clamp-2">{test.description}</p>
<p className="text-xs leading-[1.5] mb-3 line-clamp-2" style={{ color: 'var(--at-mute)' }}>
{test.description}
</p>
)}
<div className="flex items-center gap-3 text-xs text-slate-500 mt-auto mb-4">
<div className="flex items-center gap-4 text-xs mt-auto mb-4" style={{ color: 'var(--at-mute)' }}>
<span className="flex items-center gap-1">
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>list_alt</span>
{test.totalQuestions} câu
<span className="material-symbols-outlined" style={{ fontSize: 13 }}>list_alt</span>
<b className="tabular-nums" style={{ color: 'var(--at-ink)' }}>{test.totalQuestions}</b> câu
</span>
<span className="flex items-center gap-1">
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>timer</span>
{test.durationMinutes} phút
<span className="material-symbols-outlined" style={{ fontSize: 13 }}>timer</span>
<b className="tabular-nums" style={{ color: 'var(--at-ink)' }}>{test.durationMinutes}</b> phút
</span>
</div>
<button
onClick={() => navigate({ to: '/toeic/$testId', params: { testId: String(test.id) } })}
className="w-full py-2.5 bg-blue-600 text-white rounded-xl text-sm font-semibold hover:bg-blue-700 transition-colors"
className="w-full py-2.5 rounded-xl text-[13px] font-semibold transition-opacity hover:opacity-90"
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
>
Bắt đu
</button>

View File

@@ -95,60 +95,104 @@ export function WritingChecker() {
)
}
const sentenceCount = text.split(/[.!?]+/).filter(s => s.trim()).length
const wordCount = text.split(/\s+/).filter(Boolean).length
return (
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
<div className="mb-6">
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">AI Chấm Writing</h1>
<p className="text-slate-500 text-sm">Nhận phản hồi tức thì về ngữ pháp, từ vựng cấu trúc bài viết.</p>
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
{/* Editorial page head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div className="min-w-0">
<div className="at-eyebrow mb-3 inline-flex items-center gap-1.5">
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>auto_awesome</span>
AI Writing Checker
</div>
<h1 className="at-title text-4xl lg:text-[44px]">
Kiểm tra <i>bài viết</i>
</h1>
<p className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
Dán bài viết AI sẽ kiểm tra ngữ pháp, chính tả, chấm điểm IELTS/TOEIC
</p>
</div>
<div className="flex gap-2.5 flex-shrink-0">
<button
className="inline-flex items-center gap-2 px-4 py-3 rounded-xl text-[13.5px] font-semibold transition-colors hover:opacity-80"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>mic</span>
Nhập bằng giọng nói
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
'inline-flex items-center gap-2 px-5 py-3 rounded-xl text-[13.5px] font-semibold transition-opacity',
canSubmit ? 'hover:opacity-90' : 'cursor-not-allowed opacity-50',
)}
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)', border: '1px solid var(--at-ink)' }}
>
{isPending ? (
<>
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
Đang chấm...
</>
) : (
<>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>auto_awesome</span>
Kiểm tra ngay
</>
)}
</button>
</div>
</div>
<div className="flex flex-col lg:flex-row gap-5">
<div className="grid lg:grid-cols-[1.5fr_1fr] gap-5">
{/* Left: Input */}
<div className="flex-1 min-w-0">
<div className="bg-white rounded-2xl border border-slate-200 p-5">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold text-slate-700">Bài writing của bạn</span>
<span className={cn('text-xs tabular-nums', charCount > MAX_CHARS ? 'text-red-500 font-bold' : 'text-slate-400')}>
{charCount}/{MAX_CHARS}
</span>
<div className="min-w-0">
<div
className="rounded-2xl overflow-hidden"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div
className="px-5 py-3.5 flex items-center justify-between"
style={{ background: 'var(--at-paper-2)', borderBottom: '1px solid var(--at-line)' }}
>
<div className="flex gap-1.5">
<span className="at-chip">
<span className="at-chip-dot" />
Đề: Working from home
</span>
<span className="at-chip at-chip-brand">
<span className="at-chip-dot" />
Essay · Band 6-7
</span>
</div>
<div className="text-xs tabular-nums" style={{ color: charCount > MAX_CHARS ? 'var(--at-bad)' : 'var(--at-mute)' }}>
{wordCount} từ · {sentenceCount} câu · {charCount}/{MAX_CHARS}
</div>
</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value.slice(0, MAX_CHARS))}
rows={12}
dir="ltr"
placeholder="Nhập bài writing của bạn vào đây... (TOEIC email, IELTS task, hoặc đoạn văn tự do)"
className="w-full resize-none rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-left text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-blue-400 focus:bg-white transition-colors"
/>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 14 }}>info</span>
<span className={cn('text-xs font-medium', remaining <= 1 ? 'text-red-500' : 'text-slate-400')}>
<div className="p-5">
<textarea
value={text}
onChange={(e) => setText(e.target.value.slice(0, MAX_CHARS))}
rows={12}
dir="ltr"
placeholder="Bắt đầu viết hoặc dán bài của bạn ở đây..."
className="w-full resize-none bg-transparent border-none outline-none"
style={{
fontFamily: 'var(--at-sans)',
fontSize: 15,
lineHeight: 1.7,
color: 'var(--at-ink)',
minHeight: 280,
}}
/>
<div className="mt-3 flex items-center gap-1.5">
<span className="material-symbols-outlined" style={{ fontSize: 14, color: 'var(--at-mute)' }}>info</span>
<span className="text-xs font-medium" style={{ color: remaining <= 1 ? 'var(--at-bad)' : 'var(--at-mute)' }}>
Còn {remaining}/{dailyLimit} lượt hôm nay
</span>
</div>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
'flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-bold transition-all',
canSubmit
? 'bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/20'
: 'bg-slate-100 text-slate-400 cursor-not-allowed',
)}
>
{isPending ? (
<>
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
Đang chấm...
</>
) : (
<>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>auto_fix_high</span>
Chấm bài ngay
</>
)}
</button>
</div>
</div>
@@ -172,11 +216,16 @@ export function WritingChecker() {
</div>
{/* Right: Feedback */}
<div className="lg:w-80 flex-shrink-0">
<div className="flex flex-col gap-5">
{!feedback && !isPending && (
<div className="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col items-center justify-center text-center h-full min-h-48">
<span className="material-symbols-outlined text-slate-300 mb-3" style={{ fontSize: 48 }}>auto_fix_high</span>
<p className="text-sm text-slate-400">Nhập bài nhấn "Chấm bài ngay" đ nhận phản hồi từ AI</p>
<div className="at-tip">
<div className="at-tip-label">AI kiểm tra ?</div>
<div className="text-[12.5px] leading-[1.55]" style={{ color: 'var(--at-ink-2)' }}>
Ngữ pháp · Chính tả · Từ vựng học thuật · Tính mạch lạc · Chấm điểm theo band IELTS/TOEIC.
Một bài TOEIC Writing band 7+ cần{' '}
<b style={{ color: 'var(--at-warm)', fontWeight: 700 }}>ít nhất 250 từ</b> sử dụng{' '}
<b style={{ color: 'var(--at-warm)', fontWeight: 700 }}>3-4 linking words</b>.
</div>
</div>
)}

View File

@@ -48,6 +48,36 @@
}
:root {
/* Atelier palette (global — applied across entire app) */
--at-brand: #3D4BD7;
--at-brand-ink: #1A2280;
--at-brand-soft: #E9ECFE;
--at-brand-softer: #F4F5FE;
--at-ink: #0F1114;
--at-ink-2: #2A2D33;
--at-ink-3: #3E4149;
--at-mute: #6B6F76;
--at-mute-2: #9CA0A8;
--at-line: #E8E5DE;
--at-line-2: #EFECE4;
--at-paper: #FAF8F3;
--at-paper-2: #F4F1EA;
--at-surface: #FFFFFF;
--at-good: #2F7D4A;
--at-good-soft: #E4F0E7;
--at-good-ink: #1B4B2C;
--at-warm: #D26A3B;
--at-warm-soft: #F8E9DE;
--at-warm-ink: #6B2A14;
--at-bad: #C1443E;
--at-bad-soft: #F4DEDC;
--at-streak: #C15A34;
--at-streak-soft: #F7E6DC;
--at-serif: "Fraunces", "Instrument Serif", Georgia, serif;
--at-sans: "Geist", "Geist Variable", "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--at-mono: "Geist Mono", ui-monospace, SF Mono, Menlo, monospace;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
@@ -121,13 +151,106 @@
@apply border-border outline-ring/50;
}
body {
@apply bg-slate-50 text-slate-800;
background: var(--at-paper);
color: var(--at-ink);
font-family: var(--at-sans);
font-feature-settings: "ss01", "cv11";
-webkit-font-smoothing: antialiased;
letter-spacing: -0.005em;
}
html {
@apply font-sans;
}
}
/* Atelier global helpers — usable outside .atelier scope */
.at-serif { font-family: var(--at-serif); }
.at-mono { font-family: var(--at-mono); }
.at-eyebrow {
font-family: var(--at-serif);
font-style: italic;
font-weight: 500;
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--at-mute);
}
.at-title {
font-family: var(--at-serif);
font-weight: 400;
letter-spacing: -0.025em;
line-height: 1.05;
color: var(--at-ink);
}
.at-title i { font-style: italic; color: var(--at-brand); font-weight: 400; }
.at-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 999px;
background: var(--at-line-2);
color: var(--at-ink-3);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
}
.at-chip-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.at-chip-brand { background: var(--at-brand-soft); color: var(--at-brand-ink); }
.at-chip-good { background: var(--at-good-soft); color: var(--at-good-ink); }
.at-chip-warm { background: var(--at-warm-soft); color: var(--at-warm); }
.at-bar {
height: 6px;
background: var(--at-line-2);
border-radius: 999px;
overflow: hidden;
position: relative;
}
.at-bar > span {
position: absolute;
inset: 0 auto 0 0;
background: var(--at-brand);
border-radius: 999px;
transition: width 0.5s cubic-bezier(0.2, 0.7, 0.2, 1);
}
.at-pullquote {
padding: 18px;
border-radius: 16px;
background: var(--at-brand-soft);
position: relative;
overflow: hidden;
}
.at-pullquote-q {
font-family: var(--at-serif);
font-size: 15px;
font-style: italic;
line-height: 1.45;
color: var(--at-brand-ink);
letter-spacing: -0.01em;
}
.at-tip {
padding: 16px;
background: var(--at-warm-soft);
border-radius: 14px;
border: 1px solid rgba(210, 106, 59, 0.18);
}
.at-tip-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--at-warm);
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.at-tip-label::before {
content: "";
width: 5px; height: 5px; border-radius: 50%;
background: var(--at-warm);
}
/* ── Flashcard 3D flip ── */
.flashcard-scene {
perspective: 1000px;
@@ -174,3 +297,208 @@
.timer-urgent {
animation: timer-pulse 1s ease-in-out infinite;
}
/* ────────────────────────────────────────────────────────────
The Atelier — flashcard learn page scope
Tokens + typography + 3D flip card
Fonts: Fraunces + Geist + Geist Mono (loaded by route)
──────────────────────────────────────────────────────────── */
.atelier {
--at-accent: #3D4BD7;
--at-accent-soft: #E9ECFE;
--at-accent-ink: #1A2280;
--at-ink: #0F1114;
--at-ink-2: #2A2D33;
--at-mute: #6B6F76;
--at-mute-2: #9CA0A8;
--at-line: #E8E5DE;
--at-line-2: #EFECE4;
--at-paper: #FAF8F3;
--at-paper-2: #F4F1EA;
--at-good: #2F7D4A;
--at-good-soft: #E4F0E7;
--at-warm: #D26A3B;
--at-warm-soft: #F8E9DE;
--at-serif: "Fraunces", "Instrument Serif", Georgia, serif;
--at-sans: "Geist", "Geist Variable", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--at-mono: "Geist Mono", ui-monospace, SF Mono, Menlo, monospace;
background: var(--at-paper);
color: var(--at-ink);
font-family: var(--at-sans);
font-feature-settings: "ss01", "cv11";
-webkit-font-smoothing: antialiased;
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
letter-spacing: -0.005em;
}
.atelier .at-serif { font-family: var(--at-serif); }
.atelier .at-mono { font-family: var(--at-mono); }
/* Card */
.atelier .at-card-outer {
perspective: 2000px;
width: 100%;
max-width: 420px;
margin: 0 auto;
/* Size by available viewport height — never overflow */
height: min(560px, calc(100vh - 14rem));
max-height: 560px;
}
.atelier .at-card {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.75s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
animation: at-cardIn 0.5s cubic-bezier(0.2, 0.7, 0.2, 1);
}
@keyframes at-cardIn {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.atelier .at-card.is-flipped { transform: rotateY(180deg); }
.atelier .at-card-face {
position: absolute;
inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
background: #fff;
border-radius: 28px;
padding: 28px 32px;
display: flex;
flex-direction: column;
border: 1px solid var(--at-line);
box-shadow:
0 1px 2px rgba(15,17,20,0.04),
0 20px 40px -16px rgba(15,17,20,0.12),
0 4px 12px -4px rgba(15,17,20,0.06);
}
.atelier .at-card-back { transform: rotateY(180deg); }
.atelier .at-word {
font-family: var(--at-serif);
font-size: clamp(44px, 6vw, 72px);
font-weight: 400;
line-height: 1;
letter-spacing: -0.035em;
color: var(--at-ink);
font-variation-settings: "opsz" 144, "SOFT" 30, "WONK" 1;
}
.atelier .at-meaning {
font-family: var(--at-serif);
font-size: 26px;
font-weight: 400;
line-height: 1.15;
letter-spacing: -0.02em;
color: var(--at-ink);
}
.atelier .at-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--at-accent-soft);
color: var(--at-accent-ink);
border-radius: 999px;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.14em;
}
.atelier .at-chip-dot {
width: 5px; height: 5px; border-radius: 50%;
background: var(--at-accent);
}
.atelier .at-chip-mute { background: var(--at-line-2); color: var(--at-mute); }
.atelier .at-chip-mute .at-chip-dot { background: var(--at-mute); }
.atelier .at-kbd {
font-family: var(--at-mono);
font-size: 10.5px;
padding: 2px 7px;
border: 1px solid var(--at-line);
border-bottom-width: 2px;
border-radius: 5px;
color: var(--at-ink-2);
background: var(--at-paper-2);
}
.atelier .at-example {
padding: 14px 16px;
background: var(--at-paper-2);
border-radius: 12px;
border-left: 2px solid var(--at-accent);
}
.atelier .at-action {
flex: 1;
padding: 13px 18px;
border-radius: 12px;
font-weight: 600;
font-size: 13.5px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid var(--at-line);
background: #fff;
color: var(--at-ink-2);
transition: all 0.15s ease;
}
.atelier .at-action:hover:not(:disabled) { border-color: var(--at-ink); color: var(--at-ink); transform: translateY(-1px); }
.atelier .at-action:disabled { opacity: 0.4; cursor: not-allowed; }
.atelier .at-action-known {
background: var(--at-good);
color: white;
border-color: var(--at-good);
}
.atelier .at-action-known:hover:not(:disabled) { background: #236238; border-color: #236238; color: white; }
.atelier .at-action-review {
background: var(--at-warm-soft);
color: var(--at-warm);
border-color: rgba(210, 106, 59, 0.3);
}
.atelier .at-action-review:hover:not(:disabled) { background: var(--at-warm); color: white; border-color: var(--at-warm); }
.atelier .at-progress-bar {
height: 6px;
background: var(--at-line-2);
border-radius: 999px;
overflow: hidden;
position: relative;
}
.atelier .at-progress-bar > span {
position: absolute;
inset: 0 auto 0 0;
background: linear-gradient(90deg, var(--at-accent), color-mix(in oklab, var(--at-accent) 80%, white));
border-radius: 999px;
transition: width 0.5s cubic-bezier(0.2, 0.7, 0.2, 1);
}
.atelier .at-pct {
font-family: var(--at-serif);
font-size: 22px;
color: var(--at-accent);
font-weight: 400;
font-style: italic;
letter-spacing: -0.02em;
}
/* Swipe-off FX */
@keyframes at-knownFx {
0% { transform: translateY(0); }
40% { transform: translateY(-8px) rotate(2deg); }
100% { transform: translateX(120%) rotate(8deg); opacity: 0; }
}
@keyframes at-reviewFx {
0% { transform: translateY(0); }
40% { transform: translateY(-8px) rotate(-2deg); }
100% { transform: translateX(-120%) rotate(-8deg); opacity: 0; }
}
.atelier .at-card.fx-known { animation: at-knownFx 0.55s cubic-bezier(0.4,0,0.2,1) forwards; }
.atelier .at-card.fx-review { animation: at-reviewFx 0.55s cubic-bezier(0.4,0,0.2,1) forwards; }

View File

@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { Dashboard } from '@/features/dashboard/components/Dashboard'
export const Route = createFileRoute('/dashboard')({
export const Route = createFileRoute('/archivement')({
component: Dashboard,
})

View File

@@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router"
import { FlashCardTermsPage } from "@/features/flash-card/components/FlashCardTermsPage"
export const Route = createFileRoute("/flash-card/$listId/")({
component: TermsPage,
})
function TermsPage() {
const { listId } = Route.useParams()
return <FlashCardTermsPage listId={Number(listId)} />
}

View File

@@ -1,11 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"
import { FlashCardTermsPage } from "@/features/flash-card/components/FlashCardTermsPage"
import { createFileRoute, Outlet } from "@tanstack/react-router"
export const Route = createFileRoute("/flash-card/$listId")({
component: TermsPage,
component: () => <Outlet />,
})
function TermsPage() {
const { listId } = Route.useParams()
return <FlashCardTermsPage listId={Number(listId)} />
}

View File

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View File

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

View File

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB