mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
The owner's spec for a landing page is the models bento's: monochrome, the words left and the art right, no extra prose. The folds carried two to four sentences under each heading, and some a claim, a lede and a list besides. Each now says one line: ProductLanding the tagline where a page states one (the lede still describes it to search), a claim alone where the claim is the line (/payments, /treasury, /zap, the extension), and elsewhere the sentence that says what the thing is. /philosophy's two points, /dns' claim that repeated its title, /calculator's capabilities line and the Spare clauses on /research and /learn go.
876 lines
34 KiB
TypeScript
876 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.
|
|
*
|
|
* Every word in that object is drawn through `t` (`useSay`), so a page marks
|
|
* each literal where it writes it — `title: phrase('…')` (@hanzo/i18n) — and
|
|
* the reader gets it in their language. Markup passes as the page wrote it.
|
|
*
|
|
* 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 { Fold } from '@/components/marketing/fold'
|
|
import { Steps } from '@/components/preview/steps'
|
|
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, Eyebrow, Lede, More, Title } from '@hanzo/ui/marketing'
|
|
import { phrase } from '@hanzo/i18n'
|
|
import { useI18n } from '@hanzo/i18n/react'
|
|
import { useSay } from '@/components/marketing/say'
|
|
|
|
/** The marks that point somewhere, which turn over in a right-to-left page. */
|
|
const POINTS = new Set<LucideIcon>([ArrowRight, ArrowUpRight])
|
|
const mirror = (icon: LucideIcon) => (POINTS.has(icon) ? '' : undefined)
|
|
|
|
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
|
|
/**
|
|
* The product itself, playing, beside the words on the first screen
|
|
* (components/marketing/fold): /bot's is BotPreview. A page with one draws no
|
|
* surface, so the product is pictured once. Absent, the first screen draws the product's
|
|
* surface: the slug named above, else the route's own, when the catalog lists
|
|
* it; with neither, it walks the page's pillars (`what`). A page with none of
|
|
* them keeps its opening at its own height.
|
|
*/
|
|
preview?: ReactNode
|
|
/** The preview is an app's demo: on a phone it is the first screen, the words after it (Fold `stage`). */
|
|
stage?: boolean
|
|
/** What the product does, three or four short lines under the lede (Fold `points`). */
|
|
points?: 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 say = useSay()
|
|
const Icon = cta.icon ?? ArrowRight
|
|
return (
|
|
<motion.div {...fade} transition={{ duration: 0.5 }}>
|
|
<XStack mt={32} justify="center">
|
|
<More href={cta.href}>
|
|
{say(cta.label)} <Icon width={16} height={16} data-i18n-mirror={mirror(Icon)} />
|
|
</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 }) {
|
|
const say = useSay()
|
|
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>{say(f.title)}</CardTitle>
|
|
{f.body && <CardBody>{say(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}>
|
|
{say(f.link.label)} <LinkIcon width={16} height={16} data-i18n-mirror={mirror(LinkIcon)} />
|
|
</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[] } }) {
|
|
const say = useSay()
|
|
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 }}
|
|
>
|
|
{say(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 } }) {
|
|
const say = useSay()
|
|
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)">
|
|
{say(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)">
|
|
{say(p.price)}
|
|
</Text>
|
|
{p.period && <Text color="inherit">/{say(p.period)}</Text>}
|
|
</Text>
|
|
<Box mb={32} flex={1}>
|
|
<CheckList items={p.points} muted />
|
|
</Box>
|
|
<Action href={p.cta.href} fill={p.pick}>
|
|
{say(p.cta.label)} <ArrowRight width={16} height={16} data-i18n-mirror />
|
|
</Action>
|
|
</Tile>
|
|
</motion.div>
|
|
))}
|
|
</Columns>
|
|
{copy.footnote && <Footnote>{say(copy.footnote)}</Footnote>}
|
|
</Band>
|
|
)
|
|
}
|
|
|
|
/** The stat row, in the hero or in its own section — one grid, two placements. */
|
|
function StatGrid({ items }: { items: Stat[] }) {
|
|
const say = useSay()
|
|
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)">
|
|
{say(s.value)}
|
|
</Text>
|
|
<Text display="block" mt={4} fontSize="$2" lineHeight="1.1607rem" fontWeight="500" color="var(--text-secondary)">
|
|
{say(s.label)}
|
|
</Text>
|
|
{s.sub && (
|
|
<Text display="block" mt={2} fontSize="$1" lineHeight="0.9167rem" color="var(--muted-foreground)">
|
|
{say(s.sub)}
|
|
</Text>
|
|
)}
|
|
</Tile>
|
|
</motion.div>
|
|
))}
|
|
</Columns>
|
|
)
|
|
}
|
|
|
|
function PrimaryButton({ cta }: { cta: CTA }) {
|
|
const say = useSay()
|
|
const Icon = cta.icon ?? ArrowRight
|
|
return (
|
|
<Action href={cta.href} fill>
|
|
{say(cta.label)} <Icon width={16} height={16} data-i18n-mirror={mirror(Icon)} />
|
|
</Action>
|
|
)
|
|
}
|
|
|
|
function OutlineButton({ cta }: { cta: CTA }) {
|
|
const say = useSay()
|
|
const Icon = cta.icon
|
|
return (
|
|
<Action href={cta.href}>
|
|
{Icon && <Icon width={16} height={16} data-i18n-mirror={mirror(Icon)} />} {say(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 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)
|
|
const drawn = surfaceOf(p.surface ?? product?.slug)
|
|
// A page with no surface of its own walks what it is on the first screen, and
|
|
// that band is then not drawn again further down.
|
|
const walked = !p.preview && !drawn && p.what ? p.what : null
|
|
const { t } = useI18n()
|
|
const say = useSay()
|
|
|
|
return (
|
|
<Box bg="var(--background)" $platform-web={{ color: 'var(--foreground)' }}>
|
|
{product && <ProductJsonLd product={product} path={route} description={typeof p.lede === 'string' ? p.lede : undefined} />}
|
|
{/* ── The first screen: the words beside the product, running ────────── */}
|
|
<Fold
|
|
label={p.badge}
|
|
mark={Badge && <Badge width={12} height={12} color="currentColor" />}
|
|
title={p.title}
|
|
// One line under the title: the tagline where the page states one,
|
|
// the lede where it does not. The lede still describes the page to
|
|
// search and to agents (ProductJsonLd above).
|
|
claim={p.tagline}
|
|
lede={p.tagline ? undefined : p.lede}
|
|
points={p.points}
|
|
stage={p.stage}
|
|
actions={p.ctas.map((c, i) => (i === 0 ? <PrimaryButton key={c.label} cta={c} /> : <OutlineButton key={c.label} cta={c} />))}
|
|
below={
|
|
p.note || p.availableThrough?.length ? (
|
|
<>
|
|
{p.note && (
|
|
<XStack maxW={576} items="center" gap={8}>
|
|
{NoteIcon && <NoteIcon width={16} height={16} color="var(--muted-foreground)" />}
|
|
<Text render="p" {...SMALL}>
|
|
{say(p.note.text)}
|
|
</Text>
|
|
</XStack>
|
|
)}
|
|
{p.availableThrough && p.availableThrough.length > 0 && (
|
|
<XStack flexWrap="wrap" items="center" columnGap={24} rowGap={8}>
|
|
<Text {...SMALL}>{t('Available through')}</Text>
|
|
{p.availableThrough.map((s) => (
|
|
<Text key={s} {...SMALL} color="var(--text-secondary)">
|
|
{say(s)}
|
|
</Text>
|
|
))}
|
|
</XStack>
|
|
)}
|
|
</>
|
|
) : undefined
|
|
}
|
|
preview={
|
|
p.preview ??
|
|
(drawn ? (
|
|
<Surface name={drawn.title} api={drawn.api} rail={drawn.rail} here={drawn.slug} />
|
|
) : walked ? (
|
|
<Steps title={typeof walked.title === 'string' ? walked.title : (p.badge ?? phrase('What it is'))} steps={walked.pillars.map((pl) => ({ title: pl.title, body: say(pl.body) }))} />
|
|
) : undefined)
|
|
}
|
|
>
|
|
{heroStats ? (
|
|
<Box width="100%" maxW={896} mx="auto">
|
|
<StatGrid items={heroStats} />
|
|
</Box>
|
|
) : undefined}
|
|
</Fold>
|
|
|
|
{/* ── The prompt a reader hands to an agent ───────────────────────────── */}
|
|
{product && <AgentPrompt product={product} />}
|
|
|
|
{/* ── What it is ─────────────────────────────────────────────────────── */}
|
|
{p.what && !walked && (
|
|
<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>{say(pl.title)}</CardTitle>
|
|
<CardBody>{say(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>{say(s.title)}</CardTitle>
|
|
<CardBody>{say(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>{say(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}>{say(p.finalCta.eyebrow)}</Eyebrow>}
|
|
<Title quiet mt={p.finalCta.eyebrow ? '$3.5' : 0}>
|
|
{say(p.finalCta.title)}
|
|
</Title>
|
|
{p.finalCta.sub && (
|
|
<Lede mx="auto" mt={16} maxW={576}>
|
|
{say(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 }) {
|
|
const say = useSay()
|
|
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)">
|
|
{say(x)}
|
|
</Text>
|
|
</XStack>
|
|
))}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export { fade, SectionHead, ArrowUpRight }
|