Skip to content

Commit 335758e

Browse files
0xdevcollinsclaude
andauthored
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>
1 parent f33a407 commit 335758e

19 files changed

Lines changed: 3200 additions & 123 deletions

File tree

app/(landing)/hackathons/[slug]/components/sidebar/PoolAndAction.tsx

Lines changed: 76 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
AlertCircle,
1212
} from 'lucide-react';
1313
import { useHackathonData } from '@/lib/providers/hackathonProvider';
14+
import { useHackathonTracks } from '@/hooks/hackathon/use-hackathon-queries';
1415
import { useOptionalAuth } from '@/hooks/use-auth';
1516
import { useRequireAuthForAction } from '@/hooks/use-require-auth-for-action';
1617
import { useSubmission } from '@/hooks/hackathon/use-submission';
@@ -79,6 +80,18 @@ export default function PoolAndAction() {
7980
} = useHackathonData();
8081
const hackathonError = error;
8182
const isDataLoading = loading || !hackathon;
83+
84+
// Load tracks when the hackathon uses a tracked structure so the prize
85+
// list can label TRACK tiers by the actual track name instead of
86+
// falling through to a generic "4th/5th Place" auto-label.
87+
const tracksEnabled =
88+
hackathon?.prizeStructure === 'OVERALL_AND_TRACKS' ||
89+
hackathon?.prizeStructure === 'TRACKS_ONLY';
90+
const { data: hackathonTracks = [] } = useHackathonTracks(
91+
slug,
92+
tracksEnabled
93+
);
94+
const trackById = new Map(hackathonTracks.map(t => [t.id, t] as const));
8295
const participants = hackathon?.participants || [];
8396
const deadline = hackathon?.submissionDeadline;
8497
const startDate = hackathon?.startDate;
@@ -240,30 +253,70 @@ export default function PoolAndAction() {
240253
</div>
241254
</div>
242255

243-
{hackathon && hackathon.prizeTiers.length > 0 && (
244-
<div className='relative mb-5 ml-1'>
245-
<div className='bg-primary/30 absolute top-2 bottom-2 left-[5px] w-[2px]' />
246-
<div className='flex flex-col gap-3'>
247-
{hackathon.prizeTiers.map((tier, i) => (
248-
<div key={tier.id ?? i} className='flex items-start gap-4'>
249-
<span className='bg-primary relative z-10 mt-[5px] h-3 w-3 shrink-0 rounded-full ring-4 ring-[#11230F]' />
250-
<div>
251-
<p className='text-xs text-gray-500'>
252-
{tier.name ??
253-
`${i + 1}${['st', 'nd', 'rd'][i] ?? 'th'} Place`}
254-
</p>
255-
<p className='text-base font-bold text-white'>
256-
{Number(tier.prizeAmount ?? 0).toLocaleString()}{' '}
257-
<span className='font-medium text-gray-400'>
258-
{tier.currency ?? currency}
259-
</span>
260-
</p>
261-
</div>
256+
{hackathon &&
257+
hackathon.prizeTiers.length > 0 &&
258+
(() => {
259+
// Walk the tiers once, but keep a separate counter for
260+
// OVERALL placements so the "1st/2nd/3rd" labels stay
261+
// accurate even when track tiers are interleaved. Track
262+
// tiers get the actual track name (looked up via trackId)
263+
// and a "TRACK" prefix so the sidebar matches what the
264+
// organizer set up in Rewards.
265+
let overallIdx = 0;
266+
return (
267+
<div className='relative mb-5 ml-1'>
268+
<div className='bg-primary/30 absolute top-2 bottom-2 left-[5px] w-[2px]' />
269+
<div className='flex flex-col gap-3'>
270+
{hackathon.prizeTiers.map((tier, i) => {
271+
const isTrack = tier.kind === 'TRACK';
272+
const track =
273+
isTrack && tier.trackId
274+
? trackById.get(tier.trackId)
275+
: undefined;
276+
let label: string;
277+
if (isTrack) {
278+
label = track?.name ?? tier.name ?? 'Track';
279+
} else {
280+
const place = overallIdx;
281+
overallIdx += 1;
282+
label =
283+
tier.name ??
284+
`${place + 1}${['st', 'nd', 'rd'][place] ?? 'th'} Place`;
285+
}
286+
return (
287+
<div
288+
key={tier.id ?? i}
289+
className='flex items-start gap-4'
290+
>
291+
<span
292+
className={cn(
293+
'relative z-10 mt-[5px] h-3 w-3 shrink-0 rounded-full ring-4 ring-[#11230F]',
294+
isTrack ? 'bg-primary/60' : 'bg-primary'
295+
)}
296+
/>
297+
<div>
298+
<p className='text-xs text-gray-500'>
299+
{isTrack && (
300+
<span className='text-primary/80 mr-1 text-[9px] font-bold tracking-widest uppercase'>
301+
Track ·
302+
</span>
303+
)}
304+
{label}
305+
</p>
306+
<p className='text-base font-bold text-white'>
307+
{Number(tier.prizeAmount ?? 0).toLocaleString()}{' '}
308+
<span className='font-medium text-gray-400'>
309+
{tier.currency ?? currency}
310+
</span>
311+
</p>
312+
</div>
313+
</div>
314+
);
315+
})}
262316
</div>
263-
))}
264-
</div>
265-
</div>
266-
)}
317+
</div>
318+
);
319+
})()}
267320

268321
<div className='mb-5 flex items-stretch gap-0 overflow-hidden rounded-xl border border-[#252628] bg-[#191B1E]'>
269322
<div className='flex flex-1 flex-col gap-0.5 px-4 py-3'>

app/(landing)/hackathons/[slug]/components/tabs/contents/Overview.tsx

Lines changed: 150 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
'use client';
22

33
import { useParams } from 'next/navigation';
4-
import { useHackathon } from '@/hooks/hackathon/use-hackathon-queries';
4+
import {
5+
useHackathon,
6+
useHackathonTracks,
7+
} from '@/hooks/hackathon/use-hackathon-queries';
58
import { TabsContent } from '@/components/ui/tabs';
6-
import { Info, Target, Clock, Trophy, ChevronRight } from 'lucide-react';
9+
import {
10+
Info,
11+
Target,
12+
Clock,
13+
Trophy,
14+
ChevronRight,
15+
Layers,
16+
} from 'lucide-react';
717
import { Badge } from '@/components/ui/badge';
818
import { cn } from '@/lib/utils';
919

@@ -19,6 +29,17 @@ function getOrdinal(n: number) {
1929
const Overview = () => {
2030
const { slug } = useParams<{ slug: string }>();
2131
const { data: hackathon } = useHackathon(slug);
32+
// Only fetch tracks once we know the hackathon uses a tracked structure.
33+
// Avoids an extra network call on every overview render for legacy
34+
// overall-only hackathons.
35+
const tracksEnabled =
36+
hackathon?.prizeStructure === 'OVERALL_AND_TRACKS' ||
37+
hackathon?.prizeStructure === 'TRACKS_ONLY';
38+
const { data: hackathonTracks = [] } = useHackathonTracks(
39+
slug,
40+
tracksEnabled
41+
);
42+
const trackById = new Map(hackathonTracks.map(t => [t.id, t] as const));
2243

2344
const { styledContent, loading: markdownLoading } = useMarkdown(
2445
hackathon?.description || '',
@@ -218,61 +239,138 @@ const Overview = () => {
218239
</section>
219240

220241
{/* Prizes Section */}
221-
<section className='space-y-6'>
222-
<div className='flex items-center gap-2'>
223-
<h2 className='text-xl font-bold text-white'>Prizes</h2>
224-
</div>
225-
<div className='grid grid-cols-1 gap-6 md:grid-cols-3'>
226-
{hackathon.prizeTiers.map((tier, i) => (
227-
<div
228-
key={i}
229-
className={cn(
230-
'relative flex flex-col items-center rounded-2xl border bg-[#141517] p-8 text-center transition-all',
231-
i === 0
232-
? 'border-primary/30 ring-primary/20 ring-1'
233-
: 'border-white/5'
234-
)}
235-
>
236-
{i === 0 && (
237-
<Badge className='bg-primary absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-0.5 text-[10px] font-bold text-black uppercase'>
238-
Top Tier
239-
</Badge>
240-
)}
242+
{(() => {
243+
// Partition tiers into overall vs track. Tiers without an
244+
// explicit kind are treated as OVERALL for backward compat with
245+
// hackathons created before the track structure landed.
246+
const overallTiers = hackathon.prizeTiers.filter(
247+
t => !t.kind || t.kind === 'OVERALL'
248+
);
249+
const trackTiers = hackathon.prizeTiers.filter(t => t.kind === 'TRACK');
241250

242-
<div className='mb-4 flex h-14 w-14 items-center justify-center rounded-full border border-white/10 bg-white/5'>
243-
<Trophy
244-
className={cn(
245-
'h-7 w-7',
246-
i === 0
247-
? 'text-primary'
248-
: i === 1
249-
? 'text-blue-400'
250-
: 'text-gray-400'
251-
)}
252-
/>
253-
</div>
251+
return (
252+
<section className='space-y-8'>
253+
{overallTiers.length > 0 && (
254+
<div className='space-y-6'>
255+
<div className='flex items-center gap-2'>
256+
<h2 className='text-xl font-bold text-white'>
257+
{trackTiers.length > 0 ? 'Overall Prizes' : 'Prizes'}
258+
</h2>
259+
</div>
260+
<div className='grid grid-cols-1 gap-6 md:grid-cols-3'>
261+
{overallTiers.map((tier, i) => (
262+
<div
263+
key={tier.id ?? `overall-${i}`}
264+
className={cn(
265+
'relative flex flex-col items-center rounded-2xl border bg-[#141517] p-8 text-center transition-all',
266+
i === 0
267+
? 'border-primary/30 ring-primary/20 ring-1'
268+
: 'border-white/5'
269+
)}
270+
>
271+
{i === 0 && (
272+
<Badge className='bg-primary absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-0.5 text-[10px] font-bold text-black uppercase'>
273+
Top Tier
274+
</Badge>
275+
)}
276+
277+
<div className='mb-4 flex h-14 w-14 items-center justify-center rounded-full border border-white/10 bg-white/5'>
278+
<Trophy
279+
className={cn(
280+
'h-7 w-7',
281+
i === 0
282+
? 'text-primary'
283+
: i === 1
284+
? 'text-blue-400'
285+
: 'text-gray-400'
286+
)}
287+
/>
288+
</div>
289+
290+
<h3 className='text-sm font-semibold text-gray-400'>
291+
{tier.name || `${getOrdinal(i + 1)} Place`}
292+
</h3>
293+
<div className='my-2 flex items-baseline gap-1'>
294+
<span className='text-3xl font-black text-white'>
295+
{Number(tier.prizeAmount).toLocaleString()}
296+
</span>
297+
<span className='text-lg font-bold text-gray-500 uppercase'>
298+
{tier.currency || 'USDC'}
299+
</span>
300+
</div>
254301

255-
<h3 className='text-sm font-semibold text-gray-400'>
256-
{tier.name || `${getOrdinal(i + 1)} Place`}
257-
</h3>
258-
<div className='my-2 flex items-baseline gap-1'>
259-
<span className='text-3xl font-black text-white'>
260-
{Number(tier.prizeAmount).toLocaleString()}
261-
</span>
262-
<span className='text-lg font-bold text-gray-500 uppercase'>
263-
{tier.currency || 'USDC'}
264-
</span>
302+
{tier.description && (
303+
<p className='mt-4 text-xs leading-relaxed text-gray-500'>
304+
{tier.description}
305+
</p>
306+
)}
307+
</div>
308+
))}
309+
</div>
265310
</div>
311+
)}
266312

267-
{tier.description && (
268-
<p className='mt-4 text-xs leading-relaxed text-gray-500'>
269-
{tier.description}
313+
{trackTiers.length > 0 && (
314+
<div className='space-y-6'>
315+
<div className='flex items-center gap-2'>
316+
<Layers className='text-primary h-5 w-5' />
317+
<h2 className='text-xl font-bold text-white'>Track Prizes</h2>
318+
</div>
319+
<p className='text-sm text-gray-400'>
320+
Category prizes alongside the overall placements. Pick the
321+
tracks you want your submission considered for when you
322+
submit.
270323
</p>
271-
)}
272-
</div>
273-
))}
274-
</div>
275-
</section>
324+
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
325+
{trackTiers.map((tier, i) => {
326+
const track = tier.trackId
327+
? trackById.get(tier.trackId)
328+
: undefined;
329+
return (
330+
<div
331+
key={tier.id ?? `track-${i}`}
332+
className='relative flex flex-col rounded-2xl border border-white/5 bg-[#141517] p-6 transition-all hover:border-white/10'
333+
>
334+
<div className='mb-3 flex items-start justify-between gap-2'>
335+
<Badge
336+
variant='outline'
337+
className='border-primary/40 text-primary'
338+
>
339+
{track?.name ?? tier.name ?? 'Track'}
340+
</Badge>
341+
{track?.type && (
342+
<span className='text-[10px] tracking-widest text-gray-500 uppercase'>
343+
{track.type}
344+
</span>
345+
)}
346+
</div>
347+
<div className='mb-2 flex items-baseline gap-1'>
348+
<span className='text-2xl font-black text-white'>
349+
{Number(tier.prizeAmount).toLocaleString()}
350+
</span>
351+
<span className='text-sm font-bold text-gray-500 uppercase'>
352+
{tier.currency || 'USDC'}
353+
</span>
354+
</div>
355+
{(track?.description || tier.description) && (
356+
<p className='text-xs leading-relaxed text-gray-500'>
357+
{track?.description || tier.description}
358+
</p>
359+
)}
360+
{!track && tier.trackId && (
361+
<p className='mt-2 text-[10px] text-amber-400/70'>
362+
Track details loading…
363+
</p>
364+
)}
365+
</div>
366+
);
367+
})}
368+
</div>
369+
</div>
370+
)}
371+
</section>
372+
);
373+
})()}
276374

277375
<SponsorsSection
278376
slug={slug}

0 commit comments

Comments
 (0)