feat: Add gamification system

Backend:
- Add XP, level, streak fields to User model
- Add tier, category, xp_reward fields to Badge model
- Create gamification service with XP, levels, streaks, badge checking
- Add gamification router with level progress, leaderboard endpoints
- Define 16 badge types across attendance, ratings, social, milestones

Frontend:
- LevelProgressCard component with XP bar and streak display
- XPLeaderboard component showing top users
- Integrate level progress into profile page

Slug System:
- All entities now support slug-based URLs
- Performances use songslug-YYYY-MM-DD format
This commit is contained in:
fullsizemalt 2025-12-21 18:58:42 -08:00
parent 66b5039337
commit 5ffb428bb8
7 changed files with 766 additions and 4 deletions

View file

@ -1,5 +1,5 @@
from fastapi import FastAPI from fastapi import FastAPI
from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase, gamification
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@ -36,6 +36,7 @@ app.include_router(leaderboards.router)
app.include_router(stats.router) app.include_router(stats.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(chase.router) app.include_router(chase.router)
app.include_router(gamification.router)
@app.get("/") @app.get("/")
def read_root(): def read_root():

View file

@ -174,6 +174,12 @@ class User(SQLModel, table=True):
bio: Optional[str] = Field(default=None) bio: Optional[str] = Field(default=None)
avatar: Optional[str] = Field(default=None) avatar: Optional[str] = Field(default=None)
# Gamification
xp: int = Field(default=0, description="Experience points")
level: int = Field(default=1, description="User level based on XP")
streak_days: int = Field(default=0, description="Consecutive days active")
last_activity: Optional[datetime] = Field(default=None)
# Email verification # Email verification
email_verified: bool = Field(default=False) email_verified: bool = Field(default=False)
verification_token: Optional[str] = Field(default=None) verification_token: Optional[str] = Field(default=None)
@ -212,6 +218,9 @@ class Badge(SQLModel, table=True):
description: str description: str
icon: str = Field(description="Lucide icon name or image URL") icon: str = Field(description="Lucide icon name or image URL")
slug: str = Field(unique=True, index=True) slug: str = Field(unique=True, index=True)
tier: str = Field(default="bronze", description="bronze, silver, gold, platinum, diamond")
category: str = Field(default="general", description="attendance, ratings, social, milestones")
xp_reward: int = Field(default=50, description="XP awarded when badge is earned")
class UserBadge(SQLModel, table=True): class UserBadge(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)

View file

@ -0,0 +1,190 @@
"""
Gamification Router - XP, Levels, Leaderboards
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from typing import List
from pydantic import BaseModel
from datetime import datetime
from database import get_session
from models import User, Badge, UserBadge
from routers.auth import get_current_user
from services.gamification import (
calculate_level,
xp_for_next_level,
update_streak,
check_and_award_badges,
get_leaderboard,
seed_badges,
LEVEL_NAMES,
XP_REWARDS,
)
router = APIRouter(prefix="/gamification", tags=["gamification"])
class LevelProgress(BaseModel):
current_xp: int
level: int
level_name: str
xp_for_next: int
xp_progress: int
progress_percent: float
streak_days: int
class LeaderboardEntry(BaseModel):
rank: int
username: str
xp: int
level: int
level_name: str
streak: int
class BadgeResponse(BaseModel):
id: int
name: str
description: str
icon: str
slug: str
tier: str
category: str
awarded_at: datetime | None = None
class XPRewardsInfo(BaseModel):
rewards: dict
level_thresholds: List[int]
level_names: dict
@router.get("/me", response_model=LevelProgress)
async def get_my_progress(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Get current user's XP and level progress"""
xp_needed, xp_progress = xp_for_next_level(current_user.xp)
progress_percent = (xp_progress / xp_needed * 100) if xp_needed > 0 else 100
return LevelProgress(
current_xp=current_user.xp,
level=current_user.level,
level_name=LEVEL_NAMES.get(current_user.level, "Unknown"),
xp_for_next=xp_needed,
xp_progress=xp_progress,
progress_percent=round(progress_percent, 1),
streak_days=current_user.streak_days,
)
@router.post("/activity")
async def record_activity(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Record user activity and update streak"""
streak = update_streak(session, current_user)
new_badges = check_and_award_badges(session, current_user)
session.commit()
return {
"streak_days": streak,
"new_badges": [b.name for b in new_badges],
"xp": current_user.xp,
"level": current_user.level,
}
@router.get("/leaderboard", response_model=List[LeaderboardEntry])
async def get_xp_leaderboard(
limit: int = 10,
session: Session = Depends(get_session)
):
"""Get top users by XP"""
leaders = get_leaderboard(session, limit)
return [
LeaderboardEntry(
rank=i + 1,
username=l["email"],
xp=l["xp"],
level=l["level"],
level_name=l["level_name"],
streak=l["streak"],
)
for i, l in enumerate(leaders)
]
@router.get("/badges", response_model=List[BadgeResponse])
async def get_all_badges(session: Session = Depends(get_session)):
"""Get all available badges"""
badges = session.exec(select(Badge).order_by(Badge.tier, Badge.category)).all()
return [
BadgeResponse(
id=b.id,
name=b.name,
description=b.description,
icon=b.icon,
slug=b.slug,
tier=b.tier,
category=b.category,
)
for b in badges
]
@router.get("/badges/me", response_model=List[BadgeResponse])
async def get_my_badges(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Get current user's earned badges"""
user_badges = session.exec(
select(UserBadge, Badge)
.join(Badge)
.where(UserBadge.user_id == current_user.id)
.order_by(UserBadge.awarded_at.desc())
).all()
return [
BadgeResponse(
id=b.id,
name=b.name,
description=b.description,
icon=b.icon,
slug=b.slug,
tier=b.tier,
category=b.category,
awarded_at=ub.awarded_at,
)
for ub, b in user_badges
]
@router.get("/info", response_model=XPRewardsInfo)
async def get_xp_info():
"""Get XP reward values and level thresholds"""
from services.gamification import LEVEL_THRESHOLDS
return XPRewardsInfo(
rewards=XP_REWARDS,
level_thresholds=LEVEL_THRESHOLDS,
level_names=LEVEL_NAMES,
)
@router.post("/seed-badges")
async def seed_badge_data(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Seed badge definitions (admin only)"""
if current_user.role not in ["admin", "moderator"]:
raise HTTPException(status_code=403, detail="Admin access required")
seed_badges(session)
return {"message": "Badges seeded successfully"}

View file

@ -0,0 +1,257 @@
"""
Gamification Service - XP, Levels, Badges, and Streaks
"""
from datetime import datetime, timedelta
from typing import Optional, List, Tuple
from sqlmodel import Session, select, func
from models import User, Badge, UserBadge, Attendance, Rating, Review, Comment
# XP rewards for different actions
XP_REWARDS = {
"attendance_add": 25, # Mark a show as attended
"rating_submit": 10, # Submit a rating
"review_write": 50, # Write a review
"comment_post": 5, # Post a comment
"first_rating": 25, # First ever rating (bonus)
"first_review": 50, # First ever review (bonus)
"streak_bonus": 10, # Per day of streak
"daily_login": 5, # Daily activity bonus
}
# Level thresholds (XP required for each level)
LEVEL_THRESHOLDS = [
0, # Level 1
100, # Level 2
250, # Level 3
500, # Level 4
1000, # Level 5
2000, # Level 6
3500, # Level 7
5500, # Level 8
8000, # Level 9
12000, # Level 10
18000, # Level 11
26000, # Level 12
36000, # Level 13
50000, # Level 14
70000, # Level 15
]
LEVEL_NAMES = {
1: "Rookie",
2: "Fan",
3: "Enthusiast",
4: "Regular",
5: "Dedicated",
6: "Veteran",
7: "Expert",
8: "Master",
9: "Elite",
10: "Legend",
11: "Icon",
12: "Mythic",
13: "Transcendent",
14: "Eternal",
15: "Immortal",
}
def calculate_level(xp: int) -> int:
"""Calculate level based on XP"""
for level, threshold in enumerate(LEVEL_THRESHOLDS):
if xp < threshold:
return level # Previous level
return len(LEVEL_THRESHOLDS) # Max level
def xp_for_next_level(current_xp: int) -> Tuple[int, int]:
"""Returns (XP needed for next level, XP progress toward next level)"""
current_level = calculate_level(current_xp)
if current_level >= len(LEVEL_THRESHOLDS):
return 0, 0 # Max level
next_threshold = LEVEL_THRESHOLDS[current_level]
prev_threshold = LEVEL_THRESHOLDS[current_level - 1] if current_level > 0 else 0
progress = current_xp - prev_threshold
needed = next_threshold - prev_threshold
return needed, progress
def award_xp(session: Session, user: User, amount: int, reason: str) -> Tuple[int, bool]:
"""
Award XP to a user and check for level up.
Returns (new_total_xp, did_level_up)
"""
old_level = user.level
user.xp += amount
new_level = calculate_level(user.xp)
level_up = new_level > old_level
if level_up:
user.level = new_level
session.add(user)
return user.xp, level_up
def update_streak(session: Session, user: User) -> int:
"""Update user's activity streak. Returns current streak."""
now = datetime.utcnow()
if user.last_activity:
days_since = (now.date() - user.last_activity.date()).days
if days_since == 0:
# Same day, no streak change
pass
elif days_since == 1:
# Next day, increment streak
user.streak_days += 1
# Award streak bonus
award_xp(session, user, XP_REWARDS["streak_bonus"] * min(user.streak_days, 7), "streak_bonus")
else:
# Streak broken
user.streak_days = 1
else:
user.streak_days = 1
user.last_activity = now
session.add(user)
return user.streak_days
# Badge definitions for seeding
BADGE_DEFINITIONS = [
# Attendance badges
{"name": "First Show", "slug": "first-show", "description": "Marked your first show as attended", "icon": "ticket", "tier": "bronze", "category": "attendance", "xp_reward": 50},
{"name": "Regular", "slug": "regular-10", "description": "Attended 10 shows", "icon": "calendar", "tier": "bronze", "category": "attendance", "xp_reward": 100},
{"name": "Veteran", "slug": "veteran-50", "description": "Attended 50 shows", "icon": "award", "tier": "silver", "category": "attendance", "xp_reward": 250},
{"name": "Lifer", "slug": "lifer-100", "description": "Attended 100 shows", "icon": "crown", "tier": "gold", "category": "attendance", "xp_reward": 500},
{"name": "Legend", "slug": "legend-250", "description": "Attended 250 shows", "icon": "star", "tier": "platinum", "category": "attendance", "xp_reward": 1000},
# Rating badges
{"name": "First Rating", "slug": "first-rating", "description": "Submitted your first rating", "icon": "star", "tier": "bronze", "category": "ratings", "xp_reward": 25},
{"name": "Critic", "slug": "critic-50", "description": "Submitted 50 ratings", "icon": "thumbs-up", "tier": "silver", "category": "ratings", "xp_reward": 150},
{"name": "Connoisseur", "slug": "connoisseur-200", "description": "Submitted 200 ratings", "icon": "wine", "tier": "gold", "category": "ratings", "xp_reward": 400},
# Review badges
{"name": "Wordsmith", "slug": "first-review", "description": "Wrote your first review", "icon": "pen", "tier": "bronze", "category": "social", "xp_reward": 50},
{"name": "Columnist", "slug": "columnist-10", "description": "Wrote 10 reviews", "icon": "file-text", "tier": "silver", "category": "social", "xp_reward": 200},
{"name": "Essayist", "slug": "essayist-50", "description": "Wrote 50 reviews", "icon": "book-open", "tier": "gold", "category": "social", "xp_reward": 500},
# Streak badges
{"name": "Consistent", "slug": "streak-7", "description": "7-day activity streak", "icon": "flame", "tier": "bronze", "category": "milestones", "xp_reward": 75},
{"name": "Dedicated", "slug": "streak-30", "description": "30-day activity streak", "icon": "zap", "tier": "silver", "category": "milestones", "xp_reward": 300},
{"name": "Unstoppable", "slug": "streak-100", "description": "100-day activity streak", "icon": "rocket", "tier": "gold", "category": "milestones", "xp_reward": 750},
# Special badges
{"name": "Debut Hunter", "slug": "debut-witness", "description": "Was in attendance for a song debut", "icon": "sparkles", "tier": "gold", "category": "milestones", "xp_reward": 200},
{"name": "Heady Spotter", "slug": "heady-witness", "description": "Attended a top-rated performance", "icon": "trophy", "tier": "silver", "category": "milestones", "xp_reward": 150},
{"name": "Song Chaser", "slug": "chase-caught-5", "description": "Caught 5 chase songs", "icon": "target", "tier": "silver", "category": "milestones", "xp_reward": 200},
]
def check_and_award_badges(session: Session, user: User) -> List[Badge]:
"""
Check all badge criteria and award any earned badges.
Returns list of newly awarded badges.
"""
awarded = []
# Get user's existing badge slugs
existing = session.exec(
select(Badge.slug)
.join(UserBadge)
.where(UserBadge.user_id == user.id)
).all()
existing_slugs = set(existing)
# Count attendance
attendance_count = session.exec(
select(func.count(Attendance.id))
.where(Attendance.user_id == user.id)
).one() or 0
# Count ratings
rating_count = session.exec(
select(func.count(Rating.id))
.where(Rating.user_id == user.id)
).one() or 0
# Count reviews
review_count = session.exec(
select(func.count(Review.id))
.where(Review.user_id == user.id)
).one() or 0
badges_to_check = [
("first-show", attendance_count >= 1),
("regular-10", attendance_count >= 10),
("veteran-50", attendance_count >= 50),
("lifer-100", attendance_count >= 100),
("legend-250", attendance_count >= 250),
("first-rating", rating_count >= 1),
("critic-50", rating_count >= 50),
("connoisseur-200", rating_count >= 200),
("first-review", review_count >= 1),
("columnist-10", review_count >= 10),
("essayist-50", review_count >= 50),
("streak-7", user.streak_days >= 7),
("streak-30", user.streak_days >= 30),
("streak-100", user.streak_days >= 100),
]
for slug, condition in badges_to_check:
if condition and slug not in existing_slugs:
badge = session.exec(select(Badge).where(Badge.slug == slug)).first()
if badge:
user_badge = UserBadge(user_id=user.id, badge_id=badge.id)
session.add(user_badge)
award_xp(session, user, badge.xp_reward, f"badge_{slug}")
awarded.append(badge)
existing_slugs.add(slug)
if awarded:
session.commit()
return awarded
def get_leaderboard(session: Session, limit: int = 10) -> List[dict]:
"""Get top users by XP"""
users = session.exec(
select(User)
.where(User.is_active == True)
.order_by(User.xp.desc())
.limit(limit)
).all()
return [
{
"id": u.id,
"email": u.email.split("@")[0], # Just username part
"xp": u.xp,
"level": u.level,
"level_name": LEVEL_NAMES.get(u.level, "Unknown"),
"streak": u.streak_days,
}
for u in users
]
def seed_badges(session: Session):
"""Seed all badge definitions into the database"""
for badge_def in BADGE_DEFINITIONS:
existing = session.exec(
select(Badge).where(Badge.slug == badge_def["slug"])
).first()
if not existing:
badge = Badge(**badge_def)
session.add(badge)
session.commit()
print(f"Seeded {len(BADGE_DEFINITIONS)} badge definitions")

View file

@ -13,6 +13,7 @@ import { UserReviewsList } from "@/components/profile/user-reviews-list"
import { UserGroupsList } from "@/components/profile/user-groups-list" import { UserGroupsList } from "@/components/profile/user-groups-list"
import { ChaseSongsList } from "@/components/profile/chase-songs-list" import { ChaseSongsList } from "@/components/profile/chase-songs-list"
import { AttendanceSummary } from "@/components/profile/attendance-summary" import { AttendanceSummary } from "@/components/profile/attendance-summary"
import { LevelProgressCard } from "@/components/gamification/level-progress"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { motion } from "framer-motion" import { motion } from "framer-motion"
@ -178,11 +179,20 @@ export default function ProfilePage() {
</TabsList> </TabsList>
<TabsContent value="overview" className="space-y-6"> <TabsContent value="overview" className="space-y-6">
{/* Attendance Summary */} {/* Level Progress */}
<motion.div <motion.div
initial={{ opacity: 0, x: -10 }} initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
>
<LevelProgressCard />
</motion.div>
{/* Attendance Summary */}
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: 0.1 }}
> >
<AttendanceSummary /> <AttendanceSummary />
</motion.div> </motion.div>
@ -191,12 +201,11 @@ export default function ProfilePage() {
<motion.div <motion.div
initial={{ opacity: 0, x: -10 }} initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: 0.1 }} transition={{ duration: 0.2, delay: 0.2 }}
> >
<ChaseSongsList /> <ChaseSongsList />
</motion.div> </motion.div>
{/* Achievements */}
<motion.div <motion.div
initial={{ opacity: 0, x: -10 }} initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}

