Add contact form handling with Turnstile

This commit is contained in:
Calum Muir 2025-12-22 12:25:30 +00:00
parent 2fcb183743
commit 5d064d63a9
2 changed files with 194 additions and 3 deletions

View file

@ -1,8 +1,14 @@
import React from "react"; import React, { useEffect, useState } from "react";
import { SiteHeader } from "@/components/SiteHeader"; import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter"; import { SiteFooter } from "@/components/SiteFooter";
import { usePageMeta } from "@/lib/seo"; import { usePageMeta } from "@/lib/seo";
declare global {
interface Window {
turnstile?: { reset: () => void };
}
}
const navigationItems = [ const navigationItems = [
{ label: "Home", href: "/" }, { label: "Home", href: "/" },
{ label: "About", href: "/about" }, { label: "About", href: "/about" },
@ -12,12 +18,63 @@ const navigationItems = [
]; ];
export const Contact = (): JSX.Element => { export const Contact = (): JSX.Element => {
const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY ?? "";
const [formStatus, setFormStatus] = useState<
"idle" | "sending" | "success" | "error"
>("idle");
const [formMessage, setFormMessage] = useState("");
useEffect(() => {
if (!turnstileSiteKey) {
return;
}
const existingScript = document.querySelector("script[data-turnstile]");
if (existingScript) {
return;
}
const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true;
script.defer = true;
script.dataset.turnstile = "true";
document.body.appendChild(script);
}, [turnstileSiteKey]);
usePageMeta({ usePageMeta({
title: "Contact", title: "Contact",
path: "/contact", path: "/contact",
description: description:
"Contact Highland Group RDA for sessions, volunteering, and support.", "Contact Highland Group RDA for sessions, volunteering, and support.",
}); });
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (formStatus === "sending") {
return;
}
setFormStatus("sending");
setFormMessage("");
const form = event.currentTarget;
const formData = new FormData(form);
try {
const response = await fetch(form.action, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("Request failed");
}
setFormStatus("success");
setFormMessage("Thanks! Your message has been sent.");
form.reset();
if (window.turnstile?.reset) {
window.turnstile.reset();
}
} catch (error) {
setFormStatus("error");
setFormMessage("Sorry, something went wrong. Please try again.");
}
};
return ( return (
<div className="flex flex-col min-h-screen items-center bg-[#e2e2e2]"> <div className="flex flex-col min-h-screen items-center bg-[#e2e2e2]">
<main className="w-full"> <main className="w-full">
@ -87,8 +144,9 @@ export const Contact = (): JSX.Element => {
</h2> </h2>
<form <form
className="mt-4 space-y-4" className="mt-4 space-y-4"
action="#" action="/api/contact"
method="post" method="post"
onSubmit={handleSubmit}
> >
<div> <div>
<label className="block text-sm font-semibold text-black"> <label className="block text-sm font-semibold text-black">
@ -98,6 +156,8 @@ export const Contact = (): JSX.Element => {
className="mt-2 w-full rounded-md border border-black/20 bg-white px-3 py-2 text-black" className="mt-2 w-full rounded-md border border-black/20 bg-white px-3 py-2 text-black"
type="text" type="text"
name="name" name="name"
autoComplete="name"
required
/> />
</div> </div>
<div> <div>
@ -108,6 +168,8 @@ export const Contact = (): JSX.Element => {
className="mt-2 w-full rounded-md border border-black/20 bg-white px-3 py-2 text-black" className="mt-2 w-full rounded-md border border-black/20 bg-white px-3 py-2 text-black"
type="email" type="email"
name="email" name="email"
autoComplete="email"
required
/> />
</div> </div>
<div> <div>
@ -118,13 +180,26 @@ export const Contact = (): JSX.Element => {
className="mt-2 w-full rounded-md border border-black/20 bg-white px-3 py-2 text-black" className="mt-2 w-full rounded-md border border-black/20 bg-white px-3 py-2 text-black"
name="message" name="message"
rows={4} rows={4}
required
/> />
</div> </div>
<div
className="cf-turnstile"
data-sitekey={turnstileSiteKey}
/>
{formMessage ? (
<p className="text-sm font-semibold text-black">
{formMessage}
</p>
) : null}
<button <button
className="inline-flex items-center rounded-full border border-black/20 px-5 py-2 text-sm font-semibold uppercase tracking-wide text-black" className="inline-flex items-center rounded-full border border-black/20 px-5 py-2 text-sm font-semibold uppercase tracking-wide text-black"
type="submit" type="submit"
disabled={formStatus === "sending"}
> >
Send message {formStatus === "sending"
? "Sending..."
: "Send message"}
</button> </button>
</form> </form>
</div> </div>

116
functions/api/contact.ts Normal file
View file

@ -0,0 +1,116 @@
type Env = {
TURNSTILE_SECRET_KEY?: string;
RESEND_API_KEY?: string;
CONTACT_FROM_EMAIL?: string;
CONTACT_TO_EMAIL?: string;
};
const jsonResponse = (data: Record<string, unknown>, status = 200) =>
new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json" },
});
const readFormField = (value: FormDataEntryValue | null) =>
typeof value === "string" ? value.trim() : "";
export const onRequestPost: PagesFunction<Env> = async ({
request,
env,
}) => {
const formData = await request.formData();
const name = readFormField(formData.get("name"));
const email = readFormField(formData.get("email"));
const message = readFormField(formData.get("message"));
const turnstileToken = readFormField(
formData.get("cf-turnstile-response")
);
if (!name || !email || !message) {
return jsonResponse(
{ ok: false, error: "Missing required fields." },
400
);
}
if (!env.TURNSTILE_SECRET_KEY) {
return jsonResponse(
{ ok: false, error: "Turnstile secret key not configured." },
500
);
}
if (!turnstileToken) {
return jsonResponse(
{ ok: false, error: "Turnstile verification failed." },
400
);
}
const verifyBody = new URLSearchParams({
secret: env.TURNSTILE_SECRET_KEY,
response: turnstileToken,
remoteip: request.headers.get("CF-Connecting-IP") ?? "",
});
const verifyResponse = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
body: verifyBody,
}
);
const verifyResult = (await verifyResponse.json()) as {
success?: boolean;
};
if (!verifyResult.success) {
return jsonResponse(
{ ok: false, error: "Turnstile verification failed." },
400
);
}
if (!env.RESEND_API_KEY) {
return jsonResponse(
{ ok: false, error: "Email provider not configured." },
500
);
}
const fromEmail = env.CONTACT_FROM_EMAIL;
if (!fromEmail) {
return jsonResponse(
{ ok: false, error: "Sender email not configured." },
500
);
}
const toEmail = env.CONTACT_TO_EMAIL ?? "calum@muir.in";
const emailPayload = {
from: fromEmail,
to: [toEmail],
reply_to: email,
subject: "New contact form submission",
text: `Name: ${name}\nEmail: ${email}\n\n${message}`,
};
const emailResponse = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${env.RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(emailPayload),
});
if (!emailResponse.ok) {
return jsonResponse(
{ ok: false, error: "Failed to send email." },
502
);
}
return jsonResponse({ ok: true });
};