From 0d544a081d1800001e147250773529b8c6dfde11 Mon Sep 17 00:00:00 2001 From: In Lorthongpanich Date: Sun, 16 Aug 2026 03:08:58 +0700 Subject: [PATCH 1/6] Add rush application portal and CMS --- package-lock.json | 36 +- package.json | 3 +- src/app/admin/actions.ts | 98 +++ src/app/admin/page.tsx | 13 +- src/app/apply/ApplyDraftSection.tsx | 24 + src/app/apply/academic/page.tsx | 5 + src/app/apply/actions.ts | 167 ++++++ src/app/apply/additional/page.tsx | 5 + src/app/apply/involvement/page.tsx | 5 + src/app/apply/layout.tsx | 22 + src/app/apply/page.tsx | 108 ++++ src/app/apply/personal/page.tsx | 5 + src/app/apply/questions/page.tsx | 5 + src/app/apply/review/page.tsx | 5 + src/app/auth/callback/route.ts | 9 +- src/app/rush/page.tsx | 6 +- src/components/HamburgerHeader.tsx | 1 + src/components/RushApplicationManager.tsx | 565 ++++++++++++++++++ src/components/WideHeader.tsx | 2 +- src/components/apply/ApplySectionForm.tsx | 450 ++++++++++++++ src/components/apply/ApplyShell.tsx | 81 +++ src/components/apply/ApplyToast.tsx | 78 +++ src/components/apply/DummyFileField.tsx | 96 +++ src/components/apply/UmichGoogleButton.tsx | 41 ++ src/db/schema.ts | 141 ++++- src/lib/applications.ts | 224 +++++++ src/lib/apply-load.ts | 91 +++ src/lib/apply-schema.ts | 216 +++++++ src/lib/apply-steps.ts | 44 ++ src/lib/apply-store.ts | 85 +++ src/lib/rush-cycle-schema.ts | 72 +++ src/lib/rush-cycles.ts | 216 +++++++ src/lib/supabase/auth-helpers.ts | 11 + .../20260816001600_apply_tables.sql | 277 +++++++++ supabase/seed.sql | 66 ++ 35 files changed, 3256 insertions(+), 17 deletions(-) create mode 100644 src/app/apply/ApplyDraftSection.tsx create mode 100644 src/app/apply/academic/page.tsx create mode 100644 src/app/apply/actions.ts create mode 100644 src/app/apply/additional/page.tsx create mode 100644 src/app/apply/involvement/page.tsx create mode 100644 src/app/apply/layout.tsx create mode 100644 src/app/apply/page.tsx create mode 100644 src/app/apply/personal/page.tsx create mode 100644 src/app/apply/questions/page.tsx create mode 100644 src/app/apply/review/page.tsx create mode 100644 src/components/RushApplicationManager.tsx create mode 100644 src/components/apply/ApplySectionForm.tsx create mode 100644 src/components/apply/ApplyShell.tsx create mode 100644 src/components/apply/ApplyToast.tsx create mode 100644 src/components/apply/DummyFileField.tsx create mode 100644 src/components/apply/UmichGoogleButton.tsx create mode 100644 src/lib/applications.ts create mode 100644 src/lib/apply-load.ts create mode 100644 src/lib/apply-schema.ts create mode 100644 src/lib/apply-steps.ts create mode 100644 src/lib/apply-store.ts create mode 100644 src/lib/rush-cycle-schema.ts create mode 100644 src/lib/rush-cycles.ts create mode 100644 supabase/migrations/20260816001600_apply_tables.sql 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/src/app/admin/actions.ts b/src/app/admin/actions.ts index 3479c0b..53193b5 100644 --- a/src/app/admin/actions.ts +++ b/src/app/admin/actions.ts @@ -16,6 +16,14 @@ import { reorderRushEvents, toClientRushEvent, } from '@/lib/rush-events' +import { parseCycleId, parseRushCycle } from '@/lib/rush-cycle-schema' +import { + closeRushCycleNow, + createRushCycle, + getAdminCycle, + openRushCycleNow, + saveRushCycle, +} from '@/lib/rush-cycles' export type RushEventInput = RushEventWrite @@ -135,3 +143,93 @@ export async function updateRushEventOrder( return { data: null, error: 'Failed to update event order' } } } + +export async function getRushApplicationCycle() { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const data = await getAdminCycle() + return { data, error: null } +} + +export async function saveRushApplicationCycle( + cycleId: string | null, + input: unknown +) { + const auth = await requireAdmin() + if (auth.error) { + return { data: null, error: auth.error } + } + + const parsed = parseRushCycle(input) + if (parsed.error || !parsed.data) { + return { data: null, error: parsed.error } + } + + try { + if (cycleId) { + const parsedId = parseCycleId(cycleId) + if (parsedId.error || !parsedId.data) { + return { data: null, error: parsedId.error } + } + await saveRushCycle(parsedId.data, parsed.data) + } else { + await createRushCycle(parsed.data) + } + revalidatePath('/admin') + revalidatePath('/apply') + return { data: await getAdminCycle(), 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 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' } + revalidatePath('/admin') + revalidatePath('/apply') + return { data: await getAdminCycle(), 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' } + revalidatePath('/admin') + revalidatePath('/apply') + return { data: await getAdminCycle(), 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..5ae1195 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,7 +1,9 @@ import { redirect } from 'next/navigation' import { checkIsAdmin, getCurrentUser } from '@/lib/supabase/auth-helpers' import Header from '@/components/Header' +import RushApplicationManager from '@/components/RushApplicationManager' import RushScheduleManager from '@/components/RushScheduleManager' +import { getAdminCycle } from '@/lib/rush-cycles' import { getRushEvents, toClientRushEvent } from '@/lib/rush-events' import Unauthorized from '@/components/Unauthorized' @@ -19,6 +21,7 @@ export default async function AdminPage() { } const initialEvents = (await getRushEvents()).map(toClientRushEvent) + const applicationCycle = await getAdminCycle() 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)]' @@ -55,17 +58,17 @@ export default async function AdminPage() {

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

More Features

-

- Additional admin features will be added here. -

+
diff --git a/src/app/apply/ApplyDraftSection.tsx b/src/app/apply/ApplyDraftSection.tsx new file mode 100644 index 0000000..9097600 --- /dev/null +++ b/src/app/apply/ApplyDraftSection.tsx @@ -0,0 +1,24 @@ +import { ApplySectionForm } from '@/components/apply/ApplySectionForm' +import { ApplyShell } from '@/components/apply/ApplyShell' +import { requireApplyDraft } from '@/lib/apply-load' +import { applicationTitle, type ApplyStepSlug } from '@/lib/apply-steps' + +export async function ApplyDraftSection({ step }: { step: ApplyStepSlug }) { + const ctx = await requireApplyDraft() + + return ( + + + + ) +} diff --git a/src/app/apply/academic/page.tsx b/src/app/apply/academic/page.tsx new file mode 100644 index 0000000..a8648ea --- /dev/null +++ b/src/app/apply/academic/page.tsx @@ -0,0 +1,5 @@ +import { ApplyDraftSection } from '../ApplyDraftSection' + +export default function Page() { + return +} diff --git a/src/app/apply/actions.ts b/src/app/apply/actions.ts new file mode 100644 index 0000000..190d0a0 --- /dev/null +++ b/src/app/apply/actions.ts @@ -0,0 +1,167 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { requireUser } from '@/lib/supabase/auth-helpers' +import { + cycleWindow, + getActiveCycle, + getApplicationFiles, + getCycleQuestions, + getOrCreateApplication, + saveApplicationAnswers, + saveApplicationFields, + saveDummyFile, + deleteDummyFile, + submitApplication, +} from '@/lib/applications' +import { 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 } + + 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) + await saveApplicationAnswers( + auth.application.id, + questions.map((question) => ({ + questionId: question.id, + body: (input.answers[question.id] ?? '').trim() || null, + })) + ) + + 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 saved = await saveApplicationFields( + auth.application.id, + auth.user.id, + parsedFields.data + ) + if (!saved) return { error: 'Could not save before submit.' } + + const questions = await getCycleQuestions(auth.cycle.id) + await saveApplicationAnswers( + auth.application.id, + questions.map((question) => ({ + questionId: question.id, + body: (input.answers[question.id] ?? '').trim() || null, + })) + ) + + 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 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..22c8fea --- /dev/null +++ b/src/app/apply/additional/page.tsx @@ -0,0 +1,5 @@ +import { ApplyDraftSection } from '../ApplyDraftSection' + +export default function Page() { + return +} diff --git a/src/app/apply/involvement/page.tsx b/src/app/apply/involvement/page.tsx new file mode 100644 index 0000000..c76f6f5 --- /dev/null +++ b/src/app/apply/involvement/page.tsx @@ -0,0 +1,5 @@ +import { ApplyDraftSection } from '../ApplyDraftSection' + +export default function Page() { + 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..be04788 --- /dev/null +++ b/src/app/apply/page.tsx @@ -0,0 +1,108 @@ +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 { applicationTitle } from '@/lib/apply-steps' + +export default async function ApplyWelcomePage() { + const ctx = await loadApplyContext() + + 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()}.` + : 'The deadline for this cycle has passed. Please apply next semester.' + + 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..fb78fd0 --- /dev/null +++ b/src/app/apply/personal/page.tsx @@ -0,0 +1,5 @@ +import { ApplyDraftSection } from '../ApplyDraftSection' + +export default function Page() { + return +} diff --git a/src/app/apply/questions/page.tsx b/src/app/apply/questions/page.tsx new file mode 100644 index 0000000..e34b379 --- /dev/null +++ b/src/app/apply/questions/page.tsx @@ -0,0 +1,5 @@ +import { ApplyDraftSection } from '../ApplyDraftSection' + +export default function Page() { + return +} diff --git a/src/app/apply/review/page.tsx b/src/app/apply/review/page.tsx new file mode 100644 index 0000000..d0417b9 --- /dev/null +++ b/src/app/apply/review/page.tsx @@ -0,0 +1,5 @@ +import { ApplyDraftSection } from '../ApplyDraftSection' + +export default function Page() { + return +} diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index 0ecb3d0..c687525 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -16,7 +16,12 @@ export async function GET(request: Request) { } } - // URL to redirect to after sign in process completes - return NextResponse.redirect(`${origin}/admin`) + const nextParam = requestUrl.searchParams.get('next') + const next = + nextParam && nextParam.startsWith('/') && !nextParam.startsWith('//') + ? nextParam + : '/admin' + + return NextResponse.redirect(`${origin}${next}`) } diff --git a/src/app/rush/page.tsx b/src/app/rush/page.tsx index c0573d1..716538f 100644 --- a/src/app/rush/page.tsx +++ b/src/app/rush/page.tsx @@ -40,12 +40,10 @@ export default async function Rush() { Interest Form - Application Link + Application

Rush

  • Members
  • Nationals
  • +
  • Apply
  • diff --git a/src/components/RushApplicationManager.tsx b/src/components/RushApplicationManager.tsx new file mode 100644 index 0000000..571d1a4 --- /dev/null +++ b/src/components/RushApplicationManager.tsx @@ -0,0 +1,565 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { + closeRushApplicationNow, + openRushApplicationNow, + saveRushApplicationCycle, +} from '@/app/admin/actions' +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 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 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: 'Accepting responses', className: 'bg-[#315CA9] text-white' } +} + +export default function RushApplicationManager({ + initialCycle, + initialQuestions, +}: { + initialCycle: ClientRushCycle | null + initialQuestions: ClientCycleQuestion[] +}) { + const [cycleId, setCycleId] = useState(initialCycle?.id ?? null) + const [name, setName] = useState(initialCycle?.name ?? 'Fall 2026') + const [opensAt, setOpensAt] = useState( + initialCycle ? toDatetimeLocal(initialCycle.opens_at) : '' + ) + const [closesAt, setClosesAt] = useState( + initialCycle ? toDatetimeLocal(initialCycle.closes_at) : '' + ) + const [intro, setIntro] = useState(initialCycle?.intro_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 [savedAt, setSavedAt] = useState(null) + const [isEditing, setIsEditing] = useState(!initialCycle) + const [enteringKey, setEnteringKey] = useState(null) + const [exitingKeys, setExitingKeys] = useState([]) + const questionEls = useRef>(new Map()) + const removeTimeouts = useRef>>(new Map()) + + const status = useMemo( + () => + cycleStatus( + opensAt ? new Date(opensAt).toISOString() : '', + closesAt ? new Date(closesAt).toISOString() : '' + ), + [opensAt, closesAt] + ) + const cycleNameError = error === 'Cycle name is required' + const visibleQuestionCount = questions.length - exitingKeys.length + + const accepting = + Boolean(opensAt && closesAt) && + Date.now() >= new Date(opensAt).getTime() && + Date.now() <= new Date(closesAt).getTime() + + function applyServerState(data: { + cycle: ClientRushCycle | null + questions: ClientCycleQuestion[] + }) { + if (!data.cycle) return + setCycleId(data.cycle.id) + setName(data.cycle.name) + setOpensAt(toDatetimeLocal(data.cycle.opens_at)) + setClosesAt(toDatetimeLocal(data.cycle.closes_at)) + setIntro(data.cycle.intro_markdown ?? '') + setHearAbout(data.cycle.hear_about_options.join('\n')) + setQuestions( + data.questions.length ? questionsFromServer(data.questions) : [emptyQuestion()] + ) + setEnteringKey(null) + setExitingKeys([]) + } + + function payload() { + return { + name, + opens_at: new Date(opensAt).toISOString(), + closes_at: new Date(closesAt).toISOString(), + intro_markdown: intro, + hear_about_options: hearAbout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean), + is_active: true, + 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, + })), + } + } + + async function handleSave() { + setIsSaving(true) + setError(null) + const result = await saveRushApplicationCycle(cycleId, payload()) + setIsSaving(false) + if (result.error || !result.data) { + setError(result.error) + return + } + applyServerState(result.data) + setSavedAt(new Date().toLocaleTimeString()) + 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) + setSavedAt(new Date().toLocaleTimeString()) + } + + 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) + setSavedAt(new Date().toLocaleTimeString()) + } + + 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} + +
    +
    + {cycleId && accepting ? ( + + ) : null} + {cycleId && !accepting ? ( + + ) : null} + {isEditing ? ( + + ) : ( + + )} +
    +
    + +
    { + event.preventDefault() + void handleSave() + }} + > + {error && !cycleNameError ? ( +

    {error}

    + ) : null} + {savedAt && !isEditing ?

    Saved {savedAt}

    : null} + +
    +

    Welcome

    +
    +
    +
    + + { + setName(event.target.value) + if (cycleNameError) setError(null) + }} + placeholder="Fall 2026" + required + disabled={!isEditing} + /> + {cycleNameError ? ( +

    Cycle name is required

    + ) : null} +
    + +
    +
    + + setOpensAt(event.target.value)} + required + disabled={!isEditing} + /> +
    +
    + + setClosesAt(event.target.value)} + required + disabled={!isEditing} + /> +
    +
    + +
    + +