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 ? ( <> -
+
{teams.map(team => ( {
{isTeam ? ( - m.avatar ?? '')} /> + m.avatar ?? '')} + usernames={teamMembers.map(m => m.username)} + /> + ) : participant?.username ? ( + + + ) : ( { +const GroupAvatar = ({ members, usernames }: GroupAvatarProps) => { const showCount = members.length > 3; const maxVisible = showCount ? 3 : members.length; const visibleMembers = members.slice(0, maxVisible); @@ -18,14 +20,32 @@ const GroupAvatar = ({ members }: GroupAvatarProps) => { return ( - {visibleMembers.map((member, index) => ( - - - - {member.slice(0, 2).toUpperCase()} - - - ))} + {visibleMembers.map((member, index) => { + const username = usernames?.[index]; + const avatar = ( + + + + {member.slice(0, 2).toUpperCase()} + + + ); + if (username) { + return ( + + {avatar} + + ); + } + return
{avatar}
; + })} {remainingCount > 0 && ( +{remainingCount} diff --git a/components/ui/pagination.tsx b/components/ui/pagination.tsx index b127afb7..bf182b55 100644 --- a/components/ui/pagination.tsx +++ b/components/ui/pagination.tsx @@ -1,5 +1,11 @@ import React from 'react'; -import { BoundlessButton } from '../buttons'; +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; interface PaginationProps { currentPage: number; @@ -18,64 +24,52 @@ const Pagination: React.FC = ({ return null; } + const canPrev = currentPage > 1; + const canNext = currentPage < totalPages; + return ( -
-
- +
+
+
Page {currentPage} of {totalPages} - - -
- +
+ + + +
From 335758ecf40e97adaeb18841d29d872424fa2b50 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Sat, 16 May 2026 08:59:20 +0100 Subject: [PATCH 2/4] feat(hackathons): track-based prize structure + submission polish + tracks UI (#564) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../components/sidebar/PoolAndAction.tsx | 99 +- .../components/tabs/contents/Overview.tsx | 202 +++- .../components/tabs/contents/Winners.tsx | 60 +- .../[slug]/components/tabs/index.tsx | 9 +- .../[hackathonId]/settings/page.tsx | 21 +- .../submissions/SubmissionDetailModal.tsx | 156 ++- .../hackathons/submissions/SubmissionForm.tsx | 822 ++++++++++++++++ components/hackathons/winners/WinnersTab.tsx | 126 ++- .../hackathons/new/NewHackathonTab.tsx | 2 + .../hackathons/new/tabs/RewardsTab.tsx | 382 ++++++- .../new/tabs/schemas/rewardsSchema.ts | 105 +- .../hackathons/settings/TracksSettingsTab.tsx | 931 ++++++++++++++++++ hooks/hackathon/use-hackathon-queries.ts | 58 ++ lib/api/hackathons.ts | 65 ++ lib/api/hackathons/index.ts | 1 + lib/api/hackathons/tracks.ts | 204 ++++ lib/providers/hackathonProvider.tsx | 16 +- types/hackathon/core.ts | 20 + types/hackathon/participant.ts | 44 + 19 files changed, 3200 insertions(+), 123 deletions(-) create mode 100644 components/organization/hackathons/settings/TracksSettingsTab.tsx create mode 100644 lib/api/hackathons/tracks.ts diff --git a/app/(landing)/hackathons/[slug]/components/sidebar/PoolAndAction.tsx b/app/(landing)/hackathons/[slug]/components/sidebar/PoolAndAction.tsx index 4a35df03..f18c134e 100644 --- a/app/(landing)/hackathons/[slug]/components/sidebar/PoolAndAction.tsx +++ b/app/(landing)/hackathons/[slug]/components/sidebar/PoolAndAction.tsx @@ -11,6 +11,7 @@ import { AlertCircle, } from 'lucide-react'; import { useHackathonData } from '@/lib/providers/hackathonProvider'; +import { useHackathonTracks } from '@/hooks/hackathon/use-hackathon-queries'; import { useOptionalAuth } from '@/hooks/use-auth'; import { useRequireAuthForAction } from '@/hooks/use-require-auth-for-action'; import { useSubmission } from '@/hooks/hackathon/use-submission'; @@ -79,6 +80,18 @@ export default function PoolAndAction() { } = useHackathonData(); const hackathonError = error; const isDataLoading = loading || !hackathon; + + // Load tracks when the hackathon uses a tracked structure so the prize + // list can label TRACK tiers by the actual track name instead of + // falling through to a generic "4th/5th Place" auto-label. + const tracksEnabled = + hackathon?.prizeStructure === 'OVERALL_AND_TRACKS' || + hackathon?.prizeStructure === 'TRACKS_ONLY'; + const { data: hackathonTracks = [] } = useHackathonTracks( + slug, + tracksEnabled + ); + const trackById = new Map(hackathonTracks.map(t => [t.id, t] as const)); const participants = hackathon?.participants || []; const deadline = hackathon?.submissionDeadline; const startDate = hackathon?.startDate; @@ -240,30 +253,70 @@ export default function PoolAndAction() {
- {hackathon && hackathon.prizeTiers.length > 0 && ( -
-
-
- {hackathon.prizeTiers.map((tier, i) => ( -
- -
-

- {tier.name ?? - `${i + 1}${['st', 'nd', 'rd'][i] ?? 'th'} Place`} -

-

- {Number(tier.prizeAmount ?? 0).toLocaleString()}{' '} - - {tier.currency ?? currency} - -

-
+ {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 ( +
+
+
+ {hackathon.prizeTiers.map((tier, i) => { + const isTrack = tier.kind === 'TRACK'; + const track = + isTrack && tier.trackId + ? trackById.get(tier.trackId) + : undefined; + let label: string; + if (isTrack) { + label = track?.name ?? tier.name ?? 'Track'; + } else { + const place = overallIdx; + overallIdx += 1; + label = + tier.name ?? + `${place + 1}${['st', 'nd', 'rd'][place] ?? 'th'} Place`; + } + return ( +
+ +
+

+ {isTrack && ( + + Track · + + )} + {label} +

+

+ {Number(tier.prizeAmount ?? 0).toLocaleString()}{' '} + + {tier.currency ?? currency} + +

+
+
+ ); + })}
- ))} -
-
- )} +
+ ); + })()}
diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/Overview.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/Overview.tsx index 898c300b..634b3399 100644 --- a/app/(landing)/hackathons/[slug]/components/tabs/contents/Overview.tsx +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/Overview.tsx @@ -1,9 +1,19 @@ 'use client'; import { useParams } from 'next/navigation'; -import { useHackathon } from '@/hooks/hackathon/use-hackathon-queries'; +import { + useHackathon, + useHackathonTracks, +} from '@/hooks/hackathon/use-hackathon-queries'; import { TabsContent } from '@/components/ui/tabs'; -import { Info, Target, Clock, Trophy, ChevronRight } from 'lucide-react'; +import { + Info, + Target, + Clock, + Trophy, + ChevronRight, + Layers, +} from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; @@ -19,6 +29,17 @@ function getOrdinal(n: number) { const Overview = () => { const { slug } = useParams<{ slug: string }>(); const { data: hackathon } = useHackathon(slug); + // Only fetch tracks once we know the hackathon uses a tracked structure. + // Avoids an extra network call on every overview render for legacy + // overall-only hackathons. + const tracksEnabled = + hackathon?.prizeStructure === 'OVERALL_AND_TRACKS' || + hackathon?.prizeStructure === 'TRACKS_ONLY'; + const { data: hackathonTracks = [] } = useHackathonTracks( + slug, + tracksEnabled + ); + const trackById = new Map(hackathonTracks.map(t => [t.id, t] as const)); const { styledContent, loading: markdownLoading } = useMarkdown( hackathon?.description || '', @@ -218,61 +239,138 @@ const Overview = () => { {/* Prizes Section */} -
-
-

Prizes

-
-
- {hackathon.prizeTiers.map((tier, i) => ( -
- {i === 0 && ( - - Top Tier - - )} + {(() => { + // Partition tiers into overall vs track. Tiers without an + // explicit kind are treated as OVERALL for backward compat with + // hackathons created before the track structure landed. + const overallTiers = hackathon.prizeTiers.filter( + t => !t.kind || t.kind === 'OVERALL' + ); + const trackTiers = hackathon.prizeTiers.filter(t => t.kind === 'TRACK'); -
- -
+ return ( +
+ {overallTiers.length > 0 && ( +
+
+

+ {trackTiers.length > 0 ? 'Overall Prizes' : 'Prizes'} +

+
+
+ {overallTiers.map((tier, i) => ( +
+ {i === 0 && ( + + Top Tier + + )} + +
+ +
+ +

+ {tier.name || `${getOrdinal(i + 1)} Place`} +

+
+ + {Number(tier.prizeAmount).toLocaleString()} + + + {tier.currency || 'USDC'} + +
-

- {tier.name || `${getOrdinal(i + 1)} Place`} -

-
- - {Number(tier.prizeAmount).toLocaleString()} - - - {tier.currency || 'USDC'} - + {tier.description && ( +

+ {tier.description} +

+ )} +
+ ))} +
+ )} - {tier.description && ( -

- {tier.description} + {trackTiers.length > 0 && ( +

+
+ +

Track Prizes

+
+

+ Category prizes alongside the overall placements. Pick the + tracks you want your submission considered for when you + submit.

- )} -
- ))} -
-
+
+ {trackTiers.map((tier, i) => { + const track = tier.trackId + ? trackById.get(tier.trackId) + : undefined; + return ( +
+
+ + {track?.name ?? tier.name ?? 'Track'} + + {track?.type && ( + + {track.type} + + )} +
+
+ + {Number(tier.prizeAmount).toLocaleString()} + + + {tier.currency || 'USDC'} + +
+ {(track?.description || tier.description) && ( +

+ {track?.description || tier.description} +

+ )} + {!track && tier.trackId && ( +

+ Track details loading… +

+ )} +
+ ); + })} +
+
+ )} +
+ ); + })()} { - const { currentHackathon, winners, submissions } = useHackathonData(); + const { currentHackathon, winners, trackWinners, submissions } = + useHackathonData(); - if (!winners || winners.length === 0) { + const hasOverall = winners && winners.length > 0; + const hasTracks = trackWinners && trackWinners.length > 0; + + if (!hasOverall && !hasTracks) { return (
@@ -89,6 +94,55 @@ const Winners = () => {
)} + + {hasTracks && ( +
+
+
+ + + Track Prizes + +
+
+ +
+ {trackWinners!.map(tw => { + // Adapt the track-winner shape to the GeneralWinnerCard + // contract: it expects a `winner` with rank + projectName + + // logo + participants + prize + submissionId. We feed + // wonRank as the rank so the card renders sanely; the + // surrounding Badge labels which track this is for. + const adapted = { + rank: tw.wonRank, + projectName: tw.projectName, + logo: tw.logo ?? '', + teamName: tw.teamName, + participants: tw.participants, + prize: tw.prize, + submissionId: tw.submissionId, + }; + return ( +
+ + {tw.track.name} + + +
+ ); + })} +
+
+ )}
diff --git a/app/(landing)/hackathons/[slug]/components/tabs/index.tsx b/app/(landing)/hackathons/[slug]/components/tabs/index.tsx index 1678d4e4..7e84c5c8 100644 --- a/app/(landing)/hackathons/[slug]/components/tabs/index.tsx +++ b/app/(landing)/hackathons/[slug]/components/tabs/index.tsx @@ -35,6 +35,7 @@ const HackathonTabs = ({ sidebar }: HackathonTabsProps) => { const { currentHackathon, winners, + trackWinners, exploreSubmissionsTotal, loading: generalLoading, } = useHackathonData(); @@ -66,7 +67,10 @@ const HackathonTabs = ({ sidebar }: HackathonTabsProps) => { if (!currentHackathon) return []; const hasParticipants = currentHackathon._count?.participants > 0; const hasResources = currentHackathon.resources?.length > 0; - const hasWinners = !!(winners && winners.length > 0); + const hasWinners = !!( + (winners && winners.length > 0) || + (trackWinners && trackWinners.length > 0) + ); const hasAnnouncements = announcements.length > 0; const tabs = [ @@ -123,7 +127,7 @@ const HackathonTabs = ({ sidebar }: HackathonTabsProps) => { tabs.push({ id: 'winners', label: 'Winners', - badge: winners.length, + badge: (winners?.length ?? 0) + (trackWinners?.length ?? 0), }); } @@ -165,6 +169,7 @@ const HackathonTabs = ({ sidebar }: HackathonTabsProps) => { }, [ currentHackathon, winners, + trackWinners, exploreSubmissionsTotal, discussionComments.pagination.totalItems, announcements, diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/settings/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/settings/page.tsx index c8ba85c7..42a10a07 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/settings/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/settings/page.tsx @@ -14,6 +14,7 @@ import { Sliders, Eye, HandCoins, + Layers, } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/lib/api/api'; @@ -27,6 +28,7 @@ import CollaborationSettingsTab from '@/components/organization/hackathons/setti import AdvancedSettingsTab from '@/components/organization/hackathons/settings/AdvancedSettingsTab'; import SubmissionVisibilitySettingsTab from '@/components/organization/hackathons/settings/SubmissionVisibilitySettingsTab'; import PartnersSettingsTab from '@/components/organization/hackathons/settings/PartnersSettingsTab'; +import TracksSettingsTab from '@/components/organization/hackathons/settings/TracksSettingsTab'; import { AuthGuard } from '@/components/auth'; import Loading from '@/components/Loading'; @@ -232,6 +234,10 @@ export default function SettingsPage() { Rewards + + + Tracks + { await handleSave('Rewards', data); }} @@ -322,6 +334,13 @@ export default function SettingsPage() { /> + + + + )} + {/* Tagline — short pitch shown above the long description. */} + {(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tagline = (submission as any).tagline as string | undefined; + return tagline ? ( +

+ “{tagline}” +

+ ) : null; + })()} + + {/* Screenshots gallery (up to 5). Renders as a horizontal + scroll on mobile, a row of thumbnails on desktop. */} + {(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const screenshots = ((submission as any).screenshots ?? + []) as string[]; + return screenshots.length > 0 ? ( +
+

Screenshots

+ +
+ ) : null; + })()} + {/* Description */}

Description

-

{submission.description}

+

+ {submission.description} +

- {/* Introduction */} + {/* Per-track answers — only for tracks where the organizer + set a prompt / custom questions / required artifacts and + the submitter filled at least one in. We index by + trackId so the section header gets the track name. */} + {(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const entries = ((submission as any).trackEntries ?? + []) as Array<{ + trackId: string; + trackName: string; + trackAnswers?: { + promptAnswer?: string; + customAnswers?: Record; + artifacts?: Record; + }; + }>; + const withAnswers = entries.filter(e => { + const a = e.trackAnswers; + if (!a) return false; + return !!( + a.promptAnswer?.trim() || + Object.values(a.customAnswers ?? {}).some(v => v?.trim?.()) || + Object.values(a.artifacts ?? {}).some(v => v?.trim?.()) + ); + }); + if (withAnswers.length === 0) return null; + return ( +
+

Track answers

+ {withAnswers.map(e => ( +
+

+ {e.trackName} +

+ {e.trackAnswers?.promptAnswer && ( +

+ {e.trackAnswers.promptAnswer} +

+ )} + {Object.entries(e.trackAnswers?.customAnswers ?? {}) + .filter(([, v]) => v && v.trim().length > 0) + .map(([qid, value]) => ( +
+

{qid}

+

+ {value} +

+
+ ))} + {Object.entries(e.trackAnswers?.artifacts ?? {}) + .filter(([, v]) => v && v.trim().length > 0) + .map(([aid, url]) => ( +
+

{aid}

+ + {url} + +
+ ))} +
+ ))} +
+ ); + })()} + + {/* Built with — tech-stack chips, hidden when empty. */} + {(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const builtWith = ((submission as any).builtWith ?? + []) as string[]; + return builtWith.length > 0 ? ( +
+

Built with

+
+ {builtWith.map((tag, i) => ( + + {tag} + + ))} +
+
+ ) : null; + })()} + + {/* Introduction (legacy field — keep for backward compat) */} {submission.introduction && (

Introduction

@@ -271,7 +410,7 @@ export function SubmissionDetailModal({ {/* Footer Info */} -
+
@@ -292,6 +431,17 @@ export function SubmissionDetailModal({ comments
+ {(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const license = (submission as any).license as + | string + | undefined; + return license ? ( + + License · {license} + + ) : null; + })()}
{/* Disqualification Reason */} diff --git a/components/hackathons/submissions/SubmissionForm.tsx b/components/hackathons/submissions/SubmissionForm.tsx index 001f8def..b563347a 100644 --- a/components/hackathons/submissions/SubmissionForm.tsx +++ b/components/hackathons/submissions/SubmissionForm.tsx @@ -42,6 +42,7 @@ import { type SubmissionFormData, } from '@/hooks/hackathon/use-submission'; import { useHackathonData } from '@/lib/providers/hackathonProvider'; +import { listTracks, type HackathonTrack } from '@/lib/api/hackathons/tracks'; import { toast } from 'sonner'; import { Loader2, @@ -83,6 +84,16 @@ const teamMemberSchema = z path: ['email'], }); +const LICENSE_OPTIONS = [ + 'MIT', + 'Apache-2.0', + 'GPL-3.0', + 'BSD-3', + 'PROPRIETARY', + 'OTHER', +] as const; +type License = (typeof LICENSE_OPTIONS)[number]; + const baseSubmissionSchema = z.object({ projectName: z.string().min(3, 'Project name must be at least 3 characters'), category: z.string().min(1, 'Please select a category'), @@ -105,6 +116,42 @@ const baseSubmissionSchema = z.object({ participationType: z.enum(['INDIVIDUAL', 'TEAM']), teamName: z.string().optional(), teamMembers: z.array(teamMemberSchema).optional(), + /** Track ids the submitter has opted into. Capped client-side by + * hackathon.tracksMaxPerSubmission; the backend re-validates. */ + trackIds: z.array(z.string()).optional(), + /** Track-specific answers, keyed by trackId (Phase B). The backend + * validates required fields against each track's customization. */ + trackAnswers: z + .record( + z.string(), + z.object({ + promptAnswer: z.string().max(2000).optional(), + customAnswers: z.record(z.string(), z.string()).optional(), + artifacts: z.record(z.string(), z.string()).optional(), + }) + ) + .optional(), + + // ── Phase A submission polish ── + tagline: z + .string() + .max(200, 'Tagline must be 200 characters or fewer') + .optional(), + builtWith: z + .array(z.string().max(40, 'Tag must be 40 characters or fewer')) + .max(20, 'Up to 20 tags') + .optional(), + screenshots: z + .array(z.string().url('Each screenshot must be a valid URL')) + .max(5, 'Up to 5 screenshots') + .optional(), + license: z + .enum(LICENSE_OPTIONS as unknown as [License, ...License[]]) + .optional(), + // codeAttested is enforced at submit time (see onSubmit) rather than + // here so the user can move between steps without the schema blocking + // them. The Review step UI wires it as a required gate. + codeAttested: z.boolean().optional(), }); const createSubmissionSchema = (requireDemoVideo: boolean) => @@ -208,6 +255,37 @@ const SubmissionFormContent: React.FC = ({ const { user } = useAuthStatus(); const requireGithub = currentHackathon?.requireGithub ?? false; + + // ── Tracks ──────────────────────────────────────────────────────────── + // Best-effort load — if the endpoint fails the form falls back to + // overall-only and the submission still goes through. + const [availableTracks, setAvailableTracks] = useState([]); + const trackCap = currentHackathon?.tracksMaxPerSubmission ?? 3; + const prizeStructure = currentHackathon?.prizeStructure ?? 'OVERALL_ONLY'; + const tracksEnabled = prizeStructure !== 'OVERALL_ONLY'; + + useEffect(() => { + if (!hackathonSlugOrId || !tracksEnabled) { + setAvailableTracks([]); + return; + } + let cancelled = false; + listTracks(hackathonSlugOrId) + .then(rows => { + if (!cancelled) { + // Only OPT_IN tracks need a pick — OPEN tracks auto-include + // every submission, so showing them in a checkbox group would + // just confuse submitters. + setAvailableTracks(rows.filter(t => t.eligibility === 'OPT_IN')); + } + }) + .catch(() => { + if (!cancelled) setAvailableTracks([]); + }); + return () => { + cancelled = true; + }; + }, [hackathonSlugOrId, tracksEnabled]); const requireDemoVideo = currentHackathon?.requireDemoVideo ?? false; const requireOtherLinks = currentHackathon?.requireOtherLinks ?? false; @@ -277,6 +355,13 @@ const SubmissionFormContent: React.FC = ({ participationType: 'INDIVIDUAL', teamName: '', teamMembers: [], + trackIds: [], + trackAnswers: {}, + tagline: '', + builtWith: [], + screenshots: [], + license: undefined, + codeAttested: false, }, }); @@ -293,6 +378,8 @@ const SubmissionFormContent: React.FC = ({ // Initialize form when modal opens with data useEffect(() => { if (open && initialData) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const raw = initialData as any; form.reset({ projectName: initialData.projectName || '', category: initialData.category || '', @@ -303,6 +390,52 @@ const SubmissionFormContent: React.FC = ({ introduction: initialData.introduction || '', links: initialData.links || [], participationType: initialData.participationType || 'INDIVIDUAL', + trackIds: + raw.trackIds ?? + ((raw.trackEntries ?? []) as Array<{ trackId: string }>).map( + e => e.trackId + ), + // Reconstruct trackAnswers from trackEntries the backend returns + // on reload. The shape is { [trackId]: TrackAnswer }. + trackAnswers: ((): Record< + string, + { + promptAnswer?: string; + customAnswers?: Record; + artifacts?: Record; + } + > => { + const entries = (raw.trackEntries ?? []) as Array<{ + trackId: string; + trackAnswers?: { + promptAnswer?: string; + customAnswers?: Record; + artifacts?: Record; + }; + }>; + const out: Record< + string, + { + promptAnswer?: string; + customAnswers?: Record; + artifacts?: Record; + } + > = {}; + for (const e of entries) { + if (e.trackAnswers && typeof e.trackAnswers === 'object') { + out[e.trackId] = e.trackAnswers; + } + } + return out; + })(), + tagline: raw.tagline ?? '', + builtWith: raw.builtWith ?? [], + screenshots: raw.screenshots ?? [], + license: raw.license, + // The attestation timestamp coming back from the server gets + // mapped to the boolean form field; reloading a previously + // attested submission keeps the box checked. + codeAttested: !!raw.codeAttestedAt || !!raw.codeAttested, }); if (initialData.logo && isValidImageUrl(initialData.logo)) { setLogoPreview(initialData.logo); @@ -784,6 +917,65 @@ const SubmissionFormContent: React.FC = ({ })) : [], participationType: data.participationType || 'INDIVIDUAL', + trackIds: + (data.trackIds ?? currentValues.trackIds)?.filter( + (id): id is string => typeof id === 'string' && id.length > 0 + ) ?? undefined, + // Pull per-track answers only for the tracks that are + // currently selected. Stale entries from de-selected tracks + // get dropped so the backend doesn't store dangling answers. + trackAnswers: ((): + | Record< + string, + { + promptAnswer?: string; + customAnswers?: Record; + artifacts?: Record; + } + > + | undefined => { + const picked = data.trackIds ?? currentValues.trackIds ?? []; + const all = (data.trackAnswers ?? + currentValues.trackAnswers ?? + {}) as Record< + string, + { + promptAnswer?: string; + customAnswers?: Record; + artifacts?: Record; + } + >; + if (picked.length === 0) return undefined; + const next: Record< + string, + { + promptAnswer?: string; + customAnswers?: Record; + artifacts?: Record; + } + > = {}; + for (const id of picked) { + if (typeof id !== 'string' || !id) continue; + if (all[id]) next[id] = all[id]; + } + return Object.keys(next).length > 0 ? next : undefined; + })(), + // ── Phase A submission polish ─ string fields default to undefined + // (not empty string) so the backend update path doesn't clobber + // existing values with empties. + tagline: + (data.tagline ?? currentValues.tagline)?.toString().trim() || + undefined, + builtWith: + (data.builtWith ?? currentValues.builtWith) + ?.map(t => t.trim()) + .filter(t => t.length > 0) ?? undefined, + screenshots: + (data.screenshots ?? currentValues.screenshots) + ?.map(u => u.trim()) + .filter(u => u.length > 0) ?? undefined, + license: data.license ?? currentValues.license ?? undefined, + codeAttested: data.codeAttested ?? currentValues.codeAttested ?? false, }; const isValid = await form.trigger([ @@ -861,6 +1053,34 @@ const SubmissionFormContent: React.FC = ({ const participationType = safeData.participationType || 'INDIVIDUAL'; const teamId = participationType === 'TEAM' ? myTeam?.id : undefined; + // Gate the compliance fields on NEW submissions only. + // + // For an existing submission (`submissionId` is set), license + + // attestation are optional so participants who submitted before + // the Phase A polish rolled out aren't blocked from editing + // other fields like videoUrl or screenshots. They CAN fill in + // the new fields if they want — the form still shows them and + // the data round-trips. + // + // The schema marks both fields optional, so we enforce the gate + // here (at the moment of submit) instead of letting zod block + // step navigation. + const isNewSubmission = !submissionId; + if (isNewSubmission && !safeData.license) { + toast.error('Pick a license on the Review step before submitting.'); + setCurrentStep(3); + updateStepState(3, 'active'); + return; + } + if (isNewSubmission && !safeData.codeAttested) { + toast.error( + 'Please confirm the code is original or properly attributed on the Review step.' + ); + setCurrentStep(3); + updateStepState(3, 'active'); + return; + } + // Team submissions always go through a real team. handleNext blocks // advancing past step 0 if participationType is TEAM but myTeam is // empty, so we no longer stamp teamName/teamMembers from the form. @@ -1119,6 +1339,40 @@ const SubmissionFormContent: React.FC = ({ )} /> + ( + + + Tagline{' '} + + (one-line pitch, up to 200 chars) + + + + + + + Shown on hackathon cards, sidebar, and the judge queue. + {field.value + ? ` ${field.value.length} / 200` + : ' Optional but strongly recommended.'} + + + + )} + /> + = ({ )} /> + + {tracksEnabled && availableTracks.length > 0 && ( + { + const value: string[] = field.value ?? []; + const toggle = (id: string) => { + if (value.includes(id)) { + field.onChange(value.filter(v => v !== id)); + } else { + if (value.length >= trackCap) { + toast.error( + `You can enter at most ${trackCap} track${ + trackCap === 1 ? '' : 's' + }.` + ); + return; + } + field.onChange([...value, id]); + } + }; + return ( + + + Tracks{' '} + + (optional, up to {trackCap}) + + + + Pick the tracks your project should be considered for. + Overall placements always apply; tracks unlock + additional category prizes. + +
+ {availableTracks.map(track => { + const checked = value.includes(track.id); + return ( + + ); + })} +
+ {value.length > 0 && ( +

+ {value.length} of {trackCap} selected +

+ )} + +
+ ); + }} + /> + )} + + {/* Per-track answer collection (Phase B). Renders one card + per selected track, with the track's prompt, custom + questions, and required artifacts inline. The form + field is a single nested object so the value naturally + round-trips through react-hook-form. */} + {tracksEnabled && + availableTracks.length > 0 && + (() => { + const picked = + (form.watch('trackIds') as string[] | undefined) ?? []; + const tracksById = new Map( + availableTracks.map(t => [t.id, t] as const) + ); + const relevant = picked + .map(id => tracksById.get(id)) + .filter((t): t is (typeof availableTracks)[number] => !!t) + .filter( + t => + !!t.prompt || + (t.customQuestions && t.customQuestions.length > 0) || + (t.requiredArtifacts && t.requiredArtifacts.length > 0) + ); + if (relevant.length === 0) return null; + return ( + { + const value = + (field.value as + | Record< + string, + { + promptAnswer?: string; + customAnswers?: Record; + artifacts?: Record; + } + > + | undefined) ?? {}; + const setAnswer = ( + trackId: string, + patch: Partial<{ + promptAnswer: string; + customAnswers: Record; + artifacts: Record; + }> + ) => { + const prev = value[trackId] ?? {}; + field.onChange({ + ...value, + [trackId]: { ...prev, ...patch }, + }); + }; + return ( + + + Track answers{' '} + + ({relevant.length} of your selected{' '} + {relevant.length === 1 ? 'track' : 'tracks'} needs + more info) + + + {relevant.map(track => { + const answer = value[track.id] ?? {}; + return ( +
+

+ {track.name} +

+ {track.prompt && ( +
+ +