diff --git a/claude.md b/claude.md index d5f63eb6..28be66c6 100644 --- a/claude.md +++ b/claude.md @@ -53,6 +53,17 @@ Note: `air` runs `task gen-docs` as a pre-command on every rebuild, so `swag` CL - **Pagination:** Cursor-based with base64-encoded JSON cursors - **Migrations:** SQL files in `cmd/migrate/migrations/`, managed with `golang-migrate` +#### Migration Naming Convention + +Format: `{6-digit-number}_{action}_{subject}.{up|down}.sql` + +- `create` — foundational schema objects (infrastructure, core tables, initial types) +- `add` — new features, tables, columns, or triggers added after initial setup +- `alter` — modifications to existing schema objects +- `seed` — initial/default data insertion + +Each migration must be isolated to one concern — one table, one type, or one logical operation. Triggers and indexes stay with their parent table. Enum types get their own migration, separate from the table that uses them. + #### Go Handler Pattern Handlers are methods on `*application`: diff --git a/client/web/src/pages/admin/_shared/grading/GradingDetailsPanel.tsx b/client/web/src/pages/admin/_shared/grading/GradingDetailsPanel.tsx index 41a71c29..54800453 100644 --- a/client/web/src/pages/admin/_shared/grading/GradingDetailsPanel.tsx +++ b/client/web/src/pages/admin/_shared/grading/GradingDetailsPanel.tsx @@ -5,13 +5,8 @@ import { toast } from "sonner"; import { Skeleton } from "@/components/ui/skeleton"; import { fetchApplicationResumeURL } from "@/pages/admin/all-applicants/api"; import { - DemographicsSection, - EducationSection, - EventPreferencesSection, - ExperienceSection, LinksSection, - PersonalInfoSection, - ShortAnswersSection, + SchemaDetailRenderer, TimelineSection, } from "@/pages/admin/all-applicants/components/detail-sections"; import { errorAlert } from "@/shared/lib/api"; @@ -72,12 +67,7 @@ export const GradingDetailsPanel = memo(function GradingDetailsPanel({ return (
- - - - - - +

- {formatName(application.first_name, application.last_name)} + {formatName( + application.responses?.first_name as string | null, + application.responses?.last_name as string | null, + )}

