diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000..f898f59
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "ngu-api",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "engines": {
+ "node": ">=22"
+ },
+ "scripts": {
+ "dev": "node --watch src/index.js",
+ "start": "node src/index.js",
+ "migrate": "node src/migrate-cli.js"
+ },
+ "dependencies": {
+ "@hono/node-server": "^1.0.0",
+ "hono": "^4.0.0"
+ }
+}
diff --git a/server/src/db.js b/server/src/db.js
new file mode 100644
index 0000000..ad669b6
--- /dev/null
+++ b/server/src/db.js
@@ -0,0 +1,120 @@
+/* ═══════════════════════════════════════════════════════════════
+ DATABASE
+
+ One SQLite file, opened once at boot and held for the life of
+ the process. Node's own sqlite module is used when it's there
+ (Node 24+), better-sqlite3 otherwise. Their APIs overlap enough
+ that everything below works against either, as long as you:
+
+ • use positional ? parameters, never named ones
+ • pass 0/1 for booleans, never true/false
+ • use tx() rather than db.transaction()
+
+ Those three rules are the whole compatibility story.
+ ═══════════════════════════════════════════════════════════════ */
+
+import { readdirSync, readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const MIGRATIONS_DIR = join(HERE, "migrations");
+
+/* ── Driver selection ──────────────────────────────────────── */
+
+async function loadDriver() {
+ try {
+ const { DatabaseSync } = await import("node:sqlite");
+ return { Driver: DatabaseSync, name: "node:sqlite" };
+ } catch {
+ const { default: BetterSqlite3 } = await import("better-sqlite3");
+ return { Driver: BetterSqlite3, name: "better-sqlite3" };
+ }
+}
+
+/* ── Open ──────────────────────────────────────────────────────
+ WAL readers never block the writer, which matters the
+ moment a feedback POST lands mid page-load
+ NORMAL fsync on checkpoint rather than every commit; safe
+ under WAL, and much faster
+ busy wait rather than throw if something else holds the
+ write lock (a backup, usually)
+ ───────────────────────────────────────────────────────────── */
+
+export async function openDatabase(path) {
+ const { Driver, name } = await loadDriver();
+ const db = new Driver(path);
+
+ db.exec("PRAGMA journal_mode = WAL");
+ db.exec("PRAGMA foreign_keys = ON");
+ db.exec("PRAGMA synchronous = NORMAL");
+ db.exec("PRAGMA busy_timeout = 5000");
+
+ db.driverName = name;
+ return db;
+}
+
+/* ── Transactions ──────────────────────────────────────────────
+ node:sqlite has no db.transaction(), so do it by hand. Runs
+ fn() and commits, or rolls back and rethrows.
+ ───────────────────────────────────────────────────────────── */
+
+export function tx(db, fn) {
+ db.exec("BEGIN");
+ try {
+ const result = fn();
+ db.exec("COMMIT");
+ return result;
+ } catch (err) {
+ try {
+ db.exec("ROLLBACK");
+ } catch {
+ /* already rolled back */
+ }
+ throw err;
+ }
+}
+
+/* ── Migrations ────────────────────────────────────────────────
+ Files are NNN_name.sql. The leading number is the version.
+ PRAGMA user_version tracks how far we've got, so there's no
+ bookkeeping table and no ordering ambiguity.
+
+ Migrations only ever go forward. To undo something, write a
+ new migration.
+ ───────────────────────────────────────────────────────────── */
+
+export function migrate(db, { log = console.log } = {}) {
+ const current = db.prepare("PRAGMA user_version").get().user_version;
+
+ const files = readdirSync(MIGRATIONS_DIR)
+ .filter((f) => f.endsWith(".sql"))
+ .sort();
+
+ let applied = 0;
+
+ for (const file of files) {
+ const version = Number.parseInt(file.slice(0, 3), 10);
+
+ if (!Number.isInteger(version) || version < 1) {
+ throw new Error(`Migration "${file}" must start with a number, e.g. 001_`);
+ }
+ if (version <= current) continue;
+
+ const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8");
+
+ tx(db, () => {
+ db.exec(sql);
+ // Not parameterisable, but version is a validated integer.
+ db.exec(`PRAGMA user_version = ${version}`);
+ });
+
+ log(`migrated → ${file}`);
+ applied += 1;
+ }
+
+ const final = db.prepare("PRAGMA user_version").get().user_version;
+ if (applied === 0) log(`schema up to date (v${final})`);
+
+ return final;
+}
diff --git a/server/src/index.js b/server/src/index.js
new file mode 100644
index 0000000..e422284
--- /dev/null
+++ b/server/src/index.js
@@ -0,0 +1,84 @@
+/* ═══════════════════════════════════════════════════════════════
+ NGU API
+
+ Binds to localhost only. nginx is the only thing that talks to
+ it, which is what lets the routes trust X-Forwarded-For and skip
+ CORS entirely — in production the API and the site share an
+ origin, and in development Vite proxies /api so they share one
+ there too.
+ ═══════════════════════════════════════════════════════════════ */
+
+import { serve } from "@hono/node-server";
+import { Hono } from "hono";
+import { logger } from "hono/logger";
+
+import { openDatabase, migrate } from "./db.js";
+import { rateLimit } from "./rateLimit.js";
+import content from "./routes/content.js";
+import feedback from "./routes/feedback.js";
+
+const HOST = process.env.HOST ?? "127.0.0.1";
+const PORT = Number(process.env.PORT ?? 3001);
+const DB_PATH = process.env.DB_PATH ?? "./ngu.db";
+
+/* ── Boot ────────────────────────────────────────────────────── */
+
+const db = await openDatabase(DB_PATH);
+const version = migrate(db);
+
+console.log(`db ${DB_PATH} (${db.driverName}, schema v${version})`);
+
+/* ── App ─────────────────────────────────────────────────────── */
+
+const app = new Hono();
+
+app.use("*", logger());
+
+app.use("*", async (c, next) => {
+ c.set("db", db);
+ await next();
+});
+
+app.get("/api/health", (c) =>
+ c.json({ ok: true, schema: version, driver: db.driverName }),
+);
+
+app.route("/api", content);
+
+// Tighter limit on the write path than anything else gets.
+app.use("/api/feedback", rateLimit({ windowMs: 60_000, max: 5 }));
+app.route("/api/feedback", feedback);
+
+app.notFound((c) => c.json({ error: "Not found" }, 404));
+
+app.onError((err, c) => {
+ console.error(err);
+ // Never leak internals to the browser.
+ return c.json({ error: "Something went wrong." }, 500);
+});
+
+/* ── Serve ───────────────────────────────────────────────────── */
+
+const server = serve({ fetch: app.fetch, hostname: HOST, port: PORT }, (info) =>
+ console.log(`listening http://${info.address}:${info.port}`),
+);
+
+/* ── Shutdown ──────────────────────────────────────────────────
+ systemd sends SIGTERM on stop and restart. Closing the handle
+ flushes the WAL cleanly, which saves a recovery pass on the
+ next boot.
+ ───────────────────────────────────────────────────────────── */
+
+for (const signal of ["SIGTERM", "SIGINT"]) {
+ process.on(signal, () => {
+ console.log(`${signal} — shutting down`);
+ server.close(() => {
+ try {
+ db.close();
+ } catch {
+ /* nothing useful to do here */
+ }
+ process.exit(0);
+ });
+ });
+}
diff --git a/server/src/migrate-cli.js b/server/src/migrate-cli.js
new file mode 100644
index 0000000..82cdba3
--- /dev/null
+++ b/server/src/migrate-cli.js
@@ -0,0 +1,15 @@
+/* Run migrations without starting the server.
+ Useful in a deploy script, before restarting the unit.
+
+ DB_PATH=/var/lib/ngu/ngu.db pnpm migrate
+*/
+
+import { openDatabase, migrate } from "./db.js";
+
+const DB_PATH = process.env.DB_PATH ?? "./ngu.db";
+
+const db = await openDatabase(DB_PATH);
+const version = migrate(db);
+db.close();
+
+console.log(`${DB_PATH} is at schema v${version}`);
diff --git a/server/src/migrations/001_init.sql b/server/src/migrations/001_init.sql
new file mode 100644
index 0000000..31d3f51
--- /dev/null
+++ b/server/src/migrations/001_init.sql
@@ -0,0 +1,16 @@
+-- 001_init.sql
+--
+-- Placeholder so the runner has something to do on first boot and
+-- you can confirm the plumbing works end to end. The real tables
+-- (regions, region_states, state_grid, chapters, events, feedback)
+-- land in 002.
+--
+-- Once 002 exists you can leave this file alone. Never edit a
+-- migration that has already run anywhere; write the next one.
+
+CREATE TABLE IF NOT EXISTS meta (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+);
+
+INSERT OR IGNORE INTO meta (key, value) VALUES ('created_at', datetime('now'));
diff --git a/server/src/migrations/002_schema.sql b/server/src/migrations/002_schema.sql
new file mode 100644
index 0000000..e57a0fb
--- /dev/null
+++ b/server/src/migrations/002_schema.sql
@@ -0,0 +1,700 @@
+-- ═══════════════════════════════════════════════════════════════
+-- 002_schema.sql
+--
+-- Four things own a card and a page: organizations, events, people
+-- and teams. They share two tables — content_blocks for long-form
+-- description and links for buttons and socials — so a bio, an
+-- event description and a region's page all render through one
+-- component.
+--
+-- Tables are STRICT, so a column declared TEXT refuses an integer
+-- rather than quietly storing one. Worth it when the eventual
+-- writer is a web form.
+-- ═══════════════════════════════════════════════════════════════
+
+
+-- ═══════════════════════════════════════════════════════════════
+-- ORGANIZATIONS
+-- ═══════════════════════════════════════════════════════════════
+
+-- Regions, chapters, partners and NGU itself. They differ in a
+-- handful of fields, which live in side tables keyed by the same
+-- id, so events get one real foreign key to their host instead of
+-- a type/id pair SQLite can't check.
+--
+-- location_label is the display override for what the structured
+-- fields can't express: "Online", "Various venues", "Unity Village,
+-- MO". Read it first, fall back to composing from the parts.
+CREATE TABLE organizations (
+ id TEXT PRIMARY KEY, -- slug: 'northwest', 'lynnwood'
+ kind TEXT NOT NULL
+ CHECK (kind IN ('national', 'region', 'chapter', 'partner')),
+
+ name TEXT NOT NULL,
+ short_name TEXT,
+ tagline TEXT, -- one line, for the card
+ color TEXT,
+ logo TEXT, -- filename in public/org-logos/
+
+ venue TEXT,
+ address TEXT,
+ locality TEXT,
+ state_code TEXT, -- US only
+ country TEXT NOT NULL DEFAULT 'US',
+ location_label TEXT,
+ latitude REAL,
+ longitude REAL,
+ is_online INTEGER NOT NULL DEFAULT 0 CHECK (is_online IN (0, 1)),
+
+ is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+) STRICT;
+
+CREATE INDEX organizations_kind_idx ON organizations (kind, is_published, sort_order);
+CREATE INDEX organizations_state_idx ON organizations (state_code);
+
+
+CREATE TABLE regions (
+ id TEXT PRIMARY KEY REFERENCES organizations (id) ON DELETE CASCADE,
+ scope TEXT NOT NULL
+ CHECK (scope IN ('domestic', 'international', 'virtual')),
+ map_note TEXT
+) STRICT;
+
+
+-- Which map areas a region covers, and how much of each.
+--
+-- area_code is a plain string matched at render time against the
+-- keys in mapGrid.js. No foreign key, because the thing it points
+-- at isn't in this database. An unrecognised code paints nothing,
+-- which is how Africa and the UK exist as regions with no tile.
+--
+-- Replaces both GROUPS.states and SPLITS. A region owning a whole
+-- area has share 1.0 and no edge. A shared area gets one row per
+-- region, each naming its own slice, so there's no primary and
+-- secondary to keep straight.
+CREATE TABLE region_areas (
+ region_id TEXT NOT NULL REFERENCES regions (id) ON DELETE CASCADE,
+ area_code TEXT NOT NULL, -- 'WA', 'CA', 'CANADA'
+ share REAL NOT NULL DEFAULT 1.0 CHECK (share > 0 AND share <= 1),
+ edge TEXT CHECK (edge IN ('top', 'bottom')),
+ note TEXT, -- 'north', 'Salt Lake City area'
+ PRIMARY KEY (region_id, area_code)
+) STRICT;
+
+CREATE INDEX region_areas_area_idx ON region_areas (area_code);
+
+
+-- region_id is stored rather than derived from the state. Deriving
+-- it is what forced the per-chapter override in split states; the
+-- admin form should default it from the state and only ask when the
+-- state has more than one row in region_areas.
+--
+-- No `leads` column. Who runs a chapter is an affiliation, exactly
+-- as it is for every other organization.
+CREATE TABLE chapters (
+ id TEXT PRIMARY KEY REFERENCES organizations (id) ON DELETE CASCADE,
+ region_id TEXT REFERENCES regions (id) ON DELETE SET NULL,
+ meets TEXT, -- '2nd Sundays, 6:00pm'
+ started TEXT -- 'Since 2021'
+) STRICT;
+
+CREATE INDEX chapters_region_idx ON chapters (region_id);
+
+
+-- Partners get no side table. Everything they need is already on
+-- organizations, and a table holding nothing but a primary key is
+-- a place for confusion rather than data.
+
+
+-- ═══════════════════════════════════════════════════════════════
+-- EVENTS
+-- ═══════════════════════════════════════════════════════════════
+
+-- Sections are defined in Retreats.jsx, which owns their titles,
+-- accents, default colours and backgrounds. This table exists only
+-- so section_id can be a real foreign key: an unrecognised value
+-- would make an event vanish from the page with no error anywhere,
+-- which is a bug someone hunts for an hour.
+--
+-- `name` is an internal label for the eventual admin dropdown. The
+-- site never renders it.
+CREATE TABLE event_sections (
+ id TEXT PRIMARY KEY, -- 'national', 'regional', 'partner'
+ name TEXT NOT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 0
+) STRICT;
+
+
+-- Dates are stored three ways on purpose:
+--
+-- starts_on / ends_on ISO dates, nullable. What sorting and the
+-- upcoming/past split run on.
+-- date_label what the card shows. Real data includes
+-- "March/April 2026", which no date type
+-- holds and no formatter should reproduce.
+-- status an override. Null derives from ends_on,
+-- so there's no flag to remember to flip.
+CREATE TABLE events (
+ id TEXT PRIMARY KEY,
+ section_id TEXT NOT NULL REFERENCES event_sections (id),
+ host_org_id TEXT REFERENCES organizations (id) ON DELETE SET NULL,
+
+ title TEXT NOT NULL,
+ theme TEXT,
+ tagline TEXT,
+
+ starts_on TEXT, -- 'YYYY-MM-DD'
+ ends_on TEXT,
+ date_label TEXT,
+ status TEXT CHECK (status IN ('upcoming', 'past', 'cancelled')),
+
+ venue TEXT,
+ address TEXT,
+ locality TEXT,
+ state_code TEXT,
+ country TEXT NOT NULL DEFAULT 'US',
+ location_label TEXT,
+ latitude REAL,
+ longitude REAL,
+ is_online INTEGER NOT NULL DEFAULT 0 CHECK (is_online IN (0, 1)),
+
+ org_logo TEXT, -- null → host's logo
+ event_logo TEXT,
+ color TEXT, -- null → host's, then the page's
+ gradient TEXT,
+
+ is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+) STRICT;
+
+CREATE INDEX events_section_idx ON events (section_id, is_published, sort_order);
+CREATE INDEX events_host_idx ON events (host_org_id);
+CREATE INDEX events_date_idx ON events (starts_on);
+
+
+-- ═══════════════════════════════════════════════════════════════
+-- PEOPLE
+-- ═══════════════════════════════════════════════════════════════
+
+-- Public by design. Everything in this table can appear on a card,
+-- and is_published = 0 is the only thing between a row and the
+-- open web — hence the default of 0, unlike organizations.
+-- Anything that must never be served lives in person_private, so a
+-- careless SELECT * can't leak it.
+--
+-- Bio goes in content_blocks: 'card' slot for the two lines under
+-- a photo, 'body' slot for the full page with headings and lists.
+-- Socials and personal sites go in links.
+CREATE TABLE people (
+ id TEXT PRIMARY KEY, -- slug: 'jane-doe'
+ display_name TEXT NOT NULL, -- 'Jane Doe'
+ sort_name TEXT, -- 'Doe, Jane' — list ordering
+ pronouns TEXT, -- 'she/her'
+ tagline TEXT, -- fallback when no title applies
+ photo TEXT, -- filename in public/people/
+
+ public_email TEXT, -- safe to print on the site
+ public_phone TEXT,
+
+ locality TEXT,
+ state_code TEXT,
+ country TEXT NOT NULL DEFAULT 'US',
+ location_label TEXT,
+
+ is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published IN (0, 1)),
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+) STRICT;
+
+CREATE INDEX people_sort_idx ON people (is_published, sort_order, sort_name);
+
+
+-- Never joined into a public response. A separate table rather than
+-- extra columns so the boundary is structural instead of a rule
+-- someone has to remember.
+--
+-- birth_date rather than age: an age column is wrong within a year
+-- of being written. Derive it when needed, and consider first
+-- whether you need it at all — Planning Center already holds
+-- registration data, and the least sensitive record is the one you
+-- never made.
+CREATE TABLE person_private (
+ person_id TEXT PRIMARY KEY REFERENCES people (id) ON DELETE CASCADE,
+ birth_date TEXT, -- 'YYYY-MM-DD'
+ private_email TEXT,
+ private_phone TEXT,
+ address TEXT,
+ notes TEXT,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+) STRICT;
+
+
+-- ── Teams ──────────────────────────────────────────────────────
+--
+-- A team belongs to exactly one organization: NGU national has a
+-- Board and a Leadership Team, a region or chapter can have its
+-- own. An organization with a flat structure needs none — its
+-- affiliations simply carry no team_id.
+--
+-- UNIQUE (id, org_id) looks redundant against the primary key, and
+-- it is — except that it gives affiliations a composite foreign key
+-- to point at, which is what stops someone filing a person under a
+-- team belonging to a different organization.
+CREATE TABLE teams (
+ id TEXT PRIMARY KEY, -- slug: 'board', 'nw-leadership'
+ org_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ tagline TEXT,
+ color TEXT,
+ logo TEXT,
+ is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ UNIQUE (id, org_id)
+) STRICT;
+
+CREATE INDEX teams_org_idx ON teams (org_id, sort_order);
+
+
+-- ── Affiliations ───────────────────────────────────────────────
+--
+-- The leadership list for every organization on the site. A chapter
+-- lead, a regional coordinator and a national board member are the
+-- same kind of row; only org_id differs.
+--
+-- One person can hold several: chapter lead in Lynnwood and board
+-- member nationally are two rows.
+--
+-- ended_on null means current. Keeping past roles rather than
+-- deleting them is what makes an alumni list possible later.
+--
+-- is_owner marks authority within the organization, and is
+-- deliberately orthogonal to role — a board member and a chapter
+-- lead can both be owners, a long-serving volunteer isn't. It
+-- drives billing order on cards. It is NOT an edit permission:
+-- when the admin pages arrive, who may change an organization's
+-- content belongs in its own table, because the person who
+-- maintains a page is often not the person who runs the chapter.
+--
+-- Deleting a team that still has members fails rather than
+-- silently detaching them. That's the composite foreign key doing
+-- its job; clear or reassign the members first.
+CREATE TABLE affiliations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
+ org_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
+ team_id TEXT,
+
+ title TEXT, -- 'Board Chair', 'Chapter Lead'
+ role TEXT NOT NULL DEFAULT 'member'
+ CHECK (role IN ('lead', 'board', 'staff', 'volunteer', 'member')),
+ is_owner INTEGER NOT NULL DEFAULT 0 CHECK (is_owner IN (0, 1)),
+ started_on TEXT,
+ ended_on TEXT, -- null = current
+
+ is_public INTEGER NOT NULL DEFAULT 1 CHECK (is_public IN (0, 1)),
+ sort_order INTEGER NOT NULL DEFAULT 0,
+
+ FOREIGN KEY (team_id, org_id) REFERENCES teams (id, org_id)
+) STRICT;
+
+CREATE INDEX affiliations_person_idx ON affiliations (person_id);
+CREATE INDEX affiliations_org_idx
+ ON affiliations (org_id, is_public, is_owner DESC, sort_order);
+CREATE INDEX affiliations_team_idx ON affiliations (team_id, sort_order);
+
+
+-- ── People at events ───────────────────────────────────────────
+--
+-- Both the public billing (speakers, leaders) and the private
+-- record of who attended, distinguished by is_public rather than by
+-- table. It defaults to 0, so a new row is invisible until someone
+-- decides otherwise — the right way round for this.
+--
+-- If attendance ever becomes real check-in data synced from
+-- Planning Center, that belongs in its own table. This one is for
+-- the handful of names worth remembering per event.
+CREATE TABLE event_people (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ event_id TEXT NOT NULL REFERENCES events (id) ON DELETE CASCADE,
+ person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
+
+ role TEXT NOT NULL DEFAULT 'attendee'
+ CHECK (role IN ('speaker', 'leader', 'facilitator', 'host',
+ 'musician', 'volunteer', 'attendee')),
+ title TEXT, -- 'Keynote Speaker'
+ is_public INTEGER NOT NULL DEFAULT 0 CHECK (is_public IN (0, 1)),
+ sort_order INTEGER NOT NULL DEFAULT 0,
+
+ UNIQUE (event_id, person_id, role)
+) STRICT;
+
+CREATE INDEX event_people_event_idx ON event_people (event_id, is_public, sort_order);
+CREATE INDEX event_people_person_idx ON event_people (person_id);
+
+
+-- ── Awards ─────────────────────────────────────────────────────
+--
+-- An award exists independently of who won it, which is why it's
+-- two tables and not a text column on people.
+CREATE TABLE awards (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT,
+ logo TEXT,
+ sort_order INTEGER NOT NULL DEFAULT 0
+) STRICT;
+
+CREATE TABLE person_awards (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
+ award_id TEXT NOT NULL REFERENCES awards (id) ON DELETE CASCADE,
+ event_id TEXT REFERENCES events (id) ON DELETE SET NULL, -- where presented
+ awarded_on TEXT,
+ citation TEXT,
+ is_public INTEGER NOT NULL DEFAULT 1 CHECK (is_public IN (0, 1)),
+ UNIQUE (person_id, award_id, awarded_on)
+) STRICT;
+
+CREATE INDEX person_awards_person_idx ON person_awards (person_id);
+
+
+-- ── Curated lists ──────────────────────────────────────────────
+--
+-- Teams and affiliations are structural: they describe how an
+-- organization is actually run. Lists are editorial: "2026 Retreat
+-- Speakers", "Founders", anything a page wants to show that isn't
+-- an org chart. If it turns out affiliations cover everything, this
+-- pair is easy to drop — nothing depends on it.
+CREATE TABLE people_lists (
+ id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ blurb TEXT,
+ is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
+ sort_order INTEGER NOT NULL DEFAULT 0
+) STRICT;
+
+CREATE TABLE people_list_members (
+ list_id TEXT NOT NULL REFERENCES people_lists (id) ON DELETE CASCADE,
+ person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
+ note TEXT, -- overrides tagline in this list
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (list_id, person_id)
+) STRICT;
+
+
+-- ═══════════════════════════════════════════════════════════════
+-- CONTENT BLOCKS
+-- ═══════════════════════════════════════════════════════════════
+
+-- Long-form description as ordered rows, shared by all four card
+-- types.
+--
+-- slot 'card' is the short version on the tile — an event's
+-- desc_a and desc_b become two paragraph blocks here.
+-- 'body' is the full page. Same renderer, different query.
+--
+-- Blocks with children (list, links) use content_block_items.
+--
+-- owner_kind + owner_id is polymorphic, which SQLite can't express
+-- as a foreign key. The triggers below do the work a FK would.
+CREATE TABLE content_blocks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ owner_kind TEXT NOT NULL
+ CHECK (owner_kind IN ('organization', 'event', 'person', 'team')),
+ owner_id TEXT NOT NULL,
+ slot TEXT NOT NULL DEFAULT 'body' CHECK (slot IN ('card', 'body')),
+ sort_order INTEGER NOT NULL DEFAULT 0,
+
+ type TEXT NOT NULL
+ CHECK (type IN ('heading', 'subheading', 'paragraph',
+ 'list', 'links', 'quote', 'image', 'divider')),
+ text TEXT,
+ media TEXT,
+ href TEXT
+) STRICT;
+
+CREATE INDEX content_blocks_owner_idx
+ ON content_blocks (owner_kind, owner_id, slot, sort_order);
+
+
+CREATE TABLE content_block_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ block_id INTEGER NOT NULL REFERENCES content_blocks (id) ON DELETE CASCADE,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ text TEXT NOT NULL,
+ detail TEXT,
+ url TEXT -- null → plain list item
+) STRICT;
+
+CREATE INDEX content_block_items_block_idx
+ ON content_block_items (block_id, sort_order);
+
+
+-- ═══════════════════════════════════════════════════════════════
+-- LINKS
+-- ═══════════════════════════════════════════════════════════════
+
+-- Entity-level links: a Register button, an Instagram handle, a
+-- personal site. Distinct from links inside a content block, which
+-- are part of a sentence rather than a control.
+CREATE TABLE links (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ owner_kind TEXT NOT NULL
+ CHECK (owner_kind IN ('organization', 'event', 'person', 'team')),
+ owner_id TEXT NOT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+
+ kind TEXT NOT NULL DEFAULT 'action'
+ CHECK (kind IN ('action', 'social', 'website', 'email')),
+ platform TEXT, -- 'instagram', 'discord'
+ label TEXT NOT NULL,
+ url TEXT NOT NULL,
+ is_primary INTEGER NOT NULL DEFAULT 0 CHECK (is_primary IN (0, 1))
+) STRICT;
+
+CREATE INDEX links_owner_idx ON links (owner_kind, owner_id, kind, sort_order);
+
+
+-- ═══════════════════════════════════════════════════════════════
+-- FEEDBACK
+-- ═══════════════════════════════════════════════════════════════
+
+-- The only table the public can write to.
+--
+-- page_path and section_id are free text rather than foreign keys
+-- on purpose: they record where someone was when they wrote, and
+-- that shouldn't change meaning when a route is later renamed.
+CREATE TABLE feedback (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+
+ feedback_type TEXT NOT NULL DEFAULT 'general',
+ message TEXT NOT NULL,
+ name TEXT,
+ email TEXT,
+
+ page_path TEXT,
+ section_id TEXT,
+
+ status TEXT NOT NULL DEFAULT 'new'
+ CHECK (status IN ('new', 'read', 'actioned', 'archived', 'spam')),
+ admin_note TEXT,
+ user_agent TEXT,
+ ip_hash TEXT -- hashed, never the address
+) STRICT;
+
+CREATE INDEX feedback_triage_idx ON feedback (status, created_at DESC);
+
+
+-- ═══════════════════════════════════════════════════════════════
+-- INTEGRITY FOR THE POLYMORPHIC TABLES
+-- ═══════════════════════════════════════════════════════════════
+
+CREATE TRIGGER content_blocks_owner_exists
+BEFORE INSERT ON content_blocks
+BEGIN
+ SELECT CASE
+ WHEN new.owner_kind = 'event'
+ AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.owner_id)
+ THEN RAISE(ABORT, 'content_blocks: no such event')
+ WHEN new.owner_kind = 'organization'
+ AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.owner_id)
+ THEN RAISE(ABORT, 'content_blocks: no such organization')
+ WHEN new.owner_kind = 'person'
+ AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.owner_id)
+ THEN RAISE(ABORT, 'content_blocks: no such person')
+ WHEN new.owner_kind = 'team'
+ AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.owner_id)
+ THEN RAISE(ABORT, 'content_blocks: no such team')
+ END;
+END;
+
+CREATE TRIGGER links_owner_exists
+BEFORE INSERT ON links
+BEGIN
+ SELECT CASE
+ WHEN new.owner_kind = 'event'
+ AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.owner_id)
+ THEN RAISE(ABORT, 'links: no such event')
+ WHEN new.owner_kind = 'organization'
+ AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.owner_id)
+ THEN RAISE(ABORT, 'links: no such organization')
+ WHEN new.owner_kind = 'person'
+ AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.owner_id)
+ THEN RAISE(ABORT, 'links: no such person')
+ WHEN new.owner_kind = 'team'
+ AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.owner_id)
+ THEN RAISE(ABORT, 'links: no such team')
+ END;
+END;
+
+CREATE TRIGGER organizations_cleanup
+AFTER DELETE ON organizations
+BEGIN
+ DELETE FROM content_blocks WHERE owner_kind = 'organization' AND owner_id = old.id;
+ DELETE FROM links WHERE owner_kind = 'organization' AND owner_id = old.id;
+END;
+
+CREATE TRIGGER events_cleanup
+AFTER DELETE ON events
+BEGIN
+ DELETE FROM content_blocks WHERE owner_kind = 'event' AND owner_id = old.id;
+ DELETE FROM links WHERE owner_kind = 'event' AND owner_id = old.id;
+END;
+
+CREATE TRIGGER people_cleanup
+AFTER DELETE ON people
+BEGIN
+ DELETE FROM content_blocks WHERE owner_kind = 'person' AND owner_id = old.id;
+ DELETE FROM links WHERE owner_kind = 'person' AND owner_id = old.id;
+END;
+
+CREATE TRIGGER teams_cleanup
+AFTER DELETE ON teams
+BEGIN
+ DELETE FROM content_blocks WHERE owner_kind = 'team' AND owner_id = old.id;
+ DELETE FROM links WHERE owner_kind = 'team' AND owner_id = old.id;
+END;
+
+
+-- ── updated_at ─────────────────────────────────────────────────
+-- The WHEN guard stops the trigger recursing, and lets an explicit
+-- updated_at through untouched, which matters when importing.
+
+CREATE TRIGGER organizations_touch
+AFTER UPDATE ON organizations
+FOR EACH ROW WHEN new.updated_at = old.updated_at
+BEGIN
+ UPDATE organizations SET updated_at = datetime('now') WHERE id = new.id;
+END;
+
+CREATE TRIGGER events_touch
+AFTER UPDATE ON events
+FOR EACH ROW WHEN new.updated_at = old.updated_at
+BEGIN
+ UPDATE events SET updated_at = datetime('now') WHERE id = new.id;
+END;
+
+CREATE TRIGGER people_touch
+AFTER UPDATE ON people
+FOR EACH ROW WHEN new.updated_at = old.updated_at
+BEGIN
+ UPDATE people SET updated_at = datetime('now') WHERE id = new.id;
+END;
+
+
+-- ═══════════════════════════════════════════════════════════════
+-- VIEWS
+-- ═══════════════════════════════════════════════════════════════
+
+-- Events with the host resolved and the logo/colour fallbacks
+-- applied, so no handler has to remember the rules. An event with
+-- no colour of its own inherits its host organization's; if that's
+-- null too, the page applies the section default, which is where
+-- that default lives.
+CREATE VIEW v_events AS
+SELECT
+ e.*,
+ o.name AS host_name,
+ o.kind AS host_kind,
+ o.logo AS host_logo,
+ COALESCE(e.org_logo, o.logo) AS effective_org_logo,
+ COALESCE(e.color, o.color) AS effective_color,
+ COALESCE(
+ e.status,
+ CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now')
+ THEN 'past' ELSE 'upcoming' END
+ ) AS effective_status
+FROM events e
+LEFT JOIN organizations o ON o.id = e.host_org_id;
+
+
+-- Chapters flattened for the list. The API adds a map area to each
+-- row using mapGrid.js; that can't happen here because the grid
+-- isn't in this database. Leadership comes from v_org_leadership,
+-- filtered on the chapter's id.
+CREATE VIEW v_chapters AS
+SELECT
+ o.id, o.name, o.short_name, o.tagline, o.color, o.logo,
+ o.venue, o.locality, o.state_code, o.country, o.location_label,
+ o.is_online, o.sort_order,
+ c.region_id, c.meets, c.started,
+ r.name AS region_name,
+ r.color AS region_color
+FROM organizations o
+JOIN chapters c ON c.id = o.id
+LEFT JOIN organizations r ON r.id = c.region_id
+WHERE o.is_published = 1;
+
+
+-- Current, public leadership of any organization. Owners first,
+-- then explicit order, then name. A chapter page, a region page and
+-- the national Leadership page all read this; the only difference
+-- is the org_id they filter on, and whether they group by team.
+CREATE VIEW v_org_leadership AS
+SELECT
+ a.org_id,
+ a.team_id,
+ t.name AS team_name,
+ t.sort_order AS team_sort_order,
+ a.person_id,
+ a.title,
+ a.role,
+ a.is_owner,
+ a.sort_order,
+ p.display_name,
+ p.sort_name,
+ p.pronouns,
+ p.tagline,
+ p.photo,
+ p.public_email
+FROM affiliations a
+JOIN people p ON p.id = a.person_id AND p.is_published = 1
+LEFT JOIN teams t ON t.id = a.team_id
+WHERE a.is_public = 1
+ AND a.ended_on IS NULL
+ORDER BY a.org_id, a.is_owner DESC, a.sort_order, p.sort_name;
+
+
+-- Every public affiliation a person holds, current or past. Feeds
+-- the "affiliated organizations" block on a person's page, where
+-- past roles are worth showing and v_org_leadership's current-only
+-- filter would hide them.
+CREATE VIEW v_person_affiliations AS
+SELECT
+ a.person_id,
+ a.org_id,
+ a.team_id,
+ a.title,
+ a.role,
+ a.is_owner,
+ a.started_on,
+ a.ended_on,
+ (a.ended_on IS NULL) AS is_current,
+ a.sort_order,
+ o.name AS org_name,
+ o.kind AS org_kind,
+ o.logo AS org_logo,
+ o.color AS org_color,
+ t.name AS team_name
+FROM affiliations a
+JOIN organizations o ON o.id = a.org_id
+LEFT JOIN teams t ON t.id = a.team_id
+WHERE a.is_public = 1;
+
+
+-- Public event billing only. Attendance rows stay out, because
+-- is_public defaults to 0.
+CREATE VIEW v_event_people AS
+SELECT
+ ep.event_id, ep.person_id, ep.role, ep.title, ep.sort_order,
+ p.display_name, p.pronouns, p.tagline, p.photo
+FROM event_people ep
+JOIN people p ON p.id = ep.person_id AND p.is_published = 1
+WHERE ep.is_public = 1;
diff --git a/server/src/rateLimit.js b/server/src/rateLimit.js
new file mode 100644
index 0000000..5b35773
--- /dev/null
+++ b/server/src/rateLimit.js
@@ -0,0 +1,49 @@
+/* ═══════════════════════════════════════════════════════════════
+ RATE LIMIT
+
+ A fixed window counter held in process memory. It resets when
+ the service restarts and it doesn't survive a second instance,
+ both of which are fine for one systemd unit on one box.
+
+ The client IP comes from X-Forwarded-For, which nginx sets. That
+ header is only trustworthy because nothing but nginx can reach
+ this port — it binds to 127.0.0.1. Don't expose the port.
+ ═══════════════════════════════════════════════════════════════ */
+
+export function rateLimit({ windowMs = 60_000, max = 10 } = {}) {
+ const hits = new Map(); // ip → { count, resetAt }
+
+ // Drop expired entries occasionally so the map can't grow forever.
+ setInterval(() => {
+ const now = Date.now();
+ for (const [ip, entry] of hits) {
+ if (entry.resetAt <= now) hits.delete(ip);
+ }
+ }, windowMs).unref();
+
+ return async (c, next) => {
+ const ip =
+ c.req.header("x-forwarded-for")?.split(",")[0].trim() ?? "unknown";
+
+ const now = Date.now();
+ let entry = hits.get(ip);
+
+ if (!entry || entry.resetAt <= now) {
+ entry = { count: 0, resetAt: now + windowMs };
+ hits.set(ip, entry);
+ }
+
+ entry.count += 1;
+
+ if (entry.count > max) {
+ const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
+ return c.json(
+ { error: "Too many requests. Try again shortly." },
+ 429,
+ { "Retry-After": String(retryAfter) },
+ );
+ }
+
+ await next();
+ };
+}
diff --git a/server/src/routes/content.js b/server/src/routes/content.js
new file mode 100644
index 0000000..d77c2c6
--- /dev/null
+++ b/server/src/routes/content.js
@@ -0,0 +1,397 @@
+/* ═══════════════════════════════════════════════════════════════
+ CONTENT ROUTES — read-only, mounted under /api
+
+ GET /events list + the section ids
+ GET /events/:id one event, full body, people
+ GET /organizations list, ?kind=region|chapter|…
+ GET /organizations/:id one organization's page
+
+ Organizations are one table, so they're one endpoint. A region
+ and a chapter differ by a handful of fields, which arrive under
+ `details` rather than as separate routes — that's what lets a
+ list component be written once and pointed at any kind.
+
+ Responses carry their fallbacks already resolved: an event's
+ `color` is its own or its host's, and `status` is derived from
+ the dates when it isn't set. Components read one field and don't
+ reimplement the rules.
+ ═══════════════════════════════════════════════════════════════ */
+
+import { Hono } from "hono";
+
+import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js";
+
+const content = new Hono();
+
+// Content changes weekly at most, and a stale minute costs nobody
+// anything. stale-while-revalidate keeps the page instant while the
+// refresh happens behind it.
+const CACHE = "public, max-age=60, stale-while-revalidate=300";
+
+const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE });
+
+const ORG_KINDS = ["national", "region", "chapter", "partner"];
+
+const marks = (n) => Array(n).fill("?").join(",");
+
+/* ── Shapers ───────────────────────────────────────────────── */
+
+function shapeEvent(row, links, cardBlocks) {
+ const { actions, instagram } = splitLinks(links);
+
+ return {
+ id: row.id,
+ section_id: row.section_id,
+
+ title: row.title,
+ theme: row.theme,
+ tagline: row.tagline,
+
+ starts_on: row.starts_on,
+ ends_on: row.ends_on,
+ date_label: row.date_label,
+ status: row.effective_status,
+
+ location_label: row.location_label,
+ locality: row.locality,
+ state_code: row.state_code,
+ country: row.country,
+ is_online: asBool(row.is_online),
+
+ org_logo: row.effective_org_logo,
+ event_logo: row.event_logo,
+ color: row.effective_color,
+ gradient: row.gradient,
+
+ host: row.host_org_id
+ ? { id: row.host_org_id, name: row.host_name, kind: row.host_kind }
+ : null,
+
+ description: paragraphs(cardBlocks),
+ links: actions,
+ instagram,
+ };
+}
+
+/* The common card surface every organization has, whatever kind it
+ is. Kind-specific fields go in `details`, attached by the caller. */
+function shapeOrganization(row, links, cardBlocks, bodyBlocks) {
+ const { actions, socials, website, email, instagram } = splitLinks(links);
+
+ return {
+ id: row.id,
+ kind: row.kind,
+ name: row.name,
+ short_name: row.short_name,
+ tagline: row.tagline,
+ color: row.color,
+ logo: row.logo,
+
+ venue: row.venue,
+ address: row.address,
+ locality: row.locality,
+ state_code: row.state_code,
+ country: row.country,
+ location_label: row.location_label,
+ is_online: asBool(row.is_online),
+ sort_order: row.sort_order,
+
+ description: paragraphs(cardBlocks),
+ blocks: bodyBlocks,
+ links: actions,
+ socials,
+ website,
+ email,
+ instagram,
+
+ details: {},
+ leadership: [],
+ };
+}
+
+function shapeLeader(row) {
+ return {
+ person_id: row.person_id,
+ display_name: row.display_name,
+ pronouns: row.pronouns,
+ title: row.title ?? row.tagline,
+ role: row.role,
+ is_owner: asBool(row.is_owner),
+ photo: row.photo,
+ public_email: row.public_email,
+ team_id: row.team_id,
+ team_name: row.team_name,
+ };
+}
+
+/* ── Kind-specific details, batched ────────────────────────────
+ Each of these runs a fixed number of queries for the whole list
+ rather than one per organization.
+ ───────────────────────────────────────────────────────────── */
+
+function attachRegionDetails(db, orgs) {
+ const ids = orgs.filter((o) => o.kind === "region").map((o) => o.id);
+ if (ids.length === 0) return;
+
+ const rows = db
+ .prepare(`SELECT id, scope, map_note FROM regions WHERE id IN (${marks(ids.length)})`)
+ .all(...ids);
+
+ const areas = db
+ .prepare(
+ `SELECT region_id, area_code, share, edge, note
+ FROM region_areas
+ WHERE region_id IN (${marks(ids.length)})
+ ORDER BY area_code, share DESC`,
+ )
+ .all(...ids);
+
+ // A region's chapters, enough of each for a list entry.
+ const children = db
+ .prepare(
+ `SELECT c.region_id, o.id, o.name, o.location_label, o.logo
+ FROM chapters c
+ JOIN organizations o ON o.id = c.id AND o.is_published = 1
+ WHERE c.region_id IN (${marks(ids.length)})
+ ORDER BY o.sort_order, o.name`,
+ )
+ .all(...ids);
+
+ const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
+ const areasBy = new Map();
+ const childrenBy = new Map();
+
+ for (const row of areas) {
+ const list = areasBy.get(row.region_id);
+ const entry = {
+ area_code: row.area_code,
+ share: row.share,
+ edge: row.edge,
+ note: row.note,
+ };
+ if (list) list.push(entry);
+ else areasBy.set(row.region_id, [entry]);
+ }
+
+ for (const row of children) {
+ const list = childrenBy.get(row.region_id);
+ const entry = {
+ id: row.id,
+ name: row.name,
+ location_label: row.location_label,
+ logo: row.logo,
+ };
+ if (list) list.push(entry);
+ else childrenBy.set(row.region_id, [entry]);
+ }
+
+ for (const org of orgs) {
+ if (org.kind !== "region") continue;
+ org.details = {
+ scope: byId[org.id]?.scope ?? null,
+ map_note: byId[org.id]?.map_note ?? null,
+ areas: areasBy.get(org.id) ?? [],
+ chapters: childrenBy.get(org.id) ?? [],
+ };
+ }
+}
+
+function attachChapterDetails(db, orgs) {
+ const ids = orgs.filter((o) => o.kind === "chapter").map((o) => o.id);
+ if (ids.length === 0) return;
+
+ const rows = db
+ .prepare(
+ `SELECT c.id, c.region_id, c.meets, c.started,
+ r.name AS region_name, r.color AS region_color
+ FROM chapters c
+ LEFT JOIN organizations r ON r.id = c.region_id
+ WHERE c.id IN (${marks(ids.length)})`,
+ )
+ .all(...ids);
+
+ const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
+
+ for (const org of orgs) {
+ if (org.kind !== "chapter") continue;
+ const row = byId[org.id] ?? {};
+ org.details = {
+ region_id: row.region_id ?? null,
+ region_name: row.region_name ?? null,
+ region_color: row.region_color ?? null,
+ meets: row.meets ?? null,
+ started: row.started ?? null,
+ };
+ }
+}
+
+function attachLeadership(db, orgs) {
+ const ids = orgs.map((o) => o.id);
+ if (ids.length === 0) return;
+
+ const rows = db
+ .prepare(`SELECT * FROM v_org_leadership WHERE org_id IN (${marks(ids.length)})`)
+ .all(...ids);
+
+ const byOrg = new Map();
+ for (const row of rows) {
+ const list = byOrg.get(row.org_id);
+ if (list) list.push(shapeLeader(row));
+ else byOrg.set(row.org_id, [shapeLeader(row)]);
+ }
+
+ for (const org of orgs) org.leadership = byOrg.get(org.id) ?? [];
+}
+
+/* ── Events ────────────────────────────────────────────────────
+ Flat, with the section ids alongside. Retreats.tsx owns the
+ section titles and colours and filters this list by section_id.
+ ───────────────────────────────────────────────────────────── */
+
+content.get("/events", (c) => {
+ const db = c.get("db");
+
+ const sections = db
+ .prepare(`SELECT id, name, sort_order FROM event_sections ORDER BY sort_order`)
+ .all();
+
+ const rows = db
+ .prepare(
+ `SELECT * FROM v_events
+ WHERE is_published = 1
+ ORDER BY section_id, sort_order`,
+ )
+ .all();
+
+ const ids = rows.map((row) => row.id);
+ const links = loadLinks(db, "event", ids);
+ const cards = loadBlocks(db, "event", ids, "card");
+
+ const events = rows.map((row) =>
+ shapeEvent(row, links.get(row.id) ?? [], cards.get(row.id) ?? []),
+ );
+
+ return json(c, { sections, events });
+});
+
+
+/* One event, for its own page. */
+content.get("/events/:id", (c) => {
+ const db = c.get("db");
+ const id = c.req.param("id");
+
+ const row = db
+ .prepare(`SELECT * FROM v_events WHERE id = ? AND is_published = 1`)
+ .get(id);
+
+ if (!row) return c.json({ error: "No such event" }, 404);
+
+ const links = loadLinks(db, "event", [id]).get(id) ?? [];
+ const cards = loadBlocks(db, "event", [id], "card").get(id) ?? [];
+ const body = loadBlocks(db, "event", [id], "body").get(id) ?? [];
+
+ const people = db
+ .prepare(
+ `SELECT person_id, display_name, pronouns, tagline, photo, role, title
+ FROM v_event_people WHERE event_id = ? ORDER BY sort_order`,
+ )
+ .all(id);
+
+ return json(c, {
+ event: { ...shapeEvent(row, links, cards), blocks: body, people },
+ });
+});
+
+
+/* ── Organizations ─────────────────────────────────────────────
+ GET /organizations every published org
+ GET /organizations?kind=region one kind
+ GET /organizations?kind=region,chapter
+
+ Whatever the kind, the common card fields are in the same
+ places, so a list component reads `name`, `color`, `logo` and
+ `description` without knowing what it's holding, and reaches
+ into `details` only when it wants kind-specific extras.
+ ───────────────────────────────────────────────────────────── */
+
+content.get("/organizations", (c) => {
+ const db = c.get("db");
+
+ const kindParam = c.req.query("kind");
+ const kinds = kindParam
+ ? kindParam.split(",").map((k) => k.trim()).filter((k) => ORG_KINDS.includes(k))
+ : [];
+
+ if (kindParam && kinds.length === 0) {
+ return c.json({ error: `kind must be one of ${ORG_KINDS.join(", ")}` }, 400);
+ }
+
+ const filter = kinds.length > 0 ? `AND kind IN (${marks(kinds.length)})` : "";
+
+ const rows = db
+ .prepare(
+ `SELECT * FROM organizations
+ WHERE is_published = 1 ${filter}
+ ORDER BY kind, sort_order, name`,
+ )
+ .all(...kinds);
+
+ const ids = rows.map((row) => row.id);
+ const links = loadLinks(db, "organization", ids);
+ const cards = loadBlocks(db, "organization", ids, "card");
+ const bodies = loadBlocks(db, "organization", ids, "body");
+
+ const organizations = rows.map((row) =>
+ shapeOrganization(
+ row,
+ links.get(row.id) ?? [],
+ cards.get(row.id) ?? [],
+ bodies.get(row.id) ?? [],
+ ),
+ );
+
+ attachRegionDetails(db, organizations);
+ attachChapterDetails(db, organizations);
+ attachLeadership(db, organizations);
+
+ return json(c, { organizations });
+});
+
+
+/* One organization's page: region, chapter, partner or NGU. */
+content.get("/organizations/:id", (c) => {
+ const db = c.get("db");
+ const id = c.req.param("id");
+
+ const row = db
+ .prepare(`SELECT * FROM organizations WHERE id = ? AND is_published = 1`)
+ .get(id);
+
+ if (!row) return c.json({ error: "No such organization" }, 404);
+
+ const links = loadLinks(db, "organization", [id]).get(id) ?? [];
+ const cards = loadBlocks(db, "organization", [id], "card").get(id) ?? [];
+ const body = loadBlocks(db, "organization", [id], "body").get(id) ?? [];
+
+ const organization = shapeOrganization(row, links, cards, body);
+ const one = [organization];
+
+ attachRegionDetails(db, one);
+ attachChapterDetails(db, one);
+ attachLeadership(db, one);
+
+ // Everything this organization is hosting or has hosted.
+ organization.events = db
+ .prepare(
+ `SELECT id, title, date_label, effective_status AS status,
+ location_label, event_logo, effective_color AS color
+ FROM v_events
+ WHERE host_org_id = ? AND is_published = 1
+ ORDER BY sort_order`,
+ )
+ .all(id);
+
+ return json(c, { organization });
+});
+
+export default content;
diff --git a/server/src/routes/feedback.js b/server/src/routes/feedback.js
new file mode 100644
index 0000000..6a09822
--- /dev/null
+++ b/server/src/routes/feedback.js
@@ -0,0 +1,130 @@
+/* ═══════════════════════════════════════════════════════════════
+ FEEDBACK ROUTE — the only public write on the site
+
+ Everything hostile that will ever reach this service arrives
+ here, so the defences live here rather than being sprinkled
+ around:
+
+ rate limit applied where this router is mounted (5/min)
+ honeypot a field real users never see or fill
+ length caps rejected before anything touches the database
+ no HTML stored verbatim, escaped at render time
+
+ The write is a single INSERT with positional parameters, so it
+ works on node:sqlite and better-sqlite3 alike. No transaction:
+ one statement is already atomic.
+ ═══════════════════════════════════════════════════════════════ */
+
+import { Hono } from "hono";
+import { createHash, randomBytes } from "node:crypto";
+
+const feedback = new Hono();
+
+const LIMITS = {
+ name: 120,
+ email: 254,
+ message: 5000,
+ pagePath: 200,
+ sectionId: 80,
+};
+
+const MIN_MESSAGE = 10;
+
+// Must match FEEDBACK_TYPES in src/pages/sections/FeedbackForm.jsx.
+// Anything else falls back to 'general' rather than being rejected —
+// a renamed option shouldn't lose someone's submission.
+const TYPES = ["broken", "confusing", "outdated", "request", "praise", "other"];
+
+/* ── IP hashing ────────────────────────────────────────────────
+ Stored so repeat abuse from one source is visible during
+ triage, hashed so the table never holds an address. Without
+ IP_SALT the salt is regenerated each boot, which makes hashes
+ incomparable across restarts — fine for dev, set it in
+ /etc/ngu/api.env for production.
+ ───────────────────────────────────────────────────────────── */
+
+const IP_SALT = process.env.IP_SALT ?? randomBytes(16).toString("hex");
+
+if (!process.env.IP_SALT) {
+ console.warn("IP_SALT unset — feedback ip_hash values reset on restart");
+}
+
+function hashIp(ip) {
+ if (!ip) return null;
+ return createHash("sha256").update(`${IP_SALT}:${ip}`).digest("hex").slice(0, 32);
+}
+
+/* ── Input cleaning ──────────────────────────────────────────── */
+
+function clean(value, max) {
+ if (typeof value !== "string") return "";
+ return value.trim().slice(0, max);
+}
+
+// Deliberately permissive. Rejecting odd-but-valid addresses loses
+// real submissions, and the field is optional anyway.
+function looksLikeEmail(value) {
+ return value === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
+}
+
+/* ── POST /api/feedback ──────────────────────────────────────── */
+
+feedback.post("/", async (c) => {
+ let body;
+ try {
+ body = await c.req.json();
+ } catch {
+ return c.json({ error: "Expected a JSON body." }, 400);
+ }
+
+ // Honeypot. A bot fills every input it finds; a person can't see
+ // this one. Return success so the bot doesn't learn anything.
+ if (clean(body.website, 50) !== "") {
+ return c.body(null, 204);
+ }
+
+ const name = clean(body.name, LIMITS.name);
+ const email = clean(body.email, LIMITS.email);
+ const message = clean(body.message, LIMITS.message);
+ const pagePath = clean(body.pagePath, LIMITS.pagePath);
+ // The picker holds nav hashes ('#chapters'); the column holds ids.
+ const sectionId = clean(body.sectionId, LIMITS.sectionId).replace(/^#/, "");
+ const feedbackType = TYPES.includes(body.feedbackType)
+ ? body.feedbackType
+ : "general";
+
+ const errors = {};
+ if (message.length < MIN_MESSAGE) errors.message = "Please write a little more.";
+ if (!looksLikeEmail(email)) errors.email = "That email doesn't look right.";
+
+ if (Object.keys(errors).length > 0) {
+ return c.json({ error: "Validation failed", fields: errors }, 422);
+ }
+
+ const db = c.get("db");
+
+ const result = db
+ .prepare(
+ `INSERT INTO feedback
+ (feedback_type, message, name, email, page_path, section_id,
+ user_agent, ip_hash)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ )
+ .run(
+ feedbackType,
+ message,
+ name || null,
+ email || null,
+ pagePath || null,
+ sectionId || null,
+ clean(c.req.header("user-agent"), 500) || null,
+ hashIp(c.req.header("x-forwarded-for")?.split(",")[0].trim()),
+ );
+
+ // created_at and status come from column defaults.
+ console.log(`feedback #${result.lastInsertRowid} (${feedbackType})`);
+
+ return c.body(null, 204);
+});
+
+export default feedback;
diff --git a/server/src/seed.js b/server/src/seed.js
new file mode 100644
index 0000000..7aa11a1
--- /dev/null
+++ b/server/src/seed.js
@@ -0,0 +1,398 @@
+/* ═══════════════════════════════════════════════════════════════
+ SEED
+
+ Reads the two static data modules and fills the database from
+ them. Run once to make the move, and re-runnable after you tweak
+ the source files.
+
+ cd /root/NGU-Web.v1.3-sqlite/server
+ DB_PATH=./dev.db node src/seed.js
+
+ Run it from the repo, not from /srv/ngu-api — the deployed copy
+ has no src/data to read.
+
+ ⚠ It clears every content table first, so anything typed
+ straight into the database is lost. Feedback is never touched.
+
+ Section presentation (title, accent, background, defaultView) is
+ NOT imported. Retreats.jsx owns that; only the ids come across,
+ so section_id has something real to reference.
+
+ Three things it deliberately does NOT do, each flagged in the
+ warnings at the end rather than guessed at:
+
+ dates "March/April 2026" isn't parseable, and half-right
+ dates are worse than none. starts_on stays null and
+ the explicit status carries the upcoming/past split
+ exactly as it does today.
+
+ partners the five partner events are placeholders with no
+ organization behind them, so host_org_id is null.
+
+ leads "Chapter lead name" is not a person. Inventing a
+ people row from a placeholder string would put a
+ fake name on the site.
+ ═══════════════════════════════════════════════════════════════ */
+
+import { dirname, resolve } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+import { openDatabase, migrate, tx } from "./db.js";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+
+const DB_PATH = process.env.DB_PATH ?? "./dev.db";
+const EVENTS_MODULE = process.env.EVENTS_MODULE ?? "../../src/data/events.js";
+const CHAPTERS_MODULE = process.env.CHAPTERS_MODULE ?? "../../src/data/chapters.js";
+
+// The root organization. Every national retreat hangs off this, and
+// it's what makes the org_logo fallback work uniformly.
+const NGU = {
+ id: "ngu",
+ name: "Next Generation of Unity",
+ short_name: "NGU",
+ color: "#138ba0",
+ logo: "ngu-logo-white-bg.svg",
+};
+
+const warnings = [];
+const warn = (message) => warnings.push(message);
+
+/* ── Load the source modules ───────────────────────────────── */
+
+async function load(relative) {
+ const path = resolve(HERE, relative);
+ try {
+ return await import(pathToFileURL(path).href);
+ } catch (err) {
+ console.error(`\nCould not read ${path}`);
+ console.error("Set EVENTS_MODULE / CHAPTERS_MODULE if they live elsewhere.\n");
+ throw err;
+ }
+}
+
+const eventsModule = await load(EVENTS_MODULE);
+const chaptersModule = await load(CHAPTERS_MODULE);
+
+const eventsData = eventsModule.default;
+const { GROUPS, SPLITS, CHAPTERS, STATE_NAMES, groupOf } = chaptersModule;
+
+/* ── Helpers ───────────────────────────────────────────────── */
+
+const isStateCode = (code) =>
+ Boolean(code) && code !== "CANADA" && code in STATE_NAMES;
+
+const opposite = (edge) => (edge === "top" ? "bottom" : "top");
+
+const instagramUrl = (handle) =>
+ `https://instagram.com/${String(handle).replace(/^@/, "")}`;
+
+// "Unity Village, MO" → { locality, state_code }. Anything that
+// doesn't end in a real state code keeps the whole string as the
+// locality, and location_label carries the original either way.
+function splitPlace(label) {
+ if (!label) return { locality: null, state_code: null };
+
+ const comma = label.lastIndexOf(",");
+ if (comma === -1) return { locality: label.trim(), state_code: null };
+
+ const head = label.slice(0, comma).trim();
+ const tail = label.slice(comma + 1).trim();
+
+ return isStateCode(tail)
+ ? { locality: head, state_code: tail }
+ : { locality: label.trim(), state_code: null };
+}
+
+function chapterLocation(chapter) {
+ const online = chapter.state === null && !chapter.city?.includes(",");
+ if (online || /^online$/i.test(chapter.city ?? "")) {
+ return {
+ locality: null, state_code: null, country: "US",
+ location_label: chapter.city ?? "Online", is_online: 1,
+ };
+ }
+
+ if (chapter.state === "CANADA") {
+ return {
+ locality: splitPlace(chapter.city).locality,
+ state_code: null, country: "CA",
+ location_label: chapter.city, is_online: 0,
+ };
+ }
+
+ const { locality } = splitPlace(chapter.city);
+ return {
+ locality,
+ state_code: isStateCode(chapter.state) ? chapter.state : null,
+ country: "US",
+ location_label: chapter.city,
+ is_online: 0,
+ };
+}
+
+function eventLocation(label) {
+ if (!label || /^online$/i.test(label)) {
+ return {
+ locality: null, state_code: null, country: "US",
+ location_label: label ?? null, is_online: label ? 1 : 0,
+ };
+ }
+ const { locality, state_code } = splitPlace(label);
+ return { locality, state_code, country: "US", location_label: label, is_online: 0 };
+}
+
+/* ── Open ──────────────────────────────────────────────────── */
+
+const db = await openDatabase(DB_PATH);
+migrate(db, { log: () => {} });
+
+const version = db.prepare("PRAGMA user_version").get().user_version;
+if (version < 2) {
+ throw new Error(`Schema is at v${version}; seed needs v2. Check 002_schema.sql.`);
+}
+
+/* ── Statements ────────────────────────────────────────────── */
+
+const ins = {
+ org: db.prepare(`
+ INSERT INTO organizations
+ (id, kind, name, short_name, tagline, color, logo,
+ venue, locality, state_code, country, location_label, is_online,
+ is_published, sort_order)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`),
+
+ region: db.prepare(`INSERT INTO regions (id, scope, map_note) VALUES (?, ?, ?)`),
+
+ regionArea: db.prepare(`
+ INSERT INTO region_areas (region_id, area_code, share, edge, note)
+ VALUES (?, ?, ?, ?, ?)`),
+
+ chapter: db.prepare(`
+ INSERT INTO chapters (id, region_id, meets, started) VALUES (?, ?, ?, ?)`),
+
+ section: db.prepare(`
+ INSERT INTO event_sections (id, name, sort_order) VALUES (?, ?, ?)`),
+
+ event: db.prepare(`
+ INSERT INTO events
+ (id, section_id, host_org_id, title, theme,
+ date_label, status,
+ locality, state_code, country, location_label, is_online,
+ org_logo, event_logo, color, gradient, sort_order)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
+
+ block: db.prepare(`
+ INSERT INTO content_blocks (owner_kind, owner_id, slot, sort_order, type, text)
+ VALUES (?, ?, ?, ?, ?, ?)`),
+
+ link: db.prepare(`
+ INSERT INTO links (owner_kind, owner_id, sort_order, kind, platform, label, url, is_primary)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
+};
+
+const addParagraph = (kind, id, slot, order, text) => {
+ if (!text) return;
+ ins.block.run(kind, id, slot, order, "paragraph", text);
+};
+
+/* ── Clear ─────────────────────────────────────────────────────
+ Children before parents. Feedback is not in this list and is
+ never cleared.
+ ───────────────────────────────────────────────────────────── */
+
+const CLEAR = [
+ "people_list_members", "people_lists",
+ "person_awards", "awards",
+ "event_people", "affiliations", "teams",
+ "person_private", "people",
+ "content_block_items", "content_blocks", "links",
+ "events", "event_sections",
+ "chapters", "region_areas", "regions", "organizations",
+];
+
+/* ── Import ────────────────────────────────────────────────── */
+
+const counts = {};
+const bump = (key, n = 1) => (counts[key] = (counts[key] ?? 0) + n);
+
+tx(db, () => {
+ for (const table of CLEAR) db.exec(`DELETE FROM ${table}`);
+ db.exec("DELETE FROM sqlite_sequence");
+
+ /* ── The root organization ───────────────────────────────── */
+
+ ins.org.run(
+ NGU.id, "national", NGU.name, NGU.short_name, null, NGU.color, NGU.logo,
+ null, null, null, "US", null, 0, 0,
+ );
+ bump("organizations");
+
+ /* ── Regions ─────────────────────────────────────────────── */
+
+ GROUPS.forEach((group, index) => {
+ ins.org.run(
+ group.id, "region", group.name, null, null, group.color, null,
+ null, null, null, "US", null, 0, index,
+ );
+ ins.region.run(group.id, group.scope, group.note ?? null);
+ bump("organizations");
+ bump("regions");
+
+ // Whole areas. Split states are skipped here and handled below,
+ // which matters for Iowa — it appears in great-lakes.states AND
+ // in SPLITS, and inserting it twice would violate the key.
+ for (const area of group.states) {
+ if (SPLITS[area]) continue;
+ ins.regionArea.run(group.id, area, 1.0, null, null);
+ bump("region_areas");
+ }
+ });
+
+ // Shared areas, one row per region. The old SPLITS gave the
+ // sliver an explicit share and left the primary implicit; both
+ // are explicit now, so the renderer never subtracts.
+ for (const [area, split] of Object.entries(SPLITS)) {
+ ins.regionArea.run(
+ split.primary, area,
+ Number((1 - split.share).toFixed(4)),
+ opposite(split.edge),
+ split.primaryNote ?? null,
+ );
+ ins.regionArea.run(
+ split.secondary, area, split.share, split.edge, split.secondaryNote ?? null,
+ );
+ bump("region_areas", 2);
+ }
+
+ /* ── Chapters ────────────────────────────────────────────── */
+
+ CHAPTERS.forEach((chapter, index) => {
+ const place = chapterLocation(chapter);
+ const region = groupOf(chapter);
+
+ if (!region) warn(`Chapter "${chapter.id}" resolved to no region.`);
+
+ ins.org.run(
+ chapter.id, "chapter", chapter.name, null, null, null, chapter.logo ?? null,
+ chapter.where ?? null,
+ place.locality, place.state_code, place.country,
+ place.location_label, place.is_online,
+ index,
+ );
+ ins.chapter.run(
+ chapter.id, region?.id ?? null, chapter.meets ?? null, chapter.started ?? null,
+ );
+ bump("organizations");
+ bump("chapters");
+
+ addParagraph("organization", chapter.id, "body", 0, chapter.about);
+
+ let order = 0;
+ if (chapter.link) {
+ ins.link.run("organization", chapter.id, order++, "website", null, "Visit", chapter.link, 1);
+ bump("links");
+ }
+ if (chapter.contact) {
+ ins.link.run(
+ "organization", chapter.id, order++, "email", null,
+ chapter.contact, `mailto:${chapter.contact}`, 0,
+ );
+ bump("links");
+ }
+
+ if (chapter.leads) {
+ warn(`Chapter "${chapter.id}" has leads "${chapter.leads}" — add a people row and an affiliation.`);
+ }
+ });
+
+ /* ── Event sections ────────────────────────────────────────
+ Ids only. Titles, accents, colours, backgrounds and default
+ views stay in Retreats.jsx.
+ ─────────────────────────────────────────────────────────── */
+
+ eventsData.sections.forEach((section, index) => {
+ ins.section.run(section.id, section.title, index);
+ bump("event_sections");
+ });
+
+ /* ── Events ──────────────────────────────────────────────── */
+
+ const regionIds = new Set(GROUPS.map((g) => g.id));
+
+ // National retreats belong to NGU. Regional ones name their region
+ // in the slug ("northwest-2026"). Partner placeholders have no
+ // organization yet.
+ function hostFor(event, sectionId) {
+ if (sectionId === "national") return NGU.id;
+ if (sectionId === "regional") {
+ const match = [...regionIds]
+ .filter((id) => event.id.startsWith(`${id}-`))
+ .sort((a, b) => b.length - a.length)[0];
+ if (match) return match;
+ warn(`Event "${event.id}" is regional but names no region — host left null.`);
+ return null;
+ }
+ warn(`Event "${event.id}" has no partner organization — host left null.`);
+ return null;
+ }
+
+ for (const section of eventsData.sections) {
+ section.events.forEach((event, index) => {
+ const place = eventLocation(event.location);
+
+ ins.event.run(
+ event.id, section.id, hostFor(event, section.id),
+ event.title, event.theme ?? null,
+ event.date ?? null, event.status ?? null,
+ place.locality, place.state_code, place.country,
+ place.location_label, place.is_online,
+ event.org_logo ?? null, event.image ?? null,
+ event.color ?? null, event.gradient ?? null,
+ index,
+ );
+ bump("events");
+
+ // desc_a and desc_b become the card slot, in order. The body
+ // slot is left empty for the full page you'll write later.
+ addParagraph("event", event.id, "card", 0, event.desc_a);
+ addParagraph("event", event.id, "card", 1, event.desc_b);
+
+ let order = 0;
+ (event.links ?? []).forEach((link, i) => {
+ if (!/^https?:\/\//.test(link.link)) {
+ warn(`Event "${event.id}" link "${link.label}" is not a URL: ${link.link}`);
+ }
+ ins.link.run(
+ "event", event.id, order++, "action", null,
+ link.label, link.link, i === 0 ? 1 : 0,
+ );
+ bump("links");
+ });
+
+ if (event.instagram) {
+ ins.link.run(
+ "event", event.id, order++, "social", "instagram",
+ event.instagram, instagramUrl(event.instagram), 0,
+ );
+ bump("links");
+ }
+ });
+ }
+});
+
+db.close();
+
+/* ── Report ────────────────────────────────────────────────── */
+
+console.log(`\nSeeded ${DB_PATH}\n`);
+for (const [table, n] of Object.entries(counts).sort()) {
+ console.log(` ${String(n).padStart(4)} ${table}`);
+}
+
+if (warnings.length > 0) {
+ console.log(`\n${warnings.length} thing${warnings.length === 1 ? "" : "s"} to follow up:\n`);
+ for (const message of warnings) console.log(` · ${message}`);
+}
+
+console.log("");
diff --git a/server/src/shape.js b/server/src/shape.js
new file mode 100644
index 0000000..797db14
--- /dev/null
+++ b/server/src/shape.js
@@ -0,0 +1,147 @@
+/* ═══════════════════════════════════════════════════════════════
+ SHAPE
+
+ Blocks and links are polymorphic: any organization, event, person
+ or team can own them. Every list endpoint therefore needs the
+ same move — fetch the parent rows, then fetch all their children
+ in one query each and stitch.
+
+ The alternative is a query per row, which at a dozen events is
+ invisible and at two hundred is not. Three queries is three
+ queries whatever the row count, so it may as well be right now.
+
+ One limit worth knowing: SQLite caps bound parameters per
+ statement (999 on older builds). If a list ever exceeds that,
+ these need chunking. Nothing here comes close.
+ ═══════════════════════════════════════════════════════════════ */
+
+const placeholders = (n) => Array(n).fill("?").join(",");
+
+const asBool = (value) => value === 1;
+
+/* ── Links ─────────────────────────────────────────────────────
+ Map of owner_id → links, in sort order.
+ ───────────────────────────────────────────────────────────── */
+
+export function loadLinks(db, ownerKind, ids) {
+ const out = new Map();
+ if (ids.length === 0) return out;
+
+ const rows = db
+ .prepare(
+ `SELECT owner_id, kind, platform, label, url, is_primary
+ FROM links
+ WHERE owner_kind = ? AND owner_id IN (${placeholders(ids.length)})
+ ORDER BY owner_id, sort_order`,
+ )
+ .all(ownerKind, ...ids);
+
+ for (const row of rows) {
+ const link = {
+ kind: row.kind,
+ platform: row.platform,
+ label: row.label,
+ url: row.url,
+ is_primary: asBool(row.is_primary),
+ };
+ const list = out.get(row.owner_id);
+ if (list) list.push(link);
+ else out.set(row.owner_id, [link]);
+ }
+
+ return out;
+}
+
+/* ── Blocks ────────────────────────────────────────────────────
+ Map of owner_id → blocks for one slot, items attached.
+
+ Two queries: the blocks, then every item belonging to them.
+ Blocks with no children come back with an empty items array
+ rather than no key, so the renderer never has to check.
+ ───────────────────────────────────────────────────────────── */
+
+export function loadBlocks(db, ownerKind, ids, slot = "body") {
+ const out = new Map();
+ if (ids.length === 0) return out;
+
+ const blockRows = db
+ .prepare(
+ `SELECT id, owner_id, type, text, media, href
+ FROM content_blocks
+ WHERE owner_kind = ? AND slot = ? AND owner_id IN (${placeholders(ids.length)})
+ ORDER BY owner_id, sort_order`,
+ )
+ .all(ownerKind, slot, ...ids);
+
+ if (blockRows.length === 0) return out;
+
+ const byId = new Map();
+
+ for (const row of blockRows) {
+ const block = {
+ type: row.type,
+ text: row.text,
+ media: row.media,
+ href: row.href,
+ items: [],
+ };
+ byId.set(row.id, block);
+
+ const list = out.get(row.owner_id);
+ if (list) list.push(block);
+ else out.set(row.owner_id, [block]);
+ }
+
+ const blockIds = [...byId.keys()];
+
+ const itemRows = db
+ .prepare(
+ `SELECT block_id, text, detail, url
+ FROM content_block_items
+ WHERE block_id IN (${placeholders(blockIds.length)})
+ ORDER BY block_id, sort_order`,
+ )
+ .all(...blockIds);
+
+ for (const row of itemRows) {
+ byId.get(row.block_id)?.items.push({
+ text: row.text,
+ detail: row.detail,
+ url: row.url,
+ });
+ }
+
+ return out;
+}
+
+/* ── Card description ──────────────────────────────────────────
+ The card slot is paragraphs by convention, so it collapses to
+ an array of strings — desc_a and desc_b become description[0]
+ and description[1]. A non-paragraph block in the card slot is
+ ignored here; put it in the body slot instead.
+ ───────────────────────────────────────────────────────────── */
+
+export function paragraphs(blocks = []) {
+ return blocks
+ .filter((block) => block.type === "paragraph" && block.text)
+ .map((block) => block.text);
+}
+
+/* ── Split entity links ────────────────────────────────────────
+ Socials are lifted out of the list because cards treat them
+ differently: Instagram is an icon, Register is a button. The
+ underlying rows are the same table.
+ ───────────────────────────────────────────────────────────── */
+
+export function splitLinks(links = []) {
+ return {
+ actions: links.filter((link) => link.kind === "action"),
+ socials: links.filter((link) => link.kind === "social"),
+ website: links.find((link) => link.kind === "website")?.url ?? null,
+ email: links.find((link) => link.kind === "email")?.label ?? null,
+ instagram:
+ links.find((link) => link.platform === "instagram")?.label ?? null,
+ };
+}
+
+export { asBool };
diff --git a/src/components/ArrowLink.tsx b/src/components/ArrowLink.tsx
new file mode 100644
index 0000000..b2f9af0
--- /dev/null
+++ b/src/components/ArrowLink.tsx
@@ -0,0 +1,30 @@
+import { Link } from "react-router-dom";
+
+/* ═══════════════════════════════════════════════════════════════
+ ARROW LINK
+ ═══════════════════════════════════════════════════════════════ */
+
+export default function ArrowLink({ to, label, color, size = "h-9 w-9" }) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx
index b723353..7c0f6cc 100644
--- a/src/components/Banner.tsx
+++ b/src/components/Banner.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
-import { SITE_BANNER } from "../bannerConfig.js";
+import { SITE_BANNER } from "../data/bannerConfig.js";
export default function Banner() {
const [visible, setVisible] = useState(false);
diff --git a/src/components/PeopleTiles.css b/src/components/PeopleTiles.css
new file mode 100644
index 0000000..2f70d5d
--- /dev/null
+++ b/src/components/PeopleTiles.css
@@ -0,0 +1,365 @@
+/* PeopleTiles — polaroid-style people list.
+ Everything is scoped under .pl and driven by custom properties, so a section
+ can retune it inline: */
+
+.pl {
+ /* geometry */
+ --pl-scale: 1;
+ --pl-base-w: 132px;
+ --pl-w: calc(var(--pl-base-w) * var(--pl-scale));
+ --pl-gap: calc(0.875rem * var(--pl-scale));
+ --pl-pad: calc(0.5rem * var(--pl-scale));
+ --pl-photo-ratio: 1;
+ --pl-radius: 3px;
+
+ /* color */
+ --pl-accent: #138ba0;
+ --pl-frame-bg: #fff;
+ --pl-frame-edge: rgba(15, 23, 42, 0.1);
+ --pl-photo-bg: #e7edef;
+ --pl-ink: #16262b;
+ --pl-muted: #4a6b72;
+ --pl-rule: rgba(45, 200, 224, 0.3);
+ --pl-shadow: 0 1px 1px rgba(15, 23, 42, 0.06), 0 10px 18px -14px rgba(15, 23, 42, 0.5);
+
+ container-type: inline-size;
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--pl-gap) * 1.25);
+ color: var(--pl-ink);
+}
+
+.pl[data-size="sm"] {
+ --pl-base-w: 104px;
+ --pl-radius: 2px;
+}
+
+.pl[data-size="lg"] {
+ --pl-base-w: 164px;
+ --pl-photo-ratio: 4 / 5;
+}
+
+/* groups --------------------------------------------------------------- */
+
+.pl__groups {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ gap: calc(var(--pl-gap) * 1.5);
+}
+
+.pl[data-align="center"] .pl__groups,
+.pl[data-align="center"] .pl__row {
+ justify-content: center;
+}
+
+.pl__group {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--pl-gap) * 0.75);
+ min-width: 0;
+}
+
+.pl__group + .pl__group {
+ padding-left: calc(var(--pl-gap) * 1.5);
+ border-left: 1px solid var(--pl-rule);
+}
+
+.pl__group-head {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+}
+
+.pl__group-label {
+ margin: 0;
+ font-size: calc(0.9375rem * var(--pl-scale));
+ font-weight: 600;
+ letter-spacing: 0.01em;
+}
+
+.pl__group-note {
+ margin: 0;
+ font-size: calc(0.8125rem * var(--pl-scale));
+ color: var(--pl-muted);
+}
+
+.pl__row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--pl-gap);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.pl[data-overflow="scroll"] .pl__row {
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ scroll-snap-type: x proximity;
+ padding-bottom: 0.35rem;
+ scrollbar-width: thin;
+}
+
+.pl[data-overflow="scroll"] .pl__item {
+ scroll-snap-align: start;
+}
+
+.pl__item {
+ flex: 0 0 auto;
+}
+
+/* tile ----------------------------------------------------------------- */
+
+.pl__tile {
+ display: block;
+ width: var(--pl-w);
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ font: inherit;
+ color: inherit;
+ text-align: inherit;
+}
+
+.pl__tile--button {
+ cursor: pointer;
+}
+
+.pl__frame {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--pl-pad) * 0.9);
+ padding: var(--pl-pad);
+ padding-bottom: calc(var(--pl-pad) * 1.6);
+ background: var(--pl-frame-bg);
+ border: 1px solid var(--pl-frame-edge);
+ border-radius: var(--pl-radius);
+ box-shadow: var(--pl-shadow);
+ transition: transform 160ms ease, box-shadow 160ms ease;
+}
+
+.pl[data-tilt="on"] .pl__item:nth-child(odd) .pl__frame {
+ transform: rotate(-1.1deg);
+}
+
+.pl[data-tilt="on"] .pl__item:nth-child(even) .pl__frame {
+ transform: rotate(0.9deg);
+}
+
+.pl__tile--button:hover .pl__frame,
+.pl__tile--button:focus-visible .pl__frame {
+ transform: translateY(-2px);
+ box-shadow: 0 1px 1px rgba(15, 23, 42, 0.06), 0 16px 24px -16px rgba(15, 23, 42, 0.6);
+}
+
+.pl__tile--button:focus-visible {
+ outline: none;
+}
+
+.pl__tile--button:focus-visible .pl__frame {
+ outline: 2px solid var(--pl-accent);
+ outline-offset: 3px;
+}
+
+.pl__tile[aria-expanded="true"] .pl__frame {
+ border-color: var(--pl-accent);
+ box-shadow: 0 0 0 1px var(--pl-accent), 0 14px 22px -16px rgba(15, 23, 42, 0.6);
+}
+
+.pl__photo {
+ display: grid;
+ place-items: center;
+ aspect-ratio: var(--pl-photo-ratio);
+ overflow: hidden;
+ background: var(--pl-photo-bg);
+ border-radius: 1px;
+}
+
+.pl__img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.pl__initials {
+ font-size: calc(1.25rem * var(--pl-scale));
+ font-weight: 600;
+ letter-spacing: 0.03em;
+ color: var(--pl-muted);
+}
+
+.pl__caption {
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ padding: 0 calc(var(--pl-pad) * 0.25);
+ min-height: calc(1.1rem * var(--pl-scale));
+}
+
+.pl__name {
+ font-size: calc(0.875rem * var(--pl-scale));
+ font-weight: 600;
+ line-height: 1.25;
+}
+
+.pl__title {
+ font-size: calc(0.75rem * var(--pl-scale));
+ line-height: 1.3;
+ color: var(--pl-muted);
+}
+
+.pl__badge {
+ position: absolute;
+ right: calc(var(--pl-pad) * 0.6);
+ bottom: calc(var(--pl-pad) * 0.6);
+ display: grid;
+ place-items: center;
+ width: calc(1.25rem * var(--pl-scale));
+ height: calc(1.25rem * var(--pl-scale));
+ border-radius: 999px;
+ background: var(--pl-accent);
+ color: #fff;
+ transition: transform 160ms ease;
+}
+
+.pl__tile[aria-expanded="true"] .pl__badge {
+ transform: rotate(180deg);
+}
+
+/* bio panel ------------------------------------------------------------ */
+
+.pl__bio {
+ display: flex;
+ flex-direction: column;
+ gap: 0.65rem;
+ padding: 1rem 1.1rem;
+ border-left: 3px solid var(--pl-accent);
+ border-radius: 6px;
+ background: color-mix(in srgb, var(--pl-accent) 7%, #fff);
+ max-width: 68ch;
+}
+
+.pl__bio-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.pl__bio-name {
+ margin: 0;
+ font-size: 1.05rem;
+ font-weight: 600;
+}
+
+.pl__bio-title {
+ margin: 0.1rem 0 0;
+ font-size: 0.875rem;
+ color: var(--pl-muted);
+}
+
+.pl__close {
+ flex: 0 0 auto;
+ width: 1.75rem;
+ height: 1.75rem;
+ border: 1px solid var(--pl-frame-edge);
+ border-radius: 999px;
+ background: #fff;
+ color: var(--pl-muted);
+ font-size: 1.1rem;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.pl__close:hover {
+ color: var(--pl-ink);
+ border-color: var(--pl-accent);
+}
+
+.pl__facts {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem 1.5rem;
+ margin: 0;
+}
+
+.pl__fact dt {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ color: var(--pl-muted);
+}
+
+.pl__fact dd {
+ margin: 0;
+ font-size: 0.875rem;
+}
+
+.pl__fact a {
+ color: var(--pl-accent);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+.pl__bio-text {
+ margin: 0;
+ font-size: 0.9375rem;
+ line-height: 1.6;
+ max-width: 62ch;
+}
+
+.pl__empty {
+ margin: 0;
+ font-size: 0.9375rem;
+ color: var(--pl-muted);
+}
+
+.pl__sr {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+/* narrow layouts ------------------------------------------------------- */
+
+@container (max-width: 640px) {
+ .pl__group + .pl__group {
+ padding-left: 0;
+ padding-top: calc(var(--pl-gap) * 1.1);
+ border-left: 0;
+ border-top: 1px solid var(--pl-rule);
+ width: 100%;
+ }
+}
+
+@supports not (container-type: inline-size) {
+ @media (max-width: 640px) {
+ .pl__group + .pl__group {
+ padding-left: 0;
+ padding-top: calc(var(--pl-gap) * 1.1);
+ border-left: 0;
+ border-top: 1px solid var(--pl-rule);
+ width: 100%;
+ }
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .pl__frame,
+ .pl__badge {
+ transition: none;
+ }
+
+ .pl__tile--button:hover .pl__frame,
+ .pl__tile--button:focus-visible .pl__frame {
+ transform: none;
+ }
+}
diff --git a/src/components/PeopleTiles.tsx b/src/components/PeopleTiles.tsx
new file mode 100644
index 0000000..e613b55
--- /dev/null
+++ b/src/components/PeopleTiles.tsx
@@ -0,0 +1,375 @@
+import { useEffect, useId, useMemo, useRef, useState } from "react";
+import "./PeopleTiles.css";
+
+/**
+ * PeopleTiles — a horizontal, polaroid-style people list.
+ *
+ * Drop into any section:
+ *
+ *
+ *
+ * Sizes
+ * sm photo + name
+ * md photo + name + title
+ * lg photo + name + title + expandable bio (pronouns, age, primary org above the bio)
+ *
+ * Group shape
+ * { id, label?, note?, accent?, people: [person] }
+ *
+ * Person shape
+ * {
+ * id, name,
+ * title?, // "Regional Director"
+ * photo?, // "/people/jane-doe.jpg" — falls back to initials
+ * pronouns?, // "she/her"
+ * age?, // number, or use birthdate
+ * birthdate?, // "1998-04-12" — age is derived when `age` is absent
+ * org?, // "Grace Chapel" or { name, href }
+ * bio?, // string or string[] (paragraphs)
+ * accent?, // per-person override
+ * }
+ */
+
+const SIZE_FEATURES = {
+ sm: { title: false, bio: false },
+ md: { title: true, bio: false },
+ lg: { title: true, bio: true },
+};
+
+export default function PeopleTiles({
+ people,
+ groups,
+ size = "md",
+ scale = 1,
+ overflow = "wrap", // "wrap" | "scroll"
+ align = "start", // "start" | "center"
+ accent,
+ tilt = false,
+ emptyMessage = "No one listed yet.",
+ onExpand,
+ className = "",
+ style,
+ ...rest
+}) {
+ const baseId = useId().replace(/:/g, "");
+ const [openKey, setOpenKey] = useState(null);
+ const rootRef = useRef(null);
+
+ const resolvedSize = SIZE_FEATURES[size] ? size : "md";
+ const features = SIZE_FEATURES[resolvedSize];
+
+ const resolvedGroups = useMemo(() => {
+ const source = Array.isArray(groups) && groups.length
+ ? groups
+ : Array.isArray(people) && people.length
+ ? [{ id: "all", people }]
+ : [];
+
+ return source
+ .map((group, groupIndex) => ({
+ ...group,
+ id: group.id ?? `group-${groupIndex}`,
+ people: (group.people || []).filter(Boolean),
+ }))
+ .filter((group) => group.people.length > 0);
+ }, [groups, people]);
+
+ // Close the bio if the person it belongs to disappears from the data.
+ useEffect(() => {
+ if (!openKey) return;
+ const stillThere = resolvedGroups.some((group) =>
+ group.people.some((person, index) => keyFor(group, person, index) === openKey),
+ );
+ if (!stillThere) setOpenKey(null);
+ }, [openKey, resolvedGroups]);
+
+ if (!resolvedGroups.length) {
+ return emptyMessage ?
{emptyMessage}
: null;
+ }
+
+ const open = features.bio ? findByKey(resolvedGroups, openKey) : null;
+
+ function toggle(group, person, index) {
+ const key = keyFor(group, person, index);
+ const next = openKey === key ? null : key;
+ setOpenKey(next);
+ if (onExpand) onExpand(next ? person : null, next ? group : null);
+ }
+
+ function handleKeyDown(event) {
+ if (event.key === "Escape" && openKey) {
+ event.stopPropagation();
+ setOpenKey(null);
+ const button = rootRef.current?.querySelector('.pl__tile[aria-expanded="true"]');
+ if (button) button.focus();
+ }
+ }
+
+ return (
+
+ );
+}
+
+function Chevron() {
+ return (
+
+ );
+}
+
+/* helpers ------------------------------------------------------------- */
+
+function keyFor(group, person, index) {
+ return `${group.id}:${person.id ?? person.name ?? index}`;
+}
+
+function findByKey(groups, key) {
+ if (!key) return null;
+ for (const group of groups) {
+ for (let index = 0; index < group.people.length; index += 1) {
+ const person = group.people[index];
+ if (keyFor(group, person, index) === key) return { group, person };
+ }
+ }
+ return null;
+}
+
+function hasBio(person) {
+ if (Array.isArray(person.bio)) return person.bio.some(Boolean);
+ return Boolean(person.bio);
+}
+
+function initials(name = "") {
+ return name
+ .trim()
+ .split(/\s+/)
+ .slice(0, 2)
+ .map((word) => word[0] || "")
+ .join("")
+ .toUpperCase();
+}
+
+function resolveAge(person) {
+ if (typeof person.age === "number") return person.age;
+ if (!person.birthdate) return null;
+ const born = new Date(person.birthdate);
+ if (Number.isNaN(born.getTime())) return null;
+ const now = new Date();
+ let age = now.getFullYear() - born.getFullYear();
+ const monthDelta = now.getMonth() - born.getMonth();
+ if (monthDelta < 0 || (monthDelta === 0 && now.getDate() < born.getDate())) age -= 1;
+ return age >= 0 ? age : null;
+}
+
+function resolveOrg(org) {
+ if (!org) return null;
+ if (typeof org === "string") return { name: org };
+ if (!org.name) return null;
+ return org;
+}
diff --git a/src/bannerConfig.js b/src/data/bannerConfig.js
similarity index 100%
rename from src/bannerConfig.js
rename to src/data/bannerConfig.js
diff --git a/src/data/chapters.js b/src/data/chapters.js
new file mode 100644
index 0000000..5ee7e37
--- /dev/null
+++ b/src/data/chapters.js
@@ -0,0 +1,146 @@
+/* ═══════════════════════════════════════════════════════════════
+ LOCAL CHAPTER DATA
+
+ The chapter map's view of the organization list. Regions and
+ chapters are the same table and the same endpoint; this hook
+ reshapes them into what a map needs — slices per tile, counts
+ per tile, chapters grouped by region.
+
+ Its return shape is unchanged from the version that called
+ /regions and /chapters, so LocalChapters didn't have to move.
+
+ Two things live elsewhere:
+
+ the tile grid src/data/mapGrid.js. Where a state sits never
+ changes, so it isn't worth a round trip.
+
+ the fetch src/data/organizations.js. One endpoint for
+ every kind, so a section listing regions and
+ a section listing partners read alike.
+ ═══════════════════════════════════════════════════════════════ */
+
+import { useMemo } from "react";
+
+import {
+ areasSentence,
+ initialsFor,
+ useOrganizations,
+} from "./organizations.js";
+import {
+ areaForChapter,
+ buildAreaSlices,
+ countChaptersByArea,
+} from "./mapGrid.js";
+
+const byName = (a, b) => a.name.localeCompare(b.name);
+
+export { initialsFor };
+
+export function useCommunity() {
+ const regionsQuery = useOrganizations("region");
+ const chaptersQuery = useOrganizations("chapter");
+
+ const rawRegions = regionsQuery.organizations;
+ const rawChapters = chaptersQuery.organizations;
+
+ return useMemo(() => {
+ /* ── Regions ───────────────────────────────────────────────
+ scope and map_note are lifted out of `details` so the rest
+ of the app doesn't have to know they're kind-specific. */
+ const regions = rawRegions.map((org) => ({
+ ...org,
+ scope: org.details?.scope ?? null,
+ map_note: org.details?.map_note ?? null,
+ areas: org.details?.areas ?? [],
+ }));
+
+ // Flat, because buildAreaSlices takes it that way.
+ const regionAreas = regions.flatMap((region) =>
+ region.areas.map((area) => ({ ...area, region_id: region.id })),
+ );
+
+ /* ── Chapters, each tagged with the tile it lights up ──────
+ The database stores a real address; which square that maps
+ to is a rendering question, answered here once rather than
+ at every call site. */
+ const chapters = rawChapters.map((org) => ({
+ ...org,
+ region_id: org.details?.region_id ?? null,
+ region_name: org.details?.region_name ?? null,
+ region_color: org.details?.region_color ?? null,
+ meets: org.details?.meets ?? null,
+ started: org.details?.started ?? null,
+ area_code: areaForChapter(org),
+ }));
+
+ const regionById = Object.fromEntries(regions.map((r) => [r.id, r]));
+
+ /* { WA: [{ regionId, name, color, share, edge, note }], ... }
+ California comes back as two half slices rather than a
+ primary and a remainder, so the SVG never does arithmetic. */
+ const slices = buildAreaSlices(regionAreas, regions);
+
+ const chapterCounts = countChaptersByArea(chapters);
+
+ const chaptersByRegion = new Map();
+ for (const chapter of chapters) {
+ if (!chapter.region_id) continue;
+ const list = chaptersByRegion.get(chapter.region_id);
+ if (list) list.push(chapter);
+ else chaptersByRegion.set(chapter.region_id, [chapter]);
+ }
+
+ const chaptersIn = (regionId) => chaptersByRegion.get(regionId) ?? [];
+
+ /* Sorted by name to match how the page has always shown them,
+ rather than by the sort_order the API returns. */
+ const domestic = regions.filter((r) => r.scope === "domestic").sort(byName);
+ const international = regions
+ .filter((r) => r.scope === "international")
+ .sort(byName);
+ const virtual = regions.filter((r) => r.scope === "virtual");
+
+ const areasLabelFor = (regionId) =>
+ areasSentence(regionById[regionId]?.areas ?? []);
+
+ const subtextFor = (region) => {
+ const label = areasLabelFor(region.id);
+ if (label && region.map_note) return `${label} · ${region.map_note}`;
+ return label || region.map_note || "";
+ };
+
+ const regionsForArea = (areaCode) =>
+ (slices[areaCode] ?? [])
+ .map((slice) => regionById[slice.regionId])
+ .filter(Boolean);
+
+ return {
+ loading: regionsQuery.loading || chaptersQuery.loading,
+ error: regionsQuery.error ?? chaptersQuery.error,
+
+ regions,
+ regionAreas,
+ regionById,
+ domestic,
+ international,
+ virtual,
+
+ chapters,
+ chaptersIn,
+
+ slices,
+ chapterCounts,
+ regionsForArea,
+
+ areasLabelFor,
+ subtextFor,
+ };
+ }, [
+ rawRegions,
+ rawChapters,
+ regionsQuery.loading,
+ regionsQuery.error,
+ chaptersQuery.loading,
+ chaptersQuery.error,
+ ]);
+}
diff --git a/src/data/eventData.js b/src/data/eventData.js
new file mode 100644
index 0000000..ee2364e
--- /dev/null
+++ b/src/data/eventData.js
@@ -0,0 +1,54 @@
+/* ═══════════════════════════════════════════════════════════════
+ EVENT DATA
+
+ One request, filtered per section. The Retreats page has three
+ bands of events, and all three call this hook — the cache in
+ api.js keys on the path, so they share a single fetch and each
+ narrows the result to what it shows.
+
+ useEvents({ section: "national" }) one band
+ useEvents({ host: "northwest" }) a region's own events
+ useEvents({ status: "upcoming" }) a home page strip
+ useEvents() everything
+
+ Filtering here rather than in the query keeps the endpoint to
+ one cached response. At a few dozen events that's the right
+ trade; if the list ever runs to hundreds, move the filters into
+ the URL and let each become its own cache entry.
+ ═══════════════════════════════════════════════════════════════ */
+
+import { useMemo } from "react";
+
+import { useResource } from "../lib/useResource.js";
+
+const EMPTY = { events: [] };
+
+export function useEvents({ section, host, status } = {}) {
+ const { data, error, loading } = useResource("/events", { fallback: EMPTY });
+
+ const all = data?.events;
+
+ const events = useMemo(() => {
+ let list = all ?? [];
+ if (section) list = list.filter(e => e.section_id === section);
+ if (host) list = list.filter(e => e.host?.id === host);
+ if (status) list = list.filter(e => e.status === status);
+ return list;
+ }, [all, section, host, status]);
+
+ return { events, loading, error };
+}
+
+/* Past and upcoming, split. `status` arrives already resolved — the
+ explicit value when there is one, otherwise derived from ends_on
+ — so nothing here needs to know which of the two it got. */
+export function splitByStatus(events = []) {
+ const upcoming = [];
+ const past = [];
+
+ for (const event of events) {
+ (event.status === "past" ? past : upcoming).push(event);
+ }
+
+ return { upcoming, past };
+}
diff --git a/src/events.js b/src/data/events.js
similarity index 98%
rename from src/events.js
rename to src/data/events.js
index 06a49ce..6e25574 100644
--- a/src/events.js
+++ b/src/data/events.js
@@ -185,7 +185,7 @@ const eventsData = {
background: "#eef9fb",
events: [
{
- id: "partner-example",
+ id: "partner-example-1",
title: "Partner Event Name",
theme: null,
date: "Date",
@@ -206,7 +206,7 @@ const eventsData = {
]
},
{
- id: "partner-example",
+ id: "partner-example-2",
title: "Partner Event Name",
theme: null,
date: "Date",
@@ -227,7 +227,7 @@ const eventsData = {
]
},
{
- id: "partner-example",
+ id: "partner-example-3",
title: "Partner Event Name",
theme: null,
date: "Date",
@@ -248,7 +248,7 @@ const eventsData = {
]
},
{
- id: "partner-example",
+ id: "partner-example-4",
title: "Partner Event Name",
theme: null,
date: "Date",
@@ -269,7 +269,7 @@ const eventsData = {
]
},
{
- id: "partner-example",
+ id: "partner-example-5",
title: "Partner Event Name",
theme: null,
date: "Date",
diff --git a/src/data/mapGrid.js b/src/data/mapGrid.js
new file mode 100644
index 0000000..ede40cf
--- /dev/null
+++ b/src/data/mapGrid.js
@@ -0,0 +1,174 @@
+/* ═══════════════════════════════════════════════════════════════
+ MAP GRID
+
+ Pure layout. Where each tile sits, what it's called, and how to
+ work out which tile a chapter belongs to. None of this is in the
+ database because none of it changes — Rhode Island will not be
+ moving, and no admin form should offer to move it.
+
+ What IS in the database is which regions cover which areas, and
+ how much of each. That arrives as region_areas rows whose
+ area_code matches a key in AREAS below. A code with no match
+ here simply doesn't paint, which is how Africa and the UK exist
+ as regions with no tile.
+ ═══════════════════════════════════════════════════════════════ */
+
+export const GRID_COLS = 13;
+export const GRID_ROWS = 7;
+
+/* ── States ────────────────────────────────────────────────────
+ [column, row], both 1-based. A tile grid rather than true
+ geography: every state reads at the same size, it stays
+ legible on a phone, and there's no map library to load.
+ ───────────────────────────────────────────────────────────── */
+const STATE_GRID = {
+ AK: [1, 1], ME: [13, 1],
+ WA: [2, 2], ID: [3, 2], MT: [4, 2], ND: [5, 2], MN: [6, 2], WI: [7, 2],
+ MI: [8, 3], NY: [10, 2], VT: [11, 2], NH: [12, 2],
+ OR: [2, 3], NV: [3, 4], WY: [4, 3], SD: [5, 3], IA: [6, 3], IL: [7, 3],
+ IN: [7, 4], OH: [8, 4], PA: [9, 2], NJ: [10, 3], MA: [11, 3],
+ CA: [2, 4], UT: [3, 3], CO: [4, 4], NE: [5, 4], MO: [6, 4], KY: [7, 5],
+ WV: [9, 3], VA: [9, 4], MD: [10, 5], DE: [10, 4], CT: [11, 4],
+ AZ: [3, 5], NM: [4, 5], KS: [5, 5], AR: [6, 5], TN: [8, 5], NC: [10, 6],
+ DC: [9, 5], RI: [12, 3],
+ OK: [5, 6], LA: [6, 6], MS: [7, 6], AL: [8, 6], SC: [9, 6],
+ HI: [1, 7], TX: [5, 7], GA: [9, 7], FL: [10, 7],
+};
+
+/* ── Bands ─────────────────────────────────────────────────────
+ Wide areas that aren't states. A band is just a tile with a
+ span, which keeps the renderer from needing a second code path.
+ ───────────────────────────────────────────────────────────── */
+const BAND_GRID = {
+ CANADA: [3, 1, 8], // col, row, span
+};
+
+export const AREA_NAMES = {
+ AL: "Alabama", AK: "Alaska", AZ: "Arizona", AR: "Arkansas",
+ CA: "California", CO: "Colorado", CT: "Connecticut", DE: "Delaware",
+ DC: "District of Columbia", FL: "Florida", GA: "Georgia", HI: "Hawai'i",
+ ID: "Idaho", IL: "Illinois", IN: "Indiana", IA: "Iowa", KS: "Kansas",
+ KY: "Kentucky", LA: "Louisiana", ME: "Maine", MD: "Maryland",
+ MA: "Massachusetts", MI: "Michigan", MN: "Minnesota", MS: "Mississippi",
+ MO: "Missouri", MT: "Montana", NE: "Nebraska", NV: "Nevada",
+ NH: "New Hampshire", NJ: "New Jersey", NM: "New Mexico", NY: "New York",
+ NC: "North Carolina", ND: "North Dakota", OH: "Ohio", OK: "Oklahoma",
+ OR: "Oregon", PA: "Pennsylvania", RI: "Rhode Island",
+ SC: "South Carolina", SD: "South Dakota", TN: "Tennessee", TX: "Texas",
+ UT: "Utah", VT: "Vermont", VA: "Virginia", WA: "Washington",
+ WV: "West Virginia", WI: "Wisconsin", WY: "Wyoming",
+ CANADA: "Canada",
+};
+
+/* ── One list the renderer walks ───────────────────────────────
+ States and bands unified, so drawing the map is a single map()
+ over AREAS rather than two loops with different shapes.
+ ───────────────────────────────────────────────────────────── */
+export const AREAS = Object.freeze([
+ ...Object.entries(STATE_GRID).map(([code, [col, row]]) => ({
+ code,
+ name: AREA_NAMES[code] ?? code,
+ col,
+ row,
+ span: 1,
+ isState: true,
+ })),
+ ...Object.entries(BAND_GRID).map(([code, [col, row, span]]) => ({
+ code,
+ name: AREA_NAMES[code] ?? code,
+ col,
+ row,
+ span,
+ isState: false,
+ })),
+]);
+
+export const AREA_BY_CODE = Object.fromEntries(AREAS.map((a) => [a.code, a]));
+
+/* ── Which tile a chapter sits on ──────────────────────────────
+ The database stores a real address — state_code for US, and a
+ country code otherwise — rather than a tile name. This is the
+ one place that translates between the two, so adding a Mexico
+ band later means a line here and nothing in SQL.
+
+ Returns null for anything with no tile, which covers virtual
+ chapters and any country not drawn.
+ ───────────────────────────────────────────────────────────── */
+const COUNTRY_AREA = {
+ CA: "CANADA", // ISO country code, not California
+};
+
+export function areaForChapter(chapter) {
+ if (!chapter) return null;
+ if (chapter.is_online) return null;
+
+ if (chapter.country === "US") {
+ return AREA_BY_CODE[chapter.state_code] ? chapter.state_code : null;
+ }
+ return COUNTRY_AREA[chapter.country] ?? null;
+}
+
+/* ── Slices per tile ───────────────────────────────────────────
+ Turns region_areas rows into what the SVG needs: for each
+ tile, the regions painting it and the fraction each takes.
+
+ A region with share 1 and no edge fills the tile. A shared
+ tile has one row per region, each declaring its own slice, so
+ California is two entries of 0.5 rather than a primary plus a
+ remainder — no arithmetic, and the renderer doesn't need to
+ know which region "really" owns it.
+
+ regionAreas [{ region_id, area_code, share, edge, note }]
+ regions [{ id, name, color, ... }]
+ ───────────────────────────────────────────────────────────── */
+export function buildAreaSlices(regionAreas = [], regions = []) {
+ const regionById = Object.fromEntries(regions.map((r) => [r.id, r]));
+ const byArea = {};
+
+ for (const row of regionAreas) {
+ const area = AREA_BY_CODE[row.area_code];
+ const region = regionById[row.region_id];
+ if (!area || !region) continue; // untiled region, or unknown code
+
+ (byArea[row.area_code] ??= []).push({
+ regionId: region.id,
+ name: region.name,
+ color: region.color,
+ share: row.share ?? 1,
+ edge: row.edge ?? null,
+ note: row.note ?? null,
+ });
+ }
+
+ // Full-tile slice first, so a partial slice paints over it.
+ for (const slices of Object.values(byArea)) {
+ slices.sort((a, b) => b.share - a.share);
+ }
+
+ return byArea;
+}
+
+/* ── Chapter counts per tile ───────────────────────────────────
+ { WA: 2, MO: 1, CANADA: 1 }
+ ───────────────────────────────────────────────────────────── */
+export function countChaptersByArea(chapters = []) {
+ const counts = {};
+ for (const chapter of chapters) {
+ const code = areaForChapter(chapter);
+ if (code) counts[code] = (counts[code] ?? 0) + 1;
+ }
+ return counts;
+}
+
+/* ── "AK, ID, MT, OR, UT (Salt Lake City area), WA" ────────────
+ Replaces statesLabel. Note the annotation now comes straight
+ from the row rather than being looked up in a splits table and
+ branched on whether this region is the primary.
+ ───────────────────────────────────────────────────────────── */
+export function areasLabel(regionId, regionAreas = []) {
+ return regionAreas
+ .filter((row) => row.region_id === regionId && row.area_code !== "CANADA")
+ .map((row) => (row.note ? `${row.area_code} (${row.note})` : row.area_code))
+ .sort()
+ .join(", ");
+}
diff --git a/src/data/organizations.js b/src/data/organizations.js
new file mode 100644
index 0000000..50a5405
--- /dev/null
+++ b/src/data/organizations.js
@@ -0,0 +1,90 @@
+/* ═══════════════════════════════════════════════════════════════
+ ORGANIZATIONS
+
+ Regions, chapters, partners and NGU itself are one table and one
+ endpoint. Anything that lists organizations reads them the same
+ way and filters by kind:
+
+ const { organizations } = useOrganizations("region");
+ const { organizations } = useOrganizations("partner");
+ const { organizations } = useOrganizations(); // all
+
+ Every organization has the same card surface — name, colour,
+ logo, description, links. What differs by kind sits under
+ `details`, so a list component can render the common parts
+ without knowing what it's holding:
+
+ region { scope, map_note, areas[], chapters[] }
+ chapter { region_id, region_name, region_color, meets, started }
+ partner {}
+
+ Each kind is a separate request path, so the cache in api.js
+ keys them apart and two sections asking for regions share one
+ fetch.
+ ═══════════════════════════════════════════════════════════════ */
+
+import { useResource } from "../lib/useResource.js";
+
+const EMPTY = { organizations: [] };
+
+export function useOrganizations(kind) {
+ const path = kind
+ ? `/organizations?kind=${encodeURIComponent(kind)}`
+ : "/organizations";
+
+ const { data, error, loading } = useResource(path, { fallback: EMPTY });
+
+ return {
+ organizations: data?.organizations ?? [],
+ loading,
+ error,
+ };
+}
+
+/* ── Where an organization's page lives ────────────────────────
+ One place to change when routes move. Kinds with no page of
+ their own return null, and a list should render no link rather
+ than a dead one.
+ ───────────────────────────────────────────────────────────── */
+
+const PATHS = {
+ region: "/regions",
+ chapter: "/chapters",
+ partner: "/partners",
+};
+
+export function orgPath(org) {
+ const base = PATHS[org?.kind];
+ return base ? `${base}/${org.id}` : null;
+}
+
+/* ── "AK, ID, MT, OR, UT (Salt Lake City area), WA" ────────────
+ The areas a region covers, annotated where it holds only part
+ of one. Canada is a band on the map rather than somewhere you'd
+ list, so it's left out of the sentence.
+
+ The note comes straight off the row. The old statesLabel had to
+ work out whether this region was the primary or the secondary of
+ a split before it knew which note applied; there's no such thing
+ any more.
+ ───────────────────────────────────────────────────────────── */
+
+export function areasSentence(areas = []) {
+ return areas
+ .filter((area) => area.area_code !== "CANADA")
+ .map((area) => (area.note ? `${area.area_code} (${area.note})` : area.area_code))
+ .sort()
+ .join(", ");
+}
+
+/* Initials for a card with no logo. "NGU Lynnwood" → "NL", dropping
+ the org prefix so every card doesn't read "NG". */
+export function initialsFor(name = "") {
+ return name
+ .replace(/^NGU\s+/i, "")
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((word) => word[0].toUpperCase())
+ .join("");
+}
diff --git a/src/index.css b/src/index.css
index 6c6f330..78f3533 100644
--- a/src/index.css
+++ b/src/index.css
@@ -69,3 +69,143 @@ h1, h2, h3, h4, h5, h6 {
.footer-icons:hover .ig-icon { fill: var(--ig-fill); }
.fb-icon:hover { fill: #0862f7; }
.ds-icon:hover { fill: #5865f1; }
+
+
+/* Event Card Section */
+.ev-card {
+ container-type: inline-size;
+}
+
+.ev-grid {
+ display: grid;
+ gap: 0.25rem 1.5rem;
+ grid-template-columns: 2fr 1fr;
+ grid-template-areas:
+ "ngu image"
+ "info image"
+ "desc desc";
+}
+
+.ev-ngu {
+ grid-area: ngu;
+}
+
+.ev-info {
+ grid-area: info;
+}
+
+.ev-image {
+ grid-area: image;
+ justify-self: stretch;
+ align-self: center;
+ max-height: 11rem;
+}
+
+.ev-desc {
+ grid-area: desc;
+ margin-top: 1rem;
+}
+
+@container (max-width: 30rem) {
+ .ev-grid {
+ grid-template-columns: 1fr;
+ grid-template-areas:
+ "ngu"
+ "info"
+ "desc";
+ }
+
+ .ev-grid .ev-image {
+ justify-self: start;
+ width: auto;
+ max-height: 7rem;
+ margin-bottom: 0.5rem;
+ }
+}
+
+/* ═══════════════════════════════════════════════════════════════
+ SKELETON
+ Holds roughly a card's height so the page doesn't jump when the
+ data arrives. The shimmer is decoration, so it goes away for
+ anyone who has asked for less motion.
+ ═══════════════════════════════════════════════════════════════ */
+
+.skeleton {
+ border-radius: 1.5rem;
+ min-height: 22rem;
+ background: linear-gradient(
+ 100deg,
+ rgba(0, 0, 0, 0.04) 30%,
+ rgba(0, 0, 0, 0.08) 50%,
+ rgba(0, 0, 0, 0.04) 70%
+ );
+ background-size: 200% 100%;
+ animation: skeleton-shimmer 1.4s ease-in-out infinite;
+}
+
+@keyframes skeleton-shimmer {
+ from {
+ background-position: 200% 0;
+ }
+ to {
+ background-position: -200% 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .skeleton {
+ animation: none;
+ }
+}
+
+/* ═══════════════════════════════════════════════════════════════
+ UNITY REGIONS
+ Append to index.css. Scoped to .ur- so nothing here reaches the
+ rest of the site.
+ ═══════════════════════════════════════════════════════════════ */
+
+/* The 0fr → 1fr grid row animates to the panel's real height, so
+ nobody has to guess a max-height that's wrong the moment a
+ region's description grows. The inline style sets the value;
+ this sets how it moves. */
+.ur-panel {
+ display: grid;
+ transition: grid-template-rows 300ms ease;
+}
+
+.ur-chevron {
+ transition: transform 200ms ease;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ur-panel,
+ .ur-chevron {
+ transition: none;
+ }
+}
+
+/* ═══════════════════════════════════════════════════════════════
+ ORGANIZATION LIST
+ Append to index.css. Scoped to .ol- so nothing here reaches the
+ rest of the site.
+ ═══════════════════════════════════════════════════════════════ */
+
+/* The 0fr → 1fr grid row animates to the panel's real height, so
+ nobody has to guess a max-height that's wrong the moment a
+ description grows. The inline style sets the value; this sets
+ how it moves. */
+.ol-panel {
+ display: grid;
+ transition: grid-template-rows 300ms ease;
+}
+
+.ol-chevron {
+ transition: transform 200ms ease;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ol-panel,
+ .ol-chevron {
+ transition: none;
+ }
+}
diff --git a/src/lib/api.js b/src/lib/api.js
new file mode 100644
index 0000000..091a4b7
--- /dev/null
+++ b/src/lib/api.js
@@ -0,0 +1,102 @@
+/* ═══════════════════════════════════════════════════════════════
+ API CLIENT
+
+ One place that knows how to talk to the server, so components
+ never call fetch directly and swapping the transport later is a
+ single-file change.
+
+ Two things worth knowing about the design:
+
+ The cache is a module-level Map of in-flight and settled
+ promises. Two components asking for /events during the same
+ render pass share one request, and a remount inside the TTL
+ costs nothing. It resets on page load, which is the right
+ lifetime for content that changes weekly.
+
+ Every reader can pass a `fallback`. If the request fails, that
+ value is used instead. This is what keeps the Figma preview
+ working: pass the old static module as the fallback and the
+ preview renders real content with no server in sight.
+ ═══════════════════════════════════════════════════════════════ */
+
+const BASE = import.meta.env?.VITE_API_BASE ?? "/api";
+const DEFAULT_TTL = 60_000;
+
+const cache = new Map(); // path → { at, promise }
+
+export class ApiError extends Error {
+ constructor(message, { status, fields } = {}) {
+ super(message);
+ this.name = "ApiError";
+ this.status = status;
+ this.fields = fields;
+ }
+}
+
+async function request(path, options = {}) {
+ const response = await fetch(`${BASE}${path}`, {
+ headers: { Accept: "application/json", ...options.headers },
+ ...options,
+ });
+
+ if (response.status === 204) return null;
+
+ const type = response.headers.get("content-type") ?? "";
+ if (!type.includes("application/json")) {
+ // Usually the SPA fallback returning index.html for a URL the
+ // API doesn't serve. Parsing it would throw something useless.
+ throw new ApiError("Server did not return JSON.", {
+ status: response.status,
+ });
+ }
+
+ const body = await response.json();
+
+ if (!response.ok) {
+ throw new ApiError(body.error ?? "Request failed.", {
+ status: response.status,
+ fields: body.fields,
+ });
+ }
+
+ return body;
+}
+
+/* ── Reads ─────────────────────────────────────────────────────
+ get("/events") → cached for 60s
+ get("/events", { ttl: 0 }) → always fresh
+ get("/events", { fallback }) → fallback on any failure
+ ───────────────────────────────────────────────────────────── */
+
+export function get(path, { ttl = DEFAULT_TTL, fallback } = {}) {
+ const hit = cache.get(path);
+
+ if (hit && Date.now() - hit.at < ttl) return hit.promise;
+
+ const promise = request(path).catch((err) => {
+ cache.delete(path); // a failure shouldn't be cached
+ if (fallback !== undefined) {
+ console.warn(`api: ${path} failed, using fallback`, err);
+ return fallback;
+ }
+ throw err;
+ });
+
+ cache.set(path, { at: Date.now(), promise });
+ return promise;
+}
+
+export function invalidate(path) {
+ if (path) cache.delete(path);
+ else cache.clear();
+}
+
+/* ── Writes ──────────────────────────────────────────────────── */
+
+export function post(path, data) {
+ return request(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ });
+}
diff --git a/src/lib/sections.tsx b/src/lib/sections.tsx
new file mode 100644
index 0000000..9d69fdb
--- /dev/null
+++ b/src/lib/sections.tsx
@@ -0,0 +1,78 @@
+import { useState } from "react";
+
+/* ═══════════════════════════════════════════════════════════════
+ SECTION MANIFEST
+
+ A page is a list of sections, and a section is a view over data
+ the database already holds, narrowed by a filter. The map of
+ chapters and the vertical list of regions are the same rows seen
+ two ways; the three bands of retreats are one view seen three
+ times with a different filter each.
+
+ So a page declares what it wants and this turns it into what
+ PageShell takes. Every entry looks like:
+
+ {
+ id, title, blurb, accent, background, // the heading
+ Component, // the view
+ props: { ... }, // the filter
+ views: { // optional toggle
+ options: ["carousel", "grid"],
+ default: "carousel",
+ Toggle: EventCardsToggle,
+ },
+ }
+
+ Sections fetch their own data. The page never does, which is why
+ there's no loading or error state here — each view handles its
+ own, and one section failing doesn't blank the page.
+
+ Every Component receives `accent` so its empty and error notices
+ match the heading above them, and `view` when the entry declares
+ a toggle. Both are ignored harmlessly by a section that doesn't
+ want them.
+ ═══════════════════════════════════════════════════════════════ */
+
+export function useSectionManifest(manifest) {
+ // One entry per section that has a toggle, seeded from its
+ // declared default so the control is right before anything loads.
+ const [views, setViews] = useState(() =>
+ Object.fromEntries(
+ manifest
+ .filter(entry => entry.views)
+ .map(entry => [
+ entry.id,
+ entry.views.default ?? entry.views.options?.[0] ?? null,
+ ]),
+ ),
+ );
+
+ const setView = (id, value) => setViews(prev => ({ ...prev, [id]: value }));
+
+ return manifest.map(entry => {
+ const { Component, props, views: spec, ...heading } = entry;
+ const view = views[entry.id];
+ const Toggle = spec?.Toggle;
+
+ return {
+ ...heading,
+
+ actions: Toggle ? (
+ setView(entry.id, value)}
+ accent={entry.accent}
+ options={spec.options}
+ />
+ ) : undefined,
+
+ content: (
+
+ ),
+ };
+ });
+}
diff --git a/src/lib/useResource.js b/src/lib/useResource.js
new file mode 100644
index 0000000..51ff7e7
--- /dev/null
+++ b/src/lib/useResource.js
@@ -0,0 +1,52 @@
+/* ═══════════════════════════════════════════════════════════════
+ useResource
+
+ The read hook every page uses:
+
+ const { data, error, loading } = useResource("/events", {
+ fallback: { events: EVENTS }, // the old static module
+ });
+
+ Deliberately small. If the site ever needs mutation, refetch on
+ focus, or pagination, that's the point to reach for TanStack
+ Query rather than growing this file.
+
+ Note the `ignore` flag rather than an AbortController: the
+ request is shared and cached, so cancelling it would throw away
+ work another component may still want. We just stop writing
+ state after unmount.
+ ═══════════════════════════════════════════════════════════════ */
+
+import { useEffect, useState } from "react";
+import { get } from "./api.js";
+
+export function useResource(path, { ttl, fallback } = {}) {
+ // Seed with the fallback so the first paint has content when one
+ // is available, rather than flashing a spinner and then the same
+ // data a moment later.
+ const [state, setState] = useState(() => ({
+ data: fallback,
+ error: null,
+ loading: true,
+ }));
+
+ useEffect(() => {
+ let ignore = false;
+
+ setState((prev) => ({ ...prev, loading: true, error: null }));
+
+ get(path, { ttl, fallback })
+ .then((data) => {
+ if (!ignore) setState({ data, error: null, loading: false });
+ })
+ .catch((error) => {
+ if (!ignore) setState((prev) => ({ ...prev, error, loading: false }));
+ });
+
+ return () => {
+ ignore = true;
+ };
+ }, [path, ttl]); // fallback is intentionally not a dependency
+
+ return state;
+}
diff --git a/src/pages/Chapters.tsx b/src/pages/Chapters.tsx
deleted file mode 100644
index 3016e46..0000000
--- a/src/pages/Chapters.tsx
+++ /dev/null
@@ -1,717 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import {
- GROUPS,
- CHAPTERS,
- groupOf,
- groupSubtext,
- DOMESTIC,
- INTERNATIONAL,
- VIRTUAL,
- SPLITS,
- STATE_GRID,
- MAP_BANDS,
- STATE_NAMES,
- GROUP_BY_ID,
- GROUP_BY_STATE,
- groupsForState,
- CHAPTER_COUNT_BY_STATE,
- chaptersIn,
-} from "./chapters.js";
-
-/* ═══════════════════════════════════════════════════════════════
- LOCAL CHAPTERS
- A tile-grid map on the left, the chapter list on the right.
- Selecting a region on either side filters both.
-
- Why a tile grid rather than a geographic map: every state reads
- at the same size (so Rhode Island is as clickable as Texas), it
- stays legible on a phone, it needs no map library or GeoJSON,
- and a state split between two regions is just a tile painted in
- two colors. If you later want true geography, the swap point is
- — everything else works off the data.
- ═══════════════════════════════════════════════════════════════ */
-
-const TILE = 100;
-const PAD = 5;
-const COLS = 13;
-const ROWS = 7;
-
-// How wide the map + list block runs. The section heading above it
-// stays at max-w-6xl, so this deliberately breaks out past it.
-const CONTENT_MAX = "88rem";
-
-function Tile({
- code,
- x,
- y,
- size,
- width = size,
- label,
- selected,
- hovered,
- setSelected,
- setHovered,
- onPick,
- fontSize = 34,
-}) {
- const split = SPLITS[code];
- const [primary, secondary] = groupsForState(code);
- if (!primary) return null;
-
- const count = CHAPTER_COUNT_BY_STATE[code] || 0;
- const ids = [primary.id, secondary?.id].filter(Boolean);
- const active = ids.includes(selected) || ids.includes(hovered);
- const dimmed = selected && !ids.includes(selected);
- const clipId = `clip-${code}`;
-
- // Clicking a split tile cycles primary → secondary → clear, so
- // both halves are reachable without a second control.
- const cycle = () => {
- onPick?.(code);
- if (!secondary) return setSelected(selected === primary.id ? null : primary.id);
- if (selected === primary.id) return setSelected(secondary.id);
- if (selected === secondary.id) return setSelected(null);
- setSelected(primary.id);
- };
-
- const sliverH = split ? size * split.share : 0;
- const sliverY = split && split.edge === "top" ? y : y + size - sliverH;
-
- return (
- setHovered(primary.id)}
- onMouseLeave={() => setHovered(null)}
- style={{ cursor: "pointer" }}
- opacity={dimmed ? 0.25 : 1}
- className="transition-opacity duration-200"
- >
-
- {STATE_NAMES[code] || code} —{" "}
- {secondary ? `${primary.name} / ${secondary.name}` : primary.name}
- {count ? ` · ${count} chapter${count > 1 ? "s" : ""}` : ""}
-
-
-
-
-
-
-
-
- {split && (
-
- )}
-
-
-
-
-
- {label || code}
-
-
- {count > 0 && (
-
- )}
-
- );
-}
-
-function RegionMap(props) {
- const size = TILE - PAD * 2;
- // `onPick` rides along in props to each Tile via the spread below.
-
- return (
-
- );
-}
-
-function LegendButton({ group, selected, setSelected, hovered, setHovered }) {
- const on = selected === group.id || hovered === group.id;
- return (
-
- );
-}
-
-function Legend(props) {
- const { selected, setSelected } = props;
- const onMap = g => g.states.length > 0 || g.id === "west-central";
- const us = DOMESTIC.filter(onMap);
- const intl = INTERNATIONAL.filter(onMap);
-
- return (
-
- );
-}
-
-function ChapterGrid({ openId, setOpenId }) {
- // Only regions that actually have chapters get a grid.
- const populated = GROUPS.map(g => ({ group: g, list: chaptersIn(g.id) })).filter(
- ({ list }) => list.length > 0
- );
- const empty = GROUPS.filter(g => chaptersIn(g.id).length === 0);
- const openChapter = CHAPTERS.find(c => c.id === openId) || null;
-
- return (
-
- {populated.map(({ group, list }) => (
-
-
-
-
- {group.name}
-
- {list.length}
-
- {groupSubtext(group) && (
-
{groupSubtext(group)}
- )}
-
- {/* Detail panel sits above this region's grid, and only
- when the open chapter belongs to this region. */}
- {openChapter && groupOf(openChapter)?.id === group.id && (
- setOpenId(null)}
- />
- )}
-
-
- );
-}
-
-export default function LocalChapters({ view = "map" }) {
- const [selected, setSelected] = useState(null);
- const [hovered, setHovered] = useState(null);
- const [openId, setOpenId] = useState(null); // no card open on arrival
-
- // The list scrolls itself to whatever the map or legend points at.
- const listRef = useRef(null);
- const groupRefs = useRef({});
- const chapterRefs = useRef({});
-
- const scrollListTo = el => {
- const box = listRef.current;
- if (!box || !el) return;
- // Only when the list is its own scroll area (lg and up). Below
- // that it's stacked under the map and scrolling it would fight
- // the page.
- if (box.scrollHeight <= box.clientHeight) return;
- box.scrollTo({ top: el.offsetTop - 8, behavior: "smooth" });
- };
-
- // Hovering or selecting a region brings that block into view.
- useEffect(() => {
- const id = hovered || selected;
- if (id) scrollListTo(groupRefs.current[id]);
- }, [hovered, selected]);
-
- // Clicking a state jumps to its first chapter when it has one,
- // otherwise to the region it belongs to.
- const pickState = code => {
- const chapter = CHAPTERS.find(c => c.state === code);
- if (chapter && chapterRefs.current[chapter.id]) {
- return scrollListTo(chapterRefs.current[chapter.id]);
- }
- const group = GROUP_BY_STATE[code];
- if (group) scrollListTo(groupRefs.current[group.id]);
- };
-
- const shared = { selected, setSelected, hovered, setHovered };
- const listProps = { ...shared, groupRefs, chapterRefs };
-
- if (view === "grid") {
- return ;
- }
-
- return (
- /* Wider than the section heading above it — the map needs the
- room. CONTENT_MAX is the knob; drop it toward 72rem to pull
- the whole block back in line with the heading. */
-
-
- {/* Map */}
-
-
-
-
- A filled tile means a chapter meets there; a two-tone tile is a
- state shared by two regions. Select a region to filter the list.
-
-
-
- {/* List — h-0 + min-h-full makes this column take its height
- from the map column rather than the other way round, so a
- long list scrolls instead of stretching the section. */}
-
-
US Regions
- {DOMESTIC.map(g => (
-
- ))}
-
-
- International
-
- {INTERNATIONAL.map(g => (
-
- ))}
-
-
- {VIRTUAL.map(g => (
-
- ))}
-
-
-
-
- );
-}
-
diff --git a/src/pages/Community.tsx b/src/pages/Community.tsx
index 65133e1..3a7dc6f 100644
--- a/src/pages/Community.tsx
+++ b/src/pages/Community.tsx
@@ -1,63 +1,69 @@
-import { useState } from "react";
-import PageShell from "../components/PageShell.jsx";
-import LocalChapters, { ChaptersViewToggle } from "./Chapters.jsx";
+import PageShell from "../components/PageShell.tsx";
+import { useSectionManifest } from "../lib/sections.tsx";
+import OrgListMap, { OrgMapToggle } from "./sections/OrgList-Map.tsx";
+import OrgListVertical from "./sections/OrgList-Vertical.tsx";
+import OrgListCards from "./sections/OrgList-Card.tsx";
-/* Section ids match the nav hashes: #local, #virtual, #partners.
- `content` is whatever you want inside — cards, a list, plain
- copy. The empty divs below are placeholders to build into. */
-const LOCAL_ACCENT = "#138ba0";
+/* ═══════════════════════════════════════════════════════════════
+ COMMUNITY PAGE
+
+ Two views of the organizations table. The map is filtered to
+ chapters; the vertical list is filtered to regions. Same rows,
+ same endpoint, different shape on the page.
+ ═══════════════════════════════════════════════════════════════ */
+
+const SECTIONS = [
+ {
+ id: "chapters",
+ title: "Local Chapters",
+ blurb:
+ "Young adult groups meeting in person and online across the movement.",
+ accent: "#138ba0",
+ background: "#eef9fb",
+ Component: OrgListMap,
+ views: { options: ["map", "grid"], default: "map", Toggle: OrgMapToggle },
+ },
+ {
+ id: "regions",
+ title: "Unity Regions",
+ blurb:
+ "The regional organizations that support chapters and host their own gatherings.",
+ accent: "#4a6b72",
+ background: "#ffffff",
+ Component: OrgListVertical,
+ props: {
+ kind: "region",
+ pageLabel: "Region page",
+ groupBy: org => org.details?.scope,
+ groups: [
+ { key: "domestic", title: "US Unity Regions" },
+ { key: "international", title: "International Unity Regions" },
+ ],
+ },
+ },
+ {
+ id: "partners",
+ title: "Partner Organizations",
+ blurb:
+ "Organizations we collaborate with across the Unity movement and beyond.",
+ accent: "#7a5ea8",
+ background: "#eef9fb",
+ Component: OrgListCards,
+ props: {
+ kind: "partner",
+ pageLabel: "Partner page",
+ empty: "· Partner organizations coming soon ·",
+ },
+ },
+];
export default function CommunityPage() {
- // Map or grid for the chapters section. Lives here so the toggle in
- // the heading's action bar and the content below it stay in sync.
- const [chaptersView, setChaptersView] = useState("map");
-
- const sections = [
- {
- id: "local",
- title: "Local Chapters",
- blurb: "Groups meeting in person around the country. Find one near you.",
- accent: LOCAL_ACCENT,
- background: "#eef9fb",
- actions: (
-
- ),
- content: ,
- },
- {
- id: "virtual",
- title: "Virtual Community",
- blurb: "Connect from anywhere, no chapter nearby required.",
- accent: "#aac992",
- background: "#ffffff",
- content: (
-
- {/* Virtual community content */}
-
- ),
- },
- {
- id: "partners",
- title: "Partner Organizations",
- blurb: "Organizations we collaborate with across the Unity movement.",
- accent: "#7a5ea8",
- background: "#eef9fb",
- content: (
-
- {/* Partner organizations content */}
-
- ),
- },
- ];
+ const sections = useSectionManifest(SECTIONS);
return (
);
diff --git a/src/pages/Feedback.tsx b/src/pages/Feedback.tsx
index 8d23da8..c6caf8c 100644
--- a/src/pages/Feedback.tsx
+++ b/src/pages/Feedback.tsx
@@ -1,302 +1,39 @@
-import { useState } from "react";
-import PageShell from "../components/PageShell.jsx";
+import PageShell from "../components/PageShell";
+import FeedbackForm from "./sections/FeedbackForm";
/* ═══════════════════════════════════════════════════════════════
Feedback page.
Section ids match the nav hashes. Right now there's one:
- #website. Add more entries to SECTIONS (bottom of file) as the
- page grows — program feedback, retreat surveys, etc.
+ #website. Add more entries to SECTIONS as the page grows —
+ program feedback, retreat surveys, etc.
- Nothing is persisted. handleSubmit just flips to the thank-you
- panel; the POST goes where the comment marks it.
+ The form itself lives in sections/FeedbackForm, including its
+ own type list, location picker, and the POST to /api/feedback.
+ This file only decides where it sits on the page.
═══════════════════════════════════════════════════════════════ */
const ACCENT = "#138ba0";
-const MUTED = "#4a6b72";
-const MAX_CHARS = 1500;
-
-// Selectable feedback types. Add or reword freely — the grid reflows.
-const FEEDBACK_TYPES = [
- {
- id: "broken",
- label: "Something's broken",
- hint: "A link, image, or button that doesn't work",
- },
- {
- id: "confusing",
- label: "Hard to use",
- hint: "Something you couldn't find or follow",
- },
- {
- id: "outdated",
- label: "Wrong or missing info",
- hint: "Old dates, typos, an event that isn't listed",
- },
- {
- id: "request",
- label: "Feature request",
- hint: "Something you'd like the site to do",
- },
- {
- id: "praise",
- label: "Kind words",
- hint: "Tell us what's working well",
- },
- {
- id: "other",
- label: "Something else",
- hint: "Anything that doesn't fit the boxes above",
- },
-];
-
-/* ── Shared field chrome ─────────────────────────────────────── */
-
-const fieldClass =
- "w-full rounded-xl border border-[#4a6b72]/25 bg-white px-4 py-3 text-[#26454c] " +
- "placeholder:text-[#4a6b72]/50 outline-none transition-colors " +
- "focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25";
-
-function OptionalTag() {
- return (
-
- Optional
-
- );
-}
-
-/* ── Type picker ─────────────────────────────────────────────── */
-
-function TypePicker({ value, onChange }) {
- return (
-
- );
-}
-
-/* ── The form ────────────────────────────────────────────────── */
-
-function WebsiteFeedbackForm() {
- const [type, setType] = useState(null);
- const [message, setMessage] = useState("");
- const [name, setName] = useState("");
- const [email, setEmail] = useState("");
- const [sent, setSent] = useState(false);
-
- const ready = Boolean(type) && message.trim().length > 0;
-
- function handleSubmit(event) {
- event.preventDefault();
- if (!ready) return;
- // TODO: POST { type, message, name, email } somewhere.
- setSent(true);
- }
-
- function reset() {
- setType(null);
- setMessage("");
- setName("");
- setEmail("");
- setSent(false);
- }
-
- if (sent) {
- return (
-
-
- Thanks — we've got it
-
-
- {email
- ? `We'll follow up at ${email} if we have questions.`
- : "You sent this anonymously, so we won't be able to reply — but we read everything that comes in."}
-
-
-
- );
- }
-
- return (
-
- );
-}
-
-/* ── Page ────────────────────────────────────────────────────── */
const SECTIONS = [
{
id: "website",
- title: "Website Feedback and Requests",
+ title: "Website Feedback",
blurb:
"Found a broken link, spotted something out of date, or want the site to do something it doesn't? This form goes straight to the people who maintain it.",
accent: ACCENT,
background: "#eef9fb",
content: (
+ // Narrower than the usual max-w-6xl: full-width form fields
+ // are unpleasant to fill in.
-
+
),
},
];
-export default function FeedbackPage() {
+export default function Feedback() {
return (
. Swap for an API/SQLite fetch when ready —
+// the component only cares about the shape, not where it came from.
+
+export const leadershipGroups = [
+ {
+ id: "director",
+ label: "Retreat Director",
+ accent: "#138ba0",
+ people: [
+ {
+ id: 1,
+ name: "John Doe",
+ title: "Retreat Director",
+ photo: "/people/jordan-ellis.jpg",
+ pronouns: "he/him",
+ birthdate: "1994-03-08",
+ org: { name: "Unity of Des Moines", href: "https://example.org" },
+ bio: [
+ "John has coordinated Midwest retreats since 2019 and now oversees chapter launches across five states.",
+ "He runs the monthly leader call and is the first stop for chapters figuring out their first event.",
+ ],
+ },
+ ],
+ },
+ {
+ id: "team",
+ label: "Retreat Team",
+ people: [
+ {
+ id: 2,
+ name: "Priya Raman",
+ title: "Events Lead",
+ photo: "/people/priya-raman.jpg",
+ pronouns: "she/her",
+ age: 27,
+ org: "Unity Chicago",
+ bio: "Priya plans the summer retreat schedule and handles venue contracts.",
+ },
+ {
+ id: 3,
+ name: "Sam Okafor",
+ title: "Communications",
+ pronouns: "they/them",
+ age: 24,
+ org: "Unity of Milwaukee",
+ bio: "Sam writes the regional newsletter and keeps chapter pages current.",
+ },
+ ],
+ },
+];
+
+export const board = [
+ { id: 10, name: "Jack Doe", title: "NGU Board President" },
+ { id: 11, name: "Jane Doe", title: "NGU Board Treasurer" },
+ { id: 12, name: "Rev. Miranda Koberg", title: "Minister | NGU Board Secretary" },
+];
const SECTIONS = [
{
@@ -9,7 +67,7 @@ const SECTIONS = [
background: "#eef9fb",
content: (
- ) : (
- /* ── FULL: details left, large event logo right ── */
- <>
-
-
-
-
{ev.title}
- {ev.theme && (
-
"{ev.theme}"
- )}
- {ev.date &&
{ev.date}
}
- {ev.location &&
{ev.location}
}
-
-
-
-
-
- {descriptions}
- >
- )}
-
- {/* Footer — mt-auto pins it to the bottom so buttons line up
- across every card in a grid row. */}
- {ev.links.length > 0 ? (
-
- This event has concluded — thank you to everyone who joined us!
-
- ) : (
-
-
- {igHandle
- ? "Registration has not opened yet, follow our instagram for more details."
- : "Registration has not opened yet — check back soon for more details."}
-
+ ) : (
+ /* ── FULL: details left, large event logo right ── */
+ <>
+
+
+
+
{ev.title}
+ {ev.theme && (
+
"{ev.theme}"
+ )}
+ {ev.date_label &&
{ev.date_label}
}
+ {ev.location_label && (
+
{ev.location_label}
+ )}
+
+
+
+
+
+ {descriptions}
+ >
+ )}
+
+ {/* Footer — mt-auto pins it to the bottom so buttons line up
+ across every card in a grid row. */}
+ {links.length > 0 ? (
+
+ This event has concluded — thank you to everyone who joined us!
+
+ ) : (
+
+
+ {igHandle
+ ? "Registration has not opened yet, follow our instagram for more details."
+ : "Registration has not opened yet — check back soon for more details."}
+
+ );
+}
+
+/* ═══════════════════════════════════════════════════════════════
+ TOGGLE — the control for the section heading's action bar
+ ═══════════════════════════════════════════════════════════════ */
+export function EventCardsToggle({ view, setView, accent }) {
+ const btn = active => ({
+ background: active ? accent : "transparent",
+ color: active ? "#ffffff" : accent,
+ });
+
+ return (
+
+
+
+
+ );
+}
+
+/* ═══════════════════════════════════════════════════════════════
+ SECTION
+ Fetches its own slice of the events, then draws it as a carousel
+ or a grid.
+
+ An empty list means three different things now — still loading,
+ failed, or genuinely nothing scheduled — and they read very
+ differently to someone waiting, so they're distinguished rather
+ than all falling through to "coming soon".
+ ═══════════════════════════════════════════════════════════════ */
+export default function EventListCards({
+ section,
+ host,
+ status,
+ view = "carousel",
+ accent = TEAL,
+ defaultColor,
+ empty = "· Events coming soon, stay connected for announcements ·",
+}) {
+ const { events, loading, error } = useEvents({ section, host, status });
+ const cardColor = defaultColor ?? accent;
+
+ const [index, setIndex] = useState(0);
+ const [showPast, setShowPast] = useState(false);
+
+ /* Open on the first upcoming event. The list is empty on the
+ first render, so this can't be a useState initialiser — it has
+ to wait for the data and then run once. Clearing the guard when
+ the list empties means a refetch re-seeds. */
+ const seeded = useRef(false);
+ useEffect(() => {
+ if (events.length === 0) {
+ seeded.current = false;
+ return;
+ }
+ if (seeded.current) return;
+ seeded.current = true;
+
+ const first = events.findIndex(e => e.status === "upcoming");
+ setIndex(first === -1 ? events.length - 1 : first);
+ }, [events]);
+
+ // Grid view splits the list; the carousel still shows everything.
+ const { upcoming, past: pastEvents } = splitByStatus(events);
+
+ const prev = () => setIndex(i => Math.max(0, i - 1));
+ const next = () => setIndex(i => Math.min(events.length - 1, i + 1));
+
+ // Arrows and dots follow the section accent, not the active card.
+ const arrowStyle = enabled => ({
+ border: `1px solid ${accent}`,
+ background: "rgba(255,255,255,0.85)",
+ color: enabled ? accent : "#b8c6c9",
+ cursor: enabled ? "pointer" : "default",
+ opacity: enabled ? 1 : 0.4,
+ });
+
+ const notice = text => (
+
+ );
+}
diff --git a/src/pages/sections/FeedbackForm.tsx b/src/pages/sections/FeedbackForm.tsx
new file mode 100644
index 0000000..06939b1
--- /dev/null
+++ b/src/pages/sections/FeedbackForm.tsx
@@ -0,0 +1,494 @@
+/* ═══════════════════════════════════════════════════════════════
+ WEBSITE FEEDBACK FORM
+
+ Posts to /api/feedback, which is the only public write on the
+ site. The server is the authority on validation; the checks here
+ exist to stop someone hitting a 422 they could have avoided, and
+ the two sets are kept deliberately in step (MIN_MESSAGE, and the
+ ids in FEEDBACK_TYPES).
+
+ The page and section options come from navConfig, so adding a
+ page to the nav adds it to this form too.
+ ═══════════════════════════════════════════════════════════════ */
+
+import { useState } from "react";
+import { post, ApiError } from "../../lib/api.js";
+import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.js";
+
+const ACCENT = "#138ba0";
+const MUTED = "#4a6b72";
+const MAX_CHARS = 1500;
+const MIN_MESSAGE = 10; // matches the server
+
+// Sentinel for "this isn't about one particular page".
+const SITE_WIDE = "site";
+// Sentinel for "this page, but not one section of it".
+const WHOLE_PAGE = "";
+
+// Ids must match TYPES in server/src/routes/feedback.js.
+const FEEDBACK_TYPES = [
+ {
+ id: "broken",
+ label: "Something's broken",
+ hint: "A link, image, or button that doesn't work",
+ },
+ {
+ id: "confusing",
+ label: "Hard to use",
+ hint: "Something you couldn't find or follow",
+ },
+ {
+ id: "outdated",
+ label: "Wrong or missing info",
+ hint: "Old dates, typos, an event that isn't listed",
+ },
+ {
+ id: "request",
+ label: "Feature request",
+ hint: "Something you'd like the site to do",
+ },
+ {
+ id: "praise",
+ label: "Kind words",
+ hint: "Tell us what's working well",
+ },
+ {
+ id: "other",
+ label: "Something else",
+ hint: "Anything that doesn't fit the boxes above",
+ },
+];
+
+/* ── Shared field chrome ─────────────────────────────────────── */
+
+const fieldClass =
+ "w-full rounded-xl border border-[#4a6b72]/25 bg-white px-4 py-3 text-[#26454c] " +
+ "placeholder:text-[#4a6b72]/50 outline-none transition-colors " +
+ "focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25";
+
+const errorFieldClass = "border-[#b3261e] focus:border-[#b3261e] focus:ring-[#b3261e]/20";
+
+const selectClass = `${fieldClass} appearance-none pr-10`;
+
+function OptionalTag() {
+ return (
+
+ Optional
+
+ );
+}
+
+function FieldError({ id, children }) {
+ if (!children) return null;
+ return (
+
+ {children}
+
+ );
+}
+
+// Native select plus a chevron, since appearance-none strips the default one.
+function Select({ id, label, value, onChange, children }) {
+ return (
+
+ Leave this on "Not page-specific" if it applies to the whole site.
+
+
+
+
+
+ {sections.length > 0 && (
+
+ )}
+
+
+ );
+}
+
+// Human-readable version of the picked location, for the thank-you panel.
+function describeLocation(page, section) {
+ if (page === SITE_WIDE) return null;
+ const pageLabel = PAGE_LINKS.find((p) => p.path === page)?.label ?? page;
+ const sectionLabel = (PAGE_SECTIONS[page] ?? []).find(
+ (s) => s.hash === section,
+ )?.label;
+ return sectionLabel ? `${pageLabel} → ${sectionLabel}` : pageLabel;
+}
+
+/* ── The form ────────────────────────────────────────────────── */
+
+export default function FeedbackForm() {
+ const [type, setType] = useState(null);
+ const [page, setPage] = useState(SITE_WIDE);
+ const [section, setSection] = useState(WHOLE_PAGE);
+ const [message, setMessage] = useState("");
+ const [name, setName] = useState("");
+ const [email, setEmail] = useState("");
+
+ // Honeypot: never shown, never filled by a person.
+ const [website, setWebsite] = useState("");
+
+ // idle → sending → sent, or back to idle with an error to show.
+ const [status, setStatus] = useState("idle");
+ const [formError, setFormError] = useState(null);
+ const [fieldErrors, setFieldErrors] = useState({});
+
+ const sending = status === "sending";
+ const ready = Boolean(type) && message.trim().length >= MIN_MESSAGE;
+
+ async function handleSubmit(event) {
+ event.preventDefault();
+ if (!ready || sending) return;
+
+ setStatus("sending");
+ setFormError(null);
+ setFieldErrors({});
+
+ try {
+ await post("/feedback", {
+ feedbackType: type,
+ message: message.trim(),
+ name: name.trim(),
+ email: email.trim(),
+ pagePath: page === SITE_WIDE ? "" : page,
+ sectionId: section, // server strips the leading '#'
+ website,
+ });
+ setStatus("sent");
+ } catch (error) {
+ setStatus("idle");
+ if (error instanceof ApiError) {
+ setFieldErrors(error.fields ?? {});
+ setFormError(
+ error.fields
+ ? "Have another look at the highlighted fields."
+ : error.message,
+ );
+ } else {
+ // Network failure, offline, server down.
+ setFormError("Couldn't reach the server. Try again in a moment.");
+ }
+ }
+ }
+
+ function reset() {
+ setType(null);
+ setPage(SITE_WIDE);
+ setSection(WHOLE_PAGE);
+ setMessage("");
+ setName("");
+ setEmail("");
+ setWebsite("");
+ setStatus("idle");
+ setFormError(null);
+ setFieldErrors({});
+ }
+
+ if (status === "sent") {
+ const where = describeLocation(page, section);
+ return (
+
+
+ Thanks — we've got it
+
+ {where && (
+
+ Filed against {where}.
+
+ )}
+
+ {email
+ ? `We'll follow up at ${email} if we have questions.`
+ : "You sent this anonymously, so we won't be able to reply — but we read everything that comes in."}
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/pages/sections/OrgList-Card.tsx b/src/pages/sections/OrgList-Card.tsx
new file mode 100644
index 0000000..489db48
--- /dev/null
+++ b/src/pages/sections/OrgList-Card.tsx
@@ -0,0 +1,240 @@
+import { useState } from "react";
+import ArrowLink from "../../components/ArrowLink.tsx";
+import {
+ initialsFor,
+ orgPath,
+ useOrganizations,
+} from "../../data/organizations.js";
+
+/* ═══════════════════════════════════════════════════════════════
+ ORGANIZATION LIST — CARDS
+
+ A grid of organization cards. No toggle: this view is the grid,
+ which is why the section that uses it declares no `views` in the
+ page manifest and gets no control in its heading.
+
+
+
+ Where the vertical list expands in place, a card links out. The
+ card carries as much as fits at a glance and the organization's
+ own page carries the rest, so nothing here reads a `blocks`
+ array — only the short card description.
+ ═══════════════════════════════════════════════════════════════ */
+
+const LOGO_BASE = "/org-logos/";
+
+const MUTED = "#7a9299";
+const INK = "#2c4a50";
+const FALLBACK_COLOR = "#4a6b72";
+
+/* Wide enough for a logo beside two lines of text, narrow enough
+ that three fit on a laptop. The grid drops a column rather than
+ squeezing below this. */
+const CARD_MIN = "20rem";
+const GRID_MAX = "88rem";
+
+function OrgMark({ org, color }) {
+ const [failed, setFailed] = useState(false);
+
+ if (org.logo && !failed) {
+ return (
+ setFailed(true)}
+ className="h-14 w-14 object-contain rounded-xl shrink-0"
+ />
+ );
+ }
+
+ return (
+
+ {initialsFor(org.name)}
+
+ );
+}
+
+function OrgCard({ org, accent, pageLabel, siteLabel }) {
+ const color = org.color || accent;
+ const path = orgPath(org);
+
+ // Tagline first, then where they are. Partners often have one and
+ // not the other, so this is the line that's most likely to say
+ // something useful.
+ const subtitle = org.tagline || org.location_label;
+
+ return (
+
+
+
+
+
+
+ {org.name}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+ {/* Top-right rather than in the footer: the footer holds
+ outbound links, and the arrow goes somewhere different
+ in kind — deeper into this site. */}
+ {path && (
+
+ )}
+
+
+ {org.description?.length > 0 && (
+
+ {org.description[0]}
+
+ )}
+
+ {/* mt-auto pins the footer so buttons line up across a row
+ however much text each card carries. */}
+
+ );
+ }
+
+ const sorted = sort ? [...organizations].sort(sort) : organizations;
+
+ // No groups given means one undivided grid.
+ if (!groups || !groupBy) {
+ return shell(
+
+ );
+ }
+
+ return shell(
+ groups.map(group => (
+ groupBy(org) === group.key)}
+ accent={accent}
+ pageLabel={group.pageLabel ?? pageLabel}
+ siteLabel={group.siteLabel ?? siteLabel}
+ />
+ ))
+ );
+}
diff --git a/src/pages/sections/OrgList-Map.tsx b/src/pages/sections/OrgList-Map.tsx
new file mode 100644
index 0000000..52a928c
--- /dev/null
+++ b/src/pages/sections/OrgList-Map.tsx
@@ -0,0 +1,907 @@
+import { useEffect, useRef, useState } from "react";
+import { useCommunity } from "../../data/chapters.js";
+import { Link } from "react-router-dom";
+import ArrowLink from "../../components/ArrowLink.tsx";
+import { initialsFor, orgPath } from "../../data/organizations.js";
+import { AREAS, AREA_NAMES } from "../../data/mapGrid.js";
+
+/* ═══════════════════════════════════════════════════════════════
+ ORGANIZATION LIST — MAP
+
+ Organizations placed on a tile grid: the map on the left, the
+ list on the right, selecting on either side filtering both.
+
+ Filtered to chapters, and unlike the other sections that isn't
+ a prop. A map only makes sense for organizations that sit
+ somewhere, and it reads regions off the same data to colour the
+ tiles — so "plot partners instead" isn't a filter change, it's a
+ different component. Named for the view rather than the filter
+ so that stays visible.
+
+ Why a tile grid rather than a geographic map: every state reads
+ at the same size (so Rhode Island is as clickable as Texas), it
+ stays legible on a phone, it needs no map library or GeoJSON,
+ and a state split between two regions is just a tile painted in
+ two colors. If you later want true geography, the swap point is
+ — everything else works off the data.
+
+ The tile layout comes from mapGrid.js; who paints what comes
+ from the API. A state and the Canada band are the same shape
+ now, a tile with a span, so the map is one loop.
+ ═══════════════════════════════════════════════════════════════ */
+
+const TILE = 100;
+const PAD = 5;
+const COLS = 13;
+const ROWS = 7;
+
+// How wide the map + list block runs. The section heading above it
+// stays at max-w-6xl, so this deliberately breaks out past it.
+const CONTENT_MAX = "88rem";
+
+const LOGO_BASE = "/org-logos/";
+
+const MUTED = "#7a9299";
+const RULE = "#cfe3e7";
+const INK = "#2c4a50";
+const FALLBACK_COLOR = "#4a6b72";
+
+const US_TITLE = "US Unity Regions";
+const INTL_TITLE = "International Unity Regions";
+
+/* ── Body content ──────────────────────────────────────────────
+ A chapter's description is ordered blocks rather than one
+ string, so a heading or a list added later renders instead of
+ vanishing. When organization and person pages arrive this should
+ move to a shared component; small enough to live here until then.
+ ───────────────────────────────────────────────────────────── */
+function Blocks({ blocks = [], color }) {
+ if (blocks.length === 0) return null;
+
+ return (
+
+
+ {/* Two controls, two destinations: the chevron opens the
+ detail panel in place, the arrow leaves for the chapter's
+ own page. Keeping them distinct beats one control that
+ does different things depending on where you click. */}
+
+
+
+ {orgPath(chapter) && (
+
+ )}
+
+
+ );
+}
+
+function ChapterDetail({ chapter, region, onClose }) {
+ /* "Led by" comes from affiliations rather than a text field, so
+ it lists real people and stays empty until they exist. */
+ const leads = (chapter.leadership ?? [])
+ .map(person =>
+ person.title ? `${person.display_name} (${person.title})` : person.display_name
+ )
+ .join(", ");
+
+ const rows = [
+ ["Region", region.name],
+ ["Where", chapter.venue],
+ ["Meets", chapter.meets],
+ ["Led by", leads],
+ ["Since", chapter.started],
+ ].filter(([, v]) => v);
+
+ return (
+
+ )}
+
+ {/* Detail panel sits above this region's grid, and only
+ when the open chapter belongs to this region. */}
+ {openChapter && openChapter.region_id === region.id && (
+ setOpenId(null)}
+ />
+ )}
+
+
+ · Chapters couldn't be loaded just now — please try again shortly ·
+
+ );
+ }
+
+ if (view === "grid") {
+ return (
+
+ );
+ }
+
+ const shared = { selected, setSelected, hovered, setHovered };
+ const onMapIds = new Set(regionAreas.map(a => a.region_id));
+
+ const block = (region, indent) => (
+
+ );
+
+ return (
+ /* Wider than the section heading above it — the map needs the
+ room. CONTENT_MAX is the knob; drop it toward 72rem to pull
+ the whole block back in line with the heading. */
+ shell(
+
+ {/* Map */}
+
+
+
+
+ A filled tile means a chapter meets there; a two-tone tile is a
+ state shared by two Unity regions. Select a region to filter the
+ list.
+
+
+
+ {/* List — h-0 + min-h-full makes this column take its height
+ from the map column rather than the other way round, so a
+ long list scrolls instead of stretching the section. */}
+
+
+ {US_TITLE}
+
+ {domestic.map(r => block(r, true))}
+
+
+ {INTL_TITLE}
+
+ {international.map(r => block(r, true))}
+
+
+ {virtual.map(r => block(r, false))}
+
+
+
+ )
+ );
+}
diff --git a/src/pages/sections/OrgList-Vertical.tsx b/src/pages/sections/OrgList-Vertical.tsx
new file mode 100644
index 0000000..b402dfa
--- /dev/null
+++ b/src/pages/sections/OrgList-Vertical.tsx
@@ -0,0 +1,326 @@
+import { useId, useState } from "react";
+import ArrowLink from "../../components/ArrowLink.tsx";
+import {
+ areasSentence,
+ initialsFor,
+ orgPath,
+ useOrganizations,
+} from "../../data/organizations.js";
+
+/* ═══════════════════════════════════════════════════════════════
+ ORGANIZATION LIST — VERTICAL
+
+ A stack of organizations, each collapsed to a line and
+ expandable for the rest. Every row carries two ways out: an
+ arrow through to that organization's page on this site, and a
+ labelled button out to its own site when it has one.
+
+ Nothing here is region-specific. It asks for a kind and renders
+ what comes back, so the same section lists partners or chapters
+ by changing one prop:
+
+
+
+ org.details?.scope}
+ />
+
+ Kind-specific extras (the areas a region covers, the chapters
+ inside it) come from `details` and render only when present, so
+ a partner row simply doesn't have them.
+ ═══════════════════════════════════════════════════════════════ */
+
+const LOGO_BASE = "/org-logos/";
+
+const MUTED = "#7a9299";
+const RULE = "#cfe3e7";
+const INK = "#2c4a50";
+const FALLBACK_COLOR = "#4a6b72";
+
+/* ── Row button ───────────────────────────────────────────────
+ Deliberately a sibling of the summary button rather than inside
+ it — a link nested in a button is invalid, and a screen reader
+ announces the whole row as one confused control.
+ ───────────────────────────────────────────────────────────── */
+function RowButton({ as: As = "button", color, children, ...rest }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function OrgMark({ org, color }) {
+ const [failed, setFailed] = useState(false);
+
+ if (org.logo && !failed) {
+ return (
+ setFailed(true)}
+ className="h-8 w-8 object-contain shrink-0"
+ />
+ );
+ }
+
+ if (org.color) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {initialsFor(org.name)}
+
+ );
+}
+
+function OrgRow({ org, pageLabel, siteLabel }) {
+ const [open, setOpen] = useState(false);
+ const panelId = useId();
+
+ const color = org.color || FALLBACK_COLOR;
+ const path = orgPath(org);
+
+ // Region extras. Absent for every other kind, and the JSX below
+ // skips them rather than rendering empty headings.
+ const areas = areasSentence(org.details?.areas ?? []);
+ const chapterCount = (org.details?.chapters ?? []).length;
+ const note = org.details?.map_note;
+
+ /* Collapsed, a row says who it is and nothing else — the areas
+ sentence runs long and lives in the panel, where it appears
+ exactly once rather than in both places. */
+ const summary = org.tagline;
+
+ // Something has to be behind the chevron or opening it does nothing.
+ const hasPanel =
+ org.description?.length > 0 || areas || note || org.links?.length > 0;
+
+ return (
+
+
+
+
+
+ {org.website && (
+
+ {siteLabel} ↗
+
+ )}
+
+ {/* Last in the row, so the arrow lines up down the right
+ edge whether or not a row has an outbound site. */}
+ {path && (
+
+ )}
+
+
+
+ {/* 0fr → 1fr animates to the panel's real height, so nobody
+ has to guess a max-height that's wrong the moment a
+ description grows. */}
+