From 828a81f3a55215dda2e5a96e45bb0e25fb7a3144 Mon Sep 17 00:00:00 2001 From: Alejandro Veras Date: Tue, 13 Oct 2026 11:53:18 -0400 Subject: [PATCH] Add Spanish and Arabic versions, a contact form captcha and a multilingual sitemap - English, Spanish (/es) and Arabic (/ar, right to left) versions of every page, with a language switch - Self-hosted "I'm not a robot" check on the contact form; the message field is optional - Google Tag Manager, GA4 and Search Console verification in the shared layout - Sitemap, robots.txt and canonical links point at aigrowthtechnologies.com --- README.md | 15 +- scripts/convert-docs.py | 4 +- scripts/i18n.py | 130 ++++ src/app/[lang]/about/page.tsx | 14 + src/app/[lang]/contact/page.tsx | 14 + .../{ => [lang]}/industries/[slug]/page.tsx | 23 +- src/app/[lang]/industries/page.tsx | 14 + src/app/[lang]/layout.tsx | 90 +++ src/app/{ => [lang]}/page.tsx | 231 +++--- src/app/{ => [lang]}/services/[slug]/page.tsx | 23 +- src/app/[lang]/services/page.tsx | 14 + src/app/about/page.tsx | 19 - src/app/contact/page.tsx | 19 - src/app/globals.css | 300 ++++++-- src/app/industries/page.tsx | 19 - src/app/layout.tsx | 84 --- src/app/services/page.tsx | 19 - src/app/sitemap.ts | 16 +- src/components/analytics.tsx | 33 + src/components/capabilities.tsx | 25 +- src/components/content/contact-form.tsx | 156 ++-- src/components/content/content-page.tsx | 120 +-- src/components/content/human-check.tsx | 90 +++ src/components/globe-canvas.tsx | 2 + src/components/logo.tsx | 8 +- src/components/product-visuals.tsx | 41 +- src/components/query-ticker.tsx | 25 +- src/components/site-footer.tsx | 46 +- src/components/site-header.tsx | 64 +- src/content/home.ts | 134 ---- src/content/home/ar.ts | 232 ++++++ src/content/home/en.ts | 241 ++++++ src/content/home/es.ts | 241 ++++++ src/content/home/index.ts | 7 + src/content/pages/ar/about.json | 477 ++++++++++++ src/content/pages/ar/contact.json | 199 +++++ src/content/pages/ar/industries.json | 314 ++++++++ .../pages/ar/industries_automotive.json | 676 +++++++++++++++++ src/content/pages/ar/industries_b2b.json | 697 ++++++++++++++++++ src/content/pages/ar/industries_creators.json | 697 ++++++++++++++++++ .../pages/ar/industries_e-commerce.json | 631 ++++++++++++++++ .../pages/ar/industries_home-services.json | 653 ++++++++++++++++ .../pages/ar/industries_hospitality.json | 562 ++++++++++++++ .../pages/ar/industries_medical-dental.json | 644 ++++++++++++++++ src/content/pages/ar/services.json | 198 +++++ src/content/pages/ar/services_brand.json | 415 +++++++++++ src/content/pages/ar/services_consulting.json | 415 +++++++++++ .../pages/ar/services_cybersecurity.json | 442 +++++++++++ src/content/pages/ar/services_llmo.json | 529 +++++++++++++ src/content/pages/ar/services_paid-media.json | 445 +++++++++++ src/content/pages/ar/services_seo.json | 451 ++++++++++++ .../pages/ar/services_social-media.json | 449 +++++++++++ .../pages/ar/services_web-development.json | 412 +++++++++++ src/content/pages/{ => en}/about.json | 0 src/content/pages/{ => en}/contact.json | 0 src/content/pages/{ => en}/industries.json | 0 .../pages/{ => en}/industries_automotive.json | 0 .../pages/{ => en}/industries_b2b.json | 0 .../pages/{ => en}/industries_creators.json | 0 .../pages/{ => en}/industries_e-commerce.json | 0 .../{ => en}/industries_home-services.json | 0 .../{ => en}/industries_hospitality.json | 0 .../{ => en}/industries_medical-dental.json | 0 src/content/pages/{ => en}/services.json | 0 .../pages/{ => en}/services_brand.json | 0 .../pages/{ => en}/services_consulting.json | 0 .../{ => en}/services_cybersecurity.json | 0 src/content/pages/{ => en}/services_llmo.json | 0 .../pages/{ => en}/services_paid-media.json | 0 src/content/pages/{ => en}/services_seo.json | 0 .../pages/{ => en}/services_social-media.json | 0 .../{ => en}/services_web-development.json | 0 src/content/pages/es/about.json | 477 ++++++++++++ src/content/pages/es/contact.json | 199 +++++ src/content/pages/es/industries.json | 314 ++++++++ .../pages/es/industries_automotive.json | 676 +++++++++++++++++ src/content/pages/es/industries_b2b.json | 697 ++++++++++++++++++ src/content/pages/es/industries_creators.json | 697 ++++++++++++++++++ .../pages/es/industries_e-commerce.json | 631 ++++++++++++++++ .../pages/es/industries_home-services.json | 653 ++++++++++++++++ .../pages/es/industries_hospitality.json | 562 ++++++++++++++ .../pages/es/industries_medical-dental.json | 644 ++++++++++++++++ src/content/pages/es/services.json | 198 +++++ src/content/pages/es/services_brand.json | 415 +++++++++++ src/content/pages/es/services_consulting.json | 415 +++++++++++ .../pages/es/services_cybersecurity.json | 442 +++++++++++ src/content/pages/es/services_llmo.json | 529 +++++++++++++ src/content/pages/es/services_paid-media.json | 445 +++++++++++ src/content/pages/es/services_seo.json | 451 ++++++++++++ .../pages/es/services_social-media.json | 449 +++++++++++ .../pages/es/services_web-development.json | 412 +++++++++++ src/content/pages/index.ts | 125 ++++ src/i18n/config.ts | 32 + src/i18n/nav.ts | 31 + src/i18n/server.ts | 16 + src/i18n/ui/ar.ts | 81 ++ src/i18n/ui/en.ts | 83 +++ src/i18n/ui/es.ts | 81 ++ src/i18n/ui/index.ts | 7 + src/lib/captcha.ts | 41 ++ .../actions.ts => lib/contact-action.ts} | 99 +-- src/lib/metadata.ts | 23 +- src/lib/pages.ts | 105 ++- src/lib/routes.ts | 11 +- src/proxy.ts | 25 + 105 files changed, 21097 insertions(+), 788 deletions(-) create mode 100644 scripts/i18n.py create mode 100644 src/app/[lang]/about/page.tsx create mode 100644 src/app/[lang]/contact/page.tsx rename src/app/{ => [lang]}/industries/[slug]/page.tsx (51%) create mode 100644 src/app/[lang]/industries/page.tsx create mode 100644 src/app/[lang]/layout.tsx rename src/app/{ => [lang]}/page.tsx (59%) rename src/app/{ => [lang]}/services/[slug]/page.tsx (52%) create mode 100644 src/app/[lang]/services/page.tsx delete mode 100644 src/app/about/page.tsx delete mode 100644 src/app/contact/page.tsx delete mode 100644 src/app/industries/page.tsx delete mode 100644 src/app/layout.tsx delete mode 100644 src/app/services/page.tsx create mode 100644 src/components/analytics.tsx create mode 100644 src/components/content/human-check.tsx delete mode 100644 src/content/home.ts create mode 100644 src/content/home/ar.ts create mode 100644 src/content/home/en.ts create mode 100644 src/content/home/es.ts create mode 100644 src/content/home/index.ts create mode 100644 src/content/pages/ar/about.json create mode 100644 src/content/pages/ar/contact.json create mode 100644 src/content/pages/ar/industries.json create mode 100644 src/content/pages/ar/industries_automotive.json create mode 100644 src/content/pages/ar/industries_b2b.json create mode 100644 src/content/pages/ar/industries_creators.json create mode 100644 src/content/pages/ar/industries_e-commerce.json create mode 100644 src/content/pages/ar/industries_home-services.json create mode 100644 src/content/pages/ar/industries_hospitality.json create mode 100644 src/content/pages/ar/industries_medical-dental.json create mode 100644 src/content/pages/ar/services.json create mode 100644 src/content/pages/ar/services_brand.json create mode 100644 src/content/pages/ar/services_consulting.json create mode 100644 src/content/pages/ar/services_cybersecurity.json create mode 100644 src/content/pages/ar/services_llmo.json create mode 100644 src/content/pages/ar/services_paid-media.json create mode 100644 src/content/pages/ar/services_seo.json create mode 100644 src/content/pages/ar/services_social-media.json create mode 100644 src/content/pages/ar/services_web-development.json rename src/content/pages/{ => en}/about.json (100%) rename src/content/pages/{ => en}/contact.json (100%) rename src/content/pages/{ => en}/industries.json (100%) rename src/content/pages/{ => en}/industries_automotive.json (100%) rename src/content/pages/{ => en}/industries_b2b.json (100%) rename src/content/pages/{ => en}/industries_creators.json (100%) rename src/content/pages/{ => en}/industries_e-commerce.json (100%) rename src/content/pages/{ => en}/industries_home-services.json (100%) rename src/content/pages/{ => en}/industries_hospitality.json (100%) rename src/content/pages/{ => en}/industries_medical-dental.json (100%) rename src/content/pages/{ => en}/services.json (100%) rename src/content/pages/{ => en}/services_brand.json (100%) rename src/content/pages/{ => en}/services_consulting.json (100%) rename src/content/pages/{ => en}/services_cybersecurity.json (100%) rename src/content/pages/{ => en}/services_llmo.json (100%) rename src/content/pages/{ => en}/services_paid-media.json (100%) rename src/content/pages/{ => en}/services_seo.json (100%) rename src/content/pages/{ => en}/services_social-media.json (100%) rename src/content/pages/{ => en}/services_web-development.json (100%) create mode 100644 src/content/pages/es/about.json create mode 100644 src/content/pages/es/contact.json create mode 100644 src/content/pages/es/industries.json create mode 100644 src/content/pages/es/industries_automotive.json create mode 100644 src/content/pages/es/industries_b2b.json create mode 100644 src/content/pages/es/industries_creators.json create mode 100644 src/content/pages/es/industries_e-commerce.json create mode 100644 src/content/pages/es/industries_home-services.json create mode 100644 src/content/pages/es/industries_hospitality.json create mode 100644 src/content/pages/es/industries_medical-dental.json create mode 100644 src/content/pages/es/services.json create mode 100644 src/content/pages/es/services_brand.json create mode 100644 src/content/pages/es/services_consulting.json create mode 100644 src/content/pages/es/services_cybersecurity.json create mode 100644 src/content/pages/es/services_llmo.json create mode 100644 src/content/pages/es/services_paid-media.json create mode 100644 src/content/pages/es/services_seo.json create mode 100644 src/content/pages/es/services_social-media.json create mode 100644 src/content/pages/es/services_web-development.json create mode 100644 src/content/pages/index.ts create mode 100644 src/i18n/config.ts create mode 100644 src/i18n/nav.ts create mode 100644 src/i18n/server.ts create mode 100644 src/i18n/ui/ar.ts create mode 100644 src/i18n/ui/en.ts create mode 100644 src/i18n/ui/es.ts create mode 100644 src/i18n/ui/index.ts create mode 100644 src/lib/captcha.ts rename src/{app/contact/actions.ts => lib/contact-action.ts} (52%) create mode 100644 src/proxy.ts diff --git a/README.md b/README.md index 1674655..5ec2965 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ Copy `.env.example` to `.env.production` (or `.env.local` for development) and f | `RESEND_API_KEY` | yes | Resend key with sending access to `aigrowthtechnologies.com`. Without it the contact form shows an error. | | `CONTACT_FROM` | yes | Sender, on the domain verified in Resend: `AI Growth Website ` | | `CONTACT_TO` | no | Inbox that receives enquiries; separate several with commas. Defaults to `rey@aigrowth360.io`. | -| `TURNSTILE_SECRET_KEY` / `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | no | Cloudflare Turnstile. When both are set, the form shows the challenge. `NEXT_PUBLIC_*` values are baked in at build time, so rebuild after changing it. | +| `SITE_URL` | no | Public address used for canonical links, hreflang, `sitemap.xml` and `robots.txt`. Defaults to `https://aigrowthtechnologies.com`. | +| `CAPTCHA_SECRET` | no | Signs the contact form's "I'm not a robot" challenges (a self-hosted proof of work, no third-party account). Without it a random secret is used per process, which only matters across restarts. | ## Deploying to a VPS @@ -68,6 +69,12 @@ Then issue HTTPS with `sudo certbot --nginx -d aigrowthtechnologies.com -d www.a At Hostinger, point the `A` records for `@` and `www` to the VPS IP. Leave the MX records (Titan email) and the Resend records (`send`, `resend._domainkey`) as they are. +## Languages + +The site is served in English (`/`), Spanish (`/es`) and Arabic (`/ar`, right to left). `src/proxy.ts` maps the +unprefixed English URLs onto `app/[lang]`. Interface strings live in `src/i18n/ui/`, homepage copy in +`src/content/home/`, and content pages in `src/content/pages//`. + ## Content Inner pages are generated from the Word copy decks: @@ -76,4 +83,8 @@ Inner pages are generated from the Word copy decks: python3 -E scripts/convert-docs.py "" ``` -This rewrites `src/content/pages/*.json`. The homepage (`src/app/page.tsx`) is written by hand. +This rewrites `src/content/pages/en/*.json`. The homepage (`src/app/[lang]/page.tsx`) is written by hand. + +Translations keep the English structure. `python3 -E scripts/i18n.py extract ` writes the strings to translate, +`python3 -E scripts/i18n.py apply ` writes the translated pages, and +`python3 -E scripts/i18n.py index` regenerates `src/content/pages/index.ts`. diff --git a/scripts/convert-docs.py b/scripts/convert-docs.py index 337b6d9..e834890 100644 --- a/scripts/convert-docs.py +++ b/scripts/convert-docs.py @@ -7,7 +7,7 @@ Usage: Paragraphs that start with "> " were bold sentences in the deck and render as emphasised lines. Each document follows the same conventions: bold all-caps lines are eyebrows (when followed by a heading) or CTAs; Heading 1/2/3 give the structure. -The output lands in src/content/pages/.json. The homepage is hand-built +The output lands in src/content/pages/en/.json. The homepage is hand-built and is skipped. """ @@ -18,7 +18,7 @@ import re import sys import zipfile -OUT = pathlib.Path(__file__).resolve().parent.parent / "src" / "content" / "pages" +OUT = pathlib.Path(__file__).resolve().parent.parent / "src" / "content" / "pages" / "en" # Lines in the decks that are notes to the team, not site copy. DROP = [ diff --git a/scripts/i18n.py b/scripts/i18n.py new file mode 100644 index 0000000..e580a91 --- /dev/null +++ b/scripts/i18n.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Translation helpers for the generated content pages. + + python3 -E scripts/i18n.py extract + Write one flat {path: text} JSON per English page with only the strings to translate. + + python3 -E scripts/i18n.py apply + Read translated flat files from and write src/content/pages//.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) diff --git a/src/app/[lang]/about/page.tsx b/src/app/[lang]/about/page.tsx new file mode 100644 index 0000000..8d8d7f1 --- /dev/null +++ b/src/app/[lang]/about/page.tsx @@ -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 ; +} diff --git a/src/app/[lang]/contact/page.tsx b/src/app/[lang]/contact/page.tsx new file mode 100644 index 0000000..b3188d9 --- /dev/null +++ b/src/app/[lang]/contact/page.tsx @@ -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 ; +} diff --git a/src/app/industries/[slug]/page.tsx b/src/app/[lang]/industries/[slug]/page.tsx similarity index 51% rename from src/app/industries/[slug]/page.tsx rename to src/app/[lang]/industries/[slug]/page.tsx index 3ae2751..08e1e80 100644 --- a/src/app/industries/[slug]/page.tsx +++ b/src/app/[lang]/industries/[slug]/page.tsx @@ -1,23 +1,24 @@ import { Suspense } from "react"; import { notFound } from "next/navigation"; import ContentPage from "@/components/content/content-page"; +import { getUI } from "@/i18n/server"; import { getIndustry } from "@/lib/pages"; import { pageMetadata } from "@/lib/metadata"; import { industryNav } from "@/lib/routes"; -type Params = PageProps<"/industries/[slug]">["params"]; +type Params = PageProps<"/[lang]/industries/[slug]">["params"]; export function generateStaticParams() { return industryNav.map((s) => ({ slug: s.slug })); } -export async function generateMetadata({ params }: PageProps<"/industries/[slug]">) { - const { slug } = await params; - const page = getIndustry(slug); +export async function generateMetadata({ params }: PageProps<"/[lang]/industries/[slug]">) { + const [{ slug }, { locale }] = await Promise.all([params, getUI()]); + const page = getIndustry(locale, slug); return page ? pageMetadata(page) : {}; } -export default function Page({ params }: PageProps<"/industries/[slug]">) { +export default function Page({ params }: PageProps<"/[lang]/industries/[slug]">) { return ( }> @@ -26,18 +27,16 @@ export default function Page({ params }: PageProps<"/industries/[slug]">) { } async function Content({ params }: { params: Params }) { - const { slug } = await params; - const page = getIndustry(slug); + const [{ slug }, { locale, t }] = await Promise.all([params, getUI()]); + const page = getIndustry(locale, slug); if (!page) notFound(); - const name = industryNav.find((s) => s.slug === slug)?.name ?? slug; return ( ); diff --git a/src/app/[lang]/industries/page.tsx b/src/app/[lang]/industries/page.tsx new file mode 100644 index 0000000..722c039 --- /dev/null +++ b/src/app/[lang]/industries/page.tsx @@ -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 ; +} diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx new file mode 100644 index 0000000..1c7c74f --- /dev/null +++ b/src/app/[lang]/layout.tsx @@ -0,0 +1,90 @@ +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 Analytics, { AnalyticsNoScript } from "@/components/analytics"; +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 { + 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), + // Google Search Console ownership. + verification: { google: "LnRawiMDpDtTVBC2X34We70f8OHBLMVC6e9IWoHCZLs" }, + 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 ( + + + + + {t.skip} + + {/* The header reads the pathname, which is request data on dynamic routes. */} + + + +
{children}
+ + + + + ); +} diff --git a/src/app/page.tsx b/src/app/[lang]/page.tsx similarity index 59% rename from src/app/page.tsx rename to src/app/[lang]/page.tsx index d3e1475..ea771d0 100644 --- a/src/app/page.tsx +++ b/src/app/[lang]/page.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties, ReactNode } from "react"; +import { Fragment, type CSSProperties, type ReactNode } from "react"; import GlobeCanvas from "@/components/globe-canvas"; import StatCounter from "@/components/stat-counter"; import RevealObserver from "@/components/reveal-observer"; @@ -6,8 +6,9 @@ import Capabilities from "@/components/capabilities"; import QueryTicker from "@/components/query-ticker"; import ProductVisual from "@/components/product-visuals"; import Link from "next/link"; -import { stats, products, journeySteps, gaps } from "@/content/home"; -import { hrefFor } from "@/lib/routes"; +import { home } from "@/content/home"; +import { localize } from "@/i18n/config"; +import { getLocale } from "@/i18n/server"; type Vars = CSSProperties & Record<`--${string}`, string | number>; const delay = (ms: number) => ({ "--d": `${ms}ms` }) as Vars; @@ -23,7 +24,12 @@ function Eyebrow({ index, children }: { index?: string; children: ReactNode }) { ); } -export default function Home() { +export default async function Home() { + const locale = await getLocale(); + const c = home[locale]; + const to = (path: string) => localize(path, locale); + const { hero, numbers, capabilities, technology, discovery, journey, difference, cta } = c; + return ( <> {/* ───────────────────────── Hero ───────────────────────── */} @@ -31,34 +37,28 @@ export default function Home() {
- Digital Growth Architects + {hero.eyebrow}

- Where Search, AI, Media, and Technology Work Together. + {hero.title}

-

- Your customer might find you through Google today, an AI answer tomorrow, and a paid ad three - days later. -

-

They don’t think in channels. Your growth strategy shouldn’t either.

+

{hero.lead}

+

{hero.leadMuted}

-

- AI Growth Technologies brings paid media, search, AI visibility, technology, and attribution - together so each part strengthens the others. -

-

One strategy. Connected execution. A clearer view of what is actually driving growth.

+

{hero.body}

+

{hero.highlight}

- - Book a Strategy Call + + {hero.primary} - - Explore Services + + {hero.secondary}
@@ -66,18 +66,15 @@ export default function Home() {
- Scroll + {hero.scroll}
@@ -88,14 +85,14 @@ export default function Home() {
- The Numbers Behind the Work + {numbers.eyebrow}

- Good Growth Leaves Evidence. + {numbers.title}

- {stats.map((s, i) => ( + {numbers.stats.map((s, i) => (
{s.label}
@@ -110,29 +107,29 @@ export default function Home() {
- Everything Works Better Connected + {capabilities.eyebrow}

- Eight Capabilities. + {capabilities.title[0]}
- One Growth Engine. + {capabilities.title[1]}

-

- 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. -

+

{capabilities.lead}

-

To your customer, that is not SEO, paid media, AI, and web.

-

It is one experience with your business.

-

Our capabilities are designed to work that way too.

+

{capabilities.copy}

+

{capabilities.highlight}

+

{capabilities.after}

- + ({ ...it, href: to(it.href) }))} + core={capabilities.core} + />
@@ -142,37 +139,34 @@ export default function Home() {
- Proprietary Technology + {technology.eyebrow}

- We Wanted Better Answers, So We Built Better Tools. + {technology.title}

-

Marketing produces plenty of numbers.

-

The harder part is knowing which ones matter.

+

{technology.lead}

+

{technology.leadMuted}

+

{technology.copy}

- Our proprietary technology gives our teams and clients a clearer view across attribution, organic and - AI visibility, and digital resilience. -

-

- Not more data for the sake of it. Better information for the decisions that come next. + {technology.closing} {technology.closingHighlight}

- {products.map((p, i) => ( + {technology.products.map((p, i) => (
- +
- {String(i + 1).padStart(2, "0")} / 03 + {String(i + 1).padStart(2, "0")} / 03 {p.kind}

@@ -184,7 +178,7 @@ export default function Home() {

{b}

))}

- + {p.cta}
@@ -199,23 +193,25 @@ export default function Home() {
- Discovery Is Changing + {discovery.eyebrow}

- Your Next Customer May Ask Before They Search. + {discovery.title}

-
People now ask AI platforms
- +
{discovery.queryLabel}
+
- Google - Rankings - Authority + {discovery.searchPills.map((p) => ( + + {p} + + ))}
-
Your Brand
+
{discovery.yourBrand}
ChatGPT Gemini @@ -227,23 +223,18 @@ export default function Home() {
-

People still search Google.

-

- Now they also ask AI platforms which companies to consider, how products compare, what to buy, and - who to trust. -

+

{discovery.lead}

+

{discovery.leadMuted}

-

That changes what visibility means.

-

- 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.

+

{discovery.highlight}

+ {discovery.copy.map((p) => ( +

{p}

+ ))}
- - Explore AI Visibility + + {discovery.cta}
@@ -255,16 +246,16 @@ export default function Home() {
- From Attention to Action + {journey.eyebrow}

- The Last Click Rarely Tells the Whole Story. + {journey.title}

    - {journeySteps.map((s, i) => ( + {journey.steps.map((s, i) => (
  1. @@ -278,28 +269,24 @@ export default function Home() {

    - Which interaction gets the credit? + {journey.question}

    -

    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: -

    + {journey.copy.map((p) => ( +

    {p}

    + ))}
    - The more useful question -
    What actually helped move the customer toward a decision?
    + {journey.quoteEyebrow} +
    {journey.quote}
    -

    Better attribution isn’t about producing a prettier report.

    -

    - It is about knowing where the next dollar has the best chance of doing something useful. -

    +

    {journey.caption}

    +

    {journey.captionHighlight}

    - - Explore Our Approach + + {journey.cta}
@@ -310,15 +297,15 @@ export default function Home() {
- The Difference Is in the Connections + {difference.eyebrow}

- Good Channels Don’t Automatically Make a Good System. + {difference.title}

    - {gaps.map((g, i) => ( + {difference.gaps.map((g, i) => (
  • {g}

    - That is the gap we care about. + {difference.gapTitle}

    -

    We look at how discovery, acquisition, experience, data, technology, and security affect one another.

    -

    Then we strengthen the connections that make the whole system perform better.

    +

    {difference.copy}

    +

    {difference.highlight}

    -
    - No. 01 - No channel fighting for credit. -
    -
    - No. 02 - No technology added just because it is new. -
    -
    - No. 03 - No dashboard pretending the numbers mean more than they do. -
    + {difference.nos.map((n, i) => ( +
    + + {difference.noLabel} {String(i + 1).padStart(2, "0")} + + {n} +
    + ))}

    - Just a clearer system and a team accountable for making it work. + {difference.closing}

@@ -366,25 +349,23 @@ export default function Home() {
- Ready When You Are + {cta.eyebrow}

- Bring Us the Growth Problem You Can’t Quite Pin Down. + {cta.title}

-

- 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.

+ {cta.copy.map((p) => ( +

{p}

+ ))}
-

You don’t need to diagnose it before the call.

-

Bring us what you’re seeing. We’ll start there.

+

{cta.lead}

+

{cta.leadMuted}

- - Book Your Strategy Call + + {cta.button}
diff --git a/src/app/services/[slug]/page.tsx b/src/app/[lang]/services/[slug]/page.tsx similarity index 52% rename from src/app/services/[slug]/page.tsx rename to src/app/[lang]/services/[slug]/page.tsx index 56b3145..9f90d82 100644 --- a/src/app/services/[slug]/page.tsx +++ b/src/app/[lang]/services/[slug]/page.tsx @@ -1,23 +1,24 @@ import { Suspense } from "react"; import { notFound } from "next/navigation"; import ContentPage from "@/components/content/content-page"; +import { getUI } from "@/i18n/server"; import { getService } from "@/lib/pages"; import { pageMetadata } from "@/lib/metadata"; import { serviceNav } from "@/lib/routes"; -type Params = PageProps<"/services/[slug]">["params"]; +type Params = PageProps<"/[lang]/services/[slug]">["params"]; export function generateStaticParams() { return serviceNav.map((s) => ({ slug: s.slug })); } -export async function generateMetadata({ params }: PageProps<"/services/[slug]">) { - const { slug } = await params; - const page = getService(slug); +export async function generateMetadata({ params }: PageProps<"/[lang]/services/[slug]">) { + const [{ slug }, { locale }] = await Promise.all([params, getUI()]); + const page = getService(locale, slug); return page ? pageMetadata(page) : {}; } -export default function Page({ params }: PageProps<"/services/[slug]">) { +export default function Page({ params }: PageProps<"/[lang]/services/[slug]">) { return ( }> @@ -26,18 +27,16 @@ export default function Page({ params }: PageProps<"/services/[slug]">) { } async function Content({ params }: { params: Params }) { - const { slug } = await params; - const page = getService(slug); + const [{ slug }, { locale, t }] = await Promise.all([params, getUI()]); + const page = getService(locale, slug); if (!page) notFound(); - const name = serviceNav.find((s) => s.slug === slug)?.name ?? slug; return ( ); diff --git a/src/app/[lang]/services/page.tsx b/src/app/[lang]/services/page.tsx new file mode 100644 index 0000000..b54dcc3 --- /dev/null +++ b/src/app/[lang]/services/page.tsx @@ -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 ; +} diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx deleted file mode 100644 index 747f385..0000000 --- a/src/app/about/page.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx deleted file mode 100644 index eb919d7..0000000 --- a/src/app/contact/page.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/src/app/globals.css b/src/app/globals.css index aaf7646..7b772df 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -20,6 +20,12 @@ --max: 1440px; --section-y: clamp(88px, 9vw, 136px); --ease: cubic-bezier(0.2, 0.7, 0.1, 1); + /* 1 in left-to-right pages, -1 in right-to-left ones: flips horizontal motion. */ + --dir: 1; +} + +[dir="rtl"] { + --dir: -1; } *, @@ -71,7 +77,7 @@ a { .skip-link { position: absolute; - left: 16px; + inset-inline-start: 16px; top: -60px; z-index: 100; padding: 12px 18px; @@ -197,7 +203,7 @@ a { } .btn:hover .arrow { - transform: translateX(4px); + transform: translateX(calc(4px * var(--dir))); } .btn-solid { @@ -247,7 +253,7 @@ a { } .arrow-link:hover .arrow { - transform: translateX(4px); + transform: translateX(calc(4px * var(--dir))); } .pill { @@ -377,12 +383,12 @@ a { .nav a::after { content: ""; position: absolute; - left: 0; - right: 100%; + inset-inline-start: 0; + inset-inline-end: 100%; bottom: 0; height: 1px; background: var(--rosso); - transition: right 0.35s var(--ease); + transition: inset-inline-end 0.35s var(--ease); } .nav a:hover { @@ -390,7 +396,7 @@ a { } .nav a:hover::after { - right: 0; + inset-inline-end: 0; } .header-cta { @@ -591,7 +597,7 @@ a { .scroll-cue i::after { content: ""; position: absolute; - left: 0; + inset-inline-start: 0; top: -40%; width: 1px; height: 40%; @@ -619,6 +625,16 @@ a { .hero-actions .btn { flex: 1 1 100%; } + /* Longer translated labels wrap instead of running past the screen edge. */ + .btn { + max-width: 100%; + height: auto; + min-height: 54px; + padding-block: 14px; + white-space: normal; + text-align: center; + line-height: 1.5; + } } /* ============================================================ @@ -694,12 +710,12 @@ a { .stat { padding: 36px 28px 8px; - border-left: 1px solid var(--grafite); + border-inline-start: 1px solid var(--grafite); } .stat:first-child { - border-left: 0; - padding-left: 0; + border-inline-start: 0; + padding-inline-start: 0; } .stat-value { @@ -713,7 +729,7 @@ a { } .stat-value small { - margin-left: 6px; + margin-inline-start: 6px; font-size: 0.36em; font-weight: 400; line-height: 1.3; @@ -735,8 +751,8 @@ a { grid-template-columns: repeat(2, minmax(0, 1fr)); } .stat:nth-child(3) { - border-left: 0; - padding-left: 0; + border-inline-start: 0; + padding-inline-start: 0; } .stat:nth-child(n + 3) { border-top: 1px solid var(--grafite); @@ -778,7 +794,7 @@ a { background: transparent; color: var(--piombo); font: inherit; - text-align: left; + text-align: start; cursor: pointer; transition: color 0.4s var(--ease); } @@ -1238,7 +1254,7 @@ a { display: inline-block; width: 2px; height: 0.9em; - margin-left: 6px; + margin-inline-start: 6px; vertical-align: -0.08em; background: var(--rosso); animation: caret 1s steps(1) infinite; @@ -1293,11 +1309,11 @@ a { } .surface-core::before { - right: 100%; + inset-inline-end: 100%; } .surface-core::after { - left: 100%; + inset-inline-start: 100%; } @media (max-width: 1023px) { @@ -1337,8 +1353,8 @@ a { content: ""; position: absolute; top: 6px; - left: 0; - right: 0; + inset-inline-start: 0; + inset-inline-end: 0; height: 1px; background: var(--grafite); } @@ -1356,7 +1372,7 @@ a { .journey li { position: relative; - padding-right: 24px; + padding-inline-end: 24px; } .journey .dot { @@ -1406,14 +1422,14 @@ a { .journey ol { grid-template-columns: 1fr; gap: 34px; - padding-left: 34px; + padding-inline-start: 34px; } .journey ol::before, .journey ol::after { top: 0; bottom: 0; - left: 6px; - right: auto; + inset-inline-start: 6px; + inset-inline-end: auto; width: 1px; height: auto; } @@ -1427,7 +1443,7 @@ a { } .journey .dot { position: absolute; - left: -34px; + inset-inline-start: -34px; top: 4px; } .journey .channel { @@ -1534,7 +1550,7 @@ a { } .gaps li:hover { - padding-left: 12px; + padding-inline-start: 12px; } @media (max-width: 720px) { @@ -1555,14 +1571,14 @@ a { .nos div { padding: 32px 28px 0; - border-left: 1px solid var(--grafite); + border-inline-start: 1px solid var(--grafite); font-size: 17px; line-height: 1.5; } .nos div:first-child { - padding-left: 0; - border-left: 0; + padding-inline-start: 0; + border-inline-start: 0; } .nos span { @@ -1591,7 +1607,7 @@ a { .nos div, .nos div:first-child { padding: 28px 0; - border-left: 0; + border-inline-start: 0; border-bottom: 1px solid var(--grafite); } } @@ -1822,7 +1838,7 @@ a { } .nav a.is-active::after { - right: 0; + inset-inline-end: 0; } .nav-group { @@ -1835,7 +1851,7 @@ a { .nav-panel { position: absolute; top: 100%; - left: -28px; + inset-inline-start: -28px; min-width: 300px; padding-top: 1px; opacity: 0; @@ -1958,8 +1974,8 @@ a { .emph { margin: 1.2em 0 !important; - padding-left: 20px; - border-left: 1px solid var(--rosso); + padding-inline-start: 20px; + border-inline-start: 1px solid var(--rosso); font-size: clamp(18px, 1.6vw, 24px); font-weight: 300; line-height: 1.4; @@ -2017,14 +2033,14 @@ a { grid-template-columns: repeat(var(--cols, 3), minmax(0, 1fr)); margin-top: clamp(56px, 7vw, 96px); border-top: 1px solid var(--grafite); - border-left: 1px solid var(--grafite); + border-inline-start: 1px solid var(--grafite); } .card { display: flex; flex-direction: column; padding: 32px 28px 36px; - border-right: 1px solid var(--grafite); + border-inline-end: 1px solid var(--grafite); border-bottom: 1px solid var(--grafite); } @@ -2108,7 +2124,7 @@ a { } .link-card:hover .arrow-link .arrow { - transform: translateX(4px); + transform: translateX(calc(4px * var(--dir))); } @media (max-width: 1023px) { @@ -2231,7 +2247,7 @@ a { content: ""; position: absolute; top: 50%; - left: 0; + inset-inline-start: 0; width: 14px; height: 1px; background: var(--bianco); @@ -2259,7 +2275,7 @@ a { /* stats on inner pages */ .stat-copy { margin-top: 14px; - padding-right: 12px; + padding-inline-end: 12px; font-size: 13px; } @@ -2275,12 +2291,12 @@ a { display: flex; flex-direction: column; padding: 32px 24px 8px; - border-left: 1px solid var(--grafite); + border-inline-start: 1px solid var(--grafite); } .office:first-child { - padding-left: 0; - border-left: 0; + padding-inline-start: 0; + border-inline-start: 0; } .office-head { @@ -2339,8 +2355,8 @@ a { grid-template-columns: repeat(2, minmax(0, 1fr)); } .office:nth-child(3) { - padding-left: 0; - border-left: 0; + padding-inline-start: 0; + border-inline-start: 0; } .office:nth-child(n + 3) { border-top: 1px solid var(--grafite); @@ -2354,7 +2370,7 @@ a { .office, .office:nth-child(3) { padding: 28px 0; - border-left: 0; + border-inline-start: 0; } .office + .office { border-top: 1px solid var(--grafite); @@ -2573,7 +2589,7 @@ a { .hp { position: absolute; - left: -9999px; + inset-inline-start: -9999px; width: 1px; height: 1px; opacity: 0; @@ -2623,3 +2639,195 @@ a { stroke-dashoffset: 0; } } + +/* ============================================================ + Language switch + ============================================================ */ +.lang-switch { + display: flex; + align-items: center; + gap: 2px; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.16em; +} + +.site-header .lang-switch { + margin-inline-start: auto; +} + +.lang-switch a, +.lang-switch span { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + padding: 0 6px; + color: var(--piombo); + transition: color 0.3s var(--ease); +} + +.lang-switch a:hover { + color: var(--bianco); +} + +.lang-switch [aria-current] { + color: var(--bianco); + box-shadow: inset 0 -1px 0 var(--rosso); +} + +.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; +} diff --git a/src/app/industries/page.tsx b/src/app/industries/page.tsx deleted file mode 100644 index 8611374..0000000 --- a/src/app/industries/page.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx deleted file mode 100644 index 45f425c..0000000 --- a/src/app/layout.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Suspense } from "react"; -import type { Metadata, Viewport } from "next"; -import { Inter } from "next/font/google"; -import Script from "next/script"; -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: "/" }, - verification: { - google: "LnRawiMDpDtTVBC2X34We70f8OHBLMVC6e9IWoHCZLs", - }, - 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 ( - - - - -