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,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
},
}

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

View File

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

View File

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

View File

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