Compare commits
3 Commits
c284207c0f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d137fea3e | |||
| ac82825cb5 | |||
| 9a337239b2 |
@@ -3,6 +3,8 @@ import type { NextConfig } from "next";
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
cacheComponents: true,
|
||||
// The sitemap's last-modified date: the day this deploy was built.
|
||||
env: { BUILD_DATE: new Date().toISOString().slice(0, 10) },
|
||||
partialPrefetching: true,
|
||||
turbopack: {
|
||||
rules: {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Shows the sitemap index and each sitemap as a plain table in the browser. Search engines read the XML and ignore this file. -->
|
||||
<xsl:stylesheet version="1.0"
|
||||
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
|
||||
xmlns:sm="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xhtml="http://www.w3.org/1999/xhtml"
|
||||
exclude-result-prefixes="sm xhtml">
|
||||
<xsl:output method="html" encoding="UTF-8" indent="yes"/>
|
||||
|
||||
<xsl:template match="/">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<meta name="robots" content="noindex, follow"/>
|
||||
<title>XML Sitemap</title>
|
||||
<style>
|
||||
body { margin:0; background:#fff; color:#444; font:13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
|
||||
#content { max-width:1000px; margin:0 auto; padding:24px 16px 48px; }
|
||||
h1 { margin:0 0 8px; font-size:24px; font-weight:600; color:#222; }
|
||||
p.desc { margin:0 0 4px; color:#555; }
|
||||
p.desc a { color:#0073aa; }
|
||||
p.count { margin:16px 0 12px; color:#555; }
|
||||
table { width:100%; border-collapse:collapse; border:1px solid #e5e5e5; }
|
||||
th { padding:8px 10px; background:#0073aa; color:#fff; text-align:left; font-weight:600; }
|
||||
td { padding:7px 10px; border-bottom:1px solid #eee; vertical-align:top; }
|
||||
tr:nth-child(even) td { background:#f6f8fa; }
|
||||
td a { color:#0073aa; text-decoration:none; word-break:break-all; }
|
||||
td a:hover { text-decoration:underline; }
|
||||
td.n, th.n { width:40px; color:#888; }
|
||||
td.date { width:160px; white-space:nowrap; }
|
||||
td.langs a { margin-right:6px; text-transform:uppercase; font-size:11px; font-weight:600; }
|
||||
@media (max-width:640px) { .hide-sm { display:none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="content">
|
||||
<h1>XML Sitemap</h1>
|
||||
<p class="desc">This is an XML Sitemap, meant for consumption by search engines.</p>
|
||||
<p class="desc">You can find more information about XML sitemaps on <a href="https://www.sitemaps.org/" rel="noopener">sitemaps.org</a>.</p>
|
||||
<xsl:apply-templates select="sm:sitemapindex | sm:urlset"/>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</xsl:template>
|
||||
|
||||
<xsl:template match="sm:sitemapindex">
|
||||
<p class="count">This XML Sitemap Index file contains <strong><xsl:value-of select="count(sm:sitemap)"/></strong> sitemaps.</p>
|
||||
<table>
|
||||
<thead><tr><th class="n">#</th><th>Sitemap</th><th>Last Modified</th></tr></thead>
|
||||
<tbody>
|
||||
<xsl:for-each select="sm:sitemap">
|
||||
<tr>
|
||||
<td class="n"><xsl:value-of select="position()"/></td>
|
||||
<td><a href="{sm:loc}"><xsl:value-of select="sm:loc"/></a></td>
|
||||
<td class="date"><xsl:value-of select="sm:lastmod"/></td>
|
||||
</tr>
|
||||
</xsl:for-each>
|
||||
</tbody>
|
||||
</table>
|
||||
</xsl:template>
|
||||
|
||||
<xsl:template match="sm:urlset">
|
||||
<p class="count">This XML Sitemap contains <strong><xsl:value-of select="count(sm:url)"/></strong> URLs. <a href="/sitemap.xml">← Sitemap Index</a></p>
|
||||
<table>
|
||||
<thead><tr><th class="n">#</th><th>URL</th><th class="hide-sm">Languages</th><th>Last Mod.</th></tr></thead>
|
||||
<tbody>
|
||||
<xsl:for-each select="sm:url">
|
||||
<tr>
|
||||
<td class="n"><xsl:value-of select="position()"/></td>
|
||||
<td><a href="{sm:loc}"><xsl:value-of select="sm:loc"/></a></td>
|
||||
<td class="langs hide-sm">
|
||||
<xsl:for-each select="xhtml:link[@hreflang != 'x-default'][@href != current()/sm:loc]">
|
||||
<a href="{@href}"><xsl:value-of select="@hreflang"/></a>
|
||||
</xsl:for-each>
|
||||
</td>
|
||||
<td class="date"><xsl:value-of select="sm:lastmod"/></td>
|
||||
</tr>
|
||||
</xsl:for-each>
|
||||
</tbody>
|
||||
</table>
|
||||
</xsl:template>
|
||||
</xsl:stylesheet>
|
||||
@@ -2458,6 +2458,14 @@ a {
|
||||
}
|
||||
}
|
||||
|
||||
.lead-form iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 992px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.contact-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { sitemap } from "@/lib/sitemap";
|
||||
|
||||
export const GET = () => sitemap("industry");
|
||||
@@ -0,0 +1,3 @@
|
||||
import { sitemap } from "@/lib/sitemap";
|
||||
|
||||
export const GET = () => sitemap("page");
|
||||
@@ -0,0 +1,3 @@
|
||||
import { sitemap } from "@/lib/sitemap";
|
||||
|
||||
export const GET = () => sitemap("service");
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { locales, localize } from "@/i18n/config";
|
||||
import { SITE_URL, industryNav, serviceNav } from "@/lib/routes";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const paths = [
|
||||
"/",
|
||||
"/about",
|
||||
"/contact",
|
||||
"/services",
|
||||
...serviceNav.map((s) => `/services/${s.slug}`),
|
||||
"/industries",
|
||||
...industryNav.map((s) => `/industries/${s.slug}`),
|
||||
];
|
||||
const url = (p: string) => SITE_URL + (p === "/" ? "" : p);
|
||||
const lastModified = new Date();
|
||||
|
||||
return locales.flatMap((locale) =>
|
||||
paths.map((p) => ({
|
||||
url: url(localize(p, locale)),
|
||||
lastModified,
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: p === "/" ? 1 : p.split("/").length === 2 ? 0.8 : 0.6,
|
||||
alternates: { languages: Object.fromEntries(locales.map((l) => [l, url(localize(p, l))])) },
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { sitemapIndex } from "@/lib/sitemap";
|
||||
|
||||
export const GET = () => sitemapIndex();
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import GlobeCanvas from "@/components/globe-canvas";
|
||||
import StatCounter from "@/components/stat-counter";
|
||||
import RevealObserver from "@/components/reveal-observer";
|
||||
import ContactForm from "./contact-form";
|
||||
import LeadForm from "./lead-form";
|
||||
import CtaBand from "@/components/cta-band";
|
||||
import { localize } from "@/i18n/config";
|
||||
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 (
|
||||
<div className="form-layout">
|
||||
<div className="form-copy">
|
||||
@@ -380,7 +380,7 @@ function FormSection({ section, t, locale }: { section: Section; t: UI; locale:
|
||||
<CtaLinks ctas={section.ctas} />
|
||||
</div>
|
||||
<div data-reveal style={delay(200)}>
|
||||
<ContactForm locale={locale} t={t.form} services={t.services} />
|
||||
<LeadForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -550,7 +550,7 @@ export default async function ContentPage({ page, trail }: { page: Page; trail:
|
||||
<div className="wrap">
|
||||
<SectionHead section={section} index={i + 1} t={t} />
|
||||
{section.form ? (
|
||||
<FormSection section={section} t={t} locale={locale} />
|
||||
<FormSection section={section} />
|
||||
) : (
|
||||
<Variant section={section} t={t} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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" };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { locales, localize } from "@/i18n/config";
|
||||
import { SITE_URL, industryNav, serviceNav } from "./routes";
|
||||
|
||||
// Yoast-style sitemaps: /sitemap.xml is an index of one sitemap per kind of page. Each lists the
|
||||
// English pages in site order, then their Spanish and Arabic copies, with every page's translations.
|
||||
// public/sitemap-style.xml (an XSL stylesheet) shows them as tables in a browser; search engines ignore it.
|
||||
|
||||
export const sitemaps = {
|
||||
page: ["/", "/about", "/contact"],
|
||||
service: ["/services", ...serviceNav.map((s) => `/services/${s.slug}`)],
|
||||
industry: ["/industries", ...industryNav.map((s) => `/industries/${s.slug}`)],
|
||||
} as const;
|
||||
|
||||
// The day this deploy was built (set in next.config.ts), so dates only move when the site changes.
|
||||
const lastmod = process.env.BUILD_DATE;
|
||||
const url = (p: string) => SITE_URL + (p === "/" ? "" : p);
|
||||
const head = `<?xml version="1.0" encoding="UTF-8"?>\n<?xml-stylesheet type="text/xsl" href="/sitemap-style.xml"?>\n`;
|
||||
const xml = (body: string) =>
|
||||
new Response(head + body, { headers: { "content-type": "application/xml; charset=utf-8" } });
|
||||
|
||||
export function sitemapIndex() {
|
||||
const entries = Object.keys(sitemaps).map(
|
||||
(name) => ` <sitemap>
|
||||
<loc>${SITE_URL}/${name}-sitemap.xml</loc>
|
||||
<lastmod>${lastmod}</lastmod>
|
||||
</sitemap>`,
|
||||
);
|
||||
return xml(`<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${entries.join("\n")}
|
||||
</sitemapindex>
|
||||
`);
|
||||
}
|
||||
|
||||
export function sitemap(name: keyof typeof sitemaps) {
|
||||
const alternates = (p: string) =>
|
||||
[...locales.map((l) => [l, localize(p, l)]), ["x-default", p]]
|
||||
.map(([l, href]) => `\n <xhtml:link rel="alternate" hreflang="${l}" href="${url(href)}"/>`)
|
||||
.join("");
|
||||
const entries = locales.flatMap((locale) =>
|
||||
sitemaps[name].map(
|
||||
(p) => ` <url>
|
||||
<loc>${url(localize(p, locale))}</loc>
|
||||
<lastmod>${lastmod}</lastmod>${alternates(p)}
|
||||
</url>`,
|
||||
),
|
||||
);
|
||||
return xml(`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||
${entries.join("\n")}
|
||||
</urlset>
|
||||
`);
|
||||
}
|
||||
Reference in New Issue
Block a user