diff --git a/packages/website/src/hub-plans.fixture.json b/packages/website/src/hub-plans.fixture.json index ba11f1e04b..feef9e8750 100644 --- a/packages/website/src/hub-plans.fixture.json +++ b/packages/website/src/hub-plans.fixture.json @@ -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." } ] diff --git a/packages/website/src/hub-plans.test.ts b/packages/website/src/hub-plans.test.ts index ef19b5c665..9d0db494ff 100644 --- a/packages/website/src/hub-plans.test.ts +++ b/packages/website/src/hub-plans.test.ts @@ -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"); + }); +}); diff --git a/packages/website/src/hub-plans.ts b/packages/website/src/hub-plans.ts index a94d697654..ca888ee5da 100644 --- a/packages/website/src/hub-plans.ts +++ b/packages/website/src/hub-plans.ts @@ -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`); diff --git a/packages/website/src/routes/hub.tsx b/packages/website/src/routes/hub.tsx index e691ecc393..b91f952c2a 100644 --- a/packages/website/src/routes/hub.tsx +++ b/packages/website/src/routes/hub.tsx @@ -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 (
-
-

- Choose how to run Hub -

-

- 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. -

-
+

+ Choose how to run Hub +

@@ -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({ ))} )} - {actionNote !== undefined && ( -

{actionNote}

- )} {actionLabel} @@ -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: , body: "Issues, PRs, and reviews." }, { name: "Slack", icon: , body: "Channels and threads." },