update real data

This commit is contained in:
2026-04-12 23:12:29 +07:00
parent 8de8b88a3d
commit 20ae176992
11 changed files with 487 additions and 122 deletions

View File

@@ -0,0 +1,51 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuthStore } from '@/store/auth-store'
import {
fetchGamification,
fetchXuTransactions,
fetchLeaderboard,
awardActivity,
} from '@/lib/gamification-service'
export function useGamification() {
const user = useAuthStore((s) => s.user)
return useQuery({
queryKey: ['gamification', user?.id],
queryFn: () => fetchGamification(user!.id),
enabled: !!user,
staleTime: 30_000,
})
}
export function useXuTransactions(limit = 10) {
const user = useAuthStore((s) => s.user)
return useQuery({
queryKey: ['xu-transactions', user?.id, limit],
queryFn: () => fetchXuTransactions(user!.id, limit),
enabled: !!user,
staleTime: 30_000,
})
}
export function useLeaderboard() {
return useQuery({
queryKey: ['leaderboard'],
queryFn: fetchLeaderboard,
staleTime: 60_000,
})
}
export function useAwardActivity() {
const user = useAuthStore((s) => s.user)
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ xp }: { xp: number }) =>
awardActivity(user!.id, xp, user!.name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['gamification', user?.id] })
queryClient.invalidateQueries({ queryKey: ['xu-transactions', user?.id] })
queryClient.invalidateQueries({ queryKey: ['leaderboard'] })
},
})
}

View File

