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/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 => ( { 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/contents/submissions/SubmissionCard.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx index 26d00747..c1f18f3e 100644 --- a/app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; import { useQueryClient } from '@tanstack/react-query'; import { LayoutGrid, Loader2, Pencil, Trash2 } from 'lucide-react'; @@ -142,7 +143,24 @@ const SubmissionCard = ({ submission }: SubmissionCardProps) => {
{isTeam ? ( - m.avatar ?? '')} /> + m.avatar ?? '')} + usernames={teamMembers.map(m => m.username)} + /> + ) : participant?.username ? ( + + + ) : ( { 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)/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/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() { /> + + + + { +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/hackathons/submissions/SubmissionDetailModal.tsx b/components/hackathons/submissions/SubmissionDetailModal.tsx index 37a4c2cd..59fc4592 100644 --- a/components/hackathons/submissions/SubmissionDetailModal.tsx +++ b/components/hackathons/submissions/SubmissionDetailModal.tsx @@ -217,13 +217,152 @@ export function SubmissionDetailModal({
)} + {/* 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..acb808b8 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,10 +84,26 @@ 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'), + 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 @@ -99,12 +116,54 @@ 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']), 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) => @@ -126,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; @@ -208,6 +283,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 +383,13 @@ const SubmissionFormContent: React.FC = ({ participationType: 'INDIVIDUAL', teamName: '', teamMembers: [], + trackIds: [], + trackAnswers: {}, + tagline: '', + builtWith: [], + screenshots: [], + license: undefined, + codeAttested: false, }, }); @@ -293,6 +406,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 +418,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 +945,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 +1081,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 +1367,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 && ( +
+ +