Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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/<owner>/<repo>: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/<owner>/<repo>
```

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`.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
161 changes: 161 additions & 0 deletions scripts/seed-events.sh
Original file line number Diff line number Diff line change
@@ -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
36 changes: 33 additions & 3 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -18,6 +20,10 @@ import styles from './App.module.css'
*/
export default function AppMain() {
const scrollerRef = useRef<HTMLElement | null>(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()
Expand Down Expand Up @@ -51,11 +57,35 @@ export default function AppMain() {
>
<ProfileSection />
<HomeSection />
<EventsSection />
<OrgsSection />
<EventsSection
refreshKey={eventsRefreshKey}
onCreateEvent={() => setIsCreateEventOpen(true)}
onEventsChanged={() => setEventsRefreshKey((current) => current + 1)}
/>
<OrgsSection
refreshKey={organizationsRefreshKey}
onCreateOrganization={() => setIsCreateOrgOpen(true)}
onOrganizationsChanged={() => setOrganizationsRefreshKey((current) => current + 1)}
/>
</motion.div>
</main>
</div>
<CreateEventModal
isOpen={isCreateEventOpen}
onClose={() => setIsCreateEventOpen(false)}
onCreated={() => {
setEventsRefreshKey((current) => current + 1)
setIsCreateEventOpen(false)
}}
/>
<CreateOrgModal
isOpen={isCreateOrgOpen}
onClose={() => setIsCreateOrgOpen(false)}
onCreated={() => {
setOrganizationsRefreshKey((current) => current + 1)
setIsCreateOrgOpen(false)
}}
/>
<ExitOverlay />
</>
)
Expand Down
Loading
Loading