Files
williammarch.xyz/components/Contact.tsx
2026-04-02 23:02:19 +01:00

221 lines
6.2 KiB
TypeScript
Raw 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);
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: 'dark',
callback: token => {
setTurnstileToken(token);
},
'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(), // honeypot
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();
setTurnstileToken('');
if (window.turnstile && widgetIdRef.current) {
window.turnstile.reset(widgetIdRef.current);
}
} catch (err) {
setStatus('error');
setErrorMessage(
err instanceof Error ? err.message : 'Something went wrong.',
);
} finally {
if (status !== 'sent') {
setStatus(prev => (prev === 'sent' ? 'sent' : 'idle'));
}
}
}
return (
<section
id="contact"
className="py-24 px-6"
aria-labelledby="contact-heading"
>
<div className="max-w-5xl mx-auto">
<span className="label">Get in touch</span>
<h2
id="contact-heading"
className="text-[clamp(1.8rem,4vw,2.5rem)] font-black tracking-tight text-white mb-3"
>
Lets work together.
</h2>
<p className="text-sm text-white/40 max-w-2xl mb-10">
Open to interesting roles, collaborations, and serious product ideas.
</p>
<div className="grid md:grid-cols-[280px_1fr] gap-6">
<div className="flex flex-col gap-3">
{/* email + gitea blocks unchanged */}
{/* ... */}
</div>
<form
onSubmit={handleSubmit}
className="glass rounded-xl p-6 flex flex-col gap-4"
noValidate
>
<input type="hidden" name="formStartedAt" value={String(Date.now())} />
{/* 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>
{/* fields unchanged */}
{/* ... */}
<div>
{TURNSTILE_SITE_KEY ? (
<div ref={turnstileRef} />
) : (
<p className="text-xs text-red-300/80">
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-white/45">
Thanks your message has been sent.
</p>
)}
{errorMessage && (
<p className="text-xs text-red-300/80">{errorMessage}</p>
)}
</div>
</form>
</div>
</div>
</section>
);
}