fix: FIFO queue for waitlisting; send email for application form; EU logo in contact page in light mode

This commit is contained in:
2026-09-24 11:51:24 +02:00
parent e5bce55bf7
commit eba419bfc2
24 changed files with 762 additions and 244 deletions
@@ -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 },
);
}