diff --git a/scripts/convert-docs.py b/scripts/convert-docs.py new file mode 100644 index 0000000..337b6d9 --- /dev/null +++ b/scripts/convert-docs.py @@ -0,0 +1,225 @@ +""" +Convert the copy decks (.docx) into structured page JSON. + +Usage: + python3 scripts/convert-docs.py "" + +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 +and is skipped. +""" + +import html +import json +import pathlib +import re +import sys +import zipfile + +OUT = pathlib.Path(__file__).resolve().parent.parent / "src" / "content" / "pages" + +# Lines in the decks that are notes to the team, not site copy. +DROP = [ + re.compile(r"^].*?", xml, re.S): + parts, bolds = [], [] + for r in re.findall(r"].*?", p, re.S): + text = "" + for tok in re.findall(r"]*>[^<]*|]*/>", r): + text += "\n" if tok.startswith("]+>", "", tok) + if not text: + continue + parts.append(text) + if text.strip(): + bolds.append(bool(re.search(r'| w:val="(1|true)")', r))) + text = html.unescape("".join(parts)).strip() + text = re.sub(r"[ \t]*\n[ \t]*", "\n", text) + if not text or any(d.search(text) for d in DROP): + continue + st = re.search(r'" in p: + out.append({"t": "li", "v": text}) + elif bolds and all(bolds): + out.append({"t": "b", "v": text}) + else: + out.append({"t": "p", "v": text}) + return out + + +def is_caps(s): + letters = [c for c in s if c.isalpha()] + return bool(letters) and all(c.isupper() for c in letters if c not in "me") # "meSEO" + + +def new_section(eyebrow=None, title=None): + return {"eyebrow": eyebrow, "title": title, "intro": [], "items": [], "outro": [], "ctas": [], "form": False} + + +def new_item(title, kicker=None): + return {"kicker": kicker, "title": title, "subtitle": None, "body": [], "list": [], "cta": None} + + +def structure(blocks): + meta = {} + sections = [] + sec = None + item = None + pending_kicker = None + + for i, b in enumerate(blocks): + t, v = b["t"], b["v"] + nxt = blocks[i + 1]["t"] if i + 1 < len(blocks) else None + if t == "meta": + meta[b["k"]] = v + continue + + # Eyebrows: bold (or an all-caps plain line) right before an h1/h2. + if t in ("b", "p") and nxt in ("h1", "h2") and is_caps(v): + sec = new_section(eyebrow=v) + sections.append(sec) + item = None + continue + if t == "b" and nxt == "h3" and re.search(r"FREQUENTLY ASKED|FAQ", v): + sec = new_section(eyebrow=v) + sections.append(sec) + item = None + continue + if t == "b" and nxt == "h3" and "|" in v: + pending_kicker = v + continue + + if t in ("h1", "h2"): + if sec is None or sec["title"] is not None: + sec = new_section() + sections.append(sec) + sec["title"] = v + sec["level"] = t + item = None + continue + if sec is None: + sec = new_section() + sections.append(sec) + + if t == "h3": + item = new_item(v, kicker=pending_kicker) + pending_kicker = None + sec["items"].append(item) + continue + + if t == "b" and not is_caps(v) and not (item is not None and not item["body"] and item["subtitle"] is None): + # A bold sentence inside running copy: keep it as an emphasised paragraph. + t, v = "p", "> " + v + + if t == "b": + if v.strip("()").upper() == "FORM": + sec["form"] = True + elif item is not None and not item["body"] and item["subtitle"] is None and not item["list"]: + item["subtitle"] = v + elif item is not None and item["cta"] is None and re.match(r"(EXPLORE|VIEW|LEARN|SEE|EXAMINE|DISCOVER)\b", v) and not re.search(r"\bALL\b", v): + item["cta"] = v + else: + sec["ctas"].append(v) + item = None + continue + + if t == "li": + (item["list"] if item else sec["intro"]).append(v) + continue + + # paragraph + if item is not None and sec["ctas"] == []: + item["body"].append(v) + elif sec["items"]: + sec["outro"].append(v) + else: + sec["intro"].append(v) + + for s in sections: + tidy(s) + return meta, sections + + +def tidy(sec): + items = sec["items"] + # A CTA held by only the last item belongs to the section. + if len(items) > 1 and items[-1]["cta"] and not any(it["cta"] for it in items[:-1]): + sec["ctas"].insert(0, items[-1]["cta"]) + items[-1]["cta"] = None + # Trailing paragraphs beyond the other items' length are the section outro. + if len(items) > 1: + lens = [len(it["body"]) for it in items[:-1]] + keep = max(lens) + last = items[-1] + if len(last["body"]) > keep and not last["list"]: + sec["outro"] = last["body"][keep:] + sec["outro"] + last["body"] = last["body"][:keep] + sec["kind"] = kind_of(sec) + if sec["kind"] == "steps": + for it in items: + m = re.match(r"^(\d+)\.\s*(.*)$", it["title"]) + it["num"], it["title"] = m.group(1), m.group(2) + + +def kind_of(sec): + items = sec["items"] + if not items: + return "text" + titles = [it["title"] for it in items] + if all(re.match(r"^\d+\.\s", t) for t in titles): + return "steps" + if len(items) >= 3 and all(t.endswith("?") for t in titles): + return "faq" + if all(re.match(r"^[\d.,]+[%+x]?$", t) for t in titles): + return "stats" + if any("Phone:" in " ".join(it["body"]) for it in items): + return "offices" + if any(it["kicker"] for it in items): + return "showcase" + if all(it["cta"] for it in items): + return "links" + return "cards" + + +def slug_for(name): + name = name.strip().replace("https_www.aigrowthtechnologies.com_", "") + return name + + +def main(folder): + OUT.mkdir(parents=True, exist_ok=True) + for f in sorted(pathlib.Path(folder).rglob("*.docx")): + slug = slug_for(f.stem) + if not slug: + continue # homepage — hand-built + meta, sections = structure(read_blocks(f)) + hero = sections[0] + page = { + "slug": slug, + "path": "/" + slug.replace("_", "/"), + "meta": {"title": meta.get("Title Tag"), "description": meta.get("Meta Description")}, + "hero": hero, + "sections": sections[1:], + } + (OUT / f"{slug}.json").write_text(json.dumps(page, ensure_ascii=False, indent=2) + "\n") + kinds = [s["kind"] for s in sections[1:]] + print(f"{slug:32} {len(sections)-1:2} sections {' '.join(kinds)}") + + +if __name__ == "__main__": + main(sys.argv[1]) diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx new file mode 100644 index 0000000..747f385 --- /dev/null +++ b/src/app/about/page.tsx @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..eb919d7 --- /dev/null +++ b/src/app/contact/page.tsx @@ -0,0 +1,19 @@ +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 64abb0f..3a0d8ed 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -277,7 +277,7 @@ a { transition-delay: var(--d, 0ms); } -[data-reveal].is-in { +[data-reveal][data-in] { opacity: 1; transform: none; } @@ -1350,7 +1350,7 @@ a { transition: transform 2.6s var(--ease) 0.2s; } -.journey.is-in ol::after { +.journey[data-in] ol::after { transform: scaleX(1); } @@ -1371,7 +1371,7 @@ a { transition-delay: calc(var(--i) * 380ms + 300ms); } -.journey.is-in .dot { +.journey[data-in] .dot { background: var(--bianco); } @@ -1379,7 +1379,7 @@ a { border-color: var(--rosso); } -.journey.is-in li:last-child .dot { +.journey[data-in] li:last-child .dot { background: var(--rosso); } @@ -1422,7 +1422,7 @@ a { transform: scaleY(0); transform-origin: top; } - .journey.is-in ol::after { + .journey[data-in] ol::after { transform: scaleY(1); } .journey .dot { @@ -1812,3 +1812,759 @@ a { opacity: 0.45; } } + +/* ============================================================ + Navigation dropdowns + ============================================================ */ +.nav a.is-active { + color: var(--bianco); +} + +.nav a.is-active::after { + right: 0; +} + +.nav-group { + position: relative; + display: flex; + align-items: center; + height: 76px; +} + +.nav-panel { + position: absolute; + top: 100%; + left: -28px; + min-width: 300px; + padding-top: 1px; + opacity: 0; + visibility: hidden; + transform: translateY(-6px); + transition: + opacity 0.3s var(--ease), + transform 0.3s var(--ease), + visibility 0.3s; +} + +.nav-group:hover .nav-panel, +.nav-group:focus-within .nav-panel { + opacity: 1; + visibility: visible; + transform: none; +} + +.nav-panel-inner { + padding: 22px 28px 26px; + background: rgba(0, 0, 0, 0.94); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border: 1px solid var(--grafite); +} + +.nav-panel ul { + list-style: none; + margin: 16px 0 0; + padding: 16px 0 0; + border-top: 1px solid var(--grafite); +} + +.nav-panel li + li { + margin-top: 4px; +} + +.nav .nav-panel a { + display: block; + padding: 6px 0; + letter-spacing: 0.16em; +} + +.nav .nav-panel a::after { + display: none; +} + +.nav .nav-panel a:hover, +.nav .nav-panel a.is-active { + color: var(--rosso); +} + +.nav .nav-panel .nav-panel-all { + display: flex; + justify-content: space-between; + color: var(--bianco); +} + +.mobile-menu { + overflow-y: auto; +} + +.mobile-sub { + display: flex; + flex-wrap: wrap; + gap: 6px 18px; + padding: 0 0 20px; +} + +.mobile-sub a { + font-size: 11px; + font-weight: 500; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--fumo); +} + +/* ============================================================ + Inner pages + ============================================================ */ +.page-hero { + min-height: 88svh; +} + +.page-hero h1 { + max-width: 18ch; + font-size: clamp(38px, 5vw, 80px); +} + +.crumbs { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 36px; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--piombo); +} + +.crumbs span { + display: inline-flex; + gap: 10px; +} + +.crumbs a { + color: var(--fumo); + transition: color 0.3s var(--ease); +} + +.crumbs a:hover { + color: var(--rosso); +} + +.crumbs [aria-current] { + color: var(--bianco); +} + +.emph { + margin: 1.2em 0 !important; + padding-left: 20px; + border-left: 1px solid var(--rosso); + font-size: clamp(18px, 1.6vw, 24px); + font-weight: 300; + line-height: 1.4; + letter-spacing: -0.01em; + color: var(--bianco); +} + +.cta-row { + display: flex; + flex-wrap: wrap; + gap: 8px 36px; + margin-top: 28px; +} + +.outro { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 24px; + margin-top: clamp(48px, 6vw, 80px); +} + +.outro > * { + grid-column: 1 / span 7; +} + +.outro-copy { + font-size: clamp(18px, 1.7vw, 24px); + font-weight: 300; + line-height: 1.45; + letter-spacing: -0.01em; +} + +.outro-copy p { + margin: 0; +} + +.outro-copy p + p { + margin-top: 0.8em; + color: var(--fumo); +} + +.outro .cta-row { + margin-top: 0; +} + +@media (max-width: 1023px) { + .outro > * { + grid-column: 1 / -1; + } +} + +/* cards */ +.card-grid { + display: grid; + 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); +} + +.card { + display: flex; + flex-direction: column; + padding: 32px 28px 36px; + border-right: 1px solid var(--grafite); + border-bottom: 1px solid var(--grafite); +} + +.card .copy { + font-size: 14px; +} + +.card .arrow-link, +.card .tick-list { + margin-top: 24px; +} + +.card > .arrow-link { + margin-top: auto; + padding-top: 24px; + align-self: flex-start; +} + +.card-num { + font-size: 11px; + font-weight: 500; + letter-spacing: 0.2em; + color: var(--rosso); + font-variant-numeric: tabular-nums; +} + +.card-title { + margin: 36px 0 16px; + font-size: clamp(18px, 1.5vw, 22px); + font-weight: 500; + line-height: 1.25; + letter-spacing: -0.005em; +} + +.card-sub { + margin: -6px 0 16px; + font-size: 10.5px; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--fumo); +} + +.tick-list { + list-style: none; + margin: 0; + padding: 0; + border-top: 1px solid var(--grafite); +} + +.tick-list li { + display: flex; + gap: 14px; + padding: 11px 0; + border-bottom: 1px solid var(--grafite); + font-size: 13px; + line-height: 1.5; + color: var(--argento); +} + +.tick-list li::before { + content: ""; + flex: none; + width: 10px; + height: 1px; + margin-top: 0.75em; + background: var(--rosso); +} + +.link-card { + transition: background-color 0.4s var(--ease); +} + +.link-card:hover { + background: rgba(255, 255, 255, 0.03); +} + +.link-card:hover .arrow-link { + color: var(--rosso); + border-color: var(--rosso); +} + +.link-card:hover .arrow-link .arrow { + transform: translateX(4px); +} + +@media (max-width: 1023px) { + .card-grid { + grid-template-columns: repeat(min(var(--cols, 3), 2), minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .card-grid { + grid-template-columns: 1fr; + } + .card-title { + margin-top: 20px; + } +} + +/* steps */ +.steps { + list-style: none; + margin: clamp(56px, 7vw, 96px) 0 0; + padding: 0; + border-top: 1px solid var(--grafite); +} + +.step { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 24px; + padding: 36px 0; + border-bottom: 1px solid var(--grafite); + align-items: baseline; +} + +.step-num { + grid-column: 1 / span 2; + font-size: clamp(40px, 4.4vw, 72px); + font-weight: 300; + line-height: 1; + letter-spacing: -0.04em; + color: var(--piombo); + transition: color 0.4s var(--ease); +} + +.step:hover .step-num { + color: var(--rosso); +} + +.step-title { + grid-column: 3 / span 4; + margin: 0; + font-size: clamp(20px, 2vw, 28px); + font-weight: 400; + line-height: 1.2; + letter-spacing: -0.01em; + text-transform: uppercase; +} + +.step .copy { + grid-column: 8 / span 5; +} + +@media (max-width: 1023px) { + .step { + grid-template-columns: 64px 1fr; + gap: 12px 16px; + } + .step-num { + grid-column: 1; + grid-row: 1 / span 2; + font-size: 32px; + } + .step-title, + .step .copy { + grid-column: 2; + } +} + +/* faq */ +.faq { + margin-top: clamp(48px, 6vw, 80px); + border-top: 1px solid var(--grafite); +} + +.faq-item { + border-bottom: 1px solid var(--grafite); +} + +.faq-item summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 28px 0; + cursor: pointer; + list-style: none; + font-size: clamp(17px, 1.6vw, 22px); + font-weight: 400; + line-height: 1.35; + transition: color 0.3s var(--ease); +} + +.faq-item summary::-webkit-details-marker { + display: none; +} + +.faq-item summary:hover { + color: var(--argento); +} + +.faq-item summary i { + position: relative; + flex: none; + width: 14px; + height: 14px; +} + +.faq-item summary i::before, +.faq-item summary i::after { + content: ""; + position: absolute; + top: 50%; + left: 0; + width: 14px; + height: 1px; + background: var(--bianco); + transition: transform 0.35s var(--ease), background-color 0.35s var(--ease); +} + +.faq-item summary i::after { + transform: rotate(90deg); +} + +.faq-item[open] summary i::after { + transform: rotate(0deg); + background: var(--rosso); +} + +.faq-item[open] summary i::before { + background: var(--rosso); +} + +.faq-item .copy { + max-width: 72ch; + padding: 0 0 32px; +} + +/* stats on inner pages */ +.stat-copy { + margin-top: 14px; + padding-right: 12px; + font-size: 13px; +} + +/* offices */ +.offices { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: clamp(56px, 7vw, 96px); + border-top: 1px solid var(--grafite); +} + +.office { + display: flex; + flex-direction: column; + padding: 32px 24px 8px; + border-left: 1px solid var(--grafite); +} + +.office:first-child { + padding-left: 0; + border-left: 0; +} + +.office-head { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 14px; + margin-bottom: 20px; +} + +.office .card-title { + margin: 0; + font-size: clamp(22px, 2vw, 30px); + font-weight: 300; + letter-spacing: -0.02em; +} + +.office .copy { + font-size: 14px; +} + +.office-address { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 24px; + padding-top: 20px; + border-top: 1px solid var(--grafite); + font-style: normal; + font-size: 13px; + line-height: 1.5; + color: var(--argento); +} + +.office-address em { + display: block; + font-style: normal; + font-size: 9.5px; + font-weight: 500; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--piombo); +} + +.office-address a { + color: var(--bianco); + transition: color 0.3s var(--ease); +} + +.office-address a:hover { + color: var(--rosso); +} + +@media (max-width: 1023px) { + .offices { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .office:nth-child(3) { + padding-left: 0; + border-left: 0; + } + .office:nth-child(n + 3) { + border-top: 1px solid var(--grafite); + } +} + +@media (max-width: 640px) { + .offices { + grid-template-columns: 1fr; + } + .office, + .office:nth-child(3) { + padding: 28px 0; + border-left: 0; + } + .office + .office { + border-top: 1px solid var(--grafite); + } +} + +/* showcase (industries index) */ +.showcase { + margin-top: clamp(56px, 7vw, 96px); + border-top: 1px solid var(--grafite); +} + +.showcase-row { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 24px; + padding: clamp(40px, 5vw, 64px) 0; + border-bottom: 1px solid var(--grafite); +} + +.showcase-name { + grid-column: 1 / span 4; +} + +.showcase-name h3 { + margin: 20px 0 12px; + font-size: clamp(30px, 3.4vw, 52px); + font-weight: 300; + line-height: 1.02; + letter-spacing: -0.03em; +} + +.showcase-body { + grid-column: 5 / span 5; +} + +.showcase-body h4 { + margin: 0 0 18px; +} + +.showcase-body .arrow-link { + margin-top: 24px; +} + +.showcase-row > .tick-list { + grid-column: 10 / span 3; + align-self: start; +} + +@media (max-width: 1023px) { + .showcase-name, + .showcase-body, + .showcase-row > .tick-list { + grid-column: 1 / -1; + } +} + +/* contact form */ +.form-layout { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 24px; + margin-top: clamp(48px, 6vw, 80px); +} + +.form-copy { + grid-column: 1 / span 4; +} + +.form-copy .copy { + margin-top: 24px; +} + +.form-layout > :last-child { + grid-column: 6 / span 7; +} + +@media (max-width: 1023px) { + .form-copy, + .form-layout > :last-child { + grid-column: 1 / -1; + } +} + +.contact-form { + display: flex; + flex-direction: column; + gap: 28px; + padding: clamp(28px, 3vw, 44px); + border: 1px solid var(--grafite); + background: var(--nero); +} + +.field-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 28px; +} + +@media (max-width: 640px) { + .field-row { + grid-template-columns: 1fr; + } +} + +.field { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.field > span, +.field legend { + padding: 0; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--fumo); +} + +.field legend { + margin-bottom: 12px; +} + +.field input, +.field textarea { + width: 100%; + padding: 10px 0 12px; + border: 0; + border-bottom: 1px solid var(--grafite); + border-radius: 0; + background: transparent; + color: var(--bianco); + font: inherit; + font-size: 16px; + outline: none; + transition: border-color 0.3s var(--ease); + resize: vertical; +} + +.field input:focus, +.field textarea:focus { + border-color: var(--rosso); +} + +.field input::placeholder { + color: var(--piombo); +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip { + position: relative; +} + +.chip input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + pointer-events: none; +} + +.chip span { + display: inline-flex; + align-items: center; + height: 32px; + padding: 0 14px; + border: 1px solid var(--grafite); + border-radius: 9999px; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--fumo); + cursor: pointer; + transition: + border-color 0.3s var(--ease), + color 0.3s var(--ease); +} + +.chip:hover span { + color: var(--bianco); +} + +.chip input:checked + span { + border-color: var(--bianco); + color: var(--bianco); +} + +.chip input:focus-visible + span { + outline: 1px solid var(--rosso); + outline-offset: 3px; +} + +.contact-form .btn { + align-self: flex-start; +} + +.form-note { + margin: -8px 0 0; + font-size: 12px; + color: var(--piombo); +} diff --git a/src/app/industries/[slug]/page.tsx b/src/app/industries/[slug]/page.tsx new file mode 100644 index 0000000..3ae2751 --- /dev/null +++ b/src/app/industries/[slug]/page.tsx @@ -0,0 +1,44 @@ +import { Suspense } from "react"; +import { notFound } from "next/navigation"; +import ContentPage from "@/components/content/content-page"; +import { getIndustry } from "@/lib/pages"; +import { pageMetadata } from "@/lib/metadata"; +import { industryNav } from "@/lib/routes"; + +type Params = PageProps<"/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); + return page ? pageMetadata(page) : {}; +} + +export default function Page({ params }: PageProps<"/industries/[slug]">) { + return ( + }> + + + ); +} + +async function Content({ params }: { params: Params }) { + const { slug } = await params; + const page = getIndustry(slug); + if (!page) notFound(); + const name = industryNav.find((s) => s.slug === slug)?.name ?? slug; + + return ( + + ); +} diff --git a/src/app/industries/page.tsx b/src/app/industries/page.tsx new file mode 100644 index 0000000..8611374 --- /dev/null +++ b/src/app/industries/page.tsx @@ -0,0 +1,19 @@ +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 index fbf03c7..050396c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,9 @@ +import { Suspense } from "react"; import type { Metadata, Viewport } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; +import SiteHeader from "@/components/site-header"; +import SiteFooter from "@/components/site-footer"; const inter = Inter({ variable: "--font-inter", @@ -13,7 +16,10 @@ const description = export const metadata: Metadata = { metadataBase: new URL("https://www.aigrowthtechnologies.com"), - title: "AI Growth Technologies | Digital Growth for the AI Era", + title: { + default: "AI Growth Technologies | Digital Growth for the AI Era", + template: "%s", + }, description, alternates: { canonical: "/" }, openGraph: { @@ -32,8 +38,18 @@ export const viewport: Viewport = { export default function RootLayout({ children }: LayoutProps<"/">) { return ( - - {children} + + + + Skip to content + + {/* The header reads the pathname, which is request data on dynamic routes. */} + + + +
{children}
+ + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index f7687bb..d3e1475 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,13 +1,13 @@ import type { CSSProperties, ReactNode } from "react"; -import SiteHeader from "@/components/site-header"; import GlobeCanvas from "@/components/globe-canvas"; -import RevealObserver from "@/components/reveal-observer"; import StatCounter from "@/components/stat-counter"; +import RevealObserver from "@/components/reveal-observer"; import Capabilities from "@/components/capabilities"; import QueryTicker from "@/components/query-ticker"; import ProductVisual from "@/components/product-visuals"; -import { LogoStacked } from "@/components/logo"; -import { stats, products, journeySteps, gaps, footerColumns } from "@/content/home"; +import Link from "next/link"; +import { stats, products, journeySteps, gaps } from "@/content/home"; +import { hrefFor } from "@/lib/routes"; type Vars = CSSProperties & Record<`--${string}`, string | number>; const delay = (ms: number) => ({ "--d": `${ms}ms` }) as Vars; @@ -26,13 +26,6 @@ function Eyebrow({ index, children }: { index?: string; children: ReactNode }) { export default function Home() { return ( <> - - Skip to content - - - - -
{/* ───────────────────────── Hero ───────────────────────── */}
@@ -61,12 +54,12 @@ export default function Home() {

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

@@ -191,9 +184,9 @@ export default function Home() {

{b}

))} - + {p.cta} - + ))} @@ -249,9 +242,9 @@ export default function Home() {

Our SEO and LLMO work connects both sides of that shift.

- + Explore AI Visibility - + @@ -305,9 +298,9 @@ export default function Home() { It is about knowing where the next dollar has the best chance of doing something useful.

- + Explore Our Approach - +
@@ -390,79 +383,14 @@ export default function Home() {

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

-
- {/* ───────────────────────── Footer ───────────────────────── */} -
-
-
-
- -

- Paid media, SEO, AI visibility, web, attribution, and technology — connected into one accountable - digital growth system. -

- -
-
- {footerColumns.map((col) => ( -
-

{col.title}

-
    - {col.links.map((l) => ( -
  • - {l} -
  • - ))} -
-
- ))} -
-
- -
- © 2026 AI Growth Technologies. All rights reserved. - -
-
-
+ ); } diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..0eecae2 --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,6 @@ +import type { MetadataRoute } from "next"; +import { SITE_URL } from "@/lib/routes"; + +export default function robots(): MetadataRoute.Robots { + return { rules: { userAgent: "*", allow: "/" }, sitemap: `${SITE_URL}/sitemap.xml` }; +} diff --git a/src/app/services/[slug]/page.tsx b/src/app/services/[slug]/page.tsx new file mode 100644 index 0000000..56b3145 --- /dev/null +++ b/src/app/services/[slug]/page.tsx @@ -0,0 +1,44 @@ +import { Suspense } from "react"; +import { notFound } from "next/navigation"; +import ContentPage from "@/components/content/content-page"; +import { getService } from "@/lib/pages"; +import { pageMetadata } from "@/lib/metadata"; +import { serviceNav } from "@/lib/routes"; + +type Params = PageProps<"/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); + return page ? pageMetadata(page) : {}; +} + +export default function Page({ params }: PageProps<"/services/[slug]">) { + return ( + }> + + + ); +} + +async function Content({ params }: { params: Params }) { + const { slug } = await params; + const page = getService(slug); + if (!page) notFound(); + const name = serviceNav.find((s) => s.slug === slug)?.name ?? slug; + + return ( + + ); +} diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx new file mode 100644 index 0000000..f73bb47 --- /dev/null +++ b/src/app/services/page.tsx @@ -0,0 +1,19 @@ +import ContentPage from "@/components/content/content-page"; +import { pages } from "@/lib/pages"; +import { pageMetadata } from "@/lib/metadata"; + +const page = pages.services; + +export const metadata = pageMetadata(page); + +export default function ServicesPage() { + return ( + + ); +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..654f000 --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,15 @@ +import type { MetadataRoute } from "next"; +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}`), + ]; + return paths.map((p) => ({ url: SITE_URL + p, priority: p === "" ? 1 : p.split("/").length === 2 ? 0.8 : 0.6 })); +} diff --git a/src/components/capabilities.tsx b/src/components/capabilities.tsx index 7c198c1..41cf713 100644 --- a/src/components/capabilities.tsx +++ b/src/components/capabilities.tsx @@ -1,7 +1,9 @@ "use client"; import { useState } from "react"; +import Link from "next/link"; import { capabilities } from "@/content/home"; +import { hrefFor } from "@/lib/routes"; const pad = (i: number) => String(i + 1).padStart(2, "0"); @@ -77,9 +79,9 @@ export default function Capabilities() {

{p}

))} - + {c.cta} → - + ))} @@ -97,9 +99,9 @@ export default function Capabilities() {

{p}

))} - + {cap.cta} → - + diff --git a/src/components/content/contact-form.tsx b/src/components/content/contact-form.tsx new file mode 100644 index 0000000..9e59df4 --- /dev/null +++ b/src/components/content/contact-form.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { CONTACT_EMAIL, serviceNav } from "@/lib/routes"; + +const interests = [...serviceNav.map((s) => s.name), "Not sure yet"]; + +// No form backend is wired up yet, so submitting composes an email to the team +// in the visitor's mail client. Swap `onSubmit` for a real endpoint when one exists. +export default function ContactForm() { + const [sent, setSent] = useState(false); + + function onSubmit(e: FormEvent) { + e.preventDefault(); + const data = new FormData(e.currentTarget); + const get = (k: string) => String(data.get(k) ?? "").trim(); + const subject = `Growth conversation — ${get("company") || get("name")}`; + const body = [ + `Name: ${get("name")}`, + `Email: ${get("email")}`, + `Company: ${get("company")}`, + `Website: ${get("website")}`, + `Interested in: ${data.getAll("interest").join(", ") || "—"}`, + "", + get("message"), + ].join("\n"); + window.location.href = `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; + setSent(true); + } + + return ( +
+
+ + +
+
+ + +
+
+ What are you looking at? +
+ {interests.map((i) => ( + + ))} +
+
+