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>
328 lines
10 KiB
TypeScript
328 lines
10 KiB
TypeScript
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
|
import {
|
|
afterNextRender,
|
|
ChangeDetectionStrategy,
|
|
Component,
|
|
computed,
|
|
inject,
|
|
input,
|
|
signal,
|
|
} from '@angular/core';
|
|
import {
|
|
applyWhen,
|
|
email,
|
|
form,
|
|
FormField,
|
|
minLength,
|
|
required,
|
|
submit,
|
|
} from '@angular/forms/signals';
|
|
import {
|
|
PiActionButtonComponent,
|
|
PiCheckboxComponent,
|
|
PiEmailFieldComponent,
|
|
PiLabelDirective,
|
|
PiNoteComponent,
|
|
PiNumberFieldComponent,
|
|
PiSegmentedControlComponent,
|
|
PiTextareaComponent,
|
|
PiTextFieldComponent,
|
|
PiTextXSmallDirective,
|
|
type SegmentedControlOption,
|
|
} from '@pi/ui';
|
|
|
|
import type { Copy } from '../core/copy';
|
|
import type { Locale, SubmissionReceipt } from '../core/api';
|
|
|
|
type FormState = 'idle' | 'sending' | 'sent' | 'duplicate' | 'error';
|
|
type Kind = 'contribute' | 'request';
|
|
|
|
/**
|
|
* The public API's write path, reached through this app's own `/api/contact`
|
|
* handler rather than directly: the API throttles per e-mail address on the
|
|
* assumption that an SSR frontend posts on the visitor's behalf, and going
|
|
* through the server keeps the API host out of the browser.
|
|
*
|
|
* The fields are `@pi/ui`'s, which implement Signal Forms' control contracts —
|
|
* `[formField]` wires value, disabled, errors, touched and required, so none of
|
|
* those may be bound by hand here (Angular rejects it with NG8022).
|
|
*/
|
|
@Component({
|
|
selector: 'app-contact-form',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
imports: [
|
|
FormField,
|
|
PiTextFieldComponent,
|
|
PiEmailFieldComponent,
|
|
PiTextareaComponent,
|
|
PiNumberFieldComponent,
|
|
PiCheckboxComponent,
|
|
PiSegmentedControlComponent,
|
|
PiActionButtonComponent,
|
|
PiLabelDirective,
|
|
PiNoteComponent,
|
|
PiTextXSmallDirective,
|
|
],
|
|
styleUrl: './contact-form.scss',
|
|
template: `
|
|
@if (!enabled()) {
|
|
<pi-note
|
|
status="info"
|
|
appearance="fill"
|
|
gap="xs"
|
|
[title]="copy().form.closed"
|
|
[text]="''"
|
|
/>
|
|
} @else if (state() === 'sent' || state() === 'duplicate') {
|
|
<pi-note
|
|
status="success"
|
|
appearance="fill"
|
|
gap="xs"
|
|
role="status"
|
|
[title]="copy().sections.contact.title"
|
|
[text]="receiptMessage()"
|
|
/>
|
|
} @else {
|
|
<form class="landing-form" (ngSubmit)="send()" novalidate>
|
|
<!-- 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">
|
|
<pi-label [isRequired]="true">{{ copy().form.name }}</pi-label>
|
|
</pi-text-field>
|
|
|
|
<pi-email-field
|
|
[formField]="contact.email"
|
|
[placeholder]="copy().form.email"
|
|
>
|
|
<pi-label [isRequired]="true">{{ copy().form.email }}</pi-label>
|
|
</pi-email-field>
|
|
</div>
|
|
|
|
<div class="landing-form__row">
|
|
@if (kind() === 'request') {
|
|
<pi-text-field
|
|
[formField]="contact.organisation"
|
|
[placeholder]="copy().form.organisation"
|
|
>
|
|
<pi-label [isRequired]="true">{{ copy().form.organisation }}</pi-label>
|
|
</pi-text-field>
|
|
} @else {
|
|
<!--
|
|
The placeholder is not decoration. Without one, Material leaves
|
|
this label in its unfloated position and writes an inline
|
|
translateY(-50%) that drops the text straight onto the outline's
|
|
top border, struck through by it. Every other field here has a
|
|
placeholder, which is why this was the only one that looked wrong.
|
|
-->
|
|
<pi-number-field
|
|
[formField]="contact.hours_per_week"
|
|
[placeholder]="copy().form.hoursHint"
|
|
>
|
|
<pi-label>{{ copy().form.hours }}</pi-label>
|
|
</pi-number-field>
|
|
}
|
|
</div>
|
|
|
|
<pi-textarea
|
|
[formField]="contact.message"
|
|
[placeholder]="copy().form.messageHint"
|
|
>
|
|
<pi-label [isRequired]="true">{{ copy().form.message }}</pi-label>
|
|
</pi-textarea>
|
|
|
|
<!--
|
|
Honeypot. Never rendered visibly and never focusable: a submission
|
|
carrying it is filed as spam upstream and still answers 201, so a bot
|
|
learns nothing from the response.
|
|
-->
|
|
<div class="visually-hidden" aria-hidden="true">
|
|
<label>
|
|
Website
|
|
<input
|
|
type="text"
|
|
tabindex="-1"
|
|
autocomplete="off"
|
|
[value]="website()"
|
|
(input)="website.set($any($event.target).value)"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<pi-checkbox [formField]="contact.consent" [label]="copy().form.consent" />
|
|
|
|
@if (state() === 'error') {
|
|
<p xs role="alert" class="landing-form__error">{{ errorMessage() }}</p>
|
|
}
|
|
|
|
<pi-action-button
|
|
size="large"
|
|
color="primary"
|
|
[text]="state() === 'sending' ? copy().form.sending : copy().form.submit"
|
|
[disabled]="!hydrated() || state() === 'sending'"
|
|
(execute)="send()"
|
|
/>
|
|
</form>
|
|
}
|
|
`,
|
|
})
|
|
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);
|
|
|
|
protected readonly state = signal<FormState>('idle');
|
|
private readonly receipt = signal<SubmissionReceipt | null>(null);
|
|
|
|
/**
|
|
* The page is server-rendered, so for a moment the form is real HTML with no
|
|
* listener on it: a click in that window submits natively, navigates away,
|
|
* and takes the visitor's message with it. The button waits for hydration.
|
|
*/
|
|
protected readonly hydrated = signal(false);
|
|
|
|
/** Not part of the form: a bot filling it must not fail validation. */
|
|
protected readonly website = signal('');
|
|
|
|
/** Drives which second field is shown, and whether `organisation` is required. */
|
|
protected readonly kind = signal<Kind>('contribute');
|
|
|
|
private readonly model = signal({
|
|
name: '',
|
|
email: '',
|
|
organisation: '',
|
|
hours_per_week: null as number | null,
|
|
message: '',
|
|
consent: false,
|
|
});
|
|
|
|
/**
|
|
* Every rule is gated on the field having been touched.
|
|
*
|
|
* `@pi/ui`'s fields derive their error state from `errors().length` alone —
|
|
* `touched` is not part of it — so an untouched empty form would render red
|
|
* borders and "falta el nombre" before the visitor typed a character. Adding
|
|
* the rules only once a field has been left keeps that away, and `submit()`
|
|
* marks the whole tree touched before it checks validity, so an empty form
|
|
* still cannot be sent.
|
|
*/
|
|
protected readonly contact = form(this.model, (field) => {
|
|
const touched = (ctx: { state: { touched: () => boolean } }) =>
|
|
ctx.state.touched();
|
|
|
|
applyWhen(field.name, touched, (name) => {
|
|
required(name, { message: 'Falta el nombre' });
|
|
minLength(name, 2, { message: 'Nombre demasiado corto' });
|
|
});
|
|
|
|
applyWhen(field.email, touched, (address) => {
|
|
required(address, { message: 'Falta el correo' });
|
|
email(address, { message: 'Correo no válido' });
|
|
});
|
|
|
|
applyWhen(field.message, touched, (message) => {
|
|
required(message, { message: 'Falta el mensaje' });
|
|
// The API rejects anything shorter, and rightly: a one-word submission
|
|
// is never anything but noise.
|
|
minLength(message, 20, { message: 'Mínimo 20 caracteres' });
|
|
});
|
|
|
|
applyWhen(field.consent, touched, (consent) => {
|
|
required(consent, { message: 'Hace falta tu consentimiento' });
|
|
});
|
|
|
|
// Only an organisation asking for help has to name itself.
|
|
applyWhen(field.organisation, touched, (organisation) => {
|
|
required(organisation, {
|
|
when: () => this.kind() === 'request',
|
|
message: 'Falta la organización',
|
|
});
|
|
});
|
|
});
|
|
|
|
protected readonly kinds = computed<SegmentedControlOption<Kind>[]>(() => [
|
|
{ value: 'contribute', label: this.copy().form.kindContribute },
|
|
{ value: 'request', label: this.copy().form.kindRequest },
|
|
]);
|
|
|
|
protected readonly receiptMessage = computed(
|
|
() =>
|
|
this.receipt()?.message ??
|
|
(this.state() === 'duplicate'
|
|
? this.copy().form.duplicate
|
|
: this.copy().form.success)
|
|
);
|
|
|
|
protected readonly errorMessage = computed(() => {
|
|
const errors = this.receipt()?.errors;
|
|
const first = errors ? Object.values(errors)[0]?.[0] : null;
|
|
|
|
return first ?? this.copy().form.error;
|
|
});
|
|
|
|
constructor() {
|
|
afterNextRender(() => this.hydrated.set(true));
|
|
}
|
|
|
|
protected send(): void {
|
|
// `submit()` marks the tree touched and only runs the action when valid,
|
|
// so there is no separate "have they tried yet" flag to keep.
|
|
void submit(this.contact, {
|
|
action: async () => {
|
|
const value = this.model();
|
|
|
|
this.state.set('sending');
|
|
this.receipt.set(null);
|
|
|
|
const payload = {
|
|
...value,
|
|
kind: this.kind(),
|
|
organisation: value.organisation || null,
|
|
website: this.website() || null,
|
|
};
|
|
|
|
try {
|
|
const body = await this.post(payload);
|
|
|
|
this.receipt.set(body);
|
|
this.state.set(body.data?.duplicate ? 'duplicate' : 'sent');
|
|
} catch (error) {
|
|
this.receipt.set((error as HttpErrorResponse).error as SubmissionReceipt);
|
|
this.state.set('error');
|
|
}
|
|
|
|
return undefined;
|
|
},
|
|
});
|
|
}
|
|
|
|
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>(this.endpoint(), payload)
|
|
.subscribe({ next: resolve, error: reject });
|
|
});
|
|
}
|
|
}
|