+ {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. */}
+
+ )}
{/* 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 8b319b21..aca8133c 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 00000000..134502da
--- /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
+
+
+
+
+