Files
zeekayandGitHub 3df66c3990 i18n: /m* through /r* and the product kits draw their words through t (#70)
Pages from /machines to /risk, the model pages, and the kits they are made of
(page-kit, ProductLanding, LayerPage, Stack, the models kit, band's Head) mark
authored copy where it is written and draw it in the reader's language. A server
page marks a literal phrase('…') or wraps it in <T>; a client page calls t. The
kits translate a string prop through useSay (components/marketing/say), so a
page passes phrase('…') and markup passes as written. An arrow that points
turns over right to left (data-i18n-mirror).

Rebased on main: the fold-based heroes (Fold, PageHero, MachinesHero, the
product and layer pages) keep main's structure and copy, with their label,
title and lede marked phrase. /platform is main's CloudLanding; the deleted
/platform/layers and the old referral components stay deleted.

Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
2026-10-30 00:18:44 -07:00

215 lines
7.0 KiB
TypeScript

'use client'
import React from "react";
import { Check } from "lucide-react";
import { track as report } from "@hanzo/app/lib/tags";
import { View, YStack, XStack } from '@hanzo/ui'
import { Act } from './kit'
import { Line } from '@/components/type'
import { useI18n } from '@hanzo/i18n/react'
import { useSay } from '@/components/marketing/say'
interface PricingPlanProps {
/** Catalog id of the tier, and the value `plan_clicked` carries.
*
* The event needs the plan's IDENTITY, and `name` is its label. billing and
* pay both file their side of the upgrade funnel under the catalog id, so a
* card that reported "Pro" described the same tier by a name no other
* surface answers to, and no step could be joined to the next on it.
*
* OMITTED by a card that sells no catalog row, and then the card reports no
* plan click. The product pages reuse this layout for tiers the biller has
* never heard of, whose CTAs go to GitHub or a sales call rather than a
* checkout — those are not upgrade intent, and `$click` already records
* them as the interactions they are. Naming them anyway would be worse than
* silence: the subscription and machine catalogs collide on `pro` and
* `enterprise` (see the note atop lib/plans), so a hand-typed id files a
* dataroom click against the personal ladder. */
plan?: string;
name: string;
icon: React.ReactNode;
price: string;
billingPeriod?: string;
description: string;
features: string[];
popular?: boolean;
customColor?: string;
githubLink?: boolean;
contactSalesUrl?: string;
/** Live checkout URL — clicking the CTA starts the subscription. */
checkoutUrl?: string;
/** Override the primary CTA label (e.g. "Get Team plan"). */
ctaLabel?: string;
}
/* The card's words — name, price, period, description, features and the CTA
label — are drawn through `t` (`useSay`), so a caller marks each literal it
writes with `phrase('…')`. The click is reported in the authored English. */
const PricingPlan = ({
plan,
name,
icon,
price,
billingPeriod,
description,
features,
popular = false,
customColor,
githubLink = false,
contactSalesUrl,
checkoutUrl,
ctaLabel,
}: PricingPlanProps) => {
const track = (cta: string) => {
if (plan) report('plan_clicked', { plan, cta, items: [{ item_id: plan, item_name: name }] });
};
const { t } = useI18n();
const say = useSay();
const primaryLabel = ctaLabel || "Try Hanzo";
// The CTA is `kit.Act`, the one home for a pricing button: it renders the
// filled/outlined variant from `solid`, reports the click through `onPress`,
// and — the part this used to spell out four times — makes an address a real
// anchor rather than a div[role=button] a keyboard cannot start. `aside`
// opens a reference in a new tab; a checkout stays in this one so Back still
// reaches the ladder the buyer was comparing.
const renderButton = () => {
// Contact-sales tiers (Enterprise / Custom) → book a call.
if (contactSalesUrl) {
return (
<Act
label={t('Contact Sales')}
href={contactSalesUrl}
aside
onPress={() => track('Contact Sales')}
solid={popular}
size="default"
/>
);
}
// Real checkout — starts the subscription against the live billing stack.
if (checkoutUrl) {
return (
<Act
label={ctaLabel ? say(ctaLabel) : t('Try Hanzo')}
href={checkoutUrl}
onPress={() => track(primaryLabel)}
solid={popular}
size="default"
/>
);
}
// Legacy fallback (open-source repos) only if no checkout is wired.
if (githubLink || name === "Dev") {
return (
<Act
label={t('Get on GitHub')}
href="https://github.com/hanzoai/"
aside
onPress={() => track('Get on GitHub')}
solid={popular}
size="default"
/>
);
}
// No checkout and no repository: commerce does not sell this card, so no
// account can buy it. The people who can sell it are one click away.
return (
<Act
label={t('Contact Sales')}
href="/contact-sales"
onPress={() => track('Contact Sales')}
solid={popular}
size="default"
/>
);
};
return (
<YStack
position="relative"
p="$6"
borderWidth={1}
rounded="var(--radius-2xl)"
borderColor={popular ? 'var(--neutral-700)' : 'var(--neutral-800)'}
bg={
popular
? '$panel'
: '$sunken'
}
hoverStyle={{
borderColor: 'var(--neutral-700)',
bg: '$panel',
}}
backdropFilter="blur(4px)"
transition="medium"
>
{popular && (
<XStack
position="absolute"
t={-16}
l="50%"
justify="center"
px="$3"
py={4}
rounded={9999}
bg="var(--primary)"
x="-50%"
>
<Line size="sm" weight="500" color="var(--primary-foreground)">
{t('Most Popular')}
</Line>
</XStack>
)}
<XStack items="center" gap="$3" mb="$4">
{icon}
<Line render="h3" size="xl" weight="600">
{say(name)}
</Line>
</XStack>
{/* The description reserves a fixed block so the CTA below it lands at the
same height in every card. Descriptions come from commerce and differ by
a line — Plus reads three lines where Pro and Max read four — which put
the middle card's button 24px above its neighbours' and made a row of
equal-height cards look misaligned.
The grid already stretches the cards to equal height, so the slack has
to be absorbed somewhere ABOVE the button; absorbing it below (a flex-1
feature list) cannot work, because the feature lists are different
lengths too. Reserving here is the only point where all three agree.
Only from `md` up, where the cards sit side by side and alignment is
something you can see. Stacked on a phone there is nothing to align and
a floor would just add dead space between each card and its button. */}
<YStack mb="$5">
<XStack items="baseline" gap="$1" mb="$2">
<Line size="x4" weight="700">
{say(price)}
</Line>
{billingPeriod && <Line tone="muted">{say(billingPeriod)}</Line>}
</XStack>
<Line render="p" tone="muted" $md={{ minHeight: 140 }}>
{say(description)}
</Line>
</YStack>
<YStack mb="$6">{renderButton()}</YStack>
<YStack render="ul" gap="$4">
{features.map((feature) => (
<XStack render="li" key={feature} items="flex-start" gap="$3">
<View render={<Check size={20} color="var(--muted-foreground)" />} mt={2} shrink={0} />
<Line tone="soft">{say(feature)}</Line>
</XStack>
))}
</YStack>
</YStack>
);
};
export default PricingPlan;