Skip to content

Commit c13e9ea

Browse files
0xdevcollinsclaude
andauthored
fix(rewards): show track winners on the organizer rewards page (#576)
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) <noreply@anthropic.com>
1 parent 01705f8 commit c13e9ea

8 files changed

Lines changed: 419 additions & 64 deletions

File tree

app/(landing)/organizations/[id]/hackathons/[hackathonId]/rewards/page.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export default function RewardsPage() {
3131
refetchHackathon,
3232
resultsPublished,
3333
hackathon,
34+
trackWinners,
3435
} = useHackathonRewards(organizationId, hackathonId);
3536

3637
const { handleRankChange } = useRankAssignment();
@@ -43,9 +44,17 @@ export default function RewardsPage() {
4344
refetch: refetchDistributionStatus,
4445
} = useRewardDistributionStatus(organizationId, hackathonId);
4546

46-
const maxRank = useMemo(() => prizeTiers.length, [prizeTiers.length]);
47+
// `maxRank` is the number of OVERALL prize tier slots; track tiers
48+
// are not rank-numbered so they don't contribute to this cap. The
49+
// rank-based rendering (podium, rank-keyed lookups) still uses
50+
// `maxRank`. Track winners flow through `isTrackWinner` instead.
51+
const maxRank = useMemo(
52+
() => prizeTiers.filter(t => !t.kind || t.kind === 'OVERALL').length,
53+
[prizeTiers]
54+
);
4755
const winners = useMemo(
48-
() => submissions.filter(s => s.rank && s.rank <= maxRank),
56+
() =>
57+
submissions.filter(s => (s.rank && s.rank <= maxRank) || s.isTrackWinner),
4958
[submissions, maxRank]
5059
);
5160
const hasWinners = winners.length > 0;
@@ -121,6 +130,7 @@ export default function RewardsPage() {
121130
onRefreshDistributionStatus={refetchDistributionStatus}
122131
resultsPublished={resultsPublished}
123132
escrowAddress={hackathon?.escrowAddress || hackathon?.contractId}
133+
trackWinners={trackWinners}
124134
/>
125135
)}
126136

components/organization/hackathons/rewards/PreviewStep.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ interface PreviewStepProps {
1111
rank: number;
1212
prizeAmount: string;
1313
currency: string;
14+
place?: string;
15+
kind?: 'OVERALL' | 'TRACK';
16+
trackId?: string;
1417
}>;
1518
announcement: string;
1619
onEditAnnouncement: () => void;

components/organization/hackathons/rewards/PublishWinnersWizard.tsx

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,25 @@ export default function PublishWinnersWizard({
4040
hackathonId,
4141
onSuccess,
4242
}: PublishWinnersWizardProps) {
43-
const maxRank = prizeTiers.length;
43+
// `maxRank` only counts OVERALL slots — track tiers don't have
44+
// numeric ranks. The previous code used `prizeTiers.length`, which
45+
// over-counted by the number of track tiers and could let a phantom
46+
// overall rank slip through.
47+
const maxRank = useMemo(
48+
() => prizeTiers.filter(t => !t.kind || t.kind === 'OVERALL').length,
49+
[prizeTiers]
50+
);
4451

52+
// Include both overall winners (rank-keyed) AND track winners
53+
// (flagged by `isTrackWinner` via useHackathonRewards). The BE
54+
// trigger endpoint resolves the actual payout list itself; this
55+
// array only drives the preview UI.
4556
const winners = useMemo(
4657
() =>
4758
submissions.filter(
48-
s => s.rank !== undefined && s.rank !== null && s.rank <= maxRank
59+
s =>
60+
(s.rank !== undefined && s.rank !== null && s.rank <= maxRank) ||
61+
s.isTrackWinner
4962
),
5063
[submissions, maxRank]
5164
);
@@ -92,12 +105,21 @@ export default function PublishWinnersWizard({
92105
rank: tier.rank,
93106
prizeAmount: tier.prizeAmount,
94107
currency: tier.currency,
108+
place: tier.place,
109+
kind: tier.kind,
110+
trackId: tier.trackId,
95111
})),
96112
[prizeTiers]
97113
);
98114

