v1.3 - added an sqlite db and built data structure

This commit is contained in:
Zaldimmar 2026-09-25 02:35:46 -05:00
parent b0fba52c0e
commit ff5ea50e7a
37 changed files with 6414 additions and 1988 deletions

18
server/package.json Normal file
View file

@ -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"
}
}

120
server/src/db.js Normal file
View file

@ -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;
}

84
server/src/index.js Normal file
View file

@ -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);
});
});
}

15
server/src/migrate-cli.js Normal file
View file

@ -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}`);

View file

@ -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'));

View file

@ -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;

49
server/src/rateLimit.js Normal file
View file

@ -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();
};
}

View file

@ -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;

View file

@ -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;

398
server/src/seed.js Normal file
View file

@ -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("");

147
server/src/shape.js Normal file
View file

@ -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 };

View file

@ -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 (
<Link
to={to}
aria-label={label}
title={label}
className={`${size} shrink-0 rounded-full flex items-center justify-center transition-transform duration-200 hover:scale-110`}
style={{ border: `1px solid ${color}`, color }}
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</Link>
);
}

View file

@ -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);

View file

@ -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: <PeopleTiles style={{ "--pl-base-w": "150px" }} /> */
.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;
}
}

View file

@ -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:
* <PeopleTiles size="md" groups={staffGroups} />
* <PeopleTiles size="sm" people={volunteers} />
*
* 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 ? <p className="pl__empty">{emptyMessage}</p> : 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 (
<div
ref={rootRef}
className={`pl ${className}`.trim()}
data-size={resolvedSize}
data-overflow={overflow}
data-align={align}
data-tilt={tilt ? "on" : "off"}
style={{
...(scale !== 1 ? { "--pl-scale": scale } : null),
...(accent ? { "--pl-accent": accent } : null),
...style,
}}
onKeyDown={handleKeyDown}
{...rest}
>
<div className="pl__groups">
{resolvedGroups.map((group) => (
<section
key={group.id}
className="pl__group"
style={group.accent ? { "--pl-accent": group.accent } : undefined}
aria-label={group.label || undefined}
>
{(group.label || group.note) && (
<header className="pl__group-head">
{group.label && <h3 className="pl__group-label">{group.label}</h3>}
{group.note && <p className="pl__group-note">{group.note}</p>}
</header>
)}
<ul className="pl__row">
{group.people.map((person, index) => {
const key = keyFor(group, person, index);
const expandable = features.bio && hasBio(person);
const isOpen = expandable && openKey === key;
return (
<li
key={key}
className="pl__item"
style={person.accent ? { "--pl-accent": person.accent } : undefined}
>
<Tile
person={person}
showTitle={features.title}
expandable={expandable}
isOpen={isOpen}
panelId={`${baseId}-bio`}
onToggle={() => toggle(group, person, index)}
/>
</li>
);
})}
</ul>
</section>
))}
</div>
{open && (
<BioPanel
id={`${baseId}-bio`}
person={open.person}
group={open.group}
onClose={() => {
setOpenKey(null);
if (onExpand) onExpand(null, null);
}}
/>
)}
</div>
);
}
function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) {
const content = (
<>
<span className="pl__frame">
<span className="pl__photo">
<Photo src={person.photo} name={person.name} />
</span>
<span className="pl__caption">
<span className="pl__name">{person.name}</span>
{showTitle && person.title && <span className="pl__title">{person.title}</span>}
</span>
{expandable && (
<span className="pl__badge" aria-hidden="true">
<Chevron />
</span>
)}
</span>
</>
);
if (!expandable) {
return <div className="pl__tile">{content}</div>;
}
return (
<button
type="button"
className="pl__tile pl__tile--button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={onToggle}
>
{content}
<span className="pl__sr">{isOpen ? "Hide bio" : "Read bio"}</span>
</button>
);
}
function Photo({ src, name }) {
const [failed, setFailed] = useState(false);
useEffect(() => {
setFailed(false);
}, [src]);
if (!src || failed) {
return (
<span className="pl__initials" aria-hidden="true">
{initials(name)}
</span>
);
}
return (
<img
className="pl__img"
src={src}
alt=""
loading="lazy"
decoding="async"
onError={() => setFailed(true)}
/>
);
}
function BioPanel({ id, person, group, onClose }) {
const age = resolveAge(person);
const org = resolveOrg(person.org);
const paragraphs = Array.isArray(person.bio) ? person.bio : [person.bio];
return (
<div
id={id}
className="pl__bio"
role="region"
aria-label={`About ${person.name}`}
style={person.accent || group?.accent ? { "--pl-accent": person.accent || group.accent } : undefined}
>
<div className="pl__bio-head">
<div>
<p className="pl__bio-name">{person.name}</p>
{person.title && <p className="pl__bio-title">{person.title}</p>}
</div>
<button type="button" className="pl__close" onClick={onClose}>
<span className="pl__sr">Close bio</span>
<span aria-hidden="true">×</span>
</button>
</div>
{(person.pronouns || age != null || org) && (
<dl className="pl__facts">
{person.pronouns && (
<div className="pl__fact">
<dt>Pronouns</dt>
<dd>{person.pronouns}</dd>
</div>
)}
{age != null && (
<div className="pl__fact">
<dt>Age</dt>
<dd>{age}</dd>
</div>
)}
{org && (
<div className="pl__fact">
<dt>Home organization</dt>
<dd>
{org.href ? (
<a href={org.href} target="_blank" rel="noreferrer">
{org.name}
</a>
) : (
org.name
)}
</dd>
</div>
)}
</dl>
)}
{paragraphs.filter(Boolean).map((paragraph, index) => (
<p key={index} className="pl__bio-text">
{paragraph}
</p>
))}
</div>
);
}
function Chevron() {
return (
<svg viewBox="0 0 16 16" width="12" height="12" focusable="false">
<path
d="M4 6.5 8 10.5 12 6.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
/* 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;
}

146
src/data/chapters.js Normal file
View file

@ -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,
]);
}

54
src/data/eventData.js Normal file
View file

@ -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 };
}

View file

@ -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",

174
src/data/mapGrid.js Normal file
View file

@ -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(", ");
}

90
src/data/organizations.js Normal file
View file

@ -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("");
}

View file

@ -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;
}
}

102
src/lib/api.js Normal file
View file

@ -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),
});
}

78
src/lib/sections.tsx Normal file
View file

@ -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 ? (
<Toggle
view={view}
setView={value => setView(entry.id, value)}
accent={entry.accent}
options={spec.options}
/>
) : undefined,
content: (
<Component
{...(props ?? {})}
accent={entry.accent}
{...(spec ? { view } : {})}
/>
),
};
});
}

52
src/lib/useResource.js Normal file
View file

@ -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;
}

View file

@ -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
<RegionMap> — 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 (
<g
onClick={cycle}
onMouseEnter={() => setHovered(primary.id)}
onMouseLeave={() => setHovered(null)}
style={{ cursor: "pointer" }}
opacity={dimmed ? 0.25 : 1}
className="transition-opacity duration-200"
>
<title>
{STATE_NAMES[code] || code} —{" "}
{secondary ? `${primary.name} / ${secondary.name}` : primary.name}
{count ? ` · ${count} chapter${count > 1 ? "s" : ""}` : ""}
</title>
<clipPath id={clipId}>
<rect x={x} y={y} width={width} height={size} rx={14} />
</clipPath>
<g clipPath={`url(#${clipId})`}>
<rect
x={x}
y={y}
width={width}
height={size}
fill={primary.color}
fillOpacity={count ? 1 : active ? 0.5 : 0.28}
className="transition-all duration-200"
/>
{split && (
<rect
x={x}
y={sliverY}
width={width}
height={sliverH}
fill={secondary.color}
fillOpacity={count ? 1 : active ? 0.5 : 0.28}
className="transition-all duration-200"
/>
)}
</g>
<rect
x={x}
y={y}
width={width}
height={size}
rx={14}
fill="none"
stroke={active ? primary.color : "#ffffff"}
strokeOpacity={active ? 1 : 0.55}
strokeWidth={active ? 4 : 2}
className="transition-all duration-200"
/>
<text
x={x + width / 2}
y={y + size / 2 + 2}
textAnchor="middle"
dominantBaseline="middle"
fontSize={fontSize}
fontWeight="800"
fill={count ? "#ffffff" : primary.color}
style={{ pointerEvents: "none" }}
>
{label || code}
</text>
{count > 0 && (
<circle
cx={x + width - 14}
cy={y + 14}
r={7}
fill="#ffffff"
fillOpacity={0.9}
style={{ pointerEvents: "none" }}
/>
)}
</g>
);
}
function RegionMap(props) {
const size = TILE - PAD * 2;
// `onPick` rides along in props to each Tile via the spread below.
return (
<svg
viewBox={`0 0 ${COLS * TILE} ${ROWS * TILE}`}
className="w-full h-auto"
role="group"
aria-label="Chapters by region"
>
{/* Wide areas (Canada) */}
{Object.entries(MAP_BANDS).map(([code, band]) => {
const [c1, c2] = band.cols;
return (
<Tile
key={code}
code={code}
label={band.label}
x={(c1 - 1) * TILE + PAD}
y={(band.row - 1) * TILE + PAD}
size={size}
width={(c2 - c1 + 1) * TILE - PAD * 2}
fontSize={38}
{...props}
/>
);
})}
{/* States */}
{Object.entries(STATE_GRID).map(([code, [col, row]]) => (
<Tile
key={code}
code={code}
x={(col - 1) * TILE + PAD}
y={(row - 1) * TILE + PAD}
size={size}
{...props}
/>
))}
</svg>
);
}
function LegendButton({ group, selected, setSelected, hovered, setHovered }) {
const on = selected === group.id || hovered === group.id;
return (
<button
onClick={() => setSelected(selected === group.id ? null : group.id)}
onMouseEnter={() => setHovered(group.id)}
onMouseLeave={() => setHovered(null)}
onFocus={() => setHovered(group.id)}
onBlur={() => setHovered(null)}
aria-pressed={selected === group.id}
className="flex items-center gap-2 py-1.5 px-3 rounded-lg text-sm font-700 transition-all duration-200"
style={{
border: `1px solid ${group.color}`,
background: on ? group.color : "transparent",
color: on ? "#ffffff" : group.color,
opacity: selected && selected !== group.id ? 0.45 : 1,
}}
>
<span
className="h-2.5 w-2.5 rounded-full"
style={{ background: on ? "#ffffff" : group.color }}
/>
{group.name}
</button>
);
}
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 (
<div className="mt-6">
<p className="text-xs uppercase tracking-wide text-[#7a9299] mb-2">
US Regions
</p>
<div className="flex flex-wrap gap-2">
{us.map(g => (
<LegendButton key={g.id} group={g} {...props} />
))}
</div>
<div className="h-px my-4 bg-[#cfe3e7]" />
<p className="text-xs uppercase tracking-wide text-[#7a9299] mb-2">
International
</p>
<div className="flex flex-wrap gap-2 items-center">
{intl.map(g => (
<LegendButton key={g.id} group={g} {...props} />
))}
{selected && (
<button
onClick={() => setSelected(null)}
className="py-1.5 px-3 rounded-lg text-sm font-700 text-[#4a6b72] underline"
>
Show all
</button>
)}
</div>
</div>
);
}
function GroupBlock({
group,
selected,
setSelected,
setHovered,
indent,
groupRefs,
chapterRefs,
}) {
const chapters = chaptersIn(group.id);
const on = selected === group.id;
return (
<div
ref={el => groupRefs && (groupRefs.current[group.id] = el)}
className="transition-opacity duration-200"
style={{ opacity: selected && !on ? 0.35 : 1, marginLeft: indent ? "0.75rem" : 0 }}
>
<button
onClick={() => setSelected(on ? null : group.id)}
onMouseEnter={() => setHovered(group.id)}
onMouseLeave={() => setHovered(null)}
className="w-full flex items-baseline gap-2 text-left py-2"
>
<span
className="h-3 w-3 rounded-full shrink-0"
style={{ background: group.color }}
/>
<h4
className={`font-800 ${indent ? "text-lg" : "text-xl"}`}
style={{ color: group.color }}
>
{group.name}
</h4>
<span className="text-sm text-[#7a9299] ml-auto">
{chapters.length || "—"}
</span>
</button>
{groupSubtext(group) && (
<p className="text-sm text-[#7a9299] mb-2 ml-5 leading-snug">
{groupSubtext(group)}
</p>
)}
{chapters.length === 0 ? (
<p className="text-sm text-[#7a9299] ml-5 mb-4">
No chapters yet — interested in starting one?
</p>
) : (
<ul className="ml-5 mb-4 flex flex-col gap-3">
{chapters.map(c => (
<li
key={c.id}
ref={el => chapterRefs && (chapterRefs.current[c.id] = el)}
className="pl-3"
style={{ borderLeft: `2px solid ${group.color}` }}
>
<p className="font-700">{c.name}</p>
<p className="text-sm text-[#4a6b72]">
{c.city}
{c.meets ? ` · ${c.meets}` : ""}
</p>
{(c.link || c.contact) && (
<p className="text-sm mt-1 flex gap-4">
{c.link && (
<a
href={c.link}
target="_blank"
rel="noopener noreferrer"
className="font-700 underline"
style={{ color: group.color }}
>
Details
</a>
)}
{c.contact && (
<a
href={`mailto:${c.contact}`}
className="font-700 underline"
style={{ color: group.color }}
>
Contact
</a>
)}
</p>
)}
</li>
))}
</ul>
)}
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
GRID VIEW
One grid per region. Clicking a card's arrow opens a detail
panel directly above that region's grid, framed in the region
color. Selecting another card swaps the panel's contents.
═══════════════════════════════════════════════════════════════ */
const LOGO_BASE = "/chapter-logos/";
/* Logo, or the chapter's initials when there's no file. */
function ChapterLogo({ chapter, color, size = "h-14 w-14" }) {
const [failed, setFailed] = useState(false);
const initials = chapter.name
.replace(/^NGU\s+/i, "")
.split(/\s+/)
.slice(0, 2)
.map(w => w[0])
.join("")
.toUpperCase();
if (chapter.logo && !failed) {
return (
<img
src={`${LOGO_BASE}${chapter.logo}`}
alt={chapter.name}
onError={() => setFailed(true)}
className={`${size} object-contain rounded-xl shrink-0`}
/>
);
}
return (
<div
className={`${size} rounded-xl shrink-0 flex items-center justify-center font-800`}
style={{ border: `1px solid ${color}`, color }}
aria-label={chapter.name}
>
{initials}
</div>
);
}
function ChapterCard({ chapter, color, open, onOpen }) {
return (
<div
className="rounded-2xl p-4 flex items-start gap-4 transition-all duration-200"
style={{
border: `1px solid ${color}`,
background: open ? `${color}14` : "transparent",
}}
>
<ChapterLogo chapter={chapter} color={color} />
<div className="min-w-0 flex-1">
<p className="font-700 leading-tight">{chapter.name}</p>
<p className="text-sm text-[#4a6b72]">{chapter.city}</p>
{chapter.meets && (
<p className="text-sm text-[#7a9299]">{chapter.meets}</p>
)}
</div>
<button
onClick={onOpen}
aria-expanded={open}
aria-label={`${open ? "Hide" : "View"} details for ${chapter.name}`}
className="shrink-0 h-9 w-9 rounded-full flex items-center justify-center transition-transform duration-200 hover:scale-110"
style={{
border: `1px solid ${color}`,
color,
transform: open ? "rotate(90deg)" : "none",
}}
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 6l6 6-6 6" />
</svg>
</button>
</div>
);
}
function ChapterDetail({ chapter, group, onClose }) {
const rows = [
["Region", group.name],
["Where", chapter.where],
["Meets", chapter.meets],
["Led by", chapter.leads],
["Since", chapter.started],
].filter(([, v]) => v);
return (
<div
className="rounded-2xl p-6 mb-6"
style={{ border: `2px solid ${group.color}`, background: `${group.color}0f` }}
>
<div className="flex items-start gap-4">
<ChapterLogo chapter={chapter} color={group.color} size="h-20 w-20" />
<div className="min-w-0 flex-1">
<p className="text-2xl font-900 leading-tight">{chapter.name}</p>
<p className="text-[#4a6b72]">{chapter.city}</p>
</div>
<button
onClick={onClose}
aria-label="Close details"
className="shrink-0 h-8 w-8 rounded-full flex items-center justify-center text-lg font-700 transition-transform duration-200 hover:scale-110"
style={{ border: `1px solid ${group.color}`, color: group.color }}
>
×
</button>
</div>
{chapter.about && (
<p className="mt-4 leading-relaxed text-[#2c4a50]">{chapter.about}</p>
)}
{rows.length > 0 && (
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm">
{rows.map(([label, value]) => (
<div key={label} className="flex gap-2">
<dt className="text-[#7a9299] shrink-0">{label}</dt>
<dd className="font-700 text-[#2c4a50]">{value}</dd>
</div>
))}
</dl>
)}
{(chapter.link || chapter.contact) && (
<div className="mt-5 flex flex-wrap gap-3">
{chapter.link && (
<a
href={chapter.link}
target="_blank"
rel="noopener noreferrer"
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
style={{ border: `1px solid ${group.color}`, color: group.color }}
>
Visit page
</a>
)}
{chapter.contact && (
<a
href={`mailto:${chapter.contact}`}
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
style={{ border: `1px solid ${group.color}`, color: group.color }}
>
Get in touch
</a>
)}
</div>
)}
</div>
);
}
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 (
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
{populated.map(({ group, list }) => (
<section key={group.id} className="mb-12">
<div className="flex items-baseline gap-3 mb-1">
<span
className="h-3 w-3 rounded-full shrink-0"
style={{ background: group.color }}
/>
<h3 className="text-2xl font-800" style={{ color: group.color }}>
{group.name}
</h3>
<span className="text-sm text-[#7a9299]">{list.length}</span>
</div>
{groupSubtext(group) && (
<p className="text-sm text-[#7a9299] mb-5 ml-6">{groupSubtext(group)}</p>
)}
{/* Detail panel sits above this region's grid, and only
when the open chapter belongs to this region. */}
{openChapter && groupOf(openChapter)?.id === group.id && (
<ChapterDetail
chapter={openChapter}
group={group}
onClose={() => setOpenId(null)}
/>
)}
<div
className="grid gap-4 items-start"
style={{
gridTemplateColumns: "repeat(auto-fill, minmax(min(22rem, 100%), 1fr))",
}}
>
{list.map(c => (
<ChapterCard
key={c.id}
chapter={c}
color={group.color}
open={openId === c.id}
onOpen={() => setOpenId(openId === c.id ? null : c.id)}
/>
))}
</div>
</section>
))}
{empty.length > 0 && (
<p className="text-sm text-[#7a9299]">
No chapters yet in {empty.map(g => g.name).join(", ")} — interested in
starting one?
</p>
)}
</div>
);
}
/* The control for the section heading's action bar. */
export function ChaptersViewToggle({ view, setView, accent }) {
const btn = active => ({
background: active ? accent : "transparent",
color: active ? "#ffffff" : accent,
});
return (
<div
className="inline-flex rounded-xl overflow-hidden shrink-0"
style={{ border: `1px solid ${accent}` }}
role="group"
aria-label="Change how chapters are displayed"
>
{[
["map", "Map"],
["grid", "Grid"],
].map(([id, label]) => (
<button
key={id}
onClick={() => setView(id)}
aria-pressed={view === id}
className="py-2 px-4 font-700 text-sm transition-colors duration-200"
style={btn(view === id)}
>
{label}
</button>
))}
</div>
);
}
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 <ChapterGrid openId={openId} setOpenId={setOpenId} />;
}
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. */
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
<div className="grid grid-cols-1 lg:grid-cols-[1.45fr_1fr] gap-10 items-stretch">
{/* Map */}
<div>
<RegionMap {...shared} onPick={pickState} />
<Legend {...shared} />
<p className="text-sm text-[#7a9299] mt-4">
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.
</p>
</div>
{/* 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. */}
<div
ref={listRef}
className="relative lg:h-0 lg:min-h-full overflow-y-auto pr-2"
>
<h3 className="text-xl font-800 mb-1 text-[#4a6b72]">US Regions</h3>
{DOMESTIC.map(g => (
<GroupBlock key={g.id} group={g} indent {...listProps} />
))}
<h3 className="text-xl font-800 mt-6 mb-1 pt-4 border-t border-[#cfe3e7] text-[#4a6b72]">
International
</h3>
{INTERNATIONAL.map(g => (
<GroupBlock key={g.id} group={g} indent {...listProps} />
))}
<div className="mt-6 pt-4 border-t border-[#cfe3e7]">
{VIRTUAL.map(g => (
<GroupBlock key={g.id} group={g} {...listProps} />
))}
</div>
</div>
</div>
</div>
);
}

