[backport cloud/1.38] feat: invite member upsell for single-seat plans (#8822)

Backport of #8801 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8822-backport-cloud-1-38-feat-invite-member-upsell-for-single-seat-plans-3056d73d3650815586bdfdaba8a7ae2e)
by [Unito](https://www.unito.io)

Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Comfy Org PR Bot
2026-02-12 14:51:49 +09:00
committed by GitHub
parent 7afe45fdf8
commit f9e526e4a8
16 changed files with 411 additions and 129 deletions

View File

@@ -6,14 +6,15 @@
<!-- Section Header --> <!-- Section Header -->
<div class="flex w-full items-center gap-9"> <div class="flex w-full items-center gap-9">
<div class="flex min-w-0 flex-1 items-baseline gap-2"> <div class="flex min-w-0 flex-1 items-baseline gap-2">
<span <span class="text-base font-semibold text-base-foreground">
v-if="uiConfig.showMembersList"
class="text-base font-semibold text-base-foreground"
>
<template v-if="activeView === 'active'"> <template v-if="activeView === 'active'">
{{ {{
$t('workspacePanel.members.membersCount', { $t('workspacePanel.members.membersCount', {
count: members.length count:
isSingleSeatPlan || isPersonalWorkspace
? 1
: members.length,
maxSeats: maxSeats
}) })
}} }}
</template> </template>
@@ -27,7 +28,10 @@
</template> </template>
</span> </span>
</div> </div>
<div v-if="uiConfig.showSearch" class="flex items-start gap-2"> <div
v-if="uiConfig.showSearch && !isSingleSeatPlan"
class="flex items-start gap-2"
>
<SearchBox <SearchBox
v-model="searchQuery" v-model="searchQuery"
:placeholder="$t('g.search')" :placeholder="$t('g.search')"
@@ -45,14 +49,16 @@
:class=" :class="
cn( cn(
'grid w-full items-center py-2', 'grid w-full items-center py-2',
activeView === 'pending' isSingleSeatPlan
? uiConfig.pendingGridCols ? 'grid-cols-1 py-0'
: uiConfig.headerGridCols : activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
) )
" "
> >
<!-- Tab buttons in first column --> <!-- Tab buttons in first column -->
<div class="flex items-center gap-2"> <div v-if="!isSingleSeatPlan" class="flex items-center gap-2">
<Button <Button
:variant=" :variant="
activeView === 'active' ? 'secondary' : 'muted-textonly' activeView === 'active' ? 'secondary' : 'muted-textonly'
@@ -101,17 +107,19 @@
<div /> <div />
</template> </template>
<template v-else> <template v-else>
<Button <template v-if="!isSingleSeatPlan">
variant="muted-textonly" <Button
size="sm" variant="muted-textonly"
class="justify-end" size="sm"
@click="toggleSort('joinDate')" class="justify-end"
> @click="toggleSort('joinDate')"
{{ $t('workspacePanel.members.columns.joinDate') }} >
<i class="icon-[lucide--chevrons-up-down] size-4" /> {{ $t('workspacePanel.members.columns.joinDate') }}
</Button> <i class="icon-[lucide--chevrons-up-down] size-4" />
<!-- Empty cell for action column header (OWNER only) --> </Button>
<div v-if="permissions.canRemoveMembers" /> <!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canRemoveMembers" />
</template>
</template> </template>
</div> </div>
@@ -166,7 +174,7 @@
:class=" :class="
cn( cn(
'grid w-full items-center rounded-lg p-2', 'grid w-full items-center rounded-lg p-2',
uiConfig.membersGridCols, isSingleSeatPlan ? 'grid-cols-1' : uiConfig.membersGridCols,
index % 2 === 1 && 'bg-secondary-background/50' index % 2 === 1 && 'bg-secondary-background/50'
) )
" "
@@ -206,14 +214,14 @@
</div> </div>
<!-- Join date --> <!-- Join date -->
<span <span
v-if="uiConfig.showDateColumn" v-if="uiConfig.showDateColumn && !isSingleSeatPlan"
class="text-sm text-muted-foreground text-right" class="text-sm text-muted-foreground text-right"
> >
{{ formatDate(member.joinDate) }} {{ formatDate(member.joinDate) }}
</span> </span>
<!-- Remove member action (OWNER only, can't remove yourself) --> <!-- Remove member action (OWNER only, can't remove yourself) -->
<div <div
v-if="permissions.canRemoveMembers" v-if="permissions.canRemoveMembers && !isSingleSeatPlan"
class="flex items-center justify-end" class="flex items-center justify-end"
> >
<Button <Button
@@ -237,8 +245,29 @@
</template> </template>
</template> </template>
<!-- Upsell Banner -->
<div
v-if="isSingleSeatPlan"
class="flex items-center gap-2 rounded-xl border bg-secondary-background border-border-default px-4 py-3 mt-4 justify-center"
>
<p class="m-0 text-sm text-foreground">
{{
isActiveSubscription
? $t('workspacePanel.members.upsellBannerUpgrade')
: $t('workspacePanel.members.upsellBannerSubscribe')
}}
</p>
<Button
variant="muted-textonly"
class="cursor-pointer underline text-sm"
@click="showSubscriptionDialog()"
>
{{ $t('workspacePanel.members.viewPlans') }}
</Button>
</div>
<!-- Pending Invites --> <!-- Pending Invites -->
<template v-else> <template v-if="activeView === 'pending'">
<div <div
v-for="(invite, index) in filteredPendingInvites" v-for="(invite, index) in filteredPendingInvites"
:key="invite.id" :key="invite.id"
@@ -342,6 +371,8 @@ import SearchBox from '@/components/common/SearchBox.vue'
import UserAvatar from '@/components/common/UserAvatar.vue' import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser' import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI' import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type { import type {
PendingInvite, PendingInvite,
@@ -367,6 +398,27 @@ const {
} = storeToRefs(workspaceStore) } = storeToRefs(workspaceStore)
const { copyInviteLink } = workspaceStore const { copyInviteLink } = workspaceStore
const { permissions, uiConfig } = useWorkspaceUI() const { permissions, uiConfig } = useWorkspaceUI()
const {
isActiveSubscription,
subscription,
showSubscriptionDialog,
getMaxSeats
} = useBillingContext()
const maxSeats = computed(() => {
if (isPersonalWorkspace.value) return 1
const tier = subscription.value?.tier
if (!tier) return 1
const tierKey = TIER_TO_KEY[tier]
if (!tierKey) return 1
return getMaxSeats(tierKey)
})
const isSingleSeatPlan = computed(() => {
if (isPersonalWorkspace.value) return false
if (!isActiveSubscription.value) return true
return maxSeats.value <= 1
})
const searchQuery = ref('') const searchQuery = ref('')
const activeView = ref<'active' | 'pending'>('active') const activeView = ref<'active' | 'pending'>('active')

View File

@@ -55,8 +55,12 @@
" "
variant="secondary" variant="secondary"
size="lg" size="lg"
:disabled="isInviteLimitReached" :disabled="!isSingleSeatPlan && isInviteLimitReached"
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'" :class="
!isSingleSeatPlan &&
isInviteLimitReached &&
'opacity-50 cursor-not-allowed'
"
:aria-label="$t('workspacePanel.inviteMember')" :aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember" @click="handleInviteMember"
> >
@@ -129,6 +133,8 @@ import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue' import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import { buttonVariants } from '@/components/ui/button/button.variants' import { buttonVariants } from '@/components/ui/button/button.variants'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue' import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI' import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
@@ -144,8 +150,19 @@ const {
showLeaveWorkspaceDialog, showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog, showDeleteWorkspaceDialog,
showInviteMemberDialog, showInviteMemberDialog,
showInviteMemberUpsellDialog,
showEditWorkspaceDialog showEditWorkspaceDialog
} = useDialogService() } = useDialogService()
const { isActiveSubscription, subscription, getMaxSeats } = useBillingContext()
const isSingleSeatPlan = computed(() => {
if (!isActiveSubscription.value) return true
const tier = subscription.value?.tier
if (!tier) return true
const tierKey = TIER_TO_KEY[tier]
if (!tierKey) return true
return getMaxSeats(tierKey) <= 1
})
const workspaceStore = useTeamWorkspaceStore() const workspaceStore = useTeamWorkspaceStore()
const { const {
workspaceName, workspaceName,
@@ -187,11 +204,16 @@ const deleteTooltip = computed(() => {
}) })
const inviteTooltip = computed(() => { const inviteTooltip = computed(() => {
if (isSingleSeatPlan.value) return null
if (!isInviteLimitReached.value) return null if (!isInviteLimitReached.value) return null
return t('workspacePanel.inviteLimitReached') return t('workspacePanel.inviteLimitReached')
}) })
function handleInviteMember() { function handleInviteMember() {
if (isSingleSeatPlan.value) {
showInviteMemberUpsellDialog()
return
}
if (isInviteLimitReached.value) return if (isInviteLimitReached.value) return
showInviteMemberDialog() showInviteMemberDialog()
} }

View File

@@ -0,0 +1,68 @@
<template>
<div
class="flex w-full max-w-[512px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.titleSingleSeat')
: $t('workspacePanel.inviteUpsellDialog.titleNotSubscribed')
}}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onDismiss"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.messageSingleSeat')
: $t('workspacePanel.inviteUpsellDialog.messageNotSubscribed')
}}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onDismiss">
{{ $t('g.cancel') }}
</Button>
<Button variant="primary" size="lg" @click="onUpgrade">
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.upgradeToCreator')
: $t('workspacePanel.inviteUpsellDialog.viewPlans')
}}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
function onDismiss() {
dialogStore.closeDialog({ key: 'invite-member-upsell' })
}
function onUpgrade() {
dialogStore.closeDialog({ key: 'invite-member-upsell' })
showSubscriptionDialog()
}
</script>

