Compare commits

...

8 Commits

Author SHA1 Message Date
Alejandro Veras 2d137fea3e Replace the sitemap page with Yoast-style XML sitemaps: an index, one sitemap per kind of page, styled tables 2026-10-27 15:27:13 -04:00
Alejandro Veras ac82825cb5 Add a sitemap page and tidy sitemap.xml: site order, x-default and build-date last-modified 2026-10-27 15:21:32 -04:00
Alejandro Veras 9a337239b2 Replace the contact form with the GoHighLevel lead form 2026-10-26 09:50:22 -04:00
Alejandro Veras c284207c0f Use the new AI Growth Technologies logo files in the header, footer and icons 2026-10-23 12:34:52 -04:00
Alejandro Veras 76f4c053ae Answer 410 to spam URLs with paths in the query string 2026-10-21 10:24:25 -04:00
Alejandro Veras 4b1ca280b6 Add booking bands, favicon.ico, US spelling and the Dubai office address
- Insert a Book a Strategy Call band between sections on the home page and
  every inner page, since the header hides while scrolling
- Serve /favicon.ico so search engines pick up the logo mark
- Switch English copy to US spelling (optimize, optimization, behavior)
- Use the Blue Bay Tower address for the Dubai office in all languages
2026-10-20 16:56:15 -04:00
Alejandro Veras 2d7e7f2fe1 Apply website QA: light theme, clearer contact form, office emails
- Add a light theme with a header toggle; the choice is remembered and applied before paint
- Make the globe, product visuals and footer logo follow the theme
- Give the message field a full outline, strengthen the captcha box border and keep autofilled fields readable
- Accept website addresses without https://
- List the info email under every office
- Keep the phone header on one row: languages move into the menu below 480px
2026-10-16 11:56:10 -04:00
Alejandro Veras 50c5868da6 Center the main navigation between the logo and the header actions 2026-10-13 11:55:47 -04:00
41 changed files with 599 additions and 569 deletions
+2
View File
@@ -3,6 +3,8 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
cacheComponents: true, cacheComponents: true,
// The sitemap's last-modified date: the day this deploy was built.
env: { BUILD_DATE: new Date().toISOString().slice(0, 10) },
partialPrefetching: true, partialPrefetching: true,
turbopack: { turbopack: {
rules: { rules: {
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 87 KiB

+83
View File
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Shows the sitemap index and each sitemap as a plain table in the browser. Search engines read the XML and ignore this file. -->
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:sm="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml"
exclude-result-prefixes="sm xhtml">
<xsl:output method="html" encoding="UTF-8" indent="yes"/>
<xsl:template match="/">
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="robots" content="noindex, follow"/>
<title>XML Sitemap</title>
<style>
body { margin:0; background:#fff; color:#444; font:13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
#content { max-width:1000px; margin:0 auto; padding:24px 16px 48px; }
h1 { margin:0 0 8px; font-size:24px; font-weight:600; color:#222; }
p.desc { margin:0 0 4px; color:#555; }
p.desc a { color:#0073aa; }
p.count { margin:16px 0 12px; color:#555; }
table { width:100%; border-collapse:collapse; border:1px solid #e5e5e5; }
th { padding:8px 10px; background:#0073aa; color:#fff; text-align:left; font-weight:600; }
td { padding:7px 10px; border-bottom:1px solid #eee; vertical-align:top; }
tr:nth-child(even) td { background:#f6f8fa; }
td a { color:#0073aa; text-decoration:none; word-break:break-all; }
td a:hover { text-decoration:underline; }
td.n, th.n { width:40px; color:#888; }
td.date { width:160px; white-space:nowrap; }
td.langs a { margin-right:6px; text-transform:uppercase; font-size:11px; font-weight:600; }
@media (max-width:640px) { .hide-sm { display:none; } }
</style>
</head>
<body>
<div id="content">
<h1>XML Sitemap</h1>
<p class="desc">This is an XML Sitemap, meant for consumption by search engines.</p>
<p class="desc">You can find more information about XML sitemaps on <a href="https://www.sitemaps.org/" rel="noopener">sitemaps.org</a>.</p>
<xsl:apply-templates select="sm:sitemapindex | sm:urlset"/>
</div>
</body>
</html>
</xsl:template>
<xsl:template match="sm:sitemapindex">
<p class="count">This XML Sitemap Index file contains <strong><xsl:value-of select="count(sm:sitemap)"/></strong> sitemaps.</p>
<table>
<thead><tr><th class="n">#</th><th>Sitemap</th><th>Last Modified</th></tr></thead>
<tbody>
<xsl:for-each select="sm:sitemap">
<tr>
<td class="n"><xsl:value-of select="position()"/></td>
<td><a href="{sm:loc}"><xsl:value-of select="sm:loc"/></a></td>
<td class="date"><xsl:value-of select="sm:lastmod"/></td>
</tr>
</xsl:for-each>
</tbody>
</table>
</xsl:template>
<xsl:template match="sm:urlset">
<p class="count">This XML Sitemap contains <strong><xsl:value-of select="count(sm:url)"/></strong> URLs. <a href="/sitemap.xml">← Sitemap Index</a></p>
<table>
<thead><tr><th class="n">#</th><th>URL</th><th class="hide-sm">Languages</th><th>Last Mod.</th></tr></thead>
<tbody>
<xsl:for-each select="sm:url">
<tr>
<td class="n"><xsl:value-of select="position()"/></td>
<td><a href="{sm:loc}"><xsl:value-of select="sm:loc"/></a></td>
<td class="langs hide-sm">
<xsl:for-each select="xhtml:link[@hreflang != 'x-default'][@href != current()/sm:loc]">
<a href="{@href}"><xsl:value-of select="@hreflang"/></a>
</xsl:for-each>
</td>
<td class="date"><xsl:value-of select="sm:lastmod"/></td>
</tr>
</xsl:for-each>
</tbody>
</table>
</xsl:template>
</xsl:stylesheet>
+9 -1
View File
@@ -5,6 +5,7 @@ import "../globals.css";
import SiteHeader from "@/components/site-header"; import SiteHeader from "@/components/site-header";
import SiteFooter from "@/components/site-footer"; import SiteFooter from "@/components/site-footer";
import Analytics, { AnalyticsNoScript } from "@/components/analytics"; import Analytics, { AnalyticsNoScript } from "@/components/analytics";
import { themeScript } from "@/components/theme-toggle";
import { dirOf, locales, localize, ogLocale } from "@/i18n/config"; import { dirOf, locales, localize, ogLocale } from "@/i18n/config";
import { mainNav } from "@/i18n/nav"; import { mainNav } from "@/i18n/nav";
import { getUI } from "@/i18n/server"; import { getUI } from "@/i18n/server";
@@ -51,7 +52,7 @@ export async function generateMetadata(): Promise<Metadata> {
export const viewport: Viewport = { export const viewport: Viewport = {
themeColor: "#000000", themeColor: "#000000",
colorScheme: "dark", colorScheme: "dark light",
}; };
export default async function RootLayout({ children }: LayoutProps<"/[lang]">) { export default async function RootLayout({ children }: LayoutProps<"/[lang]">) {
@@ -63,6 +64,8 @@ export default async function RootLayout({ children }: LayoutProps<"/[lang]">) {
closeMenu: t.nav.closeMenu, closeMenu: t.nav.closeMenu,
language: t.nav.language, language: t.nav.language,
logoHome: t.logoHome, logoHome: t.logoHome,
lightTheme: t.nav.lightTheme,
darkTheme: t.nav.darkTheme,
}; };
return ( return (
@@ -71,7 +74,12 @@ export default async function RootLayout({ children }: LayoutProps<"/[lang]">) {
dir={dirOf(locale)} dir={dirOf(locale)}
className={`${inter.variable} ${arabic.variable}`} className={`${inter.variable} ${arabic.variable}`}
data-scroll-behavior="smooth" data-scroll-behavior="smooth"
// The theme script sets data-theme before React hydrates.
suppressHydrationWarning
> >
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body> <body>
<AnalyticsNoScript /> <AnalyticsNoScript />
<a href="#main" className="skip-link"> <a href="#main" className="skip-link">
+7 -2
View File
@@ -8,7 +8,8 @@ import ProductVisual from "@/components/product-visuals";
import Link from "next/link"; import Link from "next/link";
import { home } from "@/content/home"; import { home } from "@/content/home";
import { localize } from "@/i18n/config"; import { localize } from "@/i18n/config";
import { getLocale } from "@/i18n/server"; import { getUI } from "@/i18n/server";
import CtaBand from "@/components/cta-band";
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;
@@ -25,7 +26,7 @@ function Eyebrow({ index, children }: { index?: string; children: ReactNode }) {
} }
export default async function Home() { export default async function Home() {
const locale = await getLocale(); const { locale, t } = await getUI();
const c = home[locale]; const c = home[locale];
const to = (path: string) => localize(path, locale); const to = (path: string) => localize(path, locale);
const { hero, numbers, capabilities, technology, discovery, journey, difference, cta } = c; const { hero, numbers, capabilities, technology, discovery, journey, difference, cta } = c;
@@ -134,6 +135,8 @@ export default async function Home() {
</div> </div>
</section> </section>
<CtaBand t={t} locale={locale} />
{/* ───────────────────────── Proprietary technology ───────────────────────── */} {/* ───────────────────────── Proprietary technology ───────────────────────── */}
<section className="section" id="technology" aria-labelledby="tech-title"> <section className="section" id="technology" aria-labelledby="tech-title">
<div className="wrap"> <div className="wrap">
@@ -241,6 +244,8 @@ export default async function Home() {
</div> </div>
</section> </section>
<CtaBand t={t} locale={locale} />
{/* ───────────────────────── Attention to action ───────────────────────── */} {/* ───────────────────────── Attention to action ───────────────────────── */}
<section className="section" id="approach" aria-labelledby="journey-title"> <section className="section" id="approach" aria-labelledby="journey-title">
<div className="wrap"> <div className="wrap">
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

+206 -57
View File
@@ -13,6 +13,13 @@
--fumo: #8f8f8f; --fumo: #8f8f8f;
--piombo: #666666; --piombo: #666666;
--argento: #d2d2d2; --argento: #d2d2d2;
/* The names keep their roles in both themes: bianco is ink, nero is paper. */
--ink-rgb: 255, 255, 255;
--paper-rgb: 0, 0, 0;
--cap-line: #262626;
--panel: #070707;
--panel-line: #1c1c1c;
color-scheme: dark;
--font-sans: var(--font-inter), "Helvetica Neue", Arial, sans-serif; --font-sans: var(--font-inter), "Helvetica Neue", Arial, sans-serif;
@@ -28,6 +35,23 @@
--dir: -1; --dir: -1;
} }
/* Light theme, chosen with the header toggle. */
:root[data-theme="light"] {
--bianco: #0a0a0a;
--nero: #ffffff;
--notte: #f4f4f2;
--grafite: #d9d9d6;
--fumo: #5c5c5c;
--piombo: #808080;
--argento: #333333;
--ink-rgb: 10, 10, 10;
--paper-rgb: 255, 255, 255;
--cap-line: #e2e2e2;
--panel: #f7f7f5;
--panel-line: #e4e4e1;
color-scheme: light;
}
*, *,
*::before, *::before,
*::after { *::after {
@@ -55,7 +79,7 @@ body {
::selection { ::selection {
background: var(--rosso); background: var(--rosso);
color: var(--bianco); color: #fff;
} }
a { a {
@@ -220,7 +244,7 @@ a {
.btn-ghost { .btn-ghost {
background: transparent; background: transparent;
color: var(--bianco); color: var(--bianco);
border-color: rgba(255, 255, 255, 0.35); border-color: rgba(var(--ink-rgb), 0.35);
} }
.btn-ghost:hover { .btn-ghost:hover {
@@ -261,7 +285,7 @@ a {
align-items: center; align-items: center;
height: 30px; height: 30px;
padding: 0 14px; padding: 0 14px;
border: 1px solid rgba(255, 255, 255, 0.28); border: 1px solid rgba(var(--ink-rgb), 0.28);
border-radius: 9999px; border-radius: 9999px;
font-size: 10px; font-size: 10px;
font-weight: 500; font-weight: 500;
@@ -303,7 +327,7 @@ a {
} }
.site-header.is-scrolled { .site-header.is-scrolled {
background: rgba(0, 0, 0, 0.82); background: rgba(var(--paper-rgb), 0.82);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
border-color: var(--grafite); border-color: var(--grafite);
@@ -324,13 +348,12 @@ a {
.logo { .logo {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 14px;
} }
.logo-mark { .logo img {
display: block;
width: auto; width: auto;
height: 40px; height: 44px;
flex: none;
} }
.logo-stacked { .logo-stacked {
@@ -343,26 +366,6 @@ a {
height: auto; height: auto;
} }
.logo-type {
display: flex;
flex-direction: column;
line-height: 1;
}
.logo-type b {
font-size: 14px;
font-weight: 600;
letter-spacing: 0.2em;
}
.logo-type span {
margin-top: 5px;
font-size: 8.5px;
font-weight: 500;
letter-spacing: 0.42em;
color: var(--fumo);
}
.nav { .nav {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -376,7 +379,7 @@ a {
font-weight: 500; font-weight: 500;
letter-spacing: 0.2em; letter-spacing: 0.2em;
text-transform: uppercase; text-transform: uppercase;
color: rgba(255, 255, 255, 0.78); color: rgba(var(--ink-rgb), 0.78);
transition: color 0.3s var(--ease); transition: color 0.3s var(--ease);
} }
@@ -512,9 +515,9 @@ a {
inset: 0; inset: 0;
z-index: -1; z-index: -1;
background: background:
linear-gradient(180deg, rgba(0, 0, 0, 0.55) 0%, transparent 22%), linear-gradient(180deg, rgba(var(--paper-rgb), 0.55) 0%, transparent 22%),
linear-gradient(0deg, #000 4%, rgba(0, 0, 0, 0.6) 34%, transparent 62%), linear-gradient(0deg, var(--nero) 4%, rgba(var(--paper-rgb), 0.6) 34%, transparent 62%),
linear-gradient(90deg, rgba(0, 0, 0, 0.7) 0%, transparent 55%); linear-gradient(90deg, rgba(var(--paper-rgb), 0.7) 0%, transparent 55%);
pointer-events: none; pointer-events: none;
} }
@@ -858,13 +861,13 @@ a {
} }
.cap-map line { .cap-map line {
stroke: #262626; stroke: var(--cap-line);
stroke-width: 1; stroke-width: 1;
transition: stroke 0.5s var(--ease); transition: stroke 0.5s var(--ease);
} }
.cap-map line.on { .cap-map line.on {
stroke: rgba(255, 255, 255, 0.6); stroke: rgba(var(--ink-rgb), 0.6);
} }
.cap-map .node { .cap-map .node {
@@ -882,7 +885,7 @@ a {
.cap-map .core { .cap-map .core {
fill: var(--nero); fill: var(--nero);
stroke: rgba(255, 255, 255, 0.4); stroke: rgba(var(--ink-rgb), 0.4);
} }
.cap-map text { .cap-map text {
@@ -1002,8 +1005,8 @@ a {
grid-column: 1 / span 6; grid-column: 1 / span 6;
position: relative; position: relative;
aspect-ratio: 600 / 412; aspect-ratio: 600 / 412;
background: #070707; background: var(--panel);
outline: 1px solid #1c1c1c; outline: 1px solid var(--panel-line);
overflow: hidden; overflow: hidden;
} }
@@ -1071,7 +1074,7 @@ a {
/* journey viz */ /* journey viz */
.viz-journey .path { .viz-journey .path {
fill: none; fill: none;
stroke: rgba(255, 255, 255, 0.16); stroke: rgba(var(--ink-rgb), 0.16);
stroke-width: 1; stroke-width: 1;
} }
@@ -1099,7 +1102,7 @@ a {
.viz-journey .node { .viz-journey .node {
fill: var(--nero); fill: var(--nero);
stroke: rgba(255, 255, 255, 0.6); stroke: rgba(var(--ink-rgb), 0.6);
} }
.viz-journey .goal { .viz-journey .goal {
@@ -1117,11 +1120,11 @@ a {
/* radar viz */ /* radar viz */
.viz-radar .ring { .viz-radar .ring {
fill: none; fill: none;
stroke: rgba(255, 255, 255, 0.12); stroke: rgba(var(--ink-rgb), 0.12);
} }
.viz-radar .axis { .viz-radar .axis {
stroke: rgba(255, 255, 255, 0.07); stroke: rgba(var(--ink-rgb), 0.07);
} }
.viz-radar .sweep { .viz-radar .sweep {
@@ -1160,12 +1163,12 @@ a {
/* grid viz */ /* grid viz */
.viz-grid .cell { .viz-grid .cell {
fill: rgba(255, 255, 255, 0.035); fill: rgba(var(--ink-rgb), 0.035);
stroke: rgba(255, 255, 255, 0.08); stroke: rgba(var(--ink-rgb), 0.08);
} }
.viz-grid .cell.on { .viz-grid .cell.on {
fill: rgba(255, 255, 255, 0.85); fill: rgba(var(--ink-rgb), 0.85);
stroke: none; stroke: none;
animation: cell 4.8s var(--ease) infinite; animation: cell 4.8s var(--ease) infinite;
animation-delay: var(--t); animation-delay: var(--t);
@@ -1201,7 +1204,7 @@ a {
.viz-grid .frame { .viz-grid .frame {
fill: none; fill: none;
stroke: rgba(255, 255, 255, 0.5); stroke: rgba(var(--ink-rgb), 0.5);
} }
@media (max-width: 1023px) { @media (max-width: 1023px) {
@@ -1290,7 +1293,7 @@ a {
place-items: center; place-items: center;
width: 96px; width: 96px;
height: 96px; height: 96px;
border: 1px solid rgba(255, 255, 255, 0.35); border: 1px solid rgba(var(--ink-rgb), 0.35);
border-radius: 9999px; border-radius: 9999px;
font-size: 9px; font-size: 9px;
font-weight: 600; font-weight: 600;
@@ -1360,7 +1363,7 @@ a {
} }
.journey ol::after { .journey ol::after {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.7), var(--rosso)); background: linear-gradient(90deg, rgba(var(--ink-rgb), 0.7), var(--rosso));
transform: scaleX(0); transform: scaleX(0);
transform-origin: left; transform-origin: left;
transition: transform 2.6s var(--ease) 0.2s; transition: transform 2.6s var(--ease) 0.2s;
@@ -1434,7 +1437,7 @@ a {
height: auto; height: auto;
} }
.journey ol::after { .journey ol::after {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.7), var(--rosso)); background: linear-gradient(180deg, rgba(var(--ink-rgb), 0.7), var(--rosso));
transform: scaleY(0); transform: scaleY(0);
transform-origin: top; transform-origin: top;
} }
@@ -1542,7 +1545,7 @@ a {
} }
.gaps .break line { .gaps .break line {
stroke: rgba(255, 255, 255, 0.45); stroke: rgba(var(--ink-rgb), 0.45);
} }
.gaps .break .cut { .gaps .break .cut {
@@ -1635,7 +1638,7 @@ a {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: -1; z-index: -1;
background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.9) 55%, #000 80%); background: radial-gradient(ellipse at center, rgba(var(--paper-rgb), 0.6) 0%, rgba(var(--paper-rgb), 0.9) 55%, var(--nero) 80%);
pointer-events: none; pointer-events: none;
} }
@@ -1758,7 +1761,7 @@ a {
line-height: 0.9; line-height: 0.9;
letter-spacing: -0.05em; letter-spacing: -0.05em;
text-transform: uppercase; text-transform: uppercase;
color: rgba(255, 255, 255, 0.07); color: rgba(var(--ink-rgb), 0.07);
white-space: nowrap; white-space: nowrap;
user-select: none; user-select: none;
} }
@@ -1873,7 +1876,7 @@ a {
.nav-panel-inner { .nav-panel-inner {
padding: 22px 28px 26px; padding: 22px 28px 26px;
background: rgba(0, 0, 0, 0.94); background: rgba(var(--paper-rgb), 0.94);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
border: 1px solid var(--grafite); border: 1px solid var(--grafite);
@@ -2115,7 +2118,7 @@ a {
} }
.link-card:hover { .link-card:hover {
background: rgba(255, 255, 255, 0.03); background: rgba(var(--ink-rgb), 0.03);
} }
.link-card:hover .arrow-link { .link-card:hover .arrow-link {
@@ -2455,6 +2458,14 @@ a {
} }
} }
.lead-form iframe {
display: block;
width: 100%;
min-height: 992px;
border: none;
border-radius: 0;
}
.contact-form { .contact-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2516,11 +2527,28 @@ a {
resize: vertical; resize: vertical;
} }
/* The message gets a full outline so it reads as a box to type in. */
.field textarea {
min-height: 132px;
padding: 12px 14px;
border: 1px solid var(--grafite);
}
.field input:focus, .field input:focus,
.field textarea:focus { .field textarea:focus {
border-color: var(--rosso); border-color: var(--rosso);
} }
/* Browser autofill paints its own pale background; keep the field on-theme and readable. */
.field input:-webkit-autofill,
.field input:-webkit-autofill:hover,
.field input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--bianco);
caret-color: var(--bianco);
-webkit-box-shadow: 0 0 0 1000px var(--nero) inset;
transition: background-color 9999s;
}
.field input::placeholder { .field input::placeholder {
color: var(--piombo); color: var(--piombo);
} }
@@ -2652,9 +2680,17 @@ a {
letter-spacing: 0.16em; letter-spacing: 0.16em;
} }
/* The nav takes the free space between the logo and the actions and centres itself in it. */
.site-header .nav {
flex: 1;
justify-content: center;
}
@media (max-width: 1023px) {
.site-header .lang-switch { .site-header .lang-switch {
margin-inline-start: auto; margin-inline-start: auto;
} }
}
.lang-switch a, .lang-switch a,
.lang-switch span { .lang-switch span {
@@ -2672,6 +2708,67 @@ a {
color: var(--bianco); color: var(--bianco);
} }
.theme-toggle {
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
flex: none;
padding: 0;
border: 1px solid var(--grafite);
border-radius: 50%;
background: transparent;
color: var(--bianco);
cursor: pointer;
transition: border-color 0.3s var(--ease);
}
.theme-toggle:hover {
border-color: var(--bianco);
}
.theme-toggle svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
}
/* Shows the theme you'd switch to: a sun on dark pages, a moon on light ones. */
.theme-toggle .moon,
:root[data-theme="light"] .theme-toggle .sun {
display: none;
}
:root[data-theme="light"] .theme-toggle .moon {
display: block;
}
/* Phones: the languages move into the menu (it carries its own switch) so the logo has room. */
@media (max-width: 480px) {
.site-header .lang-switch {
display: none;
}
.site-header .theme-toggle {
margin-inline-start: auto;
}
}
/* The logos' wordmarks are white; the light theme swaps in the dark-lettered files. */
.logo .on-light,
.logo-stacked .on-light,
:root[data-theme="light"] .logo .on-dark,
:root[data-theme="light"] .logo-stacked .on-dark {
display: none;
}
:root[data-theme="light"] .logo .on-light,
:root[data-theme="light"] .logo-stacked .on-light {
display: block;
}
.lang-switch [aria-current] { .lang-switch [aria-current] {
color: var(--bianco); color: var(--bianco);
box-shadow: inset 0 -1px 0 var(--rosso); box-shadow: inset 0 -1px 0 var(--rosso);
@@ -2698,7 +2795,7 @@ a {
gap: 16px; gap: 16px;
max-width: 340px; max-width: 340px;
padding: 14px 18px; padding: 14px 18px;
border: 1px solid var(--grafite); border: 1px solid var(--piombo);
transition: border-color 0.3s var(--ease); transition: border-color 0.3s var(--ease);
} }
@@ -2731,7 +2828,7 @@ a {
width: 24px; width: 24px;
height: 24px; height: 24px;
flex: none; flex: none;
border: 1px solid var(--fumo); border: 1.5px solid var(--argento);
transition: transition:
border-color 0.3s var(--ease), border-color 0.3s var(--ease),
background-color 0.3s var(--ease); background-color 0.3s var(--ease);
@@ -2746,7 +2843,7 @@ a {
width: 18px; width: 18px;
height: 18px; height: 18px;
fill: none; fill: none;
stroke: var(--bianco); stroke: #fff;
stroke-width: 1.8; stroke-width: 1.8;
stroke-dasharray: 16; stroke-dasharray: 16;
stroke-dashoffset: 16; stroke-dashoffset: 16;
@@ -2802,7 +2899,7 @@ a {
} }
[dir="rtl"] .journey ol::after { [dir="rtl"] .journey ol::after {
background: linear-gradient(270deg, rgba(255, 255, 255, 0.7), var(--rosso)); background: linear-gradient(270deg, rgba(var(--ink-rgb), 0.7), var(--rosso));
transform-origin: right; transform-origin: right;
} }
@@ -2831,3 +2928,55 @@ html[lang="ar"] .btn,
html[lang="ar"] .nav a { html[lang="ar"] .nav a {
font-size: 13px; font-size: 13px;
} }
/* booking band between sections */
.cta-band {
position: relative;
padding-block: clamp(36px, 5vw, 56px);
border-block: 1px solid var(--grafite);
background:
radial-gradient(ellipse at 85% 50%, rgba(var(--ink-rgb), 0.05), transparent 60%),
var(--nero);
}
.cta-band-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 32px;
}
.cta-band-copy {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.cta-band-title {
margin: 0;
font-size: clamp(20px, 2.2vw, 30px);
font-weight: 300;
letter-spacing: -0.02em;
line-height: 1.2;
color: var(--bianco);
text-wrap: balance;
}
.cta-band-body {
margin: 0;
max-width: 60ch;
font-size: 15px;
color: var(--argento);
}
.cta-band .btn {
flex: none;
}
@media (max-width: 760px) {
.cta-band-inner {
flex-direction: column;
align-items: flex-start;
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 76 KiB

+3
View File
@@ -0,0 +1,3 @@
import { sitemap } from "@/lib/sitemap";
export const GET = () => sitemap("industry");
+3
View File
@@ -0,0 +1,3 @@
import { sitemap } from "@/lib/sitemap";
export const GET = () => sitemap("page");
+3
View File
@@ -0,0 +1,3 @@
import { sitemap } from "@/lib/sitemap";
export const GET = () => sitemap("service");
-27
View File
@@ -1,27 +0,0 @@
import type { MetadataRoute } from "next";
import { locales, localize } from "@/i18n/config";
import { SITE_URL, industryNav, serviceNav } from "@/lib/routes";
export default function sitemap(): MetadataRoute.Sitemap {
const paths = [
"/",
"/about",
"/contact",
"/services",
...serviceNav.map((s) => `/services/${s.slug}`),
"/industries",
...industryNav.map((s) => `/industries/${s.slug}`),
];
const url = (p: string) => SITE_URL + (p === "/" ? "" : p);
const lastModified = new Date();
return locales.flatMap((locale) =>
paths.map((p) => ({
url: url(localize(p, locale)),
lastModified,
changeFrequency: "monthly" as const,
priority: p === "/" ? 1 : p.split("/").length === 2 ? 0.8 : 0.6,
alternates: { languages: Object.fromEntries(locales.map((l) => [l, url(localize(p, l))])) },
})),
);
}
+3
View File
@@ -0,0 +1,3 @@
import { sitemapIndex } from "@/lib/sitemap";
export const GET = () => sitemapIndex();
-164
View File
@@ -1,164 +0,0 @@
"use client";
import { useActionState, useEffect, useRef, useState } from "react";
import { sendContact, type ContactState } from "@/lib/contact-action";
import type { Locale } from "@/i18n/config";
import type { UI } from "@/i18n/ui";
import { CONTACT_EMAIL, serviceNav } from "@/lib/routes";
import HumanCheck, { type Solution } from "./human-check";
const initial: ContactState = { status: "idle" };
export default function ContactForm({
locale,
t,
services,
}: {
locale: Locale;
t: UI["form"];
services: UI["services"];
}) {
const [state, action, pending] = useActionState(sendContact, initial);
const startedRef = useRef<HTMLInputElement>(null);
const [solution, setSolution] = useState<Solution | null>(null);
const [needsCheck, setNeedsCheck] = useState(false);
const [seenState, setSeenState] = useState(state);
// Interests go to the team in English, whatever language the visitor reads the site in.
const interests = [
...serviceNav.map((s) => ({ value: s.name, label: services[s.slug] })),
{ value: "Not sure yet", label: t.notSure },
];
// A solution is single-use: once the server has checked it, the visitor ticks the box again.
if (seenState !== state) {
setSeenState(state);
if (state.status === "error" && state.error !== "details") setSolution(null);
}
// When the visitor started filling the form; the server rejects instant (bot) submissions.
useEffect(() => {
if (startedRef.current && !startedRef.current.value) startedRef.current.value = String(Date.now());
}, []);
if (state.status === "sent") {
return (
<div className="contact-form form-sent" role="status">
<svg className="sent-mark" viewBox="0 0 48 48" aria-hidden="true">
<circle cx="24" cy="24" r="23" />
<path d="M14 24.5l7 7 13-14" />
</svg>
<h3 className="card-title">{t.sentTitle}</h3>
<p className="copy">{t.sentBody}</p>
</div>
);
}
const values = state.status === "error" ? state.values : undefined;
const error = needsCheck && !solution ? "human" : state.status === "error" ? state.error : null;
return (
<form
// Remount after a failed attempt so the fields are refilled with what was sent.
key={state.status === "error" ? state.attempt : 0}
className="contact-form"
action={action}
onSubmit={(e) => {
if (!solution) {
e.preventDefault();
setNeedsCheck(true);
}
}}
>
<input type="hidden" name="locale" value={locale} />
<div className="field-row">
<label className="field">
<span>{t.name}</span>
<input name="name" autoComplete="name" required maxLength={120} defaultValue={values?.name} />
</label>
<label className="field">
<span>{t.email}</span>
<input
name="email"
type="email"
autoComplete="email"
required
maxLength={200}
defaultValue={values?.email}
dir="ltr"
/>
</label>
</div>
<div className="field-row">
<label className="field">
<span>{t.company}</span>
<input name="company" autoComplete="organization" maxLength={160} defaultValue={values?.company} />
</label>
<label className="field">
<span>{t.website}</span>
<input
name="website"
type="url"
inputMode="url"
placeholder="https://"
maxLength={300}
defaultValue={values?.website}
dir="ltr"
/>
</label>
</div>
{/* Honeypot for bots — hidden from people and assistive tech. */}
<input name="company_url" className="hp" tabIndex={-1} autoComplete="off" aria-hidden="true" />
<input ref={startedRef} type="hidden" name="started" defaultValue={values ? "1" : undefined} />
<fieldset className="field">
<legend>{t.interests}</legend>
<div className="chips">
{interests.map((i) => (
<label key={i.value} className="chip">
<input
type="checkbox"
name="interest"
value={i.value}
defaultChecked={values?.interests.includes(i.value)}
/>
<span>{i.label}</span>
</label>
))}
</div>
</fieldset>
<label className="field">
<span>
{t.message} <em className="field-optional">{t.optional}</em>
</span>
<textarea name="message" rows={5} maxLength={5000} defaultValue={values?.message} />
</label>
<HumanCheck
solution={solution}
onSolved={(s) => {
setSolution(s);
setNeedsCheck(false);
}}
t={t.human}
invalid={error === "human"}
/>
<button type="submit" className="btn btn-solid" disabled={pending}>
{pending ? t.sending : t.submit} <span className="arrow">→</span>
</button>
<p className={`form-note${error ? " is-error" : ""}`} role="status">
{error ? (
<>
{t.errors[error]}
{error !== "human" && error !== "details" && (
<>
{" "}
{t.writeTo} <span dir="ltr">{CONTACT_EMAIL}</span>.
</>
)}
</>
) : (
t.note
)}
</p>
</form>
);
}
+12 -6
View File
@@ -1,9 +1,10 @@
import type { CSSProperties, ReactNode } from "react"; import { Fragment, type CSSProperties, type ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import GlobeCanvas from "@/components/globe-canvas"; import GlobeCanvas from "@/components/globe-canvas";
import StatCounter from "@/components/stat-counter"; import StatCounter from "@/components/stat-counter";
import RevealObserver from "@/components/reveal-observer"; import RevealObserver from "@/components/reveal-observer";
import ContactForm from "./contact-form"; import LeadForm from "./lead-form";
import CtaBand from "@/components/cta-band";
import { localize } from "@/i18n/config"; import { localize } from "@/i18n/config";
import { getUI } from "@/i18n/server"; import { getUI } from "@/i18n/server";
import type { UI } from "@/i18n/ui"; import type { UI } from "@/i18n/ui";
@@ -366,7 +367,7 @@ function ShowcaseSection({ section }: VariantProps) {
); );
} }
function FormSection({ section, t, locale }: { section: Section; t: UI; locale: Page["locale"] }) { function FormSection({ section }: { section: Section }) {
return ( return (
<div className="form-layout"> <div className="form-layout">
<div className="form-copy"> <div className="form-copy">
@@ -379,7 +380,7 @@ function FormSection({ section, t, locale }: { section: Section; t: UI; locale:
<CtaLinks ctas={section.ctas} /> <CtaLinks ctas={section.ctas} />
</div> </div>
<div data-reveal style={delay(200)}> <div data-reveal style={delay(200)}>
<ContactForm locale={locale} t={t.form} services={t.services} /> <LeadForm />
</div> </div>
</div> </div>
); );
@@ -537,21 +538,26 @@ export default async function ContentPage({ page, trail }: { page: Page; trail:
<PageHero page={page} crumbs={crumbs} t={t} /> <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;
// A booking band after the 2nd section and every 3rd after that, unless the section
// already ends in its own call to action or holds the form.
const band = i % 3 === 1 && i < body.length - 1 && !section.form && section.ctas.length === 0;
return ( return (
<Fragment key={i}>
<section <section
key={i}
id={section.form ? "contact-form" : undefined} id={section.form ? "contact-form" : undefined}
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} t={t} /> <SectionHead section={section} index={i + 1} t={t} />
{section.form ? ( {section.form ? (
<FormSection section={section} t={t} locale={locale} /> <FormSection section={section} />
) : ( ) : (
<Variant section={section} t={t} /> <Variant section={section} t={t} />
)} )}
</div> </div>
</section> </section>
{band && <CtaBand t={t} locale={locale} />}
</Fragment>
); );
})} })}
{closing && <ClosingCta section={closing} />} {closing && <ClosingCta section={closing} />}
-90
View File
@@ -1,90 +0,0 @@
"use client";
import { useState } from "react";
import { getChallenge } from "@/lib/contact-action";
export type Solution = { salt: string; number: number; signature: string };
type Labels = { label: string; checking: string; done: string; retry: string };
const BATCH = 400;
const MIN_SPIN_MS = 700;
const toBytes = (hex: string) => new Uint8Array(hex.match(/../g)!.map((h) => parseInt(h, 16)));
const same = (a: Uint8Array, b: Uint8Array) => a.every((v, i) => v === b[i]);
/** Find the number whose SHA-256 with the salt matches the challenge, hashing in parallel batches. */
async function solve(salt: string, challenge: string, max: number): Promise<number | null> {
const target = toBytes(challenge);
const enc = new TextEncoder();
for (let start = 0; start <= max; start += BATCH) {
const nums = Array.from({ length: Math.min(BATCH, max - start + 1) }, (_, i) => start + i);
const hashes = await Promise.all(nums.map((n) => crypto.subtle.digest("SHA-256", enc.encode(salt + n))));
const hit = hashes.findIndex((h) => same(new Uint8Array(h), target));
if (hit !== -1) return nums[hit];
}
return null;
}
/** "I'm not a robot": a self-hosted proof-of-work, solved in the browser when the box is ticked. */
export default function HumanCheck({
solution,
onSolved,
t,
invalid,
}: {
solution: Solution | null;
onSolved: (s: Solution) => void;
t: Labels;
invalid?: boolean;
}) {
const [status, setStatus] = useState<"idle" | "working" | "failed">("idle");
const state = solution ? "done" : status;
async function run() {
if (state === "working" || state === "done") return;
setStatus("working");
const startedAt = performance.now();
try {
const c = await getChallenge();
const number = await solve(c.salt, c.challenge, c.max);
if (number === null) throw new Error("No solution found");
const wait = MIN_SPIN_MS - (performance.now() - startedAt);
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
setStatus("idle");
onSolved({ salt: c.salt, number, signature: c.signature });
} catch (err) {
console.error("Human check failed", err);
setStatus("failed");
}
}
const text = { idle: t.label, working: t.checking, done: t.done, failed: t.retry }[state];
return (
<div className={`human-check is-${state}${invalid && state !== "done" ? " is-invalid" : ""}`}>
<button
type="button"
role="checkbox"
aria-checked={state === "done"}
aria-busy={state === "working"}
onClick={run}
>
<span className="human-box" aria-hidden="true">
<svg viewBox="0 0 20 20">
<path d="M5 10.5l3.2 3.2L15 6.8" />
</svg>
</span>
<span className="human-label" aria-live="polite">
{text}
</span>
</button>
{solution && (
<>
<input type="hidden" name="captcha_salt" value={solution.salt} />
<input type="hidden" name="captcha_number" value={solution.number} />
<input type="hidden" name="captcha_signature" value={solution.signature} />
</>
)}
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useEffect, useRef } from "react";
import Script from "next/script";
const FORM_ID = "AHTXnVUsjfgNHrw6IXqC";
const FORM_NAME = "AI Growth – Lead Form";
/** The GoHighLevel lead form; leads go straight into the team's CRM. */
export default function LeadForm() {
const frame = useRef<HTMLIFrameElement>(null);
// form_embed.js hides the iframe until the form reports it is ready. If that message never
// arrives, show the form anyway so it is never left invisible.
useEffect(() => {
const timers = [2500, 6000].map((ms) =>
setTimeout(() => {
const f = frame.current;
if (!f || getComputedStyle(f).visibility !== "hidden") return;
Object.assign(f.style, { opacity: "1", visibility: "visible", pointerEvents: "auto", position: "relative", left: "" });
}, ms),
);
return () => timers.forEach(clearTimeout);
}, []);
return (
<div className="lead-form">
<iframe
ref={frame}
src={`https://api.leadconnectorhq.com/widget/form/${FORM_ID}`}
id={`inline-${FORM_ID}`}
title={FORM_NAME}
data-layout="{'id':'INLINE'}"
data-trigger-type="alwaysShow"
data-trigger-value=""
data-activation-type="alwaysActivated"
data-activation-value=""
data-deactivation-type="neverDeactivate"
data-deactivation-value=""
data-form-name={FORM_NAME}
data-height="992"
data-layout-iframe-id={`inline-${FORM_ID}`}
data-form-id={FORM_ID}
data-cookie-consent="true"
data-cookie-consent-provider="auto"
/>
<Script src="https://link.msgsndr.com/js/form_embed.js" strategy="afterInteractive" />
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import Link from "next/link";
import { localize, type Locale } from "@/i18n/config";
import type { UI } from "@/i18n/ui";
/** Compact call to action placed between sections, so a booking link is always close while scrolling. */
export default function CtaBand({ t, locale }: { t: UI; locale: Locale }) {
return (
<aside className="cta-band" aria-label={t.bookCall}>
<div className="wrap cta-band-inner" data-reveal>
<div className="cta-band-copy">
<p className="cta-band-title">{t.ctaBand.title}</p>
<p className="cta-band-body">{t.ctaBand.body}</p>
</div>
<Link href={localize("/contact", locale)} className="btn btn-solid">
{t.bookCall} <span className="arrow">→</span>
</Link>
</div>
</aside>
);
}
+17 -6
View File
@@ -59,6 +59,10 @@ export default function GlobeCanvas({ layout = "hero", className = "hero-canvas"
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Dots and lines use the theme's ink colour, re-read whenever the theme changes.
const readInk = () => getComputedStyle(document.documentElement).getPropertyValue("--ink-rgb").trim() || "255, 255, 255";
let ink = readInk();
// Fibonacci sphere // Fibonacci sphere
const COUNT = 1500; const COUNT = 1500;
const points: Vec[] = []; const points: Vec[] = [];
@@ -159,15 +163,15 @@ export default function GlobeCanvas({ layout = "hero", className = "hero-canvas"
// soft halo // soft halo
const halo = ctx.createRadialGradient(cx, cy, R * 0.7, cx, cy, R * 1.45); const halo = ctx.createRadialGradient(cx, cy, R * 0.7, cx, cy, R * 1.45);
halo.addColorStop(0, "rgba(255,255,255,0.045)"); halo.addColorStop(0, `rgba(${ink},0.045)`);
halo.addColorStop(1, "rgba(255,255,255,0)"); halo.addColorStop(1, `rgba(${ink},0)`);
ctx.fillStyle = halo; ctx.fillStyle = halo;
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, w, h);
// outline // outline
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, R * 1.0, 0, Math.PI * 2); ctx.arc(cx, cy, R * 1.0, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(255,255,255,0.08)"; ctx.strokeStyle = `rgba(${ink},0.08)`;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.stroke(); ctx.stroke();
@@ -177,7 +181,7 @@ export default function GlobeCanvas({ layout = "hero", className = "hero-canvas"
const front = p.z > 0; const front = p.z > 0;
const a = front ? 0.18 + p.z * 0.62 : 0.05 + (1 + p.z) * 0.05; const a = front ? 0.18 + p.z * 0.62 : 0.05 + (1 + p.z) * 0.05;
const size = (front ? 1.15 : 0.8) * p.s; const size = (front ? 1.15 : 0.8) * p.s;
ctx.fillStyle = `rgba(255,255,255,${a.toFixed(3)})`; ctx.fillStyle = `rgba(${ink},${a.toFixed(3)})`;
ctx.fillRect(p.x - size / 2, p.y - size / 2, size, size); ctx.fillRect(p.x - size / 2, p.y - size / 2, size, size);
} }
@@ -190,7 +194,7 @@ export default function GlobeCanvas({ layout = "hero", className = "hero-canvas"
const b = proj[s]; const b = proj[s];
const vis = (a.z + b.z) / 2; const vis = (a.z + b.z) / 2;
if (vis < -0.15) continue; if (vis < -0.15) continue;
ctx.strokeStyle = `rgba(255,255,255,${(0.08 + Math.max(0, vis) * 0.22).toFixed(3)})`; ctx.strokeStyle = `rgba(${ink},${(0.08 + Math.max(0, vis) * 0.22).toFixed(3)})`;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(a.x, a.y); ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y); ctx.lineTo(b.x, b.y);
@@ -224,7 +228,7 @@ export default function GlobeCanvas({ layout = "hero", className = "hero-canvas"
ctx.beginPath(); ctx.beginPath();
ctx.arc(p.x, p.y, 3 + pulse * 14, 0, Math.PI * 2); ctx.arc(p.x, p.y, 3 + pulse * 14, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
ctx.fillStyle = `rgba(255,255,255,${(0.4 + p.z * 0.6).toFixed(3)})`; ctx.fillStyle = `rgba(${ink},${(0.4 + p.z * 0.6).toFixed(3)})`;
ctx.fillRect(p.x - 2.5, p.y - 2.5, 5, 5); ctx.fillRect(p.x - 2.5, p.y - 2.5, 5, 5);
} }
@@ -243,6 +247,12 @@ export default function GlobeCanvas({ layout = "hero", className = "hero-canvas"
}); });
ro.observe(canvas); ro.observe(canvas);
const themeObserver = new MutationObserver(() => {
ink = readInk();
if (reduced) draw(performance.now());
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
const io = new IntersectionObserver(([entry]) => { const io = new IntersectionObserver(([entry]) => {
const visible = entry.isIntersecting; const visible = entry.isIntersecting;
if (visible && !running) { if (visible && !running) {
@@ -263,6 +273,7 @@ export default function GlobeCanvas({ layout = "hero", className = "hero-canvas"
running = false; running = false;
cancelAnimationFrame(raf); cancelAnimationFrame(raf);
ro.disconnect(); ro.disconnect();
themeObserver.disconnect();
io.disconnect(); io.disconnect();
window.removeEventListener("pointermove", onMove); window.removeEventListener("pointermove", onMove);
}; };
+4 -6
View File
@@ -4,11 +4,8 @@ import Image from "next/image";
export default function Logo({ href, label }: { href: string; label: string }) { export default function Logo({ href, label }: { href: string; label: string }) {
return ( return (
<Link href={href} className="logo" aria-label={label}> <Link href={href} className="logo" aria-label={label}>
<Image className="logo-mark" src="/logo-mark.png" alt="" width={229} height={192} priority /> <Image className="on-dark" src="/logo-horizontal.png" alt="AI Growth Technologies" width={640} height={191} priority />
<span className="logo-type"> <Image className="on-light" src="/logo-horizontal-dark.png" alt="AI Growth Technologies" width={640} height={191} priority />
<b>AI GROWTH</b>
<span>TECHNOLOGIES</span>
</span>
</Link> </Link>
); );
} }
@@ -16,7 +13,8 @@ export default function Logo({ href, label }: { href: string; label: string }) {
export function LogoStacked({ href, label }: { href: string; label: string }) { export function LogoStacked({ href, label }: { href: string; label: string }) {
return ( return (
<Link href={href} className="logo-stacked" aria-label={label}> <Link href={href} className="logo-stacked" aria-label={label}>
<Image src="/logo-stacked.png" alt="AI Growth Technologies" width={480} height={547} /> <Image className="on-dark" src="/logo-stacked.png" alt="AI Growth Technologies" width={480} height={542} />
<Image className="on-light" src="/logo-stacked-dark.png" alt="AI Growth Technologies" width={480} height={542} />
</Link> </Link>
); );
} }
+2 -2
View File
@@ -25,7 +25,7 @@ function JourneyViz({ labels }: { labels: VisualLabels }) {
return ( return (
<svg className="viz viz-journey" viewBox={`0 0 ${W} ${H}`} aria-hidden="true"> <svg className="viz viz-journey" viewBox={`0 0 ${W} ${H}`} aria-hidden="true">
{[180, 280, 380].map((x) => ( {[180, 280, 380].map((x) => (
<line key={x} x1={x} y1={28} x2={x} y2={H - 28} stroke="rgba(255,255,255,0.05)" /> <line key={x} x1={x} y1={28} x2={x} y2={H - 28} style={{ stroke: "rgba(var(--ink-rgb), 0.05)" }} />
))} ))}
{sources.map((s, i) => { {sources.map((s, i) => {
const y = top + i * gap; const y = top + i * gap;
@@ -98,7 +98,7 @@ function RadarViz({ labels }: { labels: VisualLabels }) {
))} ))}
<g className="sweep"> <g className="sweep">
<path d={`M${cx} ${cy} L${cx + sweepR} ${cy} A${sweepR} ${sweepR} 0 0 0 ${ex} ${ey} Z`} fill="url(#sweepGrad)" /> <path d={`M${cx} ${cy} L${cx + sweepR} ${cy} A${sweepR} ${sweepR} 0 0 0 ${ex} ${ey} Z`} fill="url(#sweepGrad)" />
<line x1={cx} y1={cy} x2={cx + sweepR} y2={cy} stroke="rgba(255,255,255,0.6)" /> <line x1={cx} y1={cy} x2={cx + sweepR} y2={cy} style={{ stroke: "rgba(var(--ink-rgb), 0.6)" }} />
</g> </g>
{dots.map((d, i) => { {dots.map((d, i) => {
const x = cx + Math.cos(rad(d.a)) * d.r; const x = cx + Math.cos(rad(d.a)) * d.r;
+4
View File
@@ -4,6 +4,7 @@ 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 ThemeToggle from "./theme-toggle";
import { localeNames, localeShort, locales, localize, splitLocale, type Locale } from "@/i18n/config"; import { localeNames, localeShort, locales, localize, splitLocale, type Locale } from "@/i18n/config";
import type { NavItem } from "@/i18n/nav"; import type { NavItem } from "@/i18n/nav";
@@ -14,6 +15,8 @@ export type HeaderLabels = {
closeMenu: string; closeMenu: string;
language: string; language: string;
logoHome: string; logoHome: string;
lightTheme: string;
darkTheme: string;
}; };
function LanguageSwitch({ locale, path, label }: { locale: Locale; path: string; label: string }) { function LanguageSwitch({ locale, path, label }: { locale: Locale; path: string; label: string }) {
@@ -120,6 +123,7 @@ export default function SiteHeader({ locale, nav, t }: { locale: Locale; nav: Na
)} )}
</nav> </nav>
<LanguageSwitch locale={locale} path={path} label={t.language} /> <LanguageSwitch locale={locale} path={path} label={t.language} />
<ThemeToggle t={t} />
<Link href={contactHref} className="btn btn-solid header-cta"> <Link href={contactHref} className="btn btn-solid header-cta">
{t.bookCall} <span className="arrow">→</span> {t.bookCall} <span className="arrow">→</span>
</Link> </Link>
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useSyncExternalStore } from "react";
export type Theme = "dark" | "light";
// The site is dark by default; a visitor's choice is remembered in localStorage under this key.
const KEY = "theme";
/** Runs in <head> before the page paints, so a saved light theme never flashes dark first. */
export const themeScript = `try{if(localStorage.getItem("${KEY}")==="light")document.documentElement.dataset.theme="light"}catch(e){}`;
const read = (): Theme => (document.documentElement.dataset.theme === "light" ? "light" : "dark");
function subscribe(onChange: () => void) {
const observer = new MutationObserver(onChange);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
return () => observer.disconnect();
}
/** The current theme, kept in sync with <html data-theme>. */
export function useTheme(): Theme {
return useSyncExternalStore(subscribe, read, () => "dark");
}
export default function ThemeToggle({ t }: { t: { lightTheme: string; darkTheme: string } }) {
const theme = useTheme();
const label = theme === "dark" ? t.lightTheme : t.darkTheme;
function toggle() {
const next: Theme = theme === "dark" ? "light" : "dark";
if (next === "light") document.documentElement.dataset.theme = "light";
else delete document.documentElement.dataset.theme;
try {
localStorage.setItem(KEY, next);
} catch {
// Storage can be blocked (private mode); the theme still switches for this visit.
}
}
return (
<button type="button" className="theme-toggle" onClick={toggle} aria-label={label} title={label}>
<svg className="sun" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4.2" />
<path d="M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6" />
</svg>
<svg className="moon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20 14.6A8.5 8.5 0 0 1 9.4 4a8.5 8.5 0 1 0 10.6 10.6z" />
</svg>
</button>
);
}
+4 -4
View File
@@ -117,7 +117,7 @@
"subtitle": "المقر العالمي", "subtitle": "المقر العالمي",
"body": [ "body": [
"مقرنا العالمي ومركز الابتكار لدينا في منطقة الشرق الأوسط وشمال أفريقيا.", "مقرنا العالمي ومركز الابتكار لدينا في منطقة الشرق الأوسط وشمال أفريقيا.",
"Downtown Dubai, UAE\nPhone: +971 55 647 0342\nEmail: info@aigrowthtechnologies.com" "Blue Bay Tower, Office 312, 3rd Floor\nMarasi Drive Street, Business Bay\nDubai, UAE\nPhone: +971 55 647 0342\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -128,7 +128,7 @@
"subtitle": "مكتب شريك", "subtitle": "مكتب شريك",
"body": [ "body": [
"نقطة تواصل في الولايات المتحدة للشركات في تكساس وخارجها.", "نقطة تواصل في الولايات المتحدة للشركات في تكساس وخارجها.",
"Downtown Houston, TX, USA\nPhone: +1 832-350-0465" "Downtown Houston, TX, USA\nPhone: +1 832-350-0465\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -139,7 +139,7 @@
"subtitle": "مكتب شريك", "subtitle": "مكتب شريك",
"body": [ "body": [
"نقطة تواصل في الولايات المتحدة مقرها جنوب فلوريدا.", "نقطة تواصل في الولايات المتحدة مقرها جنوب فلوريدا.",
"Brickell Avenue, FL, USA\nPhone: +1 954-235-4974" "Brickell Avenue, FL, USA\nPhone: +1 954-235-4974\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -150,7 +150,7 @@
"subtitle": "مكتب شريك", "subtitle": "مكتب شريك",
"body": [ "body": [
"نقطة تواصل في الولايات المتحدة في أحد أبرز أسواق الأعمال في العالم.", "نقطة تواصل في الولايات المتحدة في أحد أبرز أسواق الأعمال في العالم.",
"Manhattan Financial District, NY, USA\nPhone: +1 713-289-4577" "Manhattan Financial District, NY, USA\nPhone: +1 713-289-4577\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
+4 -4
View File
@@ -117,7 +117,7 @@
"subtitle": "Global HQ", "subtitle": "Global HQ",
"body": [ "body": [
"Our global headquarters and innovation hub in the MENA region.", "Our global headquarters and innovation hub in the MENA region.",
"Downtown Dubai, UAE\nPhone: +971 55 647 0342\nEmail: info@aigrowthtechnologies.com" "Blue Bay Tower, Office 312, 3rd Floor\nMarasi Drive Street, Business Bay\nDubai, UAE\nPhone: +971 55 647 0342\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -128,7 +128,7 @@
"subtitle": "Partner Office", "subtitle": "Partner Office",
"body": [ "body": [
"A US point of contact for businesses across Texas and beyond.", "A US point of contact for businesses across Texas and beyond.",
"Downtown Houston, TX, USA\nPhone: +1 832-350-0465" "Downtown Houston, TX, USA\nPhone: +1 832-350-0465\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -139,7 +139,7 @@
"subtitle": "Partner Office", "subtitle": "Partner Office",
"body": [ "body": [
"A US point of contact based in South Florida.", "A US point of contact based in South Florida.",
"Brickell Avenue, FL, USA\nPhone: +1 954-235-4974" "Brickell Avenue, FL, USA\nPhone: +1 954-235-4974\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -150,7 +150,7 @@
"subtitle": "Partner Office", "subtitle": "Partner Office",
"body": [ "body": [
"A US point of contact in one of the world’s leading business markets.", "A US point of contact in one of the world’s leading business markets.",
"Manhattan Financial District, NY, USA\nPhone: +1 713-289-4577" "Manhattan Financial District, NY, USA\nPhone: +1 713-289-4577\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
+7 -7
View File
@@ -28,9 +28,9 @@
"eyebrow": "WHEN EVERY CHANNEL HAS ITS OWN PLAN", "eyebrow": "WHEN EVERY CHANNEL HAS ITS OWN PLAN",
"title": "Good Marketing Can Still Add Up to a Bad Growth System.", "title": "Good Marketing Can Still Add Up to a Bad Growth System.",
"intro": [ "intro": [
"Your SEO team is chasing rankings. Paid media is optimising campaigns. Social is building engagement. The website team is working through its own list. Everyone is busy.", "Your SEO team is chasing rankings. Paid media is optimizing campaigns. Social is building engagement. The website team is working through its own list. Everyone is busy.",
"But is the business learning anything across those channels?", "But is the business learning anything across those channels?",
"A high-converting paid search term could shape organic content. Search behaviour could reveal questions worth targeting in AI answers. Website data could expose where promising traffic disappears. Brand insights could improve the ads people see before they ever search for you.", "A high-converting paid search term could shape organic content. Search behavior could reveal questions worth targeting in AI answers. Website data could expose where promising traffic disappears. Brand insights could improve the ads people see before they ever search for you.",
"When those signals stay inside separate teams and dashboards, the business misses part of their value.", "When those signals stay inside separate teams and dashboards, the business misses part of their value.",
"We bring them together so what works in one place can make the next decision smarter." "We bring them together so what works in one place can make the next decision smarter."
], ],
@@ -108,7 +108,7 @@
"title": "Brand Creation & Optimization", "title": "Brand Creation & Optimization",
"subtitle": null, "subtitle": null,
"body": [ "body": [
"Clarify how your business looks, sounds, and positions itself so people recognise the same company wherever they encounter it.", "Clarify how your business looks, sounds, and positions itself so people recognize the same company wherever they encounter it.",
"Strong positioning gives search, media, social, and web a clearer message to carry." "Strong positioning gives search, media, social, and web a clearer message to carry."
], ],
"list": [], "list": [],
@@ -147,7 +147,7 @@
"eyebrow": "THE VALUE IS IN WHAT CONNECTS", "eyebrow": "THE VALUE IS IN WHAT CONNECTS",
"title": "One Useful Signal Should Never Stop at One Channel.", "title": "One Useful Signal Should Never Stop at One Channel.",
"intro": [ "intro": [
"A paid campaign tells us which message gets attention. Search data tells us what people are actively looking for. AI visibility shows where the brand is entering new forms of discovery. Website behaviour tells us what happens when people arrive.", "A paid campaign tells us which message gets attention. Search data tells us what people are actively looking for. AI visibility shows where the brand is entering new forms of discovery. Website behavior tells us what happens when people arrive.",
"None of those signals needs to live alone.", "None of those signals needs to live alone.",
"We use what the business is already learning to improve what happens next.", "We use what the business is already learning to improve what happens next.",
"That is where an integrated growth system becomes more valuable over time. Not because there are more channels, but because each one gives the others better information." "That is where an integrated growth system becomes more valuable over time. Not because there are more channels, but because each one gives the others better information."
@@ -163,9 +163,9 @@
"eyebrow": "SAME SYSTEM. DIFFERENT REALITY.", "eyebrow": "SAME SYSTEM. DIFFERENT REALITY.",
"title": "Different Industries Change the Playbook.", "title": "Different Industries Change the Playbook.",
"intro": [ "intro": [
"The fundamentals of growth may travel. Customer behaviour does not.", "The fundamentals of growth may travel. Customer behavior does not.",
"A hospitality company doesn't acquire customers the same way a B2B organisation does. Medical and dental businesses operate within different constraints than e-commerce brands.", "A hospitality company doesn't acquire customers the same way a B2B organization does. Medical and dental businesses operate within different constraints than e-commerce brands.",
"Home services, automotive companies, and creators each have their own buying cycles, competition, search behaviour, and conversion points.", "Home services, automotive companies, and creators each have their own buying cycles, competition, search behavior, and conversion points.",
"We keep the capabilities connected while adapting the strategy to the market they have to perform in." "We keep the capabilities connected while adapting the strategy to the market they have to perform in."
], ],
"items": [], "items": [],
@@ -123,7 +123,7 @@
"title": "Atrilyx Connects the Signals Behind the Spend.", "title": "Atrilyx Connects the Signals Behind the Spend.",
"intro": [ "intro": [
"Paid media creates information everywhere.", "Paid media creates information everywhere.",
"Campaign platforms hold media data. Your website captures behaviour. Your CRM may know which leads became customers. Revenue data tells another part of the story.", "Campaign platforms hold media data. Your website captures behavior. Your CRM may know which leads became customers. Revenue data tells another part of the story.",
"Atrilyx brings those signals into a more connected view." "Atrilyx brings those signals into a more connected view."
], ],
"items": [ "items": [
@@ -212,7 +212,7 @@
}, },
{ {
"eyebrow": "A CLEARER WAY TO RUN PAID MEDIA", "eyebrow": "A CLEARER WAY TO RUN PAID MEDIA",
"title": "From Audit to Ongoing Optimisation.", "title": "From Audit to Ongoing Optimization.",
"intro": [], "intro": [],
"items": [ "items": [
{ {
@@ -256,7 +256,7 @@
"title": "Learn", "title": "Learn",
"subtitle": null, "subtitle": null,
"body": [ "body": [
"We evaluate acquisition cost, conversion behaviour, lead or customer quality, creative performance, and the wider signals available through Atrilyx." "We evaluate acquisition cost, conversion behavior, lead or customer quality, creative performance, and the wider signals available through Atrilyx."
], ],
"list": [], "list": [],
"cta": null, "cta": null,
@@ -264,11 +264,11 @@
}, },
{ {
"kicker": null, "kicker": null,
"title": "Optimise", "title": "Optimize",
"subtitle": null, "subtitle": null,
"body": [ "body": [
"Budgets shift. Creative evolves. Weak assumptions get replaced. Strong campaigns earn room to scale.", "Budgets shift. Creative evolves. Weak assumptions get replaced. Strong campaigns earn room to scale.",
"Optimisation continues because the market keeps giving us new information." "Optimization continues because the market keeps giving us new information."
], ],
"list": [], "list": [],
"cta": null, "cta": null,
@@ -285,7 +285,7 @@
"eyebrow": "ONE MEDIA PLAN DOESN’T FIT EVERY MARKET", "eyebrow": "ONE MEDIA PLAN DOESN’T FIT EVERY MARKET",
"title": "Global Reach Needs Local Judgment.", "title": "Global Reach Needs Local Judgment.",
"intro": [ "intro": [
"Customer behaviour changes by market. So do competition, search patterns, platform usage, media costs, and creative expectations.", "Customer behavior changes by market. So do competition, search patterns, platform usage, media costs, and creative expectations.",
"AI Growth Technologies supports multi-market campaigns with a connected view of performance, while letting targeting, creative, channel mix, and budget decisions respond to local conditions.", "AI Growth Technologies supports multi-market campaigns with a connected view of performance, while letting targeting, creative, channel mix, and budget decisions respond to local conditions.",
"The strategy stays coordinated without pretending every market behaves the same way." "The strategy stays coordinated without pretending every market behaves the same way."
], ],
@@ -302,7 +302,7 @@
"eyebrow": "PAID MEDIA DOESN’T WORK ALONE", "eyebrow": "PAID MEDIA DOESN’T WORK ALONE",
"title": "Explore the Services Around Your Media Strategy.", "title": "Explore the Services Around Your Media Strategy.",
"intro": [ "intro": [
"Paid campaigns often reveal opportunities beyond the ad account. Search demand, landing-page behaviour, creative response, and conversion data can point to what to address next." "Paid campaigns often reveal opportunities beyond the ad account. Search demand, landing-page behavior, creative response, and conversion data can point to what to address next."
], ],
"items": [ "items": [
{ {
@@ -394,7 +394,7 @@
"title": "How do you measure paid media performance?", "title": "How do you measure paid media performance?",
"subtitle": null, "subtitle": null,
"body": [ "body": [
"That depends on what the business is trying to achieve. Measurement can include qualified leads, customer acquisition cost, revenue, ROAS, conversion behaviour, and other business outcomes alongside platform metrics." "That depends on what the business is trying to achieve. Measurement can include qualified leads, customer acquisition cost, revenue, ROAS, conversion behavior, and other business outcomes alongside platform metrics."
], ],
"list": [], "list": [],
"cta": null "cta": null
+11 -11
View File
@@ -3,7 +3,7 @@
"path": "/services/seo", "path": "/services/seo",
"meta": { "meta": {
"title": "SEO Services | AI Growth Technologies", "title": "SEO Services | AI Growth Technologies",
"description": "Build organic visibility with technical SEO, content, authority, local search, and meSEO insights that connect rankings with visitor behaviour." "description": "Build organic visibility with technical SEO, content, authority, local search, and meSEO insights that connect rankings with visitor behavior."
}, },
"hero": { "hero": {
"eyebrow": "RANKINGS ARE ONLY THE BEGINNING.", "eyebrow": "RANKINGS ARE ONLY THE BEGINNING.",
@@ -11,7 +11,7 @@
"intro": [ "intro": [
"A page can rank first and still bring the wrong traffic. Organic visits can climb while leads stay flat. A keyword report can look impressive without telling you what happened after someone landed.", "A page can rank first and still bring the wrong traffic. Organic visits can climb while leads stay flat. A keyword report can look impressive without telling you what happened after someone landed.",
"That is why we look at more than position.", "That is why we look at more than position.",
"AI Growth Technologies combines technical SEO, on-page optimisation, content, authority, and behavioural insight to build organic visibility around searches that matter to the business.", "AI Growth Technologies combines technical SEO, on-page optimization, content, authority, and behavioral insight to build organic visibility around searches that matter to the business.",
"Then meSEO helps us see what visitors actually do with that visibility." "Then meSEO helps us see what visitors actually do with that visibility."
], ],
"items": [], "items": [],
@@ -32,7 +32,7 @@
"Useful information. Not the whole story.", "Useful information. Not the whole story.",
"What did those visitors do next?", "What did those visitors do next?",
"Did they find what they came for? Which pages kept them moving? Where did they leave? Which search topics produced meaningful engagement or conversion?", "Did they find what they came for? Which pages kept them moving? Where did they leave? Which search topics produced meaningful engagement or conversion?",
"We connect search performance with post-click behaviour, so SEO decisions are shaped by more than what happened on the results page." "We connect search performance with post-click behavior, so SEO decisions are shaped by more than what happened on the results page."
], ],
"items": [], "items": [],
"outro": [], "outro": [],
@@ -107,7 +107,7 @@
"subtitle": null, "subtitle": null,
"body": [ "body": [
"See what organic visitors do once the ranking has done its job.", "See what organic visitors do once the ranking has done its job.",
"meSEO connects search visibility with on-site behaviour so we can identify where organic traffic engages, moves forward, converts, or disappears." "meSEO connects search visibility with on-site behavior so we can identify where organic traffic engages, moves forward, converts, or disappears."
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -164,7 +164,7 @@
"title": "Site Intelligence", "title": "Site Intelligence",
"subtitle": null, "subtitle": null,
"body": [ "body": [
"Identify technical health issues and optimisation opportunities that could affect organic performance." "Identify technical health issues and optimization opportunities that could affect organic performance."
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -198,12 +198,12 @@
}, },
{ {
"eyebrow": "THE BEST SEO INSIGHT DOESN’T HAVE TO STAY IN SEO", "eyebrow": "THE BEST SEO INSIGHT DOESN’T HAVE TO STAY IN SEO",
"title": "Search Behaviour Can Improve More Than Rankings.", "title": "Search Behavior Can Improve More Than Rankings.",
"intro": [ "intro": [
"Search is one of the clearest expressions of customer intent available to a business.", "Search is one of the clearest expressions of customer intent available to a business.",
"People tell Google what they want to know, what they are comparing, what is confusing them, and sometimes exactly what they are ready to buy.", "People tell Google what they want to know, what they are comparing, what is confusing them, and sometimes exactly what they are ready to buy.",
"That information can go further.", "That information can go further.",
"High-value queries can inform paid media. Recurring questions can reveal opportunities for LLMO and AI visibility. Organic behaviour can expose problems in web development. Search language can even sharpen brand messaging.", "High-value queries can inform paid media. Recurring questions can reveal opportunities for LLMO and AI visibility. Organic behavior can expose problems in web development. Search language can even sharpen brand messaging.",
"SEO becomes more useful when the rest of the growth system can learn from it." "SEO becomes more useful when the rest of the growth system can learn from it."
], ],
"items": [], "items": [],
@@ -272,7 +272,7 @@
"title": "Keep Compounding", "title": "Keep Compounding",
"subtitle": null, "subtitle": null,
"body": [ "body": [
"Search behaviour changes. Competitors publish. Sites grow. Algorithms evolve. New opportunities appear.", "Search behavior changes. Competitors publish. Sites grow. Algorithms evolve. New opportunities appear.",
"We keep refining the technical foundation, content, authority, and user experience around what the data continues to reveal." "We keep refining the technical foundation, content, authority, and user experience around what the data continues to reveal."
], ],
"list": [], "list": [],
@@ -380,7 +380,7 @@
"title": "What is included in your SEO services?", "title": "What is included in your SEO services?",
"subtitle": null, "subtitle": null,
"body": [ "body": [
"SEO engagements can include technical SEO, on-page optimisation, content strategy, local SEO, internal linking, authority building, competitor analysis, measurement, and behavioural analysis through meSEO. The mix depends on what the site and business actually need." "SEO engagements can include technical SEO, on-page optimization, content strategy, local SEO, internal linking, authority building, competitor analysis, measurement, and behavioral analysis through meSEO. The mix depends on what the site and business actually need."
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -410,7 +410,7 @@
"title": "What is meSEO, and how does it support SEO?", "title": "What is meSEO, and how does it support SEO?",
"subtitle": null, "subtitle": null,
"body": [ "body": [
"meSEO is AI Growth Technologies’ proprietary search and behavioural analytics platform. It helps connect ranking and search data with what organic visitors do after reaching the site, giving us another layer of evidence for content, technical, and conversion decisions." "meSEO is AI Growth Technologies’ proprietary search and behavioral analytics platform. It helps connect ranking and search data with what organic visitors do after reaching the site, giving us another layer of evidence for content, technical, and conversion decisions."
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -436,7 +436,7 @@
"title": "See Where Your Search Opportunities Really Are.", "title": "See Where Your Search Opportunities Really Are.",
"intro": [ "intro": [
"If SEO is already producing traffic, we can assess what that visibility is contributing and where valuable opportunities may still be missed.", "If SEO is already producing traffic, we can assess what that visibility is contributing and where valuable opportunities may still be missed.",
"If organic growth has stalled, we can examine the technical foundation, content, authority, competitors, and visitor behaviour to understand what deserves attention first." "If organic growth has stalled, we can examine the technical foundation, content, authority, competitors, and visitor behavior to understand what deserves attention first."
], ],
"items": [], "items": [],
"outro": [], "outro": [],
+4 -4
View File
@@ -117,7 +117,7 @@
"subtitle": "Sede global", "subtitle": "Sede global",
"body": [ "body": [
"Nuestra sede global y centro de innovación en la región MENA.", "Nuestra sede global y centro de innovación en la región MENA.",
"Downtown Dubai, UAE\nPhone: +971 55 647 0342\nEmail: info@aigrowthtechnologies.com" "Blue Bay Tower, Office 312, 3rd Floor\nMarasi Drive Street, Business Bay\nDubai, UAE\nPhone: +971 55 647 0342\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -128,7 +128,7 @@
"subtitle": "Oficina asociada", "subtitle": "Oficina asociada",
"body": [ "body": [
"Un punto de contacto en EE. UU. para empresas de Texas y más allá.", "Un punto de contacto en EE. UU. para empresas de Texas y más allá.",
"Downtown Houston, TX, USA\nPhone: +1 832-350-0465" "Downtown Houston, TX, USA\nPhone: +1 832-350-0465\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -139,7 +139,7 @@
"subtitle": "Oficina asociada", "subtitle": "Oficina asociada",
"body": [ "body": [
"Un punto de contacto en EE. UU. ubicado en el sur de Florida.", "Un punto de contacto en EE. UU. ubicado en el sur de Florida.",
"Brickell Avenue, FL, USA\nPhone: +1 954-235-4974" "Brickell Avenue, FL, USA\nPhone: +1 954-235-4974\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -150,7 +150,7 @@
"subtitle": "Oficina asociada", "subtitle": "Oficina asociada",
"body": [ "body": [
"Un punto de contacto en EE. UU. en uno de los mercados de negocios más importantes del mundo.", "Un punto de contacto en EE. UU. en uno de los mercados de negocios más importantes del mundo.",
"Manhattan Financial District, NY, USA\nPhone: +1 713-289-4577" "Manhattan Financial District, NY, USA\nPhone: +1 713-289-4577\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
+6
View File
@@ -19,8 +19,14 @@ export const ar: UI = {
openMenu: "فتح القائمة", openMenu: "فتح القائمة",
closeMenu: "إغلاق القائمة", closeMenu: "إغلاق القائمة",
language: "اللغة", language: "اللغة",
lightTheme: "التبديل إلى الوضع الفاتح",
darkTheme: "التبديل إلى الوضع الداكن",
}, },
bookCall: "احجز مكالمة استراتيجية", bookCall: "احجز مكالمة استراتيجية",
ctaBand: {
title: "هل أنت مستعد لمعرفة أين يتعثر نمو أعمالك؟",
body: "تحدث مع أحد خبراء الاستراتيجية حول قنواتك وبياناتك وما يجب إصلاحه أولاً.",
},
services: { services: {
"paid-media": "الإعلانات المدفوعة", "paid-media": "الإعلانات المدفوعة",
seo: "تحسين محركات البحث (SEO)", seo: "تحسين محركات البحث (SEO)",
+6
View File
@@ -19,8 +19,14 @@ export const en = {
openMenu: "Open menu", openMenu: "Open menu",
closeMenu: "Close menu", closeMenu: "Close menu",
language: "Language", language: "Language",
lightTheme: "Switch to light theme",
darkTheme: "Switch to dark theme",
}, },
bookCall: "Book a Strategy Call", bookCall: "Book a Strategy Call",
ctaBand: {
title: "Ready to see where growth is getting stuck?",
body: "Talk to a strategist about your channels, your data, and what to fix first.",
},
services: { services: {
"paid-media": "Paid Media", "paid-media": "Paid Media",
seo: "SEO", seo: "SEO",
+6
View File
@@ -19,8 +19,14 @@ export const es: UI = {
openMenu: "Abrir menú", openMenu: "Abrir menú",
closeMenu: "Cerrar menú", closeMenu: "Cerrar menú",
language: "Idioma", language: "Idioma",
lightTheme: "Cambiar a tema claro",
darkTheme: "Cambiar a tema oscuro",
}, },
bookCall: "Agenda una llamada estratégica", bookCall: "Agenda una llamada estratégica",
ctaBand: {
title: "¿Listo para ver dónde se está frenando tu crecimiento?",
body: "Habla con un estratega sobre tus canales, tus datos y qué conviene resolver primero.",
},
services: { services: {
"paid-media": "Medios pagados", "paid-media": "Medios pagados",
seo: "SEO", seo: "SEO",
-41
View File
@@ -1,41 +0,0 @@
import "server-only";
import { createHash, createHmac, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
// A self-hosted proof-of-work check (the ALTCHA scheme): the browser has to find the number whose
// SHA-256 with a salt matches the challenge. A person never notices the second or so it takes;
// a bot sending thousands of submissions pays for each one. No third-party account involved.
export type Challenge = { salt: string; challenge: string; signature: string; max: number };
const MAX_NUMBER = 120_000;
const TTL_MS = 30 * 60 * 1000;
// CAPTCHA_SECRET keeps challenges valid across restarts; without it, a restart only means a
// visitor with the form already open has to tick the box again.
const SECRET = process.env.CAPTCHA_SECRET || randomBytes(32).toString("hex");
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
const sign = (s: string) => createHmac("sha256", SECRET).update(s).digest("hex");
// Solutions already accepted, until they expire, so one solve can't be replayed.
const used = new Map<string, number>();
export function createChallenge(): Challenge {
const salt = `${randomBytes(12).toString("hex")}.${Date.now() + TTL_MS}`;
const challenge = sha256(salt + randomInt(MAX_NUMBER));
return { salt, challenge, signature: sign(challenge), max: MAX_NUMBER };
}
export function verifySolution(salt: string, number: string, signature: string): boolean {
const expires = Number(salt.split(".")[1]);
if (!expires || expires < Date.now() || !/^\d{1,7}$/.test(number)) return false;
const expected = Buffer.from(sign(sha256(salt + number)), "hex");
const given = Buffer.from(signature, "hex");
if (expected.length !== given.length || !timingSafeEqual(expected, given)) return false;
const now = Date.now();
for (const [key, until] of used) if (until < now) used.delete(key);
if (used.has(signature)) return false;
used.set(signature, expires);
return true;
}
-126
View File
@@ -1,126 +0,0 @@
"use server";
import { createChallenge, verifySolution, type Challenge } from "./captcha";
export type ContactError = "details" | "human" | "unavailable" | "failed";
export type ContactValues = {
name: string;
email: string;
company: string;
website: string;
interests: string[];
message: string;
};
export type ContactState =
| { status: "idle" }
| { status: "sent" }
// Values come back so the form can be refilled after a failed attempt.
| { status: "error"; error: ContactError; values: ContactValues; attempt: number };
// Where enquiries land. Override with CONTACT_TO (comma-separated for several inboxes) if it ever changes.
const TO = (process.env.CONTACT_TO || "rey@aigrowth360.io")
.split(",")
.map((a) => a.trim())
.filter(Boolean);
// Resend's shared sender only delivers to the address that owns the Resend account. To send to TO
// from any other account, set CONTACT_FROM to an address on a domain verified in Resend
// (e.g. "AI Growth Website <hello@aigrowthtechnologies.com>").
const FROM = process.env.CONTACT_FROM || "AI Growth Website <onboarding@resend.dev>";
const MIN_FILL_MS = 3000;
const MAX_LINKS = 3;
const clean = (v: FormDataEntryValue | null, max: number) => String(v ?? "").trim().slice(0, max);
const escape = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
// Bots get a normal-looking success so they don't learn what tripped them.
const silentlyDrop = (reason: string): ContactState => {
console.warn("Contact form: dropped submission —", reason);
return { status: "sent" };
};
/** A fresh proof-of-work challenge for the "I'm not a robot" check. */
export async function getChallenge(): Promise<Challenge> {
return createChallenge();
}
async function sendEmail(apiKey: string, payload: Record<string, unknown>) {
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ from: FROM, ...payload }),
});
if (!res.ok) throw new Error(`Resend ${res.status}: ${await res.text()}`);
}
export async function sendContact(prev: ContactState, formData: FormData): Promise<ContactState> {
// 1. Honeypot: real visitors never see or fill this field.
if (clean(formData.get("company_url"), 200)) return silentlyDrop("honeypot");
// 2. Time trap: people need more than a few seconds to fill the form in.
const started = Number(formData.get("started"));
if (!started || Date.now() - started < MIN_FILL_MS) return silentlyDrop("submitted too fast");
const values: ContactValues = {
name: clean(formData.get("name"), 120),
email: clean(formData.get("email"), 200),
company: clean(formData.get("company"), 160),
website: clean(formData.get("website"), 300),
interests: formData
.getAll("interest")
.map((i) => clean(i, 60))
.filter(Boolean)
.slice(0, 12),
message: clean(formData.get("message"), 5000),
};
const attempt = prev.status === "error" ? prev.attempt + 1 : 1;
const fail = (error: ContactError): ContactState => ({ status: "error", error, values, attempt });
if (!values.name || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) return fail("details");
// 3. Link spam.
if ((values.message.match(/https?:\/\/|www\./gi) ?? []).length > MAX_LINKS) return silentlyDrop("too many links");
// 4. "I'm not a robot": the proof-of-work solved in the browser.
const solved = verifySolution(
clean(formData.get("captcha_salt"), 100),
clean(formData.get("captcha_number"), 10),
clean(formData.get("captcha_signature"), 100),
);
if (!solved) return fail("human");
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
console.error("Contact form: RESEND_API_KEY is not set");
return fail("unavailable");
}
const language = clean(formData.get("locale"), 5);
const rows: [string, string][] = [
["Name", values.name],
["Email", values.email],
["Company", values.company || "—"],
["Website", values.website || "—"],
["Interested in", values.interests.join(", ") || "—"],
["Site language", language || "en"],
];
const message = values.message || "(No message)";
try {
await sendEmail(apiKey, {
to: TO,
reply_to: values.email,
subject: `New enquiry — ${values.company || values.name}`,
text: `${rows.map(([k, v]) => `${k}: ${v}`).join("\n")}\n\n${message}`,
html: `<table cellpadding="6" style="font-family:Arial,sans-serif;font-size:14px">${rows
.map(([k, v]) => `<tr><td style="color:#666">${k}</td><td><b>${escape(v)}</b></td></tr>`)
.join("")}</table><p style="font-family:Arial,sans-serif;font-size:14px;white-space:pre-wrap">${escape(message)}</p>`,
});
} catch (err) {
console.error("Contact form: could not email the team", err);
return fail("failed");
}
return { status: "sent" };
}
+51
View File
@@ -0,0 +1,51 @@
import { locales, localize } from "@/i18n/config";
import { SITE_URL, industryNav, serviceNav } from "./routes";
// Yoast-style sitemaps: /sitemap.xml is an index of one sitemap per kind of page. Each lists the
// English pages in site order, then their Spanish and Arabic copies, with every page's translations.
// public/sitemap-style.xml (an XSL stylesheet) shows them as tables in a browser; search engines ignore it.
export const sitemaps = {
page: ["/", "/about", "/contact"],
service: ["/services", ...serviceNav.map((s) => `/services/${s.slug}`)],
industry: ["/industries", ...industryNav.map((s) => `/industries/${s.slug}`)],
} as const;
// The day this deploy was built (set in next.config.ts), so dates only move when the site changes.
const lastmod = process.env.BUILD_DATE;
const url = (p: string) => SITE_URL + (p === "/" ? "" : p);
const head = `<?xml version="1.0" encoding="UTF-8"?>\n<?xml-stylesheet type="text/xsl" href="/sitemap-style.xml"?>\n`;
const xml = (body: string) =>
new Response(head + body, { headers: { "content-type": "application/xml; charset=utf-8" } });
export function sitemapIndex() {
const entries = Object.keys(sitemaps).map(
(name) => ` <sitemap>
<loc>${SITE_URL}/${name}-sitemap.xml</loc>
<lastmod>${lastmod}</lastmod>
</sitemap>`,
);
return xml(`<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${entries.join("\n")}
</sitemapindex>
`);
}
export function sitemap(name: keyof typeof sitemaps) {
const alternates = (p: string) =>
[...locales.map((l) => [l, localize(p, l)]), ["x-default", p]]
.map(([l, href]) => `\n <xhtml:link rel="alternate" hreflang="${l}" href="${url(href)}"/>`)
.join("");
const entries = locales.flatMap((locale) =>
sitemaps[name].map(
(p) => ` <url>
<loc>${url(localize(p, locale))}</loc>
<lastmod>${lastmod}</lastmod>${alternates(p)}
</url>`,
),
);
return xml(`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
${entries.join("\n")}
</urlset>
`);
}
+10 -1
View File
@@ -4,9 +4,18 @@ import { defaultLocale, isLocale } from "@/i18n/config";
// Every page lives under app/[lang]. English keeps its unprefixed URLs, so those requests are // Every page lives under app/[lang]. English keeps its unprefixed URLs, so those requests are
// rewritten to /en internally, and /en/... is redirected back to the unprefixed address. // rewritten to /en internally, and /en/... is redirected back to the unprefixed address.
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname, searchParams } = request.nextUrl;
const first = pathname.split("/")[1]; const first = pathname.split("/")[1];
// Spam sites link here with made-up paths in the query string (/?shop/items/L738.../).
// Answer 410 so search engines drop them instead of indexing copies of the page.
if ([...searchParams.keys()].some((key) => key.includes("/"))) {
return new NextResponse("Gone", {
status: 410,
headers: { "content-type": "text/plain; charset=utf-8", "x-robots-tag": "noindex" },
});
}
if (first === defaultLocale) { if (first === defaultLocale) {
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
url.pathname = pathname.slice(defaultLocale.length + 1) || "/"; url.pathname = pathname.slice(defaultLocale.length + 1) || "/";