Updated event bet page.

This commit is contained in:
2026-01-09 23:14:37 -06:00
parent b3c235a860
commit 708e51f2bd
3 changed files with 288 additions and 131 deletions

View File

@ -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 (
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-2">
{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 (
<button
key={spread}
onClick={() => setSelectedSpread(spread)}
className={`
p-3 rounded-lg border transition-all text-center
${selectedSpread === spread ? 'ring-2 ring-blue-500 border-blue-500' : 'border-gray-200 hover:border-gray-300'}
${isOfficial ? 'bg-yellow-50 border-yellow-400' : 'bg-white'}
`}
>
<div className={`text-sm font-bold ${isOfficial ? 'text-yellow-600' : 'text-gray-900'}`}>
{spread > 0 ? '+' : ''}{spread}
</div>
<div className="text-xs text-gray-500 mt-1">{totalBets} bets</div>
<div className="flex justify-center gap-2 mt-1 text-xs">
<span className="text-green-600">${homeVolume}</span>
<span className="text-gray-300">|</span>
<span className="text-red-600">${awayVolume}</span>
</div>
</button>
)
})}
</div>
)
}
return (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
{/* Header - Event Info */}
@ -352,17 +396,19 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
</div>
</div>
{/* Main Content */}
<div className="grid grid-cols-12 divide-x divide-gray-200">
{/* Main Content - fixed height container */}
<div className="grid grid-cols-12 divide-x divide-gray-200 h-[600px] overflow-hidden">
{/* 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>
<div className="col-span-3 flex flex-col overflow-hidden">
<div className="p-4 pb-2">
<h3 className="font-semibold text-gray-900 flex items-center gap-2">
<Activity size={16} className="text-gray-400" />
Order Book
</h3>
</div>
{/* Header */}
<div className="grid grid-cols-7 gap-1 py-2 px-2 text-xs text-gray-500 border-b mb-1">
<div className="grid grid-cols-7 gap-1 py-2 px-4 text-xs text-gray-500 border-b">
<span className="text-right">{event.away_team.slice(0, 4)}</span>
<span className="text-right">Vol</span>
<span></span>
@ -372,122 +418,187 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
<span className="text-left">{event.home_team.slice(0, 4)}</span>
</div>
{/* Above official line (away favored) */}
<div className="max-h-32 overflow-y-auto border-b border-gray-100">
{aboveLine.map(spread => renderOrderRow(spread))}
{/* Above official line - fills available space */}
<div className="flex-1 overflow-y-auto border-b border-gray-100 px-2">
{aboveLine.length > 0 ? (
aboveLine.map(spread => renderOrderRow(spread))
) : (
<div className="flex items-center justify-center h-full text-gray-400 text-xs">
No spreads above line
</div>
)}
</div>
{/* Official Line */}
<div className="bg-yellow-50 border-y-2 border-yellow-400">
<div className="bg-yellow-50 border-y-2 border-yellow-400 px-2 flex-shrink-0">
{renderOrderRow(event.official_spread)}
</div>
{/* Below official line (home favored) */}
<div className="max-h-32 overflow-y-auto">
{belowLine.map(spread => renderOrderRow(spread))}
{/* Below official line - fills available space */}
<div className="flex-1 overflow-y-auto px-2">
{belowLine.length > 0 ? (
belowLine.map(spread => renderOrderRow(spread))
) : (
<div className="flex items-center justify-center h-full text-gray-400 text-xs">
No spreads below line
</div>
)}
</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}%`
}}
{/* Center - Chart/Grid with Tabs */}
<div className="col-span-6 flex flex-col overflow-hidden">
{/* Tabs */}
<div className="flex border-b">
<button
onClick={() => setActiveTab('chart')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'chart'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<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>
<BarChart3 size={16} />
Chart
</button>
<button
onClick={() => setActiveTab('grid')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'grid'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<Grid3X3 size={16} />
Grid
</button>
</div>
{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' }}
/>
{/* Tab Content */}
<div className="flex-1 flex flex-col overflow-hidden p-4 min-h-0">
{activeTab === 'chart' ? (
<div className="flex flex-col h-full min-h-0">
{/* Chart Section */}
<div className="flex-1 flex flex-col min-h-0">
<h3 className="font-semibold text-gray-900 mb-2 flex-shrink-0">Market Depth</h3>
{/* 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()}
{/* Chart container */}
<div className="flex-1 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>
{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 (
<div
key={d.spread}
className="flex-1 h-full flex flex-col justify-end cursor-pointer group relative"
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: `${awayHeightPercent}%`, 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: `${homeHeightPercent}%`, 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 flex-shrink-0">
<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-1 text-xs flex-shrink-0">
<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}</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}</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>
</div>
{/* Recent Activity Section */}
<div className="h-48 flex flex-col mt-3 pt-3 border-t flex-shrink-0">
<h4 className="font-semibold text-gray-900 mb-2 text-sm flex-shrink-0">
Recent Activity ({recentActivity.length} bets)
</h4>
<div className="flex-1 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.5 px-2 bg-gray-50 rounded hover:bg-gray-100">
<span className={`font-medium ${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-700 font-mono font-medium">${bet.stake.toFixed(0)}</span>
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${
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>
))
) : (
<div className="flex items-center justify-center h-full text-gray-400">
No bets yet
</div>
)}
</div>
</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 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-400 text-center py-4">No bets yet</p>
)}
</div>
) : (
/* Grid View */
<div className="flex-1 overflow-y-auto">
<h3 className="font-semibold text-gray-900 mb-4">All Spreads</h3>
{renderGridView()}
</div>
)}
</div>
</div>
{/* Right - Quick Trade Panel */}
<div className="col-span-3 p-4 bg-gray-50">
<div className="col-span-3 p-4 bg-gray-50 overflow-y-auto">
<h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Target size={16} className="text-gray-400" />
Place Bet

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

View File

@ -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));
});