Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
104 changes: 51 additions & 53 deletions frontend/src/components/SearchableCombobox.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,6 +17,7 @@ export default function SearchableCombobox({
onChange,
placeholder = "Search…",
csvUrl,
csvType = "schools",
staticOptions = [],
className = "",
allowCustomValue = true,
Expand All @@ -26,20 +28,36 @@ export default function SearchableCombobox({
const [csvOptions, setCsvOptions] = useState<string[]>([]);
const focused = useRef(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const containerRef = useRef<HTMLDivElement>(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])],
[csvOptions, staticOptions]
);

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]);
Expand All @@ -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 && (
<div
className="fixed inset-0 z-[9]"
onMouseDown={() => {
setOpen(false);
if (allowCustomValue && inputText.trim()) onChange(inputText.trim());
else setInputText(value);
}}
/>
<div ref={containerRef} className={`relative ${className}`}>
<input
type="text"
value={displayValue}
onChange={(e) => 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 && (
<div className="absolute z-10 top-full mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-52 overflow-y-auto overscroll-contain [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:bg-gray-200 [&::-webkit-scrollbar-thumb]:rounded-full">
{filtered.map((opt) => (
<button
key={opt}
type="button"
onMouseDown={(e) => { e.preventDefault(); select(opt); }}
className={`w-full px-3 py-2 text-left text-sm font-poppins text-gray-800 hover:bg-red7 transition-colors ${
opt === value ? "bg-red7 font-medium text-red6" : ""
}`}
>
{opt}
</button>
))}
</div>
)}
<div className={`relative ${className}`}>
<input
type="text"
value={displayValue}
onChange={(e) => 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 && (
<div className="absolute z-10 top-full mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-52 overflow-y-auto [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:bg-gray-200 [&::-webkit-scrollbar-thumb]:rounded-full">
{filtered.map((opt) => (
<button
key={opt}
type="button"
onMouseDown={(e) => { e.preventDefault(); select(opt); }}
className={`w-full px-3 py-2 text-left text-sm font-poppins text-gray-800 hover:bg-red7 transition-colors ${
opt === value ? "bg-red7 font-medium text-red6" : ""
}`}
>
{opt}
</button>
))}
</div>
)}
</div>
</>
</div>
);
}
17 changes: 12 additions & 5 deletions frontend/src/components/Toast/ToastProvider.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -40,11 +41,17 @@ export default function ToastProvider({ children }: { children: React.ReactNode
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div className="fixed bottom-6 right-6 z-50 flex flex-col gap-3 pointer-events-none">
{toasts.map((t) => (
<Toast key={t.id} message={t.message} type={t.type} onDismiss={() => dismiss(t.id)} />
))}
</div>
{/* 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(
<div className="fixed bottom-6 right-6 z-50 flex flex-col gap-3 pointer-events-none">
{toasts.map((t) => (
<Toast key={t.id} message={t.message} type={t.type} onDismiss={() => dismiss(t.id)} />
))}
</div>,
document.body
)}
</ToastContext.Provider>
);
}
8 changes: 6 additions & 2 deletions frontend/src/components/registration/ApplicationPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@ function buildProfileValues(): Record<string, any> | 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;
Expand Down Expand Up @@ -126,7 +130,7 @@ export default function ApplicationPanel({ isOpen, onClose, onSubmitted }: Appli
)}

{/* Form content */}
<div className="flex-1 overflow-y-auto px-8 py-6">
<div className="flex-1 overflow-y-auto overscroll-contain px-8 py-6">
{isSubmitted ? (
<div className="flex flex-col items-center justify-center h-full gap-4 text-center">
<p className="text-6xl font-jersey10 text-red5">Done!</p>
Expand Down
26 changes: 6 additions & 20 deletions frontend/src/components/registration/DynamicForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,27 +39,13 @@ export default function DynamicForm({ config, onSubmit, isLoading = false, initi
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};

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;
Expand Down
Loading