From 67b029eada9ec8045235adb5132d706f3bc4b289 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Wed, 13 May 2026 16:05:59 +0300 Subject: [PATCH] fix(billing): redirect free workspace to checkout instead of alerting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A free workspace clicking "Connect repository" hit `POST /api/workspaces/:id/projects`, got a 402 with `requiresCheckout: true`, but the dialog only surfaced a toast — the plan-selection modal was never opened, leaving the user stuck. Make 402 + `requiresCheckout` open the plan modal globally via a shared `usePlanModal` composable, triggered from `resolveApiError` as a side effect so every existing catch site (ConnectRepoDialog, useChat, useBranches, useContentEditor, useMembers, ContentCollectionView…) routes locked workspaces through the checkout flow rather than dying as a toast. - New `usePlanModal` composable backed by `useState` for SSR-safe shared state - `resolveApiError` opens the modal on 402 + `requiresCheckout` - `default` layout mounts `PlanSelectionModal` (was only on `workspace` layout) - `workspace` layout switches to the same global state for consistency - `ConnectRepoDialog` closes its own dialog on 402 so the modal lands on top --- .../organisms/ConnectRepoDialog.vue | 12 ++++++++++ app/composables/usePlanModal.ts | 22 +++++++++++++++++++ app/layouts/default.vue | 11 ++++++++++ app/layouts/workspace.vue | 4 ++-- app/utils/api-error.ts | 15 ++++++++++++- 5 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 app/composables/usePlanModal.ts diff --git a/app/components/organisms/ConnectRepoDialog.vue b/app/components/organisms/ConnectRepoDialog.vue index d84f0533..fa36e5ba 100644 --- a/app/components/organisms/ConnectRepoDialog.vue +++ b/app/components/organisms/ConnectRepoDialog.vue @@ -129,6 +129,18 @@ async function connectRepo() { open.value = false } catch (e: unknown) { + const status = (e as { statusCode?: number, data?: { statusCode?: number, requiresCheckout?: boolean } }).statusCode + ?? (e as { data?: { statusCode?: number } }).data?.statusCode + const requiresCheckout = (e as { data?: { requiresCheckout?: boolean } }).data?.requiresCheckout + + // Plan modal is opened globally by resolveApiError. Close this dialog so + // the user lands on the plan picker, not a stacked surface. + if (status === 402 && requiresCheckout) { + open.value = false + resolveApiError(e, t('projects.connected_error')) + return + } + toast.error(resolveApiError(e, t('projects.connected_error'))) } finally { diff --git a/app/composables/usePlanModal.ts b/app/composables/usePlanModal.ts new file mode 100644 index 00000000..46f4c49e --- /dev/null +++ b/app/composables/usePlanModal.ts @@ -0,0 +1,22 @@ +/** + * Global plan-selection modal state. + * + * Mounted once per layout (`default`, `workspace`) and triggered from + * anywhere via `usePlanModal().show()`. The primary trigger is the + * client-side 402 + `requiresCheckout` handler in `resolveApiError`, + * so any API call that hits a billing-locked state surfaces the + * checkout flow instead of dying as a toast. + */ +export function usePlanModal() { + const open = useState('plan-modal-open', () => false) + + function show() { + open.value = true + } + + function hide() { + open.value = false + } + + return { open, show, hide } +} diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 768f5e2c..7bc093a4 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -2,6 +2,8 @@ const { t } = useContent() const { open: commandPaletteOpen } = useCommandPalette() const { toggle: toggleMobileSidebar } = useMobileSidebar() +const { open: planModalOpen } = usePlanModal() +const deployment = useDeployment() diff --git a/app/layouts/workspace.vue b/app/layouts/workspace.vue index 956e7d41..c2d52b1d 100644 --- a/app/layouts/workspace.vue +++ b/app/layouts/workspace.vue @@ -1,6 +1,6 @@