From b5681a12ac6cce80a60f11d23ebc7df2716896a8 Mon Sep 17 00:00:00 2001 From: pigui Date: Tue, 29 Sep 2026 15:02:06 +0200 Subject: [PATCH] Give each call its own page, and a form to answer it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 8 +- landing/src/app/core/api.ts | 3 + landing/src/app/core/copy.ts | 48 ++++ landing/src/app/core/routed-page.ts | 9 +- landing/src/app/core/submission.spec.ts | 67 ++++++ landing/src/app/core/submission.ts | 88 +++++++ landing/src/app/ui/call-card.ts | 40 +++- landing/src/app/ui/call-detail.scss | 25 ++ landing/src/app/ui/call-detail.ts | 224 ++++++++++++++++++ landing/src/app/ui/contact-form.ts | 30 ++- landing/src/app/ui/routed-page.ts | 22 +- .../routes/api/calls/[slug]/apply.post.ts | 49 ++++ landing/src/server/routes/api/contact.post.ts | 78 +----- landing/src/styles.scss | 4 + tools/mock-api.mjs | 22 ++ 15 files changed, 625 insertions(+), 92 deletions(-) create mode 100644 landing/src/app/core/submission.spec.ts create mode 100644 landing/src/app/core/submission.ts create mode 100644 landing/src/app/ui/call-detail.scss create mode 100644 landing/src/app/ui/call-detail.ts create mode 100644 landing/src/server/routes/api/calls/[slug]/apply.post.ts 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' }));