Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/components/organisms/ConnectRepoDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions app/composables/usePlanModal.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>('plan-modal-open', () => false)

function show() {
open.value = true
}

function hide() {
open.value = false
}

return { open, show, hide }
}
11 changes: 11 additions & 0 deletions app/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
const { t } = useContent()
const { open: commandPaletteOpen } = useCommandPalette()
const { toggle: toggleMobileSidebar } = useMobileSidebar()
const { open: planModalOpen } = usePlanModal()
const deployment = useDeployment()
</script>

<template>
Expand Down Expand Up @@ -37,5 +39,14 @@ const { toggle: toggleMobileSidebar } = useMobileSidebar()
<ClientOnly>
<OrganismsCommandPalette v-model:open="commandPaletteOpen" />
</ClientOnly>

<!-- Plan selection modal — opened by usePlanModal() globally on
402 + requiresCheckout, plus any explicit upgrade CTA. Hidden
on profiles without managed billing. -->
<OrganismsPlanSelectionModal
v-if="deployment.hasManagedBilling.value"
:open="planModalOpen"
@update:open="planModalOpen = $event"
/>
</div>
</template>
4 changes: 2 additions & 2 deletions app/layouts/workspace.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
const contextOpen = ref(true)
const planModalOpen = ref(false)
const { open: planModalOpen, show: showPlanModal } = usePlanModal()

const { openPortal } = useBilling()
const deployment = useDeployment()
Expand All @@ -24,7 +24,7 @@ provide('contextPanel', { open: contextOpen, toggle: toggleContext })
<!-- Main -->
<main class="flex min-w-0 flex-1 flex-col overflow-y-auto">
<MoleculesTrialBanner
@choose-plan="planModalOpen = true"
@choose-plan="showPlanModal()"
@manage-billing="openPortal()"
/>
<slot />
Expand Down
15 changes: 14 additions & 1 deletion app/utils/api-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
interface FetchError {
statusCode?: number
status?: number
data?: { message?: string, statusCode?: number, statusMessage?: string }
data?: { message?: string, statusCode?: number, statusMessage?: string, requiresCheckout?: boolean }
message?: string
}

Expand Down Expand Up @@ -44,6 +44,19 @@ export function resolveApiError(error: unknown, fallback: string): string {
?? err.data?.statusCode
?? 0

// 402 + requiresCheckout → trigger the global plan-selection modal so the
// user can pay instead of staring at a toast. Falls back to toast text below
// when no modal is mounted (e.g. /auth, error page).
if (status === 402 && err.data?.requiresCheckout && import.meta.client) {
try {
usePlanModal().show()
}
catch {
// No Nuxt context (rare — e.g. utility called outside setup). Swallow;
// the caller will still render the toast string from the return value.
}
}

// 4xx client errors — backend message is user-friendly (from errorMessage())
if (status >= 400 && status < 500) {
const message = err.data?.message ?? err.message
Expand Down
Loading