fix: FIFO queue for waitlisting; send email for application form; EU logo in contact page in light mode
This commit is contained in:
@@ -120,8 +120,8 @@ export function resetServerEnv(): void {
|
||||
* the incoming request is trusted, honouring the standard forwarding headers.
|
||||
*/
|
||||
export function getSiteUrl(request: Request): string {
|
||||
const { SITE_URL } = getServerEnv();
|
||||
if (process.env.SITE_URL?.trim()) return SITE_URL;
|
||||
const env = getServerEnv();
|
||||
if (process.env.SITE_URL?.trim() || env.isProduction) return env.SITE_URL;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const forwardedHost =
|
||||
|
||||
@@ -114,9 +114,8 @@ const EN = {
|
||||
uploaded here (see `documentsNote`), so no limit is quoted at all.
|
||||
* "an automatic acknowledgment with your reference number" - there is no
|
||||
reference-number field anywhere in the CMS, so no number is promised.
|
||||
The acknowledgement itself IS modelled, as
|
||||
`application.acknowledgementSentAt`, so it is the only half that
|
||||
survives.
|
||||
The acknowledgement itself IS sent: the CMS mails it on submission
|
||||
and stamps `application.acknowledgementSentAt` when it went out.
|
||||
* "follows up within four weeks" - that response time appears in no
|
||||
approved document; §8 of the draft marks it "[X weeks] - TBD". The
|
||||
follow-up is stated without a period attached, exactly as the per-call
|
||||
@@ -168,7 +167,7 @@ const EN = {
|
||||
*/
|
||||
documentsNote:
|
||||
"Documents are not uploaded through this form. Describe any supporting material in the summary above.",
|
||||
/** `application.acknowledgementSentAt` - modelled, so it can be said. */
|
||||
/** `application.acknowledgementSentAt` - sent, not just modelled. */
|
||||
acknowledgement: "Your submission is acknowledged automatically by email.",
|
||||
submit: "Submit application",
|
||||
errorSummaryTitle: "Please check the form",
|
||||
|
||||
@@ -147,6 +147,7 @@ function toCard(entry: StrapiEvent, now: Date): EventCardData {
|
||||
registrationStatus:
|
||||
oneOf(entry.registrationStatus, EVENT_REGISTRATION_STATUSES) ??
|
||||
"not-required",
|
||||
registrationDeadline: text(entry.registrationDeadline),
|
||||
timeStatus: deriveTimeStatus({ start, end, allDay }, now),
|
||||
recapUrl: text(entry.recapUrl),
|
||||
image: toImage(entry.coverImage, "medium"),
|
||||
|
||||
@@ -114,6 +114,7 @@ export interface EventCardData {
|
||||
/** Name of the related region entry, not a free-text string. */
|
||||
readonly region?: string;
|
||||
readonly registrationStatus: EventRegistrationStatus;
|
||||
readonly registrationDeadline?: string;
|
||||
readonly timeStatus: EventTimeStatus;
|
||||
/** Set on a past event that has a write-up - drives the "Read recap" label. */
|
||||
readonly recapUrl?: string;
|
||||
@@ -126,7 +127,6 @@ export interface EventDetailData extends EventCardData {
|
||||
/** The long body. There is no external registration portal field in the CMS. */
|
||||
readonly description?: CmsRichText;
|
||||
readonly onlineUrl?: string;
|
||||
readonly registrationDeadline?: string;
|
||||
readonly venue?: string;
|
||||
/** Editorial: the CMS offers a waitlist once the event is full. */
|
||||
readonly waitlistEnabled: boolean;
|
||||
@@ -330,8 +330,16 @@ export function collectRegions(
|
||||
* reader simply turns up, so there is nothing to submit; "closed" speaks for
|
||||
* itself; "full" and "waitlist" still take names for the waiting list.
|
||||
*/
|
||||
export function isRegistrationOpen(event: EventCardData): boolean {
|
||||
export function isRegistrationOpen(
|
||||
event: EventCardData,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
const deadline = event.registrationDeadline
|
||||
? Date.parse(event.registrationDeadline)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
(deadline === undefined || (Number.isFinite(deadline) && now < deadline)) &&
|
||||
event.timeStatus !== "completed" &&
|
||||
(event.registrationStatus === "open" ||
|
||||
event.registrationStatus === "full" ||
|
||||
|
||||
@@ -19,6 +19,7 @@ const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
export interface StrapiWriteInit {
|
||||
readonly timeoutMs?: number;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly forwardedFor?: string;
|
||||
}
|
||||
|
||||
interface StrapiErrorEnvelope {
|
||||
@@ -49,6 +50,7 @@ export async function createStrapiEntry<T>(
|
||||
accept: "application/json",
|
||||
};
|
||||
if (env.STRAPI_TOKEN) headers.authorization = `Bearer ${env.STRAPI_TOKEN}`;
|
||||
if (init.forwardedFor) headers["x-forwarded-for"] = init.forwardedFor;
|
||||
|
||||
const timeoutMs = init.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
|
||||
@@ -68,26 +68,43 @@ export interface RegistrationSubmission {
|
||||
readonly values: RegistrationValues;
|
||||
/** documentId of the event being registered for. */
|
||||
readonly eventId: string;
|
||||
readonly forwardedFor?: string;
|
||||
}
|
||||
|
||||
export async function submitRegistration(
|
||||
input: RegistrationSubmission,
|
||||
): Promise<void> {
|
||||
await createStrapiEntry(EVENT_REGISTRATIONS_RESOURCE, {
|
||||
event: input.eventId,
|
||||
fullName: input.values.fullName,
|
||||
email: input.values.email,
|
||||
phone: optional(input.values.phone),
|
||||
organisation: optional(input.values.organisation),
|
||||
// Required in the schema: without it every POST is a 400.
|
||||
consentGdpr: input.values.consentGdpr,
|
||||
});
|
||||
): Promise<"confirmed" | "waitlisted"> {
|
||||
const created = await createStrapiEntry<{
|
||||
readonly registrationState?: unknown;
|
||||
}>(
|
||||
EVENT_REGISTRATIONS_RESOURCE,
|
||||
{
|
||||
event: input.eventId,
|
||||
fullName: input.values.fullName,
|
||||
email: input.values.email,
|
||||
phone: optional(input.values.phone),
|
||||
organisation: optional(input.values.organisation),
|
||||
// Required in the schema: without it every POST is a 400.
|
||||
consentGdpr: input.values.consentGdpr,
|
||||
},
|
||||
{ forwardedFor: input.forwardedFor },
|
||||
);
|
||||
|
||||
if (
|
||||
created?.registrationState !== "confirmed" &&
|
||||
created?.registrationState !== "waitlisted"
|
||||
) {
|
||||
throw new Error("Strapi returned an invalid registration state.");
|
||||
}
|
||||
|
||||
return created.registrationState;
|
||||
}
|
||||
|
||||
export interface ApplicationSubmission {
|
||||
readonly values: ApplicationValues;
|
||||
/** documentId of the opportunity being applied to. */
|
||||
readonly opportunityId: string;
|
||||
readonly forwardedFor?: string;
|
||||
}
|
||||
|
||||
/** The key the free-text answer is filed under inside `answers` (json). */
|
||||
@@ -96,17 +113,21 @@ export const APPLICATION_MESSAGE_KEY = "message";
|
||||
export async function submitApplication(
|
||||
input: ApplicationSubmission,
|
||||
): Promise<void> {
|
||||
await createStrapiEntry(APPLICATIONS_RESOURCE, {
|
||||
opportunity: input.opportunityId,
|
||||
// `fullName` is the registration's field name; an application's is
|
||||
// `applicantName`.
|
||||
applicantName: input.values.fullName,
|
||||
email: input.values.email,
|
||||
organisation: optional(input.values.organisation),
|
||||
// `answers` is a json field - the form's one free-text question goes in
|
||||
// it, keyed, so more questions can be added without another migration.
|
||||
answers: { [APPLICATION_MESSAGE_KEY]: input.values.message },
|
||||
});
|
||||
await createStrapiEntry(
|
||||
APPLICATIONS_RESOURCE,
|
||||
{
|
||||
opportunity: input.opportunityId,
|
||||
// `fullName` is the registration's field name; an application's is
|
||||
// `applicantName`.
|
||||
applicantName: input.values.fullName,
|
||||
email: input.values.email,
|
||||
organisation: optional(input.values.organisation),
|
||||
// `answers` is a json field - the form's one free-text question goes in
|
||||
// it, keyed, so more questions can be added without another migration.
|
||||
answers: { [APPLICATION_MESSAGE_KEY]: input.values.message },
|
||||
},
|
||||
{ forwardedFor: input.forwardedFor },
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
@@ -134,6 +155,7 @@ export const APPLICATION_ANSWER_KEYS = {
|
||||
|
||||
export interface PathwayApplicationSubmission {
|
||||
readonly values: PathwayApplicationValues;
|
||||
readonly forwardedFor?: string;
|
||||
/**
|
||||
* documentId of the opportunity this application belongs to, when one is
|
||||
* known. The Get Involved form is NOT tied to a single call - it is the
|
||||
@@ -159,22 +181,26 @@ export async function submitPathwayApplication(
|
||||
): Promise<void> {
|
||||
const { values } = input;
|
||||
|
||||
await createStrapiEntry(APPLICATIONS_RESOURCE, {
|
||||
...(input.opportunityId ? { opportunity: input.opportunityId } : {}),
|
||||
applicantName: values.fullName,
|
||||
email: values.email,
|
||||
organisation: optional(values.organisation),
|
||||
answers: {
|
||||
[APPLICATION_ANSWER_KEYS.route]: values.route,
|
||||
// Only the answers actually given: an empty string in the admin panel
|
||||
// reads as "answered, with nothing", which is not what happened.
|
||||
...(values.region
|
||||
? { [APPLICATION_ANSWER_KEYS.region]: values.region }
|
||||
: {}),
|
||||
...(values.yearsOperating
|
||||
? { [APPLICATION_ANSWER_KEYS.yearsOperating]: values.yearsOperating }
|
||||
: {}),
|
||||
[APPLICATION_ANSWER_KEYS.summary]: values.summary,
|
||||
await createStrapiEntry(
|
||||
APPLICATIONS_RESOURCE,
|
||||
{
|
||||
...(input.opportunityId ? { opportunity: input.opportunityId } : {}),
|
||||
applicantName: values.fullName,
|
||||
email: values.email,
|
||||
organisation: optional(values.organisation),
|
||||
answers: {
|
||||
[APPLICATION_ANSWER_KEYS.route]: values.route,
|
||||
// Only the answers actually given: an empty string in the admin panel
|
||||
// reads as "answered, with nothing", which is not what happened.
|
||||
...(values.region
|
||||
? { [APPLICATION_ANSWER_KEYS.region]: values.region }
|
||||
: {}),
|
||||
...(values.yearsOperating
|
||||
? { [APPLICATION_ANSWER_KEYS.yearsOperating]: values.yearsOperating }
|
||||
: {}),
|
||||
[APPLICATION_ANSWER_KEYS.summary]: values.summary,
|
||||
},
|
||||
},
|
||||
});
|
||||
{ forwardedFor: input.forwardedFor },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,7 +127,11 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
try {
|
||||
await submitApplication({ values, opportunityId: opportunity.documentId });
|
||||
await submitApplication({
|
||||
values,
|
||||
opportunityId: opportunity.documentId,
|
||||
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isStrapiError(error)) throw error;
|
||||
console.error(
|
||||
|
||||
@@ -46,8 +46,7 @@
|
||||
uploaded here at all (see `documentsNote`);
|
||||
* "an automatic acknowledgment with your reference number" - no
|
||||
reference-number field exists anywhere in the CMS. The acknowledgement
|
||||
is modelled (`application.acknowledgementSentAt`) and survives; the
|
||||
number does not;
|
||||
IS sent (the CMS mails it on submission) and the number does not;
|
||||
* "follows up within four weeks" - a response time that appears in no
|
||||
approved document. §8 of the draft marks it "[X weeks] - TBD".
|
||||
|
||||
@@ -226,7 +225,10 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
try {
|
||||
await submitPathwayApplication({ values });
|
||||
await submitPathwayApplication({
|
||||
values,
|
||||
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isStrapiError(error)) throw error;
|
||||
console.error(
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { SITE_FACEBOOK_URL } from "~/lib/site-links";
|
||||
import { data, Form, Link, useLocation, useNavigation } from "react-router";
|
||||
import { data, Form, Link, useLocation, useNavigation, useRouteLoaderData } from "react-router";
|
||||
|
||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||
import { EuBadge } from "~/components/ui/EuBadge";
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
} from "./_page-kit/sections";
|
||||
|
||||
import "./_page-kit/motion.css";
|
||||
import { DEFAULT_THEME, type AppTheme } from "~/lib/theme";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
LOADER
|
||||
@@ -230,7 +231,10 @@ function validate(values: ContactValues, locale: Locale): ContactErrors {
|
||||
* though the browser decides triage state, and the value would be silently
|
||||
* discarded anyway - so the body below is the whitelist and only it.
|
||||
*/
|
||||
async function createContactSubmission(values: ContactValues): Promise<void> {
|
||||
async function createContactSubmission(
|
||||
values: ContactValues,
|
||||
forwardedFor?: string,
|
||||
): Promise<void> {
|
||||
const env = getServerEnv();
|
||||
const url = `${env.STRAPI_URL.replace(/\/+$/, "")}/api/contact-submissions`;
|
||||
|
||||
@@ -240,6 +244,7 @@ async function createContactSubmission(values: ContactValues): Promise<void> {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json",
|
||||
...(env.STRAPI_TOKEN ? { authorization: `Bearer ${env.STRAPI_TOKEN}` } : {}),
|
||||
...(forwardedFor ? { "x-forwarded-for": forwardedFor } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
@@ -282,7 +287,10 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
try {
|
||||
await createContactSubmission(values);
|
||||
await createContactSubmission(
|
||||
values,
|
||||
request.headers.get("x-forwarded-for") ?? undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[contact] submission failed", error);
|
||||
const t = createTranslator(locale);
|
||||
@@ -370,6 +378,9 @@ export default function Contact({
|
||||
const { pathname } = useLocation();
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
|
||||
const root = useRouteLoaderData<{ theme?: AppTheme }>("root");
|
||||
const theme: AppTheme = root?.theme ?? DEFAULT_THEME;
|
||||
|
||||
const errors = actionData?.errors ?? {};
|
||||
const values = actionData?.values ?? EMPTY_VALUES;
|
||||
const formError =
|
||||
@@ -681,7 +692,7 @@ export default function Contact({
|
||||
size="sm"
|
||||
statement={t("site.euStatement")}
|
||||
locale={locale}
|
||||
surface="dark"
|
||||
surface={theme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,8 +128,6 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
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)) {
|
||||
@@ -138,7 +136,7 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
|
||||
/* A bot that fills every field gets the acknowledgement and writes nothing. */
|
||||
if (isHoneypotFilled(form)) {
|
||||
return { ok: true, waitlist } satisfies ActionResult;
|
||||
return { ok: true, waitlist: isWaitlisted(event) } satisfies ActionResult;
|
||||
}
|
||||
|
||||
const { values, errors, valid } = validateRegistration(form);
|
||||
@@ -150,6 +148,7 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
let registrationState: "confirmed" | "waitlisted";
|
||||
try {
|
||||
/* Only the fields the CMS whitelist accepts are sent - see
|
||||
./_events-opportunities/submissions.server.ts. `registrationState` is
|
||||
@@ -159,7 +158,11 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
`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 });
|
||||
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
|
||||
@@ -175,7 +178,10 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
return { ok: true, waitlist } satisfies ActionResult;
|
||||
return {
|
||||
ok: true,
|
||||
waitlist: registrationState === "waitlisted",
|
||||
} satisfies ActionResult;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user