View File

@@ -0,0 +1,42 @@
<template>
<Toast group="invite-accepted" position="top-right">
<template #message="slotProps">
<div class="flex items-center gap-2 justify-between w-full">
<div class="flex flex-col justify-start">
<div class="text-base">
{{ slotProps.message.summary }}
</div>
<div class="mt-1 text-sm text-foreground">
{{ slotProps.message.detail.text }} <br />
{{ slotProps.message.detail.workspaceName }}
</div>
</div>
<Button
size="md"
variant="inverted"
@click="viewWorkspace(slotProps.message.detail.workspaceId)"
>
{{ t('workspace.viewWorkspace') }}
</Button>
</div>
</template>
</Toast>
</template>
<script setup lang="ts">
import { useToast } from 'primevue'
import Toast from 'primevue/toast'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
const { t } = useI18n()
const toast = useToast()
const { switchWithConfirmation } = useWorkspaceSwitch()
function viewWorkspace(workspaceId: string) {
void switchWithConfirmation(workspaceId)
toast.removeGroup('invite-accepted')
}
</script>

View File

@@ -1,5 +1,6 @@
import type { ComputedRef, Ref } from 'vue' import type { ComputedRef, Ref } from 'vue'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { import type {
Plan, Plan,
PreviewSubscribeResponse, PreviewSubscribeResponse,
@@ -73,4 +74,5 @@ export interface BillingState {
export interface BillingContext extends BillingState, BillingActions { export interface BillingContext extends BillingState, BillingActions {
type: ComputedRef<BillingType> type: ComputedRef<BillingType>
getMaxSeats: (tierKey: TierKey) => number
} }

View File

@@ -1,25 +1,50 @@
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Plan } from '@/platform/workspace/api/workspaceApi'
import { useBillingContext } from './useBillingContext' import { useBillingContext } from './useBillingContext'
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => { const { mockTeamWorkspacesEnabled, mockIsPersonal, mockPlans } = vi.hoisted(
const isInPersonalWorkspace = { value: true } () => ({
const activeWorkspace = { value: { id: 'personal-123', type: 'personal' } } mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] }
})
)
vi.mock('@vueuse/core', async (importOriginal) => {
const original = await importOriginal()
return { return {
useTeamWorkspaceStore: () => ({ ...(original as Record<string, unknown>),
isInPersonalWorkspace: isInPersonalWorkspace.value, createSharedComposable: (fn: (...args: unknown[]) => unknown) => fn
activeWorkspace: activeWorkspace.value,
_setPersonalWorkspace: (value: boolean) => {
isInPersonalWorkspace.value = value
activeWorkspace.value = value
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
}
})
} }
}) })
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get isInPersonalWorkspace() {
return mockIsPersonal.value
},
get activeWorkspace() {
return mockIsPersonal.value
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
},
updateActiveWorkspace: vi.fn()
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({ useSubscription: () => ({
isActiveSubscription: { value: true }, isActiveSubscription: { value: true },
@@ -52,20 +77,18 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}) })
})) }))
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => { vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => ({
const plans = { value: [] } useBillingPlans: () => ({
const currentPlanSlug = { value: null } get plans() {
return { return mockPlans
useBillingPlans: () => ({ },
plans, currentPlanSlug: { value: null },
currentPlanSlug, isLoading: { value: false },
isLoading: { value: false }, error: { value: null },
error: { value: null }, fetchPlans: vi.fn().mockResolvedValue(undefined),
fetchPlans: vi.fn().mockResolvedValue(undefined), getPlanBySlug: vi.fn().mockReturnValue(null)
getPlanBySlug: vi.fn().mockReturnValue(null) })
}) }))
}
})
vi.mock('@/platform/workspace/api/workspaceApi', () => ({ vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: { workspaceApi: {
@@ -88,6 +111,9 @@ describe('useBillingContext', () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
vi.clearAllMocks() vi.clearAllMocks()
mockTeamWorkspacesEnabled.value = false
mockIsPersonal.value = true
mockPlans.value = []
}) })
it('returns legacy type for personal workspace', () => { it('returns legacy type for personal workspace', () => {
@@ -161,4 +187,51 @@ describe('useBillingContext', () => {
const { showSubscriptionDialog } = useBillingContext() const { showSubscriptionDialog } = useBillingContext()
expect(() => showSubscriptionDialog()).not.toThrow() expect(() => showSubscriptionDialog()).not.toThrow()
}) })
describe('getMaxSeats', () => {
it('returns 1 for personal workspaces regardless of tier', () => {
const { getMaxSeats } = useBillingContext()
expect(getMaxSeats('standard')).toBe(1)
expect(getMaxSeats('creator')).toBe(1)
expect(getMaxSeats('pro')).toBe(1)
expect(getMaxSeats('founder')).toBe(1)
})
it('falls back to hardcoded values when no API plans available', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
const { getMaxSeats } = useBillingContext()
expect(getMaxSeats('standard')).toBe(1)
expect(getMaxSeats('creator')).toBe(5)
expect(getMaxSeats('pro')).toBe(20)
expect(getMaxSeats('founder')).toBe(1)
})
it('prefers API max_seats when plans are loaded', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockPlans.value = [
{
slug: 'pro-monthly',
tier: 'PRO',
duration: 'MONTHLY',
price_cents: 10000,
credits_cents: 2110000,
max_seats: 50,
availability: { available: true },
seat_summary: {
seat_count: 1,
total_cost_cents: 10000,
total_credits_cents: 2110000
}
}
]
const { getMaxSeats } = useBillingContext()
expect(getMaxSeats('pro')).toBe(50)
// Tiers without API plans still fall back to hardcoded values
expect(getMaxSeats('creator')).toBe(5)
})
})
}) })

