update real data
This commit is contained in:
51
src/hooks/use-gamification.ts
Normal file
51
src/hooks/use-gamification.ts
Normal 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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
209
src/lib/gamification-service.ts
Normal file
209
src/lib/gamification-service.ts
Normal 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 }
|
||||
}
|
||||
@@ -1,16 +1,29 @@
|
||||
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 { useGamification, useLeaderboard } from '@/hooks/use-gamification'
|
||||
import { XP_REWARDS } from '@/lib/gamification-service'
|
||||
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'
|
||||
|
||||
// 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() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const openModal = useAuthModalStore((s) => s.open)
|
||||
const { data: gam, isLoading } = useGamification()
|
||||
const { data: leaderboard } = useLeaderboard()
|
||||
|
||||
if (!user) {
|
||||
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 (
|
||||
<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>
|
||||
<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} />
|
||||
{isLoading ? (
|
||||
<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">
|
||||
<XpProgressCard state={state} />
|
||||
<WeeklySection state={state} />
|
||||
<XpProgressCard xp={xp} />
|
||||
<WeeklySection streak={streak} lastActive={lastActive} weeklyCompleted={weeklyCompleted} />
|
||||
</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"
|
||||
|
||||
@@ -5,6 +5,8 @@ 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'
|
||||
import { useAwardActivity } from '@/hooks/use-gamification'
|
||||
import { XP_REWARDS } from '@/lib/gamification-service'
|
||||
|
||||
function formatTime(s: number) {
|
||||
const m = Math.floor(s / 60)
|
||||
@@ -19,6 +21,7 @@ export function TestResult() {
|
||||
const { isAuthenticated, isLoading } = useRequireAuth()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const savedRef = useRef(false)
|
||||
const { mutate: awardActivity } = useAwardActivity()
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
@@ -29,6 +32,7 @@ export function TestResult() {
|
||||
useEffect(() => {
|
||||
if (!user || savedRef.current || questions.length === 0) return
|
||||
savedRef.current = true
|
||||
awardActivity({ xp: XP_REWARDS.test })
|
||||
saveTestResult(user.id, {
|
||||
partId,
|
||||
partName,
|
||||
|
||||
@@ -5,6 +5,8 @@ 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'
|
||||
import { useAwardActivity } from '@/hooks/use-gamification'
|
||||
import { XP_REWARDS } from '@/lib/gamification-service'
|
||||
|
||||
const MAX_CHARS = 1000
|
||||
const GUEST_LIMIT = 3
|
||||
@@ -18,6 +20,7 @@ export function WritingChecker() {
|
||||
const { mutate: checkWriting, isPending, isError, error, data: feedback } = useWritingCheck()
|
||||
const { requireAuth } = useRequireAuth()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const { mutate: awardActivity } = useAwardActivity()
|
||||
|
||||
const dailyLimit = user ? AUTH_LIMIT : GUEST_LIMIT
|
||||
|
||||
@@ -41,6 +44,7 @@ export function WritingChecker() {
|
||||
checkWriting(text, {
|
||||
onSuccess: () => {
|
||||
if (user) {
|
||||
awardActivity({ xp: XP_REWARDS.writing })
|
||||
countTodayWritingSubmissions(user.id).then((used) => setRemaining(AUTH_LIMIT - used))
|
||||
} else {
|
||||
setRemaining(getRemainingChecks())
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
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 }
|
||||
import { useLeaderboard } from '@/hooks/use-gamification'
|
||||
|
||||
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' :
|
||||
rank === 1 ? 'bg-amber-200 text-amber-800' :
|
||||
rank === 2 ? 'bg-slate-200 text-slate-700' :
|
||||
rank === 3 ? 'bg-orange-200 text-orange-700' :
|
||||
'bg-slate-100 text-slate-600',
|
||||
)}>
|
||||
{rank}
|
||||
@@ -36,68 +22,81 @@ function initials(name: string) {
|
||||
|
||||
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)
|
||||
const { data: rows, isLoading } = useLeaderboard()
|
||||
|
||||
return (
|
||||
<div className="lg:col-span-8 bg-white p-6 rounded-xl shadow-sm">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h3 className="text-base font-bold text-slate-800">Bảng xếp hạng tuần</h3>
|
||||
<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 bè</span>
|
||||
</div>
|
||||
<span className="px-3 py-1 bg-blue-600 text-white rounded-full text-xs font-bold">Top tuần này</span>
|
||||
</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)}
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-12 bg-slate-100 rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (!rows || rows.length === 0) && (
|
||||
<div className="text-center py-8 text-slate-400 text-sm">
|
||||
<span className="material-symbols-outlined block mb-2 text-slate-300" style={{ fontSize: 40 }}>leaderboard</span>
|
||||
Chưa có dữ liệu tuần này. Hãy hoàn thành bài học để xuất hiện trên bảng!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && rows && rows.length > 0 && (
|
||||
<table className="w-full text-left border-separate border-spacing-y-1.5">
|
||||
<thead>
|
||||
<tr className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
||||
<th className="pb-2 pl-4 w-16">Hạng</th>
|
||||
<th className="pb-2">Người học</th>
|
||||
<th className="pb-2 text-right pr-4">XP tuần</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const isMe = row.userId === user?.id
|
||||
return (
|
||||
<tr
|
||||
key={row.userId}
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
isMe
|
||||
? 'bg-blue-50 outline outline-2 outline-blue-200 rounded-xl'
|
||||
: 'bg-slate-50/60 hover:bg-slate-100',
|
||||
)}
|
||||
>
|
||||
<td className="py-2.5 pl-4 rounded-l-xl">
|
||||
<RankBadge rank={row.rank} />
|
||||
</td>
|
||||
<td className="py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0',
|
||||
isMe
|
||||
? 'bg-blue-600 text-white ring-2 ring-blue-300 ring-offset-1'
|
||||
: 'bg-slate-200 text-slate-600',
|
||||
)}>
|
||||
{initials(row.displayName)}
|
||||
</div>
|
||||
<span className={cn('text-sm font-bold', isMe && 'text-blue-600')}>
|
||||
{isMe ? `${row.displayName} (Bạn)` : row.displayName}
|
||||
</span>
|
||||
</div>
|
||||
<span className={cn('text-sm font-bold', isMe && 'text-blue-600')}>
|
||||
{isMe ? `${row.name} (Bạn)` : row.name}
|
||||
</td>
|
||||
<td className="py-2.5 pr-4 text-right rounded-r-xl">
|
||||
<span className={cn('text-sm font-bold', isMe ? 'text-blue-600' : 'text-slate-600')}>
|
||||
{row.xpEarned.toLocaleString('vi-VN')} XP
|
||||
</span>
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-6">
|
||||
{/* 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" />
|
||||
<span className="text-xs uppercase tracking-widest text-slate-400 font-bold">Số dư Xu</span>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-4xl font-extrabold text-slate-800">{state.xu.toLocaleString('vi-VN')}</span>
|
||||
<span className="material-symbols-outlined text-3xl text-amber-400" style={{ fontVariationSettings: "'FILL' 1" }}>monetization_on</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>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1.5 font-medium">Dùng để mở tính năng premium</p>
|
||||
</div>
|
||||
@@ -22,10 +40,14 @@ export function StatsRow({ state }: Props) {
|
||||
<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>
|
||||
<span className="text-4xl font-extrabold">{streak} Ngày</span>
|
||||
<span className="material-symbols-outlined text-3xl text-amber-300" style={{ fontVariationSettings: "'FILL' 1" }}>
|
||||
local_fire_department
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs opacity-80 mt-1.5 font-medium">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>
|
||||
|
||||
@@ -34,10 +56,10 @@ export function StatsRow({ state }: Props) {
|
||||
<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>
|
||||
<span className="text-4xl font-extrabold text-slate-800">Level {numericLevel}</span>
|
||||
</div>
|
||||
<span className="inline-block mt-2 px-3 py-1 bg-amber-50 text-amber-600 text-xs font-bold rounded-full border border-amber-200">
|
||||
Hạng {state.levelName}
|
||||
Hạng {LEVEL_NAMES[level]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-14 h-14 bg-slate-100 flex items-center justify-center rounded-2xl rotate-12 flex-shrink-0">
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
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']
|
||||
|
||||
function getTodayIdx() {
|
||||
@@ -10,9 +14,25 @@ function getTodayIdx() {
|
||||
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 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 (
|
||||
<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>
|
||||
<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>
|
||||
<span className="text-2xl font-black text-green-600">
|
||||
{state.weeklyCompleted}/{state.weeklyGoal}
|
||||
{weeklyCompleted}/{WEEKLY_GOAL}
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
{DAY_LABELS.map((label, i) => {
|
||||
const isToday = i === todayIdx
|
||||
const done = state.weekActivity[i]
|
||||
const done = weekActivity[i]
|
||||
const future = i > todayIdx
|
||||
|
||||
return (
|
||||
|
||||
@@ -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 }) {
|
||||
const r = 72
|
||||
@@ -32,17 +32,20 @@ function ProgressRing({ percent, xp, xpNext }: { percent: number; xp: number; xp
|
||||
)
|
||||
}
|
||||
|
||||
export function XpProgressCard({ state }: Props) {
|
||||
const percent = Math.round((state.xp / state.xpNextLevel) * 100)
|
||||
export function XpProgressCard({ xp }: Props) {
|
||||
const xpNext = calcXpNextLevel(xp)
|
||||
const levelXpStart = Math.floor(xp / 100) * 100
|
||||
const percent = Math.round(((xp - levelXpStart) / (xpNext - levelXpStart)) * 100)
|
||||
const nextLevel = calcNumericLevel(xp) + 1
|
||||
|
||||
return (
|
||||
<div className="lg:col-span-5 bg-white p-6 rounded-xl shadow-sm flex flex-col items-center justify-center text-center">
|
||||
<h3 className="text-base font-bold mb-5 self-start text-slate-800">Tiến độ Cấp độ</h3>
|
||||
|
||||
<ProgressRing percent={percent} xp={state.xp} xpNext={state.xpNextLevel} />
|
||||
<ProgressRing percent={percent} xp={xp} xpNext={xpNext} />
|
||||
|
||||
<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>
|
||||
|
||||
<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">
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
// Phase 3: Xu balance reads from localStorage until gamification DB is live
|
||||
const XU_STORAGE_KEY = 'xu_balance'
|
||||
const DEFAULT_XU = 50 // welcome bonus
|
||||
import { useXuTransactions } from '@/hooks/use-gamification'
|
||||
import { useGamification } from '@/hooks/use-gamification'
|
||||
import type { XuTransactionType } from '@/types'
|
||||
|
||||
function getXuBalance(): number {
|
||||
const stored = localStorage.getItem(XU_STORAGE_KEY)
|
||||
return stored ? parseInt(stored, 10) : DEFAULT_XU
|
||||
const TX_ICON: Record<XuTransactionType, string> = {
|
||||
earn_welcome: 'redeem',
|
||||
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() {
|
||||
const balance = getXuBalance()
|
||||
const { data: gam } = useGamification()
|
||||
const { data: txs, isLoading } = useXuTransactions(5)
|
||||
|
||||
const balance = gam?.xu ?? 0
|
||||
|
||||
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">
|
||||
@@ -29,16 +32,34 @@ export function XuWalletCard() {
|
||||
</div>
|
||||
|
||||
<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 có giao dịch nào
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && txs && txs.map((t) => (
|
||||
<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"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>{t.icon}</span>
|
||||
<span>{t.label}</span>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>
|
||||
{TX_ICON[t.type] ?? 'swap_horiz'}
|
||||
</span>
|
||||
<span className="truncate max-w-[120px]">{t.description ?? t.type}</span>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user