Welcome
diff --git a/src/components/portal/AlumniDirectoryTable.tsx b/src/components/portal/AlumniDirectoryTable.tsx
new file mode 100644
index 0000000..178e328
--- /dev/null
+++ b/src/components/portal/AlumniDirectoryTable.tsx
@@ -0,0 +1,514 @@
+'use client'
+
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+
+type AlumniRow = {
+ name: string
+ pledgeClass: string
+ emails: string[]
+ grad: string
+ major: string
+ company: string
+ location: string
+ phone: string
+ linkedin: string
+}
+
+const COLUMNS: { key: keyof AlumniRow; label: string; width: string }[] = [
+ { key: 'name', label: 'Name', width: '12rem' },
+ { key: 'pledgeClass', label: 'Pledge class', width: '8rem' },
+ { key: 'emails', label: 'Email', width: '16rem' },
+ { key: 'grad', label: 'Grad', width: '5rem' },
+ { key: 'major', label: 'Major', width: '14rem' },
+ { key: 'company', label: 'Company & role', width: '18rem' },
+ { key: 'location', label: 'Location', width: '11rem' },
+ { key: 'phone', label: 'Phone', width: '9rem' },
+ { key: 'linkedin', label: 'LinkedIn', width: '16rem' },
+]
+
+const DUMMY_ALUMNI: AlumniRow[] = [
+ {
+ name: 'Brian Mansfield',
+ pledgeClass: 'Founder',
+ emails: ['brian.lee.mansfield@gmail.com'],
+ grad: 'W13',
+ major: 'Computational Informatics',
+ company: 'Senior Architect at Salesforce',
+ location: 'Chicago, IL',
+ phone: '517-648-7639',
+ linkedin: 'linkedin.com/in/brianmansfield',
+ },
+ {
+ name: 'Denny Tsai',
+ pledgeClass: 'Founder',
+ emails: ['dennytsai09@gmail.com'],
+ grad: 'W13',
+ major: 'Computational Informatics',
+ company: 'Software Engineer at Uber',
+ location: 'San Francisco, CA',
+ phone: '',
+ linkedin: 'linkedin.com/in/dennytsai',
+ },
+ {
+ name: 'Jacqueline Fontaine',
+ pledgeClass: 'Founder',
+ emails: ['jacfonta@umich.edu', 'jfonta1991@gmail.com'],
+ grad: 'W13',
+ major: 'Life Science Informatics',
+ company: 'Senior Product Manager at Valant',
+ location: 'Seattle, WA',
+ phone: '',
+ linkedin: 'linkedin.com/in/jacquelinefontaine',
+ },
+ {
+ name: 'Jing Guo',
+ pledgeClass: 'Founder',
+ emails: ['jingguo@umich.edu'],
+ grad: 'W13',
+ major: 'Computer Science',
+ company: 'Software Engineer at Google',
+ location: 'Sunnyvale, CA',
+ phone: '',
+ linkedin: 'linkedin.com/in/jingguo',
+ },
+ {
+ name: 'Julie Varghese',
+ pledgeClass: 'Founder',
+ emails: ['juliev@umich.edu'],
+ grad: 'W13',
+ major: 'Informatics',
+ company: 'Product Manager at Microsoft',
+ location: 'Seattle, WA',
+ phone: '',
+ linkedin: 'linkedin.com/in/julievarghese',
+ },
+ {
+ name: 'Andy Kolean',
+ pledgeClass: 'Alpha',
+ emails: ['akolean@umich.edu'],
+ grad: 'W14',
+ major: 'Computer Science',
+ company: 'Engineering Manager at Stripe',
+ location: 'New York, NY',
+ phone: '',
+ linkedin: 'linkedin.com/in/andykolean',
+ },
+ {
+ name: 'Chris Hong',
+ pledgeClass: 'Alpha',
+ emails: ['chrishong@umich.edu'],
+ grad: 'W14',
+ major: 'Computer Science',
+ company: 'Software Engineer at Meta',
+ location: 'Menlo Park, CA',
+ phone: '',
+ linkedin: 'linkedin.com/in/chrishong',
+ },
+ {
+ name: 'Patrick Riggs',
+ pledgeClass: 'Alpha',
+ emails: ['priggs@umich.edu'],
+ grad: 'W14',
+ major: 'Computer Science',
+ company: 'Consultant at McKinsey',
+ location: 'Chicago, IL',
+ phone: '',
+ linkedin: 'linkedin.com/in/patrickriggs',
+ },
+ {
+ name: 'Jay Raina',
+ pledgeClass: 'Beta',
+ emails: ['jraina@umich.edu'],
+ grad: 'W15',
+ major: 'Computer Science',
+ company: 'Product at Capital One',
+ location: 'Washington, DC',
+ phone: '',
+ linkedin: 'linkedin.com/in/jayraina',
+ },
+ {
+ name: 'Juliana Mi',
+ pledgeClass: 'Beta',
+ emails: ['jmi@umich.edu'],
+ grad: 'W15',
+ major: 'Informatics',
+ company: 'Designer at Apple',
+ location: 'Cupertino, CA',
+ phone: '',
+ linkedin: 'linkedin.com/in/julianami',
+ },
+ {
+ name: 'Connor Waldo',
+ pledgeClass: 'Gamma',
+ emails: ['cwaldo@umich.edu'],
+ grad: 'W16',
+ major: 'Computer Science',
+ company: 'Software Engineer at Amazon',
+ location: 'Seattle, WA',
+ phone: '',
+ linkedin: 'linkedin.com/in/connorwaldo',
+ },
+ {
+ name: 'Sonia Doshi',
+ pledgeClass: 'Delta',
+ emails: ['sdoshi@umich.edu'],
+ grad: 'W16',
+ major: 'Computer Science',
+ company: 'Product Manager at Google',
+ location: 'New York, NY',
+ phone: '',
+ linkedin: 'linkedin.com/in/soniadoshi',
+ },
+ {
+ name: 'Isha Gupta',
+ pledgeClass: 'Epsilon',
+ emails: ['ishag@umich.edu'],
+ grad: 'W17',
+ major: 'Computer Science',
+ company: 'Software Engineer at Bloomberg',
+ location: 'New York, NY',
+ phone: '',
+ linkedin: 'linkedin.com/in/ishagupta',
+ },
+ {
+ name: 'Connie Liu',
+ pledgeClass: 'Zeta',
+ emails: ['cliu@umich.edu'],
+ grad: 'W17',
+ major: 'Computer Science',
+ company: 'Software Engineer at Figma',
+ location: 'San Francisco, CA',
+ phone: '',
+ linkedin: 'linkedin.com/in/connieliu',
+ },
+ {
+ name: 'Pascal Sturmfels',
+ pledgeClass: 'Eta',
+ emails: ['psturm@umich.edu'],
+ grad: 'W18',
+ major: 'Computer Science',
+ company: 'Research Scientist at DeepMind',
+ location: 'London, UK',
+ phone: '',
+ linkedin: 'linkedin.com/in/pascalsturmfels',
+ },
+]
+
+const iconBtnClass =
+ 'flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-gray-400 transition-all duration-200 hover:scale-110 hover:bg-blue-50 hover:text-[#315CA9] cursor-pointer'
+const searchInputClass =
+ 'h-9 w-44 sm:w-56 rounded-lg border border-gray-300 bg-white/80 py-0 pl-9 pr-3 text-sm text-gray-700 outline-none transition-[border-color,box-shadow] duration-200 ease-out focus:border-[#315CA9] focus:shadow-[0_0_0_3px_rgba(49,92,169,0.18)]'
+const MODAL_ANIMATION_MS = 280
+
+function SearchIcon() {
+ return (
+
+ )
+}
+
+function ExpandIcon() {
+ return (
+
+ )
+}
+
+function CollapseIcon() {
+ return (
+
+ )
+}
+
+function matchesQuery(row: AlumniRow, query: string) {
+ const needle = query.trim().toLowerCase()
+ if (!needle) return true
+ return COLUMNS.some((column) => {
+ const value = row[column.key]
+ if (Array.isArray(value)) {
+ return value.some((item) => item.toLowerCase().includes(needle))
+ }
+ return value.toLowerCase().includes(needle)
+ })
+}
+
+function CopyableValue({ value, onCopy }: { value: string; onCopy: (value: string) => void }) {
+ return (
+
+ )
+}
+
+function Spreadsheet({
+ rows,
+ tall,
+ onCopy,
+}: {
+ rows: AlumniRow[]
+ tall?: boolean
+ onCopy: (value: string) => void
+}) {
+ return (
+
+
+
+ {COLUMNS.map((column) => (
+
+ ))}
+
+
+
+ {COLUMNS.map((column, index) => (
+ |
+ {column.label}
+ |
+ ))}
+
+
+
+ {rows.length === 0 ? (
+
+ |
+ No alumni match that search.
+ |
+
+ ) : (
+ rows.map((row) => (
+
+ {COLUMNS.map((column, index) => {
+ const value = row[column.key]
+ const isCopyable = column.key === 'linkedin' || column.key === 'emails'
+ return (
+ |
+ {column.key === 'emails' ? (
+ row.emails.length > 0 ? (
+
+ {row.emails.map((email) => (
+
+ ))}
+
+ ) : (
+ '—'
+ )
+ ) : column.key === 'linkedin' && value ? (
+
+ ) : (
+ (typeof value === 'string' && value) || '—'
+ )}
+ |
+ )
+ })}
+
+ ))
+ )}
+
+
+
+ )
+}
+
+function Toolbar({
+ query,
+ expanded,
+ onQueryChange,
+ onToggleExpand,
+}: {
+ query: string
+ expanded: boolean
+ onQueryChange: (value: string) => void
+ onToggleExpand: () => void
+}) {
+ return (
+
+
+
+
+
+ onQueryChange(event.target.value)}
+ placeholder="Search alumni"
+ className={searchInputClass}
+ aria-label="Search alumni"
+ />
+
+
+
+ )
+}
+
+export function AlumniDirectoryTable({ showTitle = true }: { showTitle?: boolean }) {
+ const [query, setQuery] = useState('')
+ const [expanded, setExpanded] = useState(false)
+ const [isVisible, setIsVisible] = useState(false)
+ const [copied, setCopied] = useState(false)
+ const [copyVisible, setCopyVisible] = useState(false)
+ const closeTimeoutRef = useRef
| null>(null)
+ const copyTimeoutRef = useRef | null>(null)
+ const copyHideTimeoutRef = useRef | null>(null)
+
+ const rows = useMemo(() => DUMMY_ALUMNI.filter((row) => matchesQuery(row, query)), [query])
+
+ useEffect(() => {
+ if (!expanded) return
+ const frame = requestAnimationFrame(() => {
+ requestAnimationFrame(() => setIsVisible(true))
+ })
+ return () => cancelAnimationFrame(frame)
+ }, [expanded])
+
+ function openExpanded() {
+ if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current)
+ setIsVisible(false)
+ setExpanded(true)
+ }
+
+ function closeExpanded() {
+ setIsVisible(false)
+ closeTimeoutRef.current = setTimeout(() => setExpanded(false), MODAL_ANIMATION_MS)
+ }
+
+ useEffect(() => {
+ if (!expanded) return
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') closeExpanded()
+ }
+ window.addEventListener('keydown', onKeyDown)
+ return () => window.removeEventListener('keydown', onKeyDown)
+ }, [expanded])
+
+ useEffect(() => {
+ return () => {
+ if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current)
+ if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current)
+ if (copyHideTimeoutRef.current) clearTimeout(copyHideTimeoutRef.current)
+ }
+ }, [])
+
+ async function copyValue(value: string) {
+ try {
+ await navigator.clipboard.writeText(value)
+ } catch {
+ return
+ }
+ if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current)
+ if (copyHideTimeoutRef.current) clearTimeout(copyHideTimeoutRef.current)
+ setCopied(true)
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => setCopyVisible(true))
+ })
+ copyTimeoutRef.current = setTimeout(() => {
+ setCopyVisible(false)
+ copyHideTimeoutRef.current = setTimeout(() => setCopied(false), 280)
+ }, 1600)
+ }
+
+ const title = (
+
+
Alumni Directory
+
Find brothers by class, company, and city.
+
+ )
+
+ const toolbar = (
+
+ )
+
+ return (
+ <>
+
+ {showTitle ? title : }
+ {expanded ? null : toolbar}
+
+
+
+ {expanded &&
+ typeof window !== 'undefined' &&
+ createPortal(
+
+
+
event.stopPropagation()}
+ >
+
+ {title}
+ {toolbar}
+
+
+
+
+
+
,
+ document.body
+ )}
+
+ {copied &&
+ typeof window !== 'undefined' &&
+ createPortal(
+ ,
+ document.body
+ )}
+ >
+ )
+}
diff --git a/src/components/portal/InterviewHomeSection.tsx b/src/components/portal/InterviewHomeSection.tsx
new file mode 100644
index 0000000..bd0a5d8
--- /dev/null
+++ b/src/components/portal/InterviewHomeSection.tsx
@@ -0,0 +1,449 @@
+'use client'
+
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { DUMMY_INTERVIEWS, submittedLabel, type InterviewRow } from '@/components/portal/interview-data'
+
+const COLUMNS: { key: keyof InterviewRow | 'submitted'; label: string; width: string }[] = [
+ { key: 'company', label: 'Company', width: '10rem' },
+ { key: 'role', label: 'Role', width: '16rem' },
+ { key: 'round', label: 'Round', width: '11rem' },
+ { key: 'outcome', label: 'Outcome', width: '9rem' },
+ { key: 'asked', label: 'What they asked', width: '24rem' },
+ { key: 'brother', label: 'Brother', width: '11rem' },
+ { key: 'submitted', label: 'Submitted', width: '11rem' },
+]
+
+const btnClass =
+ 'inline-flex px-4 py-2 bg-[#315CA9] text-white rounded-lg text-sm font-semibold transition-all duration-300 hover:scale-105 hover:shadow-md cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100'
+const ghostBtnClass =
+ 'inline-flex px-4 py-2 border border-gray-300 rounded-lg text-gray-700 text-sm font-semibold transition-all duration-300 hover:scale-105 hover:bg-gray-50 hover:shadow-md cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100'
+const iconBtnClass =
+ 'flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-gray-400 transition-all duration-200 hover:scale-110 hover:bg-blue-50 hover:text-[#315CA9] cursor-pointer'
+const searchInputClass =
+ 'h-9 w-44 sm:w-56 rounded-lg border border-gray-300 bg-white/80 py-0 pl-9 pr-3 text-sm text-gray-700 outline-none transition-[border-color,box-shadow] duration-200 ease-out focus:border-[#315CA9] focus:shadow-[0_0_0_3px_rgba(49,92,169,0.18)]'
+const inputClass =
+ 'w-full px-3 py-2 border border-gray-300 rounded-md bg-white/80 text-sm text-gray-700 outline-none transition-[border-color,box-shadow] duration-200 ease-out focus:border-[#315CA9] focus:shadow-[0_0_0_3px_rgba(49,92,169,0.18)]'
+const labelClass = 'block text-sm font-medium text-gray-700 mb-1'
+const innerCardClass = 'rounded-xl border border-gray-100 bg-white/80 px-4 py-3'
+const innerCardStyle = { boxShadow: '0 2px 10px rgba(0, 0, 0, 0.04)' }
+const MODAL_ANIMATION_MS = 280
+
+const emptyForm = {
+ company: '',
+ role: '',
+ round: '',
+ outcome: 'Moved to onsite',
+ asked: '',
+ brother: '',
+}
+
+function cellValue(row: InterviewRow, key: (typeof COLUMNS)[number]['key']) {
+ if (key === 'submitted') return submittedLabel(row.daysAgo)
+ return row[key] || '—'
+}
+
+function SearchIcon() {
+ return (
+
+ )
+}
+
+function CollapseIcon() {
+ return (
+
+ )
+}
+
+function matchesQuery(row: InterviewRow, query: string) {
+ const needle = query.trim().toLowerCase()
+ if (!needle) return true
+ return COLUMNS.some((column) => cellValue(row, column.key).toLowerCase().includes(needle))
+}
+
+function Spreadsheet({ rows }: { rows: InterviewRow[] }) {
+ return (
+
+
+
+ {COLUMNS.map((column) => (
+
+ ))}
+
+
+
+ {COLUMNS.map((column, index) => (
+ |
+ {column.label}
+ |
+ ))}
+
+
+
+ {rows.length === 0 ? (
+
+ |
+ No interviews match that search.
+ |
+
+ ) : (
+ rows.map((row) => (
+
+ {COLUMNS.map((column, index) => (
+ |
+ {cellValue(row, column.key)}
+ |
+ ))}
+
+ ))
+ )}
+
+
+
+ )
+}
+
+export function InterviewHomeSection() {
+ const [people, setPeople] = useState(DUMMY_INTERVIEWS)
+ const [query, setQuery] = useState('')
+ const [expanded, setExpanded] = useState(false)
+ const [addOpen, setAddOpen] = useState(false)
+ const [isSheetVisible, setIsSheetVisible] = useState(false)
+ const [isAddVisible, setIsAddVisible] = useState(false)
+ const [form, setForm] = useState(emptyForm)
+ const closeSheetTimeoutRef = useRef | null>(null)
+ const closeAddTimeoutRef = useRef | null>(null)
+
+ const recent = people.slice(0, 3)
+ const rows = useMemo(() => people.filter((row) => matchesQuery(row, query)), [people, query])
+
+ useEffect(() => {
+ if (!expanded) return
+ const frame = requestAnimationFrame(() => {
+ requestAnimationFrame(() => setIsSheetVisible(true))
+ })
+ return () => cancelAnimationFrame(frame)
+ }, [expanded])
+
+ useEffect(() => {
+ if (!addOpen) return
+ const frame = requestAnimationFrame(() => {
+ requestAnimationFrame(() => setIsAddVisible(true))
+ })
+ return () => cancelAnimationFrame(frame)
+ }, [addOpen])
+
+ useEffect(() => {
+ return () => {
+ if (closeSheetTimeoutRef.current) clearTimeout(closeSheetTimeoutRef.current)
+ if (closeAddTimeoutRef.current) clearTimeout(closeAddTimeoutRef.current)
+ }
+ }, [])
+
+ function openSheet() {
+ if (closeSheetTimeoutRef.current) clearTimeout(closeSheetTimeoutRef.current)
+ setIsSheetVisible(false)
+ setExpanded(true)
+ }
+
+ function closeSheet() {
+ setIsSheetVisible(false)
+ closeSheetTimeoutRef.current = setTimeout(() => setExpanded(false), MODAL_ANIMATION_MS)
+ }
+
+ function openAdd() {
+ if (closeAddTimeoutRef.current) clearTimeout(closeAddTimeoutRef.current)
+ setForm(emptyForm)
+ setIsAddVisible(false)
+ setAddOpen(true)
+ }
+
+ function closeAdd() {
+ setIsAddVisible(false)
+ closeAddTimeoutRef.current = setTimeout(() => {
+ setAddOpen(false)
+ setForm(emptyForm)
+ }, MODAL_ANIMATION_MS)
+ }
+
+ useEffect(() => {
+ if (!expanded && !addOpen) return
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key !== 'Escape') return
+ if (addOpen) closeAdd()
+ else closeSheet()
+ }
+ window.addEventListener('keydown', onKeyDown)
+ return () => window.removeEventListener('keydown', onKeyDown)
+ }, [expanded, addOpen])
+
+ function handleAdd(event: React.FormEvent) {
+ event.preventDefault()
+ const next: InterviewRow = {
+ id: `local-${Date.now()}`,
+ company: form.company.trim(),
+ role: form.role.trim(),
+ round: form.round.trim(),
+ outcome: form.outcome,
+ asked: form.asked.trim(),
+ brother: form.brother.trim() || 'You',
+ daysAgo: 0,
+ }
+ setPeople((current) => [next, ...current])
+ closeAdd()
+ }
+
+ return (
+ <>
+
+
+
Interview Database
+
Find recently submitted interviews below.
+
+
+
+
+
+
+
+
+ {recent.map((entry) => (
+
+
+
+ {entry.company}
+ · {entry.role}
+
+
+ {entry.round} · {entry.outcome}
+
+
{entry.asked}
+
+
{submittedLabel(entry.daysAgo)}
+
+ ))}
+
+
+ {expanded &&
+ typeof window !== 'undefined' &&
+ createPortal(
+
+
+
event.stopPropagation()}
+ >
+
+
+
All interviews
+
Where people interviewed and what they were asked.
+
+
+
+
+
+
+ setQuery(event.target.value)}
+ placeholder="Search interviews"
+ className={searchInputClass}
+ aria-label="Search interviews"
+ />
+
+
+
+
+
+
+
+
+
+
,
+ document.body
+ )}
+
+ {addOpen &&
+ typeof window !== 'undefined' &&
+ createPortal(
+
+
+
event.stopPropagation()}
+ >
+
+
Add Interview
+
+
+
+
+
+
,
+ document.body
+ )}
+ >
+ )
+}
diff --git a/src/components/portal/TimeGreeting.tsx b/src/components/portal/TimeGreeting.tsx
new file mode 100644
index 0000000..234b16e
--- /dev/null
+++ b/src/components/portal/TimeGreeting.tsx
@@ -0,0 +1,23 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+
+function greetingForHour(hour: number) {
+ if (hour < 12) return 'Good morning'
+ if (hour < 17) return 'Good afternoon'
+ return 'Good evening'
+}
+
+export function TimeGreeting({ name }: { name: string }) {
+ const [hello, setHello] = useState('Hello')
+
+ useEffect(() => {
+ setHello(greetingForHour(new Date().getHours()))
+ }, [])
+
+ return (
+ <>
+ {hello}, {name}
+ >
+ )
+}
diff --git a/src/components/portal/interview-data.ts b/src/components/portal/interview-data.ts
new file mode 100644
index 0000000..4ae159d
--- /dev/null
+++ b/src/components/portal/interview-data.ts
@@ -0,0 +1,99 @@
+export type InterviewRow = {
+ id: string
+ company: string
+ role: string
+ round: string
+ outcome: string
+ asked: string
+ brother: string
+ daysAgo: number
+}
+
+export function submittedLabel(daysAgo: number) {
+ if (daysAgo <= 0) return 'Submitted today'
+ if (daysAgo === 1) return 'Submitted yesterday'
+ return `Submitted ${daysAgo} days ago`
+}
+
+export const DUMMY_INTERVIEWS: InterviewRow[] = [
+ {
+ id: '1',
+ company: 'Stripe',
+ role: 'Software Engineer intern',
+ round: 'Phone screen',
+ outcome: 'Moved to onsite',
+ asked: 'Two-sum variant, then a design question about idempotent payments.',
+ brother: 'Teagan Hollman',
+ daysAgo: 2,
+ },
+ {
+ id: '2',
+ company: 'Capital One',
+ role: 'Technology Development Program',
+ round: 'HireVue + Super Day',
+ outcome: 'Offer',
+ asked: 'Behavioral STAR stories, then a SQL case and a simple graph walk.',
+ brother: 'Ayan Nair',
+ daysAgo: 4,
+ },
+ {
+ id: '3',
+ company: 'Google',
+ role: 'STEP intern',
+ round: 'Two technicals',
+ outcome: 'Rejected',
+ asked: 'String manipulation, then BFS on a grid. Interviewer cared about talking through tests.',
+ brother: 'Connie Liu',
+ daysAgo: 5,
+ },
+ {
+ id: '4',
+ company: 'Meta',
+ role: 'Software Engineer intern',
+ round: 'Coding + behavioral',
+ outcome: 'Moved to onsite',
+ asked: 'LRU cache, then a product sense question about News Feed ranking.',
+ brother: 'Isha Gupta',
+ daysAgo: 8,
+ },
+ {
+ id: '5',
+ company: 'Jane Street',
+ role: 'Trading intern',
+ round: 'Probability + market making',
+ outcome: 'Rejected',
+ asked: 'Expected value of dice games, then a toy market-making simulation.',
+ brother: 'Pascal Sturmfels',
+ daysAgo: 12,
+ },
+ {
+ id: '6',
+ company: 'Amazon',
+ role: 'SDE intern',
+ round: 'OA + final round',
+ outcome: 'Offer',
+ asked: 'Leadership principles stories, then a graph copy of a nested object.',
+ brother: 'Connor Waldo',
+ daysAgo: 18,
+ },
+ {
+ id: '7',
+ company: 'Figma',
+ role: 'Software Engineer intern',
+ round: 'Systems + coding',
+ outcome: 'Moved to onsite',
+ asked: 'Collaborative cursor presence, then interval merging.',
+ brother: 'Juliana Mi',
+ daysAgo: 24,
+ },
+ {
+ id: '8',
+ company: 'Bloomberg',
+ role: 'Software Engineer intern',
+ round: 'Phone + onsite',
+ outcome: 'Offer',
+ asked: 'Design a stock ticker, then a sliding-window string problem.',
+ brother: 'Sonia Doshi',
+ daysAgo: 40,
+ },
+]
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 28e750e..3c215ca 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -22,6 +22,32 @@ export const admins = pgTable('admins', {
.notNull(),
})
+export const brothers = pgTable(
+ 'brothers',
+ {
+ id: uuid('id').defaultRandom().primaryKey().notNull(),
+ firstName: text('first_name'),
+ lastName: text('last_name'),
+ umichEmail: text('umich_email'),
+ contactEmail: text('contact_email'),
+ linkedinUrl: text('linkedin_url'),
+ photoFilename: text('photo_filename'),
+ status: text('status').default('active').notNull().$type<'active' | 'alumni'>(),
+ pledgeClass: text('pledge_class'),
+ createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
+ .default(sql`timezone('utc'::text, now())`)
+ .notNull(),
+ updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' })
+ .default(sql`timezone('utc'::text, now())`)
+ .notNull(),
+ },
+ (table) => [
+ uniqueIndex('brothers_umich_email_key')
+ .on(table.umichEmail)
+ .where(sql`${table.umichEmail} is not null`),
+ ]
+)
+
export const rushCycles = pgTable(
'rush_cycles',
{
diff --git a/src/lib/admins.ts b/src/lib/admins.ts
index 0a255b8..3691359 100644
--- a/src/lib/admins.ts
+++ b/src/lib/admins.ts
@@ -1,30 +1,63 @@
-import { asc, count, eq } from 'drizzle-orm'
+import { asc, count, eq, sql } from 'drizzle-orm'
import { db } from '@/db'
-import { admins } from '@/db/schema'
+import { admins, brothers } from '@/db/schema'
export type ClientAdmin = {
email: string
created_at: string
+ first_name: string | null
+ last_name: string | null
}
-export function toClientAdmin(row: typeof admins.$inferSelect): ClientAdmin {
+export function toClientAdmin(
+ row: typeof admins.$inferSelect,
+ brother?: { firstName: string | null; lastName: string | null } | null
+): ClientAdmin {
return {
email: row.email,
created_at: row.createdAt,
+ first_name: brother?.firstName ?? null,
+ last_name: brother?.lastName ?? null,
}
}
export async function listAdmins() {
- const rows = await db.select().from(admins).orderBy(asc(admins.email))
- return rows.map(toClientAdmin)
+ const rows = await db
+ .select({
+ admin: admins,
+ firstName: brothers.firstName,
+ lastName: brothers.lastName,
+ })
+ .from(admins)
+ .leftJoin(brothers, eq(brothers.umichEmail, admins.email))
+ .orderBy(
+ sql`lower(coalesce(${brothers.firstName}, ''))`,
+ sql`lower(coalesce(${brothers.lastName}, ''))`,
+ asc(admins.email)
+ )
+
+ return rows.map((row) => toClientAdmin(row.admin, { firstName: row.firstName, lastName: row.lastName }))
}
export async function addAdminEmail(email: string) {
const [existing] = await db.select().from(admins).where(eq(admins.email, email)).limit(1)
if (existing) return { admin: null, error: 'That email is already an admin' as const }
+ const [brother] = await db
+ .select({
+ id: brothers.id,
+ firstName: brothers.firstName,
+ lastName: brothers.lastName,
+ })
+ .from(brothers)
+ .where(eq(brothers.umichEmail, email))
+ .limit(1)
+ if (!brother) {
+ return { admin: null, error: 'No brother found with that UMich email' as const }
+ }
+
const [created] = await db.insert(admins).values({ email }).returning()
- return { admin: created ? toClientAdmin(created) : null, error: null }
+ return { admin: created ? toClientAdmin(created, brother) : null, error: null }
}
export async function removeAdminEmail(email: string, actorEmail: string) {
diff --git a/src/lib/apply-load.ts b/src/lib/apply-load.ts
index 2748ad0..1b68b6a 100644
--- a/src/lib/apply-load.ts
+++ b/src/lib/apply-load.ts
@@ -9,32 +9,42 @@ import {
getOrCreateApplication,
} from '@/lib/applications'
import type { FileSlot } from '@/lib/apply-steps'
+import { getBrotherByUmichEmail } from '@/lib/brothers'
import { requireUser } from '@/lib/supabase/auth-helpers'
+const emptyFiles = {} as Partial>
+const emptyAnswers = {} as Record
+
export async function loadApplyContext() {
const user = await requireUser()
const cycle = await getActiveCycle()
+ const isBrother = Boolean(user?.email && (await getBrotherByUmichEmail(user.email)))
if (!user) {
return {
user: null,
+ isBrother: false,
cycle,
application: null,
questions: [],
- answers: {} as Record,
- files: {} as Partial>,
+ answers: emptyAnswers,
+ files: emptyFiles,
window: cycle ? cycleWindow(cycle) : null,
}
}
- if (!cycle) {
+
+ const signedIn = { id: user.id, email: user.email! }
+
+ if (isBrother || !cycle) {
return {
- user: { id: user.id, email: user.email! },
- cycle: null,
+ user: signedIn,
+ isBrother,
+ cycle,
application: null,
questions: [],
- answers: {} as Record,
- files: {} as Partial>,
- window: null,
+ answers: emptyAnswers,
+ files: emptyFiles,
+ window: cycle ? cycleWindow(cycle) : null,
}
}
@@ -48,7 +58,8 @@ export async function loadApplyContext() {
const fileRows = await getApplicationFiles(application.id)
return {
- user: { id: user.id, email: user.email! },
+ user: signedIn,
+ isBrother: false,
cycle,
application: {
id: application.id,
@@ -76,6 +87,7 @@ export async function loadApplyContext() {
export async function requireApplyDraft() {
const ctx = await loadApplyContext()
+ if (ctx.isBrother) redirect('/apply')
if (!ctx.user || !ctx.cycle || !ctx.application) redirect('/apply')
if (ctx.application.status === 'submitted') redirect('/apply')
if (ctx.window && !ctx.window.isOpen) redirect('/apply')
diff --git a/src/lib/brother-schema.ts b/src/lib/brother-schema.ts
new file mode 100644
index 0000000..19d5b3c
--- /dev/null
+++ b/src/lib/brother-schema.ts
@@ -0,0 +1,79 @@
+import { z } from 'zod'
+
+const emptyToNull = (value: string | null | undefined) => {
+ if (value == null) return null
+ const trimmed = value.trim()
+ return trimmed === '' ? null : trimmed
+}
+
+export const brotherWriteSchema = z.object({
+ first_name: z.string().trim().min(1, 'First name is required').max(80),
+ last_name: z.string().trim().min(1, 'Last name is required').max(80),
+ umich_email: z
+ .string()
+ .trim()
+ .min(1, 'UMich email is required')
+ .max(320)
+ .toLowerCase()
+ .email('Enter a valid UMich email')
+ .refine((value) => value.endsWith('@umich.edu'), 'UMich email must be @umich.edu'),
+ pledge_class: z.string().trim().min(1, 'Pledge class is required').max(40),
+ linkedin_url: z
+ .string()
+ .trim()
+ .max(500)
+ .transform(emptyToNull)
+ .transform((value) => {
+ if (!value) return null
+ return /^https?:\/\//i.test(value) ? value : `https://${value}`
+ })
+ .refine((value) => {
+ if (value == null) return true
+ try {
+ new URL(value)
+ return true
+ } catch {
+ return false
+ }
+ }, 'Enter a valid LinkedIn URL'),
+ photo_filename: z.string().trim().max(255).transform(emptyToNull),
+})
+
+export type BrotherWrite = z.infer
+
+export type BrotherFormInput = {
+ first_name: string
+ last_name: string
+ umich_email: string
+ pledge_class: string
+ linkedin_url: string
+ photo_filename: string
+}
+
+export type ClientBrother = {
+ id: string
+ first_name: string | null
+ last_name: string | null
+ umich_email: string | null
+ contact_email: string | null
+ linkedin_url: string | null
+ photo_filename: string | null
+ status: 'active' | 'alumni'
+ pledge_class: string | null
+}
+
+export function parseBrotherWrite(input: unknown) {
+ const result = brotherWriteSchema.safeParse(input)
+ if (!result.success) {
+ return { data: null, error: result.error.issues[0]?.message ?? 'Invalid brother' }
+ }
+ return { data: result.data, error: null }
+}
+
+export function parseBrotherId(input: unknown) {
+ const result = z.uuid('Invalid brother id').safeParse(input)
+ if (!result.success) {
+ return { data: null, error: result.error.issues[0]?.message ?? 'Invalid brother id' }
+ }
+ return { data: result.data, error: null }
+}
diff --git a/src/lib/brothers.ts b/src/lib/brothers.ts
new file mode 100644
index 0000000..e20160f
--- /dev/null
+++ b/src/lib/brothers.ts
@@ -0,0 +1,123 @@
+import 'server-only'
+
+import { and, asc, eq, ne, sql } from 'drizzle-orm'
+import { db } from '@/db'
+import { admins, brothers } from '@/db/schema'
+import type { BrotherWrite, ClientBrother } from '@/lib/brother-schema'
+
+export type { ClientBrother }
+
+export function toClientBrother(row: typeof brothers.$inferSelect): ClientBrother {
+ return {
+ id: row.id,
+ first_name: row.firstName,
+ last_name: row.lastName,
+ umich_email: row.umichEmail,
+ contact_email: row.contactEmail,
+ linkedin_url: row.linkedinUrl,
+ photo_filename: row.photoFilename,
+ status: row.status,
+ pledge_class: row.pledgeClass,
+ }
+}
+
+export function brotherDisplayName(brother: ClientBrother, fallbackEmail: string) {
+ const name = [brother.first_name, brother.last_name].filter(Boolean).join(' ').trim()
+ return name || fallbackEmail
+}
+
+export async function getBrotherByUmichEmail(email: string) {
+ const normalized = email.trim().toLowerCase()
+ if (!normalized) return null
+ const [row] = await db
+ .select()
+ .from(brothers)
+ .where(eq(brothers.umichEmail, normalized))
+ .limit(1)
+ return row ? toClientBrother(row) : null
+}
+
+export async function listBrothers() {
+ const rows = await db
+ .select()
+ .from(brothers)
+ .orderBy(
+ sql`lower(coalesce(${brothers.firstName}, ''))`,
+ sql`lower(coalesce(${brothers.lastName}, ''))`,
+ asc(brothers.umichEmail)
+ )
+ return rows.map(toClientBrother)
+}
+
+function writeValues(input: BrotherWrite) {
+ return {
+ firstName: input.first_name,
+ lastName: input.last_name,
+ umichEmail: input.umich_email,
+ pledgeClass: input.pledge_class,
+ linkedinUrl: input.linkedin_url,
+ photoFilename: input.photo_filename,
+ }
+}
+
+async function umichEmailTaken(email: string, exceptId?: string) {
+ const [existing] = await db
+ .select({ id: brothers.id })
+ .from(brothers)
+ .where(
+ exceptId
+ ? and(eq(brothers.umichEmail, email), ne(brothers.id, exceptId))
+ : eq(brothers.umichEmail, email)
+ )
+ .limit(1)
+ return Boolean(existing)
+}
+
+export async function addBrotherRow(input: BrotherWrite) {
+ if (await umichEmailTaken(input.umich_email)) {
+ return { brother: null, error: 'That UMich email is already a brother' as const }
+ }
+
+ const [created] = await db
+ .insert(brothers)
+ .values({
+ ...writeValues(input),
+ status: 'active',
+ })
+ .returning()
+
+ return { brother: created ? toClientBrother(created) : null, error: null }
+}
+
+export async function updateBrotherRow(id: string, input: BrotherWrite) {
+ const [row] = await db.select({ id: brothers.id }).from(brothers).where(eq(brothers.id, id)).limit(1)
+ if (!row) return { brother: null, error: 'Brother not found' as const }
+
+ if (await umichEmailTaken(input.umich_email, id)) {
+ return { brother: null, error: 'That UMich email is already a brother' as const }
+ }
+
+ const [updated] = await db
+ .update(brothers)
+ .set(writeValues(input))
+ .where(eq(brothers.id, id))
+ .returning()
+
+ return { brother: updated ? toClientBrother(updated) : null, error: null }
+}
+
+export async function removeBrotherRow(id: string, actorEmail: string) {
+ const [row] = await db.select().from(brothers).where(eq(brothers.id, id)).limit(1)
+ if (!row) return { error: 'Brother not found' as const }
+ if (row.umichEmail && row.umichEmail === actorEmail.toLowerCase()) {
+ return { error: 'You cannot remove yourself' as const }
+ }
+ if (row.umichEmail) {
+ const [admin] = await db.select({ email: admins.email }).from(admins).where(eq(admins.email, row.umichEmail)).limit(1)
+ if (admin) return { error: 'Remove their admin access first' as const }
+ }
+
+ const deleted = await db.delete(brothers).where(eq(brothers.id, id)).returning()
+ if (!deleted.length) return { error: 'Brother not found' as const }
+ return { error: null }
+}
diff --git a/src/lib/portal.ts b/src/lib/portal.ts
new file mode 100644
index 0000000..b6f3376
--- /dev/null
+++ b/src/lib/portal.ts
@@ -0,0 +1,18 @@
+import { redirect } from 'next/navigation'
+import { getCurrentUser } from '@/lib/supabase/auth-helpers'
+import { getBrotherByUmichEmail, type ClientBrother } from '@/lib/brothers'
+
+export async function requirePortalUser(): Promise<{
+ email: string
+ brother: ClientBrother
+}> {
+ const currentUser = await getCurrentUser()
+ if (!currentUser?.email) {
+ redirect('/login')
+ }
+ const brother = await getBrotherByUmichEmail(currentUser.email)
+ if (!brother) {
+ redirect('/apply')
+ }
+ return { email: currentUser.email, brother }
+}
diff --git a/supabase/migrations/20260816120000_brothers.sql b/supabase/migrations/20260816120000_brothers.sql
new file mode 100644
index 0000000..a1d3f32
--- /dev/null
+++ b/supabase/migrations/20260816120000_brothers.sql
@@ -0,0 +1,53 @@
+-- Chapter people. umich_email is the /portal login key and is optional
+-- so alumni without a umich address can still exist as rows.
+-- Existing admins are backfilled so they can sign in after /portal ships.
+
+CREATE TABLE public.brothers (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ first_name text,
+ last_name text,
+ umich_email text,
+ contact_email text,
+ status text NOT NULL DEFAULT 'active',
+ pledge_class text,
+ created_at timestamptz NOT NULL DEFAULT timezone('utc'::text, now()),
+ updated_at timestamptz NOT NULL DEFAULT timezone('utc'::text, now()),
+ CONSTRAINT brothers_status_check CHECK (status IN ('active', 'alumni'))
+);
+
+CREATE UNIQUE INDEX brothers_umich_email_key
+ ON public.brothers (umich_email)
+ WHERE umich_email IS NOT NULL;
+
+CREATE TRIGGER update_brothers_updated_at
+ BEFORE UPDATE ON public.brothers
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+INSERT INTO public.brothers (umich_email, status)
+SELECT lower(a.email), 'active'
+FROM public.admins AS a
+WHERE NOT EXISTS (
+ SELECT 1
+ FROM public.brothers AS b
+ WHERE b.umich_email = lower(a.email)
+);
+
+ALTER TABLE public.brothers ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Admins can manage brothers"
+ ON public.brothers
+ USING (public.is_admin(auth.uid()))
+ WITH CHECK (public.is_admin(auth.uid()));
+
+CREATE POLICY "Brothers can read own row"
+ ON public.brothers FOR SELECT
+ TO authenticated
+ USING (
+ umich_email IS NOT NULL
+ AND EXISTS (
+ SELECT 1
+ FROM auth.users AS u
+ WHERE u.id = auth.uid()
+ AND lower(u.email) = umich_email
+ )
+ );
diff --git a/supabase/migrations/20260816140000_brothers_linkedin_photo.sql b/supabase/migrations/20260816140000_brothers_linkedin_photo.sql
new file mode 100644
index 0000000..483a544
--- /dev/null
+++ b/supabase/migrations/20260816140000_brothers_linkedin_photo.sql
@@ -0,0 +1,7 @@
+-- Directory fields for brothers. contact_email stays on the table for later
+-- (alumni / CSV); the admin add/edit form uses LinkedIn + photo instead.
+-- Columns stay nullable so CSV/alumni imports can still land incomplete rows;
+-- the admin form enforces first name, last name, umich email, and pledge class.
+
+ALTER TABLE public.brothers ADD COLUMN IF NOT EXISTS linkedin_url text;
+ALTER TABLE public.brothers ADD COLUMN IF NOT EXISTS photo_filename text;
From ce2056c54a66020e006d8d63559c669f119a99f5 Mon Sep 17 00:00:00 2001
From: In Lorthongpanich
Date: Sun, 16 Aug 2026 20:12:14 +0700
Subject: [PATCH 5/6] Fix interview search type error
---
src/components/portal/InterviewHomeSection.tsx | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/components/portal/InterviewHomeSection.tsx b/src/components/portal/InterviewHomeSection.tsx
index bd0a5d8..4c25129 100644
--- a/src/components/portal/InterviewHomeSection.tsx
+++ b/src/components/portal/InterviewHomeSection.tsx
@@ -38,9 +38,11 @@ const emptyForm = {
brother: '',
}
-function cellValue(row: InterviewRow, key: (typeof COLUMNS)[number]['key']) {
+function cellValue(row: InterviewRow, key: (typeof COLUMNS)[number]['key']): string {
if (key === 'submitted') return submittedLabel(row.daysAgo)
- return row[key] || '—'
+ const value = row[key]
+ if (value == null || value === '') return '—'
+ return String(value)
}
function SearchIcon() {
From 9a2e61ce050eeeb352730b6e9633458ce8211215 Mon Sep 17 00:00:00 2001
From: In Lorthongpanich
Date: Sun, 16 Aug 2026 20:33:38 +0700
Subject: [PATCH 6/6] Add admin apply preview and edit application text
---
src/app/apply/ApplyDraftSection.tsx | 16 ++++-
src/app/apply/academic/page.tsx | 9 ++-
src/app/apply/additional/page.tsx | 9 ++-
src/app/apply/involvement/page.tsx | 9 ++-
src/app/apply/page.tsx | 27 +++++++-
src/app/apply/personal/page.tsx | 9 ++-
src/app/apply/questions/page.tsx | 9 ++-
src/app/apply/review/page.tsx | 9 ++-
src/app/auth/callback/route.ts | 10 +--
src/components/RushApplicationManager.tsx | 24 ++++++-
src/components/apply/ApplySectionForm.tsx | 63 ++++++++++++-----
src/components/apply/ApplyShell.tsx | 8 +++
src/components/apply/DummyFileField.tsx | 37 ++++++++--
src/lib/applications.ts | 5 ++
src/lib/apply-load.ts | 84 +++++++++++++++++++++--
src/lib/apply-preview.ts | 21 ++++++
supabase/seed.sql | 2 +-
17 files changed, 299 insertions(+), 52 deletions(-)
create mode 100644 src/lib/apply-preview.ts
diff --git a/src/app/apply/ApplyDraftSection.tsx b/src/app/apply/ApplyDraftSection.tsx
index 9097600..ed15679 100644
--- a/src/app/apply/ApplyDraftSection.tsx
+++ b/src/app/apply/ApplyDraftSection.tsx
@@ -1,15 +1,25 @@
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 }: { step: ApplyStepSlug }) {
- const ctx = await requireApplyDraft()
+export async function ApplyDraftSection({
+ step,
+ searchParams,
+}: {
+ step: ApplyStepSlug
+ searchParams: Promise
+}) {
+ const preview = parseApplyPreview(await searchParams)
+ const ctx = await requireApplyDraft(preview)
return (
-
+
+export default function Page({
+ searchParams,
+}: {
+ searchParams: Promise
+}) {
+ return
}
diff --git a/src/app/apply/additional/page.tsx b/src/app/apply/additional/page.tsx
index 22c8fea..63b9eb5 100644
--- a/src/app/apply/additional/page.tsx
+++ b/src/app/apply/additional/page.tsx
@@ -1,5 +1,10 @@
import { ApplyDraftSection } from '../ApplyDraftSection'
+import type { ApplyPreviewQuery } from '@/lib/apply-preview'
-export default function Page() {
- return
+export default function Page({
+ searchParams,
+}: {
+ searchParams: Promise
+}) {
+ return
}
diff --git a/src/app/apply/involvement/page.tsx b/src/app/apply/involvement/page.tsx
index c76f6f5..ebbcf96 100644
--- a/src/app/apply/involvement/page.tsx
+++ b/src/app/apply/involvement/page.tsx
@@ -1,5 +1,10 @@
import { ApplyDraftSection } from '../ApplyDraftSection'
+import type { ApplyPreviewQuery } from '@/lib/apply-preview'
-export default function Page() {
- return
+export default function Page({
+ searchParams,
+}: {
+ searchParams: Promise
+}) {
+ return
}
diff --git a/src/app/apply/page.tsx b/src/app/apply/page.tsx
index 3d2888e..14e1a60 100644
--- a/src/app/apply/page.tsx
+++ b/src/app/apply/page.tsx
@@ -3,10 +3,33 @@ import { ApplyRecap } from '@/components/apply/ApplySectionForm'
import { applyCardStyle, ApplyShell } from '@/components/apply/ApplyShell'
import { UmichGoogleButton } from '@/components/apply/UmichGoogleButton'
import { loadApplyContext } from '@/lib/apply-load'
+import { applyPreviewHref, parseApplyPreview, type ApplyPreviewQuery } from '@/lib/apply-preview'
import { applicationClosedMessage, applicationTitle } from '@/lib/apply-steps'
-export default async function ApplyWelcomePage() {
- const ctx = await loadApplyContext()
+export default async function ApplyWelcomePage({
+ searchParams,
+}: {
+ searchParams: Promise
+}) {
+ const preview = parseApplyPreview(await searchParams)
+ const ctx = await loadApplyContext(preview)
+
+ if (ctx.isPreview && ctx.cycle) {
+ const title = applicationTitle(ctx.cycle.name)
+ return (
+
+
+ {ctx.cycle.introMarkdown}
+
+ Continue application
+
+
+
+ )
+ }
if (ctx.isBrother) {
return (
diff --git a/src/app/apply/personal/page.tsx b/src/app/apply/personal/page.tsx
index fb78fd0..750e2d7 100644
--- a/src/app/apply/personal/page.tsx
+++ b/src/app/apply/personal/page.tsx
@@ -1,5 +1,10 @@
import { ApplyDraftSection } from '../ApplyDraftSection'
+import type { ApplyPreviewQuery } from '@/lib/apply-preview'
-export default function Page() {
- return
+export default function Page({
+ searchParams,
+}: {
+ searchParams: Promise
+}) {
+ return
}
diff --git a/src/app/apply/questions/page.tsx b/src/app/apply/questions/page.tsx
index e34b379..74c64b6 100644
--- a/src/app/apply/questions/page.tsx
+++ b/src/app/apply/questions/page.tsx
@@ -1,5 +1,10 @@
import { ApplyDraftSection } from '../ApplyDraftSection'
+import type { ApplyPreviewQuery } from '@/lib/apply-preview'
-export default function Page() {
- return
+export default function Page({
+ searchParams,
+}: {
+ searchParams: Promise
+}) {
+ return
}
diff --git a/src/app/apply/review/page.tsx b/src/app/apply/review/page.tsx
index d0417b9..4ff9960 100644
--- a/src/app/apply/review/page.tsx
+++ b/src/app/apply/review/page.tsx
@@ -1,5 +1,10 @@
import { ApplyDraftSection } from '../ApplyDraftSection'
+import type { ApplyPreviewQuery } from '@/lib/apply-preview'
-export default function Page() {
- return
+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 927a9f9..0c21183 100644
--- a/src/app/auth/callback/route.ts
+++ b/src/app/auth/callback/route.ts
@@ -1,6 +1,7 @@
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
@@ -30,10 +31,11 @@ export async function GET(request: Request) {
const explicitNext = safeNext(requestUrl.searchParams.get('next'))
if (brother) {
- if (explicitNext === '/apply') {
- return NextResponse.redirect(`${origin}/apply`)
- }
- if (explicitNext?.startsWith('/apply/')) {
+ if (explicitNext?.startsWith('/apply')) {
+ const nextUrl = new URL(explicitNext, origin)
+ if (nextUrl.searchParams.get('preview') === '1' && (await checkIsAdmin())) {
+ return NextResponse.redirect(`${origin}${explicitNext}`)
+ }
return NextResponse.redirect(`${origin}/apply`)
}
return NextResponse.redirect(`${origin}${explicitNext ?? '/portal'}`)
diff --git a/src/components/RushApplicationManager.tsx b/src/components/RushApplicationManager.tsx
index 2f2dde1..acbeb05 100644
--- a/src/components/RushApplicationManager.tsx
+++ b/src/components/RushApplicationManager.tsx
@@ -6,6 +6,7 @@ import {
openRushApplicationNow,
saveRushApplicationCycle,
} from '@/app/admin/actions'
+import { applyPreviewHref } from '@/lib/apply-preview'
import type { ClientCycleQuestion, ClientRushCycle } from '@/lib/rush-cycles'
const inputClass =
@@ -30,6 +31,15 @@ type QuestionDraft = {
required: boolean
}
+function EyeIcon() {
+ return (
+
+ )
+}
+
function questionsFromServer(questions: ClientCycleQuestion[]): QuestionDraft[] {
return questions.map((question) => ({
key: question.id,
@@ -64,7 +74,7 @@ function cycleStatus(opensAt: string, closesAt: string) {
if (now > closes) {
return { label: 'Closed', className: 'bg-gray-200/60 text-gray-600' }
}
- return { label: 'Accepting responses', className: 'bg-[#315CA9] text-white' }
+ return { label: 'Open', className: 'bg-[#315CA9] text-white' }
}
export type RushApplicationHandle = {
@@ -284,6 +294,18 @@ const RushApplicationManager = forwardRef<