Replace the contact form with the GoHighLevel lead form

This commit is contained in:
Alejandro Veras
2026-10-26 09:50:22 -04:00
parent c284207c0f
commit 9a337239b2
7 changed files with 62 additions and 429 deletions
+8
View File
@@ -2458,6 +2458,14 @@ a {
} }
} }
.lead-form iframe {
display: block;
width: 100%;
min-height: 992px;
border: none;
border-radius: 0;
}
.contact-form { .contact-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
-168
View File
@@ -1,168 +0,0 @@
"use client";
import { useActionState, useEffect, useRef, useState } from "react";
import { sendContact, type ContactState } from "@/lib/contact-action";
import type { Locale } from "@/i18n/config";
import type { UI } from "@/i18n/ui";
import { CONTACT_EMAIL, serviceNav } from "@/lib/routes";
import HumanCheck, { type Solution } from "./human-check";
const initial: ContactState = { status: "idle" };
export default function ContactForm({
locale,
t,
services,
}: {
locale: Locale;
t: UI["form"];
services: UI["services"];
}) {
const [state, action, pending] = useActionState(sendContact, initial);
const startedRef = useRef<HTMLInputElement>(null);
const [solution, setSolution] = useState<Solution | null>(null);
const [needsCheck, setNeedsCheck] = useState(false);
const [seenState, setSeenState] = useState(state);
// Interests go to the team in English, whatever language the visitor reads the site in.
const interests = [
...serviceNav.map((s) => ({ value: s.name, label: services[s.slug] })),
{ value: "Not sure yet", label: t.notSure },
];
// A solution is single-use: once the server has checked it, the visitor ticks the box again.
if (seenState !== state) {
setSeenState(state);
if (state.status === "error" && state.error !== "details") setSolution(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());
}, []);
if (state.status === "sent") {
return (
<div className="contact-form form-sent" role="status">
<svg className="sent-mark" viewBox="0 0 48 48" aria-hidden="true">
<circle cx="24" cy="24" r="23" />
<path d="M14 24.5l7 7 13-14" />
</svg>
<h3 className="card-title">{t.sentTitle}</h3>
<p className="copy">{t.sentBody}</p>
</div>
);
}
const values = state.status === "error" ? state.values : undefined;
const error = needsCheck && !solution ? "human" : state.status === "error" ? state.error : null;
return (
<form
// Remount after a failed attempt so the fields are refilled with what was sent.
key={state.status === "error" ? state.attempt : 0}
className="contact-form"
action={action}
onSubmit={(e) => {
if (!solution) {
e.preventDefault();
setNeedsCheck(true);
}
}}
>
<input type="hidden" name="locale" value={locale} />
<div className="field-row">
<label className="field">
<span>{t.name}</span>
<input name="name" autoComplete="name" required maxLength={120} defaultValue={values?.name} />
</label>
<label className="field">
<span>{t.email}</span>
<input
name="email"
type="email"
autoComplete="email"
required
maxLength={200}
defaultValue={values?.email}
dir="ltr"
/>
</label>
</div>
<div className="field-row">
<label className="field">
<span>{t.company}</span>
<input name="company" autoComplete="organization" maxLength={160} defaultValue={values?.company} />
</label>
<label className="field">
<span>{t.website}</span>
<input
name="website"
// Plain text, not type="url": people write "company.com" and shouldn't be made to add https://.
type="text"
inputMode="url"
autoComplete="url"
autoCapitalize="none"
spellCheck={false}
placeholder="company.com"
maxLength={300}
defaultValue={values?.website}
dir="ltr"
/>
</label>
</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" defaultValue={values ? "1" : undefined} />
<fieldset className="field">
<legend>{t.interests}</legend>
<div className="chips">
{interests.map((i) => (
<label key={i.value} className="chip">
<input
type="checkbox"
name="interest"
value={i.value}
defaultChecked={values?.interests.includes(i.value)}
/>
<span>{i.label}</span>
</label>
))}
</div>
</fieldset>
<label className="field">
<span>
{t.message} <em className="field-optional">{t.optional}</em>
</span>
<textarea name="message" rows={5} maxLength={5000} defaultValue={values?.message} />
</label>
<HumanCheck
solution={solution}
onSolved={(s) => {
setSolution(s);
setNeedsCheck(false);
}}
t={t.human}
invalid={error === "human"}
/>
<button type="submit" className="btn btn-solid" disabled={pending}>
{pending ? t.sending : t.submit} <span className="arrow">→</span>
</button>
<p className={`form-note${error ? " is-error" : ""}`} role="status">
{error ? (
<>
{t.errors[error]}
{error !== "human" && error !== "details" && (
<>
{" "}
{t.writeTo} <span dir="ltr">{CONTACT_EMAIL}</span>.
</>
)}
</>
) : (
t.note
)}
</p>
</form>
);
}
+4 -4
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
import GlobeCanvas from "@/components/globe-canvas"; import GlobeCanvas from "@/components/globe-canvas";
import StatCounter from "@/components/stat-counter"; import StatCounter from "@/components/stat-counter";
import RevealObserver from "@/components/reveal-observer"; import RevealObserver from "@/components/reveal-observer";
import ContactForm from "./contact-form"; import LeadForm from "./lead-form";
import CtaBand from "@/components/cta-band"; import CtaBand from "@/components/cta-band";
import { localize } from "@/i18n/config"; import { localize } from "@/i18n/config";
import { getUI } from "@/i18n/server"; import { getUI } from "@/i18n/server";
@@ -367,7 +367,7 @@ function ShowcaseSection({ section }: VariantProps) {
); );
} }
function FormSection({ section, t, locale }: { section: Section; t: UI; locale: Page["locale"] }) { function FormSection({ section }: { section: Section }) {
return ( return (
<div className="form-layout"> <div className="form-layout">
<div className="form-copy"> <div className="form-copy">
@@ -380,7 +380,7 @@ function FormSection({ section, t, locale }: { section: Section; t: UI; locale:
<CtaLinks ctas={section.ctas} /> <CtaLinks ctas={section.ctas} />
</div> </div>
<div data-reveal style={delay(200)}> <div data-reveal style={delay(200)}>
<ContactForm locale={locale} t={t.form} services={t.services} /> <LeadForm />
</div> </div>
</div> </div>
); );
@@ -550,7 +550,7 @@ export default async function ContentPage({ page, trail }: { page: Page; trail:
<div className="wrap"> <div className="wrap">
<SectionHead section={section} index={i + 1} t={t} /> <SectionHead section={section} index={i + 1} t={t} />
{section.form ? ( {section.form ? (
<FormSection section={section} t={t} locale={locale} /> <FormSection section={section} />
) : ( ) : (
<Variant section={section} t={t} /> <Variant section={section} t={t} />
)} )}
-90
View File
@@ -1,90 +0,0 @@
"use client";
import { useState } from "react";
import { getChallenge } from "@/lib/contact-action";
export type Solution = { salt: string; number: number; signature: string };
type Labels = { label: string; checking: string; done: string; retry: string };
const BATCH = 400;
const MIN_SPIN_MS = 700;
const toBytes = (hex: string) => new Uint8Array(hex.match(/../g)!.map((h) => parseInt(h, 16)));
const same = (a: Uint8Array, b: Uint8Array) => a.every((v, i) => v === b[i]);
/** Find the number whose SHA-256 with the salt matches the challenge, hashing in parallel batches. */
async function solve(salt: string, challenge: string, max: number): Promise<number | null> {
const target = toBytes(challenge);
const enc = new TextEncoder();
for (let start = 0; start <= max; start += BATCH) {
const nums = Array.from({ length: Math.min(BATCH, max - start + 1) }, (_, i) => start + i);
const hashes = await Promise.all(nums.map((n) => crypto.subtle.digest("SHA-256", enc.encode(salt + n))));
const hit = hashes.findIndex((h) => same(new Uint8Array(h), target));
if (hit !== -1) return nums[hit];
}
return null;
}
/** "I'm not a robot": a self-hosted proof-of-work, solved in the browser when the box is ticked. */
export default function HumanCheck({
solution,
onSolved,
t,
invalid,
}: {
solution: Solution | null;
onSolved: (s: Solution) => void;
t: Labels;
invalid?: boolean;
}) {
const [status, setStatus] = useState<"idle" | "working" | "failed">("idle");
const state = solution ? "done" : status;
async function run() {
if (state === "working" || state === "done") return;
setStatus("working");
const startedAt = performance.now();
try {
const c = await getChallenge();
const number = await solve(c.salt, c.challenge, c.max);
if (number === null) throw new Error("No solution found");
const wait = MIN_SPIN_MS - (performance.now() - startedAt);
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
setStatus("idle");
onSolved({ salt: c.salt, number, signature: c.signature });
} catch (err) {
console.error("Human check failed", err);
setStatus("failed");
}
}
const text = { idle: t.label, working: t.checking, done: t.done, failed: t.retry }[state];
return (
<div className={`human-check is-${state}${invalid && state !== "done" ? " is-invalid" : ""}`}>
<button
type="button"
role="checkbox"
aria-checked={state === "done"}
aria-busy={state === "working"}
onClick={run}
>
<span className="human-box" aria-hidden="true">
<svg viewBox="0 0 20 20">
<path d="M5 10.5l3.2 3.2L15 6.8" />
</svg>
</span>
<span className="human-label" aria-live="polite">
{text}
</span>
</button>
{solution && (
<>
<input type="hidden" name="captcha_salt" value={solution.salt} />
<input type="hidden" name="captcha_number" value={solution.number} />
<input type="hidden" name="captcha_signature" value={solution.signature} />
</>
)}
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useEffect, useRef } from "react";
import Script from "next/script";
const FORM_ID = "AHTXnVUsjfgNHrw6IXqC";
const FORM_NAME = "AI Growth – Lead Form";
/** The GoHighLevel lead form; leads go straight into the team's CRM. */
export default function LeadForm() {
const frame = useRef<HTMLIFrameElement>(null);
// form_embed.js hides the iframe until the form reports it is ready. If that message never
// arrives, show the form anyway so it is never left invisible.
useEffect(() => {
const timers = [2500, 6000].map((ms) =>
setTimeout(() => {
const f = frame.current;
if (!f || getComputedStyle(f).visibility !== "hidden") return;
Object.assign(f.style, { opacity: "1", visibility: "visible", pointerEvents: "auto", position: "relative", left: "" });
}, ms),
);
return () => timers.forEach(clearTimeout);
}, []);
return (
<div className="lead-form">
<iframe
ref={frame}
src={`https://api.leadconnectorhq.com/widget/form/${FORM_ID}`}
id={`inline-${FORM_ID}`}
title={FORM_NAME}
data-layout="{'id':'INLINE'}"
data-trigger-type="alwaysShow"
data-trigger-value=""
data-activation-type="alwaysActivated"
data-activation-value=""
data-deactivation-type="neverDeactivate"
data-deactivation-value=""
data-form-name={FORM_NAME}
data-height="992"
data-layout-iframe-id={`inline-${FORM_ID}`}
data-form-id={FORM_ID}
data-cookie-consent="true"
data-cookie-consent-provider="auto"
/>
<Script src="https://link.msgsndr.com/js/form_embed.js" strategy="afterInteractive" />
</div>
);
}
-41
View File
@@ -1,41 +0,0 @@
import "server-only";
import { createHash, createHmac, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
// A self-hosted proof-of-work check (the ALTCHA scheme): the browser has to find the number whose
// SHA-256 with a salt matches the challenge. A person never notices the second or so it takes;
// a bot sending thousands of submissions pays for each one. No third-party account involved.
export type Challenge = { salt: string; challenge: string; signature: string; max: number };
const MAX_NUMBER = 120_000;
const TTL_MS = 30 * 60 * 1000;
// CAPTCHA_SECRET keeps challenges valid across restarts; without it, a restart only means a
// visitor with the form already open has to tick the box again.
const SECRET = process.env.CAPTCHA_SECRET || randomBytes(32).toString("hex");
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
const sign = (s: string) => createHmac("sha256", SECRET).update(s).digest("hex");
// Solutions already accepted, until they expire, so one solve can't be replayed.
const used = new Map<string, number>();
export function createChallenge(): Challenge {
const salt = `${randomBytes(12).toString("hex")}.${Date.now() + TTL_MS}`;
const challenge = sha256(salt + randomInt(MAX_NUMBER));
return { salt, challenge, signature: sign(challenge), max: MAX_NUMBER };
}
export function verifySolution(salt: string, number: string, signature: string): boolean {
const expires = Number(salt.split(".")[1]);
if (!expires || expires < Date.now() || !/^\d{1,7}$/.test(number)) return false;
const expected = Buffer.from(sign(sha256(salt + number)), "hex");
const given = Buffer.from(signature, "hex");
if (expected.length !== given.length || !timingSafeEqual(expected, given)) return false;
const now = Date.now();
for (const [key, until] of used) if (until < now) used.delete(key);
if (used.has(signature)) return false;
used.set(signature, expires);
return true;
}
-126
View File
@@ -1,126 +0,0 @@
"use server";
import { createChallenge, verifySolution, type Challenge } from "./captcha";
export type ContactError = "details" | "human" | "unavailable" | "failed";
export type ContactValues = {
name: string;
email: string;
company: string;
website: string;
interests: string[];
message: string;
};
export type ContactState =
| { status: "idle" }
| { status: "sent" }
// Values come back so the form can be refilled after a failed attempt.
| { status: "error"; error: ContactError; values: ContactValues; attempt: number };
// Where enquiries land. Override with CONTACT_TO (comma-separated for several inboxes) if it ever changes.
const TO = (process.env.CONTACT_TO || "rey@aigrowth360.io")
.split(",")
.map((a) => a.trim())
.filter(Boolean);
// Resend's shared sender only delivers to the address that owns the Resend account. To send to TO
// from any other account, set CONTACT_FROM to an address on a domain verified in Resend
// (e.g. "AI Growth Website <hello@aigrowthtechnologies.com>").
const FROM = process.env.CONTACT_FROM || "AI Growth Website <onboarding@resend.dev>";
const MIN_FILL_MS = 3000;
const MAX_LINKS = 3;
const clean = (v: FormDataEntryValue | null, max: number) => String(v ?? "").trim().slice(0, max);
const escape = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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" };
};
/** A fresh proof-of-work challenge for the "I'm not a robot" check. */
export async function getChallenge(): Promise<Challenge> {
return createChallenge();
}
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()}`);
}
export async function sendContact(prev: ContactState, formData: FormData): Promise<ContactState> {
// 1. Honeypot: real visitors never see or fill this field.
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 values: ContactValues = {
name: clean(formData.get("name"), 120),
email: clean(formData.get("email"), 200),
company: clean(formData.get("company"), 160),
website: clean(formData.get("website"), 300),
interests: formData
.getAll("interest")
.map((i) => clean(i, 60))
.filter(Boolean)
.slice(0, 12),
message: clean(formData.get("message"), 5000),
};
const attempt = prev.status === "error" ? prev.attempt + 1 : 1;
const fail = (error: ContactError): ContactState => ({ status: "error", error, values, attempt });
if (!values.name || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) return fail("details");
// 3. Link spam.
if ((values.message.match(/https?:\/\/|www\./gi) ?? []).length > MAX_LINKS) return silentlyDrop("too many links");
// 4. "I'm not a robot": the proof-of-work solved in the browser.
const solved = verifySolution(
clean(formData.get("captcha_salt"), 100),
clean(formData.get("captcha_number"), 10),
clean(formData.get("captcha_signature"), 100),
);
if (!solved) return fail("human");
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
console.error("Contact form: RESEND_API_KEY is not set");
return fail("unavailable");
}
const language = clean(formData.get("locale"), 5);
const rows: [string, string][] = [
["Name", values.name],
["Email", values.email],
["Company", values.company || "—"],
["Website", values.website || "—"],
["Interested in", values.interests.join(", ") || "—"],
["Site language", language || "en"],
];
const message = values.message || "(No message)";
try {
await sendEmail(apiKey, {
to: TO,
reply_to: values.email,
subject: `New enquiry — ${values.company || values.name}`,
text: `${rows.map(([k, v]) => `${k}: ${v}`).join("\n")}\n\n${message}`,
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>`,
});
} catch (err) {
console.error("Contact form: could not email the team", err);
return fail("failed");
}
return { status: "sent" };
}