From f33a4075c9a8418715e2a954769739f6ad089005 Mon Sep 17 00:00:00 2001
From: Nnaji Benjamin <60315147+Benjtalkshow@users.noreply.github.com>
Date: Sat, 16 May 2026 00:50:36 +0100
Subject: [PATCH 1/4] fix(hackathons): single-column teams tab and
primary-colored pager (#562)
* fix(hackathons): single-column teams tab and primary-colored pager
Revert the teams tab grid to a single column and rework the shared
Pagination component to match the icon-chevron layout used by the
organizer submissions and participants pages, styled with the primary
color.
* feat(submissions): link submission card avatars to profile pages
Wrap the individual avatar on SubmissionCard in a profile link and
forward team-member usernames to GroupAvatar so each clustered avatar
opens that user's profile in a new tab.
---
.../components/tabs/contents/FindTeam.tsx | 2 +-
.../contents/submissions/SubmissionCard.tsx | 20 +++-
components/avatars/GroupAvatar.tsx | 38 +++++--
components/ui/pagination.tsx | 100 ++++++++----------
4 files changed, 96 insertions(+), 64 deletions(-)
diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/FindTeam.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/FindTeam.tsx
index 3b2569fd..a279e06e 100644
--- a/app/(landing)/hackathons/[slug]/components/tabs/contents/FindTeam.tsx
+++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/FindTeam.tsx
@@ -267,7 +267,7 @@ const FindTeam = () => {
) : teams.length > 0 ? (
<>
-
+ {hackathon &&
+ hackathon.prizeTiers.length > 0 &&
+ (() => {
+ // Walk the tiers once, but keep a separate counter for
+ // OVERALL placements so the "1st/2nd/3rd" labels stay
+ // accurate even when track tiers are interleaved. Track
+ // tiers get the actual track name (looked up via trackId)
+ // and a "TRACK" prefix so the sidebar matches what the
+ // organizer set up in Rewards.
+ let overallIdx = 0;
+ return (
+
+
+ {/* Compliance.
+ - NEW submissions: license + attestation are required.
+ Submit is hard-gated client-side and the asterisks
+ show on the labels.
+ - Existing submissions (created before Phase A): both
+ fields are optional, the asterisks hide, and submit
+ isn't blocked. Filling them in still works and the
+ data round-trips. */}
+
+
+ {organizationId && hackathonId
+ ? 'No tracks created yet. Click "Add tracks" to create them, then bind tiers to each one below.'
+ : 'No tracks created yet. Create tracks in the Tracks tab first, then come back here to bind tiers to them.'}
+
+ )}
+ {tracksEnabled && hasTracks && (
+
+ {availableTracks.filter(t => !t.isArchived).length} active
+ track
+ {availableTracks.filter(t => !t.isArchived).length === 1
+ ? ''
+ : 's'}{' '}
+ available. Mark a tier's kind as “Track”
+ below to bind it.
+
+ )}
+
+
+
+ )}
+ />
+
+ {/* Tracks-unbound warning. Common case: organizer set the
+ structure + created tracks, but forgot to mark any tier's
+ kind as TRACK. Without this banner the public page shows
+ zero track prizes and the cause isn't obvious. */}
+ {showTracksUnboundBanner && (
+
+
+
+
+
+ Tracks created but no prize is bound to them
+
+
+ You have{' '}
+
+ {availableTracks.filter(t => !t.isArchived).length} track
+ {availableTracks.filter(t => !t.isArchived).length === 1
+ ? ''
+ : 's'}
+ {' '}
+ set up, but none of your prize tiers below is marked as a
+ Track. The public hackathon page will show only your overall
+ placements. To award a track prize:
+
+
+
+ Pick a prize tier you want to be a track prize (or click
+ “Add Prize Tier” for a new one).
+
+ {/* Inline tracks management. Renders the same CRUD UX as the
+ settings-page Tracks tab inside a dialog so organizers can
+ create tracks from the new-hackathon wizard or from the
+ Rewards step without leaving context. */}
+ {organizationId && hackathonId && (
+
+ )}
+
{/* ── Confirmation AlertDialog ── */}
diff --git a/components/organization/hackathons/new/tabs/schemas/rewardsSchema.ts b/components/organization/hackathons/new/tabs/schemas/rewardsSchema.ts
index 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
+
+
+
+
+
+ );
+}
diff --git a/hooks/hackathon/use-hackathon-queries.ts b/hooks/hackathon/use-hackathon-queries.ts
index af8bad53..0e693d40 100644
--- a/hooks/hackathon/use-hackathon-queries.ts
+++ b/hooks/hackathon/use-hackathon-queries.ts
@@ -19,6 +19,7 @@ import {
} from '@/lib/api/hackathons/participants';
import { getExploreSubmissions } from '@/lib/api/hackathons';
import { listAnnouncements } from '@/lib/api/hackathons/index';
+import { listTracks, type HackathonTrack } from '@/lib/api/hackathons/tracks';
import {
getTeams,
createTeam,
@@ -82,6 +83,7 @@ export const hackathonKeys = {
}
) => ['hackathon', 'exploreSubmissions', id, params] as const,
winners: (idOrSlug: string) => ['hackathon', 'winners', idOrSlug] as const,
+ tracks: (idOrSlug: string) => ['hackathon', 'tracks', idOrSlug] as const,
announcements: (idOrSlug: string) =>
['hackathon', 'announcements', idOrSlug] as const,
teams: (idOrSlug: string, params?: GetTeamOptions) =>
@@ -255,6 +257,10 @@ export function useExploreSubmissions(
/**
* Fetch winners for a hackathon.
+ *
+ * Returns the legacy `HackathonWinner[]` shape for backward compatibility
+ * via the array-typed callers. Use `useHackathonWinnersWithTracks` when
+ * you also need the track-based prize winners.
*/
export function useHackathonWinners(idOrSlug: string, enabled = true) {
return useQuery({
@@ -268,6 +274,58 @@ export function useHackathonWinners(idOrSlug: string, enabled = true) {
});
}
+/**
+ * Fetch winners + track winners for a hackathon. Used by the public
+ * winners view to render both the overall podium and per-track prizes.
+ */
+export function useHackathonWinnersWithTracks(
+ idOrSlug: string,
+ enabled = true
+) {
+ return useQuery<{
+ winners: HackathonWinner[];
+ trackWinners: import('@/lib/api/hackathons').HackathonTrackWinner[];
+ }>({
+ queryKey: [...hackathonKeys.winners(idOrSlug), 'with-tracks'],
+ queryFn: async () => {
+ const response = await getHackathonWinners(idOrSlug);
+ if (!response.success || !response.data) {
+ return { winners: [], trackWinners: [] };
+ }
+ return {
+ winners: response.data.winners ?? [],
+ trackWinners:
+ (
+ response.data as {
+ trackWinners?: import('@/lib/api/hackathons').HackathonTrackWinner[];
+ }
+ ).trackWinners ?? [],
+ };
+ },
+ enabled: !!idOrSlug && enabled,
+ });
+}
+
+/**
+ * Public list of tracks for a hackathon. Returns only active (non-archived)
+ * tracks. Used by the public detail page to render Track Prizes and the
+ * submission form to populate the track picker.
+ */
+export function useHackathonTracks(idOrSlug: string, enabled = true) {
+ return useQuery({
+ queryKey: hackathonKeys.tracks(idOrSlug),
+ queryFn: async () => {
+ try {
+ return await listTracks(idOrSlug);
+ } catch {
+ // Best-effort: a 404 / network error shouldn't kill the page.
+ return [];
+ }
+ },
+ enabled: !!idOrSlug && enabled,
+ });
+}
+
/**
* Fetch announcements for a hackathon (public, non-draft only).
*/
diff --git a/lib/api/hackathons.ts b/lib/api/hackathons.ts
index 2a998257..73c46978 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 311b6288..5ef86d4f 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 00000000..a33a86c1
--- /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 c7bc7650..898465a2 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 738aa45b..82cdd2a0 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 3b1cc74c..81dd726d 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 1269fdfa5fa636fcd10d2181ac7f7b5754eb2b95 Mon Sep 17 00:00:00 2001
From: Collins Ikechukwu
Date: Sat, 16 May 2026 12:56:22 +0100
Subject: [PATCH 3/4] Feat/submission visibility hidden until results (#566)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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)
* 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)
* fix(submissions): tighten Zod schema + surface backend debug info
- 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)
* fix(submit-page): hydrate Phase A fields + stop wiping user input on re-render
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)
---------
Co-authored-by: Claude Opus 4.7 (1M context)
---
.../hackathons/[slug]/submit/page.tsx | 55 ++++++++++++-------
.../hackathons/submissions/SubmissionForm.tsx | 36 ++++++++++--
hooks/hackathon/use-submission.ts | 16 ++++--
lib/api/api.ts | 2 +
4 files changed, 80 insertions(+), 29 deletions(-)
diff --git a/app/(landing)/hackathons/[slug]/submit/page.tsx b/app/(landing)/hackathons/[slug]/submit/page.tsx
index 05e12538..85a8c67d 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 b563347a..acb808b8 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']),
@@ -173,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;
diff --git a/hooks/hackathon/use-submission.ts b/hooks/hackathon/use-submission.ts
index 9014d6ec..c4f0f496 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 b0a0e282..d19262b8 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 80c7f2ff991e8dcd601fc3e6af6c6acbe01e4bcd Mon Sep 17 00:00:00 2001
From: Nnaji Benjamin <60315147+Benjtalkshow@users.noreply.github.com>
Date: Mon, 18 May 2026 09:52:10 +0100
Subject: [PATCH 4/4] fix(hackathons): always open submissions in a new tab
(#568)
* fix(hackathons): single-column teams tab and primary-colored pager
Revert the teams tab grid to a single column and rework the shared
Pagination component to match the icon-chevron layout used by the
organizer submissions and participants pages, styled with the primary
color.
* feat(submissions): link submission card avatars to profile pages
Wrap the individual avatar on SubmissionCard in a profile link and
forward team-member usernames to GroupAvatar so each clustered avatar
opens that user's profile in a new tab.
* fix(hackathons): always open submissions in a new tab
Across the hackathon, organizer, judge, and profile surfaces, clicking
a submission now opens the project page in a new tab so reviewers and
participants do not lose their list/queue context. Switched the
remaining anchor tags to next/link Link components.
---
.../[slug]/components/sidebar/MySubmissionPanel.tsx | 6 +++++-
.../tabs/contents/submissions/SubmissionCard.tsx | 4 ++--
.../tabs/contents/winners/GeneralWinnerCard.tsx | 5 +++--
.../tabs/contents/winners/PodiumWinnerCard.tsx | 5 +++--
.../tabs/contents/winners/TopWinnerCard.tsx | 5 +++--
app/judge/[hackathonId]/submissions/page.tsx | 13 +++++++++----
.../submissions/submission-components.tsx | 9 +++++----
components/hackathons/winners/WinnersTab.tsx | 7 ++++++-
.../organization/cards/JudgingParticipant.tsx | 4 ++++
.../hackathons/submissions/SubmissionsList.tsx | 8 +++++---
10 files changed, 45 insertions(+), 21 deletions(-)
diff --git a/app/(landing)/hackathons/[slug]/components/sidebar/MySubmissionPanel.tsx b/app/(landing)/hackathons/[slug]/components/sidebar/MySubmissionPanel.tsx
index 0757914d..d1610701 100644
--- a/app/(landing)/hackathons/[slug]/components/sidebar/MySubmissionPanel.tsx
+++ b/app/(landing)/hackathons/[slug]/components/sidebar/MySubmissionPanel.tsx
@@ -92,7 +92,11 @@ export default function MySubmissionPanel() {
status !== 'WITHDRAWN';
const handleView = () => {
- router.push(`/projects/${submission.id}?type=submission`);
+ window.open(
+ `/projects/${submission.id}?type=submission`,
+ '_blank',
+ 'noopener,noreferrer'
+ );
};
const handleEdit = () => {
diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx
index c1f18f3e..5d0d74bc 100644
--- a/app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx
+++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx
@@ -194,7 +194,7 @@ const SubmissionCard = ({ submission }: SubmissionCardProps) => {
>
)}
-
+
{
>
View Project
-
+
diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/winners/GeneralWinnerCard.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/winners/GeneralWinnerCard.tsx
index 37e74020..04da02d5 100644
--- a/app/(landing)/hackathons/[slug]/components/tabs/contents/winners/GeneralWinnerCard.tsx
+++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/winners/GeneralWinnerCard.tsx
@@ -1,3 +1,4 @@
+import Link from 'next/link';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { HackathonWinner } from '@/lib/api/hackathons';
import { SubmissionCardProps } from '@/types/hackathon';
@@ -14,7 +15,7 @@ export const GeneralWinnerCard = ({
const projectUrl = `/projects/${winner.submissionId}?type=submission`;
return (
-