forked from molecule-ai/molecule-core
Merge pull request #260 from Molecule-AI/feat/pricing-page
feat(canvas): /pricing route with plan selector + Stripe checkout
This commit is contained in:
commit
5940de61d8
78
canvas/src/app/pricing/page.tsx
Normal file
78
canvas/src/app/pricing/page.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { PricingTable } from "@/components/PricingTable";
|
||||
|
||||
/**
|
||||
* /pricing — static marketing + plan-selector route.
|
||||
*
|
||||
* Served from the same canvas deploy as the tenant UI and the apex
|
||||
* landing page. Intentionally a server component so the initial HTML
|
||||
* renders with full content for SEO; PricingTable is a client
|
||||
* component that handles the CTA click + checkout POST.
|
||||
*
|
||||
* Uses the same dark theme as the canvas so the visual transition
|
||||
* from landing → pricing → in-app experience stays cohesive.
|
||||
*/
|
||||
export const metadata = {
|
||||
title: "Pricing — Molecule AI",
|
||||
description:
|
||||
"Free while you tinker, paid tiers for shipping production multi-agent organizations. Transparent usage-based overage pricing on Pro.",
|
||||
};
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<div className="mx-auto max-w-5xl px-6 pt-20 pb-8 text-center">
|
||||
<h1 className="text-5xl font-bold tracking-tight text-white md:text-6xl">
|
||||
Pricing
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-zinc-300">
|
||||
Free while you tinker. Pay when you ship real agents to production.
|
||||
Every tier includes the full runtime stack — you upgrade for scale,
|
||||
support, and dedicated infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PricingTable />
|
||||
|
||||
<section className="mx-auto mt-20 max-w-3xl px-6 text-center">
|
||||
<h2 className="text-2xl font-semibold text-white">Questions?</h2>
|
||||
<p className="mt-2 text-zinc-400">
|
||||
We publish the{" "}
|
||||
<a
|
||||
href="https://github.com/Molecule-AI/molecule-monorepo"
|
||||
className="text-blue-400 underline hover:text-blue-300"
|
||||
>
|
||||
full source on GitHub
|
||||
</a>
|
||||
{" "}— if something's ambiguous, file an issue or{" "}
|
||||
<a
|
||||
href="mailto:support@moleculesai.app"
|
||||
className="text-blue-400 underline hover:text-blue-300"
|
||||
>
|
||||
email support
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className="mt-6 text-sm text-zinc-500">
|
||||
Prices shown in USD. Enterprise / self-hosted licensing available — contact us.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<footer className="mx-auto mt-20 max-w-5xl border-t border-zinc-800 px-6 py-6 text-center text-sm text-zinc-500">
|
||||
<p>
|
||||
© {new Date().getFullYear()} Molecule AI, Inc. ·{" "}
|
||||
<a href="/legal/terms" className="hover:text-zinc-300">
|
||||
Terms
|
||||
</a>
|
||||
{" "}·{" "}
|
||||
<a href="/legal/privacy" className="hover:text-zinc-300">
|
||||
Privacy
|
||||
</a>
|
||||
{" "}·{" "}
|
||||
<a href="/legal/dpa" className="hover:text-zinc-300">
|
||||
DPA
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
142
canvas/src/components/PricingTable.tsx
Normal file
142
canvas/src/components/PricingTable.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { plans, startCheckout, type Plan, type PlanId } from "@/lib/billing";
|
||||
import { fetchSession, redirectToLogin, type Session } from "@/lib/auth";
|
||||
import { getTenantSlug } from "@/lib/tenant";
|
||||
|
||||
/**
|
||||
* PricingTable renders the three plan cards and wires each CTA button
|
||||
* through a dispatcher:
|
||||
*
|
||||
* Free → kick to signup
|
||||
* Anonymous + paid → kick to signup (Stripe checkout after auth)
|
||||
* Authed + paid → POST /cp/billing/checkout and redirect
|
||||
* Any network failure → surface a simple error banner in-place
|
||||
*
|
||||
* Session is fetched lazily on first click rather than on mount so
|
||||
* anonymous users can browse the pricing page without a probe request.
|
||||
*/
|
||||
export function PricingTable() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingPlan, setLoadingPlan] = useState<PlanId | null>(null);
|
||||
|
||||
const handleClick = async (plan: Plan) => {
|
||||
setError(null);
|
||||
if (plan.id === "free") {
|
||||
redirectToLogin("sign-up");
|
||||
return;
|
||||
}
|
||||
setLoadingPlan(plan.id);
|
||||
try {
|
||||
// Lazy session probe — we only need it when the user commits to
|
||||
// a paid plan, not on page load.
|
||||
let session: Session | null = null;
|
||||
try {
|
||||
session = await fetchSession();
|
||||
} catch (e) {
|
||||
// Network error probing /cp/auth/me is treated the same as
|
||||
// anonymous here — a real 5xx from CP would also block a
|
||||
// Stripe checkout, so bouncing to signup is the safe path.
|
||||
session = null;
|
||||
}
|
||||
if (!session) {
|
||||
redirectToLogin("sign-up");
|
||||
return;
|
||||
}
|
||||
// Session.org_id is the WorkOS org id, not the slug — we need the
|
||||
// slug for the checkout endpoint. The slug lives in the URL on
|
||||
// tenant subdomains (<slug>.moleculesai.app), so we read it from
|
||||
// the helper. Session without a tenant slug means the user is on
|
||||
// the canvas apex and needs to pick an org first.
|
||||
const slug = getTenantSlug();
|
||||
if (!slug) {
|
||||
setError("Open a specific org on its tenant subdomain to upgrade.");
|
||||
return;
|
||||
}
|
||||
const result = await startCheckout(plan.id as Exclude<PlanId, "free">, slug);
|
||||
window.location.href = result.url;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoadingPlan(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mx-auto mb-6 max-w-3xl rounded border border-red-900 bg-red-950/40 p-4 text-sm text-red-200"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{plans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
loading={loadingPlan === plan.id}
|
||||
onSelect={() => handleClick(plan)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCard({
|
||||
plan,
|
||||
loading,
|
||||
onSelect,
|
||||
}: {
|
||||
plan: Plan;
|
||||
loading: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const ring = plan.highlighted
|
||||
? "border-blue-600 ring-2 ring-blue-600/30"
|
||||
: "border-zinc-800";
|
||||
return (
|
||||
<article
|
||||
className={`flex flex-col rounded-lg border ${ring} bg-zinc-900/40 p-6`}
|
||||
aria-labelledby={`plan-${plan.id}-name`}
|
||||
>
|
||||
{plan.highlighted && (
|
||||
<span className="mb-3 inline-block rounded-full bg-blue-600/20 px-3 py-1 text-xs font-medium text-blue-300">
|
||||
Most popular
|
||||
</span>
|
||||
)}
|
||||
<h2 id={`plan-${plan.id}-name`} className="text-xl font-semibold text-white">
|
||||
{plan.name}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">{plan.tagline}</p>
|
||||
<p className="mt-4 text-3xl font-bold text-white">{plan.price}</p>
|
||||
<ul className="mt-6 flex-1 space-y-2 text-sm text-zinc-300">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-start">
|
||||
<span className="mr-2 text-blue-400" aria-hidden>
|
||||
✓
|
||||
</span>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
disabled={loading}
|
||||
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium ${
|
||||
plan.highlighted
|
||||
? "bg-blue-600 text-white hover:bg-blue-500 disabled:bg-blue-900"
|
||||
: "border border-zinc-700 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
|
||||
}`}
|
||||
>
|
||||
{loading ? "Opening checkout…" : plan.ctaLabel}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
166
canvas/src/components/__tests__/PricingTable.test.tsx
Normal file
166
canvas/src/components/__tests__/PricingTable.test.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { PricingTable } from "../PricingTable";
|
||||
|
||||
// Module mocks — both auth and billing are network-touching, so the tests
|
||||
// script their return values. redirectToLogin is captured so we can assert
|
||||
// the "anonymous + paid plan" path bounces correctly.
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
fetchSession: vi.fn(),
|
||||
redirectToLogin: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/billing", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/billing")>("@/lib/billing");
|
||||
return {
|
||||
...actual,
|
||||
startCheckout: vi.fn(),
|
||||
};
|
||||
});
|
||||
// getTenantSlug is host-derived; override per test.
|
||||
vi.mock("@/lib/tenant", () => ({
|
||||
getTenantSlug: vi.fn(() => "acme"),
|
||||
}));
|
||||
|
||||
import { fetchSession, redirectToLogin } from "@/lib/auth";
|
||||
import { startCheckout } from "@/lib/billing";
|
||||
import { getTenantSlug } from "@/lib/tenant";
|
||||
|
||||
const mockedFetchSession = fetchSession as ReturnType<typeof vi.fn>;
|
||||
const mockedRedirectToLogin = redirectToLogin as ReturnType<typeof vi.fn>;
|
||||
const mockedStartCheckout = startCheckout as ReturnType<typeof vi.fn>;
|
||||
const mockedGetTenantSlug = getTenantSlug as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedGetTenantSlug.mockReturnValue("acme");
|
||||
// Replace window.location.href with a spy so we can verify redirect
|
||||
// intent without actually navigating the jsdom window.
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "http://localhost:3000/pricing", origin: "http://localhost:3000", pathname: "/pricing" },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("PricingTable", () => {
|
||||
it("renders all three plans with their CTAs", () => {
|
||||
render(<PricingTable />);
|
||||
expect(screen.getByRole("heading", { name: "Free" })).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "Starter" })).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "Pro" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Get started" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Upgrade to Starter" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Upgrade to Pro" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the 'Most popular' badge only on the starter card", () => {
|
||||
render(<PricingTable />);
|
||||
const badges = screen.getAllByText("Most popular");
|
||||
expect(badges.length).toBe(1);
|
||||
});
|
||||
|
||||
it("Free CTA redirects to signup without any session probe", () => {
|
||||
render(<PricingTable />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Get started" }));
|
||||
expect(mockedRedirectToLogin).toHaveBeenCalledWith("sign-up");
|
||||
expect(mockedFetchSession).not.toHaveBeenCalled();
|
||||
expect(mockedStartCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Paid CTA + anonymous → bounces to signup (no checkout call)", async () => {
|
||||
mockedFetchSession.mockResolvedValue(null);
|
||||
render(<PricingTable />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Upgrade to Starter" }));
|
||||
await waitFor(() => expect(mockedRedirectToLogin).toHaveBeenCalledWith("sign-up"));
|
||||
expect(mockedStartCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Paid CTA + authed → calls startCheckout and redirects to Stripe URL", async () => {
|
||||
mockedFetchSession.mockResolvedValue({
|
||||
user_id: "u1",
|
||||
org_id: "org-acme",
|
||||
email: "a@b.com",
|
||||
});
|
||||
mockedStartCheckout.mockResolvedValue({
|
||||
id: "cs_test",
|
||||
url: "https://checkout.stripe.com/pay/cs_test",
|
||||
});
|
||||
|
||||
render(<PricingTable />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Upgrade to Pro" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedStartCheckout).toHaveBeenCalledWith("pro", "acme"),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(window.location.href).toBe("https://checkout.stripe.com/pay/cs_test"),
|
||||
);
|
||||
expect(mockedRedirectToLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Paid CTA + authed + no tenant slug → shows 'pick an org first' error", async () => {
|
||||
mockedFetchSession.mockResolvedValue({
|
||||
user_id: "u1",
|
||||
org_id: "org-acme",
|
||||
email: "a@b.com",
|
||||
});
|
||||
mockedGetTenantSlug.mockReturnValue("");
|
||||
|
||||
render(<PricingTable />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Upgrade to Starter" }));
|
||||
|
||||
await waitFor(() => {
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.textContent).toContain("tenant subdomain");
|
||||
});
|
||||
expect(mockedStartCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces network errors from startCheckout in the error banner", async () => {
|
||||
mockedFetchSession.mockResolvedValue({
|
||||
user_id: "u1",
|
||||
org_id: "org-acme",
|
||||
email: "a@b.com",
|
||||
});
|
||||
mockedStartCheckout.mockRejectedValue(new Error("checkout: 500 boom"));
|
||||
|
||||
render(<PricingTable />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Upgrade to Pro" }));
|
||||
|
||||
await waitFor(() => {
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.textContent).toContain("500");
|
||||
});
|
||||
});
|
||||
|
||||
it("treats fetchSession network errors as anonymous (fail-closed to signup)", async () => {
|
||||
mockedFetchSession.mockRejectedValue(new Error("network down"));
|
||||
render(<PricingTable />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Upgrade to Starter" }));
|
||||
await waitFor(() => expect(mockedRedirectToLogin).toHaveBeenCalledWith("sign-up"));
|
||||
expect(mockedStartCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables the button while a checkout call is in flight", async () => {
|
||||
mockedFetchSession.mockResolvedValue({
|
||||
user_id: "u1",
|
||||
org_id: "org-acme",
|
||||
email: "a@b.com",
|
||||
});
|
||||
// Return a promise we never resolve so the button stays loading.
|
||||
mockedStartCheckout.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
render(<PricingTable />);
|
||||
const button = screen.getByRole("button", { name: "Upgrade to Pro" });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const loading = screen.getByRole("button", { name: /opening checkout/i });
|
||||
expect((loading as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
124
canvas/src/lib/__tests__/billing.test.ts
Normal file
124
canvas/src/lib/__tests__/billing.test.ts
Normal file
@ -0,0 +1,124 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { plans, startCheckout, openBillingPortal } from "../billing";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
// Each test installs its own fetch mock; restore in afterEach so a
|
||||
// failing test doesn't leak into the next one.
|
||||
global.fetch = vi.fn() as unknown as typeof fetch;
|
||||
// jsdom's default location is http://localhost:3000/; anchor the
|
||||
// return_to construction there so snapshot assertions are stable.
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
origin: "http://localhost:3000",
|
||||
pathname: "/pricing",
|
||||
href: "http://localhost:3000/pricing",
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("plans", () => {
|
||||
it("defines three canonical tiers in free → starter → pro order", () => {
|
||||
expect(plans.map((p) => p.id)).toEqual(["free", "starter", "pro"]);
|
||||
});
|
||||
|
||||
it("marks starter as highlighted (most-popular card)", () => {
|
||||
const starter = plans.find((p) => p.id === "starter");
|
||||
expect(starter?.highlighted).toBe(true);
|
||||
});
|
||||
|
||||
it("gives every plan a price, tagline, and at least one feature", () => {
|
||||
for (const plan of plans) {
|
||||
expect(plan.price).toBeTruthy();
|
||||
expect(plan.tagline).toBeTruthy();
|
||||
expect(plan.features.length).toBeGreaterThan(0);
|
||||
expect(plan.ctaLabel).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("startCheckout", () => {
|
||||
it("POSTs to /cp/billing/checkout with the expected payload shape", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "cs_test", url: "https://checkout.stripe.com/pay/cs_test" }),
|
||||
});
|
||||
|
||||
const result = await startCheckout("pro", "acme");
|
||||
|
||||
expect(result.url).toBe("https://checkout.stripe.com/pay/cs_test");
|
||||
const call = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const url = call[0] as string;
|
||||
const init = call[1] as RequestInit;
|
||||
|
||||
expect(url).toContain("/cp/billing/checkout");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.credentials).toBe("include");
|
||||
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.org_slug).toBe("acme");
|
||||
expect(body.plan).toBe("pro");
|
||||
expect(body.success_url).toContain("checkout=success");
|
||||
expect(body.cancel_url).toContain("checkout=cancel");
|
||||
});
|
||||
|
||||
it("throws with the body text on non-2xx so the UI can surface it", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 402,
|
||||
text: async () => "payment required",
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
await expect(startCheckout("starter", "acme")).rejects.toThrow(/402/);
|
||||
await expect(startCheckout("starter", "acme")).rejects.toThrow(/payment required/);
|
||||
});
|
||||
|
||||
it("uses current pathname for success/cancel URLs", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ url: "https://checkout.stripe.com/x" }),
|
||||
});
|
||||
await startCheckout("starter", "acme");
|
||||
const body = JSON.parse(
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body,
|
||||
);
|
||||
expect(body.success_url).toBe("http://localhost:3000/pricing?checkout=success");
|
||||
expect(body.cancel_url).toBe("http://localhost:3000/pricing?checkout=cancel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("openBillingPortal", () => {
|
||||
it("POSTs to /cp/billing/portal and returns the URL", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ url: "https://billing.stripe.com/session/xyz" }),
|
||||
});
|
||||
const url = await openBillingPortal("acme");
|
||||
expect(url).toBe("https://billing.stripe.com/session/xyz");
|
||||
|
||||
const call = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(call[0]).toContain("/cp/billing/portal");
|
||||
const body = JSON.parse((call[1] as RequestInit).body as string);
|
||||
expect(body.org_slug).toBe("acme");
|
||||
expect(body.return_url).toBe("http://localhost:3000/pricing");
|
||||
});
|
||||
|
||||
it("throws on non-2xx", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => "boom",
|
||||
json: async () => ({}),
|
||||
});
|
||||
await expect(openBillingPortal("acme")).rejects.toThrow(/500/);
|
||||
});
|
||||
});
|
||||
141
canvas/src/lib/billing.ts
Normal file
141
canvas/src/lib/billing.ts
Normal file
@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Canvas-side billing helper. Talks to the control plane's /cp/billing/*
|
||||
* routes — these exist on the `molecule-cp` app in prod and are mirrored
|
||||
* via fly-replay from tenant subdomains. Dev requires a locally-running
|
||||
* control plane on the same port as PLATFORM_URL or these calls 404.
|
||||
*/
|
||||
import { PLATFORM_URL } from "./api";
|
||||
|
||||
export type PlanId = "free" | "starter" | "pro";
|
||||
|
||||
/**
|
||||
* Plan is the static metadata a pricing card needs to render. Kept in
|
||||
* the frontend (not fetched from the API) because changing prices or
|
||||
* feature lists requires a deploy anyway — and most of the strings are
|
||||
* marketing copy that belongs with the rest of the UI.
|
||||
*/
|
||||
export interface Plan {
|
||||
id: PlanId;
|
||||
name: string;
|
||||
tagline: string;
|
||||
/** Human-readable price, e.g. "$0" or "$29/month". Stored as a string
|
||||
* so we don't accidentally leak per-tier pricing math to the client. */
|
||||
price: string;
|
||||
features: string[];
|
||||
/** CTA button label — varies per plan because free-tier is "Get started"
|
||||
* and paid tiers are "Upgrade to Pro" etc. */
|
||||
ctaLabel: string;
|
||||
/** Visual flag for the "most popular" highlight on the middle card. */
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
// plans is the canonical order shown on the pricing page: free → starter
|
||||
// → pro. Change the order here + the rendered columns follow. Keeping
|
||||
// this as a module-level const so tests can assert against a known list.
|
||||
export const plans: Plan[] = [
|
||||
{
|
||||
id: "free",
|
||||
name: "Free",
|
||||
tagline: "For tinkering + personal projects",
|
||||
price: "$0",
|
||||
features: [
|
||||
"3 workspaces",
|
||||
"Claude Code, LangGraph, OpenClaw runtimes",
|
||||
"Shared Redis + bounded storage",
|
||||
"Community support",
|
||||
],
|
||||
ctaLabel: "Get started",
|
||||
},
|
||||
{
|
||||
id: "starter",
|
||||
name: "Starter",
|
||||
tagline: "For small teams shipping real agents",
|
||||
price: "$29/month",
|
||||
features: [
|
||||
"10 workspaces",
|
||||
"All runtimes + plugins",
|
||||
"Private Upstash Redis namespace",
|
||||
"Email support (48h)",
|
||||
"5M LLM tokens / month included",
|
||||
],
|
||||
ctaLabel: "Upgrade to Starter",
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
tagline: "For production multi-agent orgs",
|
||||
price: "$99/month",
|
||||
features: [
|
||||
"Unlimited workspaces",
|
||||
"Dedicated Fly Machine per tenant",
|
||||
"Cross-workspace A2A audit log",
|
||||
"Priority support (24h)",
|
||||
"25M LLM tokens / month included",
|
||||
"Usage-based overage billing",
|
||||
],
|
||||
ctaLabel: "Upgrade to Pro",
|
||||
},
|
||||
];
|
||||
|
||||
export interface CheckoutResponse {
|
||||
url: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* startCheckout asks the control plane to open a Stripe Checkout session
|
||||
* for the given org + plan, then returns the Stripe URL the caller
|
||||
* should window.location.href to. success_url and cancel_url default
|
||||
* to the current page with ?checkout=success / ?checkout=cancel query
|
||||
* params so the pricing page can display a confirmation banner.
|
||||
*
|
||||
* Throws on non-2xx (caller surfaces the error — the page renders a
|
||||
* toast). Does NOT automatically redirect the browser; the caller
|
||||
* decides when to navigate.
|
||||
*/
|
||||
export async function startCheckout(
|
||||
plan: Exclude<PlanId, "free">,
|
||||
orgSlug: string,
|
||||
): Promise<CheckoutResponse> {
|
||||
const returnBase =
|
||||
typeof window !== "undefined" ? window.location.origin + window.location.pathname : "";
|
||||
const res = await fetch(`${PLATFORM_URL}/cp/billing/checkout`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
org_slug: orgSlug,
|
||||
plan,
|
||||
success_url: `${returnBase}?checkout=success`,
|
||||
cancel_url: `${returnBase}?checkout=cancel`,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`checkout: ${res.status} ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* openBillingPortal bounces the user to Stripe's hosted customer portal
|
||||
* so they can update their card, cancel, or download invoices. Same
|
||||
* error-handling contract as startCheckout.
|
||||
*/
|
||||
export async function openBillingPortal(orgSlug: string): Promise<string> {
|
||||
const returnUrl =
|
||||
typeof window !== "undefined" ? window.location.href : "";
|
||||
const res = await fetch(`${PLATFORM_URL}/cp/billing/portal`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ org_slug: orgSlug, return_url: returnUrl }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`portal: ${res.status} ${text}`);
|
||||
}
|
||||
const data = (await res.json()) as { url: string };
|
||||
return data.url;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user