mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 02:02:08 +00:00
[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:
@@ -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')
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
42
src/components/toast/InviteAcceptedToast.vue
Normal file
42
src/components/toast/InviteAcceptedToast.vue
Normal 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>
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user