From cd7ab01688c6c9537aebf31e87c53abe0fe23cf9 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Thu, 21 May 2026 10:56:18 +0100 Subject: [PATCH] fix(rewards): show track winners on the organizer rewards page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rewards page was filtering winners by `submission.rank` only, so on the track-based prize structure it silently dropped every track winner. For the Boundless × Trustless Work hackathon (3 overall + 5 track tiers), the page rendered 3 of 8 winners and the publish wizard preview showed 3 of 8 prize tiers paired. Same root cause as boundlessfi/boundless-nestjs#132: track winners live on `SubmissionTrackEntry.wonRank`, not on `submission.rank`, and the rewards UI was rank-keyed end to end. Changes: - Extend `Submission` with optional track-winner fields (`isTrackWinner`, `trackId`, `trackName`, `trackPrize`, `trackWonRank`). - `useHackathonRewards` now fetches `getHackathonWinners` in a dedicated effect that runs once results are published. The `trackWinners` payload is stamped onto matching submissions AND returned raw so the page can render a per-track section. - `useHackathonRewards` also preserves `tier.kind` and `tier.trackId` on the mapped `prizeTiers` and re-sorts so track tiers no longer collapse to the 999 fallback rank. - `rewards/page.tsx` winners filter now ORs `s.isTrackWinner` with the rank-based check, and `maxRank` counts only OVERALL tiers so the rank-keyed lookups don't get polluted with synthetic track ranks. - New `TrackWinnersSection` component renders below the existing rank-based `PodiumSection`. Mirrors the public WinnersTab pattern: one card per track with prize chip, project name, team, avatars. - `PublishWinnersWizard` includes track winners in its preview list and threads `kind`/`trackId` through to `WinnersGrid`. - `WinnersGrid` now renders overall + track tiers as separate sections. Overall keeps the 2-1-3 podium re-order; track tiers render in display order. Each tier is matched to its winner via rank OR trackId. Pairs with boundless-nestjs#132 (BE trigger endpoint already respects track winners after that merged). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hackathons/[hackathonId]/rewards/page.tsx | 14 +- .../hackathons/rewards/PreviewStep.tsx | 3 + .../rewards/PublishWinnersWizard.tsx | 28 ++- .../hackathons/rewards/RewardsPageContent.tsx | 10 ++ .../rewards/TrackWinnersSection.tsx | 114 ++++++++++++ .../hackathons/rewards/WinnersGrid.tsx | 170 +++++++++++++----- .../organization/hackathons/rewards/types.ts | 11 ++ hooks/use-hackathon-rewards.ts | 133 ++++++++++++-- 8 files changed, 419 insertions(+), 64 deletions(-) create mode 100644 components/organization/hackathons/rewards/TrackWinnersSection.tsx diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/rewards/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/rewards/page.tsx index e97849a2..ae88666f 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/rewards/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/rewards/page.tsx @@ -31,6 +31,7 @@ export default function RewardsPage() { refetchHackathon, resultsPublished, hackathon, + trackWinners, } = useHackathonRewards(organizationId, hackathonId); const { handleRankChange } = useRankAssignment(); @@ -43,9 +44,17 @@ export default function RewardsPage() { refetch: refetchDistributionStatus, } = useRewardDistributionStatus(organizationId, hackathonId); - const maxRank = useMemo(() => prizeTiers.length, [prizeTiers.length]); + // `maxRank` is the number of OVERALL prize tier slots; track tiers + // are not rank-numbered so they don't contribute to this cap. The + // rank-based rendering (podium, rank-keyed lookups) still uses + // `maxRank`. Track winners flow through `isTrackWinner` instead. + const maxRank = useMemo( + () => prizeTiers.filter(t => !t.kind || t.kind === 'OVERALL').length, + [prizeTiers] + ); const winners = useMemo( - () => submissions.filter(s => s.rank && s.rank <= maxRank), + () => + submissions.filter(s => (s.rank && s.rank <= maxRank) || s.isTrackWinner), [submissions, maxRank] ); const hasWinners = winners.length > 0; @@ -121,6 +130,7 @@ export default function RewardsPage() { onRefreshDistributionStatus={refetchDistributionStatus} resultsPublished={resultsPublished} escrowAddress={hackathon?.escrowAddress || hackathon?.contractId} + trackWinners={trackWinners} /> )} diff --git a/components/organization/hackathons/rewards/PreviewStep.tsx b/components/organization/hackathons/rewards/PreviewStep.tsx index 0cac8ebf..8282a385 100644 --- a/components/organization/hackathons/rewards/PreviewStep.tsx +++ b/components/organization/hackathons/rewards/PreviewStep.tsx @@ -11,6 +11,9 @@ interface PreviewStepProps { rank: number; prizeAmount: string; currency: string; + place?: string; + kind?: 'OVERALL' | 'TRACK'; + trackId?: string; }>; announcement: string; onEditAnnouncement: () => void; diff --git a/components/organization/hackathons/rewards/PublishWinnersWizard.tsx b/components/organization/hackathons/rewards/PublishWinnersWizard.tsx index d364a6d6..cd68ac0d 100644 --- a/components/organization/hackathons/rewards/PublishWinnersWizard.tsx +++ b/components/organization/hackathons/rewards/PublishWinnersWizard.tsx @@ -40,12 +40,25 @@ export default function PublishWinnersWizard({ hackathonId, onSuccess, }: PublishWinnersWizardProps) { - const maxRank = prizeTiers.length; + // `maxRank` only counts OVERALL slots — track tiers don't have + // numeric ranks. The previous code used `prizeTiers.length`, which + // over-counted by the number of track tiers and could let a phantom + // overall rank slip through. + const maxRank = useMemo( + () => prizeTiers.filter(t => !t.kind || t.kind === 'OVERALL').length, + [prizeTiers] + ); + // Include both overall winners (rank-keyed) AND track winners + // (flagged by `isTrackWinner` via useHackathonRewards). The BE + // trigger endpoint resolves the actual payout list itself; this + // array only drives the preview UI. const winners = useMemo( () => submissions.filter( - s => s.rank !== undefined && s.rank !== null && s.rank <= maxRank + s => + (s.rank !== undefined && s.rank !== null && s.rank <= maxRank) || + s.isTrackWinner ), [submissions, maxRank] ); @@ -92,12 +105,21 @@ export default function PublishWinnersWizard({ rank: tier.rank, prizeAmount: tier.prizeAmount, currency: tier.currency, + place: tier.place, + kind: tier.kind, + trackId: tier.trackId, })), [prizeTiers] ); + // Format the prize for a given rank-based tier slot (overall). Track + // winners look up their prize via tier.trackId in WinnersGrid + // instead; this helper stays focused on overall placements so the + // preview's existing flow doesn't get gnarlier than needed. const getPrizeForRank = (rank: number) => { - const tier = mappedPrizeTiers.find(t => t.rank === rank); + const tier = mappedPrizeTiers.find( + t => (!t.kind || t.kind === 'OVERALL') && t.rank === rank + ); if (tier) { const amount = parseFloat(tier.prizeAmount || '0').toLocaleString( 'en-US' diff --git a/components/organization/hackathons/rewards/RewardsPageContent.tsx b/components/organization/hackathons/rewards/RewardsPageContent.tsx index 045feac2..8ceef9a6 100644 --- a/components/organization/hackathons/rewards/RewardsPageContent.tsx +++ b/components/organization/hackathons/rewards/RewardsPageContent.tsx @@ -14,6 +14,7 @@ import { } from 'lucide-react'; import { BoundlessButton } from '@/components/buttons'; import PodiumSection from '@/components/organization/hackathons/rewards/PodiumSection'; +import { TrackWinnersSection } from '@/components/organization/hackathons/rewards/TrackWinnersSection'; import SubmissionsList from '@/components/organization/hackathons/rewards/SubmissionsList'; import EscrowStatusCard from '@/components/organization/hackathons/rewards/EscrowStatusCard'; import { RewardDistributionStatusBanner } from '@/components/organization/hackathons/rewards/RewardDistributionStatusBanner'; @@ -21,6 +22,7 @@ import BoundlessSheet from '@/components/sheet/boundless-sheet'; import { Submission } from '@/components/organization/hackathons/rewards/types'; import type { HackathonEscrowData, + HackathonTrackWinner, RewardDistributionStatusResponse, RewardDistributionStatusEnum, } from '@/lib/api/hackathons'; @@ -41,6 +43,12 @@ interface RewardsPageContentProps { onRefreshDistributionStatus?: () => void; resultsPublished?: boolean; escrowAddress?: string; + /** + * Per-track winners stamped by publishResults. Rendered in a + * dedicated section below the rank-based podium. Empty pre-publish + * and on OVERALL_ONLY hackathons. + */ + trackWinners?: HackathonTrackWinner[]; } export const RewardsPageContent: React.FC = ({ @@ -57,6 +65,7 @@ export const RewardsPageContent: React.FC = ({ onRefreshDistributionStatus, resultsPublished, escrowAddress, + trackWinners = [], }) => { const [isStatusSheetOpen, setIsStatusSheetOpen] = useState(false); @@ -214,6 +223,7 @@ export const RewardsPageContent: React.FC = ({
+

