Send contact form submissions by email through Resend
Server Action validates the fields, drops honeypot hits and emails the enquiry to rey@aigrowth360.io with the visitor as reply-to. Needs RESEND_API_KEY in the environment.
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
export type ContactState = { status: "idle" | "sent" | "error"; message?: string };
|
||||||
|
|
||||||
|
// Where enquiries land. Override with CONTACT_TO in the environment if it ever changes.
|
||||||
|
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
|
||||||
|
// owns the Resend account. Set CONTACT_FROM once aigrowth360.io (or another domain) is verified.
|
||||||
|
const FROM = process.env.CONTACT_FROM || "AI Growth Website <onboarding@resend.dev>";
|
||||||
|
|
||||||
|
const clean = (v: FormDataEntryValue | null, max: number) => String(v ?? "").trim().slice(0, max);
|
||||||
|
const escape = (s: string) =>
|
||||||
|
s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
||||||
|
|
||||||
|
export async function sendContact(_prev: ContactState, formData: FormData): Promise<ContactState> {
|
||||||
|
// Honeypot: real visitors never see or fill this field.
|
||||||
|
if (clean(formData.get("company_url"), 200)) return { status: "sent" };
|
||||||
|
|
||||||
|
const name = clean(formData.get("name"), 120);
|
||||||
|
const email = clean(formData.get("email"), 200);
|
||||||
|
const company = clean(formData.get("company"), 160);
|
||||||
|
const website = clean(formData.get("website"), 300);
|
||||||
|
const interests = formData
|
||||||
|
.getAll("interest")
|
||||||
|
.map((i) => clean(i, 60))
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 12);
|
||||||
|
const message = clean(formData.get("message"), 5000);
|
||||||
|
|
||||||
|
if (!name || !message || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return { status: "error", message: "Please add your name, a valid email, and a short message." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.RESEND_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
console.error("Contact form: RESEND_API_KEY is not set");
|
||||||
|
return { status: "error", message: "The form isn’t available right now." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: [string, string][] = [
|
||||||
|
["Name", name],
|
||||||
|
["Email", email],
|
||||||
|
["Company", company || "—"],
|
||||||
|
["Website", website || "—"],
|
||||||
|
["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 {
|
||||||
|
const res = await fetch("https://api.resend.com/emails", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
from: FROM,
|
||||||
|
to: [TO],
|
||||||
|
reply_to: email,
|
||||||
|
subject: `New enquiry — ${company || name}`,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
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) {
|
||||||
|
console.error("Contact form: request failed", err);
|
||||||
|
return { status: "error", message: "We couldn’t send your message." };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "sent" };
|
||||||
|
}
|
||||||
@@ -2570,3 +2570,31 @@ a {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--piombo);
|
color: var(--piombo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hp {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form .btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-note.is-error {
|
||||||
|
color: var(--rosso);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-sent {
|
||||||
|
min-height: 320px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-sent .card-title {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: clamp(22px, 2.2vw, 30px);
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,55 +1,49 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useActionState } from "react";
|
||||||
|
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" };
|
||||||
|
|
||||||
// No form backend is wired up yet, so submitting composes an email to the team
|
|
||||||
// in the visitor's mail client. Swap `onSubmit` for a real endpoint when one exists.
|
|
||||||
export default function ContactForm() {
|
export default function ContactForm() {
|
||||||
const [sent, setSent] = useState(false);
|
const [state, action, pending] = useActionState(sendContact, initial);
|
||||||
|
|
||||||
function onSubmit(e: FormEvent<HTMLFormElement>) {
|
if (state.status === "sent") {
|
||||||
e.preventDefault();
|
return (
|
||||||
const data = new FormData(e.currentTarget);
|
<div className="contact-form form-sent" role="status">
|
||||||
const get = (k: string) => String(data.get(k) ?? "").trim();
|
<span className="card-num">Received</span>
|
||||||
const subject = `Growth conversation — ${get("company") || get("name")}`;
|
<h3 className="card-title">Thanks — your message is with the team.</h3>
|
||||||
const body = [
|
<p className="copy">We’ll review what you shared and get back to you shortly.</p>
|
||||||
`Name: ${get("name")}`,
|
</div>
|
||||||
`Email: ${get("email")}`,
|
);
|
||||||
`Company: ${get("company")}`,
|
|
||||||
`Website: ${get("website")}`,
|
|
||||||
`Interested in: ${data.getAll("interest").join(", ") || "—"}`,
|
|
||||||
"",
|
|
||||||
get("message"),
|
|
||||||
].join("\n");
|
|
||||||
window.location.href = `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
|
||||||
setSent(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="contact-form" onSubmit={onSubmit}>
|
<form className="contact-form" action={action}>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Name</span>
|
<span>Name</span>
|
||||||
<input name="name" autoComplete="name" required />
|
<input name="name" autoComplete="name" required maxLength={120} />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Work email</span>
|
<span>Work email</span>
|
||||||
<input name="email" type="email" autoComplete="email" required />
|
<input name="email" type="email" autoComplete="email" required maxLength={200} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Company</span>
|
<span>Company</span>
|
||||||
<input name="company" autoComplete="organization" />
|
<input name="company" autoComplete="organization" maxLength={160} />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Website</span>
|
<span>Website</span>
|
||||||
<input name="website" type="url" inputMode="url" placeholder="https://" />
|
<input name="website" type="url" inputMode="url" placeholder="https://" maxLength={300} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Honeypot for bots — hidden from people and assistive tech. */}
|
||||||
|
<input name="company_url" className="hp" tabIndex={-1} autoComplete="off" aria-hidden="true" />
|
||||||
<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">
|
||||||
@@ -63,15 +57,15 @@ export default function ContactForm() {
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>What’s happening in the business?</span>
|
<span>What’s happening in the business?</span>
|
||||||
<textarea name="message" rows={5} required />
|
<textarea name="message" rows={5} required maxLength={5000} />
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" className="btn btn-solid">
|
<button type="submit" className="btn btn-solid" disabled={pending}>
|
||||||
Send to the Team <span className="arrow">→</span>
|
{pending ? "Sending…" : "Send to the Team"} <span className="arrow">→</span>
|
||||||
</button>
|
</button>
|
||||||
<p className="form-note" role="status">
|
<p className={`form-note${state.status === "error" ? " is-error" : ""}`} role="status">
|
||||||
{sent
|
{state.status === "error"
|
||||||
? `Your email app should have opened with the details. If it didn’t, write to ${CONTACT_EMAIL}.`
|
? `${state.message} You can also write to ${CONTACT_EMAIL}.`
|
||||||
: `Opens your email app addressed to ${CONTACT_EMAIL}.`}
|
: "We reply personally — no automated sequences."}
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user