Files
aigrowthtech/src/components/content/content-page.tsx
T
Alejandro Veras b8cf7cfbcb Build the full site from the copy decks
Adds About, Contact, Services (index + 8) and Industries (index + 7),
generated from structured JSON converted out of the .docx copy decks
by scripts/convert-docs.py. Shared header with dropdowns, footer,
contact form (mailto until a backend exists), FAQ/breadcrumb JSON-LD,
sitemap and robots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:54:50 -04:00

550 lines
17 KiB
TypeScript

import type { CSSProperties, ReactNode } from "react";
import Link from "next/link";
import GlobeCanvas from "@/components/globe-canvas";
import StatCounter from "@/components/stat-counter";
import RevealObserver from "@/components/reveal-observer";
import ContactForm from "./contact-form";
import type { Item, Page, Section } from "@/lib/pages";
import { SITE_URL, hrefFor } from "@/lib/routes";
type Vars = CSSProperties & Record<`--${string}`, string | number>;
const delay = (ms: number) => ({ "--d": `${ms}ms` }) as Vars;
const pad = (n: number) => String(n).padStart(2, "0");
export type Crumb = { name: string; href: string };
const Arrow = () => <span className="arrow">→</span>;
function Eyebrow({ index, children }: { index?: string; children: ReactNode }) {
return (
<p className="eyebrow">
{index && <span className="idx">{index}</span>}
{children}
</p>
);
}
/** "> " marks a sentence that was bold in the copy deck. */
function Para({ text, className }: { text: string; className?: string }) {
if (text.startsWith("> ")) return <p className="emph">{text.slice(2)}</p>;
return <p className={className}>{text}</p>;
}
function Paras({ items, firstClass }: { items: string[]; firstClass?: string }) {
return (
<>
{items.map((t, i) => (
<Para key={i} text={t} className={i === 0 ? firstClass : undefined} />
))}
</>
);
}
function CtaLinks({ ctas, path }: { ctas: string[]; path: string }) {
if (!ctas.length) return null;
return (
<div className="cta-row">
{ctas.map((c) => (
<Link key={c} href={hrefFor(c, path)} className="arrow-link">
{c} <Arrow />
</Link>
))}
</div>
);
}
function SectionHead({ section, index }: { section: Section; index: number }) {
const isFaq = section.kind === "faq";
const eyebrow = isFaq && !section.title ? null : section.eyebrow;
const title = section.title ?? (isFaq ? "Frequently Asked Questions" : null);
return (
<div className="section-head">
<div className="title" data-reveal>
{eyebrow && <Eyebrow index={pad(index)}>{eyebrow}</Eyebrow>}
{title && <h2 className="display h2">{title}</h2>}
</div>
</div>
);
}
function Intro({ section }: { section: Section }) {
const { intro } = section;
if (!intro.length) return null;
if (intro.length === 1) {
return (
<div className="split" data-reveal>
<div className="lead">
<Para text={intro[0]} />
</div>
<div />
</div>
);
}
return (
<div className="split">
<div className="lead" data-reveal>
<Para text={intro[0]} />
</div>
<div className="copy" data-reveal style={delay(140)}>
<Paras items={intro.slice(1)} firstClass="hl" />
</div>
</div>
);
}
function Outro({ section, path }: { section: Section; path: string }) {
if (!section.outro.length && !section.ctas.length) return null;
return (
<div className="outro" data-reveal>
{section.outro.length > 0 && (
<div className="outro-copy">
<Paras items={section.outro} />
</div>
)}
<CtaLinks ctas={section.ctas} path={path} />
</div>
);
}
function ItemList({ list }: { list: string[] }) {
if (!list.length) return null;
return (
<ul className="tick-list">
{list.map((l) => (
<li key={l}>{l}</li>
))}
</ul>
);
}
function columnsFor(items: Item[]) {
const n = items.length;
const avg = items.reduce((a, it) => a + it.body.join(" ").length, 0) / Math.max(n, 1);
if (n === 2 || (n === 4 && avg > 240)) return 2;
if (n === 4) return 4;
return 3;
}
/* ───────────── Section variants ───────────── */
function TextSection({ section, path }: { section: Section; path: string }) {
return (
<>
<Intro section={section} />
{section.ctas.length > 0 && (
<div className="split">
<div />
<div data-reveal>
<CtaLinks ctas={section.ctas} path={path} />
</div>
</div>
)}
</>
);
}
function CardsSection({ section, path }: { section: Section; path: string }) {
const cols = columnsFor(section.items);
return (
<>
<Intro section={section} />
<div className="card-grid" style={{ "--cols": cols } as Vars}>
{section.items.map((it, i) => (
<article key={it.title} className="card" data-reveal style={delay((i % cols) * 90)}>
<span className="card-num">{pad(i + 1)}</span>
<h3 className="card-title">{it.title}</h3>
{it.subtitle && <p className="card-sub">{it.subtitle}</p>}
{it.body.length > 0 && (
<div className="copy">
<Paras items={it.body} firstClass="hl" />
</div>
)}
<ItemList list={it.list} />
{it.cta && (
<Link href={hrefFor(it.cta, path)} className="arrow-link">
{it.cta} <Arrow />
</Link>
)}
</article>
))}
</div>
<Outro section={section} path={path} />
</>
);
}
function StepsSection({ section, path }: { section: Section; path: string }) {
return (
<>
<Intro section={section} />
<ol className="steps">
{section.items.map((it, i) => (
<li key={it.title} className="step" data-reveal style={delay(i * 70)}>
<span className="step-num">{it.num ?? pad(i + 1)}</span>
<h3 className="step-title">{it.title}</h3>
<div className="copy">
<Paras items={it.body} firstClass="hl" />
</div>
</li>
))}
</ol>
<Outro section={section} path={path} />
</>
);
}
function FaqSection({ section, path }: { section: Section; path: string }) {
return (
<>
<Intro section={section} />
<div className="faq">
{section.items.map((it, i) => (
<details key={it.title} className="faq-item" data-reveal style={delay(i * 50)} open={i === 0}>
<summary>
<span>{it.title}</span>
<i aria-hidden="true" />
</summary>
<div className="copy">
<Paras items={it.body} />
</div>
</details>
))}
</div>
<Outro section={section} path={path} />
</>
);
}
function LinksSection({ section, path }: { section: Section; path: string }) {
const cols = columnsFor(section.items);
return (
<>
<Intro section={section} />
<div className="card-grid link-grid" style={{ "--cols": cols } as Vars}>
{section.items.map((it, i) => (
<Link
key={it.title}
href={hrefFor(it.cta ?? "", path)}
className="card link-card"
data-reveal
style={delay((i % cols) * 90)}
>
<span className="card-num">{pad(i + 1)}</span>
<h3 className="card-title">{it.title}</h3>
<div className="copy">
<Paras items={it.body} firstClass="hl" />
</div>
<span className="arrow-link">
{it.cta} <Arrow />
</span>
</Link>
))}
</div>
<Outro section={section} path={path} />
</>
);
}
function StatsSection({ section, path }: { section: Section; path: string }) {
return (
<>
<Intro section={section} />
<div className="stats">
{section.items.map((it, i) => {
const m = it.title.match(/^([\d.,]+)(.*)$/);
const value = m ? Number(m[1].replace(/,/g, "")) : 0;
return (
<div key={it.title} className="stat" data-reveal style={delay(i * 120)}>
<StatCounter value={value} suffix={m ? m[2] : ""} />
<div className="stat-label">{it.subtitle}</div>
<div className="copy stat-copy">
<Paras items={it.body} />
</div>
</div>
);
})}
</div>
<Outro section={section} path={path} />
</>
);
}
function AddressLines({ text }: { text: string }) {
return (
<address className="office-address">
{text.split("\n").map((line) => {
const [label, ...rest] = line.split(":");
const value = rest.join(":").trim();
if (/^phone$/i.test(label.trim()) && value) {
return (
<span key={line}>
<em>Phone</em>
<a href={`tel:${value.replace(/[^\d+]/g, "")}`}>{value}</a>
</span>
);
}
if (/^email$/i.test(label.trim()) && value) {
return (
<span key={line}>
<em>Email</em>
<a href={`mailto:${value}`}>{value}</a>
</span>
);
}
return <span key={line}>{line}</span>;
})}
</address>
);
}
function OfficesSection({ section, path }: { section: Section; path: string }) {
return (
<>
<Intro section={section} />
<div className="offices">
{section.items.map((it, i) => {
const address = it.body.find((b) => b.includes("Phone:"));
const desc = it.body.filter((b) => b !== address);
return (
<article key={it.title} className="office" data-reveal style={delay(i * 100)}>
<div className="office-head">
<h3 className="card-title">{it.title}</h3>
{it.subtitle && <span className="pill">{it.subtitle}</span>}
</div>
<div className="copy">
<Paras items={desc} />
</div>
{address && <AddressLines text={address} />}
</article>
);
})}
</div>
<Outro section={section} path={path} />
</>
);
}
function ShowcaseSection({ section, path }: { section: Section; path: string }) {
return (
<>
<Intro section={section} />
<div className="showcase">
{section.items.map((it, i) => {
const [name, tagline] = (it.kicker ?? "").split("|").map((s) => s.trim());
return (
<article key={it.title} className="showcase-row" data-reveal>
<div className="showcase-name">
<span className="card-num">{pad(i + 1)}</span>
<h3>{name}</h3>
{tagline && <p className="card-sub">{tagline}</p>}
</div>
<div className="showcase-body">
<h4 className="display h3">{it.title}</h4>
<div className="copy">
<Paras items={it.body} firstClass="hl" />
</div>
{it.cta && (
<Link href={hrefFor(it.cta, path)} className="arrow-link">
{it.cta} <Arrow />
</Link>
)}
</div>
<ItemList list={it.list} />
</article>
);
})}
</div>
<Outro section={section} path={path} />
</>
);
}
function FormSection({ section, path }: { section: Section; path: string }) {
return (
<div className="form-layout">
<div className="form-copy">
<div className="lead" data-reveal>
<Para text={section.intro[0] ?? ""} />
</div>
<div className="copy" data-reveal style={delay(120)}>
<Paras items={section.intro.slice(1)} />
</div>
<CtaLinks ctas={section.ctas} path={path} />
</div>
<div data-reveal style={delay(200)}>
<ContactForm />
</div>
</div>
);
}
const variants: Record<string, (p: { section: Section; path: string }) => ReactNode> = {
text: TextSection,
cards: CardsSection,
steps: StepsSection,
faq: FaqSection,
links: LinksSection,
stats: StatsSection,
offices: OfficesSection,
showcase: ShowcaseSection,
};
/* ───────────── Hero & closing CTA ───────────── */
function PageHero({ page, crumbs }: { page: Page; crumbs: Crumb[] }) {
const { hero, path } = page;
const [name, tagline] = (hero.eyebrow ?? "").split("|").map((s) => s.trim());
return (
<section className="hero page-hero" aria-labelledby="page-title">
<GlobeCanvas />
<div className="wrap hero-inner">
<nav className="crumbs" aria-label="Breadcrumb" data-reveal>
{crumbs.map((c, i) =>
i < crumbs.length - 1 ? (
<span key={c.href}>
<Link href={c.href}>{c.name}</Link>
<i aria-hidden="true">/</i>
</span>
) : (
<span key={c.href} aria-current="page">
{c.name}
</span>
),
)}
</nav>
<div data-reveal style={delay(60)}>
<Eyebrow>
{tagline ? (
<>
<span className="idx">{name}</span> {tagline}
</>
) : (
name
)}
</Eyebrow>
</div>
<h1 id="page-title" className="display h1" data-reveal style={delay(120)}>
{hero.title}
</h1>
<div className="hero-grid">
<div className="hero-lead lead" data-reveal style={delay(240)}>
<Para text={hero.intro[0] ?? ""} />
</div>
<div className="hero-body" data-reveal style={delay(340)}>
{hero.intro.length > 1 && (
<div className="copy">
<Paras items={hero.intro.slice(1)} />
</div>
)}
<div className="hero-actions">
{hero.ctas.map((c, i) => (
<Link key={c} href={hrefFor(c, path)} className={`btn ${i === 0 ? "btn-solid" : "btn-ghost"}`}>
{c} <Arrow />
</Link>
))}
</div>
</div>
</div>
</div>
</section>
);
}
function ClosingCta({ section, path }: { section: Section; path: string }) {
const [lead, ...rest] = section.intro;
return (
<section className="cta" aria-label={section.title ?? undefined}>
<GlobeCanvas layout="center" />
<div className="wrap">
{section.eyebrow && (
<div data-reveal>
<Eyebrow>{section.eyebrow}</Eyebrow>
</div>
)}
<h2 className="display h2" data-reveal style={delay(120)}>
{section.title}
</h2>
{lead && (
<div className="copy" data-reveal style={delay(240)}>
<Paras items={[lead, ...rest]} />
</div>
)}
{section.ctas.length > 0 && (
<div data-reveal style={delay(360)}>
{section.ctas.map((c) => (
<Link key={c} href={hrefFor(c, path)} className="btn btn-solid">
{c} <Arrow />
</Link>
))}
</div>
)}
</div>
</section>
);
}
/* ───────────── Structured data ───────────── */
function JsonLd({ page, crumbs }: { page: Page; crumbs: Crumb[] }) {
const faqs = page.sections.filter((s) => s.kind === "faq").flatMap((s) => s.items);
const graph: object[] = [
{
"@type": "BreadcrumbList",
itemListElement: crumbs.map((c, i) => ({
"@type": "ListItem",
position: i + 1,
name: c.name,
item: SITE_URL + (c.href === "/" ? "" : c.href),
})),
},
];
if (faqs.length) {
graph.push({
"@type": "FAQPage",
mainEntity: faqs.map((f) => ({
"@type": "Question",
name: f.title,
acceptedAnswer: { "@type": "Answer", text: f.body.join(" ") },
})),
});
}
const json = JSON.stringify({ "@context": "https://schema.org", "@graph": graph }).replace(/</g, "\\u003c");
return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: json }} />;
}
/* ───────────── Page ───────────── */
export default function ContentPage({ page, crumbs }: { page: Page; crumbs: Crumb[] }) {
const last = page.sections[page.sections.length - 1];
const closing = last && last.kind === "text" && last.ctas.length > 0 ? last : null;
const body = closing ? page.sections.slice(0, -1) : page.sections;
return (
<>
<JsonLd page={page} crumbs={crumbs} />
<PageHero page={page} crumbs={crumbs} />
{body.map((section, i) => {
const Variant = variants[section.kind] ?? TextSection;
return (
<section
key={i}
id={section.form ? "contact-form" : undefined}
className={`section${i % 2 === 0 ? " section-night" : ""} kind-${section.kind}`}
>
<div className="wrap">
<SectionHead section={section} index={i + 1} />
{section.form ? (
<FormSection section={section} path={page.path} />
) : (
<Variant section={section} path={page.path} />
)}
</div>
</section>
);
})}
{closing && <ClosingCta section={closing} path={page.path} />}
<RevealObserver />
</>
);
}