Files
sharper.ly/apps/web/app/routes/_events-opportunities/ui.tsx
T

433 lines
13 KiB
TypeScript
Raw Normal View History

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}
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>
);
}