Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions client/src/hooks/useHuddles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] });
},
});
}
129 changes: 122 additions & 7 deletions client/src/pages/CommissionerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -41,6 +41,8 @@ import {
useAwards,
useCreateAward,
useDeleteAward,
usePayouts,
useSetPayouts,
} from "../hooks/useHuddles";
import type { Roster, TeamUser } from "../types/fantasy";
import type {
Expand Down Expand Up @@ -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<Array<{ label: string; amount: number }>>(
() => 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 (
<Panel>
<PanelHeader
title="Payout Structure"
description="Define how the prize pool is distributed. Amounts are for reference — Huddle doesn't process payments."
/>
<div className="flex flex-col gap-2">
{entries.map((e, i) => (
<div key={i} className="flex items-center gap-2">
<input
value={e.label}
onChange={(ev) => 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"
/>
<div className="relative shrink-0">
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[13px] text-muted font-sans">$</span>
<input
type="number"
min={0}
step={0.01}
value={e.amount / 100 || ""}
onChange={(ev) => 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"
/>
</div>
<button
onClick={() => removeEntry(i)}
className="text-muted hover:text-red-600 transition-colors p-1"
aria-label="Remove entry"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
<div className="flex items-center justify-between pt-1">
<Btn onClick={addEntry}>
<Plus size={13} className="mr-1" /> Add entry
</Btn>
<span className="text-[12px] font-mono text-muted">
Total pool: <span className="text-ink font-semibold">{fmt(totalCents)}</span>
</span>
</div>
<div className="flex items-center justify-end gap-3 border-t border-line pt-3">
{setPayouts.isError && (
<p className="text-[11.5px] text-red-600 font-sans flex-1">
{(setPayouts.error as Error).message}
</p>
)}
{setPayouts.isSuccess && (
<p className="text-[11.5px] text-accent font-sans">Saved!</p>
)}
<BtnPrimary onClick={handleSave} disabled={setPayouts.isPending}>
{setPayouts.isPending ? "Saving…" : "Save payouts"}
</BtnPrimary>
</div>
</Panel>
);
}

// ─── Coming-soon stub section ─────────────────────────────────────────────────

function StubSection({
Expand Down Expand Up @@ -1163,12 +1274,16 @@ export function CommissionerPage() {
tag="Finance"
/>
)}
<StubSection
icon={Trophy}
title="Payout Structure"
description="Define how the prize pool is distributed — 1st, 2nd, 3rd place, most points, best regular-season record, or any split you like."
tag="Finance"
/>
{huddle ? (
<PayoutStructurePanel huddleId={huddle.id} />
) : (
<StubSection
icon={Trophy}
title="Payout Structure"
description="Define how the prize pool is distributed — 1st, 2nd, 3rd place, most points, best regular-season record, or any split you like."
tag="Finance"
/>
)}
{huddle ? (
<CustomAwardsPanel
huddleId={huddle.id}
Expand Down
40 changes: 40 additions & 0 deletions client/src/pages/LeagueSettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
useSubmitClaim,
useRemoveClaim,
useAwards,
usePayouts,
} from "../hooks/useHuddles";
import {
Tooltip,
Expand Down Expand Up @@ -134,6 +135,42 @@ function describeUser(u: { id: string; username: string | null; email: string |
return u.username ?? u.email ?? u.id;
}

function PayoutsReadOnly({ huddleId }: { huddleId: string }) {
const { data: entries } = usePayouts(huddleId);
if (!entries || entries.length === 0) return null;

const totalCents = entries.reduce((s, e) => s + e.amount, 0);
const fmt = (cents: number) =>
`$${(cents / 100).toLocaleString("en-US", { minimumFractionDigits: 2 })}`;

return (
<Panel>
<PanelHeader
title="Payout Structure"
description="How the prize pool is distributed this season."
/>
<div className="flex flex-col divide-y divide-line">
{entries.map((e) => (
<div key={e.id} className="flex items-center justify-between py-2.5">
<span className="text-[13px] font-sans text-ink">{e.label}</span>
<span className="text-[13px] font-mono font-semibold text-ink">
{fmt(e.amount)}
</span>
</div>
))}
</div>
<div className="flex justify-between items-center pt-1 border-t border-line">
<span className="text-[11px] font-bold uppercase tracking-widest text-muted font-sans">
Total pool
</span>
<span className="text-[13px] font-mono font-semibold text-ink">
{fmt(totalCents)}
</span>
</div>
</Panel>
);
}

/** Read-only display of all huddle awards — visible to all members. */
function AwardsSection({
huddleId,
Expand Down Expand Up @@ -500,6 +537,9 @@ export function LeagueSettingsPage() {
</div>
)}

{/* Payout structure — read-only for members */}
{huddle && <PayoutsReadOnly huddleId={huddle.id} />}

{/* Awards — read-only, only shown when awards exist */}
{huddle && rosters && leagueUsers && (
<AwardsSection
Expand Down
10 changes: 10 additions & 0 deletions client/src/types/huddle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,13 @@ export interface HuddleAward {
season: string | null;
createdAt: string;
}

export interface PayoutEntry {
id: string;
label: string;
/** Amount in cents. */
amount: number;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
15 changes: 15 additions & 0 deletions server/drizzle/0005_payouts.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Payout structure entries for a huddle.
-- Each row is one line item (e.g. "1st Place $200", "Sacko -$20").
-- Commissioner replaces all entries in one PUT (delete + re-insert).
CREATE TABLE IF NOT EXISTS huddle_payout_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
huddle_id UUID NOT NULL REFERENCES huddles(id) ON DELETE CASCADE,
label TEXT NOT NULL,
amount INTEGER NOT NULL DEFAULT 0, -- cents
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS huddle_payout_entries_huddle_idx
ON huddle_payout_entries(huddle_id);
27 changes: 27 additions & 0 deletions server/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,30 @@ export const huddleAwards = pgTable(

export type HuddleAward = typeof huddleAwards.$inferSelect;
export type NewHuddleAward = typeof huddleAwards.$inferInsert;

// ── Payout structure ──────────────────────────────────────────────────────────

export const huddlePayoutEntries = pgTable(
"huddle_payout_entries",
{
id: uuid("id").defaultRandom().primaryKey(),
huddleId: uuid("huddle_id")
.notNull()
.references(() => 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;
35 changes: 35 additions & 0 deletions server/src/routes/huddleRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
},
);
}
Loading