feat(canvas): post-checkout UX — Stripe success lands on /orgs with banner

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) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-19 04:18:32 -07:00
parent 08e37f3c87
commit d77378294b
2 changed files with 60 additions and 9 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

@ -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) {