Add contact form handling with Turnstile
This commit is contained in:
parent
2fcb183743
commit
5d064d63a9
2 changed files with 194 additions and 3 deletions
|
|
@ -1,8 +1,14 @@
|
|||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { usePageMeta } from "@/lib/seo";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: { reset: () => void };
|
||||
}
|
||||
}
|
||||
|
||||
const navigationItems = [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "About", href: "/about" },
|
||||
|
|
@ -12,12 +18,63 @@ const navigationItems = [
|
|||
];
|
||||
|
||||
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({
|
||||
title: "Contact",
|
||||
path: "/contact",
|
||||
description:
|
||||
"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 (
|
||||
<div className="flex flex-col min-h-screen items-center bg-[#e2e2e2]">
|
||||
<main className="w-full">
|
||||
|
|
@ -87,8 +144,9 @@ export const Contact = (): JSX.Element => {
|
|||
</h2>
|
||||
<form
|
||||
className="mt-4 space-y-4"
|
||||
action="#"
|
||||
action="/api/contact"
|
||||
method="post"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div>
|
||||
<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"
|
||||
type="text"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
required
|
||||
/>
|
||||
</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"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</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"
|
||||
name="message"
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="cf-turnstile"
|
||||
data-sitekey={turnstileSiteKey}
|
||||
/>
|
||||
{formMessage ? (
|
||||
<p className="text-sm font-semibold text-black">
|
||||
{formMessage}
|
||||
</p>
|
||||
) : null}
|
||||
<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"
|
||||
type="submit"
|
||||
disabled={formStatus === "sending"}
|
||||
>
|
||||
Send message
|
||||
{formStatus === "sending"
|
||||
? "Sending..."
|
||||
: "Send message"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
116
functions/api/contact.ts
Normal file
116
functions/api/contact.ts
Normal 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 });
|
||||
};
|
||||
Loading…
Reference in a new issue