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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
25
landing/src/app/ui/call-detail.scss
Normal file
25
landing/src/app/ui/call-detail.scss
Normal 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;
|
||||
}
|
||||
224
landing/src/app/ui/call-detail.ts
Normal file
224
landing/src/app/ui/call-detail.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
49
landing/src/server/routes/api/calls/[slug]/apply.post.ts
Normal file
49
landing/src/server/routes/api/calls/[slug]/apply.post.ts
Normal 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' };
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user