From 3ffd5c352cf4175b7e66373aa02a9b54ce43b8fc Mon Sep 17 00:00:00 2001 From: Butter Date: Sat, 16 May 2026 17:31:37 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20side=20bets=20=E2=80=94=20league=20memb?= =?UTF-8?q?ers=20can=20wager=20on=20weekly=20matchups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a full side-bets feature: propose bets against any league member for a specific week, accept/reject incoming bets, cancel, and settle with a winner. Commissioners can cancel or settle any bet. Changes: - DB: `side_bets` table + `side_bet_status` enum (schema.ts) - Server: sideBetsService, sideBetRoutes, wired into app.ts - Client: SideBet types, useSideBets hooks, SideBetsPage - Route: /side-bets (App.tsx) - Sidebar: Handshake icon nav link, visible when a league is selected Requires a DB migration to create the side_bets table. Co-Authored-By: Butter --- client/src/App.tsx | 2 + client/src/components/Sidebar.tsx | 21 + client/src/hooks/useSideBets.ts | 153 ++++++ client/src/pages/SideBetsPage.tsx | 690 +++++++++++++++++++++++++ client/src/types/huddle.ts | 23 + server/src/app.ts | 2 + server/src/db/schema.ts | 49 ++ server/src/routes/sideBetRoutes.ts | 135 +++++ server/src/services/sideBetsService.ts | 158 ++++++ 9 files changed, 1233 insertions(+) create mode 100644 client/src/hooks/useSideBets.ts create mode 100644 client/src/pages/SideBetsPage.tsx create mode 100644 server/src/routes/sideBetRoutes.ts create mode 100644 server/src/services/sideBetsService.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index a861e9f..441cfb6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -37,6 +37,7 @@ import { SchedulePage } from "./pages/SchedulePage"; import { DraftPage } from "./pages/DraftPage"; import { CommissionerPage } from "./pages/CommissionerPage"; import { LeagueSettingsPage } from "./pages/LeagueSettingsPage"; +import { SideBetsPage } from "./pages/SideBetsPage"; export default function App() { return ( @@ -68,6 +69,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index c8f54ff..fcd7b7a 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -40,6 +40,7 @@ import { X, Shield, Settings, + Handshake, } from "lucide-react"; import { useAppSelector } from "../store/hooks"; import { useLeagueRosters, useLeagueUsers } from "../hooks/useSleeper"; @@ -203,6 +204,26 @@ export function Sidebar({ )} + {/* Side Bets — visible to all members when a league is selected */} + {selectedLeagueId && ( + + `flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors + ${ + isActive + ? "bg-highlight text-ink" + : "text-muted hover:bg-highlight hover:text-ink" + } + ${renderCollapsed ? "justify-center" : ""} + ` + } + > + + {!renderCollapsed && Side Bets} + + )} + {/* League settings — visible to all members when a league is selected */} {selectedLeagueId && ( { + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +function errorMessage(err: unknown, fallback: string): string { + if (err instanceof AxiosError) { + const data = err.response?.data as { error?: string } | undefined; + return data?.error ?? err.message ?? fallback; + } + return fallback; +} + +/** All bets for a huddle, optionally filtered to a single week. */ +export function useSideBets(huddleId: string | null, week?: number) { + const { getToken } = useAuth(); + return useQuery({ + queryKey: ["side-bets", huddleId, week ?? null], + queryFn: async () => { + const token = await getToken(); + const params = week !== undefined ? { week } : {}; + const res = await axios.get<{ bets: SideBet[] }>( + `/api/huddles/${huddleId}/bets`, + { params, headers: authHeader(token) }, + ); + return res.data.bets; + }, + enabled: !!huddleId, + staleTime: 30 * 1000, + }); +} + +export function useProposeBet() { + const { getToken } = useAuth(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (input: { + huddleId: string; + opponentId: string; + proposerRosterId?: number; + opponentRosterId?: number; + week: number; + season: string; + description: string; + amount: number; + }) => { + const token = await getToken(); + try { + const res = await axios.post<{ bet: SideBet }>( + `/api/huddles/${input.huddleId}/bets`, + { + opponentId: input.opponentId, + proposerRosterId: input.proposerRosterId, + opponentRosterId: input.opponentRosterId, + week: input.week, + season: input.season, + description: input.description, + amount: input.amount, + }, + { headers: authHeader(token) }, + ); + return res.data.bet; + } catch (err) { + throw new Error(errorMessage(err, "Failed to propose bet")); + } + }, + onSuccess: (_bet, variables) => { + queryClient.invalidateQueries({ queryKey: ["side-bets", variables.huddleId] }); + }, + }); +} + +export function useRespondToBet() { + const { getToken } = useAuth(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (input: { + huddleId: string; + betId: string; + response: "accepted" | "rejected"; + }) => { + const token = await getToken(); + try { + const res = await axios.patch<{ bet: SideBet }>( + `/api/huddles/${input.huddleId}/bets/${input.betId}`, + { action: input.response }, + { headers: authHeader(token) }, + ); + return res.data.bet; + } catch (err) { + throw new Error(errorMessage(err, "Failed to respond to bet")); + } + }, + onSuccess: (_bet, variables) => { + queryClient.invalidateQueries({ queryKey: ["side-bets", variables.huddleId] }); + }, + }); +} + +export function useCancelBet() { + const { getToken } = useAuth(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (input: { huddleId: string; betId: string }) => { + const token = await getToken(); + try { + const res = await axios.patch<{ bet: SideBet }>( + `/api/huddles/${input.huddleId}/bets/${input.betId}`, + { action: "cancelled" }, + { headers: authHeader(token) }, + ); + return res.data.bet; + } catch (err) { + throw new Error(errorMessage(err, "Failed to cancel bet")); + } + }, + onSuccess: (_bet, variables) => { + queryClient.invalidateQueries({ queryKey: ["side-bets", variables.huddleId] }); + }, + }); +} + +export function useSettleBet() { + const { getToken } = useAuth(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (input: { + huddleId: string; + betId: string; + winnerId: string; + settlementNote?: string; + }) => { + const token = await getToken(); + try { + const res = await axios.patch<{ bet: SideBet }>( + `/api/huddles/${input.huddleId}/bets/${input.betId}`, + { action: "settled", winnerId: input.winnerId, settlementNote: input.settlementNote }, + { headers: authHeader(token) }, + ); + return res.data.bet; + } catch (err) { + throw new Error(errorMessage(err, "Failed to settle bet")); + } + }, + onSuccess: (_bet, variables) => { + queryClient.invalidateQueries({ queryKey: ["side-bets", variables.huddleId] }); + }, + }); +} diff --git a/client/src/pages/SideBetsPage.tsx b/client/src/pages/SideBetsPage.tsx new file mode 100644 index 0000000..001e589 --- /dev/null +++ b/client/src/pages/SideBetsPage.tsx @@ -0,0 +1,690 @@ +/** + * SideBetsPage — league members propose and track side bets on weekly matchups. + * + * Route: /side-bets + * + * Anyone with an approved claim in the huddle can: + * - Propose a bet against another member for any week + * - Accept or reject incoming bets + * - Cancel a pending bet they proposed (or cancel an accepted bet) + * - Settle an accepted bet by declaring the winner + * + * Commissioners can cancel or settle any bet. + */ +import { useMemo, useState } from "react"; +import { useUser } from "@clerk/clerk-react"; +import { Navigate } from "react-router-dom"; +import { Handshake, Plus, X, ChevronDown, ChevronUp } from "lucide-react"; +import { useAppSelector } from "../store/hooks"; +import { useLeagueRosters, useLeagueUsers, useNFLState } from "../hooks/useSleeper"; +import { useSelectedLeagueHuddle, useHuddleDetail } from "../hooks/useHuddles"; +import { + useSideBets, + useProposeBet, + useRespondToBet, + useCancelBet, + useSettleBet, +} from "../hooks/useSideBets"; +import type { SideBet } from "../types/huddle"; + +// ── Shared primitives ───────────────────────────────────────────────────────── + +function Panel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function PanelHeader({ title, description }: { title: string; description?: string }) { + return ( +
+

