Initial commit
This commit is contained in:
305
landing/src/app/ui/contact-form.ts
Normal file
305
landing/src/app/ui/contact-form.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
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>
|
||||
<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 {
|
||||
<pi-number-field [formField]="contact.hours_per_week">
|
||||
<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);
|
||||
|
||||
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 post(payload: unknown): Promise<SubmissionReceipt> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.http
|
||||
.post<SubmissionReceipt>(`/api/contact?lang=${this.locale()}`, payload)
|
||||
.subscribe({ next: resolve, error: reject });
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user