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>
352 lines
11 KiB
TypeScript
352 lines
11 KiB
TypeScript
/* ═══════════════════════════════════════════════════════════════
|
||
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)
|