Event layout page update.
This commit is contained in:
@ -1,5 +1,5 @@
|
||||
import { apiClient } from './client'
|
||||
import type { SportEvent, SportEventWithBets } from '@/types/sport-event'
|
||||
import type { SportEvent, SportEventWithBets, EventComment, EventCommentsResponse } from '@/types/sport-event'
|
||||
|
||||
export const sportEventsApi = {
|
||||
// Public endpoints (no auth required)
|
||||
@ -33,4 +33,20 @@ export const sportEventsApi = {
|
||||
const response = await apiClient.get<SportEventWithBets>(`/api/v1/sport-events/${eventId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Comments
|
||||
getEventComments: async (eventId: number, params?: { skip?: number; limit?: number }): Promise<EventCommentsResponse> => {
|
||||
const queryParams = new URLSearchParams()
|
||||
if (params?.skip !== undefined) queryParams.append('skip', params.skip.toString())
|
||||
if (params?.limit !== undefined) queryParams.append('limit', params.limit.toString())
|
||||
|
||||
const url = `/api/v1/sport-events/${eventId}/comments${queryParams.toString() ? `?${queryParams}` : ''}`
|
||||
const response = await apiClient.get<EventCommentsResponse>(url)
|
||||
return response.data
|
||||
},
|
||||
|
||||
addEventComment: async (eventId: number, content: string): Promise<EventComment> => {
|
||||
const response = await apiClient.post<EventComment>(`/api/v1/sport-events/${eventId}/comments`, { content })
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4,7 +4,7 @@ import { WS_URL } from '@/utils/constants'
|
||||
import { useAuthStore } from '@/store'
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: 'bet_created' | 'bet_taken' | 'bet_cancelled' | 'event_updated'
|
||||
type: 'bet_created' | 'bet_taken' | 'bet_cancelled' | 'event_updated' | 'new_comment'
|
||||
data: {
|
||||
event_id: number
|
||||
bet_id?: number
|
||||
@ -17,9 +17,10 @@ interface UseEventWebSocketOptions {
|
||||
onBetCreated?: (data: WebSocketMessage['data']) => void
|
||||
onBetTaken?: (data: WebSocketMessage['data']) => void
|
||||
onBetCancelled?: (data: WebSocketMessage['data']) => void
|
||||
onNewComment?: (data: WebSocketMessage['data']) => void
|
||||
}
|
||||
|
||||
export function useEventWebSocket({ eventId, onBetCreated, onBetTaken, onBetCancelled }: UseEventWebSocketOptions) {
|
||||
export function useEventWebSocket({ eventId, onBetCreated, onBetTaken, onBetCancelled, onNewComment }: UseEventWebSocketOptions) {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
@ -29,13 +30,15 @@ export function useEventWebSocket({ eventId, onBetCreated, onBetTaken, onBetCanc
|
||||
const onBetCreatedRef = useRef(onBetCreated)
|
||||
const onBetTakenRef = useRef(onBetTaken)
|
||||
const onBetCancelledRef = useRef(onBetCancelled)
|
||||
const onNewCommentRef = useRef(onNewComment)
|
||||
|
||||
// Update refs when callbacks change
|
||||
useEffect(() => {
|
||||
onBetCreatedRef.current = onBetCreated
|
||||
onBetTakenRef.current = onBetTaken
|
||||
onBetCancelledRef.current = onBetCancelled
|
||||
}, [onBetCreated, onBetTaken, onBetCancelled])
|
||||
onNewCommentRef.current = onNewComment
|
||||
}, [onBetCreated, onBetTaken, onBetCancelled, onNewComment])
|
||||
|
||||
const invalidateEventQueries = useCallback(() => {
|
||||
console.log('[WebSocket] Refetching queries for event', eventId)
|
||||
@ -94,6 +97,11 @@ export function useEventWebSocket({ eventId, onBetCreated, onBetTaken, onBetCanc
|
||||
case 'event_updated':
|
||||
invalidateEventQueries()
|
||||
break
|
||||
case 'new_comment':
|
||||
onNewCommentRef.current?.(message.data)
|
||||
// Also refetch comments
|
||||
queryClient.invalidateQueries({ queryKey: ['event-comments', eventId] })
|
||||
break
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WebSocket] Failed to parse message:', err)
|
||||
|
||||
@ -1,22 +1,29 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAuthStore } from '@/store'
|
||||
import { sportEventsApi } from '@/api/sport-events'
|
||||
import { SpreadGrid } from '@/components/bets/SpreadGrid'
|
||||
import { TradingPanel } from '@/components/bets/TradingPanel'
|
||||
import { MyOtherBets } from '@/components/bets/MyOtherBets'
|
||||
import { Button } from '@/components/common/Button'
|
||||
import { Loading } from '@/components/common/Loading'
|
||||
import { Button } from '@/components/common/Button'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { MessageCircle, List, Send } from 'lucide-react'
|
||||
import { useEventWebSocket } from '@/hooks/useEventWebSocket'
|
||||
import { format } from 'date-fns'
|
||||
import toast from 'react-hot-toast'
|
||||
import type { EventComment } from '@/types/sport-event'
|
||||
|
||||
type TabType = 'comments' | 'mybets'
|
||||
|
||||
export const EventDetail = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const eventId = parseInt(id || '0', 10)
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const { isAuthenticated, user } = useAuthStore()
|
||||
const queryClient = useQueryClient()
|
||||
const [activeTab, setActiveTab] = useState<TabType>('comments')
|
||||
const [newComment, setNewComment] = useState('')
|
||||
const commentsEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { data: event, isLoading, error } = useQuery({
|
||||
queryKey: ['sport-event', eventId, isAuthenticated],
|
||||
@ -27,6 +34,23 @@ export const EventDetail = () => {
|
||||
enabled: eventId > 0,
|
||||
})
|
||||
|
||||
const { data: commentsData, isLoading: isLoadingComments } = useQuery({
|
||||
queryKey: ['event-comments', eventId],
|
||||
queryFn: () => sportEventsApi.getEventComments(eventId),
|
||||
enabled: eventId > 0 && activeTab === 'comments',
|
||||
})
|
||||
|
||||
const addCommentMutation = useMutation({
|
||||
mutationFn: (content: string) => sportEventsApi.addEventComment(eventId, content),
|
||||
onSuccess: () => {
|
||||
setNewComment('')
|
||||
queryClient.invalidateQueries({ queryKey: ['event-comments', eventId] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.detail || 'Failed to add comment')
|
||||
},
|
||||
})
|
||||
|
||||
// Connect to WebSocket for live updates
|
||||
useEventWebSocket({
|
||||
eventId,
|
||||
@ -48,31 +72,40 @@ export const EventDetail = () => {
|
||||
duration: 3000,
|
||||
})
|
||||
},
|
||||
onNewComment: () => {
|
||||
// Comments are automatically refetched by the WebSocket hook
|
||||
},
|
||||
})
|
||||
|
||||
// Scroll to bottom when comments change
|
||||
useEffect(() => {
|
||||
if (activeTab === 'comments') {
|
||||
commentsEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [commentsData?.comments, activeTab])
|
||||
|
||||
const handleBetCreated = () => {
|
||||
// Refetch event data to show new bet
|
||||
queryClient.invalidateQueries({ queryKey: ['sport-event', eventId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['public-sport-events'] })
|
||||
}
|
||||
|
||||
const handleBetTaken = () => {
|
||||
// Refetch event data to update bet status
|
||||
queryClient.invalidateQueries({ queryKey: ['sport-event', eventId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['public-sport-events'] })
|
||||
}
|
||||
|
||||
const handleSubmitComment = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (newComment.trim()) {
|
||||
addCommentMutation.mutate(newComment.trim())
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* <Link to="/">
|
||||
<Button variant="secondary">
|
||||
|
||||
Back to Events
|
||||
</Button>
|
||||
</Link> */}
|
||||
<div className="mt-8">
|
||||
<Loading />
|
||||
</div>
|
||||
@ -86,12 +119,6 @@ export const EventDetail = () => {
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* <Link to="/">
|
||||
<Button variant="secondary">
|
||||
|
||||
Back to Events
|
||||
</Button>
|
||||
</Link> */}
|
||||
<div className="mt-8 text-center py-12 bg-white rounded-lg shadow">
|
||||
<p className="text-gray-500">Event not found</p>
|
||||
</div>
|
||||
@ -100,17 +127,12 @@ export const EventDetail = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const comments = commentsData?.comments || []
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* <Link to="/">
|
||||
<Button variant="secondary" className="mb-6">
|
||||
|
||||
Back to Events
|
||||
</Button>
|
||||
</Link> */}
|
||||
|
||||
{/* Trading Panel - Exchange-style interface */}
|
||||
<div className="mb-8">
|
||||
<TradingPanel
|
||||
@ -120,21 +142,113 @@ export const EventDetail = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* My Other Bets - Show user's bets on other events */}
|
||||
<div className="mb-8">
|
||||
<MyOtherBets currentEventId={eventId} />
|
||||
{/* Tabbed Section - Comments / My Bets */}
|
||||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b">
|
||||
<button
|
||||
onClick={() => setActiveTab('comments')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === 'comments'
|
||||
? 'text-blue-600 border-b-2 border-blue-600 bg-blue-50/50'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<MessageCircle size={18} />
|
||||
Comments {commentsData?.total ? `(${commentsData.total})` : ''}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('mybets')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === 'mybets'
|
||||
? 'text-blue-600 border-b-2 border-blue-600 bg-blue-50/50'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<List size={18} />
|
||||
My Bets
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-0">
|
||||
{activeTab === 'comments' ? (
|
||||
<div>
|
||||
{/* Comments List */}
|
||||
<div className="h-80 overflow-y-auto p-4 space-y-4">
|
||||
{isLoadingComments ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loading />
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No comments yet. {isAuthenticated && 'Be the first to comment!'}
|
||||
</div>
|
||||
) : (
|
||||
comments.map((comment: EventComment) => {
|
||||
const isOwnComment = user?.id === comment.user_id
|
||||
return (
|
||||
<div
|
||||
key={comment.id}
|
||||
className={`flex ${isOwnComment ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[70%] rounded-lg p-3 ${
|
||||
isOwnComment
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-xs font-medium ${isOwnComment ? 'text-blue-100' : 'text-gray-500'}`}>
|
||||
{comment.username}
|
||||
</span>
|
||||
<span className={`text-xs ${isOwnComment ? 'text-blue-200' : 'text-gray-400'}`}>
|
||||
{format(new Date(comment.created_at), 'p')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm">{comment.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
<div ref={commentsEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Comment Input */}
|
||||
{isAuthenticated ? (
|
||||
<form onSubmit={handleSubmitComment} className="p-4 border-t bg-gray-50">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
maxLength={500}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!newComment.trim() || addCommentMutation.isPending}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="p-4 border-t bg-gray-50 text-center text-sm text-gray-500">
|
||||
<a href="/login" className="text-blue-600 hover:underline">Log in</a> to join the conversation
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4">
|
||||
<MyOtherBets currentEventId={eventId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spread Grid - Visual betting grid */}
|
||||
{/* <div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Spread Grid</h2>
|
||||
<SpreadGrid
|
||||
event={event}
|
||||
onBetCreated={handleBetCreated}
|
||||
onBetTaken={handleBetTaken}
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -42,6 +42,8 @@ export interface SpreadGridBet {
|
||||
status: string;
|
||||
team: string;
|
||||
can_take: boolean;
|
||||
created_at?: string;
|
||||
matched_at?: string | null;
|
||||
}
|
||||
|
||||
export type SpreadGrid = {
|
||||
@ -51,3 +53,17 @@ export type SpreadGrid = {
|
||||
export interface SportEventWithBets extends SportEvent {
|
||||
spread_grid: SpreadGrid;
|
||||
}
|
||||
|
||||
export interface EventComment {
|
||||
id: number;
|
||||
event_id: number;
|
||||
user_id: number;
|
||||
username: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface EventCommentsResponse {
|
||||
comments: EventComment[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user