From 20ae17699273fec0015ae02edb0415be78fa1a44 Mon Sep 17 00:00:00 2001 From: renolation Date: Sun, 12 Apr 2026 23:12:29 +0700 Subject: [PATCH] update real data --- src/hooks/use-gamification.ts | 51 +++++ src/lib/gamification-service.ts | 209 +++++++++++++++++++ src/pages/Dashboard.tsx | 49 ++++- src/pages/TestResult.tsx | 4 + src/pages/WritingChecker.tsx | 4 + src/pages/dashboard/LeaderboardCard.tsx | 141 +++++++------ src/pages/dashboard/StatsRow.tsx | 42 +++- src/pages/dashboard/WeeklySection.tsx | 34 ++- src/pages/dashboard/XpProgressCard.tsx | 15 +- src/pages/settings/XuWalletCard.tsx | 55 +++-- supabase/migrations/003_add_display_name.sql | 5 + 11 files changed, 487 insertions(+), 122 deletions(-) create mode 100644 src/hooks/use-gamification.ts create mode 100644 src/lib/gamification-service.ts create mode 100644 supabase/migrations/003_add_display_name.sql diff --git a/src/hooks/use-gamification.ts b/src/hooks/use-gamification.ts new file mode 100644 index 0000000..1b557ce --- /dev/null +++ b/src/hooks/use-gamification.ts @@ -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'] }) + }, + }) +} diff --git a/src/lib/gamification-service.ts b/src/lib/gamification-service.ts new file mode 100644 index 0000000..d0d5bb3 --- /dev/null +++ b/src/lib/gamification-service.ts @@ -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 = { 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): 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 { + 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) +} + +export async function fetchXuTransactions(userId: string, limit = 10): Promise { + 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) => ({ + 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 { + 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 { + 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 } +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 485ba26..4296089 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -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 (
- {/* Page header */}

Bảng thành tích

-

Xin chào, {user.name} — tiếp tục chuỗi học tập nhé!

+

+ Xin chào, {user.name} — tiếp tục chuỗi học tập nhé! +

- {/* Hero stats */} - + {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : ( + + )} - {/* Progress section */}
- - + +
- {/* Xu economy + leaderboard */}
- {/* FAB */} 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, diff --git a/src/pages/WritingChecker.tsx b/src/pages/WritingChecker.tsx index eba1638..e03a846 100644 --- a/src/pages/WritingChecker.tsx +++ b/src/pages/WritingChecker.tsx @@ -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()) diff --git a/src/pages/dashboard/LeaderboardCard.tsx b/src/pages/dashboard/LeaderboardCard.tsx index b308bb7..a61f1a7 100644 --- a/src/pages/dashboard/LeaderboardCard.tsx +++ b/src/pages/dashboard/LeaderboardCard.tsx @@ -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 (
{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 (

Bảng xếp hạng tuần

-
- Top 100 - Bạn bè -
+ Top tuần này
- - - - - - - - - - {allRows.map((row) => { - const isMe = 'isMe' in row && row.isMe - return ( - - - + + ) + })} + +
HạngNgười họcXP Tổng
- - -
-
- {initials(row.name)} + {isLoading && ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ )} + + {!isLoading && (!rows || rows.length === 0) && ( +
+ leaderboard + 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! +
+ )} + + {!isLoading && rows && rows.length > 0 && ( + + + + + + + + + + {rows.map((row) => { + const isMe = row.userId === user?.id + return ( + + + + - - - ) - })} - -
HạngNgười họcXP tuần
+ + +
+
+ {initials(row.displayName)} +
+ + {isMe ? `${row.displayName} (Bạn)` : row.displayName} +
- - {isMe ? `${row.name} (Bạn)` : row.name} +
+ + {row.xpEarned.toLocaleString('vi-VN')} XP - - - - {row.xp.toLocaleString('vi-VN')} XP - -
+
+ )}
) } diff --git a/src/pages/dashboard/StatsRow.tsx b/src/pages/dashboard/StatsRow.tsx index b29a8a3..78ed988 100644 --- a/src/pages/dashboard/StatsRow.tsx +++ b/src/pages/dashboard/StatsRow.tsx @@ -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 = { + 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 (
{/* Xu Balance */} @@ -10,8 +26,10 @@ export function StatsRow({ state }: Props) {
Số dư Xu
- {state.xu.toLocaleString('vi-VN')} - monetization_on + {xu.toLocaleString('vi-VN')} + + monetization_on +

Dùng để mở tính năng premium

@@ -22,10 +40,14 @@ export function StatsRow({ state }: Props) {
Chuỗi học tập
- {state.streak} Ngày - local_fire_department + {streak} Ngày + + local_fire_department +
-

Bạn thuộc top 5% người học!

+

+ {streak >= 7 ? 'Bạn thuộc top 5% người học!' : 'Giữ vững chuỗi học mỗi ngày nhé!'} +

@@ -34,10 +56,10 @@ export function StatsRow({ state }: Props) {
Cấp độ
- Level {state.level} + Level {numericLevel}
- Hạng {state.levelName} + Hạng {LEVEL_NAMES[level]}
diff --git a/src/pages/dashboard/WeeklySection.tsx b/src/pages/dashboard/WeeklySection.tsx index a395fce..686fdb2 100644 --- a/src/pages/dashboard/WeeklySection.tsx +++ b/src/pages/dashboard/WeeklySection.tsx @@ -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 (
@@ -21,10 +41,10 @@ export function WeeklySection({ state }: Props) {

Mục tiêu tuần

-

Hoàn thành {state.weeklyGoal} bài học mỗi tuần

+

Hoàn thành {WEEKLY_GOAL} bài học mỗi tuần

- {state.weeklyCompleted}/{state.weeklyGoal} + {weeklyCompleted}/{WEEKLY_GOAL}
@@ -41,7 +61,7 @@ export function WeeklySection({ state }: Props) {
{DAY_LABELS.map((label, i) => { const isToday = i === todayIdx - const done = state.weekActivity[i] + const done = weekActivity[i] const future = i > todayIdx return ( diff --git a/src/pages/dashboard/XpProgressCard.tsx b/src/pages/dashboard/XpProgressCard.tsx index 54c8992..f8dc0a8 100644 --- a/src/pages/dashboard/XpProgressCard.tsx +++ b/src/pages/dashboard/XpProgressCard.tsx @@ -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 (

Tiến độ Cấp độ

- +

- 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}!

- {RECENT_TRANSACTIONS.map((t) => ( + {isLoading && ( + <> + {[1, 2].map((i) => ( +
+ ))} + + )} + + {!isLoading && txs && txs.length === 0 && ( +
+ Chưa có giao dịch nào +
+ )} + + {!isLoading && txs && txs.map((t) => (
- {t.icon} - {t.label} + + {TX_ICON[t.type] ?? 'swap_horiz'} + + {t.description ?? t.type}
- {t.amount} + 0 ? 'text-green-300' : 'text-red-300'}`}> + {t.amount > 0 ? `+${t.amount}` : t.amount} Xu +
))}
diff --git a/supabase/migrations/003_add_display_name.sql b/supabase/migrations/003_add_display_name.sql new file mode 100644 index 0000000..6c31afc --- /dev/null +++ b/supabase/migrations/003_add_display_name.sql @@ -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;