View file

@ -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: (
<ChaptersViewToggle
view={chaptersView}
setView={setChaptersView}
accent={LOCAL_ACCENT}
/>
),
content: <LocalChapters view={chaptersView} />,
},
{
id: "virtual",
title: "Virtual Community",
blurb: "Connect from anywhere, no chapter nearby required.",
accent: "#aac992",
background: "#ffffff",
content: (
<div className="max-w-6xl mx-auto px-6">
{/* Virtual community content */}
</div>
),
},
{
id: "partners",
title: "Partner Organizations",
blurb: "Organizations we collaborate with across the Unity movement.",
accent: "#7a5ea8",
background: "#eef9fb",
content: (
<div className="max-w-6xl mx-auto px-6">
{/* Partner organizations content */}
</div>
),
},
];
const sections = useSectionManifest(SECTIONS);
return (
<PageShell
title="Community"
intro="Ways to connect with Next Generation of Unity, in your area, online, and through the organizations we work alongside."
intro="Chapters, regions and the people who make up Next Generation of Unity."
sections={sections}
/>
);

View file

@ -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 (
<span className="ml-2 rounded-full bg-[#eef9fb] px-2 py-0.5 text-xs font-medium text-[#138ba0]">
Optional
</span>
);
}
/* ── Type picker ─────────────────────────────────────────────── */
function TypePicker({ value, onChange }) {
return (
<fieldset>
<legend className="text-base font-semibold text-[#26454c]">
What kind of feedback is this?
</legend>
<p className="mt-1 text-sm" style={{ color: MUTED }}>
Pick the closest fit. It helps us route it to the right person.
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{FEEDBACK_TYPES.map((type) => {
const selected = value === type.id;
return (
<label key={type.id} className="cursor-pointer">
<input
type="radio"
name="feedbackType"
value={type.id}
checked={selected}
onChange={() => onChange(type.id)}
className="peer sr-only"
/>
<span
className={
"flex h-full flex-col gap-1 rounded-xl border p-4 transition-colors " +
"peer-focus-visible:ring-2 peer-focus-visible:ring-[#138ba0]/40 " +
(selected
? "border-[#138ba0] bg-[#eef9fb]"
: "border-[#4a6b72]/20 bg-white hover:border-[#138ba0]/50")
}
>
<span className="flex items-start justify-between gap-2">
<span className="font-semibold text-[#26454c]">
{type.label}
</span>
<span
aria-hidden="true"
className={
"mt-0.5 h-4 w-4 shrink-0 rounded-full border-2 transition-colors " +
(selected
? "border-[#138ba0] bg-[#138ba0] ring-2 ring-inset ring-white"
: "border-[#4a6b72]/30")
}
/>
</span>
<span className="text-sm leading-snug" style={{ color: MUTED }}>
{type.hint}
</span>
</span>
</label>
);
})}
</div>
</fieldset>
);
}
/* ── 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 (
<div
aria-live="polite"
className="rounded-2xl border border-[#138ba0]/20 bg-white p-8 sm:p-10"
>
<h3 className="text-2xl font-bold" style={{ color: ACCENT }}>
Thanks — we've got it
</h3>
<p className="mt-3 max-w-prose" style={{ color: MUTED }}>
{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."}
</p>
<button
type="button"
onClick={reset}
className="mt-6 rounded-full border border-[#138ba0] px-5 py-2.5 font-semibold text-[#138ba0] transition-colors hover:bg-[#eef9fb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40"
>
Send more feedback
</button>
</div>
);
}
return (
<form
onSubmit={handleSubmit}
noValidate
className="rounded-2xl border border-[#138ba0]/20 bg-white p-6 sm:p-8"
>
<TypePicker value={type} onChange={setType} />
{/* Message */}
<div className="mt-10">
<label
htmlFor="feedback-message"
className="text-base font-semibold text-[#26454c]"
>
Tell us more
</label>
<p className="mt-1 text-sm" style={{ color: MUTED }}>
The page you were on and what you expected to happen are the two most
useful things you can give us.
</p>
<textarea
id="feedback-message"
rows={7}
maxLength={MAX_CHARS}
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="I was looking for the summer retreat dates on the Events page and…"
className={`mt-4 resize-y ${fieldClass}`}
/>
<div className="mt-2 text-right text-xs tabular-nums" style={{ color: MUTED }}>
{message.length} / {MAX_CHARS}
</div>
</div>
{/* Optional contact details */}
<div className="mt-8 rounded-xl border border-dashed border-[#4a6b72]/30 bg-[#eef9fb]/60 p-5 sm:p-6">
<div className="flex flex-wrap items-center">
<h3 className="text-base font-semibold text-[#26454c]">
Your details
</h3>
<OptionalTag />
</div>
<p className="mt-1 max-w-prose text-sm" style={{ color: MUTED }}>
Leave these blank and your feedback comes through anonymously. Fill
them in only if you'd like a reply — we won't add you to any list.
</p>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<div>
<label
htmlFor="feedback-name"
className="block text-sm font-medium text-[#26454c]"
>
Name <span className="font-normal text-[#4a6b72]">(optional)</span>
</label>
<input
id="feedback-name"
type="text"
autoComplete="name"
value={name}
onChange={(e) => setName(e.target.value)}
className={`mt-2 ${fieldClass}`}
/>
</div>
<div>
<label
htmlFor="feedback-email"
className="block text-sm font-medium text-[#26454c]"
>
Email <span className="font-normal text-[#4a6b72]">(optional)</span>
</label>
<input
id="feedback-email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={`mt-2 ${fieldClass}`}
/>
</div>
</div>
</div>
{/* Submit */}
<div className="mt-8 flex flex-wrap items-center gap-4">
<button
type="submit"
disabled={!ready}
className="rounded-full bg-[#138ba0] px-7 py-3 font-semibold text-white transition-colors hover:bg-[#0f7183] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40 disabled:cursor-not-allowed disabled:bg-[#4a6b72]/25 disabled:text-white/80"
>
Send feedback
</button>
{!ready && (
<p className="text-sm" style={{ color: MUTED }}>
Choose a type and write a note to send.
</p>
)}
</div>
</form>
);
}
/* ── 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.
<div className="mx-auto max-w-3xl px-6">
<WebsiteFeedbackForm />
<FeedbackForm />
</div>
),
},
];
export default function FeedbackPage() {
export default function Feedback() {
return (
<PageShell
title="Feedback"

View file

@ -1,4 +1,62 @@
import PageShell from "../components/PageShell.jsx";
import PageShell from "../components/PageShell.tsx";
import PeopleTiles from "../components/PeopleTiles.tsx";
// Shape reference for <PeopleTiles />. 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: (
<div className="max-w-6xl mx-auto px-6">
{/* Board & staff content */}
<PeopleTiles size="sm" people={board} overflow="scroll" />
</div>
),
},
@ -21,7 +79,7 @@ const SECTIONS = [
background: "#ffffff",
content: (
<div className="max-w-6xl mx-auto px-6">
{/* Retreat team content */}
<PeopleTiles size="lg" groups={leadershipGroups} />
</div>
),
},

