Serve the site in English, Spanish and Arabic, add a captcha, make the message optional

- Move every route under app/[lang]; a proxy keeps English on unprefixed URLs and serves /es and /ar
- Interface strings and homepage copy per locale; content pages load a translated JSON when one exists
- Language switch in the header, hreflang alternates and a multilingual sitemap
- Right-to-left layout for Arabic with logical CSS properties, Noto Sans Arabic and no letter-spacing
- Self-hosted proof-of-work "I'm not a robot" check (no third-party account) replaces Turnstile
- The message field is optional; failed attempts refill the form instead of clearing it
This commit is contained in:
Alejandro Veras
2026-10-13 10:49:14 -04:00
parent 2f9b63ccac
commit 5bf472b735
65 changed files with 2386 additions and 756 deletions
+2 -2
View File
@@ -7,7 +7,7 @@ Usage:
Paragraphs that start with "> " were bold sentences in the deck and render as
emphasised lines. Each document follows the same conventions: bold all-caps lines are eyebrows
(when followed by a heading) or CTAs; Heading 1/2/3 give the structure.
The output lands in src/content/pages/<slug>.json. The homepage is hand-built
The output lands in src/content/pages/en/<slug>.json. The homepage is hand-built
and is skipped.
"""
@@ -18,7 +18,7 @@ import re
import sys
import zipfile
OUT = pathlib.Path(__file__).resolve().parent.parent / "src" / "content" / "pages"
OUT = pathlib.Path(__file__).resolve().parent.parent / "src" / "content" / "pages" / "en"
# Lines in the decks that are notes to the team, not site copy.
DROP = [
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Translation helpers for the generated content pages.
python3 -E scripts/i18n.py extract <out_dir>
Write one flat {path: text} JSON per English page with only the strings to translate.
python3 -E scripts/i18n.py apply <locale> <dir>
Read translated flat files from <dir> and write src/content/pages/<locale>/<slug>.json,
keeping the English structure. Fails if a translation is missing or extra.
python3 -E scripts/i18n.py index
Regenerate src/content/pages/index.ts. A locale without a page falls back to English.
"""
from __future__ import annotations
import copy
import json
import pathlib
import re
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
PAGES = ROOT / "src" / "content" / "pages"
LOCALES = ["en", "es", "ar"]
SKIP_KEYS = {"slug", "path", "form", "level", "kind", "num"}
def keep_english(value: str, section: dict | None, key: str) -> bool:
"""Office addresses and stat figures stay as they are in every language."""
if "Phone:" in value:
return True
if section and section.get("kind") == "stats" and key == "title" and re.match(r"^[\d.,]+", value):
return True
return False
def walk(node, path=(), section=None):
if isinstance(node, dict):
if "kind" in node:
section = node
for k, v in node.items():
if k in SKIP_KEYS:
continue
yield from walk(v, path + (k,), section)
elif isinstance(node, list):
for i, v in enumerate(node):
yield from walk(v, path + (str(i),), section)
elif isinstance(node, str) and node.strip():
if not keep_english(node, section, path[-1]):
yield ".".join(path), node
def set_path(node, path: str, value: str):
parts = path.split(".")
for p in parts[:-1]:
node = node[int(p)] if isinstance(node, list) else node[p]
last = parts[-1]
if isinstance(node, list):
node[int(last)] = value
else:
node[last] = value
def english_pages():
return sorted((PAGES / "en").glob("*.json"))
def extract(out_dir: str):
out = pathlib.Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
for f in english_pages():
flat = dict(walk(json.loads(f.read_text())))
(out / f.name).write_text(json.dumps(flat, ensure_ascii=False, indent=1) + "\n")
print(f"{f.name}: {len(flat)} strings")
def apply(locale: str, src_dir: str):
src = pathlib.Path(src_dir)
dest = PAGES / locale
dest.mkdir(parents=True, exist_ok=True)
problems = 0
for f in english_pages():
tf = src / f.name
if not tf.exists():
print(f"skip {f.name}: no translation yet")
continue
page = json.loads(f.read_text())
wanted = dict(walk(page))
got = json.loads(tf.read_text())
missing = [k for k in wanted if k not in got]
extra = [k for k in got if k not in wanted]
if missing or extra:
problems += 1
print(f"{f.name}: missing {missing[:5]} extra {extra[:5]}")
continue
out = copy.deepcopy(page)
for k, v in got.items():
set_path(out, k, v)
(dest / f.name).write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n")
print(f"{locale}/{f.name}: ok")
if problems:
sys.exit(1)
def ident(locale: str, name: str) -> str:
return locale + "_" + re.sub(r"\W", "_", name)
def index():
names = [f.stem for f in english_pages()]
lines = ["// Generated by scripts/i18n.py index — do not edit by hand.", ""]
table = []
for locale in LOCALES:
entries = []
for n in names:
if (PAGES / locale / f"{n}.json").exists():
lines.append(f'import {ident(locale, n)} from "./{locale}/{n}.json";')
entries.append(f' "{n}": {ident(locale, n)},')
else:
entries.append(f' "{n}": {ident("en", n)},')
table.append(f" {locale}: {{\n" + "\n".join(entries) + "\n },")
lines += ["", "export const content = {", *table, "};", ""]
(PAGES / "index.ts").write_text("\n".join(lines))
print(f"index.ts: {len(names)} pages × {len(LOCALES)} locales")
if __name__ == "__main__":
cmd, *args = sys.argv[1:]
{"extract": extract, "apply": apply, "index": index}[cmd](*args)
+14
View File
@@ -0,0 +1,14 @@
import ContentPage from "@/components/content/content-page";
import { getUI } from "@/i18n/server";
import { getStaticPage } from "@/lib/pages";
import { pageMetadata } from "@/lib/metadata";
export async function generateMetadata() {
const { locale } = await getUI();
return pageMetadata(getStaticPage(locale, "about"));
}
export default async function AboutPage() {
const { locale, t } = await getUI();
return <ContentPage page={getStaticPage(locale, "about")} trail={[{ name: t.nav.about, path: "/about" }]} />;
}
+14
View File
@@ -0,0 +1,14 @@
import ContentPage from "@/components/content/content-page";
import { getUI } from "@/i18n/server";
import { getStaticPage } from "@/lib/pages";
import { pageMetadata } from "@/lib/metadata";
export async function generateMetadata() {
const { locale } = await getUI();
return pageMetadata(getStaticPage(locale, "contact"));
}
export default async function ContactPage() {
const { locale, t } = await getUI();
return <ContentPage page={getStaticPage(locale, "contact")} trail={[{ name: t.nav.contact, path: "/contact" }]} />;
}
@@ -1,23 +1,24 @@
import { Suspense } from "react";
import { notFound } from "next/navigation";
import ContentPage from "@/components/content/content-page";
import { getUI } from "@/i18n/server";
import { getIndustry } from "@/lib/pages";
import { pageMetadata } from "@/lib/metadata";
import { industryNav } from "@/lib/routes";
type Params = PageProps<"/industries/[slug]">["params"];
type Params = PageProps<"/[lang]/industries/[slug]">["params"];
export function generateStaticParams() {
return industryNav.map((s) => ({ slug: s.slug }));
}
export async function generateMetadata({ params }: PageProps<"/industries/[slug]">) {
const { slug } = await params;
const page = getIndustry(slug);
export async function generateMetadata({ params }: PageProps<"/[lang]/industries/[slug]">) {
const [{ slug }, { locale }] = await Promise.all([params, getUI()]);
const page = getIndustry(locale, slug);
return page ? pageMetadata(page) : {};
}
export default function Page({ params }: PageProps<"/industries/[slug]">) {
export default function Page({ params }: PageProps<"/[lang]/industries/[slug]">) {
return (
<Suspense fallback={<section className="hero page-hero" aria-busy="true" />}>
<Content params={params} />
@@ -26,18 +27,16 @@ export default function Page({ params }: PageProps<"/industries/[slug]">) {
}
async function Content({ params }: { params: Params }) {
const { slug } = await params;
const page = getIndustry(slug);
const [{ slug }, { locale, t }] = await Promise.all([params, getUI()]);
const page = getIndustry(locale, slug);
if (!page) notFound();
const name = industryNav.find((s) => s.slug === slug)?.name ?? slug;
return (
<ContentPage
page={page}
crumbs={[
{ name: "Home", href: "/" },
{ name: "Industries", href: "/industries" },
{ name, href: `/industries/${slug}` },
trail={[
{ name: t.nav.industries, path: "/industries" },
{ name: t.industries[slug as keyof typeof t.industries], path: `/industries/${slug}` },
]}
/>
);
+14
View File
@@ -0,0 +1,14 @@
import ContentPage from "@/components/content/content-page";
import { getUI } from "@/i18n/server";
import { getStaticPage } from "@/lib/pages";
import { pageMetadata } from "@/lib/metadata";
export async function generateMetadata() {
const { locale } = await getUI();
return pageMetadata(getStaticPage(locale, "industries"));
}
export default async function IndustriesPage() {
const { locale, t } = await getUI();
return <ContentPage page={getStaticPage(locale, "industries")} trail={[{ name: t.nav.industries, path: "/industries" }]} />;
}
+85
View File
@@ -0,0 +1,85 @@
import { Suspense } from "react";
import type { Metadata, Viewport } from "next";
import { Inter, Noto_Sans_Arabic } from "next/font/google";
import "../globals.css";
import SiteHeader from "@/components/site-header";
import SiteFooter from "@/components/site-footer";
import { dirOf, locales, localize, ogLocale } from "@/i18n/config";
import { mainNav } from "@/i18n/nav";
import { getUI } from "@/i18n/server";
import { localeAlternates } from "@/lib/metadata";
import { SITE_URL } from "@/lib/routes";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
display: "swap",
});
// Only Arabic pages use it, so it isn't preloaded everywhere.
const arabic = Noto_Sans_Arabic({
variable: "--font-arabic",
subsets: ["arabic"],
display: "swap",
preload: false,
});
export function generateStaticParams() {
return locales.map((lang) => ({ lang }));
}
export async function generateMetadata(): Promise<Metadata> {
const { locale, t } = await getUI();
return {
metadataBase: new URL(SITE_URL),
title: { default: t.meta.title, template: "%s" },
description: t.meta.description,
alternates: localeAlternates("/", locale),
openGraph: {
type: "website",
url: localize("/", locale),
siteName: "AI Growth Technologies",
locale: ogLocale[locale],
title: t.meta.title,
description: t.meta.description,
},
};
}
export const viewport: Viewport = {
themeColor: "#000000",
colorScheme: "dark",
};
export default async function RootLayout({ children }: LayoutProps<"/[lang]">) {
const { locale, t } = await getUI();
const headerLabels = {
primary: t.nav.primary,
bookCall: t.bookCall,
openMenu: t.nav.openMenu,
closeMenu: t.nav.closeMenu,
language: t.nav.language,
logoHome: t.logoHome,
};
return (
<html
lang={locale}
dir={dirOf(locale)}
className={`${inter.variable} ${arabic.variable}`}
data-scroll-behavior="smooth"
>
<body>
<a href="#main" className="skip-link">
{t.skip}
</a>
{/* The header reads the pathname, which is request data on dynamic routes. */}
<Suspense>
<SiteHeader locale={locale} nav={mainNav(t, locale)} t={headerLabels} />
</Suspense>
<main id="main">{children}</main>
<SiteFooter />
</body>
</html>
);
}
+106 -125
View File
@@ -1,4 +1,4 @@
import type { CSSProperties, ReactNode } from "react";
import { Fragment, type CSSProperties, type ReactNode } from "react";
import GlobeCanvas from "@/components/globe-canvas";
import StatCounter from "@/components/stat-counter";
import RevealObserver from "@/components/reveal-observer";
@@ -6,8 +6,9 @@ import Capabilities from "@/components/capabilities";
import QueryTicker from "@/components/query-ticker";
import ProductVisual from "@/components/product-visuals";
import Link from "next/link";
import { stats, products, journeySteps, gaps } from "@/content/home";
import { hrefFor } from "@/lib/routes";
import { home } from "@/content/home";
import { localize } from "@/i18n/config";
import { getLocale } from "@/i18n/server";
type Vars = CSSProperties & Record<`--${string}`, string | number>;
const delay = (ms: number) => ({ "--d": `${ms}ms` }) as Vars;
@@ -23,7 +24,12 @@ function Eyebrow({ index, children }: { index?: string; children: ReactNode }) {
);
}
export default function Home() {
export default async function Home() {
const locale = await getLocale();
const c = home[locale];
const to = (path: string) => localize(path, locale);
const { hero, numbers, capabilities, technology, discovery, journey, difference, cta } = c;
return (
<>
{/* ───────────────────────── Hero ───────────────────────── */}
@@ -31,34 +37,28 @@ export default function Home() {
<GlobeCanvas />
<div className="wrap hero-inner">
<div data-reveal>
<Eyebrow>Digital Growth Architects</Eyebrow>
<Eyebrow>{hero.eyebrow}</Eyebrow>
</div>
<h1 id="hero-title" className="display h1" data-reveal style={delay(120)}>
Where Search, AI, Media, and Technology Work Together.
{hero.title}
</h1>
<div className="hero-grid">
<div className="hero-lead lead" data-reveal style={delay(260)}>
<p>
Your customer might find you through Google today, an AI answer tomorrow, and a paid ad three
days later.
</p>
<p className="muted">They don’t think in channels. Your growth strategy shouldn’t either.</p>
<p>{hero.lead}</p>
<p className="muted">{hero.leadMuted}</p>
</div>
<div className="hero-body" data-reveal style={delay(380)}>
<div className="copy">
<p>
AI Growth Technologies brings paid media, search, AI visibility, technology, and attribution
together so each part strengthens the others.
</p>
<p className="hl">One strategy. Connected execution. A clearer view of what is actually driving growth.</p>
<p>{hero.body}</p>
<p className="hl">{hero.highlight}</p>
</div>
<div className="hero-actions">
<Link href="/contact" className="btn btn-solid">
Book a Strategy Call <Arrow />
<Link href={to("/contact")} className="btn btn-solid">
{hero.primary} <Arrow />
</Link>
<Link href="/services" className="btn btn-ghost">
Explore Services <Arrow />
<Link href={to("/services")} className="btn btn-ghost">
{hero.secondary} <Arrow />
</Link>
</div>
</div>
@@ -66,18 +66,15 @@ export default function Home() {
<div className="hero-foot" data-reveal style={delay(520)}>
<div className="hero-channels" aria-hidden="true">
<span>Paid Media</span>
<i />
<span>Search</span>
<i />
<span>AI Visibility</span>
<i />
<span>Technology</span>
<i />
<span>Attribution</span>
{hero.channels.map((ch, i) => (
<Fragment key={ch}>
{i > 0 && <i />}
<span>{ch}</span>
</Fragment>
))}
</div>
<a href="#evidence" className="scroll-cue">
Scroll <i />
{hero.scroll} <i />
</a>
</div>
</div>
@@ -88,14 +85,14 @@ export default function Home() {
<div className="wrap">
<div className="section-head">
<div className="title" data-reveal>
<Eyebrow index="01">The Numbers Behind the Work</Eyebrow>
<Eyebrow index="01">{numbers.eyebrow}</Eyebrow>
<h2 id="evidence-title" className="display h2">
Good Growth Leaves Evidence.
{numbers.title}
</h2>
</div>
</div>
<div className="stats">
{stats.map((s, i) => (
{numbers.stats.map((s, i) => (
<div key={s.label} className="stat" data-reveal style={delay(i * 120)}>
<StatCounter value={s.value} suffix={s.suffix} />
<div className="stat-label">{s.label}</div>
@@ -110,29 +107,29 @@ export default function Home() {
<div className="wrap">
<div className="section-head">
<div className="title" data-reveal>
<Eyebrow index="02">Everything Works Better Connected</Eyebrow>
<Eyebrow index="02">{capabilities.eyebrow}</Eyebrow>
<h2 id="cap-title" className="display h2">
Eight Capabilities.
{capabilities.title[0]}
<br />
One Growth Engine.
{capabilities.title[1]}
</h2>
</div>
</div>
<div className="split">
<div className="lead" data-reveal>
<p>
A search can lead to an ad. An ad can lead to a website visit. An AI answer can introduce the brand
before either happens.
</p>
<p>{capabilities.lead}</p>
</div>
<div className="copy" data-reveal style={delay(140)}>
<p>To your customer, that is not SEO, paid media, AI, and web.</p>
<p className="hl">It is one experience with your business.</p>
<p>Our capabilities are designed to work that way too.</p>
<p>{capabilities.copy}</p>
<p className="hl">{capabilities.highlight}</p>
<p>{capabilities.after}</p>
</div>
</div>
<div data-reveal>
<Capabilities />
<Capabilities
items={capabilities.items.map((it) => ({ ...it, href: to(it.href) }))}
core={capabilities.core}
/>
</div>
</div>
</section>
@@ -142,37 +139,34 @@ export default function Home() {
<div className="wrap">
<div className="section-head">
<div className="title" data-reveal>
<Eyebrow index="03">Proprietary Technology</Eyebrow>
<Eyebrow index="03">{technology.eyebrow}</Eyebrow>
<h2 id="tech-title" className="display h2">
We Wanted Better Answers, So We Built Better Tools.
{technology.title}
</h2>
</div>
</div>
<div className="split">
<div className="lead" data-reveal>
<p>Marketing produces plenty of numbers.</p>
<p className="muted">The harder part is knowing which ones matter.</p>
<p>{technology.lead}</p>
<p className="muted">{technology.leadMuted}</p>
</div>
<div className="copy" data-reveal style={delay(140)}>
<p>{technology.copy}</p>
<p>
Our proprietary technology gives our teams and clients a clearer view across attribution, organic and
AI visibility, and digital resilience.
</p>
<p>
Not more data for the sake of it. <span className="hl">Better information for the decisions that come next.</span>
{technology.closing} <span className="hl">{technology.closingHighlight}</span>
</p>
</div>
</div>
<div className="products">
{products.map((p, i) => (
{technology.products.map((p, i) => (
<article key={p.name} className="product" aria-labelledby={`product-${i}`}>
<div className="product-visual" data-reveal>
<ProductVisual kind={p.visual} />
<ProductVisual kind={p.visual} labels={technology.visuals} />
</div>
<div className="product-body" data-reveal style={delay(160)}>
<div className="product-kind">
<span>{String(i + 1).padStart(2, "0")} / 03</span>
<span dir="ltr">{String(i + 1).padStart(2, "0")} / 03</span>
<span className="pill">{p.kind}</span>
</div>
<h3 id={`product-${i}`} className="product-name">
@@ -184,7 +178,7 @@ export default function Home() {
<p key={b}>{b}</p>
))}
</div>
<Link href={hrefFor(p.cta)} className="arrow-link">
<Link href={to(p.href)} className="arrow-link">
{p.cta} <Arrow />
</Link>
</div>
@@ -199,23 +193,25 @@ export default function Home() {
<div className="wrap">
<div className="section-head">
<div className="title" data-reveal>
<Eyebrow index="04">Discovery Is Changing</Eyebrow>
<Eyebrow index="04">{discovery.eyebrow}</Eyebrow>
<h2 id="disc-title" className="display h2">
Your Next Customer May Ask Before They Search.
{discovery.title}
</h2>
</div>
</div>
<div className="discovery-stage" data-reveal>
<div className="query-label">People now ask AI platforms</div>
<QueryTicker />
<div className="query-label">{discovery.queryLabel}</div>
<QueryTicker queries={discovery.queries} />
<div className="surfaces">
<div className="surface left">
<span className="pill">Google</span>
<span className="pill">Rankings</span>
<span className="pill">Authority</span>
{discovery.searchPills.map((p) => (
<span key={p} className="pill">
{p}
</span>
))}
</div>
<div className="surface-core">Your Brand</div>
<div className="surface-core">{discovery.yourBrand}</div>
<div className="surface">
<span className="pill">ChatGPT</span>
<span className="pill">Gemini</span>
@@ -227,23 +223,18 @@ export default function Home() {
<div className="split">
<div className="lead" data-reveal>
<p>People still search Google.</p>
<p className="muted">
Now they also ask AI platforms which companies to consider, how products compare, what to buy, and
who to trust.
</p>
<p>{discovery.lead}</p>
<p className="muted">{discovery.leadMuted}</p>
</div>
<div data-reveal style={delay(140)}>
<div className="copy">
<p className="hl">That changes what visibility means.</p>
<p>
Rankings still matter. But so do the information, authority, context, and signals that help AI
systems understand a brand well enough to surface it.
</p>
<p>Our SEO and LLMO work connects both sides of that shift.</p>
<p className="hl">{discovery.highlight}</p>
{discovery.copy.map((p) => (
<p key={p}>{p}</p>
))}
</div>
<Link href="/services/llmo" className="arrow-link" style={{ marginTop: 28 }}>
Explore AI Visibility <Arrow />
<Link href={to("/services/llmo")} className="arrow-link" style={{ marginTop: 28 }}>
{discovery.cta} <Arrow />
</Link>
</div>
</div>
@@ -255,16 +246,16 @@ export default function Home() {
<div className="wrap">
<div className="section-head">
<div className="title" data-reveal>
<Eyebrow index="05">From Attention to Action</Eyebrow>
<Eyebrow index="05">{journey.eyebrow}</Eyebrow>
<h2 id="journey-title" className="display h2">
The Last Click Rarely Tells the Whole Story.
{journey.title}
</h2>
</div>
</div>
<div className="journey" data-reveal-group>
<ol>
{journeySteps.map((s, i) => (
{journey.steps.map((s, i) => (
<li key={s.label} style={{ "--i": i } as Vars}>
<span className="dot" />
<span className="channel">
@@ -278,28 +269,24 @@ export default function Home() {
<div className="credit split">
<h3 className="display question" data-reveal>
Which interaction gets the credit?
{journey.question}
</h3>
<div className="copy" data-reveal style={delay(140)}>
<p>Too often, the answer depends on which dashboard you open.</p>
<p>
We bring media, search, AI visibility, and attribution closer together to answer a more useful
question:
</p>
{journey.copy.map((p) => (
<p key={p}>{p}</p>
))}
</div>
</div>
<figure className="pullquote" data-reveal>
<Eyebrow>The more useful question</Eyebrow>
<blockquote>What actually helped move the customer toward a decision?</blockquote>
<Eyebrow>{journey.quoteEyebrow}</Eyebrow>
<blockquote>{journey.quote}</blockquote>
<figcaption className="copy">
<p>Better attribution isn’t about producing a prettier report.</p>
<p className="hl">
It is about knowing where the next dollar has the best chance of doing something useful.
</p>
<p>{journey.caption}</p>
<p className="hl">{journey.captionHighlight}</p>
</figcaption>
<Link href="/about" className="arrow-link">
Explore Our Approach <Arrow />
<Link href={to("/about")} className="arrow-link">
{journey.cta} <Arrow />
</Link>
</figure>
</div>
@@ -310,15 +297,15 @@ export default function Home() {
<div className="wrap">
<div className="section-head">
<div className="title" data-reveal>
<Eyebrow index="06">The Difference Is in the Connections</Eyebrow>
<Eyebrow index="06">{difference.eyebrow}</Eyebrow>
<h2 id="diff-title" className="display h2">
Good Channels Don’t Automatically Make a Good System.
{difference.title}
</h2>
</div>
</div>
<ul className="gaps">
{gaps.map((g, i) => (
{difference.gaps.map((g, i) => (
<li key={g} data-reveal style={delay(i * 80)}>
<span>{g}</span>
<svg className="break" viewBox="0 0 120 14" aria-hidden="true">
@@ -332,31 +319,27 @@ export default function Home() {
<div className="split" style={{ marginTop: "clamp(80px, 10vw, 144px)" }}>
<h3 className="display h2" data-reveal>
That is the gap we care about.
{difference.gapTitle}
</h3>
<div className="copy" data-reveal style={delay(140)}>
<p>We look at how discovery, acquisition, experience, data, technology, and security affect one another.</p>
<p className="hl">Then we strengthen the connections that make the whole system perform better.</p>
<p>{difference.copy}</p>
<p className="hl">{difference.highlight}</p>
</div>
</div>
<div className="nos">
<div data-reveal>
<span>No. 01</span>
No channel fighting for credit.
</div>
<div data-reveal style={delay(120)}>
<span>No. 02</span>
No technology added just because it is new.
</div>
<div data-reveal style={delay(240)}>
<span>No. 03</span>
No dashboard pretending the numbers mean more than they do.
</div>
{difference.nos.map((n, i) => (
<div key={n} data-reveal style={i ? delay(i * 120) : undefined}>
<span>
{difference.noLabel} {String(i + 1).padStart(2, "0")}
</span>
{n}
</div>
))}
</div>
<p className="closing" data-reveal>
Just a clearer system and a team accountable for making it work.
{difference.closing}
</p>
</div>
</section>
@@ -366,25 +349,23 @@ export default function Home() {
<GlobeCanvas layout="center" />
<div className="wrap">
<div data-reveal>
<Eyebrow>Ready When You Are</Eyebrow>
<Eyebrow>{cta.eyebrow}</Eyebrow>
</div>
<h2 id="cta-title" className="display h2" data-reveal style={delay(120)}>
Bring Us the Growth Problem You Can’t Quite Pin Down.
{cta.title}
</h2>
<div className="copy" data-reveal style={delay(240)}>
<p>
Maybe acquisition is getting more expensive. Maybe organic visibility has stalled. Maybe AI is changing
how customers find you.
</p>
<p>Or maybe every dashboard looks healthy, but the business is telling you something different.</p>
{cta.copy.map((p) => (
<p key={p}>{p}</p>
))}
</div>
<div className="lead" data-reveal style={delay(320)}>
<p>You don’t need to diagnose it before the call.</p>
<p className="muted">Bring us what you’re seeing. We’ll start there.</p>
<p>{cta.lead}</p>
<p className="muted">{cta.leadMuted}</p>
</div>
<div data-reveal style={delay(400)}>
<Link href="/contact" className="btn btn-solid">
Book Your Strategy Call <Arrow />
<Link href={to("/contact")} className="btn btn-solid">
{cta.button} <Arrow />
</Link>
</div>
</div>
@@ -1,23 +1,24 @@
import { Suspense } from "react";
import { notFound } from "next/navigation";
import ContentPage from "@/components/content/content-page";
import { getUI } from "@/i18n/server";
import { getService } from "@/lib/pages";
import { pageMetadata } from "@/lib/metadata";
import { serviceNav } from "@/lib/routes";
type Params = PageProps<"/services/[slug]">["params"];
type Params = PageProps<"/[lang]/services/[slug]">["params"];
export function generateStaticParams() {
return serviceNav.map((s) => ({ slug: s.slug }));
}
export async function generateMetadata({ params }: PageProps<"/services/[slug]">) {
const { slug } = await params;
const page = getService(slug);
export async function generateMetadata({ params }: PageProps<"/[lang]/services/[slug]">) {
const [{ slug }, { locale }] = await Promise.all([params, getUI()]);
const page = getService(locale, slug);
return page ? pageMetadata(page) : {};
}
export default function Page({ params }: PageProps<"/services/[slug]">) {
export default function Page({ params }: PageProps<"/[lang]/services/[slug]">) {
return (
<Suspense fallback={<section className="hero page-hero" aria-busy="true" />}>
<Content params={params} />
@@ -26,18 +27,16 @@ export default function Page({ params }: PageProps<"/services/[slug]">) {
}
async function Content({ params }: { params: Params }) {
const { slug } = await params;
const page = getService(slug);
const [{ slug }, { locale, t }] = await Promise.all([params, getUI()]);
const page = getService(locale, slug);
if (!page) notFound();
const name = serviceNav.find((s) => s.slug === slug)?.name ?? slug;
return (
<ContentPage
page={page}
crumbs={[
{ name: "Home", href: "/" },
{ name: "Services", href: "/services" },
{ name, href: `/services/${slug}` },
trail={[
{ name: t.nav.services, path: "/services" },
{ name: t.services[slug as keyof typeof t.services], path: `/services/${slug}` },
]}
/>
);
+14
View File
@@ -0,0 +1,14 @@
import ContentPage from "@/components/content/content-page";
import { getUI } from "@/i18n/server";
import { getStaticPage } from "@/lib/pages";
import { pageMetadata } from "@/lib/metadata";
export async function generateMetadata() {
const { locale } = await getUI();
return pageMetadata(getStaticPage(locale, "services"));
}
export default async function ServicesPage() {
const { locale, t } = await getUI();
return <ContentPage page={getStaticPage(locale, "services")} trail={[{ name: t.nav.services, path: "/services" }]} />;
}
-19
View File
@@ -1,19 +0,0 @@
import ContentPage from "@/components/content/content-page";
import { pages } from "@/lib/pages";
import { pageMetadata } from "@/lib/metadata";
const page = pages.about;
export const metadata = pageMetadata(page);
export default function AboutPage() {
return (
<ContentPage
page={page}
crumbs={[
{ name: "Home", href: "/" },
{ name: "About", href: "/about" },
]}
/>
);
}
-19
View File
@@ -1,19 +0,0 @@
import ContentPage from "@/components/content/content-page";
import { pages } from "@/lib/pages";
import { pageMetadata } from "@/lib/metadata";
const page = pages.contact;
export const metadata = pageMetadata(page);
export default function ContactPage() {
return (
<ContentPage
page={page}
crumbs={[
{ name: "Home", href: "/" },
{ name: "Contact", href: "/contact" },
]}
/>
);
}
+250 -46
View File
@@ -20,6 +20,12 @@
--max: 1440px;
--section-y: clamp(88px, 9vw, 136px);
--ease: cubic-bezier(0.2, 0.7, 0.1, 1);
/* 1 in left-to-right pages, -1 in right-to-left ones: flips horizontal motion. */
--dir: 1;
}
[dir="rtl"] {
--dir: -1;
}
*,
@@ -71,7 +77,7 @@ a {
.skip-link {
position: absolute;
left: 16px;
inset-inline-start: 16px;
top: -60px;
z-index: 100;
padding: 12px 18px;
@@ -197,7 +203,7 @@ a {
}
.btn:hover .arrow {
transform: translateX(4px);
transform: translateX(calc(4px * var(--dir)));
}
.btn-solid {
@@ -247,7 +253,7 @@ a {
}
.arrow-link:hover .arrow {
transform: translateX(4px);
transform: translateX(calc(4px * var(--dir)));
}
.pill {
@@ -377,12 +383,12 @@ a {
.nav a::after {
content: "";
position: absolute;
left: 0;
right: 100%;
inset-inline-start: 0;
inset-inline-end: 100%;
bottom: 0;
height: 1px;
background: var(--rosso);
transition: right 0.35s var(--ease);
transition: inset-inline-end 0.35s var(--ease);
}
.nav a:hover {
@@ -390,7 +396,7 @@ a {
}
.nav a:hover::after {
right: 0;
inset-inline-end: 0;
}
.header-cta {
@@ -591,7 +597,7 @@ a {
.scroll-cue i::after {
content: "";
position: absolute;
left: 0;
inset-inline-start: 0;
top: -40%;
width: 1px;
height: 40%;
@@ -694,12 +700,12 @@ a {
.stat {
padding: 36px 28px 8px;
border-left: 1px solid var(--grafite);
border-inline-start: 1px solid var(--grafite);
}
.stat:first-child {
border-left: 0;
padding-left: 0;
border-inline-start: 0;
padding-inline-start: 0;
}
.stat-value {
@@ -713,7 +719,7 @@ a {
}
.stat-value small {
margin-left: 6px;
margin-inline-start: 6px;
font-size: 0.36em;
font-weight: 400;
line-height: 1.3;
@@ -735,8 +741,8 @@ a {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stat:nth-child(3) {
border-left: 0;
padding-left: 0;
border-inline-start: 0;
padding-inline-start: 0;
}
.stat:nth-child(n + 3) {
border-top: 1px solid var(--grafite);
@@ -778,7 +784,7 @@ a {
background: transparent;
color: var(--piombo);
font: inherit;
text-align: left;
text-align: start;
cursor: pointer;
transition: color 0.4s var(--ease);
}
@@ -1238,7 +1244,7 @@ a {
display: inline-block;
width: 2px;
height: 0.9em;
margin-left: 6px;
margin-inline-start: 6px;
vertical-align: -0.08em;
background: var(--rosso);
animation: caret 1s steps(1) infinite;
@@ -1293,11 +1299,11 @@ a {
}
.surface-core::before {
right: 100%;
inset-inline-end: 100%;
}
.surface-core::after {
left: 100%;
inset-inline-start: 100%;
}
@media (max-width: 1023px) {
@@ -1337,8 +1343,8 @@ a {
content: "";
position: absolute;
top: 6px;
left: 0;
right: 0;
inset-inline-start: 0;
inset-inline-end: 0;
height: 1px;
background: var(--grafite);
}
@@ -1356,7 +1362,7 @@ a {
.journey li {
position: relative;
padding-right: 24px;
padding-inline-end: 24px;
}
.journey .dot {
@@ -1406,14 +1412,14 @@ a {
.journey ol {
grid-template-columns: 1fr;
gap: 34px;
padding-left: 34px;
padding-inline-start: 34px;
}
.journey ol::before,
.journey ol::after {
top: 0;
bottom: 0;
left: 6px;
right: auto;
inset-inline-start: 6px;
inset-inline-end: auto;
width: 1px;
height: auto;
}
@@ -1427,7 +1433,7 @@ a {
}
.journey .dot {
position: absolute;
left: -34px;
inset-inline-start: -34px;
top: 4px;
}
.journey .channel {
@@ -1534,7 +1540,7 @@ a {
}
.gaps li:hover {
padding-left: 12px;
padding-inline-start: 12px;
}
@media (max-width: 720px) {
@@ -1555,14 +1561,14 @@ a {
.nos div {
padding: 32px 28px 0;
border-left: 1px solid var(--grafite);
border-inline-start: 1px solid var(--grafite);
font-size: 17px;
line-height: 1.5;
}
.nos div:first-child {
padding-left: 0;
border-left: 0;
padding-inline-start: 0;
border-inline-start: 0;
}
.nos span {
@@ -1591,7 +1597,7 @@ a {
.nos div,
.nos div:first-child {
padding: 28px 0;
border-left: 0;
border-inline-start: 0;
border-bottom: 1px solid var(--grafite);
}
}
@@ -1822,7 +1828,7 @@ a {
}
.nav a.is-active::after {
right: 0;
inset-inline-end: 0;
}
.nav-group {
@@ -1835,7 +1841,7 @@ a {
.nav-panel {
position: absolute;
top: 100%;
left: -28px;
inset-inline-start: -28px;
min-width: 300px;
padding-top: 1px;
opacity: 0;
@@ -1958,8 +1964,8 @@ a {
.emph {
margin: 1.2em 0 !important;
padding-left: 20px;
border-left: 1px solid var(--rosso);
padding-inline-start: 20px;
border-inline-start: 1px solid var(--rosso);
font-size: clamp(18px, 1.6vw, 24px);
font-weight: 300;
line-height: 1.4;
@@ -2017,14 +2023,14 @@ a {
grid-template-columns: repeat(var(--cols, 3), minmax(0, 1fr));
margin-top: clamp(56px, 7vw, 96px);
border-top: 1px solid var(--grafite);
border-left: 1px solid var(--grafite);
border-inline-start: 1px solid var(--grafite);
}
.card {
display: flex;
flex-direction: column;
padding: 32px 28px 36px;
border-right: 1px solid var(--grafite);
border-inline-end: 1px solid var(--grafite);
border-bottom: 1px solid var(--grafite);
}
@@ -2108,7 +2114,7 @@ a {
}
.link-card:hover .arrow-link .arrow {
transform: translateX(4px);
transform: translateX(calc(4px * var(--dir)));
}
@media (max-width: 1023px) {
@@ -2231,7 +2237,7 @@ a {
content: "";
position: absolute;
top: 50%;
left: 0;
inset-inline-start: 0;
width: 14px;
height: 1px;
background: var(--bianco);
@@ -2259,7 +2265,7 @@ a {
/* stats on inner pages */
.stat-copy {
margin-top: 14px;
padding-right: 12px;
padding-inline-end: 12px;
font-size: 13px;
}
@@ -2275,12 +2281,12 @@ a {
display: flex;
flex-direction: column;
padding: 32px 24px 8px;
border-left: 1px solid var(--grafite);
border-inline-start: 1px solid var(--grafite);
}
.office:first-child {
padding-left: 0;
border-left: 0;
padding-inline-start: 0;
border-inline-start: 0;
}
.office-head {
@@ -2339,8 +2345,8 @@ a {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.office:nth-child(3) {
padding-left: 0;
border-left: 0;
padding-inline-start: 0;
border-inline-start: 0;
}
.office:nth-child(n + 3) {
border-top: 1px solid var(--grafite);
@@ -2354,7 +2360,7 @@ a {
.office,
.office:nth-child(3) {
padding: 28px 0;
border-left: 0;
border-inline-start: 0;
}
.office + .office {
border-top: 1px solid var(--grafite);
@@ -2573,7 +2579,7 @@ a {
.hp {
position: absolute;
left: -9999px;
inset-inline-start: -9999px;
width: 1px;
height: 1px;
opacity: 0;
@@ -2623,3 +2629,201 @@ a {
stroke-dashoffset: 0;
}
}
/* ============================================================
Language switch
============================================================ */
.lang-switch {
display: flex;
align-items: center;
gap: 2px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.16em;
}
.site-header .lang-switch {
margin-inline-start: auto;
}
.lang-switch a,
.lang-switch span {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0 6px;
color: var(--piombo);
transition: color 0.3s var(--ease);
}
.lang-switch a:hover {
color: var(--bianco);
}
.lang-switch [aria-current] {
color: var(--bianco);
box-shadow: inset 0 -1px 0 var(--rosso);
}
.lang-switch [lang="ar"] {
font-family: var(--font-arabic), var(--font-sans);
font-size: 14px;
letter-spacing: 0;
}
.mobile-menu .lang-switch {
margin: 28px 0 24px;
font-size: 13px;
}
/* ============================================================
Contact form — optional label and "I'm not a robot" check
============================================================ */
.field-optional {
margin-inline-start: 8px;
font-style: normal;
color: var(--piombo);
}
.human-check {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
max-width: 340px;
padding: 14px 18px;
border: 1px solid var(--grafite);
transition: border-color 0.3s var(--ease);
}
.human-check.is-invalid {
border-color: var(--rosso);
}
.human-check button {
display: flex;
align-items: center;
gap: 14px;
padding: 0;
border: 0;
background: none;
color: var(--bianco);
font: inherit;
font-size: 14px;
text-align: start;
cursor: pointer;
}
.human-check.is-done button {
cursor: default;
}
.human-box {
position: relative;
display: grid;
place-items: center;
width: 24px;
height: 24px;
flex: none;
border: 1px solid var(--fumo);
transition:
border-color 0.3s var(--ease),
background-color 0.3s var(--ease);
}
.human-check button:hover .human-box,
.human-check button:focus-visible .human-box {
border-color: var(--bianco);
}
.human-box svg {
width: 18px;
height: 18px;
fill: none;
stroke: var(--bianco);
stroke-width: 1.8;
stroke-dasharray: 16;
stroke-dashoffset: 16;
}
.human-check.is-working .human-box {
border-color: var(--grafite);
border-top-color: var(--rosso);
border-radius: 50%;
animation: human-spin 0.8s linear infinite;
}
.human-check.is-done .human-box {
border-color: var(--rosso);
background: var(--rosso);
}
.human-check.is-done .human-box svg {
animation: draw-check 0.45s var(--ease) forwards;
}
.human-check.is-failed .human-label {
color: var(--rosso);
}
@keyframes human-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.human-check.is-working .human-box {
animation-duration: 2.4s;
}
}
/* ============================================================
Right-to-left (Arabic)
============================================================ */
[dir="rtl"] .arrow {
display: inline-block;
transform: scaleX(-1);
}
[dir="rtl"] .btn:hover .arrow,
[dir="rtl"] .arrow-link:hover .arrow {
transform: translateX(-4px) scaleX(-1);
}
[dir="rtl"] .cap-trigger .tick {
transform-origin: left;
}
[dir="rtl"] .journey ol::after {
background: linear-gradient(270deg, rgba(255, 255, 255, 0.7), var(--rosso));
transform-origin: right;
}
/* The product diagrams are drawn left to right in every language. */
.viz {
direction: ltr;
}
html[lang="ar"] {
--font-sans: var(--font-arabic), var(--font-inter), "Geeza Pro", "Segoe UI", Tahoma, sans-serif;
}
/* Tracking breaks the joins between Arabic letters. */
html[lang="ar"] body * {
letter-spacing: 0 !important;
}
html[lang="ar"] .display {
line-height: 1.35;
}
html[lang="ar"] .eyebrow,
html[lang="ar"] .pill,
html[lang="ar"] .arrow-link,
html[lang="ar"] .btn,
html[lang="ar"] .nav a {
font-size: 13px;
}
-19
View File
@@ -1,19 +0,0 @@
import ContentPage from "@/components/content/content-page";
import { pages } from "@/lib/pages";
import { pageMetadata } from "@/lib/metadata";
const page = pages.industries;
export const metadata = pageMetadata(page);
export default function IndustriesPage() {
return (
<ContentPage
page={page}
crumbs={[
{ name: "Home", href: "/" },
{ name: "Industries", href: "/industries" },
]}
/>
);
}
-55
View File
@@ -1,55 +0,0 @@
import { Suspense } from "react";
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import SiteHeader from "@/components/site-header";
import SiteFooter from "@/components/site-footer";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
display: "swap",
});
const description =
"AI Growth Technologies connects paid media, SEO, AI visibility, web, attribution, and technology into one accountable digital growth system.";
export const metadata: Metadata = {
metadataBase: new URL("https://www.aigrowthtechnologies.com"),
title: {
default: "AI Growth Technologies | Digital Growth for the AI Era",
template: "%s",
},
description,
alternates: { canonical: "/" },
openGraph: {
type: "website",
url: "/",
siteName: "AI Growth Technologies",
title: "AI Growth Technologies | Digital Growth for the AI Era",
description,
},
};
export const viewport: Viewport = {
themeColor: "#000000",
colorScheme: "dark",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en" className={inter.variable} data-scroll-behavior="smooth">
<body>
<a href="#main" className="skip-link">
Skip to content
</a>
{/* The header reads the pathname, which is request data on dynamic routes. */}
<Suspense>
<SiteHeader />
</Suspense>
<main id="main">{children}</main>
<SiteFooter />
</body>
</html>
);
}
-19
View File
@@ -1,19 +0,0 @@
import ContentPage from "@/components/content/content-page";
import { pages } from "@/lib/pages";
import { pageMetadata } from "@/lib/metadata";
const page = pages.services;
export const metadata = pageMetadata(page);
export default function ServicesPage() {
return (
<ContentPage
page={page}
crumbs={[
{ name: "Home", href: "/" },
{ name: "Services", href: "/services" },
]}
/>
);
}
+11 -2
View File
@@ -1,9 +1,10 @@
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",
@@ -11,5 +12,13 @@ export default function sitemap(): MetadataRoute.Sitemap {
"/industries",
...industryNav.map((s) => `/industries/${s.slug}`),
];
return paths.map((p) => ({ url: SITE_URL + p, priority: p === "" ? 1 : p.split("/").length === 2 ? 0.8 : 0.6 }));
const url = (p: string) => SITE_URL + (p === "/" ? "" : p);
return locales.flatMap((locale) =>
paths.map((p) => ({
url: url(localize(p, locale)),
priority: p === "/" ? 1 : p.split("/").length === 2 ? 0.8 : 0.6,
alternates: { languages: Object.fromEntries(locales.map((l) => [l, url(localize(p, l))])) },
})),
);
}
+13 -12
View File
@@ -2,8 +2,8 @@
import { useState } from "react";
import Link from "next/link";
import { capabilities } from "@/content/home";
import { hrefFor } from "@/lib/routes";
export type CapabilityItem = { name: string; headline: string; body: string[]; cta: string; href: string };
const pad = (i: number) => String(i + 1).padStart(2, "0");
@@ -11,12 +11,13 @@ const pad = (i: number) => String(i + 1).padStart(2, "0");
const SIZE = 360;
const C = SIZE / 2;
const RING = 132;
const nodes = capabilities.map((_, i) => {
const a = (i / capabilities.length) * Math.PI * 2 - Math.PI / 2;
const COUNT = 8;
const nodes = Array.from({ length: COUNT }, (_, i) => {
const a = (i / COUNT) * Math.PI * 2 - Math.PI / 2;
return { x: C + Math.cos(a) * RING, y: C + Math.sin(a) * RING };
});
function EngineMap({ active }: { active: number }) {
function EngineMap({ active, core }: { active: number; core: string }) {
return (
<svg className="cap-map" viewBox={`0 0 ${SIZE} ${SIZE}`} aria-hidden="true">
{nodes.map((a, i) =>
@@ -32,7 +33,7 @@ function EngineMap({ active }: { active: number }) {
))}
<circle className="core" cx={C} cy={C} r={30} />
<text x={C} y={C + 2.5} textAnchor="middle">
GROWTH
{core}
</text>
{nodes.map((n, i) => (
<g key={`n${i}`}>
@@ -51,14 +52,14 @@ function EngineMap({ active }: { active: number }) {
);
}
export default function Capabilities() {
export default function Capabilities({ items, core }: { items: CapabilityItem[]; core: string }) {
const [active, setActive] = useState(0);
const cap = capabilities[active];
const cap = items[active];
return (
<div className="cap-layout">
<ul className="cap-list">
{capabilities.map((c, i) => (
{items.map((c, i) => (
<li key={c.name} className={`cap-item${i === active ? " is-active" : ""}`}>
<button
type="button"
@@ -79,7 +80,7 @@ export default function Capabilities() {
<p key={p}>{p}</p>
))}
</div>
<Link href={hrefFor(c.cta)} className="arrow-link">
<Link href={c.href} className="arrow-link">
{c.cta} <span className="arrow">→</span>
</Link>
</div>
@@ -88,7 +89,7 @@ export default function Capabilities() {
</ul>
<div className="cap-panel" aria-live="polite">
<EngineMap active={active} />
<EngineMap active={active} core={core} />
<div key={active} className="cap-detail">
<div className="kicker">
{pad(active)} / {cap.name}
@@ -99,7 +100,7 @@ export default function Capabilities() {
<p key={p}>{p}</p>
))}
</div>
<Link href={hrefFor(cap.cta)} className="arrow-link">
<Link href={cap.href} className="arrow-link">
{cap.cta} <span className="arrow">→</span>
</Link>
</div>
+109 -47
View File
@@ -1,35 +1,46 @@
"use client";
import Script from "next/script";
import { useActionState, useEffect, useRef } from "react";
import { sendContact, type ContactState } from "@/app/contact/actions";
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 interests = [...serviceNav.map((s) => s.name), "Not sure yet"];
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({
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());
}, []);
// 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") {
return (
<div className="contact-form form-sent" role="status">
@@ -37,65 +48,116 @@ export default function ContactForm() {
<circle cx="24" cy="24" r="23" />
<path d="M14 24.5l7 7 13-14" />
</svg>
<h3 className="card-title">Your message was sent successfully.</h3>
<p className="copy">Thank you for reaching out. Our team will review what you shared and get back to you shortly.</p>
<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 className="contact-form" action={action}>
<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>Name</span>
<input name="name" autoComplete="name" required maxLength={120} />
<span>{t.name}</span>
<input name="name" autoComplete="name" required maxLength={120} defaultValue={values?.name} />
</label>
<label className="field">
<span>Work email</span>
<input name="email" type="email" autoComplete="email" required maxLength={200} />
<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>Company</span>
<input name="company" autoComplete="organization" maxLength={160} />
<span>{t.company}</span>
<input name="company" autoComplete="organization" maxLength={160} defaultValue={values?.company} />
</label>
<label className="field">
<span>Website</span>
<input name="website" type="url" inputMode="url" placeholder="https://" maxLength={300} />
<span>{t.website}</span>
<input
name="website"
type="url"
inputMode="url"
placeholder="https://"
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" />
<input ref={startedRef} type="hidden" name="started" defaultValue={values ? "1" : undefined} />
<fieldset className="field">
<legend>What are you looking at?</legend>
<legend>{t.interests}</legend>
<div className="chips">
{interests.map((i) => (
<label key={i} className="chip">
<input type="checkbox" name="interest" value={i} />
<span>{i}</span>
<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>What’s happening in the business?</span>
<textarea name="message" rows={5} required maxLength={5000} />
<span>
{t.message} <em className="field-optional">{t.optional}</em>
</span>
<textarea name="message" rows={5} maxLength={5000} defaultValue={values?.message} />
</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" />
</>
)}
<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 ? "Sending…" : "Send to the Team"} <span className="arrow">→</span>
{pending ? t.sending : t.submit} <span className="arrow">→</span>
</button>
<p className={`form-note${state.status === "error" ? " is-error" : ""}`} role="status">
{state.status === "error"
? `${state.message} You can also write to ${CONTACT_EMAIL}.`
: "A strategist reads every message and replies personally."}
<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>
);
+66 -54
View File
@@ -4,14 +4,19 @@ import GlobeCanvas from "@/components/globe-canvas";
import StatCounter from "@/components/stat-counter";
import RevealObserver from "@/components/reveal-observer";
import ContactForm from "./contact-form";
import type { Item, Page, Section } from "@/lib/pages";
import { SITE_URL, hrefFor } from "@/lib/routes";
import { localize } from "@/i18n/config";
import { getUI } from "@/i18n/server";
import type { UI } from "@/i18n/ui";
import type { Cta, Item, Page, Section } from "@/lib/pages";
import { SITE_URL } from "@/lib/routes";
type Vars = CSSProperties & Record<`--${string}`, string | number>;
const delay = (ms: number) => ({ "--d": `${ms}ms` }) as Vars;
const pad = (n: number) => String(n).padStart(2, "0");
export type Crumb = { name: string; href: string };
type Crumb = { name: string; href: string };
/** Breadcrumb steps after Home, as locale-free paths. */
export type Trail = { name: string; path: string }[];
const Arrow = () => <span className="arrow">→</span>;
@@ -40,23 +45,23 @@ function Paras({ items, firstClass }: { items: string[]; firstClass?: string })
);
}
function CtaLinks({ ctas, path }: { ctas: string[]; path: string }) {
function CtaLinks({ ctas }: { ctas: Cta[] }) {
if (!ctas.length) return null;
return (
<div className="cta-row">
{ctas.map((c) => (
<Link key={c} href={hrefFor(c, path)} className="arrow-link">
{c} <Arrow />
<Link key={c.label} href={c.href} className="arrow-link">
{c.label} <Arrow />
</Link>
))}
</div>
);
}
function SectionHead({ section, index }: { section: Section; index: number }) {
function SectionHead({ section, index, t }: { section: Section; index: number; t: UI }) {
const isFaq = section.kind === "faq";
const eyebrow = isFaq && !section.title ? null : section.eyebrow;
const title = section.title ?? (isFaq ? "Frequently Asked Questions" : null);
const title = section.title ?? (isFaq ? t.faqTitle : null);
return (
<div className="section-head">
<div className="title" data-reveal>
@@ -92,7 +97,7 @@ function Intro({ section }: { section: Section }) {
);
}
function Outro({ section, path }: { section: Section; path: string }) {
function Outro({ section }: { section: Section }) {
if (!section.outro.length && !section.ctas.length) return null;
return (
<div className="outro" data-reveal>
@@ -101,7 +106,7 @@ function Outro({ section, path }: { section: Section; path: string }) {
<Paras items={section.outro} />
</div>
)}
<CtaLinks ctas={section.ctas} path={path} />
<CtaLinks ctas={section.ctas} />
</div>
);
}
@@ -127,7 +132,9 @@ function columnsFor(items: Item[]) {
/* ───────────── Section variants ───────────── */
function TextSection({ section, path }: { section: Section; path: string }) {
type VariantProps = { section: Section; t: UI };
function TextSection({ section }: VariantProps) {
return (
<>
<Intro section={section} />
@@ -135,7 +142,7 @@ function TextSection({ section, path }: { section: Section; path: string }) {
<div className="split">
<div />
<div data-reveal>
<CtaLinks ctas={section.ctas} path={path} />
<CtaLinks ctas={section.ctas} />
</div>
</div>
)}
@@ -143,7 +150,7 @@ function TextSection({ section, path }: { section: Section; path: string }) {
);
}
function CardsSection({ section, path }: { section: Section; path: string }) {
function CardsSection({ section }: VariantProps) {
const cols = columnsFor(section.items);
return (
<>
@@ -161,19 +168,19 @@ function CardsSection({ section, path }: { section: Section; path: string }) {
)}
<ItemList list={it.list} />
{it.cta && (
<Link href={hrefFor(it.cta, path)} className="arrow-link">
{it.cta} <Arrow />
<Link href={it.cta.href} className="arrow-link">
{it.cta.label} <Arrow />
</Link>
)}
</article>
))}
</div>
<Outro section={section} path={path} />
<Outro section={section} />
</>
);
}
function StepsSection({ section, path }: { section: Section; path: string }) {
function StepsSection({ section }: VariantProps) {
return (
<>
<Intro section={section} />
@@ -188,12 +195,12 @@ function StepsSection({ section, path }: { section: Section; path: string }) {
</li>
))}
</ol>
<Outro section={section} path={path} />
<Outro section={section} />
</>
);
}
function FaqSection({ section, path }: { section: Section; path: string }) {
function FaqSection({ section }: VariantProps) {
return (
<>
<Intro section={section} />
@@ -210,12 +217,12 @@ function FaqSection({ section, path }: { section: Section; path: string }) {
</details>
))}
</div>
<Outro section={section} path={path} />
<Outro section={section} />
</>
);
}
function LinksSection({ section, path }: { section: Section; path: string }) {
function LinksSection({ section }: VariantProps) {
const cols = columnsFor(section.items);
return (
<>
@@ -224,7 +231,7 @@ function LinksSection({ section, path }: { section: Section; path: string }) {
{section.items.map((it, i) => (
<Link
key={it.title}
href={hrefFor(it.cta ?? "", path)}
href={it.cta?.href ?? "#"}
className="card link-card"
data-reveal
style={delay((i % cols) * 90)}
@@ -235,17 +242,17 @@ function LinksSection({ section, path }: { section: Section; path: string }) {
<Paras items={it.body} firstClass="hl" />
</div>
<span className="arrow-link">
{it.cta} <Arrow />
{it.cta?.label} <Arrow />
</span>
</Link>
))}
</div>
<Outro section={section} path={path} />
<Outro section={section} />
</>
);
}
function StatsSection({ section, path }: { section: Section; path: string }) {
function StatsSection({ section }: VariantProps) {
return (
<>
<Intro section={section} />
@@ -264,12 +271,12 @@ function StatsSection({ section, path }: { section: Section; path: string }) {
);
})}
</div>
<Outro section={section} path={path} />
<Outro section={section} />
</>
);
}
function AddressLines({ text }: { text: string }) {
function AddressLines({ text, t }: { text: string; t: UI }) {
return (
<address className="office-address">
{text.split("\n").map((line) => {
@@ -278,7 +285,7 @@ function AddressLines({ text }: { text: string }) {
if (/^phone$/i.test(label.trim()) && value) {
return (
<span key={line}>
<em>Phone</em>
<em>{t.office.phone}</em>
<a href={`tel:${value.replace(/[^\d+]/g, "")}`}>{value}</a>
</span>
);
@@ -286,7 +293,7 @@ function AddressLines({ text }: { text: string }) {
if (/^email$/i.test(label.trim()) && value) {
return (
<span key={line}>
<em>Email</em>
<em>{t.office.email}</em>
<a href={`mailto:${value}`}>{value}</a>
</span>
);
@@ -297,7 +304,7 @@ function AddressLines({ text }: { text: string }) {
);
}
function OfficesSection({ section, path }: { section: Section; path: string }) {
function OfficesSection({ section, t }: VariantProps) {
return (
<>
<Intro section={section} />
@@ -314,17 +321,17 @@ function OfficesSection({ section, path }: { section: Section; path: string }) {
<div className="copy">
<Paras items={desc} />
</div>
{address && <AddressLines text={address} />}
{address && <AddressLines text={address} t={t} />}
</article>
);
})}
</div>
<Outro section={section} path={path} />
<Outro section={section} />
</>
);
}
function ShowcaseSection({ section, path }: { section: Section; path: string }) {
function ShowcaseSection({ section }: VariantProps) {
return (
<>
<Intro section={section} />
@@ -344,8 +351,8 @@ function ShowcaseSection({ section, path }: { section: Section; path: string })
<Paras items={it.body} firstClass="hl" />
</div>
{it.cta && (
<Link href={hrefFor(it.cta, path)} className="arrow-link">
{it.cta} <Arrow />
<Link href={it.cta.href} className="arrow-link">
{it.cta.label} <Arrow />
</Link>
)}
</div>
@@ -354,12 +361,12 @@ function ShowcaseSection({ section, path }: { section: Section; path: string })
);
})}
</div>
<Outro section={section} path={path} />
<Outro section={section} />
</>
);
}
function FormSection({ section, path }: { section: Section; path: string }) {
function FormSection({ section, t, locale }: { section: Section; t: UI; locale: Page["locale"] }) {
return (
<div className="form-layout">
<div className="form-copy">
@@ -369,16 +376,16 @@ function FormSection({ section, path }: { section: Section; path: string }) {
<div className="copy" data-reveal style={delay(120)}>
<Paras items={section.intro.slice(1)} />
</div>
<CtaLinks ctas={section.ctas} path={path} />
<CtaLinks ctas={section.ctas} />
</div>
<div data-reveal style={delay(200)}>
<ContactForm />
<ContactForm locale={locale} t={t.form} services={t.services} />
</div>
</div>
);
}
const variants: Record<string, (p: { section: Section; path: string }) => ReactNode> = {
const variants: Record<string, (p: VariantProps) => ReactNode> = {
text: TextSection,
cards: CardsSection,
steps: StepsSection,
@@ -391,14 +398,14 @@ const variants: Record<string, (p: { section: Section; path: string }) => ReactN
/* ───────────── Hero & closing CTA ───────────── */
function PageHero({ page, crumbs }: { page: Page; crumbs: Crumb[] }) {
const { hero, path } = page;
function PageHero({ page, crumbs, t }: { page: Page; crumbs: Crumb[]; t: UI }) {
const { hero } = page;
const [name, tagline] = (hero.eyebrow ?? "").split("|").map((s) => s.trim());
return (
<section className="hero page-hero" aria-labelledby="page-title">
<GlobeCanvas />
<div className="wrap hero-inner">
<nav className="crumbs" aria-label="Breadcrumb" data-reveal>
<nav className="crumbs" aria-label={t.crumbs.label} data-reveal>
{crumbs.map((c, i) =>
i < crumbs.length - 1 ? (
<span key={c.href}>
@@ -438,8 +445,8 @@ function PageHero({ page, crumbs }: { page: Page; crumbs: Crumb[] }) {
)}
<div className="hero-actions">
{hero.ctas.map((c, i) => (
<Link key={c} href={hrefFor(c, path)} className={`btn ${i === 0 ? "btn-solid" : "btn-ghost"}`}>
{c} <Arrow />
<Link key={c.label} href={c.href} className={`btn ${i === 0 ? "btn-solid" : "btn-ghost"}`}>
{c.label} <Arrow />
</Link>
))}
</div>
@@ -450,7 +457,7 @@ function PageHero({ page, crumbs }: { page: Page; crumbs: Crumb[] }) {
);
}
function ClosingCta({ section, path }: { section: Section; path: string }) {
function ClosingCta({ section }: { section: Section }) {
const [lead, ...rest] = section.intro;
return (
<section className="cta" aria-label={section.title ?? undefined}>
@@ -472,8 +479,8 @@ function ClosingCta({ section, path }: { section: Section; path: string }) {
{section.ctas.length > 0 && (
<div data-reveal style={delay(360)}>
{section.ctas.map((c) => (
<Link key={c} href={hrefFor(c, path)} className="btn btn-solid">
{c} <Arrow />
<Link key={c.label} href={c.href} className="btn btn-solid">
{c.label} <Arrow />
</Link>
))}
</div>
@@ -514,7 +521,12 @@ function JsonLd({ page, crumbs }: { page: Page; crumbs: Crumb[] }) {
/* ───────────── Page ───────────── */
export default function ContentPage({ page, crumbs }: { page: Page; crumbs: Crumb[] }) {
export default async function ContentPage({ page, trail }: { page: Page; trail: Trail }) {
const { locale, t } = await getUI();
const crumbs: Crumb[] = [{ name: t.crumbs.home, path: "/" }, ...trail].map((c) => ({
name: c.name,
href: localize(c.path, locale),
}));
const last = page.sections[page.sections.length - 1];
const closing = last && last.kind === "text" && last.ctas.length > 0 ? last : null;
const body = closing ? page.sections.slice(0, -1) : page.sections;
@@ -522,7 +534,7 @@ export default function ContentPage({ page, crumbs }: { page: Page; crumbs: Crum
return (
<>
<JsonLd page={page} crumbs={crumbs} />
<PageHero page={page} crumbs={crumbs} />
<PageHero page={page} crumbs={crumbs} t={t} />
{body.map((section, i) => {
const Variant = variants[section.kind] ?? TextSection;
return (
@@ -532,17 +544,17 @@ export default function ContentPage({ page, crumbs }: { page: Page; crumbs: Crum
className={`section${i % 2 === 0 ? " section-night" : ""} kind-${section.kind}`}
>
<div className="wrap">
<SectionHead section={section} index={i + 1} />
<SectionHead section={section} index={i + 1} t={t} />
{section.form ? (
<FormSection section={section} path={page.path} />
<FormSection section={section} t={t} locale={locale} />
) : (
<Variant section={section} path={page.path} />
<Variant section={section} t={t} />
)}
</div>
</section>
);
})}
{closing && <ClosingCta section={closing} path={page.path} />}
{closing && <ClosingCta section={closing} />}
<RevealObserver />
</>
);
+90
View File
@@ -0,0 +1,90 @@
"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>
);
}
+2
View File
@@ -114,6 +114,8 @@ export default function GlobeCanvas({ layout = "hero", className = "hero-canvas"
cy = vh * 0.54;
R = Math.min(vh * 0.4, w * 0.27);
}
// Keep the globe opposite the copy in right-to-left pages.
if (layout !== "center" && getComputedStyle(canvas).direction === "rtl") cx = w - cx;
};
let rotY = -1.25; // start facing the Americas, drifting toward Europe
+4 -4
View File
@@ -1,9 +1,9 @@
import Link from "next/link";
import Image from "next/image";
export default function Logo() {
export default function Logo({ href, label }: { href: string; label: string }) {
return (
<Link href="/" className="logo" aria-label="AI Growth Technologies — home">
<Link href={href} className="logo" aria-label={label}>
<Image className="logo-mark" src="/logo-mark.png" alt="" width={229} height={192} priority />
<span className="logo-type">
<b>AI GROWTH</b>
@@ -13,9 +13,9 @@ export default function Logo() {
);
}
export function LogoStacked() {
export function LogoStacked({ href, label }: { href: string; label: string }) {
return (
<Link href="/" className="logo-stacked" aria-label="AI Growth Technologies — home">
<Link href={href} className="logo-stacked" aria-label={label}>
<Image src="/logo-stacked.png" alt="AI Growth Technologies" width={480} height={547} />
</Link>
);
+28 -13
View File
@@ -2,12 +2,21 @@ import type { CSSProperties } from "react";
type Vars = CSSProperties & Record<`--${string}`, string>;
export type VisualLabels = {
sources: string[];
conversion: string;
journeyCaption: string;
search: string;
radarCaption: string;
gridCaption: string;
};
const W = 600;
const H = 412;
/* Atrilyx — many touchpoints converging on one conversion */
function JourneyViz() {
const sources = ["Ad", "Search", "Page", "Brand", "AI Answer", "Social"];
function JourneyViz({ labels }: { labels: VisualLabels }) {
const sources = labels.sources;
const gx = 500;
const gy = H / 2;
const top = 70;
@@ -35,21 +44,21 @@ function JourneyViz() {
<rect className="goal-ring" x={gx - 7} y={gy - 7} width={14} height={14} />
<rect className="goal" x={gx - 7} y={gy - 7} width={14} height={14} />
<text x={gx + 18} y={gy + 3}>
CONVERSION
{labels.conversion.toUpperCase()}
</text>
<text className="faint" x={24} y={H - 20}>
TOUCHPOINTS → WEIGHTED CONTRIBUTION
{labels.journeyCaption.toUpperCase()}
</text>
</svg>
);
}
/* meSEO — search + AI surfaces on a radar */
function RadarViz() {
function RadarViz({ labels }: { labels: VisualLabels }) {
const cx = 300;
const cy = 206;
const dots = [
{ a: 18, r: 150, label: "SEARCH" },
{ a: 18, r: 150, label: labels.search.toUpperCase() },
{ a: 64, r: 92, label: "AI OVERVIEWS" },
{ a: 118, r: 170, label: "CHATGPT" },
{ a: 162, r: 64, label: "" },
@@ -114,14 +123,14 @@ function RadarViz() {
})}
<rect x={cx - 3} y={cy - 3} width={6} height={6} fill="#fff" />
<text className="faint" x={24} y={H - 20}>
VISIBILITY SURFACE — SEARCH + GENERATIVE
{labels.radarCaption.toUpperCase()}
</text>
</svg>
);
}
/* Korrith — monitored infrastructure grid with a scanning pass */
function GridViz() {
function GridViz({ labels }: { labels: VisualLabels }) {
const cols = 14;
const rows = 8;
const size = 28;
@@ -173,14 +182,20 @@ function GridViz() {
<rect x={0} y={0} width={W} height={64} fill="url(#scanGrad)" />
</g>
<text className="faint" x={24} y={H - 20}>
RESILIENCE LAYER — CONTINUOUS MONITORING
{labels.gridCaption.toUpperCase()}
</text>
</svg>
);
}
export default function ProductVisual({ kind }: { kind: "journey" | "radar" | "grid" }) {
if (kind === "journey") return <JourneyViz />;
if (kind === "radar") return <RadarViz />;
return <GridViz />;
export default function ProductVisual({
kind,
labels,
}: {
kind: "journey" | "radar" | "grid";
labels: VisualLabels;
}) {
if (kind === "journey") return <JourneyViz labels={labels} />;
if (kind === "radar") return <RadarViz labels={labels} />;
return <GridViz labels={labels} />;
}
+9 -16
View File
@@ -2,37 +2,30 @@
import { useEffect, useState } from "react";
// The kinds of questions the copy describes people now asking AI platforms.
const QUERIES = [
"Which companies should I consider?",
"How do these products compare?",
"What should I buy?",
"Who should I trust?",
];
export default function QueryTicker() {
const [text, setText] = useState(QUERIES[0]);
/** Types out, one after another, the kinds of questions people now ask AI platforms. */
export default function QueryTicker({ queries }: { queries: string[] }) {
const [text, setText] = useState(queries[0]);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let q = 0;
let i = QUERIES[0].length;
let i = queries[0].length;
let deleting = true;
let timer: ReturnType<typeof setTimeout>;
const step = () => {
const target = QUERIES[q];
const target = queries[q];
if (deleting) {
i--;
setText(target.slice(0, i));
if (i <= 0) {
deleting = false;
q = (q + 1) % QUERIES.length;
q = (q + 1) % queries.length;
}
timer = setTimeout(step, 28);
} else {
const next = QUERIES[q];
const next = queries[q];
i++;
setText(next.slice(0, i));
if (i >= next.length) {
@@ -46,10 +39,10 @@ export default function QueryTicker() {
timer = setTimeout(step, 2600);
return () => clearTimeout(timer);
}, []);
}, [queries]);
return (
<p className="query" aria-label={QUERIES.join(" ")}>
<p className="query" aria-label={queries.join(" ")}>
<span aria-hidden="true">{text}</span>
<span className="caret" aria-hidden="true" />
</p>
+23 -23
View File
@@ -1,6 +1,8 @@
import Link from "next/link";
import { LogoStacked } from "./logo";
import { industryNav, serviceNav } from "@/lib/routes";
import { localize } from "@/i18n/config";
import { industryLinks, serviceLinks } from "@/i18n/nav";
import { getUI } from "@/i18n/server";
// The profiles linked from the current aigrowthtechnologies.com footer.
const socials = [
@@ -36,31 +38,29 @@ const socials = [
},
];
const columns = [
{ title: "Services", links: serviceNav.map((s) => ({ label: s.name, href: `/services/${s.slug}` })) },
{ title: "Industries", links: industryNav.map((s) => ({ label: s.name, href: `/industries/${s.slug}` })) },
{
title: "Company",
links: [
{ label: "About", href: "/about" },
{ label: "All Services", href: "/services" },
{ label: "All Industries", href: "/industries" },
{ label: "Contact", href: "/contact" },
],
},
];
export default async function SiteFooter() {
const { locale, t } = await getUI();
const columns = [
{ title: t.nav.services, links: serviceLinks(t, locale) },
{ title: t.nav.industries, links: industryLinks(t, locale) },
{
title: t.footer.company,
links: [
{ label: t.nav.about, href: localize("/about", locale) },
{ label: t.nav.allServices, href: localize("/services", locale) },
{ label: t.nav.allIndustries, href: localize("/industries", locale) },
{ label: t.nav.contact, href: localize("/contact", locale) },
],
},
];
export default function SiteFooter() {
return (
<footer className="footer">
<div className="wrap">
<div className="footer-top">
<div className="footer-brand">
<LogoStacked />
<p>
Paid media, SEO, AI visibility, web, attribution, and technology — connected into one accountable digital
growth system.
</p>
<LogoStacked href={localize("/", locale)} label={t.logoHome} />
<p>{t.footer.tagline}</p>
<div className="socials">
{socials.map((s) => (
<a key={s.label} href={s.href} aria-label={s.label} target="_blank" rel="noopener noreferrer">
@@ -84,12 +84,12 @@ export default function SiteFooter() {
))}
</div>
</div>
<p className="footer-word" aria-hidden="true">
<p className="footer-word" aria-hidden="true" dir="ltr">
AI Growth Technologies
</p>
<div className="footer-bottom">
<span>© 2026 AI Growth Technologies. All rights reserved.</span>
<span>Dubai · Houston · Miami · New York</span>
<span>{t.footer.rights}</span>
<span>{t.footer.cities}</span>
</div>
</div>
</footer>
+49 -15
View File
@@ -4,10 +4,41 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import Logo from "./logo";
import { mainNav } from "@/lib/routes";
import { localeNames, localeShort, locales, localize, splitLocale, type Locale } from "@/i18n/config";
import type { NavItem } from "@/i18n/nav";
export default function SiteHeader() {
export type HeaderLabels = {
primary: string;
bookCall: string;
openMenu: string;
closeMenu: string;
language: string;
logoHome: string;
};
function LanguageSwitch({ locale, path, label }: { locale: Locale; path: string; label: string }) {
return (
<nav className="lang-switch" aria-label={label}>
{locales.map((l) =>
// A plain link: switching language swaps the document's lang and direction.
l === locale ? (
<span key={l} aria-current="true" lang={l} title={localeNames[l]}>
{localeShort[l]}
</span>
) : (
<a key={l} href={localize(path, l)} hrefLang={l} lang={l} title={localeNames[l]}>
{localeShort[l]}
</a>
),
)}
</nav>
);
}
export default function SiteHeader({ locale, nav, t }: { locale: Locale; nav: NavItem[]; t: HeaderLabels }) {
const pathname = usePathname();
const { path } = splitLocale(pathname);
const current = localize(path, locale);
const [scrolled, setScrolled] = useState(false);
const [hidden, setHidden] = useState(false);
const [open, setOpen] = useState(false);
@@ -46,7 +77,8 @@ export default function SiteHeader() {
return () => window.removeEventListener("keydown", onKey);
}, [open]);
const isActive = (href: string) => pathname === href || pathname.startsWith(href + "/");
const isActive = (href: string) => current === href || current.startsWith(href + "/");
const contactHref = localize("/contact", locale);
const classes = ["site-header", scrolled || open ? "is-scrolled" : "", hidden && !open ? "is-hidden" : ""]
.filter(Boolean)
.join(" ");
@@ -55,9 +87,9 @@ export default function SiteHeader() {
<>
<header className={classes}>
<div className="wrap bar">
<Logo />
<nav className="nav" aria-label="Primary">
{mainNav.map((l) =>
<Logo href={localize("/", locale)} label={t.logoHome} />
<nav className="nav" aria-label={t.primary}>
{nav.map((l) =>
l.children ? (
<div key={l.href} className="nav-group">
<Link href={l.href} className={isActive(l.href) ? "is-active" : undefined}>
@@ -66,12 +98,12 @@ export default function SiteHeader() {
<div className="nav-panel">
<div className="nav-panel-inner">
<Link href={l.href} className="nav-panel-all">
All {l.label} <span aria-hidden="true">→</span>
{l.allLabel} <span className="arrow" aria-hidden="true">→</span>
</Link>
<ul>
{l.children.map((c) => (
<li key={c.href}>
<Link href={c.href} className={pathname === c.href ? "is-active" : undefined}>
<Link href={c.href} className={current === c.href ? "is-active" : undefined}>
{c.label}
</Link>
</li>
@@ -87,15 +119,16 @@ export default function SiteHeader() {
),
)}
</nav>
<Link href="/contact" className="btn btn-solid header-cta">
Book a Strategy Call <span className="arrow">→</span>
<LanguageSwitch locale={locale} path={path} label={t.language} />
<Link href={contactHref} className="btn btn-solid header-cta">
{t.bookCall} <span className="arrow">→</span>
</Link>
<button
type="button"
className="menu-toggle"
aria-expanded={open}
aria-controls="mobile-menu"
aria-label={open ? "Close menu" : "Open menu"}
aria-label={open ? t.closeMenu : t.openMenu}
onClick={() => setOpen((v) => !v)}
>
<span />
@@ -105,10 +138,10 @@ export default function SiteHeader() {
</header>
<div id="mobile-menu" className={`mobile-menu${open ? " is-open" : ""}`} aria-hidden={!open} inert={!open}>
<ul>
{mainNav.map((l) => (
{nav.map((l) => (
<li key={l.href}>
<Link href={l.href}>
{l.label} <span aria-hidden="true">→</span>
{l.label} <span className="arrow" aria-hidden="true">→</span>
</Link>
{l.children && (
<div className="mobile-sub">
@@ -122,8 +155,9 @@ export default function SiteHeader() {
</li>
))}
</ul>
<Link href="/contact" className="btn btn-solid">
Book a Strategy Call <span className="arrow">→</span>
<LanguageSwitch locale={locale} path={path} label={t.language} />
<Link href={contactHref} className="btn btn-solid">
{t.bookCall} <span className="arrow">→</span>
</Link>
</div>
</>
-134
View File
@@ -1,134 +0,0 @@
export const stats = [
{ value: 120, suffix: "%", label: "Average ROI Increase" },
{ value: 7, suffix: "", label: "Global Markets" },
{ value: 24, suffix: "", label: "Specialized Channels" },
{ value: 99, suffix: "%", label: "Platform Uptime" },
];
export type Capability = {
name: string;
headline: string;
body: string[];
cta: string;
};
export const capabilities: Capability[] = [
{
name: "Paid Media",
headline: "Make Every Dollar Work Harder.",
body: [
"Plan and manage campaigns across Google, Meta, and other relevant platforms around qualified demand, conversion, and measurable return.",
],
cta: "Explore Paid Media",
},
{
name: "SEO",
headline: "Be There When Someone Goes Looking.",
body: [
"Connect technical SEO, content, authority, and search intent to strengthen organic visibility where it can influence real business decisions.",
],
cta: "Explore SEO",
},
{
name: "LLMO & AI Visibility",
headline: "Be Part of the Answer.",
body: [
"People are asking ChatGPT, Gemini, AI Overviews, Perplexity, and other generative platforms questions they once took straight to search.",
"We help brands become easier for these systems to understand, retrieve, reference, and surface.",
],
cta: "Explore LLMO & AI",
},
{
name: "Web Development",
headline: "Give the Click Somewhere Worth Landing.",
body: [
"Create fast, scalable digital experiences around the people using them, the actions you want them to take, and the marketing bringing them there.",
],
cta: "Explore Web Development",
},
{
name: "Social Media",
headline: "Stay Relevant Between Searches.",
body: [
"Build familiarity, stay part of relevant conversations, and give people another reason to remember the brand when they are ready to act.",
],
cta: "Explore Social Media",
},
{
name: "Cybersecurity",
headline: "Protect What Growth Depends On.",
body: [
"Strengthen the systems, access, monitoring, and digital infrastructure supporting the business as it grows.",
],
cta: "Explore Cybersecurity",
},
{
name: "Brand",
headline: "Give People Something to Remember.",
body: [
"Bring positioning, messaging, identity, and design together so the company feels like the same brand wherever people find it.",
],
cta: "Explore Brand",
},
{
name: "Consulting",
headline: "Some Problems Don’t Belong to One Channel.",
body: [
"Sometimes traffic is not the problem.",
"Sometimes it is the offer. The website. The measurement. Or several good ideas that simply are not working well together.",
"We look across the wider system to find what actually deserves attention.",
],
cta: "Explore Consulting",
},
];
export const products = [
{
name: "Atrilyx",
kind: "Media & Attribution Engine",
headline: "Follow the Journey, Not Just the Last Click.",
body: [
"A unified media and attribution engine built to connect signals across the customer journey and give a clearer view of what helped move someone toward conversion.",
],
cta: "Explore Atrilyx",
visual: "journey",
},
{
name: "meSEO",
kind: "Search & AI Visibility",
headline: "Search Has a New Surface Area.",
body: [
"Organic visibility no longer stops at the traditional results page.",
"meSEO brings search and emerging AI visibility into a more connected view, helping brands understand where they appear, where they do not, and where the next opportunity may be.",
],
cta: "Explore meSEO",
visual: "radar",
},
{
name: "Korrith",
kind: "Security & Resilience",
headline: "Keep the Digital Foundation Strong.",
body: [
"A security and resilience layer built around the systems businesses increasingly depend on as they grow.",
],
cta: "Explore Korrith",
visual: "grid",
},
] as const;
export const journeySteps = [
{ channel: "Paid Media", label: "A customer sees an ad." },
{ channel: "Search", label: "A week later, they search your name." },
{ channel: "Website", label: "They read a page." },
{ channel: "Brand", label: "They encounter the brand again." },
{ channel: "AI Platform", label: "Maybe they ask an AI platform about your category." },
{ channel: "Direct", label: "Then they come back directly and convert." },
];
export const gaps = [
"You can have strong SEO and a website that loses the visitor.",
"Great ads and weak attribution.",
"A memorable brand nobody can find.",
"Plenty of data and no clear idea what to do with it.",
"Or an AI strategy sitting in a presentation while the rest of the business keeps working exactly as it did before.",
];
+232
View File
@@ -0,0 +1,232 @@
import type { Home } from "./en";
export const ar: Home = {
hero: {
eyebrow: "مهندسو النمو الرقمي",
title: "حيث يعمل البحث والذكاء الاصطناعي والإعلام والتقنية معًا.",
lead: "قد يجدك عميلك اليوم عبر Google، وغدًا عبر إجابة من الذكاء الاصطناعي، وبعد ثلاثة أيام عبر إعلان مدفوع.",
leadMuted: "عملاؤك لا يفكرون بمنطق القنوات، ولا ينبغي لاستراتيجية نموك أن تفعل ذلك.",
body: "تجمع AI Growth Technologies بين الإعلانات المدفوعة والبحث والظهور في الذكاء الاصطناعي والتقنية وقياس الأثر، ليعزّز كل جزء منها الأجزاء الأخرى.",
highlight: "استراتيجية واحدة. تنفيذ مترابط. رؤية أوضح لما يدفع النمو فعلًا.",
primary: "احجز مكالمة استراتيجية",
secondary: "استكشف الخدمات",
channels: ["الإعلانات المدفوعة", "البحث", "الظهور في الذكاء الاصطناعي", "التقنية", "قياس الأثر"],
scroll: "مرّر",
},
numbers: {
eyebrow: "الأرقام وراء العمل",
title: "النمو الحقيقي يترك أثرًا واضحًا.",
stats: [
{ value: 120, suffix: "%", label: "متوسط زيادة العائد على الاستثمار" },
{ value: 7, suffix: "", label: "أسواق عالمية" },
{ value: 24, suffix: "", label: "قناة متخصصة" },
{ value: 99, suffix: "%", label: "جاهزية المنصة" },
],
},
capabilities: {
eyebrow: "كل شيء يعمل أفضل حين يكون مترابطًا",
title: ["ثماني قدرات.", "محرّك نمو واحد."],
lead: "قد يقود البحث إلى إعلان، وقد يقود الإعلان إلى زيارة الموقع، وقد تقدّم إجابة من الذكاء الاصطناعي علامتك التجارية قبل أن يحدث أيٌّ منهما.",
copy: "بالنسبة لعميلك، هذا ليس تحسينًا لمحركات البحث وإعلانات مدفوعة وذكاءً اصطناعيًا وموقعًا إلكترونيًا.",
highlight: "إنها تجربة واحدة مع عملك.",
after: "وقد صُمّمت قدراتنا لتعمل بالطريقة نفسها.",
core: "النمو",
items: [
{
name: "الإعلانات المدفوعة",
headline: "اجعل كل دولار يعمل بجهد أكبر.",
body: [
"نخطط للحملات ونديرها عبر Google وMeta والمنصات الأخرى المناسبة، مع التركيز على الطلب المؤهل والتحويل والعائد القابل للقياس.",
],
cta: "استكشف الإعلانات المدفوعة",
href: "/services/paid-media",
},
{
name: "تحسين محركات البحث (SEO)",
headline: "كن حاضرًا عندما يبحث أحدهم.",
body: [
"نربط بين تحسين محركات البحث التقني والمحتوى والموثوقية ونية البحث لتعزيز الظهور العضوي حيث يمكنه التأثير في قرارات العمل الحقيقية.",
],
cta: "استكشف SEO",
href: "/services/seo",
},
{
name: "LLMO والظهور في الذكاء الاصطناعي",
headline: "كن جزءًا من الإجابة.",
body: [
"يطرح الناس اليوم على ChatGPT وGemini وAI Overviews وPerplexity وغيرها من المنصات التوليدية أسئلة كانوا يكتبونها في محرك البحث مباشرة.",
"نساعد العلامات التجارية على أن تصبح أسهل على هذه الأنظمة في الفهم والاسترجاع والاستشهاد والعرض.",
],
cta: "استكشف LLMO والذكاء الاصطناعي",
href: "/services/llmo",
},
{
name: "تطوير المواقع",
headline: "امنح النقرة وجهة تستحق الوصول إليها.",
body: [
"نبني تجارب رقمية سريعة وقابلة للتوسع، مصممة حول الأشخاص الذين يستخدمونها والإجراءات التي تريدهم أن يتخذوها والتسويق الذي يقودهم إليها.",
],
cta: "استكشف تطوير المواقع",
href: "/services/web-development",
},
{
name: "وسائل التواصل الاجتماعي",
headline: "ابقَ حاضرًا بين عمليات البحث.",
body: [
"ابنِ الألفة، وكن جزءًا من المحادثات المهمة، وامنح الناس سببًا إضافيًا لتذكّر علامتك التجارية حين يصبحون مستعدين للتصرف.",
],
cta: "استكشف وسائل التواصل الاجتماعي",
href: "/services/social-media",
},
{
name: "الأمن السيبراني",
headline: "احمِ ما يعتمد عليه النمو.",
body: ["نعزّز الأنظمة وصلاحيات الوصول والمراقبة والبنية التحتية الرقمية التي تدعم عملك أثناء نموه."],
cta: "استكشف الأمن السيبراني",
href: "/services/cybersecurity",
},
{
name: "العلامة التجارية",
headline: "امنح الناس شيئًا يتذكرونه.",
body: [
"نجمع بين التموضع والرسائل والهوية والتصميم لتبدو شركتك العلامة التجارية نفسها أينما وجدها الناس.",
],
cta: "استكشف العلامة التجارية",
href: "/services/brand",
},
{
name: "الاستشارات",
headline: "بعض المشكلات لا تنتمي إلى قناة واحدة.",
body: [
"أحيانًا لا تكون المشكلة في حجم الزيارات.",
"أحيانًا تكون في العرض. أو الموقع الإلكتروني. أو القياس. أو في عدة أفكار جيدة لا تعمل معًا بشكل جيد.",
"ننظر إلى المنظومة بأكملها لنحدد ما يستحق الاهتمام فعلًا.",
],
cta: "استكشف الاستشارات",
href: "/services/consulting",
},
],
},
technology: {
eyebrow: "تقنياتنا الخاصة",
title: "أردنا إجابات أفضل، فبنينا أدوات أفضل.",
lead: "التسويق ينتج الكثير من الأرقام.",
leadMuted: "الأصعب هو معرفة أيّها مهم.",
copy: "تمنح تقنياتنا الخاصة فرقنا وعملاءنا رؤية أوضح لقياس الأثر، والظهور العضوي وفي الذكاء الاصطناعي، والمرونة الرقمية.",
closing: "ليس مزيدًا من البيانات لمجرد البيانات.",
closingHighlight: "بل معلومات أفضل للقرارات القادمة.",
products: [
{
name: "Atrilyx",
kind: "محرّك الإعلام وقياس الأثر",
headline: "تتبّع الرحلة كاملة، لا النقرة الأخيرة فقط.",
body: [
"محرّك موحّد للإعلام وقياس الأثر، صُمّم لربط الإشارات عبر رحلة العميل وتقديم رؤية أوضح لما ساعد في دفع الشخص نحو التحويل.",
],
cta: "استكشف Atrilyx",
href: "/services/paid-media",
visual: "journey",
},
{
name: "meSEO",
kind: "البحث والظهور في الذكاء الاصطناعي",
headline: "للبحث مساحة ظهور جديدة.",
body: [
"لم يعد الظهور العضوي يتوقف عند صفحة النتائج التقليدية.",
"يجمع meSEO بين البحث والظهور الناشئ في الذكاء الاصطناعي في رؤية أكثر ترابطًا، ليساعد العلامات التجارية على فهم أين تظهر، وأين لا تظهر، وأين قد تكون الفرصة التالية.",
],
cta: "استكشف meSEO",
href: "/services/seo",
visual: "radar",
},
{
name: "Korrith",
kind: "الأمن والمرونة",
headline: "حافظ على قوة الأساس الرقمي.",
body: ["طبقة أمن ومرونة مبنية حول الأنظمة التي تعتمد عليها الشركات أكثر فأكثر مع نموها."],
cta: "استكشف Korrith",
href: "/services/cybersecurity",
visual: "grid",
},
],
visuals: {
sources: ["إعلان", "بحث", "صفحة", "علامة", "إجابة ذكاء", "تواصل"],
conversion: "التحويل",
journeyCaption: "نقاط التواصل ← المساهمة الموزونة",
search: "البحث",
radarCaption: "مساحة الظهور — البحث + التوليدي",
gridCaption: "طبقة المرونة — مراقبة مستمرة",
},
},
discovery: {
eyebrow: "طريقة الاكتشاف تتغيّر",
title: "قد يسأل عميلك القادم قبل أن يبحث.",
queryLabel: "يسأل الناس الآن منصات الذكاء الاصطناعي",
queries: ["ما الشركات التي يجب أن أفكر فيها؟", "كيف تُقارن هذه المنتجات؟", "ماذا يجب أن أشتري؟", "بمن يجب أن أثق؟"],
searchPills: ["Google", "الترتيب", "الموثوقية"],
yourBrand: "علامتك التجارية",
lead: "ما زال الناس يبحثون في Google.",
leadMuted:
"لكنهم الآن يسألون أيضًا منصات الذكاء الاصطناعي عن الشركات التي يجب التفكير فيها، وكيف تُقارن المنتجات، وماذا يشترون، وبمن يثقون.",
highlight: "وهذا يغيّر معنى الظهور.",
copy: [
"ما زال الترتيب في نتائج البحث مهمًا. لكن الأهم أيضًا هو المعلومات والموثوقية والسياق والإشارات التي تساعد أنظمة الذكاء الاصطناعي على فهم العلامة التجارية بما يكفي لعرضها.",
"يربط عملنا في SEO وLLMO بين جانبي هذا التحول.",
],
cta: "استكشف الظهور في الذكاء الاصطناعي",
},
journey: {
eyebrow: "من الانتباه إلى الفعل",
title: "النقرة الأخيرة نادرًا ما تروي القصة كاملة.",
steps: [
{ channel: "الإعلانات المدفوعة", label: "يرى العميل إعلانًا." },
{ channel: "البحث", label: "بعد أسبوع، يبحث عن اسمك." },
{ channel: "الموقع الإلكتروني", label: "يقرأ إحدى الصفحات." },
{ channel: "العلامة التجارية", label: "يصادف العلامة التجارية مرة أخرى." },
{ channel: "منصة ذكاء اصطناعي", label: "ربما يسأل منصة ذكاء اصطناعي عن مجال عملك." },
{ channel: "مباشر", label: "ثم يعود مباشرة ويُتمّ التحويل." },
],
question: "أي تفاعل يستحق الفضل؟",
copy: [
"في أحيان كثيرة، تعتمد الإجابة على لوحة البيانات التي تفتحها.",
"نقرّب بين الإعلام والبحث والظهور في الذكاء الاصطناعي وقياس الأثر للإجابة عن سؤال أكثر فائدة:",
],
quoteEyebrow: "السؤال الأكثر فائدة",
quote: "ما الذي ساعد فعلًا في دفع العميل نحو اتخاذ القرار؟",
caption: "قياس الأثر الأفضل لا يعني إعداد تقرير أجمل.",
captionHighlight: "بل يعني معرفة أين يملك الدولار التالي أفضل فرصة لتحقيق شيء مفيد.",
cta: "تعرّف على نهجنا",
},
difference: {
eyebrow: "الفرق يكمن في الترابط",
title: "القنوات الجيدة لا تصنع منظومة جيدة تلقائيًا.",
gaps: [
"قد يكون لديك SEO قوي وموقع إلكتروني يفقد الزائر.",
"إعلانات رائعة وقياس أثر ضعيف.",
"علامة تجارية لا تُنسى لكن لا يجدها أحد.",
"بيانات كثيرة دون فكرة واضحة عمّا يجب فعله بها.",
"أو استراتيجية للذكاء الاصطناعي حبيسة عرض تقديمي، بينما يواصل باقي العمل كما كان تمامًا.",
],
gapTitle: "هذه هي الفجوة التي نهتم بها.",
copy: "ننظر إلى كيفية تأثير الاكتشاف واكتساب العملاء والتجربة والبيانات والتقنية والأمن في بعضها البعض.",
highlight: "ثم نعزّز الروابط التي تجعل المنظومة بأكملها تعمل بشكل أفضل.",
noLabel: "رقم",
nos: [
"لا قناة تتنازع على الفضل.",
"لا تقنية تُضاف لمجرد أنها جديدة.",
"لا لوحة بيانات تدّعي أن الأرقام تعني أكثر مما تعنيه.",
],
closing: "فقط منظومة أوضح، وفريق مسؤول عن إنجاحها.",
},
cta: {
eyebrow: "جاهزون متى كنت جاهزًا",
title: "أخبرنا عن تحدي النمو الذي لا تستطيع تحديده بدقة.",
copy: [
"ربما أصبحت تكلفة اكتساب العملاء أعلى. ربما توقف نمو الظهور العضوي. ربما يغيّر الذكاء الاصطناعي الطريقة التي يجدك بها العملاء.",
"أو ربما تبدو كل لوحات البيانات جيدة، لكن عملك يخبرك بشيء مختلف.",
],
lead: "لا تحتاج إلى تشخيص المشكلة قبل المكالمة.",
leadMuted: "شاركنا ما تلاحظه، وسنبدأ من هناك.",
button: "احجز مكالمتك الاستراتيجية",
},
};
+241
View File
@@ -0,0 +1,241 @@
// Homepage copy. Translations mirror this shape; hrefs are locale-free and get prefixed on render.
export const en = {
hero: {
eyebrow: "Digital Growth Architects",
title: "Where Search, AI, Media, and Technology Work Together.",
lead: "Your customer might find you through Google today, an AI answer tomorrow, and a paid ad three days later.",
leadMuted: "They don’t think in channels. Your growth strategy shouldn’t either.",
body: "AI Growth Technologies brings paid media, search, AI visibility, technology, and attribution together so each part strengthens the others.",
highlight: "One strategy. Connected execution. A clearer view of what is actually driving growth.",
primary: "Book a Strategy Call",
secondary: "Explore Services",
channels: ["Paid Media", "Search", "AI Visibility", "Technology", "Attribution"],
scroll: "Scroll",
},
numbers: {
eyebrow: "The Numbers Behind the Work",
title: "Good Growth Leaves Evidence.",
stats: [
{ value: 120, suffix: "%", label: "Average ROI Increase" },
{ value: 7, suffix: "", label: "Global Markets" },
{ value: 24, suffix: "", label: "Specialized Channels" },
{ value: 99, suffix: "%", label: "Platform Uptime" },
],
},
capabilities: {
eyebrow: "Everything Works Better Connected",
title: ["Eight Capabilities.", "One Growth Engine."],
lead: "A search can lead to an ad. An ad can lead to a website visit. An AI answer can introduce the brand before either happens.",
copy: "To your customer, that is not SEO, paid media, AI, and web.",
highlight: "It is one experience with your business.",
after: "Our capabilities are designed to work that way too.",
core: "GROWTH",
items: [
{
name: "Paid Media",
headline: "Make Every Dollar Work Harder.",
body: [
"Plan and manage campaigns across Google, Meta, and other relevant platforms around qualified demand, conversion, and measurable return.",
],
cta: "Explore Paid Media",
href: "/services/paid-media",
},
{
name: "SEO",
headline: "Be There When Someone Goes Looking.",
body: [
"Connect technical SEO, content, authority, and search intent to strengthen organic visibility where it can influence real business decisions.",
],
cta: "Explore SEO",
href: "/services/seo",
},
{
name: "LLMO & AI Visibility",
headline: "Be Part of the Answer.",
body: [
"People are asking ChatGPT, Gemini, AI Overviews, Perplexity, and other generative platforms questions they once took straight to search.",
"We help brands become easier for these systems to understand, retrieve, reference, and surface.",
],
cta: "Explore LLMO & AI",
href: "/services/llmo",
},
{
name: "Web Development",
headline: "Give the Click Somewhere Worth Landing.",
body: [
"Create fast, scalable digital experiences around the people using them, the actions you want them to take, and the marketing bringing them there.",
],
cta: "Explore Web Development",
href: "/services/web-development",
},
{
name: "Social Media",
headline: "Stay Relevant Between Searches.",
body: [
"Build familiarity, stay part of relevant conversations, and give people another reason to remember the brand when they are ready to act.",
],
cta: "Explore Social Media",
href: "/services/social-media",
},
{
name: "Cybersecurity",
headline: "Protect What Growth Depends On.",
body: ["Strengthen the systems, access, monitoring, and digital infrastructure supporting the business as it grows."],
cta: "Explore Cybersecurity",
href: "/services/cybersecurity",
},
{
name: "Brand",
headline: "Give People Something to Remember.",
body: [
"Bring positioning, messaging, identity, and design together so the company feels like the same brand wherever people find it.",
],
cta: "Explore Brand",
href: "/services/brand",
},
{
name: "Consulting",
headline: "Some Problems Don’t Belong to One Channel.",
body: [
"Sometimes traffic is not the problem.",
"Sometimes it is the offer. The website. The measurement. Or several good ideas that simply are not working well together.",
"We look across the wider system to find what actually deserves attention.",
],
cta: "Explore Consulting",
href: "/services/consulting",
},
],
},
technology: {
eyebrow: "Proprietary Technology",
title: "We Wanted Better Answers, So We Built Better Tools.",
lead: "Marketing produces plenty of numbers.",
leadMuted: "The harder part is knowing which ones matter.",
copy: "Our proprietary technology gives our teams and clients a clearer view across attribution, organic and AI visibility, and digital resilience.",
closing: "Not more data for the sake of it.",
closingHighlight: "Better information for the decisions that come next.",
products: [
{
name: "Atrilyx",
kind: "Media & Attribution Engine",
headline: "Follow the Journey, Not Just the Last Click.",
body: [
"A unified media and attribution engine built to connect signals across the customer journey and give a clearer view of what helped move someone toward conversion.",
],
cta: "Explore Atrilyx",
href: "/services/paid-media",
visual: "journey",
},
{
name: "meSEO",
kind: "Search & AI Visibility",
headline: "Search Has a New Surface Area.",
body: [
"Organic visibility no longer stops at the traditional results page.",
"meSEO brings search and emerging AI visibility into a more connected view, helping brands understand where they appear, where they do not, and where the next opportunity may be.",
],
cta: "Explore meSEO",
href: "/services/seo",
visual: "radar",
},
{
name: "Korrith",
kind: "Security & Resilience",
headline: "Keep the Digital Foundation Strong.",
body: [
"A security and resilience layer built around the systems businesses increasingly depend on as they grow.",
],
cta: "Explore Korrith",
href: "/services/cybersecurity",
visual: "grid",
},
] as { name: string; kind: string; headline: string; body: string[]; cta: string; href: string; visual: "journey" | "radar" | "grid" }[],
visuals: {
sources: ["Ad", "Search", "Page", "Brand", "AI Answer", "Social"],
conversion: "Conversion",
journeyCaption: "Touchpoints → weighted contribution",
search: "Search",
radarCaption: "Visibility surface — search + generative",
gridCaption: "Resilience layer — continuous monitoring",
},
},
discovery: {
eyebrow: "Discovery Is Changing",
title: "Your Next Customer May Ask Before They Search.",
queryLabel: "People now ask AI platforms",
queries: [
"Which companies should I consider?",
"How do these products compare?",
"What should I buy?",
"Who should I trust?",
],
searchPills: ["Google", "Rankings", "Authority"],
yourBrand: "Your Brand",
lead: "People still search Google.",
leadMuted:
"Now they also ask AI platforms which companies to consider, how products compare, what to buy, and who to trust.",
highlight: "That changes what visibility means.",
copy: [
"Rankings still matter. But so do the information, authority, context, and signals that help AI systems understand a brand well enough to surface it.",
"Our SEO and LLMO work connects both sides of that shift.",
],
cta: "Explore AI Visibility",
},
journey: {
eyebrow: "From Attention to Action",
title: "The Last Click Rarely Tells the Whole Story.",
steps: [
{ channel: "Paid Media", label: "A customer sees an ad." },
{ channel: "Search", label: "A week later, they search your name." },
{ channel: "Website", label: "They read a page." },
{ channel: "Brand", label: "They encounter the brand again." },
{ channel: "AI Platform", label: "Maybe they ask an AI platform about your category." },
{ channel: "Direct", label: "Then they come back directly and convert." },
],
question: "Which interaction gets the credit?",
copy: [
"Too often, the answer depends on which dashboard you open.",
"We bring media, search, AI visibility, and attribution closer together to answer a more useful question:",
],
quoteEyebrow: "The more useful question",
quote: "What actually helped move the customer toward a decision?",
caption: "Better attribution isn’t about producing a prettier report.",
captionHighlight: "It is about knowing where the next dollar has the best chance of doing something useful.",
cta: "Explore Our Approach",
},
difference: {
eyebrow: "The Difference Is in the Connections",
title: "Good Channels Don’t Automatically Make a Good System.",
gaps: [
"You can have strong SEO and a website that loses the visitor.",
"Great ads and weak attribution.",
"A memorable brand nobody can find.",
"Plenty of data and no clear idea what to do with it.",
"Or an AI strategy sitting in a presentation while the rest of the business keeps working exactly as it did before.",
],
gapTitle: "That is the gap we care about.",
copy: "We look at how discovery, acquisition, experience, data, technology, and security affect one another.",
highlight: "Then we strengthen the connections that make the whole system perform better.",
noLabel: "No.",
nos: [
"No channel fighting for credit.",
"No technology added just because it is new.",
"No dashboard pretending the numbers mean more than they do.",
],
closing: "Just a clearer system and a team accountable for making it work.",
},
cta: {
eyebrow: "Ready When You Are",
title: "Bring Us the Growth Problem You Can’t Quite Pin Down.",
copy: [
"Maybe acquisition is getting more expensive. Maybe organic visibility has stalled. Maybe AI is changing how customers find you.",
"Or maybe every dashboard looks healthy, but the business is telling you something different.",
],
lead: "You don’t need to diagnose it before the call.",
leadMuted: "Bring us what you’re seeing. We’ll start there.",
button: "Book Your Strategy Call",
},
};
export type Home = typeof en;
+241
View File
@@ -0,0 +1,241 @@
import type { Home } from "./en";
export const es: Home = {
hero: {
eyebrow: "Arquitectos del crecimiento digital",
title: "Donde la búsqueda, la IA, los medios y la tecnología trabajan juntos.",
lead: "Tu cliente podría encontrarte hoy en Google, mañana en una respuesta de IA y tres días después en un anuncio pagado.",
leadMuted: "Tus clientes no piensan en canales. Tu estrategia de crecimiento tampoco debería hacerlo.",
body: "AI Growth Technologies une medios pagados, búsqueda, visibilidad en IA, tecnología y atribución para que cada parte fortalezca a las demás.",
highlight: "Una estrategia. Una ejecución conectada. Una visión más clara de lo que realmente impulsa el crecimiento.",
primary: "Agenda una llamada estratégica",
secondary: "Ver servicios",
channels: ["Medios pagados", "Búsqueda", "Visibilidad en IA", "Tecnología", "Atribución"],
scroll: "Desliza",
},
numbers: {
eyebrow: "Los números detrás del trabajo",
title: "El buen crecimiento deja evidencia.",
stats: [
{ value: 120, suffix: "%", label: "Aumento promedio del ROI" },
{ value: 7, suffix: "", label: "Mercados globales" },
{ value: 24, suffix: "", label: "Canales especializados" },
{ value: 99, suffix: "%", label: "Disponibilidad de la plataforma" },
],
},
capabilities: {
eyebrow: "Todo funciona mejor conectado",
title: ["Ocho capacidades.", "Un solo motor de crecimiento."],
lead: "Una búsqueda puede llevar a un anuncio. Un anuncio puede llevar a una visita al sitio web. Una respuesta de IA puede presentar la marca antes de que ocurra cualquiera de las dos.",
copy: "Para tu cliente, eso no es SEO, medios pagados, IA y web.",
highlight: "Es una sola experiencia con tu negocio.",
after: "Nuestras capacidades están diseñadas para funcionar de la misma manera.",
core: "CRECIMIENTO",
items: [
{
name: "Medios pagados",
headline: "Haz que cada dólar rinda más.",
body: [
"Planificamos y gestionamos campañas en Google, Meta y otras plataformas relevantes, enfocadas en demanda calificada, conversión y retorno medible.",
],
cta: "Ver medios pagados",
href: "/services/paid-media",
},
{
name: "SEO",
headline: "Está ahí cuando alguien te busca.",
body: [
"Conectamos SEO técnico, contenido, autoridad e intención de búsqueda para fortalecer la visibilidad orgánica donde puede influir en decisiones reales de negocio.",
],
cta: "Ver SEO",
href: "/services/seo",
},
{
name: "LLMO y visibilidad en IA",
headline: "Forma parte de la respuesta.",
body: [
"Hoy la gente le pregunta a ChatGPT, Gemini, AI Overviews, Perplexity y otras plataformas generativas lo que antes buscaba directamente en Google.",
"Ayudamos a las marcas a ser más fáciles de entender, recuperar, citar y mostrar para estos sistemas.",
],
cta: "Ver LLMO e IA",
href: "/services/llmo",
},
{
name: "Desarrollo web",
headline: "Dale al clic un lugar que valga la pena.",
body: [
"Creamos experiencias digitales rápidas y escalables pensadas en las personas que las usan, las acciones que quieres que realicen y el marketing que las lleva hasta ahí.",
],
cta: "Ver desarrollo web",
href: "/services/web-development",
},
{
name: "Redes sociales",
headline: "Sigue siendo relevante entre búsquedas.",
body: [
"Genera familiaridad, mantente presente en las conversaciones relevantes y dale a la gente otra razón para recordar la marca cuando esté lista para actuar.",
],
cta: "Ver redes sociales",
href: "/services/social-media",
},
{
name: "Ciberseguridad",
headline: "Protege aquello de lo que depende el crecimiento.",
body: [
"Fortalecemos los sistemas, los accesos, el monitoreo y la infraestructura digital que sostienen el negocio a medida que crece.",
],
cta: "Ver ciberseguridad",
href: "/services/cybersecurity",
},
{
name: "Marca",
headline: "Dale a la gente algo que recordar.",
body: [
"Unimos posicionamiento, mensaje, identidad y diseño para que la empresa se sienta como la misma marca dondequiera que la encuentren.",
],
cta: "Ver marca",
href: "/services/brand",
},
{
name: "Consultoría",
headline: "Algunos problemas no pertenecen a un solo canal.",
body: [
"A veces el problema no es el tráfico.",
"A veces es la oferta. El sitio web. La medición. O varias buenas ideas que simplemente no están funcionando bien juntas.",
"Miramos el sistema completo para encontrar lo que realmente merece atención.",
],
cta: "Ver consultoría",
href: "/services/consulting",
},
],
},
technology: {
eyebrow: "Tecnología propia",
title: "Queríamos mejores respuestas, así que construimos mejores herramientas.",
lead: "El marketing produce muchísimos números.",
leadMuted: "Lo difícil es saber cuáles importan.",
copy: "Nuestra tecnología propia les da a nuestros equipos y clientes una visión más clara de la atribución, la visibilidad orgánica y en IA, y la resiliencia digital.",
closing: "No más datos por tener más datos.",
closingHighlight: "Mejor información para las decisiones que vienen.",
products: [
{
name: "Atrilyx",
kind: "Motor de medios y atribución",
headline: "Sigue el recorrido, no solo el último clic.",
body: [
"Un motor unificado de medios y atribución que conecta las señales a lo largo del recorrido del cliente y ofrece una visión más clara de lo que ayudó a acercar a alguien a la conversión.",
],
cta: "Ver Atrilyx",
href: "/services/paid-media",
visual: "journey",
},
{
name: "meSEO",
kind: "Búsqueda y visibilidad en IA",
headline: "La búsqueda tiene una nueva superficie.",
body: [
"La visibilidad orgánica ya no termina en la página de resultados tradicional.",
"meSEO reúne la búsqueda y la visibilidad emergente en IA en una vista más conectada, para que las marcas entiendan dónde aparecen, dónde no y dónde puede estar la próxima oportunidad.",
],
cta: "Ver meSEO",
href: "/services/seo",
visual: "radar",
},
{
name: "Korrith",
kind: "Seguridad y resiliencia",
headline: "Mantén fuerte la base digital.",
body: [
"Una capa de seguridad y resiliencia construida alrededor de los sistemas de los que las empresas dependen cada vez más a medida que crecen.",
],
cta: "Ver Korrith",
href: "/services/cybersecurity",
visual: "grid",
},
],
visuals: {
sources: ["Anuncio", "Búsqueda", "Página", "Marca", "Respuesta IA", "Redes"],
conversion: "Conversión",
journeyCaption: "Puntos de contacto → contribución ponderada",
search: "Búsqueda",
radarCaption: "Superficie de visibilidad — búsqueda + generativa",
gridCaption: "Capa de resiliencia — monitoreo continuo",
},
},
discovery: {
eyebrow: "El descubrimiento está cambiando",
title: "Tu próximo cliente puede preguntar antes de buscar.",
queryLabel: "Hoy la gente le pregunta a la IA",
queries: [
"¿Qué empresas debería considerar?",
"¿Cómo se comparan estos productos?",
"¿Qué debería comprar?",
"¿En quién debería confiar?",
],
searchPills: ["Google", "Rankings", "Autoridad"],
yourBrand: "Tu marca",
lead: "La gente todavía busca en Google.",
leadMuted:
"Ahora también le pregunta a las plataformas de IA qué empresas considerar, cómo se comparan los productos, qué comprar y en quién confiar.",
highlight: "Eso cambia lo que significa ser visible.",
copy: [
"Los rankings siguen importando. Pero también la información, la autoridad, el contexto y las señales que ayudan a los sistemas de IA a entender una marca lo suficiente como para mostrarla.",
"Nuestro trabajo de SEO y LLMO conecta ambos lados de ese cambio.",
],
cta: "Ver visibilidad en IA",
},
journey: {
eyebrow: "De la atención a la acción",
title: "El último clic rara vez cuenta toda la historia.",
steps: [
{ channel: "Medios pagados", label: "Un cliente ve un anuncio." },
{ channel: "Búsqueda", label: "Una semana después, busca tu nombre." },
{ channel: "Sitio web", label: "Lee una página." },
{ channel: "Marca", label: "Vuelve a encontrarse con la marca." },
{ channel: "Plataforma de IA", label: "Quizás le pregunta a una IA sobre tu categoría." },
{ channel: "Directo", label: "Luego regresa directamente y convierte." },
],
question: "¿Qué interacción se lleva el crédito?",
copy: [
"Con demasiada frecuencia, la respuesta depende del dashboard que abras.",
"Acercamos medios, búsqueda, visibilidad en IA y atribución para responder una pregunta más útil:",
],
quoteEyebrow: "La pregunta más útil",
quote: "¿Qué ayudó realmente a acercar al cliente a una decisión?",
caption: "Una mejor atribución no se trata de producir un reporte más bonito.",
captionHighlight: "Se trata de saber dónde el próximo dólar tiene más posibilidades de hacer algo útil.",
cta: "Conoce nuestro enfoque",
},
difference: {
eyebrow: "La diferencia está en las conexiones",
title: "Buenos canales no crean automáticamente un buen sistema.",
gaps: [
"Puedes tener un SEO sólido y un sitio web que pierde al visitante.",
"Anuncios excelentes y una atribución débil.",
"Una marca memorable que nadie encuentra.",
"Muchísimos datos y ninguna idea clara de qué hacer con ellos.",
"O una estrategia de IA guardada en una presentación mientras el resto del negocio sigue funcionando exactamente igual que antes.",
],
gapTitle: "Esa es la brecha que nos importa.",
copy: "Analizamos cómo el descubrimiento, la adquisición, la experiencia, los datos, la tecnología y la seguridad se afectan entre sí.",
highlight: "Luego fortalecemos las conexiones que hacen que todo el sistema rinda mejor.",
noLabel: "N.º",
nos: [
"Ningún canal peleando por el crédito.",
"Ninguna tecnología añadida solo porque es nueva.",
"Ningún dashboard que pretenda que los números significan más de lo que significan.",
],
closing: "Solo un sistema más claro y un equipo responsable de hacerlo funcionar.",
},
cta: {
eyebrow: "Listos cuando tú lo estés",
title: "Tráenos el problema de crecimiento que no logras identificar.",
copy: [
"Quizás la adquisición se está encareciendo. Quizás la visibilidad orgánica se estancó. Quizás la IA está cambiando la forma en que los clientes te encuentran.",
"O quizás todos los dashboards se ven bien, pero el negocio te está diciendo algo distinto.",
],
lead: "No necesitas tener el diagnóstico antes de la llamada.",
leadMuted: "Cuéntanos lo que estás viendo. Empezaremos por ahí.",
button: "Agenda tu llamada estratégica",
},
};
+7
View File
@@ -0,0 +1,7 @@
import type { Locale } from "@/i18n/config";
import { en, type Home } from "./en";
import { es } from "./es";
import { ar } from "./ar";
export type { Home };
export const home: Record<Locale, Home> = { en, es, ar };
+87
View File
@@ -0,0 +1,87 @@
// Generated by scripts/i18n.py index — do not edit by hand.
import en_about from "./en/about.json";
import en_contact from "./en/contact.json";
import en_industries from "./en/industries.json";
import en_industries_automotive from "./en/industries_automotive.json";
import en_industries_b2b from "./en/industries_b2b.json";
import en_industries_creators from "./en/industries_creators.json";
import en_industries_e_commerce from "./en/industries_e-commerce.json";
import en_industries_home_services from "./en/industries_home-services.json";
import en_industries_hospitality from "./en/industries_hospitality.json";
import en_industries_medical_dental from "./en/industries_medical-dental.json";
import en_services from "./en/services.json";
import en_services_brand from "./en/services_brand.json";
import en_services_consulting from "./en/services_consulting.json";
import en_services_cybersecurity from "./en/services_cybersecurity.json";
import en_services_llmo from "./en/services_llmo.json";
import en_services_paid_media from "./en/services_paid-media.json";
import en_services_seo from "./en/services_seo.json";
import en_services_social_media from "./en/services_social-media.json";
import en_services_web_development from "./en/services_web-development.json";
export const content = {
en: {
"about": en_about,
"contact": en_contact,
"industries": en_industries,
"industries_automotive": en_industries_automotive,
"industries_b2b": en_industries_b2b,
"industries_creators": en_industries_creators,
"industries_e-commerce": en_industries_e_commerce,
"industries_home-services": en_industries_home_services,
"industries_hospitality": en_industries_hospitality,
"industries_medical-dental": en_industries_medical_dental,
"services": en_services,
"services_brand": en_services_brand,
"services_consulting": en_services_consulting,
"services_cybersecurity": en_services_cybersecurity,
"services_llmo": en_services_llmo,
"services_paid-media": en_services_paid_media,
"services_seo": en_services_seo,
"services_social-media": en_services_social_media,
"services_web-development": en_services_web_development,
},
es: {
"about": en_about,
"contact": en_contact,
"industries": en_industries,
"industries_automotive": en_industries_automotive,
"industries_b2b": en_industries_b2b,
"industries_creators": en_industries_creators,
"industries_e-commerce": en_industries_e_commerce,
"industries_home-services": en_industries_home_services,
"industries_hospitality": en_industries_hospitality,
"industries_medical-dental": en_industries_medical_dental,
"services": en_services,
"services_brand": en_services_brand,
"services_consulting": en_services_consulting,
"services_cybersecurity": en_services_cybersecurity,
"services_llmo": en_services_llmo,
"services_paid-media": en_services_paid_media,
"services_seo": en_services_seo,
"services_social-media": en_services_social_media,
"services_web-development": en_services_web_development,
},
ar: {
"about": en_about,
"contact": en_contact,
"industries": en_industries,
"industries_automotive": en_industries_automotive,
"industries_b2b": en_industries_b2b,
"industries_creators": en_industries_creators,
"industries_e-commerce": en_industries_e_commerce,
"industries_home-services": en_industries_home_services,
"industries_hospitality": en_industries_hospitality,
"industries_medical-dental": en_industries_medical_dental,
"services": en_services,
"services_brand": en_services_brand,
"services_consulting": en_services_consulting,
"services_cybersecurity": en_services_cybersecurity,
"services_llmo": en_services_llmo,
"services_paid-media": en_services_paid_media,
"services_seo": en_services_seo,
"services_social-media": en_services_social_media,
"services_web-development": en_services_web_development,
},
};
+32
View File
@@ -0,0 +1,32 @@
// Locale settings shared by the proxy, server components and client components.
export const locales = ["en", "es", "ar"] as const;
export type Locale = (typeof locales)[number];
/** English is served from the root (`/about`); the others carry a prefix (`/es/about`). */
export const defaultLocale: Locale = "en";
export const localeNames: Record<Locale, string> = { en: "English", es: "Español", ar: "العربية" };
export const localeShort: Record<Locale, string> = { en: "EN", es: "ES", ar: "ع" };
/** Open Graph locale tags. */
export const ogLocale: Record<Locale, string> = { en: "en_US", es: "es_ES", ar: "ar_AE" };
export const isLocale = (value: string | undefined): value is Locale =>
(locales as readonly string[]).includes(value ?? "");
export const dirOf = (locale: Locale) => (locale === "ar" ? "rtl" : "ltr");
/** Prefix an internal path with the locale. Hashes and external URLs pass through untouched. */
export function localize(path: string, locale: Locale): string {
if (locale === defaultLocale || !path.startsWith("/")) return path;
return path === "/" ? `/${locale}` : `/${locale}${path}`;
}
/** Split a browser pathname into its locale and the locale-free path. */
export function splitLocale(pathname: string): { locale: Locale; path: string } {
const first = pathname.split("/")[1];
if (isLocale(first)) {
return { locale: first, path: pathname.slice(first.length + 1) || "/" };
}
return { locale: defaultLocale, path: pathname };
}
+31
View File
@@ -0,0 +1,31 @@
import { industryNav, serviceNav } from "@/lib/routes";
import { localize, type Locale } from "./config";
import type { UI } from "./ui";
export type NavLink = { label: string; href: string };
export type NavItem = NavLink & { allLabel?: string; children?: NavLink[] };
export const serviceLinks = (t: UI, locale: Locale): NavLink[] =>
serviceNav.map((s) => ({ label: t.services[s.slug], href: localize(`/services/${s.slug}`, locale) }));
export const industryLinks = (t: UI, locale: Locale): NavLink[] =>
industryNav.map((s) => ({ label: t.industries[s.slug], href: localize(`/industries/${s.slug}`, locale) }));
export function mainNav(t: UI, locale: Locale): NavItem[] {
return [
{
label: t.nav.services,
href: localize("/services", locale),
allLabel: t.nav.allServices,
children: serviceLinks(t, locale),
},
{
label: t.nav.industries,
href: localize("/industries", locale),
allLabel: t.nav.allIndustries,
children: industryLinks(t, locale),
},
{ label: t.nav.about, href: localize("/about", locale) },
{ label: t.nav.contact, href: localize("/contact", locale) },
];
}
+16
View File
@@ -0,0 +1,16 @@
import { lang } from "next/root-params";
import { notFound } from "next/navigation";
import { isLocale, type Locale } from "./config";
import { ui, type UI } from "./ui";
/** The locale of the current route, from the root `[lang]` segment. */
export async function getLocale(): Promise<Locale> {
const value = await lang();
if (!isLocale(value)) notFound();
return value;
}
export async function getUI(): Promise<{ locale: Locale; t: UI }> {
const locale = await getLocale();
return { locale, t: ui[locale] };
}
+81
View File
@@ -0,0 +1,81 @@
import type { UI } from "./en";
export const ar: UI = {
meta: {
title: "AI Growth Technologies | النمو الرقمي في عصر الذكاء الاصطناعي",
description:
"تربط AI Growth Technologies بين الإعلانات المدفوعة وتحسين محركات البحث والظهور في الذكاء الاصطناعي والمواقع الإلكترونية وقياس الأثر والتقنية في منظومة نمو رقمي واحدة خاضعة للمساءلة.",
},
skip: "انتقل إلى المحتوى",
logoHome: "AI Growth Technologies — الصفحة الرئيسية",
nav: {
primary: "التنقل الرئيسي",
services: "الخدمات",
industries: "القطاعات",
about: "من نحن",
contact: "تواصل معنا",
allServices: "جميع الخدمات",
allIndustries: "جميع القطاعات",
openMenu: "فتح القائمة",
closeMenu: "إغلاق القائمة",
language: "اللغة",
},
bookCall: "احجز مكالمة استراتيجية",
services: {
"paid-media": "الإعلانات المدفوعة",
seo: "تحسين محركات البحث (SEO)",
llmo: "LLMO والظهور في الذكاء الاصطناعي",
"web-development": "تطوير المواقع",
"social-media": "وسائل التواصل الاجتماعي",
brand: "العلامة التجارية",
consulting: "الاستشارات",
cybersecurity: "الأمن السيبراني",
},
industries: {
hospitality: "الضيافة",
"medical-dental": "الطب وطب الأسنان",
automotive: "السيارات",
"e-commerce": "التجارة الإلكترونية",
"home-services": "الخدمات المنزلية",
b2b: "B2B والبرمجيات كخدمة",
creators: "صنّاع المحتوى والعلامات الشخصية",
},
footer: {
tagline:
"الإعلانات المدفوعة وتحسين محركات البحث والظهور في الذكاء الاصطناعي والمواقع وقياس الأثر والتقنية — مترابطة في منظومة نمو رقمي واحدة خاضعة للمساءلة.",
company: "الشركة",
rights: "© 2026 AI Growth Technologies. جميع الحقوق محفوظة.",
cities: "دبي · هيوستن · ميامي · نيويورك",
},
crumbs: { label: "مسار التنقل", home: "الرئيسية" },
faqTitle: "الأسئلة الشائعة",
office: { phone: "الهاتف", email: "البريد الإلكتروني" },
form: {
name: "الاسم",
email: "البريد الإلكتروني للعمل",
company: "الشركة",
website: "الموقع الإلكتروني",
interests: "ما الذي تبحث عنه؟",
notSure: "لست متأكدًا بعد",
message: "ماذا يحدث في عملك؟",
optional: "اختياري",
submit: "أرسل إلى الفريق",
sending: "جارٍ الإرسال…",
note: "يقرأ أحد خبراء الاستراتيجية كل رسالة ويرد عليها شخصيًا.",
writeTo: "يمكنك أيضًا مراسلتنا على",
sentTitle: "تم إرسال رسالتك بنجاح.",
sentBody: "شكرًا لتواصلك معنا. سيراجع فريقنا ما شاركته وسيعود إليك قريبًا.",
human: {
label: "أنا لست روبوتًا",
checking: "جارٍ التحقق…",
done: "تم التحقق",
retry: "تعذّر التحقق. اضغط للمحاولة مرة أخرى.",
},
errors: {
details: "يرجى إدخال اسمك وبريد إلكتروني صالح.",
human: "يرجى تأكيد أنك لست روبوتًا.",
unavailable: "النموذج غير متاح حاليًا.",
failed: "تعذّر إرسال رسالتك.",
},
},
};
+83
View File
@@ -0,0 +1,83 @@
// Interface strings: navigation, footer, breadcrumbs and the contact form.
export const en = {
meta: {
title: "AI Growth Technologies | Digital Growth for the AI Era",
description:
"AI Growth Technologies connects paid media, SEO, AI visibility, web, attribution, and technology into one accountable digital growth system.",
},
skip: "Skip to content",
logoHome: "AI Growth Technologies — home",
nav: {
primary: "Primary",
services: "Services",
industries: "Industries",
about: "About",
contact: "Contact",
allServices: "All Services",
allIndustries: "All Industries",
openMenu: "Open menu",
closeMenu: "Close menu",
language: "Language",
},
bookCall: "Book a Strategy Call",
services: {
"paid-media": "Paid Media",
seo: "SEO",
llmo: "LLMO & AI Visibility",
"web-development": "Web Development",
"social-media": "Social Media",
brand: "Brand",
consulting: "Consulting",
cybersecurity: "Cybersecurity",
},
industries: {
hospitality: "Hospitality",
"medical-dental": "Medical & Dental",
automotive: "Automotive",
"e-commerce": "E-Commerce",
"home-services": "Home Services",
b2b: "B2B & SaaS",
creators: "Creators & Personal Brands",
},
footer: {
tagline:
"Paid media, SEO, AI visibility, web, attribution, and technology — connected into one accountable digital growth system.",
company: "Company",
rights: "© 2026 AI Growth Technologies. All rights reserved.",
cities: "Dubai · Houston · Miami · New York",
},
crumbs: { label: "Breadcrumb", home: "Home" },
faqTitle: "Frequently Asked Questions",
office: { phone: "Phone", email: "Email" },
form: {
name: "Name",
email: "Work email",
company: "Company",
website: "Website",
interests: "What are you looking at?",
notSure: "Not sure yet",
message: "What’s happening in the business?",
optional: "Optional",
submit: "Send to the Team",
sending: "Sending…",
note: "A strategist reads every message and replies personally.",
writeTo: "You can also write to",
sentTitle: "Your message was sent successfully.",
sentBody: "Thank you for reaching out. Our team will review what you shared and get back to you shortly.",
human: {
label: "I’m not a robot",
checking: "Verifying…",
done: "Verified",
retry: "Verification failed. Tap to try again.",
},
errors: {
details: "Please add your name and a valid email.",
human: "Please confirm you’re not a robot.",
unavailable: "The form isn’t available right now.",
failed: "We couldn’t send your message.",
},
},
};
export type UI = typeof en;
+81
View File
@@ -0,0 +1,81 @@
import type { UI } from "./en";
export const es: UI = {
meta: {
title: "AI Growth Technologies | Crecimiento digital para la era de la IA",
description:
"AI Growth Technologies conecta medios pagados, SEO, visibilidad en IA, web, atribución y tecnología en un solo sistema de crecimiento digital con responsables claros.",
},
skip: "Saltar al contenido",
logoHome: "AI Growth Technologies — inicio",
nav: {
primary: "Principal",
services: "Servicios",
industries: "Industrias",
about: "Nosotros",
contact: "Contacto",
allServices: "Todos los servicios",
allIndustries: "Todas las industrias",
openMenu: "Abrir menú",
closeMenu: "Cerrar menú",
language: "Idioma",
},
bookCall: "Agenda una llamada estratégica",
services: {
"paid-media": "Medios pagados",
seo: "SEO",
llmo: "LLMO y visibilidad en IA",
"web-development": "Desarrollo web",
"social-media": "Redes sociales",
brand: "Marca",
consulting: "Consultoría",
cybersecurity: "Ciberseguridad",
},
industries: {
hospitality: "Hotelería y hospitalidad",
"medical-dental": "Médico y dental",
automotive: "Automotriz",
"e-commerce": "E-commerce",
"home-services": "Servicios para el hogar",
b2b: "B2B y SaaS",
creators: "Creadores y marcas personales",
},
footer: {
tagline:
"Medios pagados, SEO, visibilidad en IA, web, atribución y tecnología, conectados en un solo sistema de crecimiento digital con responsables claros.",
company: "Empresa",
rights: "© 2026 AI Growth Technologies. Todos los derechos reservados.",
cities: "Dubái · Houston · Miami · Nueva York",
},
crumbs: { label: "Ruta de navegación", home: "Inicio" },
faqTitle: "Preguntas frecuentes",
office: { phone: "Teléfono", email: "Correo" },
form: {
name: "Nombre",
email: "Correo de trabajo",
company: "Empresa",
website: "Sitio web",
interests: "¿Qué te interesa?",
notSure: "Aún no lo sé",
message: "¿Qué está pasando en el negocio?",
optional: "Opcional",
submit: "Enviar al equipo",
sending: "Enviando…",
note: "Un estratega lee cada mensaje y responde personalmente.",
writeTo: "También puedes escribirnos a",
sentTitle: "Tu mensaje se envió correctamente.",
sentBody: "Gracias por escribirnos. Nuestro equipo revisará lo que compartiste y te responderá muy pronto.",
human: {
label: "No soy un robot",
checking: "Verificando…",
done: "Verificado",
retry: "No se pudo verificar. Toca para intentarlo de nuevo.",
},
errors: {
details: "Escribe tu nombre y un correo válido.",
human: "Confirma que no eres un robot.",
unavailable: "El formulario no está disponible en este momento.",
failed: "No pudimos enviar tu mensaje.",
},
},
};
+7
View File
@@ -0,0 +1,7 @@
import type { Locale } from "../config";
import { en, type UI } from "./en";
import { es } from "./es";
import { ar } from "./ar";
export type { UI };
export const ui: Record<Locale, UI> = { en, es, ar };
+41
View File
@@ -0,0 +1,41 @@
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,8 +1,21 @@
"use server";
import { headers } from "next/headers";
import { createChallenge, verifySolution, type Challenge } from "./captcha";
export type ContactState = { status: "idle" | "sent" | "error"; message?: string };
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")
@@ -27,21 +40,9 @@ const silentlyDrop = (reason: string): ContactState => {
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;
}
/** 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>) {
@@ -53,7 +54,7 @@ async function sendEmail(apiKey: string, payload: Record<string, unknown>) {
if (!res.ok) throw new Error(`Resend ${res.status}: ${await res.text()}`);
}
export async function sendContact(_prev: ContactState, formData: FormData): Promise<ContactState> {
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");
@@ -61,48 +62,56 @@ export async function sendContact(_prev: ContactState, formData: FormData): Prom
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 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);
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 (!name || !message || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { status: "error", message: "Please add your name, a valid email, and a short message." };
}
if (!values.name || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) return fail("details");
// 3. Link spam.
if ((message.match(/https?:\/\/|www\./gi) ?? []).length > MAX_LINKS) return silentlyDrop("too many links");
if ((values.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." };
}
// 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 { status: "error", message: "The form isn’t available right now." };
return fail("unavailable");
}
const language = clean(formData.get("locale"), 5);
const rows: [string, string][] = [
["Name", name],
["Email", email],
["Company", company || "—"],
["Website", website || "—"],
["Interested in", interests.join(", ") || "—"],
["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: email,
subject: `New enquiry — ${company || name}`,
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>`)
@@ -110,7 +119,7 @@ export async function sendContact(_prev: ContactState, formData: FormData): Prom
});
} catch (err) {
console.error("Contact form: could not email the team", err);
return { status: "error", message: "We couldn’t send your message." };
return fail("failed");
}
return { status: "sent" };
+21 -2
View File
@@ -1,13 +1,32 @@
import type { Metadata } from "next";
import { locales, localize, ogLocale, type Locale } from "@/i18n/config";
import type { Page } from "./pages";
/** Canonical URL plus hreflang alternates for a locale-free path. */
export function localeAlternates(path: string, locale: Locale): Metadata["alternates"] {
return {
canonical: localize(path, locale),
languages: {
...Object.fromEntries(locales.map((l) => [l, localize(path, l)])),
"x-default": path,
},
};
}
export function pageMetadata(page: Page): Metadata {
const title = page.meta.title ?? "AI Growth Technologies";
const description = page.meta.description ?? undefined;
return {
title,
description,
alternates: { canonical: page.path },
openGraph: { type: "website", url: page.path, siteName: "AI Growth Technologies", title, description },
alternates: localeAlternates(page.path, page.locale),
openGraph: {
type: "website",
url: localize(page.path, page.locale),
siteName: "AI Growth Technologies",
locale: ogLocale[page.locale],
title,
description,
},
};
}
+51 -54
View File
@@ -1,28 +1,11 @@
import about from "@/content/pages/about.json";
import contact from "@/content/pages/contact.json";
import services from "@/content/pages/services.json";
import industries from "@/content/pages/industries.json";
import paidMedia from "@/content/pages/services_paid-media.json";
import seo from "@/content/pages/services_seo.json";
import llmo from "@/content/pages/services_llmo.json";
import webDevelopment from "@/content/pages/services_web-development.json";
import socialMedia from "@/content/pages/services_social-media.json";
import brand from "@/content/pages/services_brand.json";
import consulting from "@/content/pages/services_consulting.json";
import cybersecurity from "@/content/pages/services_cybersecurity.json";
import hospitality from "@/content/pages/industries_hospitality.json";
import medicalDental from "@/content/pages/industries_medical-dental.json";
import automotive from "@/content/pages/industries_automotive.json";
import ecommerce from "@/content/pages/industries_e-commerce.json";
import homeServices from "@/content/pages/industries_home-services.json";
import b2b from "@/content/pages/industries_b2b.json";
import creators from "@/content/pages/industries_creators.json";
import { industryNav, serviceNav } from "./routes";
import { content } from "@/content/pages";
import { localize, type Locale } from "@/i18n/config";
import { hrefFor, industryNav, serviceNav } from "./routes";
export type SectionKind = "text" | "cards" | "steps" | "faq" | "links" | "stats" | "offices" | "showcase";
export type Item = {
/** A page as stored in the JSON files: CTAs are plain labels from the copy deck. */
type RawItem = {
kicker: string | null;
title: string;
subtitle: string | null;
@@ -32,55 +15,69 @@ export type Item = {
num?: string;
};
export type Section = {
type RawSection = {
eyebrow: string | null;
title: string | null;
intro: string[];
items: Item[];
items: RawItem[];
outro: string[];
ctas: string[];
form: boolean;
kind: SectionKind | string;
};
export type Page = {
type RawPage = {
slug: string;
path: string;
meta: { title: string | null; description: string | null };
hero: RawSection;
sections: RawSection[];
};
/** A page ready to render: every CTA carries its localized label and destination. */
export type Cta = { label: string; href: string };
export type Item = Omit<RawItem, "cta"> & { cta: Cta | null };
export type Section = Omit<RawSection, "ctas" | "items"> & { ctas: Cta[]; items: Item[] };
export type Page = Omit<RawPage, "hero" | "sections"> & {
locale: Locale;
hero: Section;
sections: Section[];
};
const serviceContent: Record<string, unknown> = {
"paid-media": paidMedia,
seo,
llmo,
"web-development": webDevelopment,
"social-media": socialMedia,
brand,
consulting,
cybersecurity,
};
type Key = keyof (typeof content)["en"];
const industryContent: Record<string, unknown> = {
hospitality,
"medical-dental": medicalDental,
automotive,
"e-commerce": ecommerce,
"home-services": homeServices,
b2b,
creators,
};
export const pages = { about, contact, services, industries } as unknown as Record<
"about" | "contact" | "services" | "industries",
Page
>;
export function getService(slug: string): Page | undefined {
return serviceNav.some((s) => s.slug === slug) ? (serviceContent[slug] as Page) : undefined;
// Destinations come from the English labels, which the route map understands; translated pages
// share the English structure, so the same positions line up.
function resolveSection(s: RawSection, en: RawSection, path: string, locale: Locale): Section {
const to = (label: string) => localize(hrefFor(label, path), locale);
return {
...s,
ctas: s.ctas.map((label, i) => ({ label, href: to(en.ctas[i] ?? label) })),
items: s.items.map((it, i) => ({
...it,
cta: it.cta ? { label: it.cta, href: to(en.items[i]?.cta ?? it.cta) } : null,
})),
};
}
export function getIndustry(slug: string): Page | undefined {
return industryNav.some((s) => s.slug === slug) ? (industryContent[slug] as Page) : undefined;
function getPage(locale: Locale, key: Key): Page {
const raw = content[locale][key] as RawPage;
const en = content.en[key] as RawPage;
return {
...raw,
locale,
hero: resolveSection(raw.hero, en.hero, en.path, locale),
sections: raw.sections.map((s, i) => resolveSection(s, en.sections[i], en.path, locale)),
};
}
export const getStaticPage = (locale: Locale, key: "about" | "contact" | "services" | "industries") =>
getPage(locale, key);
export function getService(locale: Locale, slug: string): Page | undefined {
return serviceNav.some((s) => s.slug === slug) ? getPage(locale, `services_${slug}` as Key) : undefined;
}
export function getIndustry(locale: Locale, slug: string): Page | undefined {
return industryNav.some((s) => s.slug === slug) ? getPage(locale, `industries_${slug}` as Key) : undefined;
}
-7
View File
@@ -24,13 +24,6 @@ export const industryNav = [
{ slug: "creators", name: "Creators & Personal Brands" },
] as const;
export const mainNav = [
{ label: "Services", href: "/services", children: serviceNav.map((s) => ({ label: s.name, href: `/services/${s.slug}` })) },
{ label: "Industries", href: "/industries", children: industryNav.map((s) => ({ label: s.name, href: `/industries/${s.slug}` })) },
{ label: "About", href: "/about" },
{ label: "Contact", href: "/contact" },
];
// Industry keywords are checked before services so "Creators & Personal Brands" never resolves to Brand.
const industryKeys: [RegExp, string][] = [
[/HOSPITALITY/, "hospitality"],
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse, type NextRequest } from "next/server";
import { defaultLocale, isLocale } from "@/i18n/config";
// 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.
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const first = pathname.split("/")[1];
if (first === defaultLocale) {
const url = request.nextUrl.clone();
url.pathname = pathname.slice(defaultLocale.length + 1) || "/";
return NextResponse.redirect(url, 308);
}
if (isLocale(first)) return;
const url = request.nextUrl.clone();
url.pathname = `/${defaultLocale}${pathname === "/" ? "" : pathname}`;
return NextResponse.rewrite(url);
}
export const config = {
// Skip Next.js internals and any file with an extension (icons, images, sitemap.xml, robots.txt).
matcher: ["/((?!_next/|api/|.*\\..*).*)"],
};