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:
parent
08e37f3c87
commit
d77378294b
@ -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.
|
||||
|
||||
@ -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