View file

@ -1,590 +1,61 @@
import { useState } from "react";
import PageShell from "../components/PageShell.tsx"
import eventsData from "../events.js";
import PageShell from "../components/PageShell.tsx";
import { useSectionManifest } from "../lib/sections.tsx";
import EventListCards, { EventCardsToggle } from "./sections/EventList-Cards.tsx";
const LOGO_FILES = import.meta.glob("../assets/event-logos/*.svg", {
eager: true,
import: "default",
});
/* ═══════════════════════════════════════════════════════════════
RETREATS PAGE
const LOGOS = Object.fromEntries(
Object.entries(LOGO_FILES).map(([path, src]) => [path.split("/").pop(), src])
);
Three bands, all the same view of the same table with a
different filter. The page declares them and does nothing else —
each section fetches its own slice.
const logoSrc = file => (file && LOGOS[file]) || null;
To reorder the page, move a line. To add a different kind of
section, add an entry with another Component.
═══════════════════════════════════════════════════════════════ */
// Used when an event doesn't name its own org_logo.
const DEFAULT_ORG_LOGO = null;
const CARD_VIEWS = { options: ["carousel", "grid"], Toggle: EventCardsToggle };
const SECTIONS = [
{
id: "national",
title: "National Retreats",
blurb: "Our flagship gatherings, open to young adults across the country.",
accent: "#138ba0",
background: "#eef9fb",
Component: EventListCards,
props: { section: "national" },
views: { ...CARD_VIEWS, default: "carousel" },
},
{
id: "regional",
title: "Regional Retreats",
blurb: "Smaller gatherings hosted by regions throughout the year.",
accent: "#aac992",
background: "#ffffff",
Component: EventListCards,
props: { section: "regional" },
views: { ...CARD_VIEWS, default: "grid" },
},
{
id: "partner",
title: "Partner Events",
blurb: "Events hosted by organizations we collaborate with.",
accent: "#7a5ea8",
background: "#eef9fb",
Component: EventListCards,
props: { section: "partner" },
views: { ...CARD_VIEWS, default: "grid" },
},
];
export default function RetreatsPage() {
const sections = useSectionManifest(SECTIONS);
/* An <img> that removes itself if the file 404s. */
function Logo({ file, alt = "", className }) {
const [failed, setFailed] = useState(false);
const src = logoSrc(file);
if (!src || failed) return null;
return (
<img
src={src}
alt={alt}
className={className}
onError={() => setFailed(true)}
<PageShell
title="Retreats"
intro="Retreats and gatherings hosted by Next Generation of Unity and our partners throughout the year."
sections={sections}
/>
);
}
const TEAL = "#138ba0"; // page heading + last-resort card color
/* ── Grid view ────────────────────────────────────────────────
The grid fits as many columns as the screen allows, with no
breakpoints: each card is at least CARD_MIN wide, and the
columns share whatever space is left over.
CARD_MIN narrowest a card may get before dropping a column
MAX_COLS ceiling on columns, so cards don't get absurd on
very wide monitors. GRID_MAX is derived from it.
───────────────────────────────────────────────────────────── */
const CARD_MIN = "32rem";
const MAX_COLS = 4;
const GRID_MAX = `calc(${MAX_COLS} * 38rem)`;
const GRID_TEMPLATE = `repeat(auto-fill, minmax(min(${CARD_MIN}, 100%), 1fr))`;
/* Collapsed past-events strip: how much shows, and the downward
fade applied while it's collapsed. */
const PAST_PEEK = "10rem";
const PAST_FADE = "linear-gradient(to bottom, black 0%, black 45%, transparent 100%)";
/* ── Carousel sizing ──────────────────────────────────────────
CARD the card itself (your original max-w-3xl)
GAP space between cards — raise to push neighbors out
FADE_DIST how far past the card's edge neighbors fade to nothing
───────────────────────────────────────────────────────────── */
const CARD = "min(48rem, 90vw)";
const GAP = "5rem";
const SLIDE = `calc(${CARD} + ${GAP})`;
const HALF_SLIDE = `calc(${CARD} / 2)`;
const FADE_DIST = "18rem";
const EDGE_FADE = `linear-gradient(to right,
transparent calc(50% - (${HALF_SLIDE} + ${FADE_DIST})),
black calc(50% - ${HALF_SLIDE}),
black calc(50% + ${HALF_SLIDE}),
transparent calc(50% + (${HALF_SLIDE} + ${FADE_DIST})))`;
/* ── Grid-card layout ─────────────────────────────────────────
Container queries, not media queries: each card reacts to its
OWN width, which is what we need now that the column count is
dynamic and the viewport says nothing about how wide a card is.
Wide card NGU logo + details on the left, event logo on the
right, descriptions full-width underneath.
Narrow card logos on their own row, then details, then
descriptions — three stacked rows.
───────────────────────────────────────────────────────────── */
const CARD_STYLES = `
.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";
}
@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;
}
}
.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; }
`;
const InstagramIcon = ({ id = "ig-gradient" }) => (
<svg
viewBox="0 0 24 24"
className="ig-icon w-6 h-6"
style={{ "--ig-fill": `url(#${id})` }}
>
<defs>
<linearGradient id={id} x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#FEDA75" />
<stop offset="25%" stopColor="#FA7E1E" />
<stop offset="50%" stopColor="#D62976" />
<stop offset="75%" stopColor="#962FBF" />
<stop offset="100%" stopColor="#4F5BD5" />
</linearGradient>
</defs>
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
</svg>
);
/* ═══════════════════════════════════════════════════════════════
EVENT CARD — one component, two sizes.
compact=false → the full card used in the carousel
compact=true → the grid card: details left, text right, and a
footer pinned to the bottom so every card in a
row is the same height with aligned buttons.
═══════════════════════════════════════════════════════════════ */
export function Card({
ev,
defaultColor = TEAL,
accent = TEAL,
compact = false,
interactive = true,
}) {
const past = ev.status === "past";
const color = ev.color || defaultColor;
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
const eventLogo = ev.image;
const igHandle = ev.instagram || null;
const igUrl = igHandle
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
: null;
const descriptions = (
<>
{ev.desc_a && (
<p className={`mb-2 leading-relaxed ${compact ? "text-base" : ""}`}>
{ev.desc_a}
</p>
)}
{ev.desc_b && (
<p className={`leading-relaxed ${compact ? "text-base" : ""}`}>
{ev.desc_b}
</p>
)}
</>
);
return (
<div
className={`rounded-3xl text-black overflow-hidden shadow-2xl h-full flex flex-col ${
compact ? "ev-card" : ""
}`}
style={{
border: `1px solid ${color}`,
background: ev.gradient || undefined,
filter: past ? "saturate(0.75)" : "none",
pointerEvents: interactive ? "auto" : "none",
}}
>
<div className={`flex-1 flex flex-col ${compact ? "p-8" : "p-10"}`}>
{compact ? (
/* ── GRID CARD: layout driven by .ev-grid container queries ── */
<div className="ev-grid mb-2">
<Logo file={orgLogo} className="ev-ngu h-11 w-auto mb-4" />
<div className="ev-info">
<h3 className="text-3xl font-900 leading-tight">{ev.title}</h3>
{ev.theme && (
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
)}
{ev.date && <p className="text-xl">{ev.date}</p>}
{ev.location && <p className="text-xl">{ev.location}</p>}
</div>
<Logo
file={eventLogo}
alt={ev.title}
className="ev-image w-full h-auto object-contain"
/>
<div className="ev-desc">{descriptions}</div>
</div>
) : (
/* ── FULL: details left, large event logo right ── */
<>
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 mb-4">
<div className="md:col-span-2">
<Logo file={orgLogo} className="h-15 w-auto mb-6" />
<h3 className="text-4xl font-900">{ev.title}</h3>
{ev.theme && (
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
)}
{ev.date && <p className="text-2xl">{ev.date}</p>}
{ev.location && <p className="text-2xl">{ev.location}</p>}
</div>
<div className="md:col-span-1 flex items-center justify-end">
<Logo
file={eventLogo}
alt={ev.title}
className="w-full h-auto max-h-72 object-contain"
/>
</div>
</div>
{descriptions}
</>
)}
{/* Footer — mt-auto pins it to the bottom so buttons line up
across every card in a grid row. */}
{ev.links.length > 0 ? (
<div className={`flex flex-wrap justify-center gap-2 mt-auto ${compact ? "pt-6" : "pt-8 gap-3"}`}>
{ev.links.map(item => (
<a
key={item.label}
href={item.link}
className={`rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center ${
compact ? "py-2.5 px-5" : "py-2.5 px-6"
}`}
style={{ border: `1px solid ${color}` }}
>
{item.label}
</a>
))}
</div>
) : past ? (
<p
className={`mt-auto text-center font-600 ${compact ? "pt-8" : "pt-8"}`}
style={{ color: accent }}
>
This event has concluded — thank you to everyone who joined us!
</p>
) : (
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
<p className="text-center font-600" style={{ color: accent }}>
{igHandle
? "Registration has not opened yet, follow our instagram for more details."
: "Registration has not opened yet — check back soon for more details."}
</p>
{igHandle && (
<a
href={igUrl}
target="_blank"
rel="noopener noreferrer"
className={`ig-link mt-4 w-fit mx-auto flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] ${
compact ? "gap-3 py-2.5 px-5" : "gap-3 py-3 px-6"
}`}
style={{ border: `1px solid ${accent}`, color: accent }}
>
<InstagramIcon id={`ig-${ev.id}`} />
{igHandle}
</a>
)}
</div>
)}
</div>
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
VIEW TOGGLE — carousel / grid segmented control
═══════════════════════════════════════════════════════════════ */
function ViewToggle({ view, setView, accent }) {
const btn = active => ({
background: active ? accent : "transparent",
color: active ? "#ffffff" : accent,
});
return (
<div
className="inline-flex rounded-xl overflow-hidden shrink-0"
style={{ border: `1px solid ${accent}` }}
role="group"
aria-label="Change how events are displayed"
>
<button
onClick={() => setView("carousel")}
aria-pressed={view === "carousel"}
className="flex items-center gap-2 py-2 px-4 font-700 text-sm transition-colors duration-200"
style={btn(view === "carousel")}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="7" y="5" width="10" height="14" rx="2" />
<path d="M3.5 8v8M20.5 8v8" />
</svg>
Carousel
</button>
<button
onClick={() => setView("grid")}
aria-pressed={view === "grid"}
className="flex items-center gap-2 py-2 px-4 font-700 text-sm transition-colors duration-200"
style={btn(view === "grid")}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</svg>
Grid
</button>
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
SECTION — heading + toggle, then carousel or grid
═══════════════════════════════════════════════════════════════ */
export function CardSection({ section, view }) {
const {
events,
accent,
defaultColor,
} = section;
const startIndex = (() => {
const i = events.findIndex(e => e.status === "upcoming");
return i === -1 ? Math.max(0, events.length - 1) : i;
})();
const [index, setIndex] = useState(startIndex);
const [showPast, setShowPast] = useState(false);
// Grid view splits the list; the carousel still shows everything.
const upcoming = events.filter(e => e.status !== "past");
const pastEvents = events.filter(e => e.status === "past");
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,
});
return (
<div className="overflow-hidden">
{events.length === 0 ? (
<p className="max-w-6xl mx-auto px-6 font-600" style={{ color: accent }}>
· Events coming soon, stay connected for announcements ·
</p>
) : view === "grid" ? (
/* ── GRID VIEW — upcoming first, past events collapsed below ── */
<div className="mx-auto px-8 md:px-12 lg:px-16" style={{ maxWidth: GRID_MAX }}>
{upcoming.length > 0 && (
<div
className="grid gap-6 items-stretch"
style={{ gridTemplateColumns: GRID_TEMPLATE }}
>
{upcoming.map(ev => (
<Card
key={ev.id}
ev={ev}
defaultColor={defaultColor}
accent={accent}
compact
/>
))}
</div>
)}
{pastEvents.length > 0 && (
<div className="mt-12">
<h3
className="text-2xl font-800 mb-6"
style={{ color: accent, opacity: 0.85 }}
>
Past Events
</h3>
{/* Collapsed: clipped to PAST_PEEK and faded out at the
bottom. Expanded: full height, no mask. */}
<div
className="relative transition-all duration-500 ease-out"
style={{
maxHeight: showPast ? "none" : PAST_PEEK,
overflow: showPast ? "visible" : "hidden",
maskImage: showPast ? "none" : PAST_FADE,
WebkitMaskImage: showPast ? "none" : PAST_FADE,
}}
>
<div
className="grid gap-6 items-stretch"
style={{ gridTemplateColumns: GRID_TEMPLATE }}
aria-hidden={!showPast}
>
{pastEvents.map(ev => (
<Card
key={ev.id}
ev={ev}
defaultColor={defaultColor}
accent={accent}
compact
interactive={showPast}
/>
))}
</div>
</div>
<div className="flex justify-center mt-6">
<button
onClick={() => setShowPast(v => !v)}
aria-expanded={showPast}
className="flex items-center gap-2 py-2.5 px-6 rounded-xl font-700 transition-all duration-200 hover:scale-105"
style={{ border: `1px solid ${accent}`, color: accent }}
>
{showPast
? "Hide past events"
: `See past events (${pastEvents.length})`}
<svg
viewBox="0 0 24 24"
className="h-4 w-4 transition-transform duration-300"
style={{ transform: showPast ? "rotate(180deg)" : "none" }}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
</div>
</div>
)}
</div>
) : (
/* ── CAROUSEL VIEW ── */
<>
<div className="relative mb-6">
<div
className="overflow-hidden"
style={{ maskImage: EDGE_FADE, WebkitMaskImage: EDGE_FADE }}
>
<div
className="flex items-stretch transition-transform duration-500 ease-out"
style={{ transform: `translateX(calc(50% - ${index + 0.5} * ${SLIDE}))` }}
>
{events.map((ev, i) => {
const active = i === index;
return (
<div
key={ev.id}
className="shrink-0"
style={{
width: SLIDE,
padding: `0 calc(${GAP} / 2)`,
cursor: active ? "default" : "pointer",
}}
onClick={() => !active && setIndex(i)}
aria-hidden={!active}
>
<Card
ev={ev}
defaultColor={defaultColor}
accent={accent}
interactive={active}
/>
</div>
);
})}
</div>
</div>
{events.length > 1 && (
<>
<button
onClick={prev}
disabled={index === 0}
aria-label="Previous event"
className="absolute left-2 md:left-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
style={arrowStyle(index > 0)}
>
‹
</button>
<button
onClick={next}
disabled={index === events.length - 1}
aria-label="Next event"
className="absolute right-2 md:right-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
style={arrowStyle(index < events.length - 1)}
>
›
</button>
</>
)}
</div>
{events.length > 1 && (
<div className="flex justify-center gap-2">
{events.map((e, i) => (
<button
key={e.id}
onClick={() => setIndex(i)}
aria-label={`Go to ${e.title}`}
className="h-2.5 rounded-full transition-all duration-200"
style={{
width: i === index ? "1.5rem" : "0.625rem",
background: i === index ? accent : "#b8c6c9",
}}
/>
))}
</div>
)}
</>
)}
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
RETREATS PAGE — hands the card sections to the shared PageShell
so this page matches Community, Leadership and Resources.
View state lives here, one entry per section, so the toggle in a
section's action bar and the cards below it stay in sync.
═══════════════════════════════════════════════════════════════ */
export default function RetreatsPage() {
const [views, setViews] = useState(() =>
Object.fromEntries(
eventsData.sections.map(s => [s.id, s.defaultView || "carousel"])
)
);
const setView = (id, value) =>
setViews(prev => ({ ...prev, [id]: value }));
const shellSections = eventsData.sections.map(section => {
const view = views[section.id];
return {
id: section.id,
title: section.title,
blurb: section.blurb,
accent: section.accent,
background: section.background,
actions: section.events.length > 0 && (
<ViewToggle
view={view}
setView={value => setView(section.id, value)}
accent={section.accent}
/>
),
content: <CardSection section={section} view={view} />,
};
});
return (
<>
<style>{CARD_STYLES}</style>
<PageShell
title="Retreats"
intro="Retreats and gatherings hosted by Next Generation of Unity and our partners throughout the year."
sections={shellSections}
/>
</>
);
}

View file

@ -1,353 +0,0 @@
/* ═══════════════════════════════════════════════════════════════
LOCAL CHAPTER DATA
GROUPS every group that can appear in the list: the 7
domestic regions, the international ones, virtual
STATE_GRID where each state sits on the tile map
SPLITS states shared by two regions (see below)
CHAPTERS the chapters themselves
To add a chapter: add an object to CHAPTERS. Its group is looked
up from its state, so the map and the list can't drift apart —
except in a split state, where the chapter names its region.
═══════════════════════════════════════════════════════════════ */
/* ── Groups ───────────────────────────────────────────────────
scope "domestic" → its own block in the list
"international" → nested under the International heading
"virtual" → region-agnostic, listed last
states which tiles it owns on the map ([] = not on the map)
───────────────────────────────────────────────────────────── */
export const GROUPS = [
{
id: "eastern",
name: "Eastern",
color: "#7a5ea8",
scope: "domestic",
states: [
"WV", "VA", "MD", "DC", "NY", "PA", "ME",
"NJ", "DE", "CT", "VT", "MA", "NH", "RI",
],
},
{
id: "great-lakes",
name: "Great Lakes",
color: "#4a6fa5",
scope: "domestic",
states: ["MN", "IA", "WI", "IL", "IN", "MI", "OH", "KY"],
},
{
id: "northwest",
name: "Northwest",
color: "#138ba0",
scope: "domestic",
states: ["AK", "WA", "OR", "ID", "MT"],
},
{
id: "south-central",
name: "South Central",
color: "#d4703f",
scope: "domestic",
states: [
"ND", "SD", "WY", "NE", "CO", "NM",
"TX", "OK", "KS", "MO", "AR", "LA",
],
},
{
id: "southeast",
name: "Southeast",
color: "#c25e8b",
scope: "domestic",
states: ["TN", "MS", "AL", "GA", "NC", "SC", "FL"],
},
{
id: "southwest",
name: "Southwest",
color: "#e0a32e",
scope: "domestic",
states: ["AZ"],
},
{
id: "west-central",
name: "West Central",
color: "#7a9e5a",
scope: "domestic",
states: [],
},
/* ── International ── */
{
id: "africa",
name: "Africa",
color: "#c98a3c",
scope: "international",
states: [],
note: "Not yet shown on the map.",
},
{
id: "canada",
name: "Canada",
color: "#a8577a",
scope: "international",
states: ["CANADA"], // a map area, not a US state
},
{
id: "pacific-rim",
name: "Pacific Rim",
color: "#2f9e8f",
scope: "international",
states: ["HI"],
note: "Hawai'i and the wider Pacific.",
},
{
id: "uk",
name: "United Kingdom",
color: "#6b7fb5",
scope: "international",
states: [],
note: "Not yet shown on the map.",
},
/* ── Region-agnostic ── */
{
id: "virtual",
name: "Virtual",
color: "#8aa1a6",
scope: "virtual",
states: [],
note: "Join from anywhere.",
},
];
/* ── Split states ─────────────────────────────────────────────
Some states belong to two regions. The tile is drawn in the
primary region's color, then `share` of it — measured from
`edge` — is painted in the secondary region's color.
primary which group owns the rest of the state
secondary which group owns the sliver
edge "top" | "bottom" — where the sliver sits
share 0-1, how much of the tile the sliver takes
primaryNote how that share reads in the region subtext
secondaryNote "
───────────────────────────────────────────────────────────── */
export const SPLITS = {
CA: {
primary: "southwest", secondary: "west-central",
edge: "top", share: 0.5,
primaryNote: "south", secondaryNote: "north",
},
NV: {
primary: "southwest", secondary: "west-central",
edge: "top", share: 0.5,
primaryNote: "south", secondaryNote: "north",
},
UT: {
primary: "southwest", secondary: "northwest",
edge: "top", share: 0.28,
primaryNote: "excl. Salt Lake City", secondaryNote: "Salt Lake City area",
},
IA: {
primary: "great-lakes", secondary: "south-central",
edge: "bottom", share: 0.22,
primaryNote: "most", secondaryNote: "small part",
},
};
/* ── Tile map layout ──────────────────────────────────────────
Each state is one square on a 12 × 7 grid, placed roughly where
it sits on a real map. A tile grid instead of true geography:
every state reads at the same size (Rhode Island included), it
stays legible on a phone, and there's no map library to load.
[column, row], both 1-based.
CANADA is a wide band across the top — not a state, so it's
listed separately in MAP_BANDS below.
───────────────────────────────────────────────────────────── */
export 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],
};
/* Wide areas that span several columns. */
export const MAP_BANDS = {
CANADA: { cols: [3, 10], row: 1, label: "Canada" },
};
export const STATE_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",
};
/* ── Chapters ─────────────────────────────────────────────────
name what the group calls itself
city city, or the country for international groups
state two-letter code, "CANADA", or null
group set this instead of `state` for a group with no map
area (africa, uk, virtual)
region ONLY needed in a split state (CA, NV, UT, IA) — say
which of the two regions the chapter belongs to
meets when it gathers
link optional URL
contact optional email
Used by the grid view and its detail panel:
logo filename in public/chapter-logos/ (e.g. "lynnwood.svg").
null → the card shows the chapter's initials instead.
about a paragraph for the expanded panel
leads who runs it
where meeting address or platform
started optional "Since 2021"
───────────────────────────────────────────────────────────── */
export const CHAPTERS = [
{
id: "lynnwood",
name: "NGU Lynnwood",
city: "Lynnwood, WA",
state: "WA",
meets: "2nd Sundays, 6:00pm",
link: null,
contact: null,
logo: null,
about:
"A monthly gathering for young adults in south Snohomish County — potlucks, discussion nights, and service projects.",
leads: "Chapter lead name",
where: "Unity in Lynnwood",
started: null,
},
{
id: "bellingham",
name: "NGU Bellingham",
city: "Bellingham, WA",
state: "WA",
meets: "Monthly — dates vary",
link: null,
contact: null,
logo: null,
about: "Short description of what this chapter does and who it's for.",
leads: null,
where: null,
started: null,
},
{
id: "unity-village",
name: "NGU Unity Village",
city: "Unity Village, MO",
state: "MO",
meets: "Monthly",
link: null,
contact: null,
},
{
id: "bay-area",
name: "NGU Bay Area",
city: "Oakland, CA",
state: "CA",
region: "west-central", // split state — northern California
meets: "Monthly",
link: null,
contact: null,
},
{
id: "toronto",
name: "NGU Toronto",
city: "Toronto, Ontario",
state: "CANADA",
meets: "Monthly",
link: null,
contact: null,
},
{
id: "discord",
name: "NGU Discord",
city: "Online",
state: null,
group: "virtual",
meets: "Always open",
link: "https://discord.com/invite/AtngzpqaX5",
contact: null,
},
];
/* ── Lookups built from the tables above ───────────────────── */
export const GROUP_BY_ID = Object.fromEntries(GROUPS.map(g => [g.id, g]));
// "WA" → the Northwest group. Split states resolve to their primary.
export const GROUP_BY_STATE = {
...Object.fromEntries(GROUPS.flatMap(g => g.states.map(s => [s, g]))),
...Object.fromEntries(
Object.entries(SPLITS).map(([s, sp]) => [s, GROUP_BY_ID[sp.primary]])
),
};
// Both groups that touch a tile, primary first
export const groupsForState = code => {
const split = SPLITS[code];
if (split) return [GROUP_BY_ID[split.primary], GROUP_BY_ID[split.secondary]];
const g = GROUP_BY_STATE[code];
return g ? [g] : [];
};
// Which group a chapter belongs to
export const groupOf = c =>
GROUP_BY_ID[c.region] || GROUP_BY_ID[c.group] || GROUP_BY_STATE[c.state];
export const chaptersIn = groupId =>
CHAPTERS.filter(c => groupOf(c)?.id === groupId);
// { WA: 2, MO: 1, ... } — how many chapters each map area has
export const CHAPTER_COUNT_BY_STATE = CHAPTERS.reduce((acc, c) => {
if (c.state) acc[c.state] = (acc[c.state] || 0) + 1;
return acc;
}, {});
/* "AK, ID, MT, OR, UT (Salt Lake City area), WA" — the states a
region covers, split states annotated, alphabetical. Groups with
no states (Africa, UK, Virtual) fall back to their own `note`. */
export const statesLabel = group => {
// split states are listed below with their annotation, not here
const whole = group.states.filter(s => s !== "CANADA" && !SPLITS[s]);
const shared = Object.entries(SPLITS)
.filter(([, sp]) => sp.primary === group.id || sp.secondary === group.id)
.map(([code, sp]) =>
sp.primary === group.id
? `${code} (${sp.primaryNote})`
: `${code} (${sp.secondaryNote})`
);
return [...whole, ...shared].sort().join(", ");
};
export const groupSubtext = group => {
const states = statesLabel(group);
if (states && group.note) return `${states} · ${group.note}`;
return states || group.note || "";
};
const byName = (a, b) => a.name.localeCompare(b.name);
export const DOMESTIC = GROUPS.filter(g => g.scope === "domestic").sort(byName);
export const INTERNATIONAL = GROUPS.filter(g => g.scope === "international").sort(byName);
export const VIRTUAL = GROUPS.filter(g => g.scope === "virtual");

View file

@ -0,0 +1,568 @@
import { useEffect, useRef, useState } from "react";
import { splitByStatus, useEvents } from "../../data/eventData.js";
/* ═══════════════════════════════════════════════════════════════
EVENT LIST — CARDS
A band of event cards, as a peek carousel or a grid. Self
contained: give it a filter and it fetches, so the same section
appears three times on Retreats with a different `section` each
time, and could appear on a region's page with `host` instead.
<EventListCards section="national" view="carousel" />
<EventListCards host="northwest" view="grid" />
`view` and `accent` come from the page's section manifest.
═══════════════════════════════════════════════════════════════ */
const LOGO_FILES = import.meta.glob("../../assets/event-logos/*.svg", {
eager: true,
import: "default",
});
const LOGOS = Object.fromEntries(
Object.entries(LOGO_FILES).map(([path, src]) => [path.split("/").pop(), src])
);
const logoSrc = file => (file && LOGOS[file]) || null;
/* Last resort only. The API already falls back to the host
organization's logo when an event doesn't name its own, so this
fires only for an event with no host at all. */
const DEFAULT_ORG_LOGO = null;
/* An <img> that removes itself if the file 404s. */
function Logo({ file, alt = "", className }) {
const [failed, setFailed] = useState(false);
const src = logoSrc(file);
if (!src || failed) return null;
return (
<img
src={src}
alt={alt}
className={className}
onError={() => setFailed(true)}
/>
);
}
const TEAL = "#138ba0"; // last-resort card color
const CARD_BG = "#ffffff"; // sits under every card, gradient or not
/* ── Grid view ────────────────────────────────────────────────
The grid fits as many columns as the screen allows, with no
breakpoints: each card is at least CARD_MIN wide, and the
columns share whatever space is left over.
CARD_MIN narrowest a card may get before dropping a column
MAX_COLS ceiling on columns, so cards don't get absurd on
very wide monitors. GRID_MAX is derived from it.
───────────────────────────────────────────────────────────── */
const CARD_MIN = "32rem";
const MAX_COLS = 4;
const GRID_MAX = `calc(${MAX_COLS} * 38rem)`;
const GRID_TEMPLATE = `repeat(auto-fill, minmax(min(${CARD_MIN}, 100%), 1fr))`;
/* Collapsed past-events strip: how much shows, and the downward
fade applied while it's collapsed. */
const PAST_PEEK = "10rem";
const PAST_FADE = "linear-gradient(to bottom, black 0%, black 45%, transparent 100%)";
/* ── Carousel sizing ──────────────────────────────────────────
CARD the card itself
GAP space between cards — raise to push neighbors out
FADE_DIST how far past the card's edge neighbors fade to nothing
───────────────────────────────────────────────────────────── */
const CARD = "min(48rem, 90vw)";
const GAP = "5rem";
const SLIDE = `calc(${CARD} + ${GAP})`;
const HALF_SLIDE = `calc(${CARD} / 2)`;
const FADE_DIST = "18rem";
const EDGE_FADE = `linear-gradient(to right,
transparent calc(50% - (${HALF_SLIDE} + ${FADE_DIST})),
black calc(50% - ${HALF_SLIDE}),
black calc(50% + ${HALF_SLIDE}),
transparent calc(50% + (${HALF_SLIDE} + ${FADE_DIST})))`;
/* Card layout CSS lives in index.css under the .ev- prefix. */
const InstagramIcon = ({ id = "ig-gradient" }) => (
<svg
viewBox="0 0 24 24"
className="ig-icon w-6 h-6"
style={{ "--ig-fill": `url(#${id})` }}
>
<defs>
<linearGradient id={id} x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#FEDA75" />
<stop offset="25%" stopColor="#FA7E1E" />
<stop offset="50%" stopColor="#D62976" />
<stop offset="75%" stopColor="#962FBF" />
<stop offset="100%" stopColor="#4F5BD5" />
</linearGradient>
</defs>
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
</svg>
);
/* ═══════════════════════════════════════════════════════════════
EVENT CARD — one component, two sizes.
compact=false → the full card used in the carousel
compact=true → the grid card: details left, text right, and a
footer pinned to the bottom so every card in a
row is the same height with aligned buttons.
Exported because it takes an event and nothing else: a region's
page or a home strip can render one without the section around
it.
Fields arrive pre-resolved from the API — `color` is the event's
own or its host's, `status` is derived from the dates when it
isn't set — so nothing here reimplements those rules.
═══════════════════════════════════════════════════════════════ */
export function Card({
ev,
defaultColor = TEAL,
accent = TEAL,
compact = false,
interactive = true,
}) {
const past = ev.status === "past";
const color = ev.color || defaultColor;
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
const eventLogo = ev.event_logo;
const links = ev.links ?? [];
const igHandle = ev.instagram || null;
const igUrl = igHandle
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
: null;
/* An ordered array, so a card can carry one paragraph or five
without the component changing. */
const descriptions = (ev.description ?? []).map((text, i) => (
<p
key={i}
className={`leading-relaxed ${compact ? "text-base" : ""} ${
i > 0 ? "mt-2" : ""
}`}
>
{text}
</p>
));
return (
<div
className={`rounded-3xl text-black overflow-hidden shadow-2xl h-full flex flex-col ${
compact ? "ev-card" : ""
}`}
style={{
border: `1px solid ${color}`,
background: ev.gradient ? `${ev.gradient}, ${CARD_BG}` : CARD_BG,
filter: past ? "saturate(0.75)" : "none",
pointerEvents: interactive ? "auto" : "none",
}}
>
<div className={`flex-1 flex flex-col ${compact ? "p-8" : "p-10"}`}>
{compact ? (
/* ── GRID CARD: layout driven by .ev-grid container queries ── */
<div className="ev-grid mb-2">
<Logo file={orgLogo} className="ev-ngu h-11 w-auto mb-4" />
<div className="ev-info">
<h3 className="text-3xl font-900 leading-tight">{ev.title}</h3>
{ev.theme && (
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
)}
{ev.date_label && <p className="text-xl">{ev.date_label}</p>}
{ev.location_label && (
<p className="text-xl">{ev.location_label}</p>
)}
</div>
<Logo
file={eventLogo}
alt={ev.title}
className="ev-image w-full h-auto object-contain"
/>
<div className="ev-desc">{descriptions}</div>
</div>
) : (
/* ── FULL: details left, large event logo right ── */
<>
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 mb-4">
<div className="md:col-span-2">
<Logo file={orgLogo} className="h-15 w-auto mb-6" />
<h3 className="text-4xl font-900">{ev.title}</h3>
{ev.theme && (
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
)}
{ev.date_label && <p className="text-2xl">{ev.date_label}</p>}
{ev.location_label && (
<p className="text-2xl">{ev.location_label}</p>
)}
</div>
<div className="md:col-span-1 flex items-center justify-end">
<Logo
file={eventLogo}
alt={ev.title}
className="w-full h-auto max-h-72 object-contain"
/>
</div>
</div>
{descriptions}
</>
)}
{/* Footer — mt-auto pins it to the bottom so buttons line up
across every card in a grid row. */}
{links.length > 0 ? (
<div className={`flex flex-wrap justify-center gap-2 mt-auto ${compact ? "pt-6" : "pt-8 gap-3"}`}>
{links.map(item => (
<a
key={item.label}
href={item.url}
className={`rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center ${
compact ? "py-2.5 px-5" : "py-2.5 px-6"
}`}
style={{ border: `1px solid ${color}` }}
>
{item.label}
</a>
))}
</div>
) : past ? (
<p
className="mt-auto text-center font-600 pt-8"
style={{ color: accent }}
>
This event has concluded — thank you to everyone who joined us!
</p>
) : (
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
<p className="text-center font-600" style={{ color: accent }}>
{igHandle
? "Registration has not opened yet, follow our instagram for more details."
: "Registration has not opened yet — check back soon for more details."}
</p>
{igHandle && (
<a
href={igUrl}
target="_blank"
rel="noopener noreferrer"
className={`ig-link mt-4 w-fit mx-auto flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] ${
compact ? "gap-3 py-2.5 px-5" : "gap-3 py-3 px-6"
}`}
style={{ border: `1px solid ${accent}`, color: accent }}
>
<InstagramIcon id={`ig-${ev.id}`} />
{igHandle}
</a>
)}
</div>
)}
</div>
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
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 (
<div
className="inline-flex rounded-xl overflow-hidden shrink-0"
style={{ border: `1px solid ${accent}` }}
role="group"
aria-label="Change how events are displayed"
>
<button
onClick={() => setView("carousel")}
aria-pressed={view === "carousel"}
className="flex items-center gap-2 py-2 px-4 font-700 text-sm transition-colors duration-200"
style={btn(view === "carousel")}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="7" y="5" width="10" height="14" rx="2" />
<path d="M3.5 8v8M20.5 8v8" />
</svg>
Carousel
</button>
<button
onClick={() => setView("grid")}
aria-pressed={view === "grid"}
className="flex items-center gap-2 py-2 px-4 font-700 text-sm transition-colors duration-200"
style={btn(view === "grid")}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</svg>
Grid
</button>
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
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 => (
<p className="max-w-6xl mx-auto px-6 font-600" style={{ color: accent }}>
{text}
</p>
);
if (loading && events.length === 0) {
return (
<div className="overflow-hidden">
<div
className="mx-auto px-8 md:px-12 lg:px-16"
style={{ maxWidth: GRID_MAX }}
>
<div className="skeleton" role="status" aria-label="Loading events" />
</div>
</div>
);
}
if (error && events.length === 0) {
return (
<div className="overflow-hidden">
{notice("· Events couldn't be loaded just now — please try again shortly ·")}
</div>
);
}
return (
<div className="overflow-hidden">
{events.length === 0 ? (
notice(empty)
) : view === "grid" ? (
/* ── GRID VIEW — upcoming first, past events collapsed below ── */
<div className="mx-auto px-8 md:px-12 lg:px-16" style={{ maxWidth: GRID_MAX }}>
{upcoming.length > 0 && (
<div
className="grid gap-6 items-stretch"
style={{ gridTemplateColumns: GRID_TEMPLATE }}
>
{upcoming.map(ev => (
<Card
key={ev.id}
ev={ev}
defaultColor={cardColor}
accent={accent}
compact
/>
))}
</div>
)}
{pastEvents.length > 0 && (
<div className="mt-12">
<h3
className="text-2xl font-800 mb-6"
style={{ color: accent, opacity: 0.85 }}
>
Past Events
</h3>
{/* Collapsed: clipped to PAST_PEEK and faded out at the
bottom. Expanded: full height, no mask. */}
<div
className="relative transition-all duration-500 ease-out"
style={{
maxHeight: showPast ? "none" : PAST_PEEK,
overflow: showPast ? "visible" : "hidden",
maskImage: showPast ? "none" : PAST_FADE,
WebkitMaskImage: showPast ? "none" : PAST_FADE,
}}
>
<div
className="grid gap-6 items-stretch"
style={{ gridTemplateColumns: GRID_TEMPLATE }}
aria-hidden={!showPast}
>
{pastEvents.map(ev => (
<Card
key={ev.id}
ev={ev}
defaultColor={cardColor}
accent={accent}
compact
interactive={showPast}
/>
))}
</div>
</div>
<div className="flex justify-center mt-6">
<button
onClick={() => setShowPast(v => !v)}
aria-expanded={showPast}
className="flex items-center gap-2 py-2.5 px-6 rounded-xl font-700 transition-all duration-200 hover:scale-105"
style={{ border: `1px solid ${accent}`, color: accent }}
>
{showPast
? "Hide past events"
: `See past events (${pastEvents.length})`}
<svg
viewBox="0 0 24 24"
className="h-4 w-4 transition-transform duration-300"
style={{ transform: showPast ? "rotate(180deg)" : "none" }}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6 9l6 6-6 6" />
</svg>
</button>
</div>
</div>
)}
</div>
) : (
/* ── CAROUSEL VIEW ── */
<>
<div className="relative mb-6">
<div
className="overflow-hidden"
style={{ maskImage: EDGE_FADE, WebkitMaskImage: EDGE_FADE }}
>
<div
className="flex items-stretch transition-transform duration-500 ease-out"
style={{ transform: `translateX(calc(50% - ${index + 0.5} * ${SLIDE}))` }}
>
{events.map((ev, i) => {
const active = i === index;
return (
<div
key={ev.id}
className="shrink-0"
style={{
width: SLIDE,
padding: `0 calc(${GAP} / 2)`,
cursor: active ? "default" : "pointer",
}}
onClick={() => !active && setIndex(i)}
aria-hidden={!active}
>
<Card
ev={ev}
defaultColor={cardColor}
accent={accent}
interactive={active}
/>
</div>
);
})}
</div>
</div>
{events.length > 1 && (
<>
<button
onClick={prev}
disabled={index === 0}
aria-label="Previous event"
className="absolute left-2 md:left-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
style={arrowStyle(index > 0)}
>
‹
</button>
<button
onClick={next}
disabled={index === events.length - 1}
aria-label="Next event"
className="absolute right-2 md:right-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
style={arrowStyle(index < events.length - 1)}
>
›
</button>
</>
)}
</div>
{events.length > 1 && (
<div className="flex justify-center gap-2">
{events.map((e, i) => (
<button
key={e.id}
onClick={() => setIndex(i)}
aria-label={`Go to ${e.title}`}
className="h-2.5 rounded-full transition-all duration-200"
style={{
width: i === index ? "1.5rem" : "0.625rem",
background: i === index ? accent : "#b8c6c9",
}}
/>
))}
</div>
)}
</>
)}
</div>
);
}

View file

@ -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 (
<span className="ml-2 rounded-full bg-[#eef9fb] px-2 py-0.5 text-xs font-medium text-[#138ba0]">
Optional
</span>
);
}
function FieldError({ id, children }) {
if (!children) return null;
return (
<p id={id} className="mt-2 text-sm text-[#b3261e]">
{children}
</p>
);
}
// Native select plus a chevron, since appearance-none strips the default one.
function Select({ id, label, value, onChange, children }) {
return (
<div>
<label htmlFor={id} className="block text-sm font-medium text-[#26454c]">
{label}
</label>
<div className="relative mt-2">
<select
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
className={selectClass}
>
{children}
</select>
<svg
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: MUTED }}
>
<path
d="M5 8l5 5 5-5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
);
}
/* ── Type picker ─────────────────────────────────────────────── */
function TypePicker({ value, onChange }) {
return (
<fieldset>
<legend className="text-base font-semibold text-[#26454c]">
What kind of feedback is this?
</legend>
<p className="mt-1 text-sm" style={{ color: MUTED }}>
Pick the closest fit. It helps us route it to the right person.
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{FEEDBACK_TYPES.map((type) => {
const selected = value === type.id;
return (
<label key={type.id} className="cursor-pointer">
<input
type="radio"
name="feedbackType"
value={type.id}
checked={selected}
onChange={() => onChange(type.id)}
className="peer sr-only"
/>
<span
className={
"flex h-full flex-col gap-1 rounded-xl border p-4 transition-colors " +
"peer-focus-visible:ring-2 peer-focus-visible:ring-[#138ba0]/40 " +
(selected
? "border-[#138ba0] bg-[#eef9fb]"
: "border-[#4a6b72]/20 bg-white hover:border-[#138ba0]/50")
}
>
<span className="flex items-start justify-between gap-2">
<span className="font-semibold text-[#26454c]">{type.label}</span>
<span
aria-hidden="true"
className={
"mt-0.5 h-4 w-4 shrink-0 rounded-full border-2 transition-colors " +
(selected
? "border-[#138ba0] bg-[#138ba0] ring-2 ring-inset ring-white"
: "border-[#4a6b72]/30")
}
/>
</span>
<span className="text-sm leading-snug" style={{ color: MUTED }}>
{type.hint}
</span>
</span>
</label>
);
})}
</div>
</fieldset>
);
}
/* ── Where on the site ───────────────────────────────────────── */
function LocationPicker({ page, section, onPageChange, onSectionChange }) {
const sections = page === SITE_WIDE ? [] : PAGE_SECTIONS[page] ?? [];
return (
<div>
<div className="flex flex-wrap items-center">
<h3 className="text-base font-semibold text-[#26454c]">
Where did you run into it?
</h3>
<OptionalTag />
</div>
<p className="mt-1 max-w-prose text-sm" style={{ color: MUTED }}>
Leave this on "Not page-specific" if it applies to the whole site.
</p>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<Select
id="feedback-page"
label="Page"
value={page}
onChange={(next) => {
onPageChange(next);
onSectionChange(WHOLE_PAGE); // sections belong to a page
}}
>
<option value={SITE_WIDE}>Not page-specific</option>
{PAGE_LINKS.map((p) => (
<option key={p.path} value={p.path}>
{p.label}
</option>
))}
</Select>
{sections.length > 0 && (
<Select
id="feedback-section"
label="Section of that page"
value={section}
onChange={onSectionChange}
>
<option value={WHOLE_PAGE}>The page as a whole</option>
{sections.map((s) => (
<option key={s.hash} value={s.hash}>
{s.label}
</option>
))}
</Select>
)}
</div>
</div>
);
}
// 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 (
<div
aria-live="polite"
className="rounded-2xl border border-[#138ba0]/20 bg-white p-8 sm:p-10"
>
<h3 className="text-2xl font-bold" style={{ color: ACCENT }}>
Thanks — we've got it
</h3>
{where && (
<p className="mt-3 text-sm" style={{ color: MUTED }}>
Filed against {where}.
</p>
)}
<p className="mt-3 max-w-prose" style={{ color: MUTED }}>
{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."}
</p>
<button
type="button"
onClick={reset}
className="mt-6 rounded-full border border-[#138ba0] px-5 py-2.5 font-semibold text-[#138ba0] transition-colors hover:bg-[#eef9fb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40"
>
Send more feedback
</button>
</div>
);
}
return (
<form
onSubmit={handleSubmit}
noValidate
className="rounded-2xl border border-[#138ba0]/20 bg-white p-6 sm:p-8"
>
<TypePicker value={type} onChange={setType} />
<div className="mt-10">
<LocationPicker
page={page}
section={section}
onPageChange={setPage}
onSectionChange={setSection}
/>
</div>
{/* Message */}
<div className="mt-10">
<label
htmlFor="feedback-message"
className="text-base font-semibold text-[#26454c]"
>
Tell us more
</label>
<p className="mt-1 text-sm" style={{ color: MUTED }}>
What you expected to happen, and what happened instead, is the most
useful thing you can give us.
</p>
<textarea
id="feedback-message"
rows={7}
maxLength={MAX_CHARS}
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="I was looking for the summer retreat dates and…"
aria-invalid={Boolean(fieldErrors.message)}
aria-describedby={fieldErrors.message ? "feedback-message-error" : undefined}
className={`mt-4 resize-y ${fieldClass} ${
fieldErrors.message ? errorFieldClass : ""
}`}
/>
<div className="mt-2 flex items-start justify-between gap-4">
<FieldError id="feedback-message-error">{fieldErrors.message}</FieldError>
<span
className="ml-auto shrink-0 text-xs tabular-nums"
style={{ color: MUTED }}
>
{message.length} / {MAX_CHARS}
</span>
</div>
</div>
{/* Optional contact details */}
<div className="mt-8 rounded-xl border border-dashed border-[#4a6b72]/30 bg-[#eef9fb]/60 p-5 sm:p-6">
<div className="flex flex-wrap items-center">
<h3 className="text-base font-semibold text-[#26454c]">Your details</h3>
<OptionalTag />
</div>
<p className="mt-1 max-w-prose text-sm" style={{ color: MUTED }}>
Leave these blank and your feedback comes through anonymously. Fill
them in only if you'd like a reply — we won't add you to any list.
</p>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<div>
<label
htmlFor="feedback-name"
className="block text-sm font-medium text-[#26454c]"
>
Name <span className="font-normal text-[#4a6b72]">(optional)</span>
</label>
<input
id="feedback-name"
type="text"
autoComplete="name"
value={name}
onChange={(e) => setName(e.target.value)}
className={`mt-2 ${fieldClass}`}
/>
</div>
<div>
<label
htmlFor="feedback-email"
className="block text-sm font-medium text-[#26454c]"
>
Email <span className="font-normal text-[#4a6b72]">(optional)</span>
</label>
<input
id="feedback-email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={Boolean(fieldErrors.email)}
aria-describedby={fieldErrors.email ? "feedback-email-error" : undefined}
className={`mt-2 ${fieldClass} ${
fieldErrors.email ? errorFieldClass : ""
}`}
/>
<FieldError id="feedback-email-error">{fieldErrors.email}</FieldError>
</div>
</div>
</div>
{/* Honeypot. Off-screen rather than display:none, since bots
skip hidden inputs. Never announced, never tabbable. */}
<div aria-hidden="true" className="absolute -left-[9999px] h-0 w-0 overflow-hidden">
<label htmlFor="feedback-website">Website</label>
<input
id="feedback-website"
type="text"
tabIndex={-1}
autoComplete="off"
value={website}
onChange={(e) => setWebsite(e.target.value)}
/>
</div>
{/* Submit */}
{formError && (
<p
role="alert"
className="mt-8 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
>
{formError}
</p>
)}
<div className="mt-6 flex flex-wrap items-center gap-4">
<button
type="submit"
disabled={!ready || sending}
className="rounded-full bg-[#138ba0] px-7 py-3 font-semibold text-white transition-colors hover:bg-[#0f7183] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40 disabled:cursor-not-allowed disabled:bg-[#4a6b72]/25 disabled:text-white/80"
>
{sending ? "Sending…" : "Send feedback"}
</button>
{!ready && !sending && (
<p className="text-sm" style={{ color: MUTED }}>
Choose a type and write at least a sentence to send.
</p>
)}
</div>
</form>
);
}

