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:
@@ -7,7 +7,7 @@ Usage:
|
|||||||
Paragraphs that start with "> " were bold sentences in the deck and render as
|
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
|
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.
|
(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.
|
and is skipped.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
import zipfile
|
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.
|
# Lines in the decks that are notes to the team, not site copy.
|
||||||
DROP = [
|
DROP = [
|
||||||
|
|||||||
+130
@@ -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)
|
||||||
@@ -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" }]} />;
|
||||||
|
}
|
||||||
@@ -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 { Suspense } from "react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import ContentPage from "@/components/content/content-page";
|
import ContentPage from "@/components/content/content-page";
|
||||||
|
import { getUI } from "@/i18n/server";
|
||||||
import { getIndustry } from "@/lib/pages";
|
import { getIndustry } from "@/lib/pages";
|
||||||
import { pageMetadata } from "@/lib/metadata";
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
import { industryNav } from "@/lib/routes";
|
import { industryNav } from "@/lib/routes";
|
||||||
|
|
||||||
type Params = PageProps<"/industries/[slug]">["params"];
|
type Params = PageProps<"/[lang]/industries/[slug]">["params"];
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return industryNav.map((s) => ({ slug: s.slug }));
|
return industryNav.map((s) => ({ slug: s.slug }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: PageProps<"/industries/[slug]">) {
|
export async function generateMetadata({ params }: PageProps<"/[lang]/industries/[slug]">) {
|
||||||
const { slug } = await params;
|
const [{ slug }, { locale }] = await Promise.all([params, getUI()]);
|
||||||
const page = getIndustry(slug);
|
const page = getIndustry(locale, slug);
|
||||||
return page ? pageMetadata(page) : {};
|
return page ? pageMetadata(page) : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page({ params }: PageProps<"/industries/[slug]">) {
|
export default function Page({ params }: PageProps<"/[lang]/industries/[slug]">) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<section className="hero page-hero" aria-busy="true" />}>
|
<Suspense fallback={<section className="hero page-hero" aria-busy="true" />}>
|
||||||
<Content params={params} />
|
<Content params={params} />
|
||||||
@@ -26,18 +27,16 @@ export default function Page({ params }: PageProps<"/industries/[slug]">) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function Content({ params }: { params: Params }) {
|
async function Content({ params }: { params: Params }) {
|
||||||
const { slug } = await params;
|
const [{ slug }, { locale, t }] = await Promise.all([params, getUI()]);
|
||||||
const page = getIndustry(slug);
|
const page = getIndustry(locale, slug);
|
||||||
if (!page) notFound();
|
if (!page) notFound();
|
||||||
const name = industryNav.find((s) => s.slug === slug)?.name ?? slug;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentPage
|
<ContentPage
|
||||||
page={page}
|
page={page}
|
||||||
crumbs={[
|
trail={[
|
||||||
{ name: "Home", href: "/" },
|
{ name: t.nav.industries, path: "/industries" },
|
||||||
{ name: "Industries", href: "/industries" },
|
{ name: t.industries[slug as keyof typeof t.industries], path: `/industries/${slug}` },
|
||||||
{ name, href: `/industries/${slug}` },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -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" }]} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 GlobeCanvas from "@/components/globe-canvas";
|
||||||
import StatCounter from "@/components/stat-counter";
|
import StatCounter from "@/components/stat-counter";
|
||||||
import RevealObserver from "@/components/reveal-observer";
|
import RevealObserver from "@/components/reveal-observer";
|
||||||
@@ -6,8 +6,9 @@ import Capabilities from "@/components/capabilities";
|
|||||||
import QueryTicker from "@/components/query-ticker";
|
import QueryTicker from "@/components/query-ticker";
|
||||||
import ProductVisual from "@/components/product-visuals";
|
import ProductVisual from "@/components/product-visuals";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { stats, products, journeySteps, gaps } from "@/content/home";
|
import { home } from "@/content/home";
|
||||||
import { hrefFor } from "@/lib/routes";
|
import { localize } from "@/i18n/config";
|
||||||
|
import { getLocale } from "@/i18n/server";
|
||||||
|
|
||||||
type Vars = CSSProperties & Record<`--${string}`, string | number>;
|
type Vars = CSSProperties & Record<`--${string}`, string | number>;
|
||||||
const delay = (ms: number) => ({ "--d": `${ms}ms` }) as Vars;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ───────────────────────── Hero ───────────────────────── */}
|
{/* ───────────────────────── Hero ───────────────────────── */}
|
||||||
@@ -31,34 +37,28 @@ export default function Home() {
|
|||||||
<GlobeCanvas />
|
<GlobeCanvas />
|
||||||
<div className="wrap hero-inner">
|
<div className="wrap hero-inner">
|
||||||
<div data-reveal>
|
<div data-reveal>
|
||||||
<Eyebrow>Digital Growth Architects</Eyebrow>
|
<Eyebrow>{hero.eyebrow}</Eyebrow>
|
||||||
</div>
|
</div>
|
||||||
<h1 id="hero-title" className="display h1" data-reveal style={delay(120)}>
|
<h1 id="hero-title" className="display h1" data-reveal style={delay(120)}>
|
||||||
Where Search, AI, Media, and Technology Work Together.
|
{hero.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="hero-grid">
|
<div className="hero-grid">
|
||||||
<div className="hero-lead lead" data-reveal style={delay(260)}>
|
<div className="hero-lead lead" data-reveal style={delay(260)}>
|
||||||
<p>
|
<p>{hero.lead}</p>
|
||||||
Your customer might find you through Google today, an AI answer tomorrow, and a paid ad three
|
<p className="muted">{hero.leadMuted}</p>
|
||||||
days later.
|
|
||||||
</p>
|
|
||||||
<p className="muted">They don’t think in channels. Your growth strategy shouldn’t either.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="hero-body" data-reveal style={delay(380)}>
|
<div className="hero-body" data-reveal style={delay(380)}>
|
||||||
<div className="copy">
|
<div className="copy">
|
||||||
<p>
|
<p>{hero.body}</p>
|
||||||
AI Growth Technologies brings paid media, search, AI visibility, technology, and attribution
|
<p className="hl">{hero.highlight}</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="hero-actions">
|
<div className="hero-actions">
|
||||||
<Link href="/contact" className="btn btn-solid">
|
<Link href={to("/contact")} className="btn btn-solid">
|
||||||
Book a Strategy Call <Arrow />
|
{hero.primary} <Arrow />
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/services" className="btn btn-ghost">
|
<Link href={to("/services")} className="btn btn-ghost">
|
||||||
Explore Services <Arrow />
|
{hero.secondary} <Arrow />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,18 +66,15 @@ export default function Home() {
|
|||||||
|
|
||||||
<div className="hero-foot" data-reveal style={delay(520)}>
|
<div className="hero-foot" data-reveal style={delay(520)}>
|
||||||
<div className="hero-channels" aria-hidden="true">
|
<div className="hero-channels" aria-hidden="true">
|
||||||
<span>Paid Media</span>
|
{hero.channels.map((ch, i) => (
|
||||||
<i />
|
<Fragment key={ch}>
|
||||||
<span>Search</span>
|
{i > 0 && <i />}
|
||||||
<i />
|
<span>{ch}</span>
|
||||||
<span>AI Visibility</span>
|
</Fragment>
|
||||||
<i />
|
))}
|
||||||
<span>Technology</span>
|
|
||||||
<i />
|
|
||||||
<span>Attribution</span>
|
|
||||||
</div>
|
</div>
|
||||||
<a href="#evidence" className="scroll-cue">
|
<a href="#evidence" className="scroll-cue">
|
||||||
Scroll <i />
|
{hero.scroll} <i />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,14 +85,14 @@ export default function Home() {
|
|||||||
<div className="wrap">
|
<div className="wrap">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div className="title" data-reveal>
|
<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">
|
<h2 id="evidence-title" className="display h2">
|
||||||
Good Growth Leaves Evidence.
|
{numbers.title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stats">
|
<div className="stats">
|
||||||
{stats.map((s, i) => (
|
{numbers.stats.map((s, i) => (
|
||||||
<div key={s.label} className="stat" data-reveal style={delay(i * 120)}>
|
<div key={s.label} className="stat" data-reveal style={delay(i * 120)}>
|
||||||
<StatCounter value={s.value} suffix={s.suffix} />
|
<StatCounter value={s.value} suffix={s.suffix} />
|
||||||
<div className="stat-label">{s.label}</div>
|
<div className="stat-label">{s.label}</div>
|
||||||
@@ -110,29 +107,29 @@ export default function Home() {
|
|||||||
<div className="wrap">
|
<div className="wrap">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div className="title" data-reveal>
|
<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">
|
<h2 id="cap-title" className="display h2">
|
||||||
Eight Capabilities.
|
{capabilities.title[0]}
|
||||||
<br />
|
<br />
|
||||||
One Growth Engine.
|
{capabilities.title[1]}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="split">
|
<div className="split">
|
||||||
<div className="lead" data-reveal>
|
<div className="lead" data-reveal>
|
||||||
<p>
|
<p>{capabilities.lead}</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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="copy" data-reveal style={delay(140)}>
|
<div className="copy" data-reveal style={delay(140)}>
|
||||||
<p>To your customer, that is not SEO, paid media, AI, and web.</p>
|
<p>{capabilities.copy}</p>
|
||||||
<p className="hl">It is one experience with your business.</p>
|
<p className="hl">{capabilities.highlight}</p>
|
||||||
<p>Our capabilities are designed to work that way too.</p>
|
<p>{capabilities.after}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div data-reveal>
|
<div data-reveal>
|
||||||
<Capabilities />
|
<Capabilities
|
||||||
|
items={capabilities.items.map((it) => ({ ...it, href: to(it.href) }))}
|
||||||
|
core={capabilities.core}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -142,37 +139,34 @@ export default function Home() {
|
|||||||
<div className="wrap">
|
<div className="wrap">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div className="title" data-reveal>
|
<div className="title" data-reveal>
|
||||||
<Eyebrow index="03">Proprietary Technology</Eyebrow>
|
<Eyebrow index="03">{technology.eyebrow}</Eyebrow>
|
||||||
<h2 id="tech-title" className="display h2">
|
<h2 id="tech-title" className="display h2">
|
||||||
We Wanted Better Answers, So We Built Better Tools.
|
{technology.title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="split">
|
<div className="split">
|
||||||
<div className="lead" data-reveal>
|
<div className="lead" data-reveal>
|
||||||
<p>Marketing produces plenty of numbers.</p>
|
<p>{technology.lead}</p>
|
||||||
<p className="muted">The harder part is knowing which ones matter.</p>
|
<p className="muted">{technology.leadMuted}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="copy" data-reveal style={delay(140)}>
|
<div className="copy" data-reveal style={delay(140)}>
|
||||||
|
<p>{technology.copy}</p>
|
||||||
<p>
|
<p>
|
||||||
Our proprietary technology gives our teams and clients a clearer view across attribution, organic and
|
{technology.closing} <span className="hl">{technology.closingHighlight}</span>
|
||||||
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>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="products">
|
<div className="products">
|
||||||
{products.map((p, i) => (
|
{technology.products.map((p, i) => (
|
||||||
<article key={p.name} className="product" aria-labelledby={`product-${i}`}>
|
<article key={p.name} className="product" aria-labelledby={`product-${i}`}>
|
||||||
<div className="product-visual" data-reveal>
|
<div className="product-visual" data-reveal>
|
||||||
<ProductVisual kind={p.visual} />
|
<ProductVisual kind={p.visual} labels={technology.visuals} />
|
||||||
</div>
|
</div>
|
||||||
<div className="product-body" data-reveal style={delay(160)}>
|
<div className="product-body" data-reveal style={delay(160)}>
|
||||||
<div className="product-kind">
|
<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>
|
<span className="pill">{p.kind}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 id={`product-${i}`} className="product-name">
|
<h3 id={`product-${i}`} className="product-name">
|
||||||
@@ -184,7 +178,7 @@ export default function Home() {
|
|||||||
<p key={b}>{b}</p>
|
<p key={b}>{b}</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Link href={hrefFor(p.cta)} className="arrow-link">
|
<Link href={to(p.href)} className="arrow-link">
|
||||||
{p.cta} <Arrow />
|
{p.cta} <Arrow />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,23 +193,25 @@ export default function Home() {
|
|||||||
<div className="wrap">
|
<div className="wrap">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div className="title" data-reveal>
|
<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">
|
<h2 id="disc-title" className="display h2">
|
||||||
Your Next Customer May Ask Before They Search.
|
{discovery.title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="discovery-stage" data-reveal>
|
<div className="discovery-stage" data-reveal>
|
||||||
<div className="query-label">People now ask AI platforms</div>
|
<div className="query-label">{discovery.queryLabel}</div>
|
||||||
<QueryTicker />
|
<QueryTicker queries={discovery.queries} />
|
||||||
<div className="surfaces">
|
<div className="surfaces">
|
||||||
<div className="surface left">
|
<div className="surface left">
|
||||||
<span className="pill">Google</span>
|
{discovery.searchPills.map((p) => (
|
||||||
<span className="pill">Rankings</span>
|
<span key={p} className="pill">
|
||||||
<span className="pill">Authority</span>
|
{p}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="surface-core">Your Brand</div>
|
<div className="surface-core">{discovery.yourBrand}</div>
|
||||||
<div className="surface">
|
<div className="surface">
|
||||||
<span className="pill">ChatGPT</span>
|
<span className="pill">ChatGPT</span>
|
||||||
<span className="pill">Gemini</span>
|
<span className="pill">Gemini</span>
|
||||||
@@ -227,23 +223,18 @@ export default function Home() {
|
|||||||
|
|
||||||
<div className="split">
|
<div className="split">
|
||||||
<div className="lead" data-reveal>
|
<div className="lead" data-reveal>
|
||||||
<p>People still search Google.</p>
|
<p>{discovery.lead}</p>
|
||||||
<p className="muted">
|
<p className="muted">{discovery.leadMuted}</p>
|
||||||
Now they also ask AI platforms which companies to consider, how products compare, what to buy, and
|
|
||||||
who to trust.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div data-reveal style={delay(140)}>
|
<div data-reveal style={delay(140)}>
|
||||||
<div className="copy">
|
<div className="copy">
|
||||||
<p className="hl">That changes what visibility means.</p>
|
<p className="hl">{discovery.highlight}</p>
|
||||||
<p>
|
{discovery.copy.map((p) => (
|
||||||
Rankings still matter. But so do the information, authority, context, and signals that help AI
|
<p key={p}>{p}</p>
|
||||||
systems understand a brand well enough to surface it.
|
))}
|
||||||
</p>
|
|
||||||
<p>Our SEO and LLMO work connects both sides of that shift.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Link href="/services/llmo" className="arrow-link" style={{ marginTop: 28 }}>
|
<Link href={to("/services/llmo")} className="arrow-link" style={{ marginTop: 28 }}>
|
||||||
Explore AI Visibility <Arrow />
|
{discovery.cta} <Arrow />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -255,16 +246,16 @@ export default function Home() {
|
|||||||
<div className="wrap">
|
<div className="wrap">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div className="title" data-reveal>
|
<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">
|
<h2 id="journey-title" className="display h2">
|
||||||
The Last Click Rarely Tells the Whole Story.
|
{journey.title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="journey" data-reveal-group>
|
<div className="journey" data-reveal-group>
|
||||||
<ol>
|
<ol>
|
||||||
{journeySteps.map((s, i) => (
|
{journey.steps.map((s, i) => (
|
||||||
<li key={s.label} style={{ "--i": i } as Vars}>
|
<li key={s.label} style={{ "--i": i } as Vars}>
|
||||||
<span className="dot" />
|
<span className="dot" />
|
||||||
<span className="channel">
|
<span className="channel">
|
||||||
@@ -278,28 +269,24 @@ export default function Home() {
|
|||||||
|
|
||||||
<div className="credit split">
|
<div className="credit split">
|
||||||
<h3 className="display question" data-reveal>
|
<h3 className="display question" data-reveal>
|
||||||
Which interaction gets the credit?
|
{journey.question}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="copy" data-reveal style={delay(140)}>
|
<div className="copy" data-reveal style={delay(140)}>
|
||||||
<p>Too often, the answer depends on which dashboard you open.</p>
|
{journey.copy.map((p) => (
|
||||||
<p>
|
<p key={p}>{p}</p>
|
||||||
We bring media, search, AI visibility, and attribution closer together to answer a more useful
|
))}
|
||||||
question:
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<figure className="pullquote" data-reveal>
|
<figure className="pullquote" data-reveal>
|
||||||
<Eyebrow>The more useful question</Eyebrow>
|
<Eyebrow>{journey.quoteEyebrow}</Eyebrow>
|
||||||
<blockquote>What actually helped move the customer toward a decision?</blockquote>
|
<blockquote>{journey.quote}</blockquote>
|
||||||
<figcaption className="copy">
|
<figcaption className="copy">
|
||||||
<p>Better attribution isn’t about producing a prettier report.</p>
|
<p>{journey.caption}</p>
|
||||||
<p className="hl">
|
<p className="hl">{journey.captionHighlight}</p>
|
||||||
It is about knowing where the next dollar has the best chance of doing something useful.
|
|
||||||
</p>
|
|
||||||
</figcaption>
|
</figcaption>
|
||||||
<Link href="/about" className="arrow-link">
|
<Link href={to("/about")} className="arrow-link">
|
||||||
Explore Our Approach <Arrow />
|
{journey.cta} <Arrow />
|
||||||
</Link>
|
</Link>
|
||||||
</figure>
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
@@ -310,15 +297,15 @@ export default function Home() {
|
|||||||
<div className="wrap">
|
<div className="wrap">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div className="title" data-reveal>
|
<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">
|
<h2 id="diff-title" className="display h2">
|
||||||
Good Channels Don’t Automatically Make a Good System.
|
{difference.title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="gaps">
|
<ul className="gaps">
|
||||||
{gaps.map((g, i) => (
|
{difference.gaps.map((g, i) => (
|
||||||
<li key={g} data-reveal style={delay(i * 80)}>
|
<li key={g} data-reveal style={delay(i * 80)}>
|
||||||
<span>{g}</span>
|
<span>{g}</span>
|
||||||
<svg className="break" viewBox="0 0 120 14" aria-hidden="true">
|
<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)" }}>
|
<div className="split" style={{ marginTop: "clamp(80px, 10vw, 144px)" }}>
|
||||||
<h3 className="display h2" data-reveal>
|
<h3 className="display h2" data-reveal>
|
||||||
That is the gap we care about.
|
{difference.gapTitle}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="copy" data-reveal style={delay(140)}>
|
<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>{difference.copy}</p>
|
||||||
<p className="hl">Then we strengthen the connections that make the whole system perform better.</p>
|
<p className="hl">{difference.highlight}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="nos">
|
<div className="nos">
|
||||||
<div data-reveal>
|
{difference.nos.map((n, i) => (
|
||||||
<span>No. 01</span>
|
<div key={n} data-reveal style={i ? delay(i * 120) : undefined}>
|
||||||
No channel fighting for credit.
|
<span>
|
||||||
</div>
|
{difference.noLabel} {String(i + 1).padStart(2, "0")}
|
||||||
<div data-reveal style={delay(120)}>
|
</span>
|
||||||
<span>No. 02</span>
|
{n}
|
||||||
No technology added just because it is new.
|
</div>
|
||||||
</div>
|
))}
|
||||||
<div data-reveal style={delay(240)}>
|
|
||||||
<span>No. 03</span>
|
|
||||||
No dashboard pretending the numbers mean more than they do.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="closing" data-reveal>
|
<p className="closing" data-reveal>
|
||||||
Just a clearer system and a team accountable for making it work.
|
{difference.closing}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -366,25 +349,23 @@ export default function Home() {
|
|||||||
<GlobeCanvas layout="center" />
|
<GlobeCanvas layout="center" />
|
||||||
<div className="wrap">
|
<div className="wrap">
|
||||||
<div data-reveal>
|
<div data-reveal>
|
||||||
<Eyebrow>Ready When You Are</Eyebrow>
|
<Eyebrow>{cta.eyebrow}</Eyebrow>
|
||||||
</div>
|
</div>
|
||||||
<h2 id="cta-title" className="display h2" data-reveal style={delay(120)}>
|
<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>
|
</h2>
|
||||||
<div className="copy" data-reveal style={delay(240)}>
|
<div className="copy" data-reveal style={delay(240)}>
|
||||||
<p>
|
{cta.copy.map((p) => (
|
||||||
Maybe acquisition is getting more expensive. Maybe organic visibility has stalled. Maybe AI is changing
|
<p key={p}>{p}</p>
|
||||||
how customers find you.
|
))}
|
||||||
</p>
|
|
||||||
<p>Or maybe every dashboard looks healthy, but the business is telling you something different.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="lead" data-reveal style={delay(320)}>
|
<div className="lead" data-reveal style={delay(320)}>
|
||||||
<p>You don’t need to diagnose it before the call.</p>
|
<p>{cta.lead}</p>
|
||||||
<p className="muted">Bring us what you’re seeing. We’ll start there.</p>
|
<p className="muted">{cta.leadMuted}</p>
|
||||||
</div>
|
</div>
|
||||||
<div data-reveal style={delay(400)}>
|
<div data-reveal style={delay(400)}>
|
||||||
<Link href="/contact" className="btn btn-solid">
|
<Link href={to("/contact")} className="btn btn-solid">
|
||||||
Book Your Strategy Call <Arrow />
|
{cta.button} <Arrow />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,23 +1,24 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import ContentPage from "@/components/content/content-page";
|
import ContentPage from "@/components/content/content-page";
|
||||||
|
import { getUI } from "@/i18n/server";
|
||||||
import { getService } from "@/lib/pages";
|
import { getService } from "@/lib/pages";
|
||||||
import { pageMetadata } from "@/lib/metadata";
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
import { serviceNav } from "@/lib/routes";
|
import { serviceNav } from "@/lib/routes";
|
||||||
|
|
||||||
type Params = PageProps<"/services/[slug]">["params"];
|
type Params = PageProps<"/[lang]/services/[slug]">["params"];
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return serviceNav.map((s) => ({ slug: s.slug }));
|
return serviceNav.map((s) => ({ slug: s.slug }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: PageProps<"/services/[slug]">) {
|
export async function generateMetadata({ params }: PageProps<"/[lang]/services/[slug]">) {
|
||||||
const { slug } = await params;
|
const [{ slug }, { locale }] = await Promise.all([params, getUI()]);
|
||||||
const page = getService(slug);
|
const page = getService(locale, slug);
|
||||||
return page ? pageMetadata(page) : {};
|
return page ? pageMetadata(page) : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page({ params }: PageProps<"/services/[slug]">) {
|
export default function Page({ params }: PageProps<"/[lang]/services/[slug]">) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<section className="hero page-hero" aria-busy="true" />}>
|
<Suspense fallback={<section className="hero page-hero" aria-busy="true" />}>
|
||||||
<Content params={params} />
|
<Content params={params} />
|
||||||
@@ -26,18 +27,16 @@ export default function Page({ params }: PageProps<"/services/[slug]">) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function Content({ params }: { params: Params }) {
|
async function Content({ params }: { params: Params }) {
|
||||||
const { slug } = await params;
|
const [{ slug }, { locale, t }] = await Promise.all([params, getUI()]);
|
||||||
const page = getService(slug);
|
const page = getService(locale, slug);
|
||||||
if (!page) notFound();
|
if (!page) notFound();
|
||||||
const name = serviceNav.find((s) => s.slug === slug)?.name ?? slug;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentPage
|
<ContentPage
|
||||||
page={page}
|
page={page}
|
||||||
crumbs={[
|
trail={[
|
||||||
{ name: "Home", href: "/" },
|
{ name: t.nav.services, path: "/services" },
|
||||||
{ name: "Services", href: "/services" },
|
{ name: t.services[slug as keyof typeof t.services], path: `/services/${slug}` },
|
||||||
{ name, href: `/services/${slug}` },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -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" }]} />;
|
||||||
|
}
|
||||||
@@ -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" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -20,6 +20,12 @@
|
|||||||
--max: 1440px;
|
--max: 1440px;
|
||||||
--section-y: clamp(88px, 9vw, 136px);
|
--section-y: clamp(88px, 9vw, 136px);
|
||||||
--ease: cubic-bezier(0.2, 0.7, 0.1, 1);
|
--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 {
|
.skip-link {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 16px;
|
inset-inline-start: 16px;
|
||||||
top: -60px;
|
top: -60px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
padding: 12px 18px;
|
padding: 12px 18px;
|
||||||
@@ -197,7 +203,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover .arrow {
|
.btn:hover .arrow {
|
||||||
transform: translateX(4px);
|
transform: translateX(calc(4px * var(--dir)));
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-solid {
|
.btn-solid {
|
||||||
@@ -247,7 +253,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.arrow-link:hover .arrow {
|
.arrow-link:hover .arrow {
|
||||||
transform: translateX(4px);
|
transform: translateX(calc(4px * var(--dir)));
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill {
|
.pill {
|
||||||
@@ -377,12 +383,12 @@ a {
|
|||||||
.nav a::after {
|
.nav a::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
inset-inline-start: 0;
|
||||||
right: 100%;
|
inset-inline-end: 100%;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--rosso);
|
background: var(--rosso);
|
||||||
transition: right 0.35s var(--ease);
|
transition: inset-inline-end 0.35s var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav a:hover {
|
.nav a:hover {
|
||||||
@@ -390,7 +396,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav a:hover::after {
|
.nav a:hover::after {
|
||||||
right: 0;
|
inset-inline-end: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-cta {
|
.header-cta {
|
||||||
@@ -591,7 +597,7 @@ a {
|
|||||||
.scroll-cue i::after {
|
.scroll-cue i::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
inset-inline-start: 0;
|
||||||
top: -40%;
|
top: -40%;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 40%;
|
height: 40%;
|
||||||
@@ -694,12 +700,12 @@ a {
|
|||||||
|
|
||||||
.stat {
|
.stat {
|
||||||
padding: 36px 28px 8px;
|
padding: 36px 28px 8px;
|
||||||
border-left: 1px solid var(--grafite);
|
border-inline-start: 1px solid var(--grafite);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat:first-child {
|
.stat:first-child {
|
||||||
border-left: 0;
|
border-inline-start: 0;
|
||||||
padding-left: 0;
|
padding-inline-start: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
@@ -713,7 +719,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-value small {
|
.stat-value small {
|
||||||
margin-left: 6px;
|
margin-inline-start: 6px;
|
||||||
font-size: 0.36em;
|
font-size: 0.36em;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
@@ -735,8 +741,8 @@ a {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
.stat:nth-child(3) {
|
.stat:nth-child(3) {
|
||||||
border-left: 0;
|
border-inline-start: 0;
|
||||||
padding-left: 0;
|
padding-inline-start: 0;
|
||||||
}
|
}
|
||||||
.stat:nth-child(n + 3) {
|
.stat:nth-child(n + 3) {
|
||||||
border-top: 1px solid var(--grafite);
|
border-top: 1px solid var(--grafite);
|
||||||
@@ -778,7 +784,7 @@ a {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--piombo);
|
color: var(--piombo);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
text-align: left;
|
text-align: start;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.4s var(--ease);
|
transition: color 0.4s var(--ease);
|
||||||
}
|
}
|
||||||
@@ -1238,7 +1244,7 @@ a {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 2px;
|
width: 2px;
|
||||||
height: 0.9em;
|
height: 0.9em;
|
||||||
margin-left: 6px;
|
margin-inline-start: 6px;
|
||||||
vertical-align: -0.08em;
|
vertical-align: -0.08em;
|
||||||
background: var(--rosso);
|
background: var(--rosso);
|
||||||
animation: caret 1s steps(1) infinite;
|
animation: caret 1s steps(1) infinite;
|
||||||
@@ -1293,11 +1299,11 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.surface-core::before {
|
.surface-core::before {
|
||||||
right: 100%;
|
inset-inline-end: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-core::after {
|
.surface-core::after {
|
||||||
left: 100%;
|
inset-inline-start: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
@@ -1337,8 +1343,8 @@ a {
|
|||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
left: 0;
|
inset-inline-start: 0;
|
||||||
right: 0;
|
inset-inline-end: 0;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--grafite);
|
background: var(--grafite);
|
||||||
}
|
}
|
||||||
@@ -1356,7 +1362,7 @@ a {
|
|||||||
|
|
||||||
.journey li {
|
.journey li {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-right: 24px;
|
padding-inline-end: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.journey .dot {
|
.journey .dot {
|
||||||
@@ -1406,14 +1412,14 @@ a {
|
|||||||
.journey ol {
|
.journey ol {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 34px;
|
gap: 34px;
|
||||||
padding-left: 34px;
|
padding-inline-start: 34px;
|
||||||
}
|
}
|
||||||
.journey ol::before,
|
.journey ol::before,
|
||||||
.journey ol::after {
|
.journey ol::after {
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 6px;
|
inset-inline-start: 6px;
|
||||||
right: auto;
|
inset-inline-end: auto;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
@@ -1427,7 +1433,7 @@ a {
|
|||||||
}
|
}
|
||||||
.journey .dot {
|
.journey .dot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -34px;
|
inset-inline-start: -34px;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
}
|
}
|
||||||
.journey .channel {
|
.journey .channel {
|
||||||
@@ -1534,7 +1540,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.gaps li:hover {
|
.gaps li:hover {
|
||||||
padding-left: 12px;
|
padding-inline-start: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -1555,14 +1561,14 @@ a {
|
|||||||
|
|
||||||
.nos div {
|
.nos div {
|
||||||
padding: 32px 28px 0;
|
padding: 32px 28px 0;
|
||||||
border-left: 1px solid var(--grafite);
|
border-inline-start: 1px solid var(--grafite);
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nos div:first-child {
|
.nos div:first-child {
|
||||||
padding-left: 0;
|
padding-inline-start: 0;
|
||||||
border-left: 0;
|
border-inline-start: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nos span {
|
.nos span {
|
||||||
@@ -1591,7 +1597,7 @@ a {
|
|||||||
.nos div,
|
.nos div,
|
||||||
.nos div:first-child {
|
.nos div:first-child {
|
||||||
padding: 28px 0;
|
padding: 28px 0;
|
||||||
border-left: 0;
|
border-inline-start: 0;
|
||||||
border-bottom: 1px solid var(--grafite);
|
border-bottom: 1px solid var(--grafite);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1822,7 +1828,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav a.is-active::after {
|
.nav a.is-active::after {
|
||||||
right: 0;
|
inset-inline-end: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group {
|
.nav-group {
|
||||||
@@ -1835,7 +1841,7 @@ a {
|
|||||||
.nav-panel {
|
.nav-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: -28px;
|
inset-inline-start: -28px;
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
padding-top: 1px;
|
padding-top: 1px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -1958,8 +1964,8 @@ a {
|
|||||||
|
|
||||||
.emph {
|
.emph {
|
||||||
margin: 1.2em 0 !important;
|
margin: 1.2em 0 !important;
|
||||||
padding-left: 20px;
|
padding-inline-start: 20px;
|
||||||
border-left: 1px solid var(--rosso);
|
border-inline-start: 1px solid var(--rosso);
|
||||||
font-size: clamp(18px, 1.6vw, 24px);
|
font-size: clamp(18px, 1.6vw, 24px);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -2017,14 +2023,14 @@ a {
|
|||||||
grid-template-columns: repeat(var(--cols, 3), minmax(0, 1fr));
|
grid-template-columns: repeat(var(--cols, 3), minmax(0, 1fr));
|
||||||
margin-top: clamp(56px, 7vw, 96px);
|
margin-top: clamp(56px, 7vw, 96px);
|
||||||
border-top: 1px solid var(--grafite);
|
border-top: 1px solid var(--grafite);
|
||||||
border-left: 1px solid var(--grafite);
|
border-inline-start: 1px solid var(--grafite);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 32px 28px 36px;
|
padding: 32px 28px 36px;
|
||||||
border-right: 1px solid var(--grafite);
|
border-inline-end: 1px solid var(--grafite);
|
||||||
border-bottom: 1px solid var(--grafite);
|
border-bottom: 1px solid var(--grafite);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2108,7 +2114,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.link-card:hover .arrow-link .arrow {
|
.link-card:hover .arrow-link .arrow {
|
||||||
transform: translateX(4px);
|
transform: translateX(calc(4px * var(--dir)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
@@ -2231,7 +2237,7 @@ a {
|
|||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 0;
|
inset-inline-start: 0;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--bianco);
|
background: var(--bianco);
|
||||||
@@ -2259,7 +2265,7 @@ a {
|
|||||||
/* stats on inner pages */
|
/* stats on inner pages */
|
||||||
.stat-copy {
|
.stat-copy {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
padding-right: 12px;
|
padding-inline-end: 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2275,12 +2281,12 @@ a {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 32px 24px 8px;
|
padding: 32px 24px 8px;
|
||||||
border-left: 1px solid var(--grafite);
|
border-inline-start: 1px solid var(--grafite);
|
||||||
}
|
}
|
||||||
|
|
||||||
.office:first-child {
|
.office:first-child {
|
||||||
padding-left: 0;
|
padding-inline-start: 0;
|
||||||
border-left: 0;
|
border-inline-start: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.office-head {
|
.office-head {
|
||||||
@@ -2339,8 +2345,8 @@ a {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
.office:nth-child(3) {
|
.office:nth-child(3) {
|
||||||
padding-left: 0;
|
padding-inline-start: 0;
|
||||||
border-left: 0;
|
border-inline-start: 0;
|
||||||
}
|
}
|
||||||
.office:nth-child(n + 3) {
|
.office:nth-child(n + 3) {
|
||||||
border-top: 1px solid var(--grafite);
|
border-top: 1px solid var(--grafite);
|
||||||
@@ -2354,7 +2360,7 @@ a {
|
|||||||
.office,
|
.office,
|
||||||
.office:nth-child(3) {
|
.office:nth-child(3) {
|
||||||
padding: 28px 0;
|
padding: 28px 0;
|
||||||
border-left: 0;
|
border-inline-start: 0;
|
||||||
}
|
}
|
||||||
.office + .office {
|
.office + .office {
|
||||||
border-top: 1px solid var(--grafite);
|
border-top: 1px solid var(--grafite);
|
||||||
@@ -2573,7 +2579,7 @@ a {
|
|||||||
|
|
||||||
.hp {
|
.hp {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -9999px;
|
inset-inline-start: -9999px;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -2623,3 +2629,201 @@ a {
|
|||||||
stroke-dashoffset: 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -1,9 +1,10 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
import { locales, localize } from "@/i18n/config";
|
||||||
import { SITE_URL, industryNav, serviceNav } from "@/lib/routes";
|
import { SITE_URL, industryNav, serviceNav } from "@/lib/routes";
|
||||||
|
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
const paths = [
|
const paths = [
|
||||||
"",
|
"/",
|
||||||
"/about",
|
"/about",
|
||||||
"/contact",
|
"/contact",
|
||||||
"/services",
|
"/services",
|
||||||
@@ -11,5 +12,13 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||||||
"/industries",
|
"/industries",
|
||||||
...industryNav.map((s) => `/industries/${s.slug}`),
|
...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))])) },
|
||||||
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
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");
|
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 SIZE = 360;
|
||||||
const C = SIZE / 2;
|
const C = SIZE / 2;
|
||||||
const RING = 132;
|
const RING = 132;
|
||||||
const nodes = capabilities.map((_, i) => {
|
const COUNT = 8;
|
||||||
const a = (i / capabilities.length) * Math.PI * 2 - Math.PI / 2;
|
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 };
|
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 (
|
return (
|
||||||
<svg className="cap-map" viewBox={`0 0 ${SIZE} ${SIZE}`} aria-hidden="true">
|
<svg className="cap-map" viewBox={`0 0 ${SIZE} ${SIZE}`} aria-hidden="true">
|
||||||
{nodes.map((a, i) =>
|
{nodes.map((a, i) =>
|
||||||
@@ -32,7 +33,7 @@ function EngineMap({ active }: { active: number }) {
|
|||||||
))}
|
))}
|
||||||
<circle className="core" cx={C} cy={C} r={30} />
|
<circle className="core" cx={C} cy={C} r={30} />
|
||||||
<text x={C} y={C + 2.5} textAnchor="middle">
|
<text x={C} y={C + 2.5} textAnchor="middle">
|
||||||
GROWTH
|
{core}
|
||||||
</text>
|
</text>
|
||||||
{nodes.map((n, i) => (
|
{nodes.map((n, i) => (
|
||||||
<g key={`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 [active, setActive] = useState(0);
|
||||||
const cap = capabilities[active];
|
const cap = items[active];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="cap-layout">
|
<div className="cap-layout">
|
||||||
<ul className="cap-list">
|
<ul className="cap-list">
|
||||||
{capabilities.map((c, i) => (
|
{items.map((c, i) => (
|
||||||
<li key={c.name} className={`cap-item${i === active ? " is-active" : ""}`}>
|
<li key={c.name} className={`cap-item${i === active ? " is-active" : ""}`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -79,7 +80,7 @@ export default function Capabilities() {
|
|||||||
<p key={p}>{p}</p>
|
<p key={p}>{p}</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Link href={hrefFor(c.cta)} className="arrow-link">
|
<Link href={c.href} className="arrow-link">
|
||||||
{c.cta} <span className="arrow">→</span>
|
{c.cta} <span className="arrow">→</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +89,7 @@ export default function Capabilities() {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="cap-panel" aria-live="polite">
|
<div className="cap-panel" aria-live="polite">
|
||||||
<EngineMap active={active} />
|
<EngineMap active={active} core={core} />
|
||||||
<div key={active} className="cap-detail">
|
<div key={active} className="cap-detail">
|
||||||
<div className="kicker">
|
<div className="kicker">
|
||||||
{pad(active)} / {cap.name}
|
{pad(active)} / {cap.name}
|
||||||
@@ -99,7 +100,7 @@ export default function Capabilities() {
|
|||||||
<p key={p}>{p}</p>
|
<p key={p}>{p}</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Link href={hrefFor(cap.cta)} className="arrow-link">
|
<Link href={cap.href} className="arrow-link">
|
||||||
{cap.cta} <span className="arrow">→</span>
|
{cap.cta} <span className="arrow">→</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,35 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Script from "next/script";
|
import { useActionState, useEffect, useRef, useState } from "react";
|
||||||
import { useActionState, useEffect, useRef } from "react";
|
import { sendContact, type ContactState } from "@/lib/contact-action";
|
||||||
import { sendContact, type ContactState } from "@/app/contact/actions";
|
import type { Locale } from "@/i18n/config";
|
||||||
|
import type { UI } from "@/i18n/ui";
|
||||||
import { CONTACT_EMAIL, serviceNav } from "@/lib/routes";
|
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" };
|
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 {
|
export default function ContactForm({
|
||||||
interface Window {
|
locale,
|
||||||
turnstile?: { reset: (el?: Element | string) => void };
|
t,
|
||||||
}
|
services,
|
||||||
}
|
}: {
|
||||||
|
locale: Locale;
|
||||||
export default function ContactForm() {
|
t: UI["form"];
|
||||||
|
services: UI["services"];
|
||||||
|
}) {
|
||||||
const [state, action, pending] = useActionState(sendContact, initial);
|
const [state, action, pending] = useActionState(sendContact, initial);
|
||||||
const startedRef = useRef<HTMLInputElement>(null);
|
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.
|
// When the visitor started filling the form; the server rejects instant (bot) submissions.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (startedRef.current && !startedRef.current.value) startedRef.current.value = String(Date.now());
|
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") {
|
if (state.status === "sent") {
|
||||||
return (
|
return (
|
||||||
<div className="contact-form form-sent" role="status">
|
<div className="contact-form form-sent" role="status">
|
||||||
@@ -37,65 +48,116 @@ export default function ContactForm() {
|
|||||||
<circle cx="24" cy="24" r="23" />
|
<circle cx="24" cy="24" r="23" />
|
||||||
<path d="M14 24.5l7 7 13-14" />
|
<path d="M14 24.5l7 7 13-14" />
|
||||||
</svg>
|
</svg>
|
||||||
<h3 className="card-title">Your message was sent successfully.</h3>
|
<h3 className="card-title">{t.sentTitle}</h3>
|
||||||
<p className="copy">Thank you for reaching out. Our team will review what you shared and get back to you shortly.</p>
|
<p className="copy">{t.sentBody}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const values = state.status === "error" ? state.values : undefined;
|
||||||
|
const error = needsCheck && !solution ? "human" : state.status === "error" ? state.error : null;
|
||||||
|
|
||||||
return (
|
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">
|
<div className="field-row">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Name</span>
|
<span>{t.name}</span>
|
||||||
<input name="name" autoComplete="name" required maxLength={120} />
|
<input name="name" autoComplete="name" required maxLength={120} defaultValue={values?.name} />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Work email</span>
|
<span>{t.email}</span>
|
||||||
<input name="email" type="email" autoComplete="email" required maxLength={200} />
|
<input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
maxLength={200}
|
||||||
|
defaultValue={values?.email}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Company</span>
|
<span>{t.company}</span>
|
||||||
<input name="company" autoComplete="organization" maxLength={160} />
|
<input name="company" autoComplete="organization" maxLength={160} defaultValue={values?.company} />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Website</span>
|
<span>{t.website}</span>
|
||||||
<input name="website" type="url" inputMode="url" placeholder="https://" maxLength={300} />
|
<input
|
||||||
|
name="website"
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
placeholder="https://"
|
||||||
|
maxLength={300}
|
||||||
|
defaultValue={values?.website}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/* Honeypot for bots — hidden from people and assistive tech. */}
|
{/* Honeypot for bots — hidden from people and assistive tech. */}
|
||||||
<input name="company_url" className="hp" tabIndex={-1} autoComplete="off" aria-hidden="true" />
|
<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">
|
<fieldset className="field">
|
||||||
<legend>What are you looking at?</legend>
|
<legend>{t.interests}</legend>
|
||||||
<div className="chips">
|
<div className="chips">
|
||||||
{interests.map((i) => (
|
{interests.map((i) => (
|
||||||
<label key={i} className="chip">
|
<label key={i.value} className="chip">
|
||||||
<input type="checkbox" name="interest" value={i} />
|
<input
|
||||||
<span>{i}</span>
|
type="checkbox"
|
||||||
|
name="interest"
|
||||||
|
value={i.value}
|
||||||
|
defaultChecked={values?.interests.includes(i.value)}
|
||||||
|
/>
|
||||||
|
<span>{i.label}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>What’s happening in the business?</span>
|
<span>
|
||||||
<textarea name="message" rows={5} required maxLength={5000} />
|
{t.message} <em className="field-optional">{t.optional}</em>
|
||||||
|
</span>
|
||||||
|
<textarea name="message" rows={5} maxLength={5000} defaultValue={values?.message} />
|
||||||
</label>
|
</label>
|
||||||
{TURNSTILE_SITE_KEY && (
|
<HumanCheck
|
||||||
<>
|
solution={solution}
|
||||||
<Script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer />
|
onSolved={(s) => {
|
||||||
<div className="cf-turnstile" data-sitekey={TURNSTILE_SITE_KEY} data-theme="dark" data-size="flexible" />
|
setSolution(s);
|
||||||
</>
|
setNeedsCheck(false);
|
||||||
)}
|
}}
|
||||||
|
t={t.human}
|
||||||
|
invalid={error === "human"}
|
||||||
|
/>
|
||||||
<button type="submit" className="btn btn-solid" disabled={pending}>
|
<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>
|
</button>
|
||||||
<p className={`form-note${state.status === "error" ? " is-error" : ""}`} role="status">
|
<p className={`form-note${error ? " is-error" : ""}`} role="status">
|
||||||
{state.status === "error"
|
{error ? (
|
||||||
? `${state.message} You can also write to ${CONTACT_EMAIL}.`
|
<>
|
||||||
: "A strategist reads every message and replies personally."}
|
{t.errors[error]}
|
||||||
|
{error !== "human" && error !== "details" && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
{t.writeTo} <span dir="ltr">{CONTACT_EMAIL}</span>.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t.note
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,14 +4,19 @@ import GlobeCanvas from "@/components/globe-canvas";
|
|||||||
import StatCounter from "@/components/stat-counter";
|
import StatCounter from "@/components/stat-counter";
|
||||||
import RevealObserver from "@/components/reveal-observer";
|
import RevealObserver from "@/components/reveal-observer";
|
||||||
import ContactForm from "./contact-form";
|
import ContactForm from "./contact-form";
|
||||||
import type { Item, Page, Section } from "@/lib/pages";
|
import { localize } from "@/i18n/config";
|
||||||
import { SITE_URL, hrefFor } from "@/lib/routes";
|
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>;
|
type Vars = CSSProperties & Record<`--${string}`, string | number>;
|
||||||
const delay = (ms: number) => ({ "--d": `${ms}ms` }) as Vars;
|
const delay = (ms: number) => ({ "--d": `${ms}ms` }) as Vars;
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
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>;
|
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;
|
if (!ctas.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="cta-row">
|
<div className="cta-row">
|
||||||
{ctas.map((c) => (
|
{ctas.map((c) => (
|
||||||
<Link key={c} href={hrefFor(c, path)} className="arrow-link">
|
<Link key={c.label} href={c.href} className="arrow-link">
|
||||||
{c} <Arrow />
|
{c.label} <Arrow />
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 isFaq = section.kind === "faq";
|
||||||
const eyebrow = isFaq && !section.title ? null : section.eyebrow;
|
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 (
|
return (
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div className="title" data-reveal>
|
<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;
|
if (!section.outro.length && !section.ctas.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="outro" data-reveal>
|
<div className="outro" data-reveal>
|
||||||
@@ -101,7 +106,7 @@ function Outro({ section, path }: { section: Section; path: string }) {
|
|||||||
<Paras items={section.outro} />
|
<Paras items={section.outro} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CtaLinks ctas={section.ctas} path={path} />
|
<CtaLinks ctas={section.ctas} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -127,7 +132,9 @@ function columnsFor(items: Item[]) {
|
|||||||
|
|
||||||
/* ───────────── Section variants ───────────── */
|
/* ───────────── Section variants ───────────── */
|
||||||
|
|
||||||
function TextSection({ section, path }: { section: Section; path: string }) {
|
type VariantProps = { section: Section; t: UI };
|
||||||
|
|
||||||
|
function TextSection({ section }: VariantProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Intro section={section} />
|
<Intro section={section} />
|
||||||
@@ -135,7 +142,7 @@ function TextSection({ section, path }: { section: Section; path: string }) {
|
|||||||
<div className="split">
|
<div className="split">
|
||||||
<div />
|
<div />
|
||||||
<div data-reveal>
|
<div data-reveal>
|
||||||
<CtaLinks ctas={section.ctas} path={path} />
|
<CtaLinks ctas={section.ctas} />
|
||||||
</div>
|
</div>
|
||||||
</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);
|
const cols = columnsFor(section.items);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -161,19 +168,19 @@ function CardsSection({ section, path }: { section: Section; path: string }) {
|
|||||||
)}
|
)}
|
||||||
<ItemList list={it.list} />
|
<ItemList list={it.list} />
|
||||||
{it.cta && (
|
{it.cta && (
|
||||||
<Link href={hrefFor(it.cta, path)} className="arrow-link">
|
<Link href={it.cta.href} className="arrow-link">
|
||||||
{it.cta} <Arrow />
|
{it.cta.label} <Arrow />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Outro section={section} path={path} />
|
<Outro section={section} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StepsSection({ section, path }: { section: Section; path: string }) {
|
function StepsSection({ section }: VariantProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Intro section={section} />
|
<Intro section={section} />
|
||||||
@@ -188,12 +195,12 @@ function StepsSection({ section, path }: { section: Section; path: string }) {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
<Outro section={section} path={path} />
|
<Outro section={section} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FaqSection({ section, path }: { section: Section; path: string }) {
|
function FaqSection({ section }: VariantProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Intro section={section} />
|
<Intro section={section} />
|
||||||
@@ -210,12 +217,12 @@ function FaqSection({ section, path }: { section: Section; path: string }) {
|
|||||||
</details>
|
</details>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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);
|
const cols = columnsFor(section.items);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -224,7 +231,7 @@ function LinksSection({ section, path }: { section: Section; path: string }) {
|
|||||||
{section.items.map((it, i) => (
|
{section.items.map((it, i) => (
|
||||||
<Link
|
<Link
|
||||||
key={it.title}
|
key={it.title}
|
||||||
href={hrefFor(it.cta ?? "", path)}
|
href={it.cta?.href ?? "#"}
|
||||||
className="card link-card"
|
className="card link-card"
|
||||||
data-reveal
|
data-reveal
|
||||||
style={delay((i % cols) * 90)}
|
style={delay((i % cols) * 90)}
|
||||||
@@ -235,17 +242,17 @@ function LinksSection({ section, path }: { section: Section; path: string }) {
|
|||||||
<Paras items={it.body} firstClass="hl" />
|
<Paras items={it.body} firstClass="hl" />
|
||||||
</div>
|
</div>
|
||||||
<span className="arrow-link">
|
<span className="arrow-link">
|
||||||
{it.cta} <Arrow />
|
{it.cta?.label} <Arrow />
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Outro section={section} path={path} />
|
<Outro section={section} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatsSection({ section, path }: { section: Section; path: string }) {
|
function StatsSection({ section }: VariantProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Intro section={section} />
|
<Intro section={section} />
|
||||||
@@ -264,12 +271,12 @@ function StatsSection({ section, path }: { section: Section; path: string }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Outro section={section} path={path} />
|
<Outro section={section} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddressLines({ text }: { text: string }) {
|
function AddressLines({ text, t }: { text: string; t: UI }) {
|
||||||
return (
|
return (
|
||||||
<address className="office-address">
|
<address className="office-address">
|
||||||
{text.split("\n").map((line) => {
|
{text.split("\n").map((line) => {
|
||||||
@@ -278,7 +285,7 @@ function AddressLines({ text }: { text: string }) {
|
|||||||
if (/^phone$/i.test(label.trim()) && value) {
|
if (/^phone$/i.test(label.trim()) && value) {
|
||||||
return (
|
return (
|
||||||
<span key={line}>
|
<span key={line}>
|
||||||
<em>Phone</em>
|
<em>{t.office.phone}</em>
|
||||||
<a href={`tel:${value.replace(/[^\d+]/g, "")}`}>{value}</a>
|
<a href={`tel:${value.replace(/[^\d+]/g, "")}`}>{value}</a>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -286,7 +293,7 @@ function AddressLines({ text }: { text: string }) {
|
|||||||
if (/^email$/i.test(label.trim()) && value) {
|
if (/^email$/i.test(label.trim()) && value) {
|
||||||
return (
|
return (
|
||||||
<span key={line}>
|
<span key={line}>
|
||||||
<em>Email</em>
|
<em>{t.office.email}</em>
|
||||||
<a href={`mailto:${value}`}>{value}</a>
|
<a href={`mailto:${value}`}>{value}</a>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -297,7 +304,7 @@ function AddressLines({ text }: { text: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OfficesSection({ section, path }: { section: Section; path: string }) {
|
function OfficesSection({ section, t }: VariantProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Intro section={section} />
|
<Intro section={section} />
|
||||||
@@ -314,17 +321,17 @@ function OfficesSection({ section, path }: { section: Section; path: string }) {
|
|||||||
<div className="copy">
|
<div className="copy">
|
||||||
<Paras items={desc} />
|
<Paras items={desc} />
|
||||||
</div>
|
</div>
|
||||||
{address && <AddressLines text={address} />}
|
{address && <AddressLines text={address} t={t} />}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Outro section={section} path={path} />
|
<Outro section={section} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShowcaseSection({ section, path }: { section: Section; path: string }) {
|
function ShowcaseSection({ section }: VariantProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Intro section={section} />
|
<Intro section={section} />
|
||||||
@@ -344,8 +351,8 @@ function ShowcaseSection({ section, path }: { section: Section; path: string })
|
|||||||
<Paras items={it.body} firstClass="hl" />
|
<Paras items={it.body} firstClass="hl" />
|
||||||
</div>
|
</div>
|
||||||
{it.cta && (
|
{it.cta && (
|
||||||
<Link href={hrefFor(it.cta, path)} className="arrow-link">
|
<Link href={it.cta.href} className="arrow-link">
|
||||||
{it.cta} <Arrow />
|
{it.cta.label} <Arrow />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -354,12 +361,12 @@ function ShowcaseSection({ section, path }: { section: Section; path: string })
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="form-layout">
|
<div className="form-layout">
|
||||||
<div className="form-copy">
|
<div className="form-copy">
|
||||||
@@ -369,16 +376,16 @@ function FormSection({ section, path }: { section: Section; path: string }) {
|
|||||||
<div className="copy" data-reveal style={delay(120)}>
|
<div className="copy" data-reveal style={delay(120)}>
|
||||||
<Paras items={section.intro.slice(1)} />
|
<Paras items={section.intro.slice(1)} />
|
||||||
</div>
|
</div>
|
||||||
<CtaLinks ctas={section.ctas} path={path} />
|
<CtaLinks ctas={section.ctas} />
|
||||||
</div>
|
</div>
|
||||||
<div data-reveal style={delay(200)}>
|
<div data-reveal style={delay(200)}>
|
||||||
<ContactForm />
|
<ContactForm locale={locale} t={t.form} services={t.services} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const variants: Record<string, (p: { section: Section; path: string }) => ReactNode> = {
|
const variants: Record<string, (p: VariantProps) => ReactNode> = {
|
||||||
text: TextSection,
|
text: TextSection,
|
||||||
cards: CardsSection,
|
cards: CardsSection,
|
||||||
steps: StepsSection,
|
steps: StepsSection,
|
||||||
@@ -391,14 +398,14 @@ const variants: Record<string, (p: { section: Section; path: string }) => ReactN
|
|||||||
|
|
||||||
/* ───────────── Hero & closing CTA ───────────── */
|
/* ───────────── Hero & closing CTA ───────────── */
|
||||||
|
|
||||||
function PageHero({ page, crumbs }: { page: Page; crumbs: Crumb[] }) {
|
function PageHero({ page, crumbs, t }: { page: Page; crumbs: Crumb[]; t: UI }) {
|
||||||
const { hero, path } = page;
|
const { hero } = page;
|
||||||
const [name, tagline] = (hero.eyebrow ?? "").split("|").map((s) => s.trim());
|
const [name, tagline] = (hero.eyebrow ?? "").split("|").map((s) => s.trim());
|
||||||
return (
|
return (
|
||||||
<section className="hero page-hero" aria-labelledby="page-title">
|
<section className="hero page-hero" aria-labelledby="page-title">
|
||||||
<GlobeCanvas />
|
<GlobeCanvas />
|
||||||
<div className="wrap hero-inner">
|
<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) =>
|
{crumbs.map((c, i) =>
|
||||||
i < crumbs.length - 1 ? (
|
i < crumbs.length - 1 ? (
|
||||||
<span key={c.href}>
|
<span key={c.href}>
|
||||||
@@ -438,8 +445,8 @@ function PageHero({ page, crumbs }: { page: Page; crumbs: Crumb[] }) {
|
|||||||
)}
|
)}
|
||||||
<div className="hero-actions">
|
<div className="hero-actions">
|
||||||
{hero.ctas.map((c, i) => (
|
{hero.ctas.map((c, i) => (
|
||||||
<Link key={c} href={hrefFor(c, path)} className={`btn ${i === 0 ? "btn-solid" : "btn-ghost"}`}>
|
<Link key={c.label} href={c.href} className={`btn ${i === 0 ? "btn-solid" : "btn-ghost"}`}>
|
||||||
{c} <Arrow />
|
{c.label} <Arrow />
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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;
|
const [lead, ...rest] = section.intro;
|
||||||
return (
|
return (
|
||||||
<section className="cta" aria-label={section.title ?? undefined}>
|
<section className="cta" aria-label={section.title ?? undefined}>
|
||||||
@@ -472,8 +479,8 @@ function ClosingCta({ section, path }: { section: Section; path: string }) {
|
|||||||
{section.ctas.length > 0 && (
|
{section.ctas.length > 0 && (
|
||||||
<div data-reveal style={delay(360)}>
|
<div data-reveal style={delay(360)}>
|
||||||
{section.ctas.map((c) => (
|
{section.ctas.map((c) => (
|
||||||
<Link key={c} href={hrefFor(c, path)} className="btn btn-solid">
|
<Link key={c.label} href={c.href} className="btn btn-solid">
|
||||||
{c} <Arrow />
|
{c.label} <Arrow />
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -514,7 +521,12 @@ function JsonLd({ page, crumbs }: { page: Page; crumbs: Crumb[] }) {
|
|||||||
|
|
||||||
/* ───────────── Page ───────────── */
|
/* ───────────── 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 last = page.sections[page.sections.length - 1];
|
||||||
const closing = last && last.kind === "text" && last.ctas.length > 0 ? last : null;
|
const closing = last && last.kind === "text" && last.ctas.length > 0 ? last : null;
|
||||||
const body = closing ? page.sections.slice(0, -1) : page.sections;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<JsonLd page={page} crumbs={crumbs} />
|
<JsonLd page={page} crumbs={crumbs} />
|
||||||
<PageHero page={page} crumbs={crumbs} />
|
<PageHero page={page} crumbs={crumbs} t={t} />
|
||||||
{body.map((section, i) => {
|
{body.map((section, i) => {
|
||||||
const Variant = variants[section.kind] ?? TextSection;
|
const Variant = variants[section.kind] ?? TextSection;
|
||||||
return (
|
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}`}
|
className={`section${i % 2 === 0 ? " section-night" : ""} kind-${section.kind}`}
|
||||||
>
|
>
|
||||||
<div className="wrap">
|
<div className="wrap">
|
||||||
<SectionHead section={section} index={i + 1} />
|
<SectionHead section={section} index={i + 1} t={t} />
|
||||||
{section.form ? (
|
{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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{closing && <ClosingCta section={closing} path={page.path} />}
|
{closing && <ClosingCta section={closing} />}
|
||||||
<RevealObserver />
|
<RevealObserver />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -114,6 +114,8 @@ export default function GlobeCanvas({ layout = "hero", className = "hero-canvas"
|
|||||||
cy = vh * 0.54;
|
cy = vh * 0.54;
|
||||||
R = Math.min(vh * 0.4, w * 0.27);
|
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
|
let rotY = -1.25; // start facing the Americas, drifting toward Europe
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
export default function Logo() {
|
export default function Logo({ href, label }: { href: string; label: string }) {
|
||||||
return (
|
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 />
|
<Image className="logo-mark" src="/logo-mark.png" alt="" width={229} height={192} priority />
|
||||||
<span className="logo-type">
|
<span className="logo-type">
|
||||||
<b>AI GROWTH</b>
|
<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 (
|
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} />
|
<Image src="/logo-stacked.png" alt="AI Growth Technologies" width={480} height={547} />
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,12 +2,21 @@ import type { CSSProperties } from "react";
|
|||||||
|
|
||||||
type Vars = CSSProperties & Record<`--${string}`, string>;
|
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 W = 600;
|
||||||
const H = 412;
|
const H = 412;
|
||||||
|
|
||||||
/* Atrilyx — many touchpoints converging on one conversion */
|
/* Atrilyx — many touchpoints converging on one conversion */
|
||||||
function JourneyViz() {
|
function JourneyViz({ labels }: { labels: VisualLabels }) {
|
||||||
const sources = ["Ad", "Search", "Page", "Brand", "AI Answer", "Social"];
|
const sources = labels.sources;
|
||||||
const gx = 500;
|
const gx = 500;
|
||||||
const gy = H / 2;
|
const gy = H / 2;
|
||||||
const top = 70;
|
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-ring" x={gx - 7} y={gy - 7} width={14} height={14} />
|
||||||
<rect className="goal" 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}>
|
<text x={gx + 18} y={gy + 3}>
|
||||||
CONVERSION
|
{labels.conversion.toUpperCase()}
|
||||||
</text>
|
</text>
|
||||||
<text className="faint" x={24} y={H - 20}>
|
<text className="faint" x={24} y={H - 20}>
|
||||||
TOUCHPOINTS → WEIGHTED CONTRIBUTION
|
{labels.journeyCaption.toUpperCase()}
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* meSEO — search + AI surfaces on a radar */
|
/* meSEO — search + AI surfaces on a radar */
|
||||||
function RadarViz() {
|
function RadarViz({ labels }: { labels: VisualLabels }) {
|
||||||
const cx = 300;
|
const cx = 300;
|
||||||
const cy = 206;
|
const cy = 206;
|
||||||
const dots = [
|
const dots = [
|
||||||
{ a: 18, r: 150, label: "SEARCH" },
|
{ a: 18, r: 150, label: labels.search.toUpperCase() },
|
||||||
{ a: 64, r: 92, label: "AI OVERVIEWS" },
|
{ a: 64, r: 92, label: "AI OVERVIEWS" },
|
||||||
{ a: 118, r: 170, label: "CHATGPT" },
|
{ a: 118, r: 170, label: "CHATGPT" },
|
||||||
{ a: 162, r: 64, label: "" },
|
{ a: 162, r: 64, label: "" },
|
||||||
@@ -114,14 +123,14 @@ function RadarViz() {
|
|||||||
})}
|
})}
|
||||||
<rect x={cx - 3} y={cy - 3} width={6} height={6} fill="#fff" />
|
<rect x={cx - 3} y={cy - 3} width={6} height={6} fill="#fff" />
|
||||||
<text className="faint" x={24} y={H - 20}>
|
<text className="faint" x={24} y={H - 20}>
|
||||||
VISIBILITY SURFACE — SEARCH + GENERATIVE
|
{labels.radarCaption.toUpperCase()}
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Korrith — monitored infrastructure grid with a scanning pass */
|
/* Korrith — monitored infrastructure grid with a scanning pass */
|
||||||
function GridViz() {
|
function GridViz({ labels }: { labels: VisualLabels }) {
|
||||||
const cols = 14;
|
const cols = 14;
|
||||||
const rows = 8;
|
const rows = 8;
|
||||||
const size = 28;
|
const size = 28;
|
||||||
@@ -173,14 +182,20 @@ function GridViz() {
|
|||||||
<rect x={0} y={0} width={W} height={64} fill="url(#scanGrad)" />
|
<rect x={0} y={0} width={W} height={64} fill="url(#scanGrad)" />
|
||||||
</g>
|
</g>
|
||||||
<text className="faint" x={24} y={H - 20}>
|
<text className="faint" x={24} y={H - 20}>
|
||||||
RESILIENCE LAYER — CONTINUOUS MONITORING
|
{labels.gridCaption.toUpperCase()}
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductVisual({ kind }: { kind: "journey" | "radar" | "grid" }) {
|
export default function ProductVisual({
|
||||||
if (kind === "journey") return <JourneyViz />;
|
kind,
|
||||||
if (kind === "radar") return <RadarViz />;
|
labels,
|
||||||
return <GridViz />;
|
}: {
|
||||||
|
kind: "journey" | "radar" | "grid";
|
||||||
|
labels: VisualLabels;
|
||||||
|
}) {
|
||||||
|
if (kind === "journey") return <JourneyViz labels={labels} />;
|
||||||
|
if (kind === "radar") return <RadarViz labels={labels} />;
|
||||||
|
return <GridViz labels={labels} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,37 +2,30 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
// The kinds of questions the copy describes people now asking AI platforms.
|
/** Types out, one after another, the kinds of questions people now ask AI platforms. */
|
||||||
const QUERIES = [
|
export default function QueryTicker({ queries }: { queries: string[] }) {
|
||||||
"Which companies should I consider?",
|
const [text, setText] = useState(queries[0]);
|
||||||
"How do these products compare?",
|
|
||||||
"What should I buy?",
|
|
||||||
"Who should I trust?",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function QueryTicker() {
|
|
||||||
const [text, setText] = useState(QUERIES[0]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||||
|
|
||||||
let q = 0;
|
let q = 0;
|
||||||
let i = QUERIES[0].length;
|
let i = queries[0].length;
|
||||||
let deleting = true;
|
let deleting = true;
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
const step = () => {
|
const step = () => {
|
||||||
const target = QUERIES[q];
|
const target = queries[q];
|
||||||
if (deleting) {
|
if (deleting) {
|
||||||
i--;
|
i--;
|
||||||
setText(target.slice(0, i));
|
setText(target.slice(0, i));
|
||||||
if (i <= 0) {
|
if (i <= 0) {
|
||||||
deleting = false;
|
deleting = false;
|
||||||
q = (q + 1) % QUERIES.length;
|
q = (q + 1) % queries.length;
|
||||||
}
|
}
|
||||||
timer = setTimeout(step, 28);
|
timer = setTimeout(step, 28);
|
||||||
} else {
|
} else {
|
||||||
const next = QUERIES[q];
|
const next = queries[q];
|
||||||
i++;
|
i++;
|
||||||
setText(next.slice(0, i));
|
setText(next.slice(0, i));
|
||||||
if (i >= next.length) {
|
if (i >= next.length) {
|
||||||
@@ -46,10 +39,10 @@ export default function QueryTicker() {
|
|||||||
|
|
||||||
timer = setTimeout(step, 2600);
|
timer = setTimeout(step, 2600);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, [queries]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p className="query" aria-label={QUERIES.join(" ")}>
|
<p className="query" aria-label={queries.join(" ")}>
|
||||||
<span aria-hidden="true">{text}</span>
|
<span aria-hidden="true">{text}</span>
|
||||||
<span className="caret" aria-hidden="true" />
|
<span className="caret" aria-hidden="true" />
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { LogoStacked } from "./logo";
|
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.
|
// The profiles linked from the current aigrowthtechnologies.com footer.
|
||||||
const socials = [
|
const socials = [
|
||||||
@@ -36,31 +38,29 @@ const socials = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const columns = [
|
export default async function SiteFooter() {
|
||||||
{ title: "Services", links: serviceNav.map((s) => ({ label: s.name, href: `/services/${s.slug}` })) },
|
const { locale, t } = await getUI();
|
||||||
{ title: "Industries", links: industryNav.map((s) => ({ label: s.name, href: `/industries/${s.slug}` })) },
|
const columns = [
|
||||||
{
|
{ title: t.nav.services, links: serviceLinks(t, locale) },
|
||||||
title: "Company",
|
{ title: t.nav.industries, links: industryLinks(t, locale) },
|
||||||
links: [
|
{
|
||||||
{ label: "About", href: "/about" },
|
title: t.footer.company,
|
||||||
{ label: "All Services", href: "/services" },
|
links: [
|
||||||
{ label: "All Industries", href: "/industries" },
|
{ label: t.nav.about, href: localize("/about", locale) },
|
||||||
{ label: "Contact", href: "/contact" },
|
{ 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 (
|
return (
|
||||||
<footer className="footer">
|
<footer className="footer">
|
||||||
<div className="wrap">
|
<div className="wrap">
|
||||||
<div className="footer-top">
|
<div className="footer-top">
|
||||||
<div className="footer-brand">
|
<div className="footer-brand">
|
||||||
<LogoStacked />
|
<LogoStacked href={localize("/", locale)} label={t.logoHome} />
|
||||||
<p>
|
<p>{t.footer.tagline}</p>
|
||||||
Paid media, SEO, AI visibility, web, attribution, and technology — connected into one accountable digital
|
|
||||||
growth system.
|
|
||||||
</p>
|
|
||||||
<div className="socials">
|
<div className="socials">
|
||||||
{socials.map((s) => (
|
{socials.map((s) => (
|
||||||
<a key={s.label} href={s.href} aria-label={s.label} target="_blank" rel="noopener noreferrer">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<p className="footer-word" aria-hidden="true">
|
<p className="footer-word" aria-hidden="true" dir="ltr">
|
||||||
AI Growth Technologies
|
AI Growth Technologies
|
||||||
</p>
|
</p>
|
||||||
<div className="footer-bottom">
|
<div className="footer-bottom">
|
||||||
<span>© 2026 AI Growth Technologies. All rights reserved.</span>
|
<span>{t.footer.rights}</span>
|
||||||
<span>Dubai · Houston · Miami · New York</span>
|
<span>{t.footer.cities}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -4,10 +4,41 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Logo from "./logo";
|
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 pathname = usePathname();
|
||||||
|
const { path } = splitLocale(pathname);
|
||||||
|
const current = localize(path, locale);
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const [hidden, setHidden] = useState(false);
|
const [hidden, setHidden] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -46,7 +77,8 @@ export default function SiteHeader() {
|
|||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [open]);
|
}, [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" : ""]
|
const classes = ["site-header", scrolled || open ? "is-scrolled" : "", hidden && !open ? "is-hidden" : ""]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
@@ -55,9 +87,9 @@ export default function SiteHeader() {
|
|||||||
<>
|
<>
|
||||||
<header className={classes}>
|
<header className={classes}>
|
||||||
<div className="wrap bar">
|
<div className="wrap bar">
|
||||||
<Logo />
|
<Logo href={localize("/", locale)} label={t.logoHome} />
|
||||||
<nav className="nav" aria-label="Primary">
|
<nav className="nav" aria-label={t.primary}>
|
||||||
{mainNav.map((l) =>
|
{nav.map((l) =>
|
||||||
l.children ? (
|
l.children ? (
|
||||||
<div key={l.href} className="nav-group">
|
<div key={l.href} className="nav-group">
|
||||||
<Link href={l.href} className={isActive(l.href) ? "is-active" : undefined}>
|
<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">
|
||||||
<div className="nav-panel-inner">
|
<div className="nav-panel-inner">
|
||||||
<Link href={l.href} className="nav-panel-all">
|
<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>
|
</Link>
|
||||||
<ul>
|
<ul>
|
||||||
{l.children.map((c) => (
|
{l.children.map((c) => (
|
||||||
<li key={c.href}>
|
<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}
|
{c.label}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -87,15 +119,16 @@ export default function SiteHeader() {
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<Link href="/contact" className="btn btn-solid header-cta">
|
<LanguageSwitch locale={locale} path={path} label={t.language} />
|
||||||
Book a Strategy Call <span className="arrow">→</span>
|
<Link href={contactHref} className="btn btn-solid header-cta">
|
||||||
|
{t.bookCall} <span className="arrow">→</span>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="menu-toggle"
|
className="menu-toggle"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-controls="mobile-menu"
|
aria-controls="mobile-menu"
|
||||||
aria-label={open ? "Close menu" : "Open menu"}
|
aria-label={open ? t.closeMenu : t.openMenu}
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
>
|
>
|
||||||
<span />
|
<span />
|
||||||
@@ -105,10 +138,10 @@ export default function SiteHeader() {
|
|||||||
</header>
|
</header>
|
||||||
<div id="mobile-menu" className={`mobile-menu${open ? " is-open" : ""}`} aria-hidden={!open} inert={!open}>
|
<div id="mobile-menu" className={`mobile-menu${open ? " is-open" : ""}`} aria-hidden={!open} inert={!open}>
|
||||||
<ul>
|
<ul>
|
||||||
{mainNav.map((l) => (
|
{nav.map((l) => (
|
||||||
<li key={l.href}>
|
<li key={l.href}>
|
||||||
<Link href={l.href}>
|
<Link href={l.href}>
|
||||||
{l.label} <span aria-hidden="true">→</span>
|
{l.label} <span className="arrow" aria-hidden="true">→</span>
|
||||||
</Link>
|
</Link>
|
||||||
{l.children && (
|
{l.children && (
|
||||||
<div className="mobile-sub">
|
<div className="mobile-sub">
|
||||||
@@ -122,8 +155,9 @@ export default function SiteHeader() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<Link href="/contact" className="btn btn-solid">
|
<LanguageSwitch locale={locale} path={path} label={t.language} />
|
||||||
Book a Strategy Call <span className="arrow">→</span>
|
<Link href={contactHref} className="btn btn-solid">
|
||||||
|
{t.bookCall} <span className="arrow">→</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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.",
|
|
||||||
];
|
|
||||||
@@ -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: "احجز مكالمتك الاستراتيجية",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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) },
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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] };
|
||||||
|
}
|
||||||
@@ -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: "تعذّر إرسال رسالتك.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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";
|
"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.
|
// 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")
|
const TO = (process.env.CONTACT_TO || "rey@aigrowth360.io")
|
||||||
@@ -27,21 +40,9 @@ const silentlyDrop = (reason: string): ContactState => {
|
|||||||
return { status: "sent" };
|
return { status: "sent" };
|
||||||
};
|
};
|
||||||
|
|
||||||
async function passesTurnstile(token: string): Promise<boolean> {
|
/** A fresh proof-of-work challenge for the "I'm not a robot" check. */
|
||||||
const secret = process.env.TURNSTILE_SECRET_KEY;
|
export async function getChallenge(): Promise<Challenge> {
|
||||||
if (!secret) return true; // Turnstile not configured yet
|
return createChallenge();
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendEmail(apiKey: string, payload: Record<string, unknown>) {
|
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()}`);
|
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.
|
// 1. Honeypot: real visitors never see or fill this field.
|
||||||
if (clean(formData.get("company_url"), 200)) return silentlyDrop("honeypot");
|
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"));
|
const started = Number(formData.get("started"));
|
||||||
if (!started || Date.now() - started < MIN_FILL_MS) return silentlyDrop("submitted too fast");
|
if (!started || Date.now() - started < MIN_FILL_MS) return silentlyDrop("submitted too fast");
|
||||||
|
|
||||||
const name = clean(formData.get("name"), 120);
|
const values: ContactValues = {
|
||||||
const email = clean(formData.get("email"), 200);
|
name: clean(formData.get("name"), 120),
|
||||||
const company = clean(formData.get("company"), 160);
|
email: clean(formData.get("email"), 200),
|
||||||
const website = clean(formData.get("website"), 300);
|
company: clean(formData.get("company"), 160),
|
||||||
const interests = formData
|
website: clean(formData.get("website"), 300),
|
||||||
.getAll("interest")
|
interests: formData
|
||||||
.map((i) => clean(i, 60))
|
.getAll("interest")
|
||||||
.filter(Boolean)
|
.map((i) => clean(i, 60))
|
||||||
.slice(0, 12);
|
.filter(Boolean)
|
||||||
const message = clean(formData.get("message"), 5000);
|
.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)) {
|
if (!values.name || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) return fail("details");
|
||||||
return { status: "error", message: "Please add your name, a valid email, and a short message." };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Link spam.
|
// 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.
|
// 4. "I'm not a robot": the proof-of-work solved in the browser.
|
||||||
if (!(await passesTurnstile(clean(formData.get("cf-turnstile-response"), 2048)))) {
|
const solved = verifySolution(
|
||||||
return { status: "error", message: "We couldn’t verify you’re human. Please try again." };
|
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;
|
const apiKey = process.env.RESEND_API_KEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
console.error("Contact form: RESEND_API_KEY is not set");
|
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][] = [
|
const rows: [string, string][] = [
|
||||||
["Name", name],
|
["Name", values.name],
|
||||||
["Email", email],
|
["Email", values.email],
|
||||||
["Company", company || "—"],
|
["Company", values.company || "—"],
|
||||||
["Website", website || "—"],
|
["Website", values.website || "—"],
|
||||||
["Interested in", interests.join(", ") || "—"],
|
["Interested in", values.interests.join(", ") || "—"],
|
||||||
|
["Site language", language || "en"],
|
||||||
];
|
];
|
||||||
|
const message = values.message || "(No message)";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendEmail(apiKey, {
|
await sendEmail(apiKey, {
|
||||||
to: TO,
|
to: TO,
|
||||||
reply_to: email,
|
reply_to: values.email,
|
||||||
subject: `New enquiry — ${company || name}`,
|
subject: `New enquiry — ${values.company || values.name}`,
|
||||||
text: `${rows.map(([k, v]) => `${k}: ${v}`).join("\n")}\n\n${message}`,
|
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
|
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>`)
|
.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) {
|
} catch (err) {
|
||||||
console.error("Contact form: could not email the team", 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" };
|
return { status: "sent" };
|
||||||
+21
-2
@@ -1,13 +1,32 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { locales, localize, ogLocale, type Locale } from "@/i18n/config";
|
||||||
import type { Page } from "./pages";
|
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 {
|
export function pageMetadata(page: Page): Metadata {
|
||||||
const title = page.meta.title ?? "AI Growth Technologies";
|
const title = page.meta.title ?? "AI Growth Technologies";
|
||||||
const description = page.meta.description ?? undefined;
|
const description = page.meta.description ?? undefined;
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: { canonical: page.path },
|
alternates: localeAlternates(page.path, page.locale),
|
||||||
openGraph: { type: "website", url: page.path, siteName: "AI Growth Technologies", title, description },
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
url: localize(page.path, page.locale),
|
||||||
|
siteName: "AI Growth Technologies",
|
||||||
|
locale: ogLocale[page.locale],
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-54
@@ -1,28 +1,11 @@
|
|||||||
import about from "@/content/pages/about.json";
|
import { content } from "@/content/pages";
|
||||||
import contact from "@/content/pages/contact.json";
|
import { localize, type Locale } from "@/i18n/config";
|
||||||
import services from "@/content/pages/services.json";
|
import { hrefFor, industryNav, serviceNav } from "./routes";
|
||||||
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";
|
|
||||||
|
|
||||||
export type SectionKind = "text" | "cards" | "steps" | "faq" | "links" | "stats" | "offices" | "showcase";
|
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;
|
kicker: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string | null;
|
subtitle: string | null;
|
||||||
@@ -32,55 +15,69 @@ export type Item = {
|
|||||||
num?: string;
|
num?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Section = {
|
type RawSection = {
|
||||||
eyebrow: string | null;
|
eyebrow: string | null;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
intro: string[];
|
intro: string[];
|
||||||
items: Item[];
|
items: RawItem[];
|
||||||
outro: string[];
|
outro: string[];
|
||||||
ctas: string[];
|
ctas: string[];
|
||||||
form: boolean;
|
form: boolean;
|
||||||
kind: SectionKind | string;
|
kind: SectionKind | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Page = {
|
type RawPage = {
|
||||||
slug: string;
|
slug: string;
|
||||||
path: string;
|
path: string;
|
||||||
meta: { title: string | null; description: string | null };
|
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;
|
hero: Section;
|
||||||
sections: Section[];
|
sections: Section[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const serviceContent: Record<string, unknown> = {
|
type Key = keyof (typeof content)["en"];
|
||||||
"paid-media": paidMedia,
|
|
||||||
seo,
|
|
||||||
llmo,
|
|
||||||
"web-development": webDevelopment,
|
|
||||||
"social-media": socialMedia,
|
|
||||||
brand,
|
|
||||||
consulting,
|
|
||||||
cybersecurity,
|
|
||||||
};
|
|
||||||
|
|
||||||
const industryContent: Record<string, unknown> = {
|
// Destinations come from the English labels, which the route map understands; translated pages
|
||||||
hospitality,
|
// share the English structure, so the same positions line up.
|
||||||
"medical-dental": medicalDental,
|
function resolveSection(s: RawSection, en: RawSection, path: string, locale: Locale): Section {
|
||||||
automotive,
|
const to = (label: string) => localize(hrefFor(label, path), locale);
|
||||||
"e-commerce": ecommerce,
|
return {
|
||||||
"home-services": homeServices,
|
...s,
|
||||||
b2b,
|
ctas: s.ctas.map((label, i) => ({ label, href: to(en.ctas[i] ?? label) })),
|
||||||
creators,
|
items: s.items.map((it, i) => ({
|
||||||
};
|
...it,
|
||||||
|
cta: it.cta ? { label: it.cta, href: to(en.items[i]?.cta ?? it.cta) } : null,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getIndustry(slug: string): Page | undefined {
|
function getPage(locale: Locale, key: Key): Page {
|
||||||
return industryNav.some((s) => s.slug === slug) ? (industryContent[slug] as Page) : undefined;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,6 @@ export const industryNav = [
|
|||||||
{ slug: "creators", name: "Creators & Personal Brands" },
|
{ slug: "creators", name: "Creators & Personal Brands" },
|
||||||
] as const;
|
] 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.
|
// Industry keywords are checked before services so "Creators & Personal Brands" never resolves to Brand.
|
||||||
const industryKeys: [RegExp, string][] = [
|
const industryKeys: [RegExp, string][] = [
|
||||||
[/HOSPITALITY/, "hospitality"],
|
[/HOSPITALITY/, "hospitality"],
|
||||||
|
|||||||
@@ -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/|.*\\..*).*)"],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user