diff --git a/docs/database-diagram.md b/docs/database-diagram.md new file mode 100644 index 0000000..3cb5a80 --- /dev/null +++ b/docs/database-diagram.md @@ -0,0 +1,149 @@ +# Database diagram (DBML) + +Paste into [dbdiagram.io](https://dbdiagram.io). + +```dbml +// Public Postgres schema for the rush application portal. +// auth.users is Supabase Auth. applications.user_id stores that UUID with no FK. + +Table admins { + email varchar [primary key, note: 'Extra web allowlist; e-board is also admin via assignments later'] + created_at timestamptz [not null, default: `now()`] +} + +Table brothers { + id uuid [pk] + first_name varchar + last_name varchar + umich_email varchar [note: 'nullable; unique when set; /portal login'] + contact_email varchar + linkedin_url varchar + photo_filename varchar [note: 'Dummy filename until S3'] + status varchar [not null, default: 'active', note: 'active | alumni'] + pledge_class varchar + created_at timestamptz [not null, default: `now()`] + updated_at timestamptz [not null, default: `now()`] +} + +Table rush_cycles { + id uuid [primary key, default: `gen_random_uuid()`] + name varchar [not null] + opens_at timestamptz [not null] + closes_at timestamptz [not null] + intro_markdown text [note: 'Welcome copy on /apply'] + closed_markdown text [note: 'Shown on /apply after close'] + public_blurb text [note: 'Copy on /rush'] + interest_form_url varchar + youtube_url varchar + calendar_url varchar + hear_about_options varchar[] [not null, default: `{}`] + is_active boolean [not null, default: false, note: 'At most one true (partial unique index)'] + created_at timestamptz [not null, default: `now()`] + updated_at timestamptz [not null, default: `now()`] + + Note: 'One live cycle on the site. Owns /rush, schedule, and application questions.' +} + +Table rush_events { + id uuid [primary key, default: `gen_random_uuid()`] + cycle_id uuid [not null] + title varchar [not null] + datetime varchar [not null] + location varchar [not null] + description text + button_label varchar + button_url varchar + order_index integer [not null, default: 0] + created_at timestamptz [not null, default: `now()`] + updated_at timestamptz [not null, default: `now()`] + + indexes { + (cycle_id, order_index) [name: 'rush_events_cycle_id_idx'] + } +} + +Table cycle_questions { + id uuid [primary key, default: `gen_random_uuid()`] + cycle_id uuid [not null] + prompt text [not null] + help_text text + max_words integer [not null] + sort_order integer [not null, default: 0] + required boolean [not null, default: true] + + indexes { + (cycle_id, sort_order) [name: 'cycle_questions_cycle_id_idx'] + } +} + +Table applications { + id uuid [primary key, default: `gen_random_uuid()`] + cycle_id uuid [not null] + user_id uuid [not null, note: 'auth.users.id; no FK'] + email varchar [not null] + status varchar [not null, default: 'draft', note: 'draft | submitted'] + submitted_at timestamptz + first_name varchar + last_name varchar + preferred_name varchar + pronouns varchar + phone varchar + majors varchar + minors varchar + graduation_year integer + gpa numeric + semesters_remaining integer + other_professional_fraternity boolean + campus_activities text + hear_about varchar[] + hear_about_other varchar + anything_else text + rush_feedback text + created_at timestamptz [not null, default: `now()`] + updated_at timestamptz [not null, default: `now()`] + + indexes { + (cycle_id, user_id) [unique, name: 'applications_cycle_user_unique'] + user_id [name: 'applications_user_id_idx'] + (cycle_id, status) [name: 'applications_cycle_status_idx'] + } +} + +Table application_answers { + id uuid [primary key, default: `gen_random_uuid()`] + application_id uuid [not null] + question_id uuid [not null] + body text + + indexes { + (application_id, question_id) [unique, name: 'application_answers_app_question_unique'] + } +} + +Table application_files { + id uuid [primary key, default: `gen_random_uuid()`] + application_id uuid [not null] + slot varchar [not null, note: 'photo | transcript | resume | resume_anonymized | life_app_screenshot'] + s3_key varchar [not null] + mime_type varchar + size_bytes integer + original_filename varchar + created_at timestamptz [not null, default: `now()`] + + indexes { + (application_id, slot) [unique, name: 'application_files_app_slot_unique'] + } +} + +Table auth_users [note: 'Supabase Auth; not a public table we migrate'] { + id uuid [primary key] + email varchar +} + +Ref: rush_events.cycle_id > rush_cycles.id [delete: cascade] +Ref: cycle_questions.cycle_id > rush_cycles.id [delete: cascade] +Ref: applications.cycle_id > rush_cycles.id [delete: restrict] +Ref: application_answers.application_id > applications.id [delete: cascade] +Ref: application_answers.question_id > cycle_questions.id [delete: restrict] +Ref: application_files.application_id > applications.id [delete: cascade] +``` diff --git a/package-lock.json b/package-lock.json index 13a2f61..82f6c11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,8 @@ "react-scroll": "^1.9.3", "react-typed": "^2.0.12", "server-only": "^0.0.1", - "zod": "^4.4.3" + "zod": "^4.4.3", + "zustand": "^5.0.15" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -2899,7 +2900,7 @@ "version": "19.1.11", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz", "integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -4280,7 +4281,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -9619,6 +9620,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 928ef63..5740fec 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "react-scroll": "^1.9.3", "react-typed": "^2.0.12", "server-only": "^0.0.1", - "zod": "^4.4.3" + "zod": "^4.4.3", + "zustand": "^5.0.15" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/public/images/beep-bop.svg b/public/images/beep-bop.svg new file mode 100644 index 0000000..0e419da --- /dev/null +++ b/public/images/beep-bop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/seed-local-admins.mjs b/scripts/seed-local-admins.mjs index cfafb1a..fbb2cc4 100644 --- a/scripts/seed-local-admins.mjs +++ b/scripts/seed-local-admins.mjs @@ -23,13 +23,18 @@ const sql = postgres(process.env.DATABASE_URL, { prepare: false }) try { for (const email of emails) { + await sql` + insert into public.brothers (umich_email, status) + values (${email}, 'active') + on conflict (umich_email) where umich_email is not null do nothing + ` await sql` insert into public.admins (email) values (${email}) on conflict (email) do nothing ` } - console.log(`Seeded ${emails.length} local admin email(s).`) + console.log(`Seeded ${emails.length} local admin email(s) as admins and brothers.`) } finally { await sql.end() } diff --git a/src/app/admin/actions.ts b/src/app/admin/actions.ts index 3479c0b..7bb8e57 100644 --- a/src/app/admin/actions.ts +++ b/src/app/admin/actions.ts @@ -10,12 +10,27 @@ import { } from '@/lib/rush-event-schema' import { createRushEvent, - getRushEvents, + getRushEventsForCycle, patchRushEvent, removeRushEvent, reorderRushEvents, toClientRushEvent, } from '@/lib/rush-events' +import { parseAdminEmail } from '@/lib/admin-schema' +import { parseBrotherId, parseBrotherWrite, type BrotherFormInput } from '@/lib/brother-schema' +import { addAdminEmail, removeAdminEmail } from '@/lib/admins' +import { addBrotherRow, removeBrotherRow, updateBrotherRow } from '@/lib/brothers' +import { parseCycleId, parseRushCycleApplication, parseRushCycleCreate, parseRushCycleMeta } from '@/lib/rush-cycle-schema' +import { + activateRushCycle, + closeRushCycleNow, + createRushCycle, + getCycleBundle, + listRushCycles, + openRushCycleNow, + saveRushCycle, + saveRushCycleMeta, +} from '@/lib/rush-cycles' export type RushEventInput = RushEventWrite @@ -27,31 +42,164 @@ async function requireAdmin() { return { user, error: null } } -export async function listRushEvents() { +function revalidateRush() { + revalidatePath('/admin') + revalidatePath('/rush') + revalidatePath('/apply') +} + +export async function addBrother(input: BrotherFormInput) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsed = parseBrotherWrite(input) + if (parsed.error || !parsed.data) { + return { data: null, error: parsed.error } + } + + try { + const result = await addBrotherRow(parsed.data) + if (result.error) return { data: null, error: result.error } + revalidatePath('/admin') + revalidatePath('/portal') + return { data: result.brother, error: null } + } catch (error) { + console.error('Error adding brother:', error) + return { data: null, error: 'Failed to add brother' } + } +} + +export async function updateBrother(id: string, input: BrotherFormInput) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsedId = parseBrotherId(id) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + + const parsed = parseBrotherWrite(input) + if (parsed.error || !parsed.data) { + return { data: null, error: parsed.error } + } + + try { + const result = await updateBrotherRow(parsedId.data, parsed.data) + if (result.error) return { data: null, error: result.error } + revalidatePath('/admin') + revalidatePath('/portal') + return { data: result.brother, error: null } + } catch (error) { + console.error('Error updating brother:', error) + return { data: null, error: 'Failed to update brother' } + } +} + +export async function removeBrother(id: string) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsed = parseBrotherId(id) + if (parsed.error || !parsed.data) { + return { data: null, error: parsed.error } + } + + try { + const result = await removeBrotherRow(parsed.data, auth.user.email ?? '') + if (result.error) return { data: null, error: result.error } + revalidatePath('/admin') + revalidatePath('/portal') + return { data: null, error: null } + } catch (error) { + console.error('Error removing brother:', error) + return { data: null, error: 'Failed to remove brother' } + } +} + +export async function addAdmin(email: string) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsed = parseAdminEmail(email) + if (parsed.error || !parsed.data) { + return { data: null, error: parsed.error } + } + + try { + const result = await addAdminEmail(parsed.data) + if (result.error) return { data: null, error: result.error } + revalidatePath('/admin') + return { data: result.admin, error: null } + } catch (error) { + console.error('Error adding admin:', error) + return { data: null, error: 'Failed to add admin' } + } +} + +export async function removeAdmin(email: string) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsed = parseAdminEmail(email) + if (parsed.error || !parsed.data) { + return { data: null, error: parsed.error } + } + + try { + const result = await removeAdminEmail(parsed.data, auth.user.email ?? '') + if (result.error) return { data: null, error: result.error } + revalidatePath('/admin') + return { data: null, error: null } + } catch (error) { + console.error('Error removing admin:', error) + return { data: null, error: 'Failed to remove admin' } + } +} + +export async function listRushEvents(cycleId: string) { const auth = await requireAdmin() if (auth.error) { return { data: null, error: auth.error } } - const events = await getRushEvents() + const parsedId = parseCycleId(cycleId) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + + const events = await getRushEventsForCycle(parsedId.data) return { data: events.map(toClientRushEvent), error: null } } -export async function insertRushEvent(event: RushEventInput) { +export async function insertRushEvent(cycleId: string, event: RushEventInput) { const auth = await requireAdmin() if (auth.error) { return { data: null, error: auth.error } } + const parsedId = parseCycleId(cycleId) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + const parsed = parseRushEvent(event) if (parsed.error || !parsed.data) { return { data: null, error: parsed.error } } try { - const created = await createRushEvent(parsed.data) - revalidatePath('/rush') - revalidatePath('/admin') + const created = await createRushEvent(parsedId.data, parsed.data) + revalidateRush() return { data: toClientRushEvent(created), error: null } } catch (error) { console.error('Error inserting rush event:', error) @@ -72,8 +220,7 @@ export async function deleteRushEvent(eventId: string) { try { await removeRushEvent(parsedId.data) - revalidatePath('/rush') - revalidatePath('/admin') + revalidateRush() return { data: null, error: null } } catch (error) { console.error('Error deleting rush event:', error) @@ -103,8 +250,7 @@ export async function updateRushEvent(eventId: string, event: RushEventInput) { if (!updated) { return { data: null, error: 'Event not found' } } - revalidatePath('/rush') - revalidatePath('/admin') + revalidateRush() return { data: toClientRushEvent(updated), error: null } } catch (error) { console.error('Error updating rush event:', error) @@ -127,11 +273,185 @@ export async function updateRushEventOrder( try { await reorderRushEvents(parsed.data) - revalidatePath('/rush') - revalidatePath('/admin') + revalidateRush() return { data: null, error: null } } catch (error) { console.error('Error updating order_index:', error) return { data: null, error: 'Failed to update event order' } } } + +export async function getRushCycleBundle(cycleId: string) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsedId = parseCycleId(cycleId) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + + const data = await getCycleBundle(parsedId.data) + if (!data) return { data: null, error: 'Cycle not found' } + return { data, error: null } +} + +export async function createRushCycleRecord(input: unknown) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsed = parseRushCycleCreate(input) + if (parsed.error || !parsed.data) { + return { data: null, error: parsed.error } + } + + try { + const bundle = await createRushCycle(parsed.data) + if (!bundle) return { data: null, error: 'Failed to create cycle' } + const cycles = await listRushCycles() + revalidateRush() + return { data: { ...bundle, cycles }, error: null } + } catch (error) { + console.error('Error creating rush cycle:', error) + return { data: null, error: 'Failed to create cycle' } + } +} + +export async function saveRushApplicationCycle(cycleId: string, input: unknown) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsedId = parseCycleId(cycleId) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + + const parsed = parseRushCycleApplication(input) + if (parsed.error || !parsed.data) { + return { data: null, error: parsed.error } + } + + try { + await saveRushCycle(parsedId.data, parsed.data) + revalidateRush() + const data = await getCycleBundle(parsedId.data) + if (!data) return { data: null, error: 'Cycle not found' } + return { data, error: null } + } catch (error) { + console.error('Error saving rush cycle:', error) + const message = error instanceof Error ? error.message : 'Failed to save rush application' + return { data: null, error: message } + } +} + +export async function saveRushCycleDetails(cycleId: string, input: unknown) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsedId = parseCycleId(cycleId) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + + const parsed = parseRushCycleMeta(input) + if (parsed.error || !parsed.data) { + return { data: null, error: parsed.error } + } + + try { + const updated = await saveRushCycleMeta(parsedId.data, parsed.data) + if (!updated) return { data: null, error: 'Cycle not found' } + revalidateRush() + const data = await getCycleBundle(parsedId.data) + if (!data) return { data: null, error: 'Cycle not found' } + return { data, error: null } + } catch (error) { + console.error('Error saving rush cycle:', error) + return { data: null, error: 'Failed to save rush cycle' } + } +} + +export async function showRushCycleOnSite(cycleId: string) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsedId = parseCycleId(cycleId) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + + try { + const updated = await activateRushCycle(parsedId.data) + if (!updated) return { data: null, error: 'Cycle not found' } + revalidateRush() + const [data, cycles] = await Promise.all([ + getCycleBundle(parsedId.data), + listRushCycles(), + ]) + if (!data) return { data: null, error: 'Cycle not found' } + return { data: { ...data, cycles }, error: null } + } catch (error) { + console.error('Error activating rush cycle:', error) + return { data: null, error: 'Failed to show cycle on site' } + } +} + +export async function closeRushApplicationNow(cycleId: string) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsedId = parseCycleId(cycleId) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + + try { + const updated = await closeRushCycleNow(parsedId.data) + if (!updated) return { data: null, error: 'Cycle not found' } + revalidateRush() + const data = await getCycleBundle(parsedId.data) + if (!data) return { data: null, error: 'Cycle not found' } + return { data, error: null } + } catch (error) { + console.error('Error closing rush cycle:', error) + return { data: null, error: 'Failed to close applications' } + } +} + +export async function openRushApplicationNow(cycleId: string) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsedId = parseCycleId(cycleId) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + + try { + const updated = await openRushCycleNow(parsedId.data) + if (!updated) return { data: null, error: 'Cycle not found' } + revalidateRush() + const [data, cycles] = await Promise.all([ + getCycleBundle(parsedId.data), + listRushCycles(), + ]) + if (!data) return { data: null, error: 'Cycle not found' } + return { data: { ...data, cycles }, error: null } + } catch (error) { + console.error('Error opening rush cycle:', error) + return { data: null, error: 'Failed to open applications' } + } +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index a05fa95..99ce220 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,8 +1,12 @@ import { redirect } from 'next/navigation' import { checkIsAdmin, getCurrentUser } from '@/lib/supabase/auth-helpers' import Header from '@/components/Header' -import RushScheduleManager from '@/components/RushScheduleManager' -import { getRushEvents, toClientRushEvent } from '@/lib/rush-events' +import AdminListManager from '@/components/AdminListManager' +import BrotherListManager from '@/components/BrotherListManager' +import AdminRushDashboard from '@/components/AdminRushDashboard' +import { listAdmins } from '@/lib/admins' +import { listBrothers } from '@/lib/brothers' +import { getAdminCycle, listRushCycles } from '@/lib/rush-cycles' import Unauthorized from '@/components/Unauthorized' export default async function AdminPage() { @@ -18,15 +22,19 @@ export default async function AdminPage() { return } - const initialEvents = (await getRushEvents()).map(toClientRushEvent) - - const sectionCardClass = - 'rounded-xl border border-gray-100 p-6 transform transition-all duration-300 ease-in-out hover:shadow-[0_12px_36px_rgba(0,0,0,0.1),0_4px_12px_rgba(0,0,0,0.05)]' - - const sectionCardStyle = { - backgroundColor: 'rgba(249, 250, 251, 0.95)', - boxShadow: '0 8px 30px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)', - } + const [cycles, applicationCycle, admins, brothers] = await Promise.all([ + listRushCycles(), + getAdminCycle(), + listAdmins(), + listBrothers(), + ]) + const initialBundle = applicationCycle.cycle + ? { + cycle: applicationCycle.cycle, + questions: applicationCycle.questions, + events: applicationCycle.events, + } + : null return (
@@ -37,6 +45,8 @@ export default async function AdminPage() {
+
+
@@ -45,29 +55,13 @@ export default async function AdminPage() {

Admin Dashboard

- -
-

- Welcome, {adminUser.email}! -

-

- Admin portal features will be available here. -

-
- {/* Placeholder for future widgets */} -
-
- -
- -
-

More Features

-

- Additional admin features will be added here. -

-
+
+ +
+ +
@@ -75,4 +69,3 @@ export default async function AdminPage() { ) } - diff --git a/src/app/apply/ApplyDraftSection.tsx b/src/app/apply/ApplyDraftSection.tsx new file mode 100644 index 0000000..ed15679 --- /dev/null +++ b/src/app/apply/ApplyDraftSection.tsx @@ -0,0 +1,34 @@ +import { ApplySectionForm } from '@/components/apply/ApplySectionForm' +import { ApplyShell } from '@/components/apply/ApplyShell' +import { requireApplyDraft } from '@/lib/apply-load' +import { parseApplyPreview, type ApplyPreviewQuery } from '@/lib/apply-preview' +import { applicationTitle, type ApplyStepSlug } from '@/lib/apply-steps' + +export async function ApplyDraftSection({ + step, + searchParams, +}: { + step: ApplyStepSlug + searchParams: Promise +}) { + const preview = parseApplyPreview(await searchParams) + const ctx = await requireApplyDraft(preview) + + return ( + + + + ) +} diff --git a/src/app/apply/academic/page.tsx b/src/app/apply/academic/page.tsx new file mode 100644 index 0000000..19ee553 --- /dev/null +++ b/src/app/apply/academic/page.tsx @@ -0,0 +1,10 @@ +import { ApplyDraftSection } from '../ApplyDraftSection' +import type { ApplyPreviewQuery } from '@/lib/apply-preview' + +export default function Page({ + searchParams, +}: { + searchParams: Promise +}) { + return +} diff --git a/src/app/apply/actions.ts b/src/app/apply/actions.ts new file mode 100644 index 0000000..c3dab94 --- /dev/null +++ b/src/app/apply/actions.ts @@ -0,0 +1,170 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { requireUser } from '@/lib/supabase/auth-helpers' +import { getBrotherByUmichEmail } from '@/lib/brothers' +import { + cycleWindow, + getActiveCycle, + getApplicationFiles, + getCycleQuestions, + getOrCreateApplication, + saveApplicationAnswers, + saveApplicationFields, + saveDummyFile, + deleteDummyFile, + submitApplication, +} from '@/lib/applications' +import { parseApplicationAnswers, parseApplicationFields, parseSubmitPayload } from '@/lib/apply-schema' +import { FILE_SLOTS, type FileSlot } from '@/lib/apply-steps' + +async function requireDraftOwner() { + const user = await requireUser() + if (!user?.email) return { error: 'Please log in with your UMich Google account.' as const } + + if (await getBrotherByUmichEmail(user.email)) { + return { error: 'Brothers cannot submit a rush application.' as const } + } + + const cycle = await getActiveCycle() + if (!cycle) return { error: 'Applications are not open.' as const } + + const window = cycleWindow(cycle) + const application = await getOrCreateApplication({ + cycleId: cycle.id, + userId: user.id, + email: user.email, + }) + + if (application.status === 'submitted') { + return { error: 'This application has already been submitted.' as const } + } + if (!window.isOpen) { + return { error: 'This application cycle is not open for edits.' as const } + } + + return { user, cycle, application, error: null } +} + +export async function saveApplyDraft(input: { + fields: unknown + answers: Record +}) { + const auth = await requireDraftOwner() + if (auth.error) return { error: auth.error } + + const parsed = parseApplicationFields(input.fields) + if (parsed.error || !parsed.data) return { error: parsed.error } + + const saved = await saveApplicationFields(auth.application.id, auth.user.id, parsed.data) + if (!saved) return { error: 'Could not save. The application may already be submitted.' } + + const questions = await getCycleQuestions(auth.cycle.id) + const parsedAnswers = parseApplicationAnswers(input.answers, questions) + if (parsedAnswers.error || !parsedAnswers.data) { + return { error: parsedAnswers.error } + } + + await saveApplicationAnswers(auth.application.id, parsedAnswers.data) + + revalidatePath('/apply') + return { error: null } +} + +export async function saveApplyDummyFile(input: { + slot: string + filename: string + mimeType: string + sizeBytes: number +}) { + const auth = await requireDraftOwner() + if (auth.error) return { error: auth.error, file: null } + + if (!FILE_SLOTS.includes(input.slot as FileSlot)) { + return { error: 'Invalid file slot', file: null } + } + if (!input.filename.trim()) { + return { error: 'Choose a file first', file: null } + } + + const saved = await saveDummyFile({ + applicationId: auth.application.id, + slot: input.slot as FileSlot, + filename: input.filename.trim(), + mimeType: input.mimeType || 'application/octet-stream', + sizeBytes: input.sizeBytes || 0, + }) + + revalidatePath('/apply') + return { + error: null, + file: { + slot: saved.slot, + filename: saved.originalFilename, + }, + } +} + +export async function deleteApplyDummyFile(slot: string) { + const auth = await requireDraftOwner() + if (auth.error) return { error: auth.error } + + if (!FILE_SLOTS.includes(slot as FileSlot)) { + return { error: 'Invalid file slot' } + } + + await deleteDummyFile(auth.application.id, slot as FileSlot) + revalidatePath('/apply') + return { error: null } +} + +export async function submitApply(input: { + fields: unknown + answers: Record +}) { + const auth = await requireDraftOwner() + if (auth.error) return { error: auth.error } + + const parsedFields = parseApplicationFields(input.fields) + if (parsedFields.error || !parsedFields.data) return { error: parsedFields.error } + + const questions = await getCycleQuestions(auth.cycle.id) + const files = await getApplicationFiles(auth.application.id) + const fileMap = Object.fromEntries( + files.map((file) => [file.slot, file.originalFilename]) + ) as Partial> + + const submitCheck = parseSubmitPayload({ + fields: parsedFields.data, + answers: input.answers, + files: fileMap, + questions: questions.map((question) => ({ + id: question.id, + prompt: question.prompt, + maxWords: question.maxWords, + required: question.required, + })), + hearAboutOptions: auth.cycle.hearAboutOptions ?? [], + }) + if (submitCheck.error) return { error: submitCheck.error } + + const parsedAnswers = parseApplicationAnswers(input.answers, questions) + if (parsedAnswers.error || !parsedAnswers.data) { + return { error: parsedAnswers.error } + } + + const saved = await saveApplicationFields( + auth.application.id, + auth.user.id, + parsedFields.data + ) + if (!saved) return { error: 'Could not save before submit.' } + + await saveApplicationAnswers(auth.application.id, parsedAnswers.data) + + const submitted = await submitApplication(auth.application.id, auth.user.id) + if (!submitted) return { error: 'Submit failed. You may have already submitted.' } + + revalidatePath('/apply') + return { error: null } +} diff --git a/src/app/apply/additional/page.tsx b/src/app/apply/additional/page.tsx new file mode 100644 index 0000000..63b9eb5 --- /dev/null +++ b/src/app/apply/additional/page.tsx @@ -0,0 +1,10 @@ +import { ApplyDraftSection } from '../ApplyDraftSection' +import type { ApplyPreviewQuery } from '@/lib/apply-preview' + +export default function Page({ + searchParams, +}: { + searchParams: Promise +}) { + return +} diff --git a/src/app/apply/involvement/page.tsx b/src/app/apply/involvement/page.tsx new file mode 100644 index 0000000..ebbcf96 --- /dev/null +++ b/src/app/apply/involvement/page.tsx @@ -0,0 +1,10 @@ +import { ApplyDraftSection } from '../ApplyDraftSection' +import type { ApplyPreviewQuery } from '@/lib/apply-preview' + +export default function Page({ + searchParams, +}: { + searchParams: Promise +}) { + return +} diff --git a/src/app/apply/layout.tsx b/src/app/apply/layout.tsx new file mode 100644 index 0000000..685cfe5 --- /dev/null +++ b/src/app/apply/layout.tsx @@ -0,0 +1,22 @@ +import Footer from '@/components/Footer' +import Header from '@/components/Header' + +export default function ApplyLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+
+
+
+
+
+
+
+
{children}
+
+
+
+
+
+ ) +} diff --git a/src/app/apply/page.tsx b/src/app/apply/page.tsx new file mode 100644 index 0000000..14e1a60 --- /dev/null +++ b/src/app/apply/page.tsx @@ -0,0 +1,150 @@ +import Link from 'next/link' +import { ApplyRecap } from '@/components/apply/ApplySectionForm' +import { applyCardStyle, ApplyShell } from '@/components/apply/ApplyShell' +import { UmichGoogleButton } from '@/components/apply/UmichGoogleButton' +import { loadApplyContext } from '@/lib/apply-load' +import { applyPreviewHref, parseApplyPreview, type ApplyPreviewQuery } from '@/lib/apply-preview' +import { applicationClosedMessage, applicationTitle } from '@/lib/apply-steps' + +export default async function ApplyWelcomePage({ + searchParams, +}: { + searchParams: Promise +}) { + const preview = parseApplyPreview(await searchParams) + const ctx = await loadApplyContext(preview) + + if (ctx.isPreview && ctx.cycle) { + const title = applicationTitle(ctx.cycle.name) + return ( + + +

{ctx.cycle.introMarkdown}

+ + Continue application + +
+
+ ) + } + + if (ctx.isBrother) { + return ( + + +

You're signed in as a brother.

+

+ This application is only available to rushees applying this cycle. Please switch accounts to apply, or return to the Brother Portal to continue. +

+ + Go to brother portal + +
+
+ ) + } + + if (!ctx.cycle) { + return ( + + +

There is no active rush application cycle right now. Please apply next semester.

+
+
+ ) + } + + const title = applicationTitle(ctx.cycle.name) + const windowClosed = ctx.window && !ctx.window.isOpen + const closedCopy = ctx.window?.isBeforeOpen + ? `Applications open ${new Date(ctx.cycle.opensAt).toLocaleString()}.` + : applicationClosedMessage(ctx.cycle.name, ctx.cycle.closedMarkdown) + + if (!ctx.user) { + if (windowClosed) { + return ( + + +

{closedCopy}

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

{ctx.cycle.introMarkdown}

+ +
+
+ ) + } + + if (ctx.application?.status === 'submitted') { + return ( + + +

+ Your application has been submitted + {ctx.application.submittedAt + ? ` (${new Date(ctx.application.submittedAt).toLocaleString()})` + : ''} + . Responses below are locked. +

+
+ +
+
+
+ ) + } + + if (ctx.window && !ctx.window.isOpen) { + return ( + + +

{closedCopy}

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

{ctx.cycle.introMarkdown}

+

Signed in as {ctx.user.email}

+ + Continue application + +
+
+ ) +} + +function WelcomeCard({ children }: { children: React.ReactNode }) { + return ( +
+
+ {children} +
+
+ ) +} diff --git a/src/app/apply/personal/page.tsx b/src/app/apply/personal/page.tsx new file mode 100644 index 0000000..750e2d7 --- /dev/null +++ b/src/app/apply/personal/page.tsx @@ -0,0 +1,10 @@ +import { ApplyDraftSection } from '../ApplyDraftSection' +import type { ApplyPreviewQuery } from '@/lib/apply-preview' + +export default function Page({ + searchParams, +}: { + searchParams: Promise +}) { + return +} diff --git a/src/app/apply/questions/page.tsx b/src/app/apply/questions/page.tsx new file mode 100644 index 0000000..74c64b6 --- /dev/null +++ b/src/app/apply/questions/page.tsx @@ -0,0 +1,10 @@ +import { ApplyDraftSection } from '../ApplyDraftSection' +import type { ApplyPreviewQuery } from '@/lib/apply-preview' + +export default function Page({ + searchParams, +}: { + searchParams: Promise +}) { + return +} diff --git a/src/app/apply/review/page.tsx b/src/app/apply/review/page.tsx new file mode 100644 index 0000000..4ff9960 --- /dev/null +++ b/src/app/apply/review/page.tsx @@ -0,0 +1,10 @@ +import { ApplyDraftSection } from '../ApplyDraftSection' +import type { ApplyPreviewQuery } from '@/lib/apply-preview' + +export default function Page({ + searchParams, +}: { + searchParams: Promise +}) { + return +} diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index 0ecb3d0..0c21183 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -1,22 +1,45 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { getBrotherByUmichEmail } from '@/lib/brothers' +import { checkIsAdmin } from '@/lib/supabase/auth-helpers' + +function safeNext(value: string | null) { + if (value && value.startsWith('/') && !value.startsWith('//')) return value + return null +} export async function GET(request: Request) { const requestUrl = new URL(request.url) const code = requestUrl.searchParams.get('code') const origin = requestUrl.origin + const supabase = await createClient() if (code) { - const supabase = await createClient() const { error } = await supabase.auth.exchangeCodeForSession(code) - + if (error) { console.error('Error exchanging code for session:', error) return NextResponse.redirect(`${origin}/login?error=auth_failed`) } } - // URL to redirect to after sign in process completes - return NextResponse.redirect(`${origin}/admin`) -} + const { + data: { user }, + } = await supabase.auth.getUser() + const email = user?.email?.toLowerCase() ?? '' + const brother = email ? await getBrotherByUmichEmail(email) : null + const explicitNext = safeNext(requestUrl.searchParams.get('next')) + if (brother) { + if (explicitNext?.startsWith('/apply')) { + const nextUrl = new URL(explicitNext, origin) + if (nextUrl.searchParams.get('preview') === '1' && (await checkIsAdmin())) { + return NextResponse.redirect(`${origin}${explicitNext}`) + } + return NextResponse.redirect(`${origin}/apply`) + } + return NextResponse.redirect(`${origin}${explicitNext ?? '/portal'}`) + } + + return NextResponse.redirect(`${origin}${explicitNext ?? '/apply'}`) +} diff --git a/src/app/globals.css b/src/app/globals.css index 3beaeda..dd4c626 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -74,6 +74,20 @@ input[type="time"].datetime-empty::-webkit-datetime-edit-ampm-field { color: inherit; } +input[type="datetime-local"].datetime-empty, +input[type="datetime-local"].datetime-empty::-webkit-datetime-edit, +input[type="datetime-local"].datetime-empty::-webkit-datetime-edit-fields-wrapper, +input[type="datetime-local"].datetime-empty::-webkit-datetime-edit-text, +input[type="datetime-local"].datetime-empty::-webkit-datetime-edit-month-field, +input[type="datetime-local"].datetime-empty::-webkit-datetime-edit-day-field, +input[type="datetime-local"].datetime-empty::-webkit-datetime-edit-year-field, +input[type="datetime-local"].datetime-empty::-webkit-datetime-edit-hour-field, +input[type="datetime-local"].datetime-empty::-webkit-datetime-edit-minute-field, +input[type="datetime-local"].datetime-empty::-webkit-datetime-edit-second-field, +input[type="datetime-local"].datetime-empty::-webkit-datetime-edit-ampm-field { + color: #9ca3af !important; +} + .homepage-grid { display: flex; flex-direction: column; diff --git a/src/app/portal/alumni/page.tsx b/src/app/portal/alumni/page.tsx new file mode 100644 index 0000000..4afc143 --- /dev/null +++ b/src/app/portal/alumni/page.tsx @@ -0,0 +1,19 @@ +import { + PortalShell, + portalSectionCardClass, + portalSectionCardStyle, +} from '@/components/PortalShell' +import { AlumniDirectoryTable } from '@/components/portal/AlumniDirectoryTable' +import { requirePortalUser } from '@/lib/portal' + +export default async function PortalAlumniPage() { + await requirePortalUser() + + return ( + +
+ +
+
+ ) +} diff --git a/src/app/portal/interviews/page.tsx b/src/app/portal/interviews/page.tsx new file mode 100644 index 0000000..b67c5d4 --- /dev/null +++ b/src/app/portal/interviews/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from 'next/navigation' +import { requirePortalUser } from '@/lib/portal' + +export default async function PortalInterviewsPage() { + await requirePortalUser() + redirect('/portal') +} diff --git a/src/app/portal/page.tsx b/src/app/portal/page.tsx new file mode 100644 index 0000000..333a0ed --- /dev/null +++ b/src/app/portal/page.tsx @@ -0,0 +1,119 @@ +import Link from 'next/link' +import { TimeGreeting } from '@/components/portal/TimeGreeting' +import { AlumniDirectoryTable } from '@/components/portal/AlumniDirectoryTable' +import { InterviewHomeSection } from '@/components/portal/InterviewHomeSection' +import { + PortalShell, + portalInnerCardClass, + portalInnerCardStyle, + portalSectionCardClass, + portalSectionCardStyle, +} from '@/components/PortalShell' +import { checkIsAdmin } from '@/lib/supabase/auth-helpers' +import { requirePortalUser } from '@/lib/portal' + +type QuickLink = { + href: string + title: string + subtitle: string + external?: boolean +} + +function LinkRow({ link }: { link: QuickLink }) { + const className = `${portalInnerCardClass} cursor-pointer transition-all duration-200 hover:bg-white` + const body = ( + <> +
+

{link.title}

+

{link.subtitle}

+
+ {link.external ? 'Open' : 'View'} + + ) + + if (link.external) { + return ( + + {body} + + ) + } + + return ( + + {body} + + ) +} + +export default async function PortalPage() { + const { email, brother } = await requirePortalUser() + const adminUser = await checkIsAdmin() + const greetingName = brother.first_name?.trim() || email + + const quickLinks: QuickLink[] = [ + { + href: 'https://drive.google.com', + title: 'Google Drive', + subtitle: 'Knowledge base', + external: true, + }, + { + href: 'https://slack.com/signin', + title: 'Slack', + subtitle: 'Chapter workspace', + external: true, + }, + ...(adminUser + ? [ + { + href: '/admin', + title: 'Admin Dashboard', + subtitle: 'Rush cycles, applications, and admins', + }, + ] + : []), + ] + + return ( + } + headerRight={ + + } + > +
+

Quick Links

+
+ {quickLinks.map((link) => ( + + ))} +
+
+ +
+

Your Tasks

+ +
+ +
+ +
+ +
+ +
+
+ ) +} diff --git a/src/app/portal/reads/page.tsx b/src/app/portal/reads/page.tsx new file mode 100644 index 0000000..0ac64b9 --- /dev/null +++ b/src/app/portal/reads/page.tsx @@ -0,0 +1,47 @@ +import Link from 'next/link' +import { + PortalShell, + portalInnerCardClass, + portalInnerCardStyle, + portalSectionCardClass, + portalSectionCardStyle, +} from '@/components/PortalShell' +import { requirePortalUser } from '@/lib/portal' + +const DUMMY_READS = [ + { name: 'Alex Kim', cycle: 'Fall 2026', status: 'Unread' }, + { name: 'Jordan Patel', cycle: 'Fall 2026', status: 'In progress' }, + { name: 'Sam Rivera', cycle: 'Fall 2026', status: 'Unread' }, +] + +export default async function PortalReadsPage() { + await requirePortalUser() + + return ( + +
+
+
+

Queue

+

Dummy list. Real reads will land here later.

+
+ + Back to portal + +
+ +
+ {DUMMY_READS.map((item) => ( +
+
+

{item.name}

+

{item.cycle}

+
+ {item.status} +
+ ))} +
+
+
+ ) +} diff --git a/src/app/rush/page.tsx b/src/app/rush/page.tsx index c0573d1..6c27bd3 100644 --- a/src/app/rush/page.tsx +++ b/src/app/rush/page.tsx @@ -2,10 +2,36 @@ import Footer from '@/components/Footer' import Header from '@/components/Header' import RushEvent from '@/components/RushEvent' import RushFaq from '@/components/RushFaq' -import { getRushEvents } from '@/lib/rush-events' +import { getActiveCycle } from '@/lib/applications' +import { getRushEventsForCycle } from '@/lib/rush-events' +import { toYoutubeEmbedUrl } from '@/lib/youtube-embed' + +function cycleDisplayName(cycleName: string) { + return cycleName.replace(/\s*\(local\)\s*/gi, '').trim() +} + +function formatDueDate(iso: string) { + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return null + return date.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + }) +} export default async function Rush() { - const events = await getRushEvents() + const cycle = await getActiveCycle() + const events = cycle ? await getRushEventsForCycle(cycle.id) : [] + const displayName = cycle ? cycleDisplayName(cycle.name) : null + const dueDate = cycle ? formatDueDate(cycle.closesAt) : null + const blurb = + cycle?.publicBlurb?.trim() || + (displayName + ? `Welcome to Kappa Theta Pi's ${displayName} Rush!\nHere's our rush schedule.${dueDate ? ` Applications are due ${dueDate}.` : ''}` + : "Welcome to Kappa Theta Pi rush. Check back soon for this semester's schedule.") + const youtubeEmbed = toYoutubeEmbedUrl(cycle?.youtubeUrl) return (
@@ -24,44 +50,48 @@ export default async function Rush() { Learn About Joining KTP!

- Welcome to Kappa Theta Pi's Winter 2026 Rush!

- Here's our rush schedule. Applications are due Saturday, January 17, 2026. + {blurb}

-

- You can also join our{' '} - - W26 Rush Google Calendar - {' '} - to see the dates, times, and locations of all Open Rush events. -

+ You can also join our{' '} + + {displayName ? `${displayName} Google Calendar` : 'rush Google Calendar'} + {' '} + to see the dates, times, and locations of all Open Rush events. +

+ ) : ( +
+ )}
@@ -91,23 +121,25 @@ export default async function Rush() { )}
-
-
-
-
-
-
- + {youtubeEmbed ? ( +
+
+
+
+
+
+ +
-
+ ) : null}
diff --git a/src/components/AdminListManager.tsx b/src/components/AdminListManager.tsx new file mode 100644 index 0000000..7b2c3b3 --- /dev/null +++ b/src/components/AdminListManager.tsx @@ -0,0 +1,144 @@ +'use client' + +import { useState } from 'react' +import { addAdmin, removeAdmin } from '@/app/admin/actions' +import type { ClientAdmin } from '@/lib/admins' + +const btnClass = + 'px-4 py-2 bg-[#315CA9] text-white rounded-lg text-sm font-semibold transition-all duration-300 hover:scale-105 hover:shadow-md cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100' +const inputClass = + 'w-full px-3 py-2 border border-gray-300 rounded-md bg-white/80 text-sm text-gray-700 outline-none transition-[border-color,box-shadow] duration-200 ease-out focus:border-[#315CA9] focus:shadow-[0_0_0_3px_rgba(49,92,169,0.18)]' +const sectionCardClass = + 'rounded-xl border border-gray-100 p-6 transform transition-all duration-300 ease-in-out hover:shadow-[0_12px_36px_rgba(0,0,0,0.1),0_4px_12px_rgba(0,0,0,0.05)]' +const sectionCardStyle = { + backgroundColor: 'rgba(249, 250, 251, 0.95)', + boxShadow: '0 8px 30px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)', +} +const innerCardClass = 'rounded-xl border border-gray-100 bg-white/80' +const innerCardStyle = { boxShadow: '0 2px 10px rgba(0, 0, 0, 0.04)' } + +export default function AdminListManager({ + currentEmail, + initialAdmins, +}: { + currentEmail: string + initialAdmins: ClientAdmin[] +}) { + const [admins, setAdmins] = useState(initialAdmins) + const [email, setEmail] = useState('') + const [error, setError] = useState(null) + const [isAdding, setIsAdding] = useState(false) + const [pendingEmail, setPendingEmail] = useState(null) + const self = currentEmail.toLowerCase() + + async function handleAdd(event: React.FormEvent) { + event.preventDefault() + setError(null) + setIsAdding(true) + const result = await addAdmin(email) + setIsAdding(false) + if (result.error) { + setError(result.error) + return + } + if (result.data) { + setAdmins((current) => + [...current, result.data!].sort((a, b) => { + const first = (a.first_name ?? '').localeCompare(b.first_name ?? '', undefined, { sensitivity: 'base' }) + if (first !== 0) return first + const last = (a.last_name ?? '').localeCompare(b.last_name ?? '', undefined, { sensitivity: 'base' }) + if (last !== 0) return last + return a.email.localeCompare(b.email) + }) + ) + } + setEmail('') + } + + async function handleRemove(target: string) { + setError(null) + setPendingEmail(target) + const result = await removeAdmin(target) + setPendingEmail(null) + if (result.error) { + setError(result.error) + return + } + setAdmins((current) => current.filter((admin) => admin.email !== target)) + } + + return ( +
+
+

Admins

+
void handleAdd(event)} className="flex min-w-0 items-center gap-2"> + setEmail(event.target.value)} + required + /> + +
+
+ +
+ {admins.length === 0 ? ( +

No admins yet.

+ ) : ( +
    + {admins.map((admin) => { + const isSelf = admin.email === self + const isLast = admins.length <= 1 + const removing = pendingEmail === admin.email + const name = [admin.first_name, admin.last_name].filter(Boolean).join(' ').trim() + return ( +
  • +
    +

    + {name || admin.email} + {isSelf ? You : null} +

    + {name ?

    {admin.email}

    : null} +
    + +
  • + ) + })} +
+ )} +
+ + {error ?

{error}

: null} +
+ ) +} diff --git a/src/components/AdminRushDashboard.tsx b/src/components/AdminRushDashboard.tsx new file mode 100644 index 0000000..a1a7d5a --- /dev/null +++ b/src/components/AdminRushDashboard.tsx @@ -0,0 +1,455 @@ +'use client' + +import { useRef, useState } from 'react' +import { + createRushCycleRecord, + getRushCycleBundle, + saveRushCycleDetails, + showRushCycleOnSite, +} from '@/app/admin/actions' +import RushApplicationManager, { + type RushApplicationHandle, +} from '@/components/RushApplicationManager' +import RushScheduleManager from '@/components/RushScheduleManager' +import type { ClientCycleQuestion, ClientRushCycle, CycleBundle } from '@/lib/rush-cycles' + +const btnClass = + 'px-4 py-2 bg-[#315CA9] text-white rounded-lg text-sm font-semibold transition-all duration-300 hover:scale-105 hover:shadow-md cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100' +const ghostBtnClass = + 'px-4 py-2 border border-gray-300 rounded-lg text-gray-700 text-sm font-semibold transition-all duration-300 hover:scale-105 hover:bg-gray-50 hover:shadow-md cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100' +const sectionCardClass = + 'rounded-xl border border-gray-100 p-6 transform transition-all duration-300 ease-in-out hover:shadow-[0_12px_36px_rgba(0,0,0,0.1),0_4px_12px_rgba(0,0,0,0.05)]' +const sectionCardStyle = { + backgroundColor: 'rgba(249, 250, 251, 0.95)', + boxShadow: '0 8px 30px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)', +} +const inputClass = + 'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-gray-700 outline-none transition-[border-color,box-shadow] duration-200 ease-out focus:border-[#315CA9] focus:shadow-[0_0_0_3px_rgba(49,92,169,0.18)] disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-600 disabled:shadow-none' +const datetimeInputClass = + 'w-full px-3 py-2 border border-gray-300 rounded-md text-sm outline-none transition-[border-color,box-shadow,color] duration-200 ease-out focus:border-[#315CA9] focus:shadow-[0_0_0_3px_rgba(49,92,169,0.18)] disabled:cursor-not-allowed disabled:bg-gray-50 disabled:shadow-none' +const labelClass = 'block text-sm font-medium text-gray-700 mb-1' +const innerCardClass = 'rounded-xl border border-gray-100 bg-white/80 p-4' +const innerCardStyle = { boxShadow: '0 2px 10px rgba(0, 0, 0, 0.04)' } +const CYCLE_FORM_ID = 'rush-cycle-form' +const DRAFT_VALUE = '__draft__' + +function toDatetimeLocal(iso: string) { + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return '' + const pad = (value: number) => String(value).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` +} + +function emptyCycleFields() { + return { + name: '', + opensAt: '', + closesAt: '', + interestFormUrl: '', + youtubeUrl: '', + calendarUrl: '', + } +} + +function fieldsFromCycle(cycle: ClientRushCycle) { + return { + name: cycle.name, + opensAt: toDatetimeLocal(cycle.opens_at), + closesAt: toDatetimeLocal(cycle.closes_at), + interestFormUrl: cycle.interest_form_url ?? '', + youtubeUrl: cycle.youtube_url ?? '', + calendarUrl: cycle.calendar_url ?? '', + } +} + +export default function AdminRushDashboard({ + initialCycles, + initialBundle, +}: { + initialCycles: ClientRushCycle[] + initialBundle: CycleBundle | null +}) { + const [cycles, setCycles] = useState(initialCycles) + const [bundle, setBundle] = useState(initialBundle) + const [isDraft, setIsDraft] = useState(!initialBundle) + const [draftKey, setDraftKey] = useState(0) + const [isEditing, setIsEditing] = useState(!initialBundle) + const [isSaving, setIsSaving] = useState(false) + const [isActivating, setIsActivating] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [fields, setFields] = useState( + initialBundle ? fieldsFromCycle(initialBundle.cycle) : emptyCycleFields() + ) + const applicationRef = useRef(null) + + const selected = isDraft ? null : bundle?.cycle ?? null + const fieldsEditable = isDraft || isEditing + + function applyBundle(next: CycleBundle, nextCycles?: ClientRushCycle[]) { + setIsDraft(false) + setIsEditing(false) + setBundle(next) + setFields(fieldsFromCycle(next.cycle)) + if (nextCycles) { + setCycles(nextCycles) + return + } + setCycles((current) => + current.map((cycle) => + cycle.id === next.cycle.id + ? next.cycle + : next.cycle.is_active + ? { ...cycle, is_active: false } + : cycle + ) + ) + } + + function startDraft() { + setIsDraft(true) + setIsEditing(true) + setError(null) + setFields(emptyCycleFields()) + setDraftKey((key) => key + 1) + } + + async function handleSelect(cycleId: string) { + if (cycleId === DRAFT_VALUE) return + if (isDraft && !confirm('Discard this new cycle?')) return + if (cycleId === selected?.id) return + setIsLoading(true) + setError(null) + const result = await getRushCycleBundle(cycleId) + setIsLoading(false) + if (result.error || !result.data) { + setError(result.error) + return + } + applyBundle(result.data) + } + + function handleNewCycle() { + if (isDraft) { + if (!confirm('Discard this new cycle and start over?')) return + startDraft() + return + } + if (isEditing && !confirm('Discard unsaved changes and start a new cycle?')) return + startDraft() + } + + function handleCancelDraft() { + if (bundle) { + setIsDraft(false) + setIsEditing(false) + setError(null) + setFields(fieldsFromCycle(bundle.cycle)) + return + } + startDraft() + } + + function cyclePayload() { + return { + name: fields.name, + opens_at: fields.opensAt ? new Date(fields.opensAt).toISOString() : '', + closes_at: fields.closesAt ? new Date(fields.closesAt).toISOString() : '', + interest_form_url: fields.interestFormUrl, + youtube_url: fields.youtubeUrl, + calendar_url: fields.calendarUrl, + } + } + + async function handleSaveCycle(event: React.FormEvent) { + event.preventDefault() + setIsSaving(true) + setError(null) + + if (isDraft) { + const application = applicationRef.current?.getDraft() + if (!application) { + setIsSaving(false) + setError('Fill out the rush application before saving.') + return + } + const result = await createRushCycleRecord({ + ...cyclePayload(), + ...application, + }) + setIsSaving(false) + if (result.error || !result.data) { + setError(result.error) + return + } + applyBundle(result.data, result.data.cycles) + return + } + + if (!selected) { + setIsSaving(false) + return + } + + const result = await saveRushCycleDetails(selected.id, cyclePayload()) + setIsSaving(false) + if (result.error || !result.data) { + setError(result.error) + return + } + applyBundle(result.data) + } + + async function handleShowOnSite() { + if (!selected) return + if ( + !confirm( + `Show ${selected.name} on /rush and /apply? The current live cycle will be replaced.` + ) + ) { + return + } + setIsActivating(true) + setError(null) + const result = await showRushCycleOnSite(selected.id) + setIsActivating(false) + if (result.error || !result.data) { + setError(result.error) + return + } + applyBundle(result.data, result.data.cycles) + } + + function handleUpdated(data: { cycle: ClientRushCycle; questions: ClientCycleQuestion[] }) { + if (!bundle) return + const next = { + ...bundle, + cycle: data.cycle, + questions: data.questions, + } + setBundle(next) + setFields(fieldsFromCycle(data.cycle)) + setCycles((current) => + current.map((cycle) => + cycle.id === data.cycle.id + ? data.cycle + : data.cycle.is_active + ? { ...cycle, is_active: false } + : cycle + ) + ) + } + + return ( +
+
+
+
+

Rush cycle

+ {cycles.length || isDraft ? ( + + ) : null} +
+
+ {isDraft ? ( + + ) : ( + + )} + {fieldsEditable ? ( + + ) : ( + + )} + +
+
+ + {error ?

{error}

: null} + +
void handleSaveCycle(event)}> +
+
+
+ + setFields((current) => ({ ...current, name: event.target.value }))} + placeholder="Fall 2026" + required + disabled={!fieldsEditable} + /> +
+ +
+
+ + + setFields((current) => ({ ...current, opensAt: event.target.value })) + } + required + disabled={!fieldsEditable} + /> +
+
+ + + setFields((current) => ({ ...current, closesAt: event.target.value })) + } + required + disabled={!fieldsEditable} + /> +
+
+ +
+
+ + + setFields((current) => ({ ...current, interestFormUrl: event.target.value })) + } + placeholder="https://forms.gle/…" + required + disabled={!fieldsEditable} + /> +
+
+ + + setFields((current) => ({ ...current, youtubeUrl: event.target.value })) + } + placeholder="https://www.youtube.com/watch?v=…" + required + disabled={!fieldsEditable} + /> +
+
+ + + setFields((current) => ({ ...current, calendarUrl: event.target.value })) + } + placeholder="https://calendar.google.com/…" + required + disabled={!fieldsEditable} + /> +
+
+
+
+
+
+ +
+ {!isDraft && selected && bundle ? ( +
+ +
+ ) : null} +
+ +
+
+
+ ) +} diff --git a/src/components/BrotherListManager.tsx b/src/components/BrotherListManager.tsx new file mode 100644 index 0000000..769fb62 --- /dev/null +++ b/src/components/BrotherListManager.tsx @@ -0,0 +1,462 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { addBrother, removeBrother, updateBrother } from '@/app/admin/actions' +import type { BrotherFormInput, ClientBrother } from '@/lib/brother-schema' + +const btnClass = + 'px-4 py-2 bg-[#315CA9] text-white rounded-lg text-sm font-semibold transition-all duration-300 hover:scale-105 hover:shadow-md cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100' +const ghostBtnClass = + 'px-4 py-2 border border-gray-300 rounded-lg text-gray-700 text-sm font-semibold transition-all duration-300 hover:scale-105 hover:bg-gray-50 hover:shadow-md cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100' +const inputClass = + 'w-full px-3 py-2 border border-gray-300 rounded-md bg-white/80 text-sm text-gray-700 outline-none transition-[border-color,box-shadow] duration-200 ease-out focus:border-[#315CA9] focus:shadow-[0_0_0_3px_rgba(49,92,169,0.18)]' +const labelClass = 'block text-sm font-medium text-gray-700 mb-1' +const sectionCardClass = + 'rounded-xl border border-gray-100 p-6 transform transition-all duration-300 ease-in-out hover:shadow-[0_12px_36px_rgba(0,0,0,0.1),0_4px_12px_rgba(0,0,0,0.05)]' +const sectionCardStyle = { + backgroundColor: 'rgba(249, 250, 251, 0.95)', + boxShadow: '0 8px 30px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)', +} +const innerCardClass = 'rounded-xl border border-gray-100 bg-white/80' +const innerCardStyle = { boxShadow: '0 2px 10px rgba(0, 0, 0, 0.04)' } +const MODAL_ANIMATION_MS = 280 + +const emptyForm: BrotherFormInput = { + first_name: '', + last_name: '', + umich_email: '', + pledge_class: '', + linkedin_url: '', + photo_filename: '', +} + +function displayName(brother: ClientBrother) { + const name = [brother.first_name, brother.last_name].filter(Boolean).join(' ').trim() + return name || brother.umich_email || 'Unnamed' +} + +function formFromBrother(brother: ClientBrother): BrotherFormInput { + return { + first_name: brother.first_name ?? '', + last_name: brother.last_name ?? '', + umich_email: brother.umich_email ?? '', + pledge_class: brother.pledge_class ?? '', + linkedin_url: brother.linkedin_url ?? '', + photo_filename: brother.photo_filename ?? '', + } +} + +export default function BrotherListManager({ + currentEmail, + initialBrothers, +}: { + currentEmail: string + initialBrothers: ClientBrother[] +}) { + const [people, setPeople] = useState(initialBrothers) + const [error, setError] = useState(null) + const [pendingId, setPendingId] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [form, setForm] = useState(emptyForm) + const [modal, setModal] = useState<'form' | 'csv' | null>(null) + const [editingId, setEditingId] = useState(null) + const [isModalVisible, setIsModalVisible] = useState(false) + const [csvFilename, setCsvFilename] = useState(null) + const csvInputRef = useRef(null) + const photoInputRef = useRef(null) + const closeTimeoutRef = useRef | null>(null) + const self = currentEmail.toLowerCase() + const isEditMode = Boolean(editingId) + + useEffect(() => { + if (!modal) return + const frame = requestAnimationFrame(() => { + requestAnimationFrame(() => setIsModalVisible(true)) + }) + return () => cancelAnimationFrame(frame) + }, [modal]) + + useEffect(() => { + return () => { + if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current) + } + }, []) + + function resetTransientState() { + setForm(emptyForm) + setEditingId(null) + setCsvFilename(null) + setError(null) + if (csvInputRef.current) csvInputRef.current.value = '' + if (photoInputRef.current) photoInputRef.current.value = '' + } + + function openAdd() { + if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current) + resetTransientState() + setIsModalVisible(false) + setModal('form') + } + + function openEdit(brother: ClientBrother) { + if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current) + resetTransientState() + setForm(formFromBrother(brother)) + setEditingId(brother.id) + setIsModalVisible(false) + setModal('form') + } + + function openCsv() { + if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current) + resetTransientState() + setIsModalVisible(false) + setModal('csv') + } + + function closeModal() { + setIsModalVisible(false) + closeTimeoutRef.current = setTimeout(() => { + setModal(null) + resetTransientState() + }, MODAL_ANIMATION_MS) + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + setError(null) + setIsSubmitting(true) + const result = editingId ? await updateBrother(editingId, form) : await addBrother(form) + setIsSubmitting(false) + if (result.error || !result.data) { + setError(result.error) + return + } + setPeople((current) => { + const next = editingId + ? current.map((person) => (person.id === editingId ? result.data! : person)) + : [...current, result.data!] + return next.sort((a, b) => { + const first = (a.first_name ?? '').localeCompare(b.first_name ?? '', undefined, { sensitivity: 'base' }) + if (first !== 0) return first + const last = (a.last_name ?? '').localeCompare(b.last_name ?? '', undefined, { sensitivity: 'base' }) + if (last !== 0) return last + return (a.umich_email ?? '').localeCompare(b.umich_email ?? '') + }) + }) + closeModal() + } + + async function handleRemove(id: string) { + setError(null) + setPendingId(id) + const result = await removeBrother(id) + setPendingId(null) + if (result.error) { + setError(result.error) + return + } + setPeople((current) => current.filter((person) => person.id !== id)) + } + + return ( +
+
+

Brothers

+
+ + +
+
+ +
+ {people.length === 0 ? ( +

No brothers yet.

+ ) : ( +
    + {people.map((person) => { + const isSelf = Boolean(person.umich_email && person.umich_email === self) + return ( +
  • +
    +

    + {displayName(person)} + {isSelf ? You : null} +

    +

    + {[person.pledge_class, person.umich_email].filter(Boolean).join(' · ')} +

    +
    +
    + + +
    +
  • + ) + })} +
+ )} +
+ + {error && !modal ?

{error}

: null} + + {modal && + typeof window !== 'undefined' && + createPortal( +
+
+
event.stopPropagation()} + > +
+

+ {modal === 'csv' ? 'Import CSV' : isEditMode ? 'Edit Brother' : 'Add Brother'} +

+ +
+ + {modal === 'csv' ? ( +
+

+ Upload a CSV of brothers. Import isn’t wired up yet — this only picks a file. +

+ setCsvFilename(event.target.files?.[0]?.name ?? null)} + /> + {csvFilename ? ( +
+ {csvFilename} + +
+ ) : ( + + )} +
+ + +
+
+ ) : ( +
void handleSubmit(event)} className="space-y-4" autoComplete="off"> +
+
+ + setForm((current) => ({ ...current, first_name: event.target.value }))} + required + /> +
+
+ + setForm((current) => ({ ...current, last_name: event.target.value }))} + required + /> +
+
+ +
+ + setForm((current) => ({ ...current, umich_email: event.target.value }))} + placeholder="uniqname@umich.edu" + required + /> +
+ +
+ + setForm((current) => ({ ...current, pledge_class: event.target.value }))} + placeholder="Psi" + required + /> +
+ +
+ + setForm((current) => ({ ...current, linkedin_url: event.target.value }))} + placeholder="https://www.linkedin.com/in/…" + /> +
+ +
+

Photo

+ + setForm((current) => ({ + ...current, + photo_filename: event.target.files?.[0]?.name ?? '', + })) + } + /> + {form.photo_filename ? ( +
+ {form.photo_filename} + +
+ ) : ( + + )} +

Placeholder for now — we only store the filename until S3 is connected.

+
+ + {error ?

{error}

: null} + +
+ + +
+
+ )} +
+
, + document.body + )} +
+ ) +} diff --git a/src/components/HamburgerHeader.tsx b/src/components/HamburgerHeader.tsx index 6a46327..9f20295 100644 --- a/src/components/HamburgerHeader.tsx +++ b/src/components/HamburgerHeader.tsx @@ -69,6 +69,7 @@ export default function HamburgerHeader() {
  • Rush
  • Members
  • Nationals
  • +
  • Apply
  • diff --git a/src/components/PortalShell.tsx b/src/components/PortalShell.tsx new file mode 100644 index 0000000..ad26951 --- /dev/null +++ b/src/components/PortalShell.tsx @@ -0,0 +1,62 @@ +import Header from '@/components/Header' + +export const portalSectionCardClass = + 'rounded-xl border border-gray-100 p-6 transform transition-all duration-300 ease-in-out hover:shadow-[0_12px_36px_rgba(0,0,0,0.1),0_4px_12px_rgba(0,0,0,0.05)]' +export const portalSectionCardStyle = { + backgroundColor: 'rgba(249, 250, 251, 0.95)', + boxShadow: '0 8px 30px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)', +} +export const portalInnerCardClass = + 'flex items-center justify-between gap-3 rounded-xl border border-gray-100 bg-white/80 px-4 py-3' +export const portalInnerCardStyle = { boxShadow: '0 2px 10px rgba(0, 0, 0, 0.04)' } +export const portalBtnClass = + 'inline-flex px-4 py-2 bg-[#315CA9] text-white rounded-lg text-sm font-semibold transition-all duration-300 hover:scale-105 hover:shadow-md cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100' + +export function PortalShell({ + title, + subtitle, + headerRight, + children, +}: { + title: string + subtitle?: React.ReactNode + headerRight?: React.ReactNode + children: React.ReactNode +}) { + return ( +
    +
    + +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +

    + {title} +

    + {subtitle ? ( +

    {subtitle}

    + ) : null} + {headerRight ? ( +
    {headerRight}
    + ) : null} +
    + {children} +
    +
    +
    +
    +
    + ) +} diff --git a/src/components/RushApplicationManager.tsx b/src/components/RushApplicationManager.tsx new file mode 100644 index 0000000..acbeb05 --- /dev/null +++ b/src/components/RushApplicationManager.tsx @@ -0,0 +1,568 @@ +'use client' + +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { + closeRushApplicationNow, + openRushApplicationNow, + saveRushApplicationCycle, +} from '@/app/admin/actions' +import { applyPreviewHref } from '@/lib/apply-preview' +import type { ClientCycleQuestion, ClientRushCycle } from '@/lib/rush-cycles' + +const inputClass = + 'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-gray-600 outline-none transition-[border-color,box-shadow] duration-200 ease-out focus:border-[#315CA9] focus:shadow-[0_0_0_3px_rgba(49,92,169,0.18)] disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-600 disabled:shadow-none' +const compactInputClass = + 'px-3 py-2 border border-gray-300 rounded-md text-sm text-gray-600 outline-none transition-[border-color,box-shadow] duration-200 ease-out focus:border-[#315CA9] focus:shadow-[0_0_0_3px_rgba(49,92,169,0.18)] disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-600 disabled:shadow-none' +const labelClass = 'block text-sm font-medium text-gray-700 mb-1' +const btnClass = + 'px-4 py-2 bg-[#315CA9] text-white rounded-lg text-sm font-semibold transition-all duration-300 hover:scale-105 hover:shadow-md cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100' +const ghostBtnClass = + 'px-4 py-2 border border-gray-300 rounded-lg text-gray-700 text-sm font-semibold transition-all duration-300 hover:scale-105 hover:bg-gray-50 hover:shadow-md cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100' +const innerCardClass = 'rounded-xl border border-gray-100 bg-white/80 p-4' +const innerCardStyle = { boxShadow: '0 2px 10px rgba(0, 0, 0, 0.04)' } +const QUESTION_ANIMATION_MS = 280 + +type QuestionDraft = { + key: string + id?: string + prompt: string + help_text: string + max_words: number + required: boolean +} + +function EyeIcon() { + return ( + + + + + ) +} + +function questionsFromServer(questions: ClientCycleQuestion[]): QuestionDraft[] { + return questions.map((question) => ({ + key: question.id, + id: question.id, + prompt: question.prompt, + help_text: question.help_text ?? '', + max_words: question.max_words, + required: question.required, + })) +} + +function emptyQuestion(): QuestionDraft { + return { + key: `new-${crypto.randomUUID()}`, + prompt: '', + help_text: '', + max_words: 350, + required: true, + } +} + +function cycleStatus(opensAt: string, closesAt: string) { + const now = Date.now() + const opens = new Date(opensAt).getTime() + const closes = new Date(closesAt).getTime() + if (!opensAt || !closesAt || Number.isNaN(opens) || Number.isNaN(closes)) { + return { label: 'Closed', className: 'bg-gray-200/60 text-gray-600' } + } + if (now < opens) { + return { label: 'Scheduled', className: 'bg-gray-200/60 text-gray-700' } + } + if (now > closes) { + return { label: 'Closed', className: 'bg-gray-200/60 text-gray-600' } + } + return { label: 'Open', className: 'bg-[#315CA9] text-white' } +} + +export type RushApplicationHandle = { + getDraft: () => { + intro_markdown: string + closed_markdown: string + hear_about_options: string[] + questions: Array<{ + id?: string + prompt: string + help_text: string + max_words: number + required: boolean + sort_order: number + }> + } +} + +const RushApplicationManager = forwardRef< + RushApplicationHandle, + { + initialCycle: ClientRushCycle | null + initialQuestions: ClientCycleQuestion[] + isDraft?: boolean + formId?: string + onUpdated?: (data: { cycle: ClientRushCycle; questions: ClientCycleQuestion[] }) => void + } +>(function RushApplicationManager( + { initialCycle, initialQuestions, isDraft = false, formId, onUpdated }, + ref +) { + const [cycleId, setCycleId] = useState(initialCycle?.id ?? null) + const [intro, setIntro] = useState(initialCycle?.intro_markdown ?? '') + const [closed, setClosed] = useState(initialCycle?.closed_markdown ?? '') + const [hearAbout, setHearAbout] = useState((initialCycle?.hear_about_options ?? []).join('\n')) + const [questions, setQuestions] = useState( + initialQuestions.length ? questionsFromServer(initialQuestions) : [emptyQuestion()] + ) + const [isSaving, setIsSaving] = useState(false) + const [isClosing, setIsClosing] = useState(false) + const [isOpening, setIsOpening] = useState(false) + const [error, setError] = useState(null) + const [isEditing, setIsEditing] = useState(isDraft) + const [enteringKey, setEnteringKey] = useState(null) + const [exitingKeys, setExitingKeys] = useState([]) + const questionEls = useRef>(new Map()) + const removeTimeouts = useRef>(new Map()) + + const status = useMemo( + () => + cycleStatus(initialCycle?.opens_at ?? '', initialCycle?.closes_at ?? ''), + [initialCycle?.opens_at, initialCycle?.closes_at] + ) + const visibleQuestionCount = questions.length - exitingKeys.length + const fieldsEditable = isDraft || isEditing + + const accepting = Boolean( + initialCycle && + Date.now() >= new Date(initialCycle.opens_at).getTime() && + Date.now() <= new Date(initialCycle.closes_at).getTime() + ) + + function applyServerState(data: { + cycle: ClientRushCycle | null + questions: ClientCycleQuestion[] + }) { + if (!data.cycle) return + setCycleId(data.cycle.id) + setIntro(data.cycle.intro_markdown ?? '') + setClosed(data.cycle.closed_markdown ?? '') + setHearAbout(data.cycle.hear_about_options.join('\n')) + setQuestions( + data.questions.length ? questionsFromServer(data.questions) : [emptyQuestion()] + ) + setEnteringKey(null) + setExitingKeys([]) + } + + function payload() { + return { + intro_markdown: intro, + closed_markdown: closed, + hear_about_options: hearAbout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean), + questions: questions + .filter((question) => !exitingKeys.includes(question.key)) + .map((question, index) => ({ + id: question.id || undefined, + prompt: question.prompt, + help_text: question.help_text, + max_words: question.max_words, + required: question.required, + sort_order: index, + })), + } + } + + useImperativeHandle(ref, () => ({ + getDraft: () => payload(), + })) + + async function handleSave() { + setIsSaving(true) + setError(null) + if (!cycleId) return + const result = await saveRushApplicationCycle(cycleId, payload()) + setIsSaving(false) + if (result.error || !result.data) { + setError(result.error) + return + } + applyServerState(result.data) + onUpdated?.(result.data) + setIsEditing(false) + } + + async function handleCloseNow() { + if (!cycleId) return + if (!confirm('Close applications now? Applicants will not be able to edit or submit.')) { + return + } + setIsClosing(true) + setError(null) + const result = await closeRushApplicationNow(cycleId) + setIsClosing(false) + if (result.error || !result.data) { + setError(result.error) + return + } + applyServerState(result.data) + onUpdated?.(result.data) + } + + async function handleOpenNow() { + if (!cycleId) return + if ( + !confirm( + 'Open applications now? If the close date already passed, it will be set to 30 days from now. You can edit that after.' + ) + ) { + return + } + setIsOpening(true) + setError(null) + const result = await openRushApplicationNow(cycleId) + setIsOpening(false) + if (result.error || !result.data) { + setError(result.error) + return + } + applyServerState(result.data) + onUpdated?.(result.data) + } + + function updateQuestion(key: string, patch: Partial) { + setQuestions((current) => + current.map((question) => (question.key === key ? { ...question, ...patch } : question)) + ) + } + + function moveQuestion(index: number, direction: -1 | 1) { + const next = index + direction + if (next < 0 || next >= questions.length) return + const copy = [...questions] + const [moved] = copy.splice(index, 1) + copy.splice(next, 0, moved) + setQuestions(copy) + } + + function addQuestion() { + const next = emptyQuestion() + setQuestions((current) => [...current, next]) + setEnteringKey(next.key) + } + + function removeQuestion(key: string) { + if (visibleQuestionCount <= 1 || exitingKeys.includes(key)) return + setExitingKeys((current) => [...current, key]) + const timeout = window.setTimeout(() => { + setQuestions((current) => current.filter((item) => item.key !== key)) + setExitingKeys((current) => current.filter((item) => item !== key)) + removeTimeouts.current.delete(key) + }, QUESTION_ANIMATION_MS) + removeTimeouts.current.set(key, timeout) + } + + useEffect(() => { + if (!enteringKey) return + const frame = window.requestAnimationFrame(() => { + const el = questionEls.current.get(enteringKey) + el?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + el?.querySelector('textarea')?.focus() + }) + const timeout = window.setTimeout(() => setEnteringKey(null), QUESTION_ANIMATION_MS) + return () => { + window.cancelAnimationFrame(frame) + window.clearTimeout(timeout) + } + }, [enteringKey]) + + useEffect(() => { + const timeouts = removeTimeouts.current + return () => { + timeouts.forEach((timeout) => window.clearTimeout(timeout)) + } + }, []) + + return ( +
    +
    +
    +

    Rush Application

    + + {status.label} + +
    +
    + {!isDraft && cycleId ? ( + + + + ) : null} + {!isDraft && cycleId && accepting ? ( + + ) : null} + {!isDraft && cycleId && !accepting ? ( + + ) : null} + {!isDraft && isEditing ? ( + + ) : null} + {!isDraft && !isEditing ? ( + + ) : null} +
    +
    + +
    { + event.preventDefault() + if (!isDraft) void handleSave() + }} + > + {error ? ( +

    {error}

    + ) : null} + +
    +

    Welcome

    +
    +
    +
    + +