View file

@ -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.
<OrgListCards kind="partner" pageLabel="Partner page" />
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 (
<img
src={`${LOGO_BASE}${org.logo}`}
alt=""
onError={() => setFailed(true)}
className="h-14 w-14 object-contain rounded-xl shrink-0"
/>
);
}
return (
<span
className="h-14 w-14 rounded-xl shrink-0 flex items-center justify-center font-800"
style={{ border: `1px solid ${color}`, color }}
aria-hidden="true"
>
{initialsFor(org.name)}
</span>
);
}
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 (
<div
className="rounded-2xl p-6 h-full flex flex-col transition-transform duration-200 hover:scale-[1.01]"
style={{ border: `1px solid ${color}`, background: "#ffffff" }}
>
<div className="flex items-start gap-4">
<OrgMark org={org} color={color} />
<div className="min-w-0 flex-1">
<h3 className="text-xl font-800 leading-tight" style={{ color }}>
{org.name}
</h3>
{subtitle && (
<p className="text-sm mt-0.5" style={{ color: MUTED }}>
{subtitle}
</p>
)}
</div>
{/* 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 && (
<ArrowLink
to={path}
label={`${org.name} — ${pageLabel}`}
color={color}
/>
)}
</div>
{org.description?.length > 0 && (
<p
className="mt-4 text-sm leading-relaxed line-clamp-4"
style={{ color: INK }}
>
{org.description[0]}
</p>
)}
{/* mt-auto pins the footer so buttons line up across a row
however much text each card carries. */}
<div className="mt-auto pt-5 flex flex-wrap gap-2">
{org.website && (
<a
href={org.website}
target="_blank"
rel="noopener noreferrer"
className="py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
style={{ border: `1px solid ${color}`, color }}
>
{siteLabel} ↗
</a>
)}
{org.email && (
<a
href={`mailto:${org.email}`}
className="py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
style={{ border: `1px solid ${color}`, color }}
>
Contact
</a>
)}
</div>
</div>
);
}
function Block({ title, orgs, accent, pageLabel, siteLabel }) {
if (orgs.length === 0) return null;
return (
<section className="mb-12">
{title && (
<h3 className="text-xl font-800 mb-5" style={{ color: FALLBACK_COLOR }}>
{title}
</h3>
)}
<div
className="grid gap-5 items-stretch"
style={{
gridTemplateColumns: `repeat(auto-fill, minmax(min(${CARD_MIN}, 100%), 1fr))`,
}}
>
{orgs.map(org => (
<OrgCard
key={org.id}
org={org}
accent={accent}
pageLabel={pageLabel}
siteLabel={siteLabel}
/>
))}
</div>
</section>
);
}
const byName = (a, b) => a.name.localeCompare(b.name);
export default function OrgListCards({
kind,
title,
groups,
groupBy,
accent = FALLBACK_COLOR,
pageLabel = "Learn more",
siteLabel = "Visit site",
sort = byName,
empty = "· Nothing to show here just yet ·",
}) {
const { organizations, loading, error } = useOrganizations(kind);
const shell = children => (
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: GRID_MAX }}>
{children}
</div>
);
if (loading && organizations.length === 0) {
return shell(
<div className="skeleton" role="status" aria-label="Loading organizations" />
);
}
if (error && organizations.length === 0) {
return shell(
<p className="font-600" style={{ color: accent }}>
· This list couldn't be loaded just now — please try again shortly ·
</p>
);
}
if (organizations.length === 0) {
return shell(
<p className="font-600" style={{ color: accent }}>
{empty}
</p>
);
}
const sorted = sort ? [...organizations].sort(sort) : organizations;
// No groups given means one undivided grid.
if (!groups || !groupBy) {
return shell(
<Block
title={title}
orgs={sorted}
accent={accent}
pageLabel={pageLabel}
siteLabel={siteLabel}
/>
);
}
return shell(
groups.map(group => (
<Block
key={group.key}
title={group.title}
orgs={sorted.filter(org => groupBy(org) === group.key)}
accent={accent}
pageLabel={group.pageLabel ?? pageLabel}
siteLabel={group.siteLabel ?? siteLabel}
/>
))
);
}

