diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index bcbfcf8..30c4a99 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/event_comment.py b/backend/app/models/event_comment.py new file mode 100644 index 0000000..14589b1 --- /dev/null +++ b/backend/app/models/event_comment.py @@ -0,0 +1,18 @@ +from sqlalchemy import String, DateTime, ForeignKey, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from app.database import Base + + +class 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() diff --git a/backend/app/routers/sport_events.py b/backend/app/routers/sport_events.py index 8dc028c..b462bb5 100644 --- a/backend/app/routers/sport_events.py +++ b/backend/app/routers/sport_events.py @@ -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 diff --git a/backend/app/schemas/event_comment.py b/backend/app/schemas/event_comment.py new file mode 100644 index 0000000..50721a3 --- /dev/null +++ b/backend/app/schemas/event_comment.py @@ -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 diff --git a/backend/manage_events.py b/backend/manage_events.py new file mode 100755 index 0000000..650b8a7 --- /dev/null +++ b/backend/manage_events.py @@ -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() diff --git a/backend/simulate_activity.py b/backend/simulate_activity.py new file mode 100755 index 0000000..69b583b --- /dev/null +++ b/backend/simulate_activity.py @@ -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() diff --git a/coinex-trade.png b/coinex-trade.png new file mode 100644 index 0000000..40097f9 Binary files /dev/null and b/coinex-trade.png differ diff --git a/frontend/src/api/sport-events.ts b/frontend/src/api/sport-events.ts index 7372e98..35a01d2 100644 --- a/frontend/src/api/sport-events.ts +++ b/frontend/src/api/sport-events.ts @@ -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(`/api/v1/sport-events/${eventId}`) return response.data }, + + // Comments + getEventComments: async (eventId: number, params?: { skip?: number; limit?: number }): Promise => { + 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(url) + return response.data + }, + + addEventComment: async (eventId: number, content: string): Promise => { + const response = await apiClient.post(`/api/v1/sport-events/${eventId}/comments`, { content }) + return response.data + }, } diff --git a/frontend/src/components/bets/TradingPanel.tsx b/frontend/src/components/bets/TradingPanel.tsx index e8217ea..c86bb7a 100644 --- a/frontend/src/components/bets/TradingPanel.tsx +++ b/frontend/src/components/bets/TradingPanel.tsx @@ -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(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 (
- {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 (
- {/* Header - Event Info */} -
+ {/* Compact Header - Exchange Style */} +
-
+ {/* Left: Teams and Spread */} +
{/* Home Team */} -
-
- {event.home_team.charAt(0)} +
+
+ {event.home_team.charAt(0)} +
+
+

{event.home_team}

+

HOME

-

{event.home_team}

-

HOME

- {/* VS / Spread */} -
-
OFFICIAL SPREAD
-
+ {/* Spread */} +
+
SPREAD
+
{event.official_spread > 0 ? '+' : ''}{event.official_spread}
-
{event.league}
+
{event.league}
{/* Away Team */} -
-
- {event.away_team.charAt(0)} +
+
+ {event.away_team.charAt(0)} +
+
+

{event.away_team}

+

AWAY

-

{event.away_team}

-

AWAY

- {/* Game Time */} -
+ {/* Center: Market Stats */} +
+
+

Volume

+

${marketStats.totalVolume.toLocaleString()}

+
+
+

Open

+

{marketStats.openBets}

+
+
+ + ${marketStats.homeVolume.toLocaleString()} +
+
+ + ${marketStats.awayVolume.toLocaleString()} +
+
+ + {/* Right: Countdown */} +
- {timeUntil.text} + {timeUntil.text}
-

- {new Date(event.game_time).toLocaleDateString('en-US', { - weekday: 'short', - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - })} -

-
-
- - {/* Stats Bar */} -
-
-
- - Volume: - ${marketStats.totalVolume.toLocaleString()} -
-
- - Bets: - {marketStats.totalBets} -
-
- - Open: - {marketStats.openBets} -
-
-
-
- - ${marketStats.homeVolume.toLocaleString()} -
-
- - ${marketStats.awayVolume.toLocaleString()} +
+

+ {new Date(event.game_time).toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + })} +

+

+ {new Date(event.game_time).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + })} +

@@ -409,16 +417,16 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr {/* Main Content - fixed height container */}
{/* Left - Order Book */} -
-
-

- +
+
+

+ Order Book

- {/* Header */} -
+ {/* Column Header */} +
{event.away_team.slice(0, 4)} Vol @@ -428,15 +436,11 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr {event.home_team.slice(0, 4)}
- {/* Above official line - fills available space, auto-scrolls to bottom */} -
- {aboveLine.length > 0 ? ( - aboveLine.map(spread => renderOrderRow(spread)) - ) : ( -
- No spreads above line -
- )} + {/* Above official line - aligned to bottom */} +
+
+ {aboveLine.map(spread => renderOrderRow(spread))} +
{/* Official Line */} @@ -444,22 +448,18 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr {renderOrderRow(event.official_spread)}
- {/* Below official line - fills available space */} -
- {belowLine.length > 0 ? ( - belowLine.map(spread => renderOrderRow(spread)) - ) : ( -
- No spreads below line -
- )} + {/* Below official line - aligned to top */} +
+
+ {belowLine.map(spread => renderOrderRow(spread))} +
{/* Center - Chart/Grid with Tabs */}
{/* Tabs */} -
+
{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 */}
{event.min_spread} - Spread + Spread (half-points) +{event.max_spread}
@@ -600,7 +599,7 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr ) : ( /* Grid View */
-

All Spreads

+

All Spreads (half-points only)

{renderGridView()}
)} @@ -654,10 +653,10 @@ export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelPr {/* Spread Selection */}
- +
- */}
@@ -86,12 +119,6 @@ export const EventDetail = () => {
- {/* - - */}

Event not found

@@ -100,17 +127,12 @@ export const EventDetail = () => { ) } + const comments = commentsData?.comments || [] + return (
- {/* - - */} - {/* Trading Panel - Exchange-style interface */}
{ />
- {/* My Other Bets - Show user's bets on other events */} -
- + {/* Tabbed Section - Comments / My Bets */} +
+ {/* Tabs */} +
+ + +
+ + {/* Tab Content */} +
+ {activeTab === 'comments' ? ( +
+ {/* Comments List */} +
+ {isLoadingComments ? ( +
+ +
+ ) : comments.length === 0 ? ( +
+ No comments yet. {isAuthenticated && 'Be the first to comment!'} +
+ ) : ( + comments.map((comment: EventComment) => { + const isOwnComment = user?.id === comment.user_id + return ( +
+
+
+ + {comment.username} + + + {format(new Date(comment.created_at), 'p')} + +
+

{comment.content}

+
+
+ ) + }) + )} +
+
+ + {/* Comment Input */} + {isAuthenticated ? ( +
+
+ 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" + /> + +
+
+ ) : ( +
+ Log in to join the conversation +
+ )} +
+ ) : ( +
+ +
+ )} +
- - {/* Spread Grid - Visual betting grid */} - {/*
-

Spread Grid

- -
*/} -
) diff --git a/frontend/src/types/sport-event.ts b/frontend/src/types/sport-event.ts index 640ec42..4a376f6 100644 --- a/frontend/src/types/sport-event.ts +++ b/frontend/src/types/sport-event.ts @@ -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; +}