Compare commits

...

5 Commits

25 changed files with 231 additions and 490 deletions
+2
View File
@@ -3,6 +3,8 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
cacheComponents: true, 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, partialPrefetching: true,
turbopack: { turbopack: {
rules: { rules: {
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 87 KiB

+83
View File
@@ -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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

+15 -26
View File
@@ -348,13 +348,12 @@ a {
.logo { .logo {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 14px;
} }
.logo-mark { .logo img {
display: block;
width: auto; width: auto;
height: 40px; height: 44px;
flex: none;
} }
.logo-stacked { .logo-stacked {
@@ -367,27 +366,6 @@ a {
height: auto; height: auto;
} }
.logo-type {
display: flex;
flex-direction: column;
line-height: 1;
}
.logo-type b {
white-space: nowrap;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.2em;
}
.logo-type span {
margin-top: 5px;
font-size: 8.5px;
font-weight: 500;
letter-spacing: 0.42em;
color: var(--fumo);
}
.nav { .nav {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2480,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;
@@ -2770,12 +2756,15 @@ a {
} }
} }
/* The stacked logo's wordmark is white; the light theme swaps in the dark-lettered file. */ /* The logos' wordmarks are white; the light theme swaps in the dark-lettered files. */
.logo .on-light,
.logo-stacked .on-light, .logo-stacked .on-light,
:root[data-theme="light"] .logo .on-dark,
:root[data-theme="light"] .logo-stacked .on-dark { :root[data-theme="light"] .logo-stacked .on-dark {
display: none; display: none;
} }
:root[data-theme="light"] .logo .on-light,
:root[data-theme="light"] .logo-stacked .on-light { :root[data-theme="light"] .logo-stacked .on-light {
display: block; display: block;
} }
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 76 KiB

+3
View File
@@ -0,0 +1,3 @@
import { sitemap } from "@/lib/sitemap";
export const GET = () => sitemap("industry");
+3
View File
@@ -0,0 +1,3 @@
import { sitemap } from "@/lib/sitemap";
export const GET = () => sitemap("page");
+3
View File
@@ -0,0 +1,3 @@
import { sitemap } from "@/lib/sitemap";
export const GET = () => sitemap("service");
-27
View File
@@ -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))])) },
})),
);
}
+3
View File
@@ -0,0 +1,3 @@
import { sitemapIndex } from "@/lib/sitemap";
export const GET = () => sitemapIndex();
-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>
);
}
+4 -7
View File
@@ -4,11 +4,8 @@ import Image from "next/image";
export default function Logo({ href, label }: { href: string; label: string }) { export default function Logo({ href, label }: { href: string; label: string }) {
return ( return (
<Link href={href} className="logo" aria-label={label}> <Link href={href} className="logo" aria-label={label}>
<Image className="logo-mark" src="/logo-mark.png" alt="" width={229} height={192} priority /> <Image className="on-dark" src="/logo-horizontal.png" alt="AI Growth Technologies" width={640} height={191} priority />
<span className="logo-type"> <Image className="on-light" src="/logo-horizontal-dark.png" alt="AI Growth Technologies" width={640} height={191} priority />
<b>AI GROWTH</b>
<span>TECHNOLOGIES</span>
</span>
</Link> </Link>
); );
} }
@@ -16,8 +13,8 @@ export default function Logo({ href, label }: { href: string; label: string }) {
export function LogoStacked({ href, label }: { href: string; label: string }) { export function LogoStacked({ href, label }: { href: string; label: string }) {
return ( return (
<Link href={href} className="logo-stacked" aria-label={label}> <Link href={href} className="logo-stacked" aria-label={label}>
<Image className="on-dark" src="/logo-stacked.png" alt="AI Growth Technologies" width={480} height={547} /> <Image className="on-dark" src="/logo-stacked.png" alt="AI Growth Technologies" width={480} height={542} />
<Image className="on-light" src="/logo-stacked-dark.png" alt="AI Growth Technologies" width={480} height={547} /> <Image className="on-light" src="/logo-stacked-dark.png" alt="AI Growth Technologies" width={480} height={542} />
</Link> </Link>
); );
} }
-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" };
}
+51
View File
@@ -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>
`);
}
+10 -1
View File
@@ -4,9 +4,18 @@ import { defaultLocale, isLocale } from "@/i18n/config";
// Every page lives under app/[lang]. English keeps its unprefixed URLs, so those requests are // Every page lives under app/[lang]. English keeps its unprefixed URLs, so those requests are
// rewritten to /en internally, and /en/... is redirected back to the unprefixed address. // rewritten to /en internally, and /en/... is redirected back to the unprefixed address.
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname, searchParams } = request.nextUrl;
const first = pathname.split("/")[1]; const first = pathname.split("/")[1];
// Spam sites link here with made-up paths in the query string (/?shop/items/L738.../).
// Answer 410 so search engines drop them instead of indexing copies of the page.
if ([...searchParams.keys()].some((key) => key.includes("/"))) {
return new NextResponse("Gone", {
status: 410,
headers: { "content-type": "text/plain; charset=utf-8", "x-robots-tag": "noindex" },
});
}
if (first === defaultLocale) { if (first === defaultLocale) {
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
url.pathname = pathname.slice(defaultLocale.length + 1) || "/"; url.pathname = pathname.slice(defaultLocale.length + 1) || "/";