@@ -0,0 +1,209 @@
import { supabase } from '@/lib/supabase'
import type { UserGamification, XuTransaction, UserLevel } from '@/types'
// XP awarded per action type
export const XP_REWARDS = { test: 20, writing: 15 } as const
// Xu awarded at streak milestones
const STREAK_XU: Record<number, number> = { 7: 20, 30: 50, 100: 100 }
function calcLevel(xp: number): UserLevel {
if (xp >= 10000) return 'master'
if (xp >= 5000) return 'gold'
if (xp >= 2000) return 'silver'
if (xp >= 500) return 'bronze'
return 'beginner'
}
function today(): string {
return new Date().toISOString().split('T')[0]
}
function yesterday(): string {
const d = new Date()
d.setDate(d.getDate() - 1)
return d.toISOString().split('T')[0]
}
export function getWeekStart(): string {
const d = new Date()
const day = d.getDay()
const diff = day === 0 ? -6 : 1 - day
d.setDate(d.getDate() + diff)
return d.toISOString().split('T')[0]
}
// Map Supabase snake_case row → camelCase type
function mapGamification(row: Record<string, unknown>): UserGamification {
return {
userId: row.user_id as string,
xp: row.xp as number,
level: row.level as UserLevel,
streak: row.streak as number,
longestStreak: row.longest_streak as number,
lastActive: (row.last_active as string) ?? null,
xu: row.xu as number,
freezeCount: row.freeze_count as number,
createdAt: row.created_at as string,
}
}
// ─── Fetch ────────────────────────────────────────────────────────────────────
export async function fetchGamification(userId: string): Promise<UserGamification | null> {
const { data, error } = await supabase
.from('user_gamification')
.select('*')
.eq('user_id', userId)
.single()
if (error) {
if (error.code === 'PGRST116') return null
throw error
}
return mapGamification(data as Record<string, unknown>)
}
export async function fetchXuTransactions(userId: string, limit = 10): Promise<XuTransaction[]> {
const { data, error } = await supabase
.from('xu_transactions')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(limit)
if (error) throw error
return (data ?? []).map((row: Record<string, unknown>) => ({
id: row.id as string,
userId: row.user_id as string,
type: row.type as XuTransaction['type'],
amount: row.amount as number,
balance: row.balance as number,
description: (row.description as string) ?? null,
createdAt: row.created_at as string,
}))
}
export interface LeaderboardRow {
userId: string
displayName: string
xpEarned: number
rank: number
}
export async function fetchLeaderboard(): Promise<LeaderboardRow[]> {
const weekStart = getWeekStart()
// Step 1: fetch leaderboard rankings
const { data: lbRows, error: lbErr } = await supabase
.from('weekly_leaderboard')
.select('user_id, xp_earned')
.eq('week_start', weekStart)
.order('xp_earned', { ascending: false })
.limit(20)
if (lbErr) throw lbErr
if (!lbRows?.length) return []
// Step 2: fetch display names from user_gamification
const userIds = lbRows.map((r) => r.user_id)
const { data: gamRows } = await supabase
.from('user_gamification')
.select('user_id, display_name')
.in('user_id', userIds)
const nameMap = new Map((gamRows ?? []).map((r) => [r.user_id, r.display_name ?? 'Người dùng']))
return lbRows.map((row, i) => ({
userId: row.user_id,
displayName: nameMap.get(row.user_id) ?? 'Người dùng',
xpEarned: row.xp_earned,
rank: i + 1,
}))
}
// ─── Award Activity ───────────────────────────────────────────────────────────
export interface AwardResult {
xpGained: number
xuGained: number
newStreak: number
levelUp: boolean
}
export async function awardActivity(
userId: string,
xpAmount: number,
displayName: string,
): Promise<AwardResult> {
const todayStr = today()
const current = await fetchGamification(userId)
const prevXp = current?.xp ?? 0
const prevXu = current?.xu ?? 50
const prevStreak = current?.streak ?? 0
const lastActive = current?.lastActive ?? null
// Streak logic
const isNewDay = lastActive !== todayStr
const isConsecutive = lastActive === yesterday()
let newStreak: number
if (!lastActive) newStreak = 1
else if (!isNewDay) newStreak = prevStreak
else if (isConsecutive) newStreak = prevStreak + 1
else newStreak = 1 // streak broken
// XP + level
const newXp = prevXp + xpAmount
const newLevel = calcLevel(newXp)
const levelUp = newLevel !== calcLevel(prevXp)
// Xu (daily goal + streak milestones, once per day)
let xuGained = 0
const txRows: Array<{ user_id: string; type: string; amount: number; balance: number; description: string }> = []
if (isNewDay) {
xuGained += 10
txRows.push({ user_id: userId, type: 'earn_daily', amount: 10, balance: 0, description: 'Hoàn thành mục tiêu ngày' })
}
if (isNewDay && STREAK_XU[newStreak]) {
const bonus = STREAK_XU[newStreak]
xuGained += bonus
txRows.push({ user_id: userId, type: 'earn_streak', amount: bonus, balance: 0, description: `Streak ${newStreak} ngày` })
}
const newXu = prevXu + xuGained
// Back-fill running balance in transaction rows
txRows.forEach((t, i) => { t.balance = prevXu + txRows.slice(0, i + 1).reduce((s, r) => s + r.amount, 0) })
// Upsert gamification state
await supabase.from('user_gamification').upsert({
user_id: userId,
xp: newXp,
level: newLevel,
streak: newStreak,
longest_streak: Math.max(current?.longestStreak ?? 0, newStreak),
last_active: todayStr,
xu: newXu,
freeze_count: current?.freezeCount ?? 0,
display_name: displayName,
}, { onConflict: 'user_id' })
if (txRows.length > 0) {
await supabase.from('xu_transactions').insert(txRows)
}
// Accumulate XP on weekly leaderboard
const weekStart = getWeekStart()
const { data: existing } = await supabase
.from('weekly_leaderboard')
.select('xp_earned')
.eq('user_id', userId)
.eq('week_start', weekStart)
.single()
await supabase.from('weekly_leaderboard').upsert({
user_id: userId,
week_start: weekStart,
xp_earned: (existing?.xp_earned ?? 0) + xpAmount,
}, { onConflict: 'user_id,week_start' })
return { xpGained: xpAmount, xuGained, newStreak, levelUp }
}

View File

