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