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 { 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
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