/* ═══════════════════════════════════════════════════════════════
ORGANIZATION DETAIL
/regions/:id /chapters/:id /partners/:id /organizations/:id
One component behind four routes, because organizations are one
table. The kind decides which extras render, not which file runs.
The URL kind is decoration — the slug is what identifies the
record — so a request for /chapters/great-lakes when that slug is
a region redirects to the canonical path rather than rendering a
correct page at a wrong address.
Leadership arrives flat with team_id and team_name on each row,
so grouping costs nothing. `teams` is fetched alongside anyway:
a team with no current public members would otherwise be
invisible here instead of listed, and its page unreachable.
═══════════════════════════════════════════════════════════════ */
import { Link, Navigate, useLocation, useParams } from 'react-router-dom'
import PageShell from '../components/PageShell.tsx'
import PageState from '../components/PageState.tsx'
import ContentBlocks from '../components/ContentBlocks.tsx'
import PeopleTiles from '../components/PeopleTiles.tsx'
import EventListCards from './sections/EventList-Cards.tsx'
import {
useOrganization,
type Leader,
type OrganizationRecord,
} from '../lib/useContent.ts'
import { orgHref, orgListHref, teamHref } from '../lib/hrefs.ts'
import { orgLogo, personPhoto } from '../lib/media.ts'
const TEAL = '#138ba0'
const BODY = '#4a6b72'
export default function OrganizationDetail() {
const { id } = useParams()
const { pathname } = useLocation()
const { data: org, loading, error, notFound, reload } = useOrganization(id)
if (!org) {
return (
)
}
// /chapters/x when x is a region: same record, wrong address.
//
// useLocation, not window.location: the latter is outside the
// router's awareness — always "/" under HashRouter, and free to
// be stale mid-navigation, either of which turns this into a
// redirect loop rather than a one-shot correction.
//
// Guarded on org.id because a record with no id would redirect to
// /chapters/undefined, which is a worse page than the one we're
// already on. All four organization routes must be registered or
// this redirects somewhere nothing matches.
const canonical = org.id ? orgHref(org.id, org.kind) : null
if (canonical && decodeURIComponent(pathname) !== canonical) {
return
}
const accent = org.color || TEAL
const back = orgListHref(org.kind)
const sections: any[] = [
{
id: 'about',
title: 'About',
accent,
background: '#ffffff',
content: (
),
})
}
// EventList-Cards fetches and renders this itself — its own
// docstring offers `host` for exactly this case, and a card there
// already knows about gradients, logos, past-event collapsing and
// the carousel. A second grid here was the same component written
// worse.
//
// `org.events` is still what decides whether the section exists at
// all: an organization that has never hosted anything shouldn't
// get a heading followed by "events coming soon".
if (org.events.length > 0) {
sections.push({
id: 'events',
title: 'Gatherings',
accent,
background: '#ffffff',
content: (
),
})
}
return
}
/* ── Pieces ──────────────────────────────────────────────────── */
function Facts({
org,
accent,
back,
}: {
org: OrganizationRecord
accent: string
back: { to: string; label: string }
}) {
const where =
org.location_label || [org.locality, org.state_code].filter(Boolean).join(', ')
return (
{org.links.map((link) => (
{link.label}
))}
{[org.website, org.email, ...org.socials]
.filter((link): link is NonNullable => Boolean(link))
.map((link) => (
{link.label}
))}
)
}
function Logo({
file,
name,
accent,
}: {
file?: string | null
name: string
accent: string
}) {
const src = orgLogo(file)
if (!src) {
return (
{name.slice(0, 2).toUpperCase()}
)
}
return
}
/* ── Leadership → team blocks ────────────────────────────────── */
type TeamBlock = {
key: string
teamId: string | null
label: string | null
tagline?: string | null
accent?: string | null
people: Leader[]
}
/* Order comes from `teams` (the admin's sort_order), not from the
order people happen to appear in. Three cases to cover:
affiliations with no team at all, teams with nobody in them, and
people filed under a team that is no longer published. */
function groupLeadership(org: OrganizationRecord): TeamBlock[] {
const byTeam = new Map()
const loose: Leader[] = []
for (const leader of org.leadership) {
if (!leader.team_id) {
loose.push(leader)
continue
}
const list = byTeam.get(leader.team_id)
if (list) list.push(leader)
else byTeam.set(leader.team_id, [leader])
}
const blocks: TeamBlock[] = []
// People who hold a role in the organization without sitting on a
// team. Usually the leads. No heading — they are the page.
if (loose.length > 0) {
blocks.push({ key: 'loose', teamId: null, label: null, people: loose })
}
for (const team of org.teams) {
blocks.push({
key: team.id,
teamId: team.id,
label: team.name,
tagline: team.tagline,
accent: team.color,
people: byTeam.get(team.id) ?? [],
})
byTeam.delete(team.id)
}
// Whatever is left is filed under an unpublished team. Its page
// isn't reachable, so the heading is plain text — but the people
// are real and shouldn't silently vanish from the org.
for (const [teamId, people] of byTeam) {
blocks.push({
key: teamId,
teamId: null,
label: people[0]?.team_name ?? null,
people,
})
}
return blocks
}