diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..c645a051 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/scripts/claude-code-setup.sh", + "timeout": 600 + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index 09199b05..20eaffd4 100644 --- a/.gitignore +++ b/.gitignore @@ -49,7 +49,8 @@ cypress.env.json /studio/.sanity .env*.local -# Claude Code -.claude/ +# Claude Code (shared settings are tracked, everything else stays local) +.claude/* +!.claude/settings.json .beads diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..37968fcd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,39 @@ +# CLAUDE.md + +Main site for Gi Effektivt / Ge Effektivt / Giv Effektivt — a multi-country (NO/SE/DK) donation platform. Next.js (Pages Router) frontend with Sanity as CMS, Auth0 for the logged-in profile pages, and the [effekt-backend](https://github.com/stiftelsen-effekt/effekt-backend) API for donation data. + +## Commands + +- `npm install` — install dependencies (Node version: see `.nvmrc`) +- `npm run dev` — dev server on http://localhost:3000 +- `npm run typecheck` — TypeScript check (`tsc --noEmit`) +- `npm test` — Jest unit tests +- `npm run lint` — ESLint +- `npm run format` — Prettier (also runs on commit via husky/lint-staged) + +Environment: copy `.env.example` to `.env.local`. The defaults point at a test Sanity dataset and a local backend; see comments in `.env.example` for the Swedish/Danish alternatives. + +## Architecture notes + +- **Custom routing**: only files with the `.page.tsx` extension are picked up by the Next.js router. `pages/[[...slug]].page.tsx` parses the slug and renders the correct page type. Page types are regular modules under `pages/` (without `.page.tsx`) that export static paths and props via the `withStaticProps()` helper. +- **Two layouts**: public pages use `components/main/layout.tsx`; everything under `pages/dashboard` uses the profile layout `components/min-side/layout.tsx` (wrapped in the Auth0 provider). Each page sets its layout via a `.Layout` property on the export. +- **Content**: fetched from Sanity with GROQ queries (`_queries.ts` and colocated query files). The Sanity studio lives in `studio/` as a separate npm project. +- **Reusable components** live in `components/`, mostly under `components/main/blocks/` for CMS-driven content blocks. + +## Verifying UI changes (Claude Code cloud sessions) + +Cloud sessions have `@playwright/test` and Chromium available (installed by `scripts/claude-code-setup.sh`). When a change affects anything user-visible: + +1. Start the dev server (`npm run dev`) and wait until the affected page compiles. +2. Write a small throwaway Playwright script that opens the affected pages against `http://localhost:3000` and saves screenshots (desktop 1440×900 and mobile 390×844 viewports) to `screenshots/` at the repo root. Launch the browser with `launchSandboxPage()` from `scripts/claude-pw-sandbox.js` — it handles the sandbox's HTTPS proxy, its untrusted CA, QUIC, and hosts like `cdn.sanity.io` whose connections Chromium can't complete (without it, Sanity images render broken). Prefer viewport screenshots; if a full-page capture is genuinely needed, clip it to at most ~4000px tall — very tall images fail to upload and are unreadable in PRs anyway. +3. Commit the screenshots to the branch and embed them in the PR description using raw URLs: `https://raw.githubusercontent.com/stiftelsen-effekt/main-site//screenshots/.png`. +4. Keep screenshots small (PNG, only the affected pages) — reviewers may ask to drop the `screenshots/` commit before merge. + +If Playwright or Chromium is missing, the environment's network allowlist probably lacks the Playwright CDN domains (`cdn.playwright.dev`, `playwright.azureedge.net`, `playwright.download.prss.microsoft.com`); say so in the PR instead of skipping verification silently. + +## Conventions + +- Prettier is the source of truth for formatting; don't hand-format. +- A fresh `npm install` can leave incidental `package-lock.json` churn (peer-dependency resolution entries). Don't commit lockfile changes unless you intentionally changed dependencies. +- Styles are CSS/SCSS modules colocated with components. +- Run `npm run typecheck` and `npm test` before considering a change done. diff --git a/scripts/claude-code-setup.sh b/scripts/claude-code-setup.sh new file mode 100755 index 00000000..1dd1032f --- /dev/null +++ b/scripts/claude-code-setup.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Prepares the cloud sandbox for Claude Code on the web (claude.ai/code). +# Runs via the SessionStart hook in .claude/settings.json; no-ops locally. + +if [ "$CLAUDE_CODE_REMOTE" != "true" ]; then + exit 0 +fi + +cd "$CLAUDE_PROJECT_DIR" || exit 0 + +if [ ! -f .env.local ]; then + cp .env.example .env.local +fi + +# Skip the Cypress binary download: download.cypress.io is not on the sandbox +# network allowlist, and the failing/hanging download otherwise aborts npm +# install (leaving node_modules empty) or burns the whole hook timeout. +# Cypress e2e runs are not part of the cloud workflow anyway. +export CYPRESS_INSTALL_BINARY=0 + +if [ ! -d node_modules ]; then + npm install --no-audit --no-fund || true +fi + +# Playwright for visual verification of UI changes (not a project dependency; +# browser binaries are cached in the cloud environment). Requires the Playwright +# CDN domains on the environment's network allowlist — see CLAUDE.md. +if [ ! -d node_modules/@playwright/test ]; then + npm install --no-save --no-audit --no-fund @playwright/test || true +fi +npx playwright install --with-deps chromium || npx playwright install chromium || true + +exit 0 diff --git a/scripts/claude-pw-sandbox.js b/scripts/claude-pw-sandbox.js new file mode 100644 index 00000000..a45217f7 --- /dev/null +++ b/scripts/claude-pw-sandbox.js @@ -0,0 +1,85 @@ +/** + * Playwright bootstrap for Claude Code cloud sandbox sessions. + * + * The sandbox routes outbound HTTPS through a proxy that Chromium neither + * picks up from the environment nor trusts (custom CA), and it advertises + * HTTP/3 which doesn't tunnel through the proxy. On top of that, Chromium + * connections to some hosts (e.g. cdn.sanity.io) are reset even via the + * proxy, while curl succeeds — requests to those hosts are fulfilled + * out-of-band with curl so images render in screenshots. + * + * Harmless locally: without proxy env vars it launches a plain browser. + * + * Usage (from a throwaway verification script at the repo root): + * const { chromium } = require("@playwright/test"); + * const { launchSandboxPage } = require("./scripts/claude-pw-sandbox"); + * const { browser, page } = await launchSandboxPage(chromium, { + * viewport: { width: 1440, height: 900 }, + * }); + * await page.goto("http://localhost:3000"); + * await page.screenshot({ path: "screenshots/home-desktop.png" }); + * await browser.close(); + */ +const { execFileSync } = require("child_process"); + +// Hosts that reset direct/proxied Chromium connections in the sandbox. +// Keep this to asset CDNs — the fallback only handles GET requests. +const CURL_FALLBACK_HOSTS = [/^cdn\.sanity\.io$/]; + +const TYPE_BY_EXT = { + svg: "image/svg+xml", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + webp: "image/webp", + gif: "image/gif", + avif: "image/avif", + ico: "image/x-icon", + json: "application/json", +}; + +function contentTypeFor(url) { + const ext = new URL(url).pathname.split(".").pop().toLowerCase(); + return TYPE_BY_EXT[ext] || "application/octet-stream"; +} + +async function launchSandboxPage(chromium, { viewport } = {}) { + const proxy = + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy; + + const browser = await chromium.launch({ + proxy: proxy ? { server: proxy } : undefined, + args: ["--disable-quic"], + }); + const context = await browser.newContext({ + ignoreHTTPSErrors: true, + viewport, + }); + + await context.route( + (url) => CURL_FALLBACK_HOSTS.some((re) => re.test(url.hostname)), + async (route) => { + if (route.request().method() !== "GET") { + await route.continue().catch(() => {}); + return; + } + const url = route.request().url(); + try { + const body = execFileSync("curl", ["-sSfL", "--max-time", "20", url], { + maxBuffer: 50 * 1024 * 1024, + }); + await route.fulfill({ body, contentType: contentTypeFor(url) }); + } catch { + await route.continue().catch(() => {}); + } + }, + ); + + const page = await context.newPage(); + return { browser, context, page }; +} + +module.exports = { launchSandboxPage };