Skip to content

Checkout buttons: spinner + direct Stripe redirect (no blank /api page) - #7810

Merged
lawrencecchen merged 1 commit into
mainfrom
feat-checkout-loading
Jul 10, 2026
Merged

lawrencecchen merged 1 commit into
mainfrom
feat-checkout-loading

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jul 10, 2026 •

Copy link
Copy Markdown
Contributor

Clicking Get Pro / Get Team navigated to /api/billing/checkout, which built the Stripe session server-side and then 302'd to Stripe — so you saw that route's blank page for the 300–800ms round-trip. Now the buttons show a spinner and redirect straight to Stripe.

How

  • Route: GET /api/billing/checkout?format=json runs the exact same logic, then returns the destination as { url } instead of a 302. The url is whatever it would have redirected to (the Stripe Checkout URL on success, or a /pricing?... state URL otherwise), so the client just navigates to it either way. The plain navigation still 302s — a no-JS fallback.
  • Client: CheckoutButton / useCheckoutRedirect render as an <a> (open-in-new-tab and no-JS still work) but intercept a plain left-click: spinner on, fetch the JSON, window.location.assign(url). Wired into the Get Pro / Get Team CTAs on app-pricing and [locale]/pricing, and into ProCtaLink (the PostHog-flagged pro CTA). Download/fallback links are untouched.

No hover prefetch (it would mint side-effectful Stripe sessions for everyone who hovers) and no new user-facing strings (the spinner is visual + aria-busy).

Verification

  • bun run typecheck clean; full web suite green.
  • New route tests: format=json returns the Stripe URL as JSON on success, and the /pricing?billing=unavailable destination as JSON when Stripe is unconfigured (no session created).

Web-only (no Swift); Vercel preview below.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Touches the billing checkout entry path and client redirect behavior; server checkout logic is unchanged with 302 and fetch fallbacks, but a JSON/client bug could affect paid upgrade flows.

Overview
Get Pro / Get Team no longer send users through a blank /api/billing/checkout page while Stripe sessions are created. Checkout CTAs stay real <a> links (no-JS and open-in-new-tab still work), but a plain left-click shows a spinner and navigates straight to the final destination.

The checkout API gains ?format=json, which runs the same logic as before and returns { url } (Stripe Checkout or a /pricing?billing=… URL) instead of a 302. CheckoutButton and useCheckoutRedirect fetch that JSON and call window.location.assign; on failure they fall back to the original href. ProCtaLink only intercepts the checkout href—the download fallback is unchanged.

Wired on [locale]/pricing, app-pricing, and compare-table team CTAs; tests cover JSON success and unconfigured Stripe.

Reviewed by Cursor Bugbot for commit c3f97ec. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Checkout buttons now show a spinner and redirect straight to Stripe, removing the blank /api page flash. No-JS users still get the normal 302 redirect.

  • New Features
    • GET /api/billing/checkout?format=json returns { url } for the same destination the route would 302 to; default navigation still 302s.
    • Added CheckoutButton and useCheckoutRedirect: anchors that intercept a plain left-click, show a spinner, fetch JSON, and window.location.assign(url); open-in-new-tab and no-JS work.
    • Integrated into app-pricing, [locale]/pricing, and ProCtaLink; download/fallback links unchanged.
    • Added tests for JSON responses when Stripe is configured or unavailable.

Written for commit c3f97ec. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added streamlined checkout navigation for Pro and Team pricing actions.
    • Checkout links now show a loading indicator and prevent duplicate interactions while processing.
    • Added JSON support for retrieving checkout destinations.
  • Bug Fixes

    • Checkout failures now fall back to standard navigation.
    • Billing-unavailable scenarios provide a consistent fallback destination.
  • Tests

    • Added coverage for successful and unavailable JSON checkout responses.

Buttons fetch the resolved checkout URL as JSON and redirect straight to Stripe
with a spinner, instead of navigating to /api/billing/checkout and flashing its
blank page while the Stripe session is built server-side.

- checkout route: ?format=json returns { url } (the same destination it would
  302 to), reusing all existing logic; plain nav still 302s (no-JS fallback).
- CheckoutButton + useCheckoutRedirect (progressive enhancement: <a> that
  intercepts a plain left-click, shows a spinner, redirects).
