Give each call its own page, and a form to answer it
A call now has its address — description, what the volunteer gets, the
commitment added up, level, closing date, project, technologies — and
the application form right there. The landing's call card leads to it
instead of scrolling to the general form, and shows the level in the
visitor's language rather than the API's `mid` or `any`.
The form is the landing's, with a `callSlug` that hides the kind
selector and posts to the new /api/calls/{slug}/apply handler: the API
refuses a `kind` on an application, which is always an offer of time.
Both handlers share one check, core/submission.ts, now tested on its
own.
A call that is filled or closed keeps its page — a link to it may
still be out there — but shows why there is no form, and where to
write instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -232,6 +232,9 @@ export const pireonApi = {
|
||||
|
||||
member: (lang: Locale, slug: string) =>
|
||||
get<PublicMember>(`/members/${encodeURIComponent(slug)}`, { lang }),
|
||||
|
||||
call: (lang: Locale, slug: string) =>
|
||||
get<PublicCall>(`/calls/${encodeURIComponent(slug)}`, { lang }),
|
||||
} as const;
|
||||
|
||||
/** POST bodies the public API accepts. Only two endpoints write. */
|
||||
|
||||
@@ -114,6 +114,18 @@ export interface Copy {
|
||||
links: string;
|
||||
since: string;
|
||||
};
|
||||
call: {
|
||||
eyebrow: string;
|
||||
seniority: Record<'any' | 'junior' | 'mid' | 'senior', string>;
|
||||
whatYouGet: string;
|
||||
commitment: string;
|
||||
totalHours: string;
|
||||
project: string;
|
||||
apply: string;
|
||||
applyLead: string;
|
||||
unavailable: string;
|
||||
unavailableLead: string;
|
||||
};
|
||||
maintenance: { title: string; back: string; until: string };
|
||||
}
|
||||
|
||||
@@ -236,6 +248,18 @@ const es: Copy = {
|
||||
links: 'Enlaces',
|
||||
since: 'En el colectivo desde',
|
||||
},
|
||||
call: {
|
||||
eyebrow: 'Convocatoria',
|
||||
seniority: { any: 'Cualquier nivel', junior: 'Junior', mid: 'Intermedio', senior: 'Senior' },
|
||||
whatYouGet: 'Qué te llevas',
|
||||
commitment: 'Dedicación',
|
||||
totalHours: 'horas en total',
|
||||
project: 'Proyecto',
|
||||
apply: 'Apúntate',
|
||||
applyLead: 'Cuéntanos quién eres y qué te gustaría hacer. Te respondemos personas, no un bot.',
|
||||
unavailable: 'Esta convocatoria ya no está abierta',
|
||||
unavailableLead: 'Si te interesa colaborar igualmente, escríbenos desde el formulario general.',
|
||||
},
|
||||
maintenance: {
|
||||
title: 'Estamos de mantenimiento',
|
||||
back: 'Volvemos en un rato.',
|
||||
@@ -362,6 +386,18 @@ const en: Copy = {
|
||||
links: 'Links',
|
||||
since: 'In the collective since',
|
||||
},
|
||||
call: {
|
||||
eyebrow: 'Open call',
|
||||
seniority: { any: 'Any level', junior: 'Junior', mid: 'Mid-level', senior: 'Senior' },
|
||||
whatYouGet: 'What you get',
|
||||
commitment: 'Commitment',
|
||||
totalHours: 'hours in total',
|
||||
project: 'Project',
|
||||
apply: 'Apply',
|
||||
applyLead: 'Tell us who you are and what you would like to do. A person will answer, not a bot.',
|
||||
unavailable: 'This call is no longer open',
|
||||
unavailableLead: 'If you would like to help anyway, write to us from the general form.',
|
||||
},
|
||||
maintenance: {
|
||||
title: 'We are under maintenance',
|
||||
back: 'Back in a while.',
|
||||
@@ -488,6 +524,18 @@ const ca: Copy = {
|
||||
links: 'Enllaços',
|
||||
since: 'Al col·lectiu des de',
|
||||
},
|
||||
call: {
|
||||
eyebrow: 'Convocatòria',
|
||||
seniority: { any: 'Qualsevol nivell', junior: 'Júnior', mid: 'Intermedi', senior: 'Sènior' },
|
||||
whatYouGet: "Què t'emportes",
|
||||
commitment: 'Dedicació',
|
||||
totalHours: 'hores en total',
|
||||
project: 'Projecte',
|
||||
apply: "Apunta-t'hi",
|
||||
applyLead: "Explica'ns qui ets i què t'agradaria fer. Et responen persones, no un bot.",
|
||||
unavailable: 'Aquesta convocatòria ja no està oberta',
|
||||
unavailableLead: "Si igualment t'interessa col·laborar, escriu-nos des del formulari general.",
|
||||
},
|
||||
maintenance: {
|
||||
title: 'Estem de manteniment',
|
||||
back: 'Tornem d’aquí una estona.',
|
||||
|
||||
@@ -13,14 +13,15 @@ import {
|
||||
type MaintenanceNotice,
|
||||
type SitePayload,
|
||||
} from './api';
|
||||
import type { PublicMember, PublicPage, PublicProject } from './api.types';
|
||||
import type { PublicCall, PublicMember, PublicPage, PublicProject } from './api.types';
|
||||
import { resolveRoute, type RouteMatch } from './routes';
|
||||
|
||||
/** The record, tagged with its type so the template can switch on it. */
|
||||
export type RoutedRecord =
|
||||
| { type: 'page'; record: PublicPage }
|
||||
| { type: 'project'; record: PublicProject }
|
||||
| { type: 'member'; record: PublicMember };
|
||||
| { type: 'member'; record: PublicMember }
|
||||
| { type: 'call'; record: PublicCall };
|
||||
|
||||
export interface RoutedPageData {
|
||||
locale: Locale;
|
||||
@@ -69,6 +70,10 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
|
||||
const { data } = await pireonApi.member(locale, slug);
|
||||
return data ? { type: 'member', record: data } : null;
|
||||
}
|
||||
case 'call': {
|
||||
const { data } = await pireonApi.call(locale, slug);
|
||||
return data ? { type: 'call', record: data } : null;
|
||||
}
|
||||
default:
|
||||
// A type the site has no page for yet: an address the API hands out
|
||||
// but nothing here renders, which is a 404 until it does.
|
||||
|
||||
67
landing/src/app/core/submission.spec.ts
Normal file
67
landing/src/app/core/submission.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { checkSubmission } from './submission';
|
||||
|
||||
const valid = {
|
||||
name: 'Ada',
|
||||
email: 'ada@example.org',
|
||||
message: 'Me gustaría echar una mano con el frontend.',
|
||||
consent: true,
|
||||
};
|
||||
|
||||
describe('checkSubmission', () => {
|
||||
it('passes a complete contact through, trimmed', () => {
|
||||
const { payload } = checkSubmission({
|
||||
...valid,
|
||||
name: ' Ada ',
|
||||
hours_per_week: '4',
|
||||
});
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
name: 'Ada',
|
||||
kind: 'contribute',
|
||||
hours_per_week: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('names every missing field at once', () => {
|
||||
const { errors } = checkSubmission({ message: 'corto' });
|
||||
|
||||
expect(Object.keys(errors ?? {}).sort()).toEqual([
|
||||
'consent',
|
||||
'email',
|
||||
'message',
|
||||
'name',
|
||||
]);
|
||||
});
|
||||
|
||||
it('asks an organisation asking for help to name itself', () => {
|
||||
expect(
|
||||
checkSubmission({ ...valid, kind: 'request' }).errors,
|
||||
).toHaveProperty('organisation');
|
||||
expect(
|
||||
checkSubmission({ ...valid, kind: 'request', organisation: 'Asociación' })
|
||||
.payload,
|
||||
).toMatchObject({
|
||||
kind: 'request',
|
||||
});
|
||||
});
|
||||
|
||||
// The API refuses a `kind` on an application to a call: it is always an
|
||||
// offer of time.
|
||||
it('sends no kind with an application to a call, whatever the body says', () => {
|
||||
const { payload } = checkSubmission(
|
||||
{ ...valid, kind: 'request' },
|
||||
{ forCall: true },
|
||||
);
|
||||
|
||||
expect(payload).toBeDefined();
|
||||
expect(payload).not.toHaveProperty('kind');
|
||||
});
|
||||
|
||||
it('keeps the honeypot, so the API can file a bot as spam', () => {
|
||||
expect(
|
||||
checkSubmission({ ...valid, website: 'http://spam' }).payload,
|
||||
).toHaveProperty('website');
|
||||
});
|
||||
});
|
||||
88
landing/src/app/core/submission.ts
Normal file
88
landing/src/app/core/submission.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* What the site's two forms send — the general contact and an application to
|
||||
* a call — checked before it costs a round trip.
|
||||
*
|
||||
* Pure, so the two server routes share it and it can be tested without a
|
||||
* request: the API would answer 422 with the same verdict, and its throttle
|
||||
* counts the attempt either way.
|
||||
*/
|
||||
import type { SubmissionPayload } from './api';
|
||||
|
||||
export interface SubmissionBody {
|
||||
name?: string;
|
||||
email?: string;
|
||||
message?: string;
|
||||
organisation?: string | null;
|
||||
hours_per_week?: number | string | null;
|
||||
kind?: 'contribute' | 'request';
|
||||
consent?: boolean;
|
||||
links?: Record<string, string>;
|
||||
/** Honeypot. Never rendered visibly; a bot that fills it is filed as spam. */
|
||||
website?: string | null;
|
||||
}
|
||||
|
||||
export type SubmissionCheck =
|
||||
| { errors: Record<string, string[]>; payload?: undefined }
|
||||
| { errors?: undefined; payload: SubmissionPayload };
|
||||
|
||||
/**
|
||||
* @param forCall An application to a call is always an offer of time: the API
|
||||
* refuses a `kind` on it, so none is sent, and no organisation is asked for.
|
||||
*/
|
||||
export function checkSubmission(
|
||||
body: SubmissionBody,
|
||||
{ forCall = false } = {},
|
||||
): SubmissionCheck {
|
||||
const kind = !forCall && body.kind === 'request' ? 'request' : 'contribute';
|
||||
|
||||
const name = body.name?.trim() ?? '';
|
||||
const email = body.email?.trim() ?? '';
|
||||
const message = body.message?.trim() ?? '';
|
||||
const organisation = body.organisation?.trim() ?? '';
|
||||
|
||||
const errors: Record<string, string[]> = {};
|
||||
|
||||
if (name.length < 2) {
|
||||
errors['name'] = ['required'];
|
||||
}
|
||||
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||
errors['email'] = ['required'];
|
||||
}
|
||||
|
||||
// The API rejects anything shorter, and rightly: a one-word submission is
|
||||
// never anything but noise.
|
||||
if (message.length < 20) {
|
||||
errors['message'] = ['min'];
|
||||
}
|
||||
|
||||
if (kind === 'request' && organisation.length === 0) {
|
||||
errors['organisation'] = ['required'];
|
||||
}
|
||||
|
||||
if (body.consent !== true) {
|
||||
errors['consent'] = ['required'];
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return { errors };
|
||||
}
|
||||
|
||||
const hours = Number(body.hours_per_week);
|
||||
|
||||
return {
|
||||
payload: {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
consent: true,
|
||||
...(forCall ? {} : { kind }),
|
||||
organisation: organisation || null,
|
||||
hours_per_week: Number.isFinite(hours) && hours > 0 ? hours : null,
|
||||
...(body.links && Object.keys(body.links).length > 0
|
||||
? { links: body.links }
|
||||
: {}),
|
||||
...(body.website ? { website: body.website } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user