From d77378294b4a8c49afe8b15d59b92fabcd6cd5e2 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 19 Apr 2026 04:18:32 -0700 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20post-checkout=20UX=20=E2=80=94?= =?UTF-8?q?=20Stripe=20success=20lands=20on=20/orgs=20with=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small polish items that together close the signup-to-running-tenant flow for real users: 1. Stripe success_url now points at /orgs?checkout=success instead of the current page (was pricing). The old behavior left people staring at plan cards with no indication payment went through — the new behavior drops them right onto their org list where they can watch the status flip. 2. /orgs shows a green "Payment confirmed, workspace spinning up" banner when it sees ?checkout=success, then clears the query param via replaceState so a reload doesn't show it again. 3. /orgs now polls every 5s while any org is awaiting_payment or provisioning. Users see the Stripe webhook's effect live — no manual refresh needed — and once every org settles the polling stops so idle tabs don't hammer /cp/orgs. Paired with PR #992 (the /orgs page itself) this makes the end-to-end flow on BILLING_REQUIRED=true deployments feel right: /pricing → Stripe → /orgs?checkout=success → banner → live poll → "Open" button when org.status transitions to running. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/app/orgs/page.tsx | 53 ++++++++++++++++++++++++++++++++---- canvas/src/lib/billing.ts | 16 ++++++++--- 2 files changed, 60 insertions(+), 9 deletions(-) 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/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) {