Skip to content

Commit bf06ced

Browse files
0xdevcollinsclaude
andcommitted
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>
1 parent 80a8270 commit bf06ced

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)