leader board + setting

This commit is contained in:
2026-04-12 22:59:46 +07:00
parent 857341132c
commit 8de8b88a3d
32 changed files with 2302 additions and 15 deletions

View File

@@ -3,9 +3,10 @@ import { cn } from '@/lib/utils'
const NAV_ITEMS = [
{ to: '/', label: 'Home', icon: 'home', matchPrefix: '/', exact: true },
{ to: '/dashboard', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/dashboard', exact: false },
{ to: '/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 },
{ to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false },
]
function isActive(pathname: string, prefix: string, exact: boolean) {

View File

@@ -5,9 +5,11 @@ import { useAuthModalStore } from '@/store/auth-modal-store'
const NAV_ITEMS = [
{ to: '/', label: 'Trang chủ', icon: 'home', matchPrefix: '/', exact: true },
{ to: '/dashboard', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/dashboard', exact: false },
{ to: '/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 },
{ to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false },
]
function isActive(pathname: string, prefix: string, exact: boolean) {

68
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,68 @@
import { Link } from '@tanstack/react-router'
import { useAuthStore } from '@/store/auth-store'
import { useAuthModalStore } from '@/store/auth-modal-store'
import { loadGamification } from './dashboard/gamification-store'
import { StatsRow } from './dashboard/StatsRow'
import { XpProgressCard } from './dashboard/XpProgressCard'
import { WeeklySection } from './dashboard/WeeklySection'
import { XuEconomyCard } from './dashboard/XuEconomyCard'
import { LeaderboardCard } from './dashboard/LeaderboardCard'
export function Dashboard() {
const user = useAuthStore((s) => s.user)
const openModal = useAuthModalStore((s) => s.open)
if (!user) {
return (
<div className="px-4 lg:px-6 py-12 max-w-6xl mx-auto flex flex-col items-center text-center gap-4">
<span className="material-symbols-outlined text-slate-300" style={{ fontSize: 64 }}>emoji_events</span>
<h1 className="text-xl font-bold text-slate-700">Bảng thành tích</h1>
<p className="text-slate-400 text-sm max-w-xs">
Đăng nhập đ xem streak, XP, Xu bảng xếp hạng của bạn.
</p>
<button
onClick={() => openModal('login')}
className="mt-2 px-6 py-2.5 bg-blue-600 text-white rounded-full font-bold text-sm hover:bg-blue-700 transition-colors"
>
Đăng nhập
</button>
</div>
)
}
const state = loadGamification()
return (
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto">
{/* Page header */}
<div className="mb-6">
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">Bảng thành tích</h1>
<p className="text-slate-400 text-sm">Xin chào, <span className="font-semibold text-slate-600">{user.name}</span> tiếp tục chuỗi học tập nhé!</p>
</div>
{/* Hero stats */}
<StatsRow state={state} />
{/* Progress section */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5 mb-5">
<XpProgressCard state={state} />
<WeeklySection state={state} />
</div>
{/* Xu economy + leaderboard */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5">
<XuEconomyCard />
<LeaderboardCard />
</div>
{/* FAB */}
<Link
to="/toeic"
className="fixed bottom-24 right-6 lg:bottom-8 lg:right-8 w-14 h-14 bg-blue-600 text-white rounded-2xl flex items-center justify-center shadow-2xl hover:scale-110 active:scale-95 transition-all z-40"
title="Học ngay"
>
<span className="material-symbols-outlined text-2xl">play_arrow</span>
</Link>
</div>
)
}

49
src/pages/Settings.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { useAuthStore } from '@/store/auth-store'
import { useAuthModalStore } from '@/store/auth-modal-store'
import { ProfileCard } from './settings/ProfileCard'
import { XuWalletCard } from './settings/XuWalletCard'
import { DailyGoalCard } from './settings/DailyGoalCard'
import { ExamDateCard } from './settings/ExamDateCard'
import { NotificationsCard } from './settings/NotificationsCard'
import { AccountCard } from './settings/AccountCard'
export function Settings() {
const user = useAuthStore((s) => s.user)
const openModal = useAuthModalStore((s) => s.open)
if (!user) {
return (
<div className="px-4 lg:px-6 py-12 max-w-6xl mx-auto flex flex-col items-center justify-center gap-4 text-center">
<span className="material-symbols-outlined text-slate-300" style={{ fontSize: 64 }}>settings</span>
<h1 className="text-xl font-bold text-slate-700">Cài đt</h1>
<p className="text-slate-400 text-sm max-w-xs">
Đăng nhập đ nhân hoá mục tiêu học tập, cài đt thông báo quản tài khoản.
</p>
<button
onClick={() => openModal('login')}
className="mt-2 px-6 py-2.5 bg-blue-600 text-white rounded-full font-bold text-sm hover:bg-blue-700 transition-colors"
>
Đăng nhập
</button>
</div>
)
}
return (
<div className="px-4 lg:px-6 py-6 max-w-5xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">Cài đt</h1>
<p className="text-slate-400 text-sm">Quản hồ , mục tiêu học tập thông báo.</p>
</div>
<div className="grid grid-cols-12 gap-5">
<ProfileCard />
<XuWalletCard />
<DailyGoalCard />
<ExamDateCard />
<NotificationsCard />
<AccountCard />
</div>
</div>
)
}

View File

@@ -0,0 +1,103 @@
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth-store'
// Phase 3: mock data until leaderboard DB table is live
const MOCK_LEADERS = [
{ rank: 1, name: 'Minh Anh', xp: 12450 },
{ rank: 2, name: 'Hoàng Nam', xp: 11200 },
{ rank: 3, name: 'Quỳnh Trang', xp: 10800 },
{ rank: 4, name: 'Đức Huy', xp: 9900 },
{ rank: 5, name: 'Phương Linh', xp: 9400 },
{ rank: 6, name: 'Trọng Khải', xp: 9100 },
]
const USER_RANK = { rank: 7, xp: 8950 }
function RankBadge({ rank }: { rank: number }) {
const gold = rank === 1
const silver = rank === 2
const bronze = rank === 3
return (
<div className={cn(
'w-8 h-8 flex items-center justify-center font-bold rounded-full text-xs',
gold ? 'bg-amber-200 text-amber-800' :
silver ? 'bg-slate-200 text-slate-700' :
bronze ? 'bg-orange-200 text-orange-700' :
'bg-slate-100 text-slate-600',
)}>
{rank}
</div>
)
}
function initials(name: string) {
return name.split(' ').map((w) => w[0]).slice(-2).join('').toUpperCase()
}
export function LeaderboardCard() {
const user = useAuthStore((s) => s.user)
const userName = user?.name ?? 'Bạn'
const allRows = [
...MOCK_LEADERS,
{ rank: USER_RANK.rank, name: userName, xp: USER_RANK.xp, isMe: true },
].sort((a, b) => a.rank - b.rank)
return (
<div className="lg:col-span-8 bg-white p-6 rounded-xl shadow-sm">
<div className="flex items-center justify-between mb-5">
<h3 className="text-base font-bold text-slate-800">Bảng xếp hạng tuần</h3>
<div className="flex gap-2">
<span className="px-3 py-1 bg-blue-600 text-white rounded-full text-xs font-bold">Top 100</span>
<span className="px-3 py-1 bg-slate-100 text-slate-500 rounded-full text-xs font-bold">Bạn </span>
</div>
</div>
<table className="w-full text-left border-separate border-spacing-y-1.5">
<thead>
<tr className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
<th className="pb-2 pl-4 w-16">Hạng</th>
<th className="pb-2">Người học</th>
<th className="pb-2 text-right pr-4">XP Tổng</th>
</tr>
</thead>
<tbody>
{allRows.map((row) => {
const isMe = 'isMe' in row && row.isMe
return (
<tr
key={row.rank}
className={cn(
'transition-colors',
isMe ? 'bg-blue-50 border-2 border-blue-200 rounded-xl' : 'bg-slate-50/60 hover:bg-slate-100',
)}
>
<td className="py-2.5 pl-4 rounded-l-xl">
<RankBadge rank={row.rank} />
</td>
<td className="py-2.5">
<div className="flex items-center gap-2.5">
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0',
isMe ? 'bg-blue-600 text-white ring-2 ring-blue-300 ring-offset-1' : 'bg-slate-200 text-slate-600',
)}>
{initials(row.name)}
</div>
<span className={cn('text-sm font-bold', isMe && 'text-blue-600')}>
{isMe ? `${row.name} (Bạn)` : row.name}
</span>
</div>
</td>
<td className="py-2.5 pr-4 text-right rounded-r-xl">
<span className={cn('text-sm font-bold', isMe ? 'text-blue-600' : 'text-slate-600')}>
{row.xp.toLocaleString('vi-VN')} XP
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

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

View File

@@ -0,0 +1,70 @@
import { cn } from '@/lib/utils'
import type { GamificationState } from './gamification-store'
interface Props { state: GamificationState }
const DAY_LABELS = ['Th 2', 'Th 3', 'Th 4', 'Th 5', 'Th 6', 'Th 7', 'CN']
function getTodayIdx() {
const d = new Date().getDay() // 0=Sun
return d === 0 ? 6 : d - 1 // Mon=0 … Sun=6
}
export function WeeklySection({ state }: Props) {
const todayIdx = getTodayIdx()
const progressPct = Math.round((state.weeklyCompleted / state.weeklyGoal) * 100)
return (
<div className="lg:col-span-7 space-y-5">
{/* Weekly goal */}
<div className="bg-white p-6 rounded-xl shadow-sm">
<div className="flex justify-between items-end mb-3">
<div>
<h3 className="text-base font-bold text-slate-800">Mục tiêu tuần</h3>
<p className="text-xs text-slate-400">Hoàn thành {state.weeklyGoal} bài học mỗi tuần</p>
</div>
<span className="text-2xl font-black text-green-600">
{state.weeklyCompleted}/{state.weeklyGoal}
</span>
</div>
<div className="w-full h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-green-400 rounded-full transition-all duration-500"
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
{/* Weekly heatmap */}
<div className="bg-white p-6 rounded-xl shadow-sm">
<h3 className="text-base font-bold text-slate-800 mb-5">Lịch sử rèn luyện</h3>
<div className="flex justify-between items-center">
{DAY_LABELS.map((label, i) => {
const isToday = i === todayIdx
const done = state.weekActivity[i]
const future = i > todayIdx
return (
<div key={label} className={cn('flex flex-col items-center gap-2.5', future && 'opacity-30')}>
<span className={cn('text-[10px] font-bold uppercase', isToday ? 'text-blue-600' : 'text-slate-400')}>
{isToday ? 'H.Nay' : label}
</span>
{isToday ? (
<div className="w-10 h-10 rounded-xl border-2 border-blue-600 border-dashed flex items-center justify-center">
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 18 }}>play_arrow</span>
</div>
) : done ? (
<div className="w-10 h-10 rounded-xl bg-green-200 flex items-center justify-center">
<span className="material-symbols-outlined text-green-700" style={{ fontSize: 18 }}>check</span>
</div>
) : (
<div className="w-10 h-10 rounded-xl bg-slate-100" />
)}
</div>
)
})}
</div>
</div>
</div>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,64 @@
// Phase 3 bridge: gamification state from localStorage until DB is live
export interface GamificationState {
xu: number
streak: number
xp: number
xpNextLevel: number
level: number
levelName: string
weeklyCompleted: number
weeklyGoal: number
weekActivity: boolean[] // MonSun, true = completed
}
const KEYS = {
xu: 'xu_balance',
streak: 'gamification_streak',
xp: 'gamification_xp',
level: 'gamification_level',
weeklyCompleted: 'gamification_weekly_completed',
}
function getNum(key: string, fallback: number) {
const v = localStorage.getItem(key)
return v ? parseInt(v, 10) : fallback
}
function getLevelName(level: number): string {
if (level >= 40) return 'Master'
if (level >= 20) return 'Gold'
if (level >= 10) return 'Silver'
if (level >= 5) return 'Bronze'
return 'Beginner'
}
function getWeekActivity(): boolean[] {
// Days MonSun — mark days up to (but not including) today as done if streak is active
const today = new Date().getDay() // 0=Sun, 1=Mon ... 6=Sat
const streak = getNum(KEYS.streak, 14)
// Convert to Mon=0 index
const todayIdx = today === 0 ? 6 : today - 1
const activity = Array(7).fill(false)
for (let i = 0; i < Math.min(todayIdx, streak, 7); i++) {
activity[i] = true
}
return activity
}
export function loadGamification(): GamificationState {
const level = getNum(KEYS.level, 14)
const xp = getNum(KEYS.xp, 1200)
return {
xu: getNum(KEYS.xu, 50),
streak: getNum(KEYS.streak, 14),
xp,
xpNextLevel: 1600, // hardcoded for Phase 3 demo
level,
levelName: getLevelName(level),
weeklyCompleted: getNum(KEYS.weeklyCompleted, 3),
weeklyGoal: 5,
weekActivity: getWeekActivity(),
}
}

View File

@@ -0,0 +1,116 @@
import { useState } from 'react'
import { useAuthStore } from '@/store/auth-store'
import { supabase } from '@/lib/supabase'
import { cn } from '@/lib/utils'
export function AccountCard() {
const logout = useAuthStore((s) => s.logout)
const [changingPw, setChangingPw] = useState(false)
const [pw, setPw] = useState({ current: '', next: '', confirm: '' })
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
const [confirmDelete, setConfirmDelete] = useState(false)
async function changePassword() {
if (pw.next !== pw.confirm) { setMsg('Mật khẩu xác nhận không khớp.'); return }
if (pw.next.length < 6) { setMsg('Mật khẩu phải có ít nhất 6 ký tự.'); return }
setSaving(true)
setMsg('')
try {
const { error } = await supabase.auth.updateUser({ password: pw.next })
if (error) throw error
setMsg('Đổi mật khẩu thành công!')
setChangingPw(false)
setPw({ current: '', next: '', confirm: '' })
} catch {
setMsg('Không thể đổi mật khẩu. Thử lại sau.')
} finally {
setSaving(false)
}
}
async function deleteAccount() {
// Supabase doesn't support self-delete via client SDK — log out and show message
await logout()
alert('Vui lòng liên hệ hỗ trợ để xóa tài khoản.')
}
return (
<section className="col-span-12 bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-lg font-bold mb-5 flex items-center gap-2 text-slate-800">
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>security</span>
Tài khoản &amp; Bảo mật
</h2>
{/* Change password */}
<div className="flex items-center justify-between py-4 border-b border-slate-100">
<div>
<p className="font-semibold text-slate-800 text-sm">Mật khẩu</p>
<p className="text-xs text-slate-400 mt-0.5">Cập nhật mật khẩu đ bảo mật tài khoản</p>
</div>
<button
onClick={() => { setChangingPw(!changingPw); setMsg('') }}
className="px-5 py-2 rounded-full border border-slate-200 text-sm font-bold hover:bg-slate-50 transition-colors"
>
Đi mật khẩu
</button>
</div>
{changingPw && (
<div className="py-4 border-b border-slate-100 space-y-3">
{(['next', 'confirm'] as const).map((field) => (
<input
key={field}
type="password"
placeholder={field === 'next' ? 'Mật khẩu mới' : 'Xác nhận mật khẩu mới'}
value={pw[field]}
onChange={(e) => setPw((p) => ({ ...p, [field]: e.target.value }))}
className="w-full max-w-sm border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
/>
))}
<div className="flex gap-2">
<button
onClick={changePassword}
disabled={saving}
className={cn('px-5 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold hover:bg-blue-700 transition-colors', saving && 'opacity-50')}
>
{saving ? 'Đang lưu...' : 'Lưu'}
</button>
<button onClick={() => { setChangingPw(false); setMsg('') }} className="px-4 py-2 text-slate-400 text-sm">
Huỷ
</button>
</div>
{msg && <p className={cn('text-sm', msg.includes('thành công') ? 'text-green-600' : 'text-red-600')}>{msg}</p>}
</div>
)}
{/* Danger zone */}
<div className="mt-6">
<p className="text-xs font-bold text-red-500 uppercase tracking-wider mb-3">Khu vực nguy hiểm</p>
<div className="flex items-center justify-between p-4 bg-red-50 rounded-xl border border-red-100">
<div>
<p className="font-semibold text-red-600 text-sm">Xóa tài khoản</p>
<p className="text-xs text-red-400 mt-0.5">Hành đng này không thể hoàn tác. Toàn bộ dữ liệu học tập sẽ bị mất.</p>
</div>
{confirmDelete ? (
<div className="flex gap-2 ml-4 flex-shrink-0">
<button onClick={deleteAccount} className="px-4 py-2 bg-red-600 text-white rounded-full text-sm font-bold hover:bg-red-700 transition-colors">
Xác nhận
</button>
<button onClick={() => setConfirmDelete(false)} className="px-4 py-2 text-slate-500 text-sm">
Huỷ
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(true)}
className="ml-4 flex-shrink-0 px-5 py-2 bg-red-600 text-white rounded-full text-sm font-bold shadow-sm hover:bg-red-700 transition-colors"
>
Xóa tài khoản
</button>
)}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,56 @@
import { useState } from 'react'
import { cn } from '@/lib/utils'
const GOAL_OPTIONS = [
{ value: '10', label: '10p', sublabel: 'Dễ dàng' },
{ value: '20', label: '20p', sublabel: 'Tiêu chuẩn' },
{ value: '30', label: '30p', sublabel: 'Thử thách' },
{ value: '60', label: '1h', sublabel: 'Chuyên sâu' },
]
const XP_MAP: Record<string, number> = { '10': 20, '20': 50, '30': 80, '60': 120 }
const STORAGE_KEY = 'settings_daily_goal'
export function DailyGoalCard() {
const [goal, setGoal] = useState<string>(() => localStorage.getItem(STORAGE_KEY) ?? '20')
function handleSelect(value: string) {
setGoal(value)
localStorage.setItem(STORAGE_KEY, value)
}
return (
<section className="col-span-12 md:col-span-6 bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-lg font-bold mb-5 flex items-center gap-2 text-slate-800">
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>target</span>
Mục tiêu hàng ngày
</h2>
<div className="grid grid-cols-2 gap-3">
{GOAL_OPTIONS.map((opt) => {
const active = goal === opt.value
return (
<button
key={opt.value}
onClick={() => handleSelect(opt.value)}
className={cn(
'p-4 rounded-xl border-2 text-center transition-all',
active
? 'border-blue-600 bg-blue-50 text-blue-600'
: 'border-slate-100 bg-slate-50 text-slate-700 hover:border-slate-200',
)}
>
<span className={cn('block text-sm font-bold', active && 'text-blue-600')}>{opt.label}</span>
<span className={cn('text-xs', active ? 'text-blue-500' : 'text-slate-400')}>{opt.sublabel}</span>
</button>
)
})}
</div>
<div className="mt-5 p-3.5 rounded-xl bg-blue-50 flex items-center justify-center gap-2 text-blue-600 font-bold text-sm">
<span className="material-symbols-outlined" style={{ fontSize: 18, fontVariationSettings: "'FILL' 1" }}>stars</span>
+{XP_MAP[goal]} XP mỗi ngày
</div>
</section>
)
}

View File

@@ -0,0 +1,86 @@
import { useState } from 'react'
const STORAGE_KEY = 'settings_exam_date'
function getDaysUntil(dateStr: string): number {
const today = new Date()
today.setHours(0, 0, 0, 0)
const target = new Date(dateStr)
target.setHours(0, 0, 0, 0)
return Math.ceil((target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
}
function formatVi(dateStr: string): string {
const d = new Date(dateStr)
return d.toLocaleDateString('vi-VN', { day: 'numeric', month: 'long', year: 'numeric' })
}
export function ExamDateCard() {
const [examDate, setExamDate] = useState<string>(() => localStorage.getItem(STORAGE_KEY) ?? '')
const [editing, setEditing] = useState(false)
const [input, setInput] = useState(examDate)
function save() {
if (!input) return
setExamDate(input)
localStorage.setItem(STORAGE_KEY, input)
setEditing(false)
}
const days = examDate ? getDaysUntil(examDate) : null
return (
<section className="col-span-12 md:col-span-6 bg-white rounded-xl p-6 shadow-sm flex flex-col">
<h2 className="text-lg font-bold mb-5 flex items-center gap-2 text-slate-800">
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>calendar_month</span>
Ngày thi TOEIC
</h2>
<div className="flex-1 flex flex-col justify-center">
{examDate && days !== null ? (
<>
<div className="bg-gradient-to-br from-blue-600 to-blue-500 rounded-xl p-6 text-white text-center shadow-lg shadow-blue-200">
<p className="text-sm font-medium opacity-80 mb-1">Đếm ngược kỳ thi</p>
<p className="text-4xl font-extrabold tracking-tight">
{days > 0 ? `Còn ${days} ngày` : days === 0 ? 'Hôm nay!' : 'Đã qua'}
</p>
<div className="mt-3 inline-flex items-center gap-1.5 px-3 py-1 bg-white/20 rounded-full text-xs">
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>event</span>
<span>{formatVi(examDate)}</span>
</div>
</div>
<button
onClick={() => { setInput(examDate); setEditing(true) }}
className="mt-4 w-full py-2.5 rounded-full border border-blue-200 text-blue-600 font-bold text-sm hover:bg-blue-50 transition-colors"
>
Thay đi ngày thi
</button>
</>
) : (
<div className="text-center py-6">
<span className="material-symbols-outlined text-slate-300 mb-2" style={{ fontSize: 48 }}>event_upcoming</span>
<p className="text-slate-400 text-sm mb-4">Chưa đt ngày thi</p>
</div>
)}
{editing || !examDate ? (
<div className="mt-3 flex gap-2">
<input
type="date"
value={input}
onChange={(e) => setInput(e.target.value)}
min={new Date().toISOString().split('T')[0]}
className="flex-1 border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
/>
<button onClick={save} disabled={!input} className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold disabled:opacity-40 hover:bg-blue-700 transition-colors">
Lưu
</button>
{editing && (
<button onClick={() => setEditing(false)} className="px-3 py-2 text-slate-400 text-sm">Huỷ</button>
)}
</div>
) : null}
</div>
</section>
)
}

View File

@@ -0,0 +1,101 @@
import { useState } from 'react'
import { cn } from '@/lib/utils'
interface NotifPrefs {
daily: boolean
streak: boolean
weekly: boolean
leaderboard: boolean
}
const STORAGE_KEY = 'settings_notifications'
const TIME_KEY = 'settings_notif_time'
const DEFAULT_PREFS: NotifPrefs = { daily: true, streak: true, weekly: false, leaderboard: true }
function loadPrefs(): NotifPrefs {
try {
return { ...DEFAULT_PREFS, ...JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') }
} catch {
return DEFAULT_PREFS
}
}
interface ToggleProps {
checked: boolean
onChange: (v: boolean) => void
}
function Toggle({ checked, onChange }: ToggleProps) {
return (
<button
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
'relative w-11 h-6 rounded-full transition-colors flex-shrink-0',
checked ? 'bg-blue-600' : 'bg-slate-200',
)}
>
<span className={cn(
'absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform',
checked ? 'translate-x-5' : 'translate-x-0.5',
)} />
</button>
)
}
export function NotificationsCard() {
const [prefs, setPrefs] = useState<NotifPrefs>(loadPrefs)
const [time, setTime] = useState(() => localStorage.getItem(TIME_KEY) ?? '20:00')
function toggle(key: keyof NotifPrefs) {
const next = { ...prefs, [key]: !prefs[key] }
setPrefs(next)
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
}
function saveTime(v: string) {
setTime(v)
localStorage.setItem(TIME_KEY, v)
}
const items: { key: keyof NotifPrefs; label: string; desc: string }[] = [
{ key: 'daily', label: 'Nhắc nhở hàng ngày', desc: 'Tùy chỉnh thời gian học mỗi ngày' },
{ key: 'streak', label: 'Cảnh báo chuỗi học tập', desc: 'Không bao giờ bỏ lỡ Streak của bạn' },
{ key: 'weekly', label: 'Nhắc nhở mục tiêu tuần', desc: 'Theo dõi tiến độ học tập hàng tuần' },
{ key: 'leaderboard', label: 'Cập nhật bảng xếp hạng', desc: 'Biết ngay khi ai đó vượt qua bạn' },
]
return (
<section className="col-span-12 bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-lg font-bold mb-6 flex items-center gap-2 text-slate-800">
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>notifications_active</span>
Cài đt thông báo
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-10 gap-y-6">
{items.map((item) => (
<div key={item.key} className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold text-slate-800 text-sm">{item.label}</p>
<p className="text-xs text-slate-400 mt-0.5">{item.desc}</p>
{item.key === 'daily' && prefs.daily && (
<div className="mt-2 inline-flex items-center gap-1.5 px-2.5 py-1 bg-slate-100 rounded-lg text-xs font-semibold text-slate-600">
<span className="material-symbols-outlined" style={{ fontSize: 13 }}>schedule</span>
<input
type="time"
value={time}
onChange={(e) => saveTime(e.target.value)}
className="bg-transparent outline-none text-xs font-semibold w-16"
/>
</div>
)}
</div>
<Toggle checked={prefs[item.key]} onChange={() => toggle(item.key)} />
</div>
))}
</div>
</section>
)
}

View File

@@ -0,0 +1,126 @@
import { useState } from 'react'
import { useAuthStore } from '@/store/auth-store'
import { supabase } from '@/lib/supabase'
import { cn } from '@/lib/utils'
export function ProfileCard() {
const user = useAuthStore((s) => s.user)
const [editingName, setEditingName] = useState(false)
const [editingEmail, setEditingEmail] = useState(false)
const [nameInput, setNameInput] = useState(user?.name ?? '')
const [emailInput, setEmailInput] = useState(user?.email ?? '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
async function saveName() {
if (!nameInput.trim()) return
setSaving(true)
setError('')
try {
const { error: err } = await supabase.auth.updateUser({ data: { name: nameInput.trim() } })
if (err) throw err
setEditingName(false)
} catch {
setError('Không thể lưu tên. Thử lại sau.')
} finally {
setSaving(false)
}
}
async function saveEmail() {
if (!emailInput.trim()) return
setSaving(true)
setError('')
try {
const { error: err } = await supabase.auth.updateUser({ email: emailInput.trim() })
if (err) throw err
setEditingEmail(false)
} catch {
setError('Không thể lưu email. Thử lại sau.')
} finally {
setSaving(false)
}
}
const initials = (user?.name ?? 'U').charAt(0).toUpperCase()
return (
<section className="col-span-12 md:col-span-8 bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-lg font-bold mb-5 flex items-center gap-2 text-slate-800">
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>person</span>
Hồ nhân
</h2>
<div className="flex items-center gap-6">
{/* Avatar */}
<div className="relative flex-shrink-0">
<div className="w-20 h-20 rounded-full bg-blue-600 flex items-center justify-center text-white text-2xl font-bold">
{initials}
</div>
</div>
{/* Fields */}
<div className="flex-1 space-y-3">
{/* Name */}
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<div className="flex-1">
<p className="text-xs text-slate-400 font-semibold uppercase tracking-wider">Họ tên</p>
{editingName ? (
<input
autoFocus
value={nameInput}
onChange={(e) => setNameInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') setEditingName(false) }}
className="mt-0.5 text-base font-semibold w-full border border-blue-300 rounded-lg px-2 py-0.5 outline-none focus:ring-2 focus:ring-blue-200"
/>
) : (
<p className="text-base font-semibold text-slate-800 mt-0.5">{user?.name ?? '—'}</p>
)}
</div>
{editingName ? (
<div className="flex gap-2 ml-3">
<button onClick={saveName} disabled={saving} className={cn('text-blue-600 font-bold text-sm', saving && 'opacity-50')}>
{saving ? 'Lưu...' : 'Lưu'}
</button>
<button onClick={() => setEditingName(false)} className="text-slate-400 text-sm">Huỷ</button>
</div>
) : (
<button onClick={() => setEditingName(true)} className="ml-3 text-blue-600 font-bold text-sm hover:underline">Chỉnh sửa</button>
)}
</div>
{/* Email */}
<div className="flex items-center justify-between py-2">
<div className="flex-1">
<p className="text-xs text-slate-400 font-semibold uppercase tracking-wider">Email</p>
{editingEmail ? (
<input
autoFocus
type="email"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveEmail(); if (e.key === 'Escape') setEditingEmail(false) }}
className="mt-0.5 text-base font-semibold w-full border border-blue-300 rounded-lg px-2 py-0.5 outline-none focus:ring-2 focus:ring-blue-200"
/>
) : (
<p className="text-base font-semibold text-slate-800 mt-0.5">{user?.email ?? '—'}</p>
)}
</div>
{editingEmail ? (
<div className="flex gap-2 ml-3">
<button onClick={saveEmail} disabled={saving} className={cn('text-blue-600 font-bold text-sm', saving && 'opacity-50')}>
{saving ? 'Lưu...' : 'Lưu'}
</button>
<button onClick={() => setEditingEmail(false)} className="text-slate-400 text-sm">Huỷ</button>
</div>
) : (
<button onClick={() => setEditingEmail(true)} className="ml-3 text-blue-600 font-bold text-sm hover:underline">Chỉnh sửa</button>
)}
</div>
</div>
</div>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
</section>
)
}

View File

@@ -0,0 +1,47 @@
// Phase 3: Xu balance reads from localStorage until gamification DB is live
const XU_STORAGE_KEY = 'xu_balance'
const DEFAULT_XU = 50 // welcome bonus
function getXuBalance(): number {
const stored = localStorage.getItem(XU_STORAGE_KEY)
return stored ? parseInt(stored, 10) : DEFAULT_XU
}
const RECENT_TRANSACTIONS = [
{ label: 'Hoàn thành bài tập', amount: '+10 Xu', icon: 'add_circle' },
{ label: 'Đổi Streak Freeze', amount: '-20 Xu', icon: 'ac_unit' },
]
export function XuWalletCard() {
const balance = getXuBalance()
return (
<section className="col-span-12 md:col-span-4 bg-blue-600 text-white rounded-xl p-6 relative overflow-hidden flex flex-col justify-between shadow-sm">
{/* Decorative blobs */}
<div className="absolute -right-6 -top-6 w-28 h-28 bg-white/10 rounded-full blur-3xl pointer-events-none" />
<div className="absolute -left-6 -bottom-6 w-28 h-28 bg-blue-400/30 rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10">
<p className="text-xs font-bold uppercase tracking-widest opacity-70 mb-1"> Xu của bạn</p>
<div className="text-4xl font-extrabold tracking-tight">
{balance.toLocaleString('vi-VN')} Xu
</div>
</div>
<div className="relative z-10 mt-5 space-y-2.5">
{RECENT_TRANSACTIONS.map((t) => (
<div
key={t.label}
className="bg-white/10 backdrop-blur-md rounded-lg px-3 py-2.5 flex items-center justify-between text-xs"
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>{t.icon}</span>
<span>{t.label}</span>
</div>
<span className="font-bold">{t.amount}</span>
</div>
))}
</div>
</section>
)
}

6
src/routes/dashboard.tsx Normal file
View File

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

6
src/routes/settings.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { Settings } from '@/pages/Settings'
export const Route = createFileRoute('/settings')({
component: Settings,
})

View File

@@ -58,3 +58,45 @@ export interface User {
email: string
name: string
}
// Phase 3 — Gamification
export type UserLevel = 'beginner' | 'bronze' | 'silver' | 'gold' | 'master'
export type XuTransactionType =
| 'earn_welcome'
| 'earn_daily'
| 'earn_streak'
| 'earn_ads'
| 'spend_freeze'
| 'spend_writing'
| 'spend_test'
export interface UserGamification {
userId: string
xp: number
level: UserLevel
streak: number
longestStreak: number
lastActive: string | null // DATE as ISO string
xu: number
freezeCount: number
createdAt: string
}
export interface XuTransaction {
id: string
userId: string
type: XuTransactionType
amount: number
balance: number
description: string | null
createdAt: string
}
export interface WeeklyLeaderboardEntry {
id: string
userId: string
weekStart: string
xpEarned: number
rank: number | null
}