mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
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.
903 lines
34 KiB
TypeScript
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 }
|