diff --git a/backend/main.py b/backend/main.py index 767a954..2c9bd49 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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(): diff --git a/backend/models.py b/backend/models.py index 628d8fd..cf12cfb 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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) diff --git a/backend/routers/gamification.py b/backend/routers/gamification.py new file mode 100644 index 0000000..45eb602 --- /dev/null +++ b/backend/routers/gamification.py @@ -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"} diff --git a/backend/services/gamification.py b/backend/services/gamification.py new file mode 100644 index 0000000..1754778 --- /dev/null +++ b/backend/services/gamification.py @@ -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") diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index e0ba5ef..02d1010 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -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() { - {/* Attendance Summary */} + {/* Level Progress */} + + + + {/* Attendance Summary */} + @@ -191,12 +201,11 @@ export default function ProfilePage() { - {/* Achievements */} (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
Loading...
+ } + + if (!progress) { + return null + } + + return ( + + + +
+ + Level Progress +
+ {progress.streak_days > 0 && ( + + + {progress.streak_days} day streak + + )} +
+
+ + {/* Level Badge */} + +
+
+ {progress.level} +
+
+ +
+
+
+

{progress.level_name}

+

+ {progress.current_xp.toLocaleString()} XP total +

+
+
+ + {/* Progress Bar */} +
+
+ + Level {progress.level + 1} + + + {progress.xp_progress} / {progress.xp_for_next} XP + +
+ +

+ {Math.round(progress.xp_for_next - progress.xp_progress)} XP until next level +

+
+ + {/* XP Tips */} +
+

Earn XP by:

+
+
+ + Rating performances +
+
+ + Writing reviews +
+
+ + Marking attendance +
+
+ + Daily streaks +
+
+
+
+
+ ) +} diff --git a/frontend/components/gamification/xp-leaderboard.tsx b/frontend/components/gamification/xp-leaderboard.tsx new file mode 100644 index 0000000..9a17430 --- /dev/null +++ b/frontend/components/gamification/xp-leaderboard.tsx @@ -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 + case 2: + return + case 3: + return + default: + return #{rank} + } +} + +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([]) + 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
Loading leaderboard...
+ } + + if (leaderboard.length === 0) { + return ( + + + + + XP Leaderboard + + + +

+ No rankings yet. Be the first! +

+
+
+ ) + } + + return ( + + + + + XP Leaderboard + + + +
+ {leaderboard.map((entry, index) => ( + +
+ {getRankIcon(entry.rank)} +
+ + + + {entry.username.slice(0, 2).toUpperCase()} + + + +
+
+ {entry.username} + + Lv.{entry.level} + +
+

+ {entry.level_name} +

+
+ +
+
+ {entry.xp.toLocaleString()} +
+
XP
+
+ + {entry.streak > 0 && ( +
+ + {entry.streak} +
+ )} +
+ ))} +
+
+
+ ) +}