Event layout page update.

This commit is contained in:
2026-01-11 15:21:17 -06:00
parent e0af183086
commit e50b2f31d3
13 changed files with 1460 additions and 183 deletions

View File

@ -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",

View 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()

View File

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

View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

View File

@ -1,5 +1,5 @@
import { apiClient } from './client'
import type { SportEvent, SportEventWithBets } from '@/types/sport-event'
import type { SportEvent, SportEventWithBets, EventComment, EventCommentsResponse } from '@/types/sport-event'
export const sportEventsApi = {
// Public endpoints (no auth required)
@ -33,4 +33,20 @@ export const sportEventsApi = {
const response = await apiClient.get<SportEventWithBets>(`/api/v1/sport-events/${eventId}`)
return response.data
},
// Comments
getEventComments: async (eventId: number, params?: { skip?: number; limit?: number }): Promise<EventCommentsResponse> => {
const queryParams = new URLSearchParams()
if (params?.skip !== undefined) queryParams.append('skip', params.skip.toString())
if (params?.limit !== undefined) queryParams.append('limit', params.limit.toString())
const url = `/api/v1/sport-events/${eventId}/comments${queryParams.toString() ? `?${queryParams}` : ''}`
const response = await apiClient.get<EventCommentsResponse>(url)
return response.data
},
addEventComment: async (eventId: number, content: string): Promise<EventComment> => {
const response = await apiClient.post<EventComment>(`/api/v1/sport-events/${eventId}/comments`, { content })
return response.data
},
}

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store'
import { useMutation, useQueryClient } from '@tanstack/react-query'
@ -8,8 +8,6 @@ import { TeamSide } from '@/types/spread-bet'
import {
Clock,
Activity,
DollarSign,
Users,
Target,
TrendingUp,
TrendingDown,
@ -43,6 +41,22 @@ function formatTimeUntil(gameTime: string): { text: string; urgent: boolean } {
return { text: `${seconds}s`, urgent: true }
}
// Helper to generate half-point spreads only (to prevent ties)
function generateHalfPointSpreads(min: number, max: number): number[] {
const spreads: number[] = []
// Round min up to nearest .5
let start = Math.ceil(min * 2) / 2
if (start % 1 === 0) start += 0.5
// Round max down to nearest .5
let end = Math.floor(max * 2) / 2
if (end % 1 === 0) end -= 0.5
for (let s = start; s <= end; s += 1) {
spreads.push(s)
}
return spreads
}
export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelProps) => {
const { isAuthenticated } = useAuthStore()
const queryClient = useQueryClient()
@ -52,9 +66,6 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
const [stakeAmount, setStakeAmount] = useState('')
const [activeTab, setActiveTab] = useState<'chart' | 'grid'>('chart')
// Ref for above spread line container
const aboveLineRef = useRef<HTMLDivElement>(null)
// Update countdown every second
useEffect(() => {
const interval = setInterval(() => {
@ -96,31 +107,25 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
return max
}, [event.spread_grid])
// Generate ALL spreads from min to max, split at official line for order book
const { aboveLine, belowLine } = useMemo(() => {
// Generate half-point spreads and split at official line for order book
const { aboveLine, belowLine, allSpreads } = useMemo(() => {
const spreads = generateHalfPointSpreads(event.min_spread, event.max_spread)
const above: number[] = []
const below: number[] = []
// Generate all possible spreads in range
for (let s = event.min_spread; s <= event.max_spread; s += 0.5) {
spreads.forEach(s => {
if (s > event.official_spread) above.push(s)
else if (s < event.official_spread) below.push(s)
}
})
return {
aboveLine: above.sort((a, b) => b - a), // High to low (highest at top)
aboveLine: above.sort((a, b) => b - a), // High to low (9.5, 8.5, 7.5...)
belowLine: below.sort((a, b) => b - a), // High to low (closest to line at top)
allSpreads: spreads,
}
}, [event.min_spread, event.max_spread, event.official_spread])
// Auto-scroll above spread line to bottom after render
useEffect(() => {
if (aboveLineRef.current) {
aboveLineRef.current.scrollTop = aboveLineRef.current.scrollHeight
}
}, [aboveLine])
// Get all bets for chart and recent activity (including matched)
// Get all bets with spread info for chart and recent activity
const allBetsWithSpread = useMemo(() => {
const bets: (SpreadGridBet & { spread: number })[] = []
Object.entries(event.spread_grid).forEach(([spread, spreadBets]) => {
@ -136,26 +141,25 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
return allBetsWithSpread
}, [allBetsWithSpread])
// Chart data - volume per spread
// Chart data - volume per spread (half-points only)
const chartData = useMemo(() => {
const data: { spread: number; homeVolume: number; awayVolume: number; total: number }[] = []
// Get all possible spreads in range
for (let s = event.min_spread; s <= event.max_spread; s += 0.5) {
allSpreads.forEach(s => {
const bets = event.spread_grid[s.toString()] || []
const homeVol = bets.filter(b => b.team === 'home').reduce((sum, b) => sum + b.stake, 0)
const awayVol = bets.filter(b => b.team === 'away').reduce((sum, b) => sum + b.stake, 0)
data.push({ spread: s, homeVolume: homeVol, awayVolume: awayVol, total: homeVol + awayVol })
}
})
return data
}, [event.spread_grid, event.min_spread, event.max_spread])
}, [event.spread_grid, allSpreads])
const chartMaxVolume = useMemo(() => {
return Math.max(...chartData.map(d => d.total), 1)
}, [chartData])
// Available bets to take
// Available bets to take at selected spread
const availableBets = useMemo(() => {
const bets = event.spread_grid[selectedSpread.toString()] || []
return bets.filter(b => b.status === 'open' && b.can_take)
@ -217,6 +221,15 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
takeBetMutation.mutate(betId)
}
// Adjust spread by 1 point (next half-point)
const adjustSpread = (delta: number) => {
const newSpread = selectedSpread + delta
// Ensure it's a valid half-point spread within range
if (newSpread >= event.min_spread && newSpread <= event.max_spread && newSpread % 1 !== 0) {
setSelectedSpread(newSpread)
}
}
// Quick stake buttons
const quickStakes = [25, 50, 100, 250, 500]
@ -275,16 +288,11 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
)
}
// Grid view - shows all spreads in a grid format
// Grid view - shows all half-point spreads in a grid format
const renderGridView = () => {
const spreads: number[] = []
for (let s = event.min_spread; s <= event.max_spread; s += 0.5) {
spreads.push(s)
}
return (
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-2">
{spreads.map(spread => {
{allSpreads.map(spread => {
const bets = event.spread_grid[spread.toString()] || []
const homeBets = bets.filter(b => b.team === 'home')
const awayBets = bets.filter(b => b.team === 'away')
@ -321,86 +329,86 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
return (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
{/* Header - Event Info */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 p-4 text-white">
{/* Compact Header - Exchange Style */}
<div className="bg-gray-800 px-4 py-3 text-white">
<div className="flex items-center justify-between">
<div className="flex items-center gap-6">
{/* Left: Teams and Spread */}
<div className="flex items-center gap-4">
{/* Home Team */}
<div className="text-center">
<div className="w-14 h-14 bg-white/20 rounded-full flex items-center justify-center mb-1">
<span className="text-2xl font-bold">{event.home_team.charAt(0)}</span>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-600/20 rounded-full flex items-center justify-center">
<span className="text-green-400 font-bold">{event.home_team.charAt(0)}</span>
</div>
<div>
<p className="font-semibold text-sm">{event.home_team}</p>
<p className="text-gray-400 text-xs">HOME</p>
</div>
<p className="font-semibold">{event.home_team}</p>
<p className="text-blue-200 text-xs">HOME</p>
</div>
{/* VS / Spread */}
<div className="text-center px-6">
<div className="text-blue-200 text-xs mb-1">OFFICIAL SPREAD</div>
<div className="text-3xl font-bold">
{/* Spread */}
<div className="text-center px-6 border-l border-r border-gray-700">
<div className="text-gray-400 text-xs">SPREAD</div>
<div className="text-yellow-400 text-2xl font-bold">
{event.official_spread > 0 ? '+' : ''}{event.official_spread}
</div>
<div className="text-blue-200 text-xs mt-1">{event.league}</div>
<div className="text-gray-400 text-xs">{event.league}</div>
</div>
{/* Away Team */}
<div className="text-center">
<div className="w-14 h-14 bg-white/20 rounded-full flex items-center justify-center mb-1">
<span className="text-2xl font-bold">{event.away_team.charAt(0)}</span>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-600/20 rounded-full flex items-center justify-center">
<span className="text-red-400 font-bold">{event.away_team.charAt(0)}</span>
</div>
<div>
<p className="font-semibold text-sm">{event.away_team}</p>
<p className="text-gray-400 text-xs">AWAY</p>
</div>
<p className="font-semibold">{event.away_team}</p>
<p className="text-blue-200 text-xs">AWAY</p>
</div>
</div>
{/* Game Time */}
<div className="text-right">
{/* Center: Market Stats */}
<div className="flex items-center gap-6 px-6 border-l border-gray-700">
<div className="text-center">
<p className="text-gray-400 text-xs">Volume</p>
<p className="font-mono font-semibold">${marketStats.totalVolume.toLocaleString()}</p>
</div>
<div className="text-center">
<p className="text-gray-400 text-xs">Open</p>
<p className="font-mono font-semibold text-green-400">{marketStats.openBets}</p>
</div>
<div className="flex items-center gap-2">
<TrendingUp size={14} className="text-green-400" />
<span className="text-green-400 font-mono text-sm">${marketStats.homeVolume.toLocaleString()}</span>
</div>
<div className="flex items-center gap-2">
<TrendingDown size={14} className="text-red-400" />
<span className="text-red-400 font-mono text-sm">${marketStats.awayVolume.toLocaleString()}</span>
</div>
</div>
{/* Right: Countdown */}
<div className="flex items-center gap-3 pl-6 border-l border-gray-700">
<div className={`
inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold
${timeUntil.urgent ? 'bg-red-500 animate-pulse' : 'bg-white/20'}
flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold
${timeUntil.urgent ? 'bg-red-500/20 text-red-400 animate-pulse' : 'bg-gray-700 text-gray-300'}
`}>
<Clock size={16} />
{timeUntil.text}
<span>{timeUntil.text}</span>
</div>
<p className="text-blue-200 text-xs mt-2">
{new Date(event.game_time).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})}
</p>
</div>
</div>
{/* Stats Bar */}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-white/20 text-sm">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<DollarSign size={14} className="text-blue-200" />
<span className="text-blue-200">Volume:</span>
<span className="font-semibold">${marketStats.totalVolume.toLocaleString()}</span>
</div>
<div className="flex items-center gap-2">
<Activity size={14} className="text-blue-200" />
<span className="text-blue-200">Bets:</span>
<span className="font-semibold">{marketStats.totalBets}</span>
</div>
<div className="flex items-center gap-2">
<Users size={14} className="text-blue-200" />
<span className="text-blue-200">Open:</span>
<span className="font-semibold text-green-300">{marketStats.openBets}</span>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<TrendingUp size={14} className="text-green-300" />
<span className="text-green-300">${marketStats.homeVolume.toLocaleString()}</span>
</div>
<div className="flex items-center gap-2">
<TrendingDown size={14} className="text-red-300" />
<span className="text-red-300">${marketStats.awayVolume.toLocaleString()}</span>
<div className="text-right">
<p className="text-gray-400 text-xs">
{new Date(event.game_time).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
})}
</p>
<p className="text-gray-300 text-xs">
{new Date(event.game_time).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
})}
</p>
</div>
</div>
</div>
@ -409,16 +417,16 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
{/* Main Content - fixed height container */}
<div className="grid grid-cols-12 divide-x divide-gray-200 h-[600px] overflow-hidden">
{/* Left - Order Book */}
<div className="col-span-3 flex flex-col overflow-hidden">
<div className="p-4 pb-2">
<h3 className="font-semibold text-gray-900 flex items-center gap-2">
<Activity size={16} className="text-gray-400" />
<div className="col-span-3 flex flex-col overflow-hidden bg-gray-50">
<div className="p-3 border-b bg-white">
<h3 className="font-semibold text-gray-900 flex items-center gap-2 text-sm">
<Activity size={14} className="text-gray-400" />
Order Book
</h3>
</div>
{/* Header */}
<div className="grid grid-cols-7 gap-1 py-2 px-4 text-xs text-gray-500 border-b">
{/* Column Header */}
<div className="grid grid-cols-7 gap-1 py-1.5 px-2 text-xs text-gray-500 border-b bg-white">
<span className="text-right">{event.away_team.slice(0, 4)}</span>
<span className="text-right">Vol</span>
<span></span>
@ -428,15 +436,11 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
<span className="text-left">{event.home_team.slice(0, 4)}</span>
</div>
{/* Above official line - fills available space, auto-scrolls to bottom */}
<div ref={aboveLineRef} className="flex-1 overflow-y-auto border-b border-gray-100 px-2">
{aboveLine.length > 0 ? (
aboveLine.map(spread => renderOrderRow(spread))
) : (
<div className="flex items-center justify-center h-full text-gray-400 text-xs">
No spreads above line
</div>
)}
{/* Above official line - aligned to bottom */}
<div className="flex-1 overflow-y-auto flex flex-col justify-end border-b border-gray-200 bg-white">
<div className="px-2">
{aboveLine.map(spread => renderOrderRow(spread))}
</div>
</div>
{/* Official Line */}
@ -444,22 +448,18 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
{renderOrderRow(event.official_spread)}
</div>
{/* Below official line - fills available space */}
<div className="flex-1 overflow-y-auto px-2">
{belowLine.length > 0 ? (
belowLine.map(spread => renderOrderRow(spread))
) : (
<div className="flex items-center justify-center h-full text-gray-400 text-xs">
No spreads below line
</div>
)}
{/* Below official line - aligned to top */}
<div className="flex-1 overflow-y-auto bg-white">
<div className="px-2">
{belowLine.map(spread => renderOrderRow(spread))}
</div>
</div>
</div>
{/* Center - Chart/Grid with Tabs */}
<div className="col-span-6 flex flex-col overflow-hidden">
{/* Tabs */}
<div className="flex border-b">
<div className="flex border-b bg-white">
<button
onClick={() => setActiveTab('chart')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
@ -507,7 +507,6 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
</div>
{chartData.map((d) => {
// Calculate heights as percentage of max, ensuring max bar fills 100%
const totalHeightPercent = (d.total / chartMaxVolume) * 100
const homeHeightPercent = d.total > 0 ? (d.homeVolume / d.total) * totalHeightPercent : 0
const awayHeightPercent = d.total > 0 ? (d.awayVolume / d.total) * totalHeightPercent : 0
@ -545,7 +544,7 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
{/* X-axis labels */}
<div className="flex justify-between mt-1 text-xs text-gray-500 px-2 flex-shrink-0">
<span>{event.min_spread}</span>
<span>Spread</span>
<span>Spread (half-points)</span>
<span>+{event.max_spread}</span>
</div>
@ -600,7 +599,7 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
) : (
/* Grid View */
<div className="flex-1 overflow-y-auto">
<h3 className="font-semibold text-gray-900 mb-4">All Spreads</h3>
<h3 className="font-semibold text-gray-900 mb-4">All Spreads (half-points only)</h3>
{renderGridView()}
</div>
)}
@ -654,10 +653,10 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
{/* Spread Selection */}
<div className="mb-4">
<label className="text-gray-600 text-xs block mb-1">Spread</label>
<label className="text-gray-600 text-xs block mb-1">Spread (half-points only)</label>
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedSpread(s => Math.max(event.min_spread, s - 0.5))}
onClick={() => adjustSpread(-1)}
className="px-4 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 font-bold"
>
@ -671,7 +670,7 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
)}
</div>
<button
onClick={() => setSelectedSpread(s => Math.min(event.max_spread, s + 0.5))}
onClick={() => adjustSpread(1)}
className="px-4 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 font-bold"
>
+
@ -722,13 +721,15 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr
{availableBets.length > 0 && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold text-gray-700 mb-2">
Take Existing Bet ({availableBets.length} available)
Take Existing Bet ({availableBets.length} available at {selectedSpread > 0 ? '+' : ''}{selectedSpread})
</h4>
<div className="space-y-2 max-h-32 overflow-y-auto">
{availableBets.map(bet => (
<div key={bet.bet_id} className="flex items-center justify-between p-2 bg-white border rounded-lg">
<div>
<p className="font-semibold text-sm">${bet.stake.toFixed(0)}</p>
<p className={`font-semibold text-sm ${bet.team === 'home' ? 'text-green-600' : 'text-red-600'}`}>
${bet.stake.toFixed(0)} on {bet.team === 'home' ? event.home_team : event.away_team}
</p>
<p className="text-xs text-gray-500">by {bet.creator_username}</p>
</div>
<button

View File

@ -10,7 +10,6 @@ import {
Crown,
Users,
Rocket,
Zap,
Share2,
Settings,
Receipt
@ -44,12 +43,6 @@ const MORE_DROPDOWN = [
description: 'Discover and gain access to new bets',
icon: Rocket
},
{
path: '/megadrop',
label: 'Megadrop',
description: 'Lock your bets and complete quests for boosted airdrop rewards',
icon: Zap
},
]
// Reusable dropdown hook
@ -225,8 +218,8 @@ export const Header = () => {
{/* Left-justified navigation links */}
<nav className="flex items-center gap-6 ml-8">
<Link to="/sports" className="text-gray-700 hover:text-primary transition-colors">
Markets
<Link to="/" className="text-gray-700 hover:text-primary transition-colors">
Events
</Link>
<Link to="/live" className="text-gray-700 hover:text-primary transition-colors">
Live

View File

@ -4,7 +4,7 @@ import { WS_URL } from '@/utils/constants'
import { useAuthStore } from '@/store'
export interface WebSocketMessage {
type: 'bet_created' | 'bet_taken' | 'bet_cancelled' | 'event_updated'
type: 'bet_created' | 'bet_taken' | 'bet_cancelled' | 'event_updated' | 'new_comment'
data: {
event_id: number
bet_id?: number
@ -17,9 +17,10 @@ interface UseEventWebSocketOptions {
onBetCreated?: (data: WebSocketMessage['data']) => void
onBetTaken?: (data: WebSocketMessage['data']) => void
onBetCancelled?: (data: WebSocketMessage['data']) => void
onNewComment?: (data: WebSocketMessage['data']) => void
}
export function useEventWebSocket({ eventId, onBetCreated, onBetTaken, onBetCancelled }: UseEventWebSocketOptions) {
export function useEventWebSocket({ eventId, onBetCreated, onBetTaken, onBetCancelled, onNewComment }: UseEventWebSocketOptions) {
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const queryClient = useQueryClient()
@ -29,13 +30,15 @@ export function useEventWebSocket({ eventId, onBetCreated, onBetTaken, onBetCanc
const onBetCreatedRef = useRef(onBetCreated)
const onBetTakenRef = useRef(onBetTaken)
const onBetCancelledRef = useRef(onBetCancelled)
const onNewCommentRef = useRef(onNewComment)
// Update refs when callbacks change
useEffect(() => {
onBetCreatedRef.current = onBetCreated
onBetTakenRef.current = onBetTaken
onBetCancelledRef.current = onBetCancelled
}, [onBetCreated, onBetTaken, onBetCancelled])
onNewCommentRef.current = onNewComment
}, [onBetCreated, onBetTaken, onBetCancelled, onNewComment])
const invalidateEventQueries = useCallback(() => {
console.log('[WebSocket] Refetching queries for event', eventId)
@ -94,6 +97,11 @@ export function useEventWebSocket({ eventId, onBetCreated, onBetTaken, onBetCanc
case 'event_updated':
invalidateEventQueries()
break
case 'new_comment':
onNewCommentRef.current?.(message.data)
// Also refetch comments
queryClient.invalidateQueries({ queryKey: ['event-comments', eventId] })
break
}
} catch (err) {
console.error('[WebSocket] Failed to parse message:', err)

View File

@ -1,22 +1,29 @@
import { Link, useParams } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState, useEffect, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuthStore } from '@/store'
import { sportEventsApi } from '@/api/sport-events'
import { SpreadGrid } from '@/components/bets/SpreadGrid'
import { TradingPanel } from '@/components/bets/TradingPanel'
import { MyOtherBets } from '@/components/bets/MyOtherBets'
import { Button } from '@/components/common/Button'
import { Loading } from '@/components/common/Loading'
import { Button } from '@/components/common/Button'
import { Header } from '@/components/layout/Header'
import { ChevronLeft } from 'lucide-react'
import { MessageCircle, List, Send } from 'lucide-react'
import { useEventWebSocket } from '@/hooks/useEventWebSocket'
import { format } from 'date-fns'
import toast from 'react-hot-toast'
import type { EventComment } from '@/types/sport-event'
type TabType = 'comments' | 'mybets'
export const EventDetail = () => {
const { id } = useParams<{ id: string }>()
const eventId = parseInt(id || '0', 10)
const { isAuthenticated } = useAuthStore()
const { isAuthenticated, user } = useAuthStore()
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState<TabType>('comments')
const [newComment, setNewComment] = useState('')
const commentsEndRef = useRef<HTMLDivElement>(null)
const { data: event, isLoading, error } = useQuery({
queryKey: ['sport-event', eventId, isAuthenticated],
@ -27,6 +34,23 @@ export const EventDetail = () => {
enabled: eventId > 0,
})
const { data: commentsData, isLoading: isLoadingComments } = useQuery({
queryKey: ['event-comments', eventId],
queryFn: () => sportEventsApi.getEventComments(eventId),
enabled: eventId > 0 && activeTab === 'comments',
})
const addCommentMutation = useMutation({
mutationFn: (content: string) => sportEventsApi.addEventComment(eventId, content),
onSuccess: () => {
setNewComment('')
queryClient.invalidateQueries({ queryKey: ['event-comments', eventId] })
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to add comment')
},
})
// Connect to WebSocket for live updates
useEventWebSocket({
eventId,
@ -48,31 +72,40 @@ export const EventDetail = () => {
duration: 3000,
})
},
onNewComment: () => {
// Comments are automatically refetched by the WebSocket hook
},
})
// Scroll to bottom when comments change
useEffect(() => {
if (activeTab === 'comments') {
commentsEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
}, [commentsData?.comments, activeTab])
const handleBetCreated = () => {
// Refetch event data to show new bet
queryClient.invalidateQueries({ queryKey: ['sport-event', eventId] })
queryClient.invalidateQueries({ queryKey: ['public-sport-events'] })
}
const handleBetTaken = () => {
// Refetch event data to update bet status
queryClient.invalidateQueries({ queryKey: ['sport-event', eventId] })
queryClient.invalidateQueries({ queryKey: ['public-sport-events'] })
}
const handleSubmitComment = (e: React.FormEvent) => {
e.preventDefault()
if (newComment.trim()) {
addCommentMutation.mutate(newComment.trim())
}
}
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="px-4 sm:px-6 lg:px-8 py-8">
{/* <Link to="/">
<Button variant="secondary">
Back to Events
</Button>
</Link> */}
<div className="mt-8">
<Loading />
</div>
@ -86,12 +119,6 @@ export const EventDetail = () => {
<div className="min-h-screen bg-gray-50">
<Header />
<div className="px-4 sm:px-6 lg:px-8 py-8">
{/* <Link to="/">
<Button variant="secondary">
Back to Events
</Button>
</Link> */}
<div className="mt-8 text-center py-12 bg-white rounded-lg shadow">
<p className="text-gray-500">Event not found</p>
</div>
@ -100,17 +127,12 @@ export const EventDetail = () => {
)
}
const comments = commentsData?.comments || []
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="px-4 sm:px-6 lg:px-8 py-8">
{/* <Link to="/">
<Button variant="secondary" className="mb-6">
Back to Events
</Button>
</Link> */}
{/* Trading Panel - Exchange-style interface */}
<div className="mb-8">
<TradingPanel
@ -120,21 +142,113 @@ export const EventDetail = () => {
/>
</div>
{/* My Other Bets - Show user's bets on other events */}
<div className="mb-8">
<MyOtherBets currentEventId={eventId} />
{/* Tabbed Section - Comments / My Bets */}
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
{/* Tabs */}
<div className="flex border-b">
<button
onClick={() => setActiveTab('comments')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
activeTab === 'comments'
? 'text-blue-600 border-b-2 border-blue-600 bg-blue-50/50'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}
>
<MessageCircle size={18} />
Comments {commentsData?.total ? `(${commentsData.total})` : ''}
</button>
<button
onClick={() => setActiveTab('mybets')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
activeTab === 'mybets'
? 'text-blue-600 border-b-2 border-blue-600 bg-blue-50/50'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}
>
<List size={18} />
My Bets
</button>
</div>
{/* Tab Content */}
<div className="p-0">
{activeTab === 'comments' ? (
<div>
{/* Comments List */}
<div className="h-80 overflow-y-auto p-4 space-y-4">
{isLoadingComments ? (
<div className="flex items-center justify-center py-8">
<Loading />
</div>
) : comments.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No comments yet. {isAuthenticated && 'Be the first to comment!'}
</div>
) : (
comments.map((comment: EventComment) => {
const isOwnComment = user?.id === comment.user_id
return (
<div
key={comment.id}
className={`flex ${isOwnComment ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[70%] rounded-lg p-3 ${
isOwnComment
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-900'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-medium ${isOwnComment ? 'text-blue-100' : 'text-gray-500'}`}>
{comment.username}
</span>
<span className={`text-xs ${isOwnComment ? 'text-blue-200' : 'text-gray-400'}`}>
{format(new Date(comment.created_at), 'p')}
</span>
</div>
<p className="text-sm">{comment.content}</p>
</div>
</div>
)
})
)}
<div ref={commentsEndRef} />
</div>
{/* Comment Input */}
{isAuthenticated ? (
<form onSubmit={handleSubmitComment} className="p-4 border-t bg-gray-50">
<div className="flex gap-2">
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Type a message..."
maxLength={500}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<Button
type="submit"
disabled={!newComment.trim() || addCommentMutation.isPending}
>
<Send className="w-4 h-4" />
</Button>
</div>
</form>
) : (
<div className="p-4 border-t bg-gray-50 text-center text-sm text-gray-500">
<a href="/login" className="text-blue-600 hover:underline">Log in</a> to join the conversation
</div>
)}
</div>
) : (
<div className="p-4">
<MyOtherBets currentEventId={eventId} />
</div>
)}
</div>
</div>
{/* Spread Grid - Visual betting grid */}
{/* <div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">Spread Grid</h2>
<SpreadGrid
event={event}
onBetCreated={handleBetCreated}
onBetTaken={handleBetTaken}
/>
</div> */}
</div>
</div>
)

View File

@ -42,6 +42,8 @@ export interface SpreadGridBet {
status: string;
team: string;
can_take: boolean;
created_at?: string;
matched_at?: string | null;
}
export type SpreadGrid = {
@ -51,3 +53,17 @@ export type SpreadGrid = {
export interface SportEventWithBets extends SportEvent {
spread_grid: SpreadGrid;
}
export interface EventComment {
id: number;
event_id: number;
user_id: number;
username: string;
content: string;
created_at: string;
}
export interface EventCommentsResponse {
comments: EventComment[];
total: number;
}