diff --git a/frontend/src/components/bets/TradingPanel.tsx b/frontend/src/components/bets/TradingPanel.tsx index 9f80eb3..ac946c4 100644 --- a/frontend/src/components/bets/TradingPanel.tsx +++ b/frontend/src/components/bets/TradingPanel.tsx @@ -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 ( - - ) -} - -// 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 ( -
- - {isHome ? homeTeam : awayTeam} {bet.spread > 0 ? '+' : ''}{bet.spread} - - ${bet.stake.toFixed(0)} - - {bet.status} - -
- ) -} - 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 ( + + ) + } return ( -
+
{/* Header - Event Info */} -
+
-
+
{/* Home Team */}
-
- - {event.home_team.charAt(0)} - +
+ {event.home_team.charAt(0)}
-

{event.home_team}

-

HOME

+

{event.home_team}

+

HOME

{/* VS / Spread */} -
-
SPREAD
-
+
+
OFFICIAL SPREAD
+
{event.official_spread > 0 ? '+' : ''}{event.official_spread}
-
Official Line
+
{event.league}
{/* Away Team */}
-
- - {event.away_team.charAt(0)} - +
+ {event.away_team.charAt(0)}
-

{event.away_team}

-

AWAY

+

{event.away_team}

+

AWAY

- {/* Game Time & Status */} + {/* Game Time */}
- + {timeUntil.text}
-

+

{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', })}

- {event.league && ( -

{event.league}

- )} +
+
+ + {/* Stats Bar */} +
+
+
+ + Volume: + ${marketStats.totalVolume.toLocaleString()} +
+
+ + Bets: + {marketStats.totalBets} +
+
+ + Open: + {marketStats.openBets} +
+
+
+
+ + ${marketStats.homeVolume.toLocaleString()} +
+
+ + ${marketStats.awayVolume.toLocaleString()} +
- {/* Market Stats Ticker */} -
-
-
- - Volume - ${marketStats.totalVolume.toLocaleString()} -
-
- - Bets - {marketStats.totalBets} -
-
- - Open - {marketStats.openBets} -
-
- - Matched - {marketStats.matchedBets} -
-
-
-
- - ${marketStats.homeVolume.toLocaleString()} -
-
- - ${marketStats.awayVolume.toLocaleString()} -
-
-
+ {/* Main Content */} +
+ {/* Left - Order Book */} +
+

+ + Order Book +

- {/* Main Trading Area */} -
- {/* Order Book */} -
-
-

- - Order Book -

-
- {event.home_team} - / - {event.away_team} -
+ {/* Header */} +
+ {event.away_team.slice(0, 4)} + Vol + + Spread + + Vol + {event.home_team.slice(0, 4)}
-
- {/* Home Side (Buy/Green) */} -
-
- Count - Volume - Spread - Open -
-
- {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 ( - { - setSelectedSide('home') - handleSpreadSelect(spread) - }} - /> - ) - })} + {/* Above official line (away favored) */} +
+ {aboveLine.map(spread => renderOrderRow(spread))} +
+ + {/* Official Line */} +
+ {renderOrderRow(event.official_spread)} +
+ + {/* Below official line (home favored) */} +
+ {belowLine.map(spread => renderOrderRow(spread))} +
+
+ + {/* Center - Volume Chart */} +
+

Market Depth

+ + {/* Chart */} +
+ {/* Official line indicator */} +
+
+ {event.official_spread > 0 ? '+' : ''}{event.official_spread}
- {/* Away Side (Sell/Red) */} -
-
- Open - Spread - Volume - Count -
-
- {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 ( - { - setSelectedSide('away') - handleSpreadSelect(spread) - }} - /> - ) - })} + {chartData.map((d) => ( +
setSelectedSpread(d.spread)} + > + {/* Away volume (red, on top) */} +
0 ? '2px' : '0' }} + /> + {/* Home volume (green, on bottom) */} +
0 ? '2px' : '0' }} + /> + + {/* Tooltip on hover */} +
+ {d.spread > 0 ? '+' : ''}{d.spread}: ${d.total.toLocaleString()} +
+ ))} +
+ + {/* X-axis labels */} +
+ {event.min_spread} + Spread + +{event.max_spread} +
+ + {/* Legend */} +
+
+
+ {event.home_team} Volume +
+
+
+ {event.away_team} Volume +
+
+
+ Official Line
{/* Recent Activity */} -
-

- - Recent Activity -

-
- {recentBets.length > 0 ? ( - recentBets.map((bet, i) => ( - +
+

Recent Activity

+
+ {recentActivity.length > 0 ? ( + recentActivity.map((bet, i) => ( +
+ + {bet.team === 'home' ? event.home_team : event.away_team} {bet.spread > 0 ? '+' : ''}{bet.spread} + + ${bet.stake.toFixed(0)} + + {bet.status} + + {bet.creator_username} +
)) ) : ( -

No bets yet

+

No bets yet

)}
- {/* Quick Trade Panel */} -
-

+ {/* Right - Quick Trade Panel */} +
+

Place Bet

{!isAuthenticated ? ( -
-
Log in to place bets
+
+

Log in to place bets

- + Log In - + Sign Up
) : ( <> - {/* Side Selection */} + {/* Team Selection */}
{/* Spread Selection */}
- +
-
- +
+ {selectedSpread > 0 ? '+' : ''}{selectedSpread} {selectedSpread === event.official_spread && ( - Official + ★ Official )}
- {/* Order Type */} -
- - -
- {/* Stake Amount */}
- +
$ 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" />
{/* Quick Stakes */} -
- {quickStakes - .filter(s => s >= event.min_bet_amount && s <= event.max_bet_amount) - .map(stake => ( - - ))} +
+ {quickStakes.filter(s => s >= event.min_bet_amount && s <= event.max_bet_amount).map(stake => ( + + ))}
- {/* Submit Button */} + {/* Create Bet Button */} + {/* Available Bets to Take */} + {availableBets.length > 0 && ( +
+

+ Take Existing Bet ({availableBets.length} available) +

+
+ {availableBets.map(bet => ( +
+
+

${bet.stake.toFixed(0)}

+

by {bet.creator_username}

+
+ +
+ ))} +
+
+ )} + {/* Bet Summary */} {stakeAmount && ( -
-
+
+
Your Position - + {selectedSide === 'home' ? event.home_team : event.away_team} {selectedSpread > 0 ? '+' : ''}{selectedSide === 'home' ? selectedSpread : -selectedSpread}
-
+
Stake - ${parseFloat(stakeAmount).toFixed(2)} + ${parseFloat(stakeAmount).toFixed(2)}
-
+
Potential Win - - ${(parseFloat(stakeAmount) * 0.9 * 2).toFixed(2)} - + ${(parseFloat(stakeAmount) * 0.9 * 2).toFixed(2)}
-
+
House Fee (10%) - ${(parseFloat(stakeAmount) * 0.1).toFixed(2)} + ${(parseFloat(stakeAmount) * 0.1).toFixed(2)}
)} diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 748da68..550b3d8 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -216,7 +216,7 @@ export const Header = () => { return (
-
+
{/* Logo */} diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index f8f7424..0fa2934 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -9,7 +9,7 @@ export const Layout = ({ children }: LayoutProps) => { return (
-
+
{children}
diff --git a/frontend/src/components/layout/RewardsLayout.tsx b/frontend/src/components/layout/RewardsLayout.tsx index ac7937a..ec33b37 100644 --- a/frontend/src/components/layout/RewardsLayout.tsx +++ b/frontend/src/components/layout/RewardsLayout.tsx @@ -31,7 +31,7 @@ export function RewardsLayout({ children, title, subtitle }: RewardsLayoutProps)
-
+
{/* Page Header */} {title && (
diff --git a/frontend/src/pages/EventDetail.tsx b/frontend/src/pages/EventDetail.tsx index 986c21a..6d7324d 100644 --- a/frontend/src/pages/EventDetail.tsx +++ b/frontend/src/pages/EventDetail.tsx @@ -38,9 +38,9 @@ export const EventDetail = () => { if (isLoading) { return ( -
+
-
+
-
-

Event not found

+
+

Event not found

@@ -75,9 +75,9 @@ export const EventDetail = () => { } return ( -
+
-
+
- {/* Trading Panel - Binance-style interface */} + {/* Trading Panel - Exchange-style interface */}
{
{/* Spread Grid - Visual betting grid */} -
-

Spread Grid

+
+

Spread Grid