- Wired into app-pricing + [locale]/pricing Get Pro / Get Team CTAs and the
  ProCtaLink pro CTA. Download/fallback links untouched. No new strings.
@vercel

vercel Bot commented Jul 10, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jul 10, 2026 11:25am
cmux-staging Building Building Preview, Comment Jul 10, 2026 11:25am

@coderabbitai

coderabbitai Bot commented Jul 10, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Checkout links now support JSON-resolved destinations, client-side redirect handling with pending indicators, and fallback navigation. Pricing CTAs use the shared checkout button, while route tests cover configured and unavailable billing responses.

Changes

Checkout flow

Layer / File(s) Summary
JSON checkout response contract
web/app/api/billing/checkout/route.ts, web/tests/billing-checkout-route.test.ts
The checkout route returns { url } for format=json while preserving redirects, with tests for Stripe-enabled and unavailable billing states.
Client checkout navigation
web/app/components/checkout-navigation.tsx
Checkout clicks are intercepted for JSON resolution, pending state displays a spinner, successful responses redirect to the returned URL, and failures fall back to normal navigation.
Pricing CTA integration
web/app/[locale]/components/pro-cta-link.tsx, web/app/[locale]/pricing/page.tsx, web/app/app-pricing/page.tsx
Pricing Pro and Team CTAs use checkout navigation, preserve compact sizing, retain analytics, and disable pointer interaction while pending.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Visitor
  participant CheckoutButton
  participant CheckoutRoute
  participant Stripe
  Visitor->>CheckoutButton: Click checkout link
  CheckoutButton->>CheckoutRoute: Request checkout URL as JSON
  CheckoutRoute->>Stripe: Create checkout session
  Stripe-->>CheckoutRoute: Return session URL
  CheckoutRoute-->>CheckoutButton: Return JSON URL
  CheckoutButton->>Visitor: Navigate to checkout
Loading

Possibly related PRs

  • manaflow-ai/cmux#6791: Added the locale pricing page and its CTA/link structure later replaced here.
  • manaflow-ai/cmux#7143: Introduced checkout resolution and redirect behavior extended here with JSON responses.
  • manaflow-ai/cmux#7662: Changed the same checkout route behavior that now supports client-side JSON redirect handling.
🚥 Pre-merge checks | ✅ 24 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers summary and testing, but it omits the required Demo Video, Review Trigger, and Checklist sections from the template. Add the missing template sections: a Demo Video link or attachment, the Review Trigger comment block, and the checklist items; also align the summary and testing headings to the template.
✅ Passed checks (24 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the checkout spinner and direct Stripe redirect change.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed No Swift production files were changed; the diff is web-only, so actor-isolation rules aren’t applicable.
Cmux Swift Blocking Runtime ✅ Passed The PR changes only web TS/TSX files; no Swift files or Xcode project files are modified, so the Swift blocking-runtime rule is not applicable.
Cmux Browser Automation Off-Main ✅ Passed PR only changes web checkout/pricing files; no cmux browser socket automation or WebKit/AppKit routing code was modified.
Cmux Expensive Synchronous Load ✅ Passed HEAD only changes TS/TSX web files; no Swift files or main-actor/history loads are introduced, so the Swift sync-load rule is not applicable.
Cmux Cache Substitution Correctness ✅ Passed Checkout JSON still uses the fresh route result; no persistence/history/undo/snapshot cache substitution, only transient spinner UI state.
Cmux No Hacky Sleeps ✅ Passed No fixed waits, polling, or timers were introduced; the new spinner is presentation-only and redirects on fetch completion.
Cmux Algorithmic Complexity ✅ Passed No new scalable collection rescans or repeated filtering/sorting were introduced; changes are click/redirect plumbing plus a JSON wrapper around existing checkout logic.
Cmux Swift Concurrency ✅ Passed The PR diff only changes web TypeScript/TSX files; no cmux-owned Swift code is introduced or modified.
Cmux Swift @Concurrent ✅ Passed No Swift files are changed in this PR; the diff is entirely web/TypeScript, so the Swift concurrent-annotation rule does not apply.
Cmux Swift File And Package Boundaries ✅ Passed No Swift files or package-boundary changes are in this diff; the PR is web-only, so the Swift boundary rule is not implicated.
Cmux Swiftpm Lockfiles ✅ Passed PR only changes web TS files; no SwiftPM/Xcode/.gitignore/workflow or Package.resolved files were touched, so the lockfile rule isn’t implicated.
Cmux Swift Logging ✅ Passed The PR changes only web TypeScript files; no Swift runtime files or logging statements were added or modified.
Cmux User-Facing Error Privacy ✅ Passed No new user-facing error text was added; the route JSON returns only redirect URLs, and visible billing banners stay generic (billingError/billingUnavailable).
Cmux Full Internationalization ✅ Passed The PR adds no new user-facing copy; CTAs reuse existing next-intl/enMessages text, and no web/messages or locale files were changed.
Cmux Swiftui State Layout ✅ Passed No Swift/SwiftUI files are changed in this diff, so the SwiftUI state/layout rule is not applicable.
Cmux Architecture Rethink ✅ Passed No Swift files changed; the PR only touches web TypeScript/route code, so the Swift architectural rethink rule doesn’t apply.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No Swift files or cmux window code changed; the PR is web-only, so the auxiliary-window shortcut rule is not applicable.
Cmux Source Artifacts ✅ Passed All changed paths are source/test files; no artifact, cache, build-output, or scratch-directory paths appear in the diff.
Cmux No Test Or Debug Seam In Production Source ✅ Passed The PR only changes web TS/TSX files; no Swift files under production Sources/ are in the diff.
Cmux No Ambient Global State ✅ Passed No Swift files were changed; the ambient-global-state rule is not applicable to this PR.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-checkout-loading

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@lawrencecchen
lawrencecchen enabled auto-merge (squash) July 10, 2026 04:47

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c3f97ec. Configure here.

) {
return;
}
if (!href.startsWith(CHECKOUT_PATH)) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolute checkout URLs not intercepted

