2026-09-11 02:46:56 +02:00
|
|
|
/* ============================================================================
|
|
|
|
|
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.
|
|
|
|
|
|
2026-10-02 12:49:11 +02:00
|
|
|
The .ics download points at the sibling resource route,
|
|
|
|
|
/{locale}/events-calendar/{slug}.ics, which streams a real RFC 5545 file.
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
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";
|
2026-10-24 12:05:51 +02:00
|
|
|
import { Turnstile } from "~/components/ui/Turnstile";
|
2026-09-11 02:46:56 +02:00
|
|
|
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";
|
2026-10-24 12:05:51 +02:00
|
|
|
import { verifyTurnstile } from "~/lib/turnstile.server";
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
import type { Route } from "./+types/events-calendar.$slug";
|
|
|
|
|
import { getRouteCopy } from "./_events-opportunities/copy";
|
|
|
|
|
import {
|
|
|
|
|
EVENT_TIME_ZONE,
|
|
|
|
|
eventRegistrationLabel,
|
|
|
|
|
eventTimeStatusKey,
|
|
|
|
|
formatEventDay,
|
|
|
|
|
formatEventWhen,
|
|
|
|
|
isRegistrationOpen,
|
|
|
|
|
isWaitlisted,
|
2026-10-02 12:49:11 +02:00
|
|
|
type EventDetailData,
|
2026-09-11 02:46:56 +02:00
|
|
|
} 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;
|
|
|
|
|
|
2026-10-02 12:49:11 +02:00
|
|
|
function addCalendarDay(date: Date): Date {
|
|
|
|
|
const next = new Date(date);
|
|
|
|
|
next.setUTCDate(next.getUTCDate() + 1);
|
|
|
|
|
return next;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toGoogleCalendarDate(date: Date): string {
|
|
|
|
|
return date.toISOString().slice(0, 10).replace(/-/g, "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toGoogleCalendarDateTime(date: Date): string {
|
|
|
|
|
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildGoogleCalendarHref(event: EventDetailData): string {
|
|
|
|
|
const start = new Date(event.start);
|
|
|
|
|
const parsedEnd = event.end ? new Date(event.end) : undefined;
|
|
|
|
|
const end =
|
|
|
|
|
parsedEnd && !Number.isNaN(parsedEnd.getTime()) ? parsedEnd : undefined;
|
|
|
|
|
const dates = event.allDay
|
|
|
|
|
? `${toGoogleCalendarDate(start)}/${toGoogleCalendarDate(
|
|
|
|
|
addCalendarDay(end ?? start),
|
|
|
|
|
)}`
|
|
|
|
|
: `${toGoogleCalendarDateTime(start)}/${toGoogleCalendarDateTime(
|
|
|
|
|
end && end.getTime() > start.getTime() ? end : start,
|
|
|
|
|
)}`;
|
|
|
|
|
|
|
|
|
|
const params = new URLSearchParams({
|
|
|
|
|
action: "TEMPLATE",
|
|
|
|
|
text: event.title,
|
|
|
|
|
dates,
|
|
|
|
|
});
|
|
|
|
|
const details = [event.summary, event.onlineUrl].filter(Boolean).join("\n\n");
|
|
|
|
|
const location = event.place ?? event.venue;
|
|
|
|
|
|
|
|
|
|
if (details) params.set("details", details);
|
|
|
|
|
if (location) params.set("location", location);
|
|
|
|
|
|
|
|
|
|
return `https://calendar.google.com/calendar/render?${params.toString()}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 02:46:56 +02:00
|
|
|
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;
|
2026-10-24 12:05:51 +02:00
|
|
|
/** True when validation passed but the Turnstile check did not. */
|
|
|
|
|
readonly verificationFailed: boolean;
|
2026-09-11 02:46:56 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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)) {
|
2026-09-24 11:51:24 +02:00
|
|
|
return { ok: true, waitlist: isWaitlisted(event) } satisfies ActionResult;
|
2026-09-11 02:46:56 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { values, errors, valid } = validateRegistration(form);
|
|
|
|
|
|
|
|
|
|
if (!valid) {
|
|
|
|
|
return data(
|
2026-10-24 12:05:51 +02:00
|
|
|
{
|
|
|
|
|
ok: false,
|
|
|
|
|
errors,
|
|
|
|
|
values,
|
|
|
|
|
failed: false,
|
|
|
|
|
verificationFailed: false,
|
|
|
|
|
} satisfies ActionResult,
|
2026-09-11 02:46:56 +02:00
|
|
|
{ status: 400 },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 12:05:51 +02:00
|
|
|
if (!(await verifyTurnstile(form))) {
|
|
|
|
|
return data(
|
|
|
|
|
{
|
|
|
|
|
ok: false,
|
|
|
|
|
errors: {},
|
|
|
|
|
values,
|
|
|
|
|
failed: false,
|
|
|
|
|
verificationFailed: true,
|
|
|
|
|
} satisfies ActionResult,
|
|
|
|
|
{ status: 403 },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 11:51:24 +02:00
|
|
|
let registrationState: "confirmed" | "waitlisted";
|
2026-09-11 02:46:56 +02:00
|
|
|
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. */
|
2026-09-24 11:51:24 +02:00
|
|
|
registrationState = await submitRegistration({
|
|
|
|
|
values,
|
|
|
|
|
eventId: event.documentId,
|
|
|
|
|
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
|
|
|
|
|
});
|
2026-09-11 02:46:56 +02:00
|
|
|
} 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(
|
2026-10-24 12:05:51 +02:00
|
|
|
{
|
|
|
|
|
ok: false,
|
|
|
|
|
errors: {},
|
|
|
|
|
values,
|
|
|
|
|
failed: true,
|
|
|
|
|
verificationFailed: false,
|
|
|
|
|
} satisfies ActionResult,
|
2026-09-11 02:46:56 +02:00
|
|
|
{ status: 502 },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 11:51:24 +02:00
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
waitlist: registrationState === "waitlisted",
|
|
|
|
|
} satisfies ActionResult;
|
2026-09-11 02:46:56 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
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">
|
2026-10-02 12:49:11 +02:00
|
|
|
<Button
|
|
|
|
|
as="a"
|
|
|
|
|
href={buildGoogleCalendarHref(event)}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
variant="secondary"
|
|
|
|
|
iconStart={
|
|
|
|
|
<img
|
|
|
|
|
src="/assets/icons/google-calendar.svg"
|
|
|
|
|
alt=""
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
height={20}
|
|
|
|
|
/>
|
|
|
|
|
}
|
|
|
|
|
fullWidth
|
|
|
|
|
>
|
|
|
|
|
{t("actions.addToGoogleCalendar")}
|
|
|
|
|
</Button>
|
2026-09-11 02:46:56 +02:00
|
|
|
<Button
|
|
|
|
|
as="a"
|
|
|
|
|
href={`${detailPath}.ics`}
|
|
|
|
|
download
|
|
|
|
|
variant="secondary"
|
|
|
|
|
fullWidth
|
|
|
|
|
>
|
2026-10-02 12:49:11 +02:00
|
|
|
{t("actions.downloadIcsFile")}
|
2026-09-11 02:46:56 +02:00
|
|
|
</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}
|
|
|
|
|
|
2026-10-24 12:05:51 +02:00
|
|
|
{result && !result.ok && result.verificationFailed ? (
|
|
|
|
|
<Notice tone="danger" role="alert" title={t("form.errorVerification")} />
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{/* A plain POST to this same URL: no fetcher and no client validation.
|
|
|
|
|
Only the Turnstile check, when configured, needs scripting. */}
|
2026-09-11 02:46:56 +02:00
|
|
|
<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>
|
|
|
|
|
|
2026-10-24 12:05:51 +02:00
|
|
|
<Turnstile
|
|
|
|
|
action="event-registration"
|
|
|
|
|
locale={locale}
|
|
|
|
|
resetKey={result}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-09-11 02:46:56 +02:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|