Skip to content

Commit 1269fdf

Browse files
0xdevcollinsclaude
andauthored
Feat/submission visibility hidden until results (#566)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 335758e commit 1269fdf

4 files changed

Lines changed: 80 additions & 29 deletions

File tree

app/(landing)/hackathons/[slug]/submit/page.tsx

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { use, useEffect, useState } from 'react';
3+
import { use, useEffect, useMemo, useState } from 'react';
44
import { useRouter } from 'next/navigation';
55
import { useHackathon } from '@/hooks/hackathon/use-hackathon-queries';
66
import { useAuthStatus } from '@/hooks/use-auth';
@@ -94,6 +94,40 @@ export default function SubmitProjectPage({
9494
router.push(`/hackathons/${hackathonSlug}?tab=submission`);
9595
};
9696

97+
// Stable initialData reference so the form doesn't re-reset (and wipe
98+
// the user's typed-but-unsaved input) on every parent re-render. The
99+
// form's reset effect depends on this object identity, so it MUST only
100+
// change when the underlying submission actually changes.
101+
//
102+
// We also pass through the Phase A polish fields (tagline, builtWith,
103+
// screenshots, license, codeAttestedAt) and trackEntries — if these
104+
// are missing, the form initialises them to empty and any save then
105+
// clobbers the server values with empties.
106+
const initialData = useMemo(() => {
107+
if (!mySubmission) return undefined;
108+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
109+
const s = mySubmission as any;
110+
return {
111+
projectName: mySubmission.projectName,
112+
category: mySubmission.category,
113+
description: mySubmission.description,
114+
logo: mySubmission.logo,
115+
banner: mySubmission.banner,
116+
videoUrl: mySubmission.videoUrl,
117+
introduction: mySubmission.introduction,
118+
links: mySubmission.links,
119+
participationType: s.participationType,
120+
teamName: s.teamName,
121+
teamMembers: s.teamMembers,
122+
tagline: s.tagline,
123+
builtWith: s.builtWith,
124+
screenshots: s.screenshots,
125+
license: s.license,
126+
codeAttestedAt: s.codeAttestedAt,
127+
trackEntries: s.trackEntries,
128+
};
129+
}, [mySubmission]);
130+
97131
const [hasInitialLoaded, setHasInitialLoaded] = useState(false);
98132

99133
useEffect(() => {
@@ -149,24 +183,7 @@ export default function SubmitProjectPage({
149183
hackathonSlugOrId={hackathonId}
150184
organizationId={orgId}
151185
submissionId={mySubmission?.id}
152-
initialData={
153-
mySubmission
154-
? {
155-
projectName: mySubmission.projectName,
156-
category: mySubmission.category,
157-
description: mySubmission.description,
158-
logo: mySubmission.logo,
159-
banner: mySubmission.banner,
160-
videoUrl: mySubmission.videoUrl,
161-
introduction: mySubmission.introduction,
162-
links: mySubmission.links,
163-
participationType: (mySubmission as any)
164-
.participationType,
165-
teamName: (mySubmission as any).teamName,
166-
teamMembers: (mySubmission as any).teamMembers,
167-
}
168-
: undefined
169-
}
186+
initialData={initialData}
170187
onSuccess={handleSuccess}
171188
onClose={handleClose}
172189
/>

components/hackathons/submissions/SubmissionForm.tsx

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,15 @@ const LICENSE_OPTIONS = [
9595
type License = (typeof LICENSE_OPTIONS)[number];
9696

9797
const baseSubmissionSchema = z.object({
98-
projectName: z.string().min(3, 'Project name must be at least 3 characters'),
98+
projectName: z
99+
.string()
100+
.min(3, 'Project name must be at least 3 characters')
101+
.max(100, 'Project name cannot exceed 100 characters'),
99102
category: z.string().min(1, 'Please select a category'),
100-
description: z.string().min(50, 'Description must be at least 50 characters'),
103+
description: z
104+
.string()
105+
.min(50, 'Description must be at least 50 characters')
106+
.max(5000, 'Description cannot exceed 5000 characters'),
101107
logo: z.string().optional(),
102108
banner: z.string().optional(),
103109
videoUrl: z
@@ -110,7 +116,13 @@ const baseSubmissionSchema = z.object({
110116
links: z.array(
111117
z.object({
112118
type: z.string(),
113-
url: z.union([z.string().url('Please enter a valid URL'), z.literal('')]),
119+
url: z.union([
120+
z
121+
.string()
122+
.url('Please enter a valid URL')
123+
.max(500, 'URL cannot exceed 500 characters'),
124+
z.literal(''),
125+
]),
114126
})
115127
),
116128
participationType: z.enum(['INDIVIDUAL', 'TEAM']),
@@ -173,7 +185,23 @@ type SubmissionFormDataLocal = z.infer<typeof baseSubmissionSchema>;
173185
interface SubmissionFormContentProps {
174186
hackathonSlugOrId: string;
175187
organizationId?: string;
176-
initialData?: Partial<SubmissionFormDataLocal>;
188+
/**
189+
* Pre-populates the form when editing an existing submission. Accepts
190+
* the raw submission shape from the API so server-only fields like
191+
* trackEntries and codeAttestedAt can be hydrated alongside the Zod-
192+
* typed form fields (which the form reads via a typed cast).
193+
*/
194+
initialData?: Partial<SubmissionFormDataLocal> & {
195+
trackEntries?: Array<{
196+
trackId: string;
197+
trackAnswers?: {
198+
promptAnswer?: string;
199+
customAnswers?: Record<string, string>;
200+
artifacts?: Record<string, string>;
201+
};
202+
}>;
203+
codeAttestedAt?: string | null;
204+
};
177205
submissionId?: string;
178206
onSuccess?: () => void;
179207
onClose?: () => void;

hooks/hackathon/use-submission.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,16 @@ import { reportError } from '@/lib/error-reporting';
1717
function getApiErrorMessage(err: unknown, fallback: string): string {
1818
const apiErr = err as ApiError | undefined;
1919
if (apiErr && typeof apiErr.message === 'string' && apiErr.message) {
20-
const firstField =
21-
Array.isArray(apiErr.errors) && apiErr.errors.length > 0
22-
? apiErr.errors[0].message
23-
: null;
24-
if (firstField && firstField !== apiErr.message) {
25-
return `${apiErr.message}: ${firstField}`;
20+
const first = Array.isArray(apiErr.errors) ? apiErr.errors[0] : undefined;
21+
const fieldMsg = first?.message;
22+
// `debug` is only present outside production; surfaces the real Prisma
23+
// reason when the generic "Data validation failed" fires.
24+
const debug = first?.debug;
25+
if (debug && debug !== apiErr.message) {
26+
return `${apiErr.message}: ${debug}`;
27+
}
28+
if (fieldMsg && fieldMsg !== apiErr.message) {
29+
return `${apiErr.message}: ${fieldMsg}`;
2630
}
2731
return apiErr.message;
2832
}

lib/api/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export interface ApiResponse<T = unknown> {
2626
export interface ApiErrorField {
2727
field?: string;
2828
message: string;
29+
/** Populated by the backend Prisma filter outside production. */
30+
debug?: string;
2931
}
3032

3133
export interface ApiError {

0 commit comments

Comments
 (0)