Plain node activation no longer navigates, case cards stay quiet evidence rows, and the terminal keeps one top-to-bottom scrollport. Co-authored-by: Cursor <cursoragent@cursor.com>
115 lines
3.6 KiB
TypeScript
115 lines
3.6 KiB
TypeScript
import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core';
|
|
import {
|
|
type ContactFieldCopy,
|
|
type ContactFieldId,
|
|
type ContactPageCopy,
|
|
} from '../../core/content/content.contracts';
|
|
import { SITE_CONFIG } from '../../core/content/site-config';
|
|
|
|
const EMPTY_VALUES: Record<ContactFieldId, string> = {
|
|
name: '',
|
|
email: '',
|
|
company: '',
|
|
projectType: '',
|
|
situation: '',
|
|
timeframe: '',
|
|
};
|
|
|
|
@Component({
|
|
selector: 'app-contact-briefing',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
templateUrl: './contact-briefing.html',
|
|
styleUrl: './contact-briefing.scss',
|
|
})
|
|
export class ContactBriefing {
|
|
readonly copy = input.required<ContactPageCopy>();
|
|
|
|
protected readonly values = signal<Record<ContactFieldId, string>>({ ...EMPTY_VALUES });
|
|
protected readonly touched = signal<Partial<Record<ContactFieldId, boolean>>>({});
|
|
protected readonly contactEmail = SITE_CONFIG.contactEmail;
|
|
protected readonly incompleteId = 'contact-incomplete';
|
|
|
|
protected readonly missingLabels = computed(() => {
|
|
const copy = this.copy();
|
|
const values = this.values();
|
|
return copy.fields
|
|
.filter((field) => field.required && values[field.id].trim().length === 0)
|
|
.map((field) => field.label);
|
|
});
|
|
|
|
protected readonly isComplete = computed(() => this.missingLabels().length === 0);
|
|
|
|
protected readonly incompleteMessage = computed(() => {
|
|
const missing = this.missingLabels();
|
|
if (missing.length === 0) {
|
|
return '';
|
|
}
|
|
|
|
return `${this.copy().incompleteHint} ${missing.join(', ')}`;
|
|
});
|
|
|
|
protected readonly mailtoHref = computed(() => {
|
|
const copy = this.copy();
|
|
const values = this.values();
|
|
const body = this.assembleBody(copy, values);
|
|
return `mailto:${SITE_CONFIG.contactEmail}?subject=${encodeURIComponent(copy.mailSubject)}&body=${encodeURIComponent(body)}`;
|
|
});
|
|
|
|
protected onInput(fieldId: ContactFieldId, event: Event): void {
|
|
const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
|
this.values.update((current) => ({ ...current, [fieldId]: target.value }));
|
|
this.markTouched(fieldId);
|
|
}
|
|
|
|
protected onBlur(fieldId: ContactFieldId): void {
|
|
this.markTouched(fieldId);
|
|
}
|
|
|
|
protected onDisabledSubmit(event: Event): void {
|
|
event.preventDefault();
|
|
}
|
|
|
|
protected controlId(field: ContactFieldCopy): string {
|
|
return `contact-${field.id}`;
|
|
}
|
|
|
|
protected showInvalid(field: ContactFieldCopy): boolean {
|
|
return (
|
|
field.required &&
|
|
this.touched()[field.id] === true &&
|
|
this.values()[field.id].trim().length === 0
|
|
);
|
|
}
|
|
|
|
protected describedBy(field: ContactFieldCopy): string | null {
|
|
const ids: string[] = [];
|
|
if (field.hint) {
|
|
ids.push(`${this.controlId(field)}-hint`);
|
|
}
|
|
if (this.showInvalid(field)) {
|
|
ids.push(this.incompleteId);
|
|
}
|
|
return ids.length > 0 ? ids.join(' ') : null;
|
|
}
|
|
|
|
private markTouched(fieldId: ContactFieldId): void {
|
|
this.touched.update((current) => ({ ...current, [fieldId]: true }));
|
|
}
|
|
|
|
private assembleBody(copy: ContactPageCopy, values: Record<ContactFieldId, string>): string {
|
|
const lines = copy.fields.flatMap((field) => {
|
|
const raw = values[field.id];
|
|
if (!field.required && raw.trim().length === 0) {
|
|
return [];
|
|
}
|
|
|
|
return [`${field.label}: ${this.displayValue(field, raw)}`];
|
|
});
|
|
return [copy.mailIntro, '', ...lines, '', copy.mailSignature].join('\n');
|
|
}
|
|
|
|
private displayValue(field: ContactFieldCopy, value: string): string {
|
|
return field.options?.find((option) => option.value === value)?.label ?? value;
|
|
}
|
|
}
|