{application.status} @@ -122,12 +118,7 @@ export const ApplicationDetailPanel = memo(function ApplicationDetailPanel({
) : application ? (
- - - - - - + {app.email} - {app.phone_e164 ?? "-"} + {app.phone ?? "-"} {app.age ?? "-"} {app.country_of_residence ?? "-"} {app.gender ?? "-"} {app.university ?? "-"} {app.major ?? "-"} {app.level_of_study ?? "-"} - {app.hackathons_attended_count ?? "-"} + {app.hackathons_attended ?? "-"} {app.submitted_at ? new Date(app.submitted_at).toLocaleDateString() diff --git a/client/web/src/pages/admin/all-applicants/components/detail-sections/DemographicsSection.tsx b/client/web/src/pages/admin/all-applicants/components/detail-sections/DemographicsSection.tsx deleted file mode 100644 index 1e689e0b..00000000 --- a/client/web/src/pages/admin/all-applicants/components/detail-sections/DemographicsSection.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Label } from "@/components/ui/label"; -import type { Application } from "@/types"; - -interface DemographicsSectionProps { - application: Application; -} - -export function DemographicsSection({ application }: DemographicsSectionProps) { - return ( -
-

Demographics

-
-
- -

{application.race || "N/A"}

-
-
- -

{application.ethnicity || "N/A"}

-
-
-
- ); -} diff --git a/client/web/src/pages/admin/all-applicants/components/detail-sections/EducationSection.tsx b/client/web/src/pages/admin/all-applicants/components/detail-sections/EducationSection.tsx deleted file mode 100644 index 64eb0141..00000000 --- a/client/web/src/pages/admin/all-applicants/components/detail-sections/EducationSection.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Label } from "@/components/ui/label"; -import type { Application } from "@/types"; - -interface EducationSectionProps { - application: Application; -} - -export function EducationSection({ application }: EducationSectionProps) { - return ( -
-

Education

-
-
- -

{application.university || "N/A"}

-
-
- -

{application.major || "N/A"}

-
-
- -

{application.level_of_study || "N/A"}

-
-
-
- ); -} diff --git a/client/web/src/pages/admin/all-applicants/components/detail-sections/EventPreferencesSection.tsx b/client/web/src/pages/admin/all-applicants/components/detail-sections/EventPreferencesSection.tsx deleted file mode 100644 index 1cbbca9e..00000000 --- a/client/web/src/pages/admin/all-applicants/components/detail-sections/EventPreferencesSection.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Badge } from "@/components/ui/badge"; -import { Label } from "@/components/ui/label"; -import type { Application } from "@/types"; - -interface EventPreferencesSectionProps { - application: Application; -} - -export function EventPreferencesSection({ - application, -}: EventPreferencesSectionProps) { - return ( -
-

Event Preferences

-
-
- -

{application.shirt_size || "N/A"}

-
-
- -
- {application.dietary_restrictions?.length > 0 ? ( - application.dietary_restrictions.map((restriction) => ( - - {restriction} - - )) - ) : ( - None - )} -
-
- {application.accommodations && ( -
- -

{application.accommodations}

-
- )} -
-
- ); -} diff --git a/client/web/src/pages/admin/all-applicants/components/detail-sections/ExperienceSection.tsx b/client/web/src/pages/admin/all-applicants/components/detail-sections/ExperienceSection.tsx deleted file mode 100644 index aab8159a..00000000 --- a/client/web/src/pages/admin/all-applicants/components/detail-sections/ExperienceSection.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Label } from "@/components/ui/label"; -import type { Application } from "@/types"; - -interface ExperienceSectionProps { - application: Application; -} - -export function ExperienceSection({ application }: ExperienceSectionProps) { - return ( -
-

Experience

-
-
- -

{application.hackathons_attended_count ?? "N/A"}

-
-
- -

{application.software_experience_level || "N/A"}

-
-
- -

{application.heard_about || "N/A"}

-
-
-
- ); -} diff --git a/client/web/src/pages/admin/all-applicants/components/detail-sections/LinksSection.tsx b/client/web/src/pages/admin/all-applicants/components/detail-sections/LinksSection.tsx index cbffaea4..14faf076 100644 --- a/client/web/src/pages/admin/all-applicants/components/detail-sections/LinksSection.tsx +++ b/client/web/src/pages/admin/all-applicants/components/detail-sections/LinksSection.tsx @@ -15,91 +15,38 @@ export function LinksSection({ onViewResume, isOpeningResume = false, }: LinksSectionProps) { - const hasLinks = - application.github || - application.linkedin || - application.website || - application.resume_path; - - if (!hasLinks) { + if (!application.resume_path) { return null; } return (
-

Links

+

Resume

- {application.github && ( - - )} - {application.linkedin && ( -
- -

- - {application.linkedin} - -

-
- )} - {application.website && ( -
- -

- - {application.website} - -

-
- )} - {application.resume_path && ( -
- -
- -
+
+ +
+
- )} +
); diff --git a/client/web/src/pages/admin/all-applicants/components/detail-sections/PersonalInfoSection.tsx b/client/web/src/pages/admin/all-applicants/components/detail-sections/PersonalInfoSection.tsx deleted file mode 100644 index 3e9033ee..00000000 --- a/client/web/src/pages/admin/all-applicants/components/detail-sections/PersonalInfoSection.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Label } from "@/components/ui/label"; -import type { Application } from "@/types"; - -interface PersonalInfoSectionProps { - application: Application; -} - -export function PersonalInfoSection({ application }: PersonalInfoSectionProps) { - return ( -
-

Personal Information

-
-
- -

{application.phone_e164 || "N/A"}

-
-
- -

{application.age ?? "N/A"}

-
-
- -

{application.country_of_residence || "N/A"}

-
-
- -

{application.gender || "N/A"}

-
-
-
- ); -} diff --git a/client/web/src/pages/admin/all-applicants/components/detail-sections/SchemaDetailRenderer.tsx b/client/web/src/pages/admin/all-applicants/components/detail-sections/SchemaDetailRenderer.tsx new file mode 100644 index 00000000..c211ccfa --- /dev/null +++ b/client/web/src/pages/admin/all-applicants/components/detail-sections/SchemaDetailRenderer.tsx @@ -0,0 +1,80 @@ +import { Label } from "@/components/ui/label"; +import { + deriveSections, + formatResponseValue, + getResponseValue, + groupFieldsBySection, +} from "@/shared/lib/schema-utils"; +import type { Application } from "@/types"; + +interface SchemaDetailRendererProps { + application: Application; + /** Sections to skip (e.g., "links" if rendered separately). */ + skipSections?: string[]; +} + +export function SchemaDetailRenderer({ + application, + skipSections = [], +}: SchemaDetailRendererProps) { + const schema = application.application_schema ?? []; + const responses = application.responses ?? {}; + const sections = deriveSections(schema); + const grouped = groupFieldsBySection(schema); + + return ( + <> + {sections + .filter((s) => !skipSections.includes(s.id)) + .map((section) => { + const fields = grouped[section.id]; + if (!fields || fields.length === 0) return null; + + return ( +
+

{section.label}

+
+ {fields.map((field) => { + const value = getResponseValue(responses, field.id, null); + // For links section fields (text type with URL-like values), render as links + if ( + section.id === "links" && + field.type === "text" && + typeof value === "string" && + value + ) { + return ( +
+ +

+ + {value} + +

+
+ ); + } + + return ( +
+ +

{formatResponseValue(value, field)}

+
+ ); + })} +
+
+ ); + })} + + ); +} diff --git a/client/web/src/pages/admin/all-applicants/components/detail-sections/ShortAnswersSection.tsx b/client/web/src/pages/admin/all-applicants/components/detail-sections/ShortAnswersSection.tsx deleted file mode 100644 index 1a63051e..00000000 --- a/client/web/src/pages/admin/all-applicants/components/detail-sections/ShortAnswersSection.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Label } from "@/components/ui/label"; -import type { Application } from "@/types"; - -interface ShortAnswersSectionProps { - application: Application; -} - -export function ShortAnswersSection({ application }: ShortAnswersSectionProps) { - if (!application.short_answer_questions?.length) { - return null; - } - - return ( -
-

Short Answers

-
- {[...application.short_answer_questions] - .sort((a, b) => a.display_order - b.display_order) - .map((q) => ( -
- -

- {application.short_answer_responses?.[q.id] || "N/A"} -

-
- ))} -
-
- ); -} diff --git a/client/web/src/pages/admin/all-applicants/components/detail-sections/index.ts b/client/web/src/pages/admin/all-applicants/components/detail-sections/index.ts index bd1b0417..4d9ecbfb 100644 --- a/client/web/src/pages/admin/all-applicants/components/detail-sections/index.ts +++ b/client/web/src/pages/admin/all-applicants/components/detail-sections/index.ts @@ -1,8 +1,3 @@ -export { DemographicsSection } from "./DemographicsSection"; -export { EducationSection } from "./EducationSection"; -export { EventPreferencesSection } from "./EventPreferencesSection"; -export { ExperienceSection } from "./ExperienceSection"; export { LinksSection } from "./LinksSection"; -export { PersonalInfoSection } from "./PersonalInfoSection"; -export { ShortAnswersSection } from "./ShortAnswersSection"; +export { SchemaDetailRenderer } from "./SchemaDetailRenderer"; export { TimelineSection } from "./TimelineSection"; diff --git a/client/web/src/pages/admin/all-applicants/types.ts b/client/web/src/pages/admin/all-applicants/types.ts index 883aab45..50f6b836 100644 --- a/client/web/src/pages/admin/all-applicants/types.ts +++ b/client/web/src/pages/admin/all-applicants/types.ts @@ -12,14 +12,14 @@ export interface ApplicationListItem { status: ApplicationStatus; first_name: string | null; last_name: string | null; - phone_e164: string | null; + phone: string | null; age: number | null; country_of_residence: string | null; gender: string | null; university: string | null; major: string | null; level_of_study: string | null; - hackathons_attended_count: number | null; + hackathons_attended: number | null; submitted_at: string | null; created_at: string; updated_at: string; @@ -29,6 +29,7 @@ export interface ApplicationListItem { waitlist_votes: number; reviews_assigned: number; reviews_completed: number; + has_resume: boolean; } export interface ApplicationListResult { diff --git a/client/web/src/pages/admin/reviews/components/ApplicationDetailsPanel.tsx b/client/web/src/pages/admin/reviews/components/ApplicationDetailsPanel.tsx index ce64c2ae..8ffca7c6 100644 --- a/client/web/src/pages/admin/reviews/components/ApplicationDetailsPanel.tsx +++ b/client/web/src/pages/admin/reviews/components/ApplicationDetailsPanel.tsx @@ -2,13 +2,13 @@ import { ExternalLink, Loader2 } from "lucide-react"; import { useCallback, useState } from "react"; import { toast } from "sonner"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { errorAlert } from "@/shared/lib/api"; import type { Application } from "@/types"; import { fetchApplicationResumeURL } from "../../all-applicants/api"; +import { SchemaDetailRenderer } from "../../all-applicants/components/detail-sections/SchemaDetailRenderer"; import type { Review } from "../types"; interface ApplicationDetailsPanelProps { @@ -51,236 +51,36 @@ export function ApplicationDetailsPanel({ return (
- {/* Personal Info */} -
-

Personal Information

-
-
- -

{application.phone_e164 || "N/A"}

-
-
- -

{application.age ?? "N/A"}

-
-
- -

{application.country_of_residence || "N/A"}

-
-
- -

{application.gender || "N/A"}

-
-
-
- - {/* Demographics */} -
-

Demographics

-
-
- -

{application.race || "N/A"}

-
-
- -

{application.ethnicity || "N/A"}

-
-
-
- - {/* Education */} -
-

Education

-
-
- -

{application.university || "N/A"}

-
-
- -

{application.major || "N/A"}

-
-
- -

{application.level_of_study || "N/A"}

-
-
-
- - {/* Experience */} -
-

Experience

-
-
- -

{application.hackathons_attended_count ?? "N/A"}

-
-
- -

{application.software_experience_level || "N/A"}

-
-
- -

{application.heard_about || "N/A"}

-
-
-
+ {/* Schema-driven fields (all sections) */} + - {/* Short Answers */} - {application.short_answer_questions?.length > 0 && ( + {/* Resume link */} + {application.resume_path && (
-

Short Answers

-
- {[...application.short_answer_questions] - .sort((a, b) => a.display_order - b.display_order) - .map((q) => ( -
- -

- {application.short_answer_responses?.[q.id] || "N/A"} -

-
- ))} -
-
- )} - - {/* AI percent */} - - {/* Event Preferences */} -
-

Event Preferences

-
-
- -

{application.shirt_size || "N/A"}

-
-
- -
- {application.dietary_restrictions?.length > 0 ? ( - application.dietary_restrictions.map((restriction) => ( - - {restriction} - - )) - ) : ( - None - )} +

Resume

+
+
+
- {application.accommodations && ( -
- -

{application.accommodations}

-
- )} -
-
- - {/* Links */} - {(application.github || - application.linkedin || - application.website || - application.resume_path) && ( -
-

Links

-
- {application.github && ( - - )} - {application.linkedin && ( -
- -

- - {application.linkedin} - -

-
- )} - {application.website && ( -
- -

- - {application.website} - -

-
- )} - {application.resume_path && ( -
- -
- -
-
- )} -
)} diff --git a/client/web/src/pages/admin/reviews/components/ReviewsTable.tsx b/client/web/src/pages/admin/reviews/components/ReviewsTable.tsx index adaa69eb..00a111b7 100644 --- a/client/web/src/pages/admin/reviews/components/ReviewsTable.tsx +++ b/client/web/src/pages/admin/reviews/components/ReviewsTable.tsx @@ -96,7 +96,7 @@ export const ReviewsTable = memo(function ReviewsTable({ {review.university ?? "-"} {review.major ?? "-"} {review.country_of_residence ?? "-"} - {review.hackathons_attended_count ?? "-"} + {review.hackathons_attended ?? "-"} {review[dateField] ? new Date(review[dateField]!).toLocaleDateString() diff --git a/client/web/src/pages/admin/reviews/types.ts b/client/web/src/pages/admin/reviews/types.ts index 694eb3ef..2241002c 100644 --- a/client/web/src/pages/admin/reviews/types.ts +++ b/client/web/src/pages/admin/reviews/types.ts @@ -20,7 +20,7 @@ export interface Review { university: string | null; major: string | null; country_of_residence: string | null; - hackathons_attended_count: number | null; + hackathons_attended: number | null; } export interface ReviewNote { diff --git a/client/web/src/pages/hacker/StatusPage.tsx b/client/web/src/pages/hacker/StatusPage.tsx index 3e8e9d39..6246685e 100644 --- a/client/web/src/pages/hacker/StatusPage.tsx +++ b/client/web/src/pages/hacker/StatusPage.tsx @@ -149,17 +149,20 @@ export default function Status() {

Name:

- {application.first_name} {application.last_name} + {String(application.responses?.first_name ?? "")}{" "} + {String(application.responses?.last_name ?? "")}

University:

- {application.university || "Not provided"} + {String( + application.responses?.university || "Not provided", + )}

Major:

- {application.major || "Not provided"} + {String(application.responses?.major || "Not provided")}

{application.submitted_at && ( diff --git a/client/web/src/pages/hacker/apply/api.ts b/client/web/src/pages/hacker/apply/api.ts index 29a1e0d3..1bc6a3b6 100644 --- a/client/web/src/pages/hacker/apply/api.ts +++ b/client/web/src/pages/hacker/apply/api.ts @@ -1,6 +1,11 @@ import { deleteRequest, patchRequest, postRequest } from "@/shared/lib/api"; import type { ApiResponse, Application } from "@/types"; +export interface UpdateApplicationPayload { + responses?: Record; + resume_path?: string; +} + const MAX_RESUME_SIZE_BYTES = 5 * 1024 * 1024; const UPLOAD_SIZE_RANGE_HEADER = `0,${MAX_RESUME_SIZE_BYTES}`; @@ -24,7 +29,7 @@ function unwrapPatchedApplication( } export async function updateMyApplication( - payload: Record, + payload: UpdateApplicationPayload, ): Promise> { const res = await patchRequest>( "/applications/me", diff --git a/client/web/src/pages/hacker/apply/components/ApplicationWizard.tsx b/client/web/src/pages/hacker/apply/components/ApplicationWizard.tsx index f29afd0a..6e663069 100644 --- a/client/web/src/pages/hacker/apply/components/ApplicationWizard.tsx +++ b/client/web/src/pages/hacker/apply/components/ApplicationWizard.tsx @@ -1,6 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { AlertCircle } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; @@ -12,25 +12,27 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; import { errorAlert, getRequest, postRequest } from "@/shared/lib/api"; -import type { Application, ShortAnswerQuestion } from "@/types"; +import { + buildDefaultValues, + deriveSections, + groupFieldsBySection, +} from "@/shared/lib/schema-utils"; +import type { Application, ApplicationSchemaField } from "@/types"; import { deleteMyResume as deleteResume, MAX_RESUME_SIZE_BYTES as MAX_RESUME_UPLOAD_SIZE_BYTES, requestResumeUploadURL as getResumeUploadURL, + type UpdateApplicationPayload, updateMyApplication, uploadResumeToSignedURL as uploadToSignedURL, } from "../api"; -import { EventInfoStep } from "../steps/EventInfoStep"; -import { ExperienceStep } from "../steps/ExperienceStep"; -import { PersonalInfoStep } from "../steps/PersonalInfoStep"; import { ReviewStep } from "../steps/ReviewStep"; -import { SchoolInfoStep } from "../steps/SchoolInfoStep"; -import { ShortAnswerStep } from "../steps/ShortAnswerStep"; +import { SchemaStepRenderer } from "../steps/SchemaStepRenderer"; import { SponsorInfoStep } from "../steps/SponsorInfoStep"; -import type { ApplicationFormData } from "../validations"; -import { applicationSchema, STEP_FIELDS } from "../validations"; +import { buildApplicationSchema } from "../validations"; import { StepIndicator } from "./StepIndicator"; import { StepNavigation } from "./StepNavigation"; @@ -41,148 +43,47 @@ interface ApplicationWizardProps { const PDF_MIME_TYPE = "application/pdf"; const MAX_RESUME_SIZE_MB = MAX_RESUME_UPLOAD_SIZE_BYTES / (1024 * 1024); -const STEPS = [ - { id: "personal", title: "Personal Info" }, - { id: "school", title: "School Info" }, - { id: "experience", title: "Experience" }, - { id: "short-answer", title: "Short Answers" }, - { id: "event", title: "Event Info" }, - { id: "sponsor", title: "Sponsor Info" }, - { id: "review", title: "Review" }, -]; - -// Dietary restriction enum type -type DietaryRestriction = - | "vegan" - | "vegetarian" - | "halal" - | "nuts" - | "fish" - | "wheat" - | "dairy" - | "eggs" - | "no_beef" - | "no_pork"; - -// Default form values -const defaultValues: ApplicationFormData = { - first_name: "", - last_name: "", - phone_e164: "", - age: 0, - country_of_residence: "", - gender: "", - race: "", - ethnicity: "", - university: "", - major: "", - level_of_study: "", - hackathons_attended_count: 0, - software_experience_level: "", - heard_about: "", - short_answer_responses: {}, - shirt_size: "", - dietary_restrictions: [] as DietaryRestriction[], - accommodations: "", - github: "", - linkedin: "", - website: "", - ack_application: false, - ack_mlh_coc: false, - ack_mlh_privacy: false, - opt_in_mlh_emails: false, -}; - -// Transform API response to form data -function transformApplicationToFormData(app: Application): ApplicationFormData { - return { - first_name: app.first_name ?? "", - last_name: app.last_name ?? "", - phone_e164: app.phone_e164 ?? "", - age: app.age ?? 0, - country_of_residence: app.country_of_residence ?? "", - gender: app.gender ?? "", - race: app.race ?? "", - ethnicity: app.ethnicity ?? "", - university: app.university ?? "", - major: app.major ?? "", - level_of_study: app.level_of_study ?? "", - hackathons_attended_count: app.hackathons_attended_count ?? 0, - software_experience_level: app.software_experience_level ?? "", - heard_about: app.heard_about ?? "", - short_answer_responses: app.short_answer_responses ?? {}, - shirt_size: app.shirt_size ?? "", - dietary_restrictions: (app.dietary_restrictions ?? - []) as DietaryRestriction[], - accommodations: app.accommodations ?? "", - github: app.github ?? "", - linkedin: app.linkedin ?? "", - website: app.website ?? "", - ack_application: app.ack_application ?? false, - ack_mlh_coc: app.ack_mlh_coc ?? false, - ack_mlh_privacy: app.ack_mlh_privacy ?? false, - opt_in_mlh_emails: app.opt_in_mlh_emails ?? false, - }; -} +/** Sections that become wizard steps, derived dynamically from the schema. */ -// Transform form data to API payload -// Using Record because react-hook-form with zod coerce types as unknown -function transformFormDataToPayload( - data: Record, +/** + * Extract form values from the API Application object. + * Responses are a flat key-value object; ack fields are top-level. + */ +function transformApplicationToFormData( + app: Application, + schemaFields: ApplicationSchemaField[], ): Record { - const payload: Record = {}; - - // String fields - only include if non-empty - const stringFields = [ - "first_name", - "last_name", - "phone_e164", - "country_of_residence", - "gender", - "race", - "ethnicity", - "university", - "major", - "level_of_study", - "software_experience_level", - "heard_about", - "shirt_size", - "accommodations", - "github", - "linkedin", - "website", - ]; - - for (const field of stringFields) { - const value = data[field]; - if (value !== undefined && value !== "") { - payload[field] = value; + const defaults = buildDefaultValues(schemaFields); + const responses = app.responses ?? {}; + + // Merge stored responses over defaults + const data: Record = { ...defaults }; + for (const [key, value] of Object.entries(responses)) { + if (value !== null && value !== undefined) { + data[key] = value; } } - // Number fields - const age = data.age as number | undefined; - if (age !== undefined && !isNaN(age)) { - payload.age = age; - } - const hackathonsCount = data.hackathons_attended_count as number | undefined; - if (hackathonsCount !== undefined) { - payload.hackathons_attended_count = hackathonsCount; - } - - // Array field - payload.dietary_restrictions = data.dietary_restrictions || []; - - // Short answer responses - payload.short_answer_responses = data.short_answer_responses || {}; + return data; +} - // Boolean fields - payload.ack_application = data.ack_application; - payload.ack_mlh_coc = data.ack_mlh_coc; - payload.ack_mlh_privacy = data.ack_mlh_privacy; - payload.opt_in_mlh_emails = data.opt_in_mlh_emails; +/** + * Transform form data into the API payload shape: { responses: {...} } + */ +function transformFormDataToPayload( + data: Record, + schemaFields: ApplicationSchemaField[], +): UpdateApplicationPayload { + const schemaFieldIds = new Set(schemaFields.map((f) => f.id)); + const responses: Record = {}; + + for (const [key, value] of Object.entries(data)) { + if (schemaFieldIds.has(key)) { + responses[key] = value; + } + } - return payload; + return { responses }; } export function ApplicationWizard({ userEmail }: ApplicationWizardProps) { @@ -192,7 +93,6 @@ export function ApplicationWizard({ userEmail }: ApplicationWizardProps) { const [saving, setSaving] = useState(false); const [submitting, setSubmitting] = useState(false); const [application, setApplication] = useState(null); - const [questions, setQuestions] = useState([]); const [apiError, setApiError] = useState(null); const [saveSuccess, setSaveSuccess] = useState(false); const [isUploadingResume, setIsUploadingResume] = useState(false); @@ -201,9 +101,61 @@ export function ApplicationWizard({ userEmail }: ApplicationWizardProps) { const isResumeBusy = isUploadingResume || isDeletingResume; + // Schema from the loaded application + const schemaFields = useMemo( + () => application?.application_schema ?? [], + [application?.application_schema], + ); + + // Derive sections from the schema + const schemaSections = useMemo( + () => deriveSections(schemaFields), + [schemaFields], + ); + + // Group fields by section + const grouped = useMemo( + () => groupFieldsBySection(schemaFields), + [schemaFields], + ); + + // Build step definitions from schema sections (+ review step at end) + const steps = useMemo(() => { + const sectionSteps = schemaSections + .filter((s) => grouped[s.id] && grouped[s.id].length > 0) + .map((s) => ({ id: s.id, title: s.label })); + return [...sectionSteps, { id: "review" as const, title: "Review" }]; + }, [schemaSections, grouped]); + + // Section labels lookup + const sectionLabels = useMemo(() => { + const labels: Record = {}; + for (const s of schemaSections) labels[s.id] = s.label; + return labels; + }, [schemaSections]); + + // Map section → step index for the review "Edit" buttons + const sectionStepMap = useMemo(() => { + const map: Record = {}; + let idx = 0; + for (const section of schemaSections) { + if (grouped[section.id] && grouped[section.id].length > 0) { + map[section.id] = idx; + idx++; + } + } + return map; + }, [schemaSections, grouped]); + + // Build Zod schema dynamically from application_schema + const formSchema = useMemo( + () => buildApplicationSchema(schemaFields), + [schemaFields], + ); + const form = useForm({ - resolver: zodResolver(applicationSchema), - defaultValues, + resolver: zodResolver(formSchema), + defaultValues: buildDefaultValues(schemaFields), mode: "onTouched", }); @@ -219,12 +171,12 @@ export function ApplicationWizard({ userEmail }: ApplicationWizardProps) { ]); if (appRes.status === 200 && appRes.data) { - setApplication(appRes.data); - if (appRes.data.short_answer_questions) { - setQuestions(appRes.data.short_answer_questions); - } - const formData = transformApplicationToFormData(appRes.data); - form.reset({ ...defaultValues, ...formData }); + const app = appRes.data; + setApplication(app); + const schema = app.application_schema ?? []; + const formData = transformApplicationToFormData(app, schema); + const defaults = buildDefaultValues(schema); + form.reset({ ...defaults, ...formData }); } if (enabledRes.status === 200 && enabledRes.data) { @@ -244,25 +196,35 @@ export function ApplicationWizard({ userEmail }: ApplicationWizardProps) { } }, [saveSuccess]); + // Get field IDs for the current step (for partial validation) + const getCurrentStepFieldIds = (): string[] => { + const stepDef = steps[currentStep]; + if (!stepDef || stepDef.id === "review") { + return []; + } + const section = stepDef.id; + return (grouped[section] ?? []).map((f) => f.id); + }; + // Validate current step fields const validateCurrentStep = async (): Promise => { - const fields = STEP_FIELDS[currentStep]; - const result = await form.trigger(fields); + const fieldIds = getCurrentStepFieldIds(); + const result = await form.trigger( + fieldIds as (keyof typeof form.formState.errors)[], + ); return result; }; - // Navigate to next step const goToNextStep = async () => { if (isResumeBusy) return; setApiError(null); const isValid = await validateCurrentStep(); if (isValid) { - setCurrentStep((prev) => Math.min(prev + 1, STEPS.length - 1)); + setCurrentStep((prev) => Math.min(prev + 1, steps.length - 1)); window.scrollTo({ top: 0, behavior: "smooth" }); } }; - // Navigate to previous step const goToPreviousStep = () => { if (isResumeBusy) return; setApiError(null); @@ -270,7 +232,6 @@ export function ApplicationWizard({ userEmail }: ApplicationWizardProps) { window.scrollTo({ top: 0, behavior: "smooth" }); }; - // Jump to specific step (from review page) const goToStep = (stepIndex: number) => { if (isResumeBusy) return; setApiError(null); @@ -278,7 +239,6 @@ export function ApplicationWizard({ userEmail }: ApplicationWizardProps) { window.scrollTo({ top: 0, behavior: "smooth" }); }; - // Save draft const saveDraft = async () => { if (isResumeBusy) return; setSaving(true); @@ -286,8 +246,7 @@ export function ApplicationWizard({ userEmail }: ApplicationWizardProps) { setSaveSuccess(false); const formData = form.getValues(); - const payload = transformFormDataToPayload(formData); - + const payload = transformFormDataToPayload(formData, schemaFields); const res = await updateMyApplication(payload); if (res.status === 200 && res.data) { @@ -300,13 +259,12 @@ export function ApplicationWizard({ userEmail }: ApplicationWizardProps) { setSaving(false); }; - // Submit application const submitApplication = async () => { if (isResumeBusy) return; setSubmitting(true); setApiError(null); - // First validate all fields + // Validate all fields const isValid = await form.trigger(); if (!isValid) { setApiError("Please complete all required fields before submitting"); @@ -314,26 +272,9 @@ export function ApplicationWizard({ userEmail }: ApplicationWizardProps) { return; } - // Validate required short answer questions - const responses = form.getValues("short_answer_responses") || {}; - const missingQuestions: string[] = []; - for (const q of questions) { - if (q.required && (!responses[q.id] || !responses[q.id].trim())) { - missingQuestions.push(q.question); - } - } - if (missingQuestions.length > 0) { - setApiError( - `Please answer the following required questions: ${missingQuestions.join(", ")}`, - ); - setSubmitting(false); - return; - } - // Save current state first const formData = form.getValues(); - const payload = transformFormDataToPayload(formData); - + const payload = transformFormDataToPayload(formData, schemaFields); const saveRes = await updateMyApplication(payload); if (saveRes.status !== 200) { @@ -530,39 +471,58 @@ export function ApplicationWizard({ userEmail }: ApplicationWizardProps) { // Render current step const renderStep = () => { - switch (currentStep) { - case 0: - return ; - case 1: - return ; - case 2: - return ; - case 3: - return ; - case 4: - return ; - case 5: - return ( - - ); - case 6: - return ( - - ); - default: - return null; + const stepDef = steps[currentStep]; + if (!stepDef) return null; + + // Last step is always Review + if (stepDef.id === "review") { + return ( + + ); + } + + const section = stepDef.id; + const fields = grouped[section] ?? []; + + // Links section gets special handling for resume upload + if (section === "links") { + return ( + + ); } + + // Personal section gets email display header + const header = + section === "personal" && userEmail ? ( +
+ + +

+ Email is from your account and cannot be changed here +

+
+ ) : undefined; + + return ( + + ); }; return ( @@ -574,7 +534,7 @@ export function ApplicationWizard({ userEmail }: ApplicationWizardProps) {
@@ -582,7 +542,6 @@ export function ApplicationWizard({ userEmail }: ApplicationWizardProps) { - {/* Error alert */} {apiError && ( @@ -591,7 +550,6 @@ export function ApplicationWizard({ userEmail }: ApplicationWizardProps) { )} - {/* Success message */} {saveSuccess && ( Saved! @@ -612,7 +570,7 @@ export function ApplicationWizard({ userEmail }: ApplicationWizardProps) { isSaving={saving} isSubmitting={submitting} isResumeBusy={isResumeBusy} - isLastStep={currentStep === STEPS.length - 1} + isLastStep={currentStep === steps.length - 1} /> diff --git a/client/web/src/pages/hacker/apply/steps/EventInfoStep.tsx b/client/web/src/pages/hacker/apply/steps/EventInfoStep.tsx deleted file mode 100644 index eec68452..00000000 --- a/client/web/src/pages/hacker/apply/steps/EventInfoStep.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { useFormContext } from "react-hook-form"; - -import { Checkbox } from "@/components/ui/checkbox"; -import { - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Textarea } from "@/components/ui/textarea"; - -import type { ApplicationFormData } from "../validations"; -import { - DIETARY_RESTRICTION_OPTIONS, - SHIRT_SIZE_OPTIONS, -} from "../validations"; - -export function EventInfoStep() { - const form = useFormContext(); - - return ( -
-
-

Event Information

-

- Help us prepare for your attendance -

-
- - ( - - Shirt Size * - - - - )} - /> - - ( - - Allergies / Dietary Restrictions - Select all that apply (optional) -
- {DIETARY_RESTRICTION_OPTIONS.map((option) => ( - { - const value = field.value || []; - return ( - - - { - if (checked) { - field.onChange([...value, option.value]); - } else { - field.onChange( - value.filter((v) => v !== option.value), - ); - } - }} - /> - - - {option.label} - - - ); - }} - /> - ))} -
- -
- )} - /> - - ( - - - Anything else we can do to better accommodate you at our - hackathon? - - -