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