From ebd5e834c797158d7e358a264ba8c830e75b0c76 Mon Sep 17 00:00:00 2001 From: Butter Date: Wed, 13 May 2026 00:20:59 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20custom=20awards=20=E2=80=94=20commissio?= =?UTF-8?q?ner=20grants=20glyph+color=20badges=20to=20teams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration: 0005_awards.sql (renamed from 0004 to avoid collision with announcements/dues migrations already merged into main) Schema: huddle_awards (glyph, color hex, title, description, rosterId, season) Server: awardsService + GET/POST/DELETE /api/huddles/:id/awards routes Client: - useAwards/useCreateAward/useDeleteAward hooks - CustomAwardsPanel in CommissionerPage: glyph input, 10-color palette, team selector, granted awards list with delete confirm - AwardsSection read-only in LeagueSettingsPage (badge strip, hidden if empty) - HuddleAwardsStrip in TeamPage TrophyRoom section --- client/src/hooks/useHuddles.ts | 67 +++++ client/src/pages/CommissionerPage.tsx | 310 +++++++++++++++++++++++- client/src/pages/LeagueSettingsPage.tsx | 68 +++++- client/src/pages/TeamPage.tsx | 77 +++++- client/src/types/huddle.ts | 12 + server/drizzle/0005_awards.sql | 17 ++ server/src/db/schema.ts | 32 +++ server/src/routes/huddleRoutes.ts | 72 ++++++ server/src/services/awardsService.ts | 187 ++++++++++++++ 9 files changed, 829 insertions(+), 13 deletions(-) create mode 100644 server/drizzle/0005_awards.sql create mode 100644 server/src/services/awardsService.ts diff --git a/client/src/hooks/useHuddles.ts b/client/src/hooks/useHuddles.ts index 4445a9c..b956bab 100644 --- a/client/src/hooks/useHuddles.ts +++ b/client/src/hooks/useHuddles.ts @@ -485,3 +485,70 @@ export function useDeleteAnnouncement() { }, }); } + +// ── Awards ──────────────────────────────────────────────────────────────────── + +import type { HuddleAward } from "../types/huddle"; + +export function useAwards(huddleId: string | null, rosterId?: number) { + const { getToken } = useAuth(); + return useQuery({ + queryKey: ["awards", huddleId, rosterId ?? null], + queryFn: async () => { + const token = await getToken(); + const params = rosterId !== undefined ? { rosterId } : {}; + const res = await axios.get<{ awards: HuddleAward[] }>( + `/api/huddles/${huddleId}/awards`, + { params, headers: authHeader(token) }, + ); + return res.data.awards; + }, + enabled: !!huddleId, + staleTime: 60 * 1000, + }); +} + +export function useCreateAward() { + const { getToken } = useAuth(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (input: { + huddleId: string; + rosterId: number; + glyph: string; + color: string; + title: string; + description?: string; + season?: string; + }) => { + const token = await getToken(); + const res = await axios.post<{ award: HuddleAward }>( + `/api/huddles/${input.huddleId}/awards`, + { rosterId: input.rosterId, glyph: input.glyph, color: input.color, + title: input.title, description: input.description, season: input.season }, + { headers: authHeader(token) }, + ); + return res.data.award; + }, + onSuccess: (_award, variables) => { + queryClient.invalidateQueries({ queryKey: ["awards", variables.huddleId] }); + }, + }); +} + +export function useDeleteAward() { + const { getToken } = useAuth(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (input: { huddleId: string; awardId: string }) => { + const token = await getToken(); + await axios.delete( + `/api/huddles/${input.huddleId}/awards/${input.awardId}`, + { headers: authHeader(token) }, + ); + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ["awards", variables.huddleId] }); + }, + }); +} diff --git a/client/src/pages/CommissionerPage.tsx b/client/src/pages/CommissionerPage.tsx index 4d79a85..f7d34b1 100644 --- a/client/src/pages/CommissionerPage.tsx +++ b/client/src/pages/CommissionerPage.tsx @@ -38,11 +38,15 @@ import { useDues, useSetDuesConfig, useSetDuesPaid, + useAwards, + useCreateAward, + useDeleteAward, } from "../hooks/useHuddles"; import type { Roster, TeamUser } from "../types/fantasy"; import type { CommissionerSummary, HuddleClaimSummary, + HuddleAward, UserSummary, } from "../types/huddle"; @@ -767,6 +771,292 @@ function DuesTrackerPanel({ ); } + +/** Preset colour swatches commissioners can choose from. */ + +/** Preset colour swatches commissioners can choose from. */ +const AWARD_COLORS = [ + { hex: "#ef4444", label: "Red" }, + { hex: "#f97316", label: "Orange" }, + { hex: "#eab308", label: "Yellow" }, + { hex: "#22c55e", label: "Green" }, + { hex: "#3b82f6", label: "Blue" }, + { hex: "#8b5cf6", label: "Purple" }, + { hex: "#ec4899", label: "Pink" }, + { hex: "#6b7280", label: "Gray" }, + { hex: "#f59e0b", label: "Amber" }, + { hex: "#14b8a6", label: "Teal" }, +]; + +/** A single award displayed as a colour-coded badge with a delete button. */ +function AwardBadge({ + award, + teamName, + onDelete, + deleting, +}: { + award: HuddleAward; + teamName: string; + onDelete: () => void; + deleting: boolean; +}) { + return ( +
+
+ {/* Coloured glyph chip */} + + {award.glyph} + +
+

