feat: core layout, UI atoms, homepage + behaviour modules
- UI atoms: Chip, Button, SplitCvButton, LangSwitch, ThemeToggle, Card, SectionLabel, TraceMotif, FramedImage - core: Base/Seo/Header/MobileNav/Footer/HintBar/SkipLink/ThemeScript - homepage sections: Hero, ProofStrip, SkillsGrid, ProjectCards, ExperienceTimeline, AboutTeaser, ContactBand + Homepage composition (EN/NO) - behaviour modules: theme, nav (overlay/hint/copy/header), observer (reveal/spy), lightbox, form — progressive enhancement, fail-silent - SEO head component: meta, hreflang from slug map, JSON-LD, OG references - move content data to src/data (avoid Astro reserved content-collection folder) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
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
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (!validate(data)) 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user