115+
// Format the prize for a given rank-based tier slot (overall). Track
116+
// winners look up their prize via tier.trackId in WinnersGrid
117+
// instead; this helper stays focused on overall placements so the
118+
// preview's existing flow doesn't get gnarlier than needed.
99119
const getPrizeForRank = (rank: number) => {
100-
const tier = mappedPrizeTiers.find(t => t.rank === rank);
120+
const tier = mappedPrizeTiers.find(
121+
t => (!t.kind || t.kind === 'OVERALL') && t.rank === rank
122+
);
101123
if (tier) {
102124
const amount = parseFloat(tier.prizeAmount || '0').toLocaleString(
103125
'en-US'

components/organization/hackathons/rewards/RewardsPageContent.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ import {
1414
} from 'lucide-react';
1515
import { BoundlessButton } from '@/components/buttons';
1616
import PodiumSection from '@/components/organization/hackathons/rewards/PodiumSection';
17+
import { TrackWinnersSection } from '@/components/organization/hackathons/rewards/TrackWinnersSection';
1718
import SubmissionsList from '@/components/organization/hackathons/rewards/SubmissionsList';
1819
import EscrowStatusCard from '@/components/organization/hackathons/rewards/EscrowStatusCard';
1920
import { RewardDistributionStatusBanner } from '@/components/organization/hackathons/rewards/RewardDistributionStatusBanner';
2021
import BoundlessSheet from '@/components/sheet/boundless-sheet';
2122
import { Submission } from '@/components/organization/hackathons/rewards/types';
2223
import type {
2324
HackathonEscrowData,
25+
HackathonTrackWinner,
2426
RewardDistributionStatusResponse,
2527
RewardDistributionStatusEnum,
2628
} from '@/lib/api/hackathons';
@@ -41,6 +43,12 @@ interface RewardsPageContentProps {
4143
onRefreshDistributionStatus?: () => void;
4244
resultsPublished?: boolean;
4345
escrowAddress?: string;
46+
/**
47+
* Per-track winners stamped by publishResults. Rendered in a
48+
* dedicated section below the rank-based podium. Empty pre-publish
49+
* and on OVERALL_ONLY hackathons.
50+
*/
51+
trackWinners?: HackathonTrackWinner[];
4452
}
4553

4654
export const RewardsPageContent: React.FC<RewardsPageContentProps> = ({
@@ -57,6 +65,7 @@ export const RewardsPageContent: React.FC<RewardsPageContentProps> = ({
5765
onRefreshDistributionStatus,
5866
resultsPublished,
5967
escrowAddress,
68+
trackWinners = [],
6069
}) => {
6170
const [isStatusSheetOpen, setIsStatusSheetOpen] = useState(false);
6271

@@ -214,6 +223,7 @@ export const RewardsPageContent: React.FC<RewardsPageContentProps> = ({
214223
</div>
215224
<div className='space-y-6'>
216225
<PodiumSection submissions={submissions} maxRank={maxRank} />
226+
<TrackWinnersSection trackWinners={trackWinners} />
217227
<div>
218228
<h3 className='mb-4 text-lg font-medium text-white'>
219229
All Submissions
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
'use client';
2+
3+
import React from 'react';
4+
import { Layers, Trophy } from 'lucide-react';
5+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
6+
import { Badge } from '@/components/ui/badge';
7+
import { cn } from '@/lib/utils';
8+
import type { HackathonTrackWinner } from '@/lib/api/hackathons';
9+
10+
interface TrackWinnersSectionProps {
11+
trackWinners: HackathonTrackWinner[];
12+
}
13+
14+
/**
15+
* Renders track winners on the organizer rewards page.
16+
*
17+
* Mirrors the pattern used by the public WinnersTab: one card per
18+
* track, scoped to its winning submission. Sits below the rank-based
19+
* PodiumSection so the page reads as "overall winners on top, track
20+
* winners below." Hidden entirely on OVERALL_ONLY hackathons (empty
21+
* trackWinners → render null).
22+
*/
23+
export const TrackWinnersSection: React.FC<TrackWinnersSectionProps> = ({
24+
trackWinners,
25+
}) => {
26+
if (!trackWinners || trackWinners.length === 0) return null;
27+
28+
return (
29+
<div className='space-y-4'>
30+
<div className='flex items-center gap-2'>
31+
<Layers className='text-primary h-5 w-5' />
32+
<h3 className='text-lg font-semibold text-white'>Track Winners</h3>
33+
<span className='text-xs text-gray-500'>
34+
({trackWinners.length} track
35+
{trackWinners.length === 1 ? '' : 's'})
36+
</span>
37+
</div>
38+
39+
<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-3'>
40+
{trackWinners.map(trackWinner => (
41+
<TrackWinnerCard
42+
key={`${trackWinner.submissionId}-${trackWinner.track.id}`}
43+
trackWinner={trackWinner}
44+
/>
45+
))}
46+
</div>
47+
</div>
48+
);
49+
};
50+
51+
const formatPrize = (raw: string | null | undefined): string | null => {
52+
if (!raw) return null;
53+
// Backend emits prize like "100 USDC" or "$100"; normalise to
54+
// "<amount> USDC" so the chip stays consistent across hackathons.
55+
const match = raw.match(/^(?:USDC)?\s*\$?(\d+(?:[.,]\d+)?)\s*(?:USDC)?$/i);
56+
return match ? `${match[1]} USDC` : raw;
57+
};
58+
59+
const TrackWinnerCard: React.FC<{ trackWinner: HackathonTrackWinner }> = ({
60+
trackWinner,
61+
}) => {
62+
const prize = formatPrize(trackWinner.prize);
63+
64+
return (
65+
<div className='bg-background-card relative w-full overflow-hidden rounded-xl border border-white/5 p-4 transition-colors duration-200 hover:border-white/10'>
66+
<div className='mb-3 flex flex-wrap items-center justify-between gap-2'>
67+
<Badge variant='outline' className='text-primary border-primary/40'>
68+
{trackWinner.track.name}
69+
</Badge>
70+
{prize && (
71+
<div className='flex items-center gap-1.5 rounded-full border border-[#2775CA]/20 bg-[#2775CA]/10 px-2.5 py-1'>
72+
<Trophy className='h-3.5 w-3.5 text-yellow-500' />
73+
<span className='text-[10px] font-bold text-white uppercase'>
74+
{prize}
75+
</span>
76+
</div>
77+
)}
78+
</div>
79+
80+
<div className='mb-3 flex items-center gap-3'>
81+
<div className='flex -space-x-2'>
82+
{trackWinner.participants.slice(0, 3).map((p, i) => (
83+
<Avatar
84+
key={`${p.username || 'anon'}-${i}`}
85+
className={cn('border-background-card h-10 w-10 border-2 shadow')}
86+
>
87+
<AvatarImage src={p.avatar} alt={p.username || 'Participant'} />
88+
<AvatarFallback className='bg-gray-800 text-xs text-white uppercase'>
89+
{p.username?.charAt(0) || '?'}
90+
</AvatarFallback>
91+
</Avatar>
92+
))}
93+
{trackWinner.participants.length > 3 && (
94+
<div className='border-background-card flex h-10 w-10 items-center justify-center rounded-full border-2 bg-gray-800 text-xs font-medium text-gray-300'>
95+
+{trackWinner.participants.length - 3}
96+
</div>
97+
)}
98+
</div>
99+
<div className='min-w-0 flex-1'>
100+
<div className='truncate text-sm font-semibold text-white'>
101+
{trackWinner.projectName}
102+
</div>
103+
{trackWinner.teamName && (
104+
<div className='truncate text-xs text-gray-400'>
105+
{trackWinner.teamName}
106+
</div>
107+
)}
108+
</div>
109+
</div>
110+
</div>
111+
);
112+
};
113+
114+
export default TrackWinnersSection;

0 commit comments

Comments
 (0)