NGU-Web/src/pages/EventDetail.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

352 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ═══════════════════════════════════════════════════════════════
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<string, string> = {
speaker: 'Speakers',
leader: 'Leaders',
facilitator: 'Facilitators',
host: 'Hosts',
musician: 'Music',
volunteer: 'Volunteers',
attendee: 'Also there',
}
const STATUS_LABEL: Record<string, string> = {
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 (
<PageState
loading={loading}
error={error}
notFound={notFound}
onRetry={reload}
noun="retreat"
backTo="/retreats"
backLabel="All retreats"
/>
)
}
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: (
<div className="max-w-6xl mx-auto px-6 space-y-8">
<Facts event={event} accent={accent} />
{event.description.map((paragraph, index) => (
<p key={index} className="leading-relaxed max-w-3xl" style={{ color: BODY }}>
{paragraph}
</p>
))}
<div className="max-w-3xl">
<ContentBlocks blocks={event.blocks} accent={accent} />
</div>
{event.links.length > 0 && (
<div className="flex flex-wrap gap-3">
{event.links.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noreferrer"
className="rounded-full px-5 py-2 text-sm font-semibold text-white transition-transform hover:scale-105"
style={{ background: accent }}
>
{link.label}
</a>
))}
</div>
)}
</div>
),
},
]
if (groups.length > 0) {
sections.push({
id: 'people',
title: 'Who’s there',
accent,
background: '#eef9fb',
content: (
<div className="max-w-6xl mx-auto px-6">
<PeopleTiles size="lg" groups={groups} accent={accent} />
</div>
),
})
}
if (event.awards.length > 0) {
sections.push({
id: 'awards',
title: 'Presented here',
blurb: 'Awards given at this gathering.',
accent,
background: '#ffffff',
content: (
<div className="max-w-6xl mx-auto px-6 space-y-6">
{event.awards.map((entry, index) => (
<div
key={`${entry.award.id}:${entry.person.id}:${index}`}
className="flex gap-4 border-l-2 pl-5"
style={{ borderColor: accent }}
>
<div>
<p className="font-semibold" style={{ color: accent }}>
<Link to={awardHref(entry.award.id)} className="hover:underline">
{entry.award.name}
</Link>
</p>
<p style={{ color: BODY }}>
<Link to={personHref(entry.person.id)} className="hover:underline">
{entry.person.name}
</Link>
</p>
{entry.citation && (
<p className="mt-1 text-sm italic opacity-80" style={{ color: BODY }}>
{entry.citation}
</p>
)}
</div>
</div>
))}
</div>
),
})
}
return (
<PageShell
title={event.title}
intro={event.theme ? event.tagline ?? undefined : event.tagline ?? undefined}
sections={sections}
/>
)
}
/* ── 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 (
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
<span
className="rounded-full px-3 py-1 font-semibold uppercase tracking-wide"
style={{
background: event.status === 'cancelled' ? '#b3261e' : accent,
color: '#ffffff',
}}
>
{STATUS_LABEL[event.status] ?? event.status}
</span>
{/* 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 && (
<span
className="rounded-full px-3 py-1 font-semibold uppercase tracking-wide"
style={{ border: `1px solid ${accent}`, color: accent }}
>
{eventTypeLabel(event.event_type)}
</span>
)}
{when && <span style={{ color: BODY }}>{when}</span>}
{where && <span style={{ color: BODY }}>{where}</span>}
{event.is_online && where !== 'Online' && (
<span style={{ color: BODY }}>Online too</span>
)}
{(event.hosts?.length ?? 0) > 0 && (
<span style={{ color: BODY }}>
Hosted by <HostList hosts={event.hosts} accent={accent} />
</span>
)}
<Link to="/retreats" className="ml-auto hover:underline" style={{ color: accent }}>
All retreats
</Link>
</div>
)
}
/* 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 (
<span key={`${host.kind}:${host.id}`}>
{index > 0 && (hosts.length > 2 ? ', ' : ' ')}
{index > 0 && index === hosts.length - 1 && 'and '}
{to ? (
<Link
to={to}
className="font-medium hover:underline"
style={{ color: accent }}
>
{host.name}
</Link>
) : (
<span className="font-medium">{host.name}</span>
)}
</span>
)
})}
</>
)
}
/* 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<string, EventPerson[]>()
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)