/* ═══════════════════════════════════════════════════════════════ PUBLIC HREFS One place that turns a record into a URL. The API deliberately never sends paths — it sends `{ kind, id }` and, for an organization, the `org_kind` that decides which of the three routes a slug belongs to. Deciding that in each component is how /regions/x and /chapters/x end up both existing for the same record, and how the history timeline ended up emitting /event/:id while the router only knew /retreats/:id. timelineRefs.ts now delegates here rather than keeping its own table, so there is one answer to "where does an event live" and changing it is changing EVENT_BASE below. navConfig.js stays the source of truth for the *nav*: these are record routes, which never appear in it. ── On missing ids ── Every builder takes an id the caller believed it had. When it doesn't, the old behaviour was to interpolate the string "undefined" into a path, render a link to it, mount a page and fetch /api/organizations/undefined — four steps between the mistake and any sign of it, none of which name the component that made it. Now the id is checked here. In dev that's a console.error with a stack trace pointing at the caller; in production the path still comes out, because a broken link beats a crashed page, and useResource refuses to fetch it. ═══════════════════════════════════════════════════════════════ */ export type OrgKind = 'national' | 'region' | 'chapter' | 'partner' export type RefKind = 'event' | 'organization' | 'team' | 'award' | 'person' /* A region, a chapter and a partner read as different things to a visitor even though they are one table, so they get one route each. 'national' is NGU itself — one record, no listing to sit under, so it falls through to the generic path. */ const ORG_BASE: Record = { region: '/regions', chapter: '/chapters', partner: '/partners', national: '/organizations', } /** Canonical base for an event page. * * /events/:id, not /retreats/:id. The listing page is called * Retreats because that's what NGU calls the gatherings it hosts, * but the records are `events`, the API route is /api/events, and * plenty of them — partner events, conferences — aren't retreats * at all. Naming the record route after one page's editorial * framing would have been wrong the first time a non-retreat got * its own page. * * Nothing redirects from /retreats/:id, because nothing ever * linked there. */ export const EVENT_BASE = '/events' const DEV = Boolean((import.meta as any)?.env?.DEV) /* Router params arrive as strings, so an id that has already been through a template literal shows up as the literal word. Those are as broken as a genuine null. */ const BAD = new Set(['', 'undefined', 'null', 'NaN']) export const isBadId = (id: unknown): boolean => id == null || BAD.has(String(id)) function checkId(id: unknown, what: string): string { if (!isBadId(id)) return String(id) if (DEV) { // console.error rather than warn: this is always a bug, and the // stack is the whole point — it names the component that passed // nothing. console.error( `hrefs: ${what}() was given ${JSON.stringify(id)}. ` + `The link it returns will 404. Caller:`, new Error('hrefs: missing id').stack, ) } return 'undefined' } /** * The API path for one record, or null when the id isn't usable. * * The mirror image of the builders below: they make the URL a * visitor sees, this makes the URL the client fetches, and both * have to agree about what counts as an id. * * It lives here rather than next to the hook that calls it because * it is a path, and paths are this file's job — and because a * component that interpolates a missing route param produces the * literal string "undefined", which the server cannot tell apart * from a slug somebody genuinely typed. It answers 404 either way, * and the log fills with GET /api/organizations/undefined with * nothing to say where it came from. * * Returning null costs a round trip and turns a mystery 404 into * the not-found page, which is what a visitor should see anyway. */ export function detailPath(base: string, id?: string | null): string | null { return isBadId(id) ? null : `${base}/${encodeURIComponent(String(id))}` } export const eventHref = (id?: string | null) => `${EVENT_BASE}/${checkId(id, 'eventHref')}` export const teamHref = (id?: string | null) => `/teams/${checkId(id, 'teamHref')}` export const awardHref = (id?: string | null) => `/awards/${checkId(id, 'awardHref')}` export const personHref = (id?: string | null) => `/people/${checkId(id, 'personHref')}` export function orgHref(id?: string | null, kind?: string | null): string { return `${ORG_BASE[kind ?? ''] ?? '/organizations'}/${checkId(id, 'orgHref')}` } /* For anything holding a polymorphic reference — timeline entries, content blocks — where the kind arrives as data rather than being known at the call site. Returns null for a kind with no page AND for a reference with no id, so the caller renders plain text instead of a dead link. This is the one the timeline wants: an entry whose ref didn't resolve should read as text, not as a link to nowhere. */ export function refHref( kind: string | null | undefined, id: string | null | undefined, orgKind?: string | null, ): string | null { if (!kind || isBadId(id)) return null switch (kind) { case 'event': return eventHref(id) case 'organization': return orgHref(id, orgKind) case 'team': return teamHref(id) case 'award': return awardHref(id) case 'person': return personHref(id) default: return null } } /* What to call the kind in a breadcrumb or a back link. */ export const ORG_KIND_LABEL: Record = { national: 'Next Generation of Unity', region: 'Region', chapter: 'Chapter', partner: 'Partner organization', } /* Where "back" goes from a record page. A chapter belongs to /community, a retreat to /retreats. */ export function orgListHref(kind?: string | null): { to: string; label: string } { switch (kind) { case 'region': return { to: '/community#local', label: 'All regions' } case 'chapter': return { to: '/community#local', label: 'All chapters' } case 'partner': return { to: '/community#partners', label: 'All partner organizations' } default: return { to: '/community', label: 'Community' } } }