Medium Severity

useCheckoutRedirect only treats hrefs that start with the relative path /api/billing/checkout as checkout links. app-pricing passes absolute URLs from appPricingCheckoutURL (for example https://cmux.com/api/billing/checkout?plan=pro&…), so start exits early, the click is not intercepted, and those buttons never show the spinner or use format=json—they still do a full navigation through the blank checkout route.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c3f97ec. Configure here.

@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR changes checkout CTAs to fetch the redirect target before navigating. The main changes are:

  • Added format=json support to the billing checkout route.
  • Added a client checkout button with spinner state.
  • Replaced pricing-page checkout links with the new button.
  • Added route tests for JSON checkout responses.

Confidence Score: 4/5

The changed checkout flow needs fixes before merging. Absolute app-pricing links still use the old blank-page path, and the JSON wrapper can lose anonymous-session cookies.

  • Absolute same-origin checkout URLs are not intercepted by the new client hook.
  • The JSON route wrapper does not preserve response side effects from the existing redirect flow.
  • Rapid activation can create extra Stripe checkout sessions.

web/app/components/checkout-navigation.tsx, web/app/api/billing/checkout/route.ts

Important Files Changed

Filename Overview
web/app/components/checkout-navigation.tsx Adds the spinner-based checkout navigation hook and button; absolute same-origin URLs are not intercepted, and rapid activation can create duplicate checkout sessions.
web/app/api/billing/checkout/route.ts Adds JSON checkout responses; the wrapper can drop response side effects from the existing redirect path.
web/app/app-pricing/page.tsx Uses the new checkout button for app-pricing Pro and Team CTAs.
web/app/[locale]/pricing/page.tsx Uses the new checkout button for localized Team CTAs.
web/app/[locale]/components/pro-cta-link.tsx Adds spinner behavior to the Pro CTA while preserving the download fallback.
web/tests/billing-checkout-route.test.ts Adds coverage for JSON checkout success and Stripe-unavailable fallback responses.

Reviews (1): Last reviewed commit: "checkout: spinner on Get Pro/Team, no bl..." | Re-trigger Greptile

) {
return;
}
if (!href.startsWith(CHECKOUT_PATH)) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Absolute Checkout URLs Bypass Spinner

When app-pricing passes an absolute same-origin checkout URL, this raw prefix check returns false because the string starts with https://... instead of /api/.... Those buttons keep doing the old full navigation to /api/billing/checkout, so users still see the blank API page before Stripe.

