Skip to content

Commit 8ac968c

Browse files
0xdevcollinsBenjtalkshowclaude
authored
Implement track-based prize structure and submission visibility updates (#578)
* fix(hackathons): single-column teams tab and primary-colored pager (#562) * fix(hackathons): single-column teams tab and primary-colored pager Revert the teams tab grid to a single column and rework the shared Pagination component to match the icon-chevron layout used by the organizer submissions and participants pages, styled with the primary color. * feat(submissions): link submission card avatars to profile pages Wrap the individual avatar on SubmissionCard in a profile link and forward team-member usernames to GroupAvatar so each clustered avatar opens that user's profile in a new tab. * feat(hackathons): track-based prize structure + submission polish + tracks UI (#564) * feat(hackathons): add "hidden until results" submission visibility mode Surfaces the new HIDDEN_UNTIL_RESULTS option (added in the nestjs PR) in the organizer settings tab. Reorders the three visibility options so the recommended "Shortlisted only" leads, the new "Hidden until results are announced" sits in the middle, and "All submissions" comes last. Rewrites the copy on the "All submissions" choice that incorrectly claimed disqualified projects would be shown -- they never were on the backend, and Phase 2 makes that an explicit guarantee. Aligns the form's default and API-fallback value with the backend default (ACCEPTED_SHORTLISTED, not ALL) so organizers don't see a misleading initial selection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(hackathons): track-based prize structure, submission polish, tracks UI Wires the frontend for the new track-based prize flow: - New TracksSettingsTab with full CRUD: name/slug/description/eligibility/ prompt/customQuestions/requiredArtifacts; per-row bulk-opt-in action with confirmation dialog for retrofitting existing submissions - RewardsTab gains a 3-card prize structure picker, per-tier kind toggle and track dropdown, amber "tracks unbound" banner, and an inline Manage Tracks dialog embedding the settings table - SubmissionForm: track picker + per-track answers (prompt / custom questions / required artifacts), tagline, builtWith chips, screenshots, license, code attestation, with soft compliance gate for already-submitted submissions. trackIds hydrate from trackEntries on edit so bulk-opted-in submitters don't strip themselves out - SubmissionDetailModal renders tagline, screenshots, built-with, license badge, and per-track answers - Public hackathon page: Overview splits prizes into Overall/Track sections; sidebar tier list shows TRACK prefix and looks up track names; Winners tab gets a Track Winners section with per-track cards - API client: lib/api/hackathons/tracks.ts with listTracks / listOrganizerTracks / createTrack / updateTrack / deleteTrack / bulkOptInAllSubmissions, plus types for HackathonTrack, TrackCustomQuestion, TrackRequiredArtifact, TrackAnswer, SubmissionTrackEntry, BulkOptInResult - Hackathon provider/hooks expose trackWinners and per-track entries Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Feat/submission visibility hidden until results (#566) * feat(hackathons): add "hidden until results" submission visibility mode Surfaces the new HIDDEN_UNTIL_RESULTS option (added in the nestjs PR) in the organizer settings tab. Reorders the three visibility options so the recommended "Shortlisted only" leads, the new "Hidden until results are announced" sits in the middle, and "All submissions" comes last. Rewrites the copy on the "All submissions" choice that incorrectly claimed disqualified projects would be shown -- they never were on the backend, and Phase 2 makes that an explicit guarantee. Aligns the form's default and API-fallback value with the backend default (ACCEPTED_SHORTLISTED, not ALL) so organizers don't see a misleading initial selection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(hackathons): track-based prize structure, submission polish, tracks UI Wires the frontend for the new track-based prize flow: - New TracksSettingsTab with full CRUD: name/slug/description/eligibility/ prompt/customQuestions/requiredArtifacts; per-row bulk-opt-in action with confirmation dialog for retrofitting existing submissions - RewardsTab gains a 3-card prize structure picker, per-tier kind toggle and track dropdown, amber "tracks unbound" banner, and an inline Manage Tracks dialog embedding the settings table - SubmissionForm: track picker + per-track answers (prompt / custom questions / required artifacts), tagline, builtWith chips, screenshots, license, code attestation, with soft compliance gate for already-submitted submissions. trackIds hydrate from trackEntries on edit so bulk-opted-in submitters don't strip themselves out - SubmissionDetailModal renders tagline, screenshots, built-with, license badge, and per-track answers - Public hackathon page: Overview splits prizes into Overall/Track sections; sidebar tier list shows TRACK prefix and looks up track names; Winners tab gets a Track Winners section with per-track cards - API client: lib/api/hackathons/tracks.ts with listTracks / listOrganizerTracks / createTrack / updateTrack / deleteTrack / bulkOptInAllSubmissions, plus types for HackathonTrack, TrackCustomQuestion, TrackRequiredArtifact, TrackAnswer, SubmissionTrackEntry, BulkOptInResult - Hackathon provider/hooks expose trackWinners and per-track entries Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(submissions): tighten Zod schema + surface backend debug info - Add max constraints that previously only existed on the backend DTO so validation fires inline (projectName 100, description 5000, URL 500). - ApiErrorField gains an optional `debug` field that the backend Prisma filter populates outside production. - useSubmission's error formatter prefers `debug` over the generic field message when present, so toasts show the real Prisma reason behind "Data validation failed" instead of a blank "validation: …" line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(submit-page): hydrate Phase A fields + stop wiping user input on re-render The submit page mapped `initialData` inline on every render, which (a) recreated the object reference on every render so the form's reset effect fired continuously and wiped values the user was typing, and (b) dropped the Phase A fields entirely (tagline, builtWith, screenshots, license, codeAttestedAt) plus trackEntries. The combined effect was the documented symptom — only logo and videoUrl survived the save because those round-tripped through the type-narrowed object literal, while tagline / builtWith / license kept appearing to "switch to empty". - Memoize `initialData` against `mySubmission` so the reference only changes when the underlying submission actually changes. - Pass through Phase A fields and trackEntries so the form can hydrate the saved values, and so a follow-up save doesn't write back empties. - Widen SubmissionFormContent's `initialData` prop to accept the raw server-side extras (trackEntries, codeAttestedAt) that the form already consumes via cast — keeps the parent's hydration explicit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(hackathons): always open submissions in a new tab (#568) * fix(hackathons): single-column teams tab and primary-colored pager Revert the teams tab grid to a single column and rework the shared Pagination component to match the icon-chevron layout used by the organizer submissions and participants pages, styled with the primary color. * feat(submissions): link submission card avatars to profile pages Wrap the individual avatar on SubmissionCard in a profile link and forward team-member usernames to GroupAvatar so each clustered avatar opens that user's profile in a new tab. * fix(hackathons): always open submissions in a new tab Across the hackathon, organizer, judge, and profile surfaces, clicking a submission now opens the project page in a new tab so reviewers and participants do not lose their list/queue context. Switched the remaining anchor tags to next/link Link components. * feat(judging): organizer dashboard — coverage, preview, per-track results (#570) (#572) Pairs with boundless-nestjs feat(judging) organizer dashboard upgrades. Adds three new components to the organizer judging page; no changes to existing components, judges, or scoring flows. - `CoverageMatrix` — heatmap on the Overview tab. Rows are submissions, columns are judges. Surfaces idle judges (mostly-empty columns) and orphan submissions (rows with 0-1 scores) at a glance — both block a defensible publish. - `AllocationPreviewCard` — sits above the Publish button on the Results tab. Read-only allocator dry-run showing overall placements + per-track winners exactly as publish-results would commit. Calls out EXCLUSIVE stacking effects (track leader losing to overall), plus surfaces publish gates (deadline, completeness, partner allocation) so the organizer sees blockers without attempting to publish. - `TrackResultsSection` — per-track collapsible standings on the Results tab. Each section is scoped to a track's opt-ins, sorted by averageScore, with the bound prize tier shown as a chip. The leader is highlighted as the current pick — soft preview only; the Allocation Preview above shows the authoritative EXCLUSIVE-stacked outcome. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(judging): unallocated partner funds become a warning, not a blocker (#574) Mirrors the backend relaxation. The allocator preview's "Ready to publish" badge previously turned amber whenever any partner contribution had unallocated balance — but the backend gate that backed that signal has been relaxed (the funds stay in escrow post-publish; they're not lost). Split the existing publish-readiness messaging into two lists: - Blockers — the hard gates (deadline, completeness, no reviews). Same red treatment, same ready-to-publish badge logic. - Warnings — informational, never blocks. Renders in blue, calls out the unallocated amount with a note that it remains in escrow. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 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> --------- Co-authored-by: Nnaji Benjamin <60315147+Benjtalkshow@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Benjtalkshow <chinedubenj@gmail.com>
1 parent 451775f commit 8ac968c

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)