/* ═══════════════════════════════════════════════════════════════ EVENT DETAIL — /retreats/:id Reached from the cards on Retreats.tsx. The API has already resolved the fallbacks — `color` is the event's own or its first host's, `status` is derived from the dates when nobody set it — so nothing here reimplements those rules. Hosts arrive as a list in billing order, each one either an organization or a person. refHref takes the kind and works out the route, which is why this file doesn't branch on it. People are grouped by their billing role rather than listed flat. A retreat with four speakers and eleven volunteers reads as two different things, and v_event_people already sorts within a role by sort_order. ═══════════════════════════════════════════════════════════════ */ import { Link, useParams } from 'react-router-dom' import PageShell, { type ShellSection } from '../components/PageShell.tsx' import PageState from '../components/PageState.tsx' import ContentBlocks from '../components/ContentBlocks.tsx' import PeopleTiles, { type PeopleGroupInput } from '../components/PeopleTiles.tsx' import { useEvent, type EventHost, type EventPerson, type EventRecord, } from '../lib/useContent.ts' import { awardHref, personHref, refHref } from '../lib/hrefs.ts' import { personPhoto } from '../lib/media.ts' import { eventTypeLabel } from '../lib/eventTypes.ts' const TEAL = '#138ba0' const BODY = '#4a6b72' /* Billing order. A role missing from here still renders, at the end, under its own name — better than a speaker vanishing because somebody added a role to the CHECK and not to this list. */ const ROLE_ORDER = [ 'speaker', 'leader', 'facilitator', 'host', 'musician', 'volunteer', 'attendee', ] as const const ROLE_LABEL: Record = { speaker: 'Speakers', leader: 'Leaders', facilitator: 'Facilitators', host: 'Hosts', musician: 'Music', volunteer: 'Volunteers', attendee: 'Also there', } const STATUS_LABEL: Record = { upcoming: 'Upcoming', past: 'Past', cancelled: 'Cancelled', } export default function EventDetail() { const { id } = useParams() const { data: event, loading, error, notFound, reload } = useEvent(id) if (!event) { return ( ) } const accent = event.color || TEAL const groups = peopleGroups(event.people, accent) const sections: ShellSection[] = [ { id: 'about', title: event.theme || 'About', blurb: event.theme ? event.tagline ?? undefined : undefined, accent, background: '#ffffff', content: (
{event.description.map((paragraph, index) => (

{paragraph}

))}
{event.links.length > 0 && (
{event.links.map((link) => ( {link.label} ))}
)}
), }, ] if (groups.length > 0) { sections.push({ id: 'people', title: 'Who’s there', accent, background: '#eef9fb', content: (
), }) } if (event.awards.length > 0) { sections.push({ id: 'awards', title: 'Presented here', blurb: 'Awards given at this gathering.', accent, background: '#ffffff', content: (
{event.awards.map((entry, index) => (

{entry.award.name}

{entry.person.name}

{entry.citation && (

{entry.citation}

)}
))}
), }) } return ( ) } /* ── The strip of facts under the heading ────────────────────── */ function Facts({ event, accent }: { event: EventRecord; accent: string }) { const where = event.location_label || [event.locality, event.state_code].filter(Boolean).join(', ') || (event.is_online ? 'Online' : null) const when = event.date_label || dateRange(event.starts_on, event.ends_on) return (
{STATUS_LABEL[event.status] ?? event.status} {/* Outlined rather than filled: the status pill is the one thing in this strip that should read as loud, and two solid blocks side by side would compete. Shown unconditionally — a card suppresses its own type badge in a band of one kind, but here there is no band to make it redundant. */} {event.event_type && ( {eventTypeLabel(event.event_type)} )} {when && {when}} {where && {where}} {event.is_online && where !== 'Online' && ( Online too )} {(event.hosts?.length ?? 0) > 0 && ( Hosted by )} All retreats
) } /* One host reads as "Hosted by Northwest"; several read as a sentence, so they're joined with commas and an "and" rather than stacked. A host whose id didn't resolve to a route renders as plain text — refHref returns null for that — because a dead link is worse than a name. */ function HostList({ hosts, accent }: { hosts: EventHost[]; accent: string }) { return ( <> {hosts.map((host, index) => { const to = refHref(host.kind, host.id, host.org_kind) return ( {index > 0 && (hosts.length > 2 ? ', ' : ' ')} {index > 0 && index === hosts.length - 1 && 'and '} {to ? ( {host.name} ) : ( {host.name} )} ) })} ) } /* date_label is what a card shows and is free text — "March/April 2026" is legitimate. This is only the fallback for an event that has dates and no label. */ function dateRange(start?: string | null, end?: string | null): string | null { if (!start) return null const from = new Date(`${start}T00:00:00`) if (Number.isNaN(from.getTime())) return start const full: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric', year: 'numeric', } if (!end || end === start) return from.toLocaleDateString(undefined, full) const to = new Date(`${end}T00:00:00`) if (Number.isNaN(to.getTime())) return from.toLocaleDateString(undefined, full) const sameYear = from.getFullYear() === to.getFullYear() const sameMonth = sameYear && from.getMonth() === to.getMonth() const left = from.toLocaleDateString( undefined, sameMonth ? { month: 'long', day: 'numeric' } : sameYear ? { month: 'long', day: 'numeric' } : full, ) return `${left} – ${to.toLocaleDateString(undefined, full)}` } /* ── v_event_people → PeopleTiles groups ─────────────────────── */ function peopleGroups(people: EventPerson[], accent: string): PeopleGroupInput[] { if (!people.length) return [] const byRole = new Map() for (const person of people) { const role = person.role || 'attendee' const list = byRole.get(role) if (list) list.push(person) else byRole.set(role, [person]) } // Known roles in billing order, then anything the CHECK has // gained since this file was written. const roles = [ ...ROLE_ORDER.filter((role) => byRole.has(role)), ...[...byRole.keys()].filter((role) => !ROLE_ORDER.includes(role as never)), ] return roles.map((role) => ({ id: `role-${role}`, label: ROLE_LABEL[role] ?? capitalize(role), accent, people: (byRole.get(role) ?? []).map((person) => ({ id: person.person_id, name: person.display_name, title: person.title, tagline: person.tagline, pronouns: person.pronouns, // PeopleTiles resolves a bare filename itself; this is here // so a hand-written entry elsewhere can't diverge. photo: personPhoto(person.photo), })), })) } const capitalize = (word: string) => word.charAt(0).toUpperCase() + word.slice(1)