+ {award.title} +

+

+ {teamName} + {award.season ? ` · ${award.season}` : ""} +

+ {award.description && ( +

+ {award.description} +

+ )} +
+
+ + Remove + +
+ ); +} + +function CustomAwardsPanel({ + huddleId, + rosters, + leagueUsers, +}: { + huddleId: string; + rosters: Roster[]; + leagueUsers: TeamUser[]; +}) { + const awardsQuery = useAwards(huddleId); + const createAward = useCreateAward(); + const deleteAward = useDeleteAward(); + + // Form state + const [glyph, setGlyph] = useState("🏆"); + const [color, setColor] = useState(AWARD_COLORS[4]!.hex); // blue default + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [rosterId, setRosterId] = useState(""); + const [season, setSeason] = useState(""); + + /** Build a display name for a roster. */ + function rosterLabel(r: Roster): string { + return rosterTeamName(r, leagueUsers); + } + + const sortedRosters = useMemo( + () => [...rosters].sort((a, b) => a.rosterId - b.rosterId), + [rosters], + ); + + function teamNameForRosterId(id: number): string { + const r = rosters.find((x) => x.rosterId === id); + return r ? rosterLabel(r) : `Team ${id}`; + } + + function handleSubmit() { + if (!rosterId || !title.trim() || !glyph.trim()) return; + createAward.mutate( + { + huddleId, + rosterId: rosterId as number, + glyph: glyph.trim(), + color, + title: title.trim(), + description: description.trim() || undefined, + season: season.trim() || undefined, + }, + { + onSuccess: () => { + setGlyph("🏆"); + setColor(AWARD_COLORS[4]!.hex); + setTitle(""); + setDescription(""); + setRosterId(""); + setSeason(""); + }, + }, + ); + } + + const awards = awardsQuery.data ?? []; + + return ( + + + + {/* ── Create form ─────────────────────────────────────────────── */} +
+ {/* Glyph + preview row */} +
+
+

+ Glyph +

+ setGlyph(e.target.value.slice(0, 4))} + maxLength={4} + className="w-16 text-center text-2xl border border-line rounded-md py-1.5 bg-paper text-ink font-sans" + placeholder="🏆" + /> +
+ {/* Large preview chip */} +
+ {glyph || "?"} +
+
+

+ Colour +

+
+ {AWARD_COLORS.map((c) => ( +
+

{color}

+
+
+ + {/* Title + team selector row */} +
+
+

+ Title +

+ setTitle(e.target.value.slice(0, 80))} + maxLength={80} + className="w-full text-[13px] font-sans border border-line rounded-md px-3 py-1.5 bg-paper text-ink" + placeholder="e.g. Sacko, Most Improved…" + /> +
+
+

+ Season +

+ setSeason(e.target.value.slice(0, 10))} + maxLength={10} + className="w-20 text-[13px] font-sans border border-line rounded-md px-2 py-1.5 bg-paper text-ink" + placeholder={String(new Date().getFullYear())} + /> +
+
+ + {/* Team selector */} +
+

+ Recipient Team +

+ +
+ + {/* Description */} +
+

+ Description{" "} + (optional) +

+