mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
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.
207 lines
6.5 KiB
TypeScript
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;
|