/* ============================================================================ EVENTS AND OPPORTUNITIES - the pieces these two sections add ---------------------------------------------------------------------------- Everything generic already exists and is imported, not rebuilt: ~/components/ui/Button Button, ArrowIcon ~/components/ui/Card CardShell, CardMedia, CardLink, CardActionHint - the primitives the card family exports "for composing new card types" ../_shared/listing-ui PageShell, PageHeader, EmptyState, TabLinks, Pagination, MetaList, MetaLine, BackLink What is genuinely new here is the status badge the draft asks for on every event and every call ("Open / Closed / Full", "Upcoming / Ongoing / Completed"), the two cards that carry the field set of draft sections 4 and 8, and the notice panel a detail page uses to explain a closed registration. RULES: tier-2 semantic tokens only, logical properties only, type from the published roles. Nothing here knows about a hex value or a direction. ========================================================================= */ import type { ReactNode } from "react"; import { CardActionHint, CardLink, CardMedia, CardShell, } from "~/components/ui/Card"; import type { CardElement, CardTitleElement } from "~/components/ui/Card"; import { toIsoDate, useTranslation } from "~/i18n"; import { MetaLine } from "../_shared/listing-ui"; import { eventActionKey, eventRegistrationLabel, eventTimeStatusKey, formatEventWhen, type EventCardData, } from "./events"; import { getRouteCopy } from "./copy"; import { formatOpportunityDate, opportunityActionKey, opportunityStatusKey, type OpportunityCardData, } from "./opportunities"; function cx(...parts: Array): string { return parts.filter(Boolean).join(" "); } /* --------------------------------------------------------------------------- STATUS BADGE ------------------------------------------------------------------------ */ /** Accent = live and joinable, neutral = informational, danger = full. */ export type BadgeTone = "accent" | "neutral" | "danger"; const BADGE_TONE: Readonly> = { accent: "border-border-accent bg-surface-accent-subtle text-text-accent", neutral: "border-border-subtle bg-surface-sunken text-text-tertiary", danger: "border-border-danger bg-surface-danger-subtle text-text-danger", }; /** * Colour is never the only carrier: the badge always spells the state out, so * it survives a monochrome print and a colour-blind reader. */ export function StatusBadge({ tone = "neutral", children, }: { tone?: BadgeTone; children: ReactNode; }) { return ( {children} ); } /** A row of badges above a card title or a detail heading. */ export function BadgeRow({ children }: { children: ReactNode }) { return
{children}
; } /* --------------------------------------------------------------------------- NOTICE ------------------------------------------------------------------------ */ export type NoticeTone = "info" | "accent" | "danger"; const NOTICE_TONE: Readonly> = { info: "border-border-default bg-surface-sunken text-text-secondary", accent: "border-border-accent bg-surface-accent-subtle text-text-primary", danger: "border-border-danger bg-surface-danger-subtle text-text-primary", }; /** * The panel that explains a state: registration closed, the call is not open * yet, the CMS could not be reached. `role` is left to the caller - a notice * that appears in response to a submission needs "status", one that is part of * the page does not. */ export function Notice({ tone = "info", title, children, role, }: { tone?: NoticeTone; title?: string; children?: ReactNode; role?: "status" | "alert"; }) { return (
{title ?

{title}

: null} {children ?
{children}
: null}
); } /* --------------------------------------------------------------------------- EVENT CARD - draft section 4 title - date and time - format/location - registration status - CTA ------------------------------------------------------------------------ */ export function EventCard({ event, href, as = "article", titleAs = "h3", className, }: { event: EventCardData; href: string; as?: CardElement; titleAs?: CardTitleElement; className?: string; }) { const { locale, t } = useTranslation(); const copy = getRouteCopy(locale); const Title = titleAs; const when = formatEventWhen(event, locale); const formatLabel = event.format === "online" ? t("events.online") : event.format === "hybrid" ? copy.events.formatHybrid : event.format === "in-person" ? t("events.inPerson") : undefined; const isPast = event.timeStatus === "completed"; return ( {event.image ? ( ) : null}
{t(eventTimeStatusKey(event.timeStatus))} {/* A finished event's registration state is history, not news. */} {isPast ? null : ( {eventRegistrationLabel(event.registrationStatus, locale)} )} <CardLink href={href} className="sh-card-link"> {event.title} </CardLink>

Boolean(item), )} /> {event.summary ? (

{event.summary}

) : null}
); } /* --------------------------------------------------------------------------- OPPORTUNITY CARD - draft section 8 title - status tag - deadline - CTA ------------------------------------------------------------------------ */ export function OpportunityCard({ opportunity, href, as = "article", titleAs = "h3", className, }: { opportunity: OpportunityCardData; href: string; as?: CardElement; titleAs?: CardTitleElement; className?: string; }) { const { locale, t } = useTranslation(); const Title = titleAs; const isOpen = opportunity.status === "open"; /* An open or closed call is defined by its deadline; a call that has not opened yet is defined by its opening date. */ const dateLine = opportunity.status === "upcoming" && opportunity.openDate ? formatOpportunityDate(opportunity.openDate, locale) : opportunity.deadline ? t("opportunities.deadline", { date: formatOpportunityDate(opportunity.deadline, locale), }) : undefined; const dateValue = opportunity.status === "upcoming" && opportunity.openDate ? opportunity.openDate : opportunity.deadline; return ( {opportunity.image ? ( ) : null}
{t(opportunityStatusKey(opportunity.status))} {opportunity.opportunityType ? ( {getRouteCopy(locale).opportunityTypes[ opportunity.opportunityType ]} ) : null} <CardLink href={href} className="sh-card-link"> {opportunity.title} </CardLink> {dateLine ? (

) : null} {opportunity.summary ? (

{opportunity.summary}

) : null}
); } /* --------------------------------------------------------------------------- DEFINITION GRID - the "Event details" block ------------------------------------------------------------------------ */ export interface DetailItem { readonly label: string; readonly value: ReactNode; } /** * A description list rather than a table: the pairs are a key/value set, they * reflow to one column on a phone, and a screen reader announces the label * with the value. */ export function DetailList({ heading, items, }: { heading: string; items: readonly DetailItem[]; }) { const present = items.filter((item) => item.value !== undefined && item.value !== null); if (present.length === 0) return null; return (

{heading}

{present.map((item) => (
{item.label}
{item.value}
))}
); } /* --------------------------------------------------------------------------- CONSENT CHECKBOX ---------------------------------------------------------------------------- `event-registration.consentGdpr` is a REQUIRED boolean in the CMS, so the reader has to be asked - out loud, in a real