@@ -1,16 +1,29 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { useAuthStore } from '@/store/auth-store' import { useAuthStore } from '@/store/auth-store'
import { useAuthModalStore } from '@/store/auth-modal-store' import { useAuthModalStore } from '@/store/auth-modal-store'
import { loadGamification } from './dashboard/gamification-store' import { useGamification, useLeaderboard } from '@/hooks/use-gamification'
import { XP_REWARDS } from '@/lib/gamification-service'
import { StatsRow } from './dashboard/StatsRow' import { StatsRow } from './dashboard/StatsRow'
import { XpProgressCard } from './dashboard/XpProgressCard' import { XpProgressCard } from './dashboard/XpProgressCard'
import { WeeklySection } from './dashboard/WeeklySection' import { WeeklySection } from './dashboard/WeeklySection'
import { XuEconomyCard } from './dashboard/XuEconomyCard' import { XuEconomyCard } from './dashboard/XuEconomyCard'
import { LeaderboardCard } from './dashboard/LeaderboardCard' import { LeaderboardCard } from './dashboard/LeaderboardCard'
// Numeric level from XP (1 per 100 XP, min 1)
export function calcNumericLevel(xp: number) {
return Math.max(1, Math.floor(xp / 100))
}
// XP needed for next numeric level
export function calcXpNextLevel(xp: number) {
return (Math.floor(xp / 100) + 1) * 100
}
export function Dashboard() { export function Dashboard() {
const user = useAuthStore((s) => s.user) const user = useAuthStore((s) => s.user)
const openModal = useAuthModalStore((s) => s.open) const openModal = useAuthModalStore((s) => s.open)
const { data: gam, isLoading } = useGamification()
const { data: leaderboard } = useLeaderboard()
if (!user) { if (!user) {
return ( return (
@@ -30,32 +43,46 @@ export function Dashboard() {
) )
} }
const state = loadGamification() // Derive weekly completed from leaderboard XP
const userLbRow = leaderboard?.find((r) => r.userId === user.id)
const weeklyXp = userLbRow?.xpEarned ?? 0
const weeklyCompleted = Math.min(Math.floor(weeklyXp / XP_REWARDS.test), 5)
const xu = gam?.xu ?? 50
const streak = gam?.streak ?? 0
const xp = gam?.xp ?? 0
const level = gam?.level ?? 'beginner'
const lastActive = gam?.lastActive ?? null
return ( return (
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto"> <div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto">
{/* Page header */}
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">Bảng thành tích</h1> <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> <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> </div>
{/* Hero stats */} {isLoading ? (
<StatsRow state={state} /> <div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-6">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-xl p-6 shadow-sm h-32 animate-pulse bg-slate-100" />
))}
</div>
) : (
<StatsRow xu={xu} streak={streak} xp={xp} level={level} />
)}
{/* Progress section */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5 mb-5"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-5 mb-5">
<XpProgressCard state={state} /> <XpProgressCard xp={xp} />
<WeeklySection state={state} /> <WeeklySection streak={streak} lastActive={lastActive} weeklyCompleted={weeklyCompleted} />
</div> </div>
{/* Xu economy + leaderboard */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-5">
<XuEconomyCard /> <XuEconomyCard />
<LeaderboardCard /> <LeaderboardCard />
</div> </div>
{/* FAB */}
<Link <Link
to="/toeic" 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" 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"

View File

@@ -5,6 +5,8 @@ import { useTestStore } from '@/store/test-store'
import { useRequireAuth } from '@/hooks/use-require-auth' import { useRequireAuth } from '@/hooks/use-require-auth'
import { useAuthStore } from '@/store/auth-store' import { useAuthStore } from '@/store/auth-store'
import { saveTestResult } from '@/lib/progress-service' import { saveTestResult } from '@/lib/progress-service'
import { useAwardActivity } from '@/hooks/use-gamification'
import { XP_REWARDS } from '@/lib/gamification-service'
function formatTime(s: number) { function formatTime(s: number) {
const m = Math.floor(s / 60) const m = Math.floor(s / 60)
@@ -19,6 +21,7 @@ export function TestResult() {
const { isAuthenticated, isLoading } = useRequireAuth() const { isAuthenticated, isLoading } = useRequireAuth()
const user = useAuthStore((s) => s.user) const user = useAuthStore((s) => s.user)
const savedRef = useRef(false) const savedRef = useRef(false)
const { mutate: awardActivity } = useAwardActivity()
useEffect(() => { useEffect(() => {
if (isLoading) return if (isLoading) return
@@ -29,6 +32,7 @@ export function TestResult() {
useEffect(() => { useEffect(() => {
if (!user || savedRef.current || questions.length === 0) return if (!user || savedRef.current || questions.length === 0) return
savedRef.current = true savedRef.current = true
awardActivity({ xp: XP_REWARDS.test })
saveTestResult(user.id, { saveTestResult(user.id, {
partId, partId,
partName, partName,

View File

@@ -5,6 +5,8 @@ import { getRemainingChecks } from '@/utils/rate-limiter'
import { useRequireAuth } from '@/hooks/use-require-auth' import { useRequireAuth } from '@/hooks/use-require-auth'
import { useAuthStore } from '@/store/auth-store' import { useAuthStore } from '@/store/auth-store'
import { countTodayWritingSubmissions } from '@/lib/progress-service' import { countTodayWritingSubmissions } from '@/lib/progress-service'
import { useAwardActivity } from '@/hooks/use-gamification'
import { XP_REWARDS } from '@/lib/gamification-service'
const MAX_CHARS = 1000 const MAX_CHARS = 1000
const GUEST_LIMIT = 3 const GUEST_LIMIT = 3
@@ -18,6 +20,7 @@ export function WritingChecker() {
const { mutate: checkWriting, isPending, isError, error, data: feedback } = useWritingCheck() const { mutate: checkWriting, isPending, isError, error, data: feedback } = useWritingCheck()
const { requireAuth } = useRequireAuth() const { requireAuth } = useRequireAuth()
const user = useAuthStore((s) => s.user) const user = useAuthStore((s) => s.user)
const { mutate: awardActivity } = useAwardActivity()
const dailyLimit = user ? AUTH_LIMIT : GUEST_LIMIT const dailyLimit = user ? AUTH_LIMIT : GUEST_LIMIT
@@ -41,6 +44,7 @@ export function WritingChecker() {
checkWriting(text, { checkWriting(text, {
onSuccess: () => { onSuccess: () => {
if (user) { if (user) {
awardActivity({ xp: XP_REWARDS.writing })
countTodayWritingSubmissions(user.id).then((used) => setRemaining(AUTH_LIMIT - used)) countTodayWritingSubmissions(user.id).then((used) => setRemaining(AUTH_LIMIT - used))
} else { } else {
setRemaining(getRemainingChecks()) setRemaining(getRemainingChecks())

View File

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

View File

@@ -1,8 +1,24 @@
import type { GamificationState } from './gamification-store' import type { UserLevel } from '@/types'
import { calcNumericLevel } from '../Dashboard'
interface Props { state: GamificationState } const LEVEL_NAMES: Record<UserLevel, string> = {
beginner: 'Beginner',
bronze: 'Đồng',
silver: 'Bạc',
gold: 'Vàng',
master: 'Master',
}
interface Props {
xu: number
streak: number
xp: number
level: UserLevel
}
export function StatsRow({ xu, streak, xp, level }: Props) {
const numericLevel = calcNumericLevel(xp)
export function StatsRow({ state }: Props) {
return ( return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-6">
{/* Xu Balance */} {/* Xu Balance */}
@@ -10,8 +26,10 @@ export function StatsRow({ state }: Props) {
<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" /> <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> <span className="text-xs uppercase tracking-widest text-slate-400 font-bold">Số Xu</span>
<div className="flex items-center gap-3 mt-1"> <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="text-4xl font-extrabold text-slate-800">{xu.toLocaleString('vi-VN')}</span>
<span className="material-symbols-outlined text-3xl text-amber-400" style={{ fontVariationSettings: "'FILL' 1" }}>monetization_on</span> <span className="material-symbols-outlined text-3xl text-amber-400" style={{ fontVariationSettings: "'FILL' 1" }}>
monetization_on
</span>
</div> </div>
<p className="text-xs text-slate-400 mt-1.5 font-medium">Dùng đ mở tính năng premium</p> <p className="text-xs text-slate-400 mt-1.5 font-medium">Dùng đ mở tính năng premium</p>
</div> </div>
@@ -22,10 +40,14 @@ export function StatsRow({ state }: Props) {
<div className="relative z-10 text-white"> <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> <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"> <div className="flex items-center gap-3 mt-1">
<span className="text-4xl font-extrabold">{state.streak} Ngày</span> <span className="text-4xl font-extrabold">{streak} Ngày</span>
<span className="material-symbols-outlined text-3xl text-amber-300" style={{ fontVariationSettings: "'FILL' 1" }}>local_fire_department</span> <span className="material-symbols-outlined text-3xl text-amber-300" style={{ fontVariationSettings: "'FILL' 1" }}>
local_fire_department
</span>
</div> </div>
<p className="text-xs opacity-80 mt-1.5 font-medium">Bạn thuộc top 5% người học!</p> <p className="text-xs opacity-80 mt-1.5 font-medium">
{streak >= 7 ? 'Bạn thuộc top 5% người học!' : 'Giữ vững chuỗi học mỗi ngày nhé!'}
</p>
</div> </div>
</div> </div>
@@ -34,10 +56,10 @@ export function StatsRow({ state }: Props) {
<div> <div>
<span className="text-xs uppercase tracking-widest text-slate-400 font-bold">Cấp đ</span> <span className="text-xs uppercase tracking-widest text-slate-400 font-bold">Cấp đ</span>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<span className="text-4xl font-extrabold text-slate-800">Level {state.level}</span> <span className="text-4xl font-extrabold text-slate-800">Level {numericLevel}</span>
</div> </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"> <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} Hạng {LEVEL_NAMES[level]}
</span> </span>
</div> </div>
<div className="w-14 h-14 bg-slate-100 flex items-center justify-center rounded-2xl rotate-12 flex-shrink-0"> <div className="w-14 h-14 bg-slate-100 flex items-center justify-center rounded-2xl rotate-12 flex-shrink-0">

View File

@@ -1,8 +1,12 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { GamificationState } from './gamification-store'
interface Props { state: GamificationState } interface Props {
streak: number
lastActive: string | null
weeklyCompleted: number
}
const WEEKLY_GOAL = 5
const DAY_LABELS = ['Th 2', 'Th 3', 'Th 4', 'Th 5', 'Th 6', 'Th 7', 'CN'] const DAY_LABELS = ['Th 2', 'Th 3', 'Th 4', 'Th 5', 'Th 6', 'Th 7', 'CN']
function getTodayIdx() { function getTodayIdx() {
@@ -10,9 +14,25 @@ function getTodayIdx() {
return d === 0 ? 6 : d - 1 // Mon=0 … Sun=6 return d === 0 ? 6 : d - 1 // Mon=0 … Sun=6
} }
export function WeeklySection({ state }: Props) { // Derive which days were active this week from streak + lastActive
function getWeekActivity(streak: number, lastActive: string | null): boolean[] {
const todayIdx = getTodayIdx() const todayIdx = getTodayIdx()
const progressPct = Math.round((state.weeklyCompleted / state.weeklyGoal) * 100) const activity = Array(7).fill(false)
if (!lastActive) return activity
const today = new Date().toISOString().split('T')[0]
const isActiveToday = lastActive === today
// Mark past days as done based on streak length (up to but not including today)
const doneDays = isActiveToday ? Math.min(todayIdx, streak - 1) : Math.min(todayIdx, streak)
for (let i = todayIdx - doneDays; i < todayIdx; i++) {
if (i >= 0) activity[i] = true
}
return activity
}
export function WeeklySection({ streak, lastActive, weeklyCompleted }: Props) {
const todayIdx = getTodayIdx()
const weekActivity = getWeekActivity(streak, lastActive)
const progressPct = Math.round((weeklyCompleted / WEEKLY_GOAL) * 100)
return ( return (
<div className="lg:col-span-7 space-y-5"> <div className="lg:col-span-7 space-y-5">
@@ -21,10 +41,10 @@ export function WeeklySection({ state }: Props) {
<div className="flex justify-between items-end mb-3"> <div className="flex justify-between items-end mb-3">
<div> <div>
<h3 className="text-base font-bold text-slate-800">Mục tiêu tuần</h3> <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> <p className="text-xs text-slate-400">Hoàn thành {WEEKLY_GOAL} bài học mỗi tuần</p>
</div> </div>
<span className="text-2xl font-black text-green-600"> <span className="text-2xl font-black text-green-600">
{state.weeklyCompleted}/{state.weeklyGoal} {weeklyCompleted}/{WEEKLY_GOAL}
</span> </span>
</div> </div>
<div className="w-full h-3 bg-slate-100 rounded-full overflow-hidden"> <div className="w-full h-3 bg-slate-100 rounded-full overflow-hidden">
@@ -41,7 +61,7 @@ export function WeeklySection({ state }: Props) {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
{DAY_LABELS.map((label, i) => { {DAY_LABELS.map((label, i) => {
const isToday = i === todayIdx const isToday = i === todayIdx
const done = state.weekActivity[i] const done = weekActivity[i]
const future = i > todayIdx const future = i > todayIdx
return ( return (

View File

@@ -1,6 +1,6 @@
import type { GamificationState } from './gamification-store' import { calcXpNextLevel, calcNumericLevel } from '../Dashboard'
interface Props { state: GamificationState } interface Props { xp: number }
function ProgressRing({ percent, xp, xpNext }: { percent: number; xp: number; xpNext: number }) { function ProgressRing({ percent, xp, xpNext }: { percent: number; xp: number; xpNext: number }) {
const r = 72 const r = 72
@@ -32,17 +32,20 @@ function ProgressRing({ percent, xp, xpNext }: { percent: number; xp: number; xp
) )
} }
export function XpProgressCard({ state }: Props) { export function XpProgressCard({ xp }: Props) {
const percent = Math.round((state.xp / state.xpNextLevel) * 100) const xpNext = calcXpNextLevel(xp)
const levelXpStart = Math.floor(xp / 100) * 100
const percent = Math.round(((xp - levelXpStart) / (xpNext - levelXpStart)) * 100)
const nextLevel = calcNumericLevel(xp) + 1
return ( return (
<div className="lg:col-span-5 bg-white p-6 rounded-xl shadow-sm flex flex-col items-center justify-center text-center"> <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> <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} /> <ProgressRing percent={percent} xp={xp} xpNext={xpNext} />
<p className="text-sm text-slate-400 font-medium mt-4"> <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}! Chỉ còn {(xpNext - xp).toLocaleString()} XP nữa đ đt Level {nextLevel}!
</p> </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"> <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">

View File

@@ -1,19 +1,22 @@
// Phase 3: Xu balance reads from localStorage until gamification DB is live import { useXuTransactions } from '@/hooks/use-gamification'
const XU_STORAGE_KEY = 'xu_balance' import { useGamification } from '@/hooks/use-gamification'
const DEFAULT_XU = 50 // welcome bonus import type { XuTransactionType } from '@/types'
function getXuBalance(): number { const TX_ICON: Record<XuTransactionType, string> = {
const stored = localStorage.getItem(XU_STORAGE_KEY) earn_welcome: 'redeem',
return stored ? parseInt(stored, 10) : DEFAULT_XU earn_daily: 'check_circle',
earn_streak: 'local_fire_department',
earn_ads: 'play_circle',
spend_freeze: 'ac_unit',
spend_writing: 'auto_fix_high',
spend_test: 'quiz',
} }
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() { export function XuWalletCard() {
const balance = getXuBalance() const { data: gam } = useGamification()
const { data: txs, isLoading } = useXuTransactions(5)
const balance = gam?.xu ?? 0
return ( 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"> <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">
@@ -29,16 +32,34 @@ export function XuWalletCard() {
</div> </div>
<div className="relative z-10 mt-5 space-y-2.5"> <div className="relative z-10 mt-5 space-y-2.5">
{RECENT_TRANSACTIONS.map((t) => ( {isLoading && (
<>
{[1, 2].map((i) => (
<div key={i} className="h-10 bg-white/10 rounded-lg animate-pulse" />
))}
</>
)}
{!isLoading && txs && txs.length === 0 && (
<div className="bg-white/10 rounded-lg px-3 py-2.5 text-xs opacity-70 text-center">
Chưa giao dịch nào
</div>
)}
{!isLoading && txs && txs.map((t) => (
<div <div
key={t.label} key={t.id}
className="bg-white/10 backdrop-blur-md rounded-lg px-3 py-2.5 flex items-center justify-between text-xs" 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"> <div className="flex items-center gap-2">
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>{t.icon}</span> <span className="material-symbols-outlined" style={{ fontSize: 16 }}>
<span>{t.label}</span> {TX_ICON[t.type] ?? 'swap_horiz'}
</span>
<span className="truncate max-w-[120px]">{t.description ?? t.type}</span>
</div> </div>
<span className="font-bold">{t.amount}</span> <span className={`font-bold ${t.amount > 0 ? 'text-green-300' : 'text-red-300'}`}>
{t.amount > 0 ? `+${t.amount}` : t.amount} Xu
</span>
</div> </div>
))} ))}
</div> </div>

View File

@@ -0,0 +1,5 @@
-- Migration 003: add display_name to user_gamification for leaderboard
-- Run in Supabase Dashboard → SQL Editor (after 002_gamification.sql)
ALTER TABLE user_gamification
ADD COLUMN IF NOT EXISTS display_name TEXT;