diff --git a/frontend/package.json b/frontend/package.json index 98fc0d4..8e459e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-icons": "^5.5.0", - "react-router-dom": "^7.13.0" + "react-router-dom": "^7.13.0", + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.21.0", diff --git a/frontend/src/components/SearchableCombobox.tsx b/frontend/src/components/SearchableCombobox.tsx index 9097439..8d87a7d 100644 --- a/frontend/src/components/SearchableCombobox.tsx +++ b/frontend/src/components/SearchableCombobox.tsx @@ -1,11 +1,12 @@ import { useState, useRef, useEffect, useMemo } from "react"; -import { loadCsvOptions } from "@/lib/loadCsvOptions"; +import { loadCsvOptions, type CsvType } from "@/lib/loadCsvOptions"; interface SearchableComboboxProps { value: string; onChange: (value: string) => void; placeholder?: string; csvUrl?: string; + csvType?: CsvType; staticOptions?: string[]; className?: string; allowCustomValue?: boolean; @@ -16,6 +17,7 @@ export default function SearchableCombobox({ onChange, placeholder = "Search…", csvUrl, + csvType = "schools", staticOptions = [], className = "", allowCustomValue = true, @@ -26,12 +28,28 @@ export default function SearchableCombobox({ const [csvOptions, setCsvOptions] = useState([]); const focused = useRef(false); const debounceRef = useRef | null>(null); + const containerRef = useRef(null); - // Load CSV options once (cached by loadCsvOptions) useEffect(() => { if (!csvUrl) return; - loadCsvOptions(csvUrl).then(setCsvOptions).catch(() => {}); - }, [csvUrl]); + loadCsvOptions(csvUrl, csvType).then(setCsvOptions).catch(() => {}); + }, [csvUrl, csvType]); + + // Close on outside pointer-down. Using a document listener (instead of a + // full-viewport backdrop) so wheel events reach the surrounding scroll + // container instead of falling through to the page body. + useEffect(() => { + if (!open) return; + const handlePointerDown = (e: MouseEvent) => { + if (!containerRef.current?.contains(e.target as Node)) { + setOpen(false); + if (allowCustomValue && inputText.trim()) onChange(inputText.trim()); + else setInputText(value); + } + }; + document.addEventListener("mousedown", handlePointerDown); + return () => document.removeEventListener("mousedown", handlePointerDown); + }, [open, allowCustomValue, inputText, value, onChange]); const allOptions = useMemo( () => [...new Set([...csvOptions, ...staticOptions])], @@ -39,7 +57,7 @@ export default function SearchableCombobox({ ); const filtered = useMemo(() => { - if (!query.trim()) return allOptions.slice(0, 100); // cap initial list + if (!query.trim()) return allOptions.slice(0, 100); const q = query.toLowerCase(); return allOptions.filter((o) => o.toLowerCase().includes(q)).slice(0, 60); }, [allOptions, query]); @@ -60,64 +78,44 @@ export default function SearchableCombobox({ const handleFocus = () => { focused.current = true; - // Sync display text from current committed value on focus setInputText(value); setOpen(true); }; const handleBlur = () => { focused.current = false; - if (allowCustomValue && inputText.trim()) { - onChange(inputText.trim()); - } else if (!allowCustomValue) { - setInputText(value); - } - setOpen(false); }; - // When not focused, always show the committed external value - const displayValue = focused.current ? inputText : value; + const displayValue = focused.current || open ? inputText : value; return ( - <> - {open && ( -
{ - setOpen(false); - if (allowCustomValue && inputText.trim()) onChange(inputText.trim()); - else setInputText(value); - }} - /> +
+ handleInputChange(e.target.value)} + onFocus={handleFocus} + onBlur={handleBlur} + placeholder={placeholder} + autoComplete="off" + className="w-full bg-white border border-gray-200 rounded-lg px-3 py-2.5 text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:border-red5 transition-colors font-poppins" + /> + {open && filtered.length > 0 && ( +
+ {filtered.map((opt) => ( + + ))} +
)} -
- handleInputChange(e.target.value)} - onFocus={handleFocus} - onBlur={handleBlur} - placeholder={placeholder} - autoComplete="off" - className="w-full bg-white border border-gray-200 rounded-lg px-3 py-2.5 text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:border-red5 transition-colors font-poppins" - /> - {open && filtered.length > 0 && ( -
- {filtered.map((opt) => ( - - ))} -
- )} -
- +
); } diff --git a/frontend/src/components/Toast/ToastProvider.tsx b/frontend/src/components/Toast/ToastProvider.tsx index 9db9913..22bca4d 100644 --- a/frontend/src/components/Toast/ToastProvider.tsx +++ b/frontend/src/components/Toast/ToastProvider.tsx @@ -1,4 +1,5 @@ import { createContext, useCallback, useContext, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import Toast from "./Toast"; export type ToastType = "success" | "error" | "info"; @@ -40,11 +41,17 @@ export default function ToastProvider({ children }: { children: React.ReactNode return ( {children} -
- {toasts.map((t) => ( - dismiss(t.id)} /> - ))} -
+ {/* Render into document.body so the toast escapes any flex/overflow + ancestor (body itself is `display: flex; overflow-x: hidden`, which + was clipping the stack). */} + {createPortal( +
+ {toasts.map((t) => ( + dismiss(t.id)} /> + ))} +
, + document.body + )}
); } diff --git a/frontend/src/components/registration/ApplicationPanel.tsx b/frontend/src/components/registration/ApplicationPanel.tsx index 91acc03..96aae42 100644 --- a/frontend/src/components/registration/ApplicationPanel.tsx +++ b/frontend/src/components/registration/ApplicationPanel.tsx @@ -20,8 +20,12 @@ function buildProfileValues(): Record | null { last_name: saved.lastName || "", email: saved.email || "", phone_number: saved.phoneNumber || "", + age: saved.age || "", + school: saved.university || "", major: saved.major || "", - dietary_restrictions: saved.dietaryRestrictions ? [saved.dietaryRestrictions] : [], + gender: saved.gender || "", + shirt_size: saved.shirtSize || "", + dietary_restrictions: saved.dietaryRestrictions || [], }; } catch { return null; @@ -126,7 +130,7 @@ export default function ApplicationPanel({ isOpen, onClose, onSubmitted }: Appli )} {/* Form content */} -
+
{isSubmitted ? (

Done!

diff --git a/frontend/src/components/registration/DynamicForm.tsx b/frontend/src/components/registration/DynamicForm.tsx index ab36012..167b91a 100644 --- a/frontend/src/components/registration/DynamicForm.tsx +++ b/frontend/src/components/registration/DynamicForm.tsx @@ -39,27 +39,13 @@ export default function DynamicForm({ config, onSubmit, isLoading = false, initi const validateForm = (): boolean => { const newErrors: Record = {}; - config.fields.forEach((field) => { - if (field.required) { - const value = formData[field.id]; - - if (field.type === "checkboxGroup" || field.type === "multipleChoiceGrid" || field.type === "preferenceGrid") { - if (!value || (Array.isArray(value) && value.length === 0) || (typeof value === "object" && Object.keys(value).length === 0)) { - newErrors[field.id] = `${field.label} is required`; - } - // For grid fields, check all rows are filled - if (field.type === "multipleChoiceGrid" || field.type === "preferenceGrid") { - const rows = field.rows; - const missingRows = rows.filter(row => !value || !value[row]); - if (missingRows.length > 0) { - newErrors[field.id] = `Please select an option for all rows`; - } - } - } else if (!value || (typeof value === "string" && value.trim() === "")) { - newErrors[field.id] = `${field.label} is required`; - } + const result = config.schema.safeParse(formData); + if (!result.success) { + for (const issue of result.error.issues) { + const field = String(issue.path[0] ?? "form"); + if (!newErrors[field]) newErrors[field] = issue.message; } - }); + } setErrors(newErrors); return Object.keys(newErrors).length === 0; diff --git a/frontend/src/components/registration/form-fields/Dropdown.tsx b/frontend/src/components/registration/form-fields/Dropdown.tsx index 5afe4ee..5fc88e3 100644 --- a/frontend/src/components/registration/form-fields/Dropdown.tsx +++ b/frontend/src/components/registration/form-fields/Dropdown.tsx @@ -1,6 +1,5 @@ -import { useState, useRef, useEffect, useMemo } from "react"; import type { DropdownFormField } from "@/lib/formConfig"; -import { loadCsvOptions } from "@/lib/loadCsvOptions"; +import SearchableCombobox from "@/components/SearchableCombobox"; interface DropdownProps { field: DropdownFormField; @@ -9,104 +8,21 @@ interface DropdownProps { error?: string; } -export default function Dropdown({ field, value, onChange, error }: DropdownProps) { - const [isOpen, setIsOpen] = useState(false); - const [inputValue, setInputValue] = useState(""); - const [searchQuery, setSearchQuery] = useState(""); - const [loadedOptions, setLoadedOptions] = useState(null); - const [isLoadingOptions, setIsLoadingOptions] = useState(false); - const [optionsError, setOptionsError] = useState(null); - const debounceRef = useRef | null>(null); - - const closeDropdown = () => { - setIsOpen(false); - setInputValue(""); - setSearchQuery(""); - }; - - const handleSearchChange = (value: string) => { - setInputValue(value); - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => setSearchQuery(value), 250); - }; - - useEffect(() => { - if (!field.optionsSource || field.optionsSource.type !== "csv") { - setLoadedOptions(null); - setIsLoadingOptions(false); - setOptionsError(null); - return; - } - - let isActive = true; - setIsLoadingOptions(true); - setOptionsError(null); - - loadCsvOptions(field.optionsSource.url) - .then((options) => { - if (isActive) { - setLoadedOptions(options); - } - }) - .catch(() => { - if (isActive) { - setLoadedOptions([]); - setOptionsError("Unable to load options"); - } - }) - .finally(() => { - if (isActive) { - setIsLoadingOptions(false); - } - }); - - return () => { - isActive = false; - }; - }, [field.optionsSource]); - - const availableOptions = useMemo(() => { - if (loadedOptions === null) { - return field.options; - } - - return [...new Set([...loadedOptions, ...field.options])]; - }, [field.options, loadedOptions]); - - const filteredOptions = useMemo(() => { - if (!field.searchable) { - return availableOptions; - } - - const normalizedQuery = searchQuery.trim().toLowerCase(); - if (!normalizedQuery) { - return availableOptions; - } +const selectCls = + "w-full bg-white border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-red5 transition-colors appearance-none cursor-pointer pr-8 font-poppins"; - return availableOptions.filter((option) => option.toLowerCase().includes(normalizedQuery)); - }, [availableOptions, field.searchable, searchQuery]); +const Chevron = () => ( + + + +); - const customValue = useMemo(() => { - if (!field.allowCustomValue || !field.searchable) { - return null; - } - - const candidate = searchQuery.trim(); - if (!candidate) { - return null; - } - - const hasExactMatch = availableOptions.some( - (option) => option.toLowerCase() === candidate.toLowerCase() - ); - if (hasExactMatch) { - return null; - } - - return candidate; - }, [availableOptions, field.allowCustomValue, field.searchable, searchQuery]); - - // Click-outside handled by a fixed backdrop rendered when open (no useEffect needed) +export default function Dropdown({ field, value, onChange, error }: DropdownProps) { + const csvSource = field.optionsSource?.type === "csv" ? field.optionsSource : undefined; return (
@@ -121,75 +37,35 @@ export default function Dropdown({ field, value, onChange, error }: DropdownProp {field.description && (

{field.description}

)} - {/* Backdrop to close dropdown on outside click — no useEffect needed */} - {isOpen && ( -
- )} -
- - {isOpen && ( -
- {field.searchable && ( -
- handleSearchChange(e.target.value)} - placeholder="Type to search…" - className="w-full border border-[#d6d3cf] rounded-md px-2 py-1.5 text-sm text-black placeholder:text-[#9b9b9b] focus:outline-none" - /> -
- )} - {isLoadingOptions && ( -

Loading options…

- )} - - {!isLoadingOptions && optionsError && ( -

{optionsError}

- )} - - {!isLoadingOptions && !optionsError && !customValue && filteredOptions.length === 0 && ( -

No matches found

- )} + {field.searchable ? ( + + ) : ( +
+ + +
+ )} - {!isLoadingOptions && !optionsError && ( -
- {customValue && ( - - )} - {filteredOptions.map((option) => ( - - ))} -
- )} -
- )} -
{error && (

{error}

)} diff --git a/frontend/src/lib/formConfig.ts b/frontend/src/lib/formConfig.ts index 0583bcc..ff9c916 100644 --- a/frontend/src/lib/formConfig.ts +++ b/frontend/src/lib/formConfig.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + // Form field types export type FormFieldType = | "text" @@ -31,6 +33,7 @@ export interface EmailFormField extends BaseFormField { export interface CsvOptionsSource { type: "csv"; url: string; + csvType: "schools" | "countries"; } export interface DropdownFormField extends BaseFormField { @@ -90,12 +93,191 @@ export type FormField = export interface FormConfig { title: string; description?: string; + schema: z.ZodType; fields: FormField[]; } +// --------------------------------------------------------------------------- +// Shared option constants (used by both the profile page and the application +// form). Kept here so the two surfaces can't drift out of sync. +// --------------------------------------------------------------------------- + +export const SCHOOLS_CSV_URL = + "https://raw.githubusercontent.com/MLH/mlh-policies/main/schools.csv"; +export const COUNTRIES_CSV_URL = + "https://raw.githubusercontent.com/lukes/ISO-3166-Countries-with-Regional-Codes/refs/heads/master/all/all.csv"; + +export const AGE_RANGES = [ + "Under 18", + "18–20", + "21–24", + "25–30", + "31+", +] as const; + +export const MAJOR_SUGGESTIONS = [ + "Africana Studies", + "Agricultural Sciences", + "American Studies", + "Animal Science", + "Anthropology", + "Applied Economics and Management", + "Archaeology", + "Architecture", + "Asian Studies", + "Astronomy", + "Atmospheric Science", + "Biological Engineering", + "Biological Sciences", + "Biology and Society", + "Biomedical Engineering", + "Biometry and Statistics", + "Chemical Engineering", + "Chemistry", + "China and Asia-Pacific Studies", + "Civil Engineering", + "Classics", + "Cognitive Science", + "College Scholar", + "Communication", + "Comparative Literature", + "Computer Science", + "Design and Environmental Analysis", + "Earth and Atmospheric Sciences", + "Economics", + "Electrical and Computer Engineering", + "Engineering Physics", + "English", + "Entomology", + "Environment and Sustainability", + "Environmental Engineering", + "Fashion Design and Management", + "Feminist, Gender, and Sexuality Studies", + "Fiber Science", + "Fine Arts", + "Food Science", + "French", + "German Studies", + "Global and Public Health Sciences", + "Global Development", + "Government", + "Health Care Policy", + "History", + "History of Art", + "Hotel Administration", + "Human Biology, Health, and Society", + "Human Development", + "Independent Major", + "Industrial and Labor Relations", + "Information Science", + "Information Science, Systems, and Technology", + "Italian", + "Jewish Studies", + "Landscape Architecture", + "Linguistics", + "Materials Science and Engineering", + "Mathematics", + "Mechanical Engineering", + "Music", + "Near Eastern Studies", + "Nutritional Sciences", + "Operations Research and Engineering", + "Performing and Media Arts", + "Philosophy", + "Physics", + "Plant Sciences", + "Psychology", + "Public Policy", + "Religious Studies", + "Science and Technology Studies", + "Sociology", + "Spanish", + "Statistical Science", + "Undecided", + "Urban and Regional Studies", + "Viticulture and Enology", +] as const; + +export const GENDER_OPTIONS = [ + "Male", + "Female", + "Non-binary", + "Prefer not to say", + "Other", +] as const; + +export const DIETARY_OPTIONS = [ + "None", + "Vegetarian", + "Vegan", + "Gluten-Free", + "Halal", + "Kosher", + "Nut Allergy", + "Other", +] as const; + +export const SHIRT_SIZES = ["XS", "S", "M", "L", "XL", "2XL"] as const; + +export const LEVEL_OF_STUDY_OPTIONS = [ + "Secondary / High School", + "Freshman", + "Sophomore", + "Junior", + "Senior", + "I'm not currently a student", +] as const; + +// --------------------------------------------------------------------------- +// Form configurations +// --------------------------------------------------------------------------- + +export const teamMatchingSchema = z.object({ + email: z.email("Enter a valid email address"), + full_name: z.string().trim().min(1, "Full name is required"), + technical_skills: z.object({ + Frontend: z.enum(["Beginner", "Intermediate", "Advanced"], { + message: "Please select an option for all rows", + }), + Backend: z.enum(["Beginner", "Intermediate", "Advanced"], { + message: "Please select an option for all rows", + }), + Design: z.enum(["Beginner", "Intermediate", "Advanced"], { + message: "Please select an option for all rows", + }), + Hardware: z.enum(["Beginner", "Intermediate", "Advanced"], { + message: "Please select an option for all rows", + }), + }), + preferred_role: z.object({ + Frontend: z.enum(["1", "2", "3", "4", "5"], { + message: "Please select an option for all rows", + }), + Backend: z.enum(["1", "2", "3", "4", "5"], { + message: "Please select an option for all rows", + }), + Design: z.enum(["1", "2", "3", "4", "5"], { + message: "Please select an option for all rows", + }), + Hardware: z.enum(["1", "2", "3", "4", "5"], { + message: "Please select an option for all rows", + }), + Any: z.enum(["1", "2", "3", "4", "5"], { + message: "Please select an option for all rows", + }), + }), + backend_skills: z.string().trim().min(1, "Backend skills are required"), + frontend_skills: z.string().trim().min(1, "Frontend skills are required"), + design_skills: z.string().trim().min(1, "Design skills are required"), + first_time_hacker: z.enum(["Yes", "No"], { + message: "Please select an option", + }), +}); + export const teamMatchingFormConfig: FormConfig = { title: "BigRed//Hacks Fall 2025 Team Matching", description: "Help us match you with the perfect team!", + schema: teamMatchingSchema, fields: [ { id: "email", @@ -162,30 +344,86 @@ export const teamMatchingFormConfig: FormConfig = { ], }; +// Schema used by the standalone Profile page. Field names use camelCase to +// match profile's FormData shape (which is what gets persisted to +// localStorage as "brh_profile"). +export const profileSchema = z.object({ + firstName: z.string().trim().min(1, "First name is required"), + lastName: z.string().trim().min(1, "Last name is required"), + email: z.email("Enter a valid email address"), + phoneNumber: z + .string() + .trim() + .regex(/^\(\d{3}\) \d{3}-\d{4}$/, "Use format (XXX) XXX-XXXX"), + age: z.enum(AGE_RANGES, { message: "Please select an age range" }), + graduationYear: z + .string() + .trim() + .regex(/^\d{4}$/, "Enter a 4-digit year") + .refine((v) => { + const n = parseInt(v, 10); + return n >= 2020 && n <= 2035; + }, "Year must be between 2020 and 2035"), + university: z.string().trim().min(1, "School is required"), + major: z.string().trim().optional().or(z.literal("")), + gender: z.enum(GENDER_OPTIONS).optional().or(z.literal("")), + dietaryRestrictions: z.array(z.enum(DIETARY_OPTIONS)).optional(), + shirtSize: z.enum(SHIRT_SIZES).optional().or(z.literal("")), +}); + +export const hackathonRegistrationApplicationSchema = z.object({ + first_name: z.string().trim().min(1, "First name is required"), + last_name: z.string().trim().min(1, "Last name is required"), + age: z.enum(AGE_RANGES, { message: "Please select an age range" }), + phone_number: z + .string() + .trim() + .regex(/^\(\d{3}\) \d{3}-\d{4}$/, "Use format (XXX) XXX-XXXX"), + email: z.email("Enter a valid email address"), + linkedin: z.url("Enter a valid LinkedIn URL").optional().or(z.literal("")), + school: z.string().trim().min(1, "School is required"), + country: z.string().trim().min(1, "Country is required"), + level_of_study: z.enum(LEVEL_OF_STUDY_OPTIONS, { + message: "Please select a level of study", + }), + major: z.string().trim().optional().or(z.literal("")), + gender: z.enum(GENDER_OPTIONS, { message: "Please select an option" }), + dietary_restrictions: z.array(z.enum(DIETARY_OPTIONS)).optional(), + shirt_size: z.enum(SHIRT_SIZES, { message: "Please select a shirt size" }), + mlh_code_of_conduct: z.literal(true, { + message: "You must agree to the MLH Code of Conduct", + }), + mlh_data_sharing_consent: z.literal(true, { + message: "You must agree to the MLH data sharing terms", + }), + mlh_emails_opt_in: z.boolean().optional(), +}); + export const hackathonRegistrationFormConfig: FormConfig = { title: "BigRed//Hacks Fall 2025 Registration", description: "Complete your registration for the hackathon", + schema: hackathonRegistrationApplicationSchema, fields: [ { id: "first_name", label: "First Name", type: "text", required: true, - placeholder: "Jane", + placeholder: "First Name", }, { id: "last_name", label: "Last Name", type: "text", required: true, - placeholder: "Smith", + placeholder: "Last Name", }, { id: "age", - label: "Age", - type: "text", + label: "Age Range", + type: "dropdown", required: true, - placeholder: "18", + options: [...AGE_RANGES], }, { id: "phone_number", @@ -199,7 +437,7 @@ export const hackathonRegistrationFormConfig: FormConfig = { label: "Email Address", type: "email", required: true, - placeholder: "your.email@cornell.edu", + placeholder: "bigredhacks@gmail.com", }, { id: "linkedin", @@ -218,7 +456,8 @@ export const hackathonRegistrationFormConfig: FormConfig = { options: [], optionsSource: { type: "csv", - url: "https://raw.githubusercontent.com/MLH/mlh-policies/main/schools.csv", + url: SCHOOLS_CSV_URL, + csvType: "schools", }, }, { @@ -230,7 +469,8 @@ export const hackathonRegistrationFormConfig: FormConfig = { options: [], optionsSource: { type: "csv", - url: "/countries.csv", + url: COUNTRIES_CSV_URL, + csvType: "countries", }, }, { @@ -238,40 +478,37 @@ export const hackathonRegistrationFormConfig: FormConfig = { label: "Level of Study", type: "dropdown", required: true, - options: [ - "Less than Secondary / High School", - "Secondary / High School", - "Undergraduate University (2 year - community college or similar)", - "Undergraduate University (3+ year)", - "Graduate University (Masters, Professional, Doctoral, etc)", - "Code School / Bootcamp", - "Other Vocational / Trade Program or Apprenticeship", - "Post Doctorate", - "Other", - "I'm not currently a student", - "Prefer not to answer", - ], + options: [...LEVEL_OF_STUDY_OPTIONS], }, { id: "major", label: "Major", - type: "text", + type: "dropdown", required: false, - placeholder: "Computer Science", + searchable: true, + allowCustomValue: true, + options: [...MAJOR_SUGGESTIONS], + }, + { + id: "gender", + label: "Gender", + type: "radio", + required: true, + options: [...GENDER_OPTIONS], }, { id: "dietary_restrictions", label: "Dietary Restrictions", type: "checkboxGroup", required: false, - options: [ - "Vegetarian", - "Vegan", - "Celiac Disease", - "Allergies", - "Kosher", - "Halal", - ], + options: [...DIETARY_OPTIONS], + }, + { + id: "shirt_size", + label: "Shirt Size", + type: "radio", + required: true, + options: [...SHIRT_SIZES], }, { id: "mlh_code_of_conduct", @@ -301,9 +538,61 @@ export const hackathonRegistrationFormConfig: FormConfig = { ], }; +export const workshopFeedbackSchema = z.object({ + email: z.email("Enter a valid email address").optional().or(z.literal("")), + workshop_name: z.string().trim().min(1, "Please select a workshop"), + rating: z.enum(["Excellent", "Good", "Average", "Poor", "Very Poor"], { + message: "Please select a rating", + }), + content_quality: z.object({ + "Content Quality": z.enum(["Poor", "Fair", "Good", "Excellent"], { + message: "Please select an option for all rows", + }), + "Instructor Knowledge": z.enum(["Poor", "Fair", "Good", "Excellent"], { + message: "Please select an option for all rows", + }), + "Pace of Workshop": z.enum(["Poor", "Fair", "Good", "Excellent"], { + message: "Please select an option for all rows", + }), + "Hands-on Activities": z.enum(["Poor", "Fair", "Good", "Excellent"], { + message: "Please select an option for all rows", + }), + }), + topics_interest: z + .object({ + "Advanced React Patterns": z.enum(["1", "2", "3", "4", "5"], { + message: "Please select an option for all rows", + }), + "DevOps and CI/CD": z.enum(["1", "2", "3", "4", "5"], { + message: "Please select an option for all rows", + }), + "Cybersecurity Basics": z.enum(["1", "2", "3", "4", "5"], { + message: "Please select an option for all rows", + }), + "Cloud Computing (AWS/Azure)": z.enum(["1", "2", "3", "4", "5"], { + message: "Please select an option for all rows", + }), + "Data Visualization": z.enum(["1", "2", "3", "4", "5"], { + message: "Please select an option for all rows", + }), + }) + .optional(), + improvements: z.string().optional(), + would_recommend: z.enum( + ["Definitely", "Probably", "Not Sure", "Probably Not", "Definitely Not"], + { message: "Please select an option" }, + ), + contact_preferences: z + .array(z.enum(["Email", "Discord", "Slack", "SMS", "Don't contact me"])) + .optional(), + materials: z.unknown().optional(), + newsletter: z.boolean().optional(), +}); + export const workshopFeedbackFormConfig: FormConfig = { title: "Workshop Feedback Form", description: "Help us improve our workshops by providing your feedback", + schema: workshopFeedbackSchema, fields: [ { id: "email", diff --git a/frontend/src/lib/loadCsvOptions.ts b/frontend/src/lib/loadCsvOptions.ts index 0270b08..912c1b4 100644 --- a/frontend/src/lib/loadCsvOptions.ts +++ b/frontend/src/lib/loadCsvOptions.ts @@ -1,3 +1,5 @@ +export type CsvType = "schools" | "countries"; + const optionsCache = new Map>(); function parseLine(line: string): string { @@ -8,7 +10,7 @@ function parseLine(line: string): string { return trimmed; } -function parseCsvOptions(csvText: string): string[] { +function parseSchoolsCsv(csvText: string): string[] { const options = csvText .split(/\r?\n/) .map(parseLine) @@ -20,26 +22,46 @@ function parseCsvOptions(csvText: string): string[] { return [...new Set(options)]; } -async function fetchCsvOptions(url: string): Promise { +function parseCountriesCsv(csvText: string): string[] { + // ISO-3166 CSV: first column is "name", rest are codes/regions + const options = csvText + .split(/\r?\n/) + .map((line) => { + const trimmed = line.trim().replace(/^\uFEFF/, ""); + // Extract first column, handling quoted values + if (trimmed.startsWith("\"")) { + const end = trimmed.indexOf("\"", 1); + return trimmed.slice(1, end).trim(); + } + return trimmed.split(",")[0].trim(); + }) + .filter(Boolean) + .filter((value) => value.toLowerCase() !== "name"); + + return [...new Set(options)]; +} + +async function fetchCsvOptions(url: string, csvType: CsvType): Promise { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to load CSV options from ${url}`); } const csvText = await response.text(); - return parseCsvOptions(csvText); + return csvType === "countries" ? parseCountriesCsv(csvText) : parseSchoolsCsv(csvText); } -export function loadCsvOptions(url: string): Promise { - const existingPromise = optionsCache.get(url); +export function loadCsvOptions(url: string, csvType: CsvType = "schools"): Promise { + const cacheKey = `${csvType}:${url}`; + const existingPromise = optionsCache.get(cacheKey); if (existingPromise) { return existingPromise; } - const optionsPromise = fetchCsvOptions(url).catch((error) => { - optionsCache.delete(url); + const optionsPromise = fetchCsvOptions(url, csvType).catch((error) => { + optionsCache.delete(cacheKey); throw error; }); - optionsCache.set(url, optionsPromise); + optionsCache.set(cacheKey, optionsPromise); return optionsPromise; } diff --git a/frontend/src/pages/ApplyPage.tsx b/frontend/src/pages/ApplyPage.tsx index 3c409b4..35eaa46 100644 --- a/frontend/src/pages/ApplyPage.tsx +++ b/frontend/src/pages/ApplyPage.tsx @@ -14,10 +14,12 @@ function buildInitialValues(): Record { last_name: saved.lastName || "", email: saved.email || "", phone_number: saved.phoneNumber || "", + age: saved.age || "", + school: saved.university || "", major: saved.major || "", - dietary_restrictions: saved.dietaryRestrictions - ? [saved.dietaryRestrictions] - : [], + gender: saved.gender || "", + shirt_size: saved.shirtSize || "", + dietary_restrictions: saved.dietaryRestrictions || [], }; } catch { return {}; diff --git a/frontend/src/pages/registration/dashboard.tsx b/frontend/src/pages/registration/dashboard.tsx index e033ff7..e0bb849 100644 --- a/frontend/src/pages/registration/dashboard.tsx +++ b/frontend/src/pages/registration/dashboard.tsx @@ -51,7 +51,9 @@ function profileCompletion(): { pct: number; missing: string[] } { dietaryRestrictions: "Dietary Restrictions", shirtSize: "Shirt Size", }; - const missing = Object.entries(fields).filter(([k]) => !saved[k]).map(([, v]) => v); + const isEmpty = (v: unknown) => + v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0); + const missing = Object.entries(fields).filter(([k]) => isEmpty(saved[k])).map(([, v]) => v); const pct = Math.round(((Object.keys(fields).length - missing.length) / Object.keys(fields).length) * 100); return { pct, missing }; } catch { diff --git a/frontend/src/pages/registration/profile.tsx b/frontend/src/pages/registration/profile.tsx index e0b091a..4037f4f 100644 --- a/frontend/src/pages/registration/profile.tsx +++ b/frontend/src/pages/registration/profile.tsx @@ -3,37 +3,15 @@ import RegistrationLayout from "../../components/layouts/RegistrationLayout"; import { useToast } from "../../components/Toast/ToastProvider"; import { supabase } from "../../config/supabase"; import SearchableCombobox from "../../components/SearchableCombobox"; - -const SCHOOLS_CSV_URL = "https://raw.githubusercontent.com/MLH/mlh-policies/main/schools.csv"; - -const AGE_RANGES = ["Under 18", "18–20", "21–24", "25–30", "31+"]; - -const MAJOR_SUGGESTIONS = [ - "Computer Science", "Computer Engineering", "Electrical Engineering", - "Mechanical Engineering", "Civil Engineering", "Chemical Engineering", - "Biomedical Engineering", "Information Science", "Software Engineering", - "Data Science", "Artificial Intelligence", "Cybersecurity", - "Mathematics", "Statistics", "Physics", "Chemistry", "Biology", - "Neuroscience", "Economics", "Business Administration", "Finance", - "Marketing", "Psychology", "Cognitive Science", "Linguistics", - "Political Science", "Sociology", "Philosophy", "Design", - "Architecture", "Art", "Music", "Undecided", -]; - -const GENDER_OPTIONS = ["Male", "Female", "Non-binary", "Prefer not to say", "Other"]; - -const DIETARY_OPTIONS = [ - "None", - "Vegetarian", - "Vegan", - "Gluten-Free", - "Halal", - "Kosher", - "Nut Allergy", - "Other", -]; - -const SHIRT_SIZES = ["XS", "S", "M", "L", "XL", "2XL"]; +import { + AGE_RANGES, + DIETARY_OPTIONS, + GENDER_OPTIONS, + MAJOR_SUGGESTIONS, + profileSchema, + SCHOOLS_CSV_URL, + SHIRT_SIZES, +} from "../../lib/formConfig"; interface FormData { firstName: string; @@ -45,7 +23,7 @@ interface FormData { university: string; major: string; gender: string; - dietaryRestrictions: string; + dietaryRestrictions: string[]; shirtSize: string; } @@ -67,9 +45,9 @@ const Chevron = () => ( ); const Field = ({ - label, required, children, + label, required, error, children, }: { - label: string; required?: boolean; children: React.ReactNode; + label: string; required?: boolean; error?: string; children: React.ReactNode; }) => (
{children} + {error &&

{error}

}
); @@ -93,18 +72,20 @@ const Profile = () => { const [form, setForm] = useState({ firstName: "", lastName: "", email: "", phoneNumber: "", age: "", graduationYear: "", university: "", major: "", - gender: "", dietaryRestrictions: "", shirtSize: "", + gender: "", dietaryRestrictions: [], shirtSize: "", }); const [emailVerified, setEmailVerified] = useState(false); const [saving, setSaving] = useState(false); + const [errors, setErrors] = useState>>({}); // Load from localStorage + Supabase auth email on mount useEffect(() => { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { try { - setForm((prev) => ({ ...prev, ...JSON.parse(stored) })); + const parsed = JSON.parse(stored); + setForm((prev) => ({ ...prev, ...parsed })); } catch { /* ignore */ } } supabase.auth.getUser().then(({ data }) => { @@ -114,8 +95,28 @@ const Profile = () => { }); }, []); + const clearFieldError = (field: keyof FormData) => { + setErrors((prev) => { + if (!prev[field]) return prev; + const next = { ...prev }; + delete next[field]; + return next; + }); + }; + const handleChange = (field: keyof FormData, value: string) => { setForm((prev) => ({ ...prev, [field]: value })); + clearFieldError(field); + }; + + const toggleDietary = (option: string) => { + setForm((prev) => ({ + ...prev, + dietaryRestrictions: prev.dietaryRestrictions.includes(option) + ? prev.dietaryRestrictions.filter((d) => d !== option) + : [...prev.dietaryRestrictions, option], + })); + clearFieldError("dietaryRestrictions"); }; const handlePhoneChange = (value: string) => { @@ -128,11 +129,21 @@ const Profile = () => { }; const handleSave = async () => { + const result = profileSchema.safeParse(form); + if (!result.success) { + const newErrors: Partial> = {}; + for (const issue of result.error.issues) { + const field = issue.path[0] as keyof FormData; + if (field && !newErrors[field]) newErrors[field] = issue.message; + } + setErrors(newErrors); + showToast("Please fix the highlighted fields.", "error"); + return; + } + setErrors({}); setSaving(true); try { - // Persist extended fields to localStorage for profile sync localStorage.setItem(STORAGE_KEY, JSON.stringify(form)); - // Update Supabase profile with full_name await fetch("/api/profile", { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -162,28 +173,28 @@ const Profile = () => { {/* Personal Info */} - + handleChange("firstName", e.target.value)} className={inputCls} /> - + handleChange("lastName", e.target.value)} className={inputCls} /> - +
handleChange("email", e.target.value)} className={`${inputCls} flex-1`} @@ -199,7 +210,7 @@ const Profile = () => {
- + { {/* Academic Info */} - +
{ /> - + handleChange("university", v)} @@ -248,7 +259,7 @@ const Profile = () => { handleChange("major", v)} - staticOptions={MAJOR_SUGGESTIONS} + staticOptions={[...MAJOR_SUGGESTIONS]} placeholder="e.g. Computer Science" allowCustomValue /> @@ -271,38 +282,70 @@ const Profile = () => {
- -
- - -
-
+
+ +
+ {DIETARY_OPTIONS.map((option) => { + const checked = form.dietaryRestrictions.includes(option); + return ( +
+ + +
+ ); + })} +
+
+
- {SHIRT_SIZES.map((size) => ( - - ))} + {SHIRT_SIZES.map((size) => { + const selected = form.shirtSize === size; + return ( +
+ + +
+ ); + })}
diff --git a/package-lock.json b/package-lock.json index e9d3416..dc601da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-icons": "^5.5.0", - "react-router-dom": "^7.13.0" + "react-router-dom": "^7.13.0", + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.21.0", @@ -120,6 +121,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1816,6 +1818,7 @@ "version": "22.19.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1843,6 +1846,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "dev": true, + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1937,6 +1941,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -2156,6 +2161,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2388,6 +2394,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2828,6 +2835,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4212,6 +4220,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4303,6 +4312,7 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4311,6 +4321,7 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4746,6 +4757,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "peer": true, "engines": { "node": ">=12" }, @@ -4871,6 +4883,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4985,6 +4998,7 @@ "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -5074,6 +5088,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "peer": true, "engines": { "node": ">=12" },