mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
Every word the landing, the header menus, the phone sheet, the footer,
the help corner and the way in (sign-in, sign-up, the reset, the
invitation, the callback, onboarding, the 404) write goes through
@hanzo/i18n's t(): one sentence is one message, its figures, names and
links arguments; copy written as data is phrase('…') where written and
t(label) where drawn. Plans, the chart, the API states and the lines
lib/kai and lib/enso build for the landing do the same.
Right to left: <html lang dir> is set before the first paint (the
package's bootScript first in the head); a figure, clock, range or
price outside a sentence is isolated; sides are logical gui props, the
layout's [data-join] corners logical, a bar grows from the start;
arrows along the line mirror (Onward for "words ->" links). The repo
dates read in the reader's language.
Gates select by slot, never by an English label (rtl, help). New gate
translated.spec.ts: in Arabic the landing at 1440 and 390, every menu
open, the phone sheet, sign-in, sign-up and the app signed in hold no
authored English, nothing scrolls sideways, every range is isolated;
French the same for English's commonest words. What another package
draws with no words prop is listed by name (@hanzo/ui's Footer label,
@hanzo/build's composer row and suggestions).
@hanzo/i18n 0.5.3 (the catalogs: 1,443 keys, every offered locale
complete), @hanzo/app 0.2.1 (the app's words). The link gate imports
the terms versions from @hanzo/app, the one SDK main now resolves.
429 lines
13 KiB
TypeScript
429 lines
13 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* The shapes /pricing is made of, stated once, in gui props.
|
|
*
|
|
* Six rate cards had each grown a private copy of the same three things — a tier
|
|
* card, a bordered rate table, and an "included with every plan" panel — and the
|
|
* tier card existed in TWO designs sitting one above the other on the same tab
|
|
* (Datastore at p-7 with a pill badge, Vector and Observability at p-6 with a
|
|
* caps one). Two designs for one concept is not a preference held in two files;
|
|
* it is the same card drawn twice and drifting.
|
|
*
|
|
* A caller passes CONTENT. Nothing here takes a class name, and nothing here
|
|
* reads one: the type ramp, the surface and the rhythm are gui props against the
|
|
* same tokens `lib/gui.ts` binds, so the page renders from the design system
|
|
* rather than from a stylesheet a build step happened to emit.
|
|
*/
|
|
|
|
import type { ViewProps } from '@hanzo/gui'
|
|
import type { ReactNode } from 'react'
|
|
import { View, YStack, XStack } from '@hanzo/ui'
|
|
import { Line, type Tone } from '@/components/type'
|
|
import { Grid } from '@hanzo/ui/grid'
|
|
import { Action as Pill } from '@hanzo/ui/marketing'
|
|
import { mix } from '@hanzo/app/lib/mix'
|
|
|
|
/* ── surface ───────────────────────────────────────────────────────────────── */
|
|
|
|
/** The one card surface this page paints — neutral fill, hairline, 16px radius. */
|
|
const SURFACE = {
|
|
bg: '$panel',
|
|
borderWidth: 1,
|
|
borderColor: '$edge',
|
|
rounded: 'var(--radius-xl)',
|
|
} as const
|
|
|
|
export function Panel({
|
|
pad = '$5',
|
|
children,
|
|
...rest
|
|
}: {
|
|
pad?: ViewProps['p']
|
|
children: ReactNode
|
|
[k: string]: unknown
|
|
}) {
|
|
return (
|
|
<YStack {...SURFACE} p={pad} {...rest}>
|
|
{children}
|
|
</YStack>
|
|
)
|
|
}
|
|
|
|
/* ── heads ─────────────────────────────────────────────────────────────────── */
|
|
|
|
/** A service's name and what it is, with an optional link out on the same line. */
|
|
export function Head({
|
|
title,
|
|
lede,
|
|
icon,
|
|
aside,
|
|
gap = '$6',
|
|
}: {
|
|
title: string
|
|
lede?: ReactNode
|
|
icon?: ReactNode
|
|
aside?: ReactNode
|
|
/** Space beneath the lede. */
|
|
gap?: ViewProps['gap']
|
|
}) {
|
|
const heading = (
|
|
<XStack items="center" gap="$3">
|
|
{icon}
|
|
<Line render="h2" size="x3" weight="700">
|
|
{title}
|
|
</Line>
|
|
</XStack>
|
|
)
|
|
const words = (
|
|
<>
|
|
<YStack mb={lede ? '$2' : undefined}>{heading}</YStack>
|
|
{lede ? (
|
|
<Line render="p" size="lg" tone="muted">
|
|
{lede}
|
|
</Line>
|
|
) : null}
|
|
</>
|
|
)
|
|
// The link goes BESIDE the words, not between the title and its lede. Putting
|
|
// it on the heading's own row reads correctly in a picture and wrongly in
|
|
// every sequential reading of the page — the pill lands mid-sentence.
|
|
return aside ? (
|
|
<XStack
|
|
items="center"
|
|
justify="space-between"
|
|
flexWrap="wrap"
|
|
gap="$4"
|
|
mb={gap}
|
|
>
|
|
<YStack flex={1} minW={280}>
|
|
{words}
|
|
</YStack>
|
|
{aside}
|
|
</XStack>
|
|
) : (
|
|
<YStack mb={gap}>{words}</YStack>
|
|
)
|
|
}
|
|
|
|
/** A section inside a service — smaller, same shape. */
|
|
export function Sub({ title, lede, gap = '$5' }: { title: string; lede?: ReactNode; gap?: ViewProps['gap'] }) {
|
|
return (
|
|
<YStack mb={lede ? undefined : gap}>
|
|
<Line render="h3" size="x2" weight="600" marginBottom="$2">
|
|
{title}
|
|
</Line>
|
|
{lede ? (
|
|
<Line render="p" size="sm" tone="muted" marginBottom={gap}>
|
|
{lede}
|
|
</Line>
|
|
) : null}
|
|
</YStack>
|
|
)
|
|
}
|
|
|
|
/* ── badge ─────────────────────────────────────────────────────────────────── */
|
|
|
|
/** A pill: "Most Popular", a tier name, "Free". */
|
|
export function Mark({ children }: { children: ReactNode }) {
|
|
return (
|
|
<XStack
|
|
display="inline-flex"
|
|
self="flex-start"
|
|
items="center"
|
|
gap="$1"
|
|
px="$2"
|
|
py={2}
|
|
rounded={9999}
|
|
borderWidth={1}
|
|
borderColor="$edge"
|
|
bg="$raised"
|
|
$platform-web={{ borderRadius: '9999px' }}
|
|
>
|
|
<Line px={10} tone="soft" weight="700" caps>
|
|
{children}
|
|
</Line>
|
|
</XStack>
|
|
)
|
|
}
|
|
|
|
/* ── tier ──────────────────────────────────────────────────────────────────── */
|
|
|
|
export interface Action {
|
|
label: string
|
|
/** Where the control goes. Required: a pricing control with no address is a
|
|
* button that takes the pointer and does nothing, and two of them sat under
|
|
* the rate card for months saying "Start building". */
|
|
href: string
|
|
/** A reference opens a new tab; a checkout stays in this one. */
|
|
aside?: boolean
|
|
onPress?: () => void
|
|
/** Drawn after the label. */
|
|
icon?: ReactNode
|
|
}
|
|
|
|
/**
|
|
* The control at the foot of a card: an ANCHOR, so Enter, cmd-click, middle-click
|
|
* and "copy link" all work. At `lg` it is the funnel's one button, pay's 56px
|
|
* pill at 16px (solid, or outlined); `default` is a row's own smaller pill.
|
|
*/
|
|
export function Act({
|
|
label,
|
|
href,
|
|
aside,
|
|
onPress,
|
|
icon,
|
|
solid,
|
|
size = 'lg',
|
|
}: Action & { solid?: boolean; size?: 'default' | 'lg' }) {
|
|
return (
|
|
<Pill
|
|
href={href}
|
|
fill={solid}
|
|
onClick={onPress}
|
|
{...(aside ? { target: '_blank', rel: 'noopener noreferrer' } : null)}
|
|
{...(size === 'lg' ? { minH: 56, fontSize: '1rem' } : null)}
|
|
>
|
|
{label}
|
|
{icon}
|
|
</Pill>
|
|
)
|
|
}
|
|
|
|
export function Actions({ children, gap = '$4' }: { children: ReactNode; gap?: ViewProps['gap'] }) {
|
|
return (
|
|
<XStack justify="center" flexWrap="wrap" gap={gap}>
|
|
{children}
|
|
</XStack>
|
|
)
|
|
}
|
|
|
|
/* ── rates ─────────────────────────────────────────────────────────────────── */
|
|
|
|
export interface Column {
|
|
head: string
|
|
/** Figures set to the line's end (the right, on a left-to-right page); a rate column is read against the decimal point. */
|
|
align?: 'right'
|
|
/** Set in the mono face — a price, not a sentence. */
|
|
mono?: boolean
|
|
/** Dropped below `md`, where four columns do not fit a phone. */
|
|
wide?: boolean
|
|
tone?: Tone
|
|
}
|
|
|
|
/**
|
|
* The table layout modes, as the element's own display. gui's `Display` type
|
|
* lists the flex box and its kin and not these; the runtime writes them. Every
|
|
* real `<table>` on the site states them from here.
|
|
*/
|
|
export const TABLE = 'table' as ViewProps['display']
|
|
export const ROW = 'table-row' as ViewProps['display']
|
|
export const CELL = 'table-cell' as ViewProps['display']
|
|
|
|
/**
|
|
* What a phone has no room for: a rate table's extra column. gui's `$max-md`
|
|
* drops it under 48rem; above that it is a cell again — stated, because a gui
|
|
* frame is a flex box until told otherwise and a `display: flex` cell falls out
|
|
* of its column. /pricing's rate cards, the /models catalogue and /zen/models
|
|
* all ask it; the words inside are a `Line`, since a frame takes no type.
|
|
*/
|
|
export function Spare({
|
|
render,
|
|
children,
|
|
...p
|
|
}: {
|
|
render: 'th' | 'td'
|
|
children?: ReactNode
|
|
} & Omit<ViewProps, 'render' | 'children'>) {
|
|
return (
|
|
<View render={render} display={CELL} $max-md={{ display: 'none' }} {...p}>
|
|
{children}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* A rate table.
|
|
*
|
|
* Real `<table>` elements, each a gui frame that states its table display: the
|
|
* box model a frame takes by default is flexbox, so a row left to it compiles
|
|
* `display: flex` and falls out of the table. The column a phone drops is a
|
|
* `Spare`.
|
|
*/
|
|
const HAIR = 'var(--border)'
|
|
|
|
/** The mono face on a cell, which is a frame and so takes no font prop: the
|
|
* family, and the cells @hanzo/design's base keeps for the face. */
|
|
const MONO = { fontFamily: 'var(--font-mono)', fontVariantLigatures: 'none' } as const
|
|
|
|
/** A cell's room and alignment. A phone takes 12px a side, so a 320px table
|
|
* holds its widest words and pills without overflowing its rounded frame.
|
|
* The sides are the line's start and end, so a right-to-left page mirrors them. */
|
|
const cell = (col: Column, head: boolean) =>
|
|
({
|
|
px: 20,
|
|
'$max-sm': { px: 12 },
|
|
py: head ? 12 : 16,
|
|
'$platform-web': { verticalAlign: 'top', textAlign: col.align ? 'end' : 'start', ...(col.mono && !head ? MONO : null) },
|
|
}) as const
|
|
|
|
export function Rates({ columns, rows }: { columns: Column[]; rows: ReactNode[][] }) {
|
|
return (
|
|
<YStack overflow="hidden" borderWidth={1} borderColor="$edge" rounded="var(--radius-xl)">
|
|
<View render="table" display={TABLE} width="100%" $platform-web={{ borderCollapse: 'collapse', textAlign: 'start' }}>
|
|
<thead>
|
|
<View
|
|
render="tr"
|
|
display={ROW}
|
|
bg={mix('var(--neutral-900)', 30)}
|
|
borderBottomWidth={1}
|
|
borderColor="var(--neutral-800)"
|
|
>
|
|
{columns.map((c) => {
|
|
const head = (
|
|
<Line tone="muted" weight="500">
|
|
{c.head}
|
|
</Line>
|
|
)
|
|
return c.wide ? (
|
|
<Spare key={c.head} render="th" {...cell(c, true)}>
|
|
{head}
|
|
</Spare>
|
|
) : (
|
|
<View key={c.head} render="th" display={CELL} {...cell(c, true)}>
|
|
{head}
|
|
</View>
|
|
)
|
|
})}
|
|
</View>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row, r) => (
|
|
<View key={r} render="tr" display={ROW} {...(r === rows.length - 1 ? null : { borderBottomWidth: 1, borderColor: HAIR })}>
|
|
{row.map((value, c) => {
|
|
const col = columns[c]
|
|
const body =
|
|
typeof value === 'string' || typeof value === 'number' ? (
|
|
<Line
|
|
size={col.tone === 'muted' ? 'sm' : undefined}
|
|
tone={col.tone ?? 'plain'}
|
|
weight={col.tone ? undefined : '500'}
|
|
>
|
|
{value}
|
|
</Line>
|
|
) : (
|
|
value
|
|
)
|
|
return col.wide ? (
|
|
<Spare key={c} render="td" {...cell(col, false)}>
|
|
{body}
|
|
</Spare>
|
|
) : (
|
|
<View key={c} render="td" display={CELL} {...cell(col, false)}>
|
|
{body}
|
|
</View>
|
|
)
|
|
})}
|
|
</View>
|
|
))}
|
|
</tbody>
|
|
</View>
|
|
</YStack>
|
|
)
|
|
}
|
|
/* ── included ──────────────────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* What every rung carries. A set that is true all at once, so it is swept rather
|
|
* than listed — four across where there is room, two on a phone.
|
|
*/
|
|
export function Chips({
|
|
title,
|
|
lede,
|
|
items,
|
|
}: {
|
|
title: string
|
|
lede?: ReactNode
|
|
items: string[]
|
|
}) {
|
|
return (
|
|
<Panel pad="$6">
|
|
<Line render="h3" size="xl" weight="600" marginBottom="$4">
|
|
{title}
|
|
</Line>
|
|
{lede ? (
|
|
<Line render="p" size="sm" tone="muted" marginBottom="$4">
|
|
{lede}
|
|
</Line>
|
|
) : null}
|
|
<Grid columns={{ min: 160, max: 4 }} gap={16}>
|
|
{items.map((item) => (
|
|
<Line key={item} size="sm" tone="muted">
|
|
{item}
|
|
</Line>
|
|
))}
|
|
</Grid>
|
|
</Panel>
|
|
)
|
|
}
|
|
|
|
/* ── one number ────────────────────────────────────────────────────────────── */
|
|
|
|
/** A single rate, boxed: block storage per GB, an observability overage. */
|
|
export function Stat({
|
|
title,
|
|
value,
|
|
unit,
|
|
note,
|
|
}: {
|
|
title?: string
|
|
value: ReactNode
|
|
unit?: string
|
|
note?: ReactNode
|
|
}) {
|
|
return (
|
|
<Panel pad="$5" alignSelf="flex-start">
|
|
{title ? (
|
|
<Line render="h3" size="lg" weight="600" marginBottom="$2">
|
|
{title}
|
|
</Line>
|
|
) : null}
|
|
<YStack display="block" mb="$1">
|
|
<Line size="x3" weight="700">
|
|
{value}
|
|
</Line>
|
|
{unit ? (
|
|
<Line size="lg" tone="muted">
|
|
{unit}
|
|
</Line>
|
|
) : null}
|
|
</YStack>
|
|
{note ? (
|
|
<Line render="p" size="sm" tone="muted">
|
|
{note}
|
|
</Line>
|
|
) : null}
|
|
</Panel>
|
|
)
|
|
}
|
|
|
|
/* ── layout ────────────────────────────────────────────────────────────────── */
|
|
|
|
/** The measure every rate card sits in. */
|
|
export function Wide({ children, ...rest }: { children: ReactNode; [k: string]: unknown }) {
|
|
return (
|
|
<YStack width="100%" maxW={1280} mx="auto" {...rest}>
|
|
{children}
|
|
</YStack>
|
|
)
|
|
}
|
|
|
|
/** A row of tier cards: as many as fit, never narrower than 240px. */
|
|
export function Ladder({ children, max = 4 }: { children: ReactNode; max?: number }) {
|
|
return (
|
|
<Grid columns={{ min: 240, max }} gap={24}>
|
|
{children}
|
|
</Grid>
|
|
)
|
|
}
|