@ -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 handleSubmi t = ( ) = > {
const handleCreateBe t = ( ) = > {
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 handleSpreadSelec t = ( sprea d : number ) = > {
setSelectedSpread ( sprea d)
const handleTakeBe t = ( betI d : number ) = > {
if ( ! isAuthenticate d) {
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/5 0 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/2 0 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-4 00 text-xs" > HOME < / p >
< p className = "font-semibold" > { event . home_team } < / p >
< p className = "text-blue-2 00 text-xs" > HOME < / p >
< / div >
{ /* VS / Spread */ }
< div className = "text-center px-4 " >
< div className = "text-gray-4 00 text-xs mb-1" > SPREAD < / div >
< div className = "text-2 xl font-bold text-yellow-400 " >
< div className = "text-center px-6 " >
< div className = "text-blue-2 00 text-xs mb-1" > OFFICIAL SPREAD < / div >
< div className = "text-3 xl font-bold" >
{ event . official_spread > 0 ? '+' : '' } { event . official_spread }
< / div >
< div className = "text-gray-5 00 text-xs" > Official Line < / div >
< div className = "text-blue-2 00 text-xs mt-1 " > { event . league } < / div >
< / div >
{ /* Away Team */ }
< div className = "text-center" >
< div className = "w-12 h-12 bg-red-900/5 0 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/2 0 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-4 00 text-xs" > AWAY < / p >
< p className = "font-semibold" > { event . away_team } < / p >
< p className = "text-blue-2 00 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-ful l 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-5 00 text-xs mt-1 " >
< p className = "text-blue-2 00 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 bor der -gray-7 00 overflow-x-auto " >
< div className = "flex items-center gap-6" >
< div className = "flex items-center gap-1.5 " >
< DollarSign size = { 12 } className = "text-gray-5 00" / >
< 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 divi de-gray-2 00" >
{ /* Left - Order Book */ }
< div className = "col-span-3 p-4 " >
< h3 className = "font-semibold text-gray-9 00 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-7 00" >
{ /* 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-5 00 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 } / >
R ecent Activity
< / h4 >
< div className = "bg-gray-800/30 rounded-lg p-2 max-h-32 overflow-y-auto ">
{ recentB ets . 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" >
{ r ecentActivity. 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 = { b et . 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-5 00 text-xs text-center py-2 " > No bets yet < / p >
< p className = "text-gray-4 00 text-center py-4 " > No bets yet < / p >
) }
< / div >
< / div >
< / div >
{ /* Quick Trade Panel */ }
< div className = "p-4 bg-gray-800/3 0" >
< 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-5 0" >
< 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-4 00 mb-4" > Log in to place bets < / div >
< div className = "text-center py-6 " >
< p className = "text-gray-5 00 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-7 00 text-gray-4 00 hover:bg-gray-60 0'
}
` }
: 'bg-white border border- gray-3 00 text-gray-7 00 hover:bg-gray-5 0'
} ` }
>
< 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-7 00 text-gray-4 00 hover:bg-gray-60 0'
}
` }
: 'bg-white border border- gray-3 00 text-gray-7 00 hover:bg-gray-5 0'
} ` }
>
< ChevronDown size = { 16 } className = "inline mr-1" / >
{ event . away_team }
< / button >
< / div >
{ /* Spread Selection */ }
< div className = "mb-4" >
< label className = "text-gray-4 00 text-xs block mb-1" > Spread < / label >
< label className = "text-gray-6 00 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-7 00 text-white rounded hover:bg-gray-600 "
className = "px-4 py-2 bg-white border border- gray-3 00 rounded-lg hover:bg-gray-50 font-bold "
>
-
−
< / button >
< div className = "flex-1 text-center" >
< span className = "text-2 xl 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-4 00 text-xs" > Official < / span >
< span className = "ml-2 text-yellow-6 00 text-xs" > ★ Official < / span >
) }
< / div >
< button
onClick = { ( ) = > setSelectedSpread ( s = > Math . min ( event . max_spread , s + 0.5 ) ) }
className = "px-3 py-2 bg-gray-7 00 text-white rounded hover:bg-gray-600 "
className = "px-4 py-2 bg-white border border- gray-3 00 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-4 00 text-xs block mb-1" > Stake Amount < / label >
< label className = "text-gray-6 00 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-6 00 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-3 00 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 >
{ /* Submi t Button */ }
{ /* Create Be t Button */ }
< button
onClick = { handleSubmi t }
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 = { handleCreateBe t }
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-4 00 mb-1" >
< div className = "mt-4 p-3 bg-white border rounded-lg text-xs" >
< div className = "flex justify-between text-gray-6 00 mb-1" >
< span > Your Position < / span >
< span className = { selectedSide === 'home' ? 'text-green-4 00' : 'text-red-4 00' } >
< span className = { selectedSide === 'home' ? 'text-green-6 00' : 'text-red-6 00' } >
{ selectedSide === 'home' ? event.home_team : event.away_team } { selectedSpread > 0 ? '+' : '' } { selectedSide === 'home' ? selectedSpread : - selectedSpread }
< / span >
< / div >
< div className = "flex justify-between text-gray-4 00 mb-1" >
< div className = "flex justify-between text-gray-6 00 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-4 00 mb-1" >
< div className = "flex justify-between text-gray-6 00 mb-1" >
< span > Potential Win < / span >
< span className = "text-green-4 00" >
$ { ( parseFloat ( stakeAmount ) * 0.9 * 2 ) . toFixed ( 2 ) }
< / span >
< span className = "text-green-6 00 font-semibold" > $ { ( parseFloat ( stakeAmount ) * 0.9 * 2 ) . toFixed ( 2 ) } < / span >
< / div >
< div className = "flex justify-between text-gray-4 00" >
< div className = "flex justify-between text-gray-6 00" >
< span > House Fee ( 10 % ) < / span >
< span className = "text-yellow-4 00" > $ { ( parseFloat ( stakeAmount ) * 0.1 ) . toFixed ( 2 ) } < / span >
< span className = "text-yellow-6 00" > $ { ( parseFloat ( stakeAmount ) * 0.1 ) . toFixed ( 2 ) } < / span >
< / div >
< / div >
) }