{title}

+ {description && ( +

{description}

+ )} +
+ ); +} + +function Btn({ + children, + onClick, + disabled, + danger, + variant = "default", +}: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + danger?: boolean; + variant?: "default" | "primary"; +}) { + if (variant === "primary") { + return ( + + ); + } + return ( + + ); +} + +// ── Status badge ────────────────────────────────────────────────────────────── + +const STATUS_STYLES: Record = { + pending: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300", + accepted: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300", + rejected: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300", + cancelled: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400", + settled: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300", +}; + +function StatusBadge({ status }: { status: string }) { + return ( + + {status} + + ); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function formatAmount(cents: number): string { + if (cents === 0) return "Bragging rights"; + const dollars = cents / 100; + return `$${dollars % 1 === 0 ? dollars.toFixed(0) : dollars.toFixed(2)}`; +} + +// ── New Bet Form ────────────────────────────────────────────────────────────── + +interface OpponentOption { + clerkUserId: string; + rosterId: number; + displayName: string; +} + +function NewBetForm({ + huddleId, + currentWeek, + season, + opponents, + myRosterId, + onClose, +}: { + huddleId: string; + currentWeek: number; + season: string; + opponents: OpponentOption[]; + myRosterId: number | null; + onClose: () => void; +}) { + const [opponentId, setOpponentId] = useState(opponents[0]?.clerkUserId ?? ""); + const [week, setWeek] = useState(currentWeek); + const [description, setDescription] = useState(""); + const [amountDollars, setAmountDollars] = useState(""); + const [error, setError] = useState(null); + + const proposeBet = useProposeBet(); + + const selectedOpponent = opponents.find((o) => o.clerkUserId === opponentId); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + const amount = amountDollars === "" ? 0 : Math.round(parseFloat(amountDollars) * 100); + if (amountDollars !== "" && (isNaN(amount) || amount < 0)) { + setError("Enter a valid dollar amount (or leave blank for bragging rights)."); + return; + } + + try { + await proposeBet.mutateAsync({ + huddleId, + opponentId, + proposerRosterId: myRosterId ?? undefined, + opponentRosterId: selectedOpponent?.rosterId, + week, + season, + description: description.trim(), + amount, + }); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to propose bet."); + } + } + + return ( + + + +
+ {/* Opponent */} +
+ + +
+ + {/* Week */} +
+ + setWeek(Number(e.target.value))} + className="border border-line rounded-md px-3 py-1.5 text-sm bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-ink/30 w-24" + required + /> +
+ + {/* Amount */} +
+ +
+ $ + setAmountDollars(e.target.value)} + className="border border-line rounded-md px-3 py-1.5 text-sm bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-ink/30 w-32" + /> +
+
+ + {/* Description */} +
+ +