Added new systems.

This commit is contained in:
2026-01-09 10:15:46 -06:00
parent 725b81494e
commit 267504e641
39 changed files with 4441 additions and 18 deletions

View File

@ -0,0 +1,101 @@
---
name: pmp-agent
description: Use this agent when the user needs assistance with project management tasks, including project planning, stakeholder management, risk assessment, scope definition, timeline creation, resource allocation, or applying PMP (Project Management Professional) methodologies and best practices. This agent is particularly valuable for structuring projects, creating project documentation, conducting status reviews, or providing guidance on project management frameworks like PMBOK.\n\nExamples:\n\n<example>\nContext: User is starting a new feature development and needs help planning it out.\nuser: "I need to plan out the implementation of the real payment integration for H2H"\nassistant: "I'll use the pmp-agent to help structure this project properly with appropriate planning documentation."\n<Task tool invocation to launch pmp-agent>\n</example>\n\n<example>\nContext: User wants to understand project risks before proceeding.\nuser: "What are the risks of migrating from SQLite to PostgreSQL?"\nassistant: "Let me use the pmp-agent to conduct a proper risk assessment for this database migration."\n<Task tool invocation to launch pmp-agent>\n</example>\n\n<example>\nContext: User needs help creating a project timeline.\nuser: "Can you help me create a timeline for launching the admin panel feature?"\nassistant: "I'll engage the pmp-agent to develop a comprehensive project timeline with milestones and dependencies."\n<Task tool invocation to launch pmp-agent>\n</example>\n\n<example>\nContext: User is conducting a project status review.\nuser: "Let's do a status check on the WebSocket implementation"\nassistant: "I'll use the pmp-agent to conduct a structured project status review following PM best practices."\n<Task tool invocation to launch pmp-agent>\n</example>
model: opus
color: orange
---
You are an expert Project Management Professional (PMP) with deep expertise in PMBOK methodologies, agile frameworks, and software development project management. You bring 15+ years of experience managing complex technical projects from inception to successful delivery.
## Your Core Competencies
### Project Planning & Initiation
- Define clear project scope, objectives, and success criteria
- Create comprehensive project charters and statements of work
- Identify and analyze stakeholders with appropriate engagement strategies
- Develop realistic work breakdown structures (WBS)
- Establish project governance frameworks
### Schedule & Resource Management
- Create detailed project schedules with dependencies and milestones
- Apply critical path method (CPM) for timeline optimization
- Allocate resources effectively based on skills and availability
- Identify resource constraints and propose mitigation strategies
- Balance workload across team members
### Risk Management
- Conduct thorough risk identification and analysis
- Develop risk registers with probability, impact, and response strategies
- Create contingency and fallback plans
- Monitor risk triggers and early warning indicators
- Apply both qualitative and quantitative risk analysis techniques
### Execution & Control
- Track project progress against baselines
- Implement earned value management (EVM) metrics when appropriate
- Manage scope changes through formal change control processes
- Conduct effective status reporting and stakeholder communications
- Identify and escalate issues requiring attention
### Quality & Integration
- Define quality standards and acceptance criteria
- Integrate project components cohesively
- Ensure deliverables meet stakeholder expectations
- Apply lessons learned from past projects
## Your Working Approach
1. **Start with Context**: Always understand the current project state, constraints, and objectives before providing recommendations.
2. **Be Pragmatic**: Tailor your recommendations to the project's scale. For an MVP like H2H, avoid over-engineering processes while maintaining essential controls.
3. **Provide Actionable Outputs**: Create concrete artifacts (timelines, risk registers, WBS, etc.) rather than abstract advice.
4. **Consider Technical Reality**: When working on software projects, account for technical dependencies, integration points, and development workflows.
5. **Communicate Clearly**: Use clear, structured formats for project documentation. Employ tables, lists, and visual hierarchies to improve comprehension.
## Output Formats
When creating project artifacts, use these formats:
### Risk Register Entry
| Risk ID | Description | Probability | Impact | Score | Response Strategy | Owner | Status |
### WBS Format
```
1.0 Project Name
1.1 Phase/Deliverable
1.1.1 Work Package
1.1.1.1 Task
```
### Status Report Structure
- Executive Summary
- Accomplishments (This Period)
- Planned Activities (Next Period)
- Risks & Issues
- Key Metrics
- Decisions Needed
### Timeline/Milestone Format
| Milestone | Target Date | Dependencies | Status | Notes |
## Context Awareness
For the H2H betting platform project:
- Recognize this is an MVP with specific scope boundaries
- Consider the tech stack (FastAPI, React, SQLite→PostgreSQL migration path)
- Account for the bet lifecycle states and critical financial operations
- Note the async patterns and real-time WebSocket requirements
- Understand the wallet/escrow system's critical invariants
## Quality Standards
- Verify all recommendations align with project constraints
- Ensure timelines account for dependencies and realistic effort estimates
- Validate that risk responses are actionable and proportionate
- Cross-check deliverables against stated success criteria
- Consider both immediate needs and future scalability
You approach every project management challenge with professionalism, structure, and a focus on delivering value while managing constraints effectively.

View File

@ -0,0 +1,50 @@
---
name: qa-agent
description: Use this agent when you need to perform quality assurance testing, validate functionality, check for bugs, review test coverage, or ensure code meets quality standards. This agent reads its detailed instructions from QA_AGENT.md in the project root.\n\nExamples:\n\n<example>\nContext: User has just completed implementing a new feature and wants it tested.\nuser: "I just finished implementing the bet cancellation feature"\nassistant: "Let me use the qa-agent to test and validate your new bet cancellation feature"\n<commentary>\nSince the user completed a new feature, use the Task tool to launch the qa-agent to perform comprehensive QA testing on the implementation.\n</commentary>\n</example>\n\n<example>\nContext: User wants to verify the application works correctly after changes.\nuser: "Can you check if the settlement flow is working properly?"\nassistant: "I'll use the qa-agent to thoroughly test the settlement flow"\n<commentary>\nThe user is requesting validation of existing functionality, so use the qa-agent to perform targeted testing on the settlement flow.\n</commentary>\n</example>\n\n<example>\nContext: User is preparing for a release and needs quality validation.\nuser: "We're about to deploy - can you run QA checks?"\nassistant: "I'll launch the qa-agent to perform pre-deployment quality assurance checks"\n<commentary>\nPre-deployment validation is a core QA responsibility, so use the qa-agent to run comprehensive checks before release.\n</commentary>\n</example>
model: opus
color: purple
---
You are an expert Quality Assurance Engineer specializing in full-stack web application testing. Your primary directive is to read and follow the detailed instructions in QA_AGENT.md located in the project root directory.
## First Action Required
Before performing any QA tasks, you MUST:
1. Read the contents of QA_AGENT.md using the appropriate file reading tool
2. Parse and understand all instructions, test cases, and procedures defined in that file
3. Follow those instructions exactly as specified
If QA_AGENT.md does not exist or cannot be read, inform the user and ask for guidance on how to proceed.
## Fallback Behavior
If QA_AGENT.md is unavailable or incomplete, apply these default QA principles for the H2H betting platform:
### Testing Areas
- **Authentication**: JWT token flow, refresh mechanism, session management
- **Wallet Operations**: Balance calculations, escrow locks, transaction integrity
- **Bet Lifecycle**: State transitions (OPEN → MATCHED → COMPLETED), validation rules
- **WebSocket Events**: Real-time updates, connection handling, event broadcasting
- **API Endpoints**: Request/response validation, error handling, edge cases
### Quality Checks
- Verify bet state transitions follow valid paths only
- Confirm wallet invariant: balance + escrow = total funds
- Test that users cannot accept their own bets
- Validate stake limits (> 0, ≤ $10,000)
- Check async database operations for race conditions
- Verify WebSocket authentication via query params
### Reporting Format
- Clearly categorize issues by severity (Critical, High, Medium, Low)
- Provide reproduction steps for each bug found
- Include expected vs actual behavior
- Reference specific code locations when applicable
## Communication
Always inform the user:
- Whether QA_AGENT.md was successfully loaded
- What testing scope you are executing
- Progress updates during lengthy test runs
- Summary of findings with actionable recommendations

View File

