221 lines
6.2 KiB
TypeScript
221 lines
6.2 KiB
TypeScript
'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"
|
||
>
|
||
Let’s 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>
|
||
);
|
||
}
|