From e0af1830861b1791ac01f6edd2484ab024f39f68 Mon Sep 17 00:00:00 2001 From: "William D. Jones" Date: Sun, 11 Jan 2026 11:25:33 -0600 Subject: [PATCH] Added h2h communication. --- backend/app/main.py | 3 +- backend/app/models/__init__.py | 2 + backend/app/models/match_comment.py | 18 ++ backend/app/routers/auth.py | 22 ++ backend/app/routers/matches.py | 181 +++++++++++ backend/app/routers/websocket.py | 69 ++++- backend/app/schemas/match_comment.py | 58 ++++ frontend/src/App.tsx | 2 + frontend/src/api/matches.ts | 19 ++ frontend/src/components/bets/MyOtherBets.tsx | 14 +- frontend/src/pages/MatchDetail.tsx | 310 +++++++++++++++++++ frontend/src/types/match.ts | 48 +++ 12 files changed, 743 insertions(+), 3 deletions(-) create mode 100644 backend/app/models/match_comment.py create mode 100644 backend/app/routers/matches.py create mode 100644 backend/app/schemas/match_comment.py create mode 100644 frontend/src/api/matches.ts create mode 100644 frontend/src/pages/MatchDetail.tsx create mode 100644 frontend/src/types/match.ts diff --git a/backend/app/main.py b/backend/app/main.py index b98498c..3710672 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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("/") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 55b60a3..bcbfcf8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/match_comment.py b/backend/app/models/match_comment.py new file mode 100644 index 0000000..6df2cf7 --- /dev/null +++ b/backend/app/models/match_comment.py @@ -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() diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 7784a40..da04502 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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, diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py new file mode 100644 index 0000000..36ed4f7 --- /dev/null +++ b/backend/app/routers/matches.py @@ -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 diff --git a/backend/app/routers/websocket.py b/backend/app/routers/websocket.py index ec6c945..c63e320 100644 --- a/backend/app/routers/websocket.py +++ b/backend/app/routers/websocket.py @@ -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] diff --git a/backend/app/schemas/match_comment.py b/backend/app/schemas/match_comment.py new file mode 100644 index 0000000..14122e2 --- /dev/null +++ b/backend/app/schemas/match_comment.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6551439..eae8902 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> {/* Rewards Routes */} } /> diff --git a/frontend/src/api/matches.ts b/frontend/src/api/matches.ts new file mode 100644 index 0000000..f63ba96 --- /dev/null +++ b/frontend/src/api/matches.ts @@ -0,0 +1,19 @@ +import { apiClient } from './client' +import type { MatchDetailResponse, MatchComment, MatchCommentCreate } from '@/types/match' + +export const matchesApi = { + getMatch: async (betId: number): Promise => { + const response = await apiClient.get(`/api/v1/matches/${betId}`) + return response.data + }, + + getComments: async (betId: number): Promise => { + const response = await apiClient.get(`/api/v1/matches/${betId}/comments`) + return response.data + }, + + addComment: async (betId: number, data: MatchCommentCreate): Promise => { + const response = await apiClient.post(`/api/v1/matches/${betId}/comments`, data) + return response.data + }, +} diff --git a/frontend/src/components/bets/MyOtherBets.tsx b/frontend/src/components/bets/MyOtherBets.tsx index 416fbd7..0fe517d 100644 --- a/frontend/src/components/bets/MyOtherBets.tsx +++ b/frontend/src/components/bets/MyOtherBets.tsx @@ -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) => { - )} + + {(bet.status === SpreadBetStatus.MATCHED || bet.status === SpreadBetStatus.COMPLETED) && ( + + + Chat + + )} + ) } @@ -202,6 +213,7 @@ export const MyOtherBets = ({ currentEventId }: MyOtherBetsProps) => { Stake Status Result + diff --git a/frontend/src/pages/MatchDetail.tsx b/frontend/src/pages/MatchDetail.tsx new file mode 100644 index 0000000..dd80524 --- /dev/null +++ b/frontend/src/pages/MatchDetail.tsx @@ -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(null) + const wsRef = useRef(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 ( +
+
+
+ +
+
+ ) + } + + if (error || !match) { + return ( +
+
+
+ + + Back + +
+

Match not found or not yet matched

+
+
+
+ ) + } + + 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 ( +
+
+
+ + + Back to Events + + + {/* Match Header */} +
+
+
+

+ {bet.home_team} vs {bet.away_team} +

+

+ {format(new Date(bet.game_time), 'PPpp')} +

+
+ + {bet.status.charAt(0).toUpperCase() + bet.status.slice(1)} + +
+ + {/* Bet Details */} +
+
+

Spread

+

{formatSpread(bet.spread, bet.team)}

+

Official: {bet.official_spread}

+
+
+

Stake (each)

+

${Number(bet.stake_amount).toFixed(2)}

+

Total pot: ${(Number(bet.stake_amount) * 2).toFixed(2)}

+
+
+

Winner Payout

+

+ ${(Number(bet.stake_amount) * 2 * (1 - Number(bet.house_commission_percent) / 100)).toFixed(2)} +

+

{bet.house_commission_percent}% commission

+
+
+
+ + {/* Participants */} +
+

Participants

+
+ {/* Creator */} +
+
+
+ +
+
+

+ {bet.creator.username} + {isCreator && (You)} +

+

Creator - {formatSpread(bet.spread, bet.team)}

+
+
+
+ + {/* Taker */} +
+
+
+ +
+
+

+ {bet.taker?.username || 'Pending...'} + {isTaker && (You)} +

+

+ Taker - {formatSpread(-bet.spread, bet.team === TeamSide.HOME ? TeamSide.AWAY : TeamSide.HOME)} +

+
+
+
+
+
+ + {/* Comments Section */} +
+
+

Comments

+

+ {can_comment ? 'Chat with your opponent' : 'Only participants can comment'} +

+
+ + {/* Comments List */} +
+ {comments.length === 0 ? ( +
+ No comments yet. {can_comment && 'Start the conversation!'} +
+ ) : ( + comments.map((comment: MatchComment) => { + const isOwnComment = user?.id === comment.user_id + return ( +
+
+
+ + {comment.username} + + + {format(new Date(comment.created_at), 'p')} + +
+

{comment.content}

+
+
+ ) + }) + )} +
+
+ + {/* Comment Input */} + {can_comment ? ( +
+
+ 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" + /> + +
+
+ ) : isAuthenticated ? ( +
+ Only bet participants can post comments +
+ ) : ( +
+ Log in to view if you can comment +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/types/match.ts b/frontend/src/types/match.ts new file mode 100644 index 0000000..4b13d90 --- /dev/null +++ b/frontend/src/types/match.ts @@ -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 +}