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>
This commit is contained in:
@@ -0,0 +1,549 @@
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user