From 2a6d0ba265e112dcaf803db115e8eabc4b577bb3 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 24 Jul 2026 05:54:21 -0700 Subject: [PATCH 01/22] feat: extract avatar circle from user avatar so that the same style can be reused in the profile page --- frontend/components/ui/AvatarCircle.tsx | 31 +++++++++++++++++++++++++ frontend/components/ui/UserAvatar.tsx | 23 ++++++------------ 2 files changed, 38 insertions(+), 16 deletions(-) create mode 100644 frontend/components/ui/AvatarCircle.tsx diff --git a/frontend/components/ui/AvatarCircle.tsx b/frontend/components/ui/AvatarCircle.tsx new file mode 100644 index 00000000..0b9486ca --- /dev/null +++ b/frontend/components/ui/AvatarCircle.tsx @@ -0,0 +1,31 @@ +interface AvatarCircleUser { + first_name?: string | null; + last_name?: string | null; + email: string; +} + +interface AvatarCircleProps { + user: AvatarCircleUser; + size?: number; +} + +export function AvatarCircle({ user, size = 32 }: AvatarCircleProps) { + const initials = + (`${user.first_name?.[0] ?? ""}${user.last_name?.[0] ?? ""}`).toUpperCase() || + user.email[0].toUpperCase(); + + const fontSize = Math.max(11, Math.round(size * 0.34)); + + return ( +
+ {initials} +
+ ); +} \ No newline at end of file diff --git a/frontend/components/ui/UserAvatar.tsx b/frontend/components/ui/UserAvatar.tsx index 93287f69..c6797f93 100644 --- a/frontend/components/ui/UserAvatar.tsx +++ b/frontend/components/ui/UserAvatar.tsx @@ -3,6 +3,7 @@ import { useState, useRef, useEffect } from "react"; import { useAuth } from "@/lib/useAuth"; import { IconLogout } from "@/components/ui/Icons"; +import { AvatarCircle } from "@/components/ui/AvatarCircle"; export function UserAvatar() { const { user, logout } = useAuth(); @@ -17,23 +18,15 @@ export function UserAvatar() { return () => document.removeEventListener("mousedown", handleClick); }, []); - const initials = user - ? (`${user.first_name?.[0] ?? ""}${user.last_name?.[0] ?? ""}`).toUpperCase() || user.email[0].toUpperCase() - : "?"; + if (!user) return null; return (
{open && ( @@ -45,12 +38,10 @@ export function UserAvatar() { }}>
- {user?.first_name && user?.last_name - ? `${user.first_name} ${user.last_name}` - : user?.email} + {user.first_name && user.last_name ? `${user.first_name} ${user.last_name}` : user.email}
- {user?.email} + {user.email}
- {user?.role} + {user.role}
From ae9dce5eba9c14e12169710fa014adab6990868a Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 24 Jul 2026 05:55:13 -0700 Subject: [PATCH 02/22] feat(profile): defined profile header section that shows the user's avatar, name, and pronouns --- frontend/components/profile/ProfileHeader.tsx | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 frontend/components/profile/ProfileHeader.tsx diff --git a/frontend/components/profile/ProfileHeader.tsx b/frontend/components/profile/ProfileHeader.tsx new file mode 100644 index 00000000..e4086530 --- /dev/null +++ b/frontend/components/profile/ProfileHeader.tsx @@ -0,0 +1,41 @@ +import { AvatarCircle } from "@/components/ui/AvatarCircle"; + +interface ProfileHeaderUser { + first_name?: string | null; + last_name?: string | null; + email: string; + pronouns?: string | null; +} + +interface ProfileHeaderProps { + user: ProfileHeaderUser; +} + +export function ProfileHeader({ user }: ProfileHeaderProps) { + const fullName = + user.first_name && user.last_name + ? `${user.first_name} ${user.last_name}` + : user.email; + + return ( +
+ +
+
+ {fullName} +
+ {user.pronouns && ( +
+ {user.pronouns} +
+ )} +
+
+ ); +} \ No newline at end of file From c99258ab1bd0ddd4cf8e9661ed1c16b68f4d6e4a Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 24 Jul 2026 06:26:20 -0700 Subject: [PATCH 03/22] feat: move auth provider to root layout.tsx so that other dir in root can access current user --- frontend/app/dashboard/layout.tsx | 12 ------------ frontend/app/layout.tsx | 5 ++++- 2 files changed, 4 insertions(+), 13 deletions(-) delete mode 100644 frontend/app/dashboard/layout.tsx diff --git a/frontend/app/dashboard/layout.tsx b/frontend/app/dashboard/layout.tsx deleted file mode 100644 index 4e86f919..00000000 --- a/frontend/app/dashboard/layout.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -import { ReactNode } from "react"; -import { AuthProvider } from "@/lib/useAuth"; - -export default function DashboardRootLayout({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} \ No newline at end of file diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index ac66d2ac..3ed70b06 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next' import { Analytics } from '@vercel/analytics/next'; +import { AuthProvider } from '@/lib/useAuth' import './globals.css' export const metadata: Metadata = { @@ -20,7 +21,9 @@ export default function RootLayout({children,}: {children: React.ReactNode}) { return ( - {children} + + {children} + From 14567c453503d0b15729f0947971cbf343e36d02 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 24 Jul 2026 06:26:49 -0700 Subject: [PATCH 04/22] feat(profile): profile view page skeleton --- frontend/app/profile/[id]/page.tsx | 74 +++++++++++++++++++++ frontend/components/profile/ProfileCard.tsx | 15 +++++ 2 files changed, 89 insertions(+) create mode 100644 frontend/app/profile/[id]/page.tsx create mode 100644 frontend/components/profile/ProfileCard.tsx diff --git a/frontend/app/profile/[id]/page.tsx b/frontend/app/profile/[id]/page.tsx new file mode 100644 index 00000000..d5c8fb60 --- /dev/null +++ b/frontend/app/profile/[id]/page.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { useAuth } from "@/lib/useAuth"; +import { usersApi } from "@/lib/api"; +import { Spinner } from "@/components/ui/Spinner"; +import { ProfileHeader } from "@/components/profile/ProfileHeader"; +import type { UserMeFull } from "@/lib/api"; +import { Topbar } from "@/components/layout/Topbar"; +import { ProfileCard } from "@/components/profile/ProfileCard"; + +export default function ProfilePage() { + const { user: currentUser, loading: authLoading } = useAuth(); + const router = useRouter(); + const params = useParams(); + const profileId = params.id as string; + + const [profile, setProfile] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (authLoading) return; // wait for auth to resolve either way + + if (!currentUser) { + router.replace("/"); // not logged in at all + return; + } + + if (String(currentUser.id) !== String(profileId)) { + router.replace("/dashboard"); + return; + } + + usersApi.meFull() + .then(setProfile) + .catch(() => setError("Failed to load profile.")); + }, [authLoading, currentUser, profileId, router]); + + if (authLoading || (!profile && !error)) { + return ( +
+ +
+ ); + } + + if (error || !profile) { + return ( +
+ {error ?? "Profile not found."} +
+ ); + } + + return ( +
+ +
+ + + + + {/* Education/Career card next */} + {/* Competition experience card next */} + {/* Volunteer experience card next */} + {/* Shirt size / dietary card next */} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/profile/ProfileCard.tsx b/frontend/components/profile/ProfileCard.tsx new file mode 100644 index 00000000..d028a2e6 --- /dev/null +++ b/frontend/components/profile/ProfileCard.tsx @@ -0,0 +1,15 @@ +import { ReactNode } from "react"; + +export function ProfileCard({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} \ No newline at end of file From 127109bcf0a49259489bcd6f4d97661768ae6fa5 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 24 Jul 2026 06:37:52 -0700 Subject: [PATCH 05/22] feat(profile): add profile option in user avatar dropdown; add IconUser; wordmark in topbar is clickable and allows users to return to their dashboard --- frontend/components/layout/Topbar.tsx | 18 +++++++++++------- frontend/components/ui/Icons.tsx | 19 +++++++++++++++++++ frontend/components/ui/UserAvatar.tsx | 19 ++++++++++++++++++- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/frontend/components/layout/Topbar.tsx b/frontend/components/layout/Topbar.tsx index 96b3be02..d1e17d4c 100644 --- a/frontend/components/layout/Topbar.tsx +++ b/frontend/components/layout/Topbar.tsx @@ -7,6 +7,7 @@ import { NewTournamentModal } from "@/components/ui/NewTournamentModal"; import { UserAvatar } from "@/components/ui/UserAvatar"; import { IconChevronDown, IconPlus } from "@/components/ui/Icons"; import { COLLAPSED_W, EXPANDED_W } from "@/components/layout/Sidebar"; +import Link from "next/link"; interface TopbarProps { showWordmark?: boolean; @@ -158,14 +159,17 @@ export function Topbar({ flexShrink: 0, }}> {showWordmark && ( - + NEXUS - + )} {showDropdown && } diff --git a/frontend/components/ui/Icons.tsx b/frontend/components/ui/Icons.tsx index 03d79550..523b475a 100644 --- a/frontend/components/ui/Icons.tsx +++ b/frontend/components/ui/Icons.tsx @@ -186,6 +186,14 @@ export function IconChevronRight({ size = 14, expanded = false, ...props }: Icon ); } +export function IconChevronLeft({ size = 14, ...props }: IconProps) { + return ( + + + + ); +} + export function IconArrowDown({ size = 22, ...props }: IconProps) { return ( @@ -254,4 +262,15 @@ export function IconInfo({ size = 16, ...props }: IconProps) { ); +} + +export function IconUser({ size = 14, ...props }: IconProps) { + return ( + + + + ); } \ No newline at end of file diff --git a/frontend/components/ui/UserAvatar.tsx b/frontend/components/ui/UserAvatar.tsx index c6797f93..047bfbf7 100644 --- a/frontend/components/ui/UserAvatar.tsx +++ b/frontend/components/ui/UserAvatar.tsx @@ -2,8 +2,9 @@ import { useState, useRef, useEffect } from "react"; import { useAuth } from "@/lib/useAuth"; -import { IconLogout } from "@/components/ui/Icons"; +import { IconLogout, IconUser } from "@/components/ui/Icons"; import { AvatarCircle } from "@/components/ui/AvatarCircle"; +import Link from "next/link"; export function UserAvatar() { const { user, logout } = useAuth(); @@ -54,6 +55,22 @@ export function UserAvatar() {
+ setOpen(false)} + style={{ + display: "flex", alignItems: "center", gap: "8px", + width: "100%", padding: "11px 16px", + fontFamily: "var(--font-sans)", fontSize: "13px", fontWeight: 500, + color: "var(--color-text-primary)", textDecoration: "none", + borderBottom: "1px solid var(--color-border)", + }} + onMouseEnter={(e) => { e.currentTarget.style.background = "var(--color-bg)"; }} + onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }} + > + + Profile + ) +} + +// ------------------------------------------------------------------------- +// Shared view-table cell style +// ------------------------------------------------------------------------- +const viewCellStyle: React.CSSProperties = { + padding: "10px 12px", + fontFamily: "var(--font-sans)", fontSize: "13px", fontWeight: 500, + color: "var(--color-text-primary)", + borderBottom: "1px solid var(--color-border)", + verticalAlign: "top", +}; + +const viewHeaderStyle: React.CSSProperties = { + textAlign: "left", padding: "8px 12px", + fontFamily: "var(--font-sans)", fontSize: "11px", fontWeight: 600, + textTransform: "uppercase", letterSpacing: "0.06em", + color: "var(--color-text-tertiary)", + borderBottom: "1px solid var(--color-border)", +}; + +function EmptyExperienceState() { + return ( +

