mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 05:02:58 +00:00
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:
@@ -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."
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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." },
|
||||
|
||||
Reference in New Issue
Block a user