Added h2h communication.
This commit is contained in:
@ -2,7 +2,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from app.database import init_db
|
||||
from app.routers import auth, users, wallet, bets, websocket, admin, sport_events, spread_bets, gamification
|
||||
from app.routers import auth, users, wallet, bets, websocket, admin, sport_events, spread_bets, gamification, matches
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@ -39,6 +39,7 @@ app.include_router(admin.router)
|
||||
app.include_router(sport_events.router)
|
||||
app.include_router(spread_bets.router)
|
||||
app.include_router(gamification.router)
|
||||
app.include_router(matches.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@ -5,6 +5,7 @@ from app.models.bet import Bet, BetProposal, BetCategory, BetStatus, BetVisibili
|
||||
from app.models.sport_event import SportEvent, SportType, EventStatus
|
||||
from app.models.spread_bet import SpreadBet, SpreadBetStatus, TeamSide
|
||||
from app.models.admin_settings import AdminSettings
|
||||
from app.models.match_comment import MatchComment
|
||||
from app.models.gamification import (
|
||||
UserStats,
|
||||
Achievement,
|
||||
@ -38,6 +39,7 @@ __all__ = [
|
||||
"SpreadBetStatus",
|
||||
"TeamSide",
|
||||
"AdminSettings",
|
||||
"MatchComment",
|
||||
# Gamification
|
||||
"UserStats",
|
||||
"Achievement",
|
||||
|
||||
18
backend/app/models/match_comment.py
Normal file
18
backend/app/models/match_comment.py
Normal file
@ -0,0 +1,18 @@
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class MatchComment(Base):
|
||||
__tablename__ = "match_comments"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
spread_bet_id: Mapped[int] = mapped_column(ForeignKey("spread_bets.id"))
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
spread_bet: Mapped["SpreadBet"] = relationship()
|
||||
user: Mapped["User"] = relationship()
|
||||
@ -32,6 +32,28 @@ async def get_current_user(
|
||||
return user
|
||||
|
||||
|
||||
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user_optional(
|
||||
token: str = Depends(oauth2_scheme_optional),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get current user if authenticated, otherwise return None."""
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
user_id: str = payload.get("sub")
|
||||
if user_id is None:
|
||||
return None
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
user = await get_user_by_id(db, int(user_id))
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
user_data: UserCreate,
|
||||
|
||||
181
backend/app/routers/matches.py
Normal file
181
backend/app/routers/matches.py
Normal file
@ -0,0 +1,181 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import User, SpreadBet, MatchComment
|
||||
from app.models.spread_bet import SpreadBetStatus
|
||||
from app.schemas.match_comment import (
|
||||
MatchComment as MatchCommentSchema,
|
||||
MatchCommentCreate,
|
||||
MatchDetailResponse,
|
||||
MatchBetDetail,
|
||||
MatchUser
|
||||
)
|
||||
from app.routers.auth import get_current_user, get_current_user_optional
|
||||
from app.routers.websocket import broadcast_to_match
|
||||
|
||||
router = APIRouter(prefix="/api/v1/matches", tags=["matches"])
|
||||
|
||||
|
||||
async def get_match_bet(bet_id: int, db: AsyncSession) -> SpreadBet:
|
||||
"""Get a matched bet by ID with all relationships loaded."""
|
||||
result = await db.execute(
|
||||
select(SpreadBet)
|
||||
.options(
|
||||
selectinload(SpreadBet.event),
|
||||
selectinload(SpreadBet.creator),
|
||||
selectinload(SpreadBet.taker)
|
||||
)
|
||||
.where(SpreadBet.id == bet_id)
|
||||
)
|
||||
bet = result.scalar_one_or_none()
|
||||
if not bet:
|
||||
raise HTTPException(status_code=404, detail="Bet not found")
|
||||
if bet.status not in [SpreadBetStatus.MATCHED, SpreadBetStatus.COMPLETED]:
|
||||
raise HTTPException(status_code=400, detail="Bet is not matched")
|
||||
return bet
|
||||
|
||||
|
||||
@router.get("/{bet_id}", response_model=MatchDetailResponse)
|
||||
async def get_match_detail(
|
||||
bet_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional)
|
||||
):
|
||||
"""Get match details - public access, anyone can view."""
|
||||
bet = await get_match_bet(bet_id, db)
|
||||
|
||||
# Get comments
|
||||
comments_result = await db.execute(
|
||||
select(MatchComment)
|
||||
.options(selectinload(MatchComment.user))
|
||||
.where(MatchComment.spread_bet_id == bet_id)
|
||||
.order_by(MatchComment.created_at.asc())
|
||||
)
|
||||
comments = comments_result.scalars().all()
|
||||
|
||||
# Check if current user can comment
|
||||
can_comment = False
|
||||
if current_user:
|
||||
can_comment = current_user.id in [bet.creator_id, bet.taker_id]
|
||||
|
||||
return MatchDetailResponse(
|
||||
bet=MatchBetDetail(
|
||||
id=bet.id,
|
||||
event_id=bet.event_id,
|
||||
spread=bet.spread,
|
||||
team=bet.team,
|
||||
stake_amount=bet.stake_amount,
|
||||
house_commission_percent=bet.house_commission_percent,
|
||||
status=bet.status,
|
||||
payout_amount=bet.payout_amount,
|
||||
winner_id=bet.winner_id,
|
||||
created_at=bet.created_at,
|
||||
matched_at=bet.matched_at,
|
||||
completed_at=bet.completed_at,
|
||||
home_team=bet.event.home_team,
|
||||
away_team=bet.event.away_team,
|
||||
game_time=bet.event.game_time,
|
||||
official_spread=bet.event.official_spread,
|
||||
creator=MatchUser(id=bet.creator.id, username=bet.creator.username),
|
||||
taker=MatchUser(id=bet.taker.id, username=bet.taker.username) if bet.taker else None
|
||||
),
|
||||
comments=[
|
||||
MatchCommentSchema(
|
||||
id=c.id,
|
||||
spread_bet_id=c.spread_bet_id,
|
||||
user_id=c.user_id,
|
||||
username=c.user.username,
|
||||
content=c.content,
|
||||
created_at=c.created_at
|
||||
)
|
||||
for c in comments
|
||||
],
|
||||
can_comment=can_comment
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{bet_id}/comments", response_model=List[MatchCommentSchema])
|
||||
async def get_match_comments(
|
||||
bet_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get all comments for a match - public access."""
|
||||
# Verify bet exists and is matched
|
||||
await get_match_bet(bet_id, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(MatchComment)
|
||||
.options(selectinload(MatchComment.user))
|
||||
.where(MatchComment.spread_bet_id == bet_id)
|
||||
.order_by(MatchComment.created_at.asc())
|
||||
)
|
||||
comments = result.scalars().all()
|
||||
|
||||
return [
|
||||
MatchCommentSchema(
|
||||
id=c.id,
|
||||
spread_bet_id=c.spread_bet_id,
|
||||
user_id=c.user_id,
|
||||
username=c.user.username,
|
||||
content=c.content,
|
||||
created_at=c.created_at
|
||||
)
|
||||
for c in comments
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{bet_id}/comments", response_model=MatchCommentSchema)
|
||||
async def add_match_comment(
|
||||
bet_id: int,
|
||||
comment_data: MatchCommentCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Add a comment to a match - only participants can comment."""
|
||||
bet = await get_match_bet(bet_id, db)
|
||||
|
||||
# Check if user is a participant
|
||||
if current_user.id not in [bet.creator_id, bet.taker_id]:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only bet participants can comment"
|
||||
)
|
||||
|
||||
# Create comment
|
||||
comment = MatchComment(
|
||||
spread_bet_id=bet_id,
|
||||
user_id=current_user.id,
|
||||
content=comment_data.content
|
||||
)
|
||||
db.add(comment)
|
||||
await db.commit()
|
||||
await db.refresh(comment)
|
||||
|
||||
comment_response = MatchCommentSchema(
|
||||
id=comment.id,
|
||||
spread_bet_id=comment.spread_bet_id,
|
||||
user_id=comment.user_id,
|
||||
username=current_user.username,
|
||||
content=comment.content,
|
||||
created_at=comment.created_at
|
||||
)
|
||||
|
||||
# Broadcast new comment to match subscribers
|
||||
await broadcast_to_match(
|
||||
bet_id,
|
||||
"new_comment",
|
||||
{
|
||||
"id": comment.id,
|
||||
"spread_bet_id": comment.spread_bet_id,
|
||||
"user_id": comment.user_id,
|
||||
"username": current_user.username,
|
||||
"content": comment.content,
|
||||
"created_at": comment.created_at.isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
return comment_response
|
||||
@ -14,6 +14,9 @@ active_connections: Dict[str, WebSocket] = {}
|
||||
# Store connections subscribed to specific events
|
||||
event_subscriptions: Dict[int, Set[str]] = {} # event_id -> set of connection_ids
|
||||
|
||||
# Store connections subscribed to specific matches (bet_id)
|
||||
match_subscriptions: Dict[int, Set[str]] = {} # bet_id -> set of connection_ids
|
||||
|
||||
# Map connection_id to websocket
|
||||
connection_websockets: Dict[str, WebSocket] = {}
|
||||
|
||||
@ -22,7 +25,8 @@ connection_websockets: Dict[str, WebSocket] = {}
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
token: str = Query(...),
|
||||
event_id: Optional[int] = Query(None)
|
||||
event_id: Optional[int] = Query(None),
|
||||
match_id: Optional[int] = Query(None)
|
||||
):
|
||||
await websocket.accept()
|
||||
|
||||
@ -51,6 +55,13 @@ async def websocket_endpoint(
|
||||
event_subscriptions[event_id].add(connection_id)
|
||||
print(f"[WebSocket] Subscribed {connection_id} to event {event_id}. Total subscribers: {len(event_subscriptions[event_id])}")
|
||||
|
||||
# Subscribe to match if specified
|
||||
if match_id:
|
||||
if match_id not in match_subscriptions:
|
||||
match_subscriptions[match_id] = set()
|
||||
match_subscriptions[match_id].add(connection_id)
|
||||
print(f"[WebSocket] Subscribed {connection_id} to match {match_id}. Total subscribers: {len(match_subscriptions[match_id])}")
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
@ -68,6 +79,17 @@ async def websocket_endpoint(
|
||||
if eid in event_subscriptions:
|
||||
event_subscriptions[eid].discard(connection_id)
|
||||
print(f"[WebSocket] {connection_id} unsubscribed from event {eid}")
|
||||
elif msg.get('action') == 'subscribe_match' and msg.get('match_id'):
|
||||
mid = msg['match_id']
|
||||
if mid not in match_subscriptions:
|
||||
match_subscriptions[mid] = set()
|
||||
match_subscriptions[mid].add(connection_id)
|
||||
print(f"[WebSocket] {connection_id} subscribed to match {mid}")
|
||||
elif msg.get('action') == 'unsubscribe_match' and msg.get('match_id'):
|
||||
mid = msg['match_id']
|
||||
if mid in match_subscriptions:
|
||||
match_subscriptions[mid].discard(connection_id)
|
||||
print(f"[WebSocket] {connection_id} unsubscribed from match {mid}")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except WebSocketDisconnect:
|
||||
@ -84,6 +106,12 @@ async def websocket_endpoint(
|
||||
subs.discard(connection_id)
|
||||
print(f"[WebSocket] Removed {connection_id} from event {eid} subscriptions")
|
||||
|
||||
# Clean up match subscriptions
|
||||
for mid, subs in match_subscriptions.items():
|
||||
if connection_id in subs:
|
||||
subs.discard(connection_id)
|
||||
print(f"[WebSocket] Removed {connection_id} from match {mid} subscriptions")
|
||||
|
||||
|
||||
async def broadcast_to_event(event_id: int, event_type: str, data: dict):
|
||||
"""Broadcast a message to all connections subscribed to an event"""
|
||||
@ -136,3 +164,42 @@ async def broadcast_event(event_type: str, data: dict, user_ids: list[int] = Non
|
||||
await ws.send_text(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def broadcast_to_match(match_id: int, event_type: str, data: dict):
|
||||
"""Broadcast a message to all connections subscribed to a match"""
|
||||
message = json.dumps({
|
||||
"type": event_type,
|
||||
"data": {"match_id": match_id, **data}
|
||||
})
|
||||
|
||||
print(f"[WebSocket] Broadcasting {event_type} to match {match_id}")
|
||||
|
||||
if match_id not in match_subscriptions:
|
||||
print(f"[WebSocket] No subscribers for match {match_id}")
|
||||
return
|
||||
|
||||
subscribers = match_subscriptions[match_id].copy()
|
||||
print(f"[WebSocket] Found {len(subscribers)} subscribers for match {match_id}")
|
||||
|
||||
disconnected = set()
|
||||
for conn_id in subscribers:
|
||||
ws = connection_websockets.get(conn_id)
|
||||
if ws:
|
||||
try:
|
||||
await ws.send_text(message)
|
||||
print(f"[WebSocket] Sent message to {conn_id}")
|
||||
except Exception as e:
|
||||
print(f"[WebSocket] Failed to send to {conn_id}: {e}")
|
||||
disconnected.add(conn_id)
|
||||
else:
|
||||
print(f"[WebSocket] Connection {conn_id} not found in websockets map")
|
||||
disconnected.add(conn_id)
|
||||
|
||||
# Clean up disconnected connections
|
||||
for conn_id in disconnected:
|
||||
match_subscriptions[match_id].discard(conn_id)
|
||||
if conn_id in active_connections:
|
||||
del active_connections[conn_id]
|
||||
if conn_id in connection_websockets:
|
||||
del connection_websockets[conn_id]
|
||||
|
||||
58
backend/app/schemas/match_comment.py
Normal file
58
backend/app/schemas/match_comment.py
Normal file
@ -0,0 +1,58 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional, List
|
||||
from app.models.spread_bet import TeamSide, SpreadBetStatus
|
||||
|
||||
|
||||
class MatchCommentCreate(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=500)
|
||||
|
||||
|
||||
class MatchComment(BaseModel):
|
||||
id: int
|
||||
spread_bet_id: int
|
||||
user_id: int
|
||||
username: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MatchUser(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
|
||||
|
||||
class MatchBetDetail(BaseModel):
|
||||
id: int
|
||||
event_id: int
|
||||
spread: float
|
||||
team: TeamSide
|
||||
stake_amount: Decimal
|
||||
house_commission_percent: Decimal
|
||||
status: SpreadBetStatus
|
||||
payout_amount: Optional[Decimal] = None
|
||||
winner_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
matched_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
# Event info
|
||||
home_team: str
|
||||
away_team: str
|
||||
game_time: datetime
|
||||
official_spread: float
|
||||
# User info
|
||||
creator: MatchUser
|
||||
taker: Optional[MatchUser] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MatchDetailResponse(BaseModel):
|
||||
bet: MatchBetDetail
|
||||
comments: List[MatchComment]
|
||||
can_comment: bool # True if current user is creator or taker
|
||||
@ -19,6 +19,7 @@ import { NewBets } from './pages/NewBets'
|
||||
import { Watchlist } from './pages/Watchlist'
|
||||
import { HowItWorks } from './pages/HowItWorks'
|
||||
import { EventDetail } from './pages/EventDetail'
|
||||
import { MatchDetail } from './pages/MatchDetail'
|
||||
import {
|
||||
RewardsIndex,
|
||||
LeaderboardPage,
|
||||
@ -76,6 +77,7 @@ function App() {
|
||||
<Route path="/watchlist" element={<Watchlist />} />
|
||||
<Route path="/how-it-works" element={<HowItWorks />} />
|
||||
<Route path="/events/:id" element={<EventDetail />} />
|
||||
<Route path="/matches/:id" element={<MatchDetail />} />
|
||||
|
||||
{/* Rewards Routes */}
|
||||
<Route path="/rewards" element={<RewardsIndex />} />
|
||||
|
||||
19
frontend/src/api/matches.ts
Normal file
19
frontend/src/api/matches.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { apiClient } from './client'
|
||||
import type { MatchDetailResponse, MatchComment, MatchCommentCreate } from '@/types/match'
|
||||
|
||||
export const matchesApi = {
|
||||
getMatch: async (betId: number): Promise<MatchDetailResponse> => {
|
||||
const response = await apiClient.get<MatchDetailResponse>(`/api/v1/matches/${betId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getComments: async (betId: number): Promise<MatchComment[]> => {
|
||||
const response = await apiClient.get<MatchComment[]>(`/api/v1/matches/${betId}/comments`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
addComment: async (betId: number, data: MatchCommentCreate): Promise<MatchComment> => {
|
||||
const response = await apiClient.post<MatchComment>(`/api/v1/matches/${betId}/comments`, data)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
@ -5,7 +5,7 @@ import { useAuthStore } from '@/store'
|
||||
import { spreadBetsApi } from '@/api/spread-bets'
|
||||
import type { SpreadBetDetail } from '@/types/spread-bet'
|
||||
import { SpreadBetStatus } from '@/types/spread-bet'
|
||||
import { ExternalLink, Trophy, XCircle } from 'lucide-react'
|
||||
import { ExternalLink, Trophy, XCircle, MessageCircle } from 'lucide-react'
|
||||
|
||||
interface MyOtherBetsProps {
|
||||
currentEventId?: number
|
||||
@ -125,6 +125,17 @@ export const MyOtherBets = ({ currentEventId }: MyOtherBetsProps) => {
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
{(bet.status === SpreadBetStatus.MATCHED || bet.status === SpreadBetStatus.COMPLETED) && (
|
||||
<Link
|
||||
to={`/matches/${bet.id}`}
|
||||
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-700 text-xs font-medium"
|
||||
>
|
||||
<MessageCircle size={14} />
|
||||
Chat
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@ -202,6 +213,7 @@ export const MyOtherBets = ({ currentEventId }: MyOtherBetsProps) => {
|
||||
<th className="px-4 py-3 text-right">Stake</th>
|
||||
<th className="px-4 py-3 text-center">Status</th>
|
||||
<th className="px-4 py-3 text-right">Result</th>
|
||||
<th className="px-4 py-3 text-center"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
|
||||
310
frontend/src/pages/MatchDetail.tsx
Normal file
310
frontend/src/pages/MatchDetail.tsx
Normal file
@ -0,0 +1,310 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAuthStore } from '@/store'
|
||||
import { matchesApi } from '@/api/matches'
|
||||
import { Button } from '@/components/common/Button'
|
||||
import { Loading } from '@/components/common/Loading'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { ChevronLeft, Send, User } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import toast from 'react-hot-toast'
|
||||
import type { MatchComment } from '@/types/match'
|
||||
import { TeamSide, SpreadBetStatus } from '@/types/spread-bet'
|
||||
import { WS_URL } from '@/utils/constants'
|
||||
|
||||
export const MatchDetail = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const betId = parseInt(id || '0', 10)
|
||||
const { isAuthenticated, user } = useAuthStore()
|
||||
const queryClient = useQueryClient()
|
||||
const [newComment, setNewComment] = useState('')
|
||||
const commentsEndRef = useRef<HTMLDivElement>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
|
||||
const { data: match, isLoading, error } = useQuery({
|
||||
queryKey: ['match', betId],
|
||||
queryFn: () => matchesApi.getMatch(betId),
|
||||
enabled: betId > 0,
|
||||
})
|
||||
|
||||
const addCommentMutation = useMutation({
|
||||
mutationFn: (content: string) => matchesApi.addComment(betId, { content }),
|
||||
onSuccess: () => {
|
||||
setNewComment('')
|
||||
queryClient.invalidateQueries({ queryKey: ['match', betId] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.detail || 'Failed to add comment')
|
||||
},
|
||||
})
|
||||
|
||||
// WebSocket for real-time comments
|
||||
useEffect(() => {
|
||||
if (!betId) return
|
||||
|
||||
const token = localStorage.getItem('token') || 'guest'
|
||||
const ws = new WebSocket(`${WS_URL}/api/v1/ws?token=${token}&match_id=${betId}`)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[MatchDetail] WebSocket connected for match', betId)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'new_comment' && msg.data.match_id === betId) {
|
||||
// Refetch comments on new message
|
||||
queryClient.invalidateQueries({ queryKey: ['match', betId] })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[MatchDetail] Failed to parse WebSocket message:', e)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.error('[MatchDetail] WebSocket error:', e)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[MatchDetail] WebSocket disconnected')
|
||||
}
|
||||
|
||||
wsRef.current = ws
|
||||
|
||||
return () => {
|
||||
ws.close()
|
||||
}
|
||||
}, [betId, queryClient])
|
||||
|
||||
// Scroll to bottom when comments change
|
||||
useEffect(() => {
|
||||
commentsEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [match?.comments])
|
||||
|
||||
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">
|
||||
<Loading />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !match) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Link to="/" className="inline-flex items-center text-blue-600 hover:text-blue-700 mb-6">
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Back
|
||||
</Link>
|
||||
<div className="text-center py-12 bg-white rounded-lg shadow">
|
||||
<p className="text-gray-500">Match not found or not yet matched</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { bet, comments, can_comment } = match
|
||||
const isCreator = user?.id === bet.creator.id
|
||||
const isTaker = bet.taker && user?.id === bet.taker.id
|
||||
|
||||
const getStatusColor = (status: SpreadBetStatus) => {
|
||||
switch (status) {
|
||||
case SpreadBetStatus.MATCHED:
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
case SpreadBetStatus.COMPLETED:
|
||||
return 'bg-green-100 text-green-800'
|
||||
case SpreadBetStatus.DISPUTED:
|
||||
return 'bg-red-100 text-red-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
const formatSpread = (spread: number, team: TeamSide) => {
|
||||
const sign = spread > 0 ? '+' : ''
|
||||
const teamName = team === TeamSide.HOME ? bet.home_team : bet.away_team
|
||||
return `${teamName} ${sign}${spread}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-8 max-w-4xl mx-auto">
|
||||
<Link to="/" className="inline-flex items-center text-blue-600 hover:text-blue-700 mb-6">
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Back to Events
|
||||
</Link>
|
||||
|
||||
{/* Match Header */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6 mb-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{bet.home_team} vs {bet.away_team}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{format(new Date(bet.game_time), 'PPpp')}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(bet.status)}`}>
|
||||
{bet.status.charAt(0).toUpperCase() + bet.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bet Details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-500 mb-1">Spread</p>
|
||||
<p className="text-lg font-semibold">{formatSpread(bet.spread, bet.team)}</p>
|
||||
<p className="text-xs text-gray-400">Official: {bet.official_spread}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-500 mb-1">Stake (each)</p>
|
||||
<p className="text-lg font-semibold">${Number(bet.stake_amount).toFixed(2)}</p>
|
||||
<p className="text-xs text-gray-400">Total pot: ${(Number(bet.stake_amount) * 2).toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-500 mb-1">Winner Payout</p>
|
||||
<p className="text-lg font-semibold text-green-600">
|
||||
${(Number(bet.stake_amount) * 2 * (1 - Number(bet.house_commission_percent) / 100)).toFixed(2)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{bet.house_commission_percent}% commission</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Participants */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Participants</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Creator */}
|
||||
<div className={`rounded-lg p-4 border-2 ${isCreator ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-gray-50'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{bet.creator.username}
|
||||
{isCreator && <span className="ml-2 text-xs text-blue-600">(You)</span>}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Creator - {formatSpread(bet.spread, bet.team)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Taker */}
|
||||
<div className={`rounded-lg p-4 border-2 ${isTaker ? 'border-green-500 bg-green-50' : 'border-gray-200 bg-gray-50'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-green-500 flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{bet.taker?.username || 'Pending...'}
|
||||
{isTaker && <span className="ml-2 text-xs text-green-600">(You)</span>}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Taker - {formatSpread(-bet.spread, bet.team === TeamSide.HOME ? TeamSide.AWAY : TeamSide.HOME)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments Section */}
|
||||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<div className="p-4 border-b bg-gray-50">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Comments</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{can_comment ? 'Chat with your opponent' : 'Only participants can comment'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Comments List */}
|
||||
<div className="h-80 overflow-y-auto p-4 space-y-4">
|
||||
{comments.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No comments yet. {can_comment && 'Start the conversation!'}
|
||||
</div>
|
||||
) : (
|
||||
comments.map((comment: MatchComment) => {
|
||||
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 */}
|
||||
{can_comment ? (
|
||||
<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>
|
||||
) : isAuthenticated ? (
|
||||
<div className="p-4 border-t bg-gray-50 text-center text-sm text-gray-500">
|
||||
Only bet participants can post comments
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 border-t bg-gray-50 text-center text-sm text-gray-500">
|
||||
<Link to="/login" className="text-blue-600 hover:underline">Log in</Link> to view if you can comment
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
frontend/src/types/match.ts
Normal file
48
frontend/src/types/match.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { TeamSide, SpreadBetStatus } from './spread-bet'
|
||||
|
||||
export interface MatchUser {
|
||||
id: number
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface MatchComment {
|
||||
id: number
|
||||
spread_bet_id: number
|
||||
user_id: number
|
||||
username: string
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface MatchCommentCreate {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface MatchBetDetail {
|
||||
id: number
|
||||
event_id: number
|
||||
spread: number
|
||||
team: TeamSide
|
||||
stake_amount: number
|
||||
house_commission_percent: number
|
||||
status: SpreadBetStatus
|
||||
payout_amount: number | null
|
||||
winner_id: number | null
|
||||
created_at: string
|
||||
matched_at: string | null
|
||||
completed_at: string | null
|
||||
// Event info
|
||||
home_team: string
|
||||
away_team: string
|
||||
game_time: string
|
||||
official_spread: number
|
||||
// User info
|
||||
creator: MatchUser
|
||||
taker: MatchUser | null
|
||||
}
|
||||
|
||||
export interface MatchDetailResponse {
|
||||
bet: MatchBetDetail
|
||||
comments: MatchComment[]
|
||||
can_comment: boolean
|
||||
}
|
||||
Reference in New Issue
Block a user