/* ============================================================================ 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 , 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> | 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(); 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: ( ), }, { 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: ( ), }, ] : []), ...(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) ? ( {event.onlineUrl} ) : ( event.onlineUrl ), }, ] : []), ].filter((item) => item.value !== undefined && item.value !== ""); return (
{t(eventTimeStatusKey(event.timeStatus))} {isPast ? null : ( {eventRegistrationLabel(event.registrationStatus, locale)} )} {event.region ? {event.region} : null}

{event.title}

{event.summary ? (

{event.summary}

) : null}
{event.image ? ( {event.image.alt} ) : null}
{event.description ? (

{copy.eventDetail.aboutHeading}

) : null}
); } /* --------------------------------------------------------------------------- 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 ( {result.waitlist ? interpolate(copy.eventDetail.waitlistConfirmationBody, { event: eventTitle, date: eventDay, }) : t("events.confirmationBody", { event: eventTitle, date: eventDay })} ); } if (isPast) { return ( {/* `safeHref` gates the CMS URL; without a usable one the notice keeps its text and simply carries no button. */} {safeHref(recapUrl) ? ( ) : null} ); } if (!registrationRequired) { return ( {copy.eventDetail.registrationNotRequiredNote} ); } if (!canRegister) { return ; } return (

{waitlist ? copy.eventDetail.waitlistHeading : copy.eventDetail.registerHeading}

{waitlist ? copy.eventDetail.registrationFull : copy.eventDetail.formIntro}

{result && !result.ok && result.failed ? ( {copy.form.submissionFailed} ) : null} {/* A plain POST to this same URL: no fetcher, no client validation, and therefore nothing that stops working with scripting off. */}
{/* REQUIRED by the CMS, and asked for out loud: an unticked box is a validation error, never a silent default. */} {/* Honeypot. Hidden from sight and from assistive technology alike. */}
); }