View File

@@ -2,6 +2,11 @@ import { computed, ref, shallowRef, toValue, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core' import { createSharedComposable } from '@vueuse/core'
import { useFeatureFlags } from '@/composables/useFeatureFlags' import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
KEY_TO_TIER,
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore' import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type { import type {
@@ -115,6 +120,16 @@ function useBillingContextInternal(): BillingContext {
toValue(activeContext.value.isActiveSubscription) toValue(activeContext.value.isActiveSubscription)
) )
function getMaxSeats(tierKey: TierKey): number {
if (type.value === 'legacy') return 1
const apiTier = KEY_TO_TIER[tierKey]
const plan = plans.value.find(
(p) => p.tier === apiTier && p.duration === 'MONTHLY'
)
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
}
// Sync subscription info to workspace store for display in workspace switcher // Sync subscription info to workspace store for display in workspace switcher
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled // A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
// This ensures the delete button is enabled after cancellation, even before the period ends // This ensures the delete button is enabled after cancellation, even before the period ends
@@ -223,6 +238,7 @@ function useBillingContextInternal(): BillingContext {
isLoading, isLoading,
error, error,
isActiveSubscription, isActiveSubscription,
getMaxSeats,
initialize, initialize,
fetchStatus, fetchStatus,

View File

@@ -2117,7 +2117,7 @@
"subscribeNow": "Subscribe Now", "subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud", "subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"workspaceNotSubscribed": "This workspace is not on a subscription", "workspaceNotSubscribed": "This workspace is not on a subscription",
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud", "subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud and invite members",
"contactOwnerToSubscribe": "Contact the workspace owner to subscribe", "contactOwnerToSubscribe": "Contact the workspace owner to subscribe",
"description": "Choose the best plan for you", "description": "Choose the best plan for you",
"descriptionWorkspace": "Choose the best plan for your workspace", "descriptionWorkspace": "Choose the best plan for your workspace",
@@ -2206,7 +2206,7 @@
"placeholder": "Dashboard workspace settings" "placeholder": "Dashboard workspace settings"
}, },
"members": { "members": {
"membersCount": "{count}/50 Members", "membersCount": "{count}/{maxSeats} Members",
"pendingInvitesCount": "{count} pending invite | {count} pending invites", "pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": { "tabs": {
"active": "Active", "active": "Active",
@@ -2222,6 +2222,9 @@
"revokeInvite": "Revoke invite", "revokeInvite": "Revoke invite",
"removeMember": "Remove member" "removeMember": "Remove member"
}, },
"upsellBannerSubscribe": "Subscribe to the Creator plan or above to invite team members to this workspace.",
"upsellBannerUpgrade": "Upgrade to the Creator plan or above to invite additional team members.",
"viewPlans": "View plans",
"noInvites": "No pending invites", "noInvites": "No pending invites",
"noMembers": "No members", "noMembers": "No members",
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,", "personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
@@ -2260,6 +2263,14 @@
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.", "message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
"revoke": "Uninvite" "revoke": "Uninvite"
}, },
"inviteUpsellDialog": {
"titleNotSubscribed": "A subscription is required to invite members",
"titleSingleSeat": "Your current plan supports a single seat",
"messageNotSubscribed": "To add team members to this workspace, you need a Creator plan or above. The Standard plan supports only a single seat (the owner).",
"messageSingleSeat": "The Standard plan includes one seat for the workspace owner. To invite additional members, upgrade to the Creator plan or above to unlock multiple seats.",
"viewPlans": "View Plans",
"upgradeToCreator": "Upgrade to Creator"
},
"inviteMemberDialog": { "inviteMemberDialog": {
"title": "Invite a person to this workspace", "title": "Invite a person to this workspace",
"message": "Create a shareable invite link to send to someone", "message": "Create a shareable invite link to send to someone",
@@ -2888,8 +2899,9 @@
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?" "message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
}, },
"inviteAccepted": "Invite Accepted", "inviteAccepted": "Invite Accepted",
"addedToWorkspace": "You have been added to {workspaceName}", "addedToWorkspace": "You have been added to:",
"inviteFailed": "Failed to Accept Invite" "inviteFailed": "Failed to Accept Invite",
"viewWorkspace": "View workspace"
}, },
"workspaceAuth": { "workspaceAuth": {
"errors": { "errors": {

View File

@@ -375,7 +375,8 @@ const {
plans: apiPlans, plans: apiPlans,
currentPlanSlug, currentPlanSlug,
fetchPlans, fetchPlans,
subscription subscription,
getMaxSeats
} = useBillingContext() } = useBillingContext()
const isCancelled = computed(() => subscription.value?.isCancelled ?? false) const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
@@ -405,11 +406,6 @@ function getPriceFromApi(tier: PricingTierConfig): number | null {
return currentBillingCycle.value === 'yearly' ? price / 12 : price return currentBillingCycle.value === 'yearly' ? price / 12 : price
} }
function getMaxSeatsFromApi(tier: PricingTierConfig): number | null {
const plan = getApiPlanForTier(tier.key, 'monthly')
return plan ? plan.max_seats : null
}
const currentTierKey = computed<TierKey | null>(() => const currentTierKey = computed<TierKey | null>(() =>
subscription.value?.tier ? TIER_TO_KEY[subscription.value.tier] : null subscription.value?.tier ? TIER_TO_KEY[subscription.value.tier] : null
) )
@@ -494,8 +490,7 @@ const getAnnualTotal = (tier: PricingTierConfig): number => {
return plan ? plan.price_cents / 100 : tier.pricing.yearly * 12 return plan ? plan.price_cents / 100 : tier.pricing.yearly * 12
} }
const getMaxMembers = (tier: PricingTierConfig): number => const getMaxMembers = (tier: PricingTierConfig): number => getMaxSeats(tier.key)
getMaxSeatsFromApi(tier) ?? tier.maxMembers
const getMonthlyCreditsPerMember = (tier: PricingTierConfig): number => const getMonthlyCreditsPerMember = (tier: PricingTierConfig): number =>
tier.pricing.credits tier.pricing.credits

View File

@@ -88,10 +88,13 @@
</div> </div>
<div class="flex items-baseline gap-1 font-inter font-semibold"> <div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">${{ tierPrice }}</span> <span class="text-2xl">${{ tierPrice }}</span>
<span class="text-base" <span class="text-base">
>{{ $t('subscription.perMonth') }} / {{
{{ $t('subscription.member') }}</span isInPersonalWorkspace
> ? $t('subscription.usdPerMonth')
: $t('subscription.usdPerMonthPerMember')
}}
</span>
</div> </div>
<div <div
v-if="isActiveSubscription" v-if="isActiveSubscription"
@@ -176,7 +179,7 @@
<div class="flex flex-col"> <div class="flex flex-col">
<div class="flex flex-col gap-3 h-full"> <div class="flex flex-col gap-3 h-full">
<div <div
class="relative flex flex-col gap-6 rounded-2xl p-5 bg-modal-panel-background justify-between h-full" class="relative flex flex-col gap-6 rounded-2xl p-5 bg-secondary-background justify-between h-full"
> >
<Button <Button
variant="muted-textonly" variant="muted-textonly"
@@ -359,7 +362,6 @@ import { useSubscriptionActions } from '@/platform/cloud/subscription/composable
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits' import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi' import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useDialogService } from '@/services/dialogService' import { useDialogService } from '@/services/dialogService'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { import {
DEFAULT_TIER_KEY, DEFAULT_TIER_KEY,
TIER_TO_KEY, TIER_TO_KEY,
@@ -388,7 +390,7 @@ const {
manageSubscription, manageSubscription,
fetchStatus, fetchStatus,
fetchBalance, fetchBalance,
plans: apiPlans getMaxSeats
} = useBillingContext() } = useBillingContext()
const { showCancelSubscriptionDialog } = useDialogService() const { showCancelSubscriptionDialog } = useDialogService()
@@ -511,23 +513,6 @@ const tierPrice = computed(() =>
const memberCount = computed(() => members.value.length) const memberCount = computed(() => members.value.length)
const nextMonthInvoice = computed(() => memberCount.value * tierPrice.value) const nextMonthInvoice = computed(() => memberCount.value * tierPrice.value)
function getApiPlanForTier(tierKey: TierKey, duration: 'monthly' | 'yearly') {
const apiDuration = duration === 'yearly' ? 'ANNUAL' : 'MONTHLY'
const apiTier = tierKey.toUpperCase()
return apiPlans.value.find(
(p) => p.tier === apiTier && p.duration === apiDuration
)
}
function getMaxSeatsFromApi(tierKey: TierKey): number | null {
const plan = getApiPlanForTier(tierKey, 'monthly')
return plan ? plan.max_seats : null
}
function getMaxMembers(tierKey: TierKey): number {
return getMaxSeatsFromApi(tierKey) ?? getTierFeatures(tierKey).maxMembers
}
const refillsDate = computed(() => { const refillsDate = computed(() => {
if (!subscription.value?.renewalDate) return '' if (!subscription.value?.renewalDate) return ''
const date = new Date(subscription.value.renewalDate) const date = new Date(subscription.value.renewalDate)
@@ -571,13 +556,18 @@ interface Benefit {
const tierBenefits = computed((): Benefit[] => { const tierBenefits = computed((): Benefit[] => {
const key = tierKey.value const key = tierKey.value
const benefits: Benefit[] = [ const benefits: Benefit[] = []
{
if (!isInPersonalWorkspace.value) {
benefits.push({
key: 'members', key: 'members',
type: 'icon', type: 'icon',
label: t('subscription.membersLabel', { count: getMaxMembers(key) }), label: t('subscription.membersLabel', { count: getMaxSeats(key) }),
icon: 'pi pi-user' icon: 'pi pi-user'
}, })
}
benefits.push(
{ {
key: 'maxDuration', key: 'maxDuration',
type: 'metric', type: 'metric',
@@ -594,7 +584,7 @@ const tierBenefits = computed((): Benefit[] => {
type: 'feature', type: 'feature',
label: t('subscription.addCreditsLabel') label: t('subscription.addCreditsLabel')
} }
] )
if (getTierFeatures(key).customLoRAs) { if (getTierFeatures(key).customLoRAs) {
benefits.push({ benefits.push({

View File

@@ -11,6 +11,13 @@ export const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
FOUNDERS_EDITION: 'founder' FOUNDERS_EDITION: 'founder'
} }
export const KEY_TO_TIER: Record<TierKey, SubscriptionTier> = {
standard: 'STANDARD',
creator: 'CREATOR',
pro: 'PRO',
founder: 'FOUNDERS_EDITION'
}
export interface TierPricing { export interface TierPricing {
monthly: number monthly: number
yearly: number yearly: number

View File

@@ -239,15 +239,6 @@ interface CreateTopupResponse {
amount_cents: number amount_cents: number
} }
interface TopupStatusResponse {
topup_id: string
status: TopupStatus
amount_cents: number
error_message?: string
created_at: string
completed_at?: string
}
type BillingOpStatus = 'pending' | 'succeeded' | 'failed' type BillingOpStatus = 'pending' | 'succeeded' | 'failed'
export interface BillingOpStatusResponse { export interface BillingOpStatusResponse {
@@ -701,23 +692,6 @@ export const workspaceApi = {
} }
}, },
/**
* Get top-up status
* GET /api/billing/topup/:id
*/
async getTopupStatus(topupId: string): Promise<TopupStatusResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<TopupStatusResponse>(
api.apiURL(`/billing/topup/${topupId}`),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/** /**
* Get billing events * Get billing events
* GET /api/billing/events * GET /api/billing/events

View File

@@ -130,8 +130,13 @@ describe('useInviteUrlLoader', () => {
expect(mockToastAdd).toHaveBeenCalledWith({ expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success', severity: 'success',
summary: 'Invite Accepted', summary: 'Invite Accepted',
detail: 'You have been added to Test Workspace', detail: {
life: 5000 text: 'You have been added to Test Workspace',
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
},
group: 'invite-accepted',
closable: true
}) })
}) })

View File

@@ -81,12 +81,17 @@ export function useInviteUrlLoader() {
toast.add({ toast.add({
severity: 'success', severity: 'success',
summary: t('workspace.inviteAccepted'), summary: t('workspace.inviteAccepted'),
detail: t( detail: {
'workspace.addedToWorkspace', text: t(
{ workspaceName: result.workspaceName }, 'workspace.addedToWorkspace',
{ escapeParameter: false } { workspaceName: result.workspaceName },
), { escapeParameter: false }
life: 5000 ),
workspaceName: result.workspaceName,
workspaceId: result.workspaceId
},
group: 'invite-accepted',
closable: true
}) })
} catch (error) { } catch (error) {
toast.add({ toast.add({

View File

@@ -624,6 +624,22 @@ export const useDialogService = () => {
}) })
} }
async function showInviteMemberUpsellDialog() {
const { default: component } =
await import('@/components/dialog/content/workspace/InviteMemberUpsellDialogContent.vue')
return dialogStore.showDialog({
key: 'invite-member-upsell',
component,
dialogComponentProps: {
...workspaceDialogPt,
pt: {
...workspaceDialogPt.pt,
root: { class: 'rounded-2xl max-w-[512px] w-full' }
}
}
})
}
async function showRevokeInviteDialog(inviteId: string) { async function showRevokeInviteDialog(inviteId: string) {
const { default: component } = const { default: component } =
await import('@/components/dialog/content/workspace/RevokeInviteDialogContent.vue') await import('@/components/dialog/content/workspace/RevokeInviteDialogContent.vue')
@@ -695,6 +711,7 @@ export const useDialogService = () => {
showRemoveMemberDialog, showRemoveMemberDialog,
showRevokeInviteDialog, showRevokeInviteDialog,
showInviteMemberDialog, showInviteMemberDialog,
showInviteMemberUpsellDialog,
showBillingComingSoonDialog, showBillingComingSoonDialog,
showCancelSubscriptionDialog showCancelSubscriptionDialog
} }

View File

@@ -16,6 +16,7 @@
</div> </div>
<GlobalToast /> <GlobalToast />
<InviteAcceptedToast />
<RerouteMigrationToast /> <RerouteMigrationToast />
<ModelImportProgressDialog /> <ModelImportProgressDialog />
<ManagerProgressToast /> <ManagerProgressToast />
@@ -44,6 +45,7 @@ import MenuHamburger from '@/components/MenuHamburger.vue'
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue' import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
import GraphCanvas from '@/components/graph/GraphCanvas.vue' import GraphCanvas from '@/components/graph/GraphCanvas.vue'
import GlobalToast from '@/components/toast/GlobalToast.vue' import GlobalToast from '@/components/toast/GlobalToast.vue'
import InviteAcceptedToast from '@/components/toast/InviteAcceptedToast.vue'
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue' import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle' import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
import { useCoreCommands } from '@/composables/useCoreCommands' import { useCoreCommands } from '@/composables/useCoreCommands'