NGU-Web/src/pages/sections/OrgList-Vertical.tsx
Zaldimmar 2428f4412a Type src/ against API and database shapes, fixing implicit anys
Adds .d.ts declarations beside the untyped JS modules imported from
TS (api.js, navConfig.js, adminSchema.js, adminNav.js, src/data/*),
with shapes taken from the server routes and migrations. get/post/
patch/del now return unknown unless the caller names the response.

Fixes found along the way:
- website/email/instagram are bare strings from splitLinks, not Link
  objects; OrganizationDetail's website and email pills rendered with
  no href or label and now link correctly.
- The chapter map falls back to FALLBACK_COLOR for a region with no
  colour instead of painting its tiles black.

Adds defineSection() so each page manifest entry's props are checked
against its own Component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 04:13:46 -05:00

377 lines
11 KiB
TypeScript

import {
useId,
useState,
type AnchorHTMLAttributes,
type ButtonHTMLAttributes,
type ReactNode,
} from "react";
import ArrowLink from "../../components/ArrowLink.tsx";
import type { OrgKind } from "../../lib/hrefs.ts";
import type { OrganizationListItem } from "../../lib/useContent.ts";
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.
───────────────────────────────────────────────────────────── */
type RowButtonProps = {
as?: "a" | "button";
color: string;
children: ReactNode;
} & AnchorHTMLAttributes<HTMLAnchorElement> &
ButtonHTMLAttributes<HTMLButtonElement>;
function RowButton({ as: As = "button", color, children, ...rest }: RowButtonProps) {
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,
}: {
org: Pick<OrganizationListItem, "logo" | "color" | "name">;
color: string;
}) {
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>
);
}
type RowLabels = { pageLabel: string; siteLabel: string };
function OrgRow({ org, pageLabel, siteLabel }: RowLabels & { org: OrganizationListItem }) {
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,
}: RowLabels & { title?: string; orgs: OrganizationListItem[] }) {
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: OrganizationListItem, b: OrganizationListItem) =>
a.name.localeCompare(b.name);
/* One heading's worth of the list. Labels override the list's own. */
type OrgGroup = {
key: string;
title: string;
pageLabel?: string;
siteLabel?: string;
};
type OrgListVerticalProps = {
kind?: OrgKind;
title?: string;
groups?: OrgGroup[];
/** Which group key an organization files under. */
groupBy?: (org: OrganizationListItem) => string | null | undefined;
accent?: string;
pageLabel?: string;
siteLabel?: string;
/** Null keeps the API's order. */
sort?: ((a: OrganizationListItem, b: OrganizationListItem) => number) | null;
empty?: string;
};
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 ·",
}: OrgListVerticalProps) {
const { organizations, loading, error } = useOrganizations(kind);
const shell = (children: ReactNode) => (
<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}
/>
))
);
}