@ -19,6 +19,13 @@ import { NewBets } from './pages/NewBets'
import { Watchlist } from './pages/Watchlist'
import { HowItWorks } from './pages/HowItWorks'
import { EventDetail } from './pages/EventDetail'
import {
RewardsIndex,
LeaderboardPage,
AchievementsPage,
LootBoxesPage,
ActivityPage,
} from './pages/rewards'
const queryClient = new QueryClient({
defaultOptions: {
@ -62,6 +69,11 @@ function App() {
<Route path="/watchlist" element={<Watchlist />} />
<Route path="/how-it-works" element={<HowItWorks />} />
<Route path="/events/:id" element={<EventDetail />} />
<Route path="/rewards" element={<RewardsIndex />} />
<Route path="/rewards/leaderboard" element={<LeaderboardPage />} />
<Route path="/rewards/achievements" element={<AchievementsPage />} />
<Route path="/rewards/loot-boxes" element={<LootBoxesPage />} />
<Route path="/rewards/activity" element={<ActivityPage />} />
<Route
path="/profile"

View File

@ -0,0 +1,83 @@
import { apiClient as api } from './client'
import type {
TierInfo,
UserStats,
UserAchievement,
LootBox,
LootBoxOpenResult,
ActivityFeedItem,
LeaderboardEntry,
WhaleBet,
RecentWin,
DailyReward,
} from '../types/gamification'
const PREFIX = '/api/v1/gamification'
// Public endpoints
export async function getLeaderboard(
category: 'profit' | 'wagered' | 'wins' | 'streak' | 'xp' = 'profit',
limit: number = 20
): Promise<LeaderboardEntry[]> {
const response = await api.get(`${PREFIX}/leaderboard/${category}`, {
params: { limit },
})
return response.data
}
export async function getWhaleBets(limit: number = 20): Promise<WhaleBet[]> {
const response = await api.get(`${PREFIX}/whale-tracker`, {
params: { limit },
})
return response.data
}
export async function getActivityFeed(limit: number = 50): Promise<ActivityFeedItem[]> {
const response = await api.get(`${PREFIX}/activity-feed`, {
params: { limit },
})
return response.data
}
export async function getRecentWins(limit: number = 20): Promise<RecentWin[]> {
const response = await api.get(`${PREFIX}/recent-wins`, {
params: { limit },
})
return response.data
}
export async function getTierInfo(): Promise<TierInfo[]> {
const response = await api.get(`${PREFIX}/tier-info`)
return response.data
}
// Authenticated endpoints
export async function getMyStats(): Promise<UserStats> {
const response = await api.get(`${PREFIX}/my-stats`)
return response.data
}
export async function getMyAchievements(): Promise<UserAchievement[]> {
const response = await api.get(`${PREFIX}/my-achievements`)
return response.data
}
export async function getMyLootBoxes(): Promise<LootBox[]> {
const response = await api.get(`${PREFIX}/my-loot-boxes`)
return response.data
}
export async function openLootBox(lootBoxId: number): Promise<LootBoxOpenResult> {
const response = await api.post(`${PREFIX}/my-loot-boxes/${lootBoxId}/open`)
return response.data
}
export async function getDailyReward(): Promise<DailyReward | { available: boolean; next_available: string }> {
const response = await api.get(`${PREFIX}/daily-reward`)
return response.data
}
export async function claimDailyReward(): Promise<DailyReward> {
const response = await api.post(`${PREFIX}/daily-reward/claim`)
return response.data
}

View File

@ -0,0 +1,122 @@
import { useQuery } from '@tanstack/react-query'
import { getMyAchievements, getMyStats } from '../../api/gamification'
import { formatDistanceToNow } from 'date-fns'
import type { UserAchievement } from '../../types/gamification'
const RARITY_COLORS: Record<string, string> = {
common: 'border-gray-300 bg-gray-50',
rare: 'border-blue-400 bg-blue-50',
epic: 'border-purple-400 bg-purple-50',
legendary: 'border-yellow-400 bg-yellow-50',
}
const RARITY_GLOW: Record<string, string> = {
common: '',
rare: 'shadow-blue-100',
epic: 'shadow-purple-100 shadow-md',
legendary: 'shadow-yellow-200 shadow-lg',
}
interface AchievementsProps {
showStreaks?: boolean
}
export default function Achievements({ showStreaks = true }: AchievementsProps) {
const { data: achievements = [], isLoading: loadingAchievements } = useQuery({
queryKey: ['my-achievements'],
queryFn: getMyAchievements,
retry: 1,
})
const { data: stats, isLoading: loadingStats } = useQuery({
queryKey: ['my-stats'],
queryFn: getMyStats,
retry: 1,
})
const isLoading = loadingAchievements || loadingStats
return (
<div className="bg-white rounded-xl shadow-sm p-6">
{/* Streaks Section */}
{showStreaks && stats && (
<div className="mb-6">
<h3 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
<span className="text-2xl">🔥</span> Streaks
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="bg-gray-50 rounded-lg p-3 text-center border">
<div className="text-3xl mb-1">
{stats.current_win_streak > 0 ? '🔥' : '❄️'}
</div>
<p className="text-2xl font-bold text-gray-900">{stats.current_win_streak}</p>
<p className="text-xs text-gray-500">Current Win Streak</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center border">
<div className="text-3xl mb-1">🏆</div>
<p className="text-2xl font-bold text-yellow-600">{stats.best_win_streak}</p>
<p className="text-xs text-gray-500">Best Win Streak</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center border">
<div className="text-3xl mb-1">📅</div>
<p className="text-2xl font-bold text-green-600">{stats.consecutive_days_betting}</p>
<p className="text-xs text-gray-500">Day Streak</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center border">
<div className="text-3xl mb-1">👥</div>
<p className="text-2xl font-bold text-blue-600">{stats.unique_opponents}</p>
<p className="text-xs text-gray-500">Unique Opponents</p>
</div>
</div>
</div>
)}
{/* Achievements Section */}
<h3 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
<span className="text-2xl">🎖</span> Achievements
{achievements.length > 0 && (
<span className="text-sm font-normal text-gray-500">({achievements.length} earned)</span>
)}
</h3>
{isLoading ? (
<div className="grid grid-cols-2 gap-3">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-20 bg-gray-100 rounded-lg animate-pulse" />
))}
</div>
) : achievements.length === 0 ? (
<div className="text-center py-6">
<p className="text-4xl mb-2">🎯</p>
<p className="text-gray-500">No achievements yet</p>
<p className="text-sm text-gray-400">Start betting to unlock achievements!</p>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
{achievements.map((ua: UserAchievement) => (
<div
key={ua.id}
className={`border rounded-lg p-3 ${RARITY_COLORS[ua.achievement.rarity]} ${RARITY_GLOW[ua.achievement.rarity]}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-2xl">{ua.achievement.icon}</span>
<div className="min-w-0 flex-1">
<p className="text-gray-900 font-medium text-sm truncate">
{ua.achievement.name}
</p>
<p className="text-xs text-green-600">+{ua.achievement.xp_reward} XP</p>
</div>
</div>
<p className="text-xs text-gray-600 line-clamp-2">
{ua.achievement.description}
</p>
<p className="text-xs text-gray-400 mt-1">
{formatDistanceToNow(new Date(ua.earned_at), { addSuffix: true })}
</p>
</div>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,132 @@
import { useQuery } from '@tanstack/react-query'
import { getActivityFeed, getRecentWins } from '../../api/gamification'
import { formatDistanceToNow } from 'date-fns'
import type { ActivityFeedItem, RecentWin } from '../../types/gamification'
const ACTIVITY_ICONS: Record<string, string> = {
bet_placed: '🎲',
bet_won: '🏆',
bet_lost: '😢',
achievement: '🎖️',
tier_up: '⬆️',
whale_bet: '🐋',
loot_box: '🎁',
daily_reward: '📅',
}
interface ActivityFeedProps {
showRecentWins?: boolean
limit?: number
}
export default function ActivityFeed({ showRecentWins = true, limit = 15 }: ActivityFeedProps) {
const { data: activities = [], isLoading: loadingActivities } = useQuery({
queryKey: ['activity-feed', limit],
queryFn: () => getActivityFeed(limit),
refetchInterval: 10000,
retry: 1,
})
const { data: recentWins = [], isLoading: loadingWins } = useQuery({
queryKey: ['recent-wins'],
queryFn: () => getRecentWins(5),
enabled: showRecentWins,
refetchInterval: 15000,
retry: 1,
})
return (
<div className="space-y-6">
{/* Recent Wins Section */}
{showRecentWins && (
<div className="bg-white rounded-xl shadow-sm p-6">
<h3 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
<span className="text-2xl">🏆</span> Recent Wins
</h3>
{loadingWins ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-12 bg-gray-100 rounded-lg animate-pulse" />
))}
</div>
) : recentWins.length === 0 ? (
<p className="text-gray-500 text-center py-3">No recent wins</p>
) : (
<div className="space-y-2">
{recentWins.map((win: RecentWin) => (
<div
key={win.id}
className="flex items-center gap-3 bg-gradient-to-r from-green-50 to-transparent p-2 rounded-lg border border-green-100"
>
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center overflow-hidden flex-shrink-0">
{win.avatar_url ? (
<img src={win.avatar_url} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-sm font-medium text-gray-600">{win.username[0].toUpperCase()}</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-gray-900 text-sm truncate">
<span className="font-medium">{win.display_name || win.username}</span>
<span className="text-gray-500"> won </span>
<span className="text-green-600 font-bold">${win.amount_won.toLocaleString()}</span>
</p>
<p className="text-xs text-gray-500 truncate">{win.event_title}</p>
</div>
<span className="text-xs text-gray-400 flex-shrink-0">
{formatDistanceToNow(new Date(win.settled_at), { addSuffix: true })}
</span>
</div>
))}
</div>
)}
</div>
)}
{/* Activity Feed */}
<div className="bg-white rounded-xl shadow-sm p-6">
<h3 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
<span className="text-2xl">📢</span> Live Activity
</h3>
{loadingActivities ? (
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-10 bg-gray-100 rounded-lg animate-pulse" />
))}
</div>
) : activities.length === 0 ? (
<p className="text-gray-500 text-center py-4">No activity yet</p>
) : (
<div className="space-y-1 max-h-80 overflow-y-auto">
{activities.map((activity: ActivityFeedItem) => (
<div
key={activity.id}
className="flex items-start gap-2 py-2 border-b border-gray-100 last:border-0"
>
<span className="text-lg flex-shrink-0">
{ACTIVITY_ICONS[activity.activity_type] || '📌'}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-600">
<span className="text-gray-900 font-medium">{activity.username}</span>{' '}
{activity.message}
{activity.amount && (
<span className="text-green-600 font-medium ml-1">
${activity.amount.toLocaleString()}
</span>
)}
</p>
<p className="text-xs text-gray-400">
{formatDistanceToNow(new Date(activity.created_at), { addSuffix: true })}
</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,128 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getLeaderboard } from '../../api/gamification'
import type { LeaderboardEntry } from '../../types/gamification'
type Category = 'profit' | 'wagered' | 'wins' | 'streak' | 'xp'
const CATEGORY_LABELS: Record<Category, string> = {
profit: 'Top Earners',
wagered: 'High Rollers',
wins: 'Most Wins',
streak: 'Hot Streaks',
xp: 'XP Leaders',
}
const TIER_COLORS: Record<string, string> = {
'Bronze I': 'text-amber-700',
'Bronze II': 'text-amber-700',
'Bronze III': 'text-amber-700',
'Silver I': 'text-gray-500',
'Silver II': 'text-gray-500',
'Silver III': 'text-gray-500',
'Gold I': 'text-yellow-600',
'Gold II': 'text-yellow-600',
'Gold III': 'text-yellow-600',
'Platinum': 'text-cyan-600',
'Diamond': 'text-purple-600',
}
function getRankIcon(rank: number): string {
if (rank === 1) return '🥇'
if (rank === 2) return '🥈'
if (rank === 3) return '🥉'
return `#${rank}`
}
function formatValue(value: number, category: Category): string {
if (category === 'profit' || category === 'wagered') {
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
if (category === 'xp') {
return `${value.toLocaleString()} XP`
}
return value.toString()
}
export default function Leaderboard() {
const [category, setCategory] = useState<Category>('profit')
const { data: entries = [], isLoading } = useQuery({
queryKey: ['leaderboard', category],
queryFn: () => getLeaderboard(category, 10),
refetchInterval: 30000,
retry: 1,
})
return (
<div className="bg-white rounded-xl shadow-sm p-6">
<h3 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
<span className="text-2xl">🏆</span> Leaderboard
</h3>
{/* Category Tabs */}
<div className="flex gap-1 mb-4 overflow-x-auto pb-2">
{(Object.keys(CATEGORY_LABELS) as Category[]).map((cat) => (
<button
key={cat}
onClick={() => setCategory(cat)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition-colors ${
category === cat
? 'bg-primary text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{CATEGORY_LABELS[cat]}
</button>
))}
</div>
{/* Leaderboard List */}
{isLoading ? (
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-12 bg-gray-100 rounded-lg animate-pulse" />
))}
</div>
) : entries.length === 0 ? (
<p className="text-gray-500 text-center py-4">No entries yet</p>
) : (
<div className="space-y-2">
{entries.map((entry: LeaderboardEntry) => (
<div
key={entry.user_id}
className={`flex items-center gap-3 p-3 rounded-lg ${
entry.rank <= 3 ? 'bg-primary/5 border border-primary/20' : 'bg-gray-50'
}`}
>
<span className={`w-8 text-center font-bold ${entry.rank <= 3 ? 'text-lg' : 'text-sm text-gray-500'}`}>
{getRankIcon(entry.rank)}
</span>
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
{entry.avatar_url ? (
<img src={entry.avatar_url} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-sm font-medium text-gray-600">{entry.username[0].toUpperCase()}</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-gray-900 font-medium text-sm truncate">
{entry.display_name || entry.username}
</p>
<p className={`text-xs ${TIER_COLORS[entry.tier_name] || 'text-gray-500'}`}>
{entry.tier_name}
</p>
</div>
<span className="text-green-600 font-bold text-sm">
{formatValue(entry.value, category)}
</span>
</div>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,174 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { getMyLootBoxes, openLootBox } from '../../api/gamification'
import type { LootBox, LootBoxRarity, LootBoxOpenResult } from '../../types/gamification'
const RARITY_CONFIG: Record<LootBoxRarity, { color: string; bg: string; border: string; icon: string }> = {
common: {
color: 'text-gray-600',
bg: 'bg-gray-100',
border: 'border-gray-300',
icon: '📦',
},
uncommon: {
color: 'text-green-600',
bg: 'bg-green-50',
border: 'border-green-300',
icon: '🎁',
},
rare: {
color: 'text-blue-600',
bg: 'bg-blue-50',
border: 'border-blue-300',
icon: '💎',
},
epic: {
color: 'text-purple-600',
bg: 'bg-purple-50',
border: 'border-purple-300',
icon: '👑',
},
legendary: {
color: 'text-yellow-600',
bg: 'bg-yellow-50',
border: 'border-yellow-400',
icon: '🌟',
},
}
const REWARD_ICONS: Record<string, string> = {
bonus_cash: '💰',
xp_boost: '⚡',
fee_reduction: '📉',
free_bet: '🎟️',
avatar_frame: '🖼️',
badge: '🏅',
nothing: '💨',
}
export default function LootBoxes() {
const queryClient = useQueryClient()
const [openingId, setOpeningId] = useState<number | null>(null)
const [result, setResult] = useState<LootBoxOpenResult | null>(null)
const [showResult, setShowResult] = useState(false)
const { data: lootBoxes = [], isLoading } = useQuery({
queryKey: ['my-loot-boxes'],
queryFn: getMyLootBoxes,
retry: 1,
})
const openMutation = useMutation({
mutationFn: openLootBox,
onSuccess: (data) => {
setResult(data)
setShowResult(true)
queryClient.invalidateQueries({ queryKey: ['my-loot-boxes'] })
queryClient.invalidateQueries({ queryKey: ['my-stats'] })
},
onSettled: () => {
setOpeningId(null)
},
})
const unopenedBoxes = lootBoxes.filter((box: LootBox) => !box.opened)
const handleOpen = (box: LootBox) => {
setOpeningId(box.id)
setResult(null)
setShowResult(false)
openMutation.mutate(box.id)
}
return (
<div className="bg-white rounded-xl shadow-sm p-6">
<h3 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
<span className="text-2xl">🎁</span> Loot Boxes
{unopenedBoxes.length > 0 && (
<span className="bg-red-500 text-white text-xs px-2 py-0.5 rounded-full">
{unopenedBoxes.length}
</span>
)}
</h3>
{/* Result Modal */}
{showResult && result && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-sm w-full text-center shadow-xl">
<div className="text-6xl mb-4">{REWARD_ICONS[result.reward_type] || '🎁'}</div>
<h4 className="text-xl font-bold text-gray-900 mb-2">
{result.reward_type === 'nothing' ? 'Better luck next time!' : 'You got:'}
</h4>
<p className="text-2xl font-bold text-green-600 mb-2">{result.reward_value}</p>
<p className="text-gray-600 mb-4">{result.message}</p>
<button
onClick={() => setShowResult(false)}
className="bg-primary hover:bg-primary/90 text-white px-6 py-2 rounded-lg font-medium transition-colors"
>
Awesome!
</button>
</div>
</div>
)}
{isLoading ? (
<div className="grid grid-cols-3 gap-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-24 bg-gray-100 rounded-lg animate-pulse" />
))}
</div>
) : unopenedBoxes.length === 0 ? (
<div className="text-center py-6">
<p className="text-4xl mb-2">📭</p>
<p className="text-gray-500">No loot boxes</p>
<p className="text-sm text-gray-400">Earn them through achievements & tier ups!</p>
</div>
) : (
<div className="grid grid-cols-3 gap-3">
{unopenedBoxes.map((box: LootBox) => {
const config = RARITY_CONFIG[box.rarity]
const isOpening = openingId === box.id
return (
<button
key={box.id}
onClick={() => handleOpen(box)}
disabled={isOpening || openMutation.isPending}
className={`${config.bg} ${config.border} border-2 rounded-lg p-3 text-center transition-all hover:scale-105 disabled:opacity-50 disabled:hover:scale-100`}
>
<div className={`text-3xl mb-1 ${isOpening ? 'animate-spin' : 'animate-bounce'}`}>
{isOpening ? '🎰' : config.icon}
</div>
<p className={`text-xs font-medium capitalize ${config.color}`}>
{box.rarity}
</p>
<p className="text-xs text-gray-400 truncate">{box.source}</p>
</button>
)
})}
</div>
)}
{/* Opened History (collapsed) */}
{lootBoxes.filter((b: LootBox) => b.opened).length > 0 && (
<details className="mt-4">
<summary className="text-sm text-gray-500 cursor-pointer hover:text-gray-700">
View opened boxes ({lootBoxes.filter((b: LootBox) => b.opened).length})
</summary>
<div className="mt-2 space-y-1 max-h-32 overflow-y-auto">
{lootBoxes
.filter((b: LootBox) => b.opened)
.map((box: LootBox) => (
<div key={box.id} className="flex items-center gap-2 text-xs text-gray-500">
<span>{RARITY_CONFIG[box.rarity].icon}</span>
<span className="capitalize">{box.rarity}</span>
<span></span>
<span>{box.reward_value || 'Nothing'}</span>
</div>
))}
</div>
</details>
)}
</div>
)
}

View File

@ -0,0 +1,199 @@
import { useQuery } from '@tanstack/react-query'
import { getMyStats, getTierInfo } from '../../api/gamification'
import type { TierInfo } from '../../types/gamification'
const TIER_ICONS: Record<string, string> = {
'Bronze I': '🥉',
'Bronze II': '🥉',
'Bronze III': '🥉',
'Silver I': '🥈',
'Silver II': '🥈',
'Silver III': '🥈',
'Gold I': '🥇',
'Gold II': '🥇',
'Gold III': '🥇',
'Platinum': '💎',
'Diamond': '👑',
}
const TIER_COLORS: Record<string, { text: string; bg: string; border: string }> = {
'Bronze I': { text: 'text-amber-700', bg: 'bg-amber-500', border: 'border-amber-400' },
'Bronze II': { text: 'text-amber-700', bg: 'bg-amber-500', border: 'border-amber-400' },
'Bronze III': { text: 'text-amber-700', bg: 'bg-amber-500', border: 'border-amber-400' },
'Silver I': { text: 'text-gray-600', bg: 'bg-gray-400', border: 'border-gray-400' },
'Silver II': { text: 'text-gray-600', bg: 'bg-gray-400', border: 'border-gray-400' },
'Silver III': { text: 'text-gray-600', bg: 'bg-gray-400', border: 'border-gray-400' },
'Gold I': { text: 'text-yellow-600', bg: 'bg-yellow-400', border: 'border-yellow-400' },
'Gold II': { text: 'text-yellow-600', bg: 'bg-yellow-400', border: 'border-yellow-400' },
'Gold III': { text: 'text-yellow-600', bg: 'bg-yellow-400', border: 'border-yellow-400' },
'Platinum': { text: 'text-cyan-600', bg: 'bg-cyan-400', border: 'border-cyan-400' },
'Diamond': { text: 'text-purple-600', bg: 'bg-purple-400', border: 'border-purple-400' },
}
interface TierProgressProps {
compact?: boolean
}
export default function TierProgress({ compact = false }: TierProgressProps) {
const { data: stats, isLoading: loadingStats } = useQuery({
queryKey: ['my-stats'],
queryFn: getMyStats,
retry: 1,
})
const { data: tiers = [], isLoading: loadingTiers } = useQuery({
queryKey: ['tier-info'],
queryFn: getTierInfo,
retry: 1,
})
const isLoading = loadingStats || loadingTiers
if (isLoading) {
return (
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="h-32 bg-gray-100 rounded-lg animate-pulse" />
</div>
)
}
if (!stats) {
return null
}
const tierColors = TIER_COLORS[stats.tier_name] || TIER_COLORS['Bronze I']
const tierIcon = TIER_ICONS[stats.tier_name] || '🏅'
if (compact) {
return (
<div className="bg-white rounded-xl shadow-sm p-4">
<div className="flex items-center gap-3">
<div className="text-3xl">{tierIcon}</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className={`font-bold ${tierColors.text}`}>{stats.tier_name}</span>
<span className="text-xs text-gray-500">{stats.xp.toLocaleString()} XP</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full ${tierColors.bg} transition-all duration-500`}
style={{ width: `${stats.tier_progress_percent}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">
{stats.tier < 10
? `${stats.xp_to_next_tier.toLocaleString()} XP to next tier`
: 'Max tier reached!'}
</p>
</div>
</div>
</div>
)
}
return (
<div className="bg-white rounded-xl shadow-sm p-6">
<h3 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
<span className="text-2xl"></span> Your Tier
</h3>
{/* Current Tier Display */}
<div className={`border-2 ${tierColors.border} rounded-xl p-4 mb-4 bg-gradient-to-br from-gray-50 to-transparent`}>
<div className="flex items-center gap-4">
<div className="text-5xl">{tierIcon}</div>
<div className="flex-1">
<p className={`text-2xl font-bold ${tierColors.text}`}>{stats.tier_name}</p>
<p className="text-gray-500 text-sm">Tier {stats.tier} of 10</p>
</div>
<div className="text-right">
<p className="text-green-600 font-bold text-lg">{stats.house_fee}%</p>
<p className="text-xs text-gray-500">House Fee</p>
</div>
</div>
{/* XP Progress */}
<div className="mt-4">
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">{stats.xp.toLocaleString()} XP</span>
{stats.tier < 10 && (
<span className="text-gray-600">
{(stats.xp + stats.xp_to_next_tier).toLocaleString()} XP
</span>
)}
</div>
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full ${tierColors.bg} transition-all duration-500`}
style={{ width: `${stats.tier_progress_percent}%` }}
/>
</div>
<p className="text-center text-sm text-gray-500 mt-2">
{stats.tier < 10
? `${stats.xp_to_next_tier.toLocaleString()} XP to next tier`
: '🎉 Max tier reached!'}
</p>
</div>
</div>
{/* Stats Summary */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="bg-gray-50 rounded-lg p-3 text-center border">
<p className={`text-lg font-bold ${stats.net_profit >= 0 ? 'text-green-600' : 'text-red-600'}`}>
${stats.net_profit >= 0 ? '+' : ''}{stats.net_profit.toLocaleString()}
</p>
<p className="text-xs text-gray-500">Net Profit</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center border">
<p className="text-lg font-bold text-gray-900">${stats.total_wagered.toLocaleString()}</p>
<p className="text-xs text-gray-500">Total Wagered</p>
</div>
</div>
{/* Tier Roadmap */}
<details className="group">
<summary className="text-sm text-gray-500 cursor-pointer hover:text-gray-700 flex items-center gap-1">
<span className="group-open:rotate-90 transition-transform"></span>
View all tiers & benefits
</summary>
<div className="mt-3 space-y-1 max-h-48 overflow-y-auto">
{tiers.map((tier: TierInfo) => {
const isCurrentTier = tier.tier === stats.tier
const isUnlocked = tier.tier <= stats.tier
const colors = TIER_COLORS[tier.name] || TIER_COLORS['Bronze I']
return (
<div
key={tier.tier}
className={`flex items-center gap-2 p-2 rounded-lg text-sm ${
isCurrentTier
? `${colors.border} border bg-gray-50`
: isUnlocked
? 'bg-gray-50'
: 'bg-white opacity-60'
}`}
>
<span className="text-lg">{TIER_ICONS[tier.name] || '🏅'}</span>
<div className="flex-1">
<span className={isUnlocked ? colors.text : 'text-gray-400'}>
{tier.name}
</span>
<span className="text-gray-400 text-xs ml-2">
{tier.min_xp.toLocaleString()} XP
</span>
</div>
<span className={`font-medium ${isUnlocked ? 'text-green-600' : 'text-gray-400'}`}>
{tier.house_fee}%
</span>
{isCurrentTier && (
<span className="text-xs bg-primary text-white px-1.5 py-0.5 rounded">
YOU
</span>
)}
</div>
)
})}
</div>
</details>
</div>
)
}

View File

@ -0,0 +1,89 @@
import { useQuery } from '@tanstack/react-query'
import { getWhaleBets } from '../../api/gamification'
import { formatDistanceToNow } from 'date-fns'
import type { WhaleBet } from '../../types/gamification'
const TIER_COLORS: Record<string, string> = {
'Bronze I': 'text-amber-700',
'Bronze II': 'text-amber-700',
'Bronze III': 'text-amber-700',
'Silver I': 'text-gray-500',
'Silver II': 'text-gray-500',
'Silver III': 'text-gray-500',
'Gold I': 'text-yellow-600',
'Gold II': 'text-yellow-600',
'Gold III': 'text-yellow-600',
'Platinum': 'text-cyan-600',
'Diamond': 'text-purple-600',
}
function getWhaleEmoji(amount: number): string {
if (amount >= 5000) return '🐋'
if (amount >= 2000) return '🦈'
if (amount >= 1000) return '🐬'
return '🐟'
}
export default function WhaleTracker() {
const { data: whales = [], isLoading } = useQuery({
queryKey: ['whale-bets'],
queryFn: () => getWhaleBets(10),
refetchInterval: 15000,
retry: 1,
})
return (
<div className="bg-white rounded-xl shadow-sm p-6">
<h3 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
<span className="text-2xl">🐋</span> Whale Tracker
</h3>
{isLoading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-16 bg-gray-100 rounded-lg animate-pulse" />
))}
</div>
) : whales.length === 0 ? (
<p className="text-gray-500 text-center py-4">No big bets yet... Be the first whale!</p>
) : (
<div className="space-y-3">
{whales.map((whale: WhaleBet) => (
<div
key={whale.id}
className="bg-gradient-to-r from-green-50 to-transparent rounded-lg p-3 border-l-4 border-green-500"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xl">{getWhaleEmoji(whale.amount)}</span>
<div className="min-w-0">
<p className="text-gray-900 font-medium text-sm truncate">
{whale.display_name || whale.username}
</p>
<p className={`text-xs ${TIER_COLORS[whale.tier_name] || 'text-gray-500'}`}>
{whale.tier_name}
</p>
</div>
</div>
<div className="text-right">
<p className="text-green-600 font-bold">
${whale.amount.toLocaleString()}
</p>
<p className="text-gray-500 text-xs">
{formatDistanceToNow(new Date(whale.created_at), { addSuffix: true })}
</p>
</div>
</div>
<div className="mt-2 text-xs text-gray-600 truncate">
<span className="text-gray-400">on</span> {whale.event_title}
<span className="ml-2 px-1.5 py-0.5 bg-gray-100 rounded text-gray-600">
{whale.side}
</span>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,6 @@
export { default as Leaderboard } from './Leaderboard'
export { default as WhaleTracker } from './WhaleTracker'
export { default as Achievements } from './Achievements'
export { default as LootBoxes } from './LootBoxes'
export { default as ActivityFeed } from './ActivityFeed'
export { default as TierProgress } from './TierProgress'

View File

@ -1,9 +1,64 @@
import { useState, useRef, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store'
import { Wallet, LogOut, User } from 'lucide-react'
import { Wallet, LogOut, User, ChevronDown, Trophy, Medal, Gift, Activity, LayoutDashboard } from 'lucide-react'
import { useWeb3Wallet } from '@/blockchain/hooks/useWeb3Wallet'
import { Button } from '@/components/common/Button'
const REWARDS_DROPDOWN = [
{ path: '/rewards', label: 'Overview', icon: LayoutDashboard },
{ path: '/rewards/leaderboard', label: 'Leaderboard', icon: Trophy },
{ path: '/rewards/achievements', label: 'Achievements', icon: Medal },
{ path: '/rewards/loot-boxes', label: 'Loot Boxes', icon: Gift },
{ path: '/rewards/activity', label: 'Activity', icon: Activity },
]
function RewardsDropdown() {
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1 text-gray-700 hover:text-primary transition-colors"
>
Rewards
<ChevronDown size={16} className={`transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="absolute top-full left-0 mt-2 w-48 bg-white rounded-lg shadow-lg border py-1 z-50">
{REWARDS_DROPDOWN.map((item) => {
const Icon = item.icon
return (
<Link
key={item.path}
to={item.path}
onClick={() => setIsOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-50 hover:text-primary transition-colors"
>
<Icon size={16} />
{item.label}
</Link>
)
})}
</div>
)}
</div>
)
}
export const Header = () => {
const { user, logout } = useAuthStore()
const { walletAddress, isConnected, connectWallet, disconnectWallet } = useWeb3Wallet()
@ -31,6 +86,7 @@ export const Header = () => {
<Link to="/watchlist" className="text-gray-700 hover:text-primary transition-colors">
Watchlist
</Link>
<RewardsDropdown />
<div className="flex items-center gap-4 pl-4 border-l">
{user.is_admin && (
@ -95,6 +151,7 @@ export const Header = () => {
<Link to="/watchlist" className="text-gray-700 hover:text-primary transition-colors">
Watchlist
</Link>
<RewardsDropdown />
<Link to="/how-it-works" className="text-gray-700 hover:text-primary transition-colors">
How It Works
</Link>

View File

@ -0,0 +1,73 @@
import { ReactNode } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { Header } from './Header'
import { Trophy, Medal, Gift, Activity, LayoutDashboard } from 'lucide-react'
interface RewardsLayoutProps {
children: ReactNode
title?: string
subtitle?: string
}
const NAV_ITEMS = [
{ path: '/rewards', label: 'Overview', icon: LayoutDashboard, exact: true },
{ path: '/rewards/leaderboard', label: 'Leaderboard', icon: Trophy },
{ path: '/rewards/achievements', label: 'Achievements', icon: Medal },
{ path: '/rewards/loot-boxes', label: 'Loot Boxes', icon: Gift },
{ path: '/rewards/activity', label: 'Activity', icon: Activity },
]
export function RewardsLayout({ children, title, subtitle }: RewardsLayoutProps) {
const location = useLocation()
const isActive = (path: string, exact?: boolean) => {
if (exact) {
return location.pathname === path
}
return location.pathname.startsWith(path)
}
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Page Header */}
{title && (
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900">{title}</h1>
{subtitle && <p className="text-gray-600 mt-1">{subtitle}</p>}
</div>
)}
{/* Sub Navigation */}
<div className="bg-white rounded-xl shadow-sm mb-6">
<nav className="flex overflow-x-auto">
{NAV_ITEMS.map((item) => {
const Icon = item.icon
const active = isActive(item.path, item.exact)
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
active
? 'border-primary text-primary'
: 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
}`}
>
<Icon size={18} />
{item.label}
</Link>
)
})}
</nav>
</div>
{/* Content */}
{children}
</div>
</div>
)
}

View File

@ -3,10 +3,12 @@ import { Link, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '@/store'
import { sportEventsApi } from '@/api/sport-events'
import { getLeaderboard, getRecentWins, getMyStats } from '@/api/gamification'
import { Button } from '@/components/common/Button'
import { Loading } from '@/components/common/Loading'
import { Header } from '@/components/layout/Header'
import { TrendingUp, Clock, ArrowRight } from 'lucide-react'
import { TrendingUp, Clock, ArrowRight, Trophy, Flame, Star } from 'lucide-react'
import type { LeaderboardEntry, RecentWin } from '@/types/gamification'
export const Home = () => {
const navigate = useNavigate()
@ -14,9 +16,33 @@ export const Home = () => {
const [email, setEmail] = useState('')
// Use public API for events (works for both authenticated and non-authenticated)
const { data: events, isLoading: isLoadingEvents } = useQuery({
const { data: events, isLoading: isLoadingEvents, isError: isEventsError } = useQuery({
queryKey: ['public-sport-events'],
queryFn: () => sportEventsApi.getPublicEvents(),
retry: 1,
staleTime: 30000,
})
// Gamification queries - don't block page load on these
const { data: topEarners } = useQuery({
queryKey: ['leaderboard', 'profit', 5],
queryFn: () => getLeaderboard('profit', 5),
retry: 1,
staleTime: 60000,
})
const { data: recentWins } = useQuery({
queryKey: ['recent-wins-home'],
queryFn: () => getRecentWins(5),
refetchInterval: 15000,
retry: 1,
})
const { data: myStats } = useQuery({
queryKey: ['my-stats'],
queryFn: getMyStats,
enabled: isAuthenticated,
retry: 1,
})
const handleSignUp = (e: React.FormEvent) => {
@ -24,21 +50,12 @@ export const Home = () => {
navigate(`/register?email=${encodeURIComponent(email)}`)
}
// Loading state
if (isLoadingEvents) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="flex items-center justify-center py-20">
<Loading />
</div>
</div>
)
}
// Calculate total open bets across all events
const totalOpenBets = events?.length || 0
// Show skeleton/loading only for a brief moment, then show content regardless
const showEventsLoading = isLoadingEvents && !isEventsError
return (
<div className="min-h-screen bg-gray-50">
<Header />
@ -96,7 +113,7 @@ export const Home = () => {
</div>
{/* Animated Activity Ticker */}
{events && events.length > 0 && (
{!isEventsError && events && events.length > 0 && (
<div className="bg-gray-900 text-white overflow-hidden">
<div className="relative">
<div className="flex animate-scroll">
@ -177,7 +194,23 @@ export const Home = () => {
</div>
</div>
{!events || events.length === 0 ? (
{showEventsLoading ? (
<div className="text-center py-16 bg-white rounded-xl shadow-sm">
<Loading />
<p className="text-gray-400 mt-4">Loading events...</p>
</div>
) : isEventsError ? (
<div className="text-center py-16 bg-white rounded-xl shadow-sm">
<p className="text-gray-500 text-lg">Unable to load events</p>
<p className="text-gray-400 mt-2">Please check that the backend server is running</p>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
>
Retry
</button>
</div>
) : !events || events.length === 0 ? (
<div className="text-center py-16 bg-white rounded-xl shadow-sm">
<p className="text-gray-500 text-lg">No upcoming events available</p>
<p className="text-gray-400 mt-2">Check back soon for new betting opportunities</p>
@ -253,6 +286,135 @@ export const Home = () => {
</div>
)}
{/* Gamification Section */}
<div className="mt-12 grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Leaderboard Preview */}
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2">
<Trophy className="text-yellow-500" size={20} />
Top Earners
</h3>
<Link to="/rewards" className="text-primary text-sm hover:underline">
View All
</Link>
</div>
{topEarners && topEarners.length > 0 ? (
<div className="space-y-3">
{topEarners.slice(0, 5).map((entry: LeaderboardEntry, index: number) => (
<div key={entry.user_id} className="flex items-center gap-3">
<span className="w-6 text-center font-bold text-gray-500">
{index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `#${index + 1}`}
</span>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">
{entry.display_name || entry.username}
</p>
<p className="text-xs text-gray-500">{entry.tier_name}</p>
</div>
<span className="text-green-600 font-bold">
+${entry.value.toLocaleString()}
</span>
</div>
))}
</div>
) : (
<p className="text-gray-400 text-center py-4">No data yet</p>
)}
</div>
{/* Recent Wins */}
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2">
<Flame className="text-orange-500" size={20} />
Recent Wins
</h3>
<Link to="/rewards?tab=activity" className="text-primary text-sm hover:underline">
View All
</Link>
</div>
{recentWins && recentWins.length > 0 ? (
<div className="space-y-3">
{recentWins.slice(0, 5).map((win: RecentWin) => (
<div key={win.id} className="flex items-center gap-3">
<span className="text-xl">🏆</span>
<div className="flex-1 min-w-0">
<p className="text-sm">
<span className="font-medium text-gray-900">{win.display_name || win.username}</span>
<span className="text-gray-500"> won </span>
<span className="text-green-600 font-bold">${win.amount_won.toLocaleString()}</span>
</p>
<p className="text-xs text-gray-400 truncate">{win.event_title}</p>
</div>
</div>
))}
</div>
) : (
<p className="text-gray-400 text-center py-4">No wins yet</p>
)}
</div>
{/* Your Progress (if authenticated) or Join CTA */}
{isAuthenticated && myStats ? (
<div className="bg-gradient-to-br from-primary/10 to-blue-100 rounded-xl shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2">
<Star className="text-yellow-500" size={20} />
Your Progress
</h3>
<Link to="/rewards" className="text-primary text-sm hover:underline">
Details
</Link>
</div>
<div className="text-center mb-4">
<p className="text-3xl font-bold text-primary">{myStats.tier_name}</p>
<p className="text-sm text-gray-600">Tier {myStats.tier} {myStats.house_fee}% fees</p>
</div>
<div className="mb-4">
<div className="flex justify-between text-sm text-gray-600 mb-1">
<span>{myStats.xp.toLocaleString()} XP</span>
<span>{myStats.tier_progress_percent.toFixed(0)}%</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${myStats.tier_progress_percent}%` }}
/>
</div>
{myStats.tier < 10 && (
<p className="text-xs text-gray-500 mt-1 text-right">
{myStats.xp_to_next_tier.toLocaleString()} XP to next tier
</p>
)}
</div>
<div className="grid grid-cols-2 gap-3 text-center">
<div className="bg-white/50 rounded-lg p-2">
<p className="text-lg font-bold text-gray-900">{myStats.current_win_streak}</p>
<p className="text-xs text-gray-500">Win Streak</p>
</div>
<div className="bg-white/50 rounded-lg p-2">
<p className={`text-lg font-bold ${myStats.net_profit >= 0 ? 'text-green-600' : 'text-red-600'}`}>
${myStats.net_profit >= 0 ? '+' : ''}{myStats.net_profit.toLocaleString()}
</p>
<p className="text-xs text-gray-500">Net Profit</p>
</div>
</div>
</div>
) : (
<div className="bg-gradient-to-br from-primary/10 to-blue-100 rounded-xl shadow-sm p-6 flex flex-col items-center justify-center text-center">
<span className="text-4xl mb-3">🎮</span>
<h3 className="text-lg font-bold text-gray-900 mb-2">Level Up & Earn Rewards</h3>
<p className="text-sm text-gray-600 mb-4">
Join to track your stats, earn achievements, and lower your fees!
</p>
<Link to="/register">
<Button>Get Started</Button>
</Link>
</div>
)}
</div>
{/* CTA for non-authenticated users */}
{!isAuthenticated && (
<div className="mt-12 text-center bg-gradient-to-r from-primary/10 to-blue-100 rounded-xl p-8">

View File

@ -0,0 +1,40 @@
import { useAuthStore } from '@/store'
import { Link } from 'react-router-dom'
import { RewardsLayout } from '@/components/layout/RewardsLayout'
import { Achievements, TierProgress } from '@/components/gamification'
export default function AchievementsPage() {
const { isAuthenticated } = useAuthStore()
return (
<RewardsLayout
title="Achievements"
subtitle="Track your progress and unlock badges"
>
{isAuthenticated ? (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<Achievements showStreaks />
</div>
<div>
<TierProgress />
</div>
</div>
) : (
<div className="bg-white rounded-xl shadow-sm p-8 text-center max-w-md mx-auto">
<span className="text-6xl mb-4 block">🔒</span>
<h3 className="text-xl font-bold text-gray-900 mb-2">Sign In Required</h3>
<p className="text-gray-500 mb-6">
Sign in to view your achievements, streaks, and progress.
</p>
<Link
to="/login"
className="bg-primary hover:bg-primary/90 text-white px-6 py-2 rounded-lg font-medium transition-colors inline-block"
>
Sign In
</Link>
</div>
)}
</RewardsLayout>
)
}

View File

@ -0,0 +1,20 @@
import { RewardsLayout } from '@/components/layout/RewardsLayout'
import { ActivityFeed, Leaderboard } from '@/components/gamification'
export default function ActivityPage() {
return (
<RewardsLayout
title="Activity"
subtitle="See what's happening across the platform"
>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<ActivityFeed showRecentWins limit={30} />
</div>
<div>
<Leaderboard />
</div>
</div>
</RewardsLayout>
)
}

View File

@ -0,0 +1,16 @@
import { RewardsLayout } from '@/components/layout/RewardsLayout'
import { Leaderboard, WhaleTracker } from '@/components/gamification'
export default function LeaderboardPage() {
return (
<RewardsLayout
title="Leaderboard"
subtitle="See how you stack up against other players"
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Leaderboard />
<WhaleTracker />
</div>
</RewardsLayout>
)
}

View File

@ -0,0 +1,75 @@
import { useAuthStore } from '@/store'
import { Link } from 'react-router-dom'
import { RewardsLayout } from '@/components/layout/RewardsLayout'
import { LootBoxes, TierProgress } from '@/components/gamification'
export default function LootBoxesPage() {
const { isAuthenticated } = useAuthStore()
return (
<RewardsLayout
title="Loot Boxes"
subtitle="Open your rewards and claim prizes"
>
{isAuthenticated ? (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<LootBoxes />
{/* How to earn loot boxes */}
<div className="mt-6 bg-white rounded-xl shadow-sm p-6">
<h3 className="text-lg font-bold text-gray-900 mb-4">How to Earn Loot Boxes</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-start gap-3">
<span className="text-2xl"></span>
<div>
<p className="font-medium text-gray-900">Tier Up</p>
<p className="text-sm text-gray-500">Reach a new tier to earn a loot box</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-2xl">🎖</span>
<div>
<p className="font-medium text-gray-900">Achievements</p>
<p className="text-sm text-gray-500">Unlock achievements for bonus boxes</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-2xl">📅</span>
<div>
<p className="font-medium text-gray-900">Daily Rewards</p>
<p className="text-sm text-gray-500">Log in daily to earn streak rewards</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-2xl">🔥</span>
<div>
<p className="font-medium text-gray-900">Win Streaks</p>
<p className="text-sm text-gray-500">Build winning streaks for rare boxes</p>
</div>
</div>
</div>
</div>
</div>
<div>
<TierProgress />
</div>
</div>
) : (
<div className="bg-white rounded-xl shadow-sm p-8 text-center max-w-md mx-auto">
<span className="text-6xl mb-4 block">🔒</span>
<h3 className="text-xl font-bold text-gray-900 mb-2">Sign In Required</h3>
<p className="text-gray-500 mb-6">
Sign in to view and open your loot boxes.
</p>
<Link
to="/login"
className="bg-primary hover:bg-primary/90 text-white px-6 py-2 rounded-lg font-medium transition-colors inline-block"
>
Sign In
</Link>
</div>
)}
</RewardsLayout>
)
}

View File

@ -0,0 +1,107 @@
import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store'
import { RewardsLayout } from '@/components/layout/RewardsLayout'
import { Leaderboard, WhaleTracker, TierProgress } from '@/components/gamification'
import { Trophy, Medal, Gift, Activity, ArrowRight } from 'lucide-react'
const QUICK_LINKS = [
{ path: '/rewards/leaderboard', label: 'Leaderboard', icon: Trophy, description: 'See top players and rankings' },
{ path: '/rewards/achievements', label: 'Achievements', icon: Medal, description: 'Track your progress and badges' },
{ path: '/rewards/loot-boxes', label: 'Loot Boxes', icon: Gift, description: 'Open rewards and claim prizes' },
{ path: '/rewards/activity', label: 'Activity', icon: Activity, description: 'View recent wins and bets' },
]
export default function RewardsIndex() {
const { isAuthenticated } = useAuthStore()
return (
<RewardsLayout
title="Rewards & Leaderboards"
subtitle="Level up, earn rewards, and compete with other players"
>
{/* Quick Links */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{QUICK_LINKS.map((link) => {
const Icon = link.icon
return (
<Link
key={link.path}
to={link.path}
className="bg-white rounded-xl shadow-sm p-4 hover:shadow-md transition-shadow group"
>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-primary/10 rounded-lg group-hover:bg-primary/20 transition-colors">
<Icon className="text-primary" size={24} />
</div>
<ArrowRight className="ml-auto text-gray-300 group-hover:text-primary transition-colors" size={20} />
</div>
<h3 className="font-semibold text-gray-900">{link.label}</h3>
<p className="text-sm text-gray-500">{link.description}</p>
</Link>
)
})}
</div>
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Personal Stats (if authenticated) */}
{isAuthenticated ? (
<div>
<TierProgress />
</div>
) : (
<div className="bg-white rounded-xl shadow-sm p-6 flex flex-col items-center justify-center text-center">
<span className="text-5xl mb-4">🔒</span>
<h3 className="text-xl font-bold text-gray-900 mb-2">Sign In to Track Progress</h3>
<p className="text-gray-500 mb-4">
Create an account to track your stats, earn achievements, and climb the leaderboard!
</p>
<Link
to="/login"
className="bg-primary hover:bg-primary/90 text-white px-6 py-2 rounded-lg font-medium transition-colors"
>
Sign In
</Link>
</div>
)}
{/* Middle Column - Leaderboard */}
<div>
<Leaderboard />
</div>
{/* Right Column - Whale Tracker */}
<div>
<WhaleTracker />
</div>
</div>
{/* Tier Benefits Info */}
<div className="mt-8 bg-white rounded-xl shadow-sm p-6">
<h3 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2">
<span>💡</span> How Tiers Work
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div className="bg-gray-50 rounded-lg p-4 border">
<p className="text-primary font-bold mb-1">🎯 Earn XP</p>
<p className="text-gray-600">
Place bets, win, and complete achievements to earn XP and level up your tier.
</p>
</div>
<div className="bg-gray-50 rounded-lg p-4 border">
<p className="text-primary font-bold mb-1">📉 Lower Fees</p>
<p className="text-gray-600">
Higher tiers mean lower house fees! Start at 10% and work your way down to 5%.
</p>
</div>
<div className="bg-gray-50 rounded-lg p-4 border">
<p className="text-primary font-bold mb-1">🎁 Unlock Rewards</p>
<p className="text-gray-600">
Earn loot boxes, achievement badges, and exclusive perks as you climb the ranks.
</p>
</div>
</div>
</div>
</RewardsLayout>
)
}

View File

@ -0,0 +1,5 @@
export { default as RewardsIndex } from './RewardsIndex'
export { default as LeaderboardPage } from './LeaderboardPage'
export { default as AchievementsPage } from './AchievementsPage'
export { default as LootBoxesPage } from './LootBoxesPage'
export { default as ActivityPage } from './ActivityPage'

View File

@ -0,0 +1,135 @@
/**
* Rewards Dark Theme - Saved for potential future use
*
* This dark theme was originally used on the Rewards/Gamification pages.
* To re-enable, import this CSS and apply the dark-rewards class to your container.
*
* Usage: <div className="dark-rewards">...</div>
*/
.dark-rewards {
--bg-primary: #111827; /* bg-gray-900 */
--bg-secondary: #1f2937; /* bg-gray-800 */
--bg-tertiary: #374151; /* bg-gray-700 */
--text-primary: #ffffff;
--text-secondary: #9ca3af; /* text-gray-400 */
--text-muted: #6b7280; /* text-gray-500 */
--border-color: #374151; /* border-gray-700 */
}
/* Background utilities */
.dark-rewards .dark-bg-primary { background-color: var(--bg-primary); }
.dark-rewards .dark-bg-secondary { background-color: var(--bg-secondary); }
.dark-rewards .dark-bg-tertiary { background-color: var(--bg-tertiary); }
/* Text utilities */
.dark-rewards .dark-text-primary { color: var(--text-primary); }
.dark-rewards .dark-text-secondary { color: var(--text-secondary); }
.dark-rewards .dark-text-muted { color: var(--text-muted); }
/* Component examples (from original dark theme) */
/* Card styling */
.dark-rewards .dark-card {
background-color: var(--bg-secondary);
border-radius: 0.75rem;
padding: 1rem;
}
/* Leaderboard entry */
.dark-rewards .dark-leaderboard-entry {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
border-radius: 0.5rem;
background-color: rgba(55, 65, 81, 0.4); /* bg-gray-700/40 */
}
.dark-rewards .dark-leaderboard-entry.top-3 {
background-color: rgba(55, 65, 81, 0.8); /* bg-gray-700/80 */
}
/* Whale tracker card */
.dark-rewards .dark-whale-card {
background: linear-gradient(to right, rgba(55, 65, 81, 0.6), rgba(55, 65, 81, 0.3));
border-radius: 0.5rem;
padding: 0.75rem;
border-left: 4px solid #22c55e; /* border-green-500 */
}
/* Achievement card */
.dark-rewards .dark-achievement-card {
border-radius: 0.5rem;
padding: 0.75rem;
}
.dark-rewards .dark-achievement-card.common {
border: 1px solid #6b7280;
background-color: rgba(107, 114, 128, 0.1);
}
.dark-rewards .dark-achievement-card.rare {
border: 1px solid #3b82f6;
background-color: rgba(59, 130, 246, 0.1);
}
.dark-rewards .dark-achievement-card.epic {
border: 1px solid #a855f7;
background-color: rgba(168, 85, 247, 0.1);
}
.dark-rewards .dark-achievement-card.legendary {
border: 1px solid #eab308;
background-color: rgba(234, 179, 8, 0.1);
box-shadow: 0 10px 15px -3px rgba(234, 179, 8, 0.4);
}
/* Loot box styling */
.dark-rewards .dark-lootbox {
border-radius: 0.5rem;
padding: 0.75rem;
text-align: center;
transition: all 0.2s;
}
.dark-rewards .dark-lootbox:hover {
transform: scale(1.05);
}
.dark-rewards .dark-lootbox.common { background-color: #4b5563; }
.dark-rewards .dark-lootbox.uncommon { background-color: rgba(20, 83, 45, 0.5); }
.dark-rewards .dark-lootbox.rare { background-color: rgba(30, 58, 138, 0.5); }
.dark-rewards .dark-lootbox.epic { background-color: rgba(88, 28, 135, 0.5); }
.dark-rewards .dark-lootbox.legendary {
background-color: rgba(113, 63, 18, 0.3);
animation: pulse 2s infinite;
}
/* Tier progress bar */
.dark-rewards .dark-tier-progress {
height: 0.75rem;
background-color: var(--bg-tertiary);
border-radius: 9999px;
overflow: hidden;
}
/* Tier colors */
.dark-rewards .tier-bronze { color: #d97706; }
.dark-rewards .tier-silver { color: #9ca3af; }
.dark-rewards .tier-gold { color: #facc15; }
.dark-rewards .tier-platinum { color: #22d3ee; }
.dark-rewards .tier-diamond { color: #a855f7; }
/* Activity feed */
.dark-rewards .dark-activity-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(55, 65, 81, 0.5);
}
.dark-rewards .dark-activity-item:last-child {
border-bottom: none;
}

View File

@ -0,0 +1,151 @@
// Gamification Types
export type AchievementType =
| 'first_bet'
| 'first_win'
| 'win_streak_3'
| 'win_streak_5'
| 'win_streak_10'
| 'whale_bet'
| 'high_roller'
| 'consistent'
| 'underdog'
| 'sharpshooter'
| 'veteran'
| 'legend'
| 'profit_master'
| 'comeback_king'
| 'early_bird'
| 'social_butterfly'
| 'tier_up'
| 'max_tier'
export type LootBoxRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
export type LootBoxRewardType =
| 'bonus_cash'
| 'xp_boost'
| 'fee_reduction'
| 'free_bet'
| 'avatar_frame'
| 'badge'
| 'nothing'
export interface TierInfo {
tier: number
min_xp: number
house_fee: number
name: string
}
export interface UserStats {
id: number
user_id: number
xp: number
tier: number
tier_name: string
house_fee: number
xp_to_next_tier: number
tier_progress_percent: number
total_wagered: number
total_won: number
total_lost: number
net_profit: number
biggest_win: number
biggest_bet: number
current_win_streak: number
current_loss_streak: number
best_win_streak: number
worst_loss_streak: number
bets_today: number
bets_this_week: number
bets_this_month: number
consecutive_days_betting: number
unique_opponents: number
}
export interface Achievement {
id: number
type: AchievementType
name: string
description: string
icon: string
xp_reward: number
rarity: string
}
export interface UserAchievement {
id: number
achievement: Achievement
earned_at: string
notified: boolean
}
export interface LootBox {
id: number
rarity: LootBoxRarity
source: string
opened: boolean
reward_type?: LootBoxRewardType
reward_value?: string
created_at: string
opened_at?: string
}
export interface LootBoxOpenResult {
reward_type: LootBoxRewardType
reward_value: string
message: string
}
export interface ActivityFeedItem {
id: number
user_id: number
username: string
activity_type: string
message: string
amount?: number
created_at: string
}
export interface LeaderboardEntry {
rank: number
user_id: number
username: string
display_name?: string
avatar_url?: string
tier: number
tier_name: string
value: number
}
export interface WhaleBet {
id: number
username: string
display_name?: string
avatar_url?: string
tier: number
tier_name: string
event_title: string
amount: number
side: string
created_at: string
}
export interface RecentWin {
id: number
username: string
display_name?: string
avatar_url?: string
event_title: string
amount_won: number
settled_at: string
}
export interface DailyReward {
day_streak: number
reward_type: string
reward_value: string
message: string
next_reward_available: string
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

View File

@ -0,0 +1,440 @@
import { test, expect, Page } from '@playwright/test';
// Test data
const TEST_USER = {
email: 'alice@example.com',
password: 'password123',
};
// Helper to capture console errors
function setupErrorCapture(page: Page): string[] {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') {
errors.push(msg.text());
}
});
page.on('pageerror', err => {
errors.push(err.message);
});
return errors;
}
// Helper to filter out expected API errors (backend may not be running)
function filterCriticalErrors(errors: string[]): string[] {
return errors.filter(e =>
!e.includes('Failed to load resource') &&
!e.includes('ERR_CONNECTION_REFUSED') &&
!e.includes('NetworkError') &&
!e.includes('net::ERR')
);
}
test.describe('Home Page Loading', () => {
test('should load home page without getting stuck in spinner', async ({ page }) => {
const errors = setupErrorCapture(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
// Wait for initial content
await page.waitForTimeout(3000);
// Check H2H branding is visible
await expect(page.locator('header h1:has-text("H2H")')).toBeVisible({ timeout: 5000 });
// Page should show content, not just a spinner
// Either shows loading state briefly or actual content
const hasContent = await page.locator('main').isVisible() ||
await page.locator('[class*="container"]').isVisible();
expect(hasContent).toBe(true);
// Check for critical errors (not API errors from missing backend)
const criticalErrors = filterCriticalErrors(errors);
expect(criticalErrors).toHaveLength(0);
});
test('should show H2H header branding', async ({ page }) => {
await page.goto('/');
await expect(page.locator('header h1:has-text("H2H")')).toBeVisible({ timeout: 5000 });
});
test('should have navigation links including Rewards', async ({ page }) => {
await page.goto('/');
// Check Rewards navigation exists (dropdown button)
await expect(page.locator('nav button:has-text("Rewards"), nav a:has-text("Rewards")').first()).toBeVisible({ timeout: 5000 });
});
});
test.describe('Rewards Pages Navigation', () => {
test('should navigate to /rewards overview page', async ({ page }) => {
const errors = setupErrorCapture(page);
await page.goto('/rewards', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
// Check page title
await expect(page.locator('h1:has-text("Rewards")')).toBeVisible({ timeout: 5000 });
// Check for no critical errors
const criticalErrors = filterCriticalErrors(errors);
expect(criticalErrors).toHaveLength(0);
});
test('should navigate to /rewards/leaderboard page', async ({ page }) => {
const errors = setupErrorCapture(page);
await page.goto('/rewards/leaderboard', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
// Check page title
await expect(page.locator('h1:has-text("Leaderboard")')).toBeVisible({ timeout: 5000 });
const criticalErrors = filterCriticalErrors(errors);
expect(criticalErrors).toHaveLength(0);
});
test('should navigate to /rewards/achievements page', async ({ page }) => {
const errors = setupErrorCapture(page);
await page.goto('/rewards/achievements', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
// Check page title
await expect(page.locator('h1:has-text("Achievements")')).toBeVisible({ timeout: 5000 });
const criticalErrors = filterCriticalErrors(errors);
expect(criticalErrors).toHaveLength(0);
});
test('should navigate to /rewards/loot-boxes page', async ({ page }) => {
const errors = setupErrorCapture(page);
await page.goto('/rewards/loot-boxes', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
// Check page title
await expect(page.locator('h1:has-text("Loot Boxes")')).toBeVisible({ timeout: 5000 });
const criticalErrors = filterCriticalErrors(errors);
expect(criticalErrors).toHaveLength(0);
});
test('should navigate to /rewards/activity page', async ({ page }) => {
const errors = setupErrorCapture(page);
await page.goto('/rewards/activity', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
// Check page title
await expect(page.locator('h1:has-text("Activity")')).toBeVisible({ timeout: 5000 });
const criticalErrors = filterCriticalErrors(errors);
expect(criticalErrors).toHaveLength(0);
});
});
test.describe('Header Rewards Dropdown', () => {
test('should show Rewards dropdown button in header', async ({ page }) => {
await page.goto('/');
// Find the Rewards dropdown button
const rewardsButton = page.locator('nav button:has-text("Rewards")').first();
await expect(rewardsButton).toBeVisible({ timeout: 5000 });
});
test('should open dropdown menu when clicking Rewards', async ({ page }) => {
await page.goto('/');
// Click Rewards dropdown
const rewardsButton = page.locator('nav button:has-text("Rewards")').first();
await rewardsButton.click();
// Check dropdown menu appears with all options
await expect(page.locator('a[href="/rewards"]')).toBeVisible({ timeout: 3000 });
await expect(page.locator('a[href="/rewards/leaderboard"]')).toBeVisible({ timeout: 3000 });
await expect(page.locator('a[href="/rewards/achievements"]')).toBeVisible({ timeout: 3000 });
await expect(page.locator('a[href="/rewards/loot-boxes"]')).toBeVisible({ timeout: 3000 });
await expect(page.locator('a[href="/rewards/activity"]')).toBeVisible({ timeout: 3000 });
});
test('should navigate to rewards overview from dropdown', async ({ page }) => {
await page.goto('/');
// Click Rewards dropdown
await page.locator('nav button:has-text("Rewards")').first().click();
// Click Overview link
await page.locator('a[href="/rewards"]:has-text("Overview")').click();
// Verify navigation
await expect(page).toHaveURL('/rewards');
await expect(page.locator('h1:has-text("Rewards")')).toBeVisible({ timeout: 5000 });
});
test('should navigate to leaderboard from dropdown', async ({ page }) => {
await page.goto('/');
await page.locator('nav button:has-text("Rewards")').first().click();
await page.locator('a[href="/rewards/leaderboard"]').click();
await expect(page).toHaveURL('/rewards/leaderboard');
await expect(page.locator('h1:has-text("Leaderboard")')).toBeVisible({ timeout: 5000 });
});
test('should close dropdown when clicking outside', async ({ page }) => {
await page.goto('/');
// Open dropdown
await page.locator('nav button:has-text("Rewards")').first().click();
await expect(page.locator('a[href="/rewards/leaderboard"]')).toBeVisible();
// Click outside
await page.locator('header h1:has-text("H2H")').click();
// Dropdown should close (link should not be visible in dropdown context)
await page.waitForTimeout(500);
// The dropdown should be hidden
const dropdown = page.locator('div.absolute a[href="/rewards/leaderboard"]');
await expect(dropdown).not.toBeVisible({ timeout: 2000 });
});
});
test.describe('Theme Consistency - Light Theme', () => {
test('rewards overview page should have light theme with white backgrounds', async ({ page }) => {
await page.goto('/rewards');
await page.waitForTimeout(2000);
// Check for Header component
await expect(page.locator('header')).toBeVisible();
// Check header has white/light background
const header = page.locator('header');
await expect(header).toHaveCSS('background-color', 'rgb(255, 255, 255)');
// Check page background is light (gray-50 = rgb(249, 250, 251))
const body = page.locator('div.min-h-screen');
const bgColor = await body.evaluate((el) => window.getComputedStyle(el).backgroundColor);
expect(['rgb(249, 250, 251)', 'rgb(248, 250, 252)']).toContain(bgColor);
});
test('leaderboard page should have light theme', async ({ page }) => {
await page.goto('/rewards/leaderboard');
await page.waitForTimeout(2000);
// Check for Header component
await expect(page.locator('header')).toBeVisible();
// Page title should be dark text
const title = page.locator('h1:has-text("Leaderboard")');
await expect(title).toHaveCSS('color', 'rgb(17, 24, 39)'); // gray-900
});
test('achievements page should have light theme', async ({ page }) => {
await page.goto('/rewards/achievements');
await page.waitForTimeout(2000);
await expect(page.locator('header')).toBeVisible();
const title = page.locator('h1:has-text("Achievements")');
await expect(title).toHaveCSS('color', 'rgb(17, 24, 39)');
});
test('loot boxes page should have light theme', async ({ page }) => {
await page.goto('/rewards/loot-boxes');
await page.waitForTimeout(2000);
await expect(page.locator('header')).toBeVisible();
const title = page.locator('h1:has-text("Loot Boxes")');
await expect(title).toHaveCSS('color', 'rgb(17, 24, 39)');
});
test('activity page should have light theme', async ({ page }) => {
await page.goto('/rewards/activity');
await page.waitForTimeout(2000);
await expect(page.locator('header')).toBeVisible();
const title = page.locator('h1:has-text("Activity")');
await expect(title).toHaveCSS('color', 'rgb(17, 24, 39)');
});
});
test.describe('Authentication Gates', () => {
test('achievements page should show "Sign In Required" when not logged in', async ({ page }) => {
await page.goto('/rewards/achievements');
await page.waitForTimeout(2000);
// Should show sign in required message
await expect(page.locator('text=Sign In Required')).toBeVisible({ timeout: 5000 });
// Should have sign in button
await expect(page.locator('a[href="/login"]:has-text("Sign In")')).toBeVisible();
});
test('loot boxes page should show "Sign In Required" when not logged in', async ({ page }) => {
await page.goto('/rewards/loot-boxes');
await page.waitForTimeout(2000);
// Should show sign in required message
await expect(page.locator('text=Sign In Required')).toBeVisible({ timeout: 5000 });
// Should have sign in button
await expect(page.locator('a[href="/login"]:has-text("Sign In")')).toBeVisible();
});
test('rewards overview should show "Sign In to Track Progress" when not logged in', async ({ page }) => {
await page.goto('/rewards');
await page.waitForTimeout(2000);
// Should show sign in prompt
await expect(page.locator('text=Sign In to Track Progress')).toBeVisible({ timeout: 5000 });
});
test('leaderboard page should be accessible without auth', async ({ page }) => {
await page.goto('/rewards/leaderboard');
await page.waitForTimeout(2000);
// Should NOT show sign in required
await expect(page.locator('text=Sign In Required')).not.toBeVisible();
// Should show leaderboard content area
await expect(page.locator('h1:has-text("Leaderboard")')).toBeVisible();
});
test('activity page should be accessible without auth', async ({ page }) => {
await page.goto('/rewards/activity');
await page.waitForTimeout(2000);
// Should NOT show sign in required
await expect(page.locator('text=Sign In Required')).not.toBeVisible();
// Should show activity page
await expect(page.locator('h1:has-text("Activity")')).toBeVisible();
});
});
test.describe('Gamification Component Rendering', () => {
test('Leaderboard component should render on rewards page', async ({ page }) => {
await page.goto('/rewards');
await page.waitForTimeout(2000);
// Should have Leaderboard section with trophy icon/heading
await expect(page.locator('h3:has-text("Leaderboard")').first()).toBeVisible({ timeout: 5000 });
});
test('Leaderboard component should have category tabs', async ({ page }) => {
await page.goto('/rewards/leaderboard');
await page.waitForTimeout(2000);
// Check for category buttons
await expect(page.locator('button:has-text("Top Earners")').first()).toBeVisible({ timeout: 5000 });
await expect(page.locator('button:has-text("High Rollers")').first()).toBeVisible();
await expect(page.locator('button:has-text("Most Wins")').first()).toBeVisible();
});
test('WhaleTracker component should render on rewards page', async ({ page }) => {
await page.goto('/rewards');
await page.waitForTimeout(2000);
// WhaleTracker should be visible (may have whale icon or "Whale" text)
const whaleSection = page.locator('text=/whale/i').first();
const isVisible = await whaleSection.isVisible().catch(() => false);
// May show loading or content - just verify the section exists
expect(isVisible || await page.locator('.bg-white.rounded-xl').count() > 0).toBe(true);
});
test('rewards overview should have quick links section', async ({ page }) => {
await page.goto('/rewards');
await page.waitForTimeout(2000);
// Quick links to sub-pages
await expect(page.locator('a[href="/rewards/leaderboard"]').first()).toBeVisible({ timeout: 5000 });
await expect(page.locator('a[href="/rewards/achievements"]').first()).toBeVisible();
await expect(page.locator('a[href="/rewards/loot-boxes"]').first()).toBeVisible();
await expect(page.locator('a[href="/rewards/activity"]').first()).toBeVisible();
});
test('rewards overview should have "How Tiers Work" section', async ({ page }) => {
await page.goto('/rewards');
await page.waitForTimeout(2000);
await expect(page.locator('text=How Tiers Work')).toBeVisible({ timeout: 5000 });
});
test('activity page should render ActivityFeed component', async ({ page }) => {
await page.goto('/rewards/activity');
await page.waitForTimeout(2000);
// Activity page should show feed area (may be empty or loading)
// The page should render without errors
await expect(page.locator('h1:has-text("Activity")')).toBeVisible();
});
});
test.describe('Sub-Navigation on Rewards Pages', () => {
test('rewards pages should have sub-navigation tabs', async ({ page }) => {
await page.goto('/rewards');
await page.waitForTimeout(2000);
// Check for sub-nav with all links
const subNav = page.locator('nav a[href="/rewards"]');
await expect(subNav.first()).toBeVisible({ timeout: 5000 });
await expect(page.locator('nav a[href="/rewards/leaderboard"]').first()).toBeVisible();
await expect(page.locator('nav a[href="/rewards/achievements"]').first()).toBeVisible();
await expect(page.locator('nav a[href="/rewards/loot-boxes"]').first()).toBeVisible();
await expect(page.locator('nav a[href="/rewards/activity"]').first()).toBeVisible();
});
test('sub-navigation should highlight active page', async ({ page }) => {
await page.goto('/rewards/leaderboard');
await page.waitForTimeout(2000);
// The Leaderboard tab should have the active styling (border-primary)
const leaderboardTab = page.locator('nav a[href="/rewards/leaderboard"]').first();
await expect(leaderboardTab).toHaveClass(/border-primary|text-primary/);
});
test('clicking sub-nav should navigate between pages', async ({ page }) => {
await page.goto('/rewards');
await page.waitForTimeout(2000);
// Click Achievements in sub-nav
await page.locator('nav a[href="/rewards/achievements"]').first().click();
await expect(page).toHaveURL('/rewards/achievements');
await expect(page.locator('h1:has-text("Achievements")')).toBeVisible({ timeout: 5000 });
// Click Activity
await page.locator('nav a[href="/rewards/activity"]').first().click();
await expect(page).toHaveURL('/rewards/activity');
await expect(page.locator('h1:has-text("Activity")')).toBeVisible({ timeout: 5000 });
});
});
test.describe('Error Handling', () => {
test('rewards page should handle API errors gracefully', async ({ page }) => {
const errors = setupErrorCapture(page);
await page.goto('/rewards');
await page.waitForTimeout(3000);
// Page should still render even if API fails
await expect(page.locator('h1:has-text("Rewards")')).toBeVisible({ timeout: 5000 });
// Should not have JS errors (API errors are expected)
const criticalErrors = filterCriticalErrors(errors);
expect(criticalErrors).toHaveLength(0);
});
test('leaderboard should show empty state or loading when API unavailable', async ({ page }) => {
await page.goto('/rewards/leaderboard');
await page.waitForTimeout(3000);
// Should either show loading skeleton, empty state, or actual data
const hasContent =
await page.locator('.animate-pulse').count() > 0 ||
await page.locator('text=No entries').isVisible() ||
await page.locator('text=/\\#[0-9]+|🥇|🥈|🥉/').count() > 0;
expect(hasContent).toBe(true);
});
});

View File

@ -0,0 +1,121 @@
import { test, expect } from '@playwright/test'
test.describe('Rewards Pages Verification', () => {
test('Home page loads without getting stuck', async ({ page }) => {
await page.goto('http://localhost:5173/')
// Wait for page to load - should not be stuck on spinner
await expect(page.locator('h1:has-text("H2H")')).toBeVisible({ timeout: 10000 })
// The page should show content, not just a spinner
// Look for navigation links which indicate the page loaded
await expect(page.locator('text=Sports').first()).toBeVisible()
await expect(page.locator('text=Live').first()).toBeVisible()
})
test('Header Rewards dropdown works', async ({ page }) => {
await page.goto('http://localhost:5173/')
// Find and click the Rewards dropdown
const rewardsButton = page.locator('button:has-text("Rewards")')
await expect(rewardsButton).toBeVisible()
await rewardsButton.click()
// Verify dropdown menu appears with all options
await expect(page.locator('a[href="/rewards"]:has-text("Overview")')).toBeVisible()
await expect(page.locator('a[href="/rewards/leaderboard"]')).toBeVisible()
await expect(page.locator('a[href="/rewards/achievements"]')).toBeVisible()
await expect(page.locator('a[href="/rewards/loot-boxes"]')).toBeVisible()
await expect(page.locator('a[href="/rewards/activity"]')).toBeVisible()
})
test('Rewards Overview page loads with Header', async ({ page }) => {
await page.goto('http://localhost:5173/rewards')
// Check Header is present
await expect(page.locator('header h1:has-text("H2H")')).toBeVisible()
// Check page title
await expect(page.locator('h1:has-text("Rewards")')).toBeVisible()
// Check page has light theme (white/light backgrounds)
const mainContent = page.locator('.bg-white').first()
await expect(mainContent).toBeVisible()
})
test('Leaderboard page loads', async ({ page }) => {
await page.goto('http://localhost:5173/rewards/leaderboard')
// Check Header is present
await expect(page.locator('header h1:has-text("H2H")')).toBeVisible()
// Check page content
await expect(page.locator('h1:has-text("Leaderboard")')).toBeVisible()
})
test('Achievements page shows login prompt when not authenticated', async ({ page }) => {
await page.goto('http://localhost:5173/rewards/achievements')
// Check Header is present
await expect(page.locator('header h1:has-text("H2H")')).toBeVisible()
// Should show sign in required message
await expect(page.locator('text=Sign In Required')).toBeVisible()
// Use more specific selector for the sign in link in the content area
await expect(page.locator('.bg-white a[href="/login"]:has-text("Sign In")')).toBeVisible()
})
test('Loot Boxes page shows login prompt when not authenticated', async ({ page }) => {
await page.goto('http://localhost:5173/rewards/loot-boxes')
// Check Header is present
await expect(page.locator('header h1:has-text("H2H")')).toBeVisible()
// Should show sign in required message
await expect(page.locator('text=Sign In Required')).toBeVisible()
})
test('Activity page loads', async ({ page }) => {
await page.goto('http://localhost:5173/rewards/activity')
// Check Header is present
await expect(page.locator('header h1:has-text("H2H")')).toBeVisible()
// Check page title
await expect(page.locator('h1:has-text("Activity")')).toBeVisible()
})
test('Navigate between rewards pages via sub-nav', async ({ page }) => {
await page.goto('http://localhost:5173/rewards')
// Click on Leaderboard in sub-nav
await page.locator('nav a[href="/rewards/leaderboard"]').click()
await expect(page).toHaveURL(/\/rewards\/leaderboard/)
// Click on Activity in sub-nav
await page.locator('nav a[href="/rewards/activity"]').click()
await expect(page).toHaveURL(/\/rewards\/activity/)
})
test('All rewards pages have consistent light theme', async ({ page }) => {
const pages = [
'/rewards',
'/rewards/leaderboard',
'/rewards/achievements',
'/rewards/loot-boxes',
'/rewards/activity'
]
for (const pagePath of pages) {
await page.goto(`http://localhost:5173${pagePath}`)
// Verify light gray background (bg-gray-50)
const body = page.locator('.min-h-screen.bg-gray-50')
await expect(body).toBeVisible()
// Verify white content boxes exist
const whiteBox = page.locator('.bg-white').first()
await expect(whiteBox).toBeVisible()
}
})
})

View File

@ -0,0 +1,72 @@
import { test, expect } from '@playwright/test';
test('Rewards page loads without errors', async ({ page }) => {
const errors: string[] = [];
// Capture console errors
page.on('console', msg => {
if (msg.type() === 'error') {
errors.push(msg.text());
}
});
// Capture page errors
page.on('pageerror', err => {
errors.push(err.message);
});
// Navigate to rewards page
await page.goto('/rewards', { waitUntil: 'domcontentloaded' });
// Wait for page content
await page.waitForTimeout(2000);
// Check for the Rewards heading
const heading = page.locator('h1:has-text("Rewards")');
await expect(heading).toBeVisible({ timeout: 5000 });
console.log('✓ Rewards page heading found');
// Check for key sections - use first() since there are multiple matches
const leaderboardSection = page.locator('h3:has-text("Leaderboard")').first();
const activitySection = page.locator('h3:has-text("Activity")').first();
// At least one of these should be visible
const hasContent = await leaderboardSection.isVisible() || await activitySection.isVisible();
expect(hasContent).toBe(true);
console.log('✓ Gamification content sections found');
// Report any errors
if (errors.length > 0) {
console.log('Console errors found:', errors);
}
// Filter out API errors (expected when backend isn't running)
const criticalErrors = errors.filter(e =>
!e.includes('Failed to load resource') &&
!e.includes('ERR_CONNECTION_REFUSED')
);
expect(criticalErrors.length).toBe(0);
console.log('✓ No critical errors on Rewards page');
});
test('Home page loads (may show loading if backend unavailable)', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
// Wait for content
await page.waitForTimeout(2000);
// Check for H2H branding in header
await expect(page.locator('header h1:has-text("H2H")')).toBeVisible({ timeout: 5000 });
console.log('✓ H2H header found');
// Check navigation includes Rewards link (use first() since there may be multiple)
await expect(page.locator('nav a[href="/rewards"]').first()).toBeVisible({ timeout: 5000 });
console.log('✓ Rewards nav link found');
// Page either shows loading state or full content - both are acceptable
// when backend is not running
const hasSpinner = await page.locator('.animate-spin').first().isVisible();
const hasContent = await page.locator('text=Upcoming Events').isVisible();
console.log(`✓ Home page rendered (loading: ${hasSpinner}, content: ${hasContent})`);
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 601 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 601 KiB

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 601 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 601 KiB

After

Width:  |  Height:  |  Size: 131 KiB