diff --git a/canvas/src/app/orgs/page.tsx b/canvas/src/app/orgs/page.tsx index 955d3f46..2d62bebe 100644 --- a/canvas/src/app/orgs/page.tsx +++ b/canvas/src/app/orgs/page.tsx @@ -37,10 +37,26 @@ export default function OrgsPage() { const [session, setSession] = useState("loading"); const [orgs, setOrgs] = useState(null); const [error, setError] = useState(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 | 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 ; + return : null} />; } return ( + {justCheckedOut && }
    {orgs.map((o) => ( @@ -109,6 +140,17 @@ export default function OrgsPage() { ); } +function CheckoutBanner() { + return ( +
    +

    + ✓ Payment confirmed. Your workspace is spinning up now — this page + refreshes automatically when it's ready. +

    +
    + ); +} + function Shell({ children }: { children: React.ReactNode }) { return (
    @@ -195,9 +237,10 @@ function OrgCTA({ org }: { org: Org }) { return {org.status}…; } -function EmptyState() { +function EmptyState({ banner }: { banner?: React.ReactNode }) { return ( + {banner}

    You don't have any organizations yet. Create one to get started — your workspace spins up automatically once billing is set up. diff --git a/canvas/src/lib/__tests__/billing.test.ts b/canvas/src/lib/__tests__/billing.test.ts index d4f4cd28..9572296c 100644 --- a/canvas/src/lib/__tests__/billing.test.ts +++ b/canvas/src/lib/__tests__/billing.test.ts @@ -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).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).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"); }); }); diff --git a/canvas/src/lib/billing.ts b/canvas/src/lib/billing.ts index 31e79db8..35f9833d 100644 --- a/canvas/src/lib/billing.ts +++ b/canvas/src/lib/billing.ts @@ -98,8 +98,16 @@ export async function startCheckout( plan: Exclude, orgSlug: string, ): Promise { - 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) {