initial commit
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
/* ============================================================================
|
||||
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}
|
||||
className="mt-2xs size-md shrink-0 rounded-[var(--radius-control)] border border-border-strong accent-[var(--interactive-primary-bg)]"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user