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()) { } @else if (state() === 'sent' || state() === 'duplicate') { } @else {
@if (!callSlug()) {
{{ copy().form.kind }}
}
{{ copy().form.name }} {{ copy().form.email }}
@if (kind() === 'request') { {{ copy().form.organisation }} } @else { {{ copy().form.hours }} }
{{ copy().form.message }} @if (state() === 'error') { } } `, }) export class ContactForm { readonly copy = input.required(); readonly locale = input.required(); readonly enabled = input(true); /** Set, the form applies to that call instead of writing in generally. */ readonly callSlug = input(null); private readonly http = inject(HttpClient); protected readonly state = signal('idle'); private readonly receipt = signal(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('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[]>(() => [ { 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 { return new Promise((resolve, reject) => { this.http .post(this.endpoint(), payload) .subscribe({ next: resolve, error: reject }); }); } }