forked from molecule-ai/molecule-core
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:
commit
845ac47147
@ -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'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't have any organizations yet. Create one to get started — your
|
||||
workspace spins up automatically once billing is set up.
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user