Render the widget on the contact, event registration and both application forms, and verify the token with Siteverify in each action before anything is written to the CMS. Verification fails closed. The check is off unless both TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY are set. Allow challenges.cloudflare.com in script-src and frame-src. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
749 lines
23 KiB
TypeScript
749 lines
23 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.
|
|
|
|
The .ics download 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 { Turnstile } from "~/components/ui/Turnstile";
|
|
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 { verifyTurnstile } from "~/lib/turnstile.server";
|
|
|
|
import type { Route } from "./+types/events-calendar.$slug";
|
|
import { getRouteCopy } from "./_events-opportunities/copy";
|
|
import {
|
|
EVENT_TIME_ZONE,
|
|
eventRegistrationLabel,
|
|
eventTimeStatusKey,
|
|
formatEventDay,
|
|
formatEventWhen,
|
|
isRegistrationOpen,
|
|
isWaitlisted,
|
|
type EventDetailData,
|
|
} 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;
|
|
|
|
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()}`;
|
|
}
|
|
|
|
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;
|
|
/** True when validation passed but the Turnstile check did not. */
|
|
readonly verificationFailed: 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,
|
|
verificationFailed: false,
|
|
} satisfies ActionResult,
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
if (!(await verifyTurnstile(form))) {
|
|
return data(
|
|
{
|
|
ok: false,
|
|
errors: {},
|
|
values,
|
|
failed: false,
|
|
verificationFailed: true,
|
|
} satisfies ActionResult,
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
|
|
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,
|
|
verificationFailed: false,
|
|
} 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={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>
|
|
<Button
|
|
as="a"
|
|
href={`${detailPath}.ics`}
|
|
download
|
|
variant="secondary"
|
|
fullWidth
|
|
>
|
|
{t("actions.downloadIcsFile")}
|
|
</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}
|
|
|
|
{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. */}
|
|
<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>
|
|
|
|
<Turnstile
|
|
action="event-registration"
|
|
locale={locale}
|
|
resetKey={result}
|
|
/>
|
|
|
|
<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>
|
|
);
|
|
}
|