Event layout page update.
This commit is contained in:
@ -6,6 +6,7 @@ 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.event_comment import EventComment
|
||||
from app.models.gamification import (
|
||||
UserStats,
|
||||
Achievement,
|
||||
@ -40,6 +41,7 @@ __all__ = [
|
||||
"TeamSide",
|
||||
"AdminSettings",
|
||||
"MatchComment",
|
||||
"EventComment",
|
||||
# Gamification
|
||||
"UserStats",
|
||||
"Achievement",
|
||||
|
||||
18
backend/app/models/event_comment.py
Normal file
18
backend/app/models/event_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 EventComment(Base):
|
||||
__tablename__ = "event_comments"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
event_id: Mapped[int] = mapped_column(ForeignKey("sport_events.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
|
||||
event: Mapped["SportEvent"] = relationship()
|
||||
user: Mapped["User"] = relationship()
|
||||
@ -6,9 +6,11 @@ from typing import List
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import User, SportEvent, SpreadBet, AdminSettings, EventStatus, SpreadBetStatus, TeamSide
|
||||
from app.models import User, SportEvent, SpreadBet, AdminSettings, EventStatus, SpreadBetStatus, TeamSide, EventComment
|
||||
from app.schemas.sport_event import SportEvent as SportEventSchema, SportEventWithBets
|
||||
from app.schemas.event_comment import EventComment as EventCommentSchema, EventCommentCreate, EventCommentsResponse
|
||||
from app.routers.auth import get_current_user
|
||||
from app.routers.websocket import broadcast_to_event
|
||||
|
||||
router = APIRouter(prefix="/api/v1/sport-events", tags=["sport-events"])
|
||||
|
||||
@ -220,3 +222,104 @@ async def get_event_with_grid(
|
||||
"updated_at": event.updated_at.isoformat(),
|
||||
"spread_grid": spread_grid
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{event_id}/comments", response_model=EventCommentsResponse)
|
||||
async def get_event_comments(
|
||||
event_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get comments for an event - public access."""
|
||||
# Verify event exists
|
||||
event_result = await db.execute(
|
||||
select(SportEvent).where(SportEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
# Get comments with user info
|
||||
result = await db.execute(
|
||||
select(EventComment)
|
||||
.options(selectinload(EventComment.user))
|
||||
.where(EventComment.event_id == event_id)
|
||||
.order_by(EventComment.created_at.asc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
comments = result.scalars().all()
|
||||
|
||||
# Get total count
|
||||
count_result = await db.execute(
|
||||
select(EventComment).where(EventComment.event_id == event_id)
|
||||
)
|
||||
total = len(count_result.scalars().all())
|
||||
|
||||
return EventCommentsResponse(
|
||||
comments=[
|
||||
EventCommentSchema(
|
||||
id=c.id,
|
||||
event_id=c.event_id,
|
||||
user_id=c.user_id,
|
||||
username=c.user.username,
|
||||
content=c.content,
|
||||
created_at=c.created_at
|
||||
)
|
||||
for c in comments
|
||||
],
|
||||
total=total
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{event_id}/comments", response_model=EventCommentSchema)
|
||||
async def add_event_comment(
|
||||
event_id: int,
|
||||
comment_data: EventCommentCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Add a comment to an event - requires authentication."""
|
||||
# Verify event exists
|
||||
event_result = await db.execute(
|
||||
select(SportEvent).where(SportEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
# Create comment
|
||||
comment = EventComment(
|
||||
event_id=event_id,
|
||||
user_id=current_user.id,
|
||||
content=comment_data.content
|
||||
)
|
||||
db.add(comment)
|
||||
await db.commit()
|
||||
await db.refresh(comment)
|
||||
|
||||
comment_response = EventCommentSchema(
|
||||
id=comment.id,
|
||||
event_id=comment.event_id,
|
||||
user_id=comment.user_id,
|
||||
username=current_user.username,
|
||||
content=comment.content,
|
||||
created_at=comment.created_at
|
||||
)
|
||||
|
||||
# Broadcast new comment to event subscribers
|
||||
await broadcast_to_event(
|
||||
event_id,
|
||||
"new_comment",
|
||||
{
|
||||
"id": comment.id,
|
||||
"event_id": comment.event_id,
|
||||
"user_id": comment.user_id,
|
||||
"username": current_user.username,
|
||||
"content": comment.content,
|
||||
"created_at": comment.created_at.isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
return comment_response
|
||||
|
||||
24
backend/app/schemas/event_comment.py
Normal file
24
backend/app/schemas/event_comment.py
Normal file
@ -0,0 +1,24 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
|
||||
class EventCommentCreate(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=500)
|
||||
|
||||
|
||||
class EventComment(BaseModel):
|
||||
id: int
|
||||
event_id: int
|
||||
user_id: int
|
||||
username: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class EventCommentsResponse(BaseModel):
|
||||
comments: List[EventComment]
|
||||
total: int
|
||||
546
backend/manage_events.py
Executable file
546
backend/manage_events.py
Executable file
@ -0,0 +1,546 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Event Management Script
|
||||
Manages sport events including:
|
||||
- Creating new upcoming events
|
||||
- Updating events to LIVE status
|
||||
- Simulating live score updates
|
||||
- Completing events and settling bets
|
||||
|
||||
Usage:
|
||||
python manage_events.py [--create N] [--update] [--settle] [--continuous] [--delay SECONDS]
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import argparse
|
||||
import random
|
||||
from decimal import Decimal
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import async_session, init_db
|
||||
from app.models import (
|
||||
User, Wallet, SportEvent, SpreadBet, Transaction,
|
||||
SportType, EventStatus, SpreadBetStatus, TeamSide,
|
||||
TransactionType, TransactionStatus
|
||||
)
|
||||
|
||||
# Team data by sport
|
||||
TEAMS = {
|
||||
SportType.FOOTBALL: {
|
||||
"NFL": [
|
||||
("Kansas City Chiefs", "Arrowhead Stadium"),
|
||||
("San Francisco 49ers", "Levi's Stadium"),
|
||||
("Philadelphia Eagles", "Lincoln Financial Field"),
|
||||
("Dallas Cowboys", "AT&T Stadium"),
|
||||
("Buffalo Bills", "Highmark Stadium"),
|
||||
("Miami Dolphins", "Hard Rock Stadium"),
|
||||
("Baltimore Ravens", "M&T Bank Stadium"),
|
||||
("Detroit Lions", "Ford Field"),
|
||||
("Green Bay Packers", "Lambeau Field"),
|
||||
("Seattle Seahawks", "Lumen Field"),
|
||||
("New York Giants", "MetLife Stadium"),
|
||||
("Los Angeles Rams", "SoFi Stadium"),
|
||||
("Cincinnati Bengals", "Paycor Stadium"),
|
||||
("Jacksonville Jaguars", "TIAA Bank Field"),
|
||||
("Minnesota Vikings", "U.S. Bank Stadium"),
|
||||
("New Orleans Saints", "Caesars Superdome"),
|
||||
],
|
||||
"NCAA": [
|
||||
("Alabama Crimson Tide", "Bryant-Denny Stadium"),
|
||||
("Georgia Bulldogs", "Sanford Stadium"),
|
||||
("Ohio State Buckeyes", "Ohio Stadium"),
|
||||
("Michigan Wolverines", "Michigan Stadium"),
|
||||
("Texas Longhorns", "Darrell K Royal Stadium"),
|
||||
("USC Trojans", "Los Angeles Memorial Coliseum"),
|
||||
("Clemson Tigers", "Memorial Stadium"),
|
||||
("Penn State Nittany Lions", "Beaver Stadium"),
|
||||
("Oregon Ducks", "Autzen Stadium"),
|
||||
("Florida State Seminoles", "Doak Campbell Stadium"),
|
||||
]
|
||||
},
|
||||
SportType.BASKETBALL: {
|
||||
"NBA": [
|
||||
("Los Angeles Lakers", "Crypto.com Arena"),
|
||||
("Boston Celtics", "TD Garden"),
|
||||
("Golden State Warriors", "Chase Center"),
|
||||
("Milwaukee Bucks", "Fiserv Forum"),
|
||||
("Phoenix Suns", "Footprint Center"),
|
||||
("Denver Nuggets", "Ball Arena"),
|
||||
("Miami Heat", "Kaseya Center"),
|
||||
("Philadelphia 76ers", "Wells Fargo Center"),
|
||||
("Brooklyn Nets", "Barclays Center"),
|
||||
("Dallas Mavericks", "American Airlines Center"),
|
||||
("New York Knicks", "Madison Square Garden"),
|
||||
("Cleveland Cavaliers", "Rocket Mortgage FieldHouse"),
|
||||
],
|
||||
"NCAA": [
|
||||
("Duke Blue Devils", "Cameron Indoor Stadium"),
|
||||
("Kentucky Wildcats", "Rupp Arena"),
|
||||
("Kansas Jayhawks", "Allen Fieldhouse"),
|
||||
("North Carolina Tar Heels", "Dean E. Smith Center"),
|
||||
("UCLA Bruins", "Pauley Pavilion"),
|
||||
("Gonzaga Bulldogs", "McCarthey Athletic Center"),
|
||||
("Villanova Wildcats", "Finneran Pavilion"),
|
||||
("Arizona Wildcats", "McKale Center"),
|
||||
]
|
||||
},
|
||||
SportType.BASEBALL: {
|
||||
"MLB": [
|
||||
("New York Yankees", "Yankee Stadium"),
|
||||
("Los Angeles Dodgers", "Dodger Stadium"),
|
||||
("Boston Red Sox", "Fenway Park"),
|
||||
("Chicago Cubs", "Wrigley Field"),
|
||||
("Atlanta Braves", "Truist Park"),
|
||||
("Houston Astros", "Minute Maid Park"),
|
||||
("San Diego Padres", "Petco Park"),
|
||||
("Philadelphia Phillies", "Citizens Bank Park"),
|
||||
("Texas Rangers", "Globe Life Field"),
|
||||
("San Francisco Giants", "Oracle Park"),
|
||||
]
|
||||
},
|
||||
SportType.HOCKEY: {
|
||||
"NHL": [
|
||||
("Vegas Golden Knights", "T-Mobile Arena"),
|
||||
("Florida Panthers", "Amerant Bank Arena"),
|
||||
("Edmonton Oilers", "Rogers Place"),
|
||||
("Dallas Stars", "American Airlines Center"),
|
||||
("Colorado Avalanche", "Ball Arena"),
|
||||
("New York Rangers", "Madison Square Garden"),
|
||||
("Boston Bruins", "TD Garden"),
|
||||
("Carolina Hurricanes", "PNC Arena"),
|
||||
("Toronto Maple Leafs", "Scotiabank Arena"),
|
||||
("Tampa Bay Lightning", "Amalie Arena"),
|
||||
]
|
||||
},
|
||||
SportType.SOCCER: {
|
||||
"EPL": [
|
||||
("Manchester City", "Etihad Stadium"),
|
||||
("Arsenal", "Emirates Stadium"),
|
||||
("Liverpool", "Anfield"),
|
||||
("Manchester United", "Old Trafford"),
|
||||
("Chelsea", "Stamford Bridge"),
|
||||
("Tottenham Hotspur", "Tottenham Hotspur Stadium"),
|
||||
("Newcastle United", "St. James' Park"),
|
||||
("Brighton", "American Express Stadium"),
|
||||
],
|
||||
"MLS": [
|
||||
("Inter Miami", "Chase Stadium"),
|
||||
("LA Galaxy", "Dignity Health Sports Park"),
|
||||
("LAFC", "BMO Stadium"),
|
||||
("Atlanta United", "Mercedes-Benz Stadium"),
|
||||
("Seattle Sounders", "Lumen Field"),
|
||||
("New York Red Bulls", "Red Bull Arena"),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_random_matchup(sport: SportType) -> tuple:
|
||||
"""Get a random matchup for a sport."""
|
||||
leagues = TEAMS.get(sport, {})
|
||||
if not leagues:
|
||||
return None
|
||||
|
||||
league = random.choice(list(leagues.keys()))
|
||||
teams = leagues[league]
|
||||
|
||||
# Pick two different teams
|
||||
home_team, home_venue = random.choice(teams)
|
||||
away_team, _ = random.choice([t for t in teams if t[0] != home_team])
|
||||
|
||||
return {
|
||||
"sport": sport,
|
||||
"league": league,
|
||||
"home_team": home_team,
|
||||
"away_team": away_team,
|
||||
"venue": home_venue
|
||||
}
|
||||
|
||||
|
||||
def generate_spread(sport: SportType) -> float:
|
||||
"""Generate a realistic spread for a sport."""
|
||||
if sport == SportType.FOOTBALL:
|
||||
# NFL/NCAA spreads typically -14 to +14
|
||||
spread = round(random.uniform(-10, 10) * 2) / 2
|
||||
elif sport == SportType.BASKETBALL:
|
||||
# NBA spreads can be larger
|
||||
spread = round(random.uniform(-12, 12) * 2) / 2
|
||||
elif sport == SportType.BASEBALL:
|
||||
# Baseball run lines are typically 1.5
|
||||
spread = random.choice([-1.5, 1.5, -2.5, 2.5])
|
||||
elif sport == SportType.HOCKEY:
|
||||
# Hockey puck lines are typically 1.5
|
||||
spread = random.choice([-1.5, 1.5, -2.5, 2.5])
|
||||
elif sport == SportType.SOCCER:
|
||||
# Soccer spreads are typically small
|
||||
spread = random.choice([-0.5, 0.5, -1, 1, -1.5, 1.5])
|
||||
else:
|
||||
spread = round(random.uniform(-7, 7) * 2) / 2
|
||||
|
||||
return spread
|
||||
|
||||
|
||||
async def create_new_events(db, admin_user_id: int, count: int = 5) -> list[SportEvent]:
|
||||
"""Create new upcoming events."""
|
||||
events = []
|
||||
|
||||
for _ in range(count):
|
||||
sport = random.choice(list(SportType))
|
||||
matchup = get_random_matchup(sport)
|
||||
|
||||
if not matchup:
|
||||
continue
|
||||
|
||||
# Random game time in the next 1-7 days
|
||||
hours_ahead = random.randint(1, 168) # 1 hour to 7 days
|
||||
game_time = datetime.utcnow() + timedelta(hours=hours_ahead)
|
||||
|
||||
spread = generate_spread(sport)
|
||||
|
||||
event = SportEvent(
|
||||
sport=matchup["sport"],
|
||||
home_team=matchup["home_team"],
|
||||
away_team=matchup["away_team"],
|
||||
official_spread=spread,
|
||||
game_time=game_time,
|
||||
venue=matchup["venue"],
|
||||
league=matchup["league"],
|
||||
min_spread=spread - 5,
|
||||
max_spread=spread + 5,
|
||||
min_bet_amount=10.0,
|
||||
max_bet_amount=1000.0,
|
||||
status=EventStatus.UPCOMING,
|
||||
created_by=admin_user_id
|
||||
)
|
||||
db.add(event)
|
||||
events.append(event)
|
||||
|
||||
await db.commit()
|
||||
|
||||
for event in events:
|
||||
print(f" [NEW] {event.home_team} vs {event.away_team} ({event.league}) - {event.game_time.strftime('%m/%d %H:%M')}")
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def update_events_to_live(db) -> list[SportEvent]:
|
||||
"""Update events that should be live (game time has passed)."""
|
||||
# Find upcoming events where game time has passed
|
||||
result = await db.execute(
|
||||
select(SportEvent).where(
|
||||
and_(
|
||||
SportEvent.status == EventStatus.UPCOMING,
|
||||
SportEvent.game_time <= datetime.utcnow()
|
||||
)
|
||||
)
|
||||
)
|
||||
events = result.scalars().all()
|
||||
|
||||
for event in events:
|
||||
event.status = EventStatus.LIVE
|
||||
event.final_score_home = 0
|
||||
event.final_score_away = 0
|
||||
print(f" [LIVE] {event.home_team} vs {event.away_team} is now LIVE!")
|
||||
|
||||
await db.commit()
|
||||
return list(events)
|
||||
|
||||
|
||||
async def update_live_scores(db) -> list[SportEvent]:
|
||||
"""Update scores for live events."""
|
||||
result = await db.execute(
|
||||
select(SportEvent).where(SportEvent.status == EventStatus.LIVE)
|
||||
)
|
||||
events = result.scalars().all()
|
||||
|
||||
for event in events:
|
||||
# Randomly add points based on sport
|
||||
if event.sport == SportType.FOOTBALL:
|
||||
# Football scoring: 3 or 7 points typically
|
||||
if random.random() < 0.3:
|
||||
scorer = random.choice(['home', 'away'])
|
||||
points = random.choice([3, 7, 6, 2])
|
||||
if scorer == 'home':
|
||||
event.final_score_home = (event.final_score_home or 0) + points
|
||||
else:
|
||||
event.final_score_away = (event.final_score_away or 0) + points
|
||||
print(f" [SCORE] {event.home_team} {event.final_score_home} - {event.final_score_away} {event.away_team}")
|
||||
|
||||
elif event.sport == SportType.BASKETBALL:
|
||||
# Basketball: 2 or 3 points frequently
|
||||
if random.random() < 0.5:
|
||||
scorer = random.choice(['home', 'away'])
|
||||
points = random.choice([2, 2, 2, 3, 1])
|
||||
if scorer == 'home':
|
||||
event.final_score_home = (event.final_score_home or 0) + points
|
||||
else:
|
||||
event.final_score_away = (event.final_score_away or 0) + points
|
||||
print(f" [SCORE] {event.home_team} {event.final_score_home} - {event.final_score_away} {event.away_team}")
|
||||
|
||||
elif event.sport in [SportType.BASEBALL, SportType.HOCKEY, SportType.SOCCER]:
|
||||
# Low scoring sports
|
||||
if random.random() < 0.15:
|
||||
scorer = random.choice(['home', 'away'])
|
||||
if scorer == 'home':
|
||||
event.final_score_home = (event.final_score_home or 0) + 1
|
||||
else:
|
||||
event.final_score_away = (event.final_score_away or 0) + 1
|
||||
print(f" [SCORE] {event.home_team} {event.final_score_home} - {event.final_score_away} {event.away_team}")
|
||||
|
||||
await db.commit()
|
||||
return list(events)
|
||||
|
||||
|
||||
async def complete_events(db) -> list[SportEvent]:
|
||||
"""Complete live events that have been running long enough."""
|
||||
result = await db.execute(
|
||||
select(SportEvent).where(SportEvent.status == EventStatus.LIVE)
|
||||
)
|
||||
events = result.scalars().all()
|
||||
|
||||
completed = []
|
||||
for event in events:
|
||||
# Calculate "game duration" based on when it went live (using updated_at as proxy)
|
||||
# Complete events after ~10 iterations (simulated game time)
|
||||
game_duration = (datetime.utcnow() - event.updated_at).total_seconds()
|
||||
|
||||
# 20% chance to complete if scores are reasonable
|
||||
home_score = event.final_score_home or 0
|
||||
away_score = event.final_score_away or 0
|
||||
|
||||
should_complete = False
|
||||
if event.sport == SportType.FOOTBALL and (home_score + away_score) >= 20:
|
||||
should_complete = random.random() < 0.3
|
||||
elif event.sport == SportType.BASKETBALL and (home_score + away_score) >= 150:
|
||||
should_complete = random.random() < 0.3
|
||||
elif event.sport in [SportType.BASEBALL, SportType.HOCKEY] and (home_score + away_score) >= 5:
|
||||
should_complete = random.random() < 0.3
|
||||
elif event.sport == SportType.SOCCER and (home_score + away_score) >= 2:
|
||||
should_complete = random.random() < 0.3
|
||||
|
||||
if should_complete:
|
||||
event.status = EventStatus.COMPLETED
|
||||
completed.append(event)
|
||||
print(f" [FINAL] {event.home_team} {home_score} - {away_score} {event.away_team}")
|
||||
|
||||
await db.commit()
|
||||
return completed
|
||||
|
||||
|
||||
async def settle_bets(db, completed_events: list[SportEvent]) -> int:
|
||||
"""Settle bets for completed events."""
|
||||
settled_count = 0
|
||||
|
||||
for event in completed_events:
|
||||
# Get all matched bets for this event
|
||||
result = await db.execute(
|
||||
select(SpreadBet)
|
||||
.options(selectinload(SpreadBet.creator), selectinload(SpreadBet.taker))
|
||||
.where(
|
||||
and_(
|
||||
SpreadBet.event_id == event.id,
|
||||
SpreadBet.status == SpreadBetStatus.MATCHED
|
||||
)
|
||||
)
|
||||
)
|
||||
bets = result.scalars().all()
|
||||
|
||||
for bet in bets:
|
||||
# Calculate spread result
|
||||
home_score = event.final_score_home or 0
|
||||
away_score = event.final_score_away or 0
|
||||
actual_spread = home_score - away_score # Positive = home won by X
|
||||
|
||||
# Creator's pick
|
||||
if bet.team == TeamSide.HOME:
|
||||
# Creator bet on home team with the spread
|
||||
# Home team needs to "cover" - actual margin > bet spread
|
||||
creator_wins = actual_spread > bet.spread
|
||||
else:
|
||||
# Creator bet on away team
|
||||
# Away team needs to cover - actual margin < bet spread (inverted)
|
||||
creator_wins = actual_spread < -bet.spread
|
||||
|
||||
# Get wallets
|
||||
creator_wallet_result = await db.execute(
|
||||
select(Wallet).where(Wallet.user_id == bet.creator_id)
|
||||
)
|
||||
creator_wallet = creator_wallet_result.scalar_one_or_none()
|
||||
|
||||
taker_wallet_result = await db.execute(
|
||||
select(Wallet).where(Wallet.user_id == bet.taker_id)
|
||||
)
|
||||
taker_wallet = taker_wallet_result.scalar_one_or_none()
|
||||
|
||||
if not creator_wallet or not taker_wallet:
|
||||
continue
|
||||
|
||||
# Calculate payout (total pot minus commission)
|
||||
total_pot = bet.stake_amount * 2
|
||||
commission = total_pot * (bet.house_commission_percent / 100)
|
||||
payout = total_pot - commission
|
||||
|
||||
winner = bet.creator if creator_wins else bet.taker
|
||||
winner_wallet = creator_wallet if creator_wins else taker_wallet
|
||||
loser_wallet = taker_wallet if creator_wins else creator_wallet
|
||||
|
||||
# Release escrow and pay winner
|
||||
creator_wallet.escrow -= bet.stake_amount
|
||||
taker_wallet.escrow -= bet.stake_amount
|
||||
winner_wallet.balance += payout
|
||||
|
||||
# Create payout transaction
|
||||
payout_tx = Transaction(
|
||||
user_id=winner.id,
|
||||
wallet_id=winner_wallet.id,
|
||||
type=TransactionType.BET_WON,
|
||||
amount=payout,
|
||||
balance_after=winner_wallet.balance,
|
||||
reference_id=bet.id,
|
||||
description=f"Won spread bet #{bet.id}",
|
||||
status=TransactionStatus.COMPLETED
|
||||
)
|
||||
db.add(payout_tx)
|
||||
|
||||
# Update bet
|
||||
bet.status = SpreadBetStatus.COMPLETED
|
||||
bet.winner_id = winner.id
|
||||
bet.payout_amount = payout
|
||||
bet.completed_at = datetime.utcnow()
|
||||
|
||||
settled_count += 1
|
||||
print(f" [SETTLED] Bet #{bet.id}: {winner.username} wins ${payout:.2f}")
|
||||
|
||||
await db.commit()
|
||||
return settled_count
|
||||
|
||||
|
||||
async def run_event_management(
|
||||
create_count: int = 0,
|
||||
do_update: bool = False,
|
||||
do_settle: bool = False,
|
||||
continuous: bool = False,
|
||||
delay: float = 5.0
|
||||
):
|
||||
"""Run the event management script."""
|
||||
print("=" * 60)
|
||||
print("H2H Event Manager")
|
||||
print("=" * 60)
|
||||
|
||||
await init_db()
|
||||
|
||||
iteration = 0
|
||||
while True:
|
||||
iteration += 1
|
||||
print(f"\n--- Iteration {iteration} ---")
|
||||
|
||||
async with async_session() as db:
|
||||
# Get or create admin user
|
||||
admin_result = await db.execute(
|
||||
select(User).where(User.is_admin == True)
|
||||
)
|
||||
admin = admin_result.scalar_one_or_none()
|
||||
|
||||
if not admin:
|
||||
# Use first user as admin
|
||||
user_result = await db.execute(select(User).limit(1))
|
||||
admin = user_result.scalar_one_or_none()
|
||||
|
||||
if not admin:
|
||||
print(" No users found. Run seed_data.py first.")
|
||||
if not continuous:
|
||||
break
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
|
||||
# Create new events
|
||||
if create_count > 0 or continuous:
|
||||
events_to_create = create_count if create_count > 0 else random.randint(1, 3)
|
||||
if continuous and random.random() < 0.3: # 30% chance in continuous mode
|
||||
print(f"\n Creating {events_to_create} new events...")
|
||||
await create_new_events(db, admin.id, events_to_create)
|
||||
elif create_count > 0:
|
||||
print(f"\n Creating {events_to_create} new events...")
|
||||
await create_new_events(db, admin.id, events_to_create)
|
||||
|
||||
# Update events
|
||||
if do_update or continuous:
|
||||
print("\n Checking for events to update...")
|
||||
|
||||
# Move upcoming to live
|
||||
await update_events_to_live(db)
|
||||
|
||||
# Update live scores
|
||||
await update_live_scores(db)
|
||||
|
||||
# Complete events
|
||||
completed = await complete_events(db)
|
||||
|
||||
# Settle bets for completed events
|
||||
if (do_settle or continuous) and completed:
|
||||
print(f"\n Settling bets for {len(completed)} completed events...")
|
||||
settled = await settle_bets(db, completed)
|
||||
print(f" Settled {settled} bets")
|
||||
|
||||
if not continuous:
|
||||
break
|
||||
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Event management complete!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Manage H2H sport events")
|
||||
parser.add_argument(
|
||||
"--create", "-c",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Number of new events to create (default: 0)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--update", "-u",
|
||||
action="store_true",
|
||||
help="Update event statuses and scores"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--settle", "-s",
|
||||
action="store_true",
|
||||
help="Settle bets for completed events"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--continuous",
|
||||
action="store_true",
|
||||
help="Run continuously until interrupted"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delay", "-d",
|
||||
type=float,
|
||||
default=5.0,
|
||||
help="Delay between iterations in seconds (default: 5.0)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# If no specific action, just create events
|
||||
if not args.create and not args.update and not args.settle and not args.continuous:
|
||||
args.create = 5 # Default: create 5 events
|
||||
|
||||
try:
|
||||
asyncio.run(run_event_management(
|
||||
create_count=args.create,
|
||||
do_update=args.update,
|
||||
do_settle=args.settle,
|
||||
continuous=args.continuous,
|
||||
delay=args.delay
|
||||
))
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nEvent management stopped by user.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
436
backend/simulate_activity.py
Executable file
436
backend/simulate_activity.py
Executable file
@ -0,0 +1,436 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Activity Simulation Script
|
||||
Simulates random user activity including:
|
||||
- New user registrations
|
||||
- Creating spread bets
|
||||
- Taking/matching bets
|
||||
- Adding comments to events and matches
|
||||
- Cancelling bets
|
||||
|
||||
Usage:
|
||||
python simulate_activity.py [--iterations N] [--delay SECONDS] [--continuous]
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import argparse
|
||||
import random
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import async_session, init_db
|
||||
from app.models import (
|
||||
User, Wallet, SportEvent, SpreadBet, EventComment, MatchComment,
|
||||
EventStatus, SpreadBetStatus, TeamSide, Transaction, TransactionType, TransactionStatus
|
||||
)
|
||||
from app.utils.security import get_password_hash
|
||||
|
||||
# Sample data for generating random users
|
||||
FIRST_NAMES = [
|
||||
"James", "Emma", "Liam", "Olivia", "Noah", "Ava", "Oliver", "Sophia",
|
||||
"Elijah", "Isabella", "Lucas", "Mia", "Mason", "Charlotte", "Ethan",
|
||||
"Amelia", "Alexander", "Harper", "Henry", "Evelyn", "Sebastian", "Luna",
|
||||
"Jack", "Camila", "Aiden", "Gianna", "Owen", "Abigail", "Samuel", "Ella",
|
||||
"Ryan", "Scarlett", "Nathan", "Emily", "Caleb", "Elizabeth", "Hunter",
|
||||
"Sofia", "Christian", "Avery", "Landon", "Chloe", "Jonathan", "Victoria"
|
||||
]
|
||||
|
||||
LAST_NAMES = [
|
||||
"Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller",
|
||||
"Davis", "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez",
|
||||
"Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin",
|
||||
"Lee", "Perez", "Thompson", "White", "Harris", "Sanchez", "Clark",
|
||||
"Ramirez", "Lewis", "Robinson", "Walker", "Young", "Allen", "King"
|
||||
]
|
||||
|
||||
# Sample comments for events
|
||||
EVENT_COMMENTS = [
|
||||
"This is going to be a great game!",
|
||||
"Home team looking strong this season",
|
||||
"I'm betting on the underdog here",
|
||||
"What do you all think about the spread?",
|
||||
"Last time these teams played it was close",
|
||||
"Weather might be a factor today",
|
||||
"Key player is out, that changes everything",
|
||||
"The odds seem off to me",
|
||||
"Anyone else feeling bullish on the away team?",
|
||||
"This matchup is always entertaining",
|
||||
"Line moved a lot overnight",
|
||||
"Sharp money coming in on the home side",
|
||||
"Public is heavy on the favorite",
|
||||
"Value play on the underdog here",
|
||||
"Injury report looking concerning",
|
||||
"Home field advantage is huge here",
|
||||
"Expecting a low-scoring affair",
|
||||
"Over/under seems too high",
|
||||
"Rivalry game, throw out the records!",
|
||||
"Coach has a great record against this opponent"
|
||||
]
|
||||
|
||||
# Sample comments for matches (between two bettors)
|
||||
MATCH_COMMENTS = [
|
||||
"Good luck!",
|
||||
"May the best bettor win",
|
||||
"This should be interesting",
|
||||
"I'm feeling confident about this one",
|
||||
"Let's see how this plays out",
|
||||
"Nice bet, looking forward to the game",
|
||||
"GL HF",
|
||||
"First time betting against you",
|
||||
"Rematch from last week!",
|
||||
"I've been waiting for this matchup",
|
||||
"Your team doesn't stand a chance!",
|
||||
"We'll see about that...",
|
||||
"Close game incoming",
|
||||
"I'll be watching every play",
|
||||
"Don't count your winnings yet!"
|
||||
]
|
||||
|
||||
|
||||
async def create_random_user(db) -> User | None:
|
||||
"""Create a new random user with a wallet."""
|
||||
first = random.choice(FIRST_NAMES)
|
||||
last = random.choice(LAST_NAMES)
|
||||
|
||||
# Generate unique username
|
||||
suffix = random.randint(100, 9999)
|
||||
username = f"{first.lower()}{last.lower()}{suffix}"
|
||||
email = f"{username}@example.com"
|
||||
|
||||
# Check if user already exists
|
||||
existing = await db.execute(
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return None
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
username=username,
|
||||
password_hash=get_password_hash("password123"),
|
||||
display_name=f"{first} {last}"
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
# Create wallet with random starting balance
|
||||
starting_balance = Decimal(str(random.randint(500, 5000)))
|
||||
wallet = Wallet(
|
||||
user_id=user.id,
|
||||
balance=starting_balance,
|
||||
escrow=Decimal("0.00")
|
||||
)
|
||||
db.add(wallet)
|
||||
await db.commit()
|
||||
|
||||
print(f" [USER] Created user: {user.username} with ${starting_balance} balance")
|
||||
return user
|
||||
|
||||
|
||||
async def create_random_bet(db, users: list[User], events: list[SportEvent]) -> SpreadBet | None:
|
||||
"""Create a random spread bet on an event."""
|
||||
if not users or not events:
|
||||
return None
|
||||
|
||||
# Filter for users with sufficient balance
|
||||
users_with_balance = []
|
||||
for user in users:
|
||||
wallet_result = await db.execute(
|
||||
select(Wallet).where(Wallet.user_id == user.id)
|
||||
)
|
||||
wallet = wallet_result.scalar_one_or_none()
|
||||
if wallet and wallet.balance >= Decimal("10"):
|
||||
users_with_balance.append((user, wallet))
|
||||
|
||||
if not users_with_balance:
|
||||
return None
|
||||
|
||||
user, wallet = random.choice(users_with_balance)
|
||||
event = random.choice(events)
|
||||
|
||||
# Random spread within event range
|
||||
spread = round(random.uniform(event.min_spread, event.max_spread) * 2) / 2 # Round to 0.5
|
||||
|
||||
# Random stake (10-50% of balance, max $500)
|
||||
max_stake = min(float(wallet.balance) * 0.5, 500)
|
||||
stake = Decimal(str(round(random.uniform(10, max(10, max_stake)), 2)))
|
||||
|
||||
# Random team selection
|
||||
team = random.choice([TeamSide.HOME, TeamSide.AWAY])
|
||||
|
||||
bet = SpreadBet(
|
||||
event_id=event.id,
|
||||
spread=spread,
|
||||
team=team,
|
||||
creator_id=user.id,
|
||||
stake_amount=stake,
|
||||
house_commission_percent=Decimal("10.00"),
|
||||
status=SpreadBetStatus.OPEN
|
||||
)
|
||||
db.add(bet)
|
||||
await db.commit()
|
||||
|
||||
team_name = event.home_team if team == TeamSide.HOME else event.away_team
|
||||
print(f" [BET] {user.username} created ${stake} bet on {team_name} {'+' if spread > 0 else ''}{spread}")
|
||||
return bet
|
||||
|
||||
|
||||
async def take_random_bet(db, users: list[User]) -> SpreadBet | None:
|
||||
"""Have a random user take an open bet."""
|
||||
# Get open bets
|
||||
result = await db.execute(
|
||||
select(SpreadBet)
|
||||
.options(selectinload(SpreadBet.event), selectinload(SpreadBet.creator))
|
||||
.where(SpreadBet.status == SpreadBetStatus.OPEN)
|
||||
)
|
||||
open_bets = result.scalars().all()
|
||||
|
||||
if not open_bets:
|
||||
return None
|
||||
|
||||
bet = random.choice(open_bets)
|
||||
|
||||
# Find eligible takers (not creator, has balance)
|
||||
eligible_takers = []
|
||||
for user in users:
|
||||
if user.id == bet.creator_id:
|
||||
continue
|
||||
wallet_result = await db.execute(
|
||||
select(Wallet).where(Wallet.user_id == user.id)
|
||||
)
|
||||
wallet = wallet_result.scalar_one_or_none()
|
||||
if wallet and wallet.balance >= bet.stake_amount:
|
||||
eligible_takers.append((user, wallet))
|
||||
|
||||
if not eligible_takers:
|
||||
return None
|
||||
|
||||
taker, taker_wallet = random.choice(eligible_takers)
|
||||
|
||||
# Get creator's wallet
|
||||
creator_wallet_result = await db.execute(
|
||||
select(Wallet).where(Wallet.user_id == bet.creator_id)
|
||||
)
|
||||
creator_wallet = creator_wallet_result.scalar_one_or_none()
|
||||
|
||||
if not creator_wallet or creator_wallet.balance < bet.stake_amount:
|
||||
return None
|
||||
|
||||
# Lock funds for both parties
|
||||
creator_wallet.balance -= bet.stake_amount
|
||||
creator_wallet.escrow += bet.stake_amount
|
||||
taker_wallet.balance -= bet.stake_amount
|
||||
taker_wallet.escrow += bet.stake_amount
|
||||
|
||||
# Create transactions
|
||||
creator_tx = Transaction(
|
||||
user_id=bet.creator_id,
|
||||
wallet_id=creator_wallet.id,
|
||||
type=TransactionType.ESCROW_LOCK,
|
||||
amount=-bet.stake_amount,
|
||||
balance_after=creator_wallet.balance,
|
||||
reference_id=bet.id,
|
||||
description=f"Escrow lock for spread bet #{bet.id}",
|
||||
status=TransactionStatus.COMPLETED
|
||||
)
|
||||
taker_tx = Transaction(
|
||||
user_id=taker.id,
|
||||
wallet_id=taker_wallet.id,
|
||||
type=TransactionType.ESCROW_LOCK,
|
||||
amount=-bet.stake_amount,
|
||||
balance_after=taker_wallet.balance,
|
||||
reference_id=bet.id,
|
||||
description=f"Escrow lock for spread bet #{bet.id}",
|
||||
status=TransactionStatus.COMPLETED
|
||||
)
|
||||
db.add(creator_tx)
|
||||
db.add(taker_tx)
|
||||
|
||||
# Update bet
|
||||
bet.taker_id = taker.id
|
||||
bet.status = SpreadBetStatus.MATCHED
|
||||
bet.matched_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
print(f" [MATCH] {taker.username} took {bet.creator.username}'s ${bet.stake_amount} bet")
|
||||
return bet
|
||||
|
||||
|
||||
async def cancel_random_bet(db) -> SpreadBet | None:
|
||||
"""Cancel a random open bet."""
|
||||
result = await db.execute(
|
||||
select(SpreadBet)
|
||||
.options(selectinload(SpreadBet.creator))
|
||||
.where(SpreadBet.status == SpreadBetStatus.OPEN)
|
||||
)
|
||||
open_bets = result.scalars().all()
|
||||
|
||||
if not open_bets:
|
||||
return None
|
||||
|
||||
# 20% chance to cancel
|
||||
if random.random() > 0.2:
|
||||
return None
|
||||
|
||||
bet = random.choice(open_bets)
|
||||
bet.status = SpreadBetStatus.CANCELLED
|
||||
await db.commit()
|
||||
|
||||
print(f" [CANCEL] {bet.creator.username} cancelled their ${bet.stake_amount} bet")
|
||||
return bet
|
||||
|
||||
|
||||
async def add_event_comment(db, users: list[User], events: list[SportEvent]) -> EventComment | None:
|
||||
"""Add a random comment to an event."""
|
||||
if not users or not events:
|
||||
return None
|
||||
|
||||
user = random.choice(users)
|
||||
event = random.choice(events)
|
||||
content = random.choice(EVENT_COMMENTS)
|
||||
|
||||
comment = EventComment(
|
||||
event_id=event.id,
|
||||
user_id=user.id,
|
||||
content=content
|
||||
)
|
||||
db.add(comment)
|
||||
await db.commit()
|
||||
|
||||
print(f" [COMMENT] {user.username} on {event.home_team} vs {event.away_team}: \"{content[:40]}...\"")
|
||||
return comment
|
||||
|
||||
|
||||
async def add_match_comment(db, users: list[User]) -> MatchComment | None:
|
||||
"""Add a random comment to a matched bet."""
|
||||
# Get matched bets
|
||||
result = await db.execute(
|
||||
select(SpreadBet)
|
||||
.options(selectinload(SpreadBet.creator), selectinload(SpreadBet.taker))
|
||||
.where(SpreadBet.status == SpreadBetStatus.MATCHED)
|
||||
)
|
||||
matched_bets = result.scalars().all()
|
||||
|
||||
if not matched_bets:
|
||||
return None
|
||||
|
||||
bet = random.choice(matched_bets)
|
||||
|
||||
# Comment from either creator or taker
|
||||
user = random.choice([bet.creator, bet.taker])
|
||||
content = random.choice(MATCH_COMMENTS)
|
||||
|
||||
comment = MatchComment(
|
||||
spread_bet_id=bet.id,
|
||||
user_id=user.id,
|
||||
content=content
|
||||
)
|
||||
db.add(comment)
|
||||
await db.commit()
|
||||
|
||||
print(f" [CHAT] {user.username} in match #{bet.id}: \"{content}\"")
|
||||
return comment
|
||||
|
||||
|
||||
async def run_simulation(iterations: int = 10, delay: float = 2.0, continuous: bool = False):
|
||||
"""Run the activity simulation."""
|
||||
print("=" * 60)
|
||||
print("H2H Activity Simulator")
|
||||
print("=" * 60)
|
||||
|
||||
await init_db()
|
||||
|
||||
iteration = 0
|
||||
while continuous or iteration < iterations:
|
||||
iteration += 1
|
||||
print(f"\n--- Iteration {iteration} ---")
|
||||
|
||||
async with async_session() as db:
|
||||
# Get existing users and events
|
||||
users_result = await db.execute(select(User))
|
||||
users = list(users_result.scalars().all())
|
||||
|
||||
events_result = await db.execute(
|
||||
select(SportEvent).where(SportEvent.status == EventStatus.UPCOMING)
|
||||
)
|
||||
events = list(events_result.scalars().all())
|
||||
|
||||
if not events:
|
||||
print(" No upcoming events found. Run manage_events.py first.")
|
||||
if not continuous:
|
||||
break
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
|
||||
# Random actions with weighted probabilities
|
||||
actions = [
|
||||
(create_random_user, 0.15), # 15% - Create user
|
||||
(lambda db: create_random_bet(db, users, events), 0.30), # 30% - Create bet
|
||||
(lambda db: take_random_bet(db, users), 0.20), # 20% - Take bet
|
||||
(lambda db: cancel_random_bet(db), 0.05), # 5% - Cancel bet
|
||||
(lambda db: add_event_comment(db, users, events), 0.15), # 15% - Event comment
|
||||
(lambda db: add_match_comment(db, users), 0.15), # 15% - Match comment
|
||||
]
|
||||
|
||||
# Perform 1-3 random actions per iteration
|
||||
num_actions = random.randint(1, 3)
|
||||
for _ in range(num_actions):
|
||||
# Weighted random selection
|
||||
rand = random.random()
|
||||
cumulative = 0
|
||||
for action_fn, probability in actions:
|
||||
cumulative += probability
|
||||
if rand <= cumulative:
|
||||
try:
|
||||
if action_fn == create_random_user:
|
||||
await action_fn(db)
|
||||
else:
|
||||
await action_fn(db)
|
||||
except Exception as e:
|
||||
print(f" [ERROR] {e}")
|
||||
break
|
||||
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Simulation complete!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Simulate H2H platform activity")
|
||||
parser.add_argument(
|
||||
"--iterations", "-n",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Number of simulation iterations (default: 10)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delay", "-d",
|
||||
type=float,
|
||||
default=2.0,
|
||||
help="Delay between iterations in seconds (default: 2.0)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--continuous", "-c",
|
||||
action="store_true",
|
||||
help="Run continuously until interrupted"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
asyncio.run(run_simulation(
|
||||
iterations=args.iterations,
|
||||
delay=args.delay,
|
||||
continuous=args.continuous
|
||||
))
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nSimulation stopped by user.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
coinex-trade.png
Normal file
BIN
coinex-trade.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 402 KiB |
@ -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