Harden the contact form and email visitors a confirmation
Adds a fill-time trap, link-spam filter and optional Cloudflare Turnstile on top of the honeypot; bot hits get a silent success. Once CONTACT_FROM points at a Resend-verified domain, the visitor receives a branded copy of their message after the response. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import Script from "next/script";
|
||||
import { useActionState, useEffect, useRef } from "react";
|
||||
import { sendContact, type ContactState } from "@/app/contact/actions";
|
||||
import { CONTACT_EMAIL, serviceNav } from "@/lib/routes";
|
||||
|
||||
const interests = [...serviceNav.map((s) => s.name), "Not sure yet"];
|
||||
const initial: ContactState = { status: "idle" };
|
||||
// Cloudflare Turnstile renders only once a site key is configured.
|
||||
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: { reset: (el?: Element | string) => void };
|
||||
}
|
||||
}
|
||||
|
||||
export default function ContactForm() {
|
||||
const [state, action, pending] = useActionState(sendContact, initial);
|
||||
const startedRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// When the visitor started filling the form; the server rejects instant (bot) submissions.
|
||||
useEffect(() => {
|
||||
if (startedRef.current && !startedRef.current.value) startedRef.current.value = String(Date.now());
|
||||
}, []);
|
||||
|
||||
// A Turnstile token is single-use, so get a fresh one after a failed attempt.
|
||||
useEffect(() => {
|
||||
if (state.status === "error") window.turnstile?.reset();
|
||||
}, [state]);
|
||||
|
||||
if (state.status === "sent") {
|
||||
return (
|
||||
<div className="contact-form form-sent" role="status">
|
||||
<span className="card-num">Received</span>
|
||||
<h3 className="card-title">Thanks — your message is with the team.</h3>
|
||||
<p className="copy">We’ll review what you shared and get back to you shortly.</p>
|
||||
<p className="copy">
|
||||
We’ll review what you shared and get back to you shortly.
|
||||
{state.copySent && " A confirmation with a copy of your message is on its way to your inbox."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +67,7 @@ export default function ContactForm() {
|
||||
</div>
|
||||
{/* Honeypot for bots — hidden from people and assistive tech. */}
|
||||
<input name="company_url" className="hp" tabIndex={-1} autoComplete="off" aria-hidden="true" />
|
||||
<input ref={startedRef} type="hidden" name="started" />
|
||||
<fieldset className="field">
|
||||
<legend>What are you looking at?</legend>
|
||||
<div className="chips">
|
||||
@@ -59,13 +83,19 @@ export default function ContactForm() {
|
||||
<span>What’s happening in the business?</span>
|
||||
<textarea name="message" rows={5} required maxLength={5000} />
|
||||
</label>
|
||||
{TURNSTILE_SITE_KEY && (
|
||||
<>
|
||||
<Script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer />
|
||||
<div className="cf-turnstile" data-sitekey={TURNSTILE_SITE_KEY} data-theme="dark" data-size="flexible" />
|
||||
</>
|
||||
)}
|
||||
<button type="submit" className="btn btn-solid" disabled={pending}>
|
||||
{pending ? "Sending…" : "Send to the Team"} <span className="arrow">→</span>
|
||||
</button>
|
||||
<p className={`form-note${state.status === "error" ? " is-error" : ""}`} role="status">
|
||||
{state.status === "error"
|
||||
? `${state.message} You can also write to ${CONTACT_EMAIL}.`
|
||||
: "We reply personally — no automated sequences."}
|
||||
: "A strategist reads every message and replies personally."}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user