update
@@ -10,6 +10,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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=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=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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -2,30 +2,79 @@ import { useRouterState } from '@tanstack/react-router'
|
|||||||
import { useTestStore } from '@/store/test-store'
|
import { useTestStore } from '@/store/test-store'
|
||||||
import { UserMenu } from '@/components/UserMenu'
|
import { UserMenu } from '@/components/UserMenu'
|
||||||
|
|
||||||
const ROUTE_TITLES: Record<string, string> = {
|
const ROUTE_TITLES: Record<string, { eyebrow: string; title: string; accent?: string }> = {
|
||||||
'/': 'Trang chủ',
|
'/': { eyebrow: 'Học TOEIC cùng AI', title: 'Trang chủ' },
|
||||||
'/writing': 'AI Chấm Writing',
|
'/archivement': { eyebrow: 'Thành tích của bạn', title: 'Tôi học', accent: 'học' },
|
||||||
'/flash-card': 'Flash Card',
|
'/toeic': { eyebrow: 'Luyện đề', title: 'TOEIC Mock Tests', accent: 'Mock' },
|
||||||
'/toeic': 'Luyện đề TOEIC',
|
'/writing': { eyebrow: 'AI Coach', title: 'Chấm Writing', accent: 'Writing' },
|
||||||
'/toeic/session': '', // dynamic — filled below
|
'/flash-card': { eyebrow: 'Từ vựng TOEIC', title: 'Flash Card', accent: 'Card' },
|
||||||
'/toeic/result': 'Kết quả bài thi',
|
'/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() {
|
export function AppHeader() {
|
||||||
const { location } = useRouterState()
|
const { location } = useRouterState()
|
||||||
const { partId, partName, answers, questions } = useTestStore()
|
const { testName, parts, answers } = useTestStore()
|
||||||
const pathname = location.pathname
|
const pathname = location.pathname
|
||||||
|
|
||||||
let title = ROUTE_TITLES[pathname] ?? 'EnglishAI'
|
// In-session mode: show test progress instead of route title
|
||||||
|
|
||||||
if (pathname === '/toeic/session') {
|
if (pathname === '/toeic/session') {
|
||||||
const answered = answers.filter((a) => a !== null).length
|
const totalQuestions = parts.reduce((sum, p) => sum + p.questions.length, 0)
|
||||||
title = `Part ${partId} — ${partName} · ${answered}/${questions.length} câu`
|
const answered = Object.values(answers).filter((a) => a !== null).length
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
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">
|
<header
|
||||||
<span className="text-sm font-semibold text-slate-700">{title}</span>
|
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 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 />
|
<UserMenu />
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { cn } from '@/lib/utils'
|
|||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: '/', label: 'Home', icon: 'home', matchPrefix: '/', exact: true },
|
{ 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: '/toeic', label: 'Luyện đề', icon: 'assignment', matchPrefix: '/toeic', exact: false },
|
||||||
{ to: '/writing', label: 'Writing', icon: 'edit_note', matchPrefix: '/writing', 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 },
|
{ to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false },
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useAuthModalStore } from '@/store/auth-modal-store'
|
|||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: '/', label: 'Trang chủ', icon: 'home', matchPrefix: '/', exact: true },
|
{ 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: '/toeic', label: 'Luyện đề TOEIC', icon: 'assignment', matchPrefix: '/toeic', exact: false },
|
||||||
{ to: '/writing', label: 'AI Writing', icon: 'edit_note', matchPrefix: '/writing', 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 },
|
{ to: '/flash-card', label: 'Flash Card', icon: 'menu_book', matchPrefix: '/flash-card', exact: false },
|
||||||
@@ -23,15 +23,43 @@ export function Sidebar() {
|
|||||||
const openModal = useAuthModalStore((s) => s.open)
|
const openModal = useAuthModalStore((s) => s.open)
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Brand */}
|
||||||
<div className="px-6 py-5 border-b border-slate-200">
|
<div className="px-5 pt-7 pb-9 flex items-start gap-2.5">
|
||||||
<div className="text-xl font-extrabold text-blue-600 tracking-tight">EnglishAI</div>
|
<div
|
||||||
<div className="text-xs text-slate-400 mt-0.5">Học tập thông minh</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>
|
</div>
|
||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<nav className="flex-1 py-3 overflow-y-auto">
|
<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) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const active = isActive(pathname, item.matchPrefix, item.exact)
|
const active = isActive(pathname, item.matchPrefix, item.exact)
|
||||||
return (
|
return (
|
||||||
@@ -39,44 +67,67 @@ export function Sidebar() {
|
|||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 mx-2 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150',
|
'relative flex items-center gap-3 px-3 py-2.5 rounded-[10px] text-[13.5px] font-medium transition-colors',
|
||||||
active
|
|
||||||
? 'bg-white text-blue-600 font-semibold shadow-sm'
|
|
||||||
: 'text-slate-500 hover:bg-white/70 hover:text-slate-800',
|
|
||||||
)}
|
)}
|
||||||
|
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)' }}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>
|
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</span>
|
</span>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User */}
|
{/* User */}
|
||||||
<div className="px-3 py-4 border-t border-slate-200">
|
<div className="px-3 py-4">
|
||||||
{user ? (
|
{user ? (
|
||||||
<div className="flex items-center gap-3 bg-white rounded-xl px-3 py-2.5">
|
<div
|
||||||
<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">
|
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()}
|
{user.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-semibold truncate">{user.name}</div>
|
<div className="text-[13px] font-semibold truncate" style={{ color: 'var(--at-ink)' }}>{user.name}</div>
|
||||||
<div className="text-xs text-slate-400 truncate">{user.email}</div>
|
<div className="text-[11px] truncate" style={{ color: 'var(--at-mute)' }}>{user.email}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal('login')}
|
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">
|
<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 text-slate-400 group-hover:text-blue-600 transition-colors" style={{ fontSize: 18 }}>person</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 18, color: 'var(--at-mute)' }}>person</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 text-left">
|
<div className="min-w-0 text-left">
|
||||||
<div className="text-sm font-semibold text-slate-600">Khách</div>
|
<div className="text-[13px] font-semibold" style={{ color: 'var(--at-ink-2)' }}>Khách</div>
|
||||||
<div className="text-xs text-blue-600 font-medium">Đăng nhập →</div>
|
<div className="text-[11px] font-medium at-serif italic" style={{ color: 'var(--at-brand)' }}>Đăng nhập →</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,22 +3,59 @@ import { useAuthStore } from '@/store/auth-store'
|
|||||||
import { useAuthModalStore } from '@/store/auth-modal-store'
|
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||||
import { useGamification, useLeaderboard } from '@/hooks/use-gamification'
|
import { useGamification, useLeaderboard } from '@/hooks/use-gamification'
|
||||||
import { XP_REWARDS } from '@/lib/gamification-service'
|
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)
|
const LEVEL_LABEL: Record<string, string> = {
|
||||||
export function calcNumericLevel(xp: number) {
|
beginner: 'Beginner',
|
||||||
return Math.max(1, Math.floor(xp / 100))
|
bronze: 'Bronze',
|
||||||
|
silver: 'Silver',
|
||||||
|
gold: 'Gold',
|
||||||
|
master: 'Master',
|
||||||
}
|
}
|
||||||
|
|
||||||
// XP needed for next numeric level
|
function calcNumericLevel(xp: number) {
|
||||||
export function calcXpNextLevel(xp: number) {
|
return Math.max(1, Math.floor(xp / 100))
|
||||||
|
}
|
||||||
|
function xpForNext(xp: number) {
|
||||||
return (Math.floor(xp / 100) + 1) * 100
|
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() {
|
export function Dashboard() {
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
const openModal = useAuthModalStore((s) => s.open)
|
const openModal = useAuthModalStore((s) => s.open)
|
||||||
@@ -27,15 +64,15 @@ export function Dashboard() {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="px-4 lg:px-6 py-12 max-w-6xl mx-auto flex flex-col items-center text-center gap-4">
|
<div className="px-4 lg:px-6 py-20 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>
|
<div className="at-serif italic text-5xl" style={{ color: 'var(--at-mute-2)' }}>Thành tích</div>
|
||||||
<h1 className="text-xl font-bold text-slate-700">Bảng thành tích</h1>
|
<p className="max-w-sm" style={{ color: 'var(--at-mute)' }}>
|
||||||
<p className="text-slate-400 text-sm max-w-xs">
|
|
||||||
Đăng nhập để xem streak, XP, Xu và bảng xếp hạng của bạn.
|
Đăng nhập để xem streak, XP, Xu và bảng xếp hạng của bạn.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal('login')}
|
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
|
Đăng nhập
|
||||||
</button>
|
</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 xu = gam?.xu ?? 50
|
||||||
const streak = gam?.streak ?? 0
|
const streak = gam?.streak ?? 0
|
||||||
const xp = gam?.xp ?? 0
|
const xp = gam?.xp ?? 0
|
||||||
const level = gam?.level ?? 'beginner'
|
const levelLabel = LEVEL_LABEL[gam?.level ?? 'beginner']
|
||||||
const lastActive = gam?.lastActive ?? null
|
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 (
|
return (
|
||||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto">
|
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
|
||||||
<div className="mb-6">
|
{/* Editorial head */}
|
||||||
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">Bảng thành tích</h1>
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
||||||
<p className="text-slate-400 text-sm">
|
<div>
|
||||||
Xin chào, <span className="font-semibold text-slate-600">{user.name}</span> — tiếp tục chuỗi học tập nhé!
|
<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>
|
</p>
|
||||||
</div>
|
</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 ? (
|
{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) => (
|
{[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>
|
</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ố dư 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">
|
{/* Row 2 — level ring + week goal + history */}
|
||||||
<XpProgressCard xp={xp} />
|
<div className="grid grid-cols-1 gap-5 mb-5" style={{ gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1.4fr)' }}>
|
||||||
<WeeklySection streak={streak} lastActive={lastActive} weeklyCompleted={weeklyCompleted} />
|
{/* 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>
|
</div>
|
||||||
|
<span className="at-serif italic text-[11px]" style={{ color: 'var(--at-mute)' }}>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5">
|
Lv.{numericLevel} → Lv.{numericLevel + 1}
|
||||||
<XuEconomyCard />
|
</span>
|
||||||
<LeaderboardCard />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-center py-5">
|
||||||
<Link
|
<LevelRing value={levelPct} xpInto={xpIntoLevel} xpGoal={100} />
|
||||||
to="/toeic"
|
</div>
|
||||||
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"
|
<div className="text-center text-[12.5px] mb-3" style={{ color: 'var(--at-mute)' }}>
|
||||||
title="Học ngay"
|
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 text-2xl">play_arrow</span>
|
<span className="material-symbols-outlined inline-block align-middle mr-1" style={{ fontSize: 15 }}>target</span>
|
||||||
</Link>
|
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>
|
||||||
|
|
||||||
|
{/* 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 có 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>
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 có 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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ố dư 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { supabase } from '@/lib/supabase'
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { EASE, computeNextReview, statusFor, type EaseKey } from '../lib/srs-intervals'
|
||||||
|
|
||||||
export interface FlashcardList {
|
export interface FlashcardList {
|
||||||
id: number
|
id: number
|
||||||
@@ -24,6 +25,8 @@ export interface FlashcardTerm {
|
|||||||
definition: string | null
|
definition: string | null
|
||||||
example: string | null
|
example: string | null
|
||||||
image_url: string | null
|
image_url: string | null
|
||||||
|
audio_tts_text: string | null
|
||||||
|
audio_lang: string | null
|
||||||
display_order: number
|
display_order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +56,7 @@ export async function fetchFlashcardLists(): Promise<FlashcardList[]> {
|
|||||||
export async function fetchFlashcardTerms(listId: number): Promise<FlashcardTerm[]> {
|
export async function fetchFlashcardTerms(listId: number): Promise<FlashcardTerm[]> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('flashcard_term')
|
.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)
|
.eq('list_id', listId)
|
||||||
.order('display_order', { ascending: true })
|
.order('display_order', { ascending: true })
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
@@ -71,18 +74,16 @@ export async function fetchUserProgress(userId: string, listId: number): Promise
|
|||||||
return data ?? []
|
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(
|
export async function upsertTermProgress(
|
||||||
userId: string,
|
userId: string,
|
||||||
termId: number,
|
termId: number,
|
||||||
listId: number,
|
listId: number,
|
||||||
status: UserProgress['status'],
|
easeKey: EaseKey,
|
||||||
easeFactor: number,
|
currentReviewCount: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
// Compute next review date based on ease_factor
|
const nextReview = computeNextReview(easeKey, currentReviewCount)
|
||||||
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 { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('user_flashcard_progress')
|
.from('user_flashcard_progress')
|
||||||
@@ -91,12 +92,63 @@ export async function upsertTermProgress(
|
|||||||
user_id: userId,
|
user_id: userId,
|
||||||
term_id: termId,
|
term_id: termId,
|
||||||
list_id: listId,
|
list_id: listId,
|
||||||
status,
|
status: statusFor(easeKey),
|
||||||
ease_factor: easeFactor,
|
ease_factor: EASE[easeKey],
|
||||||
|
review_count: currentReviewCount + 1,
|
||||||
last_reviewed_at: now,
|
last_reviewed_at: now,
|
||||||
next_review_at: nextReview,
|
next_review_at: nextReview,
|
||||||
},
|
},
|
||||||
{ onConflict: 'user_id,term_id,list_id' },
|
{ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useAuthStore } from '@/store/auth-store'
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
import { 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'
|
import type { FlashcardTerm, UserProgress } from '../api/flashcard-api'
|
||||||
|
import { EASE, type EaseKey } from '../lib/srs-intervals'
|
||||||
const EASE = {
|
|
||||||
ignored: -1,
|
|
||||||
hard: 0.1,
|
|
||||||
easy: 0.65,
|
|
||||||
known: 1.0,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
type EaseKey = keyof typeof EASE
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
listId: number
|
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) {
|
export function FlashCardLearnPage({ listId }: Props) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
@@ -26,51 +38,165 @@ export function FlashCardLearnPage({ listId }: Props) {
|
|||||||
|
|
||||||
const [isFlipped, setIsFlipped] = useState(false)
|
const [isFlipped, setIsFlipped] = useState(false)
|
||||||
const [currentIdx, setCurrentIdx] = useState(0)
|
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 [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({
|
const { data: terms = [], isLoading: loadingTerms } = useQuery({
|
||||||
queryKey: ['flashcard-terms', listId],
|
queryKey: ['flashcard-terms', listId],
|
||||||
queryFn: () => fetchFlashcardTerms(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({
|
const { data: progress = [] } = useQuery({
|
||||||
queryKey: ['flashcard-progress', user?.id, listId],
|
queryKey: ['flashcard-progress', user?.id, listId],
|
||||||
queryFn: () => fetchUserProgress(user!.id, listId),
|
queryFn: () => fetchUserProgress(user!.id, listId),
|
||||||
enabled: !!user,
|
enabled: !!user,
|
||||||
})
|
})
|
||||||
|
|
||||||
const progressMap: Record<number, UserProgress> = {}
|
const progressMap = useMemo(() => {
|
||||||
progress.forEach(p => { progressMap[p.term_id] = p })
|
const m: Record<number, UserProgress> = {}
|
||||||
|
progress.forEach(p => { m[p.term_id] = p })
|
||||||
|
return m
|
||||||
|
}, [progress])
|
||||||
|
|
||||||
// Prioritize: new + learning terms first, then known
|
// Session term ordering: prioritise due-for-review, then new, then known
|
||||||
const sessionTerms: FlashcardTerm[] = [
|
const sessionTerms: FlashcardTerm[] = useMemo(() => {
|
||||||
...terms.filter(t => {
|
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])
|
||||||
|
|
||||||
|
// 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'
|
const s = progressMap[t.id]?.status ?? 'new'
|
||||||
return s === 'new' || s === 'learning'
|
if (s === 'new') newIds.add(t.id)
|
||||||
}),
|
}
|
||||||
...terms.filter(t => progressMap[t.id]?.status === 'known'),
|
newTermIdsAtStartRef.current = newIds
|
||||||
]
|
}
|
||||||
|
}, [terms, progressMap])
|
||||||
|
|
||||||
const { mutate: saveProgress } = useMutation({
|
// Start session on mount (guarded against StrictMode double-invoke)
|
||||||
mutationFn: ({ termId, status, easeFactor }: { termId: number; status: UserProgress['status']; easeFactor: number }) =>
|
useEffect(() => {
|
||||||
upsertTermProgress(user!.id, termId, listId, status, easeFactor),
|
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: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['flashcard-progress', user?.id, listId] })
|
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 handleAnswer = useCallback((key: EaseKey) => {
|
||||||
const term = sessionTerms[currentIdx]
|
const term = sessionTerms[currentIdx]
|
||||||
if (!term || !user) return
|
if (!term || !user) return
|
||||||
|
|
||||||
const easeFactor = EASE[key]
|
const currentProgress = progressMap[term.id]
|
||||||
const status: UserProgress['status'] =
|
const reviewCount = currentProgress?.review_count ?? 0
|
||||||
key === 'known' ? 'known' :
|
|
||||||
key === 'ignored' ? 'ignored' :
|
|
||||||
'learning'
|
|
||||||
|
|
||||||
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 => ({
|
setSessionStats(prev => ({
|
||||||
known: prev.known + (key === 'known' ? 1 : 0),
|
known: prev.known + (key === 'known' ? 1 : 0),
|
||||||
@@ -78,13 +204,32 @@ export function FlashCardLearnPage({ listId }: Props) {
|
|||||||
ignored: prev.ignored + (key === 'ignored' ? 1 : 0),
|
ignored: prev.ignored + (key === 'ignored' ? 1 : 0),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (currentIdx + 1 >= sessionTerms.length) {
|
// Visual feedback: known swipes right, hard/ignored swipes left
|
||||||
setIsDone(true)
|
if (key === 'known' || key === 'easy') {
|
||||||
|
setFx('known')
|
||||||
} else {
|
} else {
|
||||||
setCurrentIdx(i => i + 1)
|
setFx('review')
|
||||||
setIsFlipped(false)
|
|
||||||
}
|
}
|
||||||
}, [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 total = sessionTerms.length
|
||||||
const progressPct = total > 0 ? Math.round((currentIdx / total) * 100) : 0
|
const progressPct = total > 0 ? Math.round((currentIdx / total) * 100) : 0
|
||||||
@@ -92,23 +237,24 @@ export function FlashCardLearnPage({ listId }: Props) {
|
|||||||
|
|
||||||
if (loadingTerms) {
|
if (loadingTerms) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="atelier flex items-center justify-center min-h-screen">
|
||||||
<div className="w-8 h-8 border-2 border-blue-100 border-t-blue-600 rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-[var(--at-line)] border-t-[var(--at-accent)] rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionTerms.length === 0) {
|
if (sessionTerms.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center gap-4 px-4">
|
<div className="atelier flex flex-col items-center justify-center min-h-screen gap-4 px-4">
|
||||||
<span className="material-symbols-outlined text-slate-300" style={{ fontSize: 56, fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
<div className="at-serif text-5xl italic text-[var(--at-mute-2)]">All clear.</div>
|
||||||
<h2 className="text-xl font-bold text-slate-700">Không có thẻ nào để học!</h2>
|
<p className="text-[var(--at-mute)] text-center max-w-sm">
|
||||||
<p className="text-slate-400 text-sm text-center">Bộ thẻ này chưa có từ nào. Vui lòng thêm từ trước.</p>
|
Không có thẻ nào cần học ngay bây giờ. Quay lại sau khi đến lịch ôn tập.
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -116,24 +262,24 @@ export function FlashCardLearnPage({ listId }: Props) {
|
|||||||
|
|
||||||
if (isDone) {
|
if (isDone) {
|
||||||
return (
|
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">
|
<div className="text-center">
|
||||||
<span className="material-symbols-outlined text-emerald-500 block mb-3" style={{ fontSize: 64, fontVariationSettings: "'FILL' 1" }}>celebration</span>
|
<div className="at-serif italic text-[var(--at-accent)] text-6xl mb-4">Bravo.</div>
|
||||||
<h2 className="text-2xl font-extrabold text-slate-800 mb-1">Hoàn thành phiên học!</h2>
|
<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-slate-500">Bạn đã ôn xong {total} thẻ trong phiên này</p>
|
<p className="text-[var(--at-mute)]">Bạn đã ôn xong {total} thẻ trong phiên này</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-3">
|
||||||
<div className="text-center px-5 py-3 bg-emerald-50 rounded-xl border border-emerald-100">
|
<div className="px-5 py-3 rounded-2xl border border-[var(--at-line)] bg-white text-center min-w-[88px]">
|
||||||
<div className="text-2xl font-extrabold text-emerald-600">{sessionStats.known}</div>
|
<div className="at-serif text-3xl text-[var(--at-good)]">{sessionStats.known}</div>
|
||||||
<div className="text-xs text-slate-400 mt-0.5">Đã biết</div>
|
<div className="text-[10px] uppercase tracking-widest text-[var(--at-mute)] mt-1">Đã biết</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center px-5 py-3 bg-blue-50 rounded-xl border border-blue-100">
|
<div className="px-5 py-3 rounded-2xl border border-[var(--at-line)] bg-white text-center min-w-[88px]">
|
||||||
<div className="text-2xl font-extrabold text-blue-600">{sessionStats.learning}</div>
|
<div className="at-serif text-3xl text-[var(--at-accent)]">{sessionStats.learning}</div>
|
||||||
<div className="text-xs text-slate-400 mt-0.5">Đang học</div>
|
<div className="text-[10px] uppercase tracking-widest text-[var(--at-mute)] mt-1">Đang học</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center px-5 py-3 bg-slate-50 rounded-xl border border-slate-200">
|
<div className="px-5 py-3 rounded-2xl border border-[var(--at-line)] bg-white text-center min-w-[88px]">
|
||||||
<div className="text-2xl font-extrabold text-slate-500">{sessionStats.ignored}</div>
|
<div className="at-serif text-3xl text-[var(--at-mute-2)]">{sessionStats.ignored}</div>
|
||||||
<div className="text-xs text-slate-400 mt-0.5">Bỏ qua</div>
|
<div className="text-[10px] uppercase tracking-widest text-[var(--at-mute)] mt-1">Bỏ qua</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -143,17 +289,19 @@ export function FlashCardLearnPage({ listId }: Props) {
|
|||||||
setIsFlipped(false)
|
setIsFlipped(false)
|
||||||
setIsDone(false)
|
setIsDone(false)
|
||||||
setSessionStats({ known: 0, learning: 0, ignored: 0 })
|
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
|
Học lại
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
|
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
|
Xem danh sách
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-slate-50">
|
<div
|
||||||
{/* Header */}
|
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"
|
||||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-xl border-b border-slate-100">
|
style={{ background: 'var(--at-paper)' }}
|
||||||
<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>
|
{/* Header row: breadcrumb + serif title on left, actions on right */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-end justify-between gap-4 mb-4 flex-shrink-0 min-w-0">
|
||||||
<span className="text-sm text-slate-500 font-medium">{currentIdx + 1} / {total} từ</span>
|
<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
|
<button
|
||||||
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
|
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>
|
||||||
|
<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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
bookmark
|
||||||
|
</span>
|
||||||
|
Đánh dấu
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Progress bar */}
|
|
||||||
<div className="w-full bg-slate-100 h-1 overflow-hidden">
|
{/* Body: card column + sidebar */}
|
||||||
<div
|
<div
|
||||||
className="h-full bg-blue-600 transition-all duration-500"
|
className="flex-1 min-h-0 lg:grid flex flex-col gap-5"
|
||||||
style={{ width: `${progressPct}%` }}
|
style={{ gridTemplateColumns: 'minmax(0, 1fr) 260px' }}
|
||||||
/>
|
>
|
||||||
</div>
|
{/* Main: card + actions + progress */}
|
||||||
</header>
|
<div className="flex flex-col items-center justify-center min-h-0">
|
||||||
|
{/* Card */}
|
||||||
<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 && (
|
{current && (
|
||||||
<div className="relative group w-full max-w-xl">
|
<div className="at-card-outer" style={{ maxWidth: 420, flexShrink: 0 }}>
|
||||||
<div className="absolute -inset-4 bg-blue-600/5 blur-3xl rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-700" />
|
|
||||||
<div
|
<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"
|
className={cn('at-card', isFlipped && 'is-flipped', fx === 'known' && 'fx-known', fx === 'review' && 'fx-review')}
|
||||||
|
key={current.id}
|
||||||
onClick={() => setIsFlipped(v => !v)}
|
onClick={() => setIsFlipped(v => !v)}
|
||||||
role="button"
|
role="button"
|
||||||
aria-label={isFlipped ? 'Nhấp để xem từ' : 'Nhấp để xem nghĩa'}
|
tabIndex={0}
|
||||||
|
aria-label={isFlipped ? 'Lật để xem từ' : 'Lật để xem nghĩa'}
|
||||||
>
|
>
|
||||||
{!isFlipped ? (
|
{/* FRONT */}
|
||||||
<>
|
<div className="at-card-face" style={{ padding: '20px 24px' }}>
|
||||||
<div className="text-xs font-bold tracking-widest text-slate-400 uppercase">TIẾNG ANH</div>
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
<span className="at-chip">
|
||||||
<h1 className="text-5xl font-extrabold tracking-tight text-blue-600">{current.word}</h1>
|
<span className="at-chip-dot" />
|
||||||
{current.phonetic && (
|
{current.part_of_speech?.toUpperCase() ?? 'TỪ VỰNG'}
|
||||||
<span className="text-slate-500 italic font-light text-lg">{current.phonetic}</span>
|
|
||||||
)}
|
|
||||||
{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>
|
||||||
|
<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="at-serif italic text-[var(--at-mute-2)]"> · {current.part_of_speech}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</>
|
|
||||||
) : (
|
<div className="flex items-center justify-center gap-2 text-[11.5px] text-[var(--at-mute)]">
|
||||||
<>
|
<span className="at-kbd">Space</span>
|
||||||
<div className="text-xs font-bold tracking-widest text-slate-400 uppercase">NGHĨA</div>
|
<span>để lật thẻ</span>
|
||||||
<div className="flex flex-col items-center gap-3 text-center">
|
</div>
|
||||||
<p className="text-2xl font-bold text-slate-800">{current.definition ?? '—'}</p>
|
</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 && (
|
{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">
|
<div className="at-example">
|
||||||
|
<div className="at-serif italic text-[14px] leading-[1.45] text-[var(--at-ink-2)]">
|
||||||
"{current.example}"
|
"{current.example}"
|
||||||
</p>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
<div className="flex items-center justify-center gap-2 text-[11.5px] text-[var(--at-mute)]">
|
||||||
<span className="text-sm font-medium">Nhấp để xem từ</span>
|
<span className="at-kbd">↵</span>
|
||||||
|
<span>lật lại</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Actions */}
|
||||||
<div className="mt-12 flex flex-col items-center gap-4 w-full max-w-xl">
|
<div className="mt-4 w-full" style={{ maxWidth: 420 }}>
|
||||||
<div className={cn(
|
<div className={cn('flex items-stretch gap-2.5 w-full transition-opacity duration-300', !isFlipped && 'opacity-40 pointer-events-none')}>
|
||||||
'flex items-stretch gap-3 w-full h-14 transition-all duration-300',
|
<button onClick={() => handleAnswer('ignored')} disabled={!isFlipped} className="at-action" style={{ padding: '11px 14px', fontSize: 13 }}>
|
||||||
!isFlipped && 'opacity-40 pointer-events-none',
|
Bỏ qua <span className="at-kbd">I</span>
|
||||||
)}>
|
|
||||||
<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>
|
||||||
<button
|
<button onClick={() => handleAnswer('hard')} disabled={!isFlipped} className="at-action at-action-review" style={{ padding: '11px 14px', fontSize: 13 }}>
|
||||||
onClick={() => handleAnswer('hard')}
|
Cần ôn <span className="at-kbd">K</span>
|
||||||
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>
|
||||||
<button
|
<button onClick={() => handleAnswer('known')} disabled={!isFlipped} className="at-action at-action-known" style={{ padding: '11px 14px', fontSize: 13 }}>
|
||||||
onClick={() => handleAnswer('easy')}
|
Đã thuộc
|
||||||
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="at-kbd" style={{ background: 'rgba(255,255,255,0.16)', color: 'rgba(255,255,255,0.9)', border: 'none' }}>J</span>
|
||||||
>
|
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</main>
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ function ListCard({ list, userId }: { list: FlashcardList; userId: string | null
|
|||||||
enabled: !!userId,
|
enabled: !!userId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const countNew = list.total_words - progress.filter(p => p.status !== 'new').length
|
|
||||||
const countLearning = progress.filter(p => p.status === 'learning').length
|
const countLearning = progress.filter(p => p.status === 'learning').length
|
||||||
const countKnown = progress.filter(p => p.status === 'known').length
|
const countKnown = progress.filter(p => p.status === 'known').length
|
||||||
const progressPct = list.total_words > 0
|
const progressPct = list.total_words > 0
|
||||||
@@ -22,68 +21,78 @@ function ListCard({ list, userId }: { list: FlashcardList; userId: string | null
|
|||||||
: 0
|
: 0
|
||||||
|
|
||||||
return (
|
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 justify-between items-start mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<div
|
||||||
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 24 }}>layers</span>
|
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>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h3 className="text-base font-bold text-slate-800 leading-tight">{list.title}</h3>
|
<h3
|
||||||
<div className="flex items-center gap-1.5 mt-0.5">
|
className="at-serif text-[17px] leading-[1.2] tracking-tight line-clamp-2"
|
||||||
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 14 }}>book</span>
|
style={{ color: 'var(--at-ink)', fontWeight: 500 }}
|
||||||
<span className="text-xs text-slate-500 font-medium">{list.total_words} từ</span>
|
>
|
||||||
|
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-[11px] font-bold tracking-wide uppercase px-2.5 py-1 rounded-full ${
|
<span className={`at-chip ${list.is_public ? 'at-chip-brand' : ''}`}>
|
||||||
list.is_public ? 'bg-blue-50 text-blue-700' : 'bg-slate-100 text-slate-500'
|
<span className="at-chip-dot" />
|
||||||
}`}>
|
|
||||||
{list.is_public ? 'Công khai' : 'Riêng tư'}
|
{list.is_public ? 'Công khai' : 'Riêng tư'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{list.description && (
|
{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="mt-2 mb-4">
|
||||||
<div className="flex justify-between items-center mb-1.5">
|
<div className="flex justify-between items-baseline mb-2">
|
||||||
<span className="text-xs font-bold text-emerald-600">Tiến độ: {progressPct}%</span>
|
<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>
|
||||||
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden">
|
<div className="at-bar">
|
||||||
<div
|
<span style={{ width: `${progressPct}%` }} />
|
||||||
className="h-full bg-emerald-500 rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${progressPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 mb-5">
|
<div className="grid grid-cols-3 gap-2 mb-5">
|
||||||
<div className="flex-1 py-2 bg-slate-50 rounded-lg text-center">
|
<Stat num={list.total_words - countLearning - countKnown} label="Mới" />
|
||||||
<p className="text-[10px] uppercase font-bold text-slate-400">Mới</p>
|
<Stat num={countLearning} label="Học" color="var(--at-brand)" />
|
||||||
<p className="text-sm font-bold text-slate-600">{countNew}</p>
|
<Stat num={countKnown} label="Biết" color="var(--at-good)" />
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 mt-auto">
|
<div className="grid grid-cols-2 gap-2 mt-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(list.id) } })}
|
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ẻ
|
Xem thẻ
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: '/flash-card/$listId/learn', params: { listId: String(list.id) } })}
|
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
|
Học ngay
|
||||||
</button>
|
</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() {
|
export function FlashCardListPage() {
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
const { data: lists = [], isLoading, isError } = useQuery({
|
const { data: lists = [], isLoading, isError } = useQuery({
|
||||||
@@ -100,48 +125,49 @@ export function FlashCardListPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
|
||||||
<div className="flex justify-between items-end mb-8">
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-extrabold text-slate-800 tracking-tight">Bộ Thẻ Từ Vựng</h1>
|
<div className="at-eyebrow mb-3">Từ vựng TOEIC</div>
|
||||||
<p className="text-slate-500 mt-1">Chọn bộ thẻ để bắt đầu học</p>
|
<h1 className="at-title text-4xl lg:text-[44px]">
|
||||||
</div>
|
Bộ thẻ <i>ghi nhớ</i>
|
||||||
<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">
|
</h1>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>filter_list</span>
|
<p className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
|
||||||
Sắp xếp: Mới nhất
|
Chọn bộ thẻ để bắt đầu — {lists.length} bộ sưu tầm
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{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) => (
|
{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
|
||||||
<div className="flex gap-3 mb-4">
|
key={i}
|
||||||
<div className="w-12 h-12 bg-slate-100 rounded-xl" />
|
className="rounded-2xl p-6 h-64 animate-pulse"
|
||||||
<div className="flex-1">
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
<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>
|
</div>
|
||||||
) : isError ? (
|
) : isError ? (
|
||||||
<div className="bg-red-50 border border-red-100 rounded-2xl p-10 text-center">
|
<div
|
||||||
<p className="text-red-500 text-sm">Không thể tải danh sách bộ thẻ. Vui lòng thử lại.</p>
|
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>
|
</div>
|
||||||
) : lists.length === 0 ? (
|
) : lists.length === 0 ? (
|
||||||
<div className="bg-white border border-slate-200 rounded-2xl p-16 text-center">
|
<div
|
||||||
<span className="material-symbols-outlined text-slate-300 mb-3 block" style={{ fontSize: 48 }}>library_books</span>
|
className="rounded-2xl p-16 text-center"
|
||||||
<p className="text-slate-500 font-medium">Chưa có bộ thẻ nào.</p>
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
<p className="text-slate-400 text-sm mt-1">Bộ thẻ từ vựng TOEIC sẽ được thêm sớm!</p>
|
>
|
||||||
|
<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 có 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>
|
||||||
) : (
|
) : (
|
||||||
<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 => (
|
{lists.map(list => (
|
||||||
<ListCard key={list.id} list={list} userId={user?.id ?? null} />
|
<ListCard key={list.id} list={list} userId={user?.id ?? null} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils'
|
|||||||
import { useAuthStore } from '@/store/auth-store'
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
import { fetchFlashcardTerms, fetchUserProgress } from '../api/flashcard-api'
|
import { fetchFlashcardTerms, fetchUserProgress } from '../api/flashcard-api'
|
||||||
import type { FlashcardTerm, UserProgress } 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'
|
type FilterStatus = 'all' | 'new' | 'learning' | 'known' | 'ignored'
|
||||||
|
|
||||||
@@ -15,11 +16,11 @@ const STATUS_LABEL: Record<string, string> = {
|
|||||||
ignored: 'Bỏ qua',
|
ignored: 'Bỏ qua',
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_STYLE: Record<string, string> = {
|
const STATUS_CLASS: Record<string, string> = {
|
||||||
new: 'bg-slate-100 text-slate-500',
|
new: 'at-chip',
|
||||||
learning: 'bg-blue-50 text-blue-700',
|
learning: 'at-chip at-chip-brand',
|
||||||
known: 'bg-emerald-50 text-emerald-700',
|
known: 'at-chip at-chip-good',
|
||||||
ignored: 'bg-rose-50 text-rose-600',
|
ignored: 'at-chip at-chip-warm',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -67,85 +68,107 @@ export function FlashCardTermsPage({ listId }: Props) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
|
||||||
{/* Header */}
|
{/* Editorial head */}
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<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
|
<button
|
||||||
onClick={() => navigate({ to: '/flash-card' })}
|
onClick={() => navigate({ to: '/flash-card' })}
|
||||||
className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-slate-100 transition-colors"
|
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 text-slate-600" style={{ fontSize: 22 }}>arrow_back</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>arrow_back</span>
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-xl font-bold text-slate-800 tracking-tight">Bộ thẻ từ vựng</h1>
|
<div className="at-eyebrow mb-2">Bộ thẻ từ vựng</div>
|
||||||
<span className="text-sm text-slate-400 font-medium">{countAll} từ</span>
|
<h1 className="at-title text-[32px] lg:text-4xl">
|
||||||
|
{countAll} <i>từ</i>
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: '/flash-card/$listId/learn', params: { listId: String(listId) } })}
|
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"
|
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: 18, fontVariationSettings: "'FILL' 1" }}>play_arrow</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 16, fontVariationSettings: "'FILL' 1" }}>play_arrow</span>
|
||||||
Bắt đầu học
|
Bắt đầu học
|
||||||
</button>
|
</button>
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter + Search */}
|
{/* Stats + filters */}
|
||||||
<div className="bg-slate-50 rounded-2xl p-5 mb-6">
|
<div
|
||||||
<div className="flex flex-col md:flex-row md:items-center gap-4 justify-between">
|
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">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
placeholder="Tìm kiếm từ..."
|
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>
|
||||||
<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 => (
|
{(['all', 'new', 'learning', 'known', 'ignored'] as FilterStatus[]).map(f => (
|
||||||
<button
|
<button
|
||||||
key={f}
|
key={f}
|
||||||
onClick={() => setFilter(f)}
|
onClick={() => setFilter(f)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-4 py-2 rounded-full text-xs font-bold whitespace-nowrap transition-colors',
|
'px-3.5 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-colors',
|
||||||
filter === f
|
|
||||||
? 'bg-slate-800 text-white'
|
|
||||||
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-100',
|
|
||||||
)}
|
)}
|
||||||
|
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]}
|
{f === 'all' ? 'Tất cả' : STATUS_LABEL[f]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Terms list */}
|
{/* Terms list */}
|
||||||
{loadingTerms ? (
|
{loadingTerms ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{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>
|
</div>
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<div className="bg-white border border-slate-200 rounded-2xl p-12 text-center">
|
<div
|
||||||
<p className="text-slate-400 text-sm">Không tìm thấy từ nào phù hợp.</p>
|
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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
{filtered.map(term => (
|
{filtered.map(term => (
|
||||||
<TermRow key={term.id} term={term} status={getStatus(term.id)} />
|
<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 (
|
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="text-center py-2 rounded-lg" style={{ background: 'var(--at-paper-2)' }}>
|
||||||
<div className="w-1/4 min-w-0">
|
<div
|
||||||
<div className="flex items-baseline gap-2 mb-1 flex-wrap">
|
className="at-serif"
|
||||||
<h3 className="text-base font-extrabold text-blue-600 tracking-tight truncate">{term.word}</h3>
|
style={{ fontSize: 22, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1, color: color ?? 'var(--at-ink)' }}
|
||||||
{term.phonetic && (
|
>
|
||||||
<span className="text-xs text-slate-400 font-medium shrink-0">{term.phonetic}</span>
|
{num}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{term.part_of_speech && (
|
<div
|
||||||
<span className="text-[10px] uppercase tracking-wider font-bold px-2 py-0.5 bg-slate-100 text-slate-500 rounded">
|
className="mt-1"
|
||||||
{term.part_of_speech}
|
style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 600 }}
|
||||||
</span>
|
>
|
||||||
)}
|
{label}
|
||||||
</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>
|
</div>
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
7
src/features/flash-card/lib/media-url.ts
Normal 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}`}`
|
||||||
|
}
|
||||||
30
src/features/flash-card/lib/srs-intervals.ts
Normal 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'
|
||||||
|
}
|
||||||
@@ -6,199 +6,293 @@ const FEATURES = [
|
|||||||
{
|
{
|
||||||
to: '/toeic',
|
to: '/toeic',
|
||||||
icon: 'assignment',
|
icon: 'assignment',
|
||||||
iconBg: 'bg-blue-50',
|
title: 'Luyện đề',
|
||||||
iconColor: 'text-blue-600',
|
accent: 'TOEIC',
|
||||||
borderColor: 'border-l-blue-600',
|
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.',
|
||||||
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',
|
|
||||||
stat: '350+ câu hỏi',
|
stat: '350+ câu hỏi',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/writing',
|
to: '/writing',
|
||||||
icon: 'auto_fix_high',
|
icon: 'auto_fix_high',
|
||||||
iconBg: 'bg-green-50',
|
title: 'AI chấm',
|
||||||
iconColor: 'text-green-600',
|
accent: 'Writing',
|
||||||
borderColor: 'border-l-green-600',
|
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.',
|
||||||
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',
|
|
||||||
stat: '3 lượt / ngày',
|
stat: '3 lượt / ngày',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/flash-card',
|
to: '/flash-card',
|
||||||
icon: 'menu_book',
|
icon: 'menu_book',
|
||||||
iconBg: 'bg-amber-50',
|
title: 'Từ vựng',
|
||||||
iconColor: 'text-amber-600',
|
accent: 'thông minh',
|
||||||
borderColor: 'border-l-amber-600',
|
desc: 'Bộ thẻ TOEIC với spaced-repetition, lật 3D, ảnh minh hoạ.',
|
||||||
title: 'Từ vựng thông minh',
|
stat: '18 000+ từ',
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const openModal = useAuthModalStore((s) => s.open)
|
const openModal = useAuthModalStore((s) => s.open)
|
||||||
|
const firstName = user?.name ?? 'bạn'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 py-8 max-w-6xl mx-auto page-enter">
|
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
|
||||||
{/* Hero */}
|
{/* Page head — editorial */}
|
||||||
<section className="flex flex-col lg:flex-row gap-10 items-center mb-12">
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
||||||
<div className="flex-1 min-w-0">
|
<div>
|
||||||
<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">
|
<div className="at-eyebrow mb-3">Học TOEIC cùng AI</div>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>auto_awesome</span>
|
<h1 className="at-title text-4xl lg:text-[44px]">
|
||||||
AI-Powered Learning
|
Chào <i>{firstName}</i>,<br />
|
||||||
</div>
|
hôm nay học <i>15 phút</i>?
|
||||||
<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>
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-slate-500 text-lg leading-relaxed mb-8 max-w-md">
|
<div className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
|
||||||
Cá 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 và tối ưu bài tập cho bạn.
|
Mục tiêu <b style={{ color: 'var(--at-ink)' }}>850</b>
|
||||||
</p>
|
<span className="mx-2 inline-block w-[3px] h-[3px] rounded-full align-middle" style={{ background: 'var(--at-mute-2)' }} />
|
||||||
<div className="flex gap-3 flex-wrap">
|
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
|
<Link
|
||||||
to="/toeic"
|
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"
|
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)' }}
|
||||||
>
|
>
|
||||||
Bắt đầu ngay
|
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>play_arrow</span>
|
||||||
|
Tiếp tục học
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview card — hidden on mobile */}
|
<div className="grid lg:grid-cols-[2fr_1fr] gap-5">
|
||||||
<div className="hidden lg:block flex-shrink-0 w-80">
|
{/* MAIN COL */}
|
||||||
<div className="bg-white rounded-2xl p-6 shadow-xl border border-slate-100">
|
<div className="flex flex-col gap-5 min-w-0">
|
||||||
<div className="flex items-center justify-between mb-5">
|
{/* Progress hero */}
|
||||||
<div>
|
<div className="rounded-2xl p-7 flex flex-wrap items-center gap-7" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
|
||||||
<div className="font-bold text-base text-slate-800">Tiến độ tuần này</div>
|
<ProgressRing value={85} />
|
||||||
<div className="text-xs text-slate-400 mt-0.5">Bạn đang làm rất tốt!</div>
|
<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>
|
||||||
<div className="bg-green-50 text-green-600 text-xs font-bold px-2.5 py-1 rounded-lg">+12%</div>
|
<div className="flex flex-wrap gap-4 items-stretch">
|
||||||
</div>
|
<Stat num="24" label="ngày còn lại" />
|
||||||
<div className="mb-4">
|
<div className="w-px self-stretch" style={{ background: 'var(--at-line)' }} />
|
||||||
<div className="flex justify-between text-xs font-semibold mb-1.5">
|
<Stat num="+46" label="điểm tháng này" />
|
||||||
<span>Reading Score</span><span className="text-blue-600">420/495</span>
|
<div className="w-px self-stretch" style={{ background: 'var(--at-line)' }} />
|
||||||
</div>
|
<Stat num="68%" label="tỷ lệ đúng" color="var(--at-good)" />
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Feature cards */}
|
{/* Feature cards */}
|
||||||
<section>
|
<div className="rounded-2xl p-6" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
|
||||||
<h2 className="text-2xl font-extrabold text-slate-800 mb-1.5">Tính năng nổi bật</h2>
|
<div className="at-eyebrow mb-1">Khám phá</div>
|
||||||
<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>
|
<h2 className="at-serif text-[22px] tracking-tight mb-5" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
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) => (
|
{FEATURES.map((f) => (
|
||||||
<Link
|
<Link
|
||||||
key={f.to}
|
key={f.to}
|
||||||
to={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`}
|
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={`w-12 h-12 ${f.iconBg} rounded-xl flex items-center justify-center mb-4`}>
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className={`material-symbols-outlined ${f.iconColor}`}>{f.icon}</span>
|
<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>
|
</div>
|
||||||
<h3 className="font-bold text-base text-slate-800 mb-2">{f.title}</h3>
|
<span className="at-chip at-chip-brand">
|
||||||
<p className="text-slate-500 text-sm leading-relaxed mb-4">{f.desc}</p>
|
<span className="at-chip-dot" />
|
||||||
<div className={`flex items-center gap-1.5 text-sm font-bold ${f.ctaColor}`}>
|
{f.stat}
|
||||||
{f.cta}
|
</span>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>arrow_forward</span>
|
|
||||||
</div>
|
</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>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
<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>
|
{/* 7-day journey */}
|
||||||
<p className="text-blue-100 mb-5">
|
<div className="rounded-2xl p-6" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
|
||||||
{user
|
<div className="flex justify-between items-end mb-4">
|
||||||
? `Chào ${user.name}! Tiếp tục luyện thi hôm nay.`
|
<div>
|
||||||
: 'Đăng ký miễn phí để lưu tiến độ và luyện thi không giới hạn.'}
|
<div className="at-eyebrow mb-1">Tuần này</div>
|
||||||
</p>
|
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
|
||||||
{user ? (
|
Lộ trình <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>7 ngày</i>
|
||||||
<Link
|
</div>
|
||||||
to="/toeic"
|
</div>
|
||||||
className="inline-block bg-white text-blue-600 px-6 py-3 rounded-xl font-bold text-sm hover:bg-blue-50 transition-colors"
|
<span className="at-chip at-chip-good">
|
||||||
|
<span className="at-chip-dot" />
|
||||||
|
+24% so với tuần trước
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<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 }}
|
||||||
>
|
>
|
||||||
Luyện thi ngay
|
{d}
|
||||||
</Link>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* 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 ký để <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>lưu tiến độ</i>.
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal('register')}
|
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 ký miễn phí
|
Đăng ký miễn phí
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate } from '@tanstack/react-router'
|
|||||||
import { CircularProgress } from '@/components/CircularProgress'
|
import { CircularProgress } from '@/components/CircularProgress'
|
||||||
import { useTestStore } from '@/store/test-store'
|
import { useTestStore } from '@/store/test-store'
|
||||||
import { TOEIC_PARTS } from '@/temp/local-data'
|
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'
|
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||||
|
|
||||||
export function ToeicPractice() {
|
export function ToeicPractice() {
|
||||||
@@ -16,8 +16,9 @@ export function ToeicPractice() {
|
|||||||
if (!requireAuth()) return
|
if (!requireAuth()) return
|
||||||
setLoadingPartId(partId)
|
setLoadingPartId(partId)
|
||||||
try {
|
try {
|
||||||
const questions = await fetchQuestions(partId, 10)
|
// TODO: replace hardcoded testId=1 with real test selection
|
||||||
startExam(partId, partName, questions)
|
const parts = await fetchQuestionsForTest(1, [partId])
|
||||||
|
startExam({ testId: 1, testName: partName, parts, totalSeconds: 0 })
|
||||||
navigate({ to: '/toeic/session' })
|
navigate({ to: '/toeic/session' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load questions:', err)
|
console.error('Failed to load questions:', err)
|
||||||
|
|||||||
@@ -10,66 +10,96 @@ export function ToeicTestList() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 py-8 max-w-6xl mx-auto page-enter">
|
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
|
||||||
<div className="mb-8">
|
{/* Editorial head */}
|
||||||
<h1 className="text-3xl font-extrabold text-slate-800 mb-2">Đề Thi TOEIC</h1>
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
||||||
<p className="text-slate-500">Chọn đề thi để bắt đầu luyện tập hoặc thi thử toàn bộ.</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{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) => (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{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.
|
Không thể tải danh sách đề thi. Vui lòng thử lại.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !error && tests.length === 0 && (
|
{!isLoading && !error && tests.length === 0 && (
|
||||||
<div className="text-center py-20 text-slate-400">
|
<div
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 48 }}>library_books</span>
|
className="rounded-2xl p-16 text-center"
|
||||||
<p className="mt-3 font-medium">Chưa có đề thi nào. Dữ liệu đang được cập nhật.</p>
|
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 có đề 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tests.length > 0 && (
|
{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) => (
|
{tests.map((test) => (
|
||||||
<div
|
<div
|
||||||
key={test.id}
|
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 && (
|
{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}
|
{test.categoryName}
|
||||||
</span>
|
</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 && (
|
{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="flex items-center gap-1">
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>list_alt</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 13 }}>list_alt</span>
|
||||||
{test.totalQuestions} câu
|
<b className="tabular-nums" style={{ color: 'var(--at-ink)' }}>{test.totalQuestions}</b> câu
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>timer</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 13 }}>timer</span>
|
||||||
{test.durationMinutes} phút
|
<b className="tabular-nums" style={{ color: 'var(--at-ink)' }}>{test.durationMinutes}</b> phút
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: '/toeic/$testId', params: { testId: String(test.id) } })}
|
onClick={() => navigate({ to: '/toeic/$testId', params: { testId: String(test.id) } })}
|
||||||
className="w-full py-2.5 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
|
Bắt đầu
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -95,47 +95,41 @@ export function WritingChecker() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const sentenceCount = text.split(/[.!?]+/).filter(s => s.trim()).length
|
||||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
const wordCount = text.split(/\s+/).filter(Boolean).length
|
||||||
<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 và cấu trúc bài viết.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-5">
|
return (
|
||||||
{/* Left: Input */}
|
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
|
||||||
<div className="flex-1 min-w-0">
|
{/* Editorial page head */}
|
||||||
<div className="bg-white rounded-2xl border border-slate-200 p-5">
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="min-w-0">
|
||||||
<span className="text-sm font-semibold text-slate-700">Bài writing của bạn</span>
|
<div className="at-eyebrow mb-3 inline-flex items-center gap-1.5">
|
||||||
<span className={cn('text-xs tabular-nums', charCount > MAX_CHARS ? 'text-red-500 font-bold' : 'text-slate-400')}>
|
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>auto_awesome</span>
|
||||||
{charCount}/{MAX_CHARS}
|
AI Writing Checker
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<h1 className="at-title text-4xl lg:text-[44px]">
|
||||||
value={text}
|
Kiểm tra <i>bài viết</i>
|
||||||
onChange={(e) => setText(e.target.value.slice(0, MAX_CHARS))}
|
</h1>
|
||||||
rows={12}
|
<p className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
|
||||||
dir="ltr"
|
Dán bài viết — AI sẽ kiểm tra ngữ pháp, chính tả, và chấm điểm IELTS/TOEIC
|
||||||
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)"
|
</p>
|
||||||
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')}>
|
|
||||||
Còn {remaining}/{dailyLimit} lượt hôm nay
|
|
||||||
</span>
|
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-bold transition-all',
|
'inline-flex items-center gap-2 px-5 py-3 rounded-xl text-[13.5px] font-semibold transition-opacity',
|
||||||
canSubmit
|
canSubmit ? 'hover:opacity-90' : 'cursor-not-allowed opacity-50',
|
||||||
? 'bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/20'
|
|
||||||
: 'bg-slate-100 text-slate-400 cursor-not-allowed',
|
|
||||||
)}
|
)}
|
||||||
|
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)', border: '1px solid var(--at-ink)' }}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
@@ -144,14 +138,64 @@ export function WritingChecker() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>auto_fix_high</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>auto_awesome</span>
|
||||||
Chấm bài ngay
|
Kiểm tra ngay
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-[1.5fr_1fr] gap-5">
|
||||||
|
{/* Left: Input */}
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{remaining <= 0 && (
|
{remaining <= 0 && (
|
||||||
<div className="mt-3 bg-amber-50 border border-amber-100 rounded-xl p-4 flex items-center gap-3">
|
<div className="mt-3 bg-amber-50 border border-amber-100 rounded-xl p-4 flex items-center gap-3">
|
||||||
<span className="material-symbols-outlined text-amber-600 flex-shrink-0" style={{ fontSize: 20 }}>schedule</span>
|
<span className="material-symbols-outlined text-amber-600 flex-shrink-0" style={{ fontSize: 20 }}>schedule</span>
|
||||||
@@ -172,11 +216,16 @@ export function WritingChecker() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Feedback */}
|
{/* Right: Feedback */}
|
||||||
<div className="lg:w-80 flex-shrink-0">
|
<div className="flex flex-col gap-5">
|
||||||
{!feedback && !isPending && (
|
{!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">
|
<div className="at-tip">
|
||||||
<span className="material-symbols-outlined text-slate-300 mb-3" style={{ fontSize: 48 }}>auto_fix_high</span>
|
<div className="at-tip-label">AI kiểm tra gì?</div>
|
||||||
<p className="text-sm text-slate-400">Nhập bài và nhấn "Chấm bài ngay" để nhận phản hồi từ AI</p>
|
<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> và sử dụng{' '}
|
||||||
|
<b style={{ color: 'var(--at-warm)', fontWeight: 700 }}>3-4 linking words</b>.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
330
src/index.css
@@ -48,6 +48,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
: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);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
@@ -121,13 +151,106 @@
|
|||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
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 {
|
html {
|
||||||
@apply font-sans;
|
@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 3D flip ── */
|
||||||
.flashcard-scene {
|
.flashcard-scene {
|
||||||
perspective: 1000px;
|
perspective: 1000px;
|
||||||
@@ -174,3 +297,208 @@
|
|||||||
.timer-urgent {
|
.timer-urgent {
|
||||||
animation: timer-pulse 1s ease-in-out infinite;
|
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; }
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { Dashboard } from '@/features/dashboard/components/Dashboard'
|
import { Dashboard } from '@/features/dashboard/components/Dashboard'
|
||||||
|
|
||||||
export const Route = createFileRoute('/dashboard')({
|
export const Route = createFileRoute('/archivement')({
|
||||||
component: Dashboard,
|
component: Dashboard,
|
||||||
})
|
})
|
||||||
11
src/routes/flash-card.$listId.index.tsx
Normal 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)} />
|
||||||
|
}
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute, Outlet } from "@tanstack/react-router"
|
||||||
import { FlashCardTermsPage } from "@/features/flash-card/components/FlashCardTermsPage"
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/flash-card/$listId")({
|
export const Route = createFileRoute("/flash-card/$listId")({
|
||||||
component: TermsPage,
|
component: () => <Outlet />,
|
||||||
})
|
})
|
||||||
|
|
||||||
function TermsPage() {
|
|
||||||
const { listId } = Route.useParams()
|
|
||||||
return <FlashCardTermsPage listId={Number(listId)} />
|
|
||||||
}
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |