From f33a4075c9a8418715e2a954769739f6ad089005 Mon Sep 17 00:00:00 2001
From: Nnaji Benjamin <60315147+Benjtalkshow@users.noreply.github.com>
Date: Sat, 16 May 2026 00:50:36 +0100
Subject: [PATCH 1/2] 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.
---
.../components/tabs/contents/FindTeam.tsx | 2 +-
.../contents/submissions/SubmissionCard.tsx | 20 +++-
components/avatars/GroupAvatar.tsx | 38 +++++--
components/ui/pagination.tsx | 100 ++++++++----------
4 files changed, 96 insertions(+), 64 deletions(-)
diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/FindTeam.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/FindTeam.tsx
index 3b2569fd9..a279e06e7 100644
--- a/app/(landing)/hackathons/[slug]/components/tabs/contents/FindTeam.tsx
+++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/FindTeam.tsx
@@ -267,7 +267,7 @@ const FindTeam = () => {
) : teams.length > 0 ? (
<>
-
+ {hackathon &&
+ hackathon.prizeTiers.length > 0 &&
+ (() => {
+ // Walk the tiers once, but keep a separate counter for
+ // OVERALL placements so the "1st/2nd/3rd" labels stay
+ // accurate even when track tiers are interleaved. Track
+ // tiers get the actual track name (looked up via trackId)
+ // and a "TRACK" prefix so the sidebar matches what the
+ // organizer set up in Rewards.
+ let overallIdx = 0;
+ return (
+
+
+ {/* Compliance.
+ - NEW submissions: license + attestation are required.
+ Submit is hard-gated client-side and the asterisks
+ show on the labels.
+ - Existing submissions (created before Phase A): both
+ fields are optional, the asterisks hide, and submit
+ isn't blocked. Filling them in still works and the
+ data round-trips. */}
+
+
+ {organizationId && hackathonId
+ ? 'No tracks created yet. Click "Add tracks" to create them, then bind tiers to each one below.'
+ : 'No tracks created yet. Create tracks in the Tracks tab first, then come back here to bind tiers to them.'}
+
+ )}
+ {tracksEnabled && hasTracks && (
+
+ {availableTracks.filter(t => !t.isArchived).length} active
+ track
+ {availableTracks.filter(t => !t.isArchived).length === 1
+ ? ''
+ : 's'}{' '}
+ available. Mark a tier's kind as “Track”
+ below to bind it.
+
+ )}
+
+
+
+ )}
+ />
+
+ {/* Tracks-unbound warning. Common case: organizer set the
+ structure + created tracks, but forgot to mark any tier's
+ kind as TRACK. Without this banner the public page shows
+ zero track prizes and the cause isn't obvious. */}
+ {showTracksUnboundBanner && (
+
+
+
+
+
+ Tracks created but no prize is bound to them
+
+
+ You have{' '}
+
+ {availableTracks.filter(t => !t.isArchived).length} track
+ {availableTracks.filter(t => !t.isArchived).length === 1
+ ? ''
+ : 's'}
+ {' '}
+ set up, but none of your prize tiers below is marked as a
+ Track. The public hackathon page will show only your overall
+ placements. To award a track prize:
+
+
+
+ Pick a prize tier you want to be a track prize (or click
+ “Add Prize Tier” for a new one).
+
+ {/* Inline tracks management. Renders the same CRUD UX as the
+ settings-page Tracks tab inside a dialog so organizers can
+ create tracks from the new-hackathon wizard or from the
+ Rewards step without leaving context. */}
+ {organizationId && hackathonId && (
+
+ )}
+
{/* ── Confirmation AlertDialog ── */}
diff --git a/components/organization/hackathons/new/tabs/schemas/rewardsSchema.ts b/components/organization/hackathons/new/tabs/schemas/rewardsSchema.ts
index 8b319b218..aca8133ce 100644
--- a/components/organization/hackathons/new/tabs/schemas/rewardsSchema.ts
+++ b/components/organization/hackathons/new/tabs/schemas/rewardsSchema.ts
@@ -1,25 +1,92 @@
import { z } from 'zod';
-export const prizeTierSchema = z.object({
- id: z.string(),
- place: z.string().trim().min(1, 'Place is required'),
- prizeAmount: z
- .string()
- .refine(
- v => !isNaN(parseFloat(v)) && parseFloat(v) >= 0,
- 'Please enter a valid prize amount'
- ),
- description: z.string().optional(),
- currency: z.string().optional().default('USDC'),
- rank: z.number().int().min(1),
- passMark: z.number().min(0).max(100),
-});
+export const prizeStructureSchema = z.enum([
+ 'OVERALL_ONLY',
+ 'OVERALL_AND_TRACKS',
+ 'TRACKS_ONLY',
+]);
+export type PrizeStructure = z.infer;
-export const rewardsSchema = z.object({
- prizeTiers: z
- .array(prizeTierSchema)
- .min(1, 'At least one prize tier is required'),
-});
+export const prizeTierKindSchema = z.enum(['OVERALL', 'TRACK']);
+export type PrizeTierKind = z.infer;
+
+export const prizeTierSchema = z
+ .object({
+ id: z.string(),
+ place: z.string().trim().min(1, 'Place is required'),
+ prizeAmount: z
+ .string()
+ .refine(
+ v => !isNaN(parseFloat(v)) && parseFloat(v) >= 0,
+ 'Please enter a valid prize amount'
+ ),
+ description: z.string().optional(),
+ currency: z.string().optional().default('USDC'),
+ rank: z.number().int().min(1),
+ passMark: z.number().min(0).max(100),
+ // Optional for backward compatibility — tiers without `kind` are
+ // treated as OVERALL by the backend.
+ kind: prizeTierKindSchema.optional(),
+ // Required when kind=TRACK. References a HackathonTrack on the same
+ // hackathon (organizer creates these in the Tracks tab).
+ trackId: z.string().optional(),
+ })
+ .superRefine((tier, ctx) => {
+ if (tier.kind === 'TRACK' && !tier.trackId) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['trackId'],
+ message: 'Pick a track for this tier',
+ });
+ }
+ if (tier.kind !== 'TRACK' && tier.trackId) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['trackId'],
+ message: 'Remove the track link or set tier kind to TRACK',
+ });
+ }
+ });
+
+export const rewardsSchema = z
+ .object({
+ prizeTiers: z
+ .array(prizeTierSchema)
+ .min(1, 'At least one prize tier is required'),
+ prizeStructure: prizeStructureSchema.optional(),
+ tracksMaxPerSubmission: z.number().int().min(1).max(20).optional(),
+ })
+ .superRefine((data, ctx) => {
+ const structure = data.prizeStructure ?? 'OVERALL_ONLY';
+ const hasTrackTier = data.prizeTiers.some(t => t.kind === 'TRACK');
+ const hasOverallTier = data.prizeTiers.some(
+ t => !t.kind || t.kind === 'OVERALL'
+ );
+ if (structure === 'OVERALL_ONLY' && hasTrackTier) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['prizeStructure'],
+ message:
+ 'Switch the structure to Overall + Tracks (or Tracks only) when any tier is a track.',
+ });
+ }
+ if (structure === 'TRACKS_ONLY' && hasOverallTier) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['prizeTiers'],
+ message:
+ 'Tracks-only mode requires every tier to be a track. Mark the overall tiers as tracks or switch structure.',
+ });
+ }
+ if (structure === 'OVERALL_AND_TRACKS' && !hasTrackTier) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['prizeTiers'],
+ message:
+ 'Add at least one track tier — or switch back to Overall only.',
+ });
+ }
+ });
export type PrizeTier = z.infer;
export type RewardsFormData = z.input;
diff --git a/components/organization/hackathons/settings/TracksSettingsTab.tsx b/components/organization/hackathons/settings/TracksSettingsTab.tsx
new file mode 100644
index 000000000..134502da1
--- /dev/null
+++ b/components/organization/hackathons/settings/TracksSettingsTab.tsx
@@ -0,0 +1,931 @@
+'use client';
+
+import React, { useCallback, useEffect, useState } from 'react';
+import { toast } from 'sonner';
+import {
+ Loader2,
+ Plus,
+ Pencil,
+ Trash2,
+ ArchiveRestore,
+ Users,
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Badge } from '@/components/ui/badge';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from '@/components/ui/dialog';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ bulkOptInAllSubmissions,
+ createTrack,
+ deleteTrack,
+ listOrganizerTracks,
+ updateTrack,
+ type CreateTrackRequest,
+ type HackathonTrack,
+ type TrackCustomQuestion,
+ type TrackEligibility,
+ type TrackRequiredArtifact,
+} from '@/lib/api/hackathons/tracks';
+import { extractApiErrorMessage } from '@/lib/api/api';
+
+interface TracksSettingsTabProps {
+ organizationId: string;
+ hackathonId: string;
+}
+
+const TRACK_TYPE_OPTIONS = [
+ { value: 'skill', label: 'Skill (e.g. Best UI/UX)' },
+ { value: 'technology', label: 'Technology (e.g. Best Use of X)' },
+ { value: 'theme', label: 'Theme (e.g. DeFi)' },
+ { value: 'special', label: 'Special Award' },
+] as const;
+
+const ELIGIBILITY_OPTIONS: Array<{
+ value: TrackEligibility;
+ label: string;
+ hint: string;
+}> = [
+ {
+ value: 'OPT_IN',
+ label: 'Opt-in',
+ hint: 'Submitters explicitly enter this track.',
+ },
+ {
+ value: 'OPEN',
+ label: 'Open',
+ hint: 'Every submission is auto-eligible. No opt-in row needed.',
+ },
+];
+
+interface TrackFormState {
+ id?: string;
+ name: string;
+ slug: string;
+ description: string;
+ type: string;
+ eligibility: TrackEligibility;
+ displayOrder: number;
+ // Phase B customization
+ prompt: string;
+ customQuestions: TrackCustomQuestion[];
+ requiredArtifacts: TrackRequiredArtifact[];
+}
+
+const emptyForm = (next: HackathonTrack[] = []): TrackFormState => ({
+ name: '',
+ slug: '',
+ description: '',
+ type: 'skill',
+ eligibility: 'OPT_IN',
+ // Default to (max existing + 10) so the new row lands at the end of the list.
+ displayOrder:
+ next.length > 0 ? Math.max(...next.map(t => t.displayOrder)) + 10 : 10,
+ prompt: '',
+ customQuestions: [],
+ requiredArtifacts: [],
+});
+
+// Generate a stable id used inside customQuestions / requiredArtifacts.
+// Doesn't need to be a real cuid; uniqueness within the track is enough.
+const tinyId = () =>
+ `q-${Math.random().toString(36).slice(2, 8)}${Date.now().toString(36).slice(-3)}`;
+
+export default function TracksSettingsTab({
+ organizationId,
+ hackathonId,
+}: TracksSettingsTabProps) {
+ const [tracks, setTracks] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [form, setForm] = useState(emptyForm());
+ const [confirmDelete, setConfirmDelete] = useState(
+ null
+ );
+ // Retrofit dialog state. Populated when the organizer clicks the
+ // "Opt in all submissions" action — we confirm before firing the
+ // endpoint because it can touch every submission for the hackathon.
+ const [confirmBulkOptIn, setConfirmBulkOptIn] =
+ useState(null);
+ const [bulkOptInBusy, setBulkOptInBusy] = useState(false);
+
+ const fetchTracks = useCallback(async () => {
+ setLoading(true);
+ try {
+ const rows = await listOrganizerTracks(organizationId, hackathonId);
+ setTracks(rows);
+ } catch (err) {
+ toast.error(extractApiErrorMessage(err) ?? 'Failed to load tracks');
+ } finally {
+ setLoading(false);
+ }
+ }, [organizationId, hackathonId]);
+
+ useEffect(() => {
+ fetchTracks();
+ }, [fetchTracks]);
+
+ const openCreate = () => {
+ setForm(emptyForm(tracks));
+ setDialogOpen(true);
+ };
+
+ const openEdit = (track: HackathonTrack) => {
+ setForm({
+ id: track.id,
+ name: track.name,
+ slug: track.slug,
+ description: track.description ?? '',
+ type: track.type ?? '',
+ eligibility: track.eligibility,
+ displayOrder: track.displayOrder,
+ prompt: track.prompt ?? '',
+ customQuestions: track.customQuestions ?? [],
+ requiredArtifacts: track.requiredArtifacts ?? [],
+ });
+ setDialogOpen(true);
+ };
+
+ const closeDialog = () => {
+ if (saving) return;
+ setDialogOpen(false);
+ };
+
+ const handleSave = async () => {
+ if (!form.name.trim()) {
+ toast.error('Track name is required');
+ return;
+ }
+ setSaving(true);
+ try {
+ // Strip blank labels before sending — leftover empty rows from
+ // accidental "Add" clicks shouldn't reach the backend.
+ const cleanedQuestions = form.customQuestions
+ .map(q => ({ ...q, label: q.label.trim() }))
+ .filter(q => q.label.length > 0);
+ const cleanedArtifacts = form.requiredArtifacts
+ .map(a => ({ ...a, label: a.label.trim() }))
+ .filter(a => a.label.length > 0);
+ if (form.id) {
+ const updated = await updateTrack(
+ organizationId,
+ hackathonId,
+ form.id,
+ {
+ name: form.name.trim(),
+ slug: form.slug.trim() || undefined,
+ description: form.description.trim() || undefined,
+ type: form.type || undefined,
+ eligibility: form.eligibility,
+ displayOrder: form.displayOrder,
+ prompt: form.prompt.trim() || undefined,
+ customQuestions: cleanedQuestions,
+ requiredArtifacts: cleanedArtifacts,
+ }
+ );
+ setTracks(prev =>
+ prev.map(t => (t.id === updated.id ? { ...t, ...updated } : t))
+ );
+ toast.success('Track updated');
+ } else {
+ const payload: CreateTrackRequest = {
+ name: form.name.trim(),
+ slug: form.slug.trim() || undefined,
+ description: form.description.trim() || undefined,
+ type: form.type || undefined,
+ eligibility: form.eligibility,
+ displayOrder: form.displayOrder,
+ prompt: form.prompt.trim() || undefined,
+ customQuestions: cleanedQuestions,
+ requiredArtifacts: cleanedArtifacts,
+ };
+ const created = await createTrack(organizationId, hackathonId, payload);
+ setTracks(prev =>
+ [...prev, created].sort((a, b) => a.displayOrder - b.displayOrder)
+ );
+ toast.success('Track created');
+ }
+ setDialogOpen(false);
+ } catch (err) {
+ toast.error(extractApiErrorMessage(err) ?? 'Failed to save track');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleDelete = async (track: HackathonTrack) => {
+ setSaving(true);
+ try {
+ await deleteTrack(organizationId, hackathonId, track.id);
+ // The backend hard-deletes when there are no entries, or archives.
+ // Re-fetch so the table reflects whichever happened.
+ await fetchTracks();
+ toast.success(
+ track.entryCount > 0
+ ? 'Track archived (had submissions)'
+ : 'Track deleted'
+ );
+ } catch (err) {
+ toast.error(extractApiErrorMessage(err) ?? 'Failed to delete track');
+ } finally {
+ setSaving(false);
+ setConfirmDelete(null);
+ }
+ };
+
+ const handleUnarchive = async (track: HackathonTrack) => {
+ setSaving(true);
+ try {
+ const updated = await updateTrack(organizationId, hackathonId, track.id, {
+ isArchived: false,
+ });
+ setTracks(prev =>
+ prev.map(t => (t.id === updated.id ? { ...t, ...updated } : t))
+ );
+ toast.success('Track restored');
+ } catch (err) {
+ toast.error(extractApiErrorMessage(err) ?? 'Failed to restore track');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleBulkOptIn = async (track: HackathonTrack) => {
+ setBulkOptInBusy(true);
+ try {
+ const result = await bulkOptInAllSubmissions(
+ organizationId,
+ hackathonId,
+ track.id
+ );
+ const parts: string[] = [];
+ if (result.added > 0) {
+ parts.push(
+ `${result.added} submission${result.added === 1 ? '' : 's'} added`
+ );
+ }
+ if (result.alreadyOptedIn > 0) {
+ parts.push(`${result.alreadyOptedIn} already in`);
+ }
+ if (result.skippedDisqualified > 0) {
+ parts.push(`${result.skippedDisqualified} disqualified skipped`);
+ }
+ const summary =
+ parts.length > 0 ? parts.join(' · ') : 'No changes needed';
+ toast.success(
+ `${result.trackName}: ${summary}${
+ result.newCap
+ ? ` · Per-submission cap raised to ${result.newCap}`
+ : ''
+ }`
+ );
+ // Refetch so the entryCount column reflects the new state.
+ await fetchTracks();
+ } catch (err) {
+ toast.error(extractApiErrorMessage(err) ?? 'Bulk opt-in failed');
+ } finally {
+ setBulkOptInBusy(false);
+ setConfirmBulkOptIn(null);
+ }
+ };
+
+ return (
+
+
+
+
Tracks
+
+ Categorical prizes alongside overall placements (e.g. Best UI/UX,
+ Best Technical). Submitters opt into tracks at submission time;
+ winners are picked from each track's opted-in pool.
+
+
+
+
+
+ {loading ? (
+
+
+
+ ) : tracks.length === 0 ? (
+
+
+ No tracks yet. Create one to unlock track-based prizes in the
+ Rewards tab.
+
+
+ ) : (
+
+
+
+ Name
+ Type
+ Eligibility
+ Entries
+ Order
+ Actions
+
+
+
+ {tracks.map(track => (
+
+
+
+ {/* Bulk opt-in: retrofit tool for hackathons where
+ submissions already exist before tracks were
+ added. Hidden for archived tracks and OPEN
+ tracks (which auto-include everyone). */}
+ {!track.isArchived && track.eligibility === 'OPT_IN' && (
+
+ )}
+ {track.isArchived ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+ !o && setConfirmDelete(null)}
+ >
+
+
+ Delete track?
+
+ {confirmDelete && confirmDelete.entryCount > 0 ? (
+ <>
+ This track has{' '}
+
+ {confirmDelete.entryCount}
+ {' '}
+ submission
+ {confirmDelete.entryCount === 1 ? '' : 's'} attached. It will
+ be archived instead of deleted so the existing entries stay
+ intact. You can restore it later.
+ >
+ ) : (
+ 'This track has no submissions yet, so it will be permanently deleted.'
+ )}
+
+
+
+ Cancel
+ confirmDelete && handleDelete(confirmDelete)}
+ disabled={saving}
+ >
+ {saving && }
+ {confirmDelete && confirmDelete.entryCount > 0
+ ? 'Archive'
+ : 'Delete'}
+
+
+
+
+
+ !o && !bulkOptInBusy && setConfirmBulkOptIn(null)}
+ >
+
+
+ Opt in all submissions?
+
+ Every existing submission in this hackathon will be added to{' '}
+
+ {confirmBulkOptIn?.name}
+
+ . Submitters can still opt themselves out by editing their
+ submission. Disqualified submissions are skipped.
+
+
+ Use this when tracks were created after submissions already exist
+ — it lets the winner allocator pick from the full pool instead of
+ only the (small) set of submitters who opted in manually.
+
+
+ If a submission would end up in more tracks than the current
+ per-submission cap allows, the cap is auto-raised.
+
+
+
+
+ Cancel
+
+
+ confirmBulkOptIn && handleBulkOptIn(confirmBulkOptIn)
+ }
+ disabled={bulkOptInBusy}
+ >
+ {bulkOptInBusy && }
+ Opt in all submissions
+
+
+
+
+
+ );
+}
diff --git a/hooks/hackathon/use-hackathon-queries.ts b/hooks/hackathon/use-hackathon-queries.ts
index af8bad539..0e693d409 100644
--- a/hooks/hackathon/use-hackathon-queries.ts
+++ b/hooks/hackathon/use-hackathon-queries.ts
@@ -19,6 +19,7 @@ import {
} from '@/lib/api/hackathons/participants';
import { getExploreSubmissions } from '@/lib/api/hackathons';
import { listAnnouncements } from '@/lib/api/hackathons/index';
+import { listTracks, type HackathonTrack } from '@/lib/api/hackathons/tracks';
import {
getTeams,
createTeam,
@@ -82,6 +83,7 @@ export const hackathonKeys = {
}
) => ['hackathon', 'exploreSubmissions', id, params] as const,
winners: (idOrSlug: string) => ['hackathon', 'winners', idOrSlug] as const,
+ tracks: (idOrSlug: string) => ['hackathon', 'tracks', idOrSlug] as const,
announcements: (idOrSlug: string) =>
['hackathon', 'announcements', idOrSlug] as const,
teams: (idOrSlug: string, params?: GetTeamOptions) =>
@@ -255,6 +257,10 @@ export function useExploreSubmissions(
/**
* Fetch winners for a hackathon.
+ *
+ * Returns the legacy `HackathonWinner[]` shape for backward compatibility
+ * via the array-typed callers. Use `useHackathonWinnersWithTracks` when
+ * you also need the track-based prize winners.
*/
export function useHackathonWinners(idOrSlug: string, enabled = true) {
return useQuery({
@@ -268,6 +274,58 @@ export function useHackathonWinners(idOrSlug: string, enabled = true) {
});
}
+/**
+ * Fetch winners + track winners for a hackathon. Used by the public
+ * winners view to render both the overall podium and per-track prizes.
+ */
+export function useHackathonWinnersWithTracks(
+ idOrSlug: string,
+ enabled = true
+) {
+ return useQuery<{
+ winners: HackathonWinner[];
+ trackWinners: import('@/lib/api/hackathons').HackathonTrackWinner[];
+ }>({
+ queryKey: [...hackathonKeys.winners(idOrSlug), 'with-tracks'],
+ queryFn: async () => {
+ const response = await getHackathonWinners(idOrSlug);
+ if (!response.success || !response.data) {
+ return { winners: [], trackWinners: [] };
+ }
+ return {
+ winners: response.data.winners ?? [],
+ trackWinners:
+ (
+ response.data as {
+ trackWinners?: import('@/lib/api/hackathons').HackathonTrackWinner[];
+ }
+ ).trackWinners ?? [],
+ };
+ },
+ enabled: !!idOrSlug && enabled,
+ });
+}
+
+/**
+ * Public list of tracks for a hackathon. Returns only active (non-archived)
+ * tracks. Used by the public detail page to render Track Prizes and the
+ * submission form to populate the track picker.
+ */
+export function useHackathonTracks(idOrSlug: string, enabled = true) {
+ return useQuery({
+ queryKey: hackathonKeys.tracks(idOrSlug),
+ queryFn: async () => {
+ try {
+ return await listTracks(idOrSlug);
+ } catch {
+ // Best-effort: a 404 / network error shouldn't kill the page.
+ return [];
+ }
+ },
+ enabled: !!idOrSlug && enabled,
+ });
+}
+
/**
* Fetch announcements for a hackathon (public, non-draft only).
*/
diff --git a/lib/api/hackathons.ts b/lib/api/hackathons.ts
index 2a998257d..73c46978c 100644
--- a/lib/api/hackathons.ts
+++ b/lib/api/hackathons.ts
@@ -404,8 +404,15 @@ export type Hackathon = {
currency?: string;
description?: string;
passMark?: number;
+ kind?: 'OVERALL' | 'TRACK';
+ trackId?: string;
}>;
+ /** Track-based prize structure. Defaults to OVERALL_ONLY. */
+ prizeStructure?: 'OVERALL_ONLY' | 'OVERALL_AND_TRACKS' | 'TRACKS_ONLY';
+ /** Cap on tracks a submission may opt into. Defaults to 3. */
+ tracksMaxPerSubmission?: number;
+
phases: Array<{
id?: string;
name?: string;
@@ -736,6 +743,22 @@ export interface ParticipantSubmission {
email: string;
} | null;
reviewedAt?: string | null;
+ /** Track entries opted into by the submitter. */
+ trackEntries?: Array<{
+ trackId: string;
+ trackSlug: string;
+ trackName: string;
+ wonRank: number | null;
+ }>;
+ /** Overall placement (1, 2, 3, ...). Null until results published. */
+ rank?: number | null;
+
+ // ── Phase A submission polish ──
+ tagline?: string;
+ builtWith?: string[];
+ screenshots?: string[];
+ license?: string;
+ codeAttestedAt?: string | null;
}
export interface ExploreSubmissionsResponse {
@@ -879,6 +902,25 @@ export interface CreateSubmissionRequest {
twitter?: string;
email?: string;
};
+ /** Optional track opt-in. Capped by hackathon.tracksMaxPerSubmission. */
+ trackIds?: string[];
+
+ /** Per-track answers (Phase B). */
+ trackAnswers?: Record<
+ string,
+ {
+ promptAnswer?: string;
+ customAnswers?: Record;
+ artifacts?: Record;
+ }
+ >;
+
+ // ── Phase A submission polish ──
+ tagline?: string;
+ builtWith?: string[];
+ screenshots?: string[];
+ license?: string;
+ codeAttested?: boolean;
}
export interface UpdateSubmissionRequest extends CreateSubmissionRequest {
@@ -2963,9 +3005,32 @@ export interface HackathonWinner {
slug?: string;
}
+export interface HackathonTrackWinner {
+ track: {
+ id: string;
+ slug: string;
+ name: string;
+ description?: string;
+ };
+ /** Placement within the track. P1 currently emits 1 only. */
+ wonRank: number;
+ projectName: string;
+ logo: string | null;
+ teamName: string | null;
+ participants: Array<{
+ userId?: string;
+ username: string;
+ avatar?: string;
+ }>;
+ prize: string;
+ submissionId: string;
+}
+
export interface GetHackathonWinnersResponse extends ApiResponse<{
hackathonId: string;
winners: HackathonWinner[];
+ /** Track winners; empty when the hackathon uses OVERALL_ONLY structure. */
+ trackWinners?: HackathonTrackWinner[];
}> {
success: true;
}
diff --git a/lib/api/hackathons/index.ts b/lib/api/hackathons/index.ts
index 311b6288f..5ef86d4f6 100644
--- a/lib/api/hackathons/index.ts
+++ b/lib/api/hackathons/index.ts
@@ -78,3 +78,4 @@ export * from './teams';
export * from './resources';
export * from './announcements';
export * from './partners';
+export * from './tracks';
diff --git a/lib/api/hackathons/tracks.ts b/lib/api/hackathons/tracks.ts
new file mode 100644
index 000000000..a33a86c11
--- /dev/null
+++ b/lib/api/hackathons/tracks.ts
@@ -0,0 +1,204 @@
+import api from '../api';
+import { ApiResponse } from '../types';
+
+// ── Types ───────────────────────────────────────────────────────────────
+
+export type TrackEligibility = 'OPT_IN' | 'OPEN';
+
+export type HackathonPrizeStructure =
+ | 'OVERALL_ONLY'
+ | 'OVERALL_AND_TRACKS'
+ | 'TRACKS_ONLY';
+
+export interface TrackCustomQuestion {
+ id: string;
+ label: string;
+ type: 'short' | 'long' | 'url';
+ maxLength?: number;
+ required?: boolean;
+}
+
+export interface TrackRequiredArtifact {
+ id: string;
+ label: string;
+ type: 'figma' | 'github' | 'video' | 'pdf' | 'url';
+ required?: boolean;
+}
+
+export interface HackathonTrack {
+ id: string;
+ hackathonId: string;
+ slug: string;
+ name: string;
+ description?: string;
+ /** Free-form classifier: 'skill' | 'technology' | 'theme' | 'special'. */
+ type?: string;
+ eligibility: TrackEligibility;
+ displayOrder: number;
+ isArchived: boolean;
+ /** Number of submissions opted into this track. */
+ entryCount: number;
+ /** Single open-ended prompt rendered on the submission form. */
+ prompt?: string;
+ /** Organizer-defined custom questions. Phase B. */
+ customQuestions?: TrackCustomQuestion[];
+ /** Required artifact slots (e.g. Figma file URL). Phase B. */
+ requiredArtifacts?: TrackRequiredArtifact[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface CreateTrackRequest {
+ name: string;
+ /** Optional. Auto-generated from name if omitted. */
+ slug?: string;
+ description?: string;
+ type?: string;
+ eligibility?: TrackEligibility;
+ displayOrder?: number;
+ prompt?: string;
+ customQuestions?: TrackCustomQuestion[];
+ requiredArtifacts?: TrackRequiredArtifact[];
+}
+
+export interface UpdateTrackRequest {
+ name?: string;
+ slug?: string;
+ description?: string;
+ type?: string;
+ eligibility?: TrackEligibility;
+ displayOrder?: number;
+ isArchived?: boolean;
+ prompt?: string;
+ customQuestions?: TrackCustomQuestion[];
+ requiredArtifacts?: TrackRequiredArtifact[];
+}
+
+/** Submitter responses to a single track's customization. */
+export interface TrackAnswer {
+ promptAnswer?: string;
+ customAnswers?: Record;
+ artifacts?: Record;
+}
+
+/**
+ * Shape returned alongside each submission once tracks are wired in.
+ * `wonRank` is stamped when results are published; null otherwise.
+ * `trackAnswers` carries the submitter's responses to the track's
+ * customization (Phase B).
+ */
+export interface SubmissionTrackEntry {
+ trackId: string;
+ trackSlug: string;
+ trackName: string;
+ wonRank: number | null;
+ trackAnswers?: TrackAnswer;
+}
+
+// ── API ─────────────────────────────────────────────────────────────────
+
+/**
+ * Public list of tracks for a hackathon. Pass includeArchived for the
+ * organizer view (the management table needs the full set so renames /
+ * un-archiving stay visible).
+ */
+export const listTracks = async (
+ idOrSlug: string,
+ options?: { includeArchived?: boolean }
+): Promise => {
+ const params = new URLSearchParams();
+ if (options?.includeArchived) {
+ params.append('includeArchived', 'true');
+ }
+ const qs = params.toString();
+ const url = `/hackathons/${idOrSlug}/tracks${qs ? `?${qs}` : ''}`;
+ const res = await api.get>(url);
+ return res.data?.data ?? [];
+};
+
+/**
+ * Organizer view. Includes archived tracks by default; pass
+ * `{ includeArchived: false }` to hide them.
+ */
+export const listOrganizerTracks = async (
+ organizationId: string,
+ hackathonId: string,
+ options?: { includeArchived?: boolean }
+): Promise => {
+ const params = new URLSearchParams();
+ if (options?.includeArchived === false) {
+ params.append('includeArchived', 'false');
+ }
+ const qs = params.toString();
+ const url = `/organizations/${organizationId}/hackathons/${hackathonId}/tracks${qs ? `?${qs}` : ''}`;
+ const res = await api.get>(url);
+ return res.data?.data ?? [];
+};
+
+export const createTrack = async (
+ organizationId: string,
+ hackathonId: string,
+ data: CreateTrackRequest
+): Promise => {
+ const res = await api.post>(
+ `/organizations/${organizationId}/hackathons/${hackathonId}/tracks`,
+ data
+ );
+ if (!res.data?.data) throw new Error('Invalid create-track response');
+ return res.data.data;
+};
+
+export const updateTrack = async (
+ organizationId: string,
+ hackathonId: string,
+ trackId: string,
+ data: UpdateTrackRequest
+): Promise => {
+ const res = await api.patch>(
+ `/organizations/${organizationId}/hackathons/${hackathonId}/tracks/${trackId}`,
+ data
+ );
+ if (!res.data?.data) throw new Error('Invalid update-track response');
+ return res.data.data;
+};
+
+/**
+ * Hard-deletes a track with no entries; soft-archives it otherwise.
+ * 204 No Content on success.
+ */
+export const deleteTrack = async (
+ organizationId: string,
+ hackathonId: string,
+ trackId: string
+): Promise => {
+ await api.delete(
+ `/organizations/${organizationId}/hackathons/${hackathonId}/tracks/${trackId}`
+ );
+};
+
+export interface BulkOptInResult {
+ trackName: string;
+ added: number;
+ alreadyOptedIn: number;
+ skippedDisqualified: number;
+ totalSubmissions: number;
+ /** Set when the hackathon's tracksMaxPerSubmission was auto-raised. */
+ newCap?: number;
+}
+
+/**
+ * Organizer-only retrofit: opt every existing submission into this track.
+ * Use when tracks were added after submissions already exist.
+ * Idempotent. Auto-bumps tracksMaxPerSubmission if needed.
+ */
+export const bulkOptInAllSubmissions = async (
+ organizationId: string,
+ hackathonId: string,
+ trackId: string
+): Promise => {
+ const res = await api.post>(
+ `/organizations/${organizationId}/hackathons/${hackathonId}/tracks/${trackId}/bulk-opt-in`
+ );
+ if (!res.data?.data) throw new Error('Invalid bulk-opt-in response');
+ return res.data.data;
+};
diff --git a/lib/providers/hackathonProvider.tsx b/lib/providers/hackathonProvider.tsx
index c7bc76501..898465a28 100644
--- a/lib/providers/hackathonProvider.tsx
+++ b/lib/providers/hackathonProvider.tsx
@@ -13,13 +13,17 @@
*/
import React, { createContext, useContext, ReactNode } from 'react';
-import type { Hackathon, HackathonWinner } from '@/lib/api/hackathons';
+import type {
+ Hackathon,
+ HackathonWinner,
+ HackathonTrackWinner,
+} from '@/lib/api/hackathons';
import type { SubmissionCardProps } from '@/types/hackathon';
import {
useHackathon,
useHackathonSubmissions,
useExploreSubmissions,
- useHackathonWinners,
+ useHackathonWinnersWithTracks,
useRefreshHackathon,
hackathonKeys,
} from '@/hooks/hackathon/use-hackathon-queries';
@@ -42,6 +46,7 @@ interface HackathonDataContextType {
exploreSubmissions: SubmissionCardProps[];
exploreSubmissionsTotal: number;
winners: HackathonWinner[];
+ trackWinners: HackathonTrackWinner[];
// Loading / error
loading: boolean;
@@ -137,10 +142,12 @@ export function HackathonDataProvider({
(currentHackathon?.resultsPublished === true || isOrganizerView);
const {
- data: winners = [],
+ data: winnersBundle = { winners: [], trackWinners: [] },
isLoading: winnersLoading,
error: winnersError,
- } = useHackathonWinners(hackathonSlug, canViewWinners);
+ } = useHackathonWinnersWithTracks(hackathonSlug, canViewWinners);
+ const winners = winnersBundle.winners;
+ const trackWinners = winnersBundle.trackWinners;
const refreshCurrentHackathon = useRefreshHackathon(hackathonSlug);
@@ -158,6 +165,7 @@ export function HackathonDataProvider({
exploreSubmissions,
exploreSubmissionsTotal,
winners,
+ trackWinners,
loading,
error,
refreshCurrentHackathon,
diff --git a/types/hackathon/core.ts b/types/hackathon/core.ts
index 738aa45bf..82cdd2a06 100644
--- a/types/hackathon/core.ts
+++ b/types/hackathon/core.ts
@@ -110,10 +110,23 @@ export interface PrizeTier {
prizeAmount?: string;
/** @deprecated Use prizeAmount. Kept for API compatibility. */
amount?: string;
+ /** Tier classification — OVERALL (default) or TRACK. Added with the
+ * track-based prize structure feature. Tiers without `kind` are
+ * treated as OVERALL by the backend. */
+ kind?: 'OVERALL' | 'TRACK';
+ /** Required when kind=TRACK. References a HackathonTrack on the same hackathon. */
+ trackId?: string;
}
+export type HackathonPrizeStructure =
+ | 'OVERALL_ONLY'
+ | 'OVERALL_AND_TRACKS'
+ | 'TRACKS_ONLY';
+
export interface HackathonRewards {
prizeTiers: PrizeTier[];
+ prizeStructure?: HackathonPrizeStructure;
+ tracksMaxPerSubmission?: number;
}
export interface JudgingCriterion {
@@ -260,8 +273,15 @@ export type Hackathon = {
currency?: string;
description?: string;
passMark?: number;
+ kind?: 'OVERALL' | 'TRACK';
+ trackId?: string;
}>;
+ /** P1 of track-based prize structure. Defaults to OVERALL_ONLY when omitted. */
+ prizeStructure?: HackathonPrizeStructure;
+ /** Cap on tracks a submission may opt into. Defaults to 3. */
+ tracksMaxPerSubmission?: number;
+
phases: Array<{
id?: string;
name?: string;
diff --git a/types/hackathon/participant.ts b/types/hackathon/participant.ts
index 3b1cc74c4..81dd726dc 100644
--- a/types/hackathon/participant.ts
+++ b/types/hackathon/participant.ts
@@ -94,6 +94,19 @@ export interface ParticipantSubmission {
email: string;
} | null;
reviewedAt?: string | null;
+ /** Track entries on this submission. Populated by the backend when the
+ * submitter opts into tracks; wonRank is stamped at publish time. */
+ trackEntries?: SubmissionTrackEntry[];
+ /** Overall placement (1, 2, 3...). Null until results are published. */
+ rank?: number | null;
+
+ // ── Phase A submission polish ──
+ tagline?: string;
+ builtWith?: string[];
+ screenshots?: string[];
+ license?: string;
+ /** ISO timestamp set when the submitter ticked the originality attestation. */
+ codeAttestedAt?: string | null;
}
export interface Participant {
@@ -166,6 +179,37 @@ export interface CreateSubmissionRequest {
twitter?: string;
email?: string;
};
+ /** Optional track opt-in. Capped by the hackathon's tracksMaxPerSubmission. */
+ trackIds?: string[];
+
+ /** Per-track answers (Phase B). Keyed by trackId. */
+ trackAnswers?: Record<
+ string,
+ {
+ promptAnswer?: string;
+ customAnswers?: Record;
+ artifacts?: Record;
+ }
+ >;
+
+ // ── Phase A submission polish ──
+ /** Short elevator pitch (~160 chars). */
+ tagline?: string;
+ /** Free-form tech-stack chips. */
+ builtWith?: string[];
+ /** Up to 5 screenshot URLs. */
+ screenshots?: string[];
+ /** License code (MIT / Apache-2.0 / GPL-3.0 / BSD-3 / PROPRIETARY / OTHER). */
+ license?: string;
+ /** True when the submitter has ticked the originality attestation. */
+ codeAttested?: boolean;
+}
+
+export interface SubmissionTrackEntry {
+ trackId: string;
+ trackSlug: string;
+ trackName: string;
+ wonRank: number | null;
}
export interface UpdateSubmissionRequest extends CreateSubmissionRequest {