View file

@ -0,0 +1,147 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Flame, Star, Trophy, Zap, TrendingUp } from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
import { motion } from "framer-motion"
interface LevelProgress {
current_xp: number
level: number
level_name: string
xp_for_next: number
xp_progress: number
progress_percent: number
streak_days: number
}
const TIER_COLORS = {
bronze: "bg-amber-700/20 text-amber-600 border-amber-600/30",
silver: "bg-slate-400/20 text-slate-300 border-slate-400/30",
gold: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
platinum: "bg-cyan-400/20 text-cyan-300 border-cyan-400/30",
diamond: "bg-purple-500/20 text-purple-300 border-purple-500/30",
}
export function LevelProgressCard() {
const [progress, setProgress] = useState<LevelProgress | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchProgress()
}, [])
const fetchProgress = async () => {
const token = localStorage.getItem("token")
if (!token) {
setLoading(false)
return
}
try {
const res = await fetch(`${getApiUrl()}/gamification/me`, {
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
const data = await res.json()
setProgress(data)
}
} catch (err) {
console.error("Failed to fetch level progress", err)
} finally {
setLoading(false)
}
}
if (loading) {
return <div className="text-muted-foreground">Loading...</div>
}
if (!progress) {
return null
}
return (
<Card className="overflow-hidden">
<CardHeader className="bg-gradient-to-r from-primary/10 via-purple-500/10 to-pink-500/10">
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-primary" />
Level Progress
</div>
{progress.streak_days > 0 && (
<Badge variant="outline" className="gap-1 bg-orange-500/10 text-orange-400 border-orange-500/30">
<Flame className="h-3 w-3" />
{progress.streak_days} day streak
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
{/* Level Badge */}
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="flex items-center gap-4"
>
<div className="relative">
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center text-2xl font-bold text-white shadow-lg">
{progress.level}
</div>
<div className="absolute -bottom-1 -right-1 bg-background rounded-full p-1">
<Star className="h-4 w-4 text-yellow-500" />
</div>
</div>
<div>
<h3 className="text-xl font-bold">{progress.level_name}</h3>
<p className="text-sm text-muted-foreground">
{progress.current_xp.toLocaleString()} XP total
</p>
</div>
</motion.div>
{/* Progress Bar */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Level {progress.level + 1}
</span>
<span className="font-mono">
{progress.xp_progress} / {progress.xp_for_next} XP
</span>
</div>
<Progress value={progress.progress_percent} className="h-3" />
<p className="text-xs text-muted-foreground text-center">
{Math.round(progress.xp_for_next - progress.xp_progress)} XP until next level
</p>
</div>
{/* XP Tips */}
<div className="pt-4 border-t">
<p className="text-xs text-muted-foreground mb-2 font-medium">Earn XP by:</p>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-2">
<Trophy className="h-3 w-3 text-primary" />
<span>Rating performances</span>
</div>
<div className="flex items-center gap-2">
<Zap className="h-3 w-3 text-yellow-500" />
<span>Writing reviews</span>
</div>
<div className="flex items-center gap-2">
<Star className="h-3 w-3 text-purple-500" />
<span>Marking attendance</span>
</div>
<div className="flex items-center gap-2">
<Flame className="h-3 w-3 text-orange-500" />
<span>Daily streaks</span>
</div>
</div>
</div>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,149 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Trophy, Flame, Medal, Crown } from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
import { motion } from "framer-motion"
interface LeaderboardEntry {
rank: number
username: string
xp: number
level: number
level_name: string
streak: number
}
const getRankIcon = (rank: number) => {
switch (rank) {
case 1:
return <Crown className="h-5 w-5 text-yellow-500" />
case 2:
return <Medal className="h-5 w-5 text-slate-400" />
case 3:
return <Medal className="h-5 w-5 text-amber-600" />
default:
return <span className="text-muted-foreground font-mono">#{rank}</span>
}
}
const getRankBg = (rank: number) => {
switch (rank) {
case 1:
return "bg-gradient-to-r from-yellow-500/20 to-amber-500/10 border-yellow-500/30"
case 2:
return "bg-gradient-to-r from-slate-400/20 to-slate-500/10 border-slate-400/30"
case 3:
return "bg-gradient-to-r from-amber-600/20 to-amber-700/10 border-amber-600/30"
default:
return "bg-muted/30"
}
}
export function XPLeaderboard() {
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchLeaderboard()
}, [])
const fetchLeaderboard = async () => {
try {
const res = await fetch(`${getApiUrl()}/gamification/leaderboard?limit=10`)
if (res.ok) {
const data = await res.json()
setLeaderboard(data)
}
} catch (err) {
console.error("Failed to fetch leaderboard", err)
} finally {
setLoading(false)
}
}
if (loading) {
return <div className="text-muted-foreground">Loading leaderboard...</div>
}
if (leaderboard.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" />
XP Leaderboard
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-center py-4">
No rankings yet. Be the first!
</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader className="bg-gradient-to-r from-yellow-500/10 via-primary/5 to-transparent">
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" />
XP Leaderboard
</CardTitle>
</CardHeader>
<CardContent className="pt-4">
<div className="space-y-2">
{leaderboard.map((entry, index) => (
<motion.div
key={entry.username}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className={`flex items-center gap-3 p-3 rounded-lg border ${getRankBg(entry.rank)}`}
>
<div className="w-8 flex justify-center">
{getRankIcon(entry.rank)}
</div>
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-primary/10 text-primary">
{entry.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{entry.username}</span>
<Badge variant="outline" className="text-xs">
Lv.{entry.level}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{entry.level_name}
</p>
</div>
<div className="text-right">
<div className="font-bold font-mono text-primary">
{entry.xp.toLocaleString()}
</div>
<div className="text-xs text-muted-foreground">XP</div>
</div>
{entry.streak > 0 && (
<div className="flex items-center gap-1 text-orange-500">
<Flame className="h-4 w-4" />
<span className="text-sm font-medium">{entry.streak}</span>
</div>
)}
</motion.div>
))}
</div>
</CardContent>
</Card>
)
}