initial commit
This commit is contained in:
@@ -0,0 +1,643 @@
|
||||
/* ============================================================================
|
||||
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();
|
||||
|
||||
const waitlist = isWaitlisted(event);
|
||||
|
||||
/* 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 } satisfies ActionResult;
|
||||
}
|
||||
|
||||
const { values, errors, valid } = validateRegistration(form);
|
||||
|
||||
if (!valid) {
|
||||
return data(
|
||||
{ ok: false, errors, values, failed: false } satisfies ActionResult,
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
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. */
|
||||
await submitRegistration({ values, eventId: event.documentId });
|
||||
} 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 } 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user