View file

@ -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
<RegionMap> — 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 (
<div className="mt-4 flex flex-col gap-3" style={{ color: INK }}>
{blocks.map((block, i) => {
switch (block.type) {
case "heading":
return (
<h4 key={i} className="text-lg font-800" style={{ color }}>
{block.text}
</h4>
);
case "subheading":
return (
<h5 key={i} className="font-700">
{block.text}
</h5>
);
case "quote":
return (
<blockquote
key={i}
className="pl-3 italic"
style={{ borderLeft: `2px solid ${color}` }}
>
{block.text}
</blockquote>
);
case "list":
case "links":
return (
<ul key={i} className="list-disc ml-5 flex flex-col gap-1">
{block.items.map((item, j) => (
<li key={j}>
{item.url ? (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="font-700 underline"
style={{ color }}
>
{item.text}
</a>
) : (
item.text
)}
{item.detail && (
<span className="text-sm" style={{ color: MUTED }}>
{" "}
— {item.detail}
</span>
)}
</li>
))}
</ul>
);
case "divider":
return <hr key={i} style={{ borderColor: RULE }} />;
default:
return (
<p key={i} className="leading-relaxed">
{block.text}
</p>
);
}
})}
</div>
);
}
/* ── Tile ──────────────────────────────────────────────────────
Every region painting this tile gets its own rect. A whole tile
is one slice with no edge; a shared tile is two, each declaring
which edge it sits on and how much it takes. Nothing here
subtracts, and the order the slices arrive in doesn't change
what's drawn.
───────────────────────────────────────────────────────────── */
function Tile({
code,
x,
y,
size,
width = size,
label,
slices = [],
count = 0,
selected,
hovered,
setSelected,
setHovered,
onPick,
fontSize = 34,
}) {
if (slices.length === 0) return null;
const primary = slices[0];
const ids = slices.map(s => s.regionId);
const active = ids.includes(selected) || ids.includes(hovered);
const dimmed = selected && !ids.includes(selected);
const clipId = `clip-${code}`;
const opacity = count ? 1 : active ? 0.5 : 0.28;
// Clicking a shared tile cycles through its regions and then
// clears, so every slice is reachable without a second control.
const cycle = () => {
onPick?.(code);
const at = ids.indexOf(selected);
setSelected(at === ids.length - 1 ? null : ids[at + 1]);
};
return (
<g
onClick={cycle}
onMouseEnter={() => setHovered(primary.regionId)}
onMouseLeave={() => setHovered(null)}
style={{ cursor: "pointer" }}
opacity={dimmed ? 0.25 : 1}
className="transition-opacity duration-200"
>
<title>
{AREA_NAMES[code] || code} — {slices.map(s => s.name).join(" / ")}
{count ? ` · ${count} chapter${count > 1 ? "s" : ""}` : ""}
</title>
<clipPath id={clipId}>
<rect x={x} y={y} width={width} height={size} rx={14} />
</clipPath>
<g clipPath={`url(#${clipId})`}>
{slices.map(slice => {
const h = slice.edge ? size * slice.share : size;
const top = slice.edge === "bottom" ? y + size - h : y;
return (
<rect
key={slice.regionId}
x={x}
y={top}
width={width}
height={h}
fill={slice.color}
fillOpacity={opacity}
className="transition-all duration-200"
/>
);
})}
</g>
<rect
x={x}
y={y}
width={width}
height={size}
rx={14}
fill="none"
stroke={active ? primary.color : "#ffffff"}
strokeOpacity={active ? 1 : 0.55}
strokeWidth={active ? 4 : 2}
className="transition-all duration-200"
/>
<text
x={x + width / 2}
y={y + size / 2 + 2}
textAnchor="middle"
dominantBaseline="middle"
fontSize={fontSize}
fontWeight="800"
fill={count ? "#ffffff" : primary.color}
style={{ pointerEvents: "none" }}
>
{label || code}
</text>
{count > 0 && (
<circle
cx={x + width - 14}
cy={y + 14}
r={7}
fill="#ffffff"
fillOpacity={0.9}
style={{ pointerEvents: "none" }}
/>
)}
</g>
);
}
function RegionMap({ slices, chapterCounts, ...props }) {
const size = TILE - PAD * 2;
return (
<svg
viewBox={`0 0 ${COLS * TILE} ${ROWS * TILE}`}
className="w-full h-auto"
role="group"
aria-label="Chapters by Unity region"
>
{/* States and bands are the same shape — a tile with a span —
so this is one loop rather than two. */}
{AREAS.map(area => (
<Tile
key={area.code}
code={area.code}
label={area.isState ? area.code : area.name}
x={(area.col - 1) * TILE + PAD}
y={(area.row - 1) * TILE + PAD}
size={size}
width={area.span * TILE - PAD * 2}
fontSize={area.isState ? 34 : 38}
slices={slices[area.code] ?? []}
count={chapterCounts[area.code] ?? 0}
{...props}
/>
))}
</svg>
);
}
function LegendButton({ region, selected, setSelected, hovered, setHovered }) {
const on = selected === region.id || hovered === region.id;
return (
<button
onClick={() => setSelected(selected === region.id ? null : region.id)}
onMouseEnter={() => setHovered(region.id)}
onMouseLeave={() => setHovered(null)}
onFocus={() => setHovered(region.id)}
onBlur={() => setHovered(null)}
aria-pressed={selected === region.id}
className="flex items-center gap-2 py-1.5 px-3 rounded-lg text-sm font-700 transition-all duration-200"
style={{
border: `1px solid ${region.color}`,
background: on ? region.color : "transparent",
color: on ? "#ffffff" : region.color,
opacity: selected && selected !== region.id ? 0.45 : 1,
}}
>
<span
className="h-2.5 w-2.5 rounded-full"
style={{ background: on ? "#ffffff" : region.color }}
/>
{region.name}
</button>
);
}
function Legend({ domestic, international, onMapIds, ...props }) {
const { selected, setSelected } = props;
// A region is on the map if it paints a tile. West Central used
// to need a hardcoded exception here because its states arrived
// only through SPLITS; it has ordinary rows now, so the exception
// is gone.
const onMap = region => onMapIds.has(region.id);
const us = domestic.filter(onMap);
const intl = international.filter(onMap);
return (
<div className="mt-6">
<p className="text-xs uppercase tracking-wide mb-2" style={{ color: MUTED }}>
{US_TITLE}
</p>
<div className="flex flex-wrap gap-2">
{us.map(r => (
<LegendButton key={r.id} region={r} {...props} />
))}
</div>
<div className="h-px my-4" style={{ background: RULE }} />
<p className="text-xs uppercase tracking-wide mb-2" style={{ color: MUTED }}>
{INTL_TITLE}
</p>
<div className="flex flex-wrap gap-2 items-center">
{intl.map(r => (
<LegendButton key={r.id} region={r} {...props} />
))}
{selected && (
<button
onClick={() => setSelected(null)}
className="py-1.5 px-3 rounded-lg text-sm font-700 underline"
style={{ color: FALLBACK_COLOR }}
>
Show all
</button>
)}
</div>
</div>
);
}
function RegionBlock({
region,
chapters,
subtext,
selected,
setSelected,
setHovered,
indent,
regionRefs,
chapterRefs,
}) {
const on = selected === region.id;
return (
<div
ref={el => regionRefs && (regionRefs.current[region.id] = el)}
className="transition-opacity duration-200"
style={{ opacity: selected && !on ? 0.35 : 1, marginLeft: indent ? "0.75rem" : 0 }}
>
<button
onClick={() => setSelected(on ? null : region.id)}
onMouseEnter={() => setHovered(region.id)}
onMouseLeave={() => setHovered(null)}
className="w-full flex items-baseline gap-2 text-left py-2"
>
<span
className="h-3 w-3 rounded-full shrink-0"
style={{ background: region.color }}
/>
<h4
className={`font-800 ${indent ? "text-lg" : "text-xl"}`}
style={{ color: region.color }}
>
{region.name}
</h4>
<span className="text-sm ml-auto" style={{ color: MUTED }}>
{chapters.length || "—"}
</span>
</button>
{subtext && (
<p className="text-sm mb-2 ml-5 leading-snug" style={{ color: MUTED }}>
{subtext}
</p>
)}
{chapters.length === 0 ? (
<p className="text-sm ml-5 mb-4" style={{ color: MUTED }}>
No chapters yet — interested in starting one?
</p>
) : (
<ul className="ml-5 mb-4 flex flex-col gap-3">
{chapters.map(c => (
<li
key={c.id}
ref={el => chapterRefs && (chapterRefs.current[c.id] = el)}
className="pl-3 flex items-start gap-3"
style={{ borderLeft: `2px solid ${region.color}` }}
>
<div className="min-w-0 flex-1">
<p className="font-700">{c.name}</p>
<p className="text-sm" style={{ color: FALLBACK_COLOR }}>
{c.location_label}
{c.meets ? ` · ${c.meets}` : ""}
</p>
{(c.website || c.email) && (
<p className="text-sm mt-1 flex gap-4">
{c.website && (
<a
href={c.website}
target="_blank"
rel="noopener noreferrer"
className="font-700 underline"
style={{ color: region.color }}
>
Details
</a>
)}
{c.email && (
<a
href={`mailto:${c.email}`}
className="font-700 underline"
style={{ color: region.color }}
>
Contact
</a>
)}
</p>
)}
</div>
{orgPath(c) && (
<ArrowLink
to={orgPath(c)}
label={`${c.name} — chapter page`}
color={region.color}
size="h-8 w-8"
/>
)}
</li>
))}
</ul>
)}
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
GRID VIEW
One grid per region. Clicking a card's arrow opens a detail
panel directly above that region's grid, framed in the region
color. Selecting another card swaps the panel's contents.
═══════════════════════════════════════════════════════════════ */
/* Logo, or the organization's initials when there's no file. */
function OrgLogo({ org, color, size = "h-14 w-14" }) {
const [failed, setFailed] = useState(false);
if (org.logo && !failed) {
return (
<img
src={`${LOGO_BASE}${org.logo}`}
alt={org.name}
onError={() => setFailed(true)}
className={`${size} object-contain rounded-xl shrink-0`}
/>
);
}
return (
<div
className={`${size} rounded-xl shrink-0 flex items-center justify-center font-800`}
style={{ border: `1px solid ${color}`, color }}
aria-label={org.name}
>
{initialsFor(org.name)}
</div>
);
}
function ChapterCard({ chapter, color, open, onOpen }) {
return (
<div
className="rounded-2xl p-4 flex items-start gap-4 transition-all duration-200"
style={{
border: `1px solid ${color}`,
background: open ? `${color}14` : "transparent",
}}
>
<OrgLogo org={chapter} color={color} />
<div className="min-w-0 flex-1">
<p className="font-700 leading-tight">{chapter.name}</p>
<p className="text-sm" style={{ color: FALLBACK_COLOR }}>
{chapter.location_label}
</p>
{chapter.meets && (
<p className="text-sm" style={{ color: MUTED }}>
{chapter.meets}
</p>
)}
</div>
{/* 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. */}
<div className="shrink-0 flex items-center gap-2">
<button
onClick={onOpen}
aria-expanded={open}
aria-label={`${open ? "Hide" : "View"} details for ${chapter.name}`}
className="h-9 w-9 rounded-full flex items-center justify-center transition-transform duration-200 hover:scale-110"
style={{
border: `1px solid ${color}`,
color,
transform: open ? "rotate(90deg)" : "none",
}}
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 6l6 6-6 6" />
</svg>
</button>
{orgPath(chapter) && (
<ArrowLink
to={orgPath(chapter)}
label={`${chapter.name} — chapter page`}
color={color}
/>
)}
</div>
</div>
);
}
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 (
<div
className="rounded-2xl p-6 mb-6"
style={{ border: `2px solid ${region.color}`, background: `${region.color}0f` }}
>
<div className="flex items-start gap-4">
<OrgLogo org={chapter} color={region.color} size="h-20 w-20" />
<div className="min-w-0 flex-1">
<p className="text-2xl font-900 leading-tight">{chapter.name}</p>
<p style={{ color: FALLBACK_COLOR }}>{chapter.location_label}</p>
</div>
<button
onClick={onClose}
aria-label="Close details"
className="shrink-0 h-8 w-8 rounded-full flex items-center justify-center text-lg font-700 transition-transform duration-200 hover:scale-110"
style={{ border: `1px solid ${region.color}`, color: region.color }}
>
×
</button>
</div>
<Blocks blocks={chapter.blocks} color={region.color} />
{rows.length > 0 && (
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1 text-sm">
{rows.map(([label, value]) => (
<div key={label} className="flex gap-2">
<dt className="shrink-0" style={{ color: MUTED }}>
{label}
</dt>
<dd className="font-700" style={{ color: INK }}>
{value}
</dd>
</div>
))}
</dl>
)}
<div className="mt-5 flex flex-wrap gap-3">
{orgPath(chapter) && (
<Link
to={orgPath(chapter)}
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
style={{ background: region.color, color: "#ffffff" }}
>
Chapter page
</Link>
)}
{chapter.website && (
<a
href={chapter.website}
target="_blank"
rel="noopener noreferrer"
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
style={{ border: `1px solid ${region.color}`, color: region.color }}
>
Visit site
</a>
)}
{chapter.email && (
<a
href={`mailto:${chapter.email}`}
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
style={{ border: `1px solid ${region.color}`, color: region.color }}
>
Get in touch
</a>
)}
</div>
</div>
);
}
function ChapterGrid({ regions, chapters, chaptersIn, subtextFor, openId, setOpenId }) {
// Only regions that actually have chapters get a grid.
const populated = regions
.map(region => ({ region, list: chaptersIn(region.id) }))
.filter(({ list }) => list.length > 0);
const empty = regions.filter(region => chaptersIn(region.id).length === 0);
const openChapter = chapters.find(c => c.id === openId) || null;
return (
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
{populated.map(({ region, list }) => {
const subtext = subtextFor(region);
return (
<section key={region.id} className="mb-12">
<div className="flex items-baseline gap-3 mb-1">
<span
className="h-3 w-3 rounded-full shrink-0"
style={{ background: region.color }}
/>
<h3 className="text-2xl font-800" style={{ color: region.color }}>
{region.name}
</h3>
<span className="text-sm" style={{ color: MUTED }}>
{list.length}
</span>
</div>
{subtext && (
<p className="text-sm mb-5 ml-6" style={{ color: MUTED }}>
{subtext}
</p>
)}
{/* Detail panel sits above this region's grid, and only
when the open chapter belongs to this region. */}
{openChapter && openChapter.region_id === region.id && (
<ChapterDetail
chapter={openChapter}
region={region}
onClose={() => setOpenId(null)}
/>
)}
<div
className="grid gap-4 items-start"
style={{
gridTemplateColumns: "repeat(auto-fill, minmax(min(22rem, 100%), 1fr))",
}}
>
{list.map(c => (
<ChapterCard
key={c.id}
chapter={c}
color={region.color}
open={openId === c.id}
onOpen={() => setOpenId(openId === c.id ? null : c.id)}
/>
))}
</div>
</section>
);
})}
{empty.length > 0 && (
<p className="text-sm" style={{ color: MUTED }}>
No chapters yet in {empty.map(r => r.name).join(", ")} — interested in
starting one?
</p>
)}
</div>
);
}
/* The control for the section heading's action bar. */
export function OrgMapToggle({ view, setView, accent }) {
const btn = active => ({
background: active ? accent : "transparent",
color: active ? "#ffffff" : accent,
});
return (
<div
className="inline-flex rounded-xl overflow-hidden shrink-0"
style={{ border: `1px solid ${accent}` }}
role="group"
aria-label="Change how chapters are displayed"
>
{[
["map", "Map"],
["grid", "Grid"],
].map(([id, label]) => (
<button
key={id}
onClick={() => setView(id)}
aria-pressed={view === id}
className="py-2 px-4 font-700 text-sm transition-colors duration-200"
style={btn(view === id)}
>
{label}
</button>
))}
</div>
);
}
export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
const {
loading,
error,
regions,
regionAreas,
domestic,
international,
virtual,
chapters,
chaptersIn,
slices,
chapterCounts,
regionsForArea,
subtextFor,
} = useCommunity();
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 regionRefs = 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(regionRefs.current[id]);
}, [hovered, selected]);
// Clicking a tile jumps to its first chapter when it has one,
// otherwise to the region it belongs to.
const pickArea = code => {
const chapter = chapters.find(c => c.area_code === code);
if (chapter && chapterRefs.current[chapter.id]) {
return scrollListTo(chapterRefs.current[chapter.id]);
}
const region = regionsForArea(code)[0];
if (region) scrollListTo(regionRefs.current[region.id]);
};
const shell = children => (
<div className="mx-auto px-8 md:px-12" style={{ maxWidth: CONTENT_MAX }}>
{children}
</div>
);
if (loading && regions.length === 0) {
return shell(
<div className="skeleton" role="status" aria-label="Loading chapters" />
);
}
if (error && regions.length === 0) {
return shell(
<p className="font-600" style={{ color: accent }}>
· Chapters couldn't be loaded just now — please try again shortly ·
</p>
);
}
if (view === "grid") {
return (
<ChapterGrid
regions={regions}
chapters={chapters}
chaptersIn={chaptersIn}
subtextFor={subtextFor}
openId={openId}
setOpenId={setOpenId}
/>
);
}
const shared = { selected, setSelected, hovered, setHovered };
const onMapIds = new Set(regionAreas.map(a => a.region_id));
const block = (region, indent) => (
<RegionBlock
key={region.id}
region={region}
chapters={chaptersIn(region.id)}
subtext={subtextFor(region)}
indent={indent}
regionRefs={regionRefs}
chapterRefs={chapterRefs}
{...shared}
/>
);
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(
<div className="grid grid-cols-1 lg:grid-cols-[1.45fr_1fr] gap-10 items-stretch">
{/* Map */}
<div>
<RegionMap
slices={slices}
chapterCounts={chapterCounts}
onPick={pickArea}
{...shared}
/>
<Legend
domestic={domestic}
international={international}
onMapIds={onMapIds}
{...shared}
/>
<p className="text-sm mt-4" style={{ color: MUTED }}>
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.
</p>
</div>
{/* 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. */}
<div
ref={listRef}
className="relative lg:h-0 lg:min-h-full overflow-y-auto pr-2"
>
<h3 className="text-xl font-800 mb-1" style={{ color: FALLBACK_COLOR }}>
{US_TITLE}
</h3>
{domestic.map(r => block(r, true))}
<h3
className="text-xl font-800 mt-6 mb-1 pt-4"
style={{ color: FALLBACK_COLOR, borderTop: `1px solid ${RULE}` }}
>
{INTL_TITLE}
</h3>
{international.map(r => block(r, true))}
<div className="mt-6 pt-4" style={{ borderTop: `1px solid ${RULE}` }}>
{virtual.map(r => block(r, false))}
</div>
</div>
</div>
)
);
}

