phase 2
This commit is contained in:
32
src/components/AppHeader.tsx
Normal file
32
src/components/AppHeader.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useRouterState } from '@tanstack/react-router'
|
||||
import { useTestStore } from '@/store/test-store'
|
||||
import { UserMenu } from '@/components/UserMenu'
|
||||
|
||||
const ROUTE_TITLES: Record<string, string> = {
|
||||
'/': 'Trang chủ',
|
||||
'/writing': 'AI Chấm Writing',
|
||||
'/vocab': 'Từ vựng TOEIC',
|
||||
'/toeic': 'Luyện đề TOEIC',
|
||||
'/toeic/session': '', // dynamic — filled below
|
||||
'/toeic/result': 'Kết quả bài thi',
|
||||
}
|
||||
|
||||
export function AppHeader() {
|
||||
const { location } = useRouterState()
|
||||
const { partId, partName, answers, questions } = useTestStore()
|
||||
const pathname = location.pathname
|
||||
|
||||
let title = ROUTE_TITLES[pathname] ?? 'EnglishAI'
|
||||
|
||||
if (pathname === '/toeic/session') {
|
||||
const answered = answers.filter((a) => a !== null).length
|
||||
title = `Part ${partId} — ${partName} · ${answered}/${questions.length} câu`
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 right-0 left-0 lg:left-60 h-16 bg-white/90 backdrop-blur-md border-b border-slate-200 z-40 flex items-center justify-between px-6">
|
||||
<span className="text-sm font-semibold text-slate-700">{title}</span>
|
||||
<UserMenu />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
37
src/components/CircularProgress.tsx
Normal file
37
src/components/CircularProgress.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
interface CircularProgressProps {
|
||||
percent: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
color?: string
|
||||
}
|
||||
|
||||
/** SVG circular progress ring with centered percentage label. */
|
||||
export function CircularProgress({
|
||||
percent,
|
||||
size = 44,
|
||||
strokeWidth = 3.5,
|
||||
color = '#2563EB',
|
||||
}: CircularProgressProps) {
|
||||
const cx = size / 2
|
||||
const radius = cx - strokeWidth
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const offset = circumference - (Math.min(percent, 100) / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center flex-shrink-0" style={{ width: size, height: size }}>
|
||||
<svg className="-rotate-90" width={size} height={size}>
|
||||
<circle cx={cx} cy={cx} r={radius} fill="none" stroke="#e2e8f0" strokeWidth={strokeWidth} />
|
||||
<circle
|
||||
cx={cx} cy={cx} r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute text-[10px] font-bold text-slate-700">{percent}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +1,59 @@
|
||||
import { useState } from "react"
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FlashCardProps {
|
||||
word?: string
|
||||
phonetic?: string
|
||||
meaningVi?: string
|
||||
example?: string
|
||||
word: string
|
||||
phonetic: string
|
||||
meaningVi: string
|
||||
example: string
|
||||
topicBadge: string
|
||||
isFlipped: boolean
|
||||
onFlip: () => void
|
||||
}
|
||||
|
||||
export function FlashCard({ word, phonetic, meaningVi, example }: FlashCardProps) {
|
||||
const [flipped, setFlipped] = useState(false)
|
||||
/** 3D flip flashcard. Front shows word/phonetic; back shows Vietnamese meaning + example. */
|
||||
export function FlashCard({ word, phonetic, meaningVi, example, topicBadge, isFlipped, onFlip }: FlashCardProps) {
|
||||
const highlightedExample = example.replace(
|
||||
new RegExp(`\\b${word}\\b`, 'gi'),
|
||||
(match) => `<strong>${match}</strong>`,
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => setFlipped((f) => !f)}
|
||||
className="rounded-lg border p-6 text-center cursor-pointer min-h-[160px] flex flex-col justify-center select-none hover:bg-gray-50"
|
||||
className="flashcard-scene w-full cursor-pointer select-none"
|
||||
style={{ height: 280 }}
|
||||
onClick={onFlip}
|
||||
role="button"
|
||||
aria-label={isFlipped ? 'Nhấn để xem từ' : 'Nhấn để xem nghĩa'}
|
||||
>
|
||||
{!flipped ? (
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{word || "word"}</p>
|
||||
{phonetic && <p className="text-gray-400 mt-1">{phonetic}</p>}
|
||||
<div className={cn('flashcard-inner w-full h-full', isFlipped && 'is-flipped')}>
|
||||
{/* Front */}
|
||||
<div className="flashcard-face bg-white border border-slate-200 shadow-lg flex flex-col items-center justify-center p-8">
|
||||
<div className="text-4xl font-extrabold text-slate-800 mb-2">{word}</div>
|
||||
<div className="text-slate-400 text-lg mb-4">{phonetic}</div>
|
||||
<span className="bg-blue-50 text-blue-600 text-xs font-bold px-3 py-1 rounded-full">
|
||||
{topicBadge}
|
||||
</span>
|
||||
<p className="mt-6 text-xs text-slate-400 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>touch_app</span>
|
||||
Nhấn để xem nghĩa
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-semibold text-blue-600">{meaningVi || "nghĩa tiếng Việt"}</p>
|
||||
{example && <p className="text-sm text-gray-500 italic">{example}</p>}
|
||||
|
||||
{/* Back */}
|
||||
<div
|
||||
className="flashcard-face flashcard-back flex flex-col items-center justify-center p-8"
|
||||
style={{ background: 'linear-gradient(135deg, #eff6ff, #dbeafe)' }}
|
||||
>
|
||||
<div className="text-3xl font-extrabold text-blue-600 mb-1">{meaningVi}</div>
|
||||
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wide mb-4">
|
||||
Nghĩa tiếng Việt
|
||||
</div>
|
||||
<div
|
||||
className="bg-white rounded-xl p-3 border border-blue-100 text-sm text-slate-500 italic text-center"
|
||||
dangerouslySetInnerHTML={{ __html: `"${highlightedExample}"` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
41
src/components/MobileNav.tsx
Normal file
41
src/components/MobileNav.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', label: 'Home', icon: 'home', matchPrefix: '/', exact: true },
|
||||
{ to: '/toeic', label: 'Luyện đề', icon: 'assignment', matchPrefix: '/toeic', exact: false },
|
||||
{ to: '/writing', label: 'Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false },
|
||||
{ to: '/vocab', label: 'Từ vựng', icon: 'menu_book', matchPrefix: '/vocab', exact: false },
|
||||
]
|
||||
|
||||
function isActive(pathname: string, prefix: string, exact: boolean) {
|
||||
return exact ? pathname === prefix : pathname.startsWith(prefix)
|
||||
}
|
||||
|
||||
export function MobileNav() {
|
||||
const { location } = useRouterState()
|
||||
const pathname = location.pathname
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 inset-x-0 lg:hidden bg-white border-t border-slate-200 z-50 flex safe-area-inset-bottom">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = isActive(pathname, item.matchPrefix, item.exact)
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={cn(
|
||||
'flex-1 flex flex-col items-center justify-center gap-0.5 py-2 min-h-[56px] text-[11px] font-medium transition-colors',
|
||||
active ? 'text-blue-600' : 'text-slate-400',
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
|
||||
{item.icon}
|
||||
</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
84
src/components/Sidebar.tsx
Normal file
84
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', label: 'Trang chủ', icon: 'home', matchPrefix: '/', exact: true },
|
||||
{ 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: '/vocab', label: 'Từ vựng', icon: 'menu_book', matchPrefix: '/vocab', exact: false },
|
||||
]
|
||||
|
||||
function isActive(pathname: string, prefix: string, exact: boolean) {
|
||||
return exact ? pathname === prefix : pathname.startsWith(prefix)
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const { location } = useRouterState()
|
||||
const pathname = location.pathname
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const openModal = useAuthModalStore((s) => s.open)
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:flex fixed inset-y-0 left-0 w-60 flex-col bg-slate-50 border-r border-slate-200 z-50">
|
||||
{/* Brand */}
|
||||
<div className="px-6 py-5 border-b border-slate-200">
|
||||
<div className="text-xl font-extrabold text-blue-600 tracking-tight">EnglishAI</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">Học tập thông minh</div>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 py-3 overflow-y-auto">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = isActive(pathname, item.matchPrefix, item.exact)
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={cn(
|
||||
'flex items-center gap-3 mx-2 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150',
|
||||
active
|
||||
? 'bg-white text-blue-600 font-semibold shadow-sm'
|
||||
: 'text-slate-500 hover:bg-white/70 hover:text-slate-800',
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>
|
||||
{item.icon}
|
||||
</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User */}
|
||||
<div className="px-3 py-4 border-t border-slate-200">
|
||||
{user ? (
|
||||
<div className="flex items-center gap-3 bg-white rounded-xl px-3 py-2.5">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-bold flex-shrink-0">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{user.name}</div>
|
||||
<div className="text-xs text-slate-400 truncate">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => openModal('login')}
|
||||
className="w-full flex items-center gap-3 bg-white rounded-xl px-3 py-2.5 hover:bg-blue-50 transition-colors group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center flex-shrink-0 group-hover:bg-blue-100 transition-colors">
|
||||
<span className="material-symbols-outlined text-slate-400 group-hover:text-blue-600 transition-colors" style={{ fontSize: 18 }}>person</span>
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<div className="text-sm font-semibold text-slate-600">Khách</div>
|
||||
<div className="text-xs text-blue-600 font-medium">Đăng nhập →</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
113
src/components/UserMenu.tsx
Normal file
113
src/components/UserMenu.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import { useUser } from '@/hooks/use-auth'
|
||||
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||
|
||||
/** Avatar circle with first letter of name, deterministic color */
|
||||
function Avatar({ name }: { name: string }) {
|
||||
const colors = ['bg-blue-600', 'bg-green-600', 'bg-violet-600', 'bg-rose-600', 'bg-amber-600']
|
||||
const color = colors[name.charCodeAt(0) % colors.length]
|
||||
return (
|
||||
<div className={`w-8 h-8 rounded-full ${color} flex items-center justify-center text-white text-sm font-bold flex-shrink-0`}>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function UserMenu() {
|
||||
const user = useUser()
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const openModal = useAuthModalStore((s) => s.open)
|
||||
const navigate = useNavigate()
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!dropdownOpen) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [dropdownOpen])
|
||||
|
||||
async function handleLogout() {
|
||||
setDropdownOpen(false)
|
||||
await logout()
|
||||
navigate({ to: '/' })
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => openModal('login')}
|
||||
className="px-3.5 py-1.5 text-sm font-semibold text-slate-600 border border-slate-300 rounded-xl hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Đăng nhập
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openModal('register')}
|
||||
className="px-3.5 py-1.5 text-sm font-semibold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Đăng ký
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setDropdownOpen((o) => !o)}
|
||||
className="flex items-center gap-2 px-2 py-1 rounded-xl hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<Avatar name={user.name} />
|
||||
<span className="text-sm font-semibold text-slate-700 max-w-28 truncate hidden sm:block">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 16 }}>
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-52 bg-white rounded-2xl shadow-lg border border-slate-200 py-1.5 z-50">
|
||||
<div className="px-4 py-2 border-b border-slate-100 mb-1">
|
||||
<div className="text-sm font-semibold text-slate-800 truncate">{user.name}</div>
|
||||
<div className="text-xs text-slate-400 truncate">{user.email}</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { setDropdownOpen(false); alert('Coming soon!') }}
|
||||
className="w-full flex items-center gap-2.5 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 18 }}>history</span>
|
||||
Lịch sử bài thi
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setDropdownOpen(false); alert('Coming soon!') }}
|
||||
className="w-full flex items-center gap-2.5 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 18 }}>edit_note</span>
|
||||
Lịch sử writing
|
||||
</button>
|
||||
|
||||
<div className="border-t border-slate-100 mt-1 pt-1">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-2.5 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-red-400" style={{ fontSize: 18 }}>logout</span>
|
||||
Đăng xuất
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
src/components/auth/AuthModal.tsx
Normal file
96
src/components/auth/AuthModal.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||
import { LoginForm } from './LoginForm'
|
||||
import { RegisterForm } from './RegisterForm'
|
||||
|
||||
export function AuthModal() {
|
||||
const { isOpen, mode, open, close } = useAuthModalStore()
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') close()
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, close])
|
||||
|
||||
// Prevent body scroll when open
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = isOpen ? 'hidden' : ''
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [isOpen])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-60 flex items-center justify-center p-4"
|
||||
style={{ zIndex: 60 }}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={close}
|
||||
/>
|
||||
|
||||
{/* Card */}
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={close}
|
||||
className="absolute top-4 right-4 text-slate-400 hover:text-slate-600 transition-colors"
|
||||
aria-label="Đóng"
|
||||
>
|
||||
<span className="material-symbols-outlined text-xl">close</span>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="material-symbols-outlined text-blue-600">school</span>
|
||||
<span className="font-bold text-slate-800">TOEIC Luyện thi</span>
|
||||
</div>
|
||||
|
||||
{/* Tab toggle */}
|
||||
<div className="flex bg-slate-100 rounded-xl p-1">
|
||||
<button
|
||||
onClick={() => open('login')}
|
||||
className={`flex-1 py-2 text-sm font-semibold rounded-lg transition-all ${
|
||||
mode === 'login'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Đăng nhập
|
||||
</button>
|
||||
<button
|
||||
onClick={() => open('register')}
|
||||
className={`flex-1 py-2 text-sm font-semibold rounded-lg transition-all ${
|
||||
mode === 'register'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Đăng ký
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
{mode === 'login' ? (
|
||||
<LoginForm
|
||||
onSuccess={close}
|
||||
onSwitchToRegister={() => open('register')}
|
||||
/>
|
||||
) : (
|
||||
<RegisterForm
|
||||
onSuccess={close}
|
||||
onSwitchToLogin={() => open('login')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
src/components/auth/LoginForm.tsx
Normal file
82
src/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
|
||||
interface LoginFormProps {
|
||||
onSuccess?: () => void
|
||||
onSwitchToRegister?: () => void
|
||||
}
|
||||
|
||||
export function LoginForm({ onSuccess, onSwitchToRegister }: LoginFormProps) {
|
||||
const login = useAuthStore((s) => s.login)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await login(email, password)
|
||||
onSuccess?.()
|
||||
} catch (err) {
|
||||
setError((err as Error).message)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Mật khẩu</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={6}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Tối thiểu 6 ký tự"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-2.5 bg-blue-600 text-white text-sm font-semibold rounded-xl hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'Đang đăng nhập...' : 'Đăng nhập'}
|
||||
</button>
|
||||
|
||||
{onSwitchToRegister && (
|
||||
<p className="text-center text-sm text-slate-500">
|
||||
Chưa có tài khoản?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToRegister}
|
||||
className="text-blue-600 font-medium hover:underline"
|
||||
>
|
||||
Đăng ký ngay
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
94
src/components/auth/RegisterForm.tsx
Normal file
94
src/components/auth/RegisterForm.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
|
||||
interface RegisterFormProps {
|
||||
onSuccess?: () => void
|
||||
onSwitchToLogin?: () => void
|
||||
}
|
||||
|
||||
export function RegisterForm({ onSuccess, onSwitchToLogin }: RegisterFormProps) {
|
||||
const register = useAuthStore((s) => s.register)
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await register(name, email, password)
|
||||
onSuccess?.()
|
||||
} catch (err) {
|
||||
setError((err as Error).message)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Tên của bạn</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Nguyễn Văn A"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Mật khẩu</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={6}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Tối thiểu 6 ký tự"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-2.5 bg-blue-600 text-white text-sm font-semibold rounded-xl hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'Đang đăng ký...' : 'Đăng ký'}
|
||||
</button>
|
||||
|
||||
{onSwitchToLogin && (
|
||||
<p className="text-center text-sm text-slate-500">
|
||||
Đã có tài khoản?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToLogin}
|
||||
className="text-blue-600 font-medium hover:underline"
|
||||
>
|
||||
Đăng nhập
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
157
src/data/mock-data.ts
Normal file
157
src/data/mock-data.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { Question, VocabWord, WritingFeedback, ToeicPart } from '@/types'
|
||||
|
||||
export const TOEIC_PARTS: ToeicPart[] = [
|
||||
{ id: 1, name: 'Part 1', nameVi: 'Mô tả hình ảnh', questionCount: 45, icon: 'image', progressPercent: 60 },
|
||||
{ id: 2, name: 'Part 2', nameVi: 'Hỏi-đáp', questionCount: 30, icon: 'question_answer', progressPercent: 40 },
|
||||
{ id: 3, name: 'Part 3', nameVi: 'Đoạn hội thoại', questionCount: 39, icon: 'forum', progressPercent: 25 },
|
||||
{ id: 4, name: 'Part 4', nameVi: 'Bài nói', questionCount: 30, icon: 'record_voice_over', progressPercent: 10 },
|
||||
{ id: 5, name: 'Part 5', nameVi: 'Điền từ', questionCount: 40, icon: 'history_edu', progressPercent: 80 },
|
||||
{ id: 6, name: 'Part 6', nameVi: 'Điền đoạn', questionCount: 16, icon: 'article', progressPercent: 50 },
|
||||
{ id: 7, name: 'Part 7', nameVi: 'Đọc hiểu', questionCount: 54, icon: 'chrome_reader_mode', progressPercent: 30 },
|
||||
]
|
||||
|
||||
export const MOCK_QUESTIONS: Question[] = [
|
||||
{
|
||||
id: 'q1', part: 2,
|
||||
text: 'What does the man suggest the woman do about the budget report?',
|
||||
options: ['A. Submit it immediately', 'B. Review it again carefully', 'C. Postpone the deadline', 'D. Ask a colleague for help'],
|
||||
correctAnswer: 1,
|
||||
explanation: 'Người đàn ông nói "You should review it carefully before submitting" — gợi ý xem xét lại báo cáo trước khi nộp.',
|
||||
},
|
||||
{
|
||||
id: 'q2', part: 2,
|
||||
text: 'Where most likely are the speakers?',
|
||||
options: ['A. In a restaurant', 'B. At a conference', 'C. In an office', 'D. At an airport'],
|
||||
correctAnswer: 2,
|
||||
explanation: 'Các từ như "meeting room", "printer", "desk" cho biết cuộc trò chuyện diễn ra trong văn phòng.',
|
||||
},
|
||||
{
|
||||
id: 'q3', part: 2,
|
||||
text: 'Why is the man calling?',
|
||||
options: ['A. To confirm a reservation', 'B. To cancel an appointment', 'C. To reschedule a meeting', 'D. To order supplies'],
|
||||
correctAnswer: 0,
|
||||
explanation: 'Từ "confirm" và "booking number" trong hội thoại chỉ rõ mục đích của cuộc gọi là xác nhận đặt chỗ.',
|
||||
},
|
||||
{
|
||||
id: 'q4', part: 2,
|
||||
text: 'What will the woman do next?',
|
||||
options: ['A. Call the manager', 'B. Send an email', 'C. Check the inventory', 'D. Update the schedule'],
|
||||
correctAnswer: 3,
|
||||
explanation: 'Người phụ nữ nói "I\'ll update the schedule right away" — cho biết hành động tiếp theo là cập nhật lịch trình.',
|
||||
},
|
||||
{
|
||||
id: 'q5', part: 2,
|
||||
text: 'What problem does the man mention?',
|
||||
options: ['A. A delayed shipment', 'B. A broken device', 'C. A missing document', 'D. A scheduling conflict'],
|
||||
correctAnswer: 0,
|
||||
explanation: '"The delivery has been delayed by two days" — vấn đề được đề cập là lô hàng bị trễ.',
|
||||
},
|
||||
{
|
||||
id: 'q6', part: 2,
|
||||
text: 'How does the woman respond to the proposal?',
|
||||
options: ['A. She accepts it', 'B. She rejects it', 'C. She needs more time', 'D. She suggests modifications'],
|
||||
correctAnswer: 3,
|
||||
explanation: '"That sounds good, but maybe we could adjust the timeline a bit" — đề xuất điều chỉnh, không chấp nhận hoàn toàn.',
|
||||
},
|
||||
{
|
||||
id: 'q7', part: 2,
|
||||
text: 'What is the purpose of the announcement?',
|
||||
options: ['A. To introduce new products', 'B. To notify schedule changes', 'C. To welcome new employees', 'D. To announce a promotion'],
|
||||
correctAnswer: 1,
|
||||
explanation: 'Thông báo nói về việc thay đổi giờ làm việc từ tuần tới — mục đích là thông báo thay đổi lịch.',
|
||||
},
|
||||
{
|
||||
id: 'q8', part: 2,
|
||||
text: 'What does the woman ask the man to do?',
|
||||
options: ['A. Prepare a presentation', 'B. Contact the client', 'C. Review the contract', 'D. Attend a training session'],
|
||||
correctAnswer: 2,
|
||||
explanation: '"Could you go over the contract before we sign?" — người phụ nữ yêu cầu xem lại hợp đồng.',
|
||||
},
|
||||
{
|
||||
id: 'q9', part: 2,
|
||||
text: 'When will the project be completed?',
|
||||
options: ['A. By the end of this week', 'B. Next Monday', 'C. In two weeks', 'D. Next month'],
|
||||
correctAnswer: 0,
|
||||
explanation: '"We should be finished by Friday" — dự án sẽ hoàn thành vào cuối tuần này.',
|
||||
},
|
||||
{
|
||||
id: 'q10', part: 2,
|
||||
text: 'What is being discussed at the meeting?',
|
||||
options: ['A. Budget allocations', 'B. Marketing strategies', 'C. Product launches', 'D. Staff promotions'],
|
||||
correctAnswer: 1,
|
||||
explanation: 'Cuộc họp tập trung vào "the new advertising campaign and social media approach" — chiến lược marketing.',
|
||||
},
|
||||
]
|
||||
|
||||
export const VOCAB_DATA: Record<string, VocabWord[]> = {
|
||||
'Tất cả': [
|
||||
{ id: 'v1', word: 'negotiate', phonetic: '/nɪˈɡoʊʃieɪt/', meaningVi: 'đàm phán', topic: 'Business', example: 'We need to negotiate the contract terms before signing.' },
|
||||
{ id: 'v2', word: 'collaborate', phonetic: '/kəˈlæbəreɪt/', meaningVi: 'hợp tác', topic: 'Business', example: 'Teams need to collaborate effectively to meet the deadline.' },
|
||||
{ id: 'v3', word: 'agenda', phonetic: '/əˈdʒendə/', meaningVi: 'chương trình nghị sự', topic: 'Office', example: 'The agenda has been sent to all meeting participants.' },
|
||||
{ id: 'v4', word: 'itinerary', phonetic: '/aɪˈtɪnəreri/', meaningVi: 'lịch trình chuyến đi', topic: 'Travel', example: 'Here is your travel itinerary for the business conference.' },
|
||||
{ id: 'v5', word: 'reimburse', phonetic: '/ˌriːɪmˈbɜːrs/', meaningVi: 'hoàn tiền', topic: 'Finance', example: 'The company will reimburse your travel expenses.' },
|
||||
{ id: 'v6', word: 'recruit', phonetic: '/rɪˈkruːt/', meaningVi: 'tuyển dụng', topic: 'HR', example: 'We are actively recruiting experienced engineers.' },
|
||||
{ id: 'v7', word: 'campaign', phonetic: '/kæmˈpeɪn/', meaningVi: 'chiến dịch', topic: 'Marketing', example: 'The marketing campaign was very successful this quarter.' },
|
||||
{ id: 'v8', word: 'implement', phonetic: '/ˈɪmplɪment/', meaningVi: 'triển khai, thực hiện', topic: 'Business', example: 'We plan to implement the new strategy next quarter.' },
|
||||
],
|
||||
'Business': [
|
||||
{ id: 'b1', word: 'negotiate', phonetic: '/nɪˈɡoʊʃieɪt/', meaningVi: 'đàm phán', topic: 'Business', example: 'We need to negotiate the contract terms.' },
|
||||
{ id: 'b2', word: 'collaborate', phonetic: '/kəˈlæbəreɪt/', meaningVi: 'hợp tác', topic: 'Business', example: 'Teams collaborate to achieve shared goals.' },
|
||||
{ id: 'b3', word: 'delegate', phonetic: '/ˈdelɪɡeɪt/', meaningVi: 'uỷ quyền, phân công', topic: 'Business', example: 'A good manager knows how to delegate tasks.' },
|
||||
{ id: 'b4', word: 'implement', phonetic: '/ˈɪmplɪment/', meaningVi: 'triển khai, thực hiện', topic: 'Business', example: 'We will implement the new policy next month.' },
|
||||
{ id: 'b5', word: 'merger', phonetic: '/ˈmɜːrdʒər/', meaningVi: 'sáp nhập công ty', topic: 'Business', example: 'The merger will create a stronger combined company.' },
|
||||
{ id: 'b6', word: 'acquisition', phonetic: '/ˌækwɪˈzɪʃən/', meaningVi: 'mua lại, thâu tóm', topic: 'Business', example: 'The acquisition was completed ahead of schedule.' },
|
||||
],
|
||||
'Office': [
|
||||
{ id: 'o1', word: 'agenda', phonetic: '/əˈdʒendə/', meaningVi: 'chương trình nghị sự', topic: 'Office', example: 'Please review the agenda before the meeting.' },
|
||||
{ id: 'o2', word: 'minutes', phonetic: '/ˈmɪnɪts/', meaningVi: 'biên bản họp', topic: 'Office', example: 'Could you take the meeting minutes today?' },
|
||||
{ id: 'o3', word: 'submit', phonetic: '/səbˈmɪt/', meaningVi: 'nộp, gửi đi', topic: 'Office', example: 'Please submit your report by Friday afternoon.' },
|
||||
{ id: 'o4', word: 'deadline', phonetic: '/ˈdedlaɪn/', meaningVi: 'hạn chót', topic: 'Office', example: 'The deadline for this project is end of month.' },
|
||||
{ id: 'o5', word: 'cubicle', phonetic: '/ˈkjuːbɪkəl/', meaningVi: 'góc làm việc riêng', topic: 'Office', example: 'Each employee has their own cubicle in the open office.' },
|
||||
],
|
||||
'Travel': [
|
||||
{ id: 't1', word: 'itinerary', phonetic: '/aɪˈtɪnəreri/', meaningVi: 'lịch trình chuyến đi', topic: 'Travel', example: 'Here is your detailed travel itinerary.' },
|
||||
{ id: 't2', word: 'boarding pass', phonetic: '/ˈbɔːrdɪŋ pæs/', meaningVi: 'thẻ lên máy bay', topic: 'Travel', example: 'Please have your boarding pass ready at the gate.' },
|
||||
{ id: 't3', word: 'layover', phonetic: '/ˈleɪoʊvər/', meaningVi: 'thời gian quá cảnh', topic: 'Travel', example: 'There is a two-hour layover in Singapore.' },
|
||||
{ id: 't4', word: 'customs', phonetic: '/ˈkʌstəmz/', meaningVi: 'hải quan', topic: 'Travel', example: 'All passengers must go through customs on arrival.' },
|
||||
{ id: 't5', word: 'baggage claim', phonetic: '/ˈbæɡɪdʒ kleɪm/', meaningVi: 'băng chuyền hành lý', topic: 'Travel', example: 'Meet us at the baggage claim after landing.' },
|
||||
],
|
||||
'Finance': [
|
||||
{ id: 'f1', word: 'reimburse', phonetic: '/ˌriːɪmˈbɜːrs/', meaningVi: 'hoàn tiền', topic: 'Finance', example: 'The company will reimburse all travel expenses.' },
|
||||
{ id: 'f2', word: 'invoice', phonetic: '/ˈɪnvɔɪs/', meaningVi: 'hoá đơn', topic: 'Finance', example: 'Please send the invoice to our accounting department.' },
|
||||
{ id: 'f3', word: 'budget', phonetic: '/ˈbʌdʒɪt/', meaningVi: 'ngân sách', topic: 'Finance', example: 'We need to stay within the approved budget.' },
|
||||
{ id: 'f4', word: 'revenue', phonetic: '/ˈrevɪnjuː/', meaningVi: 'doanh thu', topic: 'Finance', example: 'Revenue increased by 15% last quarter.' },
|
||||
{ id: 'f5', word: 'fiscal year', phonetic: '/ˈfɪskəl jɪər/', meaningVi: 'năm tài chính', topic: 'Finance', example: 'Our fiscal year ends on December 31st.' },
|
||||
],
|
||||
'HR': [
|
||||
{ id: 'h1', word: 'recruit', phonetic: '/rɪˈkruːt/', meaningVi: 'tuyển dụng', topic: 'HR', example: 'We are recruiting experienced software engineers.' },
|
||||
{ id: 'h2', word: 'probation', phonetic: '/proʊˈbeɪʃən/', meaningVi: 'thử việc', topic: 'HR', example: 'New employees have a 3-month probation period.' },
|
||||
{ id: 'h3', word: 'appraisal', phonetic: '/əˈpreɪzəl/', meaningVi: 'đánh giá nhân viên', topic: 'HR', example: 'Annual performance appraisals are held in December.' },
|
||||
{ id: 'h4', word: 'resignation', phonetic: '/ˌrezɪɡˈneɪʃən/', meaningVi: 'đơn từ chức', topic: 'HR', example: 'She submitted her resignation letter this morning.' },
|
||||
{ id: 'h5', word: 'onboarding', phonetic: '/ˈɒnbɔːrdɪŋ/', meaningVi: 'quy trình tiếp nhận nhân viên mới', topic: 'HR', example: 'The onboarding process takes about two weeks.' },
|
||||
],
|
||||
'Marketing': [
|
||||
{ id: 'm1', word: 'campaign', phonetic: '/kæmˈpeɪn/', meaningVi: 'chiến dịch', topic: 'Marketing', example: 'The marketing campaign exceeded all expectations.' },
|
||||
{ id: 'm2', word: 'demographics', phonetic: '/ˌdeməˈɡræfɪks/', meaningVi: 'nhân khẩu học', topic: 'Marketing', example: 'We need to understand our target demographics.' },
|
||||
{ id: 'm3', word: 'endorse', phonetic: '/ɪnˈdɔːrs/', meaningVi: 'chứng thực, bảo trợ', topic: 'Marketing', example: 'The product is endorsed by professional athletes.' },
|
||||
{ id: 'm4', word: 'branding', phonetic: '/ˈbrændɪŋ/', meaningVi: 'xây dựng thương hiệu', topic: 'Marketing', example: 'Consistent branding builds long-term customer trust.' },
|
||||
{ id: 'm5', word: 'conversion rate', phonetic: '/kənˈvɜːrʒən reɪt/', meaningVi: 'tỷ lệ chuyển đổi', topic: 'Marketing', example: 'Our conversion rate improved after the redesign.' },
|
||||
],
|
||||
}
|
||||
|
||||
export const MOCK_WRITING_FEEDBACK: WritingFeedback = {
|
||||
score: '6.5',
|
||||
grammar: [
|
||||
'"managers are concern" → nên dùng "concerned" (tính từ, không phải danh từ)',
|
||||
'Thiếu mạo từ "an" trước "efficient arrangement" ở câu cuối',
|
||||
'Câu "This change is expected to improve" — đúng nhưng hơi thụ động, có thể dùng active voice',
|
||||
],
|
||||
vocabulary: [
|
||||
'Tốt: "implement", "productivity", "collaboration", "arrangement"',
|
||||
'Gợi ý nâng cao: "enhance" thay "increase", "address" thay "help with"',
|
||||
'Nên thêm từ nối: "Nevertheless", "In addition", "As a result of this"',
|
||||
],
|
||||
structure: 'Bài viết có cấu trúc khá rõ ràng với mở đầu, thân bài và kết luận ngầm. Tuy nhiên cần phát triển thêm phần giải thích tác động và thêm ví dụ cụ thể để bài hoàn chỉnh hơn.',
|
||||
improvedVersion: 'The company has decided to implement a new remote work policy starting next month. All employees will be able to work from home for three days per week. This change is expected to enhance work-life balance and boost overall productivity. Nevertheless, some managers are concerned about communication challenges and team collaboration. To address these concerns, the HR department will organize training sessions to help teams adapt to this new arrangement effectively.',
|
||||
summary: 'Bài viết đạt mức Upper Intermediate (6.5) với ý tưởng rõ ràng. Cần sửa lỗi ngữ pháp cơ bản và bổ sung từ vựng phong phú hơn để đạt band 7.0+.',
|
||||
}
|
||||
5
src/hooks/use-auth.ts
Normal file
5
src/hooks/use-auth.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
|
||||
export const useAuth = () => useAuthStore()
|
||||
export const useUser = () => useAuthStore((s) => s.user)
|
||||
export const useIsAuthenticated = () => useAuthStore((s) => s.user !== null)
|
||||
@@ -1,18 +1,35 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
import type { Question } from "@/types"
|
||||
|
||||
const ANSWER_INDEX: Record<string, number> = { A: 0, B: 1, C: 2, D: 3 }
|
||||
|
||||
// Maps a Supabase row to the shared Question interface.
|
||||
// DB uses `content` + `answer` ('A'–'D'); interface uses `text` + `correctAnswer` (0–3).
|
||||
function rowToQuestion(row: Record<string, unknown>): Question {
|
||||
return {
|
||||
id: row.id as string,
|
||||
part: row.part as number,
|
||||
text: row.content as string,
|
||||
options: row.options as string[],
|
||||
correctAnswer: ANSWER_INDEX[(row.answer as string).toUpperCase()] ?? 0,
|
||||
explanation: (row.explanation as string) ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// Exported for imperative use (e.g. ToeicPractice click handler).
|
||||
// part=0 fetches all parts (Full Test).
|
||||
export async function fetchQuestions(part: number, limit = 10): Promise<Question[]> {
|
||||
let query = supabase.from('questions').select('*').limit(limit)
|
||||
if (part > 0) query = query.eq('part', part)
|
||||
const { data, error } = await query
|
||||
if (error) throw error
|
||||
return (data ?? []).map(rowToQuestion)
|
||||
}
|
||||
|
||||
export function useQuestions(part: number, limit = 10) {
|
||||
return useQuery({
|
||||
queryKey: ["questions", part, limit],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("questions")
|
||||
.select("*")
|
||||
.eq("part", part)
|
||||
.limit(limit)
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
enabled: false, // Enabled during feature implementation
|
||||
queryKey: ['questions', part, limit],
|
||||
queryFn: () => fetchQuestions(part, limit),
|
||||
})
|
||||
}
|
||||
|
||||
17
src/hooks/use-require-auth.ts
Normal file
17
src/hooks/use-require-auth.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||
|
||||
export function useRequireAuth() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const isLoading = useAuthStore((s) => s.isLoading)
|
||||
const openModal = useAuthModalStore((s) => s.open)
|
||||
|
||||
/** Returns true if authenticated. If guest, opens auth modal and returns false. */
|
||||
function requireAuth(): boolean {
|
||||
if (user) return true
|
||||
openModal('register')
|
||||
return false
|
||||
}
|
||||
|
||||
return { isAuthenticated: !!user, isLoading, requireAuth }
|
||||
}
|
||||
@@ -1,16 +1,32 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
import type { VocabWord, VocabTopic } from "@/types"
|
||||
|
||||
export function useVocab(topic?: string) {
|
||||
// Maps a Supabase row to VocabWord.
|
||||
// DB column `meaning_vi` → interface field `meaningVi`.
|
||||
function rowToVocabWord(row: Record<string, unknown>): VocabWord {
|
||||
return {
|
||||
id: row.id as string,
|
||||
word: row.word as string,
|
||||
phonetic: (row.phonetic as string) ?? '',
|
||||
meaningVi: row.meaning_vi as string,
|
||||
topic: row.topic as VocabTopic,
|
||||
example: (row.example as string) ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// Fetches ALL vocab; topic filtering is done in-component so we avoid
|
||||
// separate queries per topic and keep the cache simple.
|
||||
export function useVocab() {
|
||||
return useQuery({
|
||||
queryKey: ["vocab", topic],
|
||||
queryKey: ['vocab'],
|
||||
queryFn: async () => {
|
||||
let query = supabase.from("vocab").select("*")
|
||||
if (topic) query = query.eq("topic", topic.toLowerCase())
|
||||
const { data, error } = await query
|
||||
const { data, error } = await supabase
|
||||
.from('vocab')
|
||||
.select('*')
|
||||
.order('topic')
|
||||
if (error) throw error
|
||||
return data
|
||||
return (data ?? []).map(rowToVocabWord)
|
||||
},
|
||||
enabled: false, // Enabled during feature implementation
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,28 +1,89 @@
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
import { canUseWritingCheck, recordWritingCheckUsage } from "@/utils/rate-limiter"
|
||||
import { useAuthStore } from "@/store/auth-store"
|
||||
import { saveWritingSubmission, countTodayWritingSubmissions } from "@/lib/progress-service"
|
||||
import type { WritingFeedback } from "@/types"
|
||||
|
||||
interface WritingFeedback {
|
||||
score: string
|
||||
grammar: string[]
|
||||
vocabulary: string[]
|
||||
structure: string
|
||||
improved_version: string
|
||||
summary: string
|
||||
const AUTH_DAILY_LIMIT = 10
|
||||
const GUEST_DAILY_LIMIT = 3
|
||||
|
||||
const GLM_BASE_URL = "https://open.bigmodel.cn/api/paas/v4"
|
||||
const GLM_API_KEY = import.meta.env.VITE_GLM_API_KEY as string
|
||||
const GLM_MODEL = (import.meta.env.VITE_GLM_MODEL as string) || "GLM-4-32B-0414-128K"
|
||||
|
||||
// Keep system prompt concise — fewer tokens = more room for output.
|
||||
// improved_version omitted from schema to reduce output length; added back as optional.
|
||||
const SYSTEM_PROMPT = `You are an expert English writing teacher for TOEIC and IELTS.
|
||||
Respond ONLY with valid JSON, no markdown:
|
||||
{"score":"6.5","grammar":["issue + fix in Vietnamese"],"vocabulary":["observation in Vietnamese"],"structure":"2 sentences in Vietnamese","improved_version":"full improved text","summary":"2 sentences in Vietnamese"}`
|
||||
|
||||
async function callGlm(content: string): Promise<WritingFeedback> {
|
||||
const res = await fetch(`${GLM_BASE_URL}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${GLM_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: GLM_MODEL,
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: `Analyse:\n\n${content.slice(0, 1500)}` },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 2500,
|
||||
// Force JSON output mode (OpenAI-compatible, supported by GLM)
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error((err as { error?: { message?: string } }).error?.message ?? `GLM error ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json() as { choices: { message: { content: string } }[] }
|
||||
const raw = data.choices[0]?.message?.content ?? "{}"
|
||||
|
||||
// Strip markdown code fences defensively
|
||||
const cleaned = raw.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim()
|
||||
|
||||
try {
|
||||
return JSON.parse(cleaned) as WritingFeedback
|
||||
} catch {
|
||||
throw new Error("Phản hồi từ AI không hợp lệ. Vui lòng thử lại.")
|
||||
}
|
||||
}
|
||||
|
||||
export function useWritingCheck() {
|
||||
return useMutation({
|
||||
mutationFn: async (content: string): Promise<WritingFeedback> => {
|
||||
if (!canUseWritingCheck()) {
|
||||
throw new Error("Bạn đã dùng hết 3 lần kiểm tra hôm nay. Quay lại vào ngày mai!")
|
||||
const user = useAuthStore.getState().user
|
||||
|
||||
if (user) {
|
||||
// Server-side rate limit for authenticated users (10/day)
|
||||
const usedToday = await countTodayWritingSubmissions(user.id)
|
||||
if (usedToday >= AUTH_DAILY_LIMIT) {
|
||||
throw new Error(`Bạn đã dùng hết ${AUTH_DAILY_LIMIT} lần kiểm tra hôm nay. Quay lại vào ngày mai!`)
|
||||
}
|
||||
} else {
|
||||
// localStorage rate limit for guests (3/day)
|
||||
if (!canUseWritingCheck()) {
|
||||
throw new Error(`Bạn đã dùng hết ${GUEST_DAILY_LIMIT} lần kiểm tra hôm nay. Đăng ký để được 10 lần/ngày!`)
|
||||
}
|
||||
}
|
||||
const { data, error } = await supabase.functions.invoke("writing-check", {
|
||||
body: { content },
|
||||
})
|
||||
if (error) throw error
|
||||
recordWritingCheckUsage()
|
||||
return data as WritingFeedback
|
||||
|
||||
const feedback = await callGlm(content)
|
||||
|
||||
if (user) {
|
||||
// Save to DB (fire-and-forget)
|
||||
saveWritingSubmission(user.id, content, feedback)
|
||||
} else {
|
||||
// Persist guest usage in localStorage
|
||||
recordWritingCheckUsage()
|
||||
}
|
||||
|
||||
return feedback
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/geist";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--font-sans: 'Plus Jakarta Sans', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -55,8 +54,8 @@
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(54.6% 0.245 262.3); /* #2563EB */
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
@@ -120,11 +119,58 @@
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
@apply bg-slate-50 text-slate-800;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Flashcard 3D flip ── */
|
||||
.flashcard-scene {
|
||||
perspective: 1000px;
|
||||
}
|
||||
.flashcard-inner {
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.flashcard-inner.is-flipped {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
.flashcard-face {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.flashcard-back {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
/* ── Material Symbols ── */
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ── Page fade-in ── */
|
||||
@keyframes page-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.page-enter {
|
||||
animation: page-in 0.2s ease both;
|
||||
}
|
||||
|
||||
/* ── Timer urgent pulse ── */
|
||||
@keyframes timer-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
.timer-urgent {
|
||||
animation: timer-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
71
src/lib/progress-service.ts
Normal file
71
src/lib/progress-service.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { supabase } from '@/lib/supabase'
|
||||
|
||||
interface TestResultData {
|
||||
partId: number
|
||||
partName: string
|
||||
score: number
|
||||
total: number
|
||||
timeUsed: number
|
||||
answers: { questionId: string; selected: number | null; correct: boolean }[]
|
||||
}
|
||||
|
||||
/** Fire-and-forget: save test result. Failures are logged but don't block UI. */
|
||||
export async function saveTestResult(userId: string, data: TestResultData): Promise<void> {
|
||||
const { error } = await supabase.from('user_progress').insert({
|
||||
user_id: userId,
|
||||
type: 'test',
|
||||
data,
|
||||
})
|
||||
if (error) console.error('Failed to save test result:', error.message)
|
||||
}
|
||||
|
||||
/** Fire-and-forget: save writing submission with AI feedback. */
|
||||
export async function saveWritingSubmission(
|
||||
userId: string,
|
||||
content: string,
|
||||
feedback: object,
|
||||
): Promise<void> {
|
||||
const { error } = await supabase.from('writing_submissions').insert({
|
||||
user_id: userId,
|
||||
content,
|
||||
feedback,
|
||||
})
|
||||
if (error) console.error('Failed to save writing submission:', error.message)
|
||||
}
|
||||
|
||||
/** Count today's writing submissions for server-side rate limiting (authenticated users). */
|
||||
export async function countTodayWritingSubmissions(userId: string): Promise<number> {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const { count, error } = await supabase
|
||||
.from('writing_submissions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.gte('created_at', `${today}T00:00:00.000Z`)
|
||||
if (error) return 0
|
||||
return count ?? 0
|
||||
}
|
||||
|
||||
/** Fetch test history for a user (most recent first, max 20). */
|
||||
export async function fetchTestHistory(userId: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('user_progress')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('type', 'test')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20)
|
||||
if (error) throw error
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
/** Fetch writing history for a user (most recent first, max 20). */
|
||||
export async function fetchWritingHistory(userId: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('writing_submissions')
|
||||
.select('id, content, feedback, created_at')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20)
|
||||
if (error) throw error
|
||||
return data ?? []
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { createClient } from "@supabase/supabase-js"
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
|
||||
// Supports both key name conventions
|
||||
const supabaseAnonKey =
|
||||
import.meta.env.VITE_SUPABASE_ANON_KEY ||
|
||||
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
console.warn(
|
||||
"Supabase env vars missing. Set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY in .env",
|
||||
"Supabase env vars missing. Set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY (or VITE_SUPABASE_PUBLISHABLE_KEY) in .env",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,204 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useUser } from '@/hooks/use-auth'
|
||||
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
to: '/toeic',
|
||||
icon: 'assignment',
|
||||
iconBg: 'bg-blue-50',
|
||||
iconColor: 'text-blue-600',
|
||||
borderColor: 'border-l-blue-600',
|
||||
title: 'Luyện đề TOEIC',
|
||||
desc: 'Kho đề thi cập nhật theo cấu trúc mới nhất. Phân tích điểm yếu chi tiết từng Part.',
|
||||
cta: 'Bắt đầu ngay',
|
||||
ctaColor: 'text-blue-600',
|
||||
stat: '350+ câu hỏi',
|
||||
},
|
||||
{
|
||||
to: '/writing',
|
||||
icon: 'auto_fix_high',
|
||||
iconBg: 'bg-green-50',
|
||||
iconColor: 'text-green-600',
|
||||
borderColor: 'border-l-green-600',
|
||||
title: 'AI Chấm Writing',
|
||||
desc: 'Phản hồi tức thì về ngữ pháp, từ vựng, cấu trúc và bài viết mẫu từ AI.',
|
||||
cta: 'Thử ngay',
|
||||
ctaColor: 'text-green-600',
|
||||
stat: '3 lượt / ngày',
|
||||
},
|
||||
{
|
||||
to: '/vocab',
|
||||
icon: 'menu_book',
|
||||
iconBg: 'bg-amber-50',
|
||||
iconColor: 'text-amber-600',
|
||||
borderColor: 'border-l-amber-600',
|
||||
title: 'Từ vựng thông minh',
|
||||
desc: '720 từ TOEIC theo 6 chủ đề. Flashcard với hiệu ứng lật 3D.',
|
||||
cta: 'Khám phá',
|
||||
ctaColor: 'text-amber-600',
|
||||
stat: '720 từ vựng',
|
||||
},
|
||||
]
|
||||
|
||||
export function Home() {
|
||||
const user = useUser()
|
||||
const openModal = useAuthModalStore((s) => s.open)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-12">
|
||||
<h1 className="text-3xl font-bold">Luyện tiếng Anh TOEIC</h1>
|
||||
<p className="mt-3 text-lg text-gray-600">
|
||||
Luyện đề, kiểm tra writing, và học từ vựng TOEIC miễn phí
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="font-semibold text-lg">Luyện đề TOEIC</h2>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Luyện tập từng Part 1–7 với đề thật
|
||||
<div className="px-6 py-8 max-w-6xl mx-auto page-enter">
|
||||
{/* Hero */}
|
||||
<section className="flex flex-col lg:flex-row gap-10 items-center mb-12">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="inline-flex items-center gap-2 bg-blue-50 text-blue-600 text-xs font-bold px-3 py-1.5 rounded-full mb-5 uppercase tracking-wider">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>auto_awesome</span>
|
||||
AI-Powered Learning
|
||||
</div>
|
||||
<h1 className="text-4xl lg:text-5xl font-extrabold leading-tight text-slate-800 mb-4" style={{ letterSpacing: '-0.02em' }}>
|
||||
Luyện TOEIC<br />thông minh<br />
|
||||
<span className="text-blue-600 italic">cùng AI</span>
|
||||
</h1>
|
||||
<p className="text-slate-500 text-lg leading-relaxed mb-8 max-w-md">
|
||||
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.
|
||||
</p>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<Link
|
||||
to="/toeic"
|
||||
className="bg-blue-600 text-white px-8 py-3.5 rounded-xl font-bold text-sm hover:bg-blue-700 transition-colors shadow-lg shadow-blue-600/20"
|
||||
>
|
||||
Bắt đầu ngay
|
||||
</Link>
|
||||
<Link
|
||||
to="/writing"
|
||||
className="border border-slate-200 px-8 py-3.5 rounded-xl font-bold text-sm text-slate-500 hover:bg-white hover:border-blue-600 hover:text-blue-600 transition-all"
|
||||
>
|
||||
Thử AI Writing
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex gap-6 mt-8">
|
||||
<div>
|
||||
<div className="text-2xl font-extrabold text-blue-600">350+</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">Câu hỏi TOEIC</div>
|
||||
</div>
|
||||
<div className="w-px bg-slate-200" />
|
||||
<div>
|
||||
<div className="text-2xl font-extrabold text-green-600">720</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">Từ vựng</div>
|
||||
</div>
|
||||
<div className="w-px bg-slate-200" />
|
||||
<div>
|
||||
<div className="text-2xl font-extrabold text-amber-600">AI</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">Writing Checker</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="font-semibold text-lg">AI Writing Checker</h2>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Chấm điểm và sửa bài writing bằng AI
|
||||
</p>
|
||||
|
||||
{/* Preview card — hidden on mobile */}
|
||||
<div className="hidden lg:block flex-shrink-0 w-80">
|
||||
<div className="bg-white rounded-2xl p-6 shadow-xl border border-slate-100">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div className="font-bold text-base text-slate-800">Tiến độ tuần này</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">Bạn đang làm rất tốt!</div>
|
||||
</div>
|
||||
<div className="bg-green-50 text-green-600 text-xs font-bold px-2.5 py-1 rounded-lg">+12%</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-xs font-semibold mb-1.5">
|
||||
<span>Reading Score</span><span className="text-blue-600">420/495</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-slate-100">
|
||||
<div className="h-full bg-blue-600 rounded-full" style={{ width: '85%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-xs font-semibold mb-1.5">
|
||||
<span>Listening Score</span><span className="text-green-600">380/495</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-slate-100">
|
||||
<div className="h-full bg-green-600 rounded-full" style={{ width: '77%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||
<div className="bg-blue-50 rounded-xl p-3 border-l-4 border-blue-600">
|
||||
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 18 }}>local_fire_department</span>
|
||||
<div className="text-xl font-extrabold text-blue-600 mt-1">14</div>
|
||||
<div className="text-xs text-slate-400">Ngày Streak</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-xl p-3 border-l-4 border-green-600">
|
||||
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 18, fontVariationSettings: "'FILL' 1" }}>star</span>
|
||||
<div className="text-xl font-extrabold text-green-600 mt-1">1,250</div>
|
||||
<div className="text-xs text-slate-400">Điểm tích lũy</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-slate-100 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center flex-shrink-0">
|
||||
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 16 }}>psychology</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
<span className="font-semibold">AI gợi ý:</span> Ôn thêm Part 5 — Ngữ pháp
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="font-semibold text-lg">Từ vựng TOEIC</h2>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Flashcard 6 chủ đề: Business, Finance, HR...
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Feature cards */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-extrabold text-slate-800 mb-1.5">Tính năng nổi bật</h2>
|
||||
<p className="text-slate-500 mb-6">Hệ sinh thái học tập toàn diện được thiết kế để tối ưu hoá điểm số.</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{FEATURES.map((f) => (
|
||||
<Link
|
||||
key={f.to}
|
||||
to={f.to}
|
||||
className={`bg-white rounded-2xl p-6 border border-slate-200 border-l-4 ${f.borderColor} hover:-translate-y-1 hover:shadow-md transition-all duration-200`}
|
||||
>
|
||||
<div className={`w-12 h-12 ${f.iconBg} rounded-xl flex items-center justify-center mb-4`}>
|
||||
<span className={`material-symbols-outlined ${f.iconColor}`}>{f.icon}</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-base text-slate-800 mb-2">{f.title}</h3>
|
||||
<p className="text-slate-500 text-sm leading-relaxed mb-4">{f.desc}</p>
|
||||
<div className={`flex items-center gap-1.5 text-sm font-bold ${f.ctaColor}`}>
|
||||
{f.cta}
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>arrow_forward</span>
|
||||
</div>
|
||||
</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 className="relative z-10">
|
||||
<h3 className="text-2xl font-extrabold text-white mb-2">Sẵn sàng chinh phục 990 TOEIC?</h3>
|
||||
<p className="text-blue-100 mb-5">
|
||||
{user
|
||||
? `Chào ${user.name}! Tiếp tục luyện thi hôm nay.`
|
||||
: 'Đăng ký miễn phí để lưu tiến độ và luyện thi không giới hạn.'}
|
||||
</p>
|
||||
{user ? (
|
||||
<Link
|
||||
to="/toeic"
|
||||
className="inline-block bg-white text-blue-600 px-6 py-3 rounded-xl font-bold text-sm hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
Luyện thi ngay
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => openModal('register')}
|
||||
className="bg-white text-blue-600 px-6 py-3 rounded-xl font-bold text-sm hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
Đăng ký miễn phí
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
34
src/pages/Login.tsx
Normal file
34
src/pages/Login.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useUser } from '@/hooks/use-auth'
|
||||
import { LoginForm } from '@/components/auth/LoginForm'
|
||||
|
||||
export function LoginPage() {
|
||||
const user = useUser()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (user) navigate({ to: '/' })
|
||||
}, [user, navigate])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-slate-50">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center gap-2 mb-3">
|
||||
<span className="material-symbols-outlined text-blue-600 text-3xl">school</span>
|
||||
<span className="text-2xl font-bold text-slate-800">TOEIC Luyện thi</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-slate-700">Đăng nhập tài khoản</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||
<LoginForm
|
||||
onSuccess={() => navigate({ to: '/' })}
|
||||
onSwitchToRegister={() => navigate({ to: '/auth/register' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
src/pages/Register.tsx
Normal file
35
src/pages/Register.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useUser } from '@/hooks/use-auth'
|
||||
import { RegisterForm } from '@/components/auth/RegisterForm'
|
||||
|
||||
export function RegisterPage() {
|
||||
const user = useUser()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (user) navigate({ to: '/' })
|
||||
}, [user, navigate])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-slate-50">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center gap-2 mb-3">
|
||||
<span className="material-symbols-outlined text-blue-600 text-3xl">school</span>
|
||||
<span className="text-2xl font-bold text-slate-800">TOEIC Luyện thi</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-slate-700">Tạo tài khoản miễn phí</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Không cần xác nhận email — dùng ngay lập tức</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||
<RegisterForm
|
||||
onSuccess={() => navigate({ to: '/' })}
|
||||
onSwitchToLogin={() => navigate({ to: '/auth/login' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,223 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTestStore } from '@/store/test-store'
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import { saveTestResult } from '@/lib/progress-service'
|
||||
|
||||
function formatTime(s: number) {
|
||||
const m = Math.floor(s / 60)
|
||||
const sec = s % 60
|
||||
if (m === 0) return `${sec}s`
|
||||
return `${m}m ${sec}s`
|
||||
}
|
||||
|
||||
export function TestResult() {
|
||||
const navigate = useNavigate()
|
||||
const { partId, partName, questions, answers, timeUsed, reset } = useTestStore()
|
||||
const { isAuthenticated, isLoading } = useRequireAuth()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const savedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
if (!isAuthenticated) navigate({ to: '/toeic' })
|
||||
}, [isLoading, isAuthenticated, navigate])
|
||||
|
||||
// Save test result once when page mounts (fire-and-forget)
|
||||
useEffect(() => {
|
||||
if (!user || savedRef.current || questions.length === 0) return
|
||||
savedRef.current = true
|
||||
saveTestResult(user.id, {
|
||||
partId,
|
||||
partName,
|
||||
score: answers.filter((a, i) => a === questions[i]?.correctAnswer).length,
|
||||
total: questions.length,
|
||||
timeUsed,
|
||||
answers: questions.map((q, i) => ({
|
||||
questionId: q.id,
|
||||
selected: answers[i],
|
||||
correct: answers[i] === q.correctAnswer,
|
||||
})),
|
||||
})
|
||||
}, [user, questions, answers, partId, partName, timeUsed])
|
||||
|
||||
const correct = answers.filter((a, i) => a === questions[i]?.correctAnswer).length
|
||||
const wrong = answers.filter((a, i) => a !== null && a !== questions[i]?.correctAnswer).length
|
||||
const skipped = answers.filter((a) => a === null).length
|
||||
const total = questions.length
|
||||
const percent = total > 0 ? Math.round((correct / total) * 100) : 0
|
||||
|
||||
const circumference = 2 * Math.PI * 52
|
||||
const offset = circumference - (percent / 100) * circumference
|
||||
|
||||
function handleRetry() {
|
||||
navigate({ to: '/toeic/session' })
|
||||
}
|
||||
|
||||
function handleHome() {
|
||||
reset()
|
||||
navigate({ to: '/' })
|
||||
}
|
||||
|
||||
if (questions.length === 0) {
|
||||
return (
|
||||
<div className="px-6 py-8 max-w-6xl mx-auto text-center">
|
||||
<p className="text-slate-500 mb-4">Không có dữ liệu bài thi.</p>
|
||||
<button
|
||||
onClick={() => navigate({ to: '/toeic' })}
|
||||
className="bg-blue-600 text-white px-6 py-2.5 rounded-xl font-semibold text-sm hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Chọn Part để luyện thi
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Kết quả</h1>
|
||||
<p className="text-gray-500">Kết quả và đáp án — placeholder</p>
|
||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
||||
{/* Score header */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 mb-5">
|
||||
<div className="flex flex-col lg:flex-row items-center gap-6">
|
||||
{/* Circle */}
|
||||
<div className="flex-shrink-0 relative w-32 h-32">
|
||||
<svg className="w-full h-full -rotate-90" viewBox="0 0 120 120">
|
||||
<circle cx="60" cy="60" r="52" fill="none" stroke="#e2e8f0" strokeWidth="8" />
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke={percent >= 70 ? '#16a34a' : percent >= 50 ? '#2563eb' : '#dc2626'}
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
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">{correct}/{total}</span>
|
||||
<span className="text-xs text-slate-400 font-medium">điểm</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex-1 text-center lg:text-left">
|
||||
<div className="text-2xl font-extrabold text-slate-800 mb-1">
|
||||
{percent >= 80 ? 'Xuất sắc!' : percent >= 60 ? 'Hoàn thành!' : 'Cố gắng hơn nhé!'}
|
||||
</div>
|
||||
<div className="text-sm text-slate-400 mb-4">
|
||||
Part {partId} — {partName}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 justify-center lg:justify-start">
|
||||
<div className="bg-green-50 border border-green-100 rounded-xl px-4 py-2 text-center">
|
||||
<div className="text-xl font-extrabold text-green-600">{correct}</div>
|
||||
<div className="text-xs text-slate-400">Đúng</div>
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-100 rounded-xl px-4 py-2 text-center">
|
||||
<div className="text-xl font-extrabold text-red-600">{wrong}</div>
|
||||
<div className="text-xs text-slate-400">Sai</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl px-4 py-2 text-center">
|
||||
<div className="text-xl font-extrabold text-slate-500">{skipped}</div>
|
||||
<div className="text-xs text-slate-400">Bỏ qua</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-xl px-4 py-2 text-center">
|
||||
<div className="text-xl font-extrabold text-blue-600">{formatTime(timeUsed)}</div>
|
||||
<div className="text-xs text-slate-400">Thời gian</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex lg:flex-col gap-3 flex-shrink-0">
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
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"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>replay</span>
|
||||
Làm lại
|
||||
</button>
|
||||
<button
|
||||
onClick={handleHome}
|
||||
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"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>home</span>
|
||||
Về trang chủ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Answer review */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-6">
|
||||
<h2 className="text-base font-bold text-slate-800 mb-4">Xem lại đáp án</h2>
|
||||
<div className="space-y-4">
|
||||
{questions.map((q, i) => {
|
||||
const userAnswer = answers[i]
|
||||
const isCorrect = userAnswer === q.correctAnswer
|
||||
const isSkipped = userAnswer === null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={q.id}
|
||||
className={cn(
|
||||
'rounded-xl border p-4',
|
||||
isCorrect ? 'border-green-100 bg-green-50/50' : isSkipped ? 'border-slate-100 bg-slate-50/50' : 'border-red-100 bg-red-50/50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
'w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5',
|
||||
isCorrect ? 'bg-green-600 text-white' : isSkipped ? 'bg-slate-400 text-white' : 'bg-red-600 text-white',
|
||||
)}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 mb-2">{q.text}</p>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{q.options.map((opt, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className={cn(
|
||||
'text-xs px-2.5 py-1 rounded-lg font-medium',
|
||||
j === q.correctAnswer
|
||||
? 'bg-green-100 text-green-700 border border-green-200'
|
||||
: j === userAnswer && !isCorrect
|
||||
? 'bg-red-100 text-red-700 border border-red-200 line-through'
|
||||
: 'bg-slate-100 text-slate-500',
|
||||
)}
|
||||
>
|
||||
{['A', 'B', 'C', 'D'][j]}. {opt}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{q.explanation && (
|
||||
<p className="text-xs text-slate-500 bg-white rounded-lg px-3 py-2 border border-slate-100">
|
||||
<span className="font-semibold text-slate-600">Giải thích: </span>
|
||||
{q.explanation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex-shrink-0">
|
||||
{isCorrect ? (
|
||||
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 20 }}>check_circle</span>
|
||||
) : isSkipped ? (
|
||||
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 20 }}>remove_circle</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-red-500" style={{ fontSize: 20 }}>cancel</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,207 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTestStore } from '@/store/test-store'
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||
|
||||
const TOTAL_SECONDS = 600 // 10 minutes
|
||||
const ANSWER_LABELS = ['A', 'B', 'C', 'D']
|
||||
|
||||
function formatTime(s: number) {
|
||||
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function TestSession() {
|
||||
const navigate = useNavigate()
|
||||
const { partId, partName, questions, answers, setAnswer, submitExam } = useTestStore()
|
||||
const [currentQ, setCurrentQ] = useState(0)
|
||||
const [timeLeft, setTimeLeft] = useState(TOTAL_SECONDS)
|
||||
const { isAuthenticated, isLoading } = useRequireAuth()
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
submitExam(TOTAL_SECONDS - timeLeft)
|
||||
navigate({ to: '/toeic/result' })
|
||||
}, [submitExam, navigate, timeLeft])
|
||||
|
||||
// Countdown
|
||||
useEffect(() => {
|
||||
if (questions.length === 0) return
|
||||
const id = setInterval(() => {
|
||||
setTimeLeft((t) => {
|
||||
if (t <= 1) { clearInterval(id); handleSubmit(); return 0 }
|
||||
return t - 1
|
||||
})
|
||||
}, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [questions.length, handleSubmit])
|
||||
|
||||
// Redirect if no exam started or not authenticated (wait for auth init)
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
if (!isAuthenticated || questions.length === 0) navigate({ to: '/toeic' })
|
||||
}, [isLoading, isAuthenticated, questions.length, navigate])
|
||||
|
||||
if (questions.length === 0) return null
|
||||
|
||||
const question = questions[currentQ]
|
||||
const answeredCount = answers.filter((a) => a !== null).length
|
||||
const isUrgent = timeLeft < 60
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Làm bài</h1>
|
||||
<p className="text-gray-500">Trang làm bài — placeholder</p>
|
||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
||||
{/* Mobile progress bar */}
|
||||
<div className="lg:hidden mb-4">
|
||||
<div className="flex justify-between text-sm font-semibold mb-2">
|
||||
<span className="text-slate-700">Part {partId} — Câu {currentQ + 1}/{questions.length}</span>
|
||||
<span className={cn('font-bold tabular-nums', isUrgent ? 'text-red-600 timer-urgent' : 'text-blue-600')}>
|
||||
{formatTime(timeLeft)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-slate-200">
|
||||
<div
|
||||
className="h-full bg-blue-600 rounded-full transition-all"
|
||||
style={{ width: `${((currentQ + 1) / questions.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-5">
|
||||
{/* Left: Question */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 mb-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="bg-blue-600 text-white text-xs font-bold px-3 py-1 rounded-full">
|
||||
Câu {currentQ + 1}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">Part {partId} — {partName}</span>
|
||||
</div>
|
||||
<p className="text-base font-medium text-slate-800 leading-relaxed mb-6">
|
||||
{question.text}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{question.options.map((opt, i) => {
|
||||
const selected = answers[currentQ] === i
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setAnswer(currentQ, i)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 p-4 border-2 rounded-xl text-sm font-medium text-left transition-all',
|
||||
selected
|
||||
? 'border-blue-600 bg-blue-50 text-blue-700'
|
||||
: 'border-slate-200 hover:border-blue-300 hover:bg-blue-50/50 text-slate-700',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0',
|
||||
selected ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-500',
|
||||
)}
|
||||
>
|
||||
{ANSWER_LABELS[i]}
|
||||
</span>
|
||||
{opt}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setCurrentQ((q) => Math.max(0, q - 1))}
|
||||
disabled={currentQ === 0}
|
||||
className="flex items-center gap-2 px-5 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>chevron_left</span>
|
||||
Câu trước
|
||||
</button>
|
||||
<span className="text-xs text-slate-400 tabular-nums">{currentQ + 1} / {questions.length}</span>
|
||||
{currentQ < questions.length - 1 ? (
|
||||
<button
|
||||
onClick={() => setCurrentQ((q) => q + 1)}
|
||||
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"
|
||||
>
|
||||
Câu tiếp theo
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>chevron_right</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-red-600 text-white rounded-xl text-sm font-semibold hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Nộp bài
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>send</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel — desktop only */}
|
||||
<div className="hidden lg:flex flex-col gap-4 w-60 flex-shrink-0">
|
||||
{/* Timer */}
|
||||
<div className="bg-white rounded-2xl p-5 border border-slate-200 text-center">
|
||||
<div className="text-xs text-slate-400 font-medium mb-2">Thời gian còn lại</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-5xl font-extrabold tabular-nums mb-1',
|
||||
isUrgent ? 'text-red-600 timer-urgent' : 'text-blue-600',
|
||||
)}
|
||||
>
|
||||
{formatTime(timeLeft)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">phút : giây</div>
|
||||
</div>
|
||||
|
||||
{/* Question dots */}
|
||||
<div className="bg-white rounded-2xl p-5 border border-slate-200">
|
||||
<div className="text-xs text-slate-400 font-medium mb-3">
|
||||
Danh sách câu · {answeredCount}/{questions.length} đã trả lời
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{questions.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrentQ(i)}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-[11px] font-semibold transition-all',
|
||||
i === currentQ
|
||||
? 'border-2 border-blue-600 text-blue-600 shadow-sm shadow-blue-600/20'
|
||||
: answers[i] !== null
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'border-2 border-slate-200 text-slate-400 hover:border-blue-300',
|
||||
)}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-4 text-xs text-slate-400">
|
||||
<span className="w-4 h-4 rounded-full bg-blue-600 inline-block" /> Đã trả lời
|
||||
<span className="w-4 h-4 rounded-full border-2 border-slate-200 inline-block" /> Chưa làm
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="w-full py-3 bg-red-600 text-white rounded-xl font-bold text-sm hover:bg-red-700 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>send</span>
|
||||
Nộp bài
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile submit */}
|
||||
<div className="lg:hidden mt-4">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="w-full py-3.5 bg-red-600 text-white rounded-xl font-bold text-sm hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Nộp bài ngay
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,108 @@
|
||||
const PARTS = [
|
||||
{ id: 1, name: "Part 1", desc: "Photographs" },
|
||||
{ id: 2, name: "Part 2", desc: "Question-Response" },
|
||||
{ id: 3, name: "Part 3", desc: "Conversations" },
|
||||
{ id: 4, name: "Part 4", desc: "Short Talks" },
|
||||
{ id: 5, name: "Part 5", desc: "Incomplete Sentences" },
|
||||
{ id: 6, name: "Part 6", desc: "Text Completion" },
|
||||
{ id: 7, name: "Part 7", desc: "Reading Comprehension" },
|
||||
]
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { CircularProgress } from '@/components/CircularProgress'
|
||||
import { useTestStore } from '@/store/test-store'
|
||||
import { TOEIC_PARTS } from '@/data/mock-data'
|
||||
import { fetchQuestions } from '@/hooks/use-questions'
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||
|
||||
export function ToeicPractice() {
|
||||
const navigate = useNavigate()
|
||||
const { startExam } = useTestStore()
|
||||
const [loadingPartId, setLoadingPartId] = useState<number | null>(null)
|
||||
const { requireAuth } = useRequireAuth()
|
||||
|
||||
async function handleSelectPart(partId: number, partName: string) {
|
||||
if (!requireAuth()) return
|
||||
setLoadingPartId(partId)
|
||||
try {
|
||||
const questions = await fetchQuestions(partId, 10)
|
||||
startExam(partId, partName, questions)
|
||||
navigate({ to: '/toeic/session' })
|
||||
} catch (err) {
|
||||
console.error('Failed to load questions:', err)
|
||||
} finally {
|
||||
setLoadingPartId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Luyện đề TOEIC</h1>
|
||||
<p className="text-gray-600">Chọn Part để bắt đầu luyện tập</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{PARTS.map((part) => (
|
||||
<div key={part.id} className="rounded-lg border p-4 cursor-pointer hover:bg-gray-50">
|
||||
<div className="font-semibold">{part.name}</div>
|
||||
<div className="text-sm text-gray-500">{part.desc}</div>
|
||||
</div>
|
||||
<div className="px-6 py-8 max-w-6xl mx-auto page-enter">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-extrabold text-slate-800 mb-2">Chọn Part TOEIC</h1>
|
||||
<p className="text-slate-500">
|
||||
Hệ thống ôn luyện theo cấu trúc bài thi TOEIC thực tế. Chọn phần cụ thể để bắt đầu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
{TOEIC_PARTS.map((part) => (
|
||||
<button
|
||||
key={part.id}
|
||||
onClick={() => handleSelectPart(part.id, part.nameVi)}
|
||||
disabled={loadingPartId !== null}
|
||||
className="bg-white rounded-2xl p-5 border border-slate-200 text-left hover:-translate-y-1 hover:shadow-md transition-all duration-200 group disabled:opacity-70 disabled:cursor-wait"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-5">
|
||||
<div className="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center group-hover:bg-blue-600 transition-colors">
|
||||
{loadingPartId === part.id ? (
|
||||
<span className="w-4 h-4 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin" />
|
||||
) : (
|
||||
<span
|
||||
className="material-symbols-outlined text-blue-600 group-hover:text-white transition-colors"
|
||||
style={{ fontSize: 18 }}
|
||||
>
|
||||
{part.icon}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<CircularProgress percent={part.progressPercent} size={44} />
|
||||
</div>
|
||||
<div className="font-extrabold text-lg text-slate-800 mb-0.5">{part.name}</div>
|
||||
<div className="text-sm font-semibold text-slate-700 mb-2">{part.nameVi}</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>list_alt</span>
|
||||
{part.questionCount} câu hỏi
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Full Test card */}
|
||||
<button
|
||||
onClick={() => handleSelectPart(0, 'Full Test')}
|
||||
className="relative rounded-2xl p-5 text-left overflow-hidden hover:-translate-y-1 hover:shadow-xl transition-all duration-200"
|
||||
style={{ background: 'linear-gradient(135deg, #f59e0b, #d97706)' }}
|
||||
>
|
||||
<div className="absolute top-0 right-0 opacity-10">
|
||||
<span className="material-symbols-outlined text-white" style={{ fontSize: 80 }}>
|
||||
workspace_premium
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center mb-5">
|
||||
<span className="material-symbols-outlined text-white" style={{ fontSize: 18 }}>
|
||||
military_tech
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-extrabold text-2xl text-white mb-0.5">Full Test</div>
|
||||
<div className="text-sm font-semibold text-amber-50 mb-2">Mô phỏng thi thật 2h</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-amber-100">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>timer</span>
|
||||
120 phút · 200 câu
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tip */}
|
||||
<div className="mt-8 bg-blue-50 border border-blue-100 rounded-2xl p-5 flex items-start gap-4">
|
||||
<span className="material-symbols-outlined text-blue-600 flex-shrink-0 mt-0.5">tips_and_updates</span>
|
||||
<div>
|
||||
<div className="font-semibold text-blue-700 text-sm mb-1">Mẹo luyện thi</div>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Bắt đầu từ <strong>Part 5 (Điền từ)</strong> — phần mang lại điểm nhanh nhất vì không phụ thuộc kỹ năng nghe. Mỗi ngày 20 câu, sau 2 tuần bạn sẽ thấy cải thiện rõ rệt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,16 +1,250 @@
|
||||
const TOPICS = ["Business", "Office", "Travel", "Finance", "HR", "Marketing"]
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FlashCard } from '@/components/FlashCard'
|
||||
import { useVocabStore } from '@/store/vocab-store'
|
||||
import { useVocab } from '@/hooks/use-vocab'
|
||||
import { VOCAB_TOPICS } from '@/types'
|
||||
import type { VocabTopic, VocabWord } from '@/types'
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||
|
||||
const GUEST_CARD_LIMIT = 5
|
||||
|
||||
export function Vocabulary() {
|
||||
const { currentTopic, currentIndex, knownWords, setTopic, setCurrentIndex, markKnown, markNeedReview } = useVocabStore()
|
||||
const [isFlipped, setIsFlipped] = useState(false)
|
||||
const { isAuthenticated, requireAuth } = useRequireAuth()
|
||||
|
||||
const { data: allVocab = [], isLoading, isError } = useVocab()
|
||||
const filtered: VocabWord[] = currentTopic === 'Tất cả'
|
||||
? allVocab
|
||||
: allVocab.filter((w) => w.topic === currentTopic)
|
||||
const safeIndex = Math.min(currentIndex, Math.max(0, filtered.length - 1))
|
||||
const word = filtered[safeIndex]
|
||||
const knownInFiltered = filtered.filter((w) => knownWords.includes(w.id)).length
|
||||
|
||||
function handleSetTopic(topic: VocabTopic) {
|
||||
setTopic(topic)
|
||||
setCurrentIndex(0)
|
||||
setIsFlipped(false)
|
||||
}
|
||||
|
||||
function handlePrev() {
|
||||
if (safeIndex > 0) {
|
||||
setCurrentIndex(safeIndex - 1)
|
||||
setIsFlipped(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (safeIndex < filtered.length - 1) {
|
||||
if (!isAuthenticated && safeIndex >= GUEST_CARD_LIMIT - 1) {
|
||||
requireAuth()
|
||||
return
|
||||
}
|
||||
setCurrentIndex(safeIndex + 1)
|
||||
setIsFlipped(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMarkKnown() {
|
||||
if (!isAuthenticated && safeIndex >= GUEST_CARD_LIMIT - 1) {
|
||||
requireAuth()
|
||||
return
|
||||
}
|
||||
if (word) markKnown(word.id)
|
||||
handleNext()
|
||||
}
|
||||
|
||||
function handleMarkReview() {
|
||||
if (!isAuthenticated && safeIndex >= GUEST_CARD_LIMIT - 1) {
|
||||
requireAuth()
|
||||
return
|
||||
}
|
||||
if (word) markNeedReview(word.id)
|
||||
handleNext()
|
||||
}
|
||||
|
||||
const recentKnown = knownWords
|
||||
.map((id) => allVocab.find((w) => w.id === id))
|
||||
.filter((w): w is VocabWord => w !== undefined)
|
||||
.slice(-5)
|
||||
.reverse()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Từ vựng TOEIC</h1>
|
||||
<p className="text-gray-600">Chọn chủ đề để học flashcard</p>
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3">
|
||||
{TOPICS.map((topic) => (
|
||||
<div key={topic} className="rounded-lg border p-4 text-center cursor-pointer hover:bg-gray-50">
|
||||
<span className="font-medium">{topic}</span>
|
||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
||||
{/* Mobile topic chips */}
|
||||
<div className="lg:hidden mb-4 overflow-x-auto pb-1">
|
||||
<div className="flex gap-2 w-max">
|
||||
{VOCAB_TOPICS.map((topic) => (
|
||||
<button
|
||||
key={topic}
|
||||
onClick={() => handleSetTopic(topic)}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-full text-sm font-semibold whitespace-nowrap transition-colors',
|
||||
currentTopic === topic
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white border border-slate-200 text-slate-600 hover:border-blue-300',
|
||||
)}
|
||||
>
|
||||
{topic}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-5">
|
||||
{/* Left: Topic menu — desktop only */}
|
||||
<div className="hidden lg:block w-44 flex-shrink-0">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-3 sticky top-20">
|
||||
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wider px-2 mb-2">Chủ đề</div>
|
||||
{VOCAB_TOPICS.map((topic) => (
|
||||
<button
|
||||
key={topic}
|
||||
onClick={() => handleSetTopic(topic)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-colors',
|
||||
currentTopic === topic
|
||||
? 'bg-blue-50 text-blue-600 font-semibold'
|
||||
: 'text-slate-600 hover:bg-slate-50',
|
||||
)}
|
||||
>
|
||||
{topic}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Center: Flashcard */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{isLoading ? (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-10 flex flex-col items-center justify-center gap-3">
|
||||
<div className="w-8 h-8 border-2 border-blue-100 border-t-blue-600 rounded-full animate-spin" />
|
||||
<p className="text-sm text-slate-400">Đang tải từ vựng...</p>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="bg-red-50 rounded-2xl border border-red-100 p-10 text-center">
|
||||
<p className="text-sm text-red-500">Không thể tải từ vựng. Vui lòng thử lại.</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-10 text-center">
|
||||
<p className="text-slate-400">Không có từ vựng cho chủ đề này.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Progress */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs text-slate-400 font-medium">
|
||||
{safeIndex + 1} / {filtered.length} từ
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-blue-600">
|
||||
{knownInFiltered}/{filtered.length} đã thuộc
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-slate-200 mb-4">
|
||||
<div
|
||||
className="h-full bg-blue-600 rounded-full transition-all duration-300"
|
||||
style={{ width: `${filtered.length > 0 ? ((safeIndex + 1) / filtered.length) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{word && (
|
||||
<FlashCard
|
||||
word={word.word}
|
||||
phonetic={word.phonetic}
|
||||
meaningVi={word.meaningVi}
|
||||
example={word.example}
|
||||
topicBadge={word.topic}
|
||||
isFlipped={isFlipped}
|
||||
onFlip={() => setIsFlipped((v) => !v)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
disabled={safeIndex === 0}
|
||||
className="flex items-center gap-1.5 px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>chevron_left</span>
|
||||
Trước
|
||||
</button>
|
||||
|
||||
{/* Mark buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleMarkReview}
|
||||
className="flex items-center gap-1.5 px-4 py-2.5 border border-amber-200 bg-amber-50 text-amber-700 rounded-xl text-sm font-semibold hover:bg-amber-100 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>replay</span>
|
||||
<span className="hidden sm:inline">Cần ôn</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMarkKnown}
|
||||
className="flex items-center gap-1.5 px-4 py-2.5 border border-green-200 bg-green-50 text-green-700 rounded-xl text-sm font-semibold hover:bg-green-100 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>check</span>
|
||||
<span className="hidden sm:inline">Đã thuộc</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={safeIndex === filtered.length - 1}
|
||||
className="flex items-center gap-1.5 px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Tiếp
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Stats — desktop only */}
|
||||
<div className="hidden lg:flex flex-col gap-4 w-52 flex-shrink-0">
|
||||
{/* Today stats */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wider mb-3">Thống kê</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500">Đã xem</span>
|
||||
<span className="text-sm font-bold text-slate-800">{safeIndex + 1}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500">Đã thuộc</span>
|
||||
<span className="text-sm font-bold text-green-600">{knownWords.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500">Tổng từ</span>
|
||||
<span className="text-sm font-bold text-blue-600">{allVocab.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-slate-100">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-amber-500" style={{ fontSize: 16 }}>local_fire_department</span>
|
||||
<span className="text-xs text-slate-500">Streak hôm nay</span>
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-amber-500 mt-1">{Math.min(safeIndex + 1, 99)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recently known */}
|
||||
{recentKnown.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wider mb-3">Vừa thuộc</div>
|
||||
<div className="space-y-2">
|
||||
{recentKnown.map((w) => w && (
|
||||
<div key={w.id} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400 flex-shrink-0" />
|
||||
<span className="text-sm font-semibold text-slate-700 truncate">{w.word}</span>
|
||||
<span className="text-xs text-slate-400 truncate ml-auto">{w.meaningVi}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,16 +1,232 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useWritingCheck } from '@/hooks/use-writing-check'
|
||||
import { getRemainingChecks } from '@/utils/rate-limiter'
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import { countTodayWritingSubmissions } from '@/lib/progress-service'
|
||||
|
||||
const MAX_CHARS = 1000
|
||||
const GUEST_LIMIT = 3
|
||||
const AUTH_LIMIT = 10
|
||||
|
||||
export function WritingChecker() {
|
||||
const [text, setText] = useState('')
|
||||
const [improvedExpanded, setImprovedExpanded] = useState(false)
|
||||
const [remaining, setRemaining] = useState(getRemainingChecks)
|
||||
|
||||
const { mutate: checkWriting, isPending, isError, error, data: feedback } = useWritingCheck()
|
||||
const { requireAuth } = useRequireAuth()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
|
||||
const dailyLimit = user ? AUTH_LIMIT : GUEST_LIMIT
|
||||
|
||||
// Fetch server-side remaining count for authenticated users
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setRemaining(getRemainingChecks())
|
||||
return
|
||||
}
|
||||
countTodayWritingSubmissions(user.id).then((used) => {
|
||||
setRemaining(AUTH_LIMIT - used)
|
||||
})
|
||||
}, [user])
|
||||
|
||||
const charCount = text.length
|
||||
const canSubmit = text.trim().length > 0 && remaining > 0 && charCount <= MAX_CHARS && !isPending
|
||||
|
||||
function handleSubmit() {
|
||||
if (!requireAuth()) return
|
||||
if (!canSubmit) return
|
||||
checkWriting(text, {
|
||||
onSuccess: () => {
|
||||
if (user) {
|
||||
countTodayWritingSubmissions(user.id).then((used) => setRemaining(AUTH_LIMIT - used))
|
||||
} else {
|
||||
setRemaining(getRemainingChecks())
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
if (!user) setRemaining(getRemainingChecks())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">AI Writing Checker</h1>
|
||||
<p className="text-gray-600">
|
||||
Nhập bài writing để nhận phản hồi từ AI
|
||||
</p>
|
||||
<textarea
|
||||
className="w-full h-48 rounded-lg border p-3 text-sm resize-none"
|
||||
placeholder="Nhập bài writing của bạn tại đây..."
|
||||
disabled
|
||||
/>
|
||||
<p className="text-sm text-gray-400">Tính năng đang được phát triển</p>
|
||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">AI Chấm Writing</h1>
|
||||
<p className="text-slate-500 text-sm">Nhận phản hồi tức thì về ngữ pháp, từ vựng và cấu trúc bài viết.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-5">
|
||||
{/* Left: Input */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-semibold text-slate-700">Bài writing của bạn</span>
|
||||
<span className={cn('text-xs tabular-nums', charCount > MAX_CHARS ? 'text-red-500 font-bold' : 'text-slate-400')}>
|
||||
{charCount}/{MAX_CHARS}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value.slice(0, MAX_CHARS))}
|
||||
rows={12}
|
||||
placeholder="Nhập bài writing của bạn vào đây... (TOEIC email, IELTS task, hoặc đoạn văn tự do)"
|
||||
className="w-full resize-none rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-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>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-bold transition-all',
|
||||
canSubmit
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/20'
|
||||
: 'bg-slate-100 text-slate-400 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
|
||||
Đang chấm...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>auto_fix_high</span>
|
||||
Chấm bài ngay
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{remaining <= 0 && (
|
||||
<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>
|
||||
<p className="text-sm text-amber-700">
|
||||
Bạn đã dùng hết {dailyLimit} lượt hôm nay. Vui lòng quay lại vào ngày mai.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="mt-3 bg-red-50 border border-red-100 rounded-xl p-4 flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-red-500 flex-shrink-0" style={{ fontSize: 20 }}>error</span>
|
||||
<p className="text-sm text-red-600">
|
||||
{(error as Error)?.message ?? 'Đã có lỗi xảy ra. Vui lòng thử lại.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Feedback */}
|
||||
<div className="lg:w-80 flex-shrink-0">
|
||||
{!feedback && !isPending && (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col items-center justify-center text-center h-full min-h-48">
|
||||
<span className="material-symbols-outlined text-slate-300 mb-3" style={{ fontSize: 48 }}>auto_fix_high</span>
|
||||
<p className="text-sm text-slate-400">Nhập bài và nhấn "Chấm bài ngay" để nhận phản hồi từ AI</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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="w-10 h-10 border-2 border-blue-100 border-t-blue-600 rounded-full animate-spin mb-4" />
|
||||
<p className="text-sm text-slate-500 font-medium">AI đang phân tích bài viết...</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Thường mất 3–5 giây</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedback && !isPending && (
|
||||
<div className="space-y-3">
|
||||
{/* Band score */}
|
||||
<div className="bg-blue-600 rounded-2xl p-5 text-center">
|
||||
<div className="text-xs text-blue-200 font-medium mb-1 uppercase tracking-wider">Band Score ước tính</div>
|
||||
<div className="text-5xl font-extrabold text-white mb-1">{feedback.score}</div>
|
||||
<div className="text-xs text-blue-200">Dựa trên tiêu chí IELTS/TOEIC Writing</div>
|
||||
</div>
|
||||
|
||||
{/* Grammar */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<span className="text-sm font-bold text-slate-800">Ngữ pháp</span>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{feedback.grammar.map((item, i) => (
|
||||
<li key={i} className="text-xs text-slate-600 flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-red-400 flex-shrink-0 mt-0.5" style={{ fontSize: 14 }}>error</span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Vocabulary */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
<span className="text-sm font-bold text-slate-800">Từ vựng</span>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{feedback.vocabulary.map((item, i) => (
|
||||
<li key={i} className="text-xs text-slate-600 flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-amber-400 flex-shrink-0 mt-0.5" style={{ fontSize: 14 }}>lightbulb</span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Structure */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="text-sm font-bold text-slate-800">Cấu trúc</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">{feedback.structure}</p>
|
||||
</div>
|
||||
|
||||
{/* Improved version */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||
<button
|
||||
onClick={() => setImprovedExpanded((v) => !v)}
|
||||
className="w-full flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="text-sm font-bold text-slate-800">Bài viết cải thiện</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 18 }}>
|
||||
{improvedExpanded ? 'expand_less' : 'expand_more'}
|
||||
</span>
|
||||
</button>
|
||||
{improvedExpanded && (
|
||||
<p className="mt-3 text-xs text-slate-600 leading-relaxed border-t border-slate-100 pt-3">
|
||||
{feedback.improvedVersion}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="bg-green-50 rounded-2xl border border-green-100 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 16 }}>summarize</span>
|
||||
<span className="text-sm font-bold text-green-700">Tổng nhận xét</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">{feedback.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { createRootRoute, Link, Outlet } from "@tanstack/react-router"
|
||||
import { createRootRoute, Outlet } from '@tanstack/react-router'
|
||||
import { useEffect } from 'react'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
import { MobileNav } from '@/components/MobileNav'
|
||||
import { AuthModal } from '@/components/auth/AuthModal'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
})
|
||||
|
||||
function RootLayout() {
|
||||
const initialize = useAuthStore((s) => s.initialize)
|
||||
|
||||
useEffect(() => {
|
||||
initialize()
|
||||
}, [initialize])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-gray-900">
|
||||
<header className="border-b px-4 py-3">
|
||||
<nav className="mx-auto max-w-5xl flex items-center gap-6">
|
||||
<Link to="/" className="text-lg font-bold">
|
||||
English App
|
||||
</Link>
|
||||
<Link to="/toeic" className="text-sm text-gray-600 hover:text-gray-900">
|
||||
Luyện đề
|
||||
</Link>
|
||||
<Link to="/writing" className="text-sm text-gray-600 hover:text-gray-900">
|
||||
Writing
|
||||
</Link>
|
||||
<Link to="/vocab" className="text-sm text-gray-600 hover:text-gray-900">
|
||||
Từ vựng
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="mx-auto max-w-5xl px-4 py-6">
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<Sidebar />
|
||||
<AppHeader />
|
||||
<main className="lg:ml-60 pt-16 pb-20 lg:pb-0 min-h-screen">
|
||||
<Outlet />
|
||||
</main>
|
||||
<MobileNav />
|
||||
<AuthModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
6
src/routes/auth.login.tsx
Normal file
6
src/routes/auth.login.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { LoginPage } from '@/pages/Login'
|
||||
|
||||
export const Route = createFileRoute('/auth/login')({
|
||||
component: LoginPage,
|
||||
})
|
||||
6
src/routes/auth.register.tsx
Normal file
6
src/routes/auth.register.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { RegisterPage } from '@/pages/Register'
|
||||
|
||||
export const Route = createFileRoute('/auth/register')({
|
||||
component: RegisterPage,
|
||||
})
|
||||
15
src/store/auth-modal-store.ts
Normal file
15
src/store/auth-modal-store.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface AuthModalState {
|
||||
isOpen: boolean
|
||||
mode: 'login' | 'register'
|
||||
open: (mode?: 'login' | 'register') => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export const useAuthModalStore = create<AuthModalState>((set) => ({
|
||||
isOpen: false,
|
||||
mode: 'register',
|
||||
open: (mode = 'register') => set({ isOpen: true, mode }),
|
||||
close: () => set({ isOpen: false }),
|
||||
}))
|
||||
71
src/store/auth-store.ts
Normal file
71
src/store/auth-store.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { create } from 'zustand'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import type { User } from '@/types'
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
isLoading: boolean
|
||||
login: (email: string, password: string) => Promise<void>
|
||||
register: (name: string, email: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
initialize: () => Promise<void>
|
||||
}
|
||||
|
||||
function sessionToUser(session: { user: { id: string; email?: string; user_metadata?: { name?: string } } } | null): User | null {
|
||||
if (!session) return null
|
||||
const { id, email, user_metadata } = session.user
|
||||
const name = user_metadata?.name ?? email?.split('@')[0] ?? 'Người dùng'
|
||||
return { id, email: email ?? '', name }
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
|
||||
initialize: async () => {
|
||||
set({ isLoading: true })
|
||||
|
||||
// Restore existing session (JWT in localStorage via Supabase SDK)
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
set({ user: sessionToUser(session), isLoading: false })
|
||||
|
||||
// Keep state in sync across tabs and token refresh
|
||||
supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||
set({ user: sessionToUser(newSession), isLoading: false })
|
||||
})
|
||||
},
|
||||
|
||||
login: async (email, password) => {
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password })
|
||||
if (error) throw new Error(mapAuthError(error.message))
|
||||
// onAuthStateChange fires and updates user state
|
||||
},
|
||||
|
||||
register: async (name, email, password) => {
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: { data: { name } },
|
||||
})
|
||||
if (error) throw new Error(mapAuthError(error.message))
|
||||
// onAuthStateChange fires and updates user state
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
await supabase.auth.signOut()
|
||||
// onAuthStateChange fires → user set to null
|
||||
},
|
||||
}))
|
||||
|
||||
function mapAuthError(msg: string): string {
|
||||
if (msg.includes('already registered') || msg.includes('already exists')) {
|
||||
return 'Email này đã được sử dụng. Vui lòng đăng nhập.'
|
||||
}
|
||||
if (msg.includes('Invalid login credentials') || msg.includes('invalid_credentials')) {
|
||||
return 'Sai email hoặc mật khẩu. Vui lòng kiểm tra lại.'
|
||||
}
|
||||
if (msg.includes('Email not confirmed')) {
|
||||
return 'Email chưa được xác nhận.'
|
||||
}
|
||||
return 'Đã có lỗi xảy ra. Vui lòng thử lại.'
|
||||
}
|
||||
@@ -1,27 +1,53 @@
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { Question } from '@/types'
|
||||
|
||||
interface TestState {
|
||||
// Shell — filled during TOEIC feature implementation
|
||||
currentPart: number | null
|
||||
answers: Record<string, string>
|
||||
setCurrentPart: (part: number | null) => void
|
||||
setAnswer: (questionId: string, answer: string) => void
|
||||
interface TestStore {
|
||||
partId: number
|
||||
partName: string
|
||||
questions: Question[]
|
||||
answers: (number | null)[]
|
||||
isSubmitted: boolean
|
||||
timeUsed: number // seconds elapsed when submitted
|
||||
|
||||
startExam: (partId: number, partName: string, questions: Question[]) => void
|
||||
setAnswer: (questionIndex: number, answerIndex: number) => void
|
||||
submitExam: (timeUsed: number) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useTestStore = create<TestState>()(
|
||||
export const useTestStore = create<TestStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
currentPart: null,
|
||||
answers: {},
|
||||
setCurrentPart: (part) => set({ currentPart: part }),
|
||||
setAnswer: (questionId, answer) =>
|
||||
set((state) => ({
|
||||
answers: { ...state.answers, [questionId]: answer },
|
||||
})),
|
||||
reset: () => set({ currentPart: null, answers: {} }),
|
||||
partId: 2,
|
||||
partName: '',
|
||||
questions: [],
|
||||
answers: [],
|
||||
isSubmitted: false,
|
||||
timeUsed: 0,
|
||||
|
||||
startExam: (partId, partName, questions) =>
|
||||
set({
|
||||
partId,
|
||||
partName,
|
||||
questions,
|
||||
answers: new Array(questions.length).fill(null),
|
||||
isSubmitted: false,
|
||||
timeUsed: 0,
|
||||
}),
|
||||
|
||||
setAnswer: (questionIndex, answerIndex) =>
|
||||
set((state) => {
|
||||
const answers = [...state.answers]
|
||||
answers[questionIndex] = answerIndex
|
||||
return { answers }
|
||||
}),
|
||||
|
||||
submitExam: (timeUsed) => set({ isSubmitted: true, timeUsed }),
|
||||
|
||||
reset: () =>
|
||||
set({ partId: 2, partName: '', questions: [], answers: [], isSubmitted: false, timeUsed: 0 }),
|
||||
}),
|
||||
{ name: "test-store" },
|
||||
{ name: 'test-store' },
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,28 +1,42 @@
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { VocabTopic } from '@/types'
|
||||
|
||||
interface VocabState {
|
||||
// Shell — filled during Vocab feature implementation
|
||||
knownWords: string[]
|
||||
interface VocabStore {
|
||||
currentTopic: VocabTopic
|
||||
currentIndex: number
|
||||
knownWords: string[] // word IDs
|
||||
|
||||
setTopic: (topic: VocabTopic) => void
|
||||
setCurrentIndex: (index: number) => void
|
||||
markKnown: (wordId: string) => void
|
||||
markUnknown: (wordId: string) => void
|
||||
markNeedReview: (wordId: string) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useVocabStore = create<VocabState>()(
|
||||
export const useVocabStore = create<VocabStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
currentTopic: 'Tất cả',
|
||||
currentIndex: 0,
|
||||
knownWords: [],
|
||||
|
||||
setTopic: (currentTopic) => set({ currentTopic, currentIndex: 0 }),
|
||||
|
||||
setCurrentIndex: (currentIndex) => set({ currentIndex }),
|
||||
|
||||
markKnown: (wordId) =>
|
||||
set((state) => ({
|
||||
knownWords: [...new Set([...state.knownWords, wordId])],
|
||||
})),
|
||||
markUnknown: (wordId) =>
|
||||
|
||||
markNeedReview: (wordId) =>
|
||||
set((state) => ({
|
||||
knownWords: state.knownWords.filter((id) => id !== wordId),
|
||||
})),
|
||||
reset: () => set({ knownWords: [] }),
|
||||
|
||||
reset: () => set({ currentTopic: 'Tất cả', currentIndex: 0, knownWords: [] }),
|
||||
}),
|
||||
{ name: "vocab-store" },
|
||||
{ name: 'vocab-store' },
|
||||
),
|
||||
)
|
||||
|
||||
60
src/types/index.ts
Normal file
60
src/types/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export interface Question {
|
||||
id: string
|
||||
part: number
|
||||
text: string
|
||||
options: string[]
|
||||
correctAnswer: number // 0-3
|
||||
explanation: string
|
||||
}
|
||||
|
||||
export interface VocabWord {
|
||||
id: string
|
||||
word: string
|
||||
phonetic: string
|
||||
meaningVi: string
|
||||
topic: VocabTopic
|
||||
example: string
|
||||
}
|
||||
|
||||
export type VocabTopic =
|
||||
| 'Tất cả'
|
||||
| 'Business'
|
||||
| 'Office'
|
||||
| 'Travel'
|
||||
| 'Finance'
|
||||
| 'HR'
|
||||
| 'Marketing'
|
||||
|
||||
export const VOCAB_TOPICS: VocabTopic[] = [
|
||||
'Tất cả',
|
||||
'Business',
|
||||
'Office',
|
||||
'Travel',
|
||||
'Finance',
|
||||
'HR',
|
||||
'Marketing',
|
||||
]
|
||||
|
||||
export interface WritingFeedback {
|
||||
score: string
|
||||
grammar: string[]
|
||||
vocabulary: string[]
|
||||
structure: string
|
||||
improvedVersion: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface ToeicPart {
|
||||
id: number
|
||||
name: string
|
||||
nameVi: string
|
||||
questionCount: number
|
||||
icon: string
|
||||
progressPercent: number
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
Reference in New Issue
Block a user