feat: differentiate personal/team pricing table with two-stage team workspace flow (#9901)

## Summary

Differentiates the subscription pricing dialog between personal and team
workspaces with distinct visual treatments and a two-stage team
workspace upgrade flow.

### Changes

- **Personal pricing dialog**: Shows "P" avatar badge, "Plans for
Personal Workspace" header, and "Solo use only – Need team workspace?"
banner on each tier card
- **Team pricing dialog**: Shows workspace avatar, "Plans for Team
Workspace" header (emerald), green "Invite up to X members" badge, and
emerald border on Creator card
- **Two-stage upgrade flow**: "Need team workspace?" → closes pricing →
opens CreateWorkspaceDialog → sessionStorage flag → page reload →
WorkspaceAuthGate auto-opens team pricing dialog
- **Spacing**: Reduced vertical gaps/padding/font sizes so the table
fits without scrolling

### Key decisions

- sessionStorage key `comfy:resume-team-pricing` bridges the page reload
during workspace creation
- `onChooseTeam` prop is conditionally passed only to the personal
variant
- `resumePendingPricingFlow()` is called from WorkspaceAuthGate after
workspace initialization

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9901-feat-differentiate-personal-team-pricing-table-with-two-stage-team-workspace-flow-3226d73d365081e7af60dcca86e83673)
by [Unito](https://www.unito.io)
This commit is contained in:
Hunter
2026-03-23 09:17:19 -07:00
committed by GitHub
parent bd322314bc
commit cd45efa983
32 changed files with 1426 additions and 154 deletions

View File

@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useCreateWorkspaceUrlLoader } from './useCreateWorkspaceUrlLoader'
const preservedQueryMocks = vi.hoisted(() => ({
clearPreservedQuery: vi.fn(),
hydratePreservedQuery: vi.fn(),
mergePreservedQueryIntoQuery: vi.fn()
}))
vi.mock(
'@/platform/navigation/preservedQueryManager',
() => preservedQueryMocks
)
const mockRouteQuery = vi.hoisted(() => ({
value: {} as Record<string, string>
}))
const mockRouterReplace = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
vi.mock('vue-router', () => ({
useRoute: () => ({
query: mockRouteQuery.value
}),
useRouter: () => ({
replace: mockRouterReplace
})
}))
const mockShowTeamWorkspacesDialog = vi.hoisted(() =>
vi.fn().mockResolvedValue(undefined)
)
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showTeamWorkspacesDialog: mockShowTeamWorkspacesDialog
})
}))
describe('useCreateWorkspaceUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRouteQuery.value = {}
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('loadCreateWorkspaceFromUrl', () => {
it('does nothing when no create_workspace param present', async () => {
mockRouteQuery.value = {}
const { loadCreateWorkspaceFromUrl } = useCreateWorkspaceUrlLoader()
await loadCreateWorkspaceFromUrl()
expect(mockShowTeamWorkspacesDialog).not.toHaveBeenCalled()
expect(mockRouterReplace).not.toHaveBeenCalled()
})
it('opens create workspace dialog when param is present', async () => {
mockRouteQuery.value = { create_workspace: '1' }
const { loadCreateWorkspaceFromUrl } = useCreateWorkspaceUrlLoader()
await loadCreateWorkspaceFromUrl()
expect(mockShowTeamWorkspacesDialog).toHaveBeenCalledOnce()
})
it('restores preserved query and opens dialog', async () => {
mockRouteQuery.value = {}
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue({
create_workspace: '1'
})
const { loadCreateWorkspaceFromUrl } = useCreateWorkspaceUrlLoader()
await loadCreateWorkspaceFromUrl()
expect(preservedQueryMocks.hydratePreservedQuery).toHaveBeenCalledWith(
'create_workspace'
)
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { create_workspace: '1' }
})
expect(mockShowTeamWorkspacesDialog).toHaveBeenCalledOnce()
})
it('cleans up URL after processing', async () => {
mockRouteQuery.value = { create_workspace: '1', other: 'param' }
const { loadCreateWorkspaceFromUrl } = useCreateWorkspaceUrlLoader()
await loadCreateWorkspaceFromUrl()
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { other: 'param' }
})
})
it('clears preserved query after processing', async () => {
mockRouteQuery.value = { create_workspace: '1' }
const { loadCreateWorkspaceFromUrl } = useCreateWorkspaceUrlLoader()
await loadCreateWorkspaceFromUrl()
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'create_workspace'
)
})
it('ignores empty param', async () => {
mockRouteQuery.value = { create_workspace: '' }
const { loadCreateWorkspaceFromUrl } = useCreateWorkspaceUrlLoader()
await loadCreateWorkspaceFromUrl()
expect(mockShowTeamWorkspacesDialog).not.toHaveBeenCalled()
})
it('ignores non-string param', async () => {
mockRouteQuery.value = {
create_workspace: ['array'] as unknown as string
}
const { loadCreateWorkspaceFromUrl } = useCreateWorkspaceUrlLoader()
await loadCreateWorkspaceFromUrl()
expect(mockShowTeamWorkspacesDialog).not.toHaveBeenCalled()
})
})
})