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 {
}
`,
})
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 });
});
}
}