Initial commit

This commit is contained in:
pigui
2026-08-30 21:11:59 +02:00
commit 3f930bb46a
168 changed files with 35708 additions and 0 deletions

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