Added new systems.

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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