diff --git a/README.md b/README.md index 99b8308..6e03b72 100644 --- a/README.md +++ b/README.md @@ -161,12 +161,17 @@ landing/src/ (thin wrappers over `@pi/ui`), each with its `.scss` server/ routes/api/contact.post.ts forwards the contact form to the API + routes/api/calls/[slug]/apply.post.ts forwards an application to one call middleware/not-found-status.ts styles.scss Material theme, `@pi/ui` sheets, shared primitives ``` **Nothing renders from the browser.** All reads happen in the page's `load` -function, and the one write goes through this app's own `/api/contact` handler. +function, and the two writes go through this app's own handlers — +`/api/contact` for the general form, `/api/calls/{slug}/apply` for a call's — +which share one check (`core/submission.ts`) before spending a round trip. An +application to a call sends no `kind`: the API refuses one there, because +answering a call is always an offer of time. That is what the API expects: it throttles submissions per e-mail address rather than per IP *because* an SSR frontend posts on the visitor's behalf, and keeping the API host out of the browser means CORS never comes up. @@ -217,6 +222,7 @@ named in that language: /sobre-nosotros /en/about-us /ca/qui-som /proyectos/catalogo /en/projects/catalogo /ca/projectes/catalogo /equipo/ada /en/team/ada /ca/equip/ada +/colabora/frontend /en/contribute/frontend /ca/col-labora/frontend ``` `core/routes.ts#resolveRoute` is that rule read backwards, and its `SECTIONS` diff --git a/landing/src/app/core/api.ts b/landing/src/app/core/api.ts index 169751f..f4da02e 100644 --- a/landing/src/app/core/api.ts +++ b/landing/src/app/core/api.ts @@ -232,6 +232,9 @@ export const pireonApi = { member: (lang: Locale, slug: string) => get(`/members/${encodeURIComponent(slug)}`, { lang }), + + call: (lang: Locale, slug: string) => + get(`/calls/${encodeURIComponent(slug)}`, { lang }), } as const; /** POST bodies the public API accepts. Only two endpoints write. */ diff --git a/landing/src/app/core/copy.ts b/landing/src/app/core/copy.ts index 80c728e..10bf299 100644 --- a/landing/src/app/core/copy.ts +++ b/landing/src/app/core/copy.ts @@ -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.', diff --git a/landing/src/app/core/routed-page.ts b/landing/src/app/core/routed-page.ts index 952bf19..5a60e92 100644 --- a/landing/src/app/core/routed-page.ts +++ b/landing/src/app/core/routed-page.ts @@ -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 { 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. diff --git a/landing/src/app/core/submission.spec.ts b/landing/src/app/core/submission.spec.ts new file mode 100644 index 0000000..7a5e7ce --- /dev/null +++ b/landing/src/app/core/submission.spec.ts @@ -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'); + }); +}); diff --git a/landing/src/app/core/submission.ts b/landing/src/app/core/submission.ts new file mode 100644 index 0000000..ab8dd89 --- /dev/null +++ b/landing/src/app/core/submission.ts @@ -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; + /** Honeypot. Never rendered visibly; a bot that fills it is filed as spam. */ + website?: string | null; +} + +export type SubmissionCheck = + | { errors: Record; 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 = {}; + + 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 } : {}), + }, + }; +} diff --git a/landing/src/app/ui/call-card.ts b/landing/src/app/ui/call-card.ts index c60479f..d4b3f11 100644 --- a/landing/src/app/ui/call-card.ts +++ b/landing/src/app/ui/call-card.ts @@ -51,13 +51,19 @@ import type { PublicCall } from '../core/api.types'; } @if (call().seniority; as seniority) { - + } -

{{ call().title }}

+

+ @if (call().path; as path) { + {{ call().title }} + } @else { + {{ call().title }} + } +

