From 20ff18edbe55f0a6bdb15b78088fa03f39b5a3e7 Mon Sep 17 00:00:00 2001
From: Shamik Karkhanis
Date: Thu, 26 Mar 2026 00:20:49 -0400
Subject: [PATCH 1/9] feat(events): minimal
---
package-lock.json | 29 ++-
src/app/sections/EventsSection.module.css | 166 ++++++++++++++-
src/app/sections/EventsSection.tsx | 234 +++++++++++++++++++++-
vite.config.ts | 2 +-
4 files changed, 408 insertions(+), 23 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 5c77984..1ebc9e9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -98,6 +98,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -764,6 +765,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -787,6 +789,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -2126,8 +2129,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -2289,6 +2291,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2299,6 +2302,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -2379,6 +2383,7 @@
"integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.57.2",
"@typescript-eslint/types": "8.57.2",
@@ -2950,6 +2955,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3233,6 +3239,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3720,8 +3727,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
@@ -3822,6 +3828,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4819,6 +4826,7 @@
"integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/core": "30.3.0",
"@jest/types": "30.3.0",
@@ -5784,6 +5792,7 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@@ -6324,7 +6333,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -6915,7 +6923,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -6931,7 +6938,6 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=8"
}
@@ -6942,7 +6948,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -6982,6 +6987,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6991,6 +6997,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -7023,8 +7030,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/react-router": {
"version": "7.13.2",
@@ -7877,6 +7883,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -8027,6 +8034,7 @@
"integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.3",
@@ -8522,6 +8530,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/src/app/sections/EventsSection.module.css b/src/app/sections/EventsSection.module.css
index 907bead..347ec46 100644
--- a/src/app/sections/EventsSection.module.css
+++ b/src/app/sections/EventsSection.module.css
@@ -1,15 +1,169 @@
.eventsPanel {
- padding: 8rem 4rem;
+ display: grid;
+ grid-template-rows: repeat(2, minmax(0, auto));
+ align-content: start;
+ gap: 2rem;
+ padding: 2.5rem 0 6rem 4rem;
+ height: 100%;
+ box-sizing: border-box;
+ overflow-y: auto;
+ overscroll-behavior-y: contain;
+ scrollbar-width: none;
}
-.title {
- font: 600 40px/1 var(--font-display);
+.eventsPanel::-webkit-scrollbar {
+ display: none;
+}
+
+.eventGroup {
+ display: grid;
+ grid-template-rows: auto auto;
+ gap: 0.875rem;
+ overflow: hidden;
+ padding-bottom: 0.75rem;
+ min-height: 390px;
+}
+
+.groupHeader {
+ display: grid;
+ gap: 0;
+ padding-right: 4rem;
+}
+
+.groupTitle {
+ margin: 0;
+ color: var(--c-text-light);
+ font: 600 28px/1 var(--font-display);
text-transform: lowercase;
- margin-bottom: 2rem;
+}
+
+.carousel {
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: minmax(320px, 42%);
+ gap: var(--space-24);
+ overflow-x: auto;
+ overflow-y: visible;
+ padding: 4px 4rem 12px 0;
+ scroll-snap-type: x proximity;
+ scrollbar-width: none;
+ overscroll-behavior-x: contain;
+ -webkit-overflow-scrolling: touch;
+ align-items: stretch;
+}
+
+.carousel::-webkit-scrollbar {
+ display: none;
+}
+
+.carousel:focus-visible {
+ outline: 2px solid rgba(255, 255, 255, 0.82);
+ outline-offset: 6px;
+}
+
+.eventCard {
+ display: grid;
+ gap: 0.75rem;
+ align-content: start;
+ min-height: 0;
+ padding: 18px;
+ border-radius: 28px;
+ border: 1px solid rgba(126, 225, 218, 0.28);
+ background:
+ linear-gradient(180deg, rgba(75, 183, 173, 0.24), rgba(35, 111, 107, 0.2)),
+ rgba(255, 255, 255, 0.02);
+ box-shadow:
+ 0 18px 40px rgba(0, 46, 44, 0.22),
+ 0 1px 0 rgba(255, 255, 255, 0.08) inset;
+ backdrop-filter: blur(var(--glass-blur-soft));
+ scroll-snap-align: start;
+}
+
+.cardHeader {
+ display: grid;
+ gap: 0.375rem;
+}
+
+.cardTitle {
+ margin: 0;
color: var(--c-text-light);
+ font: 600 24px/1.06 var(--font-display);
+ text-wrap: balance;
}
-.eventsPanel p {
- font: 400 18px/1.35 var(--font-body);
+.cardDescription {
+ margin: 0;
+ color: rgba(249, 250, 250, 0.82);
+ font: 400 15px/1.35 var(--font-body);
+}
+
+.metaList {
+ display: grid;
+ gap: 8px;
+ margin: 0;
+}
+
+.metaRow {
+ display: grid;
+ grid-template-columns: 58px minmax(0, 1fr);
+ gap: var(--space-16);
+ align-items: start;
+}
+
+.metaRow dt {
+ color: rgba(249, 250, 250, 0.54);
+ font: 600 12px/1.2 var(--font-body);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.metaRow dd {
+ margin: 0;
color: var(--c-text-light);
+ font: 500 14px/1.3 var(--font-body);
+}
+
+@media (max-width: 1200px) {
+ .eventsPanel {
+ gap: 1.75rem;
+ padding: 2.5rem 0 5rem 2.75rem;
+ }
+
+ .groupHeader,
+ .carousel {
+ padding-right: 2.75rem;
+ }
+}
+
+@media (max-width: 720px) {
+ .eventsPanel {
+ gap: 1.25rem;
+ padding: 2rem 0 2.5rem 1.5rem;
+ }
+
+ .groupTitle {
+ font-size: 24px;
+ }
+
+ .groupHeader,
+ .carousel {
+ padding-right: 1.5rem;
+ }
+
+ .carousel {
+ grid-auto-columns: minmax(260px, 78vw);
+ }
+
+ .cardTitle {
+ font-size: 22px;
+ }
+
+ .eventCard {
+ min-height: 0;
+ padding: 16px;
+ }
+
+ .eventGroup {
+ min-height: auto;
+ }
}
diff --git a/src/app/sections/EventsSection.tsx b/src/app/sections/EventsSection.tsx
index f58c41a..b94ec23 100644
--- a/src/app/sections/EventsSection.tsx
+++ b/src/app/sections/EventsSection.tsx
@@ -1,15 +1,237 @@
+import type { WheelEvent } from 'react'
+import { useRef } from 'react'
import { AnimatedPanel } from '@/shared/components/AnimatedPanel'
+import { useAuth } from '@/shared/context/AuthContext'
+import { useHorizontalWheelScroll } from '@/shared/hooks/useHorizontalWheelScroll'
import styles from './EventsSection.module.css'
+const myEvents = [
+ {
+ title: 'Startup Sprint Demo Night',
+ location: 'Innovation Lab',
+ event_time: '2026-03-27T18:30:00Z',
+ description:
+ 'Pitch practice, rapid demos, and founder feedback for teams building before finals week.',
+ org_id: '550e8400-e29b-41d4-a716-446655440000',
+ },
+ {
+ title: 'Sunrise Run Club',
+ location: 'North Quad',
+ event_time: '2026-03-28T07:00:00Z',
+ description:
+ 'Low-pressure miles, coffee after, and enough accountability to actually leave your dorm.',
+ org_id: '0b2f0d2f-7f6d-49c3-9a55-946bce7d1111',
+ },
+ {
+ title: 'Design Crit and Portfolio Lab',
+ location: 'Media Studio 204',
+ event_time: '2026-03-31T17:15:00Z',
+ description: 'Bring a draft, get sharp feedback, and leave with a cleaner portfolio story.',
+ org_id: '6aa0ef8f-b8fb-4c13-b060-2e7d0f4c2222',
+ },
+ {
+ title: 'Volunteer Garden Reset',
+ location: 'Campus Greenhouse',
+ event_time: '2026-04-01T15:30:00Z',
+ description:
+ 'Help prep beds for spring planting, then split snacks and starter herbs with the crew.',
+ org_id: '2d9b7b44-8cf4-4db5-9863-1f3c2f183333',
+ },
+ {
+ title: 'Women in Tech Mixer',
+ location: 'Engineering Atrium',
+ event_time: '2026-04-02T18:00:00Z',
+ description:
+ 'Short intros, recruiter chats, and actual conversation prompts instead of awkward hovering.',
+ org_id: '7c4070f3-8fca-4f2e-9dc6-738553204444',
+ },
+ {
+ title: 'Open Mic Basement Sessions',
+ location: 'Commons Basement',
+ event_time: '2026-04-03T20:30:00Z',
+ description:
+ 'Poetry, acoustic sets, and low-stakes stage time for anyone testing something new.',
+ org_id: 'edca8c61-ff14-4fbe-8ae6-b8f11f0d5555',
+ },
+ {
+ title: 'Finance Interview Drill Room',
+ location: 'Career Center 118',
+ event_time: '2026-04-04T16:45:00Z',
+ description:
+ 'Timed technical rounds and peer feedback for anyone trying to sharpen before recruiting season.',
+ org_id: 'fb9708d9-b6e9-49ae-a181-02617cd76666',
+ },
+ {
+ title: 'Film Club Rooftop Screening',
+ location: 'West Hall Rooftop',
+ event_time: '2026-04-05T19:45:00Z',
+ description:
+ 'Bring a blanket, vote on the final cut, and stay for the debate after the credits roll.',
+ org_id: '6df10a82-a297-46a5-8e4d-8bf806e27777',
+ },
+]
+
+const recommendedEvents = [
+ {
+ title: 'Moonlight Market',
+ location: 'Student Center Lawn',
+ event_time: '2026-04-02T20:00:00Z',
+ description:
+ 'Late-night food stalls, student vendors, and a live DJ set under the string lights.',
+ org_id: '940d57fd-3c9a-4ae8-850a-d94120068888',
+ },
+ {
+ title: 'AI for Everyone Fireside',
+ location: 'Library Forum',
+ event_time: '2026-04-03T16:00:00Z',
+ description:
+ 'A practical conversation on using AI tools without losing your own judgment or voice.',
+ org_id: '4e2cc020-2d1c-4d85-9d9a-e56aa1099999',
+ },
+ {
+ title: 'Pottery Pop-Up',
+ location: 'Arts Courtyard',
+ event_time: '2026-04-05T13:00:00Z',
+ description:
+ 'Wheel demos, hand-building stations, and take-home clay kits while supplies last.',
+ org_id: '8e746a88-3cab-46b5-b80f-fdb65f2aaaaa',
+ },
+ {
+ title: 'Night Market Thrift Swap',
+ location: 'Union Arcade',
+ event_time: '2026-04-06T18:00:00Z',
+ description:
+ 'Trade pieces you are done with, browse student racks, and leave with a better jacket.',
+ org_id: '0f4f70bd-e2a8-48c7-a7bf-9a05f44bbbbb',
+ },
+ {
+ title: 'Hack and Snack Study Jam',
+ location: 'CS Lab East',
+ event_time: '2026-04-07T20:00:00Z',
+ description:
+ 'A quiet build session with mentors floating, whiteboards open, and enough snacks to stay late.',
+ org_id: 'c1763eb7-109f-4d7f-bf19-cba0a8dfcccc',
+ },
+ {
+ title: 'Street Photography Walk',
+ location: 'Downtown Station',
+ event_time: '2026-04-08T17:30:00Z',
+ description:
+ 'Golden-hour shooting prompts, editing tips, and a group critique after the walk back.',
+ org_id: 'd3176f00-8224-4302-b7ea-2c2a9476dddd',
+ },
+ {
+ title: 'Founder Office Hours',
+ location: 'Launchpad Hub',
+ event_time: '2026-04-09T14:15:00Z',
+ description:
+ 'Book a quick slot to pressure-test an idea, pricing plan, or pitch with alumni founders.',
+ org_id: '27ee52ab-0d70-49d8-9182-2cf94431eeee',
+ },
+ {
+ title: 'Campus Food Crawl',
+ location: 'Main Quad Fountain',
+ event_time: '2026-04-10T17:45:00Z',
+ description:
+ 'Small groups hit the best late-day food spots on and around campus with zero planning required.',
+ org_id: '8b3c1409-8804-4ff4-a0d1-4d330e4fffff',
+ },
+] as const
+
+type EventCardProps = {
+ title: string
+ location: string
+ event_time: string
+ description: string
+}
+
+const eventDateFormatter = new Intl.DateTimeFormat('en-US', {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+})
+
+function formatEventDate(value: string) {
+ return eventDateFormatter.format(new Date(value))
+}
+
+function EventCard({ title, location, event_time, description }: EventCardProps) {
+ return (
+
+
+
{title}
+
{description}
+
+
+
+
+
Date
+ {formatEventDate(event_time)}
+
+
+
Place
+ {location}
+
+
+
+ )
+}
+
export function EventsSection() {
+ const { isAuthed } = useAuth()
+ const myEventsRef = useRef(null)
+ const recommendedEventsRef = useRef(null)
+
+ useHorizontalWheelScroll(myEventsRef, { speed: 1, endCutoffPx: 0 })
+ useHorizontalWheelScroll(recommendedEventsRef, { speed: 1, endCutoffPx: 0 })
+
+ const stopWheelPropagation = (event: WheelEvent) => {
+ event.stopPropagation()
+ }
+
return (
-
-
events
-
- Discover what's happening on campus. Whether you're motivated by friends, food, or both.
-
-
+ {isAuthed ? (
+
+
+
+ my events
+
+
+
+
+ {myEvents.map((event) => (
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+ recommended
+
+
+
+
+ {recommendedEvents.map((event) => (
+
+ ))}
+
+
)
}
diff --git a/vite.config.ts b/vite.config.ts
index 2b564e4..16aca28 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -4,7 +4,7 @@ import path from 'path'
// https://vite.dev/config/
export default defineConfig(() => {
- const proxyTarget = 'https://dev.capyrpi.org'
+ const proxyTarget = 'http://localhost:8080'
return {
plugins: [react()],
From d320534ff0b98781f93a6536b26dd1b6dd3cefc5 Mon Sep 17 00:00:00 2001
From: Shamik Karkhanis
Date: Thu, 26 Mar 2026 19:24:07 -0400
Subject: [PATCH 2/9] event components
---
AGENTS.md | 169 ++++++++++++++++
src/app/components/EventCard.module.css | 68 +++++++
src/app/components/EventCard.tsx | 41 ++++
src/app/components/EventRail.module.css | 72 +++++++
src/app/components/EventRail.tsx | 48 +++++
src/app/data/events.ts | 84 ++++++++
src/app/sections/EventsSection.module.css | 139 -------------
src/app/sections/EventsSection.tsx | 230 +---------------------
8 files changed, 490 insertions(+), 361 deletions(-)
create mode 100644 AGENTS.md
create mode 100644 src/app/components/EventCard.module.css
create mode 100644 src/app/components/EventCard.tsx
create mode 100644 src/app/components/EventRail.module.css
create mode 100644 src/app/components/EventRail.tsx
create mode 100644 src/app/data/events.ts
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..a5f73b9
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,169 @@
+# Capy Lander
+
+Premium, horizontal-first React landing page based on Figma design `8:426`.
+
+---
+
+## 🏗️ Architecture
+
+- **React 19 + TypeScript + Vite**
+- **Feature-based folders:**
+ - `src/sections/` — Page sections (feature-based)
+ - `src/components/` — Shared UI components
+ - `src/hooks/` — Custom React hooks
+ - `src/data/` — Static content/data
+ - `src/theme/tokens.css` — Design tokens (colors, spacing, typography)
+- **CSS Modules:** All components use local CSS modules for styling
+- **Design Tokens:** All colors, spacing, and typography use CSS custom properties from `tokens.css`
+- **Framer Motion:** For reveal and premium interaction animation
+
+## 🚀 Getting Started
+
+1. **Install dependencies**:
+ ```bash
+ npm install
+ ```
+2. **Configure Environment**:
+ Create a `.env.local` file for local development (see [.env.example](.env.example)):
+ ```bash
+ cp .env.example .env.local
+ ```
+3. **Run development server**:
+ ```bash
+ npm run dev
+ ```
+
+## 🌐 Environment Variables
+
+The application uses Vite's environment variable system. For local development, use `.env.local`.
+
+| Variable | Description | Default |
+| :------------------ | :--------------------------------------------------------------------- | :---------- |
+| `VITE_API_BASE_URL` | The target backend for the dev proxy (e.g., `https://dev.capyrpi.org`) | `undefined` |
+| `VITE_API_VERSION` | The API version prefix | `/api/v1` |
+
+> [!NOTE]
+> If `VITE_API_BASE_URL` is set, Vite will automatically proxy all `/api` requests to that target. This avoids CORS issues and allows for testing against remote backends.
+
+Production build:
+
+```bash
+npm run build
+```
+
+## 🐳 Docker
+
+Build and run the production image locally:
+
+```bash
+docker build -t capy-lander:local .
+docker run --rm -p 8080:80 capy-lander:local
+```
+
+Open [http://localhost:8080](http://localhost:8080).
+
+## 🧩 Docker Compose
+
+This repo supports both Compose modes:
+
+- `image:` mode for reproducible runs (default in `docker-compose.yml`)
+- `build:` mode for local development iteration (in `docker-compose.override.yml`)
+
+By default, Docker Compose loads `docker-compose.override.yml`, so a local run builds from source:
+
+```bash
+docker compose up --build
+```
+
+To run a published registry image instead, disable overrides and set the image tag:
+
+```bash
+CAPY_IMAGE=ghcr.io//:latest docker compose -f docker-compose.yml up
+```
+
+## 🛠️ Contributing
+
+See [CONTRIBUTING.md](CONTRIBUTING.md) for full guidelines.
+
+- **Feature-based folders:** Place new features/sections in their own folder under `src/sections/` or `src/components/`.
+- **CSS Modules:** Use local CSS modules for all new components.
+- **Design Tokens:** Reference all colors, spacing, and typography via `src/theme/tokens.css`.
+- **JSDoc Comments:** Add clear JSDoc comments to all hooks and complex logic blocks, explaining _why_ the logic exists.
+
+## 🏭 GitHub Actions
+
+Workflow: `.github/workflows/docker-image.yml`
+
+- PRs to `main`: lint, build, and container build validation (no push)
+- Push to `main`: lint, build, build and push image to GHCR
+- Version tags (`v*`): lint, build, build and push versioned image tags
+
+---
+
+For questions, open an issue or start a discussion.
+
+Published image name:
+
+```text
+ghcr.io//
+```
+
+Tags include branch/PR refs, commit SHA, semver (for `v*` tags), and `latest` on the default branch.
+
+## Architecture
+
+- `src/App.tsx`: app shell + panel composition + horizontal scroll container
+- `src/hooks/useHorizontalWheelScroll.ts`: maps vertical wheel intent to horizontal scrolling
+- `src/components/*`: reusable primitives (`GlassCard`, `TopNav`, `AspectImage`)
+- `src/sections/*`: page-level sections matching Figma panel structure
+- `src/data/content.ts`: static content and asset URLs
+- `src/theme/tokens.css`: global design tokens (colors, spacing, radii, fonts, glass effects)
+
+## Design Tokens
+
+Core tokens live in `src/theme/tokens.css` and are consumed by all sections:
+
+- Global colors (`--c-bg`, `--c-surface`, `--c-accent`, text tones)
+- Glassmorphism (`--glass-blur`, `--glass-highlight`, `--glass-shadow`)
+- Type system (`--font-display`, `--font-body`)
+- Spacing/radius system (`--space-*`, `--radius-*`)
+
+This keeps visual updates centralized and safe.
+
+## Horizontal Scrolling Behavior
+
+- Vertical page scroll is disabled at document level.
+- Main scroller (`.horizontalScroller`) has x-overflow only.
+- Wheel events are intercepted and converted to horizontal movement.
+- Touchpad/mouse wheel deltas both work by choosing dominant intent (`deltaY` or `deltaX`).
+
+## SVG And Aspect Ratio Safety
+
+- All logo/icon/illustration image nodes use `AspectImage`.
+- `AspectImage` enforces `object-fit: contain` and centered positioning.
+- Containers define the intended dimensions; image content is never stretched.
+
+## Fidelity Checklist
+
+When iterating:
+
+- Verify panel widths/heights against Figma track
+- Verify card padding and inter-card gaps
+- Verify font sizes: 16, 18, 20, 24, 36, 40, 96, 160
+- Verify CTA dimensions and corner radii
+- Verify no vertical scrolling on desktop
+- Verify all SVGs/icons remain non-distorted
+
+## Public Assets
+
+- `public/assets/brand`: logo and brand marks
+- `public/assets/illustrations`: larger decorative illustrations
+- `public/assets/ui`: UI chrome shapes (pills and controls)
+- `public/assets/social`: social platform icons
+
+Canonical brand filenames:
+
+- `public/assets/brand/capy-full-white.svg`
+- `public/assets/brand/capy-full-primary.svg`
+
+Asset mapping is centralized in `src/data/content.ts`.
diff --git a/src/app/components/EventCard.module.css b/src/app/components/EventCard.module.css
new file mode 100644
index 0000000..a17502a
--- /dev/null
+++ b/src/app/components/EventCard.module.css
@@ -0,0 +1,68 @@
+.eventCard {
+ display: grid;
+ gap: var(--space-16);
+ align-content: start;
+ min-height: 0;
+ padding: 20px;
+ border-radius: 28px;
+ border: 1px solid rgba(126, 225, 218, 0.28);
+ background:
+ linear-gradient(180deg, rgba(75, 183, 173, 0.24), rgba(35, 111, 107, 0.2)),
+ rgba(255, 255, 255, 0.02);
+ backdrop-filter: blur(var(--glass-blur-soft));
+ scroll-snap-align: start;
+}
+
+.cardHeader {
+ display: grid;
+ gap: var(--space-8);
+}
+
+.cardTitle {
+ margin: 0;
+ color: var(--c-text-light);
+ font: 600 24px/1.06 var(--font-display);
+ text-wrap: balance;
+}
+
+.cardDescription {
+ margin: 0;
+ color: rgba(249, 250, 250, 0.82);
+ font: 400 15px/1.35 var(--font-body);
+}
+
+.metaList {
+ display: grid;
+ gap: var(--space-8);
+ margin: 0;
+}
+
+.metaRow {
+ display: grid;
+ grid-template-columns: 58px minmax(0, 1fr);
+ gap: var(--space-16);
+ align-items: start;
+}
+
+.metaRow dt {
+ color: rgba(249, 250, 250, 0.54);
+ font: 600 12px/1.2 var(--font-body);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.metaRow dd {
+ margin: 0;
+ color: var(--c-text-light);
+ font: 500 14px/1.3 var(--font-body);
+}
+
+@media (max-width: 720px) {
+ .eventCard {
+ padding: 16px;
+ }
+
+ .cardTitle {
+ font-size: 22px;
+ }
+}
diff --git a/src/app/components/EventCard.tsx b/src/app/components/EventCard.tsx
new file mode 100644
index 0000000..ef89a68
--- /dev/null
+++ b/src/app/components/EventCard.tsx
@@ -0,0 +1,41 @@
+import type { AppEvent } from '@/app/data/events'
+import styles from './EventCard.module.css'
+
+type EventCardProps = {
+ event: AppEvent
+}
+
+const eventDateFormatter = new Intl.DateTimeFormat('en-US', {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+})
+
+function formatEventTime(eventTime: string | null) {
+ if (!eventTime) return 'TBA'
+ return eventDateFormatter.format(new Date(eventTime))
+}
+
+export function EventCard({ event }: EventCardProps) {
+ return (
+
+
+
{event.title}
+
{event.description}
+
+
+
+
+
Time
+ {formatEventTime(event.event_time)}
+
+
+
Place
+ {event.location}
+
+
+
+ )
+}
diff --git a/src/app/components/EventRail.module.css b/src/app/components/EventRail.module.css
new file mode 100644
index 0000000..8e1dc1e
--- /dev/null
+++ b/src/app/components/EventRail.module.css
@@ -0,0 +1,72 @@
+.eventGroup {
+ display: grid;
+ grid-template-rows: auto auto;
+ align-content: start;
+ gap: 14px;
+ overflow: hidden;
+ padding-bottom: 12px;
+ min-height: 390px;
+}
+
+.groupHeader {
+ display: grid;
+ gap: 0;
+ padding-right: 64px;
+}
+
+.groupTitle {
+ margin: 0;
+ color: var(--c-text-light);
+ font: 600 28px/1 var(--font-display);
+ text-transform: lowercase;
+}
+
+.carousel {
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: minmax(320px, 42%);
+ gap: var(--space-24);
+ overflow-x: auto;
+ overflow-y: visible;
+ padding: 4px 64px 12px 0;
+ scroll-snap-type: x proximity;
+ scrollbar-width: none;
+ overscroll-behavior-x: contain;
+ -webkit-overflow-scrolling: touch;
+ align-items: stretch;
+}
+
+.carousel::-webkit-scrollbar {
+ display: none;
+}
+
+.carousel:focus-visible {
+ outline: 2px solid rgba(255, 255, 255, 0.82);
+ outline-offset: 6px;
+}
+
+@media (max-width: 1200px) {
+ .groupHeader,
+ .carousel {
+ padding-right: 44px;
+ }
+}
+
+@media (max-width: 720px) {
+ .eventGroup {
+ min-height: auto;
+ }
+
+ .groupTitle {
+ font-size: 24px;
+ }
+
+ .groupHeader,
+ .carousel {
+ padding-right: 24px;
+ }
+
+ .carousel {
+ grid-auto-columns: minmax(260px, 78vw);
+ }
+}
diff --git a/src/app/components/EventRail.tsx b/src/app/components/EventRail.tsx
new file mode 100644
index 0000000..d661cc4
--- /dev/null
+++ b/src/app/components/EventRail.tsx
@@ -0,0 +1,48 @@
+import type { WheelEvent } from 'react'
+import { useRef } from 'react'
+import type { AppEvent } from '@/app/data/events'
+import { useHorizontalWheelScroll } from '@/shared/hooks/useHorizontalWheelScroll'
+import { EventCard } from './EventCard'
+import styles from './EventRail.module.css'
+
+type EventRailProps = {
+ title: string
+ events: AppEvent[]
+ carouselLabel: string
+}
+
+function getEventKey(event: AppEvent) {
+ return `${event.org_id}-${event.title}-${event.event_time ?? 'tba'}`
+}
+
+export function EventRail({ title, events, carouselLabel }: EventRailProps) {
+ const railRef = useRef(null)
+ useHorizontalWheelScroll(railRef, { speed: 1, endCutoffPx: 0 })
+ const headingId = `${title.replace(/\s+/g, '-')}-heading`
+
+ const stopWheelPropagation = (event: WheelEvent) => {
+ event.stopPropagation()
+ }
+
+ return (
+
+
+
+ {title}
+
+
+
+
+ {events.map((event) => (
+
+ ))}
+
+
+ )
+}
diff --git a/src/app/data/events.ts b/src/app/data/events.ts
new file mode 100644
index 0000000..43d2f70
--- /dev/null
+++ b/src/app/data/events.ts
@@ -0,0 +1,84 @@
+export type AppEvent = {
+ title: string
+ location: string
+ event_time: string | null
+ description: string
+ org_id: string
+}
+
+export const myEvents: AppEvent[] = [
+ {
+ title: 'Startup Sprint Demo Night',
+ location: 'Innovation Lab',
+ event_time: '2026-03-27T18:30:00Z',
+ description:
+ 'Pitch practice, rapid demos, and founder feedback for teams building before finals week.',
+ org_id: '550e8400-e29b-41d4-a716-446655440000',
+ },
+ {
+ title: 'Sunrise Run Club',
+ location: 'North Quad',
+ event_time: '2026-03-28T11:00:00Z',
+ description:
+ 'Low-pressure miles, coffee after, and enough accountability to actually leave your dorm.',
+ org_id: '0b2f0d2f-7f6d-49c3-9a55-946bce7d1111',
+ },
+ {
+ title: 'Design Crit and Portfolio Lab',
+ location: 'Media Studio 204',
+ event_time: '2026-03-31T21:15:00Z',
+ description: 'Bring a draft, get sharp feedback, and leave with a cleaner portfolio story.',
+ org_id: '6aa0ef8f-b8fb-4c13-b060-2e7d0f4c2222',
+ },
+ {
+ title: 'Volunteer Garden Reset',
+ location: 'Campus Greenhouse',
+ event_time: null,
+ description:
+ 'Help prep beds for spring planting, then split snacks and starter herbs with the crew.',
+ org_id: '2d9b7b44-8cf4-4db5-9863-1f3c2f183333',
+ },
+]
+
+export const recommendedEvents: AppEvent[] = [
+ {
+ title: 'Moonlight Market',
+ location: 'Student Center Lawn',
+ event_time: '2026-04-02T20:00:00Z',
+ description:
+ 'Late-night food stalls, student vendors, and a live DJ set under the string lights.',
+ org_id: '940d57fd-3c9a-4ae8-850a-d94120068888',
+ },
+ {
+ title: 'AI for Everyone Fireside',
+ location: 'Library Forum',
+ event_time: '2026-04-03T16:00:00Z',
+ description:
+ 'A practical conversation on using AI tools without losing your own judgment or voice.',
+ org_id: '4e2cc020-2d1c-4d85-9d9a-e56aa1099999',
+ },
+ {
+ title: 'Pottery Pop-Up',
+ location: 'Arts Courtyard',
+ event_time: '2026-04-05T13:00:00Z',
+ description:
+ 'Wheel demos, hand-building stations, and take-home clay kits while supplies last.',
+ org_id: '8e746a88-3cab-46b5-b80f-fdb65f2aaaaa',
+ },
+ {
+ title: 'Hack and Snack Study Jam',
+ location: 'CS Lab East',
+ event_time: '2026-04-07T20:00:00Z',
+ description:
+ 'A quiet build session with mentors floating, whiteboards open, and enough snacks to stay late.',
+ org_id: 'c1763eb7-109f-4d7f-bf19-cba0a8dfcccc',
+ },
+ {
+ title: 'Founder Office Hours',
+ location: 'Launchpad Hub',
+ event_time: null,
+ description:
+ 'Book a quick slot to pressure-test an idea, pricing plan, or pitch with alumni founders.',
+ org_id: '27ee52ab-0d70-49d8-9182-2cf94431eeee',
+ },
+]
diff --git a/src/app/sections/EventsSection.module.css b/src/app/sections/EventsSection.module.css
index 347ec46..281368a 100644
--- a/src/app/sections/EventsSection.module.css
+++ b/src/app/sections/EventsSection.module.css
@@ -15,124 +15,11 @@
display: none;
}
-.eventGroup {
- display: grid;
- grid-template-rows: auto auto;
- gap: 0.875rem;
- overflow: hidden;
- padding-bottom: 0.75rem;
- min-height: 390px;
-}
-
-.groupHeader {
- display: grid;
- gap: 0;
- padding-right: 4rem;
-}
-
-.groupTitle {
- margin: 0;
- color: var(--c-text-light);
- font: 600 28px/1 var(--font-display);
- text-transform: lowercase;
-}
-
-.carousel {
- display: grid;
- grid-auto-flow: column;
- grid-auto-columns: minmax(320px, 42%);
- gap: var(--space-24);
- overflow-x: auto;
- overflow-y: visible;
- padding: 4px 4rem 12px 0;
- scroll-snap-type: x proximity;
- scrollbar-width: none;
- overscroll-behavior-x: contain;
- -webkit-overflow-scrolling: touch;
- align-items: stretch;
-}
-
-.carousel::-webkit-scrollbar {
- display: none;
-}
-
-.carousel:focus-visible {
- outline: 2px solid rgba(255, 255, 255, 0.82);
- outline-offset: 6px;
-}
-
-.eventCard {
- display: grid;
- gap: 0.75rem;
- align-content: start;
- min-height: 0;
- padding: 18px;
- border-radius: 28px;
- border: 1px solid rgba(126, 225, 218, 0.28);
- background:
- linear-gradient(180deg, rgba(75, 183, 173, 0.24), rgba(35, 111, 107, 0.2)),
- rgba(255, 255, 255, 0.02);
- box-shadow:
- 0 18px 40px rgba(0, 46, 44, 0.22),
- 0 1px 0 rgba(255, 255, 255, 0.08) inset;
- backdrop-filter: blur(var(--glass-blur-soft));
- scroll-snap-align: start;
-}
-
-.cardHeader {
- display: grid;
- gap: 0.375rem;
-}
-
-.cardTitle {
- margin: 0;
- color: var(--c-text-light);
- font: 600 24px/1.06 var(--font-display);
- text-wrap: balance;
-}
-
-.cardDescription {
- margin: 0;
- color: rgba(249, 250, 250, 0.82);
- font: 400 15px/1.35 var(--font-body);
-}
-
-.metaList {
- display: grid;
- gap: 8px;
- margin: 0;
-}
-
-.metaRow {
- display: grid;
- grid-template-columns: 58px minmax(0, 1fr);
- gap: var(--space-16);
- align-items: start;
-}
-
-.metaRow dt {
- color: rgba(249, 250, 250, 0.54);
- font: 600 12px/1.2 var(--font-body);
- text-transform: uppercase;
- letter-spacing: 0.08em;
-}
-
-.metaRow dd {
- margin: 0;
- color: var(--c-text-light);
- font: 500 14px/1.3 var(--font-body);
-}
-
@media (max-width: 1200px) {
.eventsPanel {
gap: 1.75rem;
padding: 2.5rem 0 5rem 2.75rem;
}
-
- .groupHeader,
- .carousel {
- padding-right: 2.75rem;
- }
}
@media (max-width: 720px) {
@@ -140,30 +27,4 @@
gap: 1.25rem;
padding: 2rem 0 2.5rem 1.5rem;
}
-
- .groupTitle {
- font-size: 24px;
- }
-
- .groupHeader,
- .carousel {
- padding-right: 1.5rem;
- }
-
- .carousel {
- grid-auto-columns: minmax(260px, 78vw);
- }
-
- .cardTitle {
- font-size: 22px;
- }
-
- .eventCard {
- min-height: 0;
- padding: 16px;
- }
-
- .eventGroup {
- min-height: auto;
- }
}
diff --git a/src/app/sections/EventsSection.tsx b/src/app/sections/EventsSection.tsx
index b94ec23..328df5a 100644
--- a/src/app/sections/EventsSection.tsx
+++ b/src/app/sections/EventsSection.tsx
@@ -1,237 +1,23 @@
-import type { WheelEvent } from 'react'
-import { useRef } from 'react'
+import { EventRail } from '@/app/components/EventRail'
+import { myEvents, recommendedEvents } from '@/app/data/events'
import { AnimatedPanel } from '@/shared/components/AnimatedPanel'
import { useAuth } from '@/shared/context/AuthContext'
-import { useHorizontalWheelScroll } from '@/shared/hooks/useHorizontalWheelScroll'
import styles from './EventsSection.module.css'
-const myEvents = [
- {
- title: 'Startup Sprint Demo Night',
- location: 'Innovation Lab',
- event_time: '2026-03-27T18:30:00Z',
- description:
- 'Pitch practice, rapid demos, and founder feedback for teams building before finals week.',
- org_id: '550e8400-e29b-41d4-a716-446655440000',
- },
- {
- title: 'Sunrise Run Club',
- location: 'North Quad',
- event_time: '2026-03-28T07:00:00Z',
- description:
- 'Low-pressure miles, coffee after, and enough accountability to actually leave your dorm.',
- org_id: '0b2f0d2f-7f6d-49c3-9a55-946bce7d1111',
- },
- {
- title: 'Design Crit and Portfolio Lab',
- location: 'Media Studio 204',
- event_time: '2026-03-31T17:15:00Z',
- description: 'Bring a draft, get sharp feedback, and leave with a cleaner portfolio story.',
- org_id: '6aa0ef8f-b8fb-4c13-b060-2e7d0f4c2222',
- },
- {
- title: 'Volunteer Garden Reset',
- location: 'Campus Greenhouse',
- event_time: '2026-04-01T15:30:00Z',
- description:
- 'Help prep beds for spring planting, then split snacks and starter herbs with the crew.',
- org_id: '2d9b7b44-8cf4-4db5-9863-1f3c2f183333',
- },
- {
- title: 'Women in Tech Mixer',
- location: 'Engineering Atrium',
- event_time: '2026-04-02T18:00:00Z',
- description:
- 'Short intros, recruiter chats, and actual conversation prompts instead of awkward hovering.',
- org_id: '7c4070f3-8fca-4f2e-9dc6-738553204444',
- },
- {
- title: 'Open Mic Basement Sessions',
- location: 'Commons Basement',
- event_time: '2026-04-03T20:30:00Z',
- description:
- 'Poetry, acoustic sets, and low-stakes stage time for anyone testing something new.',
- org_id: 'edca8c61-ff14-4fbe-8ae6-b8f11f0d5555',
- },
- {
- title: 'Finance Interview Drill Room',
- location: 'Career Center 118',
- event_time: '2026-04-04T16:45:00Z',
- description:
- 'Timed technical rounds and peer feedback for anyone trying to sharpen before recruiting season.',
- org_id: 'fb9708d9-b6e9-49ae-a181-02617cd76666',
- },
- {
- title: 'Film Club Rooftop Screening',
- location: 'West Hall Rooftop',
- event_time: '2026-04-05T19:45:00Z',
- description:
- 'Bring a blanket, vote on the final cut, and stay for the debate after the credits roll.',
- org_id: '6df10a82-a297-46a5-8e4d-8bf806e27777',
- },
-]
-
-const recommendedEvents = [
- {
- title: 'Moonlight Market',
- location: 'Student Center Lawn',
- event_time: '2026-04-02T20:00:00Z',
- description:
- 'Late-night food stalls, student vendors, and a live DJ set under the string lights.',
- org_id: '940d57fd-3c9a-4ae8-850a-d94120068888',
- },
- {
- title: 'AI for Everyone Fireside',
- location: 'Library Forum',
- event_time: '2026-04-03T16:00:00Z',
- description:
- 'A practical conversation on using AI tools without losing your own judgment or voice.',
- org_id: '4e2cc020-2d1c-4d85-9d9a-e56aa1099999',
- },
- {
- title: 'Pottery Pop-Up',
- location: 'Arts Courtyard',
- event_time: '2026-04-05T13:00:00Z',
- description:
- 'Wheel demos, hand-building stations, and take-home clay kits while supplies last.',
- org_id: '8e746a88-3cab-46b5-b80f-fdb65f2aaaaa',
- },
- {
- title: 'Night Market Thrift Swap',
- location: 'Union Arcade',
- event_time: '2026-04-06T18:00:00Z',
- description:
- 'Trade pieces you are done with, browse student racks, and leave with a better jacket.',
- org_id: '0f4f70bd-e2a8-48c7-a7bf-9a05f44bbbbb',
- },
- {
- title: 'Hack and Snack Study Jam',
- location: 'CS Lab East',
- event_time: '2026-04-07T20:00:00Z',
- description:
- 'A quiet build session with mentors floating, whiteboards open, and enough snacks to stay late.',
- org_id: 'c1763eb7-109f-4d7f-bf19-cba0a8dfcccc',
- },
- {
- title: 'Street Photography Walk',
- location: 'Downtown Station',
- event_time: '2026-04-08T17:30:00Z',
- description:
- 'Golden-hour shooting prompts, editing tips, and a group critique after the walk back.',
- org_id: 'd3176f00-8224-4302-b7ea-2c2a9476dddd',
- },
- {
- title: 'Founder Office Hours',
- location: 'Launchpad Hub',
- event_time: '2026-04-09T14:15:00Z',
- description:
- 'Book a quick slot to pressure-test an idea, pricing plan, or pitch with alumni founders.',
- org_id: '27ee52ab-0d70-49d8-9182-2cf94431eeee',
- },
- {
- title: 'Campus Food Crawl',
- location: 'Main Quad Fountain',
- event_time: '2026-04-10T17:45:00Z',
- description:
- 'Small groups hit the best late-day food spots on and around campus with zero planning required.',
- org_id: '8b3c1409-8804-4ff4-a0d1-4d330e4fffff',
- },
-] as const
-
-type EventCardProps = {
- title: string
- location: string
- event_time: string
- description: string
-}
-
-const eventDateFormatter = new Intl.DateTimeFormat('en-US', {
- weekday: 'short',
- month: 'short',
- day: 'numeric',
-})
-
-function formatEventDate(value: string) {
- return eventDateFormatter.format(new Date(value))
-}
-
-function EventCard({ title, location, event_time, description }: EventCardProps) {
- return (
-
-
-
{title}
-
{description}
-
-
-
-
-
Date
- {formatEventDate(event_time)}
-
-
-
Place
- {location}
-
-
-
- )
-}
-
export function EventsSection() {
const { isAuthed } = useAuth()
- const myEventsRef = useRef(null)
- const recommendedEventsRef = useRef(null)
-
- useHorizontalWheelScroll(myEventsRef, { speed: 1, endCutoffPx: 0 })
- useHorizontalWheelScroll(recommendedEventsRef, { speed: 1, endCutoffPx: 0 })
-
- const stopWheelPropagation = (event: WheelEvent) => {
- event.stopPropagation()
- }
return (
{isAuthed ? (
-
-
-
- my events
-
-
-
-
- {myEvents.map((event) => (
-
- ))}
-
-
+
) : null}
-
-
-
- recommended
-
-
-
-
- {recommendedEvents.map((event) => (
-
- ))}
-
-
+
)
}
From d603cad9d1ac58c0a87cc7d7721ae2c28c42a114 Mon Sep 17 00:00:00 2001
From: Shamik Karkhanis
Date: Thu, 26 Mar 2026 19:49:28 -0400
Subject: [PATCH 3/9] basic scrolling w/arrows
---
src/app/components/EventCard.tsx | 51 +++++++++--
src/app/components/EventRail.module.css | 77 +++++++++++++++--
src/app/components/EventRail.tsx | 84 +++++++++++++++----
src/app/data/events.ts | 72 ++++++++++++++++
src/app/sections/EventsSection.module.css | 2 +-
.../useHorizontalWheelScroll.test.tsx | 1 +
src/shared/hooks/useRevealProgress.ts | 30 ++++++-
7 files changed, 281 insertions(+), 36 deletions(-)
diff --git a/src/app/components/EventCard.tsx b/src/app/components/EventCard.tsx
index ef89a68..bd85d18 100644
--- a/src/app/components/EventCard.tsx
+++ b/src/app/components/EventCard.tsx
@@ -1,4 +1,7 @@
+import { motion } from 'framer-motion'
import type { AppEvent } from '@/app/data/events'
+import { StaggerWords } from '@/shared/components/StaggerWords'
+import { useRevealProgress } from '@/shared/hooks/useRevealProgress'
import styles from './EventCard.module.css'
type EventCardProps = {
@@ -19,23 +22,59 @@ function formatEventTime(eventTime: string | null) {
}
export function EventCard({ event }: EventCardProps) {
+ const [cardRef, { centerDelta, progress }] = useRevealProgress(0.45)
+ const delayedProgress = Math.min(1, Math.max(0, progress))
+ const side: 1 | -1 = centerDelta >= 0 ? 1 : -1
+ const outX = side * 48
+ const textInView = delayedProgress > 0.06
+ const style = {
+ opacity: delayedProgress,
+ x: outX * (1 - delayedProgress),
+ y: 10 * (1 - delayedProgress),
+ scale: 0.97 + (1 - 0.97) * delayedProgress,
+ filter: `blur(${(3 * (1 - delayedProgress)).toFixed(2)}px)`,
+ }
+
return (
-
+
-
{event.title}
-
{event.description}
+
+
+
+
+
+
Time
- {formatEventTime(event.event_time)}
+
+
+
Place
- {event.location}
+
+
+
-
+
)
}
diff --git a/src/app/components/EventRail.module.css b/src/app/components/EventRail.module.css
index 8e1dc1e..3882691 100644
--- a/src/app/components/EventRail.module.css
+++ b/src/app/components/EventRail.module.css
@@ -21,33 +21,85 @@
text-transform: lowercase;
}
+.railShell {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--space-16);
+ padding-right: 64px;
+}
+
+.controlButton {
+ width: 48px;
+ height: 48px;
+ border: 1px solid rgba(249, 250, 250, 0.28);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--c-text-light);
+ font: 700 22px/1 var(--font-display);
+ backdrop-filter: blur(var(--glass-blur-soft));
+ transition:
+ background 180ms var(--ease-premium),
+ border-color 180ms var(--ease-premium),
+ transform 180ms var(--ease-premium),
+ opacity 180ms var(--ease-premium);
+}
+
+.controlButton:not(:disabled) {
+ cursor: pointer;
+}
+
+.controlButton:not(:disabled):hover {
+ background: rgba(255, 255, 255, 0.16);
+ border-color: rgba(249, 250, 250, 0.42);
+ transform: translateY(-1px);
+}
+
+.controlButton:disabled {
+ opacity: 0.38;
+}
+
.carousel {
+ --edge-fade: 72px;
display: grid;
grid-auto-flow: column;
- grid-auto-columns: minmax(320px, 42%);
+ grid-auto-columns: minmax(320px, 440px);
gap: var(--space-24);
overflow-x: auto;
overflow-y: visible;
- padding: 4px 64px 12px 0;
+ width: 100%;
+ padding: 4px 0 12px;
scroll-snap-type: x proximity;
+ scroll-behavior: smooth;
scrollbar-width: none;
- overscroll-behavior-x: contain;
- -webkit-overflow-scrolling: touch;
align-items: stretch;
+ mask-image: linear-gradient(
+ 90deg,
+ transparent 0,
+ rgba(0, 0, 0, 0.9) var(--edge-fade),
+ #000 calc(100% - var(--edge-fade)),
+ transparent 100%
+ );
+ -webkit-mask-image: linear-gradient(
+ 90deg,
+ transparent 0,
+ rgba(0, 0, 0, 0.9) var(--edge-fade),
+ #000 calc(100% - var(--edge-fade)),
+ transparent 100%
+ );
}
.carousel::-webkit-scrollbar {
display: none;
}
-.carousel:focus-visible {
- outline: 2px solid rgba(255, 255, 255, 0.82);
- outline-offset: 6px;
+.carousel:global(.is-dragging) {
+ cursor: grabbing;
}
@media (max-width: 1200px) {
.groupHeader,
- .carousel {
+ .railShell {
padding-right: 44px;
}
}
@@ -61,12 +113,19 @@
font-size: 24px;
}
+ .controlButton {
+ width: 42px;
+ height: 42px;
+ font-size: 20px;
+ }
+
.groupHeader,
- .carousel {
+ .railShell {
padding-right: 24px;
}
.carousel {
+ --edge-fade: 36px;
grid-auto-columns: minmax(260px, 78vw);
}
}
diff --git a/src/app/components/EventRail.tsx b/src/app/components/EventRail.tsx
index d661cc4..bafaaac 100644
--- a/src/app/components/EventRail.tsx
+++ b/src/app/components/EventRail.tsx
@@ -1,7 +1,5 @@
-import type { WheelEvent } from 'react'
-import { useRef } from 'react'
+import { useEffect, useState, useRef } from 'react'
import type { AppEvent } from '@/app/data/events'
-import { useHorizontalWheelScroll } from '@/shared/hooks/useHorizontalWheelScroll'
import { EventCard } from './EventCard'
import styles from './EventRail.module.css'
@@ -17,11 +15,44 @@ function getEventKey(event: AppEvent) {
export function EventRail({ title, events, carouselLabel }: EventRailProps) {
const railRef = useRef(null)
- useHorizontalWheelScroll(railRef, { speed: 1, endCutoffPx: 0 })
const headingId = `${title.replace(/\s+/g, '-')}-heading`
+ const [canScrollBack, setCanScrollBack] = useState(false)
+ const [canScrollForward, setCanScrollForward] = useState(false)
- const stopWheelPropagation = (event: WheelEvent) => {
- event.stopPropagation()
+ useEffect(() => {
+ const rail = railRef.current
+ if (!rail) return
+
+ const updateScrollState = () => {
+ const maxScrollLeft = Math.max(0, rail.scrollWidth - rail.clientWidth)
+ setCanScrollBack(rail.scrollLeft > 4)
+ setCanScrollForward(rail.scrollLeft < maxScrollLeft - 4)
+ }
+
+ updateScrollState()
+ rail.addEventListener('scroll', updateScrollState, { passive: true })
+ window.addEventListener('resize', updateScrollState)
+
+ return () => {
+ rail.removeEventListener('scroll', updateScrollState)
+ window.removeEventListener('resize', updateScrollState)
+ }
+ }, [events.length])
+
+ const scrollRail = (direction: 'back' | 'forward') => {
+ const rail = railRef.current
+ if (!rail) return
+
+ const firstCard = rail.firstElementChild as HTMLElement | null
+ const cardWidth = firstCard?.getBoundingClientRect().width ?? rail.clientWidth * 0.82
+ const computedRailStyle = window.getComputedStyle(rail)
+ const gap = Number.parseFloat(computedRailStyle.columnGap || computedRailStyle.gap || '0') || 0
+ const delta = cardWidth + gap
+
+ rail.scrollBy({
+ left: direction === 'forward' ? delta : -delta,
+ behavior: 'smooth',
+ })
}
return (
@@ -32,16 +63,37 @@ export function EventRail({ title, events, carouselLabel }: EventRailProps) {
-
- {events.map((event) => (
-
- ))}
+
+
scrollRail('back')}
+ aria-label={`Scroll ${title} backward`}
+ disabled={!canScrollBack}
+ >
+ ←
+
+
+
+ {events.map((event) => (
+
+ ))}
+
+
+
scrollRail('forward')}
+ aria-label={`Scroll ${title} forward`}
+ disabled={!canScrollForward}
+ >
+ →
+
)
diff --git a/src/app/data/events.ts b/src/app/data/events.ts
index 43d2f70..71a10e3 100644
--- a/src/app/data/events.ts
+++ b/src/app/data/events.ts
@@ -38,6 +38,38 @@ export const myEvents: AppEvent[] = [
'Help prep beds for spring planting, then split snacks and starter herbs with the crew.',
org_id: '2d9b7b44-8cf4-4db5-9863-1f3c2f183333',
},
+ {
+ title: 'Women in Tech Mixer',
+ location: 'Engineering Atrium',
+ event_time: '2026-04-02T18:00:00Z',
+ description:
+ 'Short intros, recruiter chats, and actual conversation prompts instead of awkward hovering.',
+ org_id: '7c4070f3-8fca-4f2e-9dc6-738553204444',
+ },
+ {
+ title: 'Open Mic Basement Sessions',
+ location: 'Commons Basement',
+ event_time: '2026-04-03T20:30:00Z',
+ description:
+ 'Poetry, acoustic sets, and low-stakes stage time for anyone testing something new.',
+ org_id: 'edca8c61-ff14-4fbe-8ae6-b8f11f0d5555',
+ },
+ {
+ title: 'Finance Interview Drill Room',
+ location: 'Career Center 118',
+ event_time: '2026-04-04T16:45:00Z',
+ description:
+ 'Timed technical rounds and peer feedback for anyone trying to sharpen before recruiting season.',
+ org_id: 'fb9708d9-b6e9-49ae-a181-02617cd76666',
+ },
+ {
+ title: 'Film Club Rooftop Screening',
+ location: 'West Hall Rooftop',
+ event_time: '2026-04-05T19:45:00Z',
+ description:
+ 'Bring a blanket, vote on the final cut, and stay for the debate after the credits roll.',
+ org_id: '6df10a82-a297-46a5-8e4d-8bf806e27777',
+ },
]
export const recommendedEvents: AppEvent[] = [
@@ -81,4 +113,44 @@ export const recommendedEvents: AppEvent[] = [
'Book a quick slot to pressure-test an idea, pricing plan, or pitch with alumni founders.',
org_id: '27ee52ab-0d70-49d8-9182-2cf94431eeee',
},
+ {
+ title: 'Night Market Thrift Swap',
+ location: 'Union Arcade',
+ event_time: '2026-04-06T18:00:00Z',
+ description:
+ 'Trade pieces you are done with, browse student racks, and leave with a better jacket.',
+ org_id: '0f4f70bd-e2a8-48c7-a7bf-9a05f44bbbbb',
+ },
+ {
+ title: 'Street Photography Walk',
+ location: 'Downtown Station',
+ event_time: '2026-04-08T17:30:00Z',
+ description:
+ 'Golden-hour shooting prompts, editing tips, and a group critique after the walk back.',
+ org_id: 'd3176f00-8224-4302-b7ea-2c2a9476dddd',
+ },
+ {
+ title: 'Campus Food Crawl',
+ location: 'Main Quad Fountain',
+ event_time: '2026-04-10T17:45:00Z',
+ description:
+ 'Small groups hit the best late-day food spots on and around campus with zero planning required.',
+ org_id: '8b3c1409-8804-4ff4-a0d1-4d330e4fffff',
+ },
+ {
+ title: 'Chess and Chai Night',
+ location: 'Humanities Lounge',
+ event_time: '2026-04-11T21:00:00Z',
+ description:
+ 'Casual blitz games, beginner tables, and enough warm chai to keep the room talking.',
+ org_id: '8b6f25fd-5d8c-4f3f-a101-9071da8a1abc',
+ },
+ {
+ title: 'Late Lab Astronomy Watch',
+ location: 'Troy Roof Observatory',
+ event_time: null,
+ description:
+ 'A telescope night for anyone who wants a cleaner sky break after a long week of work.',
+ org_id: '4a21a68d-3f50-40e9-bf4a-2da6c5f344de',
+ },
]
diff --git a/src/app/sections/EventsSection.module.css b/src/app/sections/EventsSection.module.css
index 281368a..588b69f 100644
--- a/src/app/sections/EventsSection.module.css
+++ b/src/app/sections/EventsSection.module.css
@@ -3,7 +3,7 @@
grid-template-rows: repeat(2, minmax(0, auto));
align-content: start;
gap: 2rem;
- padding: 2.5rem 0 6rem 4rem;
+ padding: 5rem 0 6rem 5rem;
height: 100%;
box-sizing: border-box;
overflow-y: auto;
diff --git a/src/shared/hooks/__tests__/useHorizontalWheelScroll.test.tsx b/src/shared/hooks/__tests__/useHorizontalWheelScroll.test.tsx
index 081ca68..675cd30 100644
--- a/src/shared/hooks/__tests__/useHorizontalWheelScroll.test.tsx
+++ b/src/shared/hooks/__tests__/useHorizontalWheelScroll.test.tsx
@@ -2,6 +2,7 @@ import { render } from '@testing-library/react'
import { useHorizontalWheelScroll } from '../useHorizontalWheelScroll'
import '@testing-library/jest-dom'
import { useRef } from 'react'
+
describe('useHorizontalWheelScroll', () => {
function TestComponent() {
const ref = useRef
(null)
diff --git a/src/shared/hooks/useRevealProgress.ts b/src/shared/hooks/useRevealProgress.ts
index 1d54233..f1ff4ce 100644
--- a/src/shared/hooks/useRevealProgress.ts
+++ b/src/shared/hooks/useRevealProgress.ts
@@ -29,13 +29,35 @@ export function useRevealProgress(triggerAm
const nextProgress = Math.min(1, visibleWidth / triggerDistance)
setProgress((prev) => (Math.abs(prev - nextProgress) < 0.001 ? prev : nextProgress))
}
+
update()
- const scroller = ref.current?.closest('#scroller') as HTMLElement | null
- const scrollTarget: HTMLElement | Window = scroller ?? window
- scrollTarget.addEventListener('scroll', update, { passive: true })
+
+ const node = ref.current
+ const scrollTargets = new Set()
+ const revealScroller = node?.closest('[data-reveal-scroller]') as HTMLElement | null
+ const pageScroller = node?.closest('#scroller') as HTMLElement | null
+
+ if (revealScroller) {
+ scrollTargets.add(revealScroller)
+ }
+
+ if (pageScroller) {
+ scrollTargets.add(pageScroller)
+ }
+
+ if (scrollTargets.size === 0) {
+ scrollTargets.add(window)
+ }
+
+ scrollTargets.forEach((target) => {
+ target.addEventListener('scroll', update, { passive: true })
+ })
+
window.addEventListener('resize', update)
return () => {
- scrollTarget.removeEventListener('scroll', update)
+ scrollTargets.forEach((target) => {
+ target.removeEventListener('scroll', update)
+ })
window.removeEventListener('resize', update)
}
}, [triggerAmount])
From 266078c512cd93a1dd6f0721cbf521d6812305f5 Mon Sep 17 00:00:00 2001
From: Shamik Karkhanis
Date: Thu, 26 Mar 2026 19:53:15 -0400
Subject: [PATCH 4/9] dynamic opacity feather based on position
---
src/app/components/EventRail.module.css | 22 ++++++++++-----
src/app/components/EventRail.tsx | 2 ++
src/shared/models/event.ts | 19 +++++++++++++
src/shared/services/eventService.ts | 36 +++++++++++++++++++++++++
4 files changed, 73 insertions(+), 6 deletions(-)
create mode 100644 src/shared/models/event.ts
create mode 100644 src/shared/services/eventService.ts
diff --git a/src/app/components/EventRail.module.css b/src/app/components/EventRail.module.css
index 3882691..c8b05fd 100644
--- a/src/app/components/EventRail.module.css
+++ b/src/app/components/EventRail.module.css
@@ -60,7 +60,8 @@
}
.carousel {
- --edge-fade: 72px;
+ --left-edge-fade: 72px;
+ --right-edge-fade: 72px;
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(320px, 440px);
@@ -76,19 +77,27 @@
mask-image: linear-gradient(
90deg,
transparent 0,
- rgba(0, 0, 0, 0.9) var(--edge-fade),
- #000 calc(100% - var(--edge-fade)),
+ rgba(0, 0, 0, 0.9) var(--left-edge-fade),
+ #000 calc(100% - var(--right-edge-fade)),
transparent 100%
);
-webkit-mask-image: linear-gradient(
90deg,
transparent 0,
- rgba(0, 0, 0, 0.9) var(--edge-fade),
- #000 calc(100% - var(--edge-fade)),
+ rgba(0, 0, 0, 0.9) var(--left-edge-fade),
+ #000 calc(100% - var(--right-edge-fade)),
transparent 100%
);
}
+.carousel[data-can-scroll-back='false'] {
+ --left-edge-fade: 0px;
+}
+
+.carousel[data-can-scroll-forward='false'] {
+ --right-edge-fade: 0px;
+}
+
.carousel::-webkit-scrollbar {
display: none;
}
@@ -125,7 +134,8 @@
}
.carousel {
- --edge-fade: 36px;
+ --left-edge-fade: 36px;
+ --right-edge-fade: 36px;
grid-auto-columns: minmax(260px, 78vw);
}
}
diff --git a/src/app/components/EventRail.tsx b/src/app/components/EventRail.tsx
index bafaaac..d52d321 100644
--- a/src/app/components/EventRail.tsx
+++ b/src/app/components/EventRail.tsx
@@ -79,6 +79,8 @@ export function EventRail({ title, events, carouselLabel }: EventRailProps) {
className={styles.carousel}
data-reveal-scroller
aria-label={carouselLabel}
+ data-can-scroll-back={canScrollBack}
+ data-can-scroll-forward={canScrollForward}
>
{events.map((event) => (
diff --git a/src/shared/models/event.ts b/src/shared/models/event.ts
new file mode 100644
index 0000000..197a206
--- /dev/null
+++ b/src/shared/models/event.ts
@@ -0,0 +1,19 @@
+export type Event = {
+ eid: string
+ location: string
+ event_time: string
+ description: string
+ date_created: string
+ date_modified: string
+}
+
+export type CreateEventPayload = {
+ org_id: string
+ location: string
+ event_time: string
+ description: string
+}
+
+export type UpdateEventPayload = Partial>
+
+export type ListEventsResponse = Event[]
diff --git a/src/shared/services/eventService.ts b/src/shared/services/eventService.ts
new file mode 100644
index 0000000..8849ebc
--- /dev/null
+++ b/src/shared/services/eventService.ts
@@ -0,0 +1,36 @@
+import { apiClient } from './apiClient'
+import type {
+ CreateEventPayload,
+ Event,
+ ListEventsResponse,
+ UpdateEventPayload,
+} from '../models/event'
+
+export function listEvents(limit = 20, offset = 0) {
+ const params = new URLSearchParams({
+ limit: String(limit),
+ offset: String(offset),
+ })
+
+ return apiClient.get(`/events?${params.toString()}`, { cache: 'no-store' })
+}
+
+export function getEvent(eid: string) {
+ return apiClient.get(`/events/${eid}`, { cache: 'no-store' })
+}
+
+export function createEvent(payload: CreateEventPayload) {
+ return apiClient.post('/events', payload)
+}
+
+export function updateEvent(eid: string, payload: UpdateEventPayload) {
+ return apiClient.put(`/events/${eid}`, payload)
+}
+
+export function deleteEvent(eid: string) {
+ return apiClient.delete(`/events/${eid}`)
+}
+
+export function listEventRegistrations(eid: string) {
+ return apiClient.get(`/events/${eid}/registrations`, { cache: 'no-store' })
+}
From ff493fee143fbb433f6fa6f5a9c96a9be04343a4 Mon Sep 17 00:00:00 2001
From: Shamik Karkhanis
Date: Thu, 26 Mar 2026 21:58:57 -0400
Subject: [PATCH 5/9] sorta wired up events
---
package.json | 1 +
src/app/App.tsx | 18 +-
.../components/CreateEventModal.module.css | 178 +++++++++++++++
src/app/components/CreateEventModal.tsx | 211 ++++++++++++++++++
src/app/components/EventCard.tsx | 25 ++-
src/app/components/EventRail.module.css | 22 +-
src/app/components/EventRail.tsx | 71 +++---
src/app/sections/EventsSection.module.css | 71 +++++-
src/app/sections/EventsSection.tsx | 70 +++++-
src/shared/components/TopNav.module.css | 11 +
src/shared/components/TopNav.tsx | 42 ++--
src/shared/hooks/useEvents.ts | 50 +++++
src/shared/hooks/useUserEvents.ts | 65 ++++++
src/shared/models/event.ts | 9 +-
src/shared/services/eventService.ts | 4 +
15 files changed, 769 insertions(+), 79 deletions(-)
create mode 100644 src/app/components/CreateEventModal.module.css
create mode 100644 src/app/components/CreateEventModal.tsx
create mode 100644 src/shared/hooks/useEvents.ts
create mode 100644 src/shared/hooks/useUserEvents.ts
diff --git a/package.json b/package.json
index 2af52e0..bda6422 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
+ "seed:events": "bash scripts/seed-events.sh",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier . --write",
diff --git a/src/app/App.tsx b/src/app/App.tsx
index acb7130..169880a 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -1,7 +1,8 @@
-import { useRef, useEffect } from 'react'
+import { useRef, useEffect, useState } from 'react'
import { motion } from 'framer-motion'
import { Helmet } from 'react-helmet-async'
import { AppTopNav } from '@/app/components/AppTopNav'
+import { CreateEventModal } from '@/app/components/CreateEventModal'
import { ExitOverlay } from '@/shared/components/ExitOverlay'
import { useHorizontalWheelScroll } from '@/shared/hooks/useHorizontalWheelScroll'
import { usePageTransition } from '@/shared/hooks/usePageTransition'
@@ -18,6 +19,8 @@ import styles from './App.module.css'
*/
export default function AppMain() {
const scrollerRef = useRef(null)
+ const [isCreateEventOpen, setIsCreateEventOpen] = useState(false)
+ const [eventsRefreshKey, setEventsRefreshKey] = useState(0)
useHorizontalWheelScroll(scrollerRef, { endCutoffPx: 0 })
usePageTransition()
@@ -51,11 +54,22 @@ export default function AppMain() {
>
-
+ setIsCreateEventOpen(true)}
+ />
+ setIsCreateEventOpen(false)}
+ onCreated={() => {
+ setEventsRefreshKey((current) => current + 1)
+ setIsCreateEventOpen(false)
+ }}
+ />
>
)
diff --git a/src/app/components/CreateEventModal.module.css b/src/app/components/CreateEventModal.module.css
new file mode 100644
index 0000000..d6bd866
--- /dev/null
+++ b/src/app/components/CreateEventModal.module.css
@@ -0,0 +1,178 @@
+.overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 40;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background:
+ radial-gradient(circle at top right, rgba(241, 96, 47, 0.16), transparent 28%),
+ rgba(0, 30, 30, 0.56);
+ backdrop-filter: blur(14px);
+}
+
+.dialog {
+ width: min(100%, 640px);
+ max-height: min(90vh, 840px);
+ overflow-y: auto;
+ padding: clamp(24px, 3vw, 32px);
+ border: 1px solid rgba(128, 213, 206, 0.42);
+ border-radius: 32px;
+ background:
+ linear-gradient(180deg, rgba(23, 115, 109, 0.92), rgba(8, 77, 73, 0.95)),
+ rgba(255, 255, 255, 0.04);
+ box-shadow: 0 26px 64px rgba(0, 28, 27, 0.42);
+}
+
+.header {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.eyebrow {
+ margin: 0 0 8px;
+ color: rgba(249, 250, 250, 0.74);
+ font: 600 12px/1.2 var(--font-body);
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+.title {
+ margin: 0;
+ color: var(--c-text-light);
+ font: 700 clamp(34px, 5vw, 48px)/0.94 var(--font-display);
+ text-transform: lowercase;
+}
+
+.closeButton {
+ width: 44px;
+ height: 44px;
+ border: 1px solid rgba(249, 250, 250, 0.18);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--c-text-light);
+ font: 400 28px/1 var(--font-display);
+ cursor: pointer;
+}
+
+.copy {
+ margin: 16px 0 0;
+ color: rgba(249, 250, 250, 0.76);
+ font: 400 16px/1.55 var(--font-body);
+}
+
+.form {
+ display: grid;
+ gap: 18px;
+ margin-top: 24px;
+}
+
+.field {
+ display: grid;
+ gap: 8px;
+}
+
+.field span {
+ color: var(--c-text-light);
+ font: 600 15px/1.3 var(--font-body);
+}
+
+.field input,
+.field textarea {
+ width: 100%;
+ border: 1px solid rgba(205, 247, 242, 0.22);
+ border-radius: 20px;
+ padding: 14px 16px;
+ background: rgba(1, 44, 42, 0.28);
+ color: var(--c-text-light);
+ font: 400 16px/1.4 var(--font-body);
+ outline: none;
+ transition:
+ border-color 180ms var(--ease-premium),
+ box-shadow 180ms var(--ease-premium),
+ background 180ms var(--ease-premium);
+}
+
+.field textarea {
+ resize: vertical;
+ min-height: 132px;
+}
+
+.field input::placeholder,
+.field textarea::placeholder {
+ color: rgba(249, 250, 250, 0.44);
+}
+
+.field input:focus,
+.field textarea:focus {
+ border-color: rgba(241, 96, 47, 0.72);
+ box-shadow: 0 0 0 4px rgba(241, 96, 47, 0.12);
+ background: rgba(1, 44, 42, 0.42);
+}
+
+.metaCard {
+ display: grid;
+ gap: 6px;
+ padding: 14px 16px;
+ border: 1px solid rgba(205, 247, 242, 0.16);
+ border-radius: 20px;
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.metaLabel {
+ color: rgba(249, 250, 250, 0.72);
+ font: 600 12px/1.2 var(--font-body);
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.metaValue {
+ color: var(--c-text-light);
+ font:
+ 500 14px/1.45 ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ Monaco,
+ Consolas,
+ monospace;
+ word-break: break-all;
+}
+
+.status,
+.error {
+ margin: 0;
+ color: rgba(249, 250, 250, 0.82);
+ font: 500 14px/1.45 var(--font-body);
+}
+
+.error {
+ color: #ffc3c3;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ margin-top: 4px;
+}
+
+@media (max-width: 720px) {
+ .overlay {
+ padding: 16px;
+ }
+
+ .dialog {
+ border-radius: 24px;
+ padding: 22px 18px;
+ }
+
+ .title {
+ font-size: 32px;
+ }
+
+ .actions {
+ flex-direction: column-reverse;
+ }
+}
diff --git a/src/app/components/CreateEventModal.tsx b/src/app/components/CreateEventModal.tsx
new file mode 100644
index 0000000..f08718d
--- /dev/null
+++ b/src/app/components/CreateEventModal.tsx
@@ -0,0 +1,211 @@
+import { useEffect, useState } from 'react'
+import type { ChangeEvent, FormEvent } from 'react'
+import { AnimatePresence, motion } from 'framer-motion'
+import { PillButton } from '@/shared/components/PillButton'
+import { useAuth } from '@/shared/context/AuthContext'
+import { createEvent } from '@/shared/services/eventService'
+import styles from './CreateEventModal.module.css'
+
+type CreateEventModalProps = {
+ isOpen: boolean
+ onClose: () => void
+ onCreated: () => void
+}
+
+type CreateEventFormState = {
+ location: string
+ eventTime: string
+ description: string
+}
+
+const DEFAULT_ORG_ID = '66168f44-624a-47ad-9b07-7a92121bce01'
+
+const initialFormState: CreateEventFormState = {
+ location: '',
+ eventTime: '',
+ description: '',
+}
+
+function toEventTimeISOString(value: string) {
+ if (!value) return undefined
+
+ const date = new Date(value)
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
+}
+
+/**
+ * Dedicated modal for creating an event without leaving the dashboard.
+ * The API requires cookie auth and an org admin-scoped org_id.
+ */
+export function CreateEventModal({ isOpen, onClose, onCreated }: CreateEventModalProps) {
+ const { isAuthed, isLoading: isAuthLoading } = useAuth()
+ const [formState, setFormState] = useState(initialFormState)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (!isOpen) {
+ setFormState(initialFormState)
+ setIsSubmitting(false)
+ setError(null)
+ return
+ }
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape' && !isSubmitting) {
+ onClose()
+ }
+ }
+
+ window.addEventListener('keydown', onKeyDown)
+ return () => {
+ window.removeEventListener('keydown', onKeyDown)
+ }
+ }, [isOpen, isSubmitting, onClose])
+
+ const handleChange =
+ (field: keyof CreateEventFormState) =>
+ (event: ChangeEvent) => {
+ setFormState((current) => ({
+ ...current,
+ [field]: event.target.value,
+ }))
+ }
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault()
+
+ if (!isAuthed) {
+ setError(
+ 'Sign in first. Create event requests require your auth cookie and org admin access.',
+ )
+ return
+ }
+
+ setIsSubmitting(true)
+ setError(null)
+
+ try {
+ await createEvent({
+ org_id: DEFAULT_ORG_ID,
+ location: formState.location.trim() || undefined,
+ event_time: toEventTimeISOString(formState.eventTime),
+ description: formState.description.trim() || undefined,
+ })
+ onCreated()
+ } catch (submitError) {
+ setError(submitError instanceof Error ? submitError.message : 'Could not create event.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+
+ {isOpen ? (
+ {
+ if (!isSubmitting) {
+ onClose()
+ }
+ }}
+ >
+ event.stopPropagation()}
+ >
+
+
+
new event
+
+ create an event
+
+
+
+ x
+
+
+
+
+ Submit with the signed-in session cookie. This form posts to the hardcoded org ID `
+ {DEFAULT_ORG_ID}` and still requires org admin access for that org.
+
+
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/app/components/EventCard.tsx b/src/app/components/EventCard.tsx
index bd85d18..3cd6436 100644
--- a/src/app/components/EventCard.tsx
+++ b/src/app/components/EventCard.tsx
@@ -27,6 +27,7 @@ export function EventCard({ event }: EventCardProps) {
const side: 1 | -1 = centerDelta >= 0 ? 1 : -1
const outX = side * 48
const textInView = delayedProgress > 0.06
+ const shouldShowLocation = event.location.trim().length > 0 && event.location !== event.title
const style = {
opacity: delayedProgress,
x: outX * (1 - delayedProgress),
@@ -63,17 +64,19 @@ export function EventCard({ event }: EventCardProps) {
/>
-
-
Place
-
-
-
-
+ {shouldShowLocation ? (
+
+
Place
+
+
+
+
+ ) : null}
)
diff --git a/src/app/components/EventRail.module.css b/src/app/components/EventRail.module.css
index c8b05fd..8d8346d 100644
--- a/src/app/components/EventRail.module.css
+++ b/src/app/components/EventRail.module.css
@@ -29,6 +29,17 @@
padding-right: 64px;
}
+.emptyState {
+ display: grid;
+ place-items: center;
+ min-height: 276px;
+ padding-right: 64px;
+ color: rgba(249, 250, 250, 0.8);
+ font: 500 20px/1.4 var(--font-body);
+ letter-spacing: 0.01em;
+ text-align: center;
+}
+
.controlButton {
width: 48px;
height: 48px;
@@ -108,7 +119,8 @@
@media (max-width: 1200px) {
.groupHeader,
- .railShell {
+ .railShell,
+ .emptyState {
padding-right: 44px;
}
}
@@ -129,7 +141,8 @@
}
.groupHeader,
- .railShell {
+ .railShell,
+ .emptyState {
padding-right: 24px;
}
@@ -138,4 +151,9 @@
--right-edge-fade: 36px;
grid-auto-columns: minmax(260px, 78vw);
}
+
+ .emptyState {
+ min-height: 220px;
+ font-size: 18px;
+ }
}
diff --git a/src/app/components/EventRail.tsx b/src/app/components/EventRail.tsx
index d52d321..68df500 100644
--- a/src/app/components/EventRail.tsx
+++ b/src/app/components/EventRail.tsx
@@ -18,6 +18,7 @@ export function EventRail({ title, events, carouselLabel }: EventRailProps) {
const headingId = `${title.replace(/\s+/g, '-')}-heading`
const [canScrollBack, setCanScrollBack] = useState(false)
const [canScrollForward, setCanScrollForward] = useState(false)
+ const hasEvents = events.length > 0
useEffect(() => {
const rail = railRef.current
@@ -63,40 +64,46 @@ export function EventRail({ title, events, carouselLabel }: EventRailProps) {
-
-
scrollRail('back')}
- aria-label={`Scroll ${title} backward`}
- disabled={!canScrollBack}
- >
- ←
-
+ {hasEvents ? (
+
+
scrollRail('back')}
+ aria-label={`Scroll ${title} backward`}
+ disabled={!canScrollBack}
+ >
+ ←
+
-
- {events.map((event) => (
-
- ))}
-
+
+ {events.map((event) => (
+
+ ))}
+
-
scrollRail('forward')}
- aria-label={`Scroll ${title} forward`}
- disabled={!canScrollForward}
- >
- →
-
-
+
scrollRail('forward')}
+ aria-label={`Scroll ${title} forward`}
+ disabled={!canScrollForward}
+ >
+ →
+
+
+ ) : (
+
+ There are no events available at this time.
+
+ )}
)
}
diff --git a/src/app/sections/EventsSection.module.css b/src/app/sections/EventsSection.module.css
index 588b69f..3c2ca90 100644
--- a/src/app/sections/EventsSection.module.css
+++ b/src/app/sections/EventsSection.module.css
@@ -1,6 +1,6 @@
.eventsPanel {
display: grid;
- grid-template-rows: repeat(2, minmax(0, auto));
+ grid-template-rows: auto auto minmax(0, auto);
align-content: start;
gap: 2rem;
padding: 5rem 0 6rem 5rem;
@@ -15,11 +15,60 @@
display: none;
}
+.panelHeader {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 1rem;
+ padding-right: 4rem;
+}
+
+.eyebrow {
+ margin: 0 0 0.45rem;
+ color: rgba(249, 250, 250, 0.68);
+ font: 600 0.8rem/1.2 var(--font-body);
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+.title {
+ margin: 0;
+ color: var(--c-text-light);
+ font: 600 40px/0.96 var(--font-display);
+ text-transform: lowercase;
+}
+
+.createButton {
+ min-width: 56px;
+ width: 56px;
+ padding: 0;
+ flex: 0 0 auto;
+ font: 500 30px/1 var(--font-display);
+}
+
+.status,
+.error {
+ margin: 0;
+ padding-right: 4rem;
+ color: rgba(249, 250, 250, 0.82);
+ font: 500 1rem/1.4 var(--font-body);
+}
+
+.error {
+ color: #ffb4b4;
+}
+
@media (max-width: 1200px) {
.eventsPanel {
gap: 1.75rem;
padding: 2.5rem 0 5rem 2.75rem;
}
+
+ .panelHeader,
+ .status,
+ .error {
+ padding-right: 2.75rem;
+ }
}
@media (max-width: 720px) {
@@ -27,4 +76,24 @@
gap: 1.25rem;
padding: 2rem 0 2.5rem 1.5rem;
}
+
+ .panelHeader,
+ .status,
+ .error {
+ padding-right: 1.5rem;
+ }
+
+ .panelHeader {
+ align-items: center;
+ }
+
+ .title {
+ font-size: 2rem;
+ }
+
+ .createButton {
+ min-width: 48px;
+ width: 48px;
+ font-size: 28px;
+ }
}
diff --git a/src/app/sections/EventsSection.tsx b/src/app/sections/EventsSection.tsx
index 328df5a..bf21c1b 100644
--- a/src/app/sections/EventsSection.tsx
+++ b/src/app/sections/EventsSection.tsx
@@ -1,23 +1,71 @@
import { EventRail } from '@/app/components/EventRail'
-import { myEvents, recommendedEvents } from '@/app/data/events'
+import type { AppEvent } from '@/app/data/events'
import { AnimatedPanel } from '@/shared/components/AnimatedPanel'
+import { PillButton } from '@/shared/components/PillButton'
+import type { Event } from '@/shared/models/event'
import { useAuth } from '@/shared/context/AuthContext'
+import { useEvents } from '@/shared/hooks/useEvents'
+import { useUserEvents } from '@/shared/hooks/useUserEvents'
import styles from './EventsSection.module.css'
-export function EventsSection() {
- const { isAuthed } = useAuth()
+type EventsSectionProps = {
+ refreshKey?: number
+ onCreateEvent: () => void
+}
+
+function toAppEvent(event: Event): AppEvent {
+ return {
+ title: event.location || 'Untitled event',
+ location: event.location || 'Location to be announced',
+ event_time: event.event_time,
+ description: event.description || 'Event details coming soon.',
+ org_id: event.eid,
+ }
+}
+
+export function EventsSection({ refreshKey = 0, onCreateEvent }: EventsSectionProps) {
+ const { isAuthed, user } = useAuth()
+ const { events, isLoading, error } = useEvents(20, 0, refreshKey)
+ const {
+ events: myEvents,
+ isLoading: isLoadingMyEvents,
+ error: myEventsError,
+ } = useUserEvents(user?.uid, isAuthed, refreshKey)
+ const recommendedEvents = events.map(toAppEvent)
+ const myEventCards = myEvents.map(toAppEvent)
return (
- {isAuthed ? (
-
+
+ {isLoading ? Loading events...
: null}
+ {error ? {error}
: null}
+ {isAuthed && isLoadingMyEvents ? (
+ Loading your events...
+ ) : null}
+ {isAuthed && myEventsError ? {myEventsError}
: null}
+ {!isLoading && !error ? (
+ <>
+ {isAuthed ? (
+
+ ) : null}
+
+ >
) : null}
-
-
)
}
diff --git a/src/shared/components/TopNav.module.css b/src/shared/components/TopNav.module.css
index 0d1883d..93771db 100644
--- a/src/shared/components/TopNav.module.css
+++ b/src/shared/components/TopNav.module.css
@@ -96,6 +96,13 @@
justify-self: end;
}
+.navActions {
+ justify-self: end;
+ display: inline-flex;
+ align-items: center;
+ gap: clamp(10px, 1.2vw, 14px);
+}
+
@media (max-width: 1200px) {
.topNav {
position: sticky;
@@ -119,6 +126,10 @@
gap: var(--space-16);
}
+ .navActions {
+ gap: 10px;
+ }
+
.navPill a {
font-size: 16px;
}
diff --git a/src/shared/components/TopNav.tsx b/src/shared/components/TopNav.tsx
index ad71f7c..32cb631 100644
--- a/src/shared/components/TopNav.tsx
+++ b/src/shared/components/TopNav.tsx
@@ -1,5 +1,6 @@
import { useRef } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
+import type { ReactNode } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { StaggerWords } from './StaggerWords'
import { assets, navItems } from '../data/content'
@@ -15,6 +16,7 @@ interface TopNavProps {
ctaLabel?: string
ctaHref?: string
onCtaClickOverride?: (event: ReactMouseEvent) => void
+ actions?: ReactNode
}
export function TopNav({
@@ -23,6 +25,7 @@ export function TopNav({
ctaLabel = "let's go",
ctaHref = '/app',
onCtaClickOverride,
+ actions,
}: TopNavProps) {
const navigateWithExit = useExitNavigation()
const navRef = useRef(null)
@@ -135,21 +138,32 @@ export function TopNav({
))}
- {showCta && (
-
-
-
+ {actions}
+ {showCta && (
+
-
-
-
-
+
+
+
+
+
+
+ )}
+
)}
)
diff --git a/src/shared/hooks/useEvents.ts b/src/shared/hooks/useEvents.ts
new file mode 100644
index 0000000..bb633c7
--- /dev/null
+++ b/src/shared/hooks/useEvents.ts
@@ -0,0 +1,50 @@
+import { useEffect, useState } from 'react'
+import type { Event } from '@/shared/models/event'
+import { listEvents } from '@/shared/services/eventService'
+
+type UseEventsResult = {
+ events: Event[]
+ isLoading: boolean
+ error: string | null
+}
+
+/**
+ * Fetches the current events list for the landing page and exposes basic loading state.
+ */
+export function useEvents(limit = 20, offset = 0, refreshKey = 0): UseEventsResult {
+ const [events, setEvents] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ let isCancelled = false
+
+ async function loadEvents() {
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ const data = await listEvents(limit, offset)
+ if (!isCancelled) {
+ setEvents(data)
+ }
+ } catch (err) {
+ if (!isCancelled) {
+ setError(err instanceof Error ? err.message : 'Could not load events.')
+ }
+ } finally {
+ if (!isCancelled) {
+ setIsLoading(false)
+ }
+ }
+ }
+
+ loadEvents()
+
+ return () => {
+ isCancelled = true
+ }
+ }, [limit, offset, refreshKey])
+
+ return { events, isLoading, error }
+}
diff --git a/src/shared/hooks/useUserEvents.ts b/src/shared/hooks/useUserEvents.ts
new file mode 100644
index 0000000..1a25ea7
--- /dev/null
+++ b/src/shared/hooks/useUserEvents.ts
@@ -0,0 +1,65 @@
+import { useEffect, useState } from 'react'
+import type { Event } from '@/shared/models/event'
+import { listUserEvents } from '@/shared/services/eventService'
+
+type UseUserEventsResult = {
+ events: Event[]
+ isLoading: boolean
+ error: string | null
+}
+
+/**
+ * Loads the current authenticated user's registered events when a UID is available.
+ */
+export function useUserEvents(
+ uid: string | undefined,
+ enabled = true,
+ refreshKey = 0,
+): UseUserEventsResult {
+ const [events, setEvents] = useState([])
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ let isCancelled = false
+
+ if (!enabled || !uid) {
+ setEvents([])
+ setIsLoading(false)
+ setError(null)
+ return () => {
+ isCancelled = true
+ }
+ }
+
+ const resolvedUid = uid
+
+ async function loadEvents() {
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ const data = await listUserEvents(resolvedUid)
+ if (!isCancelled) {
+ setEvents(data)
+ }
+ } catch (err) {
+ if (!isCancelled) {
+ setError(err instanceof Error ? err.message : 'Could not load your events.')
+ }
+ } finally {
+ if (!isCancelled) {
+ setIsLoading(false)
+ }
+ }
+ }
+
+ loadEvents()
+
+ return () => {
+ isCancelled = true
+ }
+ }, [enabled, uid, refreshKey])
+
+ return { events, isLoading, error }
+}
diff --git a/src/shared/models/event.ts b/src/shared/models/event.ts
index 197a206..01650d9 100644
--- a/src/shared/models/event.ts
+++ b/src/shared/models/event.ts
@@ -3,16 +3,13 @@ export type Event = {
location: string
event_time: string
description: string
- date_created: string
- date_modified: string
+ date_created?: string
+ date_modified?: string
}
export type CreateEventPayload = {
org_id: string
- location: string
- event_time: string
- description: string
-}
+} & Partial>
export type UpdateEventPayload = Partial>
diff --git a/src/shared/services/eventService.ts b/src/shared/services/eventService.ts
index 8849ebc..4d9a4fa 100644
--- a/src/shared/services/eventService.ts
+++ b/src/shared/services/eventService.ts
@@ -15,6 +15,10 @@ export function listEvents(limit = 20, offset = 0) {
return apiClient.get(`/events?${params.toString()}`, { cache: 'no-store' })
}
+export function listUserEvents(uid: string) {
+ return apiClient.get(`/users/${uid}/events`, { cache: 'no-store' })
+}
+
export function getEvent(eid: string) {
return apiClient.get(`/events/${eid}`, { cache: 'no-store' })
}
From b96e6cba2f130b15ecce71daffef989bac0f9264 Mon Sep 17 00:00:00 2001
From: Shamik Karkhanis
Date: Thu, 26 Mar 2026 22:08:44 -0400
Subject: [PATCH 6/9] event registration wired
---
scripts/seed-events.sh | 161 +++++++++++++
src/app/App.tsx | 1 +
src/app/components/EventCard.module.css | 20 ++
src/app/components/EventCard.tsx | 16 +-
.../components/EventDetailsModal.module.css | 135 +++++++++++
src/app/components/EventDetailsModal.tsx | 220 ++++++++++++++++++
src/app/components/EventRail.tsx | 7 +-
src/app/data/events.ts | 20 ++
src/app/sections/EventsSection.tsx | 43 +++-
src/shared/models/event.ts | 8 +
src/shared/services/eventService.ts | 13 ++
11 files changed, 633 insertions(+), 11 deletions(-)
create mode 100644 scripts/seed-events.sh
create mode 100644 src/app/components/EventDetailsModal.module.css
create mode 100644 src/app/components/EventDetailsModal.tsx
diff --git a/scripts/seed-events.sh b/scripts/seed-events.sh
new file mode 100644
index 0000000..2f24dd7
--- /dev/null
+++ b/scripts/seed-events.sh
@@ -0,0 +1,161 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+API_BASE_URL="${API_BASE_URL:-http://localhost:8080}"
+API_VERSION="${API_VERSION:-/api/v1}"
+EVENT_COUNT="${EVENT_COUNT:-25}"
+ORG_ID="${ORG_ID:-66168f44-624a-47ad-9b07-7a92121bce01}"
+START_DATE="${START_DATE:-2026-03-30T17:30:00Z}"
+REGISTER_CREATED_EVENTS="${REGISTER_CREATED_EVENTS:-false}"
+REGISTER_EVERY_N="${REGISTER_EVERY_N:-1}"
+REGISTRATION_METHOD="${REGISTRATION_METHOD:-POST}"
+REGISTRATION_PATH_TEMPLATE="${REGISTRATION_PATH_TEMPLATE:-/events/{{eid}}/registrations}"
+REGISTRATION_BODY_TEMPLATE="${REGISTRATION_BODY_TEMPLATE:-}"
+SEED_USER_ID="${SEED_USER_ID:-}"
+COOKIE_HEADER="${COOKIE_HEADER:-}"
+
+export ORG_ID
+export START_DATE
+
+if ! command -v curl >/dev/null 2>&1; then
+ echo "curl is required." >&2
+ exit 1
+fi
+
+if ! command -v node >/dev/null 2>&1; then
+ echo "node is required." >&2
+ exit 1
+fi
+
+curl_with_optional_cookie() {
+ if [[ -n "${COOKIE_HEADER}" ]]; then
+ curl --silent --show-error --fail -H "Cookie: ${COOKIE_HEADER}" "$@"
+ else
+ curl --silent --show-error --fail "$@"
+ fi
+}
+
+create_event_payload() {
+ node -e '
+ const orgId = process.env.ORG_ID;
+ const payload = {
+ org_id: orgId,
+ };
+
+ process.stdout.write(JSON.stringify(payload));
+ '
+}
+
+create_event_update_payload() {
+ local index="$1"
+
+ node -e '
+ const index = Number(process.argv[1]);
+ const rawStartDate = process.env.START_DATE;
+ const startDate = new Date(rawStartDate);
+
+ if (Number.isNaN(startDate.getTime())) {
+ console.error(`Invalid START_DATE: ${rawStartDate}. Expected ISO format like 2026-03-18T17:30:00Z.`);
+ process.exit(1);
+ }
+
+ startDate.setUTCDate(startDate.getUTCDate() + index);
+ startDate.setUTCHours(18 + (index % 3), 15 * (index % 4), 0, 0);
+
+ const payload = {
+ location: `Seed Venue ${index + 1}`,
+ event_time: startDate.toISOString(),
+ description: `Seeded event ${index + 1} for local load testing and UI validation.`,
+ };
+
+ process.stdout.write(JSON.stringify(payload));
+ ' "$index"
+}
+
+extract_eid() {
+ node -e '
+ let raw = "";
+ process.stdin.on("data", (chunk) => {
+ raw += chunk;
+ });
+ process.stdin.on("end", () => {
+ try {
+ const parsed = JSON.parse(raw);
+ if (!parsed?.eid) process.exit(1);
+ process.stdout.write(parsed.eid);
+ } catch {
+ process.exit(1);
+ }
+ });
+ '
+}
+
+render_registration_body() {
+ local eid="$1"
+
+ if [[ -z "${REGISTRATION_BODY_TEMPLATE}" ]]; then
+ return 0
+ fi
+
+ local rendered="${REGISTRATION_BODY_TEMPLATE//\{\{eid\}\}/${eid}}"
+ rendered="${rendered//\{\{uid\}\}/${SEED_USER_ID}}"
+ printf '%s' "${rendered}"
+}
+
+register_for_event() {
+ local eid="$1"
+ local path="${REGISTRATION_PATH_TEMPLATE//\{\{eid\}\}/${eid}}"
+ path="${path//\{\{uid\}\}/${SEED_USER_ID}}"
+
+ local body=""
+ body="$(render_registration_body "${eid}")"
+
+ if [[ -n "${body}" ]]; then
+ curl_with_optional_cookie \
+ -X "${REGISTRATION_METHOD}" \
+ -H 'Content-Type: application/json' \
+ --data "${body}" \
+ "${API_BASE_URL}${API_VERSION}${path}" >/dev/null
+ else
+ curl_with_optional_cookie \
+ -X "${REGISTRATION_METHOD}" \
+ "${API_BASE_URL}${API_VERSION}${path}" >/dev/null
+ fi
+}
+
+update_event() {
+ local eid="$1"
+ local index="$2"
+ local body=""
+ body="$(create_event_update_payload "${index}")"
+
+ curl_with_optional_cookie \
+ -X PUT \
+ -H 'Content-Type: application/json' \
+ --data "${body}" \
+ "${API_BASE_URL}${API_VERSION}/events/${eid}" >/dev/null
+}
+
+echo "Seeding ${EVENT_COUNT} events into ${API_BASE_URL}"
+
+for ((i = 0; i < EVENT_COUNT; i += 1)); do
+ payload="$(create_event_payload)"
+ response="$(
+ curl_with_optional_cookie \
+ -X POST \
+ -H 'Content-Type: application/json' \
+ --data "${payload}" \
+ "${API_BASE_URL}${API_VERSION}/events"
+ )"
+
+ eid="$(printf '%s' "${response}" | extract_eid)"
+ echo "Created event ${i} -> ${eid}"
+ update_event "${eid}" "${i}"
+ echo "Updated event ${i} -> ${eid}"
+
+ if [[ "${REGISTER_CREATED_EVENTS}" == "true" ]] && (( i % REGISTER_EVERY_N == 0 )); then
+ register_for_event "${eid}"
+ echo "Registered for event ${i} -> ${eid}"
+ fi
+done
diff --git a/src/app/App.tsx b/src/app/App.tsx
index 169880a..b29a265 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -57,6 +57,7 @@ export default function AppMain() {
setIsCreateEventOpen(true)}
+ onEventsChanged={() => setEventsRefreshKey((current) => current + 1)}
/>
diff --git a/src/app/components/EventCard.module.css b/src/app/components/EventCard.module.css
index a17502a..710b789 100644
--- a/src/app/components/EventCard.module.css
+++ b/src/app/components/EventCard.module.css
@@ -2,8 +2,10 @@
display: grid;
gap: var(--space-16);
align-content: start;
+ width: 100%;
min-height: 0;
padding: 20px;
+ appearance: none;
border-radius: 28px;
border: 1px solid rgba(126, 225, 218, 0.28);
background:
@@ -11,6 +13,24 @@
rgba(255, 255, 255, 0.02);
backdrop-filter: blur(var(--glass-blur-soft));
scroll-snap-align: start;
+ text-align: left;
+ cursor: pointer;
+ transition:
+ transform 180ms var(--ease-premium),
+ border-color 180ms var(--ease-premium),
+ box-shadow 180ms var(--ease-premium);
+}
+
+.eventCard:hover,
+.eventCard:focus-visible {
+ border-color: rgba(241, 96, 47, 0.52);
+ box-shadow: 0 20px 38px rgba(0, 41, 39, 0.24);
+ transform: translateY(-2px);
+}
+
+.eventCard:focus-visible {
+ outline: 2px solid rgba(255, 216, 200, 0.88);
+ outline-offset: 3px;
}
.cardHeader {
diff --git a/src/app/components/EventCard.tsx b/src/app/components/EventCard.tsx
index 3cd6436..116aa45 100644
--- a/src/app/components/EventCard.tsx
+++ b/src/app/components/EventCard.tsx
@@ -6,6 +6,7 @@ import styles from './EventCard.module.css'
type EventCardProps = {
event: AppEvent
+ onSelect?: (event: AppEvent) => void
}
const eventDateFormatter = new Intl.DateTimeFormat('en-US', {
@@ -21,8 +22,8 @@ function formatEventTime(eventTime: string | null) {
return eventDateFormatter.format(new Date(eventTime))
}
-export function EventCard({ event }: EventCardProps) {
- const [cardRef, { centerDelta, progress }] = useRevealProgress(0.45)
+export function EventCard({ event, onSelect }: EventCardProps) {
+ const [cardRef, { centerDelta, progress }] = useRevealProgress(0.45)
const delayedProgress = Math.min(1, Math.max(0, progress))
const side: 1 | -1 = centerDelta >= 0 ? 1 : -1
const outX = side * 48
@@ -37,7 +38,14 @@ export function EventCard({ event }: EventCardProps) {
}
return (
-
+ onSelect?.(event)}
+ aria-label={`Open details for ${event.title}`}
+ >
@@ -78,6 +86,6 @@ export function EventCard({ event }: EventCardProps) {
) : null}
-
+
)
}
diff --git a/src/app/components/EventDetailsModal.module.css b/src/app/components/EventDetailsModal.module.css
new file mode 100644
index 0000000..8904061
--- /dev/null
+++ b/src/app/components/EventDetailsModal.module.css
@@ -0,0 +1,135 @@
+.overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 40;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background:
+ radial-gradient(circle at top right, rgba(241, 96, 47, 0.2), transparent 28%),
+ rgba(0, 25, 24, 0.62);
+ backdrop-filter: blur(16px);
+}
+
+.dialog {
+ width: min(100%, 720px);
+ max-height: min(90vh, 840px);
+ overflow-y: auto;
+ padding: clamp(24px, 3vw, 32px);
+ border: 1px solid rgba(128, 213, 206, 0.42);
+ border-radius: 32px;
+ background:
+ linear-gradient(180deg, rgba(16, 103, 98, 0.96), rgba(4, 60, 57, 0.98)),
+ rgba(255, 255, 255, 0.04);
+ box-shadow: 0 28px 68px rgba(0, 25, 24, 0.44);
+}
+
+.header {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.eyebrow {
+ margin: 0 0 8px;
+ color: rgba(249, 250, 250, 0.74);
+ font: 600 12px/1.2 var(--font-body);
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+.title {
+ margin: 0;
+ color: var(--c-text-light);
+ font: 700 clamp(34px, 5vw, 52px)/0.94 var(--font-display);
+ text-transform: lowercase;
+ text-wrap: balance;
+}
+
+.closeButton {
+ width: 44px;
+ height: 44px;
+ border: 1px solid rgba(249, 250, 250, 0.18);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--c-text-light);
+ font: 400 28px/1 var(--font-display);
+ cursor: pointer;
+}
+
+.copy {
+ margin: 18px 0 0;
+ color: rgba(249, 250, 250, 0.84);
+ font: 400 17px/1.6 var(--font-body);
+}
+
+.metaGrid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px;
+ margin-top: 24px;
+}
+
+.metaCard {
+ display: grid;
+ gap: 6px;
+ padding: 16px 18px;
+ border: 1px solid rgba(205, 247, 242, 0.16);
+ border-radius: 24px;
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.metaLabel {
+ color: rgba(249, 250, 250, 0.7);
+ font: 600 12px/1.2 var(--font-body);
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.metaValue {
+ margin: 0;
+ color: var(--c-text-light);
+ font: 500 16px/1.45 var(--font-body);
+}
+
+.status,
+.error {
+ margin: 18px 0 0;
+ color: rgba(249, 250, 250, 0.82);
+ font: 500 14px/1.45 var(--font-body);
+}
+
+.error {
+ color: #ffc3c3;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ margin-top: 24px;
+}
+
+@media (max-width: 720px) {
+ .overlay {
+ padding: 16px;
+ }
+
+ .dialog {
+ border-radius: 24px;
+ padding: 22px 18px;
+ }
+
+ .title {
+ font-size: 32px;
+ }
+
+ .metaGrid {
+ grid-template-columns: 1fr;
+ }
+
+ .actions {
+ flex-direction: column-reverse;
+ }
+}
diff --git a/src/app/components/EventDetailsModal.tsx b/src/app/components/EventDetailsModal.tsx
new file mode 100644
index 0000000..79aae6d
--- /dev/null
+++ b/src/app/components/EventDetailsModal.tsx
@@ -0,0 +1,220 @@
+import { useEffect, useState } from 'react'
+import { AnimatePresence, motion } from 'framer-motion'
+import type { AppEvent } from '@/app/data/events'
+import { PillButton } from '@/shared/components/PillButton'
+import { useAuth } from '@/shared/context/AuthContext'
+import { registerForEvent, unregisterFromEvent } from '@/shared/services/eventService'
+import styles from './EventDetailsModal.module.css'
+
+type EventDetailsModalProps = {
+ event: AppEvent | null
+ isOpen: boolean
+ onClose: () => void
+ onRegistered: () => void
+ onUnregistered: () => void
+}
+
+const eventDateFormatter = new Intl.DateTimeFormat('en-US', {
+ weekday: 'long',
+ month: 'long',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+})
+
+function formatEventTime(eventTime: string | null) {
+ if (!eventTime) return 'Time to be announced'
+ return eventDateFormatter.format(new Date(eventTime))
+}
+
+/**
+ * Shows a richer event summary and lets the signed-in user register with the
+ * same cookie-authenticated API client used elsewhere in the app.
+ */
+export function EventDetailsModal({
+ event,
+ isOpen,
+ onClose,
+ onRegistered,
+ onUnregistered,
+}: EventDetailsModalProps) {
+ const { isAuthed, isLoading: isAuthLoading, login, user } = useAuth()
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (!isOpen) {
+ setIsSubmitting(false)
+ setError(null)
+ return
+ }
+
+ const onKeyDown = (keyboardEvent: KeyboardEvent) => {
+ if (keyboardEvent.key === 'Escape' && !isSubmitting) {
+ onClose()
+ }
+ }
+
+ window.addEventListener('keydown', onKeyDown)
+ return () => {
+ window.removeEventListener('keydown', onKeyDown)
+ }
+ }, [isOpen, isSubmitting, onClose])
+
+ const handleRegister = async () => {
+ if (!event) return
+
+ if (!isAuthed) {
+ login()
+ return
+ }
+
+ if (!user?.uid) {
+ setError('No authenticated user ID is available for registration.')
+ return
+ }
+
+ setIsSubmitting(true)
+ setError(null)
+
+ try {
+ await registerForEvent(event.eid, user.uid)
+ onRegistered()
+ } catch (submitError) {
+ setError(submitError instanceof Error ? submitError.message : 'Could not register for event.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const handleUnregister = async () => {
+ if (!event || !user?.uid) return
+
+ setIsSubmitting(true)
+ setError(null)
+
+ try {
+ await unregisterFromEvent(event.eid)
+ onUnregistered()
+ } catch (submitError) {
+ setError(
+ submitError instanceof Error ? submitError.message : 'Could not unregister from event.',
+ )
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const isRegistered = event?.isRegistered === true
+ const registerLabel = !isAuthed ? 'sign in to register' : isRegistered ? 'registered' : 'register'
+
+ return (
+
+ {isOpen && event ? (
+ {
+ if (!isSubmitting) {
+ onClose()
+ }
+ }}
+ >
+ mouseEvent.stopPropagation()}
+ >
+
+
+
event details
+
+ {event.title}
+
+
+
+ x
+
+
+
+ {event.description}
+
+
+
+ Time
+ {formatEventTime(event.event_time)}
+
+
+ Location
+ {event.location || 'Location to be announced'}
+
+
+ Status
+
+ {isRegistered ? 'You are registered' : 'Open for registration'}
+
+
+
+ Attendee
+
+ {user
+ ? `${user.first_name} ${user.last_name}`.trim() || user.school_email || user.uid
+ : 'Sign in required'}
+
+
+
+
+ {isAuthLoading ? Checking session...
: null}
+ {!isAuthed && !isAuthLoading ? (
+
+ Registering uses the current authenticated session. Sign in to continue.
+
+ ) : null}
+ {error ? {error}
: null}
+
+
+ {isRegistered ? (
+
+ {isSubmitting ? 'removing...' : 'unregister'}
+
+ ) : (
+
+ {isSubmitting ? 'registering...' : registerLabel}
+
+ )}
+
+ close
+
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/app/components/EventRail.tsx b/src/app/components/EventRail.tsx
index 68df500..3e8e519 100644
--- a/src/app/components/EventRail.tsx
+++ b/src/app/components/EventRail.tsx
@@ -7,13 +7,14 @@ type EventRailProps = {
title: string
events: AppEvent[]
carouselLabel: string
+ onEventSelect?: (event: AppEvent) => void
}
function getEventKey(event: AppEvent) {
- return `${event.org_id}-${event.title}-${event.event_time ?? 'tba'}`
+ return `${event.eid}-${event.title}-${event.event_time ?? 'tba'}`
}
-export function EventRail({ title, events, carouselLabel }: EventRailProps) {
+export function EventRail({ title, events, carouselLabel, onEventSelect }: EventRailProps) {
const railRef = useRef(null)
const headingId = `${title.replace(/\s+/g, '-')}-heading`
const [canScrollBack, setCanScrollBack] = useState(false)
@@ -85,7 +86,7 @@ export function EventRail({ title, events, carouselLabel }: EventRailProps) {
data-can-scroll-forward={canScrollForward}
>
{events.map((event) => (
-
+
))}
diff --git a/src/app/data/events.ts b/src/app/data/events.ts
index 71a10e3..79be4a9 100644
--- a/src/app/data/events.ts
+++ b/src/app/data/events.ts
@@ -1,13 +1,16 @@
export type AppEvent = {
+ eid: string
title: string
location: string
event_time: string | null
description: string
org_id: string
+ isRegistered?: boolean
}
export const myEvents: AppEvent[] = [
{
+ eid: '550e8400-e29b-41d4-a716-446655440000',
title: 'Startup Sprint Demo Night',
location: 'Innovation Lab',
event_time: '2026-03-27T18:30:00Z',
@@ -16,6 +19,7 @@ export const myEvents: AppEvent[] = [
org_id: '550e8400-e29b-41d4-a716-446655440000',
},
{
+ eid: '0b2f0d2f-7f6d-49c3-9a55-946bce7d1111',
title: 'Sunrise Run Club',
location: 'North Quad',
event_time: '2026-03-28T11:00:00Z',
@@ -24,6 +28,7 @@ export const myEvents: AppEvent[] = [
org_id: '0b2f0d2f-7f6d-49c3-9a55-946bce7d1111',
},
{
+ eid: '6aa0ef8f-b8fb-4c13-b060-2e7d0f4c2222',
title: 'Design Crit and Portfolio Lab',
location: 'Media Studio 204',
event_time: '2026-03-31T21:15:00Z',
@@ -31,6 +36,7 @@ export const myEvents: AppEvent[] = [
org_id: '6aa0ef8f-b8fb-4c13-b060-2e7d0f4c2222',
},
{
+ eid: '2d9b7b44-8cf4-4db5-9863-1f3c2f183333',
title: 'Volunteer Garden Reset',
location: 'Campus Greenhouse',
event_time: null,
@@ -39,6 +45,7 @@ export const myEvents: AppEvent[] = [
org_id: '2d9b7b44-8cf4-4db5-9863-1f3c2f183333',
},
{
+ eid: '7c4070f3-8fca-4f2e-9dc6-738553204444',
title: 'Women in Tech Mixer',
location: 'Engineering Atrium',
event_time: '2026-04-02T18:00:00Z',
@@ -47,6 +54,7 @@ export const myEvents: AppEvent[] = [
org_id: '7c4070f3-8fca-4f2e-9dc6-738553204444',
},
{
+ eid: 'edca8c61-ff14-4fbe-8ae6-b8f11f0d5555',
title: 'Open Mic Basement Sessions',
location: 'Commons Basement',
event_time: '2026-04-03T20:30:00Z',
@@ -55,6 +63,7 @@ export const myEvents: AppEvent[] = [
org_id: 'edca8c61-ff14-4fbe-8ae6-b8f11f0d5555',
},
{
+ eid: 'fb9708d9-b6e9-49ae-a181-02617cd76666',
title: 'Finance Interview Drill Room',
location: 'Career Center 118',
event_time: '2026-04-04T16:45:00Z',
@@ -63,6 +72,7 @@ export const myEvents: AppEvent[] = [
org_id: 'fb9708d9-b6e9-49ae-a181-02617cd76666',
},
{
+ eid: '6df10a82-a297-46a5-8e4d-8bf806e27777',
title: 'Film Club Rooftop Screening',
location: 'West Hall Rooftop',
event_time: '2026-04-05T19:45:00Z',
@@ -74,6 +84,7 @@ export const myEvents: AppEvent[] = [
export const recommendedEvents: AppEvent[] = [
{
+ eid: '940d57fd-3c9a-4ae8-850a-d94120068888',
title: 'Moonlight Market',
location: 'Student Center Lawn',
event_time: '2026-04-02T20:00:00Z',
@@ -82,6 +93,7 @@ export const recommendedEvents: AppEvent[] = [
org_id: '940d57fd-3c9a-4ae8-850a-d94120068888',
},
{
+ eid: '4e2cc020-2d1c-4d85-9d9a-e56aa1099999',
title: 'AI for Everyone Fireside',
location: 'Library Forum',
event_time: '2026-04-03T16:00:00Z',
@@ -90,6 +102,7 @@ export const recommendedEvents: AppEvent[] = [
org_id: '4e2cc020-2d1c-4d85-9d9a-e56aa1099999',
},
{
+ eid: '8e746a88-3cab-46b5-b80f-fdb65f2aaaaa',
title: 'Pottery Pop-Up',
location: 'Arts Courtyard',
event_time: '2026-04-05T13:00:00Z',
@@ -98,6 +111,7 @@ export const recommendedEvents: AppEvent[] = [
org_id: '8e746a88-3cab-46b5-b80f-fdb65f2aaaaa',
},
{
+ eid: 'c1763eb7-109f-4d7f-bf19-cba0a8dfcccc',
title: 'Hack and Snack Study Jam',
location: 'CS Lab East',
event_time: '2026-04-07T20:00:00Z',
@@ -106,6 +120,7 @@ export const recommendedEvents: AppEvent[] = [
org_id: 'c1763eb7-109f-4d7f-bf19-cba0a8dfcccc',
},
{
+ eid: '27ee52ab-0d70-49d8-9182-2cf94431eeee',
title: 'Founder Office Hours',
location: 'Launchpad Hub',
event_time: null,
@@ -114,6 +129,7 @@ export const recommendedEvents: AppEvent[] = [
org_id: '27ee52ab-0d70-49d8-9182-2cf94431eeee',
},
{
+ eid: '0f4f70bd-e2a8-48c7-a7bf-9a05f44bbbbb',
title: 'Night Market Thrift Swap',
location: 'Union Arcade',
event_time: '2026-04-06T18:00:00Z',
@@ -122,6 +138,7 @@ export const recommendedEvents: AppEvent[] = [
org_id: '0f4f70bd-e2a8-48c7-a7bf-9a05f44bbbbb',
},
{
+ eid: 'd3176f00-8224-4302-b7ea-2c2a9476dddd',
title: 'Street Photography Walk',
location: 'Downtown Station',
event_time: '2026-04-08T17:30:00Z',
@@ -130,6 +147,7 @@ export const recommendedEvents: AppEvent[] = [
org_id: 'd3176f00-8224-4302-b7ea-2c2a9476dddd',
},
{
+ eid: '8b3c1409-8804-4ff4-a0d1-4d330e4fffff',
title: 'Campus Food Crawl',
location: 'Main Quad Fountain',
event_time: '2026-04-10T17:45:00Z',
@@ -138,6 +156,7 @@ export const recommendedEvents: AppEvent[] = [
org_id: '8b3c1409-8804-4ff4-a0d1-4d330e4fffff',
},
{
+ eid: '8b6f25fd-5d8c-4f3f-a101-9071da8a1abc',
title: 'Chess and Chai Night',
location: 'Humanities Lounge',
event_time: '2026-04-11T21:00:00Z',
@@ -146,6 +165,7 @@ export const recommendedEvents: AppEvent[] = [
org_id: '8b6f25fd-5d8c-4f3f-a101-9071da8a1abc',
},
{
+ eid: '4a21a68d-3f50-40e9-bf4a-2da6c5f344de',
title: 'Late Lab Astronomy Watch',
location: 'Troy Roof Observatory',
event_time: null,
diff --git a/src/app/sections/EventsSection.tsx b/src/app/sections/EventsSection.tsx
index bf21c1b..c17b1e7 100644
--- a/src/app/sections/EventsSection.tsx
+++ b/src/app/sections/EventsSection.tsx
@@ -1,3 +1,5 @@
+import { useMemo, useState } from 'react'
+import { EventDetailsModal } from '@/app/components/EventDetailsModal'
import { EventRail } from '@/app/components/EventRail'
import type { AppEvent } from '@/app/data/events'
import { AnimatedPanel } from '@/shared/components/AnimatedPanel'
@@ -11,10 +13,12 @@ import styles from './EventsSection.module.css'
type EventsSectionProps = {
refreshKey?: number
onCreateEvent: () => void
+ onEventsChanged: () => void
}
function toAppEvent(event: Event): AppEvent {
return {
+ eid: event.eid,
title: event.location || 'Untitled event',
location: event.location || 'Location to be announced',
event_time: event.event_time,
@@ -23,7 +27,11 @@ function toAppEvent(event: Event): AppEvent {
}
}
-export function EventsSection({ refreshKey = 0, onCreateEvent }: EventsSectionProps) {
+export function EventsSection({
+ refreshKey = 0,
+ onCreateEvent,
+ onEventsChanged,
+}: EventsSectionProps) {
const { isAuthed, user } = useAuth()
const { events, isLoading, error } = useEvents(20, 0, refreshKey)
const {
@@ -31,8 +39,16 @@ export function EventsSection({ refreshKey = 0, onCreateEvent }: EventsSectionPr
isLoading: isLoadingMyEvents,
error: myEventsError,
} = useUserEvents(user?.uid, isAuthed, refreshKey)
- const recommendedEvents = events.map(toAppEvent)
- const myEventCards = myEvents.map(toAppEvent)
+ const [selectedEvent, setSelectedEvent] = useState(null)
+ const registeredEventIds = useMemo(() => new Set(myEvents.map((event) => event.eid)), [myEvents])
+ const recommendedEvents = events.map((event) => ({
+ ...toAppEvent(event),
+ isRegistered: registeredEventIds.has(event.eid),
+ }))
+ const myEventCards = myEvents.map((event) => ({
+ ...toAppEvent(event),
+ isRegistered: true,
+ }))
return (
@@ -57,15 +73,34 @@ export function EventsSection({ refreshKey = 0, onCreateEvent }: EventsSectionPr
{!isLoading && !error ? (
<>
{isAuthed ? (
-
+
) : null}
>
) : null}
+ setSelectedEvent(null)}
+ onRegistered={() => {
+ onEventsChanged()
+ setSelectedEvent((current) => (current ? { ...current, isRegistered: true } : current))
+ }}
+ onUnregistered={() => {
+ onEventsChanged()
+ setSelectedEvent((current) => (current ? { ...current, isRegistered: false } : current))
+ }}
+ />
)
}
diff --git a/src/shared/models/event.ts b/src/shared/models/event.ts
index 01650d9..1d562ff 100644
--- a/src/shared/models/event.ts
+++ b/src/shared/models/event.ts
@@ -7,6 +7,14 @@ export type Event = {
date_modified?: string
}
+export type EventRegistration = {
+ eid?: string
+ uid?: string
+ is_attending?: boolean
+ date_created?: string
+ date_modified?: string
+}
+
export type CreateEventPayload = {
org_id: string
} & Partial>
diff --git a/src/shared/services/eventService.ts b/src/shared/services/eventService.ts
index 4d9a4fa..e4887ff 100644
--- a/src/shared/services/eventService.ts
+++ b/src/shared/services/eventService.ts
@@ -2,6 +2,7 @@ import { apiClient } from './apiClient'
import type {
CreateEventPayload,
Event,
+ EventRegistration,
ListEventsResponse,
UpdateEventPayload,
} from '../models/event'
@@ -38,3 +39,15 @@ export function deleteEvent(eid: string) {
export function listEventRegistrations(eid: string) {
return apiClient.get(`/events/${eid}/registrations`, { cache: 'no-store' })
}
+
+export function registerForEvent(eid: string, uid: string) {
+ return apiClient.post(`/events/${eid}/register`, {
+ uid,
+ is_attending: true,
+ })
+}
+
+export function unregisterFromEvent(eid: string, uid?: string) {
+ const params = uid ? `?uid=${encodeURIComponent(uid)}` : ''
+ return apiClient.delete(`/events/${eid}/register${params}`)
+}
From d8e698e5453d4b096e3aa198ab8ff7947344e8ab Mon Sep 17 00:00:00 2001
From: Shamik Karkhanis
Date: Thu, 26 Mar 2026 22:19:46 -0400
Subject: [PATCH 7/9] moved create button
---
src/app/sections/EventsSection.module.css | 20 ++++++++++++++------
1 file changed, 14 insertions(+), 6 deletions(-)
diff --git a/src/app/sections/EventsSection.module.css b/src/app/sections/EventsSection.module.css
index 3c2ca90..d50b93f 100644
--- a/src/app/sections/EventsSection.module.css
+++ b/src/app/sections/EventsSection.module.css
@@ -1,4 +1,5 @@
.eventsPanel {
+ position: relative;
display: grid;
grid-template-rows: auto auto minmax(0, auto);
align-content: start;
@@ -16,11 +17,10 @@
}
.panelHeader {
- display: flex;
- align-items: start;
- justify-content: space-between;
- gap: 1rem;
- padding-right: 4rem;
+ position: absolute;
+ top: 2.5rem;
+ right: 1.5rem;
+ z-index: 1;
}
.eyebrow {
@@ -69,6 +69,12 @@
.error {
padding-right: 2.75rem;
}
+
+ .panelHeader {
+ top: 1.75rem;
+ right: 1.1rem;
+ padding-right: 0;
+ }
}
@media (max-width: 720px) {
@@ -84,7 +90,9 @@
}
.panelHeader {
- align-items: center;
+ top: 1.25rem;
+ right: 0.85rem;
+ padding-right: 0;
}
.title {
From e1a64d765d7b06061477488e6e2a994bb3e61063 Mon Sep 17 00:00:00 2001
From: Shamik Karkhanis
Date: Thu, 26 Mar 2026 22:28:51 -0400
Subject: [PATCH 8/9] updated with event title
---
src/app/components/CreateEventModal.tsx | 14 ++++++++++++++
src/app/sections/EventsSection.tsx | 2 +-
src/shared/models/event.ts | 7 +++++--
3 files changed, 20 insertions(+), 3 deletions(-)
diff --git a/src/app/components/CreateEventModal.tsx b/src/app/components/CreateEventModal.tsx
index f08718d..681e0b7 100644
--- a/src/app/components/CreateEventModal.tsx
+++ b/src/app/components/CreateEventModal.tsx
@@ -13,6 +13,7 @@ type CreateEventModalProps = {
}
type CreateEventFormState = {
+ title: string
location: string
eventTime: string
description: string
@@ -21,6 +22,7 @@ type CreateEventFormState = {
const DEFAULT_ORG_ID = '66168f44-624a-47ad-9b07-7a92121bce01'
const initialFormState: CreateEventFormState = {
+ title: '',
location: '',
eventTime: '',
description: '',
@@ -88,6 +90,7 @@ export function CreateEventModal({ isOpen, onClose, onCreated }: CreateEventModa
try {
await createEvent({
org_id: DEFAULT_ORG_ID,
+ title: formState.title.trim() || undefined,
location: formState.location.trim() || undefined,
event_time: toEventTimeISOString(formState.eventTime),
description: formState.description.trim() || undefined,
@@ -151,6 +154,17 @@ export function CreateEventModal({ isOpen, onClose, onCreated }: CreateEventModa
+
+ Title
+
+
+
Location
>
+} & Partial>
-export type UpdateEventPayload = Partial>
+export type UpdateEventPayload = Partial<
+ Pick
+>
export type ListEventsResponse = Event[]
From 632b3efb5af533f7983df93ac3c936427e6677cb Mon Sep 17 00:00:00 2001
From: Shamik Karkhanis
Date: Thu, 26 Mar 2026 23:03:18 -0400
Subject: [PATCH 9/9] added org support
---
src/app/App.tsx | 17 +-
.../components/CreateEventModal.module.css | 6 +
src/app/components/CreateEventModal.tsx | 103 +++++-
src/app/components/CreateOrgModal.module.css | 142 ++++++++
src/app/components/CreateOrgModal.tsx | 155 ++++++++
src/app/components/OrgCard.module.css | 88 +++++
src/app/components/OrgCard.tsx | 103 ++++++
src/app/components/OrgDetailsModal.module.css | 214 +++++++++++
src/app/components/OrgDetailsModal.tsx | 339 ++++++++++++++++++
src/app/components/OrgRail.module.css | 159 ++++++++
src/app/components/OrgRail.tsx | 119 ++++++
src/app/data/organizations.ts | 7 +
src/app/sections/OrgsSection.module.css | 91 ++++-
src/app/sections/OrgsSection.tsx | 107 +++++-
src/shared/hooks/useOrganizations.ts | 50 +++
src/shared/hooks/useUserOrganizations.ts | 81 +++++
src/shared/models/organization.ts | 28 ++
src/shared/services/organizationService.ts | 63 ++++
18 files changed, 1844 insertions(+), 28 deletions(-)
create mode 100644 src/app/components/CreateOrgModal.module.css
create mode 100644 src/app/components/CreateOrgModal.tsx
create mode 100644 src/app/components/OrgCard.module.css
create mode 100644 src/app/components/OrgCard.tsx
create mode 100644 src/app/components/OrgDetailsModal.module.css
create mode 100644 src/app/components/OrgDetailsModal.tsx
create mode 100644 src/app/components/OrgRail.module.css
create mode 100644 src/app/components/OrgRail.tsx
create mode 100644 src/app/data/organizations.ts
create mode 100644 src/shared/hooks/useOrganizations.ts
create mode 100644 src/shared/hooks/useUserOrganizations.ts
create mode 100644 src/shared/models/organization.ts
create mode 100644 src/shared/services/organizationService.ts
diff --git a/src/app/App.tsx b/src/app/App.tsx
index b29a265..6da5c68 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -1,6 +1,7 @@
import { useRef, useEffect, useState } from 'react'
import { motion } from 'framer-motion'
import { Helmet } from 'react-helmet-async'
+import { CreateOrgModal } from '@/app/components/CreateOrgModal'
import { AppTopNav } from '@/app/components/AppTopNav'
import { CreateEventModal } from '@/app/components/CreateEventModal'
import { ExitOverlay } from '@/shared/components/ExitOverlay'
@@ -20,7 +21,9 @@ import styles from './App.module.css'
export default function AppMain() {
const scrollerRef = useRef(null)
const [isCreateEventOpen, setIsCreateEventOpen] = useState(false)
+ const [isCreateOrgOpen, setIsCreateOrgOpen] = useState(false)
const [eventsRefreshKey, setEventsRefreshKey] = useState(0)
+ const [organizationsRefreshKey, setOrganizationsRefreshKey] = useState(0)
useHorizontalWheelScroll(scrollerRef, { endCutoffPx: 0 })
usePageTransition()
@@ -59,7 +62,11 @@ export default function AppMain() {
onCreateEvent={() => setIsCreateEventOpen(true)}
onEventsChanged={() => setEventsRefreshKey((current) => current + 1)}
/>
-
+ setIsCreateOrgOpen(true)}
+ onOrganizationsChanged={() => setOrganizationsRefreshKey((current) => current + 1)}
+ />
@@ -71,6 +78,14 @@ export default function AppMain() {
setIsCreateEventOpen(false)
}}
/>
+ setIsCreateOrgOpen(false)}
+ onCreated={() => {
+ setOrganizationsRefreshKey((current) => current + 1)
+ setIsCreateOrgOpen(false)
+ }}
+ />
>
)
diff --git a/src/app/components/CreateEventModal.module.css b/src/app/components/CreateEventModal.module.css
index d6bd866..0216f74 100644
--- a/src/app/components/CreateEventModal.module.css
+++ b/src/app/components/CreateEventModal.module.css
@@ -80,6 +80,7 @@
}
.field input,
+.field select,
.field textarea {
width: 100%;
border: 1px solid rgba(205, 247, 242, 0.22);
@@ -106,12 +107,17 @@
}
.field input:focus,
+.field select:focus,
.field textarea:focus {
border-color: rgba(241, 96, 47, 0.72);
box-shadow: 0 0 0 4px rgba(241, 96, 47, 0.12);
background: rgba(1, 44, 42, 0.42);
}
+.field select {
+ appearance: none;
+}
+
.metaCard {
display: grid;
gap: 6px;
diff --git a/src/app/components/CreateEventModal.tsx b/src/app/components/CreateEventModal.tsx
index 681e0b7..57d1780 100644
--- a/src/app/components/CreateEventModal.tsx
+++ b/src/app/components/CreateEventModal.tsx
@@ -3,6 +3,8 @@ import type { ChangeEvent, FormEvent } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { PillButton } from '@/shared/components/PillButton'
import { useAuth } from '@/shared/context/AuthContext'
+import { useOrganizations } from '@/shared/hooks/useOrganizations'
+import { useUserOrganizations } from '@/shared/hooks/useUserOrganizations'
import { createEvent } from '@/shared/services/eventService'
import styles from './CreateEventModal.module.css'
@@ -13,15 +15,15 @@ type CreateEventModalProps = {
}
type CreateEventFormState = {
+ orgId: string
title: string
location: string
eventTime: string
description: string
}
-const DEFAULT_ORG_ID = '66168f44-624a-47ad-9b07-7a92121bce01'
-
const initialFormState: CreateEventFormState = {
+ orgId: '',
title: '',
location: '',
eventTime: '',
@@ -37,10 +39,21 @@ function toEventTimeISOString(value: string) {
/**
* Dedicated modal for creating an event without leaving the dashboard.
- * The API requires cookie auth and an org admin-scoped org_id.
+ * The API requires cookie auth and an org-scoped org_id, so the form limits
+ * selection to organizations the current user belongs to.
*/
export function CreateEventModal({ isOpen, onClose, onCreated }: CreateEventModalProps) {
- const { isAuthed, isLoading: isAuthLoading } = useAuth()
+ const { isAuthed, isLoading: isAuthLoading, user } = useAuth()
+ const {
+ organizations,
+ isLoading: isLoadingOrganizations,
+ error: organizationsError,
+ } = useOrganizations(50, 0, isOpen ? 1 : 0)
+ const {
+ organizations: myOrganizations,
+ isLoading: isLoadingMyOrganizations,
+ error: myOrganizationsError,
+ } = useUserOrganizations(organizations, user?.uid, isAuthed, isOpen ? 1 : 0)
const [formState, setFormState] = useState(initialFormState)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState(null)
@@ -65,9 +78,29 @@ export function CreateEventModal({ isOpen, onClose, onCreated }: CreateEventModa
}
}, [isOpen, isSubmitting, onClose])
+ useEffect(() => {
+ if (!isOpen || myOrganizations.length === 0) {
+ return
+ }
+
+ setFormState((current) => {
+ if (
+ current.orgId &&
+ myOrganizations.some((organization) => organization.oid === current.orgId)
+ ) {
+ return current
+ }
+
+ return {
+ ...current,
+ orgId: myOrganizations[0].oid,
+ }
+ })
+ }, [isOpen, myOrganizations])
+
const handleChange =
(field: keyof CreateEventFormState) =>
- (event: ChangeEvent) => {
+ (event: ChangeEvent) => {
setFormState((current) => ({
...current,
[field]: event.target.value,
@@ -84,12 +117,17 @@ export function CreateEventModal({ isOpen, onClose, onCreated }: CreateEventModa
return
}
+ if (!formState.orgId) {
+ setError('Join an organization first, then pick it here to create an event.')
+ return
+ }
+
setIsSubmitting(true)
setError(null)
try {
await createEvent({
- org_id: DEFAULT_ORG_ID,
+ org_id: formState.orgId,
title: formState.title.trim() || undefined,
location: formState.location.trim() || undefined,
event_time: toEventTimeISOString(formState.eventTime),
@@ -149,11 +187,25 @@ export function CreateEventModal({ isOpen, onClose, onCreated }: CreateEventModa
- Submit with the signed-in session cookie. This form posts to the hardcoded org ID `
- {DEFAULT_ORG_ID}` and still requires org admin access for that org.
+ Submit with the signed-in session cookie. You can only create events for organizations
+ you currently belong to.
+
+ Organization
+
+
+ {isAuthed ? 'Select an organization' : 'Sign in to load your organizations'}
+
+ {myOrganizations.map((organization) => (
+
+ {organization.name}
+
+ ))}
+
+
+
Title
-
- Org ID
- {DEFAULT_ORG_ID}
-
-
{isAuthLoading ? Checking session...
: null}
+ {isAuthed && isLoadingOrganizations ? (
+ Loading organizations...
+ ) : null}
+ {isAuthed && isLoadingMyOrganizations ? (
+ Loading your organizations...
+ ) : null}
{!isAuthed && !isAuthLoading ? (
Sign in before creating an event. The request is sent with `credentials: include`.
) : null}
+ {isAuthed &&
+ !isLoadingOrganizations &&
+ !isLoadingMyOrganizations &&
+ myOrganizations.length === 0 ? (
+
+ You are not in any organizations yet. Join one first to create an event.
+
+ ) : null}
+ {organizationsError ? {organizationsError}
: null}
+ {myOrganizationsError ? {myOrganizationsError}
: null}
{error ? {error}
: null}
cancel
-
+
{isSubmitting ? 'creating...' : 'create event'}
diff --git a/src/app/components/CreateOrgModal.module.css b/src/app/components/CreateOrgModal.module.css
new file mode 100644
index 0000000..10561b7
--- /dev/null
+++ b/src/app/components/CreateOrgModal.module.css
@@ -0,0 +1,142 @@
+.overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 40;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background:
+ radial-gradient(circle at top right, rgba(241, 96, 47, 0.16), transparent 28%),
+ rgba(0, 30, 30, 0.56);
+ backdrop-filter: blur(14px);
+}
+
+.dialog {
+ width: min(100%, 640px);
+ max-height: min(90vh, 840px);
+ overflow-y: auto;
+ padding: clamp(24px, 3vw, 32px);
+ border: 1px solid rgba(128, 213, 206, 0.42);
+ border-radius: 32px;
+ background:
+ linear-gradient(180deg, rgba(23, 115, 109, 0.92), rgba(8, 77, 73, 0.95)),
+ rgba(255, 255, 255, 0.04);
+ box-shadow: 0 26px 64px rgba(0, 28, 27, 0.42);
+}
+
+.header {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.eyebrow {
+ margin: 0 0 8px;
+ color: rgba(249, 250, 250, 0.74);
+ font: 600 12px/1.2 var(--font-body);
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+.title {
+ margin: 0;
+ color: var(--c-text-light);
+ font: 700 clamp(34px, 5vw, 48px)/0.94 var(--font-display);
+ text-transform: lowercase;
+}
+
+.closeButton {
+ width: 44px;
+ height: 44px;
+ border: 1px solid rgba(249, 250, 250, 0.18);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--c-text-light);
+ font: 400 28px/1 var(--font-display);
+ cursor: pointer;
+}
+
+.copy {
+ margin: 16px 0 0;
+ color: rgba(249, 250, 250, 0.76);
+ font: 400 16px/1.55 var(--font-body);
+}
+
+.form {
+ display: grid;
+ gap: 18px;
+ margin-top: 24px;
+}
+
+.field {
+ display: grid;
+ gap: 8px;
+}
+
+.field span {
+ color: var(--c-text-light);
+ font: 600 15px/1.3 var(--font-body);
+}
+
+.field input {
+ width: 100%;
+ border: 1px solid rgba(205, 247, 242, 0.22);
+ border-radius: 20px;
+ padding: 14px 16px;
+ background: rgba(1, 44, 42, 0.28);
+ color: var(--c-text-light);
+ font: 400 16px/1.4 var(--font-body);
+ outline: none;
+ transition:
+ border-color 180ms var(--ease-premium),
+ box-shadow 180ms var(--ease-premium),
+ background 180ms var(--ease-premium);
+}
+
+.field input::placeholder {
+ color: rgba(249, 250, 250, 0.44);
+}
+
+.field input:focus {
+ border-color: rgba(241, 96, 47, 0.72);
+ box-shadow: 0 0 0 4px rgba(241, 96, 47, 0.12);
+ background: rgba(1, 44, 42, 0.42);
+}
+
+.status,
+.error {
+ margin: 0;
+ color: rgba(249, 250, 250, 0.82);
+ font: 500 14px/1.45 var(--font-body);
+}
+
+.error {
+ color: #ffc3c3;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ margin-top: 4px;
+}
+
+@media (max-width: 720px) {
+ .overlay {
+ padding: 16px;
+ }
+
+ .dialog {
+ border-radius: 24px;
+ padding: 22px 18px;
+ }
+
+ .title {
+ font-size: 32px;
+ }
+
+ .actions {
+ flex-direction: column-reverse;
+ }
+}
diff --git a/src/app/components/CreateOrgModal.tsx b/src/app/components/CreateOrgModal.tsx
new file mode 100644
index 0000000..114d93b
--- /dev/null
+++ b/src/app/components/CreateOrgModal.tsx
@@ -0,0 +1,155 @@
+import { useEffect, useState } from 'react'
+import type { ChangeEvent, FormEvent } from 'react'
+import { AnimatePresence, motion } from 'framer-motion'
+import { PillButton } from '@/shared/components/PillButton'
+import { useAuth } from '@/shared/context/AuthContext'
+import { createOrganization } from '@/shared/services/organizationService'
+import styles from './CreateOrgModal.module.css'
+
+type CreateOrgModalProps = {
+ isOpen: boolean
+ onClose: () => void
+ onCreated: () => void
+}
+
+/**
+ * Dedicated modal for creating an organization from the dashboard.
+ */
+export function CreateOrgModal({ isOpen, onClose, onCreated }: CreateOrgModalProps) {
+ const { isAuthed, isLoading: isAuthLoading, login } = useAuth()
+ const [name, setName] = useState('')
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (!isOpen) {
+ setName('')
+ setIsSubmitting(false)
+ setError(null)
+ return
+ }
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape' && !isSubmitting) {
+ onClose()
+ }
+ }
+
+ window.addEventListener('keydown', onKeyDown)
+ return () => {
+ window.removeEventListener('keydown', onKeyDown)
+ }
+ }, [isOpen, isSubmitting, onClose])
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault()
+
+ if (!isAuthed) {
+ login()
+ return
+ }
+
+ setIsSubmitting(true)
+ setError(null)
+
+ try {
+ await createOrganization({ name: name.trim() })
+ onCreated()
+ } catch (submitError) {
+ setError(
+ submitError instanceof Error ? submitError.message : 'Could not create organization.',
+ )
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+
+ {isOpen ? (
+ {
+ if (!isSubmitting) {
+ onClose()
+ }
+ }}
+ >
+ event.stopPropagation()}
+ >
+
+
+
new organization
+
+ create an org
+
+
+
+ x
+
+
+
+
+ This posts directly to the organizations API with your current authenticated session.
+
+
+
+
+ Name
+ ) => setName(event.target.value)}
+ placeholder="Computer Science Club"
+ autoComplete="off"
+ required
+ />
+
+
+ {isAuthLoading ? Checking session...
: null}
+ {!isAuthed && !isAuthLoading ? (
+
+ Sign in before creating an organization. The request uses `credentials: include`.
+
+ ) : null}
+ {error ? {error}
: null}
+
+
+
+ cancel
+
+
+ {isSubmitting ? 'creating...' : 'create organization'}
+
+
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/app/components/OrgCard.module.css b/src/app/components/OrgCard.module.css
new file mode 100644
index 0000000..f645c6f
--- /dev/null
+++ b/src/app/components/OrgCard.module.css
@@ -0,0 +1,88 @@
+.orgCard {
+ display: grid;
+ gap: var(--space-16);
+ align-content: start;
+ width: 100%;
+ min-height: 0;
+ padding: 20px;
+ appearance: none;
+ border-radius: 28px;
+ border: 1px solid rgba(126, 225, 218, 0.28);
+ background:
+ linear-gradient(180deg, rgba(75, 183, 173, 0.24), rgba(35, 111, 107, 0.2)),
+ rgba(255, 255, 255, 0.02);
+ backdrop-filter: blur(var(--glass-blur-soft));
+ scroll-snap-align: start;
+ text-align: left;
+ cursor: pointer;
+ transition:
+ transform 180ms var(--ease-premium),
+ border-color 180ms var(--ease-premium),
+ box-shadow 180ms var(--ease-premium);
+}
+
+.orgCard:hover,
+.orgCard:focus-visible {
+ border-color: rgba(241, 96, 47, 0.52);
+ box-shadow: 0 20px 38px rgba(0, 41, 39, 0.24);
+ transform: translateY(-2px);
+}
+
+.orgCard:focus-visible {
+ outline: 2px solid rgba(255, 216, 200, 0.88);
+ outline-offset: 3px;
+}
+
+.cardHeader {
+ display: grid;
+ gap: var(--space-8);
+}
+
+.cardTitle {
+ margin: 0;
+ color: var(--c-text-light);
+ font: 600 24px/1.06 var(--font-display);
+ text-wrap: balance;
+}
+
+.cardDescription {
+ margin: 0;
+ color: rgba(249, 250, 250, 0.82);
+ font: 400 15px/1.35 var(--font-body);
+}
+
+.metaList {
+ display: grid;
+ gap: var(--space-8);
+ margin: 0;
+}
+
+.metaRow {
+ display: grid;
+ grid-template-columns: 58px minmax(0, 1fr);
+ gap: var(--space-16);
+ align-items: start;
+}
+
+.metaRow dt {
+ color: rgba(249, 250, 250, 0.54);
+ font: 600 12px/1.2 var(--font-body);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.metaRow dd {
+ margin: 0;
+ color: var(--c-text-light);
+ font: 500 14px/1.3 var(--font-body);
+}
+
+@media (max-width: 720px) {
+ .orgCard {
+ padding: 16px;
+ }
+
+ .cardTitle {
+ font-size: 22px;
+ }
+}
diff --git a/src/app/components/OrgCard.tsx b/src/app/components/OrgCard.tsx
new file mode 100644
index 0000000..411cd32
--- /dev/null
+++ b/src/app/components/OrgCard.tsx
@@ -0,0 +1,103 @@
+import { motion } from 'framer-motion'
+import type { AppOrganization } from '@/app/data/organizations'
+import { StaggerWords } from '@/shared/components/StaggerWords'
+import { useRevealProgress } from '@/shared/hooks/useRevealProgress'
+import styles from './OrgCard.module.css'
+
+type OrgCardProps = {
+ organization: AppOrganization
+ onSelect?: (organization: AppOrganization) => void
+}
+
+const dateFormatter = new Intl.DateTimeFormat('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+})
+
+function formatDate(value: string) {
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) {
+ return 'Unknown'
+ }
+
+ return dateFormatter.format(date)
+}
+
+function getDescription(organization: AppOrganization) {
+ if (organization.isMember) {
+ return 'Already in the loop. Open the org to check members, events, and manage your seat.'
+ }
+
+ return 'Browse the org snapshot, see what it is running, and join without leaving the dashboard.'
+}
+
+export function OrgCard({ organization, onSelect }: OrgCardProps) {
+ const [cardRef, { centerDelta, progress }] = useRevealProgress(0.45)
+ const delayedProgress = Math.min(1, Math.max(0, progress))
+ const side: 1 | -1 = centerDelta >= 0 ? 1 : -1
+ const outX = side * 48
+ const textInView = delayedProgress > 0.06
+ const style = {
+ opacity: delayedProgress,
+ x: outX * (1 - delayedProgress),
+ y: 10 * (1 - delayedProgress),
+ scale: 0.97 + (1 - 0.97) * delayedProgress,
+ filter: `blur(${(3 * (1 - delayedProgress)).toFixed(2)}px)`,
+ }
+
+ return (
+ onSelect?.(organization)}
+ aria-label={`Open details for ${organization.name}`}
+ >
+
+
+
+
+
Status
+
+
+
+
+
+
Created
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/components/OrgDetailsModal.module.css b/src/app/components/OrgDetailsModal.module.css
new file mode 100644
index 0000000..a64fe6c
--- /dev/null
+++ b/src/app/components/OrgDetailsModal.module.css
@@ -0,0 +1,214 @@
+.overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 40;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background:
+ radial-gradient(circle at top right, rgba(241, 96, 47, 0.2), transparent 28%),
+ rgba(0, 25, 24, 0.62);
+ backdrop-filter: blur(16px);
+}
+
+.dialog {
+ width: min(100%, 860px);
+ max-height: min(90vh, 840px);
+ overflow-y: auto;
+ padding: clamp(24px, 3vw, 32px);
+ border: 1px solid rgba(128, 213, 206, 0.42);
+ border-radius: 32px;
+ background:
+ linear-gradient(180deg, rgba(16, 103, 98, 0.96), rgba(4, 60, 57, 0.98)),
+ rgba(255, 255, 255, 0.04);
+ box-shadow: 0 28px 68px rgba(0, 25, 24, 0.44);
+}
+
+.header {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.eyebrow {
+ margin: 0 0 8px;
+ color: rgba(249, 250, 250, 0.74);
+ font: 600 12px/1.2 var(--font-body);
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+.title {
+ margin: 0;
+ color: var(--c-text-light);
+ font: 700 clamp(34px, 5vw, 52px)/0.94 var(--font-display);
+ text-transform: lowercase;
+ text-wrap: balance;
+}
+
+.closeButton {
+ width: 44px;
+ height: 44px;
+ border: 1px solid rgba(249, 250, 250, 0.18);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--c-text-light);
+ font: 400 28px/1 var(--font-display);
+ cursor: pointer;
+}
+
+.copy {
+ margin: 18px 0 0;
+ color: rgba(249, 250, 250, 0.84);
+ font: 400 17px/1.6 var(--font-body);
+}
+
+.metaGrid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px;
+ margin-top: 24px;
+}
+
+.metaCard,
+.listCard {
+ display: grid;
+ gap: 6px;
+ padding: 16px 18px;
+ border: 1px solid rgba(205, 247, 242, 0.16);
+ border-radius: 24px;
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.metaLabel {
+ color: rgba(249, 250, 250, 0.7);
+ font: 600 12px/1.2 var(--font-body);
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.metaValue {
+ margin: 0;
+ color: var(--c-text-light);
+ font: 500 16px/1.45 var(--font-body);
+}
+
+.contentGrid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px;
+ margin-top: 24px;
+}
+
+.listHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.listTitle {
+ margin: 0;
+ color: var(--c-text-light);
+ font: 600 20px/1.1 var(--font-display);
+ text-transform: lowercase;
+}
+
+.listCount,
+.badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 30px;
+ padding: 0 12px;
+ border-radius: 999px;
+ color: var(--c-text-light);
+ font: 600 12px/1 var(--font-body);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.list {
+ display: grid;
+ gap: 10px;
+ margin: 10px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.listItem,
+.eventItem {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 14px;
+ border-radius: 18px;
+ background: rgba(0, 20, 19, 0.18);
+ color: var(--c-text-light);
+ font: 500 14px/1.35 var(--font-body);
+}
+
+.eventItem {
+ align-items: start;
+ flex-direction: column;
+}
+
+.eventTitle {
+ font: 600 15px/1.3 var(--font-body);
+}
+
+.eventMeta {
+ color: rgba(249, 250, 250, 0.72);
+ font: 500 13px/1.35 var(--font-body);
+}
+
+.emptyCopy {
+ margin: 10px 0 0;
+ color: rgba(249, 250, 250, 0.76);
+ font: 500 14px/1.45 var(--font-body);
+}
+
+.status,
+.error {
+ margin: 18px 0 0;
+ color: rgba(249, 250, 250, 0.82);
+ font: 500 14px/1.45 var(--font-body);
+}
+
+.error {
+ color: #ffc3c3;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ margin-top: 24px;
+}
+
+@media (max-width: 720px) {
+ .overlay {
+ padding: 16px;
+ }
+
+ .dialog {
+ border-radius: 24px;
+ padding: 22px 18px;
+ }
+
+ .title {
+ font-size: 32px;
+ }
+
+ .metaGrid,
+ .contentGrid {
+ grid-template-columns: 1fr;
+ }
+
+ .actions {
+ flex-direction: column-reverse;
+ }
+}
diff --git a/src/app/components/OrgDetailsModal.tsx b/src/app/components/OrgDetailsModal.tsx
new file mode 100644
index 0000000..b3d889f
--- /dev/null
+++ b/src/app/components/OrgDetailsModal.tsx
@@ -0,0 +1,339 @@
+import { useEffect, useState } from 'react'
+import { AnimatePresence, motion } from 'framer-motion'
+import type { AppOrganization } from '@/app/data/organizations'
+import type { Event } from '@/shared/models/event'
+import type { OrganizationMember } from '@/shared/models/organization'
+import { PillButton } from '@/shared/components/PillButton'
+import { useAuth } from '@/shared/context/AuthContext'
+import {
+ addOrganizationMember,
+ listOrganizationEvents,
+ listOrganizationMembers,
+ removeOrganizationMember,
+} from '@/shared/services/organizationService'
+import styles from './OrgDetailsModal.module.css'
+
+type OrgDetailsModalProps = {
+ organization: AppOrganization | null
+ isOpen: boolean
+ onClose: () => void
+ onJoined: () => void
+ onLeft: () => void
+}
+
+const dateTimeFormatter = new Intl.DateTimeFormat('en-US', {
+ weekday: 'long',
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+})
+
+const shortDateFormatter = new Intl.DateTimeFormat('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+})
+
+function formatDateTime(value: string | null) {
+ if (!value) return 'Time to be announced'
+
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return 'Time to be announced'
+ return dateTimeFormatter.format(date)
+}
+
+function formatDate(value: string) {
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return 'Unknown'
+ return shortDateFormatter.format(date)
+}
+
+function formatMemberName(member: OrganizationMember) {
+ const fullName = `${member.first_name} ${member.last_name}`.trim()
+ return fullName || member.email || member.uid
+}
+
+/**
+ * Shows a richer organization summary and lets the signed-in user join or leave.
+ */
+export function OrgDetailsModal({
+ organization,
+ isOpen,
+ onClose,
+ onJoined,
+ onLeft,
+}: OrgDetailsModalProps) {
+ const { isAuthed, isLoading: isAuthLoading, login, user } = useAuth()
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [isLoadingDetails, setIsLoadingDetails] = useState(false)
+ const [members, setMembers] = useState([])
+ const [events, setEvents] = useState([])
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (!isOpen) {
+ setIsSubmitting(false)
+ setIsLoadingDetails(false)
+ setMembers([])
+ setEvents([])
+ setError(null)
+ return
+ }
+
+ const onKeyDown = (keyboardEvent: KeyboardEvent) => {
+ if (keyboardEvent.key === 'Escape' && !isSubmitting) {
+ onClose()
+ }
+ }
+
+ window.addEventListener('keydown', onKeyDown)
+ return () => {
+ window.removeEventListener('keydown', onKeyDown)
+ }
+ }, [isOpen, isSubmitting, onClose])
+
+ useEffect(() => {
+ if (!isOpen || !organization) {
+ return
+ }
+
+ let isCancelled = false
+
+ const loadOrganizationDetails = async () => {
+ setIsLoadingDetails(true)
+ setError(null)
+
+ try {
+ const [membersResponse, eventsResponse] = await Promise.all([
+ listOrganizationMembers(organization.oid),
+ listOrganizationEvents(organization.oid, 20, 0),
+ ])
+
+ if (!isCancelled) {
+ setMembers(membersResponse)
+ setEvents(eventsResponse)
+ }
+ } catch (loadError) {
+ if (!isCancelled) {
+ setError(
+ loadError instanceof Error ? loadError.message : 'Could not load organization details.',
+ )
+ }
+ } finally {
+ if (!isCancelled) {
+ setIsLoadingDetails(false)
+ }
+ }
+ }
+
+ loadOrganizationDetails()
+
+ return () => {
+ isCancelled = true
+ }
+ }, [isOpen, organization])
+
+ const handleJoin = async () => {
+ if (!organization) return
+
+ if (!isAuthed) {
+ login()
+ return
+ }
+
+ if (!user?.uid) {
+ setError('No authenticated user ID is available for joining this organization.')
+ return
+ }
+
+ setIsSubmitting(true)
+ setError(null)
+
+ try {
+ await addOrganizationMember(organization.oid, { uid: user.uid, is_admin: false })
+ onJoined()
+ } catch (submitError) {
+ setError(submitError instanceof Error ? submitError.message : 'Could not join organization.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const handleLeave = async () => {
+ if (!organization || !user?.uid) return
+
+ setIsSubmitting(true)
+ setError(null)
+
+ try {
+ await removeOrganizationMember(organization.oid, user.uid)
+ onLeft()
+ } catch (submitError) {
+ setError(submitError instanceof Error ? submitError.message : 'Could not leave organization.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const isMember =
+ organization?.isMember === true ||
+ (!!user?.uid && members.some((member) => member.uid === user.uid))
+ const adminCount = members.filter((member) => member.is_admin).length
+ const actionLabel = !isAuthed ? 'sign in to join' : isMember ? 'member' : 'join organization'
+
+ return (
+
+ {isOpen && organization ? (
+ {
+ if (!isSubmitting) {
+ onClose()
+ }
+ }}
+ >
+ mouseEvent.stopPropagation()}
+ >
+
+
+
organization details
+
+ {organization.name}
+
+
+
+ x
+
+
+
+
+ View the org roster, skim upcoming events, and join or leave from the same modal.
+
+
+
+
+ Status
+ {isMember ? 'You are a member' : 'Open to join'}
+
+
+ Created
+ {formatDate(organization.date_created)}
+
+
+ Updated
+ {formatDate(organization.date_modified)}
+
+
+ Members
+
+ {members.length} total
+ {adminCount > 0 ? `, ${adminCount} admin${adminCount === 1 ? '' : 's'}` : ''}
+
+
+
+
+
+
+
+
members
+ {members.length}
+
+ {members.length > 0 ? (
+
+ {members.slice(0, 6).map((member) => (
+
+ {formatMemberName(member)}
+ {member.is_admin ? admin : null}
+
+ ))}
+
+ ) : (
+ No members loaded yet.
+ )}
+
+
+
+
+
events
+ {events.length}
+
+ {events.length > 0 ? (
+
+ {events.slice(0, 4).map((event) => (
+
+ {event.title || 'Untitled event'}
+
+ {formatDateTime(event.event_time || null)}
+
+
+ ))}
+
+ ) : (
+ No organization events are scheduled yet.
+ )}
+
+
+
+ {isLoadingDetails ? (
+ Loading organization details...
+ ) : null}
+ {isAuthLoading ? Checking session...
: null}
+ {!isAuthed && !isAuthLoading ? (
+
+ Joining uses the current authenticated session. Sign in to continue.
+
+ ) : null}
+ {error ? {error}
: null}
+
+
+ {isMember ? (
+
+ {isSubmitting ? 'leaving...' : 'leave organization'}
+
+ ) : (
+
+ {isSubmitting ? 'joining...' : actionLabel}
+
+ )}
+
+ close
+
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/app/components/OrgRail.module.css b/src/app/components/OrgRail.module.css
new file mode 100644
index 0000000..3c2482a
--- /dev/null
+++ b/src/app/components/OrgRail.module.css
@@ -0,0 +1,159 @@
+.orgGroup {
+ display: grid;
+ grid-template-rows: auto auto;
+ align-content: start;
+ gap: 14px;
+ overflow: hidden;
+ padding-bottom: 12px;
+ min-height: 390px;
+}
+
+.groupHeader {
+ display: grid;
+ gap: 0;
+ padding-right: 64px;
+}
+
+.groupTitle {
+ margin: 0;
+ color: var(--c-text-light);
+ font: 600 28px/1 var(--font-display);
+ text-transform: lowercase;
+}
+
+.railShell {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--space-16);
+ padding-right: 64px;
+}
+
+.emptyState {
+ display: grid;
+ place-items: center;
+ min-height: 276px;
+ padding-right: 64px;
+ color: rgba(249, 250, 250, 0.8);
+ font: 500 20px/1.4 var(--font-body);
+ letter-spacing: 0.01em;
+ text-align: center;
+}
+
+.controlButton {
+ width: 48px;
+ height: 48px;
+ border: 1px solid rgba(249, 250, 250, 0.28);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--c-text-light);
+ font: 700 22px/1 var(--font-display);
+ backdrop-filter: blur(var(--glass-blur-soft));
+ transition:
+ background 180ms var(--ease-premium),
+ border-color 180ms var(--ease-premium),
+ transform 180ms var(--ease-premium),
+ opacity 180ms var(--ease-premium);
+}
+
+.controlButton:not(:disabled) {
+ cursor: pointer;
+}
+
+.controlButton:not(:disabled):hover {
+ background: rgba(255, 255, 255, 0.16);
+ border-color: rgba(249, 250, 250, 0.42);
+ transform: translateY(-1px);
+}
+
+.controlButton:disabled {
+ opacity: 0.38;
+}
+
+.carousel {
+ --left-edge-fade: 72px;
+ --right-edge-fade: 72px;
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: minmax(320px, 440px);
+ gap: var(--space-24);
+ overflow-x: auto;
+ overflow-y: visible;
+ width: 100%;
+ padding: 4px 0 12px;
+ scroll-snap-type: x proximity;
+ scroll-behavior: smooth;
+ scrollbar-width: none;
+ align-items: stretch;
+ mask-image: linear-gradient(
+ 90deg,
+ transparent 0,
+ rgba(0, 0, 0, 0.9) var(--left-edge-fade),
+ #000 calc(100% - var(--right-edge-fade)),
+ transparent 100%
+ );
+ -webkit-mask-image: linear-gradient(
+ 90deg,
+ transparent 0,
+ rgba(0, 0, 0, 0.9) var(--left-edge-fade),
+ #000 calc(100% - var(--right-edge-fade)),
+ transparent 100%
+ );
+}
+
+.carousel[data-can-scroll-back='false'] {
+ --left-edge-fade: 0px;
+}
+
+.carousel[data-can-scroll-forward='false'] {
+ --right-edge-fade: 0px;
+}
+
+.carousel::-webkit-scrollbar {
+ display: none;
+}
+
+.carousel:global(.is-dragging) {
+ cursor: grabbing;
+}
+
+@media (max-width: 1200px) {
+ .groupHeader,
+ .railShell,
+ .emptyState {
+ padding-right: 44px;
+ }
+}
+
+@media (max-width: 720px) {
+ .orgGroup {
+ min-height: auto;
+ }
+
+ .groupTitle {
+ font-size: 24px;
+ }
+
+ .controlButton {
+ width: 42px;
+ height: 42px;
+ font-size: 20px;
+ }
+
+ .groupHeader,
+ .railShell,
+ .emptyState {
+ padding-right: 24px;
+ }
+
+ .carousel {
+ --left-edge-fade: 36px;
+ --right-edge-fade: 36px;
+ grid-auto-columns: minmax(260px, 78vw);
+ }
+
+ .emptyState {
+ min-height: 220px;
+ font-size: 18px;
+ }
+}
diff --git a/src/app/components/OrgRail.tsx b/src/app/components/OrgRail.tsx
new file mode 100644
index 0000000..1d393f2
--- /dev/null
+++ b/src/app/components/OrgRail.tsx
@@ -0,0 +1,119 @@
+import { useEffect, useRef, useState } from 'react'
+import type { AppOrganization } from '@/app/data/organizations'
+import { OrgCard } from './OrgCard'
+import styles from './OrgRail.module.css'
+
+type OrgRailProps = {
+ title: string
+ organizations: AppOrganization[]
+ carouselLabel: string
+ onOrganizationSelect?: (organization: AppOrganization) => void
+}
+
+function getOrganizationKey(organization: AppOrganization) {
+ return `${organization.oid}-${organization.name}-${organization.date_modified}`
+}
+
+export function OrgRail({
+ title,
+ organizations,
+ carouselLabel,
+ onOrganizationSelect,
+}: OrgRailProps) {
+ const railRef = useRef(null)
+ const headingId = `${title.replace(/\s+/g, '-')}-heading`
+ const [canScrollBack, setCanScrollBack] = useState(false)
+ const [canScrollForward, setCanScrollForward] = useState(false)
+ const hasOrganizations = organizations.length > 0
+
+ useEffect(() => {
+ const rail = railRef.current
+ if (!rail) return
+
+ const updateScrollState = () => {
+ const maxScrollLeft = Math.max(0, rail.scrollWidth - rail.clientWidth)
+ setCanScrollBack(rail.scrollLeft > 4)
+ setCanScrollForward(rail.scrollLeft < maxScrollLeft - 4)
+ }
+
+ updateScrollState()
+ rail.addEventListener('scroll', updateScrollState, { passive: true })
+ window.addEventListener('resize', updateScrollState)
+
+ return () => {
+ rail.removeEventListener('scroll', updateScrollState)
+ window.removeEventListener('resize', updateScrollState)
+ }
+ }, [organizations.length])
+
+ const scrollRail = (direction: 'back' | 'forward') => {
+ const rail = railRef.current
+ if (!rail) return
+
+ const firstCard = rail.firstElementChild as HTMLElement | null
+ const cardWidth = firstCard?.getBoundingClientRect().width ?? rail.clientWidth * 0.82
+ const computedRailStyle = window.getComputedStyle(rail)
+ const gap = Number.parseFloat(computedRailStyle.columnGap || computedRailStyle.gap || '0') || 0
+ const delta = cardWidth + gap
+
+ rail.scrollBy({
+ left: direction === 'forward' ? delta : -delta,
+ behavior: 'smooth',
+ })
+ }
+
+ return (
+
+
+
+ {title}
+
+
+
+ {hasOrganizations ? (
+
+
scrollRail('back')}
+ aria-label={`Scroll ${title} backward`}
+ disabled={!canScrollBack}
+ >
+ ←
+
+
+
+ {organizations.map((organization) => (
+
+ ))}
+
+
+
scrollRail('forward')}
+ aria-label={`Scroll ${title} forward`}
+ disabled={!canScrollForward}
+ >
+ →
+
+
+ ) : (
+
+ There are no organizations available at this time.
+
+ )}
+
+ )
+}
diff --git a/src/app/data/organizations.ts b/src/app/data/organizations.ts
new file mode 100644
index 0000000..72ab4b9
--- /dev/null
+++ b/src/app/data/organizations.ts
@@ -0,0 +1,7 @@
+export type AppOrganization = {
+ oid: string
+ name: string
+ date_created: string
+ date_modified: string
+ isMember?: boolean
+}
diff --git a/src/app/sections/OrgsSection.module.css b/src/app/sections/OrgsSection.module.css
index 411a000..633c2bc 100644
--- a/src/app/sections/OrgsSection.module.css
+++ b/src/app/sections/OrgsSection.module.css
@@ -1,15 +1,88 @@
.orgsPanel {
- padding: 8rem 4rem;
+ position: relative;
+ display: grid;
+ grid-template-rows: auto auto minmax(0, auto);
+ align-content: start;
+ gap: 2rem;
+ padding: 5rem 0 6rem 5rem;
+ height: 100%;
+ box-sizing: border-box;
+ overflow-y: auto;
+ overscroll-behavior-y: contain;
+ scrollbar-width: none;
}
-.title {
- font: 600 40px/1 var(--font-display);
- text-transform: lowercase;
- margin-bottom: 2rem;
- color: var(--c-text-light);
+.orgsPanel::-webkit-scrollbar {
+ display: none;
}
-.orgsPanel p {
- font: 400 18px/1.35 var(--font-body);
- color: var(--c-text-light);
+.panelHeader {
+ position: absolute;
+ top: 2.5rem;
+ right: 1.5rem;
+ z-index: 1;
+}
+
+.createButton {
+ min-width: 56px;
+ width: 56px;
+ padding: 0;
+ flex: 0 0 auto;
+ font: 500 30px/1 var(--font-display);
+}
+
+.status,
+.error {
+ margin: 0;
+ padding-right: 4rem;
+ color: rgba(249, 250, 250, 0.82);
+ font: 500 1rem/1.4 var(--font-body);
+}
+
+.error {
+ color: #ffb4b4;
+}
+
+@media (max-width: 1200px) {
+ .orgsPanel {
+ gap: 1.75rem;
+ padding: 2.5rem 0 5rem 2.75rem;
+ }
+
+ .panelHeader,
+ .status,
+ .error {
+ padding-right: 2.75rem;
+ }
+
+ .panelHeader {
+ top: 1.75rem;
+ right: 1.1rem;
+ padding-right: 0;
+ }
+}
+
+@media (max-width: 720px) {
+ .orgsPanel {
+ gap: 1.25rem;
+ padding: 2rem 0 2.5rem 1.5rem;
+ }
+
+ .panelHeader,
+ .status,
+ .error {
+ padding-right: 1.5rem;
+ }
+
+ .panelHeader {
+ top: 1.25rem;
+ right: 0.85rem;
+ padding-right: 0;
+ }
+
+ .createButton {
+ min-width: 48px;
+ width: 48px;
+ font-size: 28px;
+ }
}
diff --git a/src/app/sections/OrgsSection.tsx b/src/app/sections/OrgsSection.tsx
index 3cbc1f7..5439825 100644
--- a/src/app/sections/OrgsSection.tsx
+++ b/src/app/sections/OrgsSection.tsx
@@ -1,13 +1,112 @@
+import { useMemo, useState } from 'react'
+import { OrgDetailsModal } from '@/app/components/OrgDetailsModal'
+import { OrgRail } from '@/app/components/OrgRail'
+import type { AppOrganization } from '@/app/data/organizations'
import { AnimatedPanel } from '@/shared/components/AnimatedPanel'
+import { PillButton } from '@/shared/components/PillButton'
+import type { Organization } from '@/shared/models/organization'
+import { useAuth } from '@/shared/context/AuthContext'
+import { useOrganizations } from '@/shared/hooks/useOrganizations'
+import { useUserOrganizations } from '@/shared/hooks/useUserOrganizations'
import styles from './OrgsSection.module.css'
-export function OrgsSection() {
+type OrgsSectionProps = {
+ refreshKey?: number
+ onCreateOrganization: () => void
+ onOrganizationsChanged: () => void
+}
+
+function toAppOrganization(organization: Organization): AppOrganization {
+ return {
+ oid: organization.oid,
+ name: organization.name || 'Untitled organization',
+ date_created: organization.date_created,
+ date_modified: organization.date_modified,
+ }
+}
+
+export function OrgsSection({
+ refreshKey = 0,
+ onCreateOrganization,
+ onOrganizationsChanged,
+}: OrgsSectionProps) {
+ const { isAuthed, user } = useAuth()
+ const { organizations, isLoading, error } = useOrganizations(20, 0, refreshKey)
+ const {
+ organizations: myOrganizations,
+ membershipByOrgId,
+ isLoading: isLoadingMyOrganizations,
+ error: myOrganizationsError,
+ } = useUserOrganizations(organizations, user?.uid, isAuthed, refreshKey)
+ const [selectedOrganization, setSelectedOrganization] = useState(null)
+ const recommendedOrganizations = useMemo(
+ () =>
+ organizations.map((organization) => ({
+ ...toAppOrganization(organization),
+ isMember: membershipByOrgId[organization.oid] === true,
+ })),
+ [organizations, membershipByOrgId],
+ )
+ const myOrganizationCards = myOrganizations.map((organization) => ({
+ ...toAppOrganization(organization),
+ isMember: true,
+ }))
+
return (
-
-
organizations
-
Connect with student clubs and groups. Find your next move in your sleep.
+
+ {isLoading ?
Loading organizations...
: null}
+ {error ?
{error}
: null}
+ {isAuthed && isLoadingMyOrganizations ? (
+
Loading your organizations...
+ ) : null}
+ {isAuthed && myOrganizationsError ? (
+
{myOrganizationsError}
+ ) : null}
+ {!isLoading && !error ? (
+ <>
+ {isAuthed ? (
+
+ ) : null}
+
+ >
+ ) : null}
+
setSelectedOrganization(null)}
+ onJoined={() => {
+ onOrganizationsChanged()
+ setSelectedOrganization((current) => (current ? { ...current, isMember: true } : current))
+ }}
+ onLeft={() => {
+ onOrganizationsChanged()
+ setSelectedOrganization((current) =>
+ current ? { ...current, isMember: false } : current,
+ )
+ }}
+ />
)
}
diff --git a/src/shared/hooks/useOrganizations.ts b/src/shared/hooks/useOrganizations.ts
new file mode 100644
index 0000000..bf08aac
--- /dev/null
+++ b/src/shared/hooks/useOrganizations.ts
@@ -0,0 +1,50 @@
+import { useEffect, useState } from 'react'
+import type { Organization } from '@/shared/models/organization'
+import { listOrganizations } from '@/shared/services/organizationService'
+
+type UseOrganizationsResult = {
+ organizations: Organization[]
+ isLoading: boolean
+ error: string | null
+}
+
+/**
+ * Fetches the organizations list used by the dashboard carousel.
+ */
+export function useOrganizations(limit = 20, offset = 0, refreshKey = 0): UseOrganizationsResult {
+ const [organizations, setOrganizations] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ let isCancelled = false
+
+ const loadOrganizations = async () => {
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ const response = await listOrganizations(limit, offset)
+ if (!isCancelled) {
+ setOrganizations(response)
+ }
+ } catch (err) {
+ if (!isCancelled) {
+ setError(err instanceof Error ? err.message : 'Could not load organizations.')
+ }
+ } finally {
+ if (!isCancelled) {
+ setIsLoading(false)
+ }
+ }
+ }
+
+ loadOrganizations()
+
+ return () => {
+ isCancelled = true
+ }
+ }, [limit, offset, refreshKey])
+
+ return { organizations, isLoading, error }
+}
diff --git a/src/shared/hooks/useUserOrganizations.ts b/src/shared/hooks/useUserOrganizations.ts
new file mode 100644
index 0000000..ab7535d
--- /dev/null
+++ b/src/shared/hooks/useUserOrganizations.ts
@@ -0,0 +1,81 @@
+import { useEffect, useState } from 'react'
+import type { Organization } from '@/shared/models/organization'
+import { listOrganizationMembers } from '@/shared/services/organizationService'
+
+type UseUserOrganizationsResult = {
+ organizations: Organization[]
+ membershipByOrgId: Record
+ isLoading: boolean
+ error: string | null
+}
+
+/**
+ * Derives the authenticated user's organizations by checking membership against
+ * the current organizations list because the API does not expose a dedicated
+ * `/users/{uid}/organizations` endpoint yet.
+ */
+export function useUserOrganizations(
+ sourceOrganizations: Organization[],
+ uid?: string,
+ isAuthed = false,
+ refreshKey = 0,
+): UseUserOrganizationsResult {
+ const [organizations, setOrganizations] = useState([])
+ const [membershipByOrgId, setMembershipByOrgId] = useState>({})
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (!isAuthed || !uid || sourceOrganizations.length === 0) {
+ setOrganizations([])
+ setMembershipByOrgId({})
+ setIsLoading(false)
+ setError(null)
+ return
+ }
+
+ let isCancelled = false
+
+ const loadMemberships = async () => {
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ const membershipEntries = await Promise.all(
+ sourceOrganizations.map(async (organization) => {
+ const members = await listOrganizationMembers(organization.oid)
+ return [organization.oid, members.some((member) => member.uid === uid)] as const
+ }),
+ )
+
+ if (isCancelled) {
+ return
+ }
+
+ const nextMembershipByOrgId = Object.fromEntries(membershipEntries)
+ setMembershipByOrgId(nextMembershipByOrgId)
+ setOrganizations(
+ sourceOrganizations.filter((organization) => nextMembershipByOrgId[organization.oid]),
+ )
+ } catch (err) {
+ if (!isCancelled) {
+ setError(err instanceof Error ? err.message : 'Could not load your organizations.')
+ setMembershipByOrgId({})
+ setOrganizations([])
+ }
+ } finally {
+ if (!isCancelled) {
+ setIsLoading(false)
+ }
+ }
+ }
+
+ loadMemberships()
+
+ return () => {
+ isCancelled = true
+ }
+ }, [sourceOrganizations, uid, isAuthed, refreshKey])
+
+ return { organizations, membershipByOrgId, isLoading, error }
+}
diff --git a/src/shared/models/organization.ts b/src/shared/models/organization.ts
new file mode 100644
index 0000000..3744e99
--- /dev/null
+++ b/src/shared/models/organization.ts
@@ -0,0 +1,28 @@
+import type { Event } from './event'
+
+export type Organization = {
+ oid: string
+ name: string
+ date_created: string
+ date_modified: string
+}
+
+export type OrganizationMember = {
+ uid: string
+ first_name: string
+ last_name: string
+ email: string
+ is_admin: boolean
+}
+
+export type CreateOrganizationPayload = Pick
+
+export type UpdateOrganizationPayload = Partial>
+
+export type CreateOrganizationMemberPayload = Pick
+
+export type ListOrganizationsResponse = Organization[]
+
+export type ListOrganizationMembersResponse = OrganizationMember[]
+
+export type ListOrganizationEventsResponse = Event[]
diff --git a/src/shared/services/organizationService.ts b/src/shared/services/organizationService.ts
new file mode 100644
index 0000000..8e3c359
--- /dev/null
+++ b/src/shared/services/organizationService.ts
@@ -0,0 +1,63 @@
+import type { Event } from '../models/event'
+import type {
+ CreateOrganizationMemberPayload,
+ CreateOrganizationPayload,
+ ListOrganizationMembersResponse,
+ ListOrganizationsResponse,
+ Organization,
+ OrganizationMember,
+ UpdateOrganizationPayload,
+} from '../models/organization'
+import { apiClient } from './apiClient'
+
+export function listOrganizations(limit = 20, offset = 0) {
+ const params = new URLSearchParams({
+ limit: String(limit),
+ offset: String(offset),
+ })
+
+ return apiClient.get(`/organizations?${params.toString()}`, {
+ cache: 'no-store',
+ })
+}
+
+export function getOrganization(oid: string) {
+ return apiClient.get(`/organizations/${oid}`, { cache: 'no-store' })
+}
+
+export function createOrganization(payload: CreateOrganizationPayload) {
+ return apiClient.post('/organizations', payload)
+}
+
+export function updateOrganization(oid: string, payload: UpdateOrganizationPayload) {
+ return apiClient.put(`/organizations/${oid}`, payload)
+}
+
+export function deleteOrganization(oid: string) {
+ return apiClient.delete(`/organizations/${oid}`)
+}
+
+export function listOrganizationMembers(oid: string) {
+ return apiClient.get(`/organizations/${oid}/members`, {
+ cache: 'no-store',
+ })
+}
+
+export function addOrganizationMember(oid: string, payload: CreateOrganizationMemberPayload) {
+ return apiClient.post(`/organizations/${oid}/members`, payload)
+}
+
+export function removeOrganizationMember(oid: string, uid: string) {
+ return apiClient.delete(`/organizations/${oid}/members/${uid}`)
+}
+
+export function listOrganizationEvents(oid: string, limit = 20, offset = 0) {
+ const params = new URLSearchParams({
+ limit: String(limit),
+ offset: String(offset),
+ })
+
+ return apiClient.get(`/organizations/${oid}/events?${params.toString()}`, {
+ cache: 'no-store',
+ })
+}