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 5efdafbb97
37 changed files with 6414 additions and 1988 deletions

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