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

344 lines
11 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);
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: 'dark',
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">
<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">
{/* Left side contact cards */}
<div className="flex flex-col gap-3">
<a
href={`mailto:${SITE.email}`}
className="glass rounded-xl p-4 flex items-center gap-3 group hover:border-white/[0.14]
hover:-translate-x-[-4px] transition-all duration-150"
>
<div
className="w-8 h-8 rounded-lg bg-white/[0.05] flex items-center justify-center flex-shrink-0"
aria-hidden="true"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="rgba(255,255,255,0.5)"
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-white/25 uppercase tracking-wider">
Email
</p>
<p className="text-xs font-medium text-white/55 truncate group-hover:text-white/80 transition-colors">
{SITE.email}
</p>
</div>
</a>
<a
href={`${GITEA_URL}/${GITEA_USERNAME}`}
target="_blank"
rel="noopener noreferrer"
className="glass rounded-xl p-4 flex items-center gap-3 group hover:border-white/[0.14]
hover:translate-x-1 transition-all duration-150"
>
<div
className="w-8 h-8 rounded-lg bg-white/[0.05] flex items-center justify-center flex-shrink-0"
aria-hidden="true"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="rgba(255,255,255,0.5)"
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-white/25 uppercase tracking-wider">
Gitea
</p>
<p className="text-xs font-medium text-white/55 truncate group-hover:text-white/80 transition-colors">
{GITEA_URL.replace(/^https?:\/\//, '')}/{GITEA_USERNAME}
</p>
</div>
</a>
</div>
{/* Form */}
<form
onSubmit={handleSubmit}
className="glass rounded-xl p-6 flex flex-col gap-4 relative"
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-white/30">
Name
</span>
<input
name="name"
type="text"
required
placeholder="Your name"
autoComplete="name"
className="bg-white/[0.03] border border-white/[0.08] rounded-lg px-3 py-2.5 text-sm
text-white placeholder-white/20 outline-none transition-colors
focus:border-white/[0.2] focus:ring-1 focus:ring-white/[0.1]"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/30">
Email
</span>
<input
name="email"
type="email"
required
placeholder="you@example.com"
autoComplete="email"
className="bg-white/[0.03] border border-white/[0.08] rounded-lg px-3 py-2.5 text-sm
text-white placeholder-white/20 outline-none transition-colors
focus:border-white/[0.2] focus:ring-1 focus:ring-white/[0.1]"
/>
</label>
</div>
<label className="flex flex-col gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/30">
Message
</span>
<textarea
name="message"
required
placeholder="Whats on your mind?"
rows={5}
className="bg-white/[0.03] border border-white/[0.08] rounded-lg px-3 py-2.5 text-sm
text-white placeholder-white/20 outline-none resize-none transition-colors
focus:border-white/[0.2] focus:ring-1 focus:ring-white/[0.1]"
/>
</label>
<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>
)}
{status === 'error' && errorMessage && (
<p className="text-xs text-red-300/80">{errorMessage}</p>
)}
</div>
</form>
</div>
</div>
</section>
);
}