Merge pull request #994 from Molecule-AI/feat/canvas-post-checkout-redirect

feat(canvas): post-checkout UX — Stripe success lands on /orgs with live banner
This commit is contained in:
Hongming Wang 2026-04-19 04:32:02 -07:00 committed by GitHub
commit ede6597cc0
3 changed files with 66 additions and 11 deletions

View File

@ -37,10 +37,26 @@ export default function OrgsPage() {
const [session, setSession] = useState<Session | null | "loading">("loading");
const [orgs, setOrgs] = useState<Org[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [justCheckedOut, setJustCheckedOut] = useState(false);
useEffect(() => {
// URLSearchParams is safe on the first render because this component
// is "use client" — window exists. Clear the flag from the URL so
// reloading the page doesn't keep showing the banner indefinitely.
if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
if (params.get("checkout") === "success") {
setJustCheckedOut(true);
window.history.replaceState({}, "", window.location.pathname);
}
}
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
let pollTimer: ReturnType<typeof setTimeout> | null = null;
const fetchOrgs = async () => {
try {
const sess = await fetchSession();
if (cancelled) return;
@ -58,15 +74,29 @@ export default function OrgsPage() {
}
const body = (await res.json()) as { orgs?: Org[] } | Org[];
const list = Array.isArray(body) ? body : body.orgs ?? [];
if (!cancelled) setOrgs(list);
if (cancelled) return;
setOrgs(list);
// Poll while anything is still moving so the user sees the
// status flip live after a Stripe Checkout. 5s is frequent
// enough to feel responsive, slow enough to not DoS the CP.
const stillMoving = list.some(
(o) => o.status === "provisioning" || o.status === "awaiting_payment"
);
if (stillMoving) {
pollTimer = setTimeout(fetchOrgs, 5_000);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
}
}
})();
};
fetchOrgs();
return () => {
cancelled = true;
if (pollTimer) clearTimeout(pollTimer);
};
}, []);
@ -87,10 +117,11 @@ export default function OrgsPage() {
);
}
if (!orgs || orgs.length === 0) {
return <EmptyState />;
return <EmptyState banner={justCheckedOut ? <CheckoutBanner /> : null} />;
}
return (
<Shell>
{justCheckedOut && <CheckoutBanner />}
<ul className="space-y-3">
{orgs.map((o) => (
<OrgRow key={o.id} org={o} />
@ -109,6 +140,17 @@ export default function OrgsPage() {
);
}
function CheckoutBanner() {
return (
<div className="mb-6 rounded-lg border border-emerald-700 bg-emerald-950 p-4">
<p className="text-sm text-emerald-200">
Payment confirmed. Your workspace is spinning up now this page
refreshes automatically when it&apos;s ready.
</p>
</div>
);
}
function Shell({ children }: { children: React.ReactNode }) {
return (
<main className="min-h-screen bg-zinc-950 text-zinc-100">
@ -195,9 +237,10 @@ function OrgCTA({ org }: { org: Org }) {
return <span className="text-sm text-zinc-500">{org.status}</span>;
}
function EmptyState() {
function EmptyState({ banner }: { banner?: React.ReactNode }) {
return (
<Shell>
{banner}
<p className="text-zinc-300">
You don&apos;t have any organizations yet. Create one to get started your
workspace spins up automatically once billing is set up.

View File

@ -82,7 +82,11 @@ describe("startCheckout", () => {
await expect(startCheckout("starter", "acme")).rejects.toThrow(/payment required/);
});
it("uses current pathname for success/cancel URLs", async () => {
it("sends users to /orgs on success, back to current page on cancel", async () => {
// success_url is fixed to /orgs regardless of where checkout was
// initiated — that's the landing page where post-payment status
// transitions are visible. cancel_url preserves the current page
// so users land back on /pricing and can retry.
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({ url: "https://checkout.stripe.com/x" }),
@ -91,7 +95,7 @@ describe("startCheckout", () => {
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.success_url).toBe("http://localhost:3000/orgs?checkout=success");
expect(body.cancel_url).toBe("http://localhost:3000/pricing?checkout=cancel");
});
});

View File

@ -98,8 +98,16 @@ export async function startCheckout(
plan: Exclude<PlanId, "free">,
orgSlug: string,
): Promise<CheckoutResponse> {
const returnBase =
typeof window !== "undefined" ? window.location.origin + window.location.pathname : "";
// On success, send the user to /orgs so they can watch their newly-
// paid org move from awaiting_payment → provisioning → running.
// Landing back on /pricing (the old default) left people staring at
// plan cards with no indication anything happened.
// On cancel, keep them on the current page so they can retry.
const origin = typeof window !== "undefined" ? window.location.origin : "";
const cancelBase =
typeof window !== "undefined"
? window.location.origin + window.location.pathname
: "";
const res = await fetch(`${PLATFORM_URL}/cp/billing/checkout`, {
method: "POST",
credentials: "include",
@ -107,8 +115,8 @@ export async function startCheckout(
body: JSON.stringify({
org_slug: orgSlug,
plan,
success_url: `${returnBase}?checkout=success`,
cancel_url: `${returnBase}?checkout=cancel`,
success_url: `${origin}/orgs?checkout=success`,
cancel_url: `${cancelBase}?checkout=cancel`,
}),
});
if (!res.ok) {