Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions canvas/src/app/pricing/page.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 142 additions & 0 deletions canvas/src/components/PricingTable.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 166 additions & 0 deletions canvas/src/components/__tests__/PricingTable.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading