diff --git a/.env.example b/.env.example
index 58f9385..62c0380 100644
--- a/.env.example
+++ b/.env.example
@@ -25,3 +25,16 @@ GOOGLE_CLIENT_SECRET=
# Your @umich.edu. Local only.
LOCAL_ADMIN_EMAILS=you@umich.edu
+
+# AWS S3 (dev bucket for local + Vercel Preview; prod bucket later)
+# Bucket CORS must allow PUT/GET from your dev origin (e.g. http://localhost:3000).
+AWS_REGION=
+AWS_ACCESS_KEY_ID=
+AWS_SECRET_ACCESS_KEY=
+S3_BUCKET=
+
+# AWS SES (application confirmation). Reuses AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY.
+# SES_REGION falls back to AWS_REGION. SES_REPLY_TO is optional.
+SES_REGION=us-east-2
+SES_FROM_EMAIL=noreply@ktpmichigan.com
+SES_REPLY_TO=
\ No newline at end of file
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 db71d64..7ebb244 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,9 @@
"name": "temp-next",
"version": "0.1.0",
"dependencies": {
+ "@aws-sdk/client-s3": "^3.1116.0",
+ "@aws-sdk/client-ses": "^3.1118.0",
+ "@aws-sdk/s3-request-presigner": "^3.1116.0",
"@fortawesome/fontawesome-svg-core": "^7.0.0",
"@fortawesome/free-solid-svg-icons": "^7.0.0",
"@fortawesome/react-fontawesome": "^0.2.3",
@@ -26,7 +29,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",
@@ -34,6 +38,7 @@
"@types/aos": "^3.0.7",
"@types/leaflet": "^1.9.20",
"@types/node": "^20",
+ "@types/papaparse": "^5.5.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-scroll": "^1.8.10",
@@ -60,6 +65,350 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@aws-sdk/checksums": {
+ "version": "3.1000.29",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.29.tgz",
+ "integrity": "sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-s3": {
+ "version": "3.1116.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1116.0.tgz",
+ "integrity": "sha512-UKRl9qSVW0rZpvSOauQNpYAy8+ONBAVYnpfKVtCyOF+FZVT1tl6MunYuHvuarCrroD2/YJs+tHTALYNgAlec3Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/checksums": "^3.1000.29",
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/credential-provider-node": "^3.972.81",
+ "@aws-sdk/middleware-sdk-s3": "^3.972.75",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.46",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/fetch-http-handler": "^5.7.2",
+ "@smithy/node-http-handler": "^4.11.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-ses": {
+ "version": "3.1118.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.1118.0.tgz",
+ "integrity": "sha512-qamd2FQKRgdj/2ipytnuvHzVzwB49PVbYoC77+Ibf/jlJu9HOaNEvMjKtnDOxo0WmIe8bCZ5C4CVSS7MipmK/w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/credential-provider-node": "^3.972.81",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/fetch-http-handler": "^5.7.2",
+ "@smithy/node-http-handler": "^4.11.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/core": {
+ "version": "3.977.9",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz",
+ "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.974.5",
+ "@aws-sdk/xml-builder": "^3.972.40",
+ "@aws/lambda-invoke-store": "^0.3.0",
+ "@smithy/core": "^3.33.3",
+ "@smithy/signature-v4": "^5.6.12",
+ "@smithy/types": "^4.17.2",
+ "bowser": "^2.11.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-env": {
+ "version": "3.972.70",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz",
+ "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-http": {
+ "version": "3.972.72",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz",
+ "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/fetch-http-handler": "^5.7.2",
+ "@smithy/node-http-handler": "^4.11.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-ini": {
+ "version": "3.973.15",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz",
+ "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/credential-provider-env": "^3.972.70",
+ "@aws-sdk/credential-provider-http": "^3.972.72",
+ "@aws-sdk/credential-provider-login": "^3.972.77",
+ "@aws-sdk/credential-provider-process": "^3.972.70",
+ "@aws-sdk/credential-provider-sso": "^3.973.14",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.76",
+ "@aws-sdk/nested-clients": "^3.997.44",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/credential-provider-imds": "^4.4.16",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-login": {
+ "version": "3.972.77",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz",
+ "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/nested-clients": "^3.997.44",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-node": {
+ "version": "3.972.81",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.81.tgz",
+ "integrity": "sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/credential-provider-env": "^3.972.70",
+ "@aws-sdk/credential-provider-http": "^3.972.72",
+ "@aws-sdk/credential-provider-ini": "^3.973.15",
+ "@aws-sdk/credential-provider-process": "^3.972.70",
+ "@aws-sdk/credential-provider-sso": "^3.973.14",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.76",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/credential-provider-imds": "^4.4.16",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-process": {
+ "version": "3.972.70",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz",
+ "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-sso": {
+ "version": "3.973.14",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz",
+ "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/nested-clients": "^3.997.44",
+ "@aws-sdk/token-providers": "3.1116.0",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-web-identity": {
+ "version": "3.972.76",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz",
+ "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/nested-clients": "^3.997.44",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-sdk-s3": {
+ "version": "3.972.75",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.75.tgz",
+ "integrity": "sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.46",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/nested-clients": {
+ "version": "3.997.44",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz",
+ "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.46",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/fetch-http-handler": "^5.7.2",
+ "@smithy/node-http-handler": "^4.11.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/s3-request-presigner": {
+ "version": "3.1116.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1116.0.tgz",
+ "integrity": "sha512-WwaaVpvrZyML5L8SNY7sUGsYlMeCHzQ/8A/ms7dz/7sfYRvPn+qf0OasUWk+zuT8qGwjsYhILD0WVtlqUU08pQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.46",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region": {
+ "version": "3.996.46",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz",
+ "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/signature-v4": "^5.6.12",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/token-providers": {
+ "version": "3.1116.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz",
+ "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/nested-clients": "^3.997.44",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/types": {
+ "version": "3.974.5",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz",
+ "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/xml-builder": {
+ "version": "3.972.40",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz",
+ "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws/lambda-invoke-store": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
+ "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -2375,6 +2724,87 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@smithy/core": {
+ "version": "3.33.3",
+ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz",
+ "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/credential-provider-imds": {
+ "version": "4.5.2",
+ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
+ "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.2",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/fetch-http-handler": {
+ "version": "5.7.2",
+ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz",
+ "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.2",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/node-http-handler": {
+ "version": "4.11.3",
+ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.3.tgz",
+ "integrity": "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/signature-v4": {
+ "version": "5.7.3",
+ "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz",
+ "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/types": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz",
+ "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/@supabase/auth-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.81.1.tgz",
@@ -2951,6 +3381,16 @@
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/papaparse": {
+ "version": "5.5.2",
+ "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
+ "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/phoenix": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
@@ -2961,7 +3401,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": {
@@ -4079,6 +4519,12 @@
"node": "*"
}
},
+ "node_modules/bowser": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
+ "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
+ "license": "MIT"
+ },
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -4342,7 +4788,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": {
@@ -10187,6 +10633,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 b0e1a76..956de7c 100644
--- a/package.json
+++ b/package.json
@@ -11,11 +11,15 @@
"db:generate": "drizzle-kit generate",
"db:studio": "drizzle-kit studio",
"db:seed-admins": "node scripts/seed-local-admins.mjs",
+ "db:seed-apps": "node scripts/seed-fake-applications.mjs",
"images:optimize:home": "node scripts/optimize-images.mjs --preset home",
"images:optimize:about": "node scripts/optimize-images.mjs --preset about",
"images:optimize:members": "node scripts/optimize-images.mjs --preset member"
},
"dependencies": {
+ "@aws-sdk/client-s3": "^3.1116.0",
+ "@aws-sdk/client-ses": "^3.1118.0",
+ "@aws-sdk/s3-request-presigner": "^3.1116.0",
"@fortawesome/fontawesome-svg-core": "^7.0.0",
"@fortawesome/free-solid-svg-icons": "^7.0.0",
"@fortawesome/react-fontawesome": "^0.2.3",
@@ -34,7 +38,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",
@@ -42,6 +47,7 @@
"@types/aos": "^3.0.7",
"@types/leaflet": "^1.9.20",
"@types/node": "^20",
+ "@types/papaparse": "^5.5.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-scroll": "^1.8.10",
diff --git a/public/emails/f26-rush/application-received.html b/public/emails/f26-rush/application-received.html
new file mode 100644
index 0000000..2f214b4
--- /dev/null
+++ b/public/emails/f26-rush/application-received.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+ KTP Application Received
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Hi there,
+
+ Thank you for applying to Kappa Theta Pi at the University of Michigan!
+
+ Your application for Kappa Theta Pi Fall 2026 Rush Application has been successfully received.
+
+ You may continue editing your responses until the application deadline.
+
+ View or update your application here:
+
+
+
+
+
+
+ View Application
+
+
+
+
+
+
+ If you have any questions about your application or the rush process, please email ktp-board@umich.edu .
+
+
+ Best,
+ Kappa Theta Pi
+ University of Michigan
+
+
+
+
+
+
+
+ Visit ktpmichigan.com and follow @ktpumich to learn more.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Having trouble viewing this email?
+ View in browser
+
+
+
+
+
+
+
+
+
+
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/public/images/home/phone_frame_3.png b/public/images/home/phone_frame_3.png
new file mode 100644
index 0000000..a34581c
Binary files /dev/null and b/public/images/home/phone_frame_3.png differ
diff --git a/public/images/home/phone_frame_4.png b/public/images/home/phone_frame_4.png
new file mode 100644
index 0000000..d002c0a
Binary files /dev/null and b/public/images/home/phone_frame_4.png differ
diff --git a/scripts/seed-fake-applications.mjs b/scripts/seed-fake-applications.mjs
new file mode 100644
index 0000000..6b5769d
--- /dev/null
+++ b/scripts/seed-fake-applications.mjs
@@ -0,0 +1,359 @@
+/**
+ * Seed fake submitted applications for local review testing.
+ *
+ * Usage:
+ * npm run db:seed-apps
+ * npm run db:seed-apps -- --count 12
+ * npm run db:seed-apps -- --cycle "Fall 2026 IN TEST"
+ *
+ * Requires DATABASE_URL in .env.local. Safe to re-run — skips seed emails
+ * already present on the target cycle.
+ */
+
+import { randomUUID } from 'crypto'
+import nextEnv from '@next/env'
+import postgres from 'postgres'
+
+const { loadEnvConfig } = nextEnv
+loadEnvConfig(process.cwd())
+
+const args = process.argv.slice(2)
+const countArg = args.find((arg, i) => args[i - 1] === '--count')
+const cycleArg = args.find((arg, i) => args[i - 1] === '--cycle')
+const count = Math.max(1, Math.min(50, Number(countArg ?? 16) || 16))
+
+if (!process.env.DATABASE_URL) {
+ console.error('DATABASE_URL is not set.')
+ process.exit(1)
+}
+
+const sql = postgres(process.env.DATABASE_URL, { prepare: false })
+
+const APPLICANTS = [
+ {
+ first: 'Alex',
+ last: 'Chen',
+ preferred: null,
+ majors: 'Computer Science',
+ activities: 'MRacing, Michigan Hackers',
+ },
+ {
+ first: 'Jordan',
+ last: 'Patel',
+ preferred: 'Jo',
+ majors: 'Data Science, Statistics',
+ activities: 'KTP interest event, WOLV TV',
+ },
+ {
+ first: 'Sam',
+ last: 'Nguyen',
+ preferred: null,
+ majors: 'Informatics',
+ activities: 'UX Club, Design at Michigan',
+ },
+ {
+ first: 'Riley',
+ last: 'Johnson',
+ preferred: null,
+ majors: 'Computer Engineering',
+ activities: 'Engineering Council, Solar Car',
+ },
+ {
+ first: 'Casey',
+ last: 'Kim',
+ preferred: 'Casey',
+ majors: 'Business Administration, CS minor',
+ activities: 'Entrepreneurship club, consulting group',
+ },
+ {
+ first: 'Morgan',
+ last: 'Williams',
+ preferred: null,
+ majors: 'Mathematics',
+ activities: 'Putnam prep, tutoring',
+ },
+ {
+ first: 'Taylor',
+ last: 'Brown',
+ preferred: null,
+ majors: 'Information Analysis',
+ activities: 'Campus recycling, IM sports',
+ },
+ {
+ first: 'Avery',
+ last: 'Martinez',
+ preferred: 'Ave',
+ majors: 'Computer Science, Economics',
+ activities: 'Investment club, app dev side projects',
+ },
+ {
+ first: 'Quinn',
+ last: 'Lee',
+ preferred: null,
+ majors: 'Electrical Engineering',
+ activities: 'IEEE, robotics lab',
+ },
+ {
+ first: 'Drew',
+ last: 'Singh',
+ preferred: null,
+ majors: 'Computer Science',
+ activities: 'Open-source contributions, hackathons',
+ },
+ {
+ first: 'Jamie',
+ last: 'Okafor',
+ preferred: 'Jamie',
+ majors: 'SI (UX Design)',
+ activities: 'Design for America, photography',
+ },
+ {
+ first: 'Blake',
+ last: 'Foster',
+ preferred: null,
+ majors: 'Computer Science',
+ activities: 'Teaching assistant, peer mentor',
+ },
+ {
+ first: 'Cameron',
+ last: 'Wright',
+ preferred: null,
+ majors: 'Cognitive Science',
+ activities: 'Research lab, debate',
+ },
+ {
+ first: 'Harper',
+ last: 'Diaz',
+ preferred: 'Harps',
+ majors: 'Computer Science, Spanish',
+ activities: 'Language exchange, hackathons',
+ },
+ {
+ first: 'Reese',
+ last: 'Nguyen',
+ preferred: null,
+ majors: 'Data Science',
+ activities: 'Analytics club, intramural soccer',
+ },
+ {
+ first: 'Parker',
+ last: 'Brooks',
+ preferred: null,
+ majors: 'Computer Engineering',
+ activities: 'MHacks, maker space',
+ },
+ {
+ first: 'Skyler',
+ last: 'Adams',
+ preferred: 'Sky',
+ majors: 'Business, CS minor',
+ activities: 'Startups club, volunteer tutoring',
+ },
+]
+
+const ESSAY_1 =
+ 'I would build a campus study-match app that pairs students by course and study style. As a first-gen student, finding study groups was hard; this product reflects my value of accessible community.'
+const ESSAY_2 =
+ 'A memory that stayed with me was fixing the projector before my high school club presentation. I learned to stay calm under pressure, which is how I approach team projects today.'
+const ESSAY_3 =
+ 'Technology — KTP would help me grow technical depth while giving back through mentorship.'
+
+function cycleSlug(name) {
+ return (
+ name
+ .replace(/\s*\(local\)\s*/gi, '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '') || 'cycle'
+ )
+}
+
+function fakeS3Key(cycleName, applicationId, slot, ext) {
+ return `applications/${cycleSlug(cycleName)}/${applicationId}/${slot}/${randomUUID()}.${ext}`
+}
+
+try {
+ const [cycle] = cycleArg
+ ? await sql`
+ select id, name
+ from public.rush_cycles
+ where name = ${cycleArg}
+ limit 1
+ `
+ : await sql`
+ select id, name
+ from public.rush_cycles
+ where is_active = true
+ limit 1
+ `
+
+ if (!cycle) {
+ console.error(
+ cycleArg
+ ? `No rush cycle named "${cycleArg}".`
+ : 'No active rush cycle found. Pass --cycle "Your Cycle Name".'
+ )
+ process.exit(1)
+ }
+
+ const questions = await sql`
+ select id, sort_order
+ from public.cycle_questions
+ where cycle_id = ${cycle.id}
+ order by sort_order asc
+ `
+
+ if (questions.length === 0) {
+ console.error(`Cycle "${cycle.name}" has no questions.`)
+ process.exit(1)
+ }
+
+ const [{ max_num: maxDisplay }] = await sql`
+ select coalesce(max(display_number), 0) as max_num
+ from public.applications
+ where cycle_id = ${cycle.id}
+ `
+
+ let nextDisplay = Number(maxDisplay) + 1
+ let created = 0
+ let skipped = 0
+
+ for (let i = 0; i < count; i += 1) {
+ const profile = APPLICANTS[i % APPLICANTS.length]
+ const index = i + 1
+ const email = `seed-applicant-${index}@umich.edu`
+ const userId = randomUUID()
+
+ const [existing] = await sql`
+ select id
+ from public.applications
+ where cycle_id = ${cycle.id}
+ and email = ${email}
+ limit 1
+ `
+
+ if (existing) {
+ skipped += 1
+ continue
+ }
+
+ const submittedAt = new Date(Date.now() - index * 3600_000).toISOString()
+ const displayNumber = nextDisplay
+ nextDisplay += 1
+
+ const [app] = await sql`
+ insert into public.applications (
+ cycle_id,
+ user_id,
+ email,
+ status,
+ submitted_at,
+ first_name,
+ last_name,
+ preferred_name,
+ pronouns,
+ phone,
+ majors,
+ minors,
+ graduation_year,
+ gpa,
+ semesters_remaining,
+ other_professional_fraternity,
+ campus_activities,
+ hear_about,
+ anything_else,
+ display_number,
+ review_count
+ )
+ values (
+ ${cycle.id},
+ ${userId},
+ ${email},
+ 'submitted',
+ ${submittedAt},
+ ${profile.first},
+ ${profile.last},
+ ${profile.preferred},
+ 'they/them',
+ '734-555-0100',
+ ${profile.majors},
+ null,
+ 2027,
+ 3.750,
+ 4,
+ false,
+ ${profile.activities},
+ ${['Instagram', 'Word of mouth']},
+ ${'Excited to rush KTP! (seed data)'},
+ ${displayNumber},
+ 0
+ )
+ returning id
+ `
+
+ const essayBodies = [ESSAY_1, ESSAY_2, ESSAY_3]
+ for (const question of questions) {
+ const body = essayBodies[question.sort_order] ?? `Seed essay for question ${question.sort_order + 1}.`
+ await sql`
+ insert into public.application_answers (application_id, question_id, body)
+ values (${app.id}, ${question.id}, ${body})
+ on conflict (application_id, question_id) do nothing
+ `
+ }
+
+ const files = [
+ { slot: 'photo', ext: 'png', mime: 'image/png', name: 'photo.png' },
+ { slot: 'transcript', ext: 'pdf', mime: 'application/pdf', name: 'transcript.pdf' },
+ { slot: 'resume', ext: 'pdf', mime: 'application/pdf', name: 'resume.pdf' },
+ {
+ slot: 'resume_anonymized',
+ ext: 'pdf',
+ mime: 'application/pdf',
+ name: 'resume-anonymized.pdf',
+ },
+ {
+ slot: 'life_app_screenshot',
+ ext: 'png',
+ mime: 'image/png',
+ name: 'life-app.png',
+ },
+ ]
+
+ for (const file of files) {
+ await sql`
+ insert into public.application_files (
+ application_id,
+ slot,
+ s3_key,
+ mime_type,
+ original_filename
+ )
+ values (
+ ${app.id},
+ ${file.slot},
+ ${fakeS3Key(cycle.name, app.id, file.slot, file.ext)},
+ ${file.mime},
+ ${file.name}
+ )
+ on conflict (application_id, slot) do nothing
+ `
+ }
+
+ created += 1
+ console.log(
+ ` #${displayNumber} ${profile.first} ${profile.last} <${email}>`
+ )
+ }
+
+ console.log(
+ `\nCycle: ${cycle.name}\nCreated ${created} application(s), skipped ${skipped} existing.`
+ )
+ if (created === 0 && skipped > 0) {
+ console.log(
+ 'All seed applicants already exist. Use a higher --count for new emails (seed-applicant-N@umich.edu).'
+ )
+ }
+} finally {
+ await sql.end()
+}
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/about/page.tsx b/src/app/about/page.tsx
index f851dfa..91a6ddd 100644
--- a/src/app/about/page.tsx
+++ b/src/app/about/page.tsx
@@ -1,242 +1,307 @@
-'use client';
-
-import React, { useState, useEffect, useRef } from 'react';
-import Link from 'next/link';
-import { Link as ScrollLink } from 'react-scroll';
-import { IoIosSpeedometer } from 'react-icons/io';
-import { MdOutlineWork } from "react-icons/md";
-import { FaPeopleGroup } from "react-icons/fa6";
-import { PiGlobeBold } from "react-icons/pi";
-import { HiAcademicCap } from "react-icons/hi2";
-import Header from '../../components/Header';
-import Footer from '../../components/Footer';
-const categories = ['President\'s Welcome', 'Our Pillars', 'History', 'DEI Commitment'];
+import Image from 'next/image'
+import { IoIosSpeedometer } from 'react-icons/io'
+import { MdOutlineWork } from 'react-icons/md'
+import { FaPeopleGroup } from 'react-icons/fa6'
+import { PiGlobeBold } from 'react-icons/pi'
+import { HiAcademicCap } from 'react-icons/hi2'
+import AboutSectionNav from '@/components/AboutSectionNav'
+import Header from '../../components/Header'
+import Footer from '../../components/Footer'
export default function About() {
- const [selectedCategory, setSelectedCategory] = useState('President\'s Welcome');
- const categoryRefs = useRef<(HTMLDivElement | null)[]>([]);
-
- useEffect(() => {
- // No longer needed since we're using button styling instead of underline
- }, [selectedCategory]);
-
- const handleCategoryClick = (category: string, index: number) => {
- setSelectedCategory(category);
- };
-
return (
-
- {/* Blob Container */}
-
+ {/* Spill clip — `page-spill-clip` is relative only on mobile (desktop CB unchanged).
+ Hero blobs: top/left only — never inset-0 (bottom:0 makes the layer page-tall,
+ so top:8% on children is 8% of the document, not the viewport). */}
+
-
- {/* Blob Container */}
-
-
- {/* Page Content */}
-
-
-
- About Us
-
-
- Learn more about who we are at Kappa Theta Pi!
-
+
+
+
+
+ About Us
+
+
+ Learn more about who we are at Kappa Theta Pi!
+
+
-
- {/* Category filter buttons */}
-
-
- {categories.map((category, index) => (
-
handleCategoryClick(category, index)}
- to={`${category.toLowerCase().replace(/\s+/g, '-')}-section`}
- smooth={true}
- duration={200}
- ref={(el: unknown) => { categoryRefs.current[index] = el as HTMLDivElement | null; }}
- >
- {category}
-
- ))}
+
-
- {/* Main content */}
-
- {/* President's Welcome */}
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
President's Welcome
-
-
- Welcome to the Alpha Chapter of Kappa Theta Pi, Michigan's premier professional technology fraternity. On behalf of our chapter, I am excited to welcome you to our fraternity's website, where you can catch a glimpse of the passion and excellence that our chapter celebrates.
-
-
- Kappa Theta Pi offers brothers the support to be extraordinary during their time at Michigan with resources centered around five pillars: professional development, alumni connections, social growth, technological advancement, and academic support. From project teams and study groups to professional development workshops and hackathons / design jams, we foster a culture of growth encouraging members to pursue their tech passions. Our chapter values diversity, with brothers contributing unique experiences and excelling as student leaders. We celebrate our diverse brotherhood, welcoming all united by a passion for technology.
-
-
- Reflecting on my time at Michigan, KTP has been the most impactful part of my college experience. I joined as a sophomore transfer student, unsure of where my path in technology would take me, both in college and beyond. Since then, KTP has given me growth, direction, and a diverse community of students that supports one another through struggles and success. To me, being a part of KTP is about figuring out your place in the present and the future, surrounded by a brilliant and ambitious community of individuals doing just the same. I invite you to explore our website and learn more about our brotherhood.
-
-
- With love,
- Teagan Hollman
- President, 2026
-
+
+
+ President's Welcome
+
+
+
+ Welcome to the Alpha Chapter of Kappa Theta Pi, Michigan's premier
+ professional technology fraternity. On behalf of our chapter, I am excited to
+ welcome you to our fraternity's website, where you can catch a glimpse of
+ the passion and excellence that our chapter celebrates.
+
+
+ Kappa Theta Pi offers brothers the support to be extraordinary during their
+ time at Michigan with resources centered around five pillars: professional
+ development, alumni connections, social growth, technological advancement,
+ and academic support. From project teams and study groups to professional
+ development workshops and hackathons / design jams, we foster a culture of
+ growth encouraging members to pursue their tech passions. Our chapter values
+ diversity, with brothers contributing unique experiences and excelling as
+ student leaders. We celebrate our diverse brotherhood, welcoming all united
+ by a passion for technology.
+
+
+ Reflecting on my time at Michigan, KTP has been the most impactful part of my
+ college experience. I joined as a sophomore transfer student, unsure of where
+ my path in technology would take me, both in college and beyond. Since then,
+ KTP has given me growth, direction, and a diverse community of students that
+ supports one another through struggles and success. To me, being a part of
+ KTP is about figuring out your place in the present and the future,
+ surrounded by a brilliant and ambitious community of individuals doing just
+ the same. I invite you to explore our website and learn more about our
+ brotherhood.
+
+
+ With love,
+
+ Teagan Hollman
+
+ President, 2026
+
+
-
- {/* Pillars */}
-
-
-
-
Our Pillars
-
+
+
+
+
Our Pillars
+
-
- {/* Professional Development */}
-
-
-
-
+
+
+
+
Professional Development
+
+ Through events like interview training, resume building, one-on-one mentorship,
+ private company recruiting, and more, Kappa Theta Pi Professional Development
+ aims to prepare members for success in any technology-related career. We take
+ pride in developing the tech leaders of the future.
+
-
Professional Development
-
Through events like interview training, resume building, one-on-one mentorship, private company recruiting, and more, Kappa Theta Pi Professional Development aims to prepare members for success in any technology-related career. We take pride in developing the tech leaders of the future.
-
- {/* Alumni Connections */}
-
-
-
-
+
+
+
Alumni Connections
+
+ Our alumni are spread out across the world and work on cutting-edge
+ technologies. They work at a plethora of companies - from tech companies like
+ Microsoft, Amazon, Facebook, Apple, and Google, to startups, consulting firms,
+ financial technology firms, and more!
+
-
Alumni Connections
-
Our alumni are spread out across the world and work on cutting-edge technologies. They work at a plethora of companies - from tech companies like Microsoft, Amazon, Facebook, Apple, and Google, to startups, consulting firms, financial technology firms, and more!
-
- {/* Social Growth */}
-
-
-
-
+
+
+
Social Growth
+
+ The people you meet in Kappa Theta Pi will go on to be some of your closest
+ friends throughout college and beyond. We host a variety of exclusive social
+ events throughout the semester through which our members can bond, some of
+ which include formal, tailgates, retreat, and apple picking.
+
-
Social Growth
-
The people you meet in Kappa Theta Pi will go on to be some of your closest friends throughout college and beyond. We host a variety of exclusive social events throughout the semester through which our members can bond, some of which include formal, tailgates, retreat, and apple picking.
-
- {/* Bottom Row */}
-
- {/* Technical Advancement */}
-
-
-
-
+
+
+
+
Technical Advancement
+
+ Kappa Theta Pi provides members numerous opportunities to enhance their current
+ technical skills, as well as learn new ones. Whether it be participation in one
+ of our various project teams or attending a technical workshop, we make it easy
+ for our members to expand their expertise.
+
-
Technical Advancement
-
Kappa Theta Pi provides members numerous opportunities to enhance their current technical skills, as well as learn new ones. Whether it be participation in one of our various project teams or attending a technical workshop, we make it easy for our members to expand their expertise.
-
- {/* Academic Support */}
-
-
-
-
+
+
+
Academic Support
+
+ Kappa Theta Pi brothers strive to foster academic growth and excellence for
+ each other. We provide a supportive network filled with some of the brightest
+ tech minds at the university that members can always rely on for help in
+ classes and extracurricular activities.
+
-
Academic Support
-
Kappa Theta Pi brothers strive to foster academic growth and excellence for each other. We provide a supportive network filled with some of the brightest tech minds at the university that members can always rely on for help in classes and extracurricular activities.
-
- {/* History */}
-
-
-
-
-
History
-
-
- Kappa Theta Pi takes pride in being the first professional technology fraternity in the country. Our members learn a plethora of skills needed to stay knowledgeable about the tech industry, as well as a strong sense of professional development for future job positions.
-
-
- KTP was founded on January 10, 2012, with the mission to create a tech community that enthusiastic students could join. In making KTP, the founders set up a strong community that has only grown in the 11 years since its inception.
-
-
- Our members come from all around campus. We are designers, analysts, computer scientists, engineers, artists, entrepreneurs, economists, philosophers, psychologists, and more. What makes the KTP community strong is our shared passion for technology and our unique backgrounds meshing together as one.
-
-
- Our alumni are part of an extensive and tight-knit network that stretches across the country. They can be found from Seattle to New York, from Silicon Valley to Detroit, in both startup companies and larger businesses. Our alumni provide valuable insight for our members' professional development.
-
+
+
+
+
+
History
+
+
+ Kappa Theta Pi takes pride in being the first professional technology
+ fraternity in the country. Our members learn a plethora of skills needed to
+ stay knowledgeable about the tech industry, as well as a strong sense of
+ professional development for future job positions.
+
+
+ KTP was founded on January 10, 2012, with the mission to create a tech
+ community that enthusiastic students could join. In making KTP, the founders
+ set up a strong community that has only grown in the 11 years since its
+ inception.
+
+
+ Our members come from all around campus. We are designers, analysts, computer
+ scientists, engineers, artists, entrepreneurs, economists, philosophers,
+ psychologists, and more. What makes the KTP community strong is our shared
+ passion for technology and our unique backgrounds meshing together as one.
+
+
+ Our alumni are part of an extensive and tight-knit network that stretches
+ across the country. They can be found from Seattle to New York, from Silicon
+ Valley to Detroit, in both startup companies and larger businesses. Our
+ alumni provide valuable insight for our members' professional
+ development.
+
+
-
-
-
-
-
+
-
-
- {/* DEI Commitment */}
-
-
-
-
DEI Commitment
-
-
- The world of technology is unique, diverse, and multi-faceted. We believe that our brothers should be too. In Kappa Theta Pi, we're passionate about cultivating an inclusive community that promotes and values diversity. Our dedication to diversity, equity, and inclusion is unwavering; these values are central to our mission and to our impact. We know that having heterogeneous perspectives helps generate better ideas to solve the nuanced problems of a changing — and increasingly diverse — world.
+
+
+
+
+ DEI Commitment
+
+
+
+ The world of technology is unique, diverse, and multi-faceted. We believe that
+ our brothers should be too. In Kappa Theta Pi, we're passionate about
+ cultivating an inclusive community that promotes and values diversity. Our
+ dedication to diversity, equity, and inclusion is unwavering; these values are
+ central to our mission and to our impact. We know that having heterogeneous
+ perspectives helps generate better ideas to solve the nuanced problems of a
+ changing — and increasingly diverse — world.
-
- In KTP, we have a responsibility to address structural inequality in our communities as well as the social and cultural dimensions of technology. We are committed to harnessing the best of KTP — our people, platform, and technical innovation — to make lasting change inside and outside of our organization.
-
+
+ In KTP, we have a responsibility to address structural inequality in our
+ communities as well as the social and cultural dimensions of technology. We are
+ committed to harnessing the best of KTP — our people, platform, and technical
+ innovation — to make lasting change inside and outside of our organization.
+
+
+
-
-
- );
+ )
}
diff --git a/src/app/admin/actions.ts b/src/app/admin/actions.ts
index 3479c0b..e7a7547 100644
--- a/src/app/admin/actions.ts
+++ b/src/app/admin/actions.ts
@@ -10,12 +10,29 @@ 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, searchBrothers, 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'
+import { saveRushRubric } from '@/lib/rubric-admin'
+import { parseRushRubricSave } from '@/lib/rubric-schema'
export type RushEventInput = RushEventWrite
@@ -27,31 +44,185 @@ async function requireAdmin() {
return { user, error: null }
}
-export async function listRushEvents() {
+function revalidateRush() {
+ revalidatePath('/admin')
+ revalidatePath('/admin/rush')
+ revalidatePath('/admin/apps')
+ revalidatePath('/rush')
+ revalidatePath('/apply')
+ revalidatePath('/portal/reads')
+}
+
+function revalidateMembers() {
+ revalidatePath('/admin')
+ revalidatePath('/admin/members')
+ revalidatePath('/portal')
+}
+
+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 }
+ revalidateMembers()
+ 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 }
+ revalidateMembers()
+ 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 }
+ revalidateMembers()
+ return { data: null, error: null }
+ } catch (error) {
+ console.error('Error removing brother:', error)
+ return { data: null, error: 'Failed to remove brother' }
+ }
+}
+
+export async function searchBrothersAction(query: string) {
const auth = await requireAdmin()
if (auth.error) {
return { data: null, error: auth.error }
}
- const events = await getRushEvents()
+ try {
+ const data = await searchBrothers(query, 8)
+ return { data, error: null }
+ } catch (error) {
+ console.error('Error searching brothers:', error)
+ return { data: null, error: 'Failed to search brothers' }
+ }
+}
+
+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 }
+ revalidateMembers()
+ 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 }
+ revalidateMembers()
+ 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 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 +243,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 +273,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 +296,212 @@ 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' }
+ }
+}
+
+export async function saveRushRubricCategories(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 = parseRushRubricSave(input)
+ if (parsed.error || !parsed.data) {
+ return { data: null, error: parsed.error }
+ }
+
+ try {
+ const categories = await saveRushRubric(parsedId.data, parsed.data)
+ revalidateRush()
+ return { data: { categories }, error: null }
+ } catch (error) {
+ console.error('Error saving rush rubric:', error)
+ const message = error instanceof Error ? error.message : 'Failed to save rubric'
+ return { data: null, error: message }
+ }
+}
diff --git a/src/app/admin/apps/[applicationId]/page.tsx b/src/app/admin/apps/[applicationId]/page.tsx
new file mode 100644
index 0000000..c51d696
--- /dev/null
+++ b/src/app/admin/apps/[applicationId]/page.tsx
@@ -0,0 +1,41 @@
+import { redirect } from 'next/navigation'
+import { AdminAppDetail } from '@/components/admin/AdminAppDetail'
+import { AdminPageShell } from '@/components/admin/AdminPageShell'
+import { AdminQuickLinks } from '@/components/admin/AdminQuickLinks'
+import Unauthorized from '@/components/Unauthorized'
+import { getAdjacentAdminApplications, getApplicationReviewDetailsForAdmin } from '@/lib/admin-applications'
+import { getAdminCycle } from '@/lib/rush-cycles'
+import { checkIsAdmin, getCurrentUser } from '@/lib/supabase/auth-helpers'
+
+export default async function AdminAppDetailPage({
+ params,
+}: {
+ params: Promise<{ applicationId: string }>
+}) {
+ const { applicationId } = await params
+ const currentUser = await getCurrentUser()
+ if (!currentUser) redirect('/login')
+
+ const adminUser = await checkIsAdmin()
+ if (!adminUser) return
+
+ const { cycle } = await getAdminCycle()
+ if (!cycle) redirect('/admin/apps')
+
+ const detail = await getApplicationReviewDetailsForAdmin(cycle.id, applicationId)
+ if (!detail) redirect('/admin/apps')
+
+ const { prevId, nextId } = await getAdjacentAdminApplications(cycle.id, applicationId)
+
+ return (
+
+
+
+
+ )
+}
diff --git a/src/app/admin/apps/actions.ts b/src/app/admin/apps/actions.ts
new file mode 100644
index 0000000..69a7e84
--- /dev/null
+++ b/src/app/admin/apps/actions.ts
@@ -0,0 +1,179 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { eq } from 'drizzle-orm'
+import { db } from '@/db'
+import { applications, rushCycles } from '@/db/schema'
+import { checkIsAdmin } from '@/lib/supabase/auth-helpers'
+import {
+ buildApplicationsExportCsv,
+ deleteApplicationForAdmin,
+ getApplicationReviewDetailsForAdmin,
+ listApplicationsForAdmin,
+ type AdminApplicationSortKey,
+} from '@/lib/admin-applications'
+import {
+ addReviewAccessEntry,
+ listReviewAccessForCycle,
+ removeReviewAccessEntry,
+ updateReviewAccessMinimum,
+} from '@/lib/review-access-admin'
+import { getApplicationFileForSlot } from '@/lib/applications'
+import { isApplicationFileKey } from '@/lib/apply-s3'
+import { FILE_SLOTS, type FileSlot } from '@/lib/apply-steps'
+import { createPresignedGetUrl } from '@/lib/s3'
+
+async function requireAdmin() {
+ const user = await checkIsAdmin()
+ if (!user) return { error: 'Unauthorized: Admin access required' as const, user: null }
+ return { error: null, user }
+}
+
+function revalidateApps() {
+ revalidatePath('/admin/apps')
+ revalidatePath('/admin')
+ revalidatePath('/portal/reads')
+}
+
+export async function listApplicationsForAdminAction(cycleId: string) {
+ const auth = await requireAdmin()
+ if (auth.error) return { error: auth.error, applications: null }
+ if (!cycleId) return { error: 'Missing cycle.', applications: null }
+
+ const applications = await listApplicationsForAdmin(cycleId)
+ return { error: null, applications }
+}
+
+export async function exportApplicationsCsvAction(
+ cycleId: string,
+ cycleName: string,
+ options?: { query?: string; sort?: AdminApplicationSortKey }
+) {
+ const auth = await requireAdmin()
+ if (auth.error) return { error: auth.error, csv: null, filename: null }
+ if (!cycleId) return { error: 'Missing cycle.', csv: null, filename: null }
+
+ const csv = await buildApplicationsExportCsv(cycleId, cycleName, options)
+ const slug = cycleName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
+ return {
+ error: null,
+ csv,
+ filename: `${slug || 'rush'}-applications.csv`,
+ }
+}
+
+export async function listReviewAccessAction(cycleId: string) {
+ const auth = await requireAdmin()
+ if (auth.error) return { error: auth.error, entries: null }
+ if (!cycleId) return { error: 'Missing cycle.', entries: null }
+
+ const entries = await listReviewAccessForCycle(cycleId)
+ return { error: null, entries }
+}
+
+export async function addReviewAccessAction(input: {
+ cycleId: string
+ email: string
+ minRequiredReviews?: number
+}) {
+ const auth = await requireAdmin()
+ if (auth.error) return { error: auth.error, entry: null }
+
+ const result = await addReviewAccessEntry(input)
+ if (result.error) return { error: result.error, entry: null }
+
+ revalidateApps()
+ return { error: null, entry: result.entry }
+}
+
+export async function removeReviewAccessAction(id: string, cycleId: string) {
+ const auth = await requireAdmin()
+ if (auth.error) return { error: auth.error }
+
+ const result = await removeReviewAccessEntry(id, cycleId)
+ if (result.error) return { error: result.error }
+
+ revalidateApps()
+ return { error: null }
+}
+
+export async function updateReviewAccessMinimumAction(input: {
+ id: string
+ cycleId: string
+ minRequiredReviews: number
+}) {
+ const auth = await requireAdmin()
+ if (auth.error) return { error: auth.error, entry: null }
+
+ const result = await updateReviewAccessMinimum(input)
+ if (result.error) return { error: result.error, entry: null }
+
+ revalidateApps()
+ return { error: null, entry: result.entry }
+}
+
+export async function getApplicationDetailAction(cycleId: string, applicationId: string) {
+ const auth = await requireAdmin()
+ if (auth.error) return { error: auth.error, detail: null }
+ if (!cycleId || !applicationId) return { error: 'Missing application.', detail: null }
+
+ const detail = await getApplicationReviewDetailsForAdmin(cycleId, applicationId)
+ if (!detail) return { error: 'Application not found.', detail: null }
+ return { error: null, detail }
+}
+
+export async function deleteApplicationAction(cycleId: string, applicationId: string) {
+ const auth = await requireAdmin()
+ if (auth.error) return { error: auth.error }
+ if (!cycleId || !applicationId) return { error: 'Missing application.' }
+
+ const result = await deleteApplicationForAdmin(cycleId, applicationId)
+ if (result.error) return { error: result.error }
+
+ revalidateApps()
+ revalidatePath(`/admin/apps/${applicationId}`)
+ return { error: null }
+}
+
+export async function getAdminApplicationFileDownloadUrl(input: {
+ applicationId: string
+ slot: string
+}) {
+ const auth = await requireAdmin()
+ if (auth.error) return { error: auth.error, downloadUrl: null }
+ if (!input.applicationId) return { error: 'Missing application.', downloadUrl: null }
+ if (!FILE_SLOTS.includes(input.slot as FileSlot)) {
+ return { error: 'Invalid file slot.' as const, downloadUrl: null }
+ }
+
+ const fileSlot = input.slot as FileSlot
+ const [app] = await db
+ .select({
+ id: applications.id,
+ cycleName: rushCycles.name,
+ })
+ .from(applications)
+ .innerJoin(rushCycles, eq(rushCycles.id, applications.cycleId))
+ .where(eq(applications.id, input.applicationId))
+ .limit(1)
+
+ if (!app) return { error: 'Application not found.', downloadUrl: null }
+
+ const file = await getApplicationFileForSlot(app.id, fileSlot)
+ if (!file?.s3Key) return { error: 'File not found.', downloadUrl: null }
+
+ if (!isApplicationFileKey(file.s3Key, app.id, fileSlot, app.cycleName)) {
+ return { error: 'Invalid file.', downloadUrl: null }
+ }
+
+ const presigned = await createPresignedGetUrl({
+ key: file.s3Key,
+ filename: file.originalFilename?.trim() || fileSlot,
+ contentType: file.mimeType,
+ })
+ if (presigned.error || !presigned.downloadUrl) {
+ return { error: presigned.error ?? 'Could not prepare download.', downloadUrl: null }
+ }
+
+ return { error: null, downloadUrl: presigned.downloadUrl }
+}
diff --git a/src/app/admin/apps/page.tsx b/src/app/admin/apps/page.tsx
new file mode 100644
index 0000000..1cef38c
--- /dev/null
+++ b/src/app/admin/apps/page.tsx
@@ -0,0 +1,184 @@
+import Link from 'next/link'
+import { redirect } from 'next/navigation'
+import { AdminAppsTable } from '@/components/admin/AdminAppsTable'
+import {
+ AdminPageShell,
+ adminPageTitleClass,
+ adminPageTitleStyle,
+} from '@/components/admin/AdminPageShell'
+import { AdminQuickLinks } from '@/components/admin/AdminQuickLinks'
+import { AdminReviewAccessManager } from '@/components/admin/AdminReviewAccessManager'
+import { AdminReviewerProgressTable } from '@/components/admin/AdminReviewerProgressTable'
+import {
+ adminHeadingClass,
+ adminInnerCardClass,
+ adminInnerCardStyle,
+ adminLinkClass,
+ adminMutedClass,
+ adminSectionCardClass,
+ adminSectionCardStyle,
+} from '@/components/admin/admin-ui'
+import Unauthorized from '@/components/Unauthorized'
+import { listApplicationsForAdmin } from '@/lib/admin-applications'
+import { listReviewerProgressForAdmin } from '@/lib/admin-reviewer-progress'
+import { listReviewAccessForCycle } from '@/lib/review-access-admin'
+import { getAdminCycle } from '@/lib/rush-cycles'
+import { checkIsAdmin, getCurrentUser } from '@/lib/supabase/auth-helpers'
+
+export default async function AdminAppsPage() {
+ const currentUser = await getCurrentUser()
+ if (!currentUser) redirect('/login')
+
+ const adminUser = await checkIsAdmin()
+ if (!adminUser) return
+
+ const { cycle } = await getAdminCycle()
+ if (!cycle) {
+ return (
+
+
+ Applications
+
+
+
+ Create a rush cycle before managing applications.
+
+
+ Go to rush admin
+
+
+ )
+ }
+
+ const [applications, reviewAccess, reviewerProgress] = await Promise.all([
+ listApplicationsForAdmin(cycle.id),
+ listReviewAccessForCycle(cycle.id),
+ listReviewerProgressForAdmin(cycle.id),
+ ])
+
+ const totalReads = applications.reduce((sum, app) => sum + app.readCount, 0)
+ const avgReadsPerApp =
+ applications.length > 0 ? (totalReads / applications.length).toFixed(1) : '—'
+ const avgReviewDurationMs = (() => {
+ const withDuration = reviewerProgress.filter((r) => r.avgDurationMs != null)
+ if (withDuration.length === 0) return null
+ return (
+ withDuration.reduce((sum, r) => sum + (r.avgDurationMs ?? 0), 0) /
+ withDuration.length
+ )
+ })()
+ const metMinimumCount = reviewerProgress.filter(
+ (r) => r.completedCount >= r.minRequiredReviews
+ ).length
+
+ return (
+
+
+ Applications
+
+
+
+
+
+
+
{cycle.name}
+
+ EBoard overview for this rush cycle. Reviewers only see anonymized applications.
+
+
+
+
+
+
+
+ Submitted
+
+
{applications.length}
+
+
+
+
+
+ Total reads
+
+
{totalReads}
+
+
+
+
+
+ Reviewers met min
+
+
+ {metMinimumCount}
+
+ {' '}
+ / {reviewAccess.length}
+
+
+
+
+
+
+
+ Avg reads / app
+
+
{avgReadsPerApp}
+
+
+
+
+
+ Avg review time
+
+
+ {avgReviewDurationMs == null
+ ? '—'
+ : Math.round(avgReviewDurationMs / 60000) < 1
+ ? '<1 min'
+ : `${Math.round(avgReviewDurationMs / 60000)} min`}
+
+
+
+
+
+
+
+
+
+ Submitted applications
+
+
+ Search, sort, and export the full applicant list.
+
+
+
+
+
+
+
+
Reviewer access
+
+ Brothers on this list can use Application Reads. They must already be in the brothers
+ directory. Site admins can always review.
+
+
+
+
+
+
+
+
Reviewer progress
+
+ Completed reads, remaining toward each reviewer's minimum, and average time per app.
+
+
+
+
+
+ )
+}
diff --git a/src/app/admin/debug/S3UploadSpike.tsx b/src/app/admin/debug/S3UploadSpike.tsx
new file mode 100644
index 0000000..3761232
--- /dev/null
+++ b/src/app/admin/debug/S3UploadSpike.tsx
@@ -0,0 +1,183 @@
+'use client'
+
+import { useRef, useState } from 'react'
+import { debugPresignPdfUpload, debugVerifyS3Upload } from '@/app/admin/debug/actions'
+
+type Step = 'idle' | 'presigning' | 'uploading' | 'verifying' | 'done' | 'error'
+
+type VerifiedObject = {
+ key: string
+ bucket: string
+ contentType: string | null
+ sizeBytes: number | null
+ etag: string | null
+ lastModified: string | null
+}
+
+export function S3UploadSpike({
+ s3Configured,
+ bucket,
+ region,
+}: {
+ s3Configured: boolean
+ bucket: string | null
+ region: string | null
+}) {
+ const inputRef = useRef
(null)
+ const [step, setStep] = useState('idle')
+ const [message, setMessage] = useState(null)
+ const [filename, setFilename] = useState(null)
+ const [object, setObject] = useState(null)
+
+ async function onChooseFile(event: React.ChangeEvent) {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ setFilename(file.name)
+ setObject(null)
+ setMessage(null)
+
+ if (file.type !== 'application/pdf') {
+ setStep('error')
+ setMessage('Choose a PDF file.')
+ if (inputRef.current) inputRef.current.value = ''
+ setFilename(null)
+ return
+ }
+
+ try {
+ setStep('presigning')
+ const presigned = await debugPresignPdfUpload({
+ contentType: file.type,
+ sizeBytes: file.size,
+ })
+ if (presigned.error || !presigned.uploadUrl || !presigned.key) {
+ setStep('error')
+ setMessage(presigned.error ?? 'Could not get presigned URL.')
+ return
+ }
+
+ setStep('uploading')
+ const uploadResponse = await fetch(presigned.uploadUrl, {
+ method: 'PUT',
+ body: file,
+ headers: { 'Content-Type': file.type },
+ })
+ if (!uploadResponse.ok) {
+ setStep('error')
+ setMessage(`Upload failed (${uploadResponse.status}). Check bucket CORS.`)
+ return
+ }
+
+ setStep('verifying')
+ const verified = await debugVerifyS3Upload(presigned.key)
+ if (verified.error || !verified.object) {
+ setStep('error')
+ setMessage(verified.error ?? 'Upload succeeded but verification failed.')
+ return
+ }
+
+ setObject(verified.object)
+ setStep('done')
+ setMessage('Upload verified.')
+ } catch (error) {
+ setStep('error')
+ setMessage(error instanceof Error ? error.message : 'Upload failed.')
+ }
+ }
+
+ function reset() {
+ setStep('idle')
+ setMessage(null)
+ setFilename(null)
+ setObject(null)
+ if (inputRef.current) inputRef.current.value = ''
+ }
+
+ const busy = step === 'presigning' || step === 'uploading' || step === 'verifying'
+ const statusLabel =
+ step === 'presigning'
+ ? 'Preparing upload…'
+ : step === 'uploading'
+ ? 'Uploading…'
+ : step === 'verifying'
+ ? 'Verifying…'
+ : null
+
+ return (
+
+ {!s3Configured ? (
+
+ S3 env vars are missing in .env.local. Restart dev after setting them.
+
+ ) : (
+
+ Bucket: {bucket}
+ {region ? (
+ <>
+ {' '}
+ · Region: {region}
+ >
+ ) : null}
+
+ )}
+
+
void onChooseFile(event)}
+ />
+
+
+
+ {busy ? statusLabel : filename ?? 'No file chosen'}
+
+
inputRef.current?.click()}
+ className="cursor-pointer rounded-[40px] border border-[#315CA9] px-4 py-2 text-sm font-semibold text-[#315CA9] transition-all duration-300 hover:scale-105 hover:bg-[#315CA9] hover:text-white hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50"
+ >
+ Choose PDF
+
+ {filename && !busy ? (
+
+ Reset
+
+ ) : null}
+
+
+ {message ? (
+
{message}
+ ) : null}
+
+ {object ? (
+
+
+
Key
+ {object.key}
+
+
+
Size
+ {object.sizeBytes?.toLocaleString() ?? '—'} bytes
+
+
+
ETag
+ {object.etag ?? '—'}
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/app/admin/debug/actions.ts b/src/app/admin/debug/actions.ts
new file mode 100644
index 0000000..1f1fb4f
--- /dev/null
+++ b/src/app/admin/debug/actions.ts
@@ -0,0 +1,74 @@
+'use server'
+
+import { randomUUID } from 'crypto'
+import { createPresignedPutUrl, headS3Object } from '@/lib/s3'
+import { checkIsAdmin } from '@/lib/supabase/auth-helpers'
+
+const DEBUG_PDF_MAX_BYTES = 10 * 1024 * 1024
+const DEBUG_PDF_MIME = 'application/pdf'
+
+async function requireAdmin() {
+ const user = await checkIsAdmin()
+ if (!user) {
+ return { error: 'Unauthorized: Admin access required' as const, user: null }
+ }
+ return { error: null, user }
+}
+
+function debugKeyForUser(userId: string) {
+ return `debug/${userId}/${randomUUID()}.pdf`
+}
+
+function isOwnedDebugKey(key: string, userId: string) {
+ return key.startsWith(`debug/${userId}/`) && key.endsWith('.pdf') && !key.includes('..')
+}
+
+export async function debugPresignPdfUpload(input: { contentType: string; sizeBytes: number }) {
+ const auth = await requireAdmin()
+ if (auth.error || !auth.user) {
+ return { error: auth.error, uploadUrl: null, key: null, bucket: null }
+ }
+
+ if (input.contentType !== DEBUG_PDF_MIME) {
+ return { error: 'Only PDF files are allowed.', uploadUrl: null, key: null, bucket: null }
+ }
+
+ if (input.sizeBytes <= 0 || input.sizeBytes > DEBUG_PDF_MAX_BYTES) {
+ return {
+ error: `File must be between 1 byte and ${DEBUG_PDF_MAX_BYTES / (1024 * 1024)} MB.`,
+ uploadUrl: null,
+ key: null,
+ bucket: null,
+ }
+ }
+
+ const key = debugKeyForUser(auth.user.id)
+ const presigned = await createPresignedPutUrl({
+ key,
+ contentType: DEBUG_PDF_MIME,
+ })
+
+ if (presigned.error || !presigned.uploadUrl) {
+ return { error: presigned.error, uploadUrl: null, key: null, bucket: null }
+ }
+
+ return {
+ error: null,
+ uploadUrl: presigned.uploadUrl,
+ key,
+ bucket: presigned.bucket,
+ }
+}
+
+export async function debugVerifyS3Upload(key: string) {
+ const auth = await requireAdmin()
+ if (auth.error || !auth.user) {
+ return { error: auth.error, object: null }
+ }
+
+ if (!isOwnedDebugKey(key, auth.user.id)) {
+ return { error: 'Invalid key.', object: null }
+ }
+
+ return headS3Object(key)
+}
diff --git a/src/app/admin/debug/page.tsx b/src/app/admin/debug/page.tsx
index b9c66b1..7470a71 100644
--- a/src/app/admin/debug/page.tsx
+++ b/src/app/admin/debug/page.tsx
@@ -1,29 +1,26 @@
-import { createClient } from '@/lib/supabase/server'
+import { redirect } from 'next/navigation'
import Link from 'next/link'
import Header from '@/components/Header'
+import Unauthorized from '@/components/Unauthorized'
+import { getS3ConfigStatus } from '@/lib/s3'
+import { checkIsAdmin, getCurrentUser } from '@/lib/supabase/auth-helpers'
+import { createClient } from '@/lib/supabase/server'
+import { S3UploadSpike } from '@/app/admin/debug/S3UploadSpike'
export default async function DebugPage() {
- const supabase = await createClient()
-
- const {
- data: { user },
- error: userError,
- } = await supabase.auth.getUser()
-
- let adminCheck = null
- let adminError = null
- if (user?.email) {
- const result = await supabase
- .from('admins')
- .select('*')
- .eq('email', user.email.toLowerCase())
- .single()
+ const currentUser = await getCurrentUser()
+ if (!currentUser) {
+ redirect('/login')
+ }
- adminCheck = result.data
- adminError = result.error
+ const adminUser = await checkIsAdmin()
+ if (!adminUser) {
+ return
}
+ const supabase = await createClient()
const { data: allAdmins } = await supabase.from('admins').select('*')
+ const s3Status = getS3ConfigStatus()
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)]'
@@ -54,46 +51,24 @@ export default async function DebugPage() {
-
User Info
- {userError && (
-
Error: {userError.message}
- )}
- {user ? (
-
-
- Email: {user.email}
-
-
- User ID: {user.id}
-
-
- Email ends with @umich.edu: {' '}
- {user.email?.endsWith('@umich.edu') ? 'Yes' : 'No'}
-
-
- ) : (
-
No user found. Please log in.
- )}
+
S3 Upload Test
+
-
Admin Check
- {adminError && (
-
- Error: {adminError.message} (Code: {adminError.code})
-
- )}
- {adminCheck ? (
-
- You are in the admins table.
+
User Info
+
+
+ Email: {adminUser.email}
- ) : user ? (
-
- You are not in the admins table. Your email is: {user.email}
+
+ User ID: {adminUser.id}
- ) : (
-
Cannot check — no user logged in.
- )}
+
@@ -101,16 +76,11 @@ export default async function DebugPage() {
{allAdmins && allAdmins.length > 0 ? (
{allAdmins.map((admin) => {
- const isCurrentUser =
- user?.email?.toLowerCase() === admin.email
+ const isCurrentUser = adminUser.email?.toLowerCase() === admin.email
return (
{admin.email}
{isCurrentUser && ' (this is you)'}
diff --git a/src/app/admin/members/page.tsx b/src/app/admin/members/page.tsx
new file mode 100644
index 0000000..33db17e
--- /dev/null
+++ b/src/app/admin/members/page.tsx
@@ -0,0 +1,38 @@
+import { redirect } from 'next/navigation'
+import AdminListManager from '@/components/AdminListManager'
+import BrotherListManager from '@/components/BrotherListManager'
+import {
+ AdminPageShell,
+ adminPageTitleClass,
+ adminPageTitleStyle,
+} from '@/components/admin/AdminPageShell'
+import { AdminQuickLinks } from '@/components/admin/AdminQuickLinks'
+import Unauthorized from '@/components/Unauthorized'
+import { listAdmins } from '@/lib/admins'
+import { listBrothers } from '@/lib/brothers'
+import { checkIsAdmin, getCurrentUser } from '@/lib/supabase/auth-helpers'
+
+export default async function AdminMembersPage() {
+ const currentUser = await getCurrentUser()
+ if (!currentUser) redirect('/login')
+
+ const adminUser = await checkIsAdmin()
+ if (!adminUser) return
+
+ const [admins, brothers] = await Promise.all([listAdmins(), listBrothers()])
+
+ return (
+
+
+ Members
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index a05fa95..a88e730 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -1,78 +1,60 @@
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 {
+ AdminPageShell,
+ adminPageTitleStyle,
+} from '@/components/admin/AdminPageShell'
+import { AdminQuickLinks } from '@/components/admin/AdminQuickLinks'
+import { SignedInAccountBar } from '@/components/SignedInAccountBar'
import Unauthorized from '@/components/Unauthorized'
+import { checkIsAdmin, getCurrentUser } from '@/lib/supabase/auth-helpers'
+
+function VerifiedCheckIcon() {
+ return (
+
+
+
+ )
+}
export default async function AdminPage() {
const currentUser = await getCurrentUser()
-
- if (!currentUser) {
- redirect('/login')
- }
+ if (!currentUser) redirect('/login')
const adminUser = await checkIsAdmin()
+ if (!adminUser) return
- if (!adminUser) {
- 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 email = adminUser.email ?? currentUser.email ?? undefined
return (
-
-
-
-
- {/* Blob Container — green + blue, clustered around the header */}
-
-
-
-
-
-
- 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.
-
-
-
-
-
-
+
+
+
+ Admin Dashboard
+
+
+
-
+
+
+
)
}
-
diff --git a/src/app/admin/rush/page.tsx b/src/app/admin/rush/page.tsx
new file mode 100644
index 0000000..9d60249
--- /dev/null
+++ b/src/app/admin/rush/page.tsx
@@ -0,0 +1,41 @@
+import { redirect } from 'next/navigation'
+import AdminRushDashboard from '@/components/AdminRushDashboard'
+import {
+ AdminPageShell,
+ adminPageTitleClass,
+ adminPageTitleStyle,
+} from '@/components/admin/AdminPageShell'
+import { AdminQuickLinks } from '@/components/admin/AdminQuickLinks'
+import Unauthorized from '@/components/Unauthorized'
+import { getAdminCycle, listRushCycles } from '@/lib/rush-cycles'
+import { checkIsAdmin, getCurrentUser } from '@/lib/supabase/auth-helpers'
+
+export default async function AdminRushPage() {
+ const currentUser = await getCurrentUser()
+ if (!currentUser) redirect('/login')
+
+ const adminUser = await checkIsAdmin()
+ if (!adminUser) return
+
+ const [cycles, applicationCycle] = await Promise.all([listRushCycles(), getAdminCycle()])
+ const initialBundle = applicationCycle.cycle
+ ? {
+ cycle: applicationCycle.cycle,
+ questions: applicationCycle.questions,
+ events: applicationCycle.events,
+ categories: applicationCycle.categories,
+ }
+ : null
+
+ return (
+
+
+ Rush
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/apply/ApplyDraftSection.tsx b/src/app/apply/ApplyDraftSection.tsx
new file mode 100644
index 0000000..75d470e
--- /dev/null
+++ b/src/app/apply/ApplyDraftSection.tsx
@@ -0,0 +1,40 @@
+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..bca66ef
--- /dev/null
+++ b/src/app/apply/actions.ts
@@ -0,0 +1,336 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { checkIsAdmin, requireUser } from '@/lib/supabase/auth-helpers'
+import { getBrotherByUmichEmail } from '@/lib/brothers'
+import {
+ cycleWindow,
+ deleteApplicationFileRecord,
+ getActiveCycle,
+ getApplicationFileForSlot,
+ getApplicationFiles,
+ getApplicationForUser,
+ getCycleQuestions,
+ getOrCreateApplication,
+ saveApplicationAnswers,
+ saveApplicationFields,
+ saveApplicationFileRecord,
+ submitApplication,
+} from '@/lib/applications'
+import { parseApplicationAnswers, parseApplicationFields, parseSubmitPayload } from '@/lib/apply-schema'
+import { resolveUploadMime, validateApplyFile } from '@/lib/apply-files'
+import { buildApplicationFileKey, isApplicationFileKey, isDeletableS3ObjectKey } from '@/lib/apply-s3'
+import { sendApplicationConfirmation } from '@/lib/application-confirmation-email'
+import { createPresignedGetUrl, createPresignedPutUrl, deleteS3Object, headS3Object } from '@/lib/s3'
+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 isAdmin = await checkIsAdmin()
+ if (!isAdmin && (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 (!window.isOpen && !isAdmin) {
+ return { error: 'This application cycle is not open for edits.' as const }
+ }
+
+ return { user, cycle, application, error: null }
+}
+
+async function requireApplicationOwner() {
+ const user = await requireUser()
+ if (!user?.email) return { error: 'Please log in with your UMich Google account.' as const }
+
+ const isAdmin = await checkIsAdmin()
+ if (!isAdmin && (await getBrotherByUmichEmail(user.email))) {
+ return { error: 'Brothers cannot access rush applications.' as const }
+ }
+
+ const cycle = await getActiveCycle()
+ if (!cycle) return { error: 'Applications are not open.' as const }
+
+ const application = await getApplicationForUser(cycle.id, user.id)
+ if (!application) {
+ return { error: 'No application found for this cycle.' as const }
+ }
+
+ return { user, cycle, application, error: null }
+}
+
+function validateFileInput(input: {
+ slot: string
+ filename: string
+ mimeType: string
+ sizeBytes: number
+}) {
+ if (!FILE_SLOTS.includes(input.slot as FileSlot)) {
+ return { error: 'Invalid file slot' as const, slot: null, contentType: null }
+ }
+ if (!input.filename.trim()) {
+ return { error: 'Choose a file first' as const, slot: null, contentType: null }
+ }
+
+ const slot = input.slot as FileSlot
+ const fileCheck = validateApplyFile({
+ slot,
+ filename: input.filename,
+ mimeType: input.mimeType,
+ sizeBytes: input.sizeBytes,
+ })
+ if (fileCheck.error) {
+ return { error: fileCheck.error, slot: null, contentType: null }
+ }
+
+ const contentType = resolveUploadMime({
+ slot,
+ mimeType: input.mimeType,
+ filename: input.filename,
+ })
+ if (!contentType) {
+ return { error: 'Unsupported file type.', slot: null, contentType: null }
+ }
+
+ return { error: null, slot, contentType }
+}
+
+export async function presignApplyFileUpload(input: {
+ slot: string
+ filename: string
+ mimeType: string
+ sizeBytes: number
+}) {
+ const auth = await requireDraftOwner()
+ if (auth.error) {
+ return { error: auth.error, uploadUrl: null, key: null }
+ }
+
+ const parsed = validateFileInput(input)
+ if (parsed.error || !parsed.slot || !parsed.contentType) {
+ return { error: parsed.error, uploadUrl: null, key: null }
+ }
+
+ const key = buildApplicationFileKey(
+ auth.cycle.name,
+ auth.application.id,
+ parsed.slot,
+ parsed.contentType
+ )
+ const presigned = await createPresignedPutUrl({
+ key,
+ contentType: parsed.contentType,
+ })
+ if (presigned.error || !presigned.uploadUrl) {
+ return { error: presigned.error ?? 'Could not prepare upload.', uploadUrl: null, key: null }
+ }
+
+ return { error: null, uploadUrl: presigned.uploadUrl, key }
+}
+
+export async function confirmApplyFileUpload(input: {
+ slot: string
+ key: string
+ filename: string
+ mimeType: string
+ sizeBytes: number
+}) {
+ const auth = await requireDraftOwner()
+ if (auth.error) {
+ return { error: auth.error, file: null }
+ }
+
+ const parsed = validateFileInput(input)
+ if (parsed.error || !parsed.slot || !parsed.contentType) {
+ return { error: parsed.error, file: null }
+ }
+ if (!isApplicationFileKey(input.key, auth.application.id, parsed.slot, auth.cycle.name)) {
+ return { error: 'Invalid upload key.', file: null }
+ }
+
+ const object = await headS3Object(input.key)
+ if (object.error || !object.object) {
+ return { error: 'Upload not found in storage. Try again.', file: null }
+ }
+ if (object.object.sizeBytes !== input.sizeBytes) {
+ return { error: 'Uploaded file size does not match.', file: null }
+ }
+
+ const previous = await getApplicationFileForSlot(auth.application.id, parsed.slot)
+ const saved = await saveApplicationFileRecord({
+ applicationId: auth.application.id,
+ slot: parsed.slot,
+ s3Key: input.key,
+ mimeType: parsed.contentType,
+ sizeBytes: input.sizeBytes,
+ originalFilename: input.filename.trim(),
+ })
+
+ if (
+ previous?.s3Key &&
+ previous.s3Key !== input.key &&
+ isDeletableS3ObjectKey(previous.s3Key)
+ ) {
+ await deleteS3Object(previous.s3Key)
+ }
+
+ revalidatePath('/apply')
+ return {
+ error: null,
+ file: {
+ slot: saved.slot,
+ filename: saved.originalFilename,
+ },
+ }
+}
+
+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 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' }
+ }
+
+ const fileSlot = slot as FileSlot
+ const removed = await deleteApplicationFileRecord(auth.application.id, fileSlot)
+ if (removed?.s3Key && isDeletableS3ObjectKey(removed.s3Key)) {
+ await deleteS3Object(removed.s3Key)
+ }
+
+ revalidatePath('/apply')
+ return { error: null }
+}
+
+export async function getApplyFileDownloadUrl(slot: string) {
+ const auth = await requireApplicationOwner()
+ if (auth.error) {
+ return { error: auth.error, downloadUrl: null }
+ }
+
+ if (!FILE_SLOTS.includes(slot as FileSlot)) {
+ return { error: 'Invalid file slot' as const, downloadUrl: null }
+ }
+
+ const fileSlot = slot as FileSlot
+ const file = await getApplicationFileForSlot(auth.application.id, fileSlot)
+ if (!file?.s3Key) {
+ return { error: 'File not found.' as const, downloadUrl: null }
+ }
+
+ if (!isApplicationFileKey(file.s3Key, auth.application.id, fileSlot, auth.cycle.name)) {
+ return { error: 'Invalid file.' as const, downloadUrl: null }
+ }
+
+ const presigned = await createPresignedGetUrl({
+ key: file.s3Key,
+ filename: file.originalFilename?.trim() || `${fileSlot}`,
+ contentType: file.mimeType,
+ })
+ if (presigned.error || !presigned.downloadUrl) {
+ return { error: presigned.error ?? 'Could not prepare download.', downloadUrl: null }
+ }
+
+ return { error: null, downloadUrl: presigned.downloadUrl }
+}
+
+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)
+
+ if (auth.application.status === 'draft') {
+ const submitted = await submitApplication(auth.application.id, auth.user.id)
+ if (!submitted) return { error: 'Submit failed.' }
+
+ // Email failure must not roll back a successful submission.
+ const emailResult = await sendApplicationConfirmation({
+ email: auth.application.email,
+ preferredName: parsedFields.data.preferred_name,
+ firstName: parsedFields.data.first_name,
+ cycleName: auth.cycle.name,
+ closesAt: auth.cycle.closesAt,
+ })
+ if (emailResult.error) {
+ console.error('Application confirmation email failed:', emailResult.error)
+ }
+ }
+
+ 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..8fd72fe
--- /dev/null
+++ b/src/app/apply/layout.tsx
@@ -0,0 +1,38 @@
+import Footer from '@/components/Footer'
+import Header from '@/components/Header'
+
+export default function ApplyLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {/*
+ Mobile blobs on the outer relative CB (not inside page-spill-clip) so soft
+ edges can sit under the sticky header. Sideways spill is clipped via
+ .apply-hero-blobs { overflow: hidden }. top/left only — never inset-0.
+ */}
+
+
+
+
+ {/* Spill clip — relative only on mobile; desktop CB stays the outer `relative` */}
+
+ {/* Desktop blobs — same positions as before */}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/apply/page.tsx b/src/app/apply/page.tsx
new file mode 100644
index 0000000..ebb580e
--- /dev/null
+++ b/src/app/apply/page.tsx
@@ -0,0 +1,168 @@
+import Link from 'next/link'
+import { ApplySubmittedHome } from '@/components/apply/ApplySubmittedHome'
+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 params = await searchParams
+ const preview = parseApplyPreview(params)
+ const ctx = await loadApplyContext(preview)
+ const showUpdated = params.updated === '1'
+
+ if (ctx.isPreview && ctx.cycle) {
+ const title = applicationTitle(ctx.cycle.name)
+ return (
+
+
+ {ctx.cycle.introMarkdown}
+
+ Continue application
+
+
+
+ )
+ }
+
+ if (ctx.isBrother && !ctx.isAdmin) {
+ 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') {
+ const canEdit = Boolean(ctx.window?.isOpen || ctx.isAdmin)
+
+ return (
+
+
+
+ )
+ }
+
+ if (ctx.window && !ctx.window.isOpen && !ctx.isAdmin) {
+ return (
+
+
+ {closedCopy}
+
+
+ )
+ }
+
+ const adminHasProgress =
+ ctx.isAdmin &&
+ (Object.values(ctx.application?.fields ?? {}).some((value) =>
+ Array.isArray(value) ? value.length > 0 : value != null && value !== ''
+ ) ||
+ Object.values(ctx.answers).some((answer) => answer.trim()) ||
+ Object.keys(ctx.files).length > 0)
+
+ const continueLabel = ctx.isAdmin
+ ? adminHasProgress
+ ? 'Continue as administrator'
+ : 'Start as administrator'
+ : 'Continue application'
+
+ return (
+
+
+ {ctx.cycle.introMarkdown}
+ {ctx.isAdmin ? (
+
+ You are testing as an administrator. Your responses are saved to a live draft for this
+ cycle. If you submit, please delete the test application afterward so it is not reviewed
+ with real applicants.
+
+ ) : null}
+
+ {continueLabel}
+
+
+
+ )
+}
+
+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..00f2a1c 100644
--- a/src/app/auth/callback/route.ts
+++ b/src/app/auth/callback/route.ts
@@ -1,22 +1,48 @@
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')) {
+ if (await checkIsAdmin()) {
+ return NextResponse.redirect(`${origin}${explicitNext}`)
+ }
+ return NextResponse.redirect(`${origin}/apply`)
+ }
+ return NextResponse.redirect(`${origin}${explicitNext ?? '/portal'}`)
+ }
+
+ // Rushee / non-brother: never send to portal or admin from this callback
+ if (explicitNext?.startsWith('/apply')) {
+ return NextResponse.redirect(`${origin}${explicitNext}`)
+ }
+ return NextResponse.redirect(`${origin}/apply`)
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 3beaeda..6587026 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -28,20 +28,30 @@ body {
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
- overflow-x: hidden;
+ /* clip (not hidden) — hidden on body breaks position:sticky headers */
+ overflow-x: clip;
overflow-y: auto;
}
+/* Mobile only: make page clip wrappers contain abspos blobs without shifting desktop CBs */
+@media (max-width: 1023px) {
+ .page-spill-clip {
+ position: relative;
+ overflow-x: clip;
+ }
+}
+
/* Improve SVG rendering quality */
img[src*=".svg"] {
image-rendering: crisp-edges;
}
-/* Specific styling for phone frame images to improve quality */
+/* Phone frame PNGs — don't use crisp-edges (that was for the old SVGs) */
img[src*="phone_frame"] {
- image-rendering: crisp-edges;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
+ image-rendering: auto;
+ width: auto !important;
+ max-width: none;
+ object-fit: contain;
}
code {
@@ -52,10 +62,139 @@ html {
scroll-behavior: smooth;
}
-/* Match Tailwind v4 ::placeholder color on empty date/time fields */
+/* Empty date/time — force slate-500 to beat Tailwind text-slate-100 on admin fields */
input[type="date"].datetime-empty,
-input[type="time"].datetime-empty {
- color: color-mix(in oklab, currentcolor 50%, transparent);
+input[type="time"].datetime-empty,
+input[type="datetime-local"].datetime-empty {
+ color: #64748b !important;
+}
+
+input[type="date"],
+input[type="time"],
+input[type="datetime-local"] {
+ color-scheme: dark;
+ display: flex;
+ align-items: center;
+ box-sizing: border-box;
+ /* Native controls have a large intrinsic min-width — without this they blow out grid cards */
+ min-width: 0 !important;
+ max-width: 100% !important;
+ width: 100% !important;
+}
+
+/*
+ * iOS Safari: the ::-webkit-datetime-edit internals keep a fixed preferred width
+ * even when the is width:100%. Force those pieces to shrink/clip,
+ * and keep the value vertically centered in h-10 fields.
+ */
+input[type="date"]::-webkit-datetime-edit,
+input[type="time"]::-webkit-datetime-edit,
+input[type="datetime-local"]::-webkit-datetime-edit {
+ min-width: 0;
+ max-width: 100%;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+}
+
+input[type="date"]::-webkit-datetime-edit-fields-wrapper,
+input[type="time"]::-webkit-datetime-edit-fields-wrapper,
+input[type="datetime-local"]::-webkit-datetime-edit-fields-wrapper {
+ min-width: 0;
+ max-width: 100%;
+ display: flex;
+ align-items: center;
+}
+
+/* Wrapper used around date/time fields in admin forms */
+.admin-datetime-wrap {
+ min-width: 0;
+ max-width: 100%;
+ overflow: hidden;
+ border-radius: 0.375rem; /* match rounded-md fields */
+ /* Keep a visible right edge/radius when WebKit content is clipped inside */
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
+}
+
+.admin-datetime-wrap:focus-within {
+ box-shadow:
+ inset 0 0 0 1px rgba(255, 255, 255, 0.3),
+ 0 0 0 3px rgba(255, 255, 255, 0.08);
+}
+
+.admin-datetime-wrap input[type='date'],
+.admin-datetime-wrap input[type='time'],
+.admin-datetime-wrap input[type='datetime-local'] {
+ /* Wrapper draws the border — avoid a double line / clipped input stroke */
+ border-color: transparent !important;
+ border-radius: 0;
+ box-shadow: none !important;
+}
+
+/* Phones: slightly tighter field padding so datetime chrome fits the card */
+@media (max-width: 430px) {
+ input[type='date'].admin-field,
+ input[type='time'].admin-field,
+ input[type='datetime-local'].admin-field,
+ input[type='date'],
+ input[type='time'],
+ input[type='datetime-local'] {
+ padding-left: 0.5rem;
+ padding-right: 0.35rem;
+ font-size: 0.8125rem;
+ }
+
+ input[type='date']::-webkit-calendar-picker-indicator,
+ input[type='time']::-webkit-calendar-picker-indicator,
+ input[type='datetime-local']::-webkit-calendar-picker-indicator {
+ width: 1rem;
+ height: 1rem;
+ margin-left: 0;
+ background-size: 1rem 1rem;
+ }
+}
+
+/* Replace native (often black) picker glyphs with slate-500 SVGs */
+input[type="date"]::-webkit-calendar-picker-indicator,
+input[type="datetime-local"]::-webkit-calendar-picker-indicator {
+ cursor: pointer;
+ opacity: 1;
+ width: 1.1rem;
+ height: 1.1rem;
+ padding: 0;
+ margin-left: 0.25rem;
+ border: none;
+ color: transparent;
+ background-color: transparent;
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: 1.1rem 1.1rem;
+ filter: none;
+ -webkit-appearance: none;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2364748b' stroke-width='1.75'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M8 2v2m8-2v2M4.5 8h15M6 4h12a2 2 0 012 2v13a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2z'/%3E%3C/svg%3E");
+}
+
+input[type="time"]::-webkit-calendar-picker-indicator {
+ cursor: pointer;
+ opacity: 1;
+ width: 1.1rem;
+ height: 1.1rem;
+ padding: 0;
+ margin-left: 0.25rem;
+ border: none;
+ color: transparent;
+ background-color: transparent;
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: 1.1rem 1.1rem;
+ filter: none;
+ -webkit-appearance: none;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2364748b' stroke-width='1.75'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M12 7v5l3 2m6-2a9 9 0 11-18 0 9 9 0 0118 0z'/%3E%3C/svg%3E");
}
input[type="date"].datetime-empty::-webkit-datetime-edit,
@@ -70,8 +209,18 @@ input[type="time"].datetime-empty::-webkit-datetime-edit-text,
input[type="time"].datetime-empty::-webkit-datetime-edit-hour-field,
input[type="time"].datetime-empty::-webkit-datetime-edit-minute-field,
input[type="time"].datetime-empty::-webkit-datetime-edit-second-field,
-input[type="time"].datetime-empty::-webkit-datetime-edit-ampm-field {
- color: inherit;
+input[type="time"].datetime-empty::-webkit-datetime-edit-ampm-field,
+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: #64748b !important;
}
.homepage-grid {
@@ -112,6 +261,8 @@ input[type="time"].datetime-empty::-webkit-datetime-edit-ampm-field {
position: absolute;
width: 100%;
filter: blur(70px);
+ /* Decorative only — never block links/buttons underneath */
+ pointer-events: none;
}
.blob-rush-vid {
@@ -256,17 +407,22 @@ input[type="time"].datetime-empty::-webkit-datetime-edit-ampm-field {
/* animation: blob-movement-two 15s ease-in-out infinite both; */
}
-/* Desktop blobs - largest size starting at md breakpoint */
+/*
+ Desktop blob sizes.
+ Keep one cascade (md → lg) with no competing !important on eight/nine.
+ Turbopack preserves source order; the old 280×200 !important block won in
+ `next dev` while prod CSS merged it away — so local About looked smaller
+ than Vercel/production for the same commit.
+*/
@media (min-width: 768px) {
-
.shape-blob.eight {
- height: 200px !important;
- width: 280px !important;
+ height: 250px;
+ width: 350px;
}
.shape-blob.nine {
- height: 200px !important;
- width: 280px !important;
+ height: 250px;
+ width: 350px;
}
.shape-blob.ten {
@@ -283,18 +439,6 @@ input[type="time"].datetime-empty::-webkit-datetime-edit-ampm-field {
}
}
-@media (min-width: 768px) {
- .shape-blob.eight {
- height: 250px;
- width: 350px;
- }
-
- .shape-blob.nine {
- height: 250px;
- width: 350px;
- }
-}
-
@media (min-width: 1024px) {
.shape-blob.eight {
height: 300px;
@@ -393,6 +537,609 @@ input[type="time"].datetime-empty::-webkit-datetime-edit-ampm-field {
}
}
+/* Home mobile blobs only — kill expensive blur; keep left/top/size from base rules */
+@media (max-width: 1023px) {
+ .blob-c.home-mobile-blobs {
+ filter: none;
+ /* Let the page wrapper clip sideways spill — don't box-clip soft edges here */
+ overflow: visible;
+ }
+
+ .home-mobile-blobs .shape-blob.twelve {
+ border-radius: 50%;
+ background: radial-gradient(
+ ellipse at center,
+ #a8d4ff 0%,
+ rgba(168, 212, 255, 0.55) 38%,
+ transparent 72%
+ );
+ -webkit-animation: none !important;
+ animation: none !important;
+ /* Match keyframe rest; base rotate(-180deg) would snap in without this */
+ transform: none;
+ top: 16%;
+ /* Pair centered on the page (blue sits slightly right of mid) */
+ left: calc(50% - 140px);
+ height: 260px;
+ width: 400px;
+ }
+
+ .home-mobile-blobs .shape-blob.thirteen {
+ border-radius: 50%;
+ background: radial-gradient(
+ ellipse at center,
+ #9ceb9c 0%,
+ rgba(156, 235, 156, 0.55) 38%,
+ transparent 72%
+ );
+ -webkit-animation: none !important;
+ animation: none !important;
+ transform: none;
+ top: 11%;
+ /* Green sits slightly left of mid so both colors read */
+ left: calc(50% - 260px);
+ height: 260px;
+ width: 400px;
+ }
+
+ /*
+ * About mobile only — drop blur + freeze motion.
+ * Hero keeps base left/top/size. Photo stacks re-anchor below so both
+ * colors stay visible without blur (page-math left% clips in a narrow column).
+ */
+ .blob-c.about-mobile-blobs {
+ filter: none;
+ }
+
+ /* Hero stacks — clip edge spill (visible was escaping after body overflow-x: clip) */
+ .blob-c.about-hero-blobs {
+ overflow: hidden;
+ }
+
+ .about-mobile-blobs .shape-blob.eight {
+ border-radius: 50%;
+ background: radial-gradient(
+ ellipse at center,
+ #a8d4ff 0%,
+ rgba(168, 212, 255, 0.55) 38%,
+ transparent 72%
+ );
+ -webkit-animation: none !important;
+ animation: none !important;
+ transform: none;
+ }
+
+ .about-mobile-blobs .shape-blob.nine {
+ border-radius: 50%;
+ background: radial-gradient(
+ ellipse at center,
+ #9ceb9c 0%,
+ rgba(156, 235, 156, 0.55) 38%,
+ transparent 72%
+ );
+ -webkit-animation: none !important;
+ animation: none !important;
+ transform: none;
+ }
+
+ /* Header pair — nudge from base; blue left a bit, green stays */
+ .about-hero-blobs .shape-blob.eight,
+ .rush-hero-blobs .shape-blob.eight,
+ .members-hero-blobs .shape-blob.eight,
+ .nationals-hero-blobs .shape-blob.eight {
+ left: calc(40% - 195px);
+ top: 4%;
+ height: 240px;
+ width: 320px;
+ }
+
+ .about-hero-blobs .shape-blob.nine,
+ .rush-hero-blobs .shape-blob.nine,
+ .members-hero-blobs .shape-blob.nine,
+ .nationals-hero-blobs .shape-blob.nine {
+ left: calc(70% - 145px);
+ top: 4%;
+ height: 240px;
+ width: 320px;
+ }
+
+ .blob-c.about-photo-blobs {
+ min-height: 0;
+ inset: 0;
+ height: 100%;
+ width: 100%;
+ overflow: visible;
+ }
+
+ .about-photo-blobs .shape-blob.eight {
+ left: 50%;
+ top: 50%;
+ height: 220px;
+ width: 280px;
+ transform: translate(-80%, -50%);
+ }
+
+ .about-photo-blobs .shape-blob.nine {
+ left: 50%;
+ top: 50%;
+ height: 220px;
+ width: 280px;
+ transform: translate(-20%, -50%);
+ }
+
+ /*
+ * Rush mobile only — drop blur + freeze motion.
+ * Hero size/position shared with about-hero-blobs above.
+ * Video stack re-anchors around the embed.
+ */
+ .blob-c.rush-mobile-blobs {
+ filter: none;
+ }
+
+ /* Hero only — clip edge spill; video stack stays visible below */
+ .blob-c.rush-hero-blobs {
+ overflow: hidden;
+ }
+
+ .rush-mobile-blobs .shape-blob.eight {
+ border-radius: 50%;
+ background: radial-gradient(
+ ellipse at center,
+ #a8d4ff 0%,
+ rgba(168, 212, 255, 0.55) 38%,
+ transparent 72%
+ );
+ -webkit-animation: none !important;
+ animation: none !important;
+ transform: none;
+ }
+
+ .rush-mobile-blobs .shape-blob.nine {
+ border-radius: 50%;
+ background: radial-gradient(
+ ellipse at center,
+ #9ceb9c 0%,
+ rgba(156, 235, 156, 0.55) 38%,
+ transparent 72%
+ );
+ -webkit-animation: none !important;
+ animation: none !important;
+ transform: none;
+ }
+
+ .rush-mobile-blobs .shape-blob.rush-static-green,
+ .rush-mobile-blobs .shape-blob.rush-static-blue {
+ border-radius: 50%;
+ -webkit-animation: none !important;
+ animation: none !important;
+ }
+
+ .rush-mobile-blobs .shape-blob.rush-static-green {
+ background: radial-gradient(
+ ellipse at center,
+ #9ceb9c 0%,
+ rgba(156, 235, 156, 0.55) 38%,
+ transparent 72%
+ );
+ }
+
+ .rush-mobile-blobs .shape-blob.rush-static-blue {
+ background: radial-gradient(
+ ellipse at center,
+ #a8d4ff 0%,
+ rgba(168, 212, 255, 0.55) 38%,
+ transparent 72%
+ );
+ }
+
+ .blob-c.rush-video-blobs {
+ min-height: 0;
+ inset: 0;
+ height: 100%;
+ width: 100%;
+ overflow: visible;
+ }
+
+ .rush-video-blobs .shape-blob.rush-static-green {
+ left: 50%;
+ top: 50%;
+ height: 340px;
+ width: 380px;
+ transform: translate(5%, -35%);
+ }
+
+ .rush-video-blobs .shape-blob.rush-static-blue {
+ left: 50%;
+ top: 50%;
+ height: 300px;
+ width: 280px;
+ transform: translate(-25%, -20%);
+ }
+
+ /*
+ * Members mobile only — drop blur + freeze motion.
+ * Own selectors (same numbers as about/rush hero) so Members tweaks
+ * cannot look like they edited About/Rush rules.
+ */
+ .blob-c.members-mobile-blobs {
+ filter: none;
+ }
+
+ .blob-c.members-hero-blobs {
+ overflow: hidden;
+ }
+
+ .members-mobile-blobs .shape-blob.eight {
+ border-radius: 50%;
+ background: radial-gradient(
+ ellipse at center,
+ #a8d4ff 0%,
+ rgba(168, 212, 255, 0.55) 38%,
+ transparent 72%
+ );
+ -webkit-animation: none !important;
+ animation: none !important;
+ transform: none;
+ }
+
+ .members-mobile-blobs .shape-blob.nine {
+ border-radius: 50%;
+ background: radial-gradient(
+ ellipse at center,
+ #9ceb9c 0%,
+ rgba(156, 235, 156, 0.55) 38%,
+ transparent 72%
+ );
+ -webkit-animation: none !important;
+ animation: none !important;
+ transform: none;
+ }
+
+ /*
+ * Nationals mobile only — drop blur + freeze motion.
+ * Desktop uses a separate blob stack (lg:block) outside these classes.
+ */
+ .blob-c.nationals-mobile-blobs {
+ filter: none;
+ }
+
+ .blob-c.nationals-hero-blobs {
+ overflow: hidden;
+ }
+
+ .nationals-mobile-blobs .shape-blob.eight {
+ border-radius: 50%;
+ background: radial-gradient(
+ ellipse at center,
+ #a8d4ff 0%,
+ rgba(168, 212, 255, 0.55) 38%,
+ transparent 72%
+ );
+ -webkit-animation: none !important;
+ animation: none !important;
+ transform: none;
+ }
+
+ .nationals-mobile-blobs .shape-blob.nine {
+ border-radius: 50%;
+ background: radial-gradient(
+ ellipse at center,
+ #9ceb9c 0%,
+ rgba(156, 235, 156, 0.55) 38%,
+ transparent 72%
+ );
+ -webkit-animation: none !important;
+ animation: none !important;
+ transform: none;
+ }
+
+ /*
+ * Apply mobile only — drop blur + freeze motion.
+ * Keep Apply’s own left/top (not the About/Rush header pair).
+ * Desktop uses a separate lg:block stack with inline positions.
+ */
+ .blob-c.apply-mobile-blobs {
+ filter: none;
+ }
+
+ /* Clip sideways spill; container is top-0 of the page so soft edges still sit under the header */
+ .blob-c.apply-hero-blobs {
+ overflow: hidden;
+ }
+
+ .apply-hero-blobs .shape-blob.eight {
+ /* Pair centered on the page (blue slightly right of mid) */
+ left: calc(50% - 120px);
+ top: 14%;
+ height: 240px;
+ width: 320px;
+ }
+
+ .apply-hero-blobs .shape-blob.nine {
+ /* Green slightly left of mid so both colors read */
+ left: calc(50% - 220px);
+ top: 18%;
+ height: 240px;
+ width: 320px;
+ }
+
+ .apply-mobile-blobs .shape-blob.eight {
+ border-radius: 50%;
+ background: radial-gradient(
+ ellipse at center,
+ #a8d4ff 0%,
+ rgba(168, 212, 255, 0.55) 38%,
+ transparent 72%
+ );
+ -webkit-animation: none !important;
+ animation: none !important;
+ transform: none;
+ }
+
+ .apply-mobile-blobs .shape-blob.nine {
+ border-radius: 50%;
+ background: radial-gradient(
+ ellipse at center,
+ #9ceb9c 0%,
+ rgba(156, 235, 156, 0.55) 38%,
+ transparent 72%
+ );
+ -webkit-animation: none !important;
+ animation: none !important;
+ transform: none;
+ }
+
+ /*
+ * Login mobile — freeze/no-blur. Soft glow = radial with an explicit radius
+ * smaller than the box so the fade finishes inside (no hard box edge, no
+ * 0×0 box-shadow speck). overflow:hidden clips sideways spill only.
+ */
+ .blob-c.login-mobile-blobs {
+ filter: none;
+ overflow: hidden;
+ min-height: 100vh;
+ }
+
+ .login-mobile-blobs .shape-blob.eight,
+ .login-mobile-blobs .shape-blob.nine {
+ border-radius: 0;
+ opacity: 1;
+ -webkit-animation: none !important;
+ animation: none !important;
+ transform: none;
+ box-shadow: none !important;
+ border: none;
+ padding: 0;
+ height: 640px;
+ width: 640px;
+ }
+
+ .login-mobile-blobs .shape-blob.eight {
+ /* radius inside box — empty margin past the fade */
+ background: radial-gradient(
+ circle 260px at center,
+ rgba(255, 255, 255, 0.16) 0%,
+ rgba(255, 255, 255, 0.09) 38%,
+ rgba(255, 255, 255, 0.03) 68%,
+ transparent 100%
+ ) !important;
+ top: calc(50vh - 340px);
+ left: calc(50% - 420px);
+ }
+
+ .login-mobile-blobs .shape-blob.nine {
+ background: radial-gradient(
+ circle 280px at center,
+ rgba(168, 212, 255, 0.18) 0%,
+ rgba(168, 212, 255, 0.1) 38%,
+ rgba(168, 212, 255, 0.035) 68%,
+ transparent 100%
+ ) !important;
+ top: calc(50vh - 260px);
+ left: calc(50% - 160px);
+ }
+
+ /*
+ * Unauthorized mobile only — mid-viewport like login (50%/50vh).
+ * Desktop Unauthorized blobs are untouched.
+ */
+ .blob-c.unauthorized-mobile-blobs {
+ filter: none;
+ overflow: hidden;
+ min-height: 100vh;
+ }
+
+ .unauthorized-mobile-blobs .shape-blob.eight,
+ .unauthorized-mobile-blobs .shape-blob.nine {
+ border-radius: 0;
+ opacity: 1;
+ -webkit-animation: none !important;
+ animation: none !important;
+ transform: none;
+ box-shadow: none !important;
+ border: none;
+ padding: 0;
+ height: 820px;
+ width: 820px;
+ }
+
+ .unauthorized-mobile-blobs .shape-blob.eight {
+ background: radial-gradient(
+ circle 360px at center,
+ rgba(168, 212, 255, 0.55) 0%,
+ rgba(168, 212, 255, 0.3) 38%,
+ rgba(168, 212, 255, 0.08) 68%,
+ transparent 100%
+ ) !important;
+ top: calc(50vh - 430px);
+ left: calc(50% - 520px);
+ }
+
+ .unauthorized-mobile-blobs .shape-blob.nine {
+ background: radial-gradient(
+ circle 380px at center,
+ rgba(156, 235, 156, 0.5) 0%,
+ rgba(156, 235, 156, 0.28) 38%,
+ rgba(156, 235, 156, 0.08) 68%,
+ transparent 100%
+ ) !important;
+ top: calc(50vh - 340px);
+ left: calc(50% - 220px);
+ }
+
+ /*
+ * Portal + Admin mobile — same freeze/no-blur recipe as login.
+ * Inline left/top/right/bottom from PortalBlobShapes stay; only paint/motion change.
+ * Desktop blob-c (lg:block) is untouched.
+ */
+ .blob-c.portal-mobile-blobs {
+ filter: none;
+ overflow: hidden;
+ }
+
+ .portal-mobile-blobs .shape-blob.eight,
+ .portal-mobile-blobs .shape-blob.nine {
+ border-radius: 0;
+ opacity: 1;
+ -webkit-animation: none !important;
+ animation: none !important;
+ transform: none;
+ box-shadow: none !important;
+ height: 420px;
+ width: 420px;
+ }
+
+ .portal-mobile-blobs--dark .shape-blob.eight {
+ background: radial-gradient(
+ circle 160px at center,
+ rgba(255, 255, 255, 0.12) 0%,
+ rgba(255, 255, 255, 0.07) 38%,
+ rgba(255, 255, 255, 0.022) 68%,
+ transparent 100%
+ ) !important;
+ }
+
+ .portal-mobile-blobs--dark .shape-blob.nine {
+ background: radial-gradient(
+ circle 170px at center,
+ rgba(168, 212, 255, 0.14) 0%,
+ rgba(168, 212, 255, 0.075) 38%,
+ rgba(168, 212, 255, 0.025) 68%,
+ transparent 100%
+ ) !important;
+ }
+
+ .portal-mobile-blobs--light .shape-blob.eight {
+ background: radial-gradient(
+ circle 160px at center,
+ rgba(168, 212, 255, 0.85) 0%,
+ rgba(168, 212, 255, 0.42) 38%,
+ transparent 72%
+ ) !important;
+ }
+
+ .portal-mobile-blobs--light .shape-blob.nine {
+ background: radial-gradient(
+ circle 170px at center,
+ rgba(156, 235, 156, 0.85) 0%,
+ rgba(156, 235, 156, 0.42) 38%,
+ transparent 72%
+ ) !important;
+ }
+
+ /* Nudge corners — !important beats inline desktop positions (mobile only) */
+ .portal-mobile-blobs .shape-blob:nth-child(1) {
+ left: calc(-22% - 40px) !important;
+ top: 2% !important;
+ right: auto !important;
+ bottom: auto !important;
+ }
+
+ .portal-mobile-blobs .shape-blob:nth-child(2) {
+ left: calc(-8% - 20px) !important;
+ top: 6% !important;
+ right: auto !important;
+ bottom: auto !important;
+ }
+
+ .portal-mobile-blobs .shape-blob:nth-child(3) {
+ left: auto !important;
+ right: -26% !important;
+ top: auto !important;
+ bottom: -12% !important;
+ }
+
+ .portal-mobile-blobs .shape-blob:nth-child(4) {
+ left: auto !important;
+ right: -8% !important;
+ top: auto !important;
+ bottom: -10% !important;
+ }
+}
+
+/*
+ * Mid screens (tablet / large phone landscape): bigger frozen blobs.
+ * Phone sizes stay above; desktop blur stacks (md/lg) are untouched.
+ */
+@media (min-width: 640px) and (max-width: 1023px) {
+ .home-mobile-blobs .shape-blob.twelve {
+ height: 340px;
+ width: 520px;
+ left: calc(50% - 200px);
+ }
+
+ .home-mobile-blobs .shape-blob.thirteen {
+ height: 340px;
+ width: 520px;
+ left: calc(50% - 320px);
+ }
+
+ .about-hero-blobs .shape-blob.eight,
+ .rush-hero-blobs .shape-blob.eight,
+ .members-hero-blobs .shape-blob.eight,
+ .nationals-hero-blobs .shape-blob.eight,
+ .about-hero-blobs .shape-blob.nine,
+ .rush-hero-blobs .shape-blob.nine,
+ .members-hero-blobs .shape-blob.nine,
+ .nationals-hero-blobs .shape-blob.nine {
+ height: 320px;
+ width: 420px;
+ }
+
+ .about-photo-blobs .shape-blob.eight,
+ .about-photo-blobs .shape-blob.nine {
+ height: 300px;
+ width: 360px;
+ }
+
+ .rush-video-blobs .shape-blob.rush-static-green {
+ height: 440px;
+ width: 500px;
+ }
+
+ .rush-video-blobs .shape-blob.rush-static-blue {
+ height: 390px;
+ width: 360px;
+ }
+
+ .apply-hero-blobs .shape-blob.eight {
+ height: 320px;
+ width: 420px;
+ left: calc(50% - 170px);
+ }
+
+ .apply-hero-blobs .shape-blob.nine {
+ height: 320px;
+ width: 420px;
+ left: calc(50% - 270px);
+ }
+}
+
+/* Video stack — allow soft edges on desktop blur too (page html clips sideways spill) */
+.blob-c.rush-video-blobs {
+ overflow: visible;
+}
+
.footer {
background-size: cover;
padding: 2rem 0;
@@ -656,6 +1403,18 @@ img.blue-shadow {
transform: translateY(-10px);
}
+@media (max-width: 640px) {
+ .pledge-class {
+ bottom: 8px;
+ right: 8px;
+ font-size: 12px;
+ }
+
+ .active-member:hover .pledge-class {
+ transform: translateY(-10px);
+ }
+}
+
.contact-us {
width: 250px;
height: 50px;
@@ -814,8 +1573,149 @@ img.blue-shadow {
background-color: var(--light-blue);
}
-.hover-text-custom:hover {
- color: #8ddd88;
+/* Smooth color for nav/footer text links + admin/portal text actions */
+.hover-text-custom,
+.tap-text {
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+ touch-action: manipulation;
+ transition: color 0.2s cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+/* Desktop hover only — avoids sticky color after tap on phones */
+@media (hover: hover) and (pointer: fine) {
+ .hover-text-custom:hover {
+ color: #8ddd88;
+ transition-duration: 0.18s;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ }
+
+ /* Brother/admin text actions — soft blue, not nav green */
+ .tap-text:hover:not(:has(.tap-text-label)) {
+ color: #a8d4ff;
+ transition-duration: 0.18s;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ }
+
+ .tap-text:hover .tap-text-label {
+ color: #a8d4ff;
+ transition-duration: 0.18s;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ }
+
+ .tap-card:hover .tap-text-label {
+ color: #a8d4ff;
+ transition-duration: 0.18s;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ }
+}
+
+/*
+ * Mobile tap press only — do not override desktop hover:scale transitions.
+ * (Desktop keeps Tailwind transition-all / hover:scale-105.)
+ */
+@media (max-width: 1023px) {
+ .tap-press,
+ .tap-card,
+ .contact-us,
+ .more-about-us a {
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+ touch-action: manipulation;
+ /* Release spring — slight overshoot so it reads as bounce-back, not a stuck dip */
+ transition:
+ transform 0.38s cubic-bezier(0.34, 1.45, 0.64, 1),
+ background-color 0.28s cubic-bezier(0.33, 1, 0.68, 1),
+ color 0.28s cubic-bezier(0.33, 1, 0.68, 1),
+ box-shadow 0.28s cubic-bezier(0.33, 1, 0.68, 1);
+ }
+
+ /*
+ * Kill sticky hover:scale-105, but use scale(1) — not transform:none.
+ * transform:none !important snapped release and killed the bounce.
+ */
+ .tap-press:hover:not(.is-pressed):not(.is-pressed-scale),
+ .tap-card:hover:not(.is-pressed),
+ .contact-us:hover:not(.is-pressed),
+ a:hover:not(:has(.is-pressed)):not(:has(.is-pressed-scale)) > .contact-us:not(.is-pressed) {
+ transform: scale(1) !important;
+ }
+
+ .contact-us.is-pressed,
+ .tap-press.is-pressed {
+ transition:
+ transform 0.1s cubic-bezier(0.33, 1, 0.68, 1),
+ background-color 0.1s cubic-bezier(0.33, 1, 0.68, 1),
+ color 0.1s cubic-bezier(0.33, 1, 0.68, 1);
+ transform: scale(0.94) !important;
+ background-color: #234c8b !important;
+ color: #ffffff !important;
+ }
+
+ /*
+ * Brother/admin dark buttons (#163556) — dip darker, not to public #234c8b
+ * which reads as a lighter flash on those CTAs.
+ */
+ .tap-press.tap-press-dark.is-pressed {
+ background-color: #0f2840 !important;
+ color: #ffffff !important;
+ }
+
+ /* Selected pills: scale only — never apply navy (set via JS is-pressed-scale) */
+ .tap-press.is-pressed-scale {
+ transition: transform 0.1s cubic-bezier(0.33, 1, 0.68, 1);
+ transform: scale(0.94) !important;
+ }
+
+ /* Text link — no pill fill, just deepen the blue */
+ .more-about-us a.is-pressed {
+ background-color: transparent !important;
+ color: #234c8b !important;
+ }
+
+ .hover-text-custom.is-pressed {
+ background-color: transparent !important;
+ color: #8ddd88 !important;
+ transform: none !important;
+ transition-duration: 0.04s;
+ transition-timing-function: linear;
+ }
+
+ /* Brother/admin text — soft blue flash (not nav green); no nav delay in JS */
+ .tap-text.is-pressed:not(:has(.tap-text-label)) {
+ background-color: transparent !important;
+ color: #a8d4ff !important;
+ transform: none !important;
+ transition-duration: 0.04s;
+ transition-timing-function: linear;
+ }
+
+ /* Section cards: only the View/Open label flashes (not the whole card title) */
+ .tap-text.is-pressed {
+ background-color: transparent !important;
+ transform: none !important;
+ }
+
+ .tap-text.is-pressed .tap-text-label {
+ color: #a8d4ff !important;
+ transition-duration: 0.04s;
+ transition-timing-function: linear;
+ }
+
+ /*
+ * Quick-link cards — pill-like press + spring bounce (no sticky color through nav).
+ * Slightly softer scale than pills since the hit target is larger.
+ */
+ .tap-card.is-pressed {
+ transition: transform 0.1s cubic-bezier(0.33, 1, 0.68, 1);
+ transform: scale(0.97) !important;
+ background-color: transparent !important;
+ }
+
+ .tap-card.is-pressed .tap-text-label {
+ color: #a8d4ff !important;
+ transition: color 0.1s cubic-bezier(0.33, 1, 0.68, 1);
+ }
}
button:hover .close-icon path {
@@ -824,6 +1724,7 @@ button:hover .close-icon path {
/* Nationals page styles */
.logo-container-2{
+ position: relative;
width: 72px;
/* Set the width */
height: 72px;
@@ -837,6 +1738,7 @@ button:hover .close-icon path {
justify-content: center;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
/* Optional: add a shadow */
+ overflow: hidden;
}
.logo-container-2 .logo-image {
@@ -868,7 +1770,6 @@ button:hover .close-icon path {
}
.network-logo-simple {
- animation: simple-fade-in 0.5s ease-out;
transition: transform 0.2s ease;
display: inline-block;
object-fit: contain;
@@ -881,6 +1782,8 @@ button:hover .close-icon path {
.network-logo-simple {
min-width: 50px;
max-width: 150px;
+ opacity: 0;
+ animation: simple-fade-in 0.5s ease-out forwards;
}
}
@@ -948,4 +1851,4 @@ button:hover .close-icon path {
.leaflet-control-zoom a:hover {
background: #f9fafb !important;
color: #315CA9 !important;
-}
+}
\ No newline at end of file
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 630b055..597c8a0 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Inter } from "next/font/google";
+import TapFeedback from "@/components/TapFeedback";
import "./globals.css";
const geistSans = Geist({
@@ -41,6 +42,7 @@ export default function RootLayout({
+
{children}