All Submissions diff --git a/components/organization/hackathons/rewards/TrackWinnersSection.tsx b/components/organization/hackathons/rewards/TrackWinnersSection.tsx new file mode 100644 index 00000000..9502a1a5 --- /dev/null +++ b/components/organization/hackathons/rewards/TrackWinnersSection.tsx @@ -0,0 +1,114 @@ +'use client'; + +import React from 'react'; +import { Layers, Trophy } from 'lucide-react'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import type { HackathonTrackWinner } from '@/lib/api/hackathons'; + +interface TrackWinnersSectionProps { + trackWinners: HackathonTrackWinner[]; +} + +/** + * Renders track winners on the organizer rewards page. + * + * Mirrors the pattern used by the public WinnersTab: one card per + * track, scoped to its winning submission. Sits below the rank-based + * PodiumSection so the page reads as "overall winners on top, track + * winners below." Hidden entirely on OVERALL_ONLY hackathons (empty + * trackWinners → render null). + */ +export const TrackWinnersSection: React.FC = ({ + trackWinners, +}) => { + if (!trackWinners || trackWinners.length === 0) return null; + + return ( +
+
+ +

Track Winners

+ + ({trackWinners.length} track + {trackWinners.length === 1 ? '' : 's'}) + +
+ +
+ {trackWinners.map(trackWinner => ( + + ))} +
+
+ ); +}; + +const formatPrize = (raw: string | null | undefined): string | null => { + if (!raw) return null; + // Backend emits prize like "100 USDC" or "$100"; normalise to + // " USDC" so the chip stays consistent across hackathons. + const match = raw.match(/^(?:USDC)?\s*\$?(\d+(?:[.,]\d+)?)\s*(?:USDC)?$/i); + return match ? `${match[1]} USDC` : raw; +}; + +const TrackWinnerCard: React.FC<{ trackWinner: HackathonTrackWinner }> = ({ + trackWinner, +}) => { + const prize = formatPrize(trackWinner.prize); + + return ( +
+
+ + {trackWinner.track.name} + + {prize && ( +
+ + + {prize} + +
+ )} +
+ +
+
+ {trackWinner.participants.slice(0, 3).map((p, i) => ( + + + + {p.username?.charAt(0) || '?'} + + + ))} + {trackWinner.participants.length > 3 && ( +
+ +{trackWinner.participants.length - 3} +
+ )} +
+
+
+ {trackWinner.projectName} +
+ {trackWinner.teamName && ( +
+ {trackWinner.teamName} +
+ )} +
+
+
+ ); +}; + +export default TrackWinnersSection; diff --git a/components/organization/hackathons/rewards/WinnersGrid.tsx b/components/organization/hackathons/rewards/WinnersGrid.tsx index 6632d77e..c8fa89de 100644 --- a/components/organization/hackathons/rewards/WinnersGrid.tsx +++ b/components/organization/hackathons/rewards/WinnersGrid.tsx @@ -5,12 +5,17 @@ import { cn } from '@/lib/utils'; import { Submission } from './types'; import WinnerCard from './WinnerCard'; +interface WinnersGridTier { + rank: number; + prizeAmount: string; + currency: string; + place?: string; + kind?: 'OVERALL' | 'TRACK'; + trackId?: string; +} + interface WinnersGridProps { - prizeTiers: Array<{ - rank: number; - prizeAmount: string; - currency: string; - }>; + prizeTiers: WinnersGridTier[]; winners: Submission[]; getPrizeForRank: (rank: number) => { amount: string; @@ -19,6 +24,22 @@ interface WinnersGridProps { }; } +const isOverallTier = (t: WinnersGridTier) => !t.kind || t.kind === 'OVERALL'; +const isTrackTier = (t: WinnersGridTier) => t.kind === 'TRACK' && !!t.trackId; + +const formatTrackPrize = ( + amount: string | undefined, + currency: string | undefined +) => { + const cleanAmount = parseFloat(amount || '0').toLocaleString('en-US'); + const cleanCurrency = currency || 'USDC'; + return { + amount: cleanAmount, + currency: cleanCurrency, + label: `${cleanAmount} ${cleanCurrency}`, + }; +}; + export default function WinnersGrid({ prizeTiers, winners, @@ -26,9 +47,40 @@ export default function WinnersGrid({ }: WinnersGridProps) { const totalTiers = prizeTiers.length; - // Filter only tiers that have an assigned winner - const tiersWithWinners = useMemo(() => { - return prizeTiers.filter(tier => winners.some(w => w.rank === tier.rank)); + // Build the display list as (tier, winner) pairs. Overall tiers are + // matched to winners by `rank`, track tiers by `trackId`. Tiers + // without a winner are skipped (preserves the previous "only show + // tiers with assigned winners" behaviour). + const displayPairs = useMemo(() => { + type Pair = { key: string; tier: WinnersGridTier; winner: Submission }; + const overallPairs: Pair[] = []; + const trackPairs: Pair[] = []; + for (const tier of prizeTiers) { + if (isOverallTier(tier)) { + const winner = winners.find( + w => w.rank === tier.rank && !w.isTrackWinner + ); + if (winner) { + overallPairs.push({ + key: `overall-${tier.rank}`, + tier, + winner, + }); + } + } else if (isTrackTier(tier)) { + const winner = winners.find( + w => w.isTrackWinner && w.trackId === tier.trackId + ); + if (winner) { + trackPairs.push({ + key: `track-${tier.trackId}`, + tier, + winner, + }); + } + } + } + return { overallPairs, trackPairs }; }, [prizeTiers, winners]); const getGridCols = (count: number) => { @@ -38,20 +90,20 @@ export default function WinnersGrid({ return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'; }; - const getTierOrder = (availableTiers: typeof prizeTiers) => { - const sortedTiers = [...availableTiers].sort((a, b) => a.rank - b.rank); - - if (sortedTiers.length === 3) { - const secondTier = sortedTiers.find(t => t.rank === 2) || sortedTiers[1]; - const firstTier = sortedTiers.find(t => t.rank === 1) || sortedTiers[0]; - const thirdTier = sortedTiers.find(t => t.rank === 3) || sortedTiers[2]; - return [secondTier, firstTier, thirdTier].filter(Boolean); + // 1st/2nd/3rd podium re-ordering (2-1-3) only kicks in when exactly + // three overall winners are present, matching the prior look. + const orderedOverall = useMemo(() => { + const sorted = [...displayPairs.overallPairs].sort( + (a, b) => a.tier.rank - b.tier.rank + ); + if (sorted.length === 3) { + const first = sorted.find(p => p.tier.rank === 1) ?? sorted[0]; + const second = sorted.find(p => p.tier.rank === 2) ?? sorted[1]; + const third = sorted.find(p => p.tier.rank === 3) ?? sorted[2]; + return [second, first, third].filter(Boolean); } - - return sortedTiers; - }; - - const tiersToDisplay = getTierOrder(tiersWithWinners); + return sorted; + }, [displayPairs.overallPairs]); return (
@@ -61,32 +113,58 @@ export default function WinnersGrid({
-
- {tiersToDisplay.map(tier => { - if (!tier) return null; - - const rank = tier.rank; - const winner = winners.find(s => s.rank === rank); - const prize = getPrizeForRank(rank); - const amount = prize.amount || '0'; - const currency = prize.currency || 'USDC'; - const label = prize.label; + {orderedOverall.length > 0 && ( +
+ {orderedOverall.map(({ key, tier, winner }) => { + const prize = getPrizeForRank(tier.rank); + return ( + + ); + })} +
+ )} - return ( - - ); - })} -
+ {displayPairs.trackPairs.length > 0 && ( +
+
+ Track Winners +
+
+ {displayPairs.trackPairs.map(({ key, tier, winner }) => { + const prize = formatTrackPrize(tier.prizeAmount, tier.currency); + return ( + + ); + })} +
+
+ )}

); } diff --git a/components/organization/hackathons/rewards/types.ts b/components/organization/hackathons/rewards/types.ts index 8f040687..0633aaaf 100644 --- a/components/organization/hackathons/rewards/types.ts +++ b/components/organization/hackathons/rewards/types.ts @@ -13,4 +13,15 @@ export interface Submission { judgeCount?: number; category?: string; commentCount?: number; + // ── Track winner enrichment (post-publish only) ── + // Populated by useHackathonRewards from the published winners + // payload. `rank` stays null for track winners (their win lives on + // SubmissionTrackEntry.wonRank, not submission.rank), so consumers + // that need to count winners must OR `rank` with `isTrackWinner`. + isTrackWinner?: boolean; + trackId?: string; + trackName?: string; + trackPrize?: string; + /** Placement within the track. Currently always 1 in P1. */ + trackWonRank?: number; } diff --git a/hooks/use-hackathon-rewards.ts b/hooks/use-hackathon-rewards.ts index 06231076..2b4a8fe4 100644 --- a/hooks/use-hackathon-rewards.ts +++ b/hooks/use-hackathon-rewards.ts @@ -7,7 +7,9 @@ import { getHackathonEscrow, type Hackathon, type HackathonEscrowData, + type HackathonTrackWinner, } from '@/lib/api/hackathons'; +import { getHackathonWinners } from '@/lib/api/hackathon'; import { getJudgingResults, type JudgingResult, @@ -76,6 +78,12 @@ interface UseHackathonRewardsReturn { refetchHackathon: () => Promise; resultsPublished: boolean; hackathon: Hackathon | null; + /** + * Per-track winners stamped by publishResults. Empty until results + * are published, and empty for OVERALL_ONLY hackathons. The page + * renders these in a separate section below the rank-based podium. + */ + trackWinners: HackathonTrackWinner[]; } export const useHackathonRewards = ( @@ -91,6 +99,7 @@ export const useHackathonRewards = ( const [isLoadingSubmissions, setIsLoadingSubmissions] = useState(true); const [error, setError] = useState(null); const [hackathon, setHackathon] = useState(null); + const [trackWinners, setTrackWinners] = useState([]); const isFetchingEscrowRef = useRef(false); const lastFetchedContractIdRef = useRef(null); @@ -141,24 +150,58 @@ export const useHackathonRewards = ( setHackathon(fetchedHackathon); if (fetchedHackathon.prizeTiers) { - // Sort tiers by amount descending or use parsed numeric rank from place if available - const sortedTiers = [...fetchedHackathon.prizeTiers].sort( - (a: any, b: any) => { - const rankA = parseInt(a.place?.match(/\d+/)?.[0] || '999'); - const rankB = parseInt(b.place?.match(/\d+/)?.[0] || '999'); - if (rankA !== rankB) return rankA - rankB; - - const amountA = parseFloat(a.prizeAmount || '0'); - const amountB = parseFloat(b.prizeAmount || '0'); - return amountB - amountA; - } + // Sort: OVERALL tiers first by parsed numeric place ("1st", + // "2nd", etc.) ascending, then by amount descending; TRACK + // tiers after, in their original order (which matches the + // organizer-defined displayOrder). The previous code fell + // back to rank=999 for any non-numeric place string, which + // collapsed every track tier to the same slot. + const indexedTiers = (fetchedHackathon.prizeTiers as any[]).map( + (tier, i) => ({ tier, originalIndex: i }) + ); + + const isTrack = (t: any) => t.kind === 'TRACK'; + + const overallEntries = indexedTiers.filter(e => !isTrack(e.tier)); + const trackEntries = indexedTiers.filter(e => isTrack(e.tier)); + + overallEntries.sort((a, b) => { + const rankA = parseInt( + a.tier.place?.match(/\d+/)?.[0] || '999', + 10 + ); + const rankB = parseInt( + b.tier.place?.match(/\d+/)?.[0] || '999', + 10 + ); + if (rankA !== rankB) return rankA - rankB; + const amountA = parseFloat(a.tier.prizeAmount || '0'); + const amountB = parseFloat(b.tier.prizeAmount || '0'); + return amountB - amountA; + }); + // Track entries keep their original ordering — that matches + // HackathonTrack.displayOrder on the backend. + trackEntries.sort((a, b) => a.originalIndex - b.originalIndex); + + const sortedTiers = [...overallEntries, ...trackEntries].map( + e => e.tier ); const tiers: PrizeTier[] = sortedTiers.map( (tier: any, index: number) => { - const parsedRank = parseInt( - tier.place?.match(/\d+/)?.[0] || String(index + 1) + // Overall tiers get a numeric rank parsed from "place". + // Track tiers get a synthetic rank slot AFTER the highest + // overall rank so existing rank-keyed lookups don't + // collide with overall ranks. Components that render + // track winners should branch on `kind === 'TRACK'` + // instead of relying on this rank value. + const numericFromPlace = parseInt( + tier.place?.match(/\d+/)?.[0] || '', + 10 ); + const parsedRank = !isNaN(numericFromPlace) + ? numericFromPlace + : index + 1; return { id: tier.id || `tier-${index + 1}`, place: tier.place || `${getOrdinalSuffix(index + 1)} Place`, @@ -167,6 +210,8 @@ export const useHackathonRewards = ( passMark: tier.passMark || 0, description: tier.description, rank: parsedRank, + kind: tier.kind, + trackId: tier.trackId, }; } ); @@ -396,6 +441,67 @@ export const useHackathonRewards = ( } }, [organizationId, hackathonId]); + // Post-publish track-winner enrichment. + // + // Track winners are stamped on `SubmissionTrackEntry.wonRank`, not on + // `submission.rank`, so the submissions list above has no idea who + // won a track. We pull `/judging/winners` (which returns the + // overall + trackWinners payload) and merge the track-winner info + // into the corresponding submission rows. Runs only after results + // are published — pre-publish, trackWinners doesn't exist yet. + useEffect(() => { + if (!hackathon?.resultsPublished || !hackathonId) { + setTrackWinners([]); + return; + } + let cancelled = false; + (async () => { + try { + const winnersRes = await getHackathonWinners(hackathonId); + if (cancelled) return; + if (!winnersRes.success || !winnersRes.data) { + setTrackWinners([]); + return; + } + const data = winnersRes.data as { + trackWinners?: HackathonTrackWinner[]; + }; + const fetched = data.trackWinners ?? []; + setTrackWinners(fetched); + if (fetched.length === 0) return; + const byId = new Map(fetched.map(tw => [tw.submissionId, tw])); + setSubmissions(prev => + prev.map(sub => { + const tw = byId.get(sub.id); + if (!tw) return sub; + return { + ...sub, + isTrackWinner: true, + trackId: tw.track.id, + trackName: tw.track.name, + trackPrize: tw.prize, + trackWonRank: tw.wonRank, + }; + }) + ); + } catch (winnersErr) { + if (cancelled) return; + // Best-effort: an unreachable winners endpoint shouldn't kill + // the rewards page. Overall winners (from judging results) still + // render; track winners just go missing. + reportError(winnersErr, { + context: 'rewards-fetchTrackWinners', + organizationId, + hackathonId, + }); + setTrackWinners([]); + } + })(); + return () => { + cancelled = true; + }; + }, [hackathon?.resultsPublished, hackathonId, organizationId]); + return { submissions, setSubmissions, @@ -410,5 +516,6 @@ export const useHackathonRewards = ( refetchHackathon: fetchHackathon, resultsPublished: !!hackathon?.resultsPublished, hackathon, + trackWinners, }; };