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} + +
+
+ +
+ +
+ {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} -
-
- -
- -
- {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) => ( - - ))} +
+ + +
+ {events.map((event) => ( + + ))} +
+ +
) 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 ? ( +