@if (call().project?.title; as project) {

{{ project }}

} @@ -82,14 +88,28 @@ import type { PublicCall } from '../core/api.types'; >{{ copy().labels.closes }} {{ closes }} } - + + @if (call().path; as path) { + + + + } @else { + + } diff --git a/landing/src/app/ui/call-detail.scss b/landing/src/app/ui/call-detail.scss new file mode 100644 index 0000000..7313e39 --- /dev/null +++ b/landing/src/app/ui/call-detail.scss @@ -0,0 +1,25 @@ +@use './detail'; + +:host { + display: block; +} + +// A textarea's line breaks are the writer's paragraphs. +.call-text { + margin: 0; + font-size: 1.0625rem; + line-height: 1.7; + white-space: pre-line; +} + +.call-project-link { + color: var(--pi-color-primary); +} + +.call-form-lead { + margin: -0.5rem 0 1.5rem; +} + +.call-closed-link { + margin: 1rem 0 0; +} diff --git a/landing/src/app/ui/call-detail.ts b/landing/src/app/ui/call-detail.ts new file mode 100644 index 0000000..9c9c3c3 --- /dev/null +++ b/landing/src/app/ui/call-detail.ts @@ -0,0 +1,224 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { + PiChipComponent, + PiNoteComponent, + PiTextColorDirective, + PiTextLargeDirective, + PiTextWeightBoldDirective, +} from '@pi/ui'; + +import type { Locale } from '../core/api'; +import type { PublicCall } from '../core/api.types'; +import type { Copy } from '../core/copy'; +import { homeHref } from '../core/routes'; +import { ContactForm } from './contact-form'; + +/** + * A call for help on its own address, with the form to answer it. + * + * `description` and `what_you_get` come from textareas, not a rich editor, so + * they are printed as text with their line breaks kept. A call that is no + * longer open still has an address — a link to it may be out there — but no + * form: the API would refuse the application anyway. + */ +@Component({ + selector: 'app-call-detail', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ContactForm, + PiChipComponent, + PiNoteComponent, + PiTextColorDirective, + PiTextLargeDirective, + PiTextWeightBoldDirective, + ], + styleUrl: './call-detail.scss', + template: ` +
+
+

{{ copy().call.eyebrow }}

+ +

{{ call().title }}

+ + @if (call().project?.title; as project) { +

+ @if (call().project.path; as path) { + {{ project }} + } @else { + {{ project }} + } +

+ } + +
    + @if (hours(); as label) { +
  • + +
  • + } + @if (weeks(); as label) { +
  • + +
  • + } + @if (call().seniority; as seniority) { +
  • + +
  • + } + @for (technology of call().technologies ?? []; track technology.id) { +
  • + +
  • + } +
