Files
hanzo.ai/components/product/ProductLanding.tsx
zeekay bba4e5ca40 A product page ends on one call: the block above the closing keeps the docs and the neighbours and drops its own Get your API key
Every product page ended on "Start building with X" and a filled Get your
API key, then one screen later on the layout's "Build what's next." and a
filled Try Hanzo, in a different button shape: two closing calls a reader
had to choose between. StartBuilding becomes Related: the product's docs
link and its related capabilities, no heading and no button, so the
closing call below is the one way in. And the open-source row no longer
shows a "0 stars" pill, which read as an abandoned repository.
2026-10-18 16:25:16 -07:00

903 lines
34 KiB
TypeScript

'use client'
/**
* ProductLanding — the ONE shared kit every featured Hanzo product page renders
* through, so the detailed pages that the apex nav now links into (on hanzo.ai
* itself) look first-class and identical in style to the flagship Enso landing.
*
* Content is data — each page passes ONE typed props object; this file owns the
* layout. Honest by construction: pass only real copy, real links, and real
* (measured) stats — never fabricated benchmarks.
*
* TWO BLOCKS ARE THE KIT'S RATHER THAN A PAGE'S: `AgentPrompt` under the hero
* and `Related` last. Both take the product `lib/product` resolves from
* the ROUTE, so no page states them, none can state them differently, and the
* command and the docs link each carries are the audited ones or nothing.
*
* NO CLASSES AND NO STYLE OBJECTS. The page shape comes from
* `components/marketing/band`'s `Band`, `Head` and `Card` — the rule, the
* rhythm, the gutter, the centred head and the tile — the type and the
* controls from @hanzo/ui's marketing components, and everything left is gui props
* over @hanzo/design's tokens.
*
* RADIUS MAPS BY NAME: `rounded-lg` IS `var(--radius-lg)`, `rounded-2xl` IS
* `var(--radius-2xl)`. The utility compiles to a bare alias of the token, and
* the VALUE behind that token is @hanzo/design's rather than Tailwind's —
* `app/globals.css` imports `tokens/radius.css` with no `layer(...)`, so its
* `:root` beats Tailwind's `@layer theme` defaults at any specificity and
* regardless of order. Both declare `--radius-2xl`; design's 1.5rem wins over
* Tailwind's 1rem.
*
* The `/* rounded-md *\/`-style comments in `radius.css` say which Tailwind
* name a rung was DESIGNED to serve. They are a design note, not the emitted
* mapping — reading them as the mapping is what produces an off-by-one that
* shrinks every corner on fifteen pages.
*
* `rounded-3xl` is the one exception, and only because @hanzo/design does not
* define `--radius-3xl` at all: that one really does fall through to Tailwind's
* theme, at 1.5rem.
*/
import type { ComponentProps, ReactNode } from 'react'
import { motion } from '@/components/motion'
import { ArrowRight, ArrowUpRight, Check } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import Link from 'next/link'
import { useRoute } from '@/components/shell/route'
import { AgentPrompt } from '@/components/product/AgentPrompt'
import { ProductJsonLd } from '@/components/product/ProductJsonLd'
import { Related } from '@/components/product/Related'
import { Surface } from '@/components/product/Surface'
import { at } from '@/lib/product'
import { getPrimitive, getPrimitiveCategory } from '@/lib/data/cloud-primitives'
import { Box, Text, View, XStack } from '@hanzo/ui'
/* Under another name because this file already EXPORTS a type called `Grid` —
the titled set of cards — and that name is caller-visible. */
import { Grid as Columns } from '@hanzo/ui/grid'
import { Band, Card, Head } from '@/components/marketing/band'
import { Action, Chip, Claim, Display, Eyebrow, Lede, More, Title } from '@hanzo/ui/marketing'
const fade = {
initial: { opacity: 0, y: 20 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true },
}
export interface CTA {
label: string
href: string
icon?: LucideIcon
}
export interface Pillar {
icon: LucideIcon
title: string
body: string
}
/**
* One card. The icon, the body, the list and the link are each optional because
* pages genuinely differ on them — a chain list is a title and its members, a
* use case is a title and a paragraph, an exchange product is all four — and a
* second card component for each combination is how a kit stops being one.
*/
export interface Feature {
icon?: LucideIcon
title: string
/** Markup where the sentence is markup: a command belongs in `code`. */
body?: ReactNode
/** What the card holds, one per line. */
points?: string[]
/**
* Where the card goes. WITH a `label` that is a link inside the card, in
* words the reader can see; WITHOUT one the whole card is the link and the
* page gains no words it did not have. The arity is the difference, so
* there is one field rather than one per treatment.
*/
link?: { href: string; label?: string; icon?: LucideIcon }
}
export interface Stat {
icon?: LucideIcon
value: string
label: string
sub?: string
}
export interface Step {
n: string
title: string
/** Markup, for the same reason a Feature's body is: a step can BE a command. */
body: ReactNode
icon?: LucideIcon
}
/**
* A plan, as a rate card states one. `price` is a STRING because "Custom" is a
* price a reader understands and a number is not: the moment this takes a
* number it has to grow a way to say the thing that is not one.
*/
export interface Plan {
name: string
price: string
/** "mo" — rendered after a slash, and absent on a plan that is not periodic. */
period?: string
points: string[]
cta: CTA
/** The one plan the page recommends. */
pick?: boolean
}
export interface SectionCopy {
/** Optional: a section that never had one is not given one here. */
eyebrow?: string
/** A heading may be markup: one names a CLI verb and sets it in code. */
title?: ReactNode
sub?: string
/** One link under the section — "View all X", where the cards are a sample. */
link?: CTA
}
/** A titled grid of cards. A page has as many as it has things to say. */
export type Grid = SectionCopy & { items: Feature[] }
export interface ProductLandingProps {
/** Hero */
badge?: string
badgeIcon?: LucideIcon
title: ReactNode
/**
* One line between the title and the lede — what the product IS, where the
* lede says what it does. Optional: most products say it in the lede.
*/
tagline?: string
lede: ReactNode
/** First CTA renders as the solid white primary; the rest as neutral outlines. */
ctas: CTA[]
note?: { icon?: LucideIcon; text: string }
availableThrough?: string[]
/**
* Catalog slug of the product whose surface this page draws.
*
* ONE STRING, because the catalog already answers everything else: the name
* in the titlebar, the route in the footer, and the corner of the console the
* product sits in. A slug the catalog has dropped draws nothing rather than
* an empty window.
*/
surface?: string
/**
* What-it-is — numbered pillar grid. Optional: a page whose first section is
* a plain card grid has no pillars, and numbering its cards would be writing
* content it does not have.
*/
what?: SectionCopy & { pillars: Pillar[] }
/**
* The titled card grids, in order. One field with an arity — a page saying one
* thing passes the grid, a page saying five passes five — rather than a second
* name for the same section repeated.
*/
features?: Grid | Grid[]
/** How-it-works — numbered steps. */
steps?: SectionCopy & { items: Step[] }
/**
* A set of short names. A grid of cards over-dresses a list of chain names or
* of site kinds, and a card per word is how a page comes to look like it is
* saying more than it is.
*/
chips?: SectionCopy & { items: string[] }
/** The rate card. */
plans?: SectionCopy & { items: Plan[]; footnote?: string }
/**
* Measured stats row. `hero` puts it under the lede, which is where a page
* whose numbers ARE the promise wants it; otherwise it is its own section.
*/
stats?: Partial<SectionCopy> & { items: Stat[]; footnote?: string; hero?: boolean }
/** Terminal-style code block. */
code?: {
/** An anchor, where a control above sends the reader here ("#install"). */
id?: string
head?: SectionCopy
/** The language, shown in the terminal chrome when no file is named. */
lang?: string
/** A file name, which is what these pages label the frame with. */
name?: string
/** The sample, highlighted at build: a `<Code>` (components/code) from the
server page. A transcript passes its own markup in a `<Session>`. */
source: ReactNode
ctas?: CTA[]
}
/**
* The card that closes a page, where the page has a close of its own to make.
*
* OPTIONAL, because `Related` and the site's closing call render under it: a
* page whose card said only "read the docs" and "get a key" was saying what
* the blocks below it say, twice. Pass one only where it
* carries something that block does not — a link into the product itself, or
* a sentence the page needs last. `sub` is optional too: several pages' card
* is a heading and its buttons.
*/
finalCta?: {
/** A word above the heading — usually the product's own name. */
eyebrow?: string
title: string
sub?: ReactNode
buttons: CTA[]
icon?: LucideIcon
}
/**
* Sections only one product has, rendered after the shared ones and before
* the final CTA.
*
* The kit describes what every product page shares; it is not a ceiling. A
* product with more to say than the shared shape holds — Team has an AI
* coworker gallery, the people who lead it, and a compliance surface — used
* to have to stop using the kit to say it, which is how a page ends up
* looking like it belongs to a different site. Handing those sections in
* here keeps one standard for the parts that ARE shared.
*
* It goes before the final CTA rather than after because a call to action
* that is not last is not final: anything following it reads as content the
* reader was asked to leave for.
*/
children?: ReactNode
}
/* ── The card, and the two ways this page draws one ───────────────────────────
A card is `components/marketing/band`'s `Card` on this page's ground —
the muted surface at half strength (neutral-900 on dark, a pale grey on light),
a token mix gui's colour type does not take, so
it rides `$platform-web`. Every card is the animated child of a stagger: the
motion element carries the entrance and nothing else, and the card inside it
fills the cell. */
const GROUND = 'color-mix(in oklab, var(--muted) 50%, transparent)'
function Tile({ children, ...p }: ComponentProps<typeof Card>) {
return (
<Card height="100%" bg={undefined} {...p} $platform-web={{ backgroundColor: GROUND, ...p['$platform-web'] }}>
{children}
</Card>
)
}
/** The link an address names: the router's for a path on this site, a plain anchor anywhere else. */
function to(href: string) {
return href.startsWith('/') ? <Link href={href} /> : <a href={href} />
}
/**
* A card that IS a link: the hover is the whole reason it is a component — the
* edge brightens on approach. The corner is the unlinked card's, so the two
* halves of one row round alike.
*/
function LinkTile({ href, children }: { href: string; children: ReactNode }) {
return (
<Box
render={to(href)}
display="block"
height="100%"
p={28}
borderWidth={1}
borderColor="var(--border)"
rounded="var(--radius-2xl)"
hoverStyle={{ borderColor: 'var(--border-strong)' }}
$platform-web={{ backgroundColor: GROUND, textDecoration: 'none' }}
>
{children}
</Box>
)
}
/** A card's own heading and its paragraph — not a section head, so not a role. `panel` is one rung up, where a pillar or a step names itself. */
function CardTitle({ panel, children }: { panel?: boolean; children: ReactNode }) {
return (
<Text
render="h3"
display="block"
mb={8}
fontSize={panel ? '$4' : '$3'}
lineHeight={panel ? '1.4583rem' : '1.3125rem'}
fontWeight="600"
color="var(--foreground)"
>
{children}
</Text>
)
}
function CardBody({ children }: { children: ReactNode }) {
return (
<Text render="p" display="block" fontSize="$2" lineHeight="1.3203rem" color="var(--muted-foreground)">
{children}
</Text>
)
}
/** How narrow a column may get before a grid drops one, per shape.
*
* `Fit` is ONE number and a cap, so it cannot reproduce a pair of breakpoints
* exactly: `md:grid-cols-2 lg:grid-cols-3` asks for 348px columns at 768 and
* 304px ones at 1024, and no single floor is both. Each value below is chosen
* to land the WIDEST step on the breakpoint it had — 3-up at 1024, 3-up at 768
* — so a desktop is unchanged and the narrower steps arrive a little earlier. */
const DECK = { min: 304, max: 3 } as const
const TRIO = { min: 224, max: 3 } as const
const ROW = { min: 200, max: 4 } as const
/** A section with nothing to say above its cards renders no head at all. */
function SectionHead({ eyebrow, title, sub }: SectionCopy) {
return <Head eyebrow={eyebrow} title={title} lede={sub} />
}
/** The link that follows a set of cards, when the cards are a sample of more. */
function SectionLink({ cta }: { cta: CTA }) {
const Icon = cta.icon ?? ArrowRight
return (
<motion.div {...fade} transition={{ duration: 0.5 }}>
<XStack mt={32} justify="center">
<More href={cta.href}>
{cta.label} <Icon width={16} height={16} />
</More>
</XStack>
</motion.div>
)
}
/**
* The plate a card's mark sits on. `Disc` in components/marketing/band is the
* same idea as a circle; these pages draw a rounded square, at two sizes.
*/
function Plate({ size, mark: Mark, mb }: { size: number; mark: LucideIcon; mb?: number }) {
/* Half the plate, which is the proportion both sizes already drew: 20 in 40,
24 in 44 was 22 rounded to the even pixel a 24-unit viewBox renders on. */
const mark = size === 40 ? 20 : 24
return (
<XStack
width={size}
height={size}
mb={mb}
items="center"
justify="center"
rounded="var(--radius-lg)"
bg="$raised"
>
<Mark width={mark} height={mark} color="var(--foreground)" />
</XStack>
)
}
/**
* One titled grid. The card carries whichever of icon/body/points/link it was
* given — there is one card on this site, and it has optional parts.
*/
export function Cards({ grid, index = 0 }: { grid: Grid; index?: number }) {
return (
<Band key={index}>
<SectionHead eyebrow={grid.eyebrow} title={grid.title} sub={grid.sub} />
<Columns columns={DECK} gap={24}>
{grid.items.map((f, i) => {
const LinkIcon = f.link?.icon ?? ArrowRight
const card = (
<>
{f.icon && <Plate size={40} mark={f.icon} mb={16} />}
<CardTitle>{f.title}</CardTitle>
{f.body && <CardBody>{f.body}</CardBody>}
{f.points && f.points.length > 0 && (
<Box mt={16}>
<CheckList items={f.points} muted />
</Box>
)}
{f.link?.label && (
<More href={f.link.href} mt={16}>
{f.link.label} <LinkIcon width={16} height={16} />
</More>
)}
</>
)
return (
<motion.div key={f.title} {...fade} transition={{ duration: 0.5, delay: i * 0.05 }}>
{f.link && !f.link.label ? <LinkTile href={f.link.href}>{card}</LinkTile> : <Tile>{card}</Tile>}
</motion.div>
)
})}
</Columns>
{grid.link && <SectionLink cta={grid.link} />}
</Band>
)
}
/** A row of short names, each in an outline on the page's ground. */
function Chips({ copy }: { copy: SectionCopy & { items: string[] } }) {
return (
<Band measure={896}>
<SectionHead eyebrow={copy.eyebrow} title={copy.title} sub={copy.sub} />
<motion.div {...fade} transition={{ duration: 0.5 }}>
<XStack flexWrap="wrap" justify="center" gap={12}>
{copy.items.map((s) => (
<Chip
key={s}
px={16}
py={8}
bg={undefined}
$theme-light={{ borderColor: 'var(--border)', color: 'var(--muted-foreground)' }}
$platform-web={{ backgroundColor: GROUND }}
>
{s}
</Chip>
))}
</XStack>
</motion.div>
{copy.link && <SectionLink cta={copy.link} />}
</Band>
)
}
/** A footnote under a set of cards: centred, a measure wide. `quiet` is the stats' smaller, dimmer one. */
function Footnote({ quiet, children }: { quiet?: boolean; children: ReactNode }) {
return (
<Text
render="p"
display="block"
mt={32}
mx="auto"
maxW={672}
text="center"
fontSize={quiet ? '$1' : '$2'}
lineHeight={quiet ? '0.9167rem' : '1.1607rem'}
color={quiet ? 'var(--text-tertiary)' : 'var(--muted-foreground)'}
>
{children}
</Text>
)
}
/** The rate card. The picked plan is marked by its BORDER, not by a louder fill. */
function Plans({ copy }: { copy: SectionCopy & { items: Plan[]; footnote?: string } }) {
return (
<Band measure={1024}>
<SectionHead eyebrow={copy.eyebrow} title={copy.title} sub={copy.sub} />
<Columns columns={TRIO} gap={24}>
{copy.items.map((p, i) => (
<motion.div key={p.name} {...fade} transition={{ duration: 0.5, delay: i * 0.06 }}>
<Tile display="flex" flexDirection="column" borderWidth={p.pick ? 2 : 1} borderColor={p.pick ? 'var(--border-selected)' : 'var(--border)'}>
<Text render="h3" display="block" fontSize="$4" lineHeight="1.4583rem" fontWeight="600" color="var(--foreground)">
{p.name}
</Text>
<Text render="div" display="block" mt={4} mb={24} fontSize="$3" lineHeight="1.25rem" color="var(--muted-foreground)">
<Text fontSize="$8" lineHeight="1.95rem" fontWeight="700" color="var(--foreground)">
{p.price}
</Text>
{p.period && <Text color="inherit">/{p.period}</Text>}
</Text>
<Box mb={32} flex={1}>
<CheckList items={p.points} muted />
</Box>
<Action href={p.cta.href} fill={p.pick}>
{p.cta.label} <ArrowRight width={16} height={16} />
</Action>
</Tile>
</motion.div>
))}
</Columns>
{copy.footnote && <Footnote>{copy.footnote}</Footnote>}
</Band>
)
}
/** The stat row, in the hero or in its own section — one grid, two placements. */
function StatGrid({ items }: { items: Stat[] }) {
return (
<Columns columns={ROW} gap={24}>
{items.map((s, i) => (
<motion.div key={s.label} {...fade} transition={{ duration: 0.5, delay: i * 0.05 }}>
<Tile $platform-web={{ textAlign: 'center' }}>
{s.icon && (
<XStack justify="center" mb={16}>
<s.icon width={24} height={24} color="var(--neutral-400)" />
</XStack>
)}
<Text display="block" fontSize="$10" lineHeight="2.2222rem" fontWeight="700" color="var(--foreground)">
{s.value}
</Text>
<Text display="block" mt={4} fontSize="$2" lineHeight="1.1607rem" fontWeight="500" color="var(--text-secondary)">
{s.label}
</Text>
{s.sub && (
<Text display="block" mt={2} fontSize="$1" lineHeight="0.9167rem" color="var(--muted-foreground)">
{s.sub}
</Text>
)}
</Tile>
</motion.div>
))}
</Columns>
)
}
function PrimaryButton({ cta }: { cta: CTA }) {
const Icon = cta.icon ?? ArrowRight
return (
<Action href={cta.href} fill>
{cta.label} <Icon width={16} height={16} />
</Action>
)
}
function OutlineButton({ cta }: { cta: CTA }) {
const Icon = cta.icon
return (
<Action href={cta.href}>
{Icon && <Icon width={16} height={16} />} {cta.label}
</Action>
)
}
function CTARow({ ctas }: { ctas: CTA[] }) {
return (
<XStack flexWrap="wrap" justify="center" gap={16}>
{ctas.map((c, i) => (i === 0 ? <PrimaryButton key={c.label} cta={c} /> : <OutlineButton key={c.label} cta={c} />))}
</XStack>
)
}
/** A line of muted small type under the hero's actions. */
const SMALL = { fontSize: '$2', lineHeight: '1.1607rem', color: 'var(--muted-foreground)' } as const
/* ── The terminal frame ────────────────────────────────────────────────────── */
function Frame({ name, children }: { name: string; children: ReactNode }) {
return (
<Box
overflow="hidden"
rounded="var(--radius-2xl)"
borderWidth={1}
borderColor="var(--border)"
$platform-web={{ backgroundColor: 'color-mix(in oklab, var(--muted) 60%, transparent)' }}
>
<XStack
items="center"
gap={8}
borderBottomWidth={1}
borderColor="var(--border)"
px={16}
py={10}
$platform-web={{ backgroundColor: 'color-mix(in oklab, var(--muted) 80%, transparent)' }}
>
{[0, 1, 2].map((k) => (
<View key={k} render="span" width={10} height={10} rounded="var(--radius-full)" bg="var(--neutral-700)" />
))}
<Text ml={8} fontFamily="$mono" fontSize="$1" lineHeight="0.9167rem" color="var(--muted-foreground)">
{name}
</Text>
</XStack>
{children}
</Box>
)
}
/**
* A terminal session drawn in its own markup — a prompt, a command and what it
* printed — for the pages whose sample is a transcript rather than source. Code
* is a `<Code>` (components/code) and needs none of this.
*
* `whiteSpace` is stated rather than left to the UA: this is the one place on
* the page where a line break is content.
*/
export function Session({ children }: { children: ReactNode }) {
return (
<Text
render="pre"
display="block"
overflowX="auto"
p={20}
my={13}
fontFamily="$mono"
fontSize="$2"
lineHeight="1.3203rem"
whiteSpace="pre"
color="var(--text-secondary)"
>
<code>{children}</code>
</Text>
)
}
/* ── The final card ───────────────────────────────────────────────────────── */
/** One of the two soft lights in the final card's corners. */
function Wash({ corner }: { corner: 'tr' | 'bl' }) {
return (
<Box
position="absolute"
width={256}
height={256}
rounded="var(--radius-full)"
filter="blur(64px)"
{...(corner === 'tr' ? { r: -96, t: -96 } : { b: -96, l: -96 })}
$platform-web={{ backgroundColor: 'color-mix(in oklab, var(--pure-white) 5%, transparent)' }}
/>
)
}
/** What the catalog says about the product a page draws the surface of. */
function surfaceOf(slug?: string) {
if (!slug) return null
const product = getPrimitive(slug)
const category = getPrimitiveCategory(slug)
if (!product || !category) return null
return { title: product.title, api: product.api, rail: category.id, slug }
}
export function ProductLanding(p: ProductLandingProps) {
const Badge = p.badgeIcon
const NoteIcon = p.note?.icon
const FinalIcon = p.finalCta?.icon
const grids: Grid[] = p.features ? (Array.isArray(p.features) ? p.features : [p.features]) : []
const heroStats = p.stats?.hero ? p.stats.items : null
// The catalog answers everything the drawn surface needs, so a page states a
// slug and nothing else. A slug the catalog has dropped draws nothing.
const drawn = surfaceOf(p.surface)
const route = useRoute()
// Which product this is, off the route — so the shared blocks below reach
// every page through the kit rather than through sixty call sites.
const product = at(route)
return (
<Box bg="var(--background)" pt={64} $platform-web={{ color: 'var(--foreground)' }}>
{product && <ProductJsonLd product={product} path={route} description={typeof p.lede === 'string' ? p.lede : undefined} />}
{/* ── Hero ───────────────────────────────────────────────────────────── */}
{/* Its own height on a phone, so the copy opens the screen; from $md it
fills the first screen and centres there. */}
<XStack
render="section"
position="relative"
items="center"
justify="center"
overflow="hidden"
py={48}
px={16}
$sm={{ px: 24 }}
$md={{ minH: '80vh', py: 96 }}
$lg={{ px: 32 }}
>
{/* A BLOCK, not a stack: an inline-flex badge and an inline-flex note
centre themselves against `text-align` here, which is how they have
always been centred. Inside a flex column each would be an item and
would stretch the full measure instead. */}
<Box position="relative" z="var(--z-raised)" width="100%" maxW={1024} mx="auto" $platform-web={{ textAlign: 'center' }}>
{p.badge && (
<motion.div {...fade} transition={{ duration: 0.5 }}>
<Chip
mb={32}
px={16}
py={8}
bg={undefined}
$theme-light={{ borderColor: 'var(--border)', color: 'var(--muted-foreground)' }}
$platform-web={{ backgroundColor: 'color-mix(in oklab, var(--pure-white) 5%, transparent)' }}
>
{Badge && <Badge width={16} height={16} color="var(--foreground)" />}
<Text fontSize="$2" lineHeight="1.1607rem" fontWeight="500" color="var(--text-secondary)">
{p.badge}
</Text>
</Chip>
</motion.div>
)}
<motion.div {...fade} transition={{ duration: 0.5, delay: 0.1 }}>
<Display>{p.title}</Display>
</motion.div>
{p.tagline && (
<motion.div {...fade} transition={{ duration: 0.5, delay: 0.13 }}>
<Claim mt={24}>{p.tagline}</Claim>
</motion.div>
)}
<motion.div {...fade} transition={{ duration: 0.5, delay: 0.15 }}>
<Lede mx="auto" maxW={768} mt={p.tagline ? 16 : 24}>
{p.lede}
</Lede>
</motion.div>
{heroStats && (
<motion.div {...fade} transition={{ duration: 0.5, delay: 0.18 }}>
<Box mx="auto" mt={48} maxW={896}>
<StatGrid items={heroStats} />
</Box>
</motion.div>
)}
<motion.div {...fade} transition={{ duration: 0.5, delay: 0.22 }}>
<Box mt={40}>
<CTARow ctas={p.ctas} />
</Box>
</motion.div>
{p.note && (
<motion.div {...fade} transition={{ duration: 0.5, delay: 0.3 }}>
<XStack mt={24} mx="auto" maxW={576} items="center" justify="center" gap={8}>
{NoteIcon && <NoteIcon width={16} height={16} color="var(--muted-foreground)" />}
<Text render="p" {...SMALL}>
{p.note.text}
</Text>
</XStack>
</motion.div>
)}
{p.availableThrough && p.availableThrough.length > 0 && (
<motion.div {...fade} transition={{ duration: 0.5, delay: 0.36 }}>
<XStack mt={32} flexWrap="wrap" items="center" justify="center" columnGap={24} rowGap={8}>
<Text {...SMALL}>Available through</Text>
{p.availableThrough.map((s) => (
<Text key={s} {...SMALL} color="var(--text-secondary)">
{s}
</Text>
))}
</XStack>
</motion.div>
)}
</Box>
</XStack>
{/* ── The prompt a reader hands to an agent ───────────────────────────── */}
{product && <AgentPrompt product={product} />}
{/* ── The product, running ───────────────────────────────────────────── */}
{/* Its own section rather than a Band: the rhythm here is a rule and a top
padding only — the surface wants the section under it to sit close. */}
{drawn && (
<Box
render="section"
borderTopWidth={1}
borderColor="var(--border)"
pt={64}
px={16}
$sm={{ px: 24 }}
$lg={{ px: 32 }}
>
<Box width="100%" maxW={1024} mx="auto">
<Surface
name={drawn.title}
api={drawn.api}
rail={drawn.rail}
here={drawn.slug}
/>
</Box>
</Box>
)}
{/* ── What it is ─────────────────────────────────────────────────────── */}
{p.what && (
<Band>
<SectionHead eyebrow={p.what.eyebrow} title={p.what.title} sub={p.what.sub} />
<Columns columns={TRIO} gap={24}>
{p.what.pillars.map((pl, i) => (
<motion.div key={pl.title} {...fade} transition={{ duration: 0.5, delay: i * 0.06 }}>
<Tile>
<XStack mb={20} items="center" justify="space-between">
<Plate size={44} mark={pl.icon} />
<Text fontSize="$2" lineHeight="1.1607rem" fontWeight="600" color="var(--text-tertiary)">
{String(i + 1).padStart(2, '0')}
</Text>
</XStack>
<CardTitle panel>{pl.title}</CardTitle>
<CardBody>{pl.body}</CardBody>
</Tile>
</motion.div>
))}
</Columns>
</Band>
)}
{/* ── The card grids, in the order the page states them ───────────────── */}
{grids.map((g, i) => (
<Cards key={i} grid={g} index={i} />
))}
{/* ── How it works ───────────────────────────────────────────────────── */}
{p.steps && (
<Band measure={1024}>
<SectionHead eyebrow={p.steps.eyebrow} title={p.steps.title} sub={p.steps.sub} />
<Columns columns={TRIO} gap={24}>
{p.steps.items.map((s, i) => (
<motion.div key={s.n} {...fade} transition={{ duration: 0.5, delay: i * 0.06 }}>
<Tile>
{s.icon && <Plate size={44} mark={s.icon} mb={16} />}
<Text display="block" mb={16} fontSize="$8" lineHeight="1.95rem" fontWeight="700" color="var(--neutral-700)">
{s.n}
</Text>
<CardTitle panel>{s.title}</CardTitle>
<CardBody>{s.body}</CardBody>
</Tile>
</motion.div>
))}
</Columns>
</Band>
)}
{p.chips && <Chips copy={p.chips} />}
{p.plans && <Plans copy={p.plans} />}
{/* ── Stats ──────────────────────────────────────────────────────────── */}
{p.stats && !p.stats.hero && (
<Band>
{p.stats.title && <SectionHead eyebrow={p.stats.eyebrow} title={p.stats.title} sub={p.stats.sub} />}
<StatGrid items={p.stats.items} />
{p.stats.footnote && <Footnote quiet>{p.stats.footnote}</Footnote>}
</Band>
)}
{/* ── Code ───────────────────────────────────────────────────────────── */}
{p.code && (
<Band measure={768} id={p.code.id}>
{p.code.head && <SectionHead eyebrow={p.code.head.eyebrow} title={p.code.head.title} sub={p.code.head.sub} />}
<motion.div {...fade} transition={{ duration: 0.5 }}>
<Frame name={p.code.name ?? p.code.lang}>{p.code.source}</Frame>
</motion.div>
{p.code.ctas && p.code.ctas.length > 0 && (
<Box mt={32}>
<CTARow ctas={p.code.ctas} />
</Box>
)}
</Band>
)}
{p.children}
{/* ── Final CTA ──────────────────────────────────────────────────────── */}
{p.finalCta && (
<Band measure={896}>
<motion.div {...fade} transition={{ duration: 0.5 }}>
<Tile position="relative" overflow="hidden" p={0} $platform-web={{ textAlign: 'center' }}>
<Box position="absolute" t={0} r={0} b={0} l={0} pointerEvents="none">
<Wash corner="tr" />
<Wash corner="bl" />
</Box>
{/* The room inside the card lives here rather than on the card, so
it can step up at 768 — the wash above is `inset: 0` either way. */}
<Box position="relative" z="var(--z-raised)" p={40} $md={{ p: 56 }}>
{FinalIcon && (
<XStack justify="center" mb={16}>
<FinalIcon width={40} height={40} color="var(--foreground)" />
</XStack>
)}
{p.finalCta.eyebrow && <Eyebrow mb={12}>{p.finalCta.eyebrow}</Eyebrow>}
<Title quiet mt={p.finalCta.eyebrow ? '$3.5' : 0}>
{p.finalCta.title}
</Title>
{p.finalCta.sub && (
<Lede mx="auto" mt={16} maxW={576}>
{p.finalCta.sub}
</Lede>
)}
<Box mt={32}>
<CTARow ctas={p.finalCta.buttons} />
</Box>
</Box>
</Tile>
</motion.div>
</Band>
)}
{/* ── The docs, and what sits beside this product ─────────────────────── */}
{product && <Related product={product} />}
</Box>
)
}
/** Small inline helper for pages that want a checklist inside custom sections. */
export function CheckList({ items, muted }: { items: string[]; muted?: boolean }) {
return (
<View render="ul" display="grid" gap={8} my={13} pl={40}>
{items.map((x) => (
<XStack key={x} render="li" items="center" gap={8}>
<Check width={16} height={16} color={muted ? 'var(--muted-foreground)' : 'var(--foreground)'} />
<Text fontSize="$2" lineHeight="1.1607rem" color="var(--text-secondary)">
{x}
</Text>
</XStack>
))}
</View>
)
}
export { fade, SectionHead, ArrowUpRight }