033c9ec315
- vitest: dictionary parity, slug-map bijection, content-schema validation, JSON-LD (23) - playwright: both locales, language-switch mapping (incl. no-JS), CV downloads, form happy-path + honeypot, axe a11y on 5 templates x 2 themes (23) - fix: raise ink-faint + light accent to meet WCAG AA 4.5:1 (axe-verified) - fix: gate reveal animations behind html.js so content is visible without JS (progressive enhancement - was hidden at opacity 0); scan reduced-motion path - fix: validate contact form before the anti-bot time-trap (no false success) - chore: ESLint node globals, typed diagram props, resvg fontFiles, prettier pass Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
102 lines
3.6 KiB
TypeScript
102 lines
3.6 KiB
TypeScript
/*
|
|
Contact form (A9). Loaded only on the contact page. Client validation + honeypot +
|
|
time-trap, POST to the relay, then success/error panel swap. Progressive enhancement
|
|
over a visible e-mail address (the form degrades to mailto if this never runs).
|
|
*/
|
|
const form = document.getElementById('contact-form') as HTMLFormElement | null;
|
|
|
|
if (form) {
|
|
const loadedAt = Date.now();
|
|
const successPanel = document.getElementById('form-success');
|
|
const errorPanel = document.getElementById('form-error');
|
|
const submitBtn = form.querySelector<HTMLButtonElement>('button[type="submit"]');
|
|
const submitLabel = submitBtn?.querySelector<HTMLElement>('[data-label]');
|
|
const sendingText = submitBtn?.dataset.sending ?? 'Sending';
|
|
const sendText = submitLabel?.textContent ?? 'Send';
|
|
|
|
const msgRequired = form.dataset.msgRequired ?? 'Required';
|
|
const msgEmail = form.dataset.msgEmail ?? 'Invalid e-mail';
|
|
|
|
function fieldError(name: string, message: string | null): void {
|
|
const field = form!.querySelector<HTMLElement>(`[name="${name}"]`);
|
|
const err = form!.querySelector<HTMLElement>(`[data-error-for="${name}"]`);
|
|
if (field) field.setAttribute('aria-invalid', message ? 'true' : 'false');
|
|
if (err) {
|
|
err.textContent = message ?? '';
|
|
err.hidden = !message;
|
|
}
|
|
}
|
|
|
|
function validate(data: Record<string, string>): boolean {
|
|
let ok = true;
|
|
let firstInvalid: string | null = null;
|
|
const require = (name: string) => {
|
|
if (!data[name]?.trim()) {
|
|
fieldError(name, msgRequired);
|
|
ok = false;
|
|
firstInvalid ??= name;
|
|
} else {
|
|
fieldError(name, null);
|
|
}
|
|
};
|
|
require('name');
|
|
if (!data.email?.trim()) {
|
|
fieldError('email', msgRequired);
|
|
ok = false;
|
|
firstInvalid ??= 'email';
|
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
|
fieldError('email', msgEmail);
|
|
ok = false;
|
|
firstInvalid ??= 'email';
|
|
} else {
|
|
fieldError('email', null);
|
|
}
|
|
require('message');
|
|
if (firstInvalid) form!.querySelector<HTMLElement>(`[name="${firstInvalid}"]`)?.focus();
|
|
return ok;
|
|
}
|
|
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(form);
|
|
const data = {
|
|
name: String(fd.get('name') ?? ''),
|
|
email: String(fd.get('email') ?? ''),
|
|
message: String(fd.get('message') ?? ''),
|
|
company: String(fd.get('company') ?? ''), // honeypot
|
|
};
|
|
|
|
// Validate first, so a real person who submits an incomplete form always sees
|
|
// the field errors (never a false "success").
|
|
if (!validate(data)) return;
|
|
|
|
// Honeypot filled or submitted implausibly fast → treat as bot, feign success.
|
|
if (data.company || Date.now() - loadedAt < 2000) {
|
|
form.hidden = true;
|
|
successPanel?.removeAttribute('hidden');
|
|
return;
|
|
}
|
|
|
|
submitBtn?.setAttribute('disabled', 'true');
|
|
if (submitLabel) submitLabel.textContent = sendingText;
|
|
submitBtn?.classList.add('is-sending');
|
|
|
|
try {
|
|
const res = await fetch('/api/contact', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: data.name, email: data.email, message: data.message }),
|
|
});
|
|
if (!res.ok) throw new Error(String(res.status));
|
|
form.hidden = true;
|
|
successPanel?.removeAttribute('hidden');
|
|
} catch {
|
|
errorPanel?.removeAttribute('hidden');
|
|
} finally {
|
|
submitBtn?.removeAttribute('disabled');
|
|
submitBtn?.classList.remove('is-sending');
|
|
if (submitLabel) submitLabel.textContent = sendText;
|
|
}
|
|
});
|
|
}
|