+
+
+ +
+
+ @if (call().description; as description) { +

{{ description }}

+ } + + @if (call().what_you_get; as whatYouGet) { +
+

{{ copy().call.whatYouGet }}

+

{{ whatYouGet }}

+
+ } + +
+ @if (isOpen()) { +

{{ copy().call.apply }}

+

+ {{ copy().call.applyLead }} +

+ + } @else { + + + } +
+
+ + +
+ `, +}) +export class CallDetail { + readonly call = input.required(); + readonly copy = input.required(); + readonly locale = input.required(); + + protected readonly isOpen = computed(() => this.call().status === 'open'); + + protected readonly contactHref = computed( + () => `${homeHref(this.locale())}#contacto`, + ); + + // `pi-chip-value` renders the number and its unit as adjacent spans with no + // separation, so the label is built here, as on the landing's card. + protected readonly hours = computed(() => { + const hours = this.call().hours_per_week; + + return hours ? `${hours} ${this.copy().labels.hoursPerWeek}` : null; + }); + + protected readonly weeks = computed(() => { + const weeks = this.call().duration_weeks; + + return weeks ? `${weeks} ${this.copy().labels.weeks}` : null; + }); + + /** "4 h/semana · 8 semanas · 32 horas en total", with whatever is known. */ + protected readonly commitment = computed(() => { + const total = this.call().total_hours; + const parts = [ + this.hours(), + this.weeks(), + total ? `${total} ${this.copy().call.totalHours}` : null, + ].filter(Boolean); + + return parts.length > 0 ? parts.join(' · ') : null; + }); + + protected readonly closesAt = computed(() => { + const value = this.call().closes_at; + + return value + ? new Intl.DateTimeFormat(this.locale(), { + day: 'numeric', + month: 'long', + year: 'numeric', + }).format(new Date(value)) + : null; + }); +} diff --git a/landing/src/app/ui/contact-form.ts b/landing/src/app/ui/contact-form.ts index 1692f59..aa15c6e 100644 --- a/landing/src/app/ui/contact-form.ts +++ b/landing/src/app/ui/contact-form.ts @@ -84,14 +84,17 @@ type Kind = 'contribute' | 'request'; /> } @else {
-
- {{ copy().form.kind }} - -
+ + @if (!callSlug()) { +
+ {{ copy().form.kind }} + +
+ }
@@ -177,6 +180,8 @@ export class ContactForm { readonly copy = input.required(); readonly locale = input.required(); readonly enabled = input(true); + /** Set, the form applies to that call instead of writing in generally. */ + readonly callSlug = input(null); private readonly http = inject(HttpClient); @@ -305,10 +310,17 @@ export class ContactForm { }); } + private endpoint(): string { + const slug = this.callSlug(); + const path = slug ? `/api/calls/${encodeURIComponent(slug)}/apply` : '/api/contact'; + + return `${path}?lang=${this.locale()}`; + } + private post(payload: unknown): Promise { return new Promise((resolve, reject) => { this.http - .post(`/api/contact?lang=${this.locale()}`, payload) + .post(this.endpoint(), payload) .subscribe({ next: resolve, error: reject }); }); } diff --git a/landing/src/app/ui/routed-page.ts b/landing/src/app/ui/routed-page.ts index b180c0a..f934381 100644 --- a/landing/src/app/ui/routed-page.ts +++ b/landing/src/app/ui/routed-page.ts @@ -6,6 +6,7 @@ import { } from '@angular/core'; import { copyFor } from '../core/copy'; +import { CallDetail } from './call-detail'; import type { RoutedPageData, RoutedRecord } from '../core/routed-page'; import { ContentPage } from './content-page'; import { MemberDetail } from './member-detail'; @@ -19,7 +20,7 @@ import { ProjectDetail } from './project-detail'; @Component({ selector: 'app-routed-page', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ContentPage, MemberDetail, PageShell, ProjectDetail], + imports: [CallDetail, ContentPage, MemberDetail, PageShell, ProjectDetail], template: ` @if (data().found; as found) { @@ -39,6 +40,13 @@ import { ProjectDetail } from './project-detail'; [locale]="data().locale" /> } + @case ('call') { + + } @case ('member') { { + const slug = getRouterParam(event, 'slug') ?? ''; + const body = (await readBody(event)) ?? {}; + const locale = resolveLocale(getQuery(event)['lang']); + const { errors, payload } = checkSubmission(body, { forCall: true }); + + if (errors) { + setResponseStatus(event, 422); + + return { errors, message: 'Validation failed' }; + } + + try { + const outcome = await postSubmission( + `/calls/${encodeURIComponent(slug)}/apply`, + payload, + locale, + ); + + setResponseStatus(event, outcome.status); + + return outcome.body; + } catch { + setResponseStatus(event, 502); + + return { message: 'upstream_unreachable' }; + } +}); diff --git a/landing/src/server/routes/api/contact.post.ts b/landing/src/server/routes/api/contact.post.ts index 772064e..ac4b483 100644 --- a/landing/src/server/routes/api/contact.post.ts +++ b/landing/src/server/routes/api/contact.post.ts @@ -1,90 +1,30 @@ /** - * The browser's only write path. + * The general form's write path. * * The public API throttles submissions per e-mail address rather than per IP * precisely because an SSR frontend sends everything from one address, so the * form posts here and this handler forwards it. Keeping the API host out of * the browser also keeps CORS out of the picture. */ -import { defineEventHandler, readBody, setResponseStatus, getQuery } from 'h3'; +import { defineEventHandler, getQuery, readBody, setResponseStatus } from 'h3'; +import { postSubmission, resolveLocale } from '../../../app/core/api'; import { - postSubmission, - resolveLocale, - type SubmissionPayload, -} from '../../../app/core/api'; - -interface ContactBody { - name?: string; - email?: string; - message?: string; - organisation?: string | null; - hours_per_week?: number | string | null; - kind?: 'contribute' | 'request'; - consent?: boolean; - links?: Record; - /** Honeypot. Never rendered visibly; a bot that fills it is filed as spam. */ - website?: string | null; -} + checkSubmission, + type SubmissionBody, +} from '../../../app/core/submission'; export default defineEventHandler(async (event) => { - const body = (await readBody(event)) ?? {}; + const body = (await readBody(event)) ?? {}; const locale = resolveLocale(getQuery(event)['lang']); - const kind = body.kind === 'request' ? 'request' : 'contribute'; + const { errors, payload } = checkSubmission(body); - const name = body.name?.trim() ?? ''; - const email = body.email?.trim() ?? ''; - const message = body.message?.trim() ?? ''; - const organisation = body.organisation?.trim() ?? ''; - - const errors: Record = {}; - - 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']; - } - - // Caught here rather than spent on a round trip: the API would answer 422 - // with the same verdict, and its throttle counts the attempt either way. - if (Object.keys(errors).length > 0) { + if (errors) { setResponseStatus(event, 422); return { errors, message: 'Validation failed' }; } - const hours = Number(body.hours_per_week); - - const payload: SubmissionPayload = { - name, - email, - message, - consent: true, - 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 } : {}), - }; - try { const outcome = await postSubmission('/contact', payload, locale); diff --git a/landing/src/styles.scss b/landing/src/styles.scss index d903924..0d709f4 100644 --- a/landing/src/styles.scss +++ b/landing/src/styles.scss @@ -224,6 +224,10 @@ html { // A card's title leads to the record's own page. The card itself stays // unlinked: it also holds links of its own (demo, repository) and a link // inside a link is invalid. +.landing-card__button-link { + text-decoration: none; +} + .landing-card__title-link { color: inherit; text-decoration: none; diff --git a/tools/mock-api.mjs b/tools/mock-api.mjs index b400da7..31dc850 100644 --- a/tools/mock-api.mjs +++ b/tools/mock-api.mjs @@ -133,6 +133,20 @@ for (const listed of data['/public/v1/projects']) { }; } +// Calls link to their own page, and one of them is closed so the page can +// show what a visitor sees when a link to a filled call is still out there. +for (const call of data['/public/v1/calls']) { + call.path = `/colabora/${call.slug}`; + call.alternates = { es: call.path, en: `/en/contribute/${call.slug}`, ca: `/ca/col-labora/${call.slug}` }; + data[`/public/v1/calls/${call.slug}`] = { + ...call, + status: call.id === 2 ? 'filled' : 'open', + what_you_get: 'Revisiones de código con alguien del colectivo.\nUn proyecto real en tu portfolio.', + total_hours: call.hours_per_week * (call.duration_weeks ?? 4), + project: { ...call.project, path: `/proyectos/${call.project.slug}` }, + }; +} + for (const listed of data['/public/v1/members']) { data[`/public/v1/members/${listed.slug}`] = { ...listed, @@ -150,6 +164,14 @@ createServer((req, res) => { res.setHeader('content-type', 'application/json'); + // The two write endpoints: accept whatever arrives, as the real API does + // for a valid submission. + if (req.method === 'POST') { + res.statusCode = 201; + res.end(JSON.stringify({ data: { id: 1, kind: 'contribute', status: 'new' } })); + return; + } + if (payload === undefined) { res.statusCode = 404; res.end(JSON.stringify({ message: 'not found' }));