Files
zeekayandGitHub e20dc71ad8 i18n: /s* to /z*, the app's rooms and settings read their copy through t (#69)
The product pages from /s3 to /zap, /university and its course data,
/trust, /security, /sdks, /support, /tasks, /startups, /sms-opt-in, the
buying specialist, Zen, Kai's guide, Studio, the settings pages, and the
shared sales, solutions, zap and ui pieces. Client code draws with t(),
server components with <T>, copy held as data is marked with phrase()
and drawn through t() where it lands. A link or a figure inside a
sentence is a value, so a translator reorders the whole sentence.

Rebased on main: every page main moved onto Fold or Steps keeps main's
layout, with its words wrapped (label, title, lede, points). Steps now
draws a step's body through t, as it already drew the title. Settings
keeps main's usage panel, refresh, rewards wallet and sign-in wallet
rows, each label wrapped.

Left English: the skills page's install line (a command and two URLs),
and the solution eyebrow and sibling heading built from a kind label.

Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
2026-10-29 23:51:44 -07:00

401 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
/**
* The savings calculator on /enterprise: what the tokens a team uses today cost
* at the providers' list prices, on Hanzo Cloud, and on GPUs of its own.
*
* EVERY RATE IS READ WHEN THE PAGE OPENS, and none is typed: the providers'
* rates and ours from the price list (GET /v1/pricing), a GPU hour from the
* same list's on-demand rates, a Team member's month from the plan list
* (GET /v1/billing/plans), and what Hanzo bills for a provider's model from
* the model list (GET /v1/models). Every model of the labs the price list prices
* is offered, its featured ones first (`labs`). The volumes, the people and the GPUs are the
* reader's; the ones the page opens with are an example and say so. Each column
* says what it assumes under its figure (lib/savings has the arithmetic).
*
* The Enterprise agreement is priced with our team, so no total here includes
* it, and the GPU column says so beside its figure.
*/
import { useMemo, useState, type ReactNode } from 'react'
import { ChevronDown, Minus, Plus } from 'lucide-react'
import { Button, Input, Label, Picker, Text, ToggleGroup, ToggleGroupItem, View, XStack, YStack } from '@hanzo/ui'
import { Loading, Unreachable } from '@/components/api/state'
import { usePlans, usePricing, useServed } from '@/lib/api/use'
import type { Lab, Own, Plan, Pricing, Served } from '@/lib/api/read'
import { bought, cost, even, gpus, HOURS, percent, rate, saves, usd, type Rates, type Tokens } from '@/lib/savings'
import { day } from '@/lib/tally'
import { CARD } from './kit'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
/** A row of use: a provider's model and what it takes a month. */
const ZERO = 0
const NO_TOKENS: Tokens = { input: ZERO, output: ZERO }
type Row = { model: string; mTokIn: number; mTokOut: number; spend: number; share: number }
/** How a reader states use: tokens a month, or dollars a month. */
type Mode = 'tokens' | 'spend'
/** The most rows a reader adds. */
const ROWS = 3
/**
* A lab's model the calculator can price: paid, with both rates, at its list
* price, and itself. A `:batch` row is the same model discounted, and a `~` row
* ("~anthropic/claude-opus-latest") is an alias of whichever model is the lab's
* latest, listed under a "~anthropic" provider of its own; both repeat a row
* that is already offered.
*/
const priced = (m: Lab) => !m.isFree && m.pricing.input > 0 && m.pricing.output > 0 && !m.id.endsWith(':batch') && !m.id.startsWith('~')
/**
* The models a reader picks from: every model of the labs the price list prices.
* The price list's featured ones lead, dearest first, and the page opens on the
* first of them; every other follows under its lab, A to Z. The featured set is
* curated in hanzoai/pricing (featuredModelIds); what is offered is not, so a
* lab's new model is here the day the price list lists it.
*/
function labs(doc: Pricing): { featured: Lab[]; byLab: [string, Lab[]][]; all: Lab[] } {
const paid = doc.thirdPartyModels.filter(priced)
const featured = paid.filter((m) => m.featured).sort((a, b) => b.pricing.output - a.pricing.output || a.name.localeCompare(b.name))
const rest = paid.filter((m) => !m.featured)
const byLab = [...new Set(rest.map((m) => m.provider))]
.sort((a, b) => a.localeCompare(b))
.map((p): [string, Lab[]] => [p, rest.filter((m) => m.provider === p).sort((a, b) => a.name.localeCompare(b.name))])
return { featured, byLab, all: [...featured, ...byLab.flatMap(([, ms]) => ms)] }
}
/** Ours to move the tokens to: the Enso SKUs the price list prices. */
const ours = (doc: Pricing): Own[] =>
doc.hanzoModels.filter((m) => m.family === 'enso' && m.pricing?.input != null && m.pricing.output != null && !m.name.includes('/'))
/** A provider's model by the name the reader reads: "Anthropic: Claude Opus 4.8" stays whole. */
const label = (m: Lab) => m.name
const at = (m: { pricing?: { input?: number | null; output?: number | null } }): Rates => ({ input: m.pricing?.input ?? 0, output: m.pricing?.output ?? 0 })
/** What Hanzo bills a provider's model at, from the model list, where it lists it. */
function billed(served: Served[] | null, id: string): Rates | null {
const p = served?.find((s) => s.id === id)?.pricing
return p?.input_per_million != null && p.output_per_million != null ? { input: p.input_per_million, output: p.output_per_million } : null
}
/** A number field's value, never below nothing. */
const amount = (v: string) => {
const n = Number(v)
return Number.isFinite(n) && n > 0 ? n : 0
}
const FIELD = { height: 48, fontSize: '1rem', rounded: 12, bg: 'var(--background)' } as const
const CHOICE = {
height: 48,
minH: 48,
py: 11,
pl: 14,
pr: 40,
fontSize: '1rem',
lineHeight: '1.5rem',
rounded: 12,
bg: 'var(--background)',
'$platform-web': { appearance: 'none', WebkitAppearance: 'none' },
} as const
function Field({ id, label: name, children }: { id: string; label: string; children: ReactNode }) {
return (
<YStack gap="$1.5" minW={0} flex={1}>
<Label htmlFor={id} fontSize="$2" color="var(--muted-foreground)">
{name}
</Label>
{children}
</YStack>
)
}
/** A native select with its chevron, which the platform no longer draws. */
function Choice({ id, value, onChange, children }: { id: string; value: string; onChange: (v: string) => void; children: ReactNode }) {
return (
<View position="relative" minW={0}>
<Picker id={id} {...CHOICE} value={value} onChange={(e) => onChange(e.target.value)} width="100%">
{children}
</Picker>
<View position="absolute" r={14} t={0} b={0} justify="center" pointerEvents="none" aria-hidden>
<ChevronDown size={16} color="var(--muted-foreground)" />
</View>
</View>
)
}
function Amount({ id, value, onChange, step = 1 }: { id: string; value: number; onChange: (v: number) => void; step?: number }) {
return <Input id={id} {...FIELD} type="number" inputMode="decimal" min={0} step={step} value={String(value)} onChange={(e) => onChange(amount(e.target.value))} />
}
/** One of the three totals: its name, its figure, what it saves against list, and what it assumes. */
function Total({ slot, name, figure, unit, saving, children }: { slot: string; name: string; figure: string; unit: string; saving?: number | null; children: ReactNode }) {
const { t } = useI18n()
return (
<YStack gap="$2" py="$4" borderTopWidth={1} borderColor="var(--border)" data-figure={slot}>
<XStack justify="space-between" items="baseline" gap="$3" flexWrap="wrap">
<Text render="h3" fontSize="$4" fontWeight="600" color="var(--foreground)">
{name}
</Text>
{saving != null ? (
<Text fontSize="$2" fontWeight="600" color="var(--foreground)" px={10} py={2} rounded="var(--radius-full)" borderWidth={1} borderColor="var(--border-strong)" data-saving={slot}>
{saving >= 0 ? t('{percent} less than list', { percent: percent(saving) }) : t('{percent} more than list', { percent: percent(-saving) })}
</Text>
) : null}
</XStack>
<Text fontSize="2.25rem" lineHeight="2.5rem" fontWeight="500" color="var(--foreground)" $platform-web={{ fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em' }}>
{figure}
<Text render="span" fontSize="$3" fontWeight="400" color="var(--muted-foreground)">
{` ${unit}`}
</Text>
</Text>
<Text render="p" fontSize="$2" lineHeight="1.35rem" color="var(--muted-foreground)">
{children}
</Text>
</YStack>
)
}
export default function Savings() {
const pricing = usePricing()
const plans = usePlans()
const served = useServed()
if (pricing.state === 'loading' || plans.state === 'loading') return <Loading what={phrase('the price list and the plans')} />
if (pricing.state === 'unreachable') return <Unreachable what={phrase('the price list')} />
return <Calculator doc={pricing.data} plans={plans.state === 'ready' ? plans.data : null} served={served.state === 'ready' ? served.data : null} />
}
function Calculator({ doc, plans, served }: { doc: Pricing; plans: Plan[] | null; served: Served[] | null }) {
const { t } = useI18n()
const offered = useMemo(() => labs(doc), [doc])
const choices = offered.all
const skus = useMemo(() => ours(doc), [doc])
const racks = doc.infrastructure?.gpu ?? []
const team = plans?.find((p) => p.category === 'team' && p.pricePerUser && (p.priceMonthly ?? 0) > 0) ?? null
// The example the page opens with: the dearest featured model, half a billion tokens in and a tenth of that out.
const [mode, setMode] = useState<Mode>('tokens')
const [rows, setRows] = useState<Row[]>(() => (choices[0] ? [{ model: choices[0].id, mTokIn: 500, mTokOut: 50, spend: 5000, share: 10 }] : []))
const [people, setPeople] = useState(50)
const [sku, setSku] = useState(() => skus.find((m) => m.name === 'enso')?.name ?? skus[0]?.name ?? '')
const [rack, setRack] = useState(() => racks[0]?.gpu ?? '')
const [count, setCount] = useState(1)
const config = racks.find((g) => g.gpu === rack) ?? racks[0]
const [hourly, setHourly] = useState<number | null>(null)
const hour = hourly ?? config?.price ?? 0
if (!choices.length || !skus.length) return <Unreachable what={phrase('the models the price list prices')} />
const mine = skus.find((m) => m.name === sku) ?? skus[0]
const set = (i: number, patch: Partial<Row>) => setRows((rs) => rs.map((r, k) => (k === i ? { ...r, ...patch } : r)))
// Each row as tokens a month, whichever way it was stated.
const usage = rows.map((r) => {
const m = choices.find((c) => c.id === r.model) ?? choices[0]
const tokens: Tokens = mode === 'tokens' ? { input: r.mTokIn, output: r.mTokOut } : (bought(r.spend, Math.min(100, r.share) / 100, at(m)) ?? NO_TOKENS)
return { m, tokens }
})
const total: Tokens = usage.reduce((t, u) => ({ input: t.input + u.tokens.input, output: t.output + u.tokens.output }), NO_TOKENS)
const listed = usage.reduce((s, u) => s + cost(u.tokens, at(u.m)), 0)
const onCloud = cost(total, at(mine))
const seats = team && people > 0 && (team.limits?.maxMembers == null || people <= Number(team.limits.maxMembers)) ? people * (team.priceMonthly ?? 0) : null
const cloud = onCloud + (seats ?? 0)
const fleet = gpus(count, hour)
const point = even(fleet, total, listed)
const kept = usage.every((u) => billed(served, u.m.id)) ? usage.reduce((s, u) => s + cost(u.tokens, billed(served, u.m.id)!), 0) : null
const millions = (v: number) =>
v >= 1000
? t('{n} billion', { n: (v / 1000).toLocaleString('en-US', { maximumFractionDigits: 2 }) })
: t('{n} million', { n: v.toLocaleString('en-US', { maximumFractionDigits: 1 }) })
// The Hanzo Cloud column's first sentence, which runs on into the seats where Team prices them.
const same = {
input: millions(total.input),
output: millions(total.output),
model: mine.fullName ?? mine.name,
inRate: rate(at(mine).input),
outRate: rate(at(mine).output),
cost: usd(onCloud),
}
const seated = (n: number, plan: string, seat: string, all: string) =>
n === 1
? t('The same {input} tokens in and {output} out on {model} at {inRate} in and {outRate} out per million: {cost}, plus {people} member on {plan} at {seat} a member a month: {seats}.', { ...same, people: n.toLocaleString('en-US'), plan, seat, seats: all })
: t('The same {input} tokens in and {output} out on {model} at {inRate} in and {outRate} out per million: {cost}, plus {people} members on {plan} at {seat} a member a month: {seats}.', { ...same, people: n.toLocaleString('en-US'), plan, seat, seats: all })
const alone = t('The same {input} tokens in and {output} out on {model} at {inRate} in and {outRate} out per million: {cost}.', same)
const gpu = config?.gpu ?? t('GPU')
return (
<View display="grid" gridTemplateColumns="minmax(0, 1fr)" gap={16} $lg={{ gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)' }} data-slot="savings">
<YStack {...CARD} p={24} $sm={{ p: 28 }} gap="$5" minW={0}>
<XStack justify="space-between" items="center" gap="$3" flexWrap="wrap">
<Text render="h3" fontSize="$5" fontWeight="600" color="var(--foreground)">
{t('What you use a month')}
</Text>
<XStack p={4} rounded={9999} bg="var(--background)">
<ToggleGroup type="single" value={mode} onValueChange={(v) => v && setMode(v as Mode)} disableDeactivation aria-label={t('State use by')}>
<ToggleGroupItem value="tokens" variant="outline" rounded="$10" borderColor="transparent" px="$3">
{t('Tokens')}
</ToggleGroupItem>
<ToggleGroupItem value="spend" variant="outline" rounded="$10" borderColor="transparent" px="$3">
{t('Spend')}
</ToggleGroupItem>
</ToggleGroup>
</XStack>
</XStack>
{rows.map((r, i) => (
<YStack key={i} gap="$3" data-row={i}>
{/* A field grows along a row; in its own row the model's keeps its height. */}
<XStack>
<Field id={`model-${i}`} label={i ? t('Model {n}', { n: i + 1 }) : t('Model you use today')}>
<Choice id={`model-${i}`} value={r.model} onChange={(v) => set(i, { model: v })}>
{offered.featured.length ? (
<optgroup label={t('Featured')}>
{offered.featured.map((m) => (
<option key={m.id} value={m.id}>
{label(m)}
</option>
))}
</optgroup>
) : null}
{offered.byLab.map(([lab, ms]) => (
<optgroup key={lab} label={lab}>
{ms.map((m) => (
<option key={m.id} value={m.id}>
{label(m)}
</option>
))}
</optgroup>
))}
</Choice>
</Field>
</XStack>
<XStack gap="$3" flexWrap="wrap">
{mode === 'tokens' ? (
<>
<Field id={`in-${i}`} label={t('Input tokens, millions')}>
<Amount id={`in-${i}`} value={r.mTokIn} onChange={(v) => set(i, { mTokIn: v })} />
</Field>
<Field id={`out-${i}`} label={t('Output tokens, millions')}>
<Amount id={`out-${i}`} value={r.mTokOut} onChange={(v) => set(i, { mTokOut: v })} />
</Field>
</>
) : (
<>
<Field id={`spend-${i}`} label={t('Spend, dollars')}>
<Amount id={`spend-${i}`} value={r.spend} onChange={(v) => set(i, { spend: v })} step={100} />
</Field>
<Field id={`share-${i}`} label={t('Output share of tokens, %')}>
<Amount id={`share-${i}`} value={r.share} onChange={(v) => set(i, { share: Math.min(100, v) })} />
</Field>
</>
)}
</XStack>
{rows.length > 1 ? (
<Button size="sm" variant="ghost" self="flex-start" onPress={() => setRows((rs) => rs.filter((_, k) => k !== i))} aria-label={t('Remove {model}', { model: label(usage[i].m) })}>
<XStack items="center" gap="$1.5">
<Minus size={14} aria-hidden />
<Text fontSize="$2" color="inherit">
{t('Remove')}
</Text>
</XStack>
</Button>
) : null}
</YStack>
))}
{rows.length < ROWS ? (
<Button size="sm" variant="outline" self="flex-start" rounded="var(--radius-full)" onPress={() => setRows((rs) => [...rs, { model: choices[Math.min(rs.length, choices.length - 1)].id, mTokIn: 100, mTokOut: 10, spend: 1000, share: 10 }])}>
<XStack items="center" gap="$1.5">
<Plus size={14} aria-hidden />
<Text fontSize="$2" fontWeight="500" color="inherit">
{t('Add a model')}
</Text>
</XStack>
</Button>
) : null}
<YStack gap="$3" pt="$4" borderTopWidth={1} borderColor="var(--border)">
<XStack gap="$3" flexWrap="wrap">
<Field id="people" label={t('People')}>
<Amount id="people" value={people} onChange={(v) => setPeople(Math.floor(v))} />
</Field>
<Field id="sku" label={t('On Hanzo Cloud, moved to')}>
<Choice id="sku" value={mine.name} onChange={setSku}>
{skus.map((m) => (
<option key={m.name} value={m.name}>
{m.fullName ?? m.name}
</option>
))}
</Choice>
</Field>
</XStack>
{racks.length ? (
<XStack gap="$3" flexWrap="wrap">
<Field id="rack" label={t('Your GPUs')}>
<Choice
id="rack"
value={config?.gpu ?? ''}
onChange={(v) => {
setRack(v)
setHourly(null)
}}
>
{racks.map((g) => (
<option key={g.gpu} value={g.gpu}>
{`${g.gpu}, ${g.vram}`}
</option>
))}
</Choice>
</Field>
<Field id="count" label={t('How many')}>
<Amount id="count" value={count} onChange={(v) => setCount(Math.max(1, Math.floor(v)))} />
</Field>
<Field id="hour" label={t('An hour of it, dollars')}>
<Amount id="hour" value={hour} onChange={setHourly} step={0.01} />
</Field>
</XStack>
) : null}
<Text render="p" fontSize="$1" lineHeight="1.25rem" color="var(--muted-foreground)">
{doc.updated
? t("The page opens with an example. Put in your own volumes, people and GPUs; every rate is our price list's, as it stood on {date}.", { date: day(doc.updated) })
: t("The page opens with an example. Put in your own volumes, people and GPUs; every rate is our price list's.")}
</Text>
</YStack>
</YStack>
<YStack {...CARD} p={24} $sm={{ p: 28 }} gap={0} minW={0} aria-live="polite">
<Text render="h3" fontSize="$5" fontWeight="600" color="var(--foreground)" pb="$2">
{t('What it costs a month')}
</Text>
<Total slot="list" name={t('At list prices')} figure={usd(listed)} unit={t('a month')}>
{t("{rates}, the provider's rate as our price list carries it. Seats on a provider's own plans are not priced here.", {
rates: usage
.map((u) => t('{model} at {input} in and {output} out per million tokens', { model: label(u.m), input: rate(u.m.pricing.input), output: rate(u.m.pricing.output) }))
.join('; '),
})}
</Total>
<Total slot="cloud" name="Hanzo Cloud" figure={usd(cloud)} unit={t('a month')} saving={saves(cloud, listed)}>
{seats !== null && team
? `${seated(people, team.name, usd(team.priceMonthly ?? 0), usd(seats))} ${t('{plan} includes usage for each member; every token is priced here anyway.', { plan: team.name })}`
: team && team.limits?.maxMembers != null && people > Number(team.limits.maxMembers)
? `${alone} ${t('{plan} is for up to {max} members; past that, members are part of Enterprise.', { plan: team.name, max: String(team.limits.maxMembers) })}`
: alone}
{kept !== null ? ` ${t('Keeping the same models through Hanzo bills them at the rates our model list states: {cost} a month.', { cost: usd(kept) })}` : ''}
</Total>
<Total slot="gpus" name={t('Enterprise, on your GPUs')} figure={usd(fleet)} unit={t('a month, plus the agreement')} saving={saves(fleet, listed)}>
{`${
hourly === null
? t('{count} × {gpu} on all month ({hours} hours) at {rate} an hour, our price list’s on-demand rate; put in what an hour of your own costs.', { count, gpu, hours: HOURS, rate: usd(hour) })
: t('{count} × {gpu} on all month ({hours} hours) at {rate} an hour.', { count, gpu, hours: HOURS, rate: usd(hour) })
} ${t('No per-token charge, and members are unlimited. The agreement itself is priced with our team and is not in this figure.')}`}
{point
? ` ${t('These GPUs cost what list prices do at {tokens} tokens a month at your mix, about {rate} tokens a second around the clock. Whether they serve that is sized with you, against your models and your load.', { tokens: millions(point.millions), rate: Math.round(point.perSecond).toLocaleString('en-US') })}`
: ''}
</Total>
</YStack>
</View>
)
}