From 80a82707ec8eceeef083d6ff7e33b5af6866c7f9 Mon Sep 17 00:00:00 2001
From: Collins Ikechukwu
Date: Fri, 15 May 2026 08:55:41 +0100
Subject: [PATCH 1/4] feat(hackathons): add "hidden until results" submission
visibility mode
Surfaces the new HIDDEN_UNTIL_RESULTS option (added in the nestjs PR) in
the organizer settings tab. Reorders the three visibility options so the
recommended "Shortlisted only" leads, the new "Hidden until results are
announced" sits in the middle, and "All submissions" comes last. Rewrites
the copy on the "All submissions" choice that incorrectly claimed
disqualified projects would be shown -- they never were on the backend,
and Phase 2 makes that an explicit guarantee. Aligns the form's default
and API-fallback value with the backend default (ACCEPTED_SHORTLISTED,
not ALL) so organizers don't see a misleading initial selection.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../SubmissionVisibilitySettingsTab.tsx | 41 ++++++++++++++-----
lib/api/hackathons.ts | 1 +
2 files changed, 32 insertions(+), 10 deletions(-)
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.
diff --git a/lib/api/hackathons.ts b/lib/api/hackathons.ts
index 4adef112d..2a998257d 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 {
From bf06ced24c60633bd5bd8370f81546428d96b3a7 Mon Sep 17 00:00:00 2001
From: Collins Ikechukwu
Date: Sat, 16 May 2026 07:43:00 +0100
Subject: [PATCH 2/4] feat(hackathons): track-based prize structure, submission
polish, tracks UI
Wires the frontend for the new track-based prize flow:
- New TracksSettingsTab with full CRUD: name/slug/description/eligibility/
prompt/customQuestions/requiredArtifacts; per-row bulk-opt-in action with
confirmation dialog for retrofitting existing submissions
- RewardsTab gains a 3-card prize structure picker, per-tier kind toggle and
track dropdown, amber "tracks unbound" banner, and an inline Manage Tracks
dialog embedding the settings table
- SubmissionForm: track picker + per-track answers (prompt / custom
questions / required artifacts), tagline, builtWith chips, screenshots,
license, code attestation, with soft compliance gate for already-submitted
submissions. trackIds hydrate from trackEntries on edit so bulk-opted-in
submitters don't strip themselves out
- SubmissionDetailModal renders tagline, screenshots, built-with, license
badge, and per-track answers
- Public hackathon page: Overview splits prizes into Overall/Track sections;
sidebar tier list shows TRACK prefix and looks up track names; Winners tab
gets a Track Winners section with per-track cards
- API client: lib/api/hackathons/tracks.ts with listTracks /
listOrganizerTracks / createTrack / updateTrack / deleteTrack /
bulkOptInAllSubmissions, plus types for HackathonTrack,
TrackCustomQuestion, TrackRequiredArtifact, TrackAnswer,
SubmissionTrackEntry, BulkOptInResult
- Hackathon provider/hooks expose trackWinners and per-track entries
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../components/sidebar/PoolAndAction.tsx | 99 +-
.../components/tabs/contents/Overview.tsx | 202 +++-
.../components/tabs/contents/Winners.tsx | 60 +-
.../[slug]/components/tabs/index.tsx | 9 +-
.../[hackathonId]/settings/page.tsx | 21 +-
.../submissions/SubmissionDetailModal.tsx | 156 ++-
.../hackathons/submissions/SubmissionForm.tsx | 822 ++++++++++++++++
components/hackathons/winners/WinnersTab.tsx | 126 ++-
.../hackathons/new/NewHackathonTab.tsx | 2 +
.../hackathons/new/tabs/RewardsTab.tsx | 382 ++++++-
.../new/tabs/schemas/rewardsSchema.ts | 105 +-
.../hackathons/settings/TracksSettingsTab.tsx | 931 ++++++++++++++++++
hooks/hackathon/use-hackathon-queries.ts | 58 ++
lib/api/hackathons.ts | 65 ++
lib/api/hackathons/index.ts | 1 +
lib/api/hackathons/tracks.ts | 204 ++++
lib/providers/hackathonProvider.tsx | 16 +-
types/hackathon/core.ts | 20 +
types/hackathon/participant.ts | 44 +
19 files changed, 3200 insertions(+), 123 deletions(-)
create mode 100644 components/organization/hackathons/settings/TracksSettingsTab.tsx
create mode 100644 lib/api/hackathons/tracks.ts
diff --git a/app/(landing)/hackathons/[slug]/components/sidebar/PoolAndAction.tsx b/app/(landing)/hackathons/[slug]/components/sidebar/PoolAndAction.tsx
index 4a35df039..f18c134e5 100644
--- a/app/(landing)/hackathons/[slug]/components/sidebar/PoolAndAction.tsx
+++ b/app/(landing)/hackathons/[slug]/components/sidebar/PoolAndAction.tsx
@@ -11,6 +11,7 @@ import {
AlertCircle,
} from 'lucide-react';
import { useHackathonData } from '@/lib/providers/hackathonProvider';
+import { useHackathonTracks } from '@/hooks/hackathon/use-hackathon-queries';
import { useOptionalAuth } from '@/hooks/use-auth';
import { useRequireAuthForAction } from '@/hooks/use-require-auth-for-action';
import { useSubmission } from '@/hooks/hackathon/use-submission';
@@ -79,6 +80,18 @@ export default function PoolAndAction() {
} = useHackathonData();
const hackathonError = error;
const isDataLoading = loading || !hackathon;
+
+ // Load tracks when the hackathon uses a tracked structure so the prize
+ // list can label TRACK tiers by the actual track name instead of
+ // falling through to a generic "4th/5th Place" auto-label.
+ const tracksEnabled =
+ hackathon?.prizeStructure === 'OVERALL_AND_TRACKS' ||
+ hackathon?.prizeStructure === 'TRACKS_ONLY';
+ const { data: hackathonTracks = [] } = useHackathonTracks(
+ slug,
+ tracksEnabled
+ );
+ const trackById = new Map(hackathonTracks.map(t => [t.id, t] as const));
const participants = hackathon?.participants || [];
const deadline = hackathon?.submissionDeadline;
const startDate = hackathon?.startDate;
@@ -240,30 +253,70 @@ export default function PoolAndAction() {
- {hackathon && hackathon.prizeTiers.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).
+
+ Pick the track from the dropdown next to it. Save when done.
+
+
+
+ Tracks still waiting for a tier:{' '}
+ {unboundActiveTracks.map(t => t.name).join(', ')}
+
+
+
+
+ )}
+
{/* Summary Card */}
(
1}
control={form.control}
totalTiers={fields.length}
+ availableTracks={availableTracks}
+ tracksEnabled={tracksEnabled}
/>
))}
@@ -1074,6 +1400,40 @@ export default function RewardsTab({
+ {/* 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 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/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 {
From 8a9b4adeaed524332a7b4bd9655719c333499b02 Mon Sep 17 00:00:00 2001
From: Collins Ikechukwu
Date: Sat, 16 May 2026 12:16:10 +0100
Subject: [PATCH 3/4] fix(submissions): tighten Zod schema + surface backend
debug info
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add max constraints that previously only existed on the backend DTO so
validation fires inline (projectName 100, description 5000, URL 500).
- ApiErrorField gains an optional `debug` field that the backend Prisma
filter populates outside production.
- useSubmission's error formatter prefers `debug` over the generic field
message when present, so toasts show the real Prisma reason behind
"Data validation failed" instead of a blank "validation: …" line.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../hackathons/submissions/SubmissionForm.tsx | 18 +++++++++++++++---
hooks/hackathon/use-submission.ts | 16 ++++++++++------
lib/api/api.ts | 2 ++
3 files changed, 27 insertions(+), 9 deletions(-)
diff --git a/components/hackathons/submissions/SubmissionForm.tsx b/components/hackathons/submissions/SubmissionForm.tsx
index b563347ab..6c5da7576 100644
--- a/components/hackathons/submissions/SubmissionForm.tsx
+++ b/components/hackathons/submissions/SubmissionForm.tsx
@@ -95,9 +95,15 @@ const LICENSE_OPTIONS = [
type License = (typeof LICENSE_OPTIONS)[number];
const baseSubmissionSchema = z.object({
- projectName: z.string().min(3, 'Project name must be at least 3 characters'),
+ projectName: z
+ .string()
+ .min(3, 'Project name must be at least 3 characters')
+ .max(100, 'Project name cannot exceed 100 characters'),
category: z.string().min(1, 'Please select a category'),
- description: z.string().min(50, 'Description must be at least 50 characters'),
+ description: z
+ .string()
+ .min(50, 'Description must be at least 50 characters')
+ .max(5000, 'Description cannot exceed 5000 characters'),
logo: z.string().optional(),
banner: z.string().optional(),
videoUrl: z
@@ -110,7 +116,13 @@ const baseSubmissionSchema = z.object({
links: z.array(
z.object({
type: z.string(),
- url: z.union([z.string().url('Please enter a valid URL'), z.literal('')]),
+ url: z.union([
+ z
+ .string()
+ .url('Please enter a valid URL')
+ .max(500, 'URL cannot exceed 500 characters'),
+ z.literal(''),
+ ]),
})
),
participationType: z.enum(['INDIVIDUAL', 'TEAM']),
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 {
From a36a87ce52ca6c78cb0bd3f2a04976cba762d3f9 Mon Sep 17 00:00:00 2001
From: Collins Ikechukwu
Date: Sat, 16 May 2026 12:24:16 +0100
Subject: [PATCH 4/4] fix(submit-page): hydrate Phase A fields + stop wiping
user input on re-render
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The submit page mapped `initialData` inline on every render, which (a)
recreated the object reference on every render so the form's reset
effect fired continuously and wiped values the user was typing, and (b)
dropped the Phase A fields entirely (tagline, builtWith, screenshots,
license, codeAttestedAt) plus trackEntries. The combined effect was the
documented symptom — only logo and videoUrl survived the save because
those round-tripped through the type-narrowed object literal, while
tagline / builtWith / license kept appearing to "switch to empty".
- Memoize `initialData` against `mySubmission` so the reference only
changes when the underlying submission actually changes.
- Pass through Phase A fields and trackEntries so the form can hydrate
the saved values, and so a follow-up save doesn't write back empties.
- Widen SubmissionFormContent's `initialData` prop to accept the raw
server-side extras (trackEntries, codeAttestedAt) that the form
already consumes via cast — keeps the parent's hydration explicit.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../hackathons/[slug]/submit/page.tsx | 55 ++++++++++++-------
.../hackathons/submissions/SubmissionForm.tsx | 18 +++++-
2 files changed, 53 insertions(+), 20 deletions(-)
diff --git a/app/(landing)/hackathons/[slug]/submit/page.tsx b/app/(landing)/hackathons/[slug]/submit/page.tsx
index 05e125382..85a8c67de 100644
--- a/app/(landing)/hackathons/[slug]/submit/page.tsx
+++ b/app/(landing)/hackathons/[slug]/submit/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import { use, useEffect, useState } from 'react';
+import { use, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useHackathon } from '@/hooks/hackathon/use-hackathon-queries';
import { useAuthStatus } from '@/hooks/use-auth';
@@ -94,6 +94,40 @@ export default function SubmitProjectPage({
router.push(`/hackathons/${hackathonSlug}?tab=submission`);
};
+ // Stable initialData reference so the form doesn't re-reset (and wipe
+ // the user's typed-but-unsaved input) on every parent re-render. The
+ // form's reset effect depends on this object identity, so it MUST only
+ // change when the underlying submission actually changes.
+ //
+ // We also pass through the Phase A polish fields (tagline, builtWith,
+ // screenshots, license, codeAttestedAt) and trackEntries — if these
+ // are missing, the form initialises them to empty and any save then
+ // clobbers the server values with empties.
+ const initialData = useMemo(() => {
+ if (!mySubmission) return undefined;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const s = mySubmission as any;
+ return {
+ projectName: mySubmission.projectName,
+ category: mySubmission.category,
+ description: mySubmission.description,
+ logo: mySubmission.logo,
+ banner: mySubmission.banner,
+ videoUrl: mySubmission.videoUrl,
+ introduction: mySubmission.introduction,
+ links: mySubmission.links,
+ participationType: s.participationType,
+ teamName: s.teamName,
+ teamMembers: s.teamMembers,
+ tagline: s.tagline,
+ builtWith: s.builtWith,
+ screenshots: s.screenshots,
+ license: s.license,
+ codeAttestedAt: s.codeAttestedAt,
+ trackEntries: s.trackEntries,
+ };
+ }, [mySubmission]);
+
const [hasInitialLoaded, setHasInitialLoaded] = useState(false);
useEffect(() => {
@@ -149,24 +183,7 @@ export default function SubmitProjectPage({
hackathonSlugOrId={hackathonId}
organizationId={orgId}
submissionId={mySubmission?.id}
- initialData={
- mySubmission
- ? {
- projectName: mySubmission.projectName,
- category: mySubmission.category,
- description: mySubmission.description,
- logo: mySubmission.logo,
- banner: mySubmission.banner,
- videoUrl: mySubmission.videoUrl,
- introduction: mySubmission.introduction,
- links: mySubmission.links,
- participationType: (mySubmission as any)
- .participationType,
- teamName: (mySubmission as any).teamName,
- teamMembers: (mySubmission as any).teamMembers,
- }
- : undefined
- }
+ initialData={initialData}
onSuccess={handleSuccess}
onClose={handleClose}
/>
diff --git a/components/hackathons/submissions/SubmissionForm.tsx b/components/hackathons/submissions/SubmissionForm.tsx
index 6c5da7576..acb808b88 100644
--- a/components/hackathons/submissions/SubmissionForm.tsx
+++ b/components/hackathons/submissions/SubmissionForm.tsx
@@ -185,7 +185,23 @@ type SubmissionFormDataLocal = z.infer;
interface SubmissionFormContentProps {
hackathonSlugOrId: string;
organizationId?: string;
- initialData?: Partial;
+ /**
+ * Pre-populates the form when editing an existing submission. Accepts
+ * the raw submission shape from the API so server-only fields like
+ * trackEntries and codeAttestedAt can be hydrated alongside the Zod-
+ * typed form fields (which the form reads via a typed cast).
+ */
+ initialData?: Partial & {
+ trackEntries?: Array<{
+ trackId: string;
+ trackAnswers?: {
+ promptAnswer?: string;
+ customAnswers?: Record;
+ artifacts?: Record;
+ };
+ }>;
+ codeAttestedAt?: string | null;
+ };
submissionId?: string;
onSuccess?: () => void;
onClose?: () => void;