View file

@ -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:
<OrgListVertical kind="partner" title="Our Partners" />
<OrgListVertical
kind="region"
groups={[
{ key: "domestic", title: "US Unity Regions" },
{ key: "international", title: "International Unity Regions" },
]}
groupBy={org => 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 (
<As
className="shrink-0 whitespace-nowrap py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
style={{ border: `1px solid ${color}`, color }}
{...rest}
>
{children}
</As>
);
}
function OrgMark({ org, color }) {
const [failed, setFailed] = useState(false);
if (org.logo && !failed) {
return (
<img
src={`${LOGO_BASE}${org.logo}`}
alt=""
onError={() => setFailed(true)}
className="h-8 w-8 object-contain shrink-0"
/>
);
}
if (org.color) {
return (
<span
className="h-3 w-3 rounded-full shrink-0"
style={{ background: color }}
aria-hidden="true"
/>
);
}
return (
<span
className="h-8 w-8 rounded-lg shrink-0 flex items-center justify-center text-xs font-800"
style={{ border: `1px solid ${color}`, color }}
aria-hidden="true"
>
{initialsFor(org.name)}
</span>
);
}
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 (
<div className="py-3" style={{ borderTop: `1px solid ${RULE}` }}>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={() => setOpen(v => !v)}
aria-expanded={open}
aria-controls={panelId}
className="flex-1 min-w-0 flex items-center gap-3 text-left py-1"
>
<svg
viewBox="0 0 24 24"
className="ol-chevron h-4 w-4 shrink-0"
style={{ color, transform: open ? "rotate(90deg)" : "none" }}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 6l6 6-6 6" />
</svg>
<OrgMark org={org} color={color} />
<span className="min-w-0">
<span className="block font-800 text-lg leading-tight" style={{ color }}>
{org.name}
</span>
{summary && (
<span className="block text-sm leading-snug" style={{ color: MUTED }}>
{summary}
</span>
)}
</span>
{chapterCount > 0 && (
<span
className="ml-auto text-sm shrink-0"
style={{ color: MUTED }}
title={`${chapterCount} chapter${chapterCount > 1 ? "s" : ""}`}
>
{chapterCount}
</span>
)}
</button>
<div className="flex items-center gap-2">
{org.website && (
<RowButton
as="a"
href={org.website}
target="_blank"
rel="noopener noreferrer"
color={color}
>
{siteLabel} ↗
</RowButton>
)}
{/* Last in the row, so the arrow lines up down the right
edge whether or not a row has an outbound site. */}
{path && (
<ArrowLink
to={path}
label={`${org.name} — ${pageLabel}`}
color={color}
/>
)}
</div>
</div>
{/* 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. */}
<div
id={panelId}
className="ol-panel"
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
>
<div className="overflow-hidden">
<div className="pt-2 pb-3 pl-10 pr-2 flex flex-col gap-3">
{org.description?.map((text, i) => (
<p key={i} className="leading-relaxed" style={{ color: INK }}>
{text}
</p>
))}
{areas && (
<p className="text-sm" style={{ color: MUTED }}>
<span className="font-700">Covers</span> {areas}
</p>
)}
{note && (
<p className="text-sm" style={{ color: MUTED }}>
{note}
</p>
)}
{!hasPanel && (
<p className="text-sm" style={{ color: MUTED }}>
More about this region soon.
</p>
)}
{org.links?.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{org.links.map(link => (
<RowButton
key={link.url}
as="a"
href={link.url}
target="_blank"
rel="noopener noreferrer"
color={color}
>
{link.label}
</RowButton>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
function Block({ title, orgs, pageLabel, siteLabel }) {
if (orgs.length === 0) return null;
return (
<section className="mb-10">
{title && (
<h3 className="text-xl font-800 mb-1" style={{ color: FALLBACK_COLOR }}>
{title}
</h3>
)}
{orgs.map(org => (
<OrgRow key={org.id} org={org} pageLabel={pageLabel} siteLabel={siteLabel} />
))}
</section>
);
}
const byName = (a, b) => a.name.localeCompare(b.name);
export default function OrgListVertical({
kind,
title,
groups,
groupBy,
accent = FALLBACK_COLOR,
pageLabel = "Region page",
siteLabel = "Visit site",
sort = byName,
empty = "· Nothing to show here just yet ·",
}) {
const { organizations, loading, error } = useOrganizations(kind);
const shell = children => (
<div className="mx-auto px-8 md:px-12 max-w-6xl">{children}</div>
);
if (loading && organizations.length === 0) {
return shell(
<div className="skeleton" role="status" aria-label="Loading organizations" />
);
}
if (error && organizations.length === 0) {
return shell(
<p className="font-600" style={{ color: accent }}>
· This list couldn't be loaded just now — please try again shortly ·
</p>
);
}
if (organizations.length === 0) {
return shell(
<p className="font-600" style={{ color: accent }}>
{empty}
</p>
);
}
const sorted = sort ? [...organizations].sort(sort) : organizations;
// No groups given means one undivided list.
if (!groups || !groupBy) {
return shell(
<Block title={title} orgs={sorted} pageLabel={pageLabel} siteLabel={siteLabel} />
);
}
return shell(
groups.map(group => (
<Block
key={group.key}
title={group.title}
orgs={sorted.filter(org => groupBy(org) === group.key)}
pageLabel={group.pageLabel ?? pageLabel}
siteLabel={group.siteLabel ?? siteLabel}
/>
))
);
}

View file

@ -17,6 +17,15 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
"/api": {
target: "http://127.0.0.1:5173",
changeOrigin: false,
xfwd: true,
},
},
},
})
type SiteConfiguration = {