Files
hanzo.ai/components/pricing/PricingPlan.tsx
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
Every import, the catalog pin and the docs name @hanzo/app and
github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and
components/rooms.tsx is components/app.tsx.
2026-10-22 19:07:20 -07:00

207 lines
6.5 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'
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;
}
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 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="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={primaryLabel}
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="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="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)">
Most Popular
</Line>
</XStack>
)}
<XStack items="center" gap="$3" mb="$4">
{icon}
<Line render="h3" size="xl" weight="600">
{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">
{price}
</Line>
{billingPeriod && <Line tone="muted">{billingPeriod}</Line>}
</XStack>
<Line render="p" tone="muted" $md={{ minHeight: 140 }}>
{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">{feature}</Line>
</XStack>
))}
</YStack>
</YStack>
);
};
export default PricingPlan;