Files
Portfolio/src/app/shared/contact-briefing/contact-briefing.ts
Antonio Ledebuhr 55d012cc10 Open the systems-map gist dialog and turn the dock into a default-open TTY.
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>
2026-08-27 00:41:20 +02:00

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