diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8406735 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# API Configuration +# The target URL for the Vite dev proxy. +# In production, this should likely be the same origin. +VITE_API_BASE_URL=https://dev.capyrpi.org + +# The path prefix for API calls. +VITE_API_VERSION=/api/v1 + +# OAuth & Local Development +# The host header sent to the backend for redirect URI generation. +VITE_DEV_HOST=localhost:5173 + +# The protocol header sent to the backend for redirect URI generation. +VITE_DEV_PROTO=http \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6493b7..00b024d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,9 +4,11 @@ on: pull_request: branches: - main + - develop push: branches: - main + - develop permissions: contents: read diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 4393330..723ab94 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -4,9 +4,11 @@ on: pull_request: branches: - main + - develop push: branches: - main + - develop tags: - 'v*' workflow_dispatch: diff --git a/.gitignore b/.gitignore index a547bf3..ae8fc19 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +.agents/ 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/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3401713 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,36 @@ +# Contributing to Capy Lander + +Thank you for considering contributing! To keep the codebase clean and maintainable, please follow these guidelines: + +## Feature-Based Folder Structure + +- Place new features or sections in their own folder under `src/sections/` or `src/components/` as appropriate. +- Shared hooks go in `src/hooks/`. +- Shared data and utilities go in `src/data/` or `src/utils/`. + +## CSS Modules & Styling + +- Use CSS modules for all new components (e.g., `ComponentName.module.css`). +- Avoid global styles except for tokens and resets in `src/theme/tokens.css` and `index.css`. +- Reference design tokens via the `tokens.css` custom properties for all colors, spacing, and typography. + +## Design Tokens + +- Always use the CSS variables defined in `src/theme/tokens.css` for colors, spacing, and font sizes. +- Do not hardcode values; update tokens if new design values are needed. + +## Code Style + +- Use TypeScript for all files. +- Write clear JSDoc comments for all hooks and complex logic blocks, explaining both what and why. +- Run `npm run lint` before submitting a PR. + +## Pull Requests + +- Keep PRs focused and small. One feature or fix per PR. +- Add or update documentation as needed. +- Ensure all tests and builds pass before requesting review. + +--- + +For questions, open an issue or ask in discussions. Happy coding! diff --git a/README.md b/README.md index 2d3722a..a5f73b9 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,49 @@ # Capy Lander -Premium, horizontal-first React landing page implementation based on Figma design `8:426`. - -## Stack - -- React 19 + TypeScript + Vite -- Framer Motion for reveal and premium interaction animation -- CSS tokens + componentized sections for maintainability - -## Run - -```bash -npm install -npm run dev -``` +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: @@ -21,7 +51,7 @@ Production build: npm run build ``` -## Docker +## 🐳 Docker Build and run the production image locally: @@ -30,16 +60,16 @@ docker build -t capy-lander:local . docker run --rm -p 8080:80 capy-lander:local ``` -Open `http://localhost:8080`. +Open [http://localhost:8080](http://localhost:8080). -## Docker Compose +## 🧩 Docker Compose -This repo supports both common Compose modes: +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 automatically loads `docker-compose.override.yml`, so a local run builds from source: +By default, Docker Compose loads `docker-compose.override.yml`, so a local run builds from source: ```bash docker compose up --build @@ -51,14 +81,27 @@ To run a published registry image instead, disable overrides and set the image t CAPY_IMAGE=ghcr.io//:latest docker compose -f docker-compose.yml up ``` -## GitHub Image Builder +## 🛠️ Contributing -GitHub Actions workflow: `.github/workflows/docker-image.yml` +See [CONTRIBUTING.md](CONTRIBUTING.md) for full guidelines. -- Pull requests to `main`: lint, build, and container build validation (no push) +- **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 diff --git a/__mocks__/styleMock.cjs b/__mocks__/styleMock.cjs new file mode 100644 index 0000000..4ba52ba --- /dev/null +++ b/__mocks__/styleMock.cjs @@ -0,0 +1 @@ +module.exports = {} diff --git a/agent.md b/agent.md deleted file mode 100644 index 07d4a68..0000000 --- a/agent.md +++ /dev/null @@ -1,56 +0,0 @@ -# agent.md - -## Goal - -Maintain pixel-accurate implementation quality against Figma while preserving a premium, polished, glassmorphism visual system. - -## Core Rules - -- Keep the horizontal storytelling layout as the primary desktop experience. -- Keep vertical page scrolling disabled unless explicitly requested. -- Preserve section decomposition in `src/sections`. -- Reuse primitives in `src/components` before creating new one-off implementations. -- Do not hardcode colors in section files when a token can be used. - -## Styling Standards - -- Use `src/theme/tokens.css` as the source of truth for: - - colors - - spacing - - radii - - typography - - motion easing - - glass effects -- Prefer semantic class names by role (`heroPanel`, `contactLead`, `railStatus`) over purely visual naming. - -## Motion Standards - -- Use Framer Motion for: - - section reveals - - subtle transitions - - hover/focus feedback -- Keep motion restrained and intentional. -- Respect reduced-motion preferences in future enhancements. - -## Asset Handling Standards - -- Keep SVGs and icons ratio-safe using `AspectImage`. -- Avoid fixed stretching with conflicting width/height constraints. -- If replacing remote assets, keep equivalent intrinsic dimensions. - -## Figma QA Standards - -Before merging visual changes: - -- Validate panel spacing and alignments against target node. -- Validate text size and line-height consistency. -- Validate CTA and nav geometry. -- Validate that no element distorts at supported breakpoints. -- Validate horizontal scroll interaction with mouse and trackpad. - -## Maintainability Standards - -- Keep content in `src/data/content.ts`. -- Keep presentational logic in section components. -- Keep behavior logic in hooks. -- Keep app-level orchestration in `src/App.tsx`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..4e12222 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,82 @@ +# Capy Architecture + +## Overview + +The capy application is divided into two distinct sub-applications served from a single React SPA via `react-router-dom`: + +- **Lander (`/`)**: A promotional landing page showcasing features and collecting interest. Resides in `src/lander/`. +- **App (`/app`)**: The main functioning application. Resides in `src/app/`. + +## API Communication + +The frontend communicates with the backend via a centralized `apiClient`. + +- **Development**: A **Dynamic Proxy** in Vite forwards `/api` requests to the target specified in `VITE_API_BASE_URL` (from `.env.local`). This preserves same-origin behavior for cookies and auth redirects. +- **Production**: Requests are typically relative (`/api/v1`), assuming the frontend and backend are served from the same origin. + +## Directory Structure + +``` +src/ +├── main.tsx # Entry point — React.lazy + Suspense + BrowserRouter +├── index.css # Global resets and token import only +├── css-modules.d.ts # TypeScript declaration for *.module.css +├── lander/ # Landing page domain +│ ├── Lander.tsx # Lander root component +│ ├── Lander.module.css +│ └── sections/ # Per-panel section components +│ ├── HeroSection.tsx / .module.css +│ ├── FeaturesSection.tsx / .module.css +│ ├── InterfaceSection.tsx / .module.css +│ ├── ContactSection.tsx / .module.css +│ ├── CapyRailSection.tsx / .module.css +│ └── __tests__/ +├── app/ # Application domain +│ ├── App.tsx # App root component +│ ├── App.module.css +│ ├── components/ +│ │ └── AppTopNav.tsx # App-specific nav wrapper +│ └── sections/ # Per-panel section components +│ ├── HomeSection.tsx / .module.css +│ ├── ProfileSection.tsx / .module.css +│ ├── ProfileField.tsx # Inline field component (shares ProfileSection.module.css) +│ ├── EventsSection.tsx / .module.css +│ └── OrgsSection.tsx / .module.css +├── error/ # Error page domain +│ ├── ErrorPage.tsx +│ └── ErrorPage.module.css +└── shared/ # Domain-agnostic shared code + ├── components/ # Reusable UI primitives + │ ├── TopNav.tsx / .module.css + │ ├── PillButton.tsx / .module.css + │ ├── GlassCard.tsx / .module.css + │ ├── AnimatedPanel.tsx + │ ├── StaggerWords.tsx / .module.css + │ ├── TypewriterWord.tsx / .module.css + │ ├── AspectImage.tsx / .module.css + │ ├── ErrorBoundary.tsx + │ └── __tests__/ + ├── context/ + │ └── AuthContext.tsx # Auth provider + useAuth hook + ├── data/ + │ └── content.ts # Static copywriting and nav items + ├── hooks/ + │ ├── useExitNavigation.ts + │ ├── useHorizontalWheelScroll.ts + │ ├── useRevealProgress.ts + │ └── __tests__/ + ├── models/ + │ ├── user.ts # normalizeUser transform + │ └── __tests__/ + ├── services/ + │ └── apiClient.ts # Centralized fetch wrapper + ├── theme/ + │ └── tokens.css # Design tokens (colors, spacing, radii, fonts, glass effects) + └── types/ + └── auth.ts # User and AuthContextType interfaces +``` + +## Styling + +Global styles are limited to resetting the box model and defining variables in `src/index.css`. +All component styling uses CSS Modules (e.g., `HeroSection.module.css`) to prevent global style leaks. Use `import styles from './Component.module.css'` and assign via `className={styles.className}`. diff --git a/docs/components.md b/docs/components.md new file mode 100644 index 0000000..5a2e82e --- /dev/null +++ b/docs/components.md @@ -0,0 +1,78 @@ +# Shared Components Library + +All shared, reusable components for Capy-Lander and Capy-App reside in `src/shared/components/`. +**Do not reinvent these components in your domain folders (`src/lander/` or `src/app/`)!** + +## Available Components + +### `ExitOverlay` + +A shared overlay `
` that provides seamless background transitions when navigating between routes. Used by `Lander`, `App`, and `ErrorPage`. + +- **Activation:** Triggered by the `is-exiting` class on `document.body` (set by `useExitNavigation`). +- **Fade-back:** Supports the `fading-back` class for bfcache restoration animations. +- **Usage:** Drop `` as the last child inside your route root. Pair with `usePageTransition()` for lifecycle management. + +### `TopNav` + +The primary navigation bar. + +- **Usage:** Main router navigation, automatically injects the `navItems` sequence from `src/shared/data/content.ts`. +- **Properties:** It self-manages state (`activeHref`, scroll tracking, `useExitNavigation` routing). + +### `PillButton` + +A polymorphic button/anchor with motion animations and variant styles. Wraps `framer-motion` and uses `PillButton.module.css`. + +- **Props:** + - `as?: 'button' | 'a'` — Renders as a ` + )) + ) : ( +
No matching items found.
+ )} +
+ + + + ) +} diff --git a/src/app/components/AppTopNav.module.css b/src/app/components/AppTopNav.module.css new file mode 100644 index 0000000..d5d497e --- /dev/null +++ b/src/app/components/AppTopNav.module.css @@ -0,0 +1,45 @@ +.searchButton { + width: clamp(48px, 5.4vw, 60px); + min-width: clamp(48px, 5.4vw, 60px); + height: clamp(48px, 5.4vw, 60px); + margin-inline-end: clamp(4px, 0.75vw, 10px); + border: 1px solid rgba(128, 213, 206, 0.8); + border-radius: 999px; + display: inline-grid; + place-items: center; + color: var(--c-text-light); + background: linear-gradient(180deg, rgba(59, 163, 154, 0.32), rgba(46, 125, 120, 0.24)); + box-shadow: 0 12px 28px rgba(0, 36, 34, 0.22); + cursor: pointer; + transition: + transform 220ms var(--ease-premium), + box-shadow 220ms var(--ease-premium), + border-color 220ms var(--ease-premium), + background 220ms var(--ease-premium); +} + +.searchButton:hover, +.searchButton:focus-visible { + transform: translateY(-1px); + border-color: rgba(181, 245, 240, 0.92); + background: linear-gradient(180deg, rgba(72, 182, 172, 0.42), rgba(43, 133, 126, 0.32)); +} + +.searchButton:focus-visible { + outline: 2px solid rgba(255, 255, 255, 0.82); + outline-offset: 3px; +} + +.searchGlyph { + display: block; + width: 24px; + height: 24px; +} + +@media (max-width: 1200px) { + .searchButton { + width: 48px; + min-width: 48px; + height: 48px; + } +} diff --git a/src/app/components/AppTopNav.tsx b/src/app/components/AppTopNav.tsx new file mode 100644 index 0000000..112dd7b --- /dev/null +++ b/src/app/components/AppTopNav.tsx @@ -0,0 +1,44 @@ +import type { MouseEvent } from 'react' +import { TopNav } from '@/shared/components/TopNav' +import { SearchIcon } from '@/shared/components/icons/SearchIcon' +import { appNavItems } from '@/shared/data/content' +import { useAuth } from '@/shared/context/AuthContext' +import { API_VERSION } from '@/shared/services/apiClient' +import styles from './AppTopNav.module.css' + +type AppTopNavProps = { + onOpenSearch: () => void +} + +export function AppTopNav({ onOpenSearch }: AppTopNavProps) { + const { isAuthed, login, logout } = useAuth() + + const onCtaClickOverride = (event: MouseEvent) => { + event.preventDefault() + if (isAuthed) { + logout() + } else { + login() + } + } + + return ( + + + + } + /> + ) +} diff --git a/src/app/components/CreateEventModal.module.css b/src/app/components/CreateEventModal.module.css new file mode 100644 index 0000000..66d4634 --- /dev/null +++ b/src/app/components/CreateEventModal.module.css @@ -0,0 +1,193 @@ +.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 { + display: flex; + width: min(100%, 640px); + max-height: min(90vh, 840px); + overflow-y: auto; + padding: clamp(24px, 3vw, 32px) 16px clamp(24px, 3vw, 32px) 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); + overflow: hidden; +} + +.content { + flex: 1; + min-height: 0; + overflow-y: auto; + padding-right: 16px; +} + +.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 select, +.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 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; + 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..11cdf84 --- /dev/null +++ b/src/app/components/CreateEventModal.tsx @@ -0,0 +1,327 @@ +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 { useOrganizations } from '@/shared/hooks/useOrganizations' +import { useUserOrganizations } from '@/shared/hooks/useUserOrganizations' +import { createEvent } from '@/shared/services/eventService' +import styles from './CreateEventModal.module.css' + +type CreateEventModalProps = { + isOpen: boolean + onClose: () => void + onCreated: () => void +} + +type CreateEventFormState = { + orgId: string + title: string + location: string + eventTime: string + description: string +} + +function getCurrentDateTimeInputValue() { + const now = new Date() + now.setSeconds(0, 0) + + const timezoneOffset = now.getTimezoneOffset() + const localDate = new Date(now.getTime() - timezoneOffset * 60_000) + return localDate.toISOString().slice(0, 16) +} + +function createInitialFormState(): CreateEventFormState { + return { + orgId: '', + title: '', + location: '', + eventTime: getCurrentDateTimeInputValue(), + description: '', + } +} + +function toEventTimeISOString(value: string) { + if (!value) return null + + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : date.toISOString() +} + +/** + * Dedicated modal for creating an event without leaving the dashboard. + * 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, 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(() => createInitialFormState()) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!isOpen) { + setFormState(createInitialFormState()) + setIsSubmitting(false) + setError(null) + return + } + + setFormState((current) => { + if (current.eventTime.length > 0) { + return current + } + + return { + ...current, + eventTime: getCurrentDateTimeInputValue(), + } + }) + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !isSubmitting) { + onClose() + } + } + + window.addEventListener('keydown', onKeyDown) + return () => { + window.removeEventListener('keydown', onKeyDown) + } + }, [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) => { + 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 + } + + 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: formState.orgId, + title: formState.title.trim() || undefined, + 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 ? ( +