Added new systems.
This commit is contained in:
@ -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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
|
||||
}
|
||||
Reference in New Issue
Block a user