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/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/scripts/seed-events.sh b/scripts/seed-events.sh new file mode 100644 index 0000000..2f24dd7 --- /dev/null +++ b/scripts/seed-events.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash + +set -euo pipefail + +API_BASE_URL="${API_BASE_URL:-http://localhost:8080}" +API_VERSION="${API_VERSION:-/api/v1}" +EVENT_COUNT="${EVENT_COUNT:-25}" +ORG_ID="${ORG_ID:-66168f44-624a-47ad-9b07-7a92121bce01}" +START_DATE="${START_DATE:-2026-03-30T17:30:00Z}" +REGISTER_CREATED_EVENTS="${REGISTER_CREATED_EVENTS:-false}" +REGISTER_EVERY_N="${REGISTER_EVERY_N:-1}" +REGISTRATION_METHOD="${REGISTRATION_METHOD:-POST}" +REGISTRATION_PATH_TEMPLATE="${REGISTRATION_PATH_TEMPLATE:-/events/{{eid}}/registrations}" +REGISTRATION_BODY_TEMPLATE="${REGISTRATION_BODY_TEMPLATE:-}" +SEED_USER_ID="${SEED_USER_ID:-}" +COOKIE_HEADER="${COOKIE_HEADER:-}" + +export ORG_ID +export START_DATE + +if ! command -v curl >/dev/null 2>&1; then + echo "curl is required." >&2 + exit 1 +fi + +if ! command -v node >/dev/null 2>&1; then + echo "node is required." >&2 + exit 1 +fi + +curl_with_optional_cookie() { + if [[ -n "${COOKIE_HEADER}" ]]; then + curl --silent --show-error --fail -H "Cookie: ${COOKIE_HEADER}" "$@" + else + curl --silent --show-error --fail "$@" + fi +} + +create_event_payload() { + node -e ' + const orgId = process.env.ORG_ID; + const payload = { + org_id: orgId, + }; + + process.stdout.write(JSON.stringify(payload)); + ' +} + +create_event_update_payload() { + local index="$1" + + node -e ' + const index = Number(process.argv[1]); + const rawStartDate = process.env.START_DATE; + const startDate = new Date(rawStartDate); + + if (Number.isNaN(startDate.getTime())) { + console.error(`Invalid START_DATE: ${rawStartDate}. Expected ISO format like 2026-03-18T17:30:00Z.`); + process.exit(1); + } + + startDate.setUTCDate(startDate.getUTCDate() + index); + startDate.setUTCHours(18 + (index % 3), 15 * (index % 4), 0, 0); + + const payload = { + location: `Seed Venue ${index + 1}`, + event_time: startDate.toISOString(), + description: `Seeded event ${index + 1} for local load testing and UI validation.`, + }; + + process.stdout.write(JSON.stringify(payload)); + ' "$index" +} + +extract_eid() { + node -e ' + let raw = ""; + process.stdin.on("data", (chunk) => { + raw += chunk; + }); + process.stdin.on("end", () => { + try { + const parsed = JSON.parse(raw); + if (!parsed?.eid) process.exit(1); + process.stdout.write(parsed.eid); + } catch { + process.exit(1); + } + }); + ' +} + +render_registration_body() { + local eid="$1" + + if [[ -z "${REGISTRATION_BODY_TEMPLATE}" ]]; then + return 0 + fi + + local rendered="${REGISTRATION_BODY_TEMPLATE//\{\{eid\}\}/${eid}}" + rendered="${rendered//\{\{uid\}\}/${SEED_USER_ID}}" + printf '%s' "${rendered}" +} + +register_for_event() { + local eid="$1" + local path="${REGISTRATION_PATH_TEMPLATE//\{\{eid\}\}/${eid}}" + path="${path//\{\{uid\}\}/${SEED_USER_ID}}" + + local body="" + body="$(render_registration_body "${eid}")" + + if [[ -n "${body}" ]]; then + curl_with_optional_cookie \ + -X "${REGISTRATION_METHOD}" \ + -H 'Content-Type: application/json' \ + --data "${body}" \ + "${API_BASE_URL}${API_VERSION}${path}" >/dev/null + else + curl_with_optional_cookie \ + -X "${REGISTRATION_METHOD}" \ + "${API_BASE_URL}${API_VERSION}${path}" >/dev/null + fi +} + +update_event() { + local eid="$1" + local index="$2" + local body="" + body="$(create_event_update_payload "${index}")" + + curl_with_optional_cookie \ + -X PUT \ + -H 'Content-Type: application/json' \ + --data "${body}" \ + "${API_BASE_URL}${API_VERSION}/events/${eid}" >/dev/null +} + +echo "Seeding ${EVENT_COUNT} events into ${API_BASE_URL}" + +for ((i = 0; i < EVENT_COUNT; i += 1)); do + payload="$(create_event_payload)" + response="$( + curl_with_optional_cookie \ + -X POST \ + -H 'Content-Type: application/json' \ + --data "${payload}" \ + "${API_BASE_URL}${API_VERSION}/events" + )" + + eid="$(printf '%s' "${response}" | extract_eid)" + echo "Created event ${i} -> ${eid}" + update_event "${eid}" "${i}" + echo "Updated event ${i} -> ${eid}" + + if [[ "${REGISTER_CREATED_EVENTS}" == "true" ]] && (( i % REGISTER_EVERY_N == 0 )); then + register_for_event "${eid}" + echo "Registered for event ${i} -> ${eid}" + fi +done diff --git a/src/app/App.tsx b/src/app/App.tsx index acb7130..6da5c68 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,7 +1,9 @@ -import { useRef, useEffect } from 'react' +import { useRef, useEffect, useState } from 'react' import { motion } from 'framer-motion' import { Helmet } from 'react-helmet-async' +import { CreateOrgModal } from '@/app/components/CreateOrgModal' import { AppTopNav } from '@/app/components/AppTopNav' +import { CreateEventModal } from '@/app/components/CreateEventModal' import { ExitOverlay } from '@/shared/components/ExitOverlay' import { useHorizontalWheelScroll } from '@/shared/hooks/useHorizontalWheelScroll' import { usePageTransition } from '@/shared/hooks/usePageTransition' @@ -18,6 +20,10 @@ import styles from './App.module.css' */ export default function AppMain() { const scrollerRef = useRef(null) + const [isCreateEventOpen, setIsCreateEventOpen] = useState(false) + const [isCreateOrgOpen, setIsCreateOrgOpen] = useState(false) + const [eventsRefreshKey, setEventsRefreshKey] = useState(0) + const [organizationsRefreshKey, setOrganizationsRefreshKey] = useState(0) useHorizontalWheelScroll(scrollerRef, { endCutoffPx: 0 }) usePageTransition() @@ -51,11 +57,35 @@ export default function AppMain() { > - - + setIsCreateEventOpen(true)} + onEventsChanged={() => setEventsRefreshKey((current) => current + 1)} + /> + setIsCreateOrgOpen(true)} + onOrganizationsChanged={() => setOrganizationsRefreshKey((current) => current + 1)} + /> + setIsCreateEventOpen(false)} + onCreated={() => { + setEventsRefreshKey((current) => current + 1) + setIsCreateEventOpen(false) + }} + /> + setIsCreateOrgOpen(false)} + onCreated={() => { + setOrganizationsRefreshKey((current) => current + 1) + setIsCreateOrgOpen(false) + }} + /> ) diff --git a/src/app/components/CreateEventModal.module.css b/src/app/components/CreateEventModal.module.css new file mode 100644 index 0000000..0216f74 --- /dev/null +++ b/src/app/components/CreateEventModal.module.css @@ -0,0 +1,184 @@ +.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 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..57d1780 --- /dev/null +++ b/src/app/components/CreateEventModal.tsx @@ -0,0 +1,300 @@ +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 +} + +const initialFormState: CreateEventFormState = { + orgId: '', + title: '', + 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-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(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]) + + 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 ? ( +