Files
sharper.ly/apps/web/app/routes/events-calendar.$slug.tsx
T

650 lines
21 KiB
TypeScript

/* ============================================================================
EVENTS CALENDAR - one event
----------------------------------------------------------------------------
/{locale}/events-calendar/{slug}
Draft section 4: the detail page carries the registration, the confirmation
screen ("You're registered.") and - because an event whose places are gone
still has to be joinable later - a waitlist state.
GDPR CONSENT
`event-registration.consentGdpr` is REQUIRED in the CMS, so the form asks
for it with a real, labelled checkbox in both languages and the action
refuses to submit without it. It is never a hidden input: consent that the
reader did not give is not consent.
"Add to calendar" points at the sibling resource route,
/{locale}/events-calendar/{slug}.ics, which streams a real RFC 5545 file.
ROUTE
Registered in app/routes.ts as "eventsDetail", a SIBLING of the listing route
under the ":lang" layout - a listing renders a page, not an <Outlet/>, so a
detail page can never be its child. Typed against its generated `./+types/`
module.
404 / 503
A slug that does not exist is a 404 - including "the collection has not been
created yet", which `fetchBySlug` maps to null. A CMS outage is NOT turned
into a 404: it propagates, so a crawler is never told that a real event has
been removed.
========================================================================= */
import {
data,
Form,
useActionData,
useLoaderData,
useNavigation,
} from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Input } from "~/components/ui/Input";
import {
formatDate,
interpolate,
localizedPath,
sectionPath,
toIsoDate,
translate,
useTranslation,
} from "~/i18n";
import { safeHref } from "~/lib/safe-href";
import { buildBreadcrumbJsonLd, buildEventJsonLd, buildMeta } from "~/lib/seo";
import { isStrapiError, requireEntity } from "~/lib/strapi";
import type { Route } from "./+types/events-calendar.$slug";
import { getRouteCopy } from "./_events-opportunities/copy";
import {
EVENT_TIME_ZONE,
eventRegistrationLabel,
eventTimeStatusKey,
formatEventDay,
formatEventWhen,
isRegistrationOpen,
isWaitlisted,
} from "./_events-opportunities/events";
import { fetchEvent } from "./_events-opportunities/events.server";
import {
CONSENT_FIELD,
FIELD_ERROR_KEYS,
HONEYPOT_FIELD,
isHoneypotFilled,
validateRegistration,
type FieldErrors,
type RegistrationValues,
} from "./_events-opportunities/forms";
import { readRootSeo } from "./_events-opportunities/root-seo";
import { submitRegistration } from "./_events-opportunities/submissions.server";
import {
BadgeRow,
ConsentCheckbox,
DetailList,
Notice,
StatusBadge,
type DetailItem,
} from "./_events-opportunities/ui";
import {
detailAlternateDescriptors,
SELF_ALTERNATES_HANDLE,
} from "./_shared/alternates";
import { requireLocale } from "./_shared/listing";
import { BackLink, PageShell } from "./_shared/listing-ui";
import { RichText } from "./_shared/RichText";
/**
* A Strapi 5 localisation has its own slug, so this route emits its own
* canonical + hreflang from `alternates` below rather than letting the shell
* swap "/en/" for "/ar/" onto a URL that does not exist.
*/
export const handle = SELF_ALTERNATES_HANDLE;
export async function loader({ params }: Route.LoaderArgs) {
const locale = requireLocale(params.lang);
const slug = params.slug ?? "";
const { event, alternates } = requireEntity(await fetchEvent(slug, locale));
return { locale, event, alternates };
}
/* ---------------------------------------------------------------------------
REGISTRATION
------------------------------------------------------------------------ */
type ActionResult =
| { readonly ok: true; readonly waitlist: boolean }
| {
readonly ok: false;
readonly errors: FieldErrors;
readonly values: RegistrationValues;
/** True when validation passed but the CMS refused the write. */
readonly failed: boolean;
};
export async function action({ params, request }: Route.ActionArgs) {
const locale = requireLocale(params.lang);
const slug = params.slug ?? "";
const { event } = requireEntity(await fetchEvent(slug, locale));
const form = await request.formData();
/* A form nobody can submit through the page must not be submittable through
a POST either - registration state is checked again on the server. */
if (!isRegistrationOpen(event)) {
throw data("Registration is closed", { status: 409 });
}
/* A bot that fills every field gets the acknowledgement and writes nothing. */
if (isHoneypotFilled(form)) {
return { ok: true, waitlist: isWaitlisted(event) } satisfies ActionResult;
}
const { values, errors, valid } = validateRegistration(form);
if (!valid) {
return data(
{ ok: false, errors, values, failed: false } satisfies ActionResult,
{ status: 400 },
);
}
let registrationState: "confirmed" | "waitlisted";
try {
/* Only the fields the CMS whitelist accepts are sent - see
./_events-opportunities/submissions.server.ts. `registrationState` is
deliberately NOT among them: the CMS middleware derives it from the
event's own capacity and waitlist settings, which is the only place the
count of taken places can be read without trusting the client. The
`waitlist` flag below is therefore presentational - it decides which
sentence the reader sees, and it is read from the same event fields the
middleware weighs, so the two agree. */
registrationState = await submitRegistration({
values,
eventId: event.documentId,
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
});
} catch (error) {
if (!isStrapiError(error)) throw error;
/* A 4xx here is the CMS refusing the registration on its own terms - the
event filled up or closed between the page render and this POST - and a
5xx is an outage. The reader sees one honest "that did not go through"
either way; the distinction is kept in the log for staff. */
console.error(
`[events] registration for "${slug}" was rejected: ${error.status} ${error.message}`,
);
return data(
{ ok: false, errors: {}, values, failed: true } satisfies ActionResult,
{ status: 502 },
);
}
return {
ok: true,
waitlist: registrationState === "waitlisted",
} satisfies ActionResult;
}
/* ---------------------------------------------------------------------------
META
------------------------------------------------------------------------ */
export function meta({ matches, loaderData }: Route.MetaArgs) {
const root = readRootSeo(matches);
const result = loaderData as Awaited<ReturnType<typeof loader>> | undefined;
const copy = getRouteCopy(root.locale);
if (!result) {
return buildMeta({
locale: root.locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: copy.events.heading,
description: copy.events.metaDescription,
});
}
const { event } = result;
const when = formatEventWhen(event, root.locale, { withTimeZone: true });
const description =
event.summary ?? [when, event.place].filter(Boolean).join(" · ");
const descriptors = buildMeta({
locale: root.locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: event.title,
description,
...(event.image
? { image: { url: event.image.src, alt: event.image.alt } }
: {}),
jsonLd: [
buildEventJsonLd({
siteUrl: root.siteUrl,
locale: root.locale,
name: event.title,
pathname: root.pathname,
startDate: toIsoDate(event.start) ?? event.start,
...(event.end ? { endDate: toIsoDate(event.end) ?? event.end } : {}),
...(event.summary ? { description: event.summary } : {}),
...(event.image ? { image: event.image.src } : {}),
...(event.venue ? { locationName: event.venue } : {}),
...(event.region ? { addressLocality: event.region } : {}),
...(event.onlineUrl ? { onlineUrl: event.onlineUrl } : {}),
isOnline: event.format === "online" || event.format === "hybrid",
}),
buildBreadcrumbJsonLd(root.siteUrl, [
{
name: translate(root.locale, "nav.sections.home"),
pathname: localizedPath("/", root.locale),
},
{
name: translate(root.locale, "nav.sections.events"),
pathname: sectionPath("events", root.locale),
},
{ name: event.title, pathname: root.pathname },
]),
],
});
return [
...descriptors,
...detailAlternateDescriptors({
siteUrl: root.siteUrl,
section: "events",
locale: root.locale,
slugs: result.alternates,
}),
];
}
/* ---------------------------------------------------------------------------
PAGE
------------------------------------------------------------------------ */
export default function EventDetail() {
const { event } = useLoaderData<typeof loader>();
const result = useActionData() as ActionResult | undefined;
const navigation = useNavigation();
const { locale, t } = useTranslation();
const copy = getRouteCopy(locale);
const listPath = sectionPath("events", locale);
const detailPath = `${listPath}/${encodeURIComponent(event.slug)}`;
const isSubmitting = navigation.state === "submitting";
const isPast = event.timeStatus === "completed";
const waitlist = isWaitlisted(event);
const canRegister = isRegistrationOpen(event);
const errors = result && !result.ok ? result.errors : {};
const values = result && !result.ok ? result.values : undefined;
const fieldError = (name: string) => {
const code = errors[name];
return code ? t(FIELD_ERROR_KEYS[code]) : undefined;
};
const formatLabel =
event.format === "online"
? t("events.online")
: event.format === "hybrid"
? copy.events.formatHybrid
: event.format === "in-person"
? t("events.inPerson")
: undefined;
const details: DetailItem[] = [
{
label: t("events.dateTime"),
value: (
<time dateTime={toIsoDate(event.start)}>
{formatEventWhen(event, locale, { withTimeZone: true })}
</time>
),
},
{
label: t("events.location"),
value: [formatLabel, event.place].filter(Boolean).join(" · ") || undefined,
},
{
label: t("events.registrationStatus"),
value: eventRegistrationLabel(event.registrationStatus, locale),
},
...(event.registrationDeadline
? [
{
label: copy.eventDetail.registrationDeadlineLabel,
value: (
<time dateTime={toIsoDate(event.registrationDeadline)}>
{formatDate(event.registrationDeadline, locale, {
dateStyle: "long",
timeZone: EVENT_TIME_ZONE,
})}
</time>
),
},
]
: []),
...(event.onlineUrl
? [
{
label: copy.eventDetail.joinOnlineLabel,
/* CMS-supplied URL: `safeHref` rejects anything that is not
http/https/mailto/tel, and the reader still sees the address
the editor entered - as text rather than as a link. */
value: safeHref(event.onlineUrl) ? (
<a
href={safeHref(event.onlineUrl)}
target="_blank"
rel="noopener noreferrer"
className="text-text-link underline underline-offset-2 hover:text-text-link-hover"
>
{event.onlineUrl}
</a>
) : (
event.onlineUrl
),
},
]
: []),
].filter((item) => item.value !== undefined && item.value !== "");
return (
<PageShell>
<BackLink to={listPath} label={copy.eventDetail.backToList} />
<header className="flex max-w-[var(--layout-prose-max)] flex-col gap-md">
<BadgeRow>
<StatusBadge tone={isPast ? "neutral" : "accent"}>
{t(eventTimeStatusKey(event.timeStatus))}
</StatusBadge>
{isPast ? null : (
<StatusBadge
tone={
waitlist
? "danger"
: event.registrationStatus === "closed"
? "neutral"
: "accent"
}
>
{eventRegistrationLabel(event.registrationStatus, locale)}
</StatusBadge>
)}
{event.region ? <StatusBadge>{event.region}</StatusBadge> : null}
</BadgeRow>
<h1 className="display-sm text-text-primary">{event.title}</h1>
<p className="body-lg text-text-secondary">
<time dateTime={toIsoDate(event.start)}>
{formatEventWhen(event, locale, { withTimeZone: true })}
</time>
</p>
{event.summary ? (
<p className="body-lg text-text-secondary">{event.summary}</p>
) : null}
</header>
{event.image ? (
<img
src={event.image.src}
alt={event.image.alt}
{...(event.image.width ? { width: event.image.width } : {})}
{...(event.image.height ? { height: event.image.height } : {})}
className="w-full rounded-surface border border-border-subtle object-cover"
/>
) : null}
<div className="flex flex-col gap-2xl lg:flex-row lg:items-start">
<div className="flex min-w-0 flex-1 flex-col gap-lg">
{event.description ? (
<section
aria-labelledby="event-about"
className="flex flex-col gap-md"
>
<h2 id="event-about" className="heading-h2 text-text-primary">
{copy.eventDetail.aboutHeading}
</h2>
<RichText value={event.description} />
</section>
) : null}
<RegistrationSection
detailPath={detailPath}
canRegister={canRegister}
isPast={isPast}
waitlist={waitlist}
result={result}
isSubmitting={isSubmitting}
fieldError={fieldError}
values={values}
eventTitle={event.title}
eventDay={formatEventDay(event, locale)}
registrationRequired={event.registrationStatus !== "not-required"}
recapUrl={event.recapUrl}
/>
</div>
<aside className="flex w-full flex-col gap-lg lg:w-[22rem] lg:shrink-0">
<DetailList heading={copy.eventDetail.detailsHeading} items={details} />
<div className="flex flex-col gap-xs">
<Button
as="a"
href={`${detailPath}.ics`}
download
variant="secondary"
fullWidth
>
{t("actions.addToCalendar")}
</Button>
<p className="body-sm text-text-tertiary">
{copy.eventDetail.addToCalendarHint}
</p>
{event.allDay ? null : (
<p className="body-sm text-text-tertiary">
{copy.eventDetail.timeZoneNote}
</p>
)}
</div>
</aside>
</div>
</PageShell>
);
}
/* ---------------------------------------------------------------------------
REGISTRATION SECTION
Its own component so the page above stays readable - the state machine is
small but real: closed, elsewhere, past, waitlist, open, acknowledged.
------------------------------------------------------------------------ */
function RegistrationSection({
detailPath,
canRegister,
isPast,
waitlist,
result,
isSubmitting,
fieldError,
values,
eventTitle,
eventDay,
registrationRequired,
recapUrl,
}: {
detailPath: string;
canRegister: boolean;
isPast: boolean;
waitlist: boolean;
result: ActionResult | undefined;
isSubmitting: boolean;
fieldError: (name: string) => string | undefined;
values: RegistrationValues | undefined;
eventTitle: string;
eventDay: string;
registrationRequired: boolean;
recapUrl?: string;
}) {
const { t, locale } = useTranslation();
const copy = getRouteCopy(locale);
if (result?.ok) {
return (
<Notice
tone="accent"
role="status"
title={
result.waitlist
? copy.eventDetail.waitlistConfirmationTitle
: t("events.confirmationTitle")
}
>
{result.waitlist
? interpolate(copy.eventDetail.waitlistConfirmationBody, {
event: eventTitle,
date: eventDay,
})
: t("events.confirmationBody", { event: eventTitle, date: eventDay })}
</Notice>
);
}
if (isPast) {
return (
<Notice tone="info" title={copy.eventDetail.eventCompleted}>
{/* `safeHref` gates the CMS URL; without a usable one the notice
keeps its text and simply carries no button. */}
{safeHref(recapUrl) ? (
<Button
as="a"
href={safeHref(recapUrl)}
variant="secondary"
size="sm"
iconEnd={<ArrowIcon />}
>
{t("actions.readRecap")}
</Button>
) : null}
</Notice>
);
}
if (!registrationRequired) {
return (
<Notice
tone="info"
title={copy.eventDetail.registrationNotRequired}
>
{copy.eventDetail.registrationNotRequiredNote}
</Notice>
);
}
if (!canRegister) {
return <Notice tone="info" title={copy.eventDetail.registrationClosed} />;
}
return (
<section
id="register"
aria-labelledby="register-heading"
className="flex flex-col gap-lg rounded-card border border-border-subtle bg-surface-raised p-lg"
>
<div className="flex flex-col gap-xs">
<h2 id="register-heading" className="heading-h3 text-text-primary">
{waitlist
? copy.eventDetail.waitlistHeading
: copy.eventDetail.registerHeading}
</h2>
<p className="body-sm text-text-secondary">
{waitlist
? copy.eventDetail.registrationFull
: copy.eventDetail.formIntro}
</p>
</div>
{result && !result.ok && result.failed ? (
<Notice tone="danger" role="alert" title={t("form.errorGeneric")}>
{copy.form.submissionFailed}
</Notice>
) : null}
{/* A plain POST to this same URL: no fetcher, no client validation, and
therefore nothing that stops working with scripting off. */}
<Form method="post" action={detailPath} className="flex flex-col gap-md">
<div className="grid gap-md sm:grid-cols-2">
<Input
name="fullName"
label={t("form.name")}
required
autoComplete="name"
defaultValue={values?.fullName}
error={fieldError("fullName")}
/>
<Input
name="email"
type="email"
label={t("form.email")}
required
autoComplete="email"
inputMode="email"
defaultValue={values?.email}
error={fieldError("email")}
/>
<Input
name="organisation"
label={t("form.organisation")}
hint={t("form.optional")}
autoComplete="organization"
defaultValue={values?.organisation}
/>
<Input
name="phone"
type="tel"
label={t("form.phone")}
hint={t("form.optional")}
autoComplete="tel"
defaultValue={values?.phone}
/>
</div>
{/* REQUIRED by the CMS, and asked for out loud: an unticked box is a
validation error, never a silent default. */}
<ConsentCheckbox
name={CONSENT_FIELD}
label={copy.eventDetail.consentLabel}
hint={copy.eventDetail.consentHint}
defaultChecked={values?.consentGdpr ?? false}
{...(fieldError(CONSENT_FIELD)
? { error: fieldError(CONSENT_FIELD) }
: {})}
/>
{/* Honeypot. Hidden from sight and from assistive technology alike. */}
<div aria-hidden="true" className="hidden">
<label htmlFor={HONEYPOT_FIELD}>{copy.form.honeypotLabel}</label>
<input
id={HONEYPOT_FIELD}
name={HONEYPOT_FIELD}
type="text"
tabIndex={-1}
autoComplete="off"
/>
</div>
<div className="flex flex-wrap items-center gap-sm">
<Button
type="submit"
variant="primary"
isLoading={isSubmitting}
iconEnd={<ArrowIcon />}
>
{waitlist ? t("events.joinWaitlist") : t("actions.register")}
</Button>
</div>
</Form>
</section>
);
}