339 lines
11 KiB
TypeScript
339 lines
11 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);
|
||
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="What’s 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>
|
||
);
|
||
}
|