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 01/15] 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 03/15] 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 04/15] 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 (
-
+ Each section shows submissions opted into that track, sorted by their
+ average score across all judges. The highlighted row is the current
+ leader — at publish time, EXCLUSIVE stacking may promote a runner-up if
+ the leader wins an overall placement or another track.
+
+ No submissions have opted into this track yet. Use the{' '}
+
+ Settings → Tracks → Opt in all
+ {' '}
+ action if you want to retrofit existing submissions.
+
+ ) : (
+
+ )}
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/lib/api/hackathons/judging.ts b/lib/api/hackathons/judging.ts
index f2c9ec8a..0b65e407 100644
--- a/lib/api/hackathons/judging.ts
+++ b/lib/api/hackathons/judging.ts
@@ -87,6 +87,10 @@ export interface JudgingResult {
hasDisagreement: boolean;
prize?: string;
overriddenRank?: number; // Added to track manual overrides
+ /** Track opt-ins for this submission. Empty for OVERALL_ONLY hackathons
+ * or submissions that didn't pick any track. Used to group results
+ * per-track in the organizer dashboard. */
+ trackIds?: string[];
}
export interface AggregatedJudgingResults {
@@ -646,6 +650,118 @@ export interface JudgingCompletenessPreview {
}>;
}
+// ── Coverage matrix (Phase 3: dashboard) ────────────────────────────
+
+export interface JudgingCoverageJudge {
+ userId: string;
+ name: string;
+ scoredCount: number;
+ missingCount: number;
+ lastScoredAt: string | null;
+}
+
+export interface JudgingCoverageSubmission {
+ submissionId: string;
+ projectName: string;
+ /** User IDs of judges who scored this submission. */
+ scoredBy: string[];
+ scoredCount: number;
+ missingCount: number;
+ isCovered: boolean;
+}
+
+export interface JudgingCoverage {
+ hackathonId: string;
+ judges: JudgingCoverageJudge[];
+ submissions: JudgingCoverageSubmission[];
+ summary: {
+ totalSubmissions: number;
+ totalJudges: number;
+ expectedScores: number;
+ actualScores: number;
+ submissionsFullyCovered: number;
+ submissionsPartiallyCovered: number;
+ submissionsUncovered: number;
+ };
+}
+
+/**
+ * Full judges × submissions coverage matrix for the organizer
+ * dashboard. Used to render the heatmap that exposes idle judges and
+ * orphan submissions.
+ */
+export const getJudgingCoverage = async (
+ organizationId: string,
+ hackathonId: string
+): Promise> => {
+ const res = await api.get(
+ `/organizations/${organizationId}/hackathons/${hackathonId}/judging/coverage`
+ );
+ return res.data;
+};
+
+// ── Allocator preview (Phase 2: dashboard) ──────────────────────────
+
+export interface AllocationPreviewOverallEntry {
+ rank: number;
+ submissionId: string;
+ projectName: string;
+ averageScore: number;
+ prizeAmount?: string;
+ currency?: string;
+ isOverride: boolean;
+}
+
+export interface AllocationPreviewTrackEntry {
+ trackId: string;
+ trackName: string;
+ trackSlug: string;
+ prizeAmount?: string;
+ currency?: string;
+ winner: {
+ submissionId: string;
+ projectName: string;
+ averageScore: number;
+ } | null;
+ runnersUp: Array<{
+ submissionId: string;
+ projectName: string;
+ averageScore: number;
+ }>;
+ /** Why a track has no winner. NO_ENTRIES = no submissions opted in;
+ * NO_SCORED_ENTRIES = opted in but no judge scored them. */
+ skippedReason: 'NO_ENTRIES' | 'NO_SCORED_ENTRIES' | null;
+}
+
+export interface AllocationPreview {
+ hackathonId: string;
+ overall: AllocationPreviewOverallEntry[];
+ tracks: AllocationPreviewTrackEntry[];
+ gates: {
+ submissionDeadlinePassed: boolean;
+ complete: boolean;
+ incompleteSubmissionCount: number;
+ reviewedCount: number;
+ unallocatedPartnerContributionAmount: number;
+ currency: string;
+ };
+}
+
+/**
+ * Read-only allocator dry-run. Returns the overall + per-track outcome
+ * `publishJudgingResults` would produce, plus the publish-gate flags so
+ * the UI can render a "what's blocking publish?" panel.
+ */
+export const getAllocationPreview = async (
+ organizationId: string,
+ hackathonId: string
+): Promise> => {
+ const res = await api.get(
+ `/organizations/${organizationId}/hackathons/${hackathonId}/judging/preview-allocation`
+ );
+ return res.data;
+};
+
export const getJudgingCompleteness = async (
organizationId: string,
hackathonId: string
From 39ff6ee3cce1900cd7a6359c74a07a5b9001a4bc Mon Sep 17 00:00:00 2001
From: Collins Ikechukwu
Date: Wed, 20 May 2026 11:41:08 +0100
Subject: [PATCH 06/15] feat(judging): unallocated partner funds become a
warning, not a blocker (#574)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Mirrors the backend relaxation. The allocator preview's
"Ready to publish" badge previously turned amber whenever any partner
contribution had unallocated balance — but the backend gate that
backed that signal has been relaxed (the funds stay in escrow
post-publish; they're not lost).
Split the existing publish-readiness messaging into two lists:
- Blockers — the hard gates (deadline, completeness, no reviews).
Same red treatment, same ready-to-publish badge logic.
- Warnings — informational, never blocks. Renders in blue, calls out
the unallocated amount with a note that it remains in escrow.
Co-authored-by: Claude Opus 4.7 (1M context)
---
.../judging/AllocationPreviewCard.tsx | 26 +++++++++++++++++--
1 file changed, 24 insertions(+), 2 deletions(-)
diff --git a/components/organization/hackathons/judging/AllocationPreviewCard.tsx b/components/organization/hackathons/judging/AllocationPreviewCard.tsx
index 402eeb88..871ad93c 100644
--- a/components/organization/hackathons/judging/AllocationPreviewCard.tsx
+++ b/components/organization/hackathons/judging/AllocationPreviewCard.tsx
@@ -130,11 +130,17 @@ export default function AllocationPreviewCard({
if (gates.reviewedCount === 0) {
blockers.push('No submissions have been reviewed yet.');
}
+
+ // Unallocated partner contributions are surfaced as a non-blocking
+ // warning. The escrow still holds the funds; organizers can release
+ // or refund them after publish. The backend gate that prevented this
+ // is intentionally relaxed (see judging.service.publishResults).
+ const warnings: string[] = [];
if (gates.unallocatedPartnerContributionAmount > 0.0000001) {
- blockers.push(
+ warnings.push(
`${gates.unallocatedPartnerContributionAmount.toFixed(2)} ${
gates.currency
- } of partner contributions are unallocated.`
+ } of partner contributions are unallocated. Publish will succeed; funds remain in escrow until you release or refund them.`
);
}
@@ -191,6 +197,22 @@ export default function AllocationPreviewCard({
+ {/* Summary header: completion state + total pool. Replaces the
+ minimal "3/8 Winners Assigned" that read as confusing in the
+ wizard preview. */}
+
@@ -150,15 +201,16 @@ export default function WinnersGrid({
return (
);
})}
From 7e6b84206026a69cccea7ed6bd82991b0613fae8 Mon Sep 17 00:00:00 2001
From: Nnaji Benjamin <60315147+Benjtalkshow@users.noreply.github.com>
Date: Thu, 21 May 2026 11:47:15 +0100
Subject: [PATCH 09/15] feat(hackathons): add judging dataset to export
dropdown (#577)
Surface a new "Judging" entry alongside the existing export options
(Winners, Submissions, etc.) so organizers can download judging
criteria, judges, aggregated results, per-judge scores, and judge
comments. Requires the matching backend dataset to be deployed.
---
components/organization/hackathons/details/ExportButton.tsx | 5 +++++
lib/api/hackathons/rewards.ts | 1 +
2 files changed, 6 insertions(+)
diff --git a/components/organization/hackathons/details/ExportButton.tsx b/components/organization/hackathons/details/ExportButton.tsx
index dd0a2504..a4242862 100644
--- a/components/organization/hackathons/details/ExportButton.tsx
+++ b/components/organization/hackathons/details/ExportButton.tsx
@@ -50,6 +50,11 @@ const DATASETS = [
label: 'Winners',
description: 'Wallet address, activation & USDC trustline',
},
+ {
+ id: 'judging',
+ label: 'Judging',
+ description: 'Results, judges, scores & comments',
+ },
] as const;
export function ExportButton({
diff --git a/lib/api/hackathons/rewards.ts b/lib/api/hackathons/rewards.ts
index 1b383396..58a75aa9 100644
--- a/lib/api/hackathons/rewards.ts
+++ b/lib/api/hackathons/rewards.ts
@@ -214,6 +214,7 @@ export const exportHackathon = async (
| 'submissions'
| 'prize_tiers'
| 'winners'
+ | 'judging'
| 'full' = 'full'
): Promise => {
const res = await api.get(
From 6ecdad48bbd6609052eb5b190160ecee8b7128bb Mon Sep 17 00:00:00 2001
From: Collins Ikechukwu
Date: Thu, 21 May 2026 13:01:37 +0100
Subject: [PATCH 10/15] fix(rewards): match track winners by submissionId, not
participantId (#582)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The publish-wizard preview was still showing "3 of 8 winners assigned"
even after the track-winner enrichment landed in #576. The
enrichment effect was looking up trackWinners by `sub.id`, but the
rewards data mapper sets `Submission.id` to the participant ID, not
the submission row ID. The Map lookup keyed by
`HackathonTrackWinner.submissionId` never matched, so no submissions
got `isTrackWinner = true` stamped.
For the Boundless × Trustless Work hackathon (3 overall + 5 track
winners), the wizard saw only the 3 overall winners — the 5 track
winners never made it into the `winners` array.
Fix:
- Add `submissionId?: string` to the `Submission` type.
- Mapper populates it from `submissionData.id || sub.id ||
sub.submissionId`. The mapper's `id` field stays on the participant
ID for compatibility with the existing rank-assignment code that
already keys off it.
- Track-winner enrichment looks up `byId.get(sub.submissionId)` first,
falls back to `byId.get(sub.id)` for older rows where the mapper
output predated the new field.
After this, the wizard will show "All 8 winners assigned • 1,500 USDC
pool" with the three overall placements and five track winners.
Co-authored-by: Claude Opus 4.7 (1M context)
---
components/organization/hackathons/rewards/types.ts | 7 +++++++
hooks/use-hackathon-rewards.ts | 9 ++++++++-
lib/utils/rewards-data-mapper.ts | 6 ++++++
3 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/components/organization/hackathons/rewards/types.ts b/components/organization/hackathons/rewards/types.ts
index 0633aaaf..788d1adf 100644
--- a/components/organization/hackathons/rewards/types.ts
+++ b/components/organization/hackathons/rewards/types.ts
@@ -1,5 +1,12 @@
export interface Submission {
id: string;
+ /**
+ * The actual HackathonSubmission row ID, distinct from `id` which the
+ * rewards data mapper sets to the participant ID. Required for
+ * matching submissions against backend payloads (judging results,
+ * track winners) that key by submissionId.
+ */
+ submissionId?: string;
name: string;
projectName: string;
avatar?: string;
diff --git a/hooks/use-hackathon-rewards.ts b/hooks/use-hackathon-rewards.ts
index 2b4a8fe4..ec4240cd 100644
--- a/hooks/use-hackathon-rewards.ts
+++ b/hooks/use-hackathon-rewards.ts
@@ -472,7 +472,14 @@ export const useHackathonRewards = (
const byId = new Map(fetched.map(tw => [tw.submissionId, tw]));
setSubmissions(prev =>
prev.map(sub => {
- const tw = byId.get(sub.id);
+ // Match against the real submission row ID (now threaded
+ // through by the mapper). Fall back to `sub.id` for any
+ // mapper output that predates the `submissionId` field —
+ // older rows would have `id === submissionId` when
+ // participant data was missing.
+ const tw =
+ (sub.submissionId && byId.get(sub.submissionId)) ||
+ byId.get(sub.id);
if (!tw) return sub;
return {
...sub,
diff --git a/lib/utils/rewards-data-mapper.ts b/lib/utils/rewards-data-mapper.ts
index a423640e..67137675 100644
--- a/lib/utils/rewards-data-mapper.ts
+++ b/lib/utils/rewards-data-mapper.ts
@@ -63,6 +63,12 @@ export const mapJudgingSubmissionToRewardSubmission = (
return {
id: participant.id || sub.id || '',
+ // The real submission row ID, used by backend payloads (judging
+ // results, track winners) that key by submissionId. The mapper's
+ // `id` field stays on the participant ID for compatibility with
+ // existing rank-assignment and display code that already keys off
+ // it.
+ submissionId: submissionData.id || sub.id || sub.submissionId || '',
participantId: participant.id || sub.id || '',
name,
projectName: submissionData.projectName || '',
From 3fb4121ec0b50fbb7586423ba52a86c107c28fdd Mon Sep 17 00:00:00 2001
From: Collins Ikechukwu
Date: Thu, 21 May 2026 19:03:08 +0100
Subject: [PATCH 11/15] fix(auth): send absolute callbackURL on Google sign-up
(#584)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Google sign-up flow passed `callbackURL: process.env.NEXT_PUBLIC_APP_URL || '/'`
to Better Auth. Better Auth treats a relative path as relative to the
API host that processed the OAuth callback, not the frontend host.
When `NEXT_PUBLIC_APP_URL` wasn't set (or was missing in the runtime
env even though available at build time on Next.js), the fallback `'/'`
sent users to the API host's root after OAuth completed. The session
cookie WAS set on the shared `.boundlessfi.xyz` domain during the
callback, but the user landed on a blank API page and thought sign-up
had failed. Clearing browser cache (cookies survive — different
section in Chrome) didn't drop the cookie, so the next visit to the
frontend silently restored their session and they appeared
"automatically logged in."
Fix: always build an absolute `callbackURL` pointing at the frontend
host. Same pattern LoginWrapper already uses — falls back to
window.location.origin at runtime, then to the env var at build/SSR,
then to the production canonical URL. All three are in the BE's
`trustedOrigins` list so Better Auth won't reject the URL.
Co-authored-by: Claude Opus 4.7 (1M context)
---
components/auth/SignupWrapper.tsx | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/components/auth/SignupWrapper.tsx b/components/auth/SignupWrapper.tsx
index 0e91e2c8..83564df8 100644
--- a/components/auth/SignupWrapper.tsx
+++ b/components/auth/SignupWrapper.tsx
@@ -27,11 +27,25 @@ const SignupWrapper = ({
setIsLoading(true);
setLoadingState(true);
+ // Better Auth treats a relative `callbackURL` as relative to the API
+ // host that handled the OAuth callback (e.g. api.boundlessfi.xyz),
+ // not the frontend host. The previous default of '/' caused
+ // successful sign-ups to land on the API host's root, so users saw
+ // a blank/404 page and thought sign-up had failed — yet the session
+ // cookie was already set, so a later cache clear silently logged
+ // them in. Always send an absolute URL pointing at the frontend.
+ const callbackURL =
+ typeof window !== 'undefined'
+ ? window.location.origin
+ : (
+ process.env.NEXT_PUBLIC_APP_URL || 'https://boundlessfi.xyz'
+ ).replace(/\/$/, '');
+
try {
await authClient.signIn.social(
{
provider: 'google',
- callbackURL: process.env.NEXT_PUBLIC_APP_URL || '/',
+ callbackURL,
},
{
onRequest: () => {
From a5db3c40b7c09bd1d977d64e4c8187019cb4242c Mon Sep 17 00:00:00 2001
From: Nnaji Benjamin <60315147+Benjtalkshow@users.noreply.github.com>
Date: Fri, 22 May 2026 10:18:41 +0100
Subject: [PATCH 12/15] fix(submissions): unbreak submission detail page +
publish hackathon recap (#586)
* fix(submissions): wire submission entity type through votes, comments, and rank chip
The submission detail page (/projects/[slug]?type=submission) was
treating submissions as crowdfunding projects, which broke every
interactive surface for hackathon entries:
- Sidebar getVoteCounts and the voters tab's getProjectVotes were
hardcoded to CROWDFUNDING_CAMPAIGN, so the lookup failed with
"Failed to load voting data" and disabled the buttons.
- createVote on the voters tab was also hardcoded, so even a click
through hit Prisma's "Invalid reference to related record" (P2025)
for non-existent campaign rows.
- ProjectComments used CommentEntityType.PROJECT, so comments and
replies posted against a non-existent project record.
- The Follow button hardcoded entityType='PROJECT' with the
submission's id, leading to "Failed to update follow status". The
follow EntityType enum has no HACKATHON_SUBMISSION value, so the
button is hidden on submission pages.
- The status badge displayed raw enum values like "SHORTLISTED" and
there was no signal that a submission was a winner.
All vote/comment surfaces now derive the entity type from the URL,
matching the backend's HACKATHON_SUBMISSION enum on both votes and
comments. The page mapper surfaces submissionRank and a friendly
submissionStatus on the project, and the sidebar header renders a
"Winner" / "Rank #N" chip next to the existing status badge.
* fix(blog): use object-cover so card and detail covers fill the frame
The earlier switch back to object-contain left letterbox bands around
banners that aren't an exact 2:1 ratio on both the BlogCard grid and
the post-details hero. With properly-sized cover banners, object-cover
fills the frame without visible cropping. Authors should size cover
images for a 2:1 ratio on the card and the responsive heights on the
details page.
* feat(blog): publish Boundless x Trustless Work hackathon winners recap
Adds the post-hackathon recap announcing the three main winners
(Conductor, Crypt, GoPadi), the five honorable mentions, and the
Showcase recognitions from the May 16 finale. Featured on the blog
index.
* chore(deps): npm audit fix to clear js-cookie high-severity advisory
GHSA-qjx8-664m-686j (js-cookie per-instance prototype hijack in
assign()) was newly flagged as high severity. The pre-push hook runs
`npm audit --audit-level=high` and refused to push any branch until
this was patched. Lockfile-only update, no package.json changes,
semver-compatible. Bundled here because the gate was blocking the
parent PR; reviewers can treat this as an unrelated chore commit.
---
app/(landing)/projects/[slug]/page.tsx | 27 +-
components/landing-page/blog/BlogCard.tsx | 2 +-
.../landing-page/blog/BlogPostDetails.tsx | 2 +-
.../comment-section/project-comments.tsx | 14 +-
.../project-sidebar/ProjectSidebarActions.tsx | 19 +-
.../project-sidebar/ProjectSidebarHeader.tsx | 16 +-
.../project-details/project-sidebar/index.tsx | 9 +-
.../project-details/project-sidebar/types.ts | 8 +-
.../project-details/project-voters/index.tsx | 19 +-
...dless-trustless-work-hackathon-winners.mdx | 116 ++
features/projects/types/index.ts | 6 +
package-lock.json | 1597 ++++++++---------
12 files changed, 962 insertions(+), 873 deletions(-)
create mode 100644 content/blog/boundless-trustless-work-hackathon-winners.mdx
diff --git a/app/(landing)/projects/[slug]/page.tsx b/app/(landing)/projects/[slug]/page.tsx
index e79be2de..5f3af021 100644
--- a/app/(landing)/projects/[slug]/page.tsx
+++ b/app/(landing)/projects/[slug]/page.tsx
@@ -273,6 +273,29 @@ function mapSubmissionToCrowdfunding(
const projectId = subData.id || subData._id || '';
+ // Map raw submission status to a friendly display label so the sidebar
+ // badge reads "Shortlisted" instead of "SHORTLISTED". The raw value is
+ // also preserved on submissionStatus so anything that needs the original
+ // enum value can still get it.
+ const submissionRank: number | null =
+ typeof subData.rank === 'number' ? subData.rank : null;
+ const rawSubmissionStatus: string =
+ typeof subData.status === 'string' ? subData.status : '';
+ const friendlySubmissionStatus = (() => {
+ switch (rawSubmissionStatus.toUpperCase()) {
+ case 'SUBMITTED':
+ return 'Submitted';
+ case 'SHORTLISTED':
+ return 'Shortlisted';
+ case 'DISQUALIFIED':
+ return 'Disqualified';
+ case 'WITHDRAWN':
+ return 'Withdrawn';
+ default:
+ return rawSubmissionStatus || 'pending';
+ }
+ })();
+
// Find demo video in links if not provided directly
let demoVideoUrl = subData.videoUrl || '';
if (!demoVideoUrl && socialLinks.length > 0) {
@@ -320,7 +343,9 @@ function mapSubmissionToCrowdfunding(
vision: null,
details: null,
category: subData.category || 'General',
- status: subData.status || 'pending',
+ status: friendlySubmissionStatus,
+ submissionRank,
+ submissionStatus: rawSubmissionStatus || null,
creatorId: subData.participantId || subData.userId || '',
organizationId: subData.organizationId || null,
teamMembers: teamMembers,
diff --git a/components/landing-page/blog/BlogCard.tsx b/components/landing-page/blog/BlogCard.tsx
index ebd44dcc..9570d2ca 100644
--- a/components/landing-page/blog/BlogCard.tsx
+++ b/components/landing-page/blog/BlogCard.tsx
@@ -24,7 +24,7 @@ const BlogCard = ({ post, onCardClick }: BlogCardProps) => {
src={post.coverImage}
alt={post.title}
fill
- className='object-contain transition-transform duration-500 group-hover:scale-105'
+ className='object-cover transition-transform duration-500 group-hover:scale-105'
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'
/>
{/* Gradient Overlay */}
diff --git a/components/landing-page/blog/BlogPostDetails.tsx b/components/landing-page/blog/BlogPostDetails.tsx
index fe07c03a..1397c2d0 100644
--- a/components/landing-page/blog/BlogPostDetails.tsx
+++ b/components/landing-page/blog/BlogPostDetails.tsx
@@ -140,7 +140,7 @@ const BlogPostDetails: React.FC = ({
src={post.coverImage}
alt={post.title}
fill
- className='rounded-lg object-contain'
+ className='rounded-lg object-cover'
priority
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 70vw'
/>
diff --git a/components/project-details/comment-section/project-comments.tsx b/components/project-details/comment-section/project-comments.tsx
index 18a74a52..3bc949e2 100644
--- a/components/project-details/comment-section/project-comments.tsx
+++ b/components/project-details/comment-section/project-comments.tsx
@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
+import { useSearchParams } from 'next/navigation';
import { useOptionalAuth } from '@/hooks/use-auth';
import { useCommentSystem } from '@/hooks/use-comment-system';
import { useCommentRealtime } from '@/hooks/use-comment-realtime';
@@ -17,6 +18,11 @@ interface ProjectCommentsProps {
export function ProjectComments({ projectId }: ProjectCommentsProps) {
const { user } = useOptionalAuth();
+ const searchParams = useSearchParams();
+ const entityType =
+ searchParams.get('type') === 'submission'
+ ? CommentEntityType.HACKATHON_SUBMISSION
+ : CommentEntityType.PROJECT;
const [sortBy, setSortBy] = useState<
'createdAt' | 'updatedAt' | 'totalReactions'
>('createdAt');
@@ -28,7 +34,7 @@ export function ProjectComments({ projectId }: ProjectCommentsProps) {
// Initialize the comment system for this project
const commentSystem = useCommentSystem({
- entityType: CommentEntityType.PROJECT,
+ entityType,
entityId: projectId,
page: 1,
limit: 20,
@@ -38,7 +44,7 @@ export function ProjectComments({ projectId }: ProjectCommentsProps) {
// Real-time updates
useCommentRealtime(
{
- entityType: CommentEntityType.PROJECT,
+ entityType,
entityId: projectId,
userId: currentUserId,
enabled: true,
@@ -71,7 +77,7 @@ export function ProjectComments({ projectId }: ProjectCommentsProps) {
try {
await commentSystem.createComment.createComment({
content,
- entityType: CommentEntityType.PROJECT,
+ entityType,
entityId: projectId,
});
} catch (error) {
@@ -84,7 +90,7 @@ export function ProjectComments({ projectId }: ProjectCommentsProps) {
await commentSystem.createComment.createComment({
content,
parentId: parentCommentId,
- entityType: CommentEntityType.PROJECT,
+ entityType,
entityId: projectId,
} as any);
} catch (error) {
diff --git a/components/project-details/project-sidebar/ProjectSidebarActions.tsx b/components/project-details/project-sidebar/ProjectSidebarActions.tsx
index 0f5e6e06..af5a327e 100644
--- a/components/project-details/project-sidebar/ProjectSidebarActions.tsx
+++ b/components/project-details/project-sidebar/ProjectSidebarActions.tsx
@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
+import { useSearchParams } from 'next/navigation';
import {
ArrowUp,
DollarSign,
@@ -24,6 +25,8 @@ export function ProjectSidebarActions({
crowdfund,
}: ProjectSidebarActionsProps) {
const [isSharePopupOpen, setIsSharePopupOpen] = useState(false);
+ const searchParams = useSearchParams();
+ const isSubmission = searchParams.get('type') === 'submission';
const handleShareClick = () => {
setIsSharePopupOpen(true);
@@ -109,13 +112,15 @@ export function ProjectSidebarActions({
)}
-
-
-
+ {!isSubmission && (
+
+
+
+ )}
{
switch (projectStatus) {
case 'CAMPAIGNING':
@@ -21,11 +22,16 @@ export function ProjectSidebarHeader({
case 'idea':
return 'bg-warning-75 border-warning-600 text-warning-600';
case 'Validated':
+ case 'Shortlisted':
return 'bg-success-75 border-success-600 text-success-600';
case 'Rejected':
+ case 'Disqualified':
return 'bg-red-900/30 border-red-600 text-red-400';
+ case 'Withdrawn':
+ return 'bg-gray-800/60 border-gray-700 text-gray-300';
case 'pending':
case 'SUBMITTED':
+ case 'Submitted':
return 'bg-gray-800 border-gray-700 text-white';
default:
return 'text-white border-gray-700';
@@ -60,6 +66,14 @@ export function ProjectSidebarHeader({
>
{projectStatus}
- You have not completed identity verification yet.
+ ) : status ? (
+
+ ) : null}
+
+ {!loading && status?.canStartNew && isLocalhost() && (
+
+ Using localhost? If verification is blocked by the browser, use a
+ tunnel (e.g. ngrok) and set your backend FRONTEND_URL or
+ DIDIT_CALLBACK_URL to the tunnel URL. See DIDIT_INTEGRATION.md →
+ Troubleshooting.
- Using localhost? If verification is blocked by the browser, use
- a tunnel (e.g. ngrok) and set your backend FRONTEND_URL or
- DIDIT_CALLBACK_URL to the tunnel URL. See DIDIT_INTEGRATION.md →
- Troubleshooting.
-
+ Expected completion: by {estimatedOn} (
+ {status.reviewWindow.minBusinessDays}-
+ {status.reviewWindow.maxBusinessDays} business days). We will email
+ you when it’s done — no need to wait on this page.
+