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:
pigui
2026-09-29 15:02:06 +02:00
parent 439f951f2e
commit b5681a12ac
15 changed files with 625 additions and 92 deletions

View File

@@ -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`

View File

@@ -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. */

View File

@@ -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.',

View File

@@ -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.

View 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');
});
});

View 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 } : {}),
},
};
}

View File

@@ -51,13 +51,19 @@ import type { PublicCall } from '../core/api.types';
<pi-chip size="small" appearance="outline" [label]="label" />
}
@if (call().seniority; as seniority) {
<pi-chip size="small" appearance="outline" [label]="seniority" />
<pi-chip size="small" appearance="outline" [label]="copy().call.seniority[seniority]" />
}
</div>
</pi-card-top-content>
<pi-card-center-content>
<h3 bold class="landing-card__title">{{ call().title }}</h3>
<h3 bold class="landing-card__title">
@if (call().path; as path) {
<a class="landing-card__title-link" [href]="path">{{ call().title }}</a>
} @else {
{{ call().title }}
}
</h3>
@if (call().project?.title; as project) {
<p xs pi-color color="grey">{{ project }}</p>
}
@@ -82,14 +88,28 @@ import type { PublicCall } from '../core/api.types';
>{{ copy().labels.closes }} {{ closes }}</span
>
}
<pi-action-button
size="small"
color="primary"
appearance="outline"
[hasIcon]="true"
[text]="copy().labels.apply"
(execute)="apply.emit()"
/>
<!-- To the call's own page and its form when it has one; to the
landing's general form otherwise. -->
@if (call().path; as path) {
<a class="landing-card__button-link" [href]="path">
<pi-action-button
size="small"
color="primary"
appearance="outline"
[hasIcon]="true"
[text]="copy().labels.apply"
/>
</a>
} @else {
<pi-action-button
size="small"
color="primary"
appearance="outline"
[hasIcon]="true"
[text]="copy().labels.apply"
(execute)="apply.emit()"
/>
}
</div>
</pi-card-bottom-content>
</pi-card>

View File

@@ -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;
}

View File

@@ -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: `
<header class="content-hero">
<div class="landing-shell">
<p class="landing-eyebrow">{{ copy().call.eyebrow }}</p>
<h1 bold class="content-hero__title">{{ call().title }}</h1>
@if (call().project?.title; as project) {
<p l pi-color color="grey" class="content-hero__lead">
@if (call().project.path; as path) {
<a class="call-project-link" [href]="path">{{ project }}</a>
} @else {
{{ project }}
}
</p>
}
<ul class="content-hero__chips">
@if (hours(); as label) {
<li>
<pi-chip
size="small"
color="accent"
icon="time-detail"
[label]="label"
/>
</li>
}
@if (weeks(); as label) {
<li>
<pi-chip size="small" appearance="outline" [label]="label" />
</li>
}
@if (call().seniority; as seniority) {
<li>
<pi-chip
size="small"
appearance="outline"
[label]="copy().call.seniority[seniority]"
/>
</li>
}
@for (technology of call().technologies ?? []; track technology.id) {
<li>
<pi-chip
size="small"
appearance="outline"
[label]="technology.name"
/>
</li>
}
</ul>
</div>
</header>
<div class="landing-shell content-page__layout">
<article class="content-page__main">
@if (call().description; as description) {
<p class="call-text">{{ description }}</p>
}
@if (call().what_you_get; as whatYouGet) {
<section class="content-page__section">
<h2>{{ copy().call.whatYouGet }}</h2>
<p class="call-text">{{ whatYouGet }}</p>
</section>
}
<section class="content-page__section" id="apuntate">
@if (isOpen()) {
<h2>{{ copy().call.apply }}</h2>
<p class="content-page__muted call-form-lead">
{{ copy().call.applyLead }}
</p>
<app-contact-form
[copy]="copy()"
[locale]="locale()"
[callSlug]="call().slug"
/>
} @else {
<pi-note
status="info"
appearance="fill"
gap="xs"
[title]="copy().call.unavailable"
[text]="copy().call.unavailableLead"
/>
<p class="call-closed-link">
<a class="call-project-link" [href]="contactHref()"
>{{ copy().nav.contact }} →</a
>
</p>
}
</section>
</article>
<aside class="content-page__aside">
<div class="landing-card content-page__panel">
<dl class="content-page__facts">
@if (commitment(); as commitment) {
<div>
<dt>{{ copy().call.commitment }}</dt>
<dd>{{ commitment }}</dd>
</div>
}
@if (call().seniority; as seniority) {
<div>
<dt>{{ copy().labels.seniority }}</dt>
<dd>{{ copy().call.seniority[seniority] }}</dd>
</div>
}
@if (closesAt(); as closes) {
<div>
<dt>{{ copy().labels.closes }}</dt>
<dd>{{ closes }}</dd>
</div>
}
@if (call().project; as project) {
<div>
<dt>{{ copy().call.project }}</dt>
<dd>
@if (project.path) {
<a [href]="project.path">{{ project.title }}</a>
} @else {
{{ project.title }}
}
</dd>
</div>
}
</dl>
</div>
</aside>
</div>
`,
})
export class CallDetail {
readonly call = input.required<PublicCall>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
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;
});
}

View File

@@ -84,14 +84,17 @@ type Kind = 'contribute' | 'request';
/>
} @else {
<form class="landing-form" (ngSubmit)="send()" novalidate>
<fieldset class="landing-form__kind">
<legend>{{ copy().form.kind }}</legend>
<pi-segmented-control
[ariaLabel]="copy().form.kind"
[options]="kinds()"
[(value)]="kind"
/>
</fieldset>
<!-- An application to a call is always an offer of time. -->
@if (!callSlug()) {
<fieldset class="landing-form__kind">
<legend>{{ copy().form.kind }}</legend>
<pi-segmented-control
[ariaLabel]="copy().form.kind"
[options]="kinds()"
[(value)]="kind"
/>
</fieldset>
}
<div class="landing-form__row">
<pi-text-field [formField]="contact.name" [placeholder]="copy().form.name">
@@ -177,6 +180,8 @@ export class ContactForm {
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
readonly enabled = input(true);
/** Set, the form applies to that call instead of writing in generally. */
readonly callSlug = input<string | null>(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<SubmissionReceipt> {
return new Promise((resolve, reject) => {
this.http
.post<SubmissionReceipt>(`/api/contact?lang=${this.locale()}`, payload)
.post<SubmissionReceipt>(this.endpoint(), payload)
.subscribe({ next: resolve, error: reject });
});
}

View File

@@ -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: `
<app-page-shell [data]="data()" [seo]="seo()">
@if (data().found; as found) {
@@ -39,6 +40,13 @@ import { ProjectDetail } from './project-detail';
[locale]="data().locale"
/>
}
@case ('call') {
<app-call-detail
[call]="found.record"
[copy]="copy()"
[locale]="data().locale"
/>
}
@case ('member') {
<app-member-detail
[member]="found.record"
@@ -99,5 +107,17 @@ export function seoFor(found: RoutedRecord | null): PageSeo | null {
type: 'profile',
};
}
case 'call': {
const call = found.record;
const project = call.project?.title;
return {
title: `Pireon — ${call.title}${project ? ` · ${project}` : ''}`,
description: call.description?.slice(0, 160) || '',
path: call.path,
alternates: call.alternates ?? {},
type: 'article',
};
}
}
}

View File

@@ -0,0 +1,49 @@
/**
* An application to one call, forwarded to the API for the reason the general
* form is (see `contact.post.ts`).
*
* The slug travels as it came: the API resolves it in the request's language
* and answers 404 for a call that is not open, which is passed on as it is.
*/
import {
defineEventHandler,
getQuery,
getRouterParam,
readBody,
setResponseStatus,
} from 'h3';
import { postSubmission, resolveLocale } from '../../../../../app/core/api';
import {
checkSubmission,
type SubmissionBody,
} from '../../../../../app/core/submission';
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug') ?? '';
const body = (await readBody<SubmissionBody>(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' };
}
});

View File

@@ -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<string, string>;
/** 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<ContactBody>(event)) ?? {};
const body = (await readBody<SubmissionBody>(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<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'];
}
// 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);

View File

@@ -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;

View File

@@ -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' }));