Updated order book. Looking better.

This commit is contained in:
2026-01-09 20:40:33 -06:00
parent 0dd77eee90
commit b3c235a860
5 changed files with 382 additions and 416 deletions

View File

@ -6,17 +6,13 @@ import { spreadBetsApi } from '@/api/spread-bets'
import type { SportEventWithBets, SpreadGridBet } from '@/types/sport-event'
import { TeamSide } from '@/types/spread-bet'
import {
TrendingUp,
TrendingDown,
Clock,
Activity,
DollarSign,
Users,
Zap,
ChevronUp,
ChevronDown,
Target,
BarChart3,
TrendingUp,
TrendingDown,
} from 'lucide-react'
import toast from 'react-hot-toast'
@ -45,107 +41,6 @@ function formatTimeUntil(gameTime: string): { text: string; urgent: boolean } {
return { text: `${seconds}s`, urgent: true }
}
// Order book row component
interface OrderRowProps {
spread: number
bets: SpreadGridBet[]
side: 'home' | 'away'
officialSpread: number
maxVolume: number
onClick: () => void
}
const OrderRow = ({ spread, bets, side, officialSpread, maxVolume, onClick }: OrderRowProps) => {
const totalVolume = bets.reduce((sum, b) => sum + b.stake, 0)
const betCount = bets.length
const openBets = bets.filter(b => b.status === 'open')
const takeableBets = openBets.filter(b => b.can_take)
const volumePercent = maxVolume > 0 ? (totalVolume / maxVolume) * 100 : 0
const isOfficial = spread === officialSpread
return (
<button
onClick={onClick}
className={`
w-full grid grid-cols-4 gap-2 py-1.5 px-2 text-sm transition-colors relative overflow-hidden
${side === 'home'
? 'hover:bg-green-900/30 text-green-400'
: 'hover:bg-red-900/30 text-red-400'
}
${isOfficial ? 'bg-yellow-900/20' : ''}
`}
>
{/* Volume bar background */}
<div
className={`absolute inset-y-0 ${side === 'home' ? 'right-0' : 'left-0'} opacity-20 ${
side === 'home' ? 'bg-green-500' : 'bg-red-500'
}`}
style={{ width: `${volumePercent}%` }}
/>
{side === 'home' ? (
<>
<span className="text-left relative z-10 text-gray-400">{betCount || '-'}</span>
<span className="text-right relative z-10 font-mono">
{totalVolume > 0 ? `$${totalVolume.toLocaleString()}` : '-'}
</span>
<span className={`text-right relative z-10 font-bold ${isOfficial ? 'text-yellow-400' : ''}`}>
{spread > 0 ? '+' : ''}{spread}
{isOfficial && <span className="ml-1 text-yellow-500"></span>}
</span>
<span className="text-right relative z-10">
{takeableBets.length > 0 && (
<span className="px-1.5 py-0.5 bg-green-600 text-white text-xs rounded">
{takeableBets.length}
</span>
)}
</span>
</>
) : (
<>
<span className="text-left relative z-10">
{takeableBets.length > 0 && (
<span className="px-1.5 py-0.5 bg-red-600 text-white text-xs rounded">
{takeableBets.length}
</span>
)}
</span>
<span className={`text-left relative z-10 font-bold ${isOfficial ? 'text-yellow-400' : ''}`}>
{spread > 0 ? '+' : ''}{spread}
{isOfficial && <span className="ml-1 text-yellow-500"></span>}
</span>
<span className="text-left relative z-10 font-mono">
{totalVolume > 0 ? `$${totalVolume.toLocaleString()}` : '-'}
</span>
<span className="text-right relative z-10 text-gray-400">{betCount || '-'}</span>
</>
)}
</button>
)
}
// Recent trade item
interface RecentTradeProps {
bet: SpreadGridBet & { spread: number }
homeTeam: string
awayTeam: string
}
const RecentTrade = ({ bet, homeTeam, awayTeam }: RecentTradeProps) => {
const isHome = bet.team === 'home'
return (
<div className="flex items-center justify-between py-1 text-xs">
<span className={isHome ? 'text-green-400' : 'text-red-400'}>
{isHome ? homeTeam : awayTeam} {bet.spread > 0 ? '+' : ''}{bet.spread}
</span>
<span className="text-gray-400 font-mono">${bet.stake.toFixed(0)}</span>
<span className={`${bet.status === 'open' ? 'text-blue-400' : 'text-yellow-400'}`}>
{bet.status}
</span>
</div>
)
}
export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelProps) => {
const { isAuthenticated } = useAuthStore()
const queryClient = useQueryClient()
@ -153,7 +48,6 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
const [selectedSide, setSelectedSide] = useState<'home' | 'away'>('home')
const [selectedSpread, setSelectedSpread] = useState(event.official_spread)
const [stakeAmount, setStakeAmount] = useState('')
const [orderType, setOrderType] = useState<'create' | 'take'>('create')
// Update countdown every second
useEffect(() => {
@ -193,32 +87,69 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
.sort((a, b) => b - a) // High to low
}, [event.spread_grid])
// Get max volume for visualization
const maxVolume = useMemo(() => {
// Split spreads at official line for order book
const { aboveLine, belowLine, maxVolume } = useMemo(() => {
const above: number[] = []
const below: number[] = []
let max = 0
Object.values(event.spread_grid).forEach(bets => {
sortedSpreads.forEach(spread => {
const bets = event.spread_grid[spread.toString()] || []
const vol = bets.reduce((sum, b) => sum + b.stake, 0)
if (vol > max) max = vol
})
return max
}, [event.spread_grid])
// Get recent bets
const recentBets = useMemo(() => {
const allBets: (SpreadGridBet & { spread: number })[] = []
Object.entries(event.spread_grid).forEach(([spread, bets]) => {
bets.forEach(bet => {
allBets.push({ ...bet, spread: Number(spread) })
if (spread > event.official_spread) above.push(spread)
else if (spread < event.official_spread) below.push(spread)
})
// Add official line to both for display
return {
aboveLine: above.sort((a, b) => a - b), // Low to high (closest to line at bottom)
belowLine: below.sort((a, b) => b - a), // High to low (closest to line at top)
maxVolume: max
}
}, [sortedSpreads, event.spread_grid, event.official_spread])
// Get all bets for chart and recent activity (including matched)
const allBetsWithSpread = useMemo(() => {
const bets: (SpreadGridBet & { spread: number })[] = []
Object.entries(event.spread_grid).forEach(([spread, spreadBets]) => {
spreadBets.forEach(bet => {
bets.push({ ...bet, spread: Number(spread) })
})
})
return allBets.slice(0, 10)
return bets
}, [event.spread_grid])
// Get available bets to take at selected spread
// Recent activity - all bets sorted by recency (we don't have timestamps, so just show all)
const recentActivity = useMemo(() => {
return allBetsWithSpread.slice(0, 15) // Show last 15
}, [allBetsWithSpread])
// Chart data - volume per spread
const chartData = useMemo(() => {
const data: { spread: number; homeVolume: number; awayVolume: number; total: number }[] = []
// Get all possible spreads in range
for (let s = event.min_spread; s <= event.max_spread; s += 0.5) {
const bets = event.spread_grid[s.toString()] || []
const homeVol = bets.filter(b => b.team === 'home').reduce((sum, b) => sum + b.stake, 0)
const awayVol = bets.filter(b => b.team === 'away').reduce((sum, b) => sum + b.stake, 0)
data.push({ spread: s, homeVolume: homeVol, awayVolume: awayVol, total: homeVol + awayVol })
}
return data
}, [event.spread_grid, event.min_spread, event.max_spread])
const chartMaxVolume = useMemo(() => {
return Math.max(...chartData.map(d => d.total), 1)
}, [chartData])
// Available bets to take
const availableBets = useMemo(() => {
const bets = event.spread_grid[selectedSpread.toString()] || []
return bets.filter(b => b.status === 'open' && b.can_take && b.team !== selectedSide)
}, [event.spread_grid, selectedSpread, selectedSide])
return bets.filter(b => b.status === 'open' && b.can_take)
}, [event.spread_grid, selectedSpread])
// Create bet mutation
const createBetMutation = useMutation({
@ -240,7 +171,6 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
mutationFn: (betId: number) => spreadBetsApi.takeBet(betId),
onSuccess: () => {
toast.success('Bet taken successfully!')
setStakeAmount('')
queryClient.invalidateQueries({ queryKey: ['sport-event'] })
onBetTaken?.()
},
@ -249,7 +179,7 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
},
})
const handleSubmit = () => {
const handleCreateBet = () => {
if (!isAuthenticated) {
toast.error('Please log in to place a bet')
return
@ -261,78 +191,124 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
return
}
if (orderType === 'take' && availableBets.length > 0) {
// Take the first available bet that matches the stake
const matchingBet = availableBets.find(b => b.stake === amount) || availableBets[0]
takeBetMutation.mutate(matchingBet.bet_id)
} else {
createBetMutation.mutate({
event_id: event.id,
spread: selectedSpread,
team: selectedSide === 'home' ? TeamSide.HOME : TeamSide.AWAY,
stake_amount: amount,
})
}
createBetMutation.mutate({
event_id: event.id,
spread: selectedSpread,
team: selectedSide === 'home' ? TeamSide.HOME : TeamSide.AWAY,
stake_amount: amount,
})
}
const handleSpreadSelect = (spread: number) => {
setSelectedSpread(spread)
const handleTakeBet = (betId: number) => {
if (!isAuthenticated) {
toast.error('Please log in to take a bet')
return
}
takeBetMutation.mutate(betId)
}
// Quick stake buttons
const quickStakes = [25, 50, 100, 250, 500, 1000]
const quickStakes = [25, 50, 100, 250, 500]
// Order book row renderer
const renderOrderRow = (spread: number) => {
const bets = event.spread_grid[spread.toString()] || []
const homeBets = bets.filter(b => b.team === 'home')
const awayBets = bets.filter(b => b.team === 'away')
const homeVolume = homeBets.reduce((sum, b) => sum + b.stake, 0)
const awayVolume = awayBets.reduce((sum, b) => sum + b.stake, 0)
const homeOpen = homeBets.filter(b => b.status === 'open' && b.can_take).length
const awayOpen = awayBets.filter(b => b.status === 'open' && b.can_take).length
return (
<button
key={spread}
onClick={() => setSelectedSpread(spread)}
className={`
grid grid-cols-7 gap-1 py-1.5 px-2 text-xs hover:bg-gray-100 transition-colors w-full
${selectedSpread === spread ? 'bg-blue-50 border-l-2 border-blue-500' : ''}
`}
>
{/* Away side */}
<span className={`text-right ${awayOpen > 0 ? 'text-red-600 font-semibold' : 'text-gray-400'}`}>
{awayOpen > 0 ? awayOpen : '-'}
</span>
<span className="text-right text-red-600 font-mono">
{awayVolume > 0 ? `$${awayVolume.toLocaleString()}` : '-'}
</span>
<div className="col-span-1 flex justify-end">
<div
className="h-4 bg-red-200 rounded-l"
style={{ width: `${maxVolume > 0 ? (awayVolume / maxVolume) * 100 : 0}%`, minWidth: awayVolume > 0 ? '4px' : '0' }}
/>
</div>
{/* Spread (center) */}
<span className={`text-center font-bold ${spread === event.official_spread ? 'text-yellow-600 bg-yellow-100 rounded' : 'text-gray-900'}`}>
{spread > 0 ? '+' : ''}{spread}
</span>
{/* Home side */}
<div className="col-span-1 flex justify-start">
<div
className="h-4 bg-green-200 rounded-r"
style={{ width: `${maxVolume > 0 ? (homeVolume / maxVolume) * 100 : 0}%`, minWidth: homeVolume > 0 ? '4px' : '0' }}
/>
</div>
<span className="text-left text-green-600 font-mono">
{homeVolume > 0 ? `$${homeVolume.toLocaleString()}` : '-'}
</span>
<span className={`text-left ${homeOpen > 0 ? 'text-green-600 font-semibold' : 'text-gray-400'}`}>
{homeOpen > 0 ? homeOpen : '-'}
</span>
</button>
)
}
return (
<div className="bg-gray-900 rounded-xl overflow-hidden shadow-2xl">
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
{/* Header - Event Info */}
<div className="bg-gradient-to-r from-gray-800 to-gray-900 p-4 border-b border-gray-700">
<div className="bg-gradient-to-r from-blue-600 to-blue-700 p-4 text-white">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-6">
{/* Home Team */}
<div className="text-center">
<div className="w-12 h-12 bg-green-900/50 rounded-full flex items-center justify-center mb-1">
<span className="text-2xl font-bold text-green-400">
{event.home_team.charAt(0)}
</span>
<div className="w-14 h-14 bg-white/20 rounded-full flex items-center justify-center mb-1">
<span className="text-2xl font-bold">{event.home_team.charAt(0)}</span>
</div>
<p className="text-white font-semibold text-sm">{event.home_team}</p>
<p className="text-green-400 text-xs">HOME</p>
<p className="font-semibold">{event.home_team}</p>
<p className="text-blue-200 text-xs">HOME</p>
</div>
{/* VS / Spread */}
<div className="text-center px-4">
<div className="text-gray-400 text-xs mb-1">SPREAD</div>
<div className="text-2xl font-bold text-yellow-400">
<div className="text-center px-6">
<div className="text-blue-200 text-xs mb-1">OFFICIAL SPREAD</div>
<div className="text-3xl font-bold">
{event.official_spread > 0 ? '+' : ''}{event.official_spread}
</div>
<div className="text-gray-500 text-xs">Official Line</div>
<div className="text-blue-200 text-xs mt-1">{event.league}</div>
</div>
{/* Away Team */}
<div className="text-center">
<div className="w-12 h-12 bg-red-900/50 rounded-full flex items-center justify-center mb-1">
<span className="text-2xl font-bold text-red-400">
{event.away_team.charAt(0)}
</span>
<div className="w-14 h-14 bg-white/20 rounded-full flex items-center justify-center mb-1">
<span className="text-2xl font-bold">{event.away_team.charAt(0)}</span>
</div>
<p className="text-white font-semibold text-sm">{event.away_team}</p>
<p className="text-red-400 text-xs">AWAY</p>
<p className="font-semibold">{event.away_team}</p>
<p className="text-blue-200 text-xs">AWAY</p>
</div>
</div>
{/* Game Time & Status */}
{/* Game Time */}
<div className="text-right">
<div className={`
inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-semibold
${timeUntil.urgent
? 'bg-red-900/50 text-red-400 animate-pulse'
: 'bg-gray-800 text-gray-300'
}
inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold
${timeUntil.urgent ? 'bg-red-500 animate-pulse' : 'bg-white/20'}
`}>
<Clock size={14} />
<Clock size={16} />
{timeUntil.text}
</div>
<p className="text-gray-500 text-xs mt-1">
<p className="text-blue-200 text-xs mt-2">
{new Date(event.game_time).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
@ -341,269 +317,250 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
minute: '2-digit',
})}
</p>
{event.league && (
<p className="text-gray-600 text-xs mt-0.5">{event.league}</p>
)}
</div>
</div>
{/* Stats Bar */}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-white/20 text-sm">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<DollarSign size={14} className="text-blue-200" />
<span className="text-blue-200">Volume:</span>
<span className="font-semibold">${marketStats.totalVolume.toLocaleString()}</span>
</div>
<div className="flex items-center gap-2">
<Activity size={14} className="text-blue-200" />
<span className="text-blue-200">Bets:</span>
<span className="font-semibold">{marketStats.totalBets}</span>
</div>
<div className="flex items-center gap-2">
<Users size={14} className="text-blue-200" />
<span className="text-blue-200">Open:</span>
<span className="font-semibold text-green-300">{marketStats.openBets}</span>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<TrendingUp size={14} className="text-green-300" />
<span className="text-green-300">${marketStats.homeVolume.toLocaleString()}</span>
</div>
<div className="flex items-center gap-2">
<TrendingDown size={14} className="text-red-300" />
<span className="text-red-300">${marketStats.awayVolume.toLocaleString()}</span>
</div>
</div>
</div>
</div>
{/* Market Stats Ticker */}
<div className="bg-gray-800/50 px-4 py-2 flex items-center justify-between text-xs border-b border-gray-700 overflow-x-auto">
<div className="flex items-center gap-6">
<div className="flex items-center gap-1.5">
<DollarSign size={12} className="text-gray-500" />
<span className="text-gray-400">Volume</span>
<span className="text-white font-semibold">${marketStats.totalVolume.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5">
<Activity size={12} className="text-gray-500" />
<span className="text-gray-400">Bets</span>
<span className="text-white font-semibold">{marketStats.totalBets}</span>
</div>
<div className="flex items-center gap-1.5">
<Zap size={12} className="text-green-500" />
<span className="text-gray-400">Open</span>
<span className="text-green-400 font-semibold">{marketStats.openBets}</span>
</div>
<div className="flex items-center gap-1.5">
<Users size={12} className="text-yellow-500" />
<span className="text-gray-400">Matched</span>
<span className="text-yellow-400 font-semibold">{marketStats.matchedBets}</span>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<TrendingUp size={12} className="text-green-500" />
<span className="text-green-400">${marketStats.homeVolume.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5">
<TrendingDown size={12} className="text-red-500" />
<span className="text-red-400">${marketStats.awayVolume.toLocaleString()}</span>
</div>
</div>
</div>
{/* Main Content */}
<div className="grid grid-cols-12 divide-x divide-gray-200">
{/* Left - Order Book */}
<div className="col-span-3 p-4">
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2">
<Activity size={16} className="text-gray-400" />
Order Book
</h3>
{/* Main Trading Area */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-0 lg:divide-x lg:divide-gray-700">
{/* Order Book */}
<div className="lg:col-span-2 p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-white font-semibold flex items-center gap-2">
<BarChart3 size={16} className="text-gray-400" />
Order Book
</h3>
<div className="flex items-center gap-2 text-xs">
<span className="text-green-400">{event.home_team}</span>
<span className="text-gray-500">/</span>
<span className="text-red-400">{event.away_team}</span>
</div>
{/* Header */}
<div className="grid grid-cols-7 gap-1 py-2 px-2 text-xs text-gray-500 border-b mb-1">
<span className="text-right">{event.away_team.slice(0, 4)}</span>
<span className="text-right">Vol</span>
<span></span>
<span className="text-center">Spread</span>
<span></span>
<span className="text-left">Vol</span>
<span className="text-left">{event.home_team.slice(0, 4)}</span>
</div>
<div className="grid grid-cols-2 gap-2">
{/* Home Side (Buy/Green) */}
<div className="bg-gray-800/30 rounded-lg overflow-hidden">
<div className="grid grid-cols-4 gap-2 py-2 px-2 text-xs text-gray-500 border-b border-gray-700">
<span>Count</span>
<span className="text-right">Volume</span>
<span className="text-right">Spread</span>
<span className="text-right">Open</span>
</div>
<div className="max-h-64 overflow-y-auto">
{sortedSpreads.map(spread => {
const bets = event.spread_grid[spread.toString()] || []
const homeBets = bets.filter(b => b.team === 'home')
if (homeBets.length === 0 && spread !== event.official_spread) return null
return (
<OrderRow
key={`home-${spread}`}
spread={spread}
bets={homeBets}
side="home"
officialSpread={event.official_spread}
maxVolume={maxVolume}
onClick={() => {
setSelectedSide('home')
handleSpreadSelect(spread)
}}
/>
)
})}
{/* Above official line (away favored) */}
<div className="max-h-32 overflow-y-auto border-b border-gray-100">
{aboveLine.map(spread => renderOrderRow(spread))}
</div>
{/* Official Line */}
<div className="bg-yellow-50 border-y-2 border-yellow-400">
{renderOrderRow(event.official_spread)}
</div>
{/* Below official line (home favored) */}
<div className="max-h-32 overflow-y-auto">
{belowLine.map(spread => renderOrderRow(spread))}
</div>
</div>
{/* Center - Volume Chart */}
<div className="col-span-6 p-4">
<h3 className="font-semibold text-gray-900 mb-3">Market Depth</h3>
{/* Chart */}
<div className="h-48 flex items-end gap-px relative bg-gray-50 rounded-lg p-2">
{/* Official line indicator */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-yellow-500 z-10"
style={{
left: `${((event.official_spread - event.min_spread) / (event.max_spread - event.min_spread)) * 100}%`
}}
>
<div className="absolute -top-1 left-1/2 -translate-x-1/2 bg-yellow-500 text-white text-xs px-1 rounded">
{event.official_spread > 0 ? '+' : ''}{event.official_spread}
</div>
</div>
{/* Away Side (Sell/Red) */}
<div className="bg-gray-800/30 rounded-lg overflow-hidden">
<div className="grid grid-cols-4 gap-2 py-2 px-2 text-xs text-gray-500 border-b border-gray-700">
<span>Open</span>
<span>Spread</span>
<span>Volume</span>
<span className="text-right">Count</span>
</div>
<div className="max-h-64 overflow-y-auto">
{sortedSpreads.map(spread => {
const bets = event.spread_grid[spread.toString()] || []
const awayBets = bets.filter(b => b.team === 'away')
if (awayBets.length === 0 && spread !== event.official_spread) return null
return (
<OrderRow
key={`away-${spread}`}
spread={-spread} // Show opposite spread for away
bets={awayBets}
side="away"
officialSpread={-event.official_spread}
maxVolume={maxVolume}
onClick={() => {
setSelectedSide('away')
handleSpreadSelect(spread)
}}
/>
)
})}
{chartData.map((d) => (
<div
key={d.spread}
className="flex-1 flex flex-col justify-end cursor-pointer group"
onClick={() => setSelectedSpread(d.spread)}
>
{/* Away volume (red, on top) */}
<div
className={`w-full bg-red-400 hover:bg-red-500 transition-colors ${
d.spread === selectedSpread ? 'ring-2 ring-blue-500' : ''
}`}
style={{ height: `${(d.awayVolume / chartMaxVolume) * 100}%`, minHeight: d.awayVolume > 0 ? '2px' : '0' }}
/>
{/* Home volume (green, on bottom) */}
<div
className={`w-full bg-green-400 hover:bg-green-500 transition-colors ${
d.spread === selectedSpread ? 'ring-2 ring-blue-500' : ''
}`}
style={{ height: `${(d.homeVolume / chartMaxVolume) * 100}%`, minHeight: d.homeVolume > 0 ? '2px' : '0' }}
/>
{/* Tooltip on hover */}
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 bg-gray-900 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-20">
{d.spread > 0 ? '+' : ''}{d.spread}: ${d.total.toLocaleString()}
</div>
</div>
))}
</div>
{/* X-axis labels */}
<div className="flex justify-between mt-1 text-xs text-gray-500 px-2">
<span>{event.min_spread}</span>
<span>Spread</span>
<span>+{event.max_spread}</span>
</div>
{/* Legend */}
<div className="flex items-center justify-center gap-6 mt-3 text-xs">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-green-400 rounded" />
<span className="text-gray-600">{event.home_team} Volume</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-red-400 rounded" />
<span className="text-gray-600">{event.away_team} Volume</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-0.5 bg-yellow-500" />
<span className="text-gray-600">Official Line</span>
</div>
</div>
{/* Recent Activity */}
<div className="mt-4">
<h4 className="text-gray-400 text-xs font-semibold mb-2 flex items-center gap-1">
<Activity size={12} />
Recent Activity
</h4>
<div className="bg-gray-800/30 rounded-lg p-2 max-h-32 overflow-y-auto">
{recentBets.length > 0 ? (
recentBets.map((bet, i) => (
<RecentTrade
key={`${bet.bet_id}-${i}`}
bet={bet}
homeTeam={event.home_team}
awayTeam={event.away_team}
/>
<div className="mt-4 pt-4 border-t">
<h4 className="font-semibold text-gray-900 mb-2 text-sm">Recent Activity</h4>
<div className="max-h-32 overflow-y-auto space-y-1">
{recentActivity.length > 0 ? (
recentActivity.map((bet, i) => (
<div key={`${bet.bet_id}-${i}`} className="flex items-center justify-between text-xs py-1 px-2 bg-gray-50 rounded">
<span className={bet.team === 'home' ? 'text-green-600' : 'text-red-600'}>
{bet.team === 'home' ? event.home_team : event.away_team} {bet.spread > 0 ? '+' : ''}{bet.spread}
</span>
<span className="text-gray-600 font-mono">${bet.stake.toFixed(0)}</span>
<span className={`px-1.5 py-0.5 rounded text-xs ${
bet.status === 'open' ? 'bg-green-100 text-green-700' :
bet.status === 'matched' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-700'
}`}>
{bet.status}
</span>
<span className="text-gray-400">{bet.creator_username}</span>
</div>
))
) : (
<p className="text-gray-500 text-xs text-center py-2">No bets yet</p>
<p className="text-gray-400 text-center py-4">No bets yet</p>
)}
</div>
</div>
</div>
{/* Quick Trade Panel */}
<div className="p-4 bg-gray-800/30">
<h3 className="text-white font-semibold mb-4 flex items-center gap-2">
{/* Right - Quick Trade Panel */}
<div className="col-span-3 p-4 bg-gray-50">
<h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Target size={16} className="text-gray-400" />
Place Bet
</h3>
{!isAuthenticated ? (
<div className="text-center py-8">
<div className="text-gray-400 mb-4">Log in to place bets</div>
<div className="text-center py-6">
<p className="text-gray-500 mb-4">Log in to place bets</p>
<div className="flex gap-2 justify-center">
<Link
to="/login"
className="px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors text-sm"
>
<Link to="/login" className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 text-sm">
Log In
</Link>
<Link
to="/register"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors text-sm"
>
<Link to="/register" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">
Sign Up
</Link>
</div>
</div>
) : (
<>
{/* Side Selection */}
{/* Team Selection */}
<div className="grid grid-cols-2 gap-2 mb-4">
<button
onClick={() => setSelectedSide('home')}
className={`
py-3 rounded-lg font-semibold transition-all
${selectedSide === 'home'
className={`py-3 rounded-lg font-semibold transition-all text-sm ${
selectedSide === 'home'
? 'bg-green-600 text-white'
: 'bg-gray-700 text-gray-400 hover:bg-gray-600'
}
`}
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
<ChevronUp size={16} className="inline mr-1" />
{event.home_team}
</button>
<button
onClick={() => setSelectedSide('away')}
className={`
py-3 rounded-lg font-semibold transition-all
${selectedSide === 'away'
className={`py-3 rounded-lg font-semibold transition-all text-sm ${
selectedSide === 'away'
? 'bg-red-600 text-white'
: 'bg-gray-700 text-gray-400 hover:bg-gray-600'
}
`}
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
<ChevronDown size={16} className="inline mr-1" />
{event.away_team}
</button>
</div>
{/* Spread Selection */}
<div className="mb-4">
<label className="text-gray-400 text-xs block mb-1">Spread</label>
<label className="text-gray-600 text-xs block mb-1">Spread</label>
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedSpread(s => Math.max(event.min_spread, s - 0.5))}
className="px-3 py-2 bg-gray-700 text-white rounded hover:bg-gray-600"
className="px-4 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 font-bold"
>
-
</button>
<div className="flex-1 text-center">
<span className="text-2xl font-bold text-white">
<div className="flex-1 text-center py-2 bg-white border border-gray-300 rounded-lg">
<span className="text-xl font-bold text-gray-900">
{selectedSpread > 0 ? '+' : ''}{selectedSpread}
</span>
{selectedSpread === event.official_spread && (
<span className="ml-2 text-yellow-400 text-xs">Official</span>
<span className="ml-2 text-yellow-600 text-xs"> Official</span>
)}
</div>
<button
onClick={() => setSelectedSpread(s => Math.min(event.max_spread, s + 0.5))}
className="px-3 py-2 bg-gray-700 text-white rounded hover:bg-gray-600"
className="px-4 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 font-bold"
>
+
</button>
</div>
</div>
{/* Order Type */}
<div className="grid grid-cols-2 gap-2 mb-4">
<button
onClick={() => setOrderType('create')}
className={`
py-2 text-sm rounded-lg font-medium transition-all
${orderType === 'create'
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-400 hover:bg-gray-600'
}
`}
>
Create Bet
</button>
<button
onClick={() => setOrderType('take')}
disabled={availableBets.length === 0}
className={`
py-2 text-sm rounded-lg font-medium transition-all
${orderType === 'take'
? 'bg-purple-600 text-white'
: 'bg-gray-700 text-gray-400 hover:bg-gray-600'
}
${availableBets.length === 0 ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
Take Bet {availableBets.length > 0 && `(${availableBets.length})`}
</button>
</div>
{/* Stake Amount */}
<div className="mb-4">
<label className="text-gray-400 text-xs block mb-1">Stake Amount</label>
<label className="text-gray-600 text-xs block mb-1">Stake Amount</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
@ -611,72 +568,81 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
value={stakeAmount}
onChange={(e) => setStakeAmount(e.target.value)}
placeholder={`${event.min_bet_amount} - ${event.max_bet_amount}`}
className="w-full bg-gray-700 border border-gray-600 rounded-lg py-3 pl-8 pr-4 text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
className="w-full bg-white border border-gray-300 rounded-lg py-3 pl-8 pr-4 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Quick Stakes */}
<div className="grid grid-cols-3 gap-2 mb-4">
{quickStakes
.filter(s => s >= event.min_bet_amount && s <= event.max_bet_amount)
.map(stake => (
<button
key={stake}
onClick={() => setStakeAmount(stake.toString())}
className="py-2 text-xs bg-gray-700 text-gray-300 rounded hover:bg-gray-600 transition-colors"
>
${stake}
</button>
))}
<div className="grid grid-cols-5 gap-1 mb-4">
{quickStakes.filter(s => s >= event.min_bet_amount && s <= event.max_bet_amount).map(stake => (
<button
key={stake}
onClick={() => setStakeAmount(stake.toString())}
className="py-2 text-xs bg-white border border-gray-300 rounded hover:bg-gray-50"
>
${stake}
</button>
))}
</div>
{/* Submit Button */}
{/* Create Bet Button */}
<button
onClick={handleSubmit}
disabled={createBetMutation.isPending || takeBetMutation.isPending || !stakeAmount}
className={`
w-full py-4 rounded-lg font-bold text-white transition-all
${selectedSide === 'home'
? 'bg-green-600 hover:bg-green-500'
: 'bg-red-600 hover:bg-red-500'
}
${(createBetMutation.isPending || takeBetMutation.isPending || !stakeAmount)
? 'opacity-50 cursor-not-allowed'
: ''
}
`}
onClick={handleCreateBet}
disabled={createBetMutation.isPending || !stakeAmount}
className={`w-full py-3 rounded-lg font-bold text-white transition-all mb-3 ${
selectedSide === 'home' ? 'bg-green-600 hover:bg-green-700' : 'bg-red-600 hover:bg-red-700'
} ${(createBetMutation.isPending || !stakeAmount) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{createBetMutation.isPending || takeBetMutation.isPending
? 'Processing...'
: orderType === 'take'
? `Take ${selectedSide === 'home' ? event.away_team : event.home_team} Bet`
: `Bet on ${selectedSide === 'home' ? event.home_team : event.away_team}`
}
{createBetMutation.isPending ? 'Creating...' : `Create ${selectedSide === 'home' ? event.home_team : event.away_team} Bet`}
</button>
{/* Available Bets to Take */}
{availableBets.length > 0 && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold text-gray-700 mb-2">
Take Existing Bet ({availableBets.length} available)
</h4>
<div className="space-y-2 max-h-32 overflow-y-auto">
{availableBets.map(bet => (
<div key={bet.bet_id} className="flex items-center justify-between p-2 bg-white border rounded-lg">
<div>
<p className="font-semibold text-sm">${bet.stake.toFixed(0)}</p>
<p className="text-xs text-gray-500">by {bet.creator_username}</p>
</div>
<button
onClick={() => handleTakeBet(bet.bet_id)}
disabled={takeBetMutation.isPending}
className="px-3 py-1.5 bg-purple-600 text-white rounded text-sm hover:bg-purple-700 disabled:opacity-50"
>
Take
</button>
</div>
))}
</div>
</div>
)}
{/* Bet Summary */}
{stakeAmount && (
<div className="mt-4 p-3 bg-gray-700/50 rounded-lg text-xs">
<div className="flex justify-between text-gray-400 mb-1">
<div className="mt-4 p-3 bg-white border rounded-lg text-xs">
<div className="flex justify-between text-gray-600 mb-1">
<span>Your Position</span>
<span className={selectedSide === 'home' ? 'text-green-400' : 'text-red-400'}>
<span className={selectedSide === 'home' ? 'text-green-600' : 'text-red-600'}>
{selectedSide === 'home' ? event.home_team : event.away_team} {selectedSpread > 0 ? '+' : ''}{selectedSide === 'home' ? selectedSpread : -selectedSpread}
</span>
</div>
<div className="flex justify-between text-gray-400 mb-1">
<div className="flex justify-between text-gray-600 mb-1">
<span>Stake</span>
<span className="text-white">${parseFloat(stakeAmount).toFixed(2)}</span>
<span className="text-gray-900 font-semibold">${parseFloat(stakeAmount).toFixed(2)}</span>
</div>
<div className="flex justify-between text-gray-400 mb-1">
<div className="flex justify-between text-gray-600 mb-1">
<span>Potential Win</span>
<span className="text-green-400">
${(parseFloat(stakeAmount) * 0.9 * 2).toFixed(2)}
</span>
<span className="text-green-600 font-semibold">${(parseFloat(stakeAmount) * 0.9 * 2).toFixed(2)}</span>
</div>
<div className="flex justify-between text-gray-400">
<div className="flex justify-between text-gray-600">
<span>House Fee (10%)</span>
<span className="text-yellow-400">${(parseFloat(stakeAmount) * 0.1).toFixed(2)}</span>
<span className="text-yellow-600">${(parseFloat(stakeAmount) * 0.1).toFixed(2)}</span>
</div>
</div>
)}

View File

@ -216,7 +216,7 @@ export const Header = () => {
return (
<header className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="px-4 sm:px-6 lg:px-8">
<div className="flex items-center h-16">
{/* Logo */}
<Link to="/" className="flex items-center">

View File

@ -9,7 +9,7 @@ export const Layout = ({ children }: LayoutProps) => {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<main className="px-4 sm:px-6 lg:px-8 py-6">
{children}
</main>
</div>

View File

@ -31,7 +31,7 @@ export function RewardsLayout({ children, title, subtitle }: RewardsLayoutProps)
<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">
<div className="px-4 sm:px-6 lg:px-8 py-6">
{/* Page Header */}
{title && (
<div className="mb-6">