+ No experience added yet +

+ ); +} + +// ------------------------------------------------------------------------- +// Competition experience — view +// ------------------------------------------------------------------------- +interface CompetitionExperienceRow { + id: number; + event: { name: string }; + school: string; + notes: string | null; +} + +export function CompetitionExperienceTableView({ rows }: { rows: CompetitionExperienceRow[] }) { + if (rows.length === 0) return ; + + return ( +
+ + + + + + + + + {["School", "Event", "Notes"].map((h) => ( + + ))} + + + + {rows.map((row, i) => { + const isSameSchoolAsPrev = i > 0 && rows[i - 1].school === row.school; + if (isSameSchoolAsPrev) { + return ( + + + + + ); + } + // count how many consecutive rows share this school, for rowSpan + let span = 1; + while (i + span < rows.length && rows[i + span].school === row.school) span++; + + return ( + + + + + + ); + })} + +
{h}
{row.event.name}{row.notes ?? "—"}
{row.school}{row.event.name}{row.notes ?? "—"}
+
+ ); +} + +// ------------------------------------------------------------------------- +// Volunteer experience — view +// ------------------------------------------------------------------------- +interface VolunteerExperienceRow { + id: number; + tournament_name: string; + role: string; + year: number; + event: { name: string } | null; + notes: { event?: string; other?: string } | null; +} + +function volunteerEventDisplay(row: VolunteerExperienceRow): string { + if (row.event) return row.event.name; + if (row.notes?.event) return row.notes.event; + return "—"; +} + +export function VolunteerExperienceTableView({ rows }: { rows: VolunteerExperienceRow[] }) { + if (rows.length === 0) return ; + + return ( +
+ + + + + + + + + + + {["Year", "Tournament", "Event", "Role", "Notes"].map((h) => ( + + ))} + + + + {rows.map((row) => ( + + + + + + + + ))} + +
{h}
{row.year}{row.tournament_name}{volunteerEventDisplay(row)}{row.role}{row.notes?.other ?? "—"}
+
+ ); } \ No newline at end of file From 02e248a17781eedffd59db967e438439f41c5926 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 24 Jul 2026 08:54:36 -0700 Subject: [PATCH 09/22] fix: auto-create nexus_test database on fresh Postgres volume nexus_test previously only existed because it was created manually with a one-off createdb command, so dropping the postgres_data volume silently broke the test suite. Mount db-init/ to docker-entrypoint-initdb.d so Postgres creates it automatically on first container init, and update the README accordingly. --- README.md | 7 +------ backend/.gitignore | 2 ++ backend/db-init/01-create-test-db.sql | 5 +++++ backend/docker-compose.yaml | 1 + 4 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 backend/db-init/01-create-test-db.sql diff --git a/README.md b/README.md index 5fec5eaf..34d78ac4 100644 --- a/README.md +++ b/README.md @@ -111,13 +111,8 @@ pnpm dev ## Running Tests -Tests run against a dedicated `nexus_test` Postgres database on the same Docker container as dev — this keeps test transaction/rollback semantics identical to prod (SQLite doesn't support the savepoints the test fixtures rely on). Create it once: +Tests run against a dedicated `nexus_test` Postgres database on the same Docker container as dev — this keeps test transaction/rollback semantics identical to prod (SQLite doesn't support the savepoints the test fixtures rely on). `nexus_test` is created automatically by `db-init/01-create-test-db.sql` the first time the container starts on a fresh `postgres_data` volume (Postgres's `docker-entrypoint-initdb.d` convention) — no manual step needed, including after `docker-compose down -v`. -```bash -docker exec backend-db-1 createdb -U nexus nexus_test -``` - -Then run: ```bash cd backend pytest diff --git a/backend/.gitignore b/backend/.gitignore index 53c373ea..545f1f86 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -16,6 +16,8 @@ credentials.json # Database *.db *.sqlite +*.dump +.db-backup/ # Testing .pytest_cache/ diff --git a/backend/db-init/01-create-test-db.sql b/backend/db-init/01-create-test-db.sql new file mode 100644 index 00000000..5ebef330 --- /dev/null +++ b/backend/db-init/01-create-test-db.sql @@ -0,0 +1,5 @@ +-- Runs automatically on first init of a fresh postgres_data volume +-- (docker-entrypoint-initdb.d convention). Creates the dedicated test +-- database so `pytest` works without a manual `createdb` step, even +-- after the volume has been dropped and recreated. +CREATE DATABASE nexus_test; diff --git a/backend/docker-compose.yaml b/backend/docker-compose.yaml index 439bf0c3..51adb629 100644 --- a/backend/docker-compose.yaml +++ b/backend/docker-compose.yaml @@ -10,6 +10,7 @@ services: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data + - ./db-init:/docker-entrypoint-initdb.d volumes: postgres_data: \ No newline at end of file From 2dd663b899a6fb20bf182dce9fca8a73c1c08fc8 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 24 Jul 2026 09:11:05 -0700 Subject: [PATCH 10/22] chore(tests): update event, user experience, and user tests to reflect changes in schema where EventResponse is nested in experience responses and EventCategoryResponse is nested in event responses --- backend/tests/api/test_events.py | 4 ++-- backend/tests/api/test_user_experience.py | 10 +++++----- backend/tests/api/test_users.py | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/tests/api/test_events.py b/backend/tests/api/test_events.py index f34eecb9..5fd1265e 100644 --- a/backend/tests/api/test_events.py +++ b/backend/tests/api/test_events.py @@ -32,7 +32,7 @@ def test_admin_can_create_event(self, client, admin_user, event_category): assert res.status_code == 201 data = res.json() assert data["name"] == "Boomilever" - assert data["category_id"] == event_category.id + assert data["category"]["id"] == event_category.id def test_non_admin_forbidden(self, client, td_user, event_category): login(client, "td@test.com", "tdpass") @@ -67,7 +67,7 @@ def test_update_category_id(self, client, admin_user, event_category_factory, ev login(client, "admin@test.com", "adminpass") res = client.patch(f"/events/{event.id}/", json={"category_id": other_category.id}) assert res.status_code == 200 - assert res.json()["category_id"] == other_category.id + assert res.json()["category"]["id"] == other_category.id def test_missing_event_404(self, client, admin_user): login(client, "admin@test.com", "adminpass") diff --git a/backend/tests/api/test_user_experience.py b/backend/tests/api/test_user_experience.py index 5bb2bb24..7a118c9d 100644 --- a/backend/tests/api/test_user_experience.py +++ b/backend/tests/api/test_user_experience.py @@ -16,7 +16,7 @@ def test_valid_event_and_school(self, client, td_user, event): }) assert res.status_code == 201 data = res.json() - assert data["event_id"] == event.id + assert data["event"]["id"] == event.id assert data["school"] == "MIT" def test_invalid_event_id_404(self, client, td_user): @@ -49,7 +49,7 @@ def test_partial_update(self, client, db, td_user, event): assert res.status_code == 200 data = res.json() assert data["school"] == "Caltech" - assert data["event_id"] == event.id # untouched + assert data["event"]["id"] == event.id # untouched def test_missing_entry_404(self, client, td_user): login(client, "td@test.com", "tdpass") @@ -116,7 +116,7 @@ def test_minimal_fields(self, client, td_user): assert data["tournament_name"] == "Regionals" assert data["year"] == 2025 assert data["role"] == "Event Supervisor" - assert data["event_id"] is None + assert data["event"] is None assert data["notes"] is None def test_with_event_id_no_notes_event(self, client, td_user, event): @@ -128,7 +128,7 @@ def test_with_event_id_no_notes_event(self, client, td_user, event): "event_id": event.id, }) assert res.status_code == 201 - assert res.json()["event_id"] == event.id + assert res.json()["event"]["id"] == event.id def test_with_notes_event_no_event_id(self, client, td_user): login(client, "td@test.com", "tdpass") @@ -140,7 +140,7 @@ def test_with_notes_event_no_event_id(self, client, td_user): }) assert res.status_code == 201 data = res.json() - assert data["event_id"] is None + assert data["event"] is None assert data["notes"]["event"] == "Custom Event Name" def test_event_id_and_notes_event_mutually_exclusive(self, client, td_user, event): diff --git a/backend/tests/api/test_users.py b/backend/tests/api/test_users.py index de6fde68..1a193415 100644 --- a/backend/tests/api/test_users.py +++ b/backend/tests/api/test_users.py @@ -216,7 +216,7 @@ def test_competition_and_volunteer_experience_populated(self, client, td_user, d assert len(data["competition_experience"]) == 1 assert data["competition_experience"][0]["school"] == "MIT" - assert data["competition_experience"][0]["event_id"] == event.id + assert data["competition_experience"][0]["event"]["id"] == event.id assert len(data["volunteer_experience"]) == 1 assert data["volunteer_experience"][0]["tournament_name"] == "Regionals" From 401bf308574a7b9888cfcaf59e84f5aad1cedf92 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 24 Jul 2026 09:48:30 -0700 Subject: [PATCH 11/22] feat(profile): add logistics section and extract experience sections into its own section component --- frontend/app/profile/[id]/page.tsx | 25 +++----- .../{ => sections}/EducationCareerSection.tsx | 0 .../profile/sections/ExperienceSections.tsx | 55 ++++++++++++++++++ .../profile/sections/LogisticsSection.tsx | 58 +++++++++++++++++++ .../profile/{ => sections}/ProfileHeader.tsx | 0 5 files changed, 120 insertions(+), 18 deletions(-) rename frontend/components/profile/{ => sections}/EducationCareerSection.tsx (100%) create mode 100644 frontend/components/profile/sections/ExperienceSections.tsx create mode 100644 frontend/components/profile/sections/LogisticsSection.tsx rename frontend/components/profile/{ => sections}/ProfileHeader.tsx (100%) diff --git a/frontend/app/profile/[id]/page.tsx b/frontend/app/profile/[id]/page.tsx index 8d65c53a..c734a05b 100644 --- a/frontend/app/profile/[id]/page.tsx +++ b/frontend/app/profile/[id]/page.tsx @@ -5,12 +5,13 @@ import { useParams, useRouter } from "next/navigation"; import { useAuth } from "@/lib/useAuth"; import { usersApi } from "@/lib/api"; import { Spinner } from "@/components/ui/Spinner"; -import { ProfileHeader } from "@/components/profile/ProfileHeader"; +import { ProfileHeader } from "@/components/profile/sections/ProfileHeader"; import type { UserMeFull } from "@/lib/api"; import { Topbar } from "@/components/layout/Topbar"; import { ProfileCard } from "@/components/profile/ProfileCard"; -import { EducationCareerSection } from "@/components/profile/EducationCareerSection"; -import { CompetitionExperienceTableView, VolunteerExperienceTableView } from "@/components/profile/ExperienceTables"; +import { EducationCareerSection } from "@/components/profile/sections/EducationCareerSection"; +import { CompetitionExperienceSection, VolunteerExperienceSection } from "@/components/profile/sections/ExperienceSections"; +import { LogisticsSection } from "@/components/profile/sections/LogisticsSection"; export default function ProfilePage() { const { user: currentUser, loading: authLoading } = useAuth(); @@ -66,28 +67,16 @@ export default function ProfilePage() { {profile.has_competition_experience !== false && ( -

