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:
+128
-23
@@ -1,20 +1,117 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
export type ContactState = { status: "idle" | "sent" | "error"; message?: string };
|
import { headers } from "next/headers";
|
||||||
|
import { after } from "next/server";
|
||||||
|
|
||||||
|
export type ContactState = { status: "idle" | "sent" | "error"; message?: string; copySent?: boolean };
|
||||||
|
|
||||||
// Where enquiries land. Override with CONTACT_TO in the environment if it ever changes.
|
// Where enquiries land. Override with CONTACT_TO in the environment if it ever changes.
|
||||||
const TO = process.env.CONTACT_TO || "rey@aigrowth360.io";
|
const TO = process.env.CONTACT_TO || "rey@aigrowth360.io";
|
||||||
// Resend's shared sender works without verifying a domain, but only delivers to the address that
|
// Resend's shared sender works without a verified domain, but only delivers to the address that
|
||||||
// owns the Resend account. Set CONTACT_FROM once aigrowth360.io (or another domain) is verified.
|
// owns the Resend account. Set CONTACT_FROM to an address on a domain verified in Resend
|
||||||
|
// (e.g. "AI Growth Technologies <hello@aigrowth360.io>") to also send visitors a confirmation.
|
||||||
const FROM = process.env.CONTACT_FROM || "AI Growth Website <onboarding@resend.dev>";
|
const FROM = process.env.CONTACT_FROM || "AI Growth Website <onboarding@resend.dev>";
|
||||||
|
const CAN_EMAIL_VISITORS = Boolean(process.env.CONTACT_FROM);
|
||||||
|
const SITE = process.env.VERCEL_PROJECT_PRODUCTION_URL
|
||||||
|
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
|
||||||
|
: "https://www.aigrowthtechnologies.com";
|
||||||
|
|
||||||
|
const MIN_FILL_MS = 3000;
|
||||||
|
const MAX_LINKS = 3;
|
||||||
|
|
||||||
const clean = (v: FormDataEntryValue | null, max: number) => String(v ?? "").trim().slice(0, max);
|
const clean = (v: FormDataEntryValue | null, max: number) => String(v ?? "").trim().slice(0, max);
|
||||||
const escape = (s: string) =>
|
const escape = (s: string) =>
|
||||||
s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
||||||
|
|
||||||
|
// Bots get a normal-looking success so they don't learn what tripped them.
|
||||||
|
const silentlyDrop = (reason: string): ContactState => {
|
||||||
|
console.warn("Contact form: dropped submission —", reason);
|
||||||
|
return { status: "sent" };
|
||||||
|
};
|
||||||
|
|
||||||
|
async function passesTurnstile(token: string): Promise<boolean> {
|
||||||
|
const secret = process.env.TURNSTILE_SECRET_KEY;
|
||||||
|
if (!secret) return true; // Turnstile not configured yet
|
||||||
|
if (!token) return false;
|
||||||
|
const ip = (await headers()).get("x-forwarded-for")?.split(",")[0]?.trim();
|
||||||
|
const body = new URLSearchParams({ secret, response: token });
|
||||||
|
if (ip) body.set("remoteip", ip);
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
|
||||||
|
const data = (await res.json()) as { success?: boolean };
|
||||||
|
return data.success === true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Contact form: Turnstile check failed", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendEmail(apiKey: string, payload: Record<string, unknown>) {
|
||||||
|
const res = await fetch("https://api.resend.com/emails", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ from: FROM, ...payload }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Resend ${res.status}: ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmationEmail(name: string, rows: [string, string][], message: string) {
|
||||||
|
const first = name.split(/\s+/)[0];
|
||||||
|
const text = [
|
||||||
|
`Hi ${first},`,
|
||||||
|
"",
|
||||||
|
"Thanks for reaching out to AI Growth Technologies. Your message is with our team, and a strategist will review what you shared and get back to you shortly.",
|
||||||
|
"",
|
||||||
|
"Here's a copy of what you sent:",
|
||||||
|
"",
|
||||||
|
...rows.map(([k, v]) => `${k}: ${v}`),
|
||||||
|
"",
|
||||||
|
message,
|
||||||
|
"",
|
||||||
|
"— The AI Growth Technologies team",
|
||||||
|
SITE,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div style="background:#000;padding:32px 16px;font-family:Arial,Helvetica,sans-serif">
|
||||||
|
<div style="max-width:560px;margin:0 auto;background:#111;border:1px solid #303030;color:#fff">
|
||||||
|
<div style="padding:28px 32px;border-bottom:1px solid #303030">
|
||||||
|
<img src="${SITE}/logo-mark.png" width="44" alt="AI Growth Technologies" style="display:block">
|
||||||
|
</div>
|
||||||
|
<div style="padding:32px">
|
||||||
|
<p style="margin:0 0 6px;font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:#da291c">Message received</p>
|
||||||
|
<h1 style="margin:0 0 20px;font-size:24px;font-weight:400;line-height:1.25">Thanks, ${escape(first)}. We’ve got it.</h1>
|
||||||
|
<p style="margin:0 0 24px;font-size:15px;line-height:1.7;color:#cfcfcf">
|
||||||
|
Your message is with our team. A strategist will review what you shared and get back to you shortly.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 10px;font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:#8f8f8f">What you sent</p>
|
||||||
|
<table cellpadding="0" cellspacing="0" style="width:100%;font-size:14px;border-top:1px solid #303030">
|
||||||
|
${rows
|
||||||
|
.map(
|
||||||
|
([k, v]) =>
|
||||||
|
`<tr><td style="padding:10px 0;border-bottom:1px solid #303030;color:#8f8f8f;width:130px">${k}</td><td style="padding:10px 0;border-bottom:1px solid #303030;color:#fff">${escape(v)}</td></tr>`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</table>
|
||||||
|
<p style="margin:20px 0 0;font-size:14px;line-height:1.7;color:#cfcfcf;white-space:pre-wrap">${escape(message)}</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:20px 32px;border-top:1px solid #303030;font-size:12px;color:#8f8f8f">
|
||||||
|
AI Growth Technologies · Dubai · Houston · Miami · New York<br>
|
||||||
|
<a href="${SITE}" style="color:#fff">${SITE.replace(/^https?:\/\//, "")}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return { subject: "We received your message — AI Growth Technologies", text, html };
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendContact(_prev: ContactState, formData: FormData): Promise<ContactState> {
|
export async function sendContact(_prev: ContactState, formData: FormData): Promise<ContactState> {
|
||||||
// Honeypot: real visitors never see or fill this field.
|
// 1. Honeypot: real visitors never see or fill this field.
|
||||||
if (clean(formData.get("company_url"), 200)) return { status: "sent" };
|
if (clean(formData.get("company_url"), 200)) return silentlyDrop("honeypot");
|
||||||
|
|
||||||
|
// 2. Time trap: people need more than a few seconds to fill the form in.
|
||||||
|
const started = Number(formData.get("started"));
|
||||||
|
if (!started || Date.now() - started < MIN_FILL_MS) return silentlyDrop("submitted too fast");
|
||||||
|
|
||||||
const name = clean(formData.get("name"), 120);
|
const name = clean(formData.get("name"), 120);
|
||||||
const email = clean(formData.get("email"), 200);
|
const email = clean(formData.get("email"), 200);
|
||||||
@@ -31,6 +128,14 @@ export async function sendContact(_prev: ContactState, formData: FormData): Prom
|
|||||||
return { status: "error", message: "Please add your name, a valid email, and a short message." };
|
return { status: "error", message: "Please add your name, a valid email, and a short message." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Link spam.
|
||||||
|
if ((message.match(/https?:\/\/|www\./gi) ?? []).length > MAX_LINKS) return silentlyDrop("too many links");
|
||||||
|
|
||||||
|
// 4. Cloudflare Turnstile, once its keys are configured.
|
||||||
|
if (!(await passesTurnstile(clean(formData.get("cf-turnstile-response"), 2048)))) {
|
||||||
|
return { status: "error", message: "We couldn’t verify you’re human. Please try again." };
|
||||||
|
}
|
||||||
|
|
||||||
const apiKey = process.env.RESEND_API_KEY;
|
const apiKey = process.env.RESEND_API_KEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
console.error("Contact form: RESEND_API_KEY is not set");
|
console.error("Contact form: RESEND_API_KEY is not set");
|
||||||
@@ -44,32 +149,32 @@ export async function sendContact(_prev: ContactState, formData: FormData): Prom
|
|||||||
["Website", website || "—"],
|
["Website", website || "—"],
|
||||||
["Interested in", interests.join(", ") || "—"],
|
["Interested in", interests.join(", ") || "—"],
|
||||||
];
|
];
|
||||||
const text = `${rows.map(([k, v]) => `${k}: ${v}`).join("\n")}\n\n${message}`;
|
|
||||||
const html = `<table cellpadding="6" style="font-family:Arial,sans-serif;font-size:14px">${rows
|
|
||||||
.map(([k, v]) => `<tr><td style="color:#666">${k}</td><td><b>${escape(v)}</b></td></tr>`)
|
|
||||||
.join("")}</table><p style="font-family:Arial,sans-serif;font-size:14px;white-space:pre-wrap">${escape(message)}</p>`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("https://api.resend.com/emails", {
|
await sendEmail(apiKey, {
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
from: FROM,
|
|
||||||
to: [TO],
|
to: [TO],
|
||||||
reply_to: email,
|
reply_to: email,
|
||||||
subject: `New enquiry — ${company || name}`,
|
subject: `New enquiry — ${company || name}`,
|
||||||
text,
|
text: `${rows.map(([k, v]) => `${k}: ${v}`).join("\n")}\n\n${message}`,
|
||||||
html,
|
html: `<table cellpadding="6" style="font-family:Arial,sans-serif;font-size:14px">${rows
|
||||||
}),
|
.map(([k, v]) => `<tr><td style="color:#666">${k}</td><td><b>${escape(v)}</b></td></tr>`)
|
||||||
|
.join("")}</table><p style="font-family:Arial,sans-serif;font-size:14px;white-space:pre-wrap">${escape(message)}</p>`,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
console.error("Contact form: Resend responded", res.status, await res.text());
|
|
||||||
return { status: "error", message: "We couldn’t send your message." };
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Contact form: request failed", err);
|
console.error("Contact form: could not email the team", err);
|
||||||
return { status: "error", message: "We couldn’t send your message." };
|
return { status: "error", message: "We couldn’t send your message." };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status: "sent" };
|
// The visitor's copy goes out after the response, and never fails the submission.
|
||||||
|
if (CAN_EMAIL_VISITORS) {
|
||||||
|
after(async () => {
|
||||||
|
try {
|
||||||
|
await sendEmail(apiKey, { to: [email], reply_to: TO, ...confirmationEmail(name, rows, message) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Contact form: could not send the visitor confirmation", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "sent", copySent: CAN_EMAIL_VISITORS };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,44 @@
|
|||||||
"use client";
|
"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 { sendContact, type ContactState } from "@/app/contact/actions";
|
||||||
import { CONTACT_EMAIL, serviceNav } from "@/lib/routes";
|
import { CONTACT_EMAIL, serviceNav } from "@/lib/routes";
|
||||||
|
|
||||||
const interests = [...serviceNav.map((s) => s.name), "Not sure yet"];
|
const interests = [...serviceNav.map((s) => s.name), "Not sure yet"];
|
||||||
const initial: ContactState = { status: "idle" };
|
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() {
|
export default function ContactForm() {
|
||||||
const [state, action, pending] = useActionState(sendContact, initial);
|
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") {
|
if (state.status === "sent") {
|
||||||
return (
|
return (
|
||||||
<div className="contact-form form-sent" role="status">
|
<div className="contact-form form-sent" role="status">
|
||||||
<span className="card-num">Received</span>
|
<span className="card-num">Received</span>
|
||||||
<h3 className="card-title">Thanks — your message is with the team.</h3>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -44,6 +67,7 @@ export default function ContactForm() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Honeypot for bots — hidden from people and assistive tech. */}
|
{/* Honeypot for bots — hidden from people and assistive tech. */}
|
||||||
<input name="company_url" className="hp" tabIndex={-1} autoComplete="off" aria-hidden="true" />
|
<input name="company_url" className="hp" tabIndex={-1} autoComplete="off" aria-hidden="true" />
|
||||||
|
<input ref={startedRef} type="hidden" name="started" />
|
||||||
<fieldset className="field">
|
<fieldset className="field">
|
||||||
<legend>What are you looking at?</legend>
|
<legend>What are you looking at?</legend>
|
||||||
<div className="chips">
|
<div className="chips">
|
||||||
@@ -59,13 +83,19 @@ export default function ContactForm() {
|
|||||||
<span>What’s happening in the business?</span>
|
<span>What’s happening in the business?</span>
|
||||||
<textarea name="message" rows={5} required maxLength={5000} />
|
<textarea name="message" rows={5} required maxLength={5000} />
|
||||||
</label>
|
</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}>
|
<button type="submit" className="btn btn-solid" disabled={pending}>
|
||||||
{pending ? "Sending…" : "Send to the Team"} <span className="arrow">→</span>
|
{pending ? "Sending…" : "Send to the Team"} <span className="arrow">→</span>
|
||||||
</button>
|
</button>
|
||||||
<p className={`form-note${state.status === "error" ? " is-error" : ""}`} role="status">
|
<p className={`form-note${state.status === "error" ? " is-error" : ""}`} role="status">
|
||||||
{state.status === "error"
|
{state.status === "error"
|
||||||
? `${state.message} You can also write to ${CONTACT_EMAIL}.`
|
? `${state.message} You can also write to ${CONTACT_EMAIL}.`
|
||||||
: "We reply personally — no automated sequences."}
|
: "A strategist reads every message and replies personally."}
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user