diff --git a/client/src/pages/Contact.tsx b/client/src/pages/Contact.tsx index b81400c..ea9f83a 100644 --- a/client/src/pages/Contact.tsx +++ b/client/src/pages/Contact.tsx @@ -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) => { + 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 (
@@ -87,8 +144,9 @@ export const Contact = (): JSX.Element => {
@@ -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 />
@@ -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 />
+
+ {formMessage ? ( +

+ {formMessage} +

+ ) : null}
diff --git a/functions/api/contact.ts b/functions/api/contact.ts new file mode 100644 index 0000000..c30b8f9 --- /dev/null +++ b/functions/api/contact.ts @@ -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, 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 = 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 }); +};