- Competition Experience -

- +
)} {profile.has_volunteer_experience !== false && ( -

- Volunteer Experience -

- +
)} - {/* Shirt size / dietary card next */} + ); diff --git a/frontend/components/profile/EducationCareerSection.tsx b/frontend/components/profile/sections/EducationCareerSection.tsx similarity index 100% rename from frontend/components/profile/EducationCareerSection.tsx rename to frontend/components/profile/sections/EducationCareerSection.tsx diff --git a/frontend/components/profile/sections/ExperienceSections.tsx b/frontend/components/profile/sections/ExperienceSections.tsx new file mode 100644 index 00000000..c89a323e --- /dev/null +++ b/frontend/components/profile/sections/ExperienceSections.tsx @@ -0,0 +1,55 @@ +import { CompetitionExperienceTableView, VolunteerExperienceTableView } from "@/components/profile/ExperienceTables"; + +interface CompetitionExperienceSectionUser { + has_competition_experience: boolean | null; + competition_experience: { + id: number; + event: { name: string }; + school: string; + notes: string | null; + }[]; +} + +export function CompetitionExperienceSection({ user }: { user: CompetitionExperienceSectionUser }) { + if (user.has_competition_experience === false) return null; + + return ( +
+

+ Competition Experience +

+ +
+ ); +} + +interface VolunteerExperienceSectionUser { + has_volunteer_experience: boolean | null; + volunteer_experience: { + id: number; + tournament_name: string; + role: string; + year: number; + event: { name: string } | null; + notes: { event?: string; other?: string } | null; + }[]; +} + +export function VolunteerExperienceSection({ user }: { user: VolunteerExperienceSectionUser }) { + if (user.has_volunteer_experience === false) return null; + + return ( +
+

+ Volunteer Experience +

+ +
+ ); +} \ No newline at end of file diff --git a/frontend/components/profile/sections/LogisticsSection.tsx b/frontend/components/profile/sections/LogisticsSection.tsx new file mode 100644 index 00000000..e6a24e54 --- /dev/null +++ b/frontend/components/profile/sections/LogisticsSection.tsx @@ -0,0 +1,58 @@ +import { Badge } from "@/components/ui/Badge"; + +interface LogisticsUser { + shirt_size: "XS" | "S" | "M" | "L" | "XL" | "XXL" | null; + dietary_restriction: string | null; +} + +function Field({ label, value }: { label: string; value: string | null }) { + return ( +
+
+ {label} +
+
+ {value !== null ? value : "No info yet"} +
+
+ ); +} + +export function LogisticsSection({ user }: { user: LogisticsUser }) { + return ( +
+

+ Logistics +

+
+
+
+ Shirt Size +
+ {user.shirt_size !== null ? ( + {user.shirt_size} + ) : ( + + No info yet + + )} +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/profile/ProfileHeader.tsx b/frontend/components/profile/sections/ProfileHeader.tsx similarity index 100% rename from frontend/components/profile/ProfileHeader.tsx rename to frontend/components/profile/sections/ProfileHeader.tsx From ab9c8acf0eaf7df374b8bf4c696587a6f94a3578 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 24 Jul 2026 10:05:32 -0700 Subject: [PATCH 12/22] fix(frontend): wire up Tailwind v4 theme tokens and fix cascade-layer reset --- frontend/app/globals.css | 94 +++++++++++++++++++++---------------- frontend/tailwind.config.ts | 94 ------------------------------------- 2 files changed, 54 insertions(+), 134 deletions(-) delete mode 100644 frontend/tailwind.config.ts diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 25ed9038..b685905a 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,5 +1,48 @@ @import "tailwindcss"; +/* Tailwind v4 reads tokens from here (not tailwind.config.ts, which v4 never + auto-loads). @theme both generates the utility classes below AND publishes + these as real --color-* etc. CSS vars at :root, so existing inline + style={{ color: 'var(--color-success)' }} code keeps working unchanged. */ +@theme { + --color-surface: #FFFFFF; + --color-border: #E2E2DE; + --color-border-strong: #C8C8C2; + + --color-accent: #0A0A0A; + --color-accent-subtle: #F0F0EC; + + --color-danger: #E53E3E; + --color-danger-subtle: #FFF5F5; + + --color-success: #22C55E; + --color-success-subtle: #F0FDF4; + + /* aliased, not literal — var(--color-text-*) is used directly in 30+ + inline styles across the app, so that name has to keep existing */ + --color-primary: var(--color-text-primary); + --color-secondary: var(--color-text-secondary); + --color-tertiary: var(--color-text-tertiary); + --color-inverse: var(--color-text-inverse); + + --font-sans: 'Geist', system-ui, sans-serif; + --font-serif: Georgia, serif; + --font-mono: 'Geist Mono', 'Courier New', monospace; + + --text-2xs: 11px; + --text-2xs--line-height: 16px; + + --radius-sm: 3px; + --radius-md: 6px; + --radius-lg: 10px; + + --shadow-sm: 0 1px 2px 0 rgba(0,0,0,0.05); + --shadow-md: 0 2px 8px 0 rgba(0,0,0,0.08), 0 1px 2px 0 rgba(0,0,0,0.04); + --shadow-lg: 0 8px 24px 0 rgba(0,0,0,0.10), 0 2px 4px 0 rgba(0,0,0,0.06); + + --duration-base: 150ms; +} + /* Geist fonts via NPM-hosted CDN */ @font-face { font-family: 'Geist'; @@ -91,27 +134,17 @@ Design tokens ------------------------------------------------------------------------- */ :root { - /* Colors */ - --color-bg: #F7F7F5; - --color-surface: #FFFFFF; - --color-border: #E2E2DE; - --color-border-strong: #C8C8C2; + /* Colors not consumed as Tailwind utility classes — see @theme above + for the ones that are (accent, border, success, danger, text-*, etc.) */ + --color-bg: #F7F7F5; + --color-accent-hover: #2A2A2A; --color-text-primary: #0A0A0A; --color-text-secondary: #6B6B65; --color-text-tertiary: #9B9B93; --color-text-inverse: #FFFFFF; - --color-accent: #0A0A0A; - --color-accent-hover: #2A2A2A; - --color-accent-subtle: #F0F0EC; - - --color-danger: #E53E3E; - --color-danger-subtle: #FFF5F5; - --color-danger-subtle-hover: #FEEAEA; - - --color-success: #22C55E; - --color-success-subtle: #F0FDF4; + --color-danger-subtle-hover: #FEEAEA; --color-success-subtle-hover: #E6FAF0; --color-warning: #EAB308; @@ -125,15 +158,6 @@ --color-status-assigned: #1D4ED8; --color-status-removed: #9B9B93; - /* Typography - --font-mono → Geist Mono (body text, inputs, data values, code) - --font-sans → Geist Sans (UI labels, buttons, nav, subheadings) - --font-serif → Georgia (h1, h2, hero, wordmarks) - */ - --font-mono: 'Geist Mono', 'Courier New', monospace; - --font-sans: 'Geist', system-ui, sans-serif; - --font-serif: Georgia, serif; - /* Spacing scale */ --space-1: 4px; --space-2: 8px; @@ -146,16 +170,6 @@ --space-12: 48px; --space-16: 64px; - /* Radius */ - --radius-sm: 3px; - --radius-md: 6px; - --radius-lg: 10px; - - /* Shadows */ - --shadow-sm: 0 1px 2px 0 rgba(0,0,0,0.05); - --shadow-md: 0 2px 8px 0 rgba(0,0,0,0.08), 0 1px 2px 0 rgba(0,0,0,0.04); - --shadow-lg: 0 8px 24px 0 rgba(0,0,0,0.10), 0 2px 4px 0 rgba(0,0,0,0.06); - /* Transitions */ --transition-fast: 100ms ease; --transition-base: 150ms ease; @@ -169,10 +183,12 @@ /* ------------------------------------------------------------------------- Base reset & typography ------------------------------------------------------------------------- */ -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; +@layer base { + *, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + } } html { @@ -247,8 +263,6 @@ input, textarea, select { .font-serif { font-family: var(--font-serif); } .font-sans { font-family: var(--font-sans); } .font-mono { font-family: var(--font-mono); } -.text-secondary { color: var(--color-text-secondary); } -.text-tertiary { color: var(--color-text-tertiary); } .divider { height: 1px; diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts deleted file mode 100644 index 77bd8b58..00000000 --- a/frontend/tailwind.config.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { Config } from 'tailwindcss' - -const config: Config = { - content: [ - './pages/**/*.{js,ts,jsx,tsx,mdx}', - './components/**/*.{js,ts,jsx,tsx,mdx}', - './app/**/*.{js,ts,jsx,tsx,mdx}', - './lib/**/*.{js,ts,jsx,tsx,mdx}', - ], - theme: { - extend: { - colors: { - bg: 'var(--color-bg)', - surface: 'var(--color-surface)', - border: 'var(--color-border)', - 'border-strong':'var(--color-border-strong)', - accent: 'var(--color-accent)', - 'accent-hover': 'var(--color-accent-hover)', - 'accent-subtle':'var(--color-accent-subtle)', - danger: 'var(--color-danger)', - 'danger-subtle':'var(--color-danger-subtle)', - success: 'var(--color-success)', - warning: 'var(--color-warning)', - }, - textColor: { - primary: 'var(--color-text-primary)', - secondary: 'var(--color-text-secondary)', - tertiary: 'var(--color-text-tertiary)', - inverse: 'var(--color-text-inverse)', - }, - fontFamily: { - sans: ['Geist', 'system-ui', 'sans-serif'], - serif: ['Georgia', 'serif'], - display: ['Geist', 'system-ui', 'sans-serif'], - mono: ['Geist Mono', 'Courier New', 'monospace'], - }, - fontSize: { - '2xs': ['11px', { lineHeight: '16px' }], - xs: ['12px', { lineHeight: '16px' }], - sm: ['13px', { lineHeight: '20px' }], - base: ['14px', { lineHeight: '20px' }], - md: ['15px', { lineHeight: '22px' }], - lg: ['16px', { lineHeight: '24px' }], - xl: ['18px', { lineHeight: '26px' }], - '2xl': ['22px', { lineHeight: '30px' }], - '3xl': ['28px', { lineHeight: '36px' }], - '4xl': ['36px', { lineHeight: '44px' }], - '5xl': ['48px', { lineHeight: '56px' }], - '6xl': ['64px', { lineHeight: '72px' }], - }, - borderRadius: { - sm: 'var(--radius-sm)', - md: 'var(--radius-md)', - lg: 'var(--radius-lg)', - }, - boxShadow: { - sm: 'var(--shadow-sm)', - md: 'var(--shadow-md)', - lg: 'var(--shadow-lg)', - }, - transitionDuration: { - fast: '100ms', - base: '150ms', - slow: '250ms', - }, - spacing: { - sidebar: 'var(--sidebar-width)', - topbar: 'var(--topbar-height)', - }, - keyframes: { - 'fade-in': { - from: { opacity: '0' }, - to: { opacity: '1' }, - }, - 'fade-up': { - from: { opacity: '0', transform: 'translateY(8px)' }, - to: { opacity: '1', transform: 'translateY(0)' }, - }, - 'slide-in-right': { - from: { opacity: '0', transform: 'translateX(12px)' }, - to: { opacity: '1', transform: 'translateX(0)' }, - }, - }, - animation: { - 'fade-in': 'fade-in 200ms ease forwards', - 'fade-up': 'fade-up 250ms ease forwards', - 'slide-in-right': 'slide-in-right 200ms ease forwards', - }, - }, - }, - plugins: [], -} - -export default config \ No newline at end of file From dc67df760d0fea8571f6f42fa7a26d094ae1a832 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 24 Jul 2026 10:24:35 -0700 Subject: [PATCH 13/22] fix(auth): use useAuth() instead of removed authApi.me() in verify-email page - authApi.me() and the User type were removed; user was only checked for truthiness anyway - pulls user from the root-level AuthProvider (UserMeSlim) instead --- frontend/app/(auth)/verify-email/page.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/app/(auth)/verify-email/page.tsx b/frontend/app/(auth)/verify-email/page.tsx index fdaa3e23..59d9ca82 100644 --- a/frontend/app/(auth)/verify-email/page.tsx +++ b/frontend/app/(auth)/verify-email/page.tsx @@ -3,7 +3,8 @@ import { Button } from "@/components/ui/Button" import { IconCheckCircle, IconXCircle } from "@/components/ui/Icons" import { Spinner } from "@/components/ui/Spinner" -import { ApiError, authApi, User } from "@/lib/api" +import { ApiError, authApi } from "@/lib/api" +import { useAuth } from "@/lib/useAuth" import { useRouter, useSearchParams } from "next/navigation" import { Suspense, useEffect, useState } from "react" @@ -23,7 +24,7 @@ function VerifyEmailContent() { // 3 Error // ──────────────────────────────────────────────────────────────────────── const [state, setState] = useState(1) - const [user, setUser] = useState(null) + const { user } = useAuth() const [sendEmailSuccess, setSendEmailSuccess] = useState(false) const [loading, setLoading] = useState(false) const [errors, setErrors] = useState<{ @@ -37,8 +38,6 @@ function VerifyEmailContent() { const router = useRouter() useEffect(() => { - - authApi.me().then(u => setUser(u)).catch(() => {}) authApi.verifyEmail(token ?? '').then(() => setState(2)).catch(err => { const message = err instanceof ApiError ? err.message : "Something went wrong" From 0d7e710102110dbf789434ce83a6855fa4712c20 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 24 Jul 2026 11:45:25 -0700 Subject: [PATCH 14/22] feat(profile): add floating edit button; update edit icon --- frontend/app/profile/[id]/page.tsx | 32 ++++++++++++++++++++++++++++++ frontend/components/ui/Icons.tsx | 6 ++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/frontend/app/profile/[id]/page.tsx b/frontend/app/profile/[id]/page.tsx index c734a05b..213ff21e 100644 --- a/frontend/app/profile/[id]/page.tsx +++ b/frontend/app/profile/[id]/page.tsx @@ -12,6 +12,35 @@ import { ProfileCard } from "@/components/profile/ProfileCard"; import { EducationCareerSection } from "@/components/profile/sections/EducationCareerSection"; import { CompetitionExperienceSection, VolunteerExperienceSection } from "@/components/profile/sections/ExperienceSections"; import { LogisticsSection } from "@/components/profile/sections/LogisticsSection"; +import Link from "next/link"; +import { IconEdit } from "@/components/ui/Icons"; + + +interface FloatingEditButtonProps { + profileId: string | number; +} + +export function FloatingEditButton({ profileId }: FloatingEditButtonProps) { + return ( + { e.currentTarget.style.transform = "scale(1.06)"; }} + onMouseLeave={(e) => { e.currentTarget.style.transform = "scale(1)"; }} + > + + + ); +} + export default function ProfilePage() { const { user: currentUser, loading: authLoading } = useAuth(); @@ -78,6 +107,9 @@ export default function ProfilePage() { )} + {currentUser?.id === profile.id && ( + + )} ); } \ No newline at end of file diff --git a/frontend/components/ui/Icons.tsx b/frontend/components/ui/Icons.tsx index 523b475a..ef7eb17d 100644 --- a/frontend/components/ui/Icons.tsx +++ b/frontend/components/ui/Icons.tsx @@ -115,11 +115,9 @@ export function IconArrowLeft({ size = 14, ...props }: IconProps) { export function IconEdit({ size = 14, ...props }: IconProps) { return ( - + From 2f55645239a09d16d9610541e3a9aa02a3eed994 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 24 Jul 2026 12:11:49 -0700 Subject: [PATCH 15/22] feat(profile): add email and phone number to profile header --- .../profile/sections/ProfileHeader.tsx | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/frontend/components/profile/sections/ProfileHeader.tsx b/frontend/components/profile/sections/ProfileHeader.tsx index e4086530..f5ed6422 100644 --- a/frontend/components/profile/sections/ProfileHeader.tsx +++ b/frontend/components/profile/sections/ProfileHeader.tsx @@ -1,17 +1,15 @@ import { AvatarCircle } from "@/components/ui/AvatarCircle"; +import { formatPhone } from "@/lib/auth"; interface ProfileHeaderUser { first_name?: string | null; last_name?: string | null; email: string; + phone: string | null; pronouns?: string | null; } -interface ProfileHeaderProps { - user: ProfileHeaderUser; -} - -export function ProfileHeader({ user }: ProfileHeaderProps) { +export function ProfileHeader({ user }: { user: ProfileHeaderUser }) { const fullName = user.first_name && user.last_name ? `${user.first_name} ${user.last_name}` @@ -35,6 +33,22 @@ export function ProfileHeader({ user }: ProfileHeaderProps) { {user.pronouns} )} +
+ + {user.email} + + {user.phone && ( + <> + + + {formatPhone(user.phone)} + + + )} +
); From f2f28a45a0fdeff8ef40ef64b7c130aa67c7244b Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 24 Jul 2026 12:51:13 -0700 Subject: [PATCH 16/22] refactor(sign-up): extract fields for reuse for profile edit --- frontend/app/(auth)/sign-up/page.tsx | 712 ++++++++---------- frontend/components/profile/ProfileFields.tsx | 213 ++++++ .../components/profile/ProfileQuestion.tsx | 44 +- 3 files changed, 566 insertions(+), 403 deletions(-) create mode 100644 frontend/components/profile/ProfileFields.tsx diff --git a/frontend/app/(auth)/sign-up/page.tsx b/frontend/app/(auth)/sign-up/page.tsx index dd23aee4..d847fc4e 100644 --- a/frontend/app/(auth)/sign-up/page.tsx +++ b/frontend/app/(auth)/sign-up/page.tsx @@ -9,17 +9,19 @@ import { useRouter } from "next/navigation" import { checkPassword, formatPhone, validateEmail, validatePassword, validatePhone, validateDateOfBirth } from "@/lib/auth" import { IconArrowLeft, IconCheckCircle, IconXCircle } from "@/components/ui/Icons" import { Input } from "@/components/ui/Input" -import { Combobox } from "@/components/ui/Combobox" import { Button } from "@/components/ui/Button" -import { Select } from "@/components/ui/Select" -import { RadioGroup } from "@/components/ui/RadioGroup" -import { Textarea } from "@/components/ui/Textarea" import { Modal } from "@/components/ui/Modal" +import { ProfileCard } from "@/components/profile/ProfileCard" +import { ProfileQuestion } from "@/components/profile/ProfileQuestion" +import { + PronounsField, StudentStatusField, + UniversityField, MajorField, YearLevelField, GraduationYearField, + EmployerField, YesNoField, ShirtSizeField, DietaryRestrictionField, +} from "@/components/profile/ProfileFields" import { CompetitionExperienceTable, CompetitionExperienceDraft, isCompetitionRowValid, VolunteerExperienceTable, VolunteerExperienceDraft, isVolunteerRowValid } from "@/components/profile/ExperienceTables" -import { ProfileQuestion } from "@/components/profile/ProfileQuestion" import { useFormattedInputChange } from "@/lib/useFormattedInput" @@ -48,28 +50,11 @@ const STATE = { COMPLETE: 14, } as const -const COMMON_PRONOUNS = ["she/her", "he/him", "they/them", "she/they", "he/they", "any pronouns"] - export default function SignUpPage() { - // ── Sign-up step states ────────────────────────────────────────────────── - // 1 Account creation form - // 2 Student status question - // 3 University, major, year level, graduation year (student path) - // 4 Employer (non-student path) - // 5 Competed in Science Olympiad before? (yes / no) - // 6 Competition experience text - // 7 Volunteered for Science Olympiad before? (yes / no) - // 8 Volunteering experience text - // 9 Shirt size - // 10 Dietary restrictions? (yes / no) - // 11 Dietary restriction text - // 12 Complete button activated - // ──────────────────────────────────────────────────────────────────────── const [state, setState] = useState(STATE.ACCOUNT) const [user, setUser] = useState(null) const [loading, setLoading] = useState(false) - const [name, setName] = useState<{ first: string, last: string }>({ first: '', last: '' }) const [email, setEmail] = useState('') const [phone, setPhone] = useState('') @@ -256,9 +241,6 @@ export default function SignUpPage() { return } - // TODO: decide partial-save recovery behavior — if updateMe() succeeds but a - // competition/volunteer experience POST fails mid-loop, flags are saved but - // rows may be incomplete. For now: surface the error, don't redirect. try { await usersApi.updateMe(cleaned) @@ -436,414 +418,366 @@ export default function SignUpPage() { )} {state >= STATE.DATE_OF_BIRTH && ( -
+
{showVerifyModal && } + +
+

NEXUS

+
-
-

NEXUS

-
- -
-

Complete Your Profile

-
- -
- setState(STATE.PRONOUNS)} - onNext={() => { - const err = validateDateOfBirth(profileData.date_of_birth ?? '') - if (err) { - setErrors(er => ({ ...er, date_of_birth: err })) - return - } - setState(STATE.PRONOUNS) - }} - isActive={state === STATE.DATE_OF_BIRTH} - > { - setProfileData(d => ({ ...d, date_of_birth: e.target.value })) - setErrors(er => ({ ...er, date_of_birth: undefined })) - }} - error={errors.date_of_birth} - fullWidth - /> - - - {state >= STATE.PRONOUNS && ( +
+

Complete Your Profile

+
+ setState(STATE.STUDENT_STATUS)} + question="What is your date of birth?" + onSkip={() => setState(STATE.PRONOUNS)} onNext={() => { - if (!profileData.pronouns?.trim()) { - setErrors(er => ({ ...er, pronouns: "Cannot be empty." })) + const err = validateDateOfBirth(profileData.date_of_birth ?? '') + if (err) { + setErrors(er => ({ ...er, date_of_birth: err })) return } - setState(STATE.STUDENT_STATUS) + setState(STATE.PRONOUNS) }} - isActive={state === STATE.PRONOUNS} - > - p} - getLabel={p => p} - value={profileData.pronouns ?? ''} - allowFreeText - placeholder="Type your pronouns..." - error={errors.pronouns} - onChange={(text) => { - setProfileData(d => ({ ...d, pronouns: text })) - setErrors(er => ({ ...er, pronouns: undefined })) - }} + isActive={state === STATE.DATE_OF_BIRTH} + > { + setProfileData(d => ({ ...d, date_of_birth: e.target.value })) + setErrors(er => ({ ...er, date_of_birth: undefined })) + }} + error={errors.date_of_birth} + fullWidth /> - )} - - { state >= STATE.STUDENT_STATUS && ( - setState(STATE.STUDENT_STATUS + 3)} - isActive={state === STATE.STUDENT_STATUS} - >= STATE.UNIVERSITY && (profileData.student_status === "Undergraduate" || profileData.student_status === "Graduate") && ( +
+ + { - setProfileData(d => ({...d, university: e.target.value})) + error={errors.university} + onChange={(v) => { + setProfileData(d => ({...d, university: v})) setErrors(er => ({...er, university: undefined})) }} - error={errors.university} - /> - - - + + + { - setProfileData(d => ({...d, major: e.target.value})) + error={errors.major} + onChange={(v) => { + setProfileData(d => ({...d, major: v})) setErrors(er => ({...er, major: undefined})) }} - error={errors.major} - /> - - - 0 ? setErrors(er => ({...er, ...ers})) : setState(STATE.UNIVERSITY + 2) + }} + isActive={state === STATE.UNIVERSITY} + > + { - const raw = e.target.value.replace(/\D/g, '').slice(0, 4) - setErrors(er => ({ ...er, graduation_year: raw.length > 0 && raw.length < 4 ? "Must be a valid year." : undefined })) - setProfileData(d => ({ ...d, graduation_year: raw ? Number(raw) : undefined })) - }} - error={errors.graduation_year} - /> - -
- )} + onValidate={(err) => setErrors(er => ({ ...er, graduation_year: err }))} + onChange={(v) => setProfileData(d => ({ ...d, graduation_year: v }))} + /> +
+ + )} - {state >= STATE.EMPLOYER && profileData.student_status === "Non-Student" && ( - setState(STATE.COMPETED_BEFORE)} - onNext={() => { - !profileData.employer ? setErrors(er => ({...er, employer: "Cannot be empty."})) : setState(STATE.COMPETED_BEFORE) - }} - isActive={state === STATE.EMPLOYER} - >= STATE.EMPLOYER && profileData.student_status === "Non-Student" && ( + setState(STATE.COMPETED_BEFORE)} + onNext={() => { + !profileData.employer ? setErrors(er => ({...er, employer: "Cannot be empty."})) : setState(STATE.COMPETED_BEFORE) + }} + isActive={state === STATE.EMPLOYER} + > + { - setProfileData(d => ({...d, employer: e.target.value})) + error={errors.employer} + onChange={(v) => { + setProfileData(d => ({...d, employer: v})) setErrors(er => ({...er, employer: undefined})) }} - error={errors.employer} - /> - - )} + /> + + )} - {state >= STATE.COMPETED_BEFORE && ( - { - setState(STATE.COMPETED_BEFORE + 2) - }} - isActive={state === STATE.COMPETED_BEFORE} - > - { - const val = v === "yes" - setProfileData(d => ({ ...d, has_competition_experience: val })) - if (state >= STATE.COMPETED_BEFORE + 2) return - setState(val ? STATE.COMPETITION_EXP : STATE.COMPETED_BEFORE + 2) + {state >= STATE.COMPETED_BEFORE && ( + { + setState(STATE.COMPETED_BEFORE + 2) }} - options={[ - { value: "yes", label: "Yes" }, - { value: "no", label: "No" }, - ]} - showCircle={false} - solid - /> - - )} + isActive={state === STATE.COMPETED_BEFORE} + > + { + setProfileData(d => ({ ...d, has_competition_experience: val })) + if (state >= STATE.COMPETED_BEFORE + 2) return + setState(val ? STATE.COMPETITION_EXP : STATE.COMPETED_BEFORE + 2) + }} + /> + + )} - {state >= STATE.COMPETITION_EXP && profileData.has_competition_experience && ( - { - setProfileData(d => ({ ...d, has_competition_experience: undefined })) - setCompetitionRows([]) - setState(STATE.VOLUNTEERED_BEFORE) - }} - onNext={() => { - if (competitionRows.length === 0 || !competitionRows.every(isCompetitionRowValid)) { - setErrors(er => ({ ...er, competition_exp: "Each entry needs a school and a matched event." })) - return - } - setState(STATE.VOLUNTEERED_BEFORE) - }} - isActive={state === STATE.COMPETITION_EXP} - > - - {errors.competition_exp && ( -

- {errors.competition_exp} -

- )} -
- )} + {state >= STATE.COMPETITION_EXP && profileData.has_competition_experience && ( + { + setProfileData(d => ({ ...d, has_competition_experience: undefined })) + setCompetitionRows([]) + setState(STATE.VOLUNTEERED_BEFORE) + }} + onNext={() => { + if (competitionRows.length === 0 || !competitionRows.every(isCompetitionRowValid)) { + setErrors(er => ({ ...er, competition_exp: "Each entry needs a school and a matched event." })) + return + } + setState(STATE.VOLUNTEERED_BEFORE) + }} + isActive={state === STATE.COMPETITION_EXP} + > + + {errors.competition_exp && ( +

+ {errors.competition_exp} +

+ )} +
+ )} - {state >= STATE.VOLUNTEERED_BEFORE && ( - { - setState(STATE.SHIRT_SIZE) - }} - isActive={state === STATE.VOLUNTEERED_BEFORE} - > - { - const val = v === "yes" - setProfileData(d => ({ ...d, has_volunteer_experience: val })) - if (state >= STATE.SHIRT_SIZE) return - setState(val ? STATE.VOLUNTEERING_EXP : STATE.SHIRT_SIZE) + {state >= STATE.VOLUNTEERED_BEFORE && ( + { + setState(STATE.SHIRT_SIZE) }} - options={[ - { value: "yes", label: "Yes" }, - { value: "no", label: "No" }, - ]} - showCircle={false} - solid - /> - - )} + isActive={state === STATE.VOLUNTEERED_BEFORE} + > + { + setProfileData(d => ({ ...d, has_volunteer_experience: val })) + if (state >= STATE.SHIRT_SIZE) return + setState(val ? STATE.VOLUNTEERING_EXP : STATE.SHIRT_SIZE) + }} + /> + + )} - {state >= STATE.VOLUNTEERING_EXP && profileData.has_volunteer_experience && ( - { - setProfileData(d => ({ ...d, has_volunteer_experience: undefined })) - setVolunteerRows([]) - setState(STATE.SHIRT_SIZE) - }} - onNext={() => { - if (volunteerRows.length === 0 || !volunteerRows.every(isVolunteerRowValid)) { - setErrors(er => ({ ...er, volunteering_exp: "Each entry needs a tournament name, a 4-digit year, and a role." })) - return - } - setState(STATE.SHIRT_SIZE) - }} - isActive={state === STATE.VOLUNTEERING_EXP} - > - - {errors.volunteering_exp && ( -

- {errors.volunteering_exp} -

- )} -
- )} + {state >= STATE.VOLUNTEERING_EXP && profileData.has_volunteer_experience && ( + { + setProfileData(d => ({ ...d, has_volunteer_experience: undefined })) + setVolunteerRows([]) + setState(STATE.SHIRT_SIZE) + }} + onNext={() => { + if (volunteerRows.length === 0 || !volunteerRows.every(isVolunteerRowValid)) { + setErrors(er => ({ ...er, volunteering_exp: "Each entry needs a tournament name, a 4-digit year, and a role." })) + return + } + setState(STATE.SHIRT_SIZE) + }} + isActive={state === STATE.VOLUNTEERING_EXP} + > + + {errors.volunteering_exp && ( +

+ {errors.volunteering_exp} +

+ )} +
+ )} - {state >= STATE.SHIRT_SIZE && ( - { - setProfileData(d => ({ ...d, shirt_size: undefined })) - setState(STATE.DIETARY_RESTRICTIONS) - }} - isActive={state === STATE.SHIRT_SIZE} - > - { - setProfileData(d => ({ ...d, shirt_size: v as SHIRT_SIZE })) - if (state === STATE.SHIRT_SIZE) setState(STATE.DIETARY_RESTRICTIONS) + {state >= STATE.SHIRT_SIZE && ( + { + setProfileData(d => ({ ...d, shirt_size: undefined })) + setState(STATE.DIETARY_RESTRICTIONS) }} - options={["XS", "S", "M", "L", "XL", "XXL"].map(size => ({ value: size, label: size }))} - showCircle={false} - solid - /> - - )} + isActive={state === STATE.SHIRT_SIZE} + > + { + setProfileData(d => ({ ...d, shirt_size: v })) + if (state === STATE.SHIRT_SIZE) setState(STATE.DIETARY_RESTRICTIONS) + }} + /> + + )} - {state >= STATE.DIETARY_RESTRICTIONS && ( - { - setState(STATE.COMPLETE) - }} - isActive={state === STATE.DIETARY_RESTRICTIONS} - > - { - const val = v === "yes" - setHasDietary(val) - if (state >= STATE.COMPLETE) return - setState(val ? STATE.DIETARY_TEXT : STATE.COMPLETE) + {state >= STATE.DIETARY_RESTRICTIONS && ( + { + setState(STATE.COMPLETE) }} - options={[ - { value: "yes", label: "Yes" }, - { value: "no", label: "No" }, - ]} - showCircle={false} - solid - /> - - )} + isActive={state === STATE.DIETARY_RESTRICTIONS} + > + { + setHasDietary(val) + if (state >= STATE.COMPLETE) return + setState(val ? STATE.DIETARY_TEXT : STATE.COMPLETE) + }} + /> + + )} - {state >= STATE.DIETARY_TEXT && hasDietary && ( - { - setHasDietary(null) - setState(STATE.COMPLETE) - }} - onNext={() => { - !profileData.dietary_restriction ? setErrors(er => ({...er, dietary_restriction: "Cannot be empty."})) - : setState(STATE.COMPLETE) - }} - isActive={state === STATE.DIETARY_TEXT} - >