Added new systems.
101
frontend/.claude/agents/pmp-agent.md
Normal 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.
|
||||
50
frontend/.claude/agents/qa-agent.md
Normal 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
|
||||
@ -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"
|
||||
|
||||
83
frontend/src/api/gamification.ts
Normal 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
|
||||
}
|
||||
122
frontend/src/components/gamification/Achievements.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
frontend/src/components/gamification/ActivityFeed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
frontend/src/components/gamification/Leaderboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
174
frontend/src/components/gamification/LootBoxes.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
199
frontend/src/components/gamification/TierProgress.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
89
frontend/src/components/gamification/WhaleTracker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
frontend/src/components/gamification/index.ts
Normal 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'
|
||||
@ -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>
|
||||
|
||||
73
frontend/src/components/layout/RewardsLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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">
|
||||
|
||||
40
frontend/src/pages/rewards/AchievementsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
frontend/src/pages/rewards/ActivityPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
frontend/src/pages/rewards/LeaderboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
frontend/src/pages/rewards/LootBoxesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
107
frontend/src/pages/rewards/RewardsIndex.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
frontend/src/pages/rewards/index.ts
Normal 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'
|
||||
135
frontend/src/styles/rewards-dark-theme.css
Normal 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;
|
||||
}
|
||||
151
frontend/src/types/gamification.ts
Normal 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
|
||||
}
|
||||
BIN
frontend/test-results/home-with-events.png
Normal file
|
After Width: | Height: | Size: 675 KiB |
BIN
frontend/test-results/rewards-dropdown.png
Normal file
|
After Width: | Height: | Size: 225 KiB |
440
frontend/tests/rewards-comprehensive.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
121
frontend/tests/rewards-verification.spec.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
72
frontend/tests/rewards.spec.ts
Normal 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})`);
|
||||
});
|
||||
|
Before Width: | Height: | Size: 601 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 601 KiB After Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 601 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 601 KiB After Width: | Height: | Size: 131 KiB |