Files
hanzo.ai/components/product/Related.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

70 lines
3.3 KiB
TypeScript

/**
* The last block on a product page, above the site's one closing call: this
* product's docs, and what sits beside it in the catalog.
*
* It carries no call of its own. It used to open on "Start building with X"
* and a filled "Get your API key", which put two closing calls a screen apart
* on every product page — this one, then the layout's "Build what's next."
* with Try Hanzo — in two different button shapes. The way in is the closing
* call's; what only this block knows is which docs page is the product's and
* which products are its neighbours.
*
* `docs` IS A LINK OR IT IS NOTHING. `lib/constants/products-metadata` answers
* null for every product docs does not publish a page for, and this block drops
* the link on null rather than aiming at the index. A near-miss page is worse
* than no link, because it looks like it worked.
*
* The related rows are read, never typed. Membership comes from the commerce
* catalog (`lib/data/catalog.json`, through `cloud-primitives`), visibility from
* `lib/publish`, and the descriptor from the same copy deck the mega-menu sets
* under a leaf. With no docs page and no neighbours the block does not render.
*/
import { XStack, YStack } from '@/components/ui'
import { Eyebrow, Leaf, Lift, Line, More } from '@hanzo/ui/marketing'
import { Band } from '@/components/marketing/band'
import { cloudCategories, getPrimitiveCategory } from '@/lib/data/cloud-primitives'
import type { Product } from '@/lib/product'
/** The rest of this product's corner of the catalog, at most three. */
function near(slug: string) {
const home = getPrimitiveCategory(slug)
const shown = home && cloudCategories.find((c) => c.id === home.id)
return (shown?.items ?? []).filter((i) => i.slug !== slug).slice(0, 3)
}
/** A small grey line, on the leading its utility paired with `text-sm`. */
const SMALL = { fontSize: '$2', color: 'var(--muted-foreground)', lineHeight: '1.4286' } as const
/** A row, as `Leaf` — the role whose reason to exist is the hover. `height`
* is auto here: the role stretches to a grid cell, and these are a column. */
const ROW = { py: 14, px: 20, borderColor: "var(--border)", display: "flex", flexWrap: "wrap", items: "baseline", gap: 12, height: "auto", rounded: "var(--radius-xl)", bg: "var(--card)", hoverStyle: { borderColor: "var(--border-selected)" } } as const
/** The name states no ink: it rests in the row's and lifts with the card. */
const NAME = { fontSize: "$2", lineHeight: "1.4286", fontWeight: "600" } as const
export function Related({ product }: { product: Product }) {
const rows = near(product.slug)
if (rows.length === 0 && !product.docs) return null
return (
<Band measure={896}>
<XStack flexWrap="wrap" justify="space-between" items="baseline" gap={12} mb={16}>
<Eyebrow>{rows.length > 0 ? 'Related capabilities' : product.name}</Eyebrow>
{product.docs ? <More href={product.docs}>{`${product.name} docs →`}</More> : null}
</XStack>
{rows.length > 0 ? (
<YStack gap={8}>
{rows.map((i) => (
<Leaf href={i.href} key={i.slug} {...ROW} hoverStyle={{ borderColor: "var(--border-selected)" }}>
<Lift {...NAME}>
{i.title}
</Lift>
{i.desc ? <Line {...SMALL}>{i.desc}</Line> : null}
</Leaf>
))}
</YStack>
) : null}
</Band>
)
}