+ {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. */}
+
+ Each section shows submissions opted into that track, sorted by their
+ average score across all judges. The highlighted row is the current
+ leader — at publish time, EXCLUSIVE stacking may promote a runner-up if
+ the leader wins an overall placement or another track.
+
+ No submissions have opted into this track yet. Use the{' '}
+
+ Settings → Tracks → Opt in all
+ {' '}
+ action if you want to retrofit existing submissions.
+
+ ) : (
+
+ )}
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/components/organization/hackathons/new/NewHackathonTab.tsx b/components/organization/hackathons/new/NewHackathonTab.tsx
index af25cf0ce..aa205c38d 100644
--- a/components/organization/hackathons/new/NewHackathonTab.tsx
+++ b/components/organization/hackathons/new/NewHackathonTab.tsx
@@ -261,6 +261,8 @@ export default function NewHackathonTab({
onSave={saveRewardsStep}
initialData={stepData.rewards}
isLoading={loadingStates.rewards}
+ organizationId={derivedOrgId}
+ hackathonId={draftId ?? undefined}
/>
diff --git a/components/organization/hackathons/new/tabs/RewardsTab.tsx b/components/organization/hackathons/new/tabs/RewardsTab.tsx
index 0e3aeea84..178b4e8e1 100644
--- a/components/organization/hackathons/new/tabs/RewardsTab.tsx
+++ b/components/organization/hackathons/new/tabs/RewardsTab.tsx
@@ -1,4 +1,10 @@
-import React, { useState, useMemo, useEffect, useRef } from 'react';
+import React, {
+ useState,
+ useMemo,
+ useEffect,
+ useRef,
+ useCallback,
+} from 'react';
import { BoundlessButton } from '@/components/buttons';
import { toast } from 'sonner';
import {
@@ -63,6 +69,15 @@ import {
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
+import { listOrganizerTracks } from '@/lib/api/hackathons/tracks';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from '@/components/ui/dialog';
+import TracksSettingsTab from '@/components/organization/hackathons/settings/TracksSettingsTab';
interface RewardsTabProps {
onContinue?: () => void;
@@ -72,6 +87,10 @@ interface RewardsTabProps {
/** Required to call the financial preview dry-run endpoint */
organizationId?: string;
hackathonId?: string;
+ /** Tracks the organizer has created. Passed in so the parent can keep
+ * ownership of the fetch + react-query / refresh logic.
+ * Defaults to [] which collapses the track UI back to overall-only. */
+ availableTracks?: TrackOption[];
}
const PRIZE_PRESETS = {
@@ -138,6 +157,17 @@ interface PrizeTierProps {
canRemove: boolean;
control: Control;
totalTiers: number;
+ /** Tracks the organizer has created on this hackathon. */
+ availableTracks: TrackOption[];
+ /** When true, tier kind picker + track binding UI is visible. */
+ tracksEnabled: boolean;
+}
+
+export interface TrackOption {
+ id: string;
+ name: string;
+ slug: string;
+ isArchived: boolean;
}
// ─── Sortable Prize Tier ─────────────────────────────────────────────────────
@@ -148,6 +178,8 @@ const PrizeTierComponent = ({
canRemove,
control,
totalTiers,
+ availableTracks,
+ tracksEnabled,
}: PrizeTierProps) => {
const {
attributes,
@@ -260,6 +292,75 @@ const PrizeTierComponent = ({
)}
/>
+
+ {/* Tier kind + track binding (only when structure includes tracks) */}
+ {tracksEnabled && (
+
+ )}
{/* Delete Button */}
@@ -602,7 +703,37 @@ export default function RewardsTab({
isLoading = false,
organizationId,
hackathonId,
+ availableTracks: availableTracksProp,
}: RewardsTabProps) {
+ // Tracks state. When the parent passes `availableTracks` we honor it
+ // and skip the internal fetch (settings page already maintains its own
+ // list). Otherwise we fetch + manage tracks ourselves so the new-
+ // hackathon wizard works without parent plumbing.
+ const [internalTracks, setInternalTracks] = useState([]);
+ const availableTracks = availableTracksProp ?? internalTracks;
+ const refetchTracks = useCallback(async () => {
+ if (availableTracksProp) return; // parent owns this state
+ if (!organizationId || !hackathonId) return;
+ try {
+ const rows = await listOrganizerTracks(organizationId, hackathonId);
+ setInternalTracks(
+ rows.map(r => ({
+ id: r.id,
+ name: r.name,
+ slug: r.slug,
+ isArchived: r.isArchived,
+ }))
+ );
+ } catch {
+ // Best-effort: track UI degrades gracefully if the call fails.
+ setInternalTracks([]);
+ }
+ }, [availableTracksProp, organizationId, hackathonId]);
+ useEffect(() => {
+ refetchTracks();
+ }, [refetchTracks]);
+ const [manageTracksOpen, setManageTracksOpen] = useState(false);
+
const [showPresets, setShowPresets] = useState(false);
const [pendingPreset, setPendingPreset] = useState<
keyof typeof PRIZE_PRESETS | null
@@ -633,15 +764,25 @@ export default function RewardsTab({
if (initialData?.prizeTiers?.length) {
return {
...initialData,
- prizeTiers: initialData.prizeTiers.map((tier, idx) => ({
- id: (tier as any).id || `tier-init-${idx}-${Date.now()}`,
- place: tier.place || `${PLACE_LABELS[idx] || `${idx + 1}th`} Place`,
- prizeAmount: String(tier.prizeAmount ?? '0'),
- description: tier.description || '',
- currency: tier.currency || 'USDC',
- rank: Number(tier.rank ?? idx + 1),
- passMark: Number((tier as any).passMark ?? 0),
- })),
+ prizeTiers: initialData.prizeTiers.map((tier, idx) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const raw = tier as any;
+ return {
+ id: raw.id || `tier-init-${idx}-${Date.now()}`,
+ place: tier.place || `${PLACE_LABELS[idx] || `${idx + 1}th`} Place`,
+ prizeAmount: String(tier.prizeAmount ?? '0'),
+ description: tier.description || '',
+ currency: tier.currency || 'USDC',
+ rank: Number(tier.rank ?? idx + 1),
+ passMark: Number(raw.passMark ?? 0),
+ // Preserve track-binding fields when reloading a saved
+ // draft — without these the per-tier track picker shows
+ // blank even though the structure picker remembers the
+ // organizer's choice.
+ ...(raw.kind !== undefined && { kind: raw.kind }),
+ ...(raw.trackId !== undefined && { trackId: raw.trackId }),
+ };
+ }),
};
}
return {
@@ -889,6 +1030,39 @@ export default function RewardsTab({
form.formState.errors.prizeTiers?.message ||
(form.formState.errors.prizeTiers?.root as any)?.message;
+ // Live structure picker value drives:
+ // - Whether per-tier kind/track UI is shown.
+ // - Whether the schema's superRefine rejects mismatched tiers.
+ const prizeStructure = useWatch({
+ control: form.control,
+ name: 'prizeStructure',
+ defaultValue: 'OVERALL_ONLY',
+ });
+ const tracksEnabled =
+ prizeStructure === 'OVERALL_AND_TRACKS' || prizeStructure === 'TRACKS_ONLY';
+ const hasTracks = availableTracks.some(t => !t.isArchived);
+
+ // Detect "tracks created but no tier is bound to them" — the most
+ // common reason the public page shows zero track prizes. Surfaces a
+ // banner with a direct CTA so organizers don't have to guess what
+ // step they missed.
+ const watchedTiers = useWatch({
+ control: form.control,
+ name: 'prizeTiers',
+ defaultValue: form.getValues('prizeTiers') || [],
+ });
+ const hasAnyTrackTier = (watchedTiers ?? []).some(t => t?.kind === 'TRACK');
+ const showTracksUnboundBanner =
+ tracksEnabled && hasTracks && !hasAnyTrackTier;
+ const boundTrackIds = new Set(
+ (watchedTiers ?? [])
+ .filter(t => t?.kind === 'TRACK' && t?.trackId)
+ .map(t => t!.trackId as string)
+ );
+ const unboundActiveTracks = availableTracks.filter(
+ t => !t.isArchived && !boundTrackIds.has(t.id)
+ );
+
return (
+ {/* 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/SubmissionVisibilitySettingsTab.tsx b/components/organization/hackathons/settings/SubmissionVisibilitySettingsTab.tsx
index 8bf72f289..5644e48ad 100644
--- a/components/organization/hackathons/settings/SubmissionVisibilitySettingsTab.tsx
+++ b/components/organization/hackathons/settings/SubmissionVisibilitySettingsTab.tsx
@@ -47,7 +47,8 @@ export default function SubmissionVisibilitySettingsTab({
resolver: zodResolver(visibilitySettingsSchema),
defaultValues: {
submissionVisibility: SubmissionVisibility.PUBLIC,
- submissionStatusVisibility: SubmissionStatusVisibility.ALL,
+ submissionStatusVisibility:
+ SubmissionStatusVisibility.ACCEPTED_SHORTLISTED,
},
});
@@ -61,7 +62,7 @@ export default function SubmissionVisibilitySettingsTab({
response.data.submissionVisibility || SubmissionVisibility.PUBLIC,
submissionStatusVisibility:
response.data.submissionStatusVisibility ||
- SubmissionStatusVisibility.ALL,
+ SubmissionStatusVisibility.ACCEPTED_SHORTLISTED,
});
}
} catch (error) {
@@ -181,14 +182,18 @@ export default function SubmissionVisibilitySettingsTab({
-
All Submissions
+
+ Shortlisted only (recommended)
+
- Show all projects (accepted, shortlisted, and
- rejected).
+ Submissions stay hidden until you shortlist them.
+ Disqualified projects are never shown.
@@ -196,17 +201,33 @@ export default function SubmissionVisibilitySettingsTab({
- Accepted/Shortlisted Only
+ Hidden until results are announced
+
+
+ Submissions stay hidden from everyone (except the
+ participants who made them and your team) until you
+ publish results. Then only shortlisted projects
+ appear.
+
+
+
+
+
+
+
+
All submissions
- Only show projects that have been approved or
- shortlisted.
+ Every submission is visible as soon as it's
+ made. Disqualified projects are still hidden.
+ 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.
+
+
+
+
+ New track
+
+
+
+ {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 => (
+
+
+
+ )}
+
+
+
+ !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 a588d4daa..bb5912167 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) =>
@@ -253,6 +255,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({
@@ -266,6 +272,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/hooks/hackathon/use-submission.ts b/hooks/hackathon/use-submission.ts
index 9014d6ec6..c4f0f496f 100644
--- a/hooks/hackathon/use-submission.ts
+++ b/hooks/hackathon/use-submission.ts
@@ -17,12 +17,16 @@ import { reportError } from '@/lib/error-reporting';
function getApiErrorMessage(err: unknown, fallback: string): string {
const apiErr = err as ApiError | undefined;
if (apiErr && typeof apiErr.message === 'string' && apiErr.message) {
- const firstField =
- Array.isArray(apiErr.errors) && apiErr.errors.length > 0
- ? apiErr.errors[0].message
- : null;
- if (firstField && firstField !== apiErr.message) {
- return `${apiErr.message}: ${firstField}`;
+ const first = Array.isArray(apiErr.errors) ? apiErr.errors[0] : undefined;
+ const fieldMsg = first?.message;
+ // `debug` is only present outside production; surfaces the real Prisma
+ // reason when the generic "Data validation failed" fires.
+ const debug = first?.debug;
+ if (debug && debug !== apiErr.message) {
+ return `${apiErr.message}: ${debug}`;
+ }
+ if (fieldMsg && fieldMsg !== apiErr.message) {
+ return `${apiErr.message}: ${fieldMsg}`;
}
return apiErr.message;
}
diff --git a/lib/api/api.ts b/lib/api/api.ts
index b0a0e2829..d19262b81 100644
--- a/lib/api/api.ts
+++ b/lib/api/api.ts
@@ -26,6 +26,8 @@ export interface ApiResponse {
export interface ApiErrorField {
field?: string;
message: string;
+ /** Populated by the backend Prisma filter outside production. */
+ debug?: string;
}
export interface ApiError {
diff --git a/lib/api/hackathons.ts b/lib/api/hackathons.ts
index 4adef112d..73c46978c 100644
--- a/lib/api/hackathons.ts
+++ b/lib/api/hackathons.ts
@@ -32,6 +32,7 @@ export enum SubmissionVisibility {
export enum SubmissionStatusVisibility {
ALL = 'ALL',
ACCEPTED_SHORTLISTED = 'ACCEPTED_SHORTLISTED',
+ HIDDEN_UNTIL_RESULTS = 'HIDDEN_UNTIL_RESULTS',
}
export enum VenueType {
@@ -403,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;
@@ -735,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 {
@@ -878,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 {
@@ -2962,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/judging.ts b/lib/api/hackathons/judging.ts
index f2c9ec8ad..0b65e4071 100644
--- a/lib/api/hackathons/judging.ts
+++ b/lib/api/hackathons/judging.ts
@@ -87,6 +87,10 @@ export interface JudgingResult {
hasDisagreement: boolean;
prize?: string;
overriddenRank?: number; // Added to track manual overrides
+ /** Track opt-ins for this submission. Empty for OVERALL_ONLY hackathons
+ * or submissions that didn't pick any track. Used to group results
+ * per-track in the organizer dashboard. */
+ trackIds?: string[];
}
export interface AggregatedJudgingResults {
@@ -646,6 +650,118 @@ export interface JudgingCompletenessPreview {
}>;
}
+// ── Coverage matrix (Phase 3: dashboard) ────────────────────────────
+
+export interface JudgingCoverageJudge {
+ userId: string;
+ name: string;
+ scoredCount: number;
+ missingCount: number;
+ lastScoredAt: string | null;
+}
+
+export interface JudgingCoverageSubmission {
+ submissionId: string;
+ projectName: string;
+ /** User IDs of judges who scored this submission. */
+ scoredBy: string[];
+ scoredCount: number;
+ missingCount: number;
+ isCovered: boolean;
+}
+
+export interface JudgingCoverage {
+ hackathonId: string;
+ judges: JudgingCoverageJudge[];
+ submissions: JudgingCoverageSubmission[];
+ summary: {
+ totalSubmissions: number;
+ totalJudges: number;
+ expectedScores: number;
+ actualScores: number;
+ submissionsFullyCovered: number;
+ submissionsPartiallyCovered: number;
+ submissionsUncovered: number;
+ };
+}
+
+/**
+ * Full judges × submissions coverage matrix for the organizer
+ * dashboard. Used to render the heatmap that exposes idle judges and
+ * orphan submissions.
+ */
+export const getJudgingCoverage = async (
+ organizationId: string,
+ hackathonId: string
+): Promise> => {
+ const res = await api.get(
+ `/organizations/${organizationId}/hackathons/${hackathonId}/judging/coverage`
+ );
+ return res.data;
+};
+
+// ── Allocator preview (Phase 2: dashboard) ──────────────────────────
+
+export interface AllocationPreviewOverallEntry {
+ rank: number;
+ submissionId: string;
+ projectName: string;
+ averageScore: number;
+ prizeAmount?: string;
+ currency?: string;
+ isOverride: boolean;
+}
+
+export interface AllocationPreviewTrackEntry {
+ trackId: string;
+ trackName: string;
+ trackSlug: string;
+ prizeAmount?: string;
+ currency?: string;
+ winner: {
+ submissionId: string;
+ projectName: string;
+ averageScore: number;
+ } | null;
+ runnersUp: Array<{
+ submissionId: string;
+ projectName: string;
+ averageScore: number;
+ }>;
+ /** Why a track has no winner. NO_ENTRIES = no submissions opted in;
+ * NO_SCORED_ENTRIES = opted in but no judge scored them. */
+ skippedReason: 'NO_ENTRIES' | 'NO_SCORED_ENTRIES' | null;
+}
+
+export interface AllocationPreview {
+ hackathonId: string;
+ overall: AllocationPreviewOverallEntry[];
+ tracks: AllocationPreviewTrackEntry[];
+ gates: {
+ submissionDeadlinePassed: boolean;
+ complete: boolean;
+ incompleteSubmissionCount: number;
+ reviewedCount: number;
+ unallocatedPartnerContributionAmount: number;
+ currency: string;
+ };
+}
+
+/**
+ * Read-only allocator dry-run. Returns the overall + per-track outcome
+ * `publishJudgingResults` would produce, plus the publish-gate flags so
+ * the UI can render a "what's blocking publish?" panel.
+ */
+export const getAllocationPreview = async (
+ organizationId: string,
+ hackathonId: string
+): Promise> => {
+ const res = await api.get(
+ `/organizations/${organizationId}/hackathons/${hackathonId}/judging/preview-allocation`
+ );
+ return res.data;
+};
+
export const getJudgingCompleteness = async (
organizationId: string,
hackathonId: string
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 {