diff --git a/backend/models.py b/backend/models.py index cf12cfb..0fbfb32 100644 --- a/backend/models.py +++ b/backend/models.py @@ -180,6 +180,14 @@ class User(SQLModel, table=True): streak_days: int = Field(default=0, description="Consecutive days active") last_activity: Optional[datetime] = Field(default=None) + # Custom Titles & Flair (tracker forum style) + custom_title: Optional[str] = Field(default=None, description="Custom title chosen by user") + title_color: Optional[str] = Field(default=None, description="Hex color for username display") + flair: Optional[str] = Field(default=None, description="Small text/emoji beside name") + is_early_adopter: bool = Field(default=False, description="First 100 users get special perks") + is_supporter: bool = Field(default=False, description="Donated/supported the platform") + joined_at: datetime = Field(default_factory=datetime.utcnow) + # Email verification email_verified: bool = Field(default=False) verification_token: Optional[str] = Field(default=None) diff --git a/backend/routers/attendance.py b/backend/routers/attendance.py index 461159d..4d6e4fe 100644 --- a/backend/routers/attendance.py +++ b/backend/routers/attendance.py @@ -5,6 +5,7 @@ from database import get_session from models import Attendance, User, Show from schemas import AttendanceCreate, AttendanceRead from auth import get_current_user +from services.gamification import award_xp, check_and_award_badges, update_streak, XP_REWARDS router = APIRouter(prefix="/attendance", tags=["attendance"]) @@ -32,6 +33,12 @@ def mark_attendance( db_attendance = Attendance(**attendance.model_dump(), user_id=current_user.id) session.add(db_attendance) + + # Award XP for marking attendance + new_xp, level_up = award_xp(session, current_user, XP_REWARDS["attendance_add"], "attendance") + update_streak(session, current_user) + new_badges = check_and_award_badges(session, current_user) + session.commit() session.refresh(db_attendance) return db_attendance diff --git a/backend/routers/gamification.py b/backend/routers/gamification.py index 45eb602..69ff1b8 100644 --- a/backend/routers/gamification.py +++ b/backend/routers/gamification.py @@ -188,3 +188,184 @@ async def seed_badge_data( seed_badges(session) return {"message": "Badges seeded successfully"} + + +# ========================================== +# TITLE/FLAIR SHOP ENDPOINTS +# ========================================== + +from services.gamification import ( + get_available_titles, + get_available_colors, + get_available_flairs, + purchase_title, + purchase_color, + purchase_flair, + get_user_display, + EARLY_ADOPTER_PERKS, +) + + +class ShopItem(BaseModel): + name: str + type: str + cost: int + level_required: int + is_owned: bool = False + + +class PurchaseRequest(BaseModel): + item_name: str + + +@router.get("/shop/titles") +async def get_title_shop( + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session) +): + """Get available titles for purchase""" + available = get_available_titles(current_user) + + return { + "current_title": current_user.custom_title, + "current_xp": current_user.xp, + "items": [ + { + "name": title, + "type": info["type"], + "cost": info["cost"], + "level_required": info["level_required"], + "is_owned": current_user.custom_title == title, + } + for title, info in available.items() + ] + } + + +@router.post("/shop/titles/purchase") +async def buy_title( + request: PurchaseRequest, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session) +): + """Purchase a title with XP""" + success, message = purchase_title(session, current_user, request.item_name) + + if not success: + raise HTTPException(status_code=400, detail=message) + + return { + "success": True, + "message": message, + "new_title": current_user.custom_title, + "remaining_xp": current_user.xp, + } + + +@router.get("/shop/colors") +async def get_color_shop( + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session) +): + """Get available username colors for purchase""" + available = get_available_colors(current_user) + + return { + "current_color": current_user.title_color, + "current_xp": current_user.xp, + "items": [ + { + "name": name, + "hex": info["hex"], + "cost": info["cost"], + "is_owned": current_user.title_color == info["hex"], + } + for name, info in available.items() + ] + } + + +@router.post("/shop/colors/purchase") +async def buy_color( + request: PurchaseRequest, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session) +): + """Purchase a username color with XP""" + success, message = purchase_color(session, current_user, request.item_name) + + if not success: + raise HTTPException(status_code=400, detail=message) + + return { + "success": True, + "message": message, + "new_color": current_user.title_color, + "remaining_xp": current_user.xp, + } + + +@router.get("/shop/flairs") +async def get_flair_shop( + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session) +): + """Get available flairs for purchase""" + available = get_available_flairs(current_user) + + return { + "current_flair": current_user.flair, + "current_xp": current_user.xp, + "items": [ + { + "name": flair, + "cost": info["cost"], + "is_owned": current_user.flair == flair, + } + for flair, info in available.items() + ] + } + + +@router.post("/shop/flairs/purchase") +async def buy_flair( + request: PurchaseRequest, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session) +): + """Purchase a flair with XP""" + success, message = purchase_flair(session, current_user, request.item_name) + + if not success: + raise HTTPException(status_code=400, detail=message) + + return { + "success": True, + "message": message, + "new_flair": current_user.flair, + "remaining_xp": current_user.xp, + } + + +@router.get("/user/{user_id}/display") +async def get_user_display_info( + user_id: int, + session: Session = Depends(get_session) +): + """Get a user's display info (title, color, flair)""" + user = session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return get_user_display(user) + + +@router.get("/early-adopter-perks") +async def get_early_adopter_info(): + """Get information about early adopter perks""" + return { + "perks": EARLY_ADOPTER_PERKS, + "description": "The first 100 users get exclusive perks including unique titles, colors, " + "and a 10% XP bonus on all actions!" + } + diff --git a/backend/routers/reviews.py b/backend/routers/reviews.py index 2eedcea..e37cf5d 100644 --- a/backend/routers/reviews.py +++ b/backend/routers/reviews.py @@ -1,10 +1,11 @@ from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query -from sqlmodel import Session, select +from sqlmodel import Session, select, func from database import get_session from models import Review, User from schemas import ReviewCreate, ReviewRead from auth import get_current_user +from services.gamification import award_xp, check_and_award_badges, update_streak, XP_REWARDS router = APIRouter(prefix="/reviews", tags=["reviews"]) @@ -17,6 +18,21 @@ def create_review( db_review = Review.model_validate(review) db_review.user_id = current_user.id session.add(db_review) + + # Check if this is user's first review for bonus XP + review_count = session.exec( + select(func.count(Review.id)).where(Review.user_id == current_user.id) + ).one() or 0 + + # Award XP + xp_amount = XP_REWARDS["review_write"] + if review_count == 0: + xp_amount += XP_REWARDS["first_review"] # Bonus for first review + + award_xp(session, current_user, xp_amount, "review") + update_streak(session, current_user) + check_and_award_badges(session, current_user) + session.commit() session.refresh(db_review) return db_review diff --git a/backend/routers/social.py b/backend/routers/social.py index 2627edb..9dcf1db 100644 --- a/backend/routers/social.py +++ b/backend/routers/social.py @@ -7,6 +7,7 @@ from models import Comment, Rating, User, Profile, Reaction from schemas import CommentCreate, CommentRead, RatingCreate, RatingRead, ReactionCreate, ReactionRead from auth import get_current_user from helpers import create_notification +from services.gamification import award_xp, check_and_award_badges, update_streak, XP_REWARDS router = APIRouter(prefix="/social", tags=["social"]) @@ -102,7 +103,7 @@ def create_rating( existing_rating = session.exec(query).first() if existing_rating: - # Update existing + # Update existing (no XP for updating) existing_rating.score = rating.score session.add(existing_rating) session.commit() @@ -112,6 +113,21 @@ def create_rating( db_rating = Rating.model_validate(rating) db_rating.user_id = current_user.id session.add(db_rating) + + # Award XP for new rating + # Check if first rating for bonus + rating_count = session.exec( + select(func.count(Rating.id)).where(Rating.user_id == current_user.id) + ).one() or 0 + + xp_amount = XP_REWARDS["rating_submit"] + if rating_count == 0: + xp_amount += XP_REWARDS["first_rating"] # Bonus for first rating + + award_xp(session, current_user, xp_amount, "rating") + update_streak(session, current_user) + check_and_award_badges(session, current_user) + session.commit() session.refresh(db_rating) return db_rating diff --git a/backend/services/gamification.py b/backend/services/gamification.py index 1754778..429a9b7 100644 --- a/backend/services/gamification.py +++ b/backend/services/gamification.py @@ -255,3 +255,216 @@ def seed_badges(session: Session): session.commit() print(f"Seeded {len(BADGE_DEFINITIONS)} badge definitions") + + +# ========================================== +# USER TITLE & FLAIR SYSTEM (Tracker-style) +# ========================================== + +# Titles that can be unlocked at certain levels +LEVEL_TITLES = { + 1: ["Rookie", "Newbie", "Fresh Ears"], + 3: ["Fan", "Listener", "Devotee"], + 5: ["Regular", "Familiar Face", "Couch Tour Pro"], + 7: ["Veteran", "Road Warrior", "Tour Rat"], + 10: ["Legend", "OG", "Scene Elder"], + 12: ["Mythic", "Phenom", "Enlightened"], + 15: ["Immortal", "Transcendent One", "Ascended"], +} + +# Titles that can be purchased with XP +PURCHASABLE_TITLES = { + "Jam Connoisseur": {"cost": 500, "min_level": 3}, + "Setlist Savant": {"cost": 750, "min_level": 5}, + "Show Historian": {"cost": 1000, "min_level": 5}, + "Type II Specialist": {"cost": 1500, "min_level": 7}, + "Heady Scholar": {"cost": 2000, "min_level": 8}, + "Rager": {"cost": 500, "min_level": 3}, + "Rail Rider": {"cost": 750, "min_level": 4}, + "Taper Section Regular": {"cost": 1000, "min_level": 5}, + "Lot Lizard": {"cost": 600, "min_level": 4}, + "Show Whisperer": {"cost": 2500, "min_level": 10}, +} + +# Username colors that can be purchased with XP +PURCHASABLE_COLORS = { + "Sage Green": {"hex": "#6B9B6B", "cost": 300, "min_level": 2}, + "Ocean Blue": {"hex": "#4A90D9", "cost": 300, "min_level": 2}, + "Sunset Orange": {"hex": "#E67E22", "cost": 300, "min_level": 2}, + "Royal Purple": {"hex": "#9B59B6", "cost": 500, "min_level": 4}, + "Ruby Red": {"hex": "#E74C3C", "cost": 500, "min_level": 4}, + "Electric Cyan": {"hex": "#00CED1", "cost": 750, "min_level": 6}, + "Gold": {"hex": "#FFD700", "cost": 1000, "min_level": 8}, + "Rainbow": {"hex": "gradient", "cost": 2500, "min_level": 10}, +} + +# Flairs (small text/emoji beside username) +PURCHASABLE_FLAIRS = { + "⚡": {"cost": 100, "min_level": 1}, + "🎸": {"cost": 100, "min_level": 1}, + "🎵": {"cost": 100, "min_level": 1}, + "🌈": {"cost": 200, "min_level": 3}, + "🔥": {"cost": 200, "min_level": 3}, + "⭐": {"cost": 300, "min_level": 5}, + "👑": {"cost": 500, "min_level": 7}, + "🚀": {"cost": 400, "min_level": 6}, + "💎": {"cost": 750, "min_level": 9}, + "🌟": {"cost": 1000, "min_level": 10}, +} + +# Early adopter perks +EARLY_ADOPTER_PERKS = { + "free_title_change": True, # Early adopters can change title for free (once per month) + "exclusive_titles": ["Pioneer", "Founding Member", "OG User", "Day One"], + "exclusive_colors": {"Pioneer Gold": "#FFB347", "Genesis Green": "#50C878"}, + "exclusive_flair": ["🥇", "🏆"], + "title_color": "#FFB347", # Default gold color for early adopters + "bonus_xp_multiplier": 1.1, # 10% XP bonus +} + + +def get_available_titles(user: User) -> dict: + """Get all titles available to this user based on level and status""" + available = {} + + # Level-based titles + for level, titles in LEVEL_TITLES.items(): + if user.level >= level: + for title in titles: + available[title] = {"type": "level", "level_required": level, "cost": 0} + + # Purchasable titles + for title, info in PURCHASABLE_TITLES.items(): + if user.level >= info["min_level"]: + available[title] = {"type": "purchase", "level_required": info["min_level"], "cost": info["cost"]} + + # Early adopter exclusive titles + if user.is_early_adopter: + for title in EARLY_ADOPTER_PERKS["exclusive_titles"]: + available[title] = {"type": "early_adopter", "level_required": 1, "cost": 0} + + return available + + +def get_available_colors(user: User) -> dict: + """Get all colors available to this user""" + available = {} + + for name, info in PURCHASABLE_COLORS.items(): + if user.level >= info["min_level"]: + available[name] = {"hex": info["hex"], "cost": info["cost"]} + + # Early adopter exclusive colors + if user.is_early_adopter: + for name, hex_color in EARLY_ADOPTER_PERKS["exclusive_colors"].items(): + available[name] = {"hex": hex_color, "cost": 0} + + return available + + +def get_available_flairs(user: User) -> dict: + """Get all flairs available to this user""" + available = {} + + for flair, info in PURCHASABLE_FLAIRS.items(): + if user.level >= info["min_level"]: + available[flair] = {"cost": info["cost"]} + + # Early adopter exclusive flairs + if user.is_early_adopter: + for flair in EARLY_ADOPTER_PERKS["exclusive_flair"]: + available[flair] = {"cost": 0} + + return available + + +def purchase_title(session: Session, user: User, title: str) -> Tuple[bool, str]: + """Attempt to purchase a title. Returns (success, message)""" + available = get_available_titles(user) + + if title not in available: + return False, "Title not available at your level" + + info = available[title] + cost = info["cost"] + + # Early adopters get free title changes for level/early_adopter titles + if user.is_early_adopter and info["type"] in ["level", "early_adopter"]: + cost = 0 + + if user.xp < cost: + return False, f"Not enough XP. Need {cost}, have {user.xp}" + + # Deduct XP and set title + user.xp -= cost + user.custom_title = title + session.add(user) + session.commit() + + return True, f"Title '{title}' acquired!" + (f" (-{cost} XP)" if cost > 0 else " (Free!)") + + +def purchase_color(session: Session, user: User, color_name: str) -> Tuple[bool, str]: + """Attempt to purchase a username color""" + available = get_available_colors(user) + + if color_name not in available: + return False, "Color not available at your level" + + info = available[color_name] + cost = info["cost"] + + if user.xp < cost: + return False, f"Not enough XP. Need {cost}, have {user.xp}" + + # Deduct XP and set color + user.xp -= cost + user.title_color = info["hex"] + session.add(user) + session.commit() + + return True, f"Color '{color_name}' applied!" + (f" (-{cost} XP)" if cost > 0 else " (Free!)") + + +def purchase_flair(session: Session, user: User, flair: str) -> Tuple[bool, str]: + """Attempt to purchase a flair""" + available = get_available_flairs(user) + + if flair not in available: + return False, "Flair not available at your level" + + info = available[flair] + cost = info["cost"] + + if user.xp < cost: + return False, f"Not enough XP. Need {cost}, have {user.xp}" + + # Deduct XP and set flair + user.xp -= cost + user.flair = flair + session.add(user) + session.commit() + + return True, f"Flair {flair} acquired!" + (f" (-{cost} XP)" if cost > 0 else " (Free!)") + + +def get_user_display(user: User) -> dict: + """Get the full display info for a user including title, color, flair""" + username = user.email.split("@")[0] if user.email else "Anonymous" + + # Determine title to show + display_title = user.custom_title + if not display_title: + display_title = LEVEL_NAMES.get(user.level, "User") + + return { + "username": username, + "title": display_title, + "color": user.title_color, + "flair": user.flair, + "level": user.level, + "xp": user.xp, + "is_early_adopter": user.is_early_adopter, + "is_supporter": user.is_supporter, + } + diff --git a/docs/AUDIT_AND_PLAN.md b/docs/AUDIT_AND_PLAN.md new file mode 100644 index 0000000..eb6b3d7 --- /dev/null +++ b/docs/AUDIT_AND_PLAN.md @@ -0,0 +1,295 @@ +# Elmeg Platform Audit & Implementation Plan +> +> **Date**: December 22, 2024 + +--- + +## Executive Summary + +This audit examines the Elmeg platform against spec'd features, user stories, and interaction gaps. The platform has strong core functionality but has several incomplete areas that impact user experience. + +--- + +## 🔴 Critical Gaps (High Priority) + +### 1. Email System Not Functional + +**Status**: Backend model ready, email sending not implemented + +**Impact**: Users cannot: + +- Verify their email addresses +- Reset passwords +- Receive notification emails + +**User Stories Affected**: + +- ❌ "As a new user, I want to receive a verification email" +- ❌ "As a user, I want to reset my password if I forgot it" + +**Fix Required**: + +- Implement `backend/services/email_service.py` +- Integrate with AWS SES (docs exist at `AWS_SES_SETUP.md`) +- Connect auth endpoints to email service + +--- + +### 2. XP Not Actually Awarded + +**Status**: Models and endpoints exist, but XP isn't awarded on actions + +**Impact**: Gamification system is purely cosmetic - actions don't increase XP + +**User Stories Affected**: + +- ❌ "As a user, I want to earn XP when I rate a performance" +- ❌ "As a user, I want to earn XP when I mark attendance" + +**Fix Required**: + +- Hook `award_xp()` into attendance, rating, review endpoints +- Call `check_and_award_badges()` after XP-earning actions + +--- + +### 3. Frontend Not Using Slug URLs + +**Status**: API supports slugs, frontend still uses numeric IDs + +**Impact**: URLs are non-memorable (e.g., `/songs/69` instead of `/songs/tweezer`) + +**Fix Required**: + +- Update all `` components to use slug +- Add slug to API response schemas +- Update frontend routing to accept slug params + +--- + +## 🟡 Important Gaps (Medium Priority) + +### 4. Onboarding Flow Incomplete + +**Status**: `/welcome` page exists but is minimal + +**Gaps**: + +- No guided tour for new users +- No prompt to set up profile +- No progressive disclosure of features + +**User Stories Affected**: + +- ❌ "As a new user, I want a guided introduction to the platform" + +--- + +### 5. Chase Song "Mark as Caught" Not Wired + +**Status**: Backend endpoint exists, no frontend UI + +**Impact**: Users can add chase songs but can't mark them as caught at shows + +**Fix Required**: + +- Add "Mark Caught" button on show detail page +- Connect to `POST /chase/songs/{id}/caught` + +--- + +### 6. Performance Rating Disconnected + +**Status**: RatingInput component exists, not connected to performances + +**Impact**: Users can see ratings but can't submit their own on performance pages + +**Fix Required**: + +- Wire up `POST /ratings` endpoint on performance detail page +- Award XP when rating is submitted + +--- + +### 7. Notification Center Empty + +**Status**: Backend + frontend components exist, no triggers + +**Impact**: Bell icon in header shows nothing useful + +**Fix Required**: + +- Create notifications on: ratings received, badge earned, reply to comment +- Add notification sound/toast for new notifications + +--- + +### 8. Groups Feature Skeletal + +**Status**: CRUD exists, no member activity + +**Gaps**: + +- Can't see what members are doing +- No group leaderboards +- No group chat/discussions + +--- + +## 🟢 Working Features (Verified) + +| Feature | Status | Notes | +|---------|--------|-------| +| User registration/login | ✅ | Works | +| Show/Song/Venue browsing | ✅ | Working | +| Performance detail pages | ✅ | With navigation | +| Slug-based API lookups | ✅ | All entities | +| Comment sections | ✅ | Threaded | +| Review system | ✅ | With ratings | +| Chase song list | ✅ | Add/remove works | +| Attendance tracking | ✅ | Basic | +| Profile page | ✅ | With stats | +| Activity feed | ✅ | Global | +| Heady Version display | ✅ | Top performances | +| Admin panel | ✅ | User/content management | +| Mod panel | ✅ | Reports/nicknames | +| Theme toggle | ✅ | Light/dark | +| Settings/preferences | ✅ | Wiki mode | + +--- + +## 📋 Implementation Plan + +### Sprint 1: Critical Infrastructure (Est. 4-6 hours) + +#### 1.1 Email Service Integration + +``` +- [ ] Create EmailService class with AWS SES +- [ ] Implement send_verification_email() +- [ ] Implement send_password_reset_email() +- [ ] Wire up auth endpoints +- [ ] Test email flow end-to-end +``` + +#### 1.2 XP Award Hooks + +``` +- [ ] Hook award_xp() into attendance.py +- [ ] Hook award_xp() into reviews.py +- [ ] Hook award_xp() into ratings endpoint +- [ ] Call check_and_award_badges() automatically +- [ ] Add "XP earned" toast feedback on frontend +``` + +--- + +### Sprint 2: URL & UX Polish (Est. 3-4 hours) + +#### 2.1 Slug URLs on Frontend + +``` +- [ ] Add slug to Song, Show, Venue, Performance response schemas +- [ ] Update Link components to use slug +- [ ] Verify all routes work with slugs +- [ ] Update internal links in ActivityFeed +- [ ] Update search results to use slugs +``` + +#### 2.2 Performance Rating Widget + +``` +- [ ] Add RatingInput to performance detail page +- [ ] Connect to POST /ratings endpoint +- [ ] Show user's existing rating if any +- [ ] Animate rating confirmation +- [ ] Award XP on rating +``` + +--- + +### Sprint 3: Feature Completion (Est. 4-5 hours) + +#### 3.1 Chase Song Completion + +``` +- [ ] Add "Mark Caught" button on show detail page +- [ ] Show user's chase songs that match show setlist +- [ ] Animate "caught" celebration +- [ ] Award badge for catching 5 songs +``` + +#### 3.2 Notification Triggers + +``` +- [ ] Create notification on badge earned +- [ ] Create notification on comment reply +- [ ] Create notification on review reaction +- [ ] Add toast/sound for new notifications +``` + +#### 3.3 Onboarding Experience + +``` +- [ ] Create multi-step welcome wizard +- [ ] Prompt profile setup (bio, avatar) +- [ ] Highlight key features +- [ ] Set first badge on completion +``` + +--- + +### Sprint 4: Social Enhancement (Est. 3-4 hours) + +#### 4.1 XP Leaderboard Integration + +``` +- [ ] Add XP leaderboard to home page +- [ ] Add leaderboard to /leaderboards page +- [ ] Add "Your Rank" indicator +- [ ] Weekly/monthly/all-time views +``` + +#### 4.2 Groups Upgrade + +``` +- [ ] Show member activity in group +- [ ] Group XP leaderboard +- [ ] Group attendance stats +``` + +--- + +## 📊 Priority Matrix + +| Item | Impact | Effort | Priority | +|------|--------|--------|----------| +| Email service | High | Medium | P1 | +| XP award hooks | High | Low | P1 | +| Slug URLs on frontend | Medium | Low | P2 | +| Performance rating widget | High | Low | P2 | +| Chase "Mark Caught" | Medium | Low | P2 | +| Notification triggers | Medium | Medium | P3 | +| Onboarding wizard | Medium | Medium | P3 | +| Groups enhancement | Low | High | P4 | + +--- + +## Recommended Execution Order + +1. **Now**: XP award hooks (quick win, high impact) +2. **Today**: Performance rating widget +3. **Today**: Slug URLs on frontend +4. **Next**: Email service (requires AWS config) +5. **Next**: Chase song completion +6. **Later**: Notifications, onboarding, groups + +--- + +## Quick Wins (Can Do in 30 min each) + +1. ✨ Wire XP awards to attendance/review endpoints +2. 🎯 Add performance rating widget +3. 🔗 Update frontend links to use slugs +4. 🏆 Add XP leaderboard to home page +5. 🎵 Add "Mark Caught" button to show pages diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index d17bfeb..7b85c0c 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,4 +1,5 @@ import { ActivityFeed } from "@/components/feed/activity-feed" +import { XPLeaderboard } from "@/components/gamification/xp-leaderboard" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import Link from "next/link" @@ -202,7 +203,7 @@ export default async function Home() { {/* Activity Feed */} -
+

Recent Activity

@@ -211,6 +212,11 @@ export default async function Home() {
+ + {/* XP Leaderboard */} +
+ +
{/* Quick Links */}