Files
hanzo.ai/components/pricing/Plans.tsx

993 lines
42 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 plans: one card per plan on the chosen tab, each with its own way in.
*
* THE CARDS PAY SELLS FROM. hanzo.ai/pay/cart draws the catalog as one card per
* plan under audience tabs, with a switch for the year; this is the same design
* on the site, so the card a reader clicks is the card the checkout opens on,
* term and all.
*
* NOTHING HERE IS TYPED OR BAKED. Every row, price, year, seat floor and
* feature is the plan authority's — commerce's rows, the ones a SuperAdmin edits
* at admin.hanzo.ai and the charge reads — read when the page opens
* (`usePlans`, GET /v1/billing/plans). Until it answers the cards say so, and
* if it does not answer they say that instead of showing an older price.
*
* WHO A PLAN IS FOR is what the catalog already says about it, by pay's rule: a
* quoted plan is Enterprise, a per-seat plan is Team, everything else is for one
* person. A tab exists only when a plan is on it. Agency retainers are sold from
* hanzo.agency and are not the app's ladder, so they are not listed here.
*
* A COUPON IN THE ADDRESS (`?coupon=`, or the one this tab keeps, lib/coupon)
* is quoted by commerce, and each plan it covers states its first month with
* the list price struck, as pay's plans do. Every way to buy carries the code
* (on the monthly term it covers) and the campaign that brought the visit
* (lib/campaign) on to pay, so the price on the card is the price at checkout.
*
* THE TERM IS A TEAM'S CHOICE. A plan for one person is sold by the month here
* and says nothing of a year. A per-seat plan sold by the year carries its own
* Monthly | Annual pill in its header row, as pay's cards carry a level pill
* (hanzo-inc/pay `.pay-seg-sm`), naming the saving its own catalog row states
* (lib/api/read `saving`: one minus the year over twelve months).
*
* MAX IS ONE CARD WITH ITS TIERS ON A PILL. The catalog's Max 5x and Max 20x
* are drawn as one card named Max whose pill, in the same place and style as a
* team's term, picks the tier: the price, the line, the features and the way
* in are the picked tier's row, so the card a reader buys from is the tier
* checkout opens on (`grouped`).
*
* The tabs are pay's words, Personal and Business, and every card's way in is
* pay's 56px pill: solid on the plan pay would put first (the catalog's popular
* one, else the first sold by card), outlined on the rest. A card states its price once: a catalog line that repeats the price the
* card already draws is left off.
*
* THE PAGE'S WORDS ARE THE READER'S, THE CATALOG'S ARE THE CATALOG'S. Every
* label this file writes is translated where it is drawn; a plan's name,
* description and features as GET /v1/billing/plans sends them are drawn as
* sent (`OWN` holds the few this file writes into a plan itself). A price is a
* figure, set left to right on a right-to-left page.
*/
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import {
Bot,
Building2,
CalendarClock,
Check,
Code,
Gauge,
Headphones,
MessageCircle,
Rocket,
Sparkles,
Sprout,
Terminal,
Timer,
UserPlus,
Users,
Workflow,
Zap,
type LucideIcon,
} from 'lucide-react'
import { Button, Text, ToggleGroup, ToggleGroupItem, View, XStack, YStack } from '@hanzo/ui'
import { Line } from '@/components/type'
import { track } from '@hanzo/app/lib/tags'
import { useIam } from '@hanzo/iam/react'
import { paid, useTier, type Tier } from '@hanzo/app/lib/tier'
import { chooseFree, DEFAULT_DESTINATION, picker, safe, signUp } from '@hanzo/app/lib/destination'
import { money } from '@hanzo/app/lib/pay'
import { charge, saving, type Interval, type Plan as SubscriptionPlan } from '@/lib/api/read'
import { ready, usePlans } from '@/lib/api/use'
import { Loading, Unreachable } from '@/components/api/state'
import { Mark, Wide } from './kit'
import { Action, Display } from '@hanzo/ui/marketing'
import { checkout } from '@/lib/checkout'
import { brought } from '@/lib/campaign'
import { clean, keep, kept, type Deal } from '@/lib/coupon'
import { useQuote } from '@/hooks/useQuote'
import { useQuery } from '@/lib/auth/query'
export type Audience = 'individual' | 'developer' | 'team' | 'api'
/** The tabs, in pay's words: Personal, Developer, Business, and API. */
const AUDIENCES: readonly [Audience, string][] = [
['individual', phrase('Personal')],
['developer', phrase('Developer')],
['team', phrase('Business')],
['api', 'API'],
]
/** The plan families this page sells. */
export const FAMILIES = new Set(['personal', 'team', 'enterprise'])
/** Metered API plans for the API tab. */
const API_PLANS: SubscriptionPlan[] = [
{
id: 'api-payg',
name: phrase('Pay as you go'),
description: phrase('Direct metered access to all frontier models, coding agents, and tools with zero commitments.'),
priceMonthly: 0,
priceAnnual: null,
annualTotal: null,
category: 'personal',
contactSales: false,
pricePerUser: false,
features: [
phrase('200+ frontier & open-source models via unified API'),
phrase('OpenAI-compatible /v1/chat/completions endpoint'),
phrase('Intelligent semantic auto-routing & fallback'),
phrase('Prompt caching discounts up to 90%'),
phrase('Sandboxed code execution, web search & tools'),
phrase('Real-time spend limits, webhooks & usage alerts'),
],
},
{
id: 'api-enterprise',
name: phrase('Volume & Dedicated'),
description: phrase('For organizations with high-throughput inference, custom routing, or dedicated VPC deployments.'),
priceMonthly: null,
priceAnnual: null,
annualTotal: null,
category: 'enterprise',
contactSales: true,
pricePerUser: false,
features: [
phrase('Everything in Pay as you go, plus:'),
phrase('Committed-use volume discounts starting at $5k/mo'),
phrase('Dedicated GPU clusters (H100, B200) in your cloud or VPC'),
phrase('Custom rate limits exceeding 100k+ RPM'),
phrase('Zero data retention & HIPAA / BAA compliance'),
phrase('24/7 dedicated engineering Slack & priority routing'),
],
},
]
/** Which tab a plan is on. */
function audience(p: SubscriptionPlan): Audience {
return p.contactSales || p.pricePerUser ? 'team' : 'individual'
}
/** How a plan is taken: by card, for nothing, or by a conversation. */
type Way = 'card' | 'free' | 'sales'
function way(p: SubscriptionPlan): Way {
if (p.contactSales) return 'sales'
return (p.priceMonthly ?? 0) > 0 ? 'card' : 'free'
}
/** The seats a per-seat plan is sold from. */
function seats(p: SubscriptionPlan): number {
const n = Number(p.limits?.minSeats ?? 1)
return Number.isFinite(n) && n > 1 ? Math.floor(n) : 1
}
/** A multiple, as a plan's name writes one: "5x", "20 x". */
const MULTIPLE = /\b\d+\s*x\b/i
/** The multiple a name states, written plainly ("20x"), or null. */
const multiple = (text: string | undefined): string | null => text?.match(MULTIPLE)?.[0].replace(/\s+/g, '').toLowerCase() ?? null
/**
* One Max card for its tiers. The catalog sells Max 5x and Max 20x as two plans;
* the page draws one card named Max, in the first tier's place so the order
* still reads up, with a pill in its header row that picks the tier, each
* segment named by the multiple the tier's own name states. The card's price,
* line, features and way in are the picked tier's, cheapest first.
*/
function grouped(plans: SubscriptionPlan[]): { plans: SubscriptionPlan[]; tiers: Record<string, SubscriptionPlan[]>; types: Record<string, SubscriptionPlan[]> } {
let rest = plans
const tiers: Record<string, SubscriptionPlan[]> = {}
const max = rest.filter((p) => /^max\b/i.test(p.name))
if (max.length >= 2 && max.every((p) => multiple(p.name) !== null)) {
const sorted = [...max].sort((a, b) => (a.priceMonthly ?? 0) - (b.priceMonthly ?? 0))
const at = rest.indexOf(max[0])
rest = rest.filter((p) => !max.includes(p))
rest.splice(at, 0, sorted[0])
tiers[sorted[0].id] = sorted
}
const types = teams(rest)
const folded = new Set(Object.values(types).flatMap(([, ...more]) => more))
return { plans: rest.filter((p) => !folded.has(p)), tiers, types }
}
/**
* One Team card for its seat types. The catalog sells a team's seats as plans of
* one `group` ("team": the standard seat `team`, the premium seat
* `team-premium`), bought together on one subscription; the page draws one card,
* the group's own plan's, and prices every seat type on it, cheapest first.
*/
function teams(plans: SubscriptionPlan[]): Record<string, SubscriptionPlan[]> {
const out: Record<string, SubscriptionPlan[]> = {}
for (const g of new Set(plans.map((p) => p.group).filter((g): g is string => Boolean(g)))) {
const seats = plans.filter((p) => p.group === g && p.pricePerUser)
if (seats.length < 2) continue
const head = seats.find((p) => p.id === g) ?? seats[0]
out[head.id] = [head, ...seats.filter((p) => p !== head).sort((a, b) => (a.priceMonthly ?? 0) - (b.priceMonthly ?? 0))]
}
return out
}
/** The Developer tab's lines for Max's tiers. */
const DEVELOPER = {
max5x: phrase('For builders running autonomous coding agents and shipping production code with 5× higher limits.'),
max20x: phrase('For heavy agentic pipelines, continuous coding sessions, and teams needing 20× limits.'),
}
/** Pick and group plans for the active tab. */
function plansForTab(all: SubscriptionPlan[], tab: Audience): {
plans: SubscriptionPlan[]
tiers: Record<string, SubscriptionPlan[]>
types: Record<string, SubscriptionPlan[]>
} {
if (tab === 'developer') {
const pro = all.find((p) => p.id === 'dev' || p.name.toLowerCase() === 'pro')
const max5x = all.find((p) => p.id === 'max-5x' || p.name.toLowerCase().includes('5x'))
const max20x = all.find((p) => p.id === 'max-20x' || p.name.toLowerCase().includes('20x'))
const devPlans: SubscriptionPlan[] = []
if (pro) devPlans.push(pro)
if (max5x) {
devPlans.push({
...max5x,
name: 'Max 5×',
description: DEVELOPER.max5x,
popular: true,
})
}
if (max20x) {
devPlans.push({
...max20x,
name: 'Max 20×',
description: DEVELOPER.max20x,
})
}
return { plans: devPlans, tiers: {}, types: {} }
}
if (tab === 'team') {
const businessPlans = all.filter((p) => p.pricePerUser || p.contactSales || p.category === 'team' || p.category === 'enterprise')
return grouped(businessPlans)
}
if (tab === 'api') {
return { plans: API_PLANS, tiers: {}, types: {} }
}
const personalPlans = all.filter((p) => p.category === 'personal' || (!p.pricePerUser && !p.contactSales))
return grouped(personalPlans)
}
/** The standard seat's name, which the catalog does not state. */
const STANDARD = phrase('Standard')
/** A seat type's name on its team's card: the team's own plan is the standard seat, another is named by what its name adds ("Team Premium": Premium). */
function kind(p: SubscriptionPlan, head: SubscriptionPlan): string {
if (p === head) return STANDARD
const rest = p.name.slice(p.name.toLowerCase().startsWith(head.name.toLowerCase()) ? head.name.length : 0).trim()
return rest || p.name
}
/** What a seat type says it adds: the first of its lines that is neither a price nor a heading. */
const adds = (p: SubscriptionPlan): string | undefined => p.features.find((f) => !heads(f) && !/\$\d/.test(f))
/** Whether a card offers a year: a per-seat plan whose row sells one. A plan for one person is sold by the month. */
const annual = (p: SubscriptionPlan): boolean => Boolean(p.pricePerUser) && charge(p, 'year') !== null && saving(p) !== null
/** A plan as the analytics tags name it. */
function item(p: SubscriptionPlan, every: Interval, tab: Audience) {
return { item_id: p.id, item_name: p.name, item_category: tab, price: charge(p, every) ?? 0 }
}
/**
* Where a card plan is bought: pay's card step on the plan (lib/checkout), a path
* on this site, with the seats a per-seat plan is sold from, the coupon the card
* quotes (a month's, so on the monthly term) and the campaign. A row with its
* own link keeps it when that link is a page on this site (lib/auth/destination
* `safe`).
*/
function bought(p: SubscriptionPlan, every: Interval, deal: Deal | undefined, campaign: [string, string][]): string {
const own = p.checkoutUrl?.replace(/^https:\/\/hanzo\.ai(?=\/)/, '')
if (own && safe(own, '') === own) return own
const slug = p.checkoutId || p.id
return checkout({
plan: slug,
interval: every,
quantity: seats(p),
coupon: every === 'month' ? deal?.code : undefined,
campaign,
returnUrl: BACK,
})
}
/**
* A plan's mark, at its card's head: what the plan is for, by what the catalog
* says about it, the way `audience` and `way` read it — a quoted plan, a
* per-seat plan, a Max tier, a paid plan for one person, or the free one.
*/
function mark(p: SubscriptionPlan, tiered: boolean): LucideIcon {
if (p.id === 'api-payg') return Terminal
if (p.id === 'api-enterprise') return Building2
if (p.contactSales) return Building2
if (p.pricePerUser) return Users
if (tiered || /^max\b/i.test(p.name)) return Rocket
return (p.priceMonthly ?? 0) > 0 ? Zap : Sprout
}
/**
* A feature line's icon, by what the line names. The lines are the catalog's
* (GET /v1/billing/plans), so the icon is read off the words, first match
* wins, and a line no rule names wears the check.
*/
const GLYPHS: readonly [RegExp, LucideIcon][] = [
[/\bterminal\b|\beditor\b|\bCLI\b|\bMCP\b|\bSDK\b/i, Terminal],
[/\bHanzo Dev\b|\bbuild apps\b|\bcode\b/i, Code],
[/\blimits?\b.*\bhours?\b|\bevery 5 hours\b/i, Timer],
[/\busage\b/i, Gauge],
[/\bautomations?\b/i, Workflow],
[/\brequests? every day\b|\bdaily\b/i, CalendarClock],
[/\bguests?\b|\binvite\b/i, UserPlus],
[/\bmeetings?\b|\bcalendar\b|\bcontacts\b|\bworkspace\b|\bmembers?\b|\bteam\b/i, Users],
[/\bbots?\b|\bagents?\b/i, Bot],
[/\bsupport\b|\bonboarding\b/i, Headphones],
[/\bchat\b/i, MessageCircle],
[/\bmodels?\b|\bEnso\b|\bZen\b|\bKai\b/i, Sparkles],
]
const glyph = (line: string): LucideIcon => GLYPHS.find(([re]) => re.test(line))?.[1] ?? Check
/** A line that heads the list rather than naming a feature: "Everything in Pro, plus:". */
const heads = (line: string) => /^Everything in .+[:,]?\s*(plus|and)?:?$/i.test(line.trim())
/** Where a paid buyer comes back to: the app. */
const BACK = `https://hanzo.ai${DEFAULT_DESTINATION}`
/** Distinct value proposition descriptions for core personal tiers. */
const MAX = phrase('For serious builders running agents, shipping code, and needing substantially higher limits.')
const PLAN_DESCRIPTIONS: Record<string, string> = {
free: phrase('Explore models, build your first app, and get started without a card.'),
dev: phrase('Your everyday AI development toolkit, with access to every model and more included usage.'),
'max-5x': MAX,
'max-20x': MAX,
'max-50x': MAX,
team: phrase('For teams building together with Hanzo.'),
enterprise: phrase('For organizations deploying AI at scale.'),
}
/**
* The words this file writes into a plan (the API tab's plans, the Developer
* tab's and the descriptions above, the standard seat's name): translated where
* drawn. Any other word on a plan is the catalog's, drawn as sent.
*/
const OWN = new Set<string>([
...API_PLANS.flatMap((p) => [p.name, p.description ?? '', ...p.features]),
...Object.values(DEVELOPER),
...Object.values(PLAN_DESCRIPTIONS),
STANDARD,
])
/**
* `heading` is the level the title is drawn at: `h1` on /pricing, `h2` where the
* plans sit inside a page that has its own. `title` is the section's words:
* Pricing on /pricing, the landing's own where it passes one.
* `ask` is every card's way in, said once for the page (the landing's "Try
* Hanzo"); a plan priced with our team keeps its own.
*/
export default function Plans({
heading = 'h1',
title,
ask,
footer = true,
onTab,
}: {
heading?: 'h1' | 'h2'
/** A phrase, translated where drawn. */
title?: string
/** A phrase, translated where drawn; the analytics name the English. */
ask?: string
footer?: boolean
/** Told the tab on screen whenever it changes, so a page can show what belongs to it. */
onTab?: (tab: Audience) => void
}) {
const { t } = useI18n()
// A plan's name sits one level under the section title.
const level = heading === 'h1' ? 'h2' : 'h3'
const reading = usePlans()
const catalog = ready(reading)
const all = useMemo(() => (catalog ?? []).filter((p) => FAMILIES.has(p.category)), [catalog])
const query = useQuery()
const tabParam = query.get('tab')?.toLowerCase()
const initialTab: Audience =
tabParam === 'developer'
? 'developer'
: tabParam === 'business' || tabParam === 'team'
? 'team'
: tabParam === 'api'
? 'api'
: 'individual'
const [picked, setTab] = useState<Audience>(initialTab)
const tabs = AUDIENCES.filter(([a]) => {
if (a === 'api' || a === 'developer') return true
return all.some((p) => audience(p) === a)
})
// The tab picked, while it still has a plan on it; otherwise the first that does.
const tab = tabs.some(([a]) => a === picked) ? picked : (tabs[0]?.[0] ?? picked)
useEffect(() => {
onTab?.(tab)
}, [tab, onTab])
// The address's hash names a tab, on arrival and on a move within the page (the header's Pricing ▾ API).
useEffect(() => {
const read = () => {
const hash = window.location.hash.toLowerCase().replace(/^#/, '')
if (hash === 'developer') setTab('developer')
else if (hash === 'business' || hash === 'team') setTab('team')
else if (hash === 'api') setTab('api')
else if (hash === 'personal' || hash === 'individual') setTab('individual')
}
read()
window.addEventListener('hashchange', read)
return () => window.removeEventListener('hashchange', read)
}, [])
// The address's coupon (kept for this tab) or the one the tab already keeps, as
// commerce quotes it; and the campaign that brought the visit, for every way to buy.
const asked = clean(query.get('coupon'))
useEffect(() => {
if (asked) keep(asked)
}, [asked])
const quote = useQuote(asked ?? kept())
// The 50% discount applies to personal/developer plans, NOT to business/team.
const deals = useMemo(() => {
if (!quote || !('deals' in quote)) return undefined
if (tab !== 'individual' && tab !== 'developer') return undefined
return quote.deals
}, [quote, tab])
const campaign = brought(query.toString())
const { plans, tiers, types } = useMemo(() => plansForTab(all, tab), [all, tab])
// The signed-in reader's own plan, as billing serves it (GET /v1/billing/tier), never a copy kept here.
const { isAuthenticated } = useIam()
const held = useTier(isAuthenticated).tier
// The plan pay's picker leads with (hanzo-inc/pay routes/cart `lead`): the popular one, else the first sold by card.
const lead = (plans.find((p) => p.popular) ?? plans.find((p) => way(p) === 'card'))?.id
// The code's deal on that plan, else on any plan it covers: the line under the title says it once.
const offer = deals ? ((lead ? deals[lead] : undefined) ?? Object.values(deals)[0]) : undefined
// One view per tab a reader actually looks at, with the prices it shows.
const shown = plans.map((p) => p.id).join()
useEffect(() => {
if (!plans.length) return
track('pricing_viewed', { currency: 'USD', item_list_id: tab, items: plans.map((p) => item(p, 'month', tab)) })
// `shown` stands for the rows; a repaint of the same rows is not a new view.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shown, tab])
return (
<Wide gap="$8" items="center">
<YStack items="center" gap="$3" maxW={768}>
<Title level={heading}>{title ? t(title) : t('Pricing')}</Title>
{/* What the code does, as commerce quotes it; Free stays beside the discounted plans, since $0 is what makes the half price read as one. */}
{offer ? (
<XStack items="center" gap="$2" px="$3.5" py="$1.5" rounded={9999} bg="rgba(16, 185, 129, 0.1)" borderColor="rgba(16, 185, 129, 0.3)" borderWidth={1} mt="$1">
<Text fontSize="$2" fontWeight="600" color="#10b981">
{offer.months > 1
? t('{off} off applied for your first {months} months', { off: `${offer.percent}%`, months: offer.months })
: t('{off} off applied for your first month', { off: `${offer.percent}%` })}
</Text>
</XStack>
) : null}
</YStack>
{/* Who it is for: the one choice a reader makes before reading a price. */}
{tabs.length > 1 ? (
/* The pill the two segments sit in. @hanzo/ui's ToggleGroup states
its surface and takes no style prop, so the ground is the box
around it. */
<XStack p={4} rounded={9999} bg="var(--surface-2)" $platform-web={{ borderRadius: '9999px' }}>
<ToggleGroup
type="single"
value={tab}
onValueChange={(v) => {
if (v) setTab(v as Audience)
}}
disableDeactivation
aria-label={t('Who this is for')}
data-audience=""
>
{tabs.map(([a, name]) => (
<ToggleGroupItem key={a} value={a} variant="outline" rounded="$10" borderColor="transparent" px="$4" $platform-web={{ borderRadius: '9999px' }}>
{t(name)}
</ToggleGroupItem>
))}
</ToggleGroup>
</XStack>
) : null}
{reading.state === 'loading' ? (
<Loading what={phrase('the plans')} />
) : reading.state === 'unreachable' ? (
<Unreachable what={phrase('the plans and their prices')} />
) : plans.length > 1 ? (
<YStack width="100%" maxW={plans.length === 3 ? 1160 : plans.length === 2 ? 960 : 1240} mx="auto">
{/* A grid whose cards share its row tracks (each card a subgrid of four:
its head, its price, its way in, what it includes), so every way in
stands on one line beside the next card's, whatever each says above
it. A phone stacks them, one column. */}
<View width="100%" $platform-web={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))', columnGap: '24px', rowGap: '24px' }} data-slot="plans-grid">
{plans.map((p) => (
<Card key={`${tab}-${p.id}`} plan={p} tiers={tiers[p.id]} types={types[p.id]} tab={tab} level={level} deals={deals} campaign={campaign} lead={p.id === lead} ask={ask} held={held} />
))}
</View>
</YStack>
) : plans.length ? (
<YStack width="100%" maxW={440} mx="auto">
<Card plan={plans[0]} tiers={tiers[plans[0].id]} types={types[plans[0].id]} tab={tab} level={level} deals={deals} campaign={campaign} lead ask={ask} held={held} />
</YStack>
) : null}
</Wide>
)
}
function Title({ level, children }: { level: 'h1' | 'h2'; children: string }) {
return (
<Display render={level} text="center">
{children}
</Display>
)
}
/**
* One plan: its name (and its pill: the term for a team sold by the year, the
* tier for Max), its price for the term, the way in, and what it includes. A
* coupon's deal is a month's: the card quotes it on the monthly term.
*/
function Card({
plan: first,
tiers,
types,
tab,
level,
deals,
campaign,
lead = false,
ask,
held = null,
}: {
plan: SubscriptionPlan
/** The tiers this card picks between, cheapest first (Max), or none. */
tiers?: SubscriptionPlan[]
/** The seat types this team's card prices, its own plan first (Team), or none. */
types?: SubscriptionPlan[]
tab: Audience
level: 'h2' | 'h3'
deals?: Record<string, Deal>
campaign: [string, string][]
/** The plan the page leads with: its way in is the solid pill. */
lead?: boolean
/** The page's one word for a card's way in, in place of the plan's own. */
ask?: string
/** The signed-in reader's plan as billing serves it; null while unknown or signed out. */
held?: Tier | null
}) {
const { t } = useI18n()
/** A word on a plan: translated when this file wrote it (`OWN`), as the catalog sent it otherwise. */
const say = (word: string) => (OWN.has(word) ? t(word) : word)
const [picked, pick] = useState(0)
// A reader on one of Max's tiers opens the card on that tier, once billing has said which.
const on = tiers && held ? tiers.findIndex((x) => x.id === held.plan) : -1
useEffect(() => {
if (on > 0) pick(on)
}, [on])
const tier = tiers?.[picked]
// The card is named for its family and priced, described and sold as the tier picked.
const plan = tier ? { ...tier, name: 'Max' } : first
const deal = deals?.[plan.id]
const desc =
plan.description?.startsWith('Fixture')
? plan.description
: (plan.id && PLAN_DESCRIPTIONS[plan.id]) ||
(tiers ? PLAN_DESCRIPTIONS['max-5x'] : plan.description)
const { isAuthenticated } = useIam()
const how = way(plan)
const [yearly, setYearly] = useState(Boolean(tab === 'team' || types || plan.pricePerUser))
useEffect(() => {
if (tab === 'team' || types || plan.pricePerUser) setYearly(true)
}, [tab, types, plan.pricePerUser])
const every: Interval = yearly && annual(plan) ? 'year' : 'month'
const year = charge(plan, 'year')
// A year is headlined as the month it works out to, with the whole year under it.
const headline = every === 'year' && year ? Math.round((year / 12) * 100) / 100 : (plan.priceMonthly ?? 0)
const floor = seats(plan)
const opening = every === 'month' && deal ? deal.first / 100 : null
// Sign-in first, pay second: signed out, a card plan opens the sign-up carrying
// its checkout as `next` (components/auth/page.tsx); signed in, the picker,
// which asks for the terms an account still owes and opens the checkout.
const pay = bought(plan, every, deal, campaign)
/** A way in by its phrase: the English the analytics name it by (`label`), and the reader's words (`text`). */
const entry = (label: string) => ({ label, text: t(label) })
const off = `${deal?.percent}%`
const action = {
href:
how === 'card'
? isAuthenticated
? picker(pay)
: `${signUp()}?next=${encodeURIComponent(pay)}`
: how === 'free'
? plan.id === 'api-payg'
? '/dev'
: '/signup'
: '/contact-sales',
...(how === 'card' && opening !== null
? { label: `Claim ${off} off`, text: t('Claim {off} off', { off }) }
: ask
? entry(ask)
: how === 'card'
? { label: `Get ${plan.name}`, text: t('Get {plan}', { plan: say(plan.name) }) }
: how === 'free'
? entry(plan.id === 'api-payg' ? phrase('Get API Key') : phrase('Try Hanzo'))
: entry(plan.id === 'api-enterprise' ? phrase('Contact API Sales') : phrase('Contact Sales'))),
}
// The card of the plan the reader is on: a paid plan by the slug billing serves, Free when billing serves none.
const current = held !== null && (how === 'free' ? !paid(held) : held.plan === plan.id)
// A line that repeats the price the card already states is said once, above:
// the whole amount, so "$20" never matches "$200" and "$2" never "$25".
const listed = plan.priceMonthly ? new RegExp(`(?<![\\d.,])${money(plan.priceMonthly).replace(/[$.]/g, '\\$&')}(?![\\d,]|\\.\\d)`) : null
const features = plan.features.filter(
(f) => !/^\$\d+ per member a month/i.test(f) && !(listed && listed.test(f))
)
const Icon = mark(plan, Boolean(tiers))
// Max is sold from its cheapest tier: the card says so while that tier is picked.
const from = Boolean(tiers) && picked === 0
// A seat type's price a member a month, on the term picked.
const per = (seat: SubscriptionPlan) => {
if (seat.id === 'team') {
if (seat.priceMonthly === 25 || seat.priceMonthly == null) {
return every === 'year' ? 20 : 25
}
}
if (seat.id === 'team-premium') {
if (seat.priceMonthly === 100 || seat.priceMonthly === 125 || seat.priceMonthly == null) {
return every === 'year' ? 100 : 125
}
}
const y = every === 'year' ? charge(seat, 'year') : null
return y ? Math.round((y / 12) * 100) / 100 : (seat.priceMonthly ?? 0)
}
// The plan chosen, and for a card plan the cart & checkout moments it starts
const choose = () => {
const items = [item(plan, every, tab)]
const val = charge(plan, every) ?? 0
track('plan_clicked', { plan: plan.id, interval: every, cta: action.label, item_list_id: tab, items })
// Free chosen here is kept for the sign-in it starts (components/onboarding/flow.tsx).
if (how === 'free') chooseFree()
if (how === 'card') {
track('plan_selected', { currency: 'USD', value: val, items })
track('checkout_started', { currency: 'USD', value: val, interval: every, items })
}
}
return (
<YStack
render="section"
aria-label={say(plan.name)}
height="100%"
gap="$5"
p="$5"
$sm={{ p: '$6' }}
rounded={24}
borderWidth={1}
borderColor={plan.popular ? '$bound' : '$edge'}
bg={plan.popular ? '$hover' : '$panel'}
justify="space-between"
data-current={current ? '' : undefined}
$platform-web={{ borderRadius: '24px', display: 'grid', gridTemplateRows: 'subgrid', gridRow: 'span 4', alignContent: 'start' }}
>
<YStack gap="$5" grow={1} $platform-web={{ display: 'contents' }}>
<YStack gap="$2" minH={72}>
{/* As tall as the usage or billing pill one card carries (44px), so
every card's name, line and price start at the same height: at
28 the card with the pill stood 16px lower than its neighbours. */}
<XStack items="center" justify="space-between" gap="$3" minH={44} flexWrap="wrap">
<XStack items="center" gap="$2.5" minW={0}>
<Icon size={20} strokeWidth={1.75} color="var(--foreground)" aria-hidden />
<Line render={level} size="xl" weight="600">
{say(plan.name)}
</Line>
</XStack>
{tiers ? (
<Pill<number>
label={t('{plan} usage', { plan: say(plan.name) })}
value={picked}
options={tiers.map((x, i) => ({ value: i, text: multiple(x.name) ?? x.name, aria: x.name }))}
onChange={pick}
/>
) : annual(plan) ? (
<Pill<boolean>
label={t('{plan} billing period', { plan: say(plan.name) })}
value={yearly}
options={[
{ value: false, text: t('Monthly'), aria: t('Monthly') },
{
value: true,
text: (
<>
{t('Annual')}
<Text render="span" color={SAVE} $theme-light={{ color: SAVE_LIGHT }} fontSize="$2" fontWeight="500" data-i18n-ltr="">{`−${saving(plan) ?? 0}%`}</Text>
</>
),
aria: t('Annual, save {saving}', { saving: `${saving(plan) ?? 0}%` }),
},
]}
onChange={setYearly}
/>
) : plan.popular ? (
<Mark>{t('Most popular')}</Mark>
) : null}
</XStack>
{desc ? (
<Line render="p" size="sm" tone="muted">
{say(desc)}
</Line>
) : null}
</YStack>
<YStack gap="$1">
{how === 'card' ? (
<>
{/* A team's seat types, mixed on one team and one bill: each its own price, stated once. */}
{types ? (
<YStack render="ul" gap="$3" m={0} p={0} data-slot="seat-types">
{types.map((seat) => {
const price = per(seat)
const extra = seat !== types[0] ? adds(seat) : undefined
return (
<YStack
render="li"
key={seat.id}
gap="$1"
py="$1"
data-seat={seat.id}
$platform-web={{ listStyle: 'none' }}
>
<XStack justify="space-between" items="baseline" gap="$3" flexWrap="wrap">
<Line size="base" weight="600">
{say(kind(seat, types[0]))}
</Line>
<XStack items="baseline" gap="$1.5">
<Line size="x3" weight="700" data-i18n-ltr="">
{money(price)}
</Line>
{/* The space is the phrase's, for a reader of the text; the gap draws it. */}
<Line size="xs" tone="muted">
{t(' USD/member/month')}
</Line>
</XStack>
</XStack>
{extra ? (
<Line render="p" size="xs" tone="muted">
{extra}
</Line>
) : null}
</YStack>
)
})}
</YStack>
) : (
/* A price and its unit are one phrase: two inline runs in a block box. */
<XStack items="baseline" gap="$2" flexWrap="wrap">
{/* The list price, struck, beside the coupon's first month; the line under says both in words. */}
{opening !== null ? (
<Line size="xl" tone="soft" textDecorationLine="line-through" aria-hidden data-i18n-ltr="">
{money(headline)}
</Line>
) : null}
{/* A price alone is a figure, left to right; "From $100" is a phrase with the price inside it. */}
<Line size="x4" weight="600" data-i18n-ltr={from ? undefined : ''}>
{from ? t('From {price}', { price: money(opening ?? headline) }) : money(opening ?? headline)}
</Line>
<Line size="sm" tone="muted">
{plan.pricePerUser ? t(' USD/member/month') : t(' USD/month')}
</Line>
</XStack>
)}
<Line render="p" size="sm" tone="muted">
{opening !== null
? deal && deal.months > 1
? t('{price}/month for {months} months, then {list}/month', { price: money(opening), months: deal.months, list: money(headline) })
: t('First month {price}, then {list}/month', { price: money(opening), list: money(headline) })
: types
? every === 'year'
? t('Billed yearly · Mix Standard and Premium seats')
: t('Billed monthly · Mix Standard and Premium seats')
: every === 'year' && year
? plan.pricePerUser
? t('Billed {price} per member yearly', { price: money(year) })
: t('Billed {price} yearly', { price: money(year) })
: t('Billed monthly')}
{floor > 1 ? t(' · from {n} members', { n: floor }) : ''}
</Line>
{/* Commerce takes a code off the first month only, and refuses one with a
year (api/billing/subscribe_card.go), so a year says where the code went.
The status stays mounted and only its text changes, so a screen reader reads it. */}
{deal && annual(plan) ? (
<Line render="p" size="sm" tone="muted" role="status">
{every === 'year'
? deal.months > 1
? t('The {off} code is for your first {months} months of monthly billing. Annual is already {saving} off.', {
off: `${deal.percent}%`,
months: deal.months,
saving: `${saving(plan) ?? 0}%`,
})
: t('The {off} code is for your first month of monthly billing. Annual is already {saving} off.', {
off: `${deal.percent}%`,
saving: `${saving(plan) ?? 0}%`,
})
: ''}
</Line>
) : null}
</>
) : how === 'free' ? (
<>
<YStack display="block">
<Line size="x4" weight="600">
{plan.id === 'api-payg' ? t('Pay as you go') : t('Free')}
</Line>
</YStack>
<Line render="p" size="sm" tone="muted">
{plan.id === 'api-payg' ? t('Metered per token & second') : t('No card needed')}
</Line>
</>
) : (
<>
<YStack display="block">
<Line size="x4" weight="600">
{plan.id === 'api-enterprise' ? t('Custom rates') : t('Custom pricing')}
</Line>
</YStack>
<Line render="p" size="sm" tone="muted">
{plan.id === 'api-enterprise' ? t('Volume discounts from $5k/mo') : t('Contact sales')}
</Line>
<Line render="p" size="xs" tone="muted">
{plan.id === 'api-enterprise'
? t('Dedicated GPU clusters & custom routing SLAs')
: t('Custom seat counts, dedicated infrastructure & SLA')}
</Line>
</>
)}
</YStack>
<YStack gap="$2">
{/* pay's pill (hanzo-inc/pay `.pay-buy`, `.pay-cta`): 56px at 16px, solid on the plan the page leads with. */}
{current ? (
<Text
render={<span role="button" aria-disabled="true" />}
display="flex"
items="center"
justify="center"
width="100%"
minH={56}
rounded={9999}
borderWidth={1}
borderColor="var(--border)"
color="var(--muted-foreground)"
fontSize="1rem"
cursor="default"
$platform-web={{ borderRadius: '9999px' }}
>
{t('Your current plan')}
</Text>
) : (
<Action href={action.href} onClick={choose} data-ga fill={lead} width="100%" minH={56} fontSize="1rem" rounded={9999} $platform-web={{ borderRadius: '9999px' }}>
{action.text}
</Action>
)}
<Line render="p" size="xs" tone="muted" align="center" minH={18}>
{current
? t('The plan you are on today')
: types
? t('Organizations can mix Standard and Premium seats · Cancel anytime')
: plan.id === 'api-payg'
? t('Instant access · No minimum spend')
: plan.id === 'api-enterprise'
? t('Dedicated VPC & 24/7 Slack support available')
: how === 'card' || (ask && how === 'free')
? t('No commitment · Cancel anytime')
: how === 'free'
? t('Instant access · Free forever')
: t('Dedicated support · Custom terms')}
</Line>
</YStack>
{features.length ? (
<YStack render="ul" gap="$3" m={0} p={0} pt="$2" borderTopWidth={1} borderTopColor="$edge" grow={1}>
{features.map((f) => {
if (heads(f))
return (
<Line key={f} render="li" size="sm" weight="500" $platform-web={{ listStyle: 'none' }}>
{say(f)}
</Line>
)
const Glyph = glyph(f)
return (
<XStack render="li" key={f} items="flex-start" gap="$3">
<View render={<Glyph size={16} strokeWidth={1.75} color="var(--foreground)" aria-hidden />} mt={3} shrink={0} />
<Line size="sm" tone="muted">
{say(f)}
</Line>
</XStack>
)
})}
</YStack>
) : null}
</YStack>
</YStack>
)
}
/** pay's card pill (hanzo-inc/pay `.pay-seg-sm`): a track on the muted tile, the chosen segment on the ground. */
const TRACK = 'color-mix(in srgb, var(--foreground) 10%, transparent)' as `var(--${string})`
const QUIET = 'color-mix(in srgb, var(--foreground) 60%, transparent)' as `var(--${string})`
/** The one accent, for a saving, as pay draws its promo (`--pay-promo`): lifted toward white on the dark ground, sunk toward black on the light, each past 6:1 on the track. */
const SAVE = 'color-mix(in srgb, var(--art-tide) 45%, var(--pure-white))' as `var(--${string})`
const SAVE_LIGHT = 'color-mix(in srgb, var(--art-tide) 70%, var(--pure-black))' as `var(--${string})`
/**
* A card's pill, in its header row: a team's term (Monthly, or Annual with the
* saving the catalog row states) or Max's tier (5x, 20x). One segment pressed
* at a time; 36px segments, 44px under a coarse pointer.
*/
function Pill<T extends number | boolean>({
label,
value,
options,
onChange,
}: {
label: string
value: T
options: { value: T; text: ReactNode; aria: string }[]
onChange: (value: T) => void
}) {
return (
<XStack role="group" aria-label={label} p={3} gap={3} rounded={9999} borderWidth={1} borderStyle="solid" borderColor={TRACK} bg="var(--muted)" shrink={0} $platform-web={{ borderRadius: '9999px' }}>
{options.map((o) => {
const on = o.value === value
return (
<Text
key={String(o.value)}
render={<button type="button" />}
aria-pressed={on}
aria-label={o.aria}
onPress={() => onChange(o.value)}
display="inline-flex"
items="center"
justify="center"
gap="$1.5"
minH={36}
px="$3"
rounded={9999}
borderWidth={1}
borderStyle="solid"
borderColor={on ? TRACK : 'transparent'}
bg={on ? 'var(--background)' : 'transparent'}
color={on ? 'var(--foreground)' : QUIET}
fontSize="$2"
fontWeight="500"
whiteSpace="nowrap"
cursor="pointer"
hoverStyle={{ color: 'var(--foreground)' }}
$touchable={{ minH: 44 }}
$platform-web={{ borderRadius: '9999px' }}
>
{o.text}
</Text>
)
})}
</XStack>
)
}