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:
parent
66b5039337
commit
5ffb428bb8
7 changed files with 766 additions and 4 deletions
|
|
@ -1,5 +1,5 @@
|
|||
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
|
||||
|
||||
|
|
@ -36,6 +36,7 @@ app.include_router(leaderboards.router)
|
|||
app.include_router(stats.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(chase.router)
|
||||
app.include_router(gamification.router)
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
|
|
|
|||
|
|
@ -174,6 +174,12 @@ class User(SQLModel, table=True):
|
|||
bio: 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_verified: bool = Field(default=False)
|
||||
verification_token: Optional[str] = Field(default=None)
|
||||
|
|
@ -212,6 +218,9 @@ class Badge(SQLModel, table=True):
|
|||
description: str
|
||||
icon: str = Field(description="Lucide icon name or image URL")
|
||||
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):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
|
|
|
|||
190
backend/routers/gamification.py
Normal file
190
backend/routers/gamification.py
Normal 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"}
|
||||
257
backend/services/gamification.py
Normal file
257
backend/services/gamification.py
Normal 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")
|
||||
|
|
@ -13,6 +13,7 @@ import { UserReviewsList } from "@/components/profile/user-reviews-list"
|
|||
import { UserGroupsList } from "@/components/profile/user-groups-list"
|
||||
import { ChaseSongsList } from "@/components/profile/chase-songs-list"
|
||||
import { AttendanceSummary } from "@/components/profile/attendance-summary"
|
||||
import { LevelProgressCard } from "@/components/gamification/level-progress"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
|
|
@ -178,11 +179,20 @@ export default function ProfilePage() {
|
|||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
{/* Attendance Summary */}
|
||||
{/* Level Progress */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
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 />
|
||||
</motion.div>
|
||||
|
|
@ -191,12 +201,11 @@ export default function ProfilePage() {
|
|||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.2, delay: 0.1 }}
|
||||
transition={{ duration: 0.2, delay: 0.2 }}
|
||||
>
|
||||
<ChaseSongsList />
|
||||
</motion.div>
|
||||
|
||||
{/* Achievements */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
|
|
|
|||
147
frontend/components/gamification/level-progress.tsx
Normal file
147
frontend/components/gamification/level-progress.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
149
frontend/components/gamification/xp-leaderboard.tsx
Normal file
149
frontend/components/gamification/xp-leaderboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue