diff --git a/frontend/src/components/bets/TradingPanel.tsx b/frontend/src/components/bets/TradingPanel.tsx
index ac946c4..48a1fb9 100644
--- a/frontend/src/components/bets/TradingPanel.tsx
+++ b/frontend/src/components/bets/TradingPanel.tsx
@@ -13,6 +13,8 @@ import {
Target,
TrendingUp,
TrendingDown,
+ BarChart3,
+ Grid3X3,
} from 'lucide-react'
import toast from 'react-hot-toast'
@@ -48,6 +50,7 @@ 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 [activeTab, setActiveTab] = useState<'chart' | 'grid'>('chart')
// Update countdown every second
useEffect(() => {
@@ -80,35 +83,32 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
return { totalVolume, totalBets, openBets, matchedBets, homeVolume, awayVolume }
}, [event.spread_grid])
- // Get sorted spreads for order book
- const sortedSpreads = useMemo(() => {
- return Object.keys(event.spread_grid)
- .map(Number)
- .sort((a, b) => b - a) // High to low
- }, [event.spread_grid])
-
- // Split spreads at official line for order book
- const { aboveLine, belowLine, maxVolume } = useMemo(() => {
- const above: number[] = []
- const below: number[] = []
+ // Calculate max volume across all spreads for order book bars
+ const maxVolume = useMemo(() => {
let max = 0
-
- sortedSpreads.forEach(spread => {
- const bets = event.spread_grid[spread.toString()] || []
+ Object.values(event.spread_grid).forEach(bets => {
const vol = bets.reduce((sum, b) => sum + b.stake, 0)
if (vol > max) max = vol
-
- if (spread > event.official_spread) above.push(spread)
- else if (spread < event.official_spread) below.push(spread)
})
+ return max
+ }, [event.spread_grid])
- // 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
+ // Generate ALL spreads from min to max, split at official line for order book
+ const { aboveLine, belowLine } = useMemo(() => {
+ const above: number[] = []
+ const below: number[] = []
+
+ // Generate all possible spreads in range
+ for (let s = event.min_spread; s <= event.max_spread; s += 0.5) {
+ if (s > event.official_spread) above.push(s)
+ else if (s < event.official_spread) below.push(s)
}
- }, [sortedSpreads, event.spread_grid, event.official_spread])
+
+ return {
+ aboveLine: above.sort((a, b) => b - a), // High to low (highest at top)
+ belowLine: below.sort((a, b) => b - a), // High to low (closest to line at top)
+ }
+ }, [event.min_spread, event.max_spread, event.official_spread])
// Get all bets for chart and recent activity (including matched)
const allBetsWithSpread = useMemo(() => {
@@ -121,9 +121,9 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
return bets
}, [event.spread_grid])
- // Recent activity - all bets sorted by recency (we don't have timestamps, so just show all)
+ // Recent activity - all bets
const recentActivity = useMemo(() => {
- return allBetsWithSpread.slice(0, 15) // Show last 15
+ return allBetsWithSpread
}, [allBetsWithSpread])
// Chart data - volume per spread
@@ -265,6 +265,50 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
)
}
+ // Grid view - shows all spreads in a grid format
+ const renderGridView = () => {
+ const spreads: number[] = []
+ for (let s = event.min_spread; s <= event.max_spread; s += 0.5) {
+ spreads.push(s)
+ }
+
+ return (
+
+ {spreads.map(spread => {
+ 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 totalBets = bets.length
+ const isOfficial = spread === event.official_spread
+
+ return (
+
+ )
+ })}
+
+ )
+ }
+
return (
{/* Header - Event Info */}
@@ -352,17 +396,19 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
- {/* Main Content */}
-
+ {/* Main Content - fixed height container */}
+
{/* Left - Order Book */}
-
-
-
- Order Book
-
+
+
{/* Header */}
-
+
{event.away_team.slice(0, 4)}
Vol
@@ -372,122 +418,187 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
{event.home_team.slice(0, 4)}
- {/* Above official line (away favored) */}
-
- {aboveLine.map(spread => renderOrderRow(spread))}
+ {/* Above official line - fills available space */}
+
+ {aboveLine.length > 0 ? (
+ aboveLine.map(spread => renderOrderRow(spread))
+ ) : (
+
+ No spreads above line
+
+ )}
{/* Official Line */}
-
+
{renderOrderRow(event.official_spread)}
- {/* Below official line (home favored) */}
-
- {belowLine.map(spread => renderOrderRow(spread))}
+ {/* Below official line - fills available space */}
+
+ {belowLine.length > 0 ? (
+ belowLine.map(spread => renderOrderRow(spread))
+ ) : (
+
+ No spreads below line
+
+ )}
- {/* Center - Volume Chart */}
-
-
Market Depth
-
- {/* Chart */}
-
- {/* Official line indicator */}
-
+ {/* Tabs */}
+
+
+
+ Chart
+
+
+
- {chartData.map((d) => (
-
setSelectedSpread(d.spread)}
- >
- {/* Away volume (red, on top) */}
-
0 ? '2px' : '0' }}
- />
- {/* Home volume (green, on bottom) */}
-
0 ? '2px' : '0' }}
- />
+ {/* Tab Content */}
+
+ {activeTab === 'chart' ? (
+
+ {/* Chart Section */}
+
+
Market Depth
- {/* Tooltip on hover */}
-
- {d.spread > 0 ? '+' : ''}{d.spread}: ${d.total.toLocaleString()}
+ {/* Chart container */}
+
+ {/* Official line indicator */}
+
+
+ {event.official_spread > 0 ? '+' : ''}{event.official_spread}
+
+
+
+ {chartData.map((d) => {
+ // Calculate heights as percentage of max, ensuring max bar fills 100%
+ const totalHeightPercent = (d.total / chartMaxVolume) * 100
+ const homeHeightPercent = d.total > 0 ? (d.homeVolume / d.total) * totalHeightPercent : 0
+ const awayHeightPercent = d.total > 0 ? (d.awayVolume / d.total) * totalHeightPercent : 0
+
+ return (
+
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 */}
+
+
+
+ {/* Recent Activity Section */}
+
+
+ Recent Activity ({recentActivity.length} bets)
+
+
+ {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
+
+ )}
+
- ))}
-
-
- {/* X-axis labels */}
-
- {event.min_spread}
- Spread
- +{event.max_spread}
-
-
- {/* Legend */}
-
-
-
-
{event.home_team} Volume
-
-
-
-
{event.away_team} Volume
-
-
-
-
- {/* Recent Activity */}
-
-
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
- )}
-
+ ) : (
+ /* Grid View */
+
+
All Spreads
+ {renderGridView()}
+
+ )}
{/* Right - Quick Trade Panel */}
-
+
Place Bet
diff --git a/frontend/test-results/event-detail.png b/frontend/test-results/event-detail.png
new file mode 100644
index 0000000..28d01b7
Binary files /dev/null and b/frontend/test-results/event-detail.png differ
diff --git a/frontend/tests/debug-trading-panel.spec.ts b/frontend/tests/debug-trading-panel.spec.ts
new file mode 100644
index 0000000..3983c3a
--- /dev/null
+++ b/frontend/tests/debug-trading-panel.spec.ts
@@ -0,0 +1,46 @@
+import { test } from '@playwright/test';
+
+test('capture trading panel screenshot', async ({ page }) => {
+ // Navigate to event detail
+ await page.goto('/events/1', { waitUntil: 'networkidle' });
+ await page.waitForTimeout(3000);
+
+ // Take screenshot
+ await page.screenshot({ path: 'test-results/event-detail.png', fullPage: true });
+
+ // Check the spread values being used
+ const spreadInfo = await page.evaluate(() => {
+ // Look for the spread grid to understand the range
+ const spreadGridCells = document.querySelectorAll('.grid button');
+ const spreads: string[] = [];
+ spreadGridCells.forEach(cell => {
+ const text = cell.textContent;
+ if (text && (text.includes('+') || text.includes('-') || text.match(/^\\d/))) {
+ spreads.push(text.substring(0, 10));
+ }
+ });
+
+ // Check order book sections
+ const orderBookContainer = document.querySelector('.col-span-3');
+ const aboveSection = orderBookContainer?.querySelectorAll('.flex-1.overflow-y-auto')[0];
+ const belowSection = orderBookContainer?.querySelectorAll('.flex-1.overflow-y-auto')[1];
+
+ // Count rows in each section
+ const aboveRows = aboveSection?.querySelectorAll('button').length || 0;
+ const belowRows = belowSection?.querySelectorAll('button').length || 0;
+
+ // Get the official spread from the header
+ const officialSpreadElement = document.querySelector('.text-3xl.font-bold');
+ const officialSpread = officialSpreadElement?.textContent;
+
+ return {
+ officialSpread,
+ aboveRowCount: aboveRows,
+ belowRowCount: belowRows,
+ aboveSectionHTML: aboveSection?.innerHTML?.substring(0, 500) || 'not found',
+ belowSectionHTML: belowSection?.innerHTML?.substring(0, 500) || 'not found',
+ };
+ });
+
+ console.log('Spread info:', JSON.stringify(spreadInfo, null, 2));
+});