diff --git a/client/src/hooks/useHuddles.ts b/client/src/hooks/useHuddles.ts index b956bab..f49070d 100644 --- a/client/src/hooks/useHuddles.ts +++ b/client/src/hooks/useHuddles.ts @@ -552,3 +552,49 @@ export function useDeleteAward() { }, }); } + +// ── Payouts ─────────────────────────────────────────────────────────────────── + +import type { PayoutEntry } from "../types/huddle"; + +export function usePayouts(huddleId: string | null) { + const { getToken } = useAuth(); + return useQuery({ + queryKey: ["payouts", huddleId], + queryFn: async () => { + const token = await getToken(); + const res = await axios.get<{ entries: PayoutEntry[] }>( + `/api/huddles/${huddleId}/payouts`, + { headers: authHeader(token) }, + ); + return res.data.entries; + }, + enabled: !!huddleId, + staleTime: 60 * 1000, + }); +} + +export function useSetPayouts() { + const { getToken } = useAuth(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + huddleId, + entries, + }: { + huddleId: string; + entries: Array<{ label: string; amount: number }>; + }) => { + const token = await getToken(); + const res = await axios.put<{ entries: PayoutEntry[] }>( + `/api/huddles/${huddleId}/payouts`, + { entries }, + { headers: authHeader(token) }, + ); + return res.data.entries; + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ["payouts", variables.huddleId] }); + }, + }); +} diff --git a/client/src/pages/CommissionerPage.tsx b/client/src/pages/CommissionerPage.tsx index f7d34b1..e715fd8 100644 --- a/client/src/pages/CommissionerPage.tsx +++ b/client/src/pages/CommissionerPage.tsx @@ -19,7 +19,7 @@ */ import { useState, useMemo } from "react"; import { Navigate, useNavigate } from "react-router-dom"; -import { Megaphone, DollarSign, Trophy, Award } from "lucide-react"; +import { Megaphone, DollarSign, Trophy, Award, Plus, Trash2 } from "lucide-react"; import { useAppSelector } from "../store/hooks"; import { useLeagueUsers, useLeagueRosters } from "../hooks/useSleeper"; import { @@ -41,6 +41,8 @@ import { useAwards, useCreateAward, useDeleteAward, + usePayouts, + useSetPayouts, } from "../hooks/useHuddles"; import type { Roster, TeamUser } from "../types/fantasy"; import type { @@ -1057,6 +1059,115 @@ function CustomAwardsPanel({ } +// ─── Payout structure panel ────────────────────────────────────────────────── + +const DEFAULT_ENTRIES = [ + { label: "1st Place", amount: 0 }, + { label: "2nd Place", amount: 0 }, + { label: "3rd Place", amount: 0 }, +]; + +function PayoutStructurePanel({ huddleId }: { huddleId: string }) { + const { data: saved } = usePayouts(huddleId); + const setPayouts = useSetPayouts(); + + const [entries, setEntries] = useState>( + () => DEFAULT_ENTRIES, + ); + const [initialized, setInitialized] = useState(false); + + useMemo(() => { + if (saved !== undefined && !initialized) { + setEntries( + saved.length > 0 + ? saved.map((e) => ({ label: e.label, amount: e.amount })) + : DEFAULT_ENTRIES, + ); + setInitialized(true); + } + }, [saved, initialized]); + + const totalCents = entries.reduce((s, e) => s + (e.amount || 0), 0); + const fmt = (cents: number) => + `$${(cents / 100).toLocaleString("en-US", { minimumFractionDigits: 2 })}`; + + const updateEntry = (i: number, field: "label" | "amount", val: string) => { + setEntries((prev) => + prev.map((e, idx) => + idx === i + ? { ...e, [field]: field === "amount" ? Math.round(parseFloat(val || "0") * 100) : val } + : e, + ), + ); + }; + + const addEntry = () => setEntries((prev) => [...prev, { label: "", amount: 0 }]); + const removeEntry = (i: number) => setEntries((prev) => prev.filter((_, idx) => idx !== i)); + const handleSave = () => setPayouts.mutate({ huddleId, entries }); + + return ( + + +
+ {entries.map((e, i) => ( +
+ updateEntry(i, "label", ev.target.value)} + placeholder="Label (e.g. 1st Place)" + maxLength={120} + className="flex-1 text-[13px] font-sans border border-line rounded-md px-3 py-1.5 bg-paper text-ink" + /> +
+ $ + updateEntry(i, "amount", ev.target.value)} + placeholder="0.00" + className="w-24 text-[13px] font-sans border border-line rounded-md pl-6 pr-2 py-1.5 bg-paper text-ink" + /> +
+ +
+ ))} +
+
+ + Add entry + + + Total pool: {fmt(totalCents)} + +
+
+ {setPayouts.isError && ( +

+ {(setPayouts.error as Error).message} +

+ )} + {setPayouts.isSuccess && ( +

Saved!

+ )} + + {setPayouts.isPending ? "Saving…" : "Save payouts"} + +
+
+ ); +} + // ─── Coming-soon stub section ───────────────────────────────────────────────── function StubSection({ @@ -1163,12 +1274,16 @@ export function CommissionerPage() { tag="Finance" /> )} - + {huddle ? ( + + ) : ( + + )} {huddle ? ( s + e.amount, 0); + const fmt = (cents: number) => + `$${(cents / 100).toLocaleString("en-US", { minimumFractionDigits: 2 })}`; + + return ( + + +
+ {entries.map((e) => ( +
+ {e.label} + + {fmt(e.amount)} + +
+ ))} +
+
+ + Total pool + + + {fmt(totalCents)} + +
+
+ ); +} + /** Read-only display of all huddle awards — visible to all members. */ function AwardsSection({ huddleId, @@ -500,6 +537,9 @@ export function LeagueSettingsPage() { )} + {/* Payout structure — read-only for members */} + {huddle && } + {/* Awards — read-only, only shown when awards exist */} {huddle && rosters && leagueUsers && ( huddles.id, { onDelete: "cascade" }), + label: text("label").notNull(), + amount: integer("amount").notNull().default(0), + sortOrder: integer("sort_order").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (t) => ({ + byHuddle: index("huddle_payout_entries_huddle_idx").on(t.huddleId), + }), +); + +export type HuddlePayoutEntry = typeof huddlePayoutEntries.$inferSelect; +export type NewHuddlePayoutEntry = typeof huddlePayoutEntries.$inferInsert; diff --git a/server/src/routes/huddleRoutes.ts b/server/src/routes/huddleRoutes.ts index 5b75b38..fbaf130 100644 --- a/server/src/routes/huddleRoutes.ts +++ b/server/src/routes/huddleRoutes.ts @@ -37,6 +37,7 @@ import { createAward, deleteAward, } from "../services/awardsService.js"; +import { listPayouts, setPayouts } from "../services/payoutsService.js"; const clerkSecretKey = process.env["CLERK_SECRET_KEY"]; if (!clerkSecretKey) { @@ -765,4 +766,38 @@ export function initHuddleRoutes(app: Express) { } }, ); + + // GET /api/huddles/:id/payouts — any authenticated member + app.get( + "/api/huddles/:id/payouts", + requireAuth, + async (req: Request, res: Response) => { + try { + const entries = await listPayouts(req.params.id!); + res.json({ entries }); + } catch (err) { + handleError(err, res); + } + }, + ); + + // PUT /api/huddles/:id/payouts — commissioner only, replaces all entries + app.put( + "/api/huddles/:id/payouts", + requireAuth, + async (req: Request, res: Response) => { + try { + const { userId } = getAuth(req); + const { entries } = req.body as { entries: Array<{ label: string; amount: number }> }; + if (!Array.isArray(entries)) { + res.status(400).json({ error: "entries must be an array" }); + return; + } + const saved = await setPayouts(req.params.id!, userId!, entries); + res.json({ entries: saved }); + } catch (err) { + handleError(err, res); + } + }, + ); } diff --git a/server/src/services/payoutsService.ts b/server/src/services/payoutsService.ts new file mode 100644 index 0000000..ecb433b --- /dev/null +++ b/server/src/services/payoutsService.ts @@ -0,0 +1,74 @@ +/** + * payoutsService.ts + * + * Manages payout structure entries for a huddle. The commissioner defines + * how the prize pool is split (1st place, 2nd place, special awards, etc.) + * by replacing the full list in a single PUT. Members can view it read-only. + */ +import { eq, asc } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { huddlePayoutEntries } from "../db/schema.js"; +import { isCommissioner, HuddlesServiceError } from "./huddlesService.js"; + +const fail = (status: number, msg: string): never => { + throw new HuddlesServiceError(status, msg); +}; + +export interface PayoutEntryInput { + label: string; + /** Amount in cents. */ + amount: number; +} + +export async function listPayouts(huddleId: string) { + return db + .select() + .from(huddlePayoutEntries) + .where(eq(huddlePayoutEntries.huddleId, huddleId)) + .orderBy(asc(huddlePayoutEntries.sortOrder), asc(huddlePayoutEntries.createdAt)); +} + +/** + * Replaces all payout entries for a huddle. Commissioner-only. + * Validates labels (non-empty, ≤120 chars) and amounts (≥0). + */ +export async function setPayouts( + huddleId: string, + userId: string, + entries: PayoutEntryInput[], +) { + if (!(await isCommissioner(huddleId, userId))) { + fail(403, "Only commissioners can update the payout structure"); + } + + if (entries.length > 50) { + fail(400, "Maximum 50 payout entries"); + } + + for (const e of entries) { + if (!e.label?.trim() || e.label.trim().length > 120) { + fail(400, "Each label must be 1–120 characters"); + } + if (!Number.isInteger(e.amount) || e.amount < 0) { + fail(400, "Amount must be a non-negative integer (cents)"); + } + } + + // Full replace: delete existing then insert new set. + await db + .delete(huddlePayoutEntries) + .where(eq(huddlePayoutEntries.huddleId, huddleId)); + + if (entries.length > 0) { + await db.insert(huddlePayoutEntries).values( + entries.map((e, i) => ({ + huddleId, + label: e.label.trim(), + amount: e.amount, + sortOrder: i, + })), + ); + } + + return listPayouts(huddleId); +}