2026-09-11 02:46:56 +02:00
|
|
|
/* ============================================================================
|
|
|
|
|
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 | false | null | undefined>): 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<Record<BadgeTone, string>> = {
|
|
|
|
|
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 (
|
|
|
|
|
<span
|
|
|
|
|
className={cx(
|
|
|
|
|
"inline-flex items-center gap-2xs rounded-pill border px-xs py-2xs label-md",
|
|
|
|
|
BADGE_TONE[tone],
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** A row of badges above a card title or a detail heading. */
|
|
|
|
|
export function BadgeRow({ children }: { children: ReactNode }) {
|
|
|
|
|
return <div className="flex flex-wrap items-center gap-xs">{children}</div>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
NOTICE
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
export type NoticeTone = "info" | "accent" | "danger";
|
|
|
|
|
|
|
|
|
|
const NOTICE_TONE: Readonly<Record<NoticeTone, string>> = {
|
|
|
|
|
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 (
|
|
|
|
|
<div
|
|
|
|
|
role={role}
|
|
|
|
|
className={cx(
|
|
|
|
|
"flex flex-col gap-xs rounded-card border p-lg",
|
|
|
|
|
NOTICE_TONE[tone],
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{title ? <p className="body-md-bold text-text-primary">{title}</p> : null}
|
|
|
|
|
{children ? <div className="body-md">{children}</div> : null}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
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 (
|
|
|
|
|
<CardShell
|
|
|
|
|
as={as}
|
|
|
|
|
linked
|
|
|
|
|
className={cx("flex-col overflow-hidden", className)}
|
|
|
|
|
>
|
|
|
|
|
{event.image ? (
|
|
|
|
|
<CardMedia
|
|
|
|
|
image={{ src: event.image.src, alt: event.image.alt }}
|
|
|
|
|
className="aspect-[9/5] w-full"
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col gap-md p-lg">
|
|
|
|
|
<div className="flex flex-col gap-xs">
|
|
|
|
|
<BadgeRow>
|
|
|
|
|
<StatusBadge tone={isPast ? "neutral" : "accent"}>
|
|
|
|
|
{t(eventTimeStatusKey(event.timeStatus))}
|
|
|
|
|
</StatusBadge>
|
|
|
|
|
|
|
|
|
|
{/* A finished event's registration state is history, not news. */}
|
|
|
|
|
{isPast ? null : (
|
|
|
|
|
<StatusBadge
|
|
|
|
|
tone={
|
|
|
|
|
event.registrationStatus === "full"
|
|
|
|
|
? "danger"
|
|
|
|
|
: event.registrationStatus === "closed"
|
|
|
|
|
? "neutral"
|
|
|
|
|
: "accent"
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{eventRegistrationLabel(event.registrationStatus, locale)}
|
|
|
|
|
</StatusBadge>
|
|
|
|
|
)}
|
|
|
|
|
</BadgeRow>
|
|
|
|
|
|
|
|
|
|
<Title className="sh-card-title body-lg-bold text-text-primary">
|
|
|
|
|
<CardLink href={href} className="sh-card-link">
|
|
|
|
|
{event.title}
|
|
|
|
|
</CardLink>
|
|
|
|
|
</Title>
|
|
|
|
|
|
|
|
|
|
<p className="body-sm-bold text-text-secondary">
|
|
|
|
|
<time dateTime={toIsoDate(event.start)}>{when}</time>
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<MetaLine items={[formatLabel, event.place, event.region].filter(
|
|
|
|
|
(item): item is string => Boolean(item),
|
|
|
|
|
)} />
|
|
|
|
|
|
|
|
|
|
{event.summary ? (
|
|
|
|
|
<p className="body-sm text-text-secondary line-clamp-3">
|
|
|
|
|
{event.summary}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<CardActionHint label={t(eventActionKey(event))} />
|
|
|
|
|
</div>
|
|
|
|
|
</CardShell>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
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 (
|
|
|
|
|
<CardShell
|
|
|
|
|
as={as}
|
|
|
|
|
linked
|
|
|
|
|
className={cx("flex-col overflow-hidden", className)}
|
|
|
|
|
>
|
|
|
|
|
{opportunity.image ? (
|
|
|
|
|
<CardMedia
|
|
|
|
|
image={{ src: opportunity.image.src, alt: opportunity.image.alt }}
|
|
|
|
|
className="aspect-[9/5] w-full"
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col gap-md p-lg">
|
|
|
|
|
<div className="flex flex-col gap-xs">
|
|
|
|
|
<BadgeRow>
|
|
|
|
|
<StatusBadge tone={isOpen ? "accent" : "neutral"}>
|
|
|
|
|
{t(opportunityStatusKey(opportunity.status))}
|
|
|
|
|
</StatusBadge>
|
|
|
|
|
{opportunity.opportunityType ? (
|
|
|
|
|
<StatusBadge>
|
|
|
|
|
{getRouteCopy(locale).opportunityTypes[
|
|
|
|
|
opportunity.opportunityType
|
|
|
|
|
]}
|
|
|
|
|
</StatusBadge>
|
|
|
|
|
) : null}
|
|
|
|
|
</BadgeRow>
|
|
|
|
|
|
|
|
|
|
<Title className="sh-card-title body-lg-bold text-text-primary">
|
|
|
|
|
<CardLink href={href} className="sh-card-link">
|
|
|
|
|
{opportunity.title}
|
|
|
|
|
</CardLink>
|
|
|
|
|
</Title>
|
|
|
|
|
|
|
|
|
|
{dateLine ? (
|
|
|
|
|
<p className="body-sm-bold text-text-secondary">
|
|
|
|
|
<time dateTime={toIsoDate(dateValue)}>{dateLine}</time>
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{opportunity.summary ? (
|
|
|
|
|
<p className="body-sm text-text-secondary line-clamp-3">
|
|
|
|
|
{opportunity.summary}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<CardActionHint label={t(opportunityActionKey(opportunity.status))} />
|
|
|
|
|
</div>
|
|
|
|
|
</CardShell>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
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 (
|
|
|
|
|
<section
|
|
|
|
|
aria-label={heading}
|
|
|
|
|
className="flex flex-col gap-md rounded-card border border-border-subtle bg-surface-raised p-lg"
|
|
|
|
|
>
|
|
|
|
|
<h2 className="label-md uppercase text-text-tertiary">{heading}</h2>
|
|
|
|
|
<dl className="grid gap-md sm:grid-cols-2">
|
|
|
|
|
{present.map((item) => (
|
|
|
|
|
<div key={item.label} className="flex flex-col gap-2xs">
|
|
|
|
|
<dt className="body-sm text-text-tertiary">{item.label}</dt>
|
|
|
|
|
<dd className="body-md-bold text-text-primary">{item.value}</dd>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</dl>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
CONSENT CHECKBOX
|
|
|
|
|
----------------------------------------------------------------------------
|
|
|
|
|
`event-registration.consentGdpr` is a REQUIRED boolean in the CMS, so the
|
|
|
|
|
reader has to be asked - out loud, in a real <label>, with the reason next
|
|
|
|
|
to it. ~/components/ui has no Checkbox (this is the only one in the site),
|
|
|
|
|
so the control is composed here from the same tier-2 tokens the rest of the
|
|
|
|
|
section uses; promote it to ~/components/ui when a second form needs one.
|
|
|
|
|
|
|
|
|
|
Accessibility: the label wraps nothing - it is associated by `htmlFor`, so
|
|
|
|
|
the hint and the error can sit outside it and still be announced, via
|
|
|
|
|
`aria-describedby`. `aria-invalid` marks the failure for a screen reader,
|
|
|
|
|
and the message is text, never colour alone.
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
export function ConsentCheckbox({
|
|
|
|
|
name,
|
|
|
|
|
label,
|
|
|
|
|
hint,
|
|
|
|
|
error,
|
|
|
|
|
defaultChecked = false,
|
|
|
|
|
}: {
|
|
|
|
|
name: string;
|
|
|
|
|
label: string;
|
|
|
|
|
hint?: string;
|
|
|
|
|
error?: string;
|
|
|
|
|
defaultChecked?: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const hintId = hint ? `${name}-hint` : undefined;
|
|
|
|
|
const errorId = error ? `${name}-error` : undefined;
|
|
|
|
|
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-2xs">
|
|
|
|
|
<div className="flex items-start gap-sm">
|
|
|
|
|
<input
|
|
|
|
|
id={name}
|
|
|
|
|
name={name}
|
|
|
|
|
type="checkbox"
|
|
|
|
|
value="true"
|
|
|
|
|
defaultChecked={defaultChecked}
|
|
|
|
|
required
|
|
|
|
|
aria-invalid={error ? true : undefined}
|
|
|
|
|
aria-describedby={describedBy}
|
2026-09-20 11:14:15 +02:00
|
|
|
className="mt-2xs size-md shrink-0 rounded-chip border border-border-strong accent-[var(--interactive-primary-bg)]"
|
2026-09-11 02:46:56 +02:00
|
|
|
/>
|
|
|
|
|
<label htmlFor={name} className="body-sm text-text-primary">
|
|
|
|
|
{label}
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{hint ? (
|
|
|
|
|
<p id={hintId} className="body-sm text-text-tertiary ps-lg">
|
|
|
|
|
{hint}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{error ? (
|
|
|
|
|
<p id={errorId} className="body-sm text-text-danger ps-lg">
|
|
|
|
|
{error}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|