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 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():
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
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 { 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 }}
|
||||||
|
|
|
||||||
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