Suggested change
if (!href.startsWith(CHECKOUT_PATH)) return;
const checkoutUrl = new URL(href, window.location.origin);
if (checkoutUrl.origin !== window.location.origin || checkoutUrl.pathname !== CHECKOUT_PATH) return;

Comment on lines +39 to +41
return NextResponse.json({
url: location ?? new URL("/pricing?billing=error", request.url).toString(),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Anonymous Checkout Drops Cookies

The JSON path builds a fresh response and only copies the redirect URL. On signed-out checkout, resolveCheckout() can create an anonymous Stack user before redirecting; if that sets the session cookie on the original response, the browser reaches Stripe but returns to /api/billing/complete without the anonymous session needed to attach the purchase to that user.

Comment on lines +35 to +36
if (pending) return;
setPending(true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Pending Guard Allows Duplicate Sessions

The duplicate-click guard depends on React state that updates after the handler returns. A fast second activation can run the old callback with pending === false, issue a second format=json fetch, and create another Stripe Checkout Session before the first redirect wins.

@lawrencecchen
lawrencecchen merged commit e8287e6 into main Jul 10, 2026
31 of 33 checks passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@web/app/components/checkout-navigation.tsx`:
- Around line 33-34: Update the checkout link handling in the checkout
navigation component to parse href with the URL API and compare its pathname
against CHECKOUT_PATH, rather than using href.startsWith. Preserve the existing
event prevention and loading behavior after matching checkout URLs, including
absolute URLs returned by appPricingCheckoutURL.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 778f9fed-f388-49a7-ae8e-cd209fefcae9

📥 Commits

Reviewing files that changed from the base of the PR and between f38b303 and c3f97ec.

📒 Files selected for processing (6)
  • web/app/[locale]/components/pro-cta-link.tsx
  • web/app/[locale]/pricing/page.tsx
  • web/app/api/billing/checkout/route.ts
  • web/app/app-pricing/page.tsx
  • web/app/components/checkout-navigation.tsx
  • web/tests/billing-checkout-route.test.ts

Comment on lines +33 to +34
if (!href.startsWith(CHECKOUT_PATH)) return;
event?.preventDefault();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 1. Check billing URL helper implementations and path constants
rg -n -A10 'withCheckoutExternalBrowserIntent|appPricingCheckoutURL|TEAM_CHECKOUT_PATH|PRO_CHECKOUT_PATH' web/app/lib/billing.ts

# 2. Check if --button-foreground CSS variable is defined (affects CheckoutButton vs ProCtaLink color consistency)
rg -n -e '--button-foreground' web/ -g '*.css' -g '*.scss' -g '*.tsx'

# 3. Check PrimaryLink style for comparison (previous component replaced by CheckoutButton)
rg -n -A15 'function PrimaryLink' web/app/components/pricing-shared.tsx

Repository: manaflow-ai/cmux

Length of output: 2391


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== checkout-navigation =="
sed -n '1,220p' web/app/components/checkout-navigation.tsx

echo
echo "== billing helpers =="
sed -n '1,220p' web/app/lib/billing.ts

echo
echo "== app-pricing page call site =="
sed -n '1,220p' web/app/app-pricing/page.tsx

echo
echo "== any origin-sensitive checkout URL builder =="
rg -n -A8 -B4 'configuredAppPricingCheckoutURL|appPricingCheckoutURL|withCheckoutExternalBrowserIntent|CHECKOUT_EXTERNAL_BROWSER_PARAM|CHECKOUT_PATH' web/app/lib web/app/app-pricing

Repository: manaflow-ai/cmux

Length of output: 20607


Match checkout links by pathname, not raw string prefix

appPricingCheckoutURL(...) can return absolute URLs, so href.startsWith(CHECKOUT_PATH) skips interception on the app-pricing page and the button falls back to a plain navigation with no spinner. Normalize the URL and compare the pathname instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/components/checkout-navigation.tsx` around lines 33 - 34, Update the
checkout link handling in the checkout navigation component to parse href with
the URL API and compare its pathname against CHECKOUT_PATH, rather than using
href.startsWith. Preserve the existing event prevention and loading behavior
after matching checkout URLs, including absolute URLs returned by
appPricingCheckoutURL.

This branch was successfully deployed

1 active deployment
Preview – cmux — c3f97ec4 Deployed Jul 10, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant