mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
194 lines
7.3 KiB
TypeScript
194 lines
7.3 KiB
TypeScript
/**
|
|
* ContactForm.tsx
|
|
* ---------------------------------------------------------------------------
|
|
* Accessible contact form island. Posts to /api/contact, which either sends an
|
|
* email via SMTP or fires a webhook. Manages idle / submitting / success / error
|
|
* states, does lightweight client validation, and includes a honeypot field for
|
|
* crude spam defense (the server also rate-limits).
|
|
*
|
|
* The form is keyboard-accessible, labels every field, and announces status
|
|
* changes via an aria-live region.
|
|
*/
|
|
import { useState, type FormEvent } from 'react';
|
|
|
|
type Status = 'idle' | 'submitting' | 'success' | 'error';
|
|
|
|
interface FieldErrors {
|
|
name?: string;
|
|
email?: string;
|
|
message?: string;
|
|
}
|
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
|
export default function ContactForm() {
|
|
const [status, setStatus] = useState<Status>('idle');
|
|
const [errors, setErrors] = useState<FieldErrors>({});
|
|
const [errorMsg, setErrorMsg] = useState<string>('');
|
|
|
|
function validate(values: { name: string; email: string; message: string }): FieldErrors {
|
|
const e: FieldErrors = {};
|
|
if (!values.name.trim()) e.name = 'Please enter your name.';
|
|
if (!values.email.trim()) e.email = 'Please enter your email.';
|
|
else if (!EMAIL_RE.test(values.email)) e.email = 'That email address looks off.';
|
|
if (!values.message.trim()) e.message = 'Please enter a message.';
|
|
else if (values.message.trim().length < 10) e.message = 'A little more detail, please (10+ characters).';
|
|
return e;
|
|
}
|
|
|
|
async function onSubmit(ev: FormEvent<HTMLFormElement>) {
|
|
ev.preventDefault();
|
|
const form = ev.currentTarget;
|
|
const data = new FormData(form);
|
|
const values = {
|
|
name: String(data.get('name') ?? '').trim(),
|
|
email: String(data.get('email') ?? '').trim(),
|
|
message: String(data.get('message') ?? '').trim(),
|
|
// Honeypot: bots fill this, humans never see it.
|
|
company: String(data.get('company') ?? ''),
|
|
};
|
|
|
|
const found = validate(values);
|
|
setErrors(found);
|
|
if (Object.keys(found).length > 0) return;
|
|
|
|
setStatus('submitting');
|
|
setErrorMsg('');
|
|
try {
|
|
const res = await fetch('/api/contact', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(values),
|
|
});
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
throw new Error(body.error || 'Something went wrong. Please try again.');
|
|
}
|
|
setStatus('success');
|
|
form.reset();
|
|
} catch (err) {
|
|
setStatus('error');
|
|
setErrorMsg(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
|
}
|
|
}
|
|
|
|
const inputBase =
|
|
'w-full rounded-lg border bg-transparent px-3 py-2.5 text-sm outline-none transition-colors focus:border-[var(--color-accent)]';
|
|
const labelBase = 'mb-1.5 block font-sans text-sm font-medium';
|
|
|
|
if (status === 'success') {
|
|
return (
|
|
<div
|
|
className="flex flex-col items-center gap-3 rounded-xl border p-8 text-center"
|
|
style={{ borderColor: 'var(--color-accent)', background: 'color-mix(in srgb, var(--color-accent) 8%, transparent)' }}
|
|
>
|
|
<span
|
|
className="inline-flex h-12 w-12 items-center justify-center rounded-full"
|
|
style={{ background: 'var(--color-accent)', color: 'var(--color-accent-contrast)' }}
|
|
aria-hidden="true"
|
|
>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M20 6L9 17l-5-5" />
|
|
</svg>
|
|
</span>
|
|
<h3 className="font-sans text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
|
|
Message sent.
|
|
</h3>
|
|
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
|
Thanks for reaching out. I will get back to you soon.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => setStatus('idle')}
|
|
className="btn btn-secondary mt-2"
|
|
>
|
|
Send another
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={onSubmit} noValidate className="flex flex-col gap-5">
|
|
{/* Honeypot: visually hidden, not aria-hidden so screen readers can skip it. */}
|
|
<div style={{ position: 'absolute', left: '-9999px', top: 'auto', width: 1, height: 1, overflow: 'hidden' }} aria-hidden="true">
|
|
<label htmlFor="company">Company (leave blank)</label>
|
|
<input id="company" name="company" type="text" tabIndex={-1} autoComplete="off" />
|
|
</div>
|
|
|
|
<div className="flex flex-col">
|
|
<label htmlFor="name" className={labelBase} style={{ color: 'var(--color-text)' }}>Name</label>
|
|
<input
|
|
id="name"
|
|
name="name"
|
|
type="text"
|
|
autoComplete="name"
|
|
className={inputBase}
|
|
style={{
|
|
borderColor: errors.name ? 'var(--color-accent)' : 'var(--color-border)',
|
|
color: 'var(--color-text)',
|
|
}}
|
|
aria-invalid={Boolean(errors.name)}
|
|
aria-describedby={errors.name ? 'name-error' : undefined}
|
|
/>
|
|
{errors.name && <p id="name-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.name}</p>}
|
|
</div>
|
|
|
|
<div className="flex flex-col">
|
|
<label htmlFor="email" className={labelBase} style={{ color: 'var(--color-text)' }}>Email</label>
|
|
<input
|
|
id="email"
|
|
name="email"
|
|
type="email"
|
|
autoComplete="email"
|
|
className={inputBase}
|
|
style={{
|
|
borderColor: errors.email ? 'var(--color-accent)' : 'var(--color-border)',
|
|
color: 'var(--color-text)',
|
|
}}
|
|
aria-invalid={Boolean(errors.email)}
|
|
aria-describedby={errors.email ? 'email-error' : undefined}
|
|
/>
|
|
{errors.email && <p id="email-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.email}</p>}
|
|
</div>
|
|
|
|
<div className="flex flex-col">
|
|
<label htmlFor="message" className={labelBase} style={{ color: 'var(--color-text)' }}>Message</label>
|
|
<textarea
|
|
id="message"
|
|
name="message"
|
|
rows={5}
|
|
className={inputBase}
|
|
style={{
|
|
borderColor: errors.message ? 'var(--color-accent)' : 'var(--color-border)',
|
|
color: 'var(--color-text)',
|
|
resize: 'vertical',
|
|
}}
|
|
aria-invalid={Boolean(errors.message)}
|
|
aria-describedby={errors.message ? 'message-error' : undefined}
|
|
/>
|
|
{errors.message && <p id="message-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.message}</p>}
|
|
</div>
|
|
|
|
{status === 'error' && (
|
|
<p className="rounded-lg p-3 text-sm" style={{ background: 'color-mix(in srgb, var(--color-accent) 12%, transparent)', color: 'var(--color-accent)' }}>
|
|
{errorMsg}
|
|
</p>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={status === 'submitting'}
|
|
className="btn btn-primary self-start disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{status === 'submitting' ? 'Sending…' : 'Send message'}
|
|
</button>
|
|
|
|
{/* Screen-reader status announcements. */}
|
|
<p className="sr-only" role="status" aria-live="polite">
|
|
{status === 'submitting' ? 'Sending your message.' : ''}
|
|
</p>
|
|
</form>
|
|
);
|
|
}
|