import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react"; type FormState = { name: string; email: string; message: string; website: string; }; type ToastTone = "info" | "success" | "error"; type ToastState = { id: number; tone: ToastTone; message: string; }; type ContactApiResponse = { ok: boolean; message?: string; error?: string; }; const TURNSTILE_ACTION = "contact_form"; const HONEYPOT_FIELD = "website"; const API_URL = import.meta.env.VITE_CONTACT_API_URL || "/api/contact"; const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY; const initialForm: FormState = { name: "", email: "", message: "", website: "", }; const validateForm = (form: FormState): string | null => { const name = form.name.trim(); const email = form.email.trim(); const message = form.message.trim(); if (name.length < 2 || name.length > 80) { return "Please enter a name between 2 and 80 characters."; } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return "Please enter a valid email address."; } if (message.length < 20 || message.length > 2000) { return "Your message should be between 20 and 2000 characters."; } return null; }; const toastToneClasses: Record = { info: "border-primary/60", success: "border-primary/70", error: "border-contrast/80", }; export function Contact() { const [form, setForm] = useState(initialForm); const [token, setToken] = useState(""); const [submitting, setSubmitting] = useState(false); const [toast, setToast] = useState(null); const [turnstileReady, setTurnstileReady] = useState(false); const startedAtRef = useRef(Date.now()); const turnstileContainerRef = useRef(null); const turnstileWidgetIdRef = useRef(null); const toastTimerRef = useRef(null); const showToast = (tone: ToastTone, message: string): void => { setToast({ id: Date.now(), tone, message }); }; const resetTurnstile = (): void => { setToken(""); if (window.turnstile && turnstileWidgetIdRef.current !== null) { window.turnstile.reset(turnstileWidgetIdRef.current); } }; useEffect(() => { if (!TURNSTILE_SITE_KEY || !turnstileContainerRef.current) { return; } let cancelled = false; const renderTurnstile = (): void => { if (cancelled || !turnstileContainerRef.current || turnstileWidgetIdRef.current !== null) { return; } if (!window.turnstile) { window.setTimeout(renderTurnstile, 120); return; } const widgetId = window.turnstile.render(turnstileContainerRef.current, { sitekey: TURNSTILE_SITE_KEY, action: TURNSTILE_ACTION, callback: (value: string) => { setToken(value); }, "expired-callback": () => { setToken(""); }, "error-callback": () => { setToken(""); }, }); turnstileWidgetIdRef.current = widgetId; setTurnstileReady(true); }; renderTurnstile(); return () => { cancelled = true; if (window.turnstile && turnstileWidgetIdRef.current !== null) { window.turnstile.remove(turnstileWidgetIdRef.current); turnstileWidgetIdRef.current = null; } }; }, []); useEffect(() => { if (!toast) { return; } if (toastTimerRef.current !== null) { window.clearTimeout(toastTimerRef.current); } toastTimerRef.current = window.setTimeout(() => { setToast(null); toastTimerRef.current = null; }, 5000); return () => { if (toastTimerRef.current !== null) { window.clearTimeout(toastTimerRef.current); } }; }, [toast]); const handleChange = ( event: ChangeEvent, ): void => { const { name, value } = event.currentTarget; setForm((previous) => ({ ...previous, [name]: value })); }; const handleSubmit = async (event: FormEvent): Promise => { event.preventDefault(); if (submitting) { return; } if (!TURNSTILE_SITE_KEY) { showToast("error", "Turnstile is not configured yet."); return; } const validationError = validateForm(form); if (validationError) { showToast("error", validationError); return; } if (!token) { showToast("error", "Please complete human verification before submitting."); return; } setSubmitting(true); showToast("info", "Sending your message..."); const payload = { name: form.name.trim(), email: form.email.trim(), message: form.message.trim(), [HONEYPOT_FIELD]: form.website, startedAt: startedAtRef.current, turnstileToken: token, }; try { const response = await fetch(API_URL, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(payload), }); let responseBody: ContactApiResponse | null = null; try { responseBody = (await response.json()) as ContactApiResponse; } catch { responseBody = null; } if (!response.ok || !responseBody?.ok) { throw new Error(responseBody?.error || "Unable to send message right now."); } setForm(initialForm); startedAtRef.current = Date.now(); resetTurnstile(); showToast("success", responseBody.message || "Message sent."); } catch (error) { const message = error instanceof Error ? error.message : "Unable to send message right now."; showToast("error", message); resetTurnstile(); } finally { setSubmitting(false); } }; return (

Contact

Send me a message and I'll reply from my personal inbox.