feat(website): Hub plan cards in USD with one Get started button (#5555)

* feat(website): use one Get started button on Hub's Free and Pro cards

* feat(website): show Hub plans in USD with price-only cards
This commit is contained in:
Mohamed Boudra
2026-09-28 17:48:47 +01:00
committed by GitHub
parent cf4509631b
commit d0a30ed4da
4 changed files with 49 additions and 44 deletions
+2 -2
View File
@@ -26,7 +26,7 @@
"interval": "monthly",
"intervalCount": 1,
"unitAmount": 0,
"currency": "eur",
"currency": "usd",
"tooltip": null
}
]
@@ -57,7 +57,7 @@
"interval": "monthly",
"intervalCount": 1,
"unitAmount": 1500,
"currency": "eur",
"currency": "usd",
"tooltip": "Seats are Hub members and pending invitations. People who only trigger agents through GitHub, Slack, or Discord do not count as seats."
}
]
+13 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
// The documented `GET /api/billing/plans` response, copied from getpaseo/hub docs/public-api.md.
import documentedResponse from "./hub-plans.fixture.json" with { type: "json" };
import { parseHubPlansResponse } from "./hub-plans";
import { formatPlanPrice, parseHubPlansResponse, planPriceQualifier } from "./hub-plans";
describe("parseHubPlansResponse", () => {
it("reads Free and Pro, their monthly prices, and the figures each includes", () => {
@@ -18,7 +18,7 @@ describe("parseHubPlansResponse", () => {
expect(plans.paid.name).toBe("Pro");
expect(plans.paid.included).toEqual({ seats: null, executionsPerMonth: null });
expect(plans.paid.price.unitAmount).toBe(1500);
expect(plans.paid.price.currency).toBe("eur");
expect(plans.paid.price.currency).toBe("usd");
expect(plans.paid.billing.unit.label).toBe("seat");
});
@@ -52,3 +52,14 @@ describe("parseHubPlansResponse", () => {
);
});
});
describe("plan price copy", () => {
it("shows Free as $0 a month and Pro as $15 per seat a month", () => {
const plans = parseHubPlansResponse(documentedResponse);
expect(formatPlanPrice(plans.free.price)).toBe("$0");
expect(planPriceQualifier(plans.free)).toBe("/ month");
expect(formatPlanPrice(plans.paid.price)).toBe("$15");
expect(planPriceQualifier(plans.paid)).toBe("per seat / month");
});
});
+17
View File
@@ -55,6 +55,23 @@ export const getHubPlans = createServerFn({ method: "GET" }).handler(async () =>
return parseHubPlansResponse(await response.json());
});
/** The plan's monthly figure in its own currency, e.g. "$15". */
export function formatPlanPrice(price: HubPlanOffer["price"]): string {
return new Intl.NumberFormat("en", {
style: "currency",
currency: price.currency.toUpperCase(),
minimumFractionDigits: 0,
}).format(price.unitAmount / 100);
}
/** What the figure is for: "per seat / month" when each seat is charged, "/ month" when the
* plan costs nothing, since a free plan has no per-seat charge to describe. */
export function planPriceQualifier(plan: HubPlanOffer): string {
const period = plan.price.intervalCount === 1 ? "month" : `${plan.price.intervalCount} months`;
if (plan.price.unitAmount === 0) return `/ ${period}`;
return `per ${plan.billing.unit.label} / ${period}`;
}
function selectOffer(plans: readonly HubBillingPlan[], slug: string): HubPlanOffer {
const plan = plans.find((candidate) => candidate.slug === slug);
if (plan === undefined) throw new Error(`Hub plan "${slug}" is unavailable`);
+17 -40
View File
@@ -12,7 +12,13 @@ import { DiscordIcon, GitHubIcon, SlackIcon } from "~/components/brand-icons";
import { AGENT_PAGES } from "~/data/agent-pages";
import { FAQItem } from "~/components/faq-item";
import { SiteShell } from "~/components/site-shell";
import { getHubPlans, type HubPlanOffer, type HubPlans } from "~/hub-plans";
import {
formatPlanPrice,
getHubPlans,
planPriceQualifier,
type HubPlanOffer,
type HubPlans,
} from "~/hub-plans";
import { pageMeta } from "~/meta";
export const Route = createFileRoute("/hub")({
@@ -104,15 +110,9 @@ function Pricing({ plans }: { plans: HubPlans | null }) {
return (
<section className="space-y-6" aria-labelledby="pricing-heading">
<div className="space-y-2">
<h2 id="pricing-heading" className="text-xl font-medium">
Choose how to run Hub
</h2>
<p className="max-w-2xl leading-relaxed text-white/70">
Every option runs agents on the daemons you already have. {plans.free.name} needs no card,
and {plans.paid.name} is one click away under Billing once you are signed in.
</p>
</div>
<h2 id="pricing-heading" className="text-xl font-medium">
Choose how to run Hub
</h2>
<div className="grid gap-4 md:grid-cols-3">
<PlanCard
name="Self-hosted"
@@ -125,23 +125,21 @@ function Pricing({ plans }: { plans: HubPlans | null }) {
/>
<PlanCard
name={plans.free.name}
price={formatPrice(plans.free.price)}
priceQualifier={planQualifier(plans.free)}
price={formatPlanPrice(plans.free.price)}
priceQualifier={planPriceQualifier(plans.free)}
priceTooltip={plans.free.price.tooltip}
features={planFeatures(plans.free)}
actionHref={HOSTED_HUB_URL}
actionLabel="Create a free account"
actionNote="No card, never expires."
actionLabel="Get started"
/>
<PlanCard
name={plans.paid.name}
price={formatPrice(plans.paid.price)}
priceQualifier={planQualifier(plans.paid)}
price={formatPlanPrice(plans.paid.price)}
priceQualifier={planPriceQualifier(plans.paid)}
priceTooltip={plans.paid.price.tooltip}
features={planFeatures(plans.paid)}
actionHref={HOSTED_HUB_URL}
actionLabel={`Go to Hub for ${plans.paid.name}`}
actionNote={`Upgrade to ${plans.paid.name} from Billing, whether you work alone or with a team.`}
actionLabel="Get started"
featured
/>
</div>
@@ -184,7 +182,6 @@ function PlanCard({
features,
actionHref,
actionLabel,
actionNote,
featured = false,
}: {
name: string;
@@ -194,7 +191,6 @@ function PlanCard({
features: readonly PlanFeature[];
actionHref: string;
actionLabel: string;
actionNote?: string;
featured?: boolean;
}) {
return (
@@ -220,12 +216,9 @@ function PlanCard({
))}
</ul>
)}
{actionNote !== undefined && (
<p className="mt-8 text-center text-xs leading-relaxed text-white/35">{actionNote}</p>
)}
<a
href={actionHref}
className={`rounded-md px-4 py-2 text-center text-sm font-medium transition-colors ${actionNote === undefined ? "mt-8" : "mt-3"} ${featured ? "bg-white text-black hover:bg-white/90" : "border border-white/15 text-white/80 hover:border-white/25 hover:text-white"}`}
className={`rounded-md px-4 py-2 text-center text-sm font-medium transition-colors mt-8 ${featured ? "bg-white text-black hover:bg-white/90" : "border border-white/15 text-white/80 hover:border-white/25 hover:text-white"}`}
>
{actionLabel}
</a>
@@ -247,22 +240,6 @@ function InfoTip({ text }: { text: string }) {
);
}
function planQualifier(plan: HubPlanOffer): string {
return `per ${plan.billing.unit.label} / ${formatBillingPeriod(plan.price)}`;
}
function formatPrice(price: HubPlanOffer["price"]): string {
return new Intl.NumberFormat("en", {
style: "currency",
currency: price.currency.toUpperCase(),
minimumFractionDigits: 0,
}).format(price.unitAmount / 100);
}
function formatBillingPeriod(price: HubPlanOffer["price"]): string {
return price.intervalCount === 1 ? "month" : `${price.intervalCount} months`;
}
const TRIGGER_SURFACES = [
{ name: "GitHub", icon: <GitHubIcon className="h-6 w-6" />, body: "Issues, PRs, and reviews." },
{ name: "Slack", icon: <SlackIcon className="h-6 w-6" />, body: "Channels and threads." },