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 (
+
+ )
+}
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 (
+
+ )
+}
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
+
+
+
+
+ {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}
+
+
+
+
+
+ {!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}
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+ ) : (
+
+ )}
+
+
,
+ 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