mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
1005 lines
43 KiB
TypeScript
1005 lines
43 KiB
TypeScript
'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", "50×". */
|
||
const MULTIPLE = /\b\d+\s*[x×]/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.'),
|
||
max50x: phrase('For the biggest autonomous workloads, continuous coding swarms, and teams needing 50× 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 max50x = all.find((p) => p.id === 'max-50x' || p.name.toLowerCase().includes('50x'))
|
||
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,
|
||
})
|
||
}
|
||
if (max50x) {
|
||
devPlans.push({
|
||
...max50x,
|
||
name: 'Max 50×',
|
||
description: DEVELOPER.max50x,
|
||
})
|
||
}
|
||
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 row whose catalog entry sells one and saves against 12 months. */
|
||
const annual = (p: SubscriptionPlan): boolean => 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
|
||
? 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>
|
||
<XStack gap="$2" items="center" flexWrap="wrap">
|
||
{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}
|
||
/>
|
||
) : null}
|
||
{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 && !tiers ? (
|
||
<Mark>{t('Most popular')}</Mark>
|
||
) : null}
|
||
</XStack>
|
||
</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>
|
||
)
|
||
}
|