Files
williammarch.xyz/components/Contact.tsx
2026-04-03 16:45:28 +01:00

339 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useRef, useState } from 'react';
import { SITE, GITEA_URL, GITEA_USERNAME } from '@/lib/config';
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
declare global {
interface Window {
turnstile?: {
render: (
container: string | HTMLElement,
options: {
sitekey: string;
theme?: 'light' | 'dark' | 'auto';
callback?: (token: string) => void;
'expired-callback'?: () => void;
'error-callback'?: () => void;
}
) => string;
reset: (widgetId?: string) => void;
};
}
}
type Status = 'idle' | 'loading' | 'sent' | 'error';
export default function Contact() {
const [status, setStatus] = useState<Status>('idle');
const [errorMessage, setErrorMessage] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const widgetIdRef = useRef<string | null>(null);
const turnstileRef = useRef<HTMLDivElement | null>(null);
const startedAtRef = useRef<number>(Date.now());
useEffect(() => {
if (!TURNSTILE_SITE_KEY) {
setErrorMessage('Captcha is not configured correctly.');
return;
}
const scriptId = 'cf-turnstile-script';
const renderWidget = () => {
if (!window.turnstile || !turnstileRef.current || widgetIdRef.current) return;
widgetIdRef.current = window.turnstile.render(turnstileRef.current, {
sitekey: TURNSTILE_SITE_KEY,
theme: 'light',
callback: (token) => {
setTurnstileToken(token);
setErrorMessage('');
},
'expired-callback': () => {
setTurnstileToken('');
},
'error-callback': () => {
setTurnstileToken('');
setErrorMessage('Captcha failed to load. Please try again.');
},
});
};
if (document.getElementById(scriptId)) {
renderWidget();
return;
}
const script = document.createElement('script');
script.id = scriptId;
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
script.async = true;
script.defer = true;
script.onload = renderWidget;
document.body.appendChild(script);
}, []);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus('loading');
setErrorMessage('');
const form = e.currentTarget;
const fd = new FormData(form);
const payload = {
name: String(fd.get('name') || '').trim(),
email: String(fd.get('email') || '').trim(),
message: String(fd.get('message') || '').trim(),
company: String(fd.get('company') || '').trim(),
turnstileToken,
formStartedAt: String(fd.get('formStartedAt') || ''),
};
if (!payload.name || !payload.email || !payload.message) {
setStatus('error');
setErrorMessage('Please complete all fields.');
return;
}
if (!payload.turnstileToken) {
setStatus('error');
setErrorMessage('Please complete the captcha.');
return;
}
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data?.error || 'Failed to send message.');
}
setStatus('sent');
form.reset();
startedAtRef.current = Date.now();
setTurnstileToken('');
if (window.turnstile && widgetIdRef.current) {
window.turnstile.reset(widgetIdRef.current);
}
} catch (err) {
setStatus('error');
setErrorMessage(
err instanceof Error ? err.message : 'Something went wrong.',
);
if (window.turnstile && widgetIdRef.current) {
window.turnstile.reset(widgetIdRef.current);
}
setTurnstileToken('');
}
}
return (
<section
id="contact"
className="py-24 px-6"
aria-labelledby="contact-heading"
>
<div className="max-w-5xl mx-auto min-w-0">
<h2
id="contact-heading"
className="text-[clamp(1.8rem,4vw,2.5rem)] font-black tracking-tight text-slate-900 mb-3"
>
</h2>
<p className="text-sm text-slate-600 max-w-2xl mb-10">
</p>
<div className="grid md:grid-cols-[280px_1fr] gap-6 items-start">
{/* Left side contact cards */}
<div className="flex flex-col gap-3">
<a
href={`mailto:${SITE.email}`}
className="rounded-xl border border-slate-200/80 bg-white/80 p-4 flex items-center gap-3 group transition-all duration-150 hover:border-slate-300 hover:-translate-x-[1px]"
>
<div
className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center flex-shrink-0 border border-slate-200"
aria-hidden="true"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="rgba(15,23,42,0.6)"
strokeWidth="1.8"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
</div>
<div className="min-w-0">
<p className="text-[10px] text-slate-500 uppercase tracking-wider">
Email
</p>
<p className="text-xs font-medium text-slate-800 truncate group-hover:text-slate-900 transition-colors">
{SITE.email}
</p>
</div>
</a>
<a
href={`${GITEA_URL}/${GITEA_USERNAME}`}
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-slate-200/80 bg-white/80 p-4 flex items-center gap-3 group transition-all duration-150 hover:border-slate-300 hover:translate-x-[1px]"
>
<div
className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center flex-shrink-0 border border-slate-200"
aria-hidden="true"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="rgba(15,23,42,0.6)"
strokeWidth="1.8"
>
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.2c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
<path d="M9 18c-4.51 2-5-2-7-2" />
</svg>
</div>
<div className="min-w-0">
<p className="text-[10px] text-slate-500 uppercase tracking-wider">
Gitea
</p>
<p className="text-xs font-medium text-slate-800 truncate group-hover:text-slate-900 transition-colors">
{GITEA_URL.replace(/^https?:\/\//, '')}/{GITEA_USERNAME}
</p>
</div>
</a>
</div>
{/* Form */}
<form
onSubmit={handleSubmit}
className="glass rounded-2xl p-6 flex flex-col gap-4 relative border border-slate-200/80 bg-white/80"
noValidate
>
<input
type="hidden"
name="formStartedAt"
value={String(startedAtRef.current)}
/>
{/* Honeypot */}
<div
className="absolute left-[-5000px] opacity-0 pointer-events-none"
aria-hidden="true"
>
<label htmlFor="company">Company</label>
<input
id="company"
name="company"
type="text"
tabIndex={-1}
autoComplete="off"
/>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<label className="flex flex-col gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500">
Name
</span>
<input
name="name"
type="text"
required
placeholder="Your name"
autoComplete="name"
className="bg-white border border-slate-200 rounded-lg px-3 py-2.5 text-sm
text-slate-900 placeholder-slate-400 outline-none transition-colors
focus:border-slate-400 focus:ring-1 focus:ring-slate-300"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500">
Email
</span>
<input
name="email"
type="email"
required
placeholder="you@example.com"
autoComplete="email"
className="bg-white border border-slate-200 rounded-lg px-3 py-2.5 text-sm
text-slate-900 placeholder-slate-400 outline-none transition-colors
focus:border-slate-400 focus:ring-1 focus:ring-slate-300"
/>
</label>
</div>
<label className="flex flex-col gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500">
Message
</span>
<textarea
name="message"
required
placeholder="Whats on your mind?"
rows={5}
className="bg-white border border-slate-200 rounded-lg px-3 py-2.5 text-sm
text-slate-900 placeholder-slate-400 outline-none resize-none transition-colors
focus:border-slate-400 focus:ring-1 focus:ring-slate-300"
/>
</label>
<div>
{TURNSTILE_SITE_KEY ? (
<div ref={turnstileRef} />
) : (
<p className="text-xs text-red-500">
Captcha is not configured correctly.
</p>
)}
</div>
<div className="flex items-center gap-4 flex-wrap">
<button
type="submit"
disabled={status === 'loading'}
className="btn btn-primary disabled:opacity-60 disabled:cursor-not-allowed"
>
{status === 'loading'
? 'Sending…'
: status === 'sent'
? 'Message sent'
: 'Send message'}
</button>
{status === 'sent' && (
<p className="text-xs text-slate-600">
Thanks your message has been sent.
</p>
)}
{status === 'error' && errorMessage && (
<p className="text-xs text-red-500">{errorMessage}</p>
)}
</div>
</form>
</div>
</div>
</section>
);
}