Event layout page update.

This commit is contained in:
2026-01-11 15:21:17 -06:00
parent e0af183086
commit e50b2f31d3
13 changed files with 1460 additions and 183 deletions

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store'
import { useMutation, useQueryClient } from '@tanstack/react-query'
@ -8,8 +8,6 @@ import { TeamSide } from '@/types/spread-bet'
import {
Clock,
Activity,
DollarSign,
Users,
Target,
TrendingUp,
TrendingDown,
@ -43,6 +41,22 @@ function formatTimeUntil(gameTime: string): { text: string; urgent: boolean } {
return { text: `${seconds}s`, urgent: true }
}
// Helper to generate half-point spreads only (to prevent ties)
function generateHalfPointSpreads(min: number, max: number): number[] {
const spreads: number[] = []
// Round min up to nearest .5
let start = Math.ceil(min * 2) / 2
if (start % 1 === 0) start += 0.5
// Round max down to nearest .5
let end = Math.floor(max * 2) / 2
if (end % 1 === 0) end -= 0.5
for (let s = start; s <= end; s += 1) {
spreads.push(s)
}
return spreads
}
export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelProps) => {
const { isAuthenticated } = useAuthStore()
const queryClient = useQueryClient()
@ -52,9 +66,6 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
const [stakeAmount, setStakeAmount] = useState('')
const [activeTab, setActiveTab] = useState<'chart' | 'grid'>('chart')
// Ref for above spread line container
const aboveLineRef = useRef<HTMLDivElement>(null)
// Update countdown every second
useEffect(() => {
const interval = setInterval(() => {
@ -96,31 +107,25 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
return max
}, [event.spread_grid])
// Generate ALL spreads from min to max, split at official line for order book
const { aboveLine, belowLine } = useMemo(() => {
// Generate half-point spreads and split at official line for order book
const { aboveLine, belowLine, allSpreads } = useMemo(() => {
const spreads = generateHalfPointSpreads(event.min_spread, event.max_spread)
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) {
spreads.forEach(s => {
if (s > event.official_spread) above.push(s)
else if (s < event.official_spread) below.push(s)
}
})
return {
aboveLine: above.sort((a, b) => b - a), // High to low (highest at top)
aboveLine: above.sort((a, b) => b - a), // High to low (9.5, 8.5, 7.5...)
belowLine: below.sort((a, b) => b - a), // High to low (closest to line at top)
allSpreads: spreads,
}
}, [event.min_spread, event.max_spread, event.official_spread])
// Auto-scroll above spread line to bottom after render
useEffect(() => {
if (aboveLineRef.current) {
aboveLineRef.current.scrollTop = aboveLineRef.current.scrollHeight
}
}, [aboveLine])
// Get all bets for chart and recent activity (including matched)
// Get all bets with spread info for chart and recent activity
const allBetsWithSpread = useMemo(() => {
const bets: (SpreadGridBet & { spread: number })[] = []
Object.entries(event.spread_grid).forEach(([spread, spreadBets]) => {
@ -136,26 +141,25 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
return allBetsWithSpread
}, [allBetsWithSpread])
// Chart data - volume per spread
// Chart data - volume per spread (half-points only)
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) {
allSpreads.forEach(s => {
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])
}, [event.spread_grid, allSpreads])
const chartMaxVolume = useMemo(() => {
return Math.max(...chartData.map(d => d.total), 1)
}, [chartData])
// Available bets to take
// Available bets to take at selected spread
const availableBets = useMemo(() => {
const bets = event.spread_grid[selectedSpread.toString()] || []
return bets.filter(b => b.status === 'open' && b.can_take)
@ -217,6 +221,15 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
takeBetMutation.mutate(betId)
}
// Adjust spread by 1 point (next half-point)
const adjustSpread = (delta: number) => {
const newSpread = selectedSpread + delta
// Ensure it's a valid half-point spread within range
if (newSpread >= event.min_spread && newSpread <= event.max_spread && newSpread % 1 !== 0) {
setSelectedSpread(newSpread)
}
}
// Quick stake buttons
const quickStakes = [25, 50, 100, 250, 500]
@ -275,16 +288,11 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
)
}
// Grid view - shows all spreads in a grid format
// Grid view - shows all half-point 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 => {
{allSpreads.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')
@ -321,86 +329,86 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
return (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
{/* Header - Event Info */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 p-4 text-white">
{/* Compact Header - Exchange Style */}
<div className="bg-gray-800 px-4 py-3 text-white">
<div className="flex items-center justify-between">
<div className="flex items-center gap-6">
{/* Left: Teams and Spread */}
<div className="flex items-center gap-4">
{/* Home Team */}
<div className="text-center">
<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 className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-600/20 rounded-full flex items-center justify-center">
<span className="text-green-400 font-bold">{event.home_team.charAt(0)}</span>
</div>
<div>
<p className="font-semibold text-sm">{event.home_team}</p>
<p className="text-gray-400 text-xs">HOME</p>
</div>
<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-6">
<div className="text-blue-200 text-xs mb-1">OFFICIAL SPREAD</div>
<div className="text-3xl font-bold">
{/* Spread */}
<div className="text-center px-6 border-l border-r border-gray-700">
<div className="text-gray-400 text-xs">SPREAD</div>
<div className="text-yellow-400 text-2xl font-bold">
{event.official_spread > 0 ? '+' : ''}{event.official_spread}
</div>
<div className="text-blue-200 text-xs mt-1">{event.league}</div>
<div className="text-gray-400 text-xs">{event.league}</div>
</div>
{/* Away Team */}
<div className="text-center">
<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 className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-600/20 rounded-full flex items-center justify-center">
<span className="text-red-400 font-bold">{event.away_team.charAt(0)}</span>
</div>
<div>
<p className="font-semibold text-sm">{event.away_team}</p>
<p className="text-gray-400 text-xs">AWAY</p>
</div>
<p className="font-semibold">{event.away_team}</p>
<p className="text-blue-200 text-xs">AWAY</p>
</div>
</div>
{/* Game Time */}
<div className="text-right">
{/* Center: Market Stats */}
<div className="flex items-center gap-6 px-6 border-l border-gray-700">
<div className="text-center">
<p className="text-gray-400 text-xs">Volume</p>
<p className="font-mono font-semibold">${marketStats.totalVolume.toLocaleString()}</p>
</div>
<div className="text-center">
<p className="text-gray-400 text-xs">Open</p>
<p className="font-mono font-semibold text-green-400">{marketStats.openBets}</p>
</div>
<div className="flex items-center gap-2">
<TrendingUp size={14} className="text-green-400" />
<span className="text-green-400 font-mono text-sm">${marketStats.homeVolume.toLocaleString()}</span>
</div>
<div className="flex items-center gap-2">
<TrendingDown size={14} className="text-red-400" />
<span className="text-red-400 font-mono text-sm">${marketStats.awayVolume.toLocaleString()}</span>
</div>
</div>
{/* Right: Countdown */}
<div className="flex items-center gap-3 pl-6 border-l border-gray-700">
<div className={`
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'}
flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold
${timeUntil.urgent ? 'bg-red-500/20 text-red-400 animate-pulse' : 'bg-gray-700 text-gray-300'}
`}>
<Clock size={16} />
{timeUntil.text}
<span>{timeUntil.text}</span>
</div>
<p className="text-blue-200 text-xs mt-2">
{new Date(event.game_time).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})}
</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 className="text-right">
<p className="text-gray-400 text-xs">
{new Date(event.game_time).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
})}
</p>
<p className="text-gray-300 text-xs">
{new Date(event.game_time).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
})}
</p>
</div>
</div>
</div>
@ -409,16 +417,16 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
{/* 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 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" />
<div className="col-span-3 flex flex-col overflow-hidden bg-gray-50">
<div className="p-3 border-b bg-white">
<h3 className="font-semibold text-gray-900 flex items-center gap-2 text-sm">
<Activity size={14} className="text-gray-400" />
Order Book
</h3>
</div>
{/* Header */}
<div className="grid grid-cols-7 gap-1 py-2 px-4 text-xs text-gray-500 border-b">
{/* Column Header */}
<div className="grid grid-cols-7 gap-1 py-1.5 px-2 text-xs text-gray-500 border-b bg-white">
<span className="text-right">{event.away_team.slice(0, 4)}</span>
<span className="text-right">Vol</span>
<span></span>
@ -428,15 +436,11 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
<span className="text-left">{event.home_team.slice(0, 4)}</span>
</div>
{/* Above official line - fills available space, auto-scrolls to bottom */}
<div ref={aboveLineRef} 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>
)}
{/* Above official line - aligned to bottom */}
<div className="flex-1 overflow-y-auto flex flex-col justify-end border-b border-gray-200 bg-white">
<div className="px-2">
{aboveLine.map(spread => renderOrderRow(spread))}
</div>
</div>
{/* Official Line */}
@ -444,22 +448,18 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
{renderOrderRow(event.official_spread)}
</div>
{/* 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>
)}
{/* Below official line - aligned to top */}
<div className="flex-1 overflow-y-auto bg-white">
<div className="px-2">
{belowLine.map(spread => renderOrderRow(spread))}
</div>
</div>
</div>
{/* Center - Chart/Grid with Tabs */}
<div className="col-span-6 flex flex-col overflow-hidden">
{/* Tabs */}
<div className="flex border-b">
<div className="flex border-b bg-white">
<button
onClick={() => setActiveTab('chart')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
@ -507,7 +507,6 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
</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
@ -545,7 +544,7 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
{/* 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>Spread (half-points)</span>
<span>+{event.max_spread}</span>
</div>
@ -600,7 +599,7 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
) : (
/* Grid View */
<div className="flex-1 overflow-y-auto">
<h3 className="font-semibold text-gray-900 mb-4">All Spreads</h3>
<h3 className="font-semibold text-gray-900 mb-4">All Spreads (half-points only)</h3>
{renderGridView()}
</div>
)}
@ -654,10 +653,10 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
{/* Spread Selection */}
<div className="mb-4">
<label className="text-gray-600 text-xs block mb-1">Spread</label>
<label className="text-gray-600 text-xs block mb-1">Spread (half-points only)</label>
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedSpread(s => Math.max(event.min_spread, s - 0.5))}
onClick={() => adjustSpread(-1)}
className="px-4 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 font-bold"
>
@ -671,7 +670,7 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
)}
</div>
<button
onClick={() => setSelectedSpread(s => Math.min(event.max_spread, s + 0.5))}
onClick={() => adjustSpread(1)}
className="px-4 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 font-bold"
>
+
@ -722,13 +721,15 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
{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)
Take Existing Bet ({availableBets.length} available at {selectedSpread > 0 ? '+' : ''}{selectedSpread})
</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={`font-semibold text-sm ${bet.team === 'home' ? 'text-green-600' : 'text-red-600'}`}>
${bet.stake.toFixed(0)} on {bet.team === 'home' ? event.home_team : event.away_team}
</p>
<p className="text-xs text-gray-500">by {bet.creator_username}</p>
</div>
<button

View File

@ -10,7 +10,6 @@ import {
Crown,
Users,
Rocket,
Zap,
Share2,
Settings,
Receipt
@ -44,12 +43,6 @@ const MORE_DROPDOWN = [
description: 'Discover and gain access to new bets',
icon: Rocket
},
{
path: '/megadrop',
label: 'Megadrop',
description: 'Lock your bets and complete quests for boosted airdrop rewards',
icon: Zap
},
]
// Reusable dropdown hook
@ -225,8 +218,8 @@ export const Header = () => {
{/* Left-justified navigation links */}
<nav className="flex items-center gap-6 ml-8">
<Link to="/sports" className="text-gray-700 hover:text-primary transition-colors">
Markets
<Link to="/" className="text-gray-700 hover:text-primary transition-colors">
Events
</Link>
<Link to="/live" className="text-gray-700 hover:text-primary transition-colors">
Live