mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 17:28:58 +00:00
Compare commits
234 Commits
codex/rum-
...
jaewon/bil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0e88ba824 | ||
|
|
1d9ea071f1 | ||
|
|
799655fc48 | ||
|
|
64284dcaac | ||
|
|
1235faf250 | ||
|
|
33402333e0 | ||
|
|
4c2f45814b | ||
|
|
6ffe346cf8 | ||
|
|
23ffc89ff2 | ||
|
|
e904ba98fb | ||
|
|
93c6d26ad4 | ||
|
|
9745e4d84a | ||
|
|
f746534e60 | ||
|
|
fe46beb137 | ||
|
|
7e779de532 | ||
|
|
4e609999d4 | ||
|
|
d526bcd3dc | ||
|
|
09dd1f4826 | ||
|
|
d29a0b4167 | ||
|
|
13ca482b77 | ||
|
|
3d228e3306 | ||
|
|
456cf7227e | ||
|
|
298529895d | ||
|
|
2d12005629 | ||
|
|
fc18ae7f8a | ||
|
|
dafcf206fb | ||
|
|
eef3d739aa | ||
|
|
9a0becfd06 | ||
|
|
de466b72e5 | ||
|
|
29df1542d5 | ||
|
|
67ea86e60c | ||
|
|
0ebf3618f6 | ||
|
|
b6909e37c7 | ||
|
|
0574c2971d | ||
|
|
fd79281e43 | ||
|
|
ab082bf952 | ||
|
|
6c3856d8c4 | ||
|
|
2b9dd72d87 | ||
|
|
a0c7127155 | ||
|
|
78dcc16a3b | ||
|
|
6ec0f331b3 | ||
|
|
e3c3473f97 | ||
|
|
a3858b39c4 | ||
|
|
47ec1de9d0 | ||
|
|
912320bb7d | ||
|
|
17bea9d97c | ||
|
|
6389549199 | ||
|
|
2bbbf65321 | ||
|
|
b86f24bd3c | ||
|
|
75da9212d1 | ||
|
|
757156b09d | ||
|
|
b2b1fe8730 | ||
|
|
f528e6b8b6 | ||
|
|
88133b0b56 | ||
|
|
74799d2e94 | ||
|
|
2b642e77f6 | ||
|
|
0073063bdb | ||
|
|
9fe12cf975 | ||
|
|
a4e53bdbe1 | ||
|
|
a5781c483b | ||
|
|
3b07b5e1c4 | ||
|
|
739b873dd6 | ||
|
|
c1b42b2743 | ||
|
|
e804967e1e | ||
|
|
18eced3ea0 | ||
|
|
54b869db95 | ||
|
|
6183bc5edc | ||
|
|
759e3e2daa | ||
|
|
195d244211 | ||
|
|
145ffd4c0c | ||
|
|
666f2a368c | ||
|
|
0234944159 | ||
|
|
eced564a5d | ||
|
|
08f4b0f926 | ||
|
|
7ce3276f76 | ||
|
|
120010863d | ||
|
|
56e5e2971e | ||
|
|
83f3ee65ae | ||
|
|
bd95849e4b | ||
|
|
ffc7643e9e | ||
|
|
9968380f1c | ||
|
|
68de563907 | ||
|
|
ba72b5c4e8 | ||
|
|
675d04af2b | ||
|
|
e8e5333c8c | ||
|
|
1984d57365 | ||
|
|
2a05922de5 | ||
|
|
5e17feb1e8 | ||
|
|
83e269a802 | ||
|
|
53b77adf41 | ||
|
|
0092a45983 | ||
|
|
2fb9313f7c | ||
|
|
0dea3481ec | ||
|
|
0913c88f8e | ||
|
|
3a480d17e7 | ||
|
|
917f1e8e53 | ||
|
|
c31774b54d | ||
|
|
6711b3d11f | ||
|
|
ec3dcf1c94 | ||
|
|
e1f251479c | ||
|
|
66d0b773d9 | ||
|
|
a51183ae8a | ||
|
|
0f645514ac | ||
|
|
55c00900dc | ||
|
|
81e0bd04b3 | ||
|
|
93bb2a0e9e | ||
|
|
96ac29b871 | ||
|
|
37270850f9 | ||
|
|
c4871bf432 | ||
|
|
0c8a8af6d9 | ||
|
|
9a3bad326c | ||
|
|
2f3a827b45 | ||
|
|
a0b254b982 | ||
|
|
98e558cf3d | ||
|
|
8c098ee213 | ||
|
|
e30d35e655 | ||
|
|
881cf3a663 | ||
|
|
341c8469fa | ||
|
|
1bc10fa6dc | ||
|
|
766c6ded82 | ||
|
|
65f5c02b16 | ||
|
|
33495bd015 | ||
|
|
071780c27c | ||
|
|
785f1bbf8c | ||
|
|
6f2ed144e7 | ||
|
|
564255fb32 | ||
|
|
1473639f85 | ||
|
|
19fb019555 | ||
|
|
e2b67ecca1 | ||
|
|
1231e8d8c0 | ||
|
|
c0f3f6fb69 | ||
|
|
75131c2614 | ||
|
|
5261bb361e | ||
|
|
9ef8266bae | ||
|
|
a7c8f3ad82 | ||
|
|
76422f6bc2 | ||
|
|
4fd8acc4ea | ||
|
|
d9f54420b6 | ||
|
|
1c97eb016c | ||
|
|
af110fc3a2 | ||
|
|
f8993d2279 | ||
|
|
dc6c48c6dd | ||
|
|
9e7043e4ee | ||
|
|
548e260be0 | ||
|
|
b4ee092fd3 | ||
|
|
d8774b7221 | ||
|
|
6583e65dca | ||
|
|
b1d5ff8094 | ||
|
|
c9b77dd0ee | ||
|
|
4e596e5e5f | ||
|
|
8526c5c872 | ||
|
|
9c6dd9c5a5 | ||
|
|
232310e4b8 | ||
|
|
5681b5d9e6 | ||
|
|
ef1d6293db | ||
|
|
e2f48b6120 | ||
|
|
893d734ee1 | ||
|
|
c5fd2a4117 | ||
|
|
11b54604c5 | ||
|
|
cd48dc9de9 | ||
|
|
5248addf15 | ||
|
|
bdfd94da3c | ||
|
|
e00b8cffe8 | ||
|
|
61085d5134 | ||
|
|
1ce8c5876d | ||
|
|
c7da4241e5 | ||
|
|
64b4410365 | ||
|
|
594145d31a | ||
|
|
bd4694185d | ||
|
|
271d3ea571 | ||
|
|
1b187e8acb | ||
|
|
cca62e703a | ||
|
|
5d882c6364 | ||
|
|
1ac4f2764d | ||
|
|
21998649f2 | ||
|
|
a2b84ec170 | ||
|
|
edfe2e690d | ||
|
|
ac3e816549 | ||
|
|
c06bb29020 | ||
|
|
e90b2618d2 | ||
|
|
85eb4ea741 | ||
|
|
2ca2360216 | ||
|
|
4b7a51ec29 | ||
|
|
8fa27a7732 | ||
|
|
ffc852137c | ||
|
|
51a064bba2 | ||
|
|
99d817dfc5 | ||
|
|
b75ebde746 | ||
|
|
4d414ca2d3 | ||
|
|
391f9e464a | ||
|
|
93d6c5c68d | ||
|
|
a7b284c73e | ||
|
|
5a7ff04b01 | ||
|
|
992da60ba7 | ||
|
|
2ccb689c87 | ||
|
|
2174f5b348 | ||
|
|
b80768fe90 | ||
|
|
d3f075938d | ||
|
|
6898024cc9 | ||
|
|
920e43aabd | ||
|
|
6f92f3835b | ||
|
|
68a548eb9b | ||
|
|
250a617d6c | ||
|
|
605b685184 | ||
|
|
26be182ff3 | ||
|
|
261406de3f | ||
|
|
b37884c465 | ||
|
|
fb6e84783f | ||
|
|
9e48c13d98 | ||
|
|
5924dd92b6 | ||
|
|
b695686504 | ||
|
|
e4ff4becb7 | ||
|
|
e12d3d7f40 | ||
|
|
f737d9b749 | ||
|
|
4b3b6a841c | ||
|
|
ff628e49e3 | ||
|
|
8b5e7d4ec5 | ||
|
|
0c6ca27823 | ||
|
|
7c4ffb3485 | ||
|
|
ff4a690496 | ||
|
|
2fd8f685ed | ||
|
|
72507cf225 | ||
|
|
6cadc4eb5a | ||
|
|
a013d4b123 | ||
|
|
f25ef177b8 | ||
|
|
dfd08cd13d | ||
|
|
e1bad829e7 | ||
|
|
71d1ece521 | ||
|
|
39854ae51b | ||
|
|
a0f2445a01 | ||
|
|
30e0e0b031 | ||
|
|
5efb45b407 | ||
|
|
99b5107275 | ||
|
|
d46846410a |
@@ -83,6 +83,16 @@ const config: StorybookConfig = {
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
|
||||
},
|
||||
{
|
||||
find: '@/composables/useFeatureFlags',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/stores/teamWorkspaceStore',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/teamWorkspaceStore.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/formatUtil',
|
||||
replacement:
|
||||
|
||||
161
browser_tests/tests/billingFacadeConsumers.spec.ts
Normal file
161
browser_tests/tests/billingFacadeConsumers.spec.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
/**
|
||||
* Billing facade consumers — FE-933 (B3) regression.
|
||||
*
|
||||
* The repointed surfaces (avatar popover tier badge / balance, free-tier
|
||||
* dialog renewal date) must keep rendering from `useBillingContext`, which in
|
||||
* a personal workspace routes through the legacy `/customers/*` endpoints
|
||||
* (mocked here). Drives a raw `page` (not the `comfyPage` fixture) so the
|
||||
* cloud app boots against fully mocked endpoints — same pattern as
|
||||
* creditsTile.spec.ts. `team_workspaces_enabled: false` keeps the topbar on
|
||||
* the legacy popover variant that FE-933 repointed.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
async function mockCloudBoot(
|
||||
page: Page,
|
||||
subscriptionStatus: CloudSubscriptionStatusResponse,
|
||||
remoteConfig: RemoteConfig = { team_workspaces_enabled: false }
|
||||
) {
|
||||
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(remoteConfig)))
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace: keeps the billing facade on the legacy
|
||||
// `/customers/*` path when team workspaces are enabled.
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: 'Personal Workspace',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
await page.route('**/customers/cloud-subscription-status', (r) =>
|
||||
r.fulfill(jsonRoute(subscriptionStatus))
|
||||
)
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
amount_micros: 6000, // -> 12,660 credits
|
||||
currency: 'usd',
|
||||
effective_balance_micros: 6000
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async function bootApp(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Billing facade consumers (FE-933)', { tag: '@cloud' }, () => {
|
||||
test('avatar popover renders tier badge and balance from the facade', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-02-20T10:00:00Z',
|
||||
end_date: null
|
||||
})
|
||||
await bootApp(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
const popover = page.locator('.current-user-popover')
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
await expect(popover.getByText('Pro', { exact: true })).toBeVisible()
|
||||
await expect(popover.getByText('12,660')).toBeVisible()
|
||||
await expect(popover.getByTestId('add-credits-button')).toBeVisible()
|
||||
})
|
||||
|
||||
test('free-tier dialog shows the renewal date from the facade', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
// Subscription gating is config-driven: with subscription_required on,
|
||||
// the cloud subscription extension calls requireActiveSubscription() at
|
||||
// boot, which opens the free-tier dialog for an inactive FREE user.
|
||||
// (refreshRemoteConfig overwrites window.__CONFIG__ from /api/features,
|
||||
// so the flag must come from the features mock, not an init script.)
|
||||
// The free-tier dialog branch additionally requires an active personal
|
||||
// workspace, so this boots with team workspaces enabled (production
|
||||
// shape) — the facade still routes personal through `/customers/*`.
|
||||
await mockCloudBoot(
|
||||
page,
|
||||
{
|
||||
is_active: false,
|
||||
subscription_tier: 'FREE',
|
||||
subscription_duration: 'MONTHLY',
|
||||
// 10:00Z keeps the en-US calendar date stable across CI timezones.
|
||||
renewal_date: '2099-02-20T10:00:00Z',
|
||||
end_date: null
|
||||
},
|
||||
{ team_workspaces_enabled: true, subscription_required: true }
|
||||
)
|
||||
await bootApp(page)
|
||||
|
||||
// T5: the dialog must source the date from facade renewalDate — when this
|
||||
// line read the legacy store it silently vanished for team users.
|
||||
await expect(
|
||||
page.getByText('Your credits refresh on Feb 20, 2099.')
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
231
browser_tests/tests/dialogs/creditsTile.spec.ts
Normal file
231
browser_tests/tests/dialogs/creditsTile.spec.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
|
||||
// devtools backend during setup.
|
||||
|
||||
/**
|
||||
* Credits tile (Settings ▸ Workspace ▸ Plan & Credits) — DES-247 / FE-964.
|
||||
*
|
||||
* The credits tile only lives inside the authenticated cloud app, which the
|
||||
* shared `comfyPage` fixture can't boot (it expects the OSS devtools backend).
|
||||
* Instead this drives a raw page: mock Firebase auth + every boot endpoint so
|
||||
* the cloud app initializes against fully stubbed data, with a single personal
|
||||
* workspace that routes the billing facade through the legacy `/customers/*`
|
||||
* endpoints (mocked with an active Pro subscription). The tile should then
|
||||
* render its total / progress bar / monthly+additional breakdown / add-credits.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
async function mockCloudBoot(page: Page) {
|
||||
// Frontend-origin boot endpoints (proxied to the backend in production).
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// `teamWorkspacesEnabled` from it (the `ff:` localStorage override is
|
||||
// dev-only), and the flag gates the Workspace settings panel.
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ team_workspaces_enabled: true } satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
// Include the mock user so the multi-user select screen auto-selects it
|
||||
// (paired with the `Comfy.userId` localStorage seed below).
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Workspace list — a single personal workspace keeps the billing facade on
|
||||
// the legacy `/customers/*` path.
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: 'Personal Workspace',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Legacy billing (api.comfy.org/customers/*).
|
||||
await page.route('**/customers/cloud-subscription-status', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-02-20T00:00:00Z',
|
||||
end_date: null
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
amount_micros: 6000, // -> 12,660 total credits
|
||||
currency: 'usd',
|
||||
effective_balance_micros: 6000,
|
||||
cloud_credit_balance_micros: 5000, // -> 10,550 monthly remaining
|
||||
prepaid_balance_micros: 1000 // -> 2,110 additional
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async function mockBalance(
|
||||
page: Page,
|
||||
balance: { amount: number; monthly: number; prepaid: number }
|
||||
) {
|
||||
await page.unroute('**/customers/balance')
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
amount_micros: balance.amount,
|
||||
currency: 'usd',
|
||||
effective_balance_micros: balance.amount,
|
||||
cloud_credit_balance_micros: balance.monthly,
|
||||
prepaid_balance_micros: balance.prepaid
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Boots the mocked cloud app and opens Settings ▸ Workspace ▸ Plan & Credits. */
|
||||
async function openPlanAndCredits(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
|
||||
// Pre-select the mock user to skip the user-select screen.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
// Open Settings ▸ Workspace.
|
||||
await page
|
||||
.getByRole('button', { name: /^Settings/ })
|
||||
.first()
|
||||
.click()
|
||||
const dialog = page.getByTestId('settings-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
|
||||
|
||||
return dialog.getByRole('main')
|
||||
}
|
||||
|
||||
test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
|
||||
test('renders the unified tile with breakdown and add-credits', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page)
|
||||
|
||||
const content = await openPlanAndCredits(page)
|
||||
|
||||
// Total + remaining suffix (Pro monthly allowance = 21,100; remaining
|
||||
// 10,550 -> used 10,550).
|
||||
await expect(content.getByText('Total credits')).toBeVisible()
|
||||
await expect(content.getByText('12,660')).toBeVisible()
|
||||
|
||||
// Monthly usage bar header + used / left-of-total labels.
|
||||
await expect(content.getByText('Monthly', { exact: true })).toBeVisible()
|
||||
await expect(content.getByText(/Refills Feb/)).toBeVisible()
|
||||
await expect(content.getByText('10,550 used')).toBeVisible()
|
||||
await expect(content.getByText('10,550 left of 21,100')).toBeVisible()
|
||||
|
||||
// Additional credits row + subtitle.
|
||||
await expect(content.getByText('Additional credits')).toBeVisible()
|
||||
await expect(content.getByText('2,110')).toBeVisible()
|
||||
await expect(content.getByText('Used after monthly runs out')).toBeVisible()
|
||||
|
||||
// Permission-gated add-credits action (personal owner can top up).
|
||||
await expect(
|
||||
content.getByRole('button', { name: 'Add credits' })
|
||||
).toBeVisible()
|
||||
|
||||
// Narrow container (DES-247 responsive variants): drop the used/remaining
|
||||
// labels and the breakdown subtitle, compact the monthly summary numbers.
|
||||
await page.setViewportSize({ width: 360, height: 800 })
|
||||
await expect(content.getByText('10,550 used')).toBeHidden()
|
||||
await expect(content.getByText('remaining', { exact: true })).toBeHidden()
|
||||
await expect(content.getByText('Used after monthly runs out')).toBeHidden()
|
||||
await expect(content.getByText('10,550 left of 21,100')).toBeHidden()
|
||||
await expect(content.getByText('11K left of 21K')).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders the depleted-credit empty states', async ({ page }) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page)
|
||||
// Monthly allowance fully spent; additional credits keep generation going.
|
||||
await mockBalance(page, { amount: 1000, monthly: 0, prepaid: 1000 })
|
||||
|
||||
const content = await openPlanAndCredits(page)
|
||||
|
||||
// 0-monthly state: depletion notice + IN USE badge on additional credits.
|
||||
await expect(
|
||||
content.getByText('Monthly credits are used up. Refills Feb 20')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
content.getByText("You're now spending additional credits.")
|
||||
).toBeVisible()
|
||||
await expect(content.getByText('In use')).toBeVisible()
|
||||
await expect(content.getByText('0 left of 21,100')).toBeVisible()
|
||||
|
||||
// Drain the remaining additional credits and refresh the tile: the
|
||||
// out-of-credits notice takes over and the badge drops.
|
||||
await mockBalance(page, { amount: 0, monthly: 0, prepaid: 0 })
|
||||
await content.getByRole('button', { name: 'Refresh credits' }).click()
|
||||
|
||||
await expect(
|
||||
content.getByText("You're out of credits. Credits refill Feb 20")
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
content.getByText('Add more credits to continue generating.')
|
||||
).toBeVisible()
|
||||
await expect(content.getByText('In use')).toBeHidden()
|
||||
await expect(
|
||||
content.getByRole('button', { name: 'Add credits' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
411
browser_tests/tests/dialogs/memberRoleChange.spec.ts
Normal file
411
browser_tests/tests/dialogs/memberRoleChange.spec.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
BillingStatusResponse,
|
||||
Member,
|
||||
Plan,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
|
||||
// devtools backend during setup.
|
||||
|
||||
/**
|
||||
* Member role change (Settings ▸ Workspace ▸ Members) — FE-770 / Figma
|
||||
* 2993-15512.
|
||||
*
|
||||
* The viewer is a promoted owner (not the workspace creator), so the spec can
|
||||
* distinguish the creator guard from the self guard: the creator row and the
|
||||
* viewer's own row hide the row menu, every other row exposes
|
||||
* "Change role ›" (Owner / Member) plus "Remove member". Promoting a member
|
||||
* sends PATCH /api/workspace/members/:id {role}, flips the Role column,
|
||||
* re-sorts the row under the creator, and the promoted owner stays demotable.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
const CREATOR: Member = {
|
||||
id: 'u-liz',
|
||||
name: 'Liz',
|
||||
email: 'liz@test.comfy.org',
|
||||
joined_at: '2025-01-01T00:00:00Z',
|
||||
role: 'owner',
|
||||
is_original_owner: true
|
||||
}
|
||||
// Matches the CloudAuthHelper mock user so this row counts as "(You)".
|
||||
const VIEWER: Member = {
|
||||
id: 'u-me',
|
||||
name: 'E2E Test User',
|
||||
email: 'e2e@test.comfy.org',
|
||||
joined_at: '2025-01-02T00:00:00Z',
|
||||
role: 'owner',
|
||||
is_original_owner: false
|
||||
}
|
||||
const JANE: Member = {
|
||||
id: 'u-jane',
|
||||
name: 'Jane',
|
||||
email: 'jane@test.comfy.org',
|
||||
joined_at: '2025-01-03T00:00:00Z',
|
||||
role: 'member',
|
||||
is_original_owner: false
|
||||
}
|
||||
const JOHN: Member = {
|
||||
id: 'u-john',
|
||||
name: 'John',
|
||||
email: 'john@test.comfy.org',
|
||||
joined_at: '2025-01-04T00:00:00Z',
|
||||
role: 'member',
|
||||
is_original_owner: false
|
||||
}
|
||||
|
||||
interface RoleChangeRequest {
|
||||
url: string
|
||||
role: string
|
||||
}
|
||||
|
||||
interface MemberMockState {
|
||||
members: Member[]
|
||||
patches: RoleChangeRequest[]
|
||||
}
|
||||
|
||||
async function mockCloudBoot(page: Page): Promise<MemberMockState> {
|
||||
const state: MemberMockState = {
|
||||
members: [CREATOR, VIEWER, JANE, JOHN].map((m) => ({ ...m })),
|
||||
patches: []
|
||||
}
|
||||
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// the workspaces flag from it (the `ff:` localStorage override is dev-only).
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ team_workspaces_enabled: true } satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
const teamWorkspace: WorkspaceWithRole = {
|
||||
id: 'ws-team',
|
||||
name: 'Team Comfy',
|
||||
type: 'team',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
joined_at: '2025-01-02T00:00:00Z',
|
||||
role: 'owner',
|
||||
subscription_tier: 'PRO'
|
||||
}
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(jsonRoute({ workspaces: [teamWorkspace] }))
|
||||
)
|
||||
|
||||
await page.route('**/api/workspace/members**', (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() === 'PATCH') {
|
||||
const url = request.url()
|
||||
const id = url.match(/\/api\/workspace\/members\/([^/?]+)/)?.[1]
|
||||
const { role } = request.postDataJSON() as { role: Member['role'] }
|
||||
state.patches.push({ url, role })
|
||||
const member = state.members.find((m) => m.id === id)
|
||||
if (member) member.role = role
|
||||
// The store applies the returned Member to local state; echo the full
|
||||
// updated row so role/id survive the round trip.
|
||||
return route.fulfill(jsonRoute(member))
|
||||
}
|
||||
return route.fulfill(
|
||||
jsonRoute({
|
||||
members: state.members,
|
||||
pagination: { offset: 0, limit: 50, total: state.members.length }
|
||||
})
|
||||
)
|
||||
})
|
||||
await page.route('**/api/workspace/invites', (r) =>
|
||||
r.fulfill(jsonRoute({ invites: [] }))
|
||||
)
|
||||
|
||||
const billingStatus: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_status: 'active',
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
plan_slug: 'pro-monthly',
|
||||
billing_status: 'paid',
|
||||
has_funds: true,
|
||||
renewal_date: '2099-02-20T00:00:00Z'
|
||||
}
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(jsonRoute(billingStatus))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
amount_micros: 6000,
|
||||
currency: 'usd',
|
||||
effective_balance_micros: 6000,
|
||||
cloud_credit_balance_micros: 5000,
|
||||
prepaid_balance_micros: 1000
|
||||
})
|
||||
)
|
||||
)
|
||||
// `max_seats > 1` on the current plan is what flips `isOnTeamPlan`,
|
||||
// which gates the whole role-management UI.
|
||||
const proPlan: Plan = {
|
||||
slug: 'pro-monthly',
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
price_cents: 10000,
|
||||
credits_cents: 21100,
|
||||
max_seats: 30,
|
||||
availability: { available: true },
|
||||
seat_summary: {
|
||||
seat_count: 4,
|
||||
total_cost_cents: 40000,
|
||||
total_credits_cents: 0
|
||||
}
|
||||
}
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(jsonRoute({ current_plan_slug: 'pro-monthly', plans: [proPlan] }))
|
||||
)
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
async function openMembersTab(page: Page) {
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: /^Settings/ })
|
||||
.first()
|
||||
.click()
|
||||
const dialog = page.getByTestId('settings-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
|
||||
|
||||
const content = dialog.getByRole('main')
|
||||
await content.getByRole('tab', { name: /Members/ }).click()
|
||||
await expect(content.getByText('4 of 30 members')).toBeVisible()
|
||||
return content
|
||||
}
|
||||
|
||||
function memberRow(content: ReturnType<Page['locator']>, email: string) {
|
||||
return content
|
||||
.locator('div.grid')
|
||||
.filter({ has: content.page().getByText(email, { exact: true }) })
|
||||
}
|
||||
|
||||
// Reka submenus open on real pointer travel or keyboard; Playwright's
|
||||
// synthetic hover doesn't trigger the pointermove handler, so drive the
|
||||
// subtrigger with ArrowRight instead.
|
||||
async function openChangeRoleSubmenu(page: Page) {
|
||||
const trigger = page.getByRole('menuitem', { name: 'Change role' })
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.press('ArrowRight')
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Owner', exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
async function setupCloudPage(page: Page): Promise<MemberMockState> {
|
||||
const state = await mockCloudBoot(page)
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
localStorage.setItem('Comfy.Workspace.LastWorkspaceId', 'ws-team')
|
||||
})
|
||||
return state
|
||||
}
|
||||
|
||||
test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
|
||||
test('row menus respect creator and self guards', async ({ page }) => {
|
||||
test.setTimeout(60_000)
|
||||
await setupCloudPage(page)
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
// US8/US9 — no row actions on the creator row (Liz) nor on the viewer's
|
||||
// own row; the two plain members each expose a menu.
|
||||
await expect(
|
||||
memberRow(content, 'john@test.comfy.org').getByRole('button', {
|
||||
name: 'More Options'
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
memberRow(content, 'jane@test.comfy.org').getByRole('button', {
|
||||
name: 'More Options'
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
memberRow(content, 'liz@test.comfy.org').getByRole('button', {
|
||||
name: 'More Options'
|
||||
})
|
||||
).toHaveCount(0)
|
||||
await expect(
|
||||
memberRow(content, 'e2e@test.comfy.org').getByRole('button', {
|
||||
name: 'More Options'
|
||||
})
|
||||
).toHaveCount(0)
|
||||
|
||||
// US1/US12 — the row menu exposes Change role and the FE-768 remove flow.
|
||||
await memberRow(content, 'jane@test.comfy.org')
|
||||
.getByRole('button', { name: 'More Options' })
|
||||
.click()
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Change role' })
|
||||
).toBeVisible()
|
||||
await page.getByRole('menuitem', { name: 'Remove member' }).click()
|
||||
await expect(page.getByText('Remove this member?')).toBeVisible()
|
||||
})
|
||||
|
||||
test('promote and demote round trip updates the Role column', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(90_000)
|
||||
const state = await setupCloudPage(page)
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const emails = content.getByText(/@test\.comfy\.org/)
|
||||
await expect(emails).toHaveText([
|
||||
'liz@test.comfy.org',
|
||||
'e2e@test.comfy.org',
|
||||
'john@test.comfy.org',
|
||||
'jane@test.comfy.org'
|
||||
])
|
||||
|
||||
const janeRow = memberRow(content, 'jane@test.comfy.org')
|
||||
await janeRow.getByRole('button', { name: 'More Options' }).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
|
||||
// US3 — picking the current role is a no-op.
|
||||
await page.getByRole('menuitem', { name: 'Member', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: /an owner\?/ })).toHaveCount(
|
||||
0
|
||||
)
|
||||
expect(state.patches).toHaveLength(0)
|
||||
|
||||
// US4 — promote dialog copy straight from Figma.
|
||||
await janeRow.getByRole('button', { name: 'More Options' }).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page.getByRole('menuitem', { name: 'Owner', exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText("They'll be able to:")).toBeVisible()
|
||||
await expect(page.getByText('Add additional credits')).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Manage members, payment methods, and workspace settings')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Promote and demote other owners (except the workspace creator).'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
// US5 — cancel leaves the role untouched.
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
).toHaveCount(0)
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
expect(state.patches).toHaveLength(0)
|
||||
|
||||
// US6 — confirming PATCHes the proposed contract and re-sorts the row
|
||||
// under the creator; the promoted owner keeps its row menu.
|
||||
await janeRow.getByRole('button', { name: 'More Options' }).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page.getByRole('menuitem', { name: 'Owner', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Make owner' }).click()
|
||||
|
||||
await expect(page.getByText('Role updated')).toBeVisible()
|
||||
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
|
||||
await expect(emails).toHaveText([
|
||||
'liz@test.comfy.org',
|
||||
'e2e@test.comfy.org',
|
||||
'jane@test.comfy.org',
|
||||
'john@test.comfy.org'
|
||||
])
|
||||
expect(state.patches).toEqual([
|
||||
{
|
||||
url: expect.stringContaining('/api/workspace/members/u-jane'),
|
||||
role: 'owner'
|
||||
}
|
||||
])
|
||||
|
||||
// US7 — demote round trip from the promoted owner row.
|
||||
await janeRow.getByRole('button', { name: 'More Options' }).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page.getByRole('menuitem', { name: 'Member', exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Demote Jane to member?' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText("They'll lose admin access.")).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Demote to member' }).click()
|
||||
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
expect(state.patches).toHaveLength(2)
|
||||
expect(state.patches[1].role).toBe('member')
|
||||
})
|
||||
|
||||
test('failed role change keeps the dialog open with an error toast', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
await setupCloudPage(page)
|
||||
// Override the member route so PATCH fails after boot succeeds.
|
||||
await page.route('**/api/workspace/members/**', (route: Route) =>
|
||||
route.request().method() === 'PATCH'
|
||||
? route.fulfill({ status: 500, body: '{}' })
|
||||
: route.fallback()
|
||||
)
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, 'jane@test.comfy.org')
|
||||
await janeRow.getByRole('button', { name: 'More Options' }).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page.getByRole('menuitem', { name: 'Owner', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Make owner' }).click()
|
||||
|
||||
// US10 — error toast, dialog stays open, role unchanged.
|
||||
await expect(page.getByText('Failed to update role')).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
243
browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Normal file
243
browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
Member,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
/**
|
||||
* The `?pricing=` deep link opens the pricing table on app load, gated to the
|
||||
* original owner (canManageSubscriptionLifecycle). Drives a raw `page` so the
|
||||
* cloud app boots against fully mocked endpoints, like the survey-gate spec.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
// CloudAuthHelper.mockAuth() signs in as this email; the original-owner gate
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
function jsonRoute(body: unknown) {
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
|
||||
async function mockCloudBoot(page: Page) {
|
||||
// `/api/features` is the remote-config source; enable team workspaces so the
|
||||
// unified pricing table (and the lifecycle gate) are live.
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ team_workspaces_enabled: true } satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/user', (r) =>
|
||||
r.fulfill(jsonRoute({ status: 'active' }))
|
||||
)
|
||||
// Disable the experimental Asset API: with it on (cloud default) the
|
||||
// unmocked asset endpoints 403 and workflow restore throws uncaught,
|
||||
// aborting the GraphCanvas onMounted chain before the deep-link loader.
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(jsonRoute({ 'Comfy.Assets.UseAssetAPI': false }))
|
||||
)
|
||||
await page.route('**/api/settings/**', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
// Queue/prompt status: a missing exec_info throws on boot and aborts the
|
||||
// GraphCanvas onMounted chain before the deep-link loader runs.
|
||||
await page.route('**/api/prompt', (r) =>
|
||||
r.fulfill(jsonRoute({ exec_info: { queue_remaining: 0 } }))
|
||||
)
|
||||
await page.route('**/api/queue', (r) =>
|
||||
r.fulfill(jsonRoute({ queue_running: [], queue_pending: [] }))
|
||||
)
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
}
|
||||
|
||||
async function mockBilling(page: Page) {
|
||||
// Minimal valid shapes so the billing facade resolves while the dialog mounts.
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_status: 'active',
|
||||
subscription_tier: 'pro',
|
||||
subscription_duration: 'MONTHLY',
|
||||
billing_status: 'paid'
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(jsonRoute({ plans: [] }))
|
||||
)
|
||||
await page.route('**/customers/cloud-subscription-status', (r) =>
|
||||
r.fulfill(jsonRoute({ is_active: false }))
|
||||
)
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
|
||||
)
|
||||
}
|
||||
|
||||
function workspace(
|
||||
type: 'personal' | 'team',
|
||||
role: 'owner' | 'member'
|
||||
): WorkspaceWithRole {
|
||||
return {
|
||||
id: `ws-${type}`,
|
||||
name: type === 'team' ? 'My Team' : 'Personal Workspace',
|
||||
type,
|
||||
role,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
}
|
||||
|
||||
async function mockWorkspace(
|
||||
page: Page,
|
||||
ws: WorkspaceWithRole,
|
||||
members: Member[]
|
||||
) {
|
||||
await page.route('**/api/workspaces', async (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
await route.fulfill(jsonRoute({ workspaces: [ws] }))
|
||||
})
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
workspace: { id: ws.id, name: ws.name, type: ws.type },
|
||||
role: ws.role,
|
||||
permissions: []
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/workspace/members**', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
members,
|
||||
pagination: { offset: 0, limit: 50, total: members.length }
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async function bootCloud(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
// Pre-select the mock user to skip the user-select screen.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
}
|
||||
|
||||
const pricingHeading = (page: Page) =>
|
||||
page.getByRole('heading', { name: 'Choose a Plan' })
|
||||
|
||||
function member(
|
||||
overrides: Partial<Member> & Pick<Member, 'email' | 'role'>
|
||||
): Member {
|
||||
return {
|
||||
id: `user-${overrides.email}`,
|
||||
name: overrides.email,
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
is_original_owner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Pricing table deep link', { tag: '@cloud' }, () => {
|
||||
test('opens the pricing table for a personal owner', async ({ page }) => {
|
||||
test.setTimeout(60_000)
|
||||
await mockCloudBoot(page)
|
||||
await mockBilling(page)
|
||||
await mockWorkspace(page, workspace('personal', 'owner'), [])
|
||||
await bootCloud(page)
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=1`)
|
||||
|
||||
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
|
||||
await expect(page).not.toHaveURL(/[?&]pricing=/)
|
||||
})
|
||||
|
||||
test('opens on the Team tab for ?pricing=team', async ({ page }) => {
|
||||
test.setTimeout(60_000)
|
||||
await mockCloudBoot(page)
|
||||
await mockBilling(page)
|
||||
await mockWorkspace(page, workspace('personal', 'owner'), [])
|
||||
await bootCloud(page)
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=team`)
|
||||
|
||||
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'For Teams' })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('opens for a team original owner', async ({ page }) => {
|
||||
test.setTimeout(60_000)
|
||||
await mockCloudBoot(page)
|
||||
await mockBilling(page)
|
||||
await mockWorkspace(page, workspace('team', 'owner'), [
|
||||
member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true })
|
||||
])
|
||||
await bootCloud(page)
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=1`)
|
||||
|
||||
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
|
||||
})
|
||||
|
||||
test('is a silent no-op for a team member', async ({ page }) => {
|
||||
test.setTimeout(60_000)
|
||||
await mockCloudBoot(page)
|
||||
await mockBilling(page)
|
||||
await mockWorkspace(page, workspace('team', 'member'), [
|
||||
member({
|
||||
email: 'creator@test.comfy.org',
|
||||
role: 'owner',
|
||||
is_original_owner: true
|
||||
}),
|
||||
member({ email: SELF_EMAIL, role: 'member' })
|
||||
])
|
||||
await bootCloud(page)
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=1`)
|
||||
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
await expect(page).not.toHaveURL(/[?&]pricing=/)
|
||||
await expect(pricingHeading(page)).toBeHidden()
|
||||
})
|
||||
})
|
||||
@@ -236,6 +236,8 @@
|
||||
--secondary-background: var(--color-smoke-200);
|
||||
--secondary-background-hover: var(--color-smoke-400);
|
||||
--secondary-background-selected: var(--color-smoke-600);
|
||||
--tertiary-background: var(--color-smoke-400);
|
||||
--tertiary-background-hover: var(--color-smoke-500);
|
||||
--base-background: var(--color-white);
|
||||
--primary-background: var(--color-azure-400);
|
||||
--primary-background-hover: var(--color-cobalt-800);
|
||||
@@ -384,6 +386,8 @@
|
||||
--secondary-background: var(--color-charcoal-600);
|
||||
--secondary-background-hover: var(--color-charcoal-400);
|
||||
--secondary-background-selected: var(--color-charcoal-200);
|
||||
--tertiary-background: var(--color-charcoal-400);
|
||||
--tertiary-background-hover: var(--color-charcoal-300);
|
||||
--base-background: var(--color-charcoal-800);
|
||||
--primary-background: var(--color-azure-600);
|
||||
--primary-background-hover: var(--color-azure-400);
|
||||
@@ -554,6 +558,8 @@
|
||||
--color-secondary-background: var(--secondary-background);
|
||||
--color-secondary-background-hover: var(--secondary-background-hover);
|
||||
--color-secondary-background-selected: var(--secondary-background-selected);
|
||||
--color-tertiary-background: var(--tertiary-background);
|
||||
--color-tertiary-background-hover: var(--tertiary-background-hover);
|
||||
--color-primary-background: var(--primary-background);
|
||||
--color-primary-background-hover: var(--primary-background-hover);
|
||||
--color-destructive-background: var(--destructive-background);
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineOptions({
|
||||
@@ -50,11 +52,22 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem
|
||||
v-else
|
||||
:class="itemClass"
|
||||
v-tooltip="
|
||||
item.tooltip ? { value: String(item.tooltip), showDelay: 0 } : undefined
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
itemClass,
|
||||
String(item.class ?? ''),
|
||||
Boolean(item.tooltip) && toValue(item.disabled) && 'pointer-events-auto'
|
||||
)
|
||||
"
|
||||
:disabled="toValue(item.disabled) ?? !item.command"
|
||||
@select="item.command?.({ originalEvent: $event, item })"
|
||||
>
|
||||
<i class="size-5 shrink-0" :class="item.icon" />
|
||||
<!-- Items declaring an icon key (even empty) keep the slot so labels align
|
||||
within icon-bearing menus; icon-less menus render labels flush-left. -->
|
||||
<i v-if="'icon' in item" class="size-5 shrink-0" :class="item.icon" />
|
||||
<div class="mr-auto truncate" v-text="item.label" />
|
||||
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
DropdownMenuArrow,
|
||||
@@ -7,13 +8,16 @@ import {
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, toValue } from 'vue'
|
||||
import { computed, ref, toValue } from 'vue'
|
||||
|
||||
import DropdownItem from '@/components/common/DropdownItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { ButtonVariants } from '../ui/button/button.variants'
|
||||
|
||||
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
|
||||
const MODAL_BASE_Z_INDEX = 1700
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
@@ -41,10 +45,20 @@ const contentClass = computed(() =>
|
||||
contentProp
|
||||
)
|
||||
)
|
||||
|
||||
// Body-portaled content keeps its static z-1700 unless a dialog that joined
|
||||
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
|
||||
// past that dialog so the menu isn't hidden behind it.
|
||||
const open = ref(false)
|
||||
const contentStyle = computed(() => {
|
||||
if (!open.value) return undefined
|
||||
const topZIndex = ZIndex.getCurrent('modal')
|
||||
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuRoot v-model:open="open">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
|
||||
@@ -60,6 +74,7 @@ const contentClass = computed(() =>
|
||||
:collision-padding="10"
|
||||
v-bind="$attrs"
|
||||
:class="contentClass"
|
||||
:style="contentStyle"
|
||||
>
|
||||
<slot :item-class>
|
||||
<DropdownItem
|
||||
|
||||
56
src/components/common/DropdownMenu.zindex.test.ts
Normal file
56
src/components/common/DropdownMenu.zindex.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import DropdownMenu from './DropdownMenu.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderMenu() {
|
||||
return render(DropdownMenu, {
|
||||
props: { entries: [{ label: 'Item A' }] },
|
||||
global: { plugins: [i18n], directives: { tooltip: {} } }
|
||||
})
|
||||
}
|
||||
|
||||
let openModal: HTMLElement | undefined
|
||||
|
||||
afterEach(() => {
|
||||
if (openModal) {
|
||||
ZIndex.clear(openModal)
|
||||
openModal = undefined
|
||||
}
|
||||
})
|
||||
|
||||
describe('DropdownMenu z-index', () => {
|
||||
it('opens above a dialog registered with the modal z-index counter', async () => {
|
||||
openModal = document.createElement('div')
|
||||
ZIndex.set('modal', openModal, 1700)
|
||||
const dialogZ = Number(openModal.style.zIndex)
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderMenu()
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
const menu = await screen.findByRole('menu')
|
||||
expect(Number(menu.style.zIndex)).toBeGreaterThan(dialogZ)
|
||||
})
|
||||
|
||||
it('leaves the static z-index untouched when no dialog is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderMenu()
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
const menu = await screen.findByRole('menu')
|
||||
expect(menu.style.zIndex).toBe('')
|
||||
expect(menu.className).toContain('z-1700')
|
||||
})
|
||||
})
|
||||
95
src/components/dialog/content/setting/CreditsPanel.vue
Normal file
95
src/components/dialog/content/setting/CreditsPanel.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="credits-container flex h-full flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('credits.credits') }}
|
||||
</h2>
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<CreditsTile />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="m-0">{{ $t('credits.activity') }}</h3>
|
||||
<Button variant="muted-textonly" @click="handleCreditsHistoryClick">
|
||||
<i class="pi pi-arrow-up-right" />
|
||||
{{ $t('credits.invoiceHistory') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<UsageLogsTable ref="usageLogsTableRef" />
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button variant="muted-textonly" @click="handleFaqClick">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('credits.faqs') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('subscription.partnerNodesCredits') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleMessageSupport">
|
||||
<i class="pi pi-comments" />
|
||||
{{ $t('credits.messageSupport') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const authStore = useAuthStore()
|
||||
const authActions = useAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
|
||||
|
||||
watch(
|
||||
() => authStore.lastBalanceUpdateTime,
|
||||
(newTime, oldTime) => {
|
||||
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
|
||||
usageLogsTableRef.value.refresh()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleCreditsHistoryClick = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const handleMessageSupport = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'credits_panel'
|
||||
})
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
const handleFaqClick = () => {
|
||||
window.open(
|
||||
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -1,195 +0,0 @@
|
||||
<template>
|
||||
<div class="credits-container h-full">
|
||||
<!-- Legacy Design -->
|
||||
<div class="flex h-full flex-col">
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('credits.credits') }}
|
||||
</h2>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-sm font-medium text-muted">
|
||||
{{ $t('credits.yourCreditBalance') }}
|
||||
</h3>
|
||||
<div class="flex items-center justify-between">
|
||||
<UserCredit text-class="text-3xl font-bold" />
|
||||
<Skeleton v-if="loading" width="2rem" height="2rem" />
|
||||
<Button
|
||||
v-else-if="isActiveSubscription"
|
||||
:loading="loading"
|
||||
@click="handlePurchaseCreditsClick"
|
||||
>
|
||||
{{ $t('credits.purchaseCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row items-center">
|
||||
<Skeleton
|
||||
v-if="balanceLoading"
|
||||
width="12rem"
|
||||
height="1rem"
|
||||
class="text-xs"
|
||||
/>
|
||||
<div v-else-if="formattedLastUpdateTime" class="text-xs text-muted">
|
||||
{{ $t('credits.lastUpdated') }}: {{ formattedLastUpdateTime }}
|
||||
</div>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('g.refresh')"
|
||||
@click="() => authActions.fetchBalance()"
|
||||
>
|
||||
<i class="pi pi-refresh" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3>{{ $t('credits.activity') }}</h3>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
:loading="loading"
|
||||
@click="handleCreditsHistoryClick"
|
||||
>
|
||||
<i class="pi pi-arrow-up-right" />
|
||||
{{ $t('credits.invoiceHistory') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<template v-if="creditHistory.length > 0">
|
||||
<div class="grow">
|
||||
<DataTable :value="creditHistory" :show-headers="false">
|
||||
<Column field="title" :header="$t('g.name')">
|
||||
<template #body="{ data }">
|
||||
<div class="text-sm font-medium">{{ data.title }}</div>
|
||||
<div class="text-xs text-muted">{{ data.timestamp }}</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="amount" :header="$t('g.amount')">
|
||||
<template #body="{ data }">
|
||||
<div
|
||||
:class="[
|
||||
'text-center text-base font-medium',
|
||||
data.isPositive ? 'text-sky-500' : 'text-red-400'
|
||||
]"
|
||||
>
|
||||
{{ data.isPositive ? '+' : '-' }}${{
|
||||
formatMetronomeCurrency(data.amount, 'usd')
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Divider />
|
||||
|
||||
<UsageLogsTable ref="usageLogsTableRef" />
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button variant="muted-textonly" @click="handleFaqClick">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('credits.faqs') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('subscription.partnerNodesCredits') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleMessageSupport">
|
||||
<i class="pi pi-comments" />
|
||||
{{ $t('credits.messageSupport') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Divider from 'primevue/divider'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { formatMetronomeCurrency } from '@/utils/formatUtil'
|
||||
|
||||
interface CreditHistoryItemData {
|
||||
title: string
|
||||
timestamp: string
|
||||
amount: number
|
||||
isPositive: boolean
|
||||
}
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const dialogService = useDialogService()
|
||||
const authStore = useAuthStore()
|
||||
const authActions = useAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
|
||||
|
||||
const formattedLastUpdateTime = computed(() =>
|
||||
authStore.lastBalanceUpdateTime
|
||||
? authStore.lastBalanceUpdateTime.toLocaleString()
|
||||
: ''
|
||||
)
|
||||
|
||||
watch(
|
||||
() => authStore.lastBalanceUpdateTime,
|
||||
(newTime, oldTime) => {
|
||||
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
|
||||
usageLogsTableRef.value.refresh()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handlePurchaseCreditsClick = () => {
|
||||
// Track purchase credits entry from Settings > Credits panel
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
const handleCreditsHistoryClick = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const handleMessageSupport = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'credits_panel'
|
||||
})
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
const handleFaqClick = () => {
|
||||
window.open(
|
||||
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const creditHistory = ref<CreditHistoryItemData[]>([])
|
||||
</script>
|
||||
@@ -197,6 +197,7 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { usePricingTableUrlLoader } from '@/platform/cloud/subscription/composables/usePricingTableUrlLoader'
|
||||
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
|
||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
||||
|
||||
@@ -461,6 +462,7 @@ const { flags } = useFeatureFlags()
|
||||
// Set up URL loaders during setup phase so useRoute/useRouter work correctly
|
||||
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
|
||||
const createWorkspaceUrlLoader = isCloud ? useCreateWorkspaceUrlLoader() : null
|
||||
const pricingTableUrlLoader = isCloud ? usePricingTableUrlLoader() : null
|
||||
useCanvasDrop(canvasRef)
|
||||
useLitegraphSettings()
|
||||
useNodeBadge()
|
||||
@@ -587,6 +589,19 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Open the pricing table from URL if present (e.g., ?pricing=1 / ?pricing=team).
|
||||
// Not gated on the team-workspaces flag: it also drives personal/legacy users.
|
||||
if (pricingTableUrlLoader) {
|
||||
try {
|
||||
await pricingTableUrlLoader.loadPricingTableFromUrl()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[GraphCanvas] Failed to load pricing table from URL:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
|
||||
const { useReleaseStore } =
|
||||
await import('@/platform/updates/common/releaseStore')
|
||||
|
||||
@@ -1,37 +1,18 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { h, ref } from 'vue'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import type { BalanceInfo, SubscriptionInfo } from '@/composables/billing/types'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
|
||||
|
||||
// Mock all firebase modules
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
getApp: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('firebase/auth', () => ({
|
||||
getAuth: vi.fn(),
|
||||
setPersistence: vi.fn(),
|
||||
browserLocalPersistence: {},
|
||||
onAuthStateChanged: vi.fn(),
|
||||
signInWithEmailAndPassword: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock pinia
|
||||
vi.mock('pinia')
|
||||
|
||||
// Mock showSettingsDialog and showTopUpCreditsDialog
|
||||
const mockShowSettingsDialog = vi.fn()
|
||||
const mockShowTopUpCreditsDialog = vi.fn()
|
||||
|
||||
// Mock the settings dialog composable
|
||||
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
useSettingsDialog: vi.fn(() => ({
|
||||
show: mockShowSettingsDialog,
|
||||
@@ -40,7 +21,6 @@ vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock window.open
|
||||
const originalWindowOpen = window.open
|
||||
beforeEach(() => {
|
||||
window.open = vi.fn()
|
||||
@@ -50,7 +30,6 @@ afterAll(() => {
|
||||
window.open = originalWindowOpen
|
||||
})
|
||||
|
||||
// Mock the useCurrentUser composable
|
||||
const mockHandleSignOut = vi.fn()
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: vi.fn(() => ({
|
||||
@@ -61,60 +40,50 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useAuthActions composable
|
||||
const mockLogout = vi.fn()
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: vi.fn(() => ({
|
||||
fetchBalance: vi.fn().mockResolvedValue(undefined),
|
||||
logout: mockLogout
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the dialog service
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => ({
|
||||
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the authStore with hoisted state for per-test manipulation
|
||||
const mockAuthStoreState = vi.hoisted(() => ({
|
||||
balance: {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 100_000,
|
||||
currency: 'usd'
|
||||
} as {
|
||||
amount_micros?: number
|
||||
effective_balance_micros?: number
|
||||
currency: string
|
||||
},
|
||||
isFetchingBalance: false
|
||||
}))
|
||||
function makeSubscription(
|
||||
overrides: Partial<SubscriptionInfo> = {}
|
||||
): SubscriptionInfo {
|
||||
return {
|
||||
isActive: true,
|
||||
tier: 'CREATOR',
|
||||
duration: 'MONTHLY',
|
||||
planSlug: null,
|
||||
renewalDate: null,
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => ({
|
||||
getAuthHeader: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
|
||||
balance: mockAuthStoreState.balance,
|
||||
isFetchingBalance: mockAuthStoreState.isFetchingBalance
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useSubscription composable
|
||||
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
|
||||
const mockFetchBalance = vi.fn().mockResolvedValue(undefined)
|
||||
const mockIsActiveSubscription = ref(true)
|
||||
const mockIsFreeTier = ref(false)
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: vi.fn(() => ({
|
||||
isActiveSubscription: ref(true),
|
||||
const mockTier = ref<SubscriptionInfo['tier']>('CREATOR')
|
||||
const mockSubscription = ref<SubscriptionInfo | null>(makeSubscription())
|
||||
const mockBalance = ref<BalanceInfo | null>(null)
|
||||
const mockIsLoading = ref(false)
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
isActiveSubscription: mockIsActiveSubscription,
|
||||
isFreeTier: mockIsFreeTier,
|
||||
subscriptionTierName: ref('Creator'),
|
||||
subscriptionTier: ref('CREATOR'),
|
||||
fetchStatus: mockFetchStatus
|
||||
tier: mockTier,
|
||||
subscription: mockSubscription,
|
||||
balance: mockBalance,
|
||||
isLoading: mockIsLoading,
|
||||
fetchStatus: mockFetchStatus,
|
||||
fetchBalance: mockFetchBalance
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useSubscriptionDialog composable
|
||||
const mockShowPricingTable = vi.fn()
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
@@ -127,7 +96,6 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
// Mock UserAvatar component
|
||||
vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
default: {
|
||||
name: 'UserAvatarMock',
|
||||
@@ -137,22 +105,10 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock UserCredit component
|
||||
vi.mock('@/components/common/UserCredit.vue', () => ({
|
||||
default: {
|
||||
name: 'UserCreditMock',
|
||||
render() {
|
||||
return h('div', 'Credit: 100')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock formatCreditsFromCents
|
||||
vi.mock('@/base/credits/comfyCredits', () => ({
|
||||
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
|
||||
}))
|
||||
|
||||
// Mock useExternalLink
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: vi.fn(() => ({
|
||||
buildDocsUrl: vi.fn((path) => `https://docs.comfy.org${path}`),
|
||||
@@ -162,14 +118,12 @@ vi.mock('@/composables/useExternalLink', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock useTelemetry
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackAddApiCreditButtonClicked: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock isCloud with hoisted state for per-test toggling
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: true }))
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
@@ -178,25 +132,37 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
|
||||
default: {
|
||||
default: defineComponent({
|
||||
name: 'SubscribeButtonMock',
|
||||
render() {
|
||||
return h('div', 'Subscribe Button')
|
||||
emits: ['subscribed'],
|
||||
setup(_, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'subscribe-button-mock',
|
||||
onClick: () => emit('subscribed')
|
||||
},
|
||||
'Subscribe Button'
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('CurrentUserPopoverLegacy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockIsActiveSubscription.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 100_000,
|
||||
mockTier.value = 'CREATOR'
|
||||
mockSubscription.value = makeSubscription()
|
||||
mockBalance.value = {
|
||||
amountMicros: 100_000,
|
||||
effectiveBalanceMicros: 100_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
mockAuthStoreState.isFetchingBalance = false
|
||||
mockIsLoading.value = false
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
@@ -230,7 +196,47 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
|
||||
it('fetches the balance through the billing facade on mount', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(mockFetchBalance).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes subscription status through the billing facade after subscribing', async () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByTestId('subscribe-button-mock'))
|
||||
|
||||
expect(mockFetchStatus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('subscription tier badge', () => {
|
||||
it('renders the tier name derived from the facade tier', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('Creator')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the yearly tier name when the facade subscription is annual', () => {
|
||||
mockSubscription.value = makeSubscription({ duration: 'ANNUAL' })
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('Creator Yearly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the badge when the facade reports no tier', () => {
|
||||
mockTier.value = null
|
||||
mockSubscription.value = null
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.queryByText('Creator')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('formats and displays the facade balance', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(formatCreditsFromCents).toHaveBeenCalledWith({
|
||||
@@ -245,6 +251,14 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('1000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a skeleton instead of the balance while billing is loading', () => {
|
||||
mockIsLoading.value = true
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.queryByText('1000')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders logout menu item with correct text', () => {
|
||||
renderComponent()
|
||||
|
||||
@@ -324,11 +338,11 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
describe('effective_balance_micros handling', () => {
|
||||
it('uses effective_balance_micros when present (positive balance)', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 200_000,
|
||||
effective_balance_micros: 150_000,
|
||||
describe('facade balance handling', () => {
|
||||
it('uses effectiveBalanceMicros when present (positive balance)', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 200_000,
|
||||
effectiveBalanceMicros: 150_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -345,10 +359,10 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('1500')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses effective_balance_micros when zero', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 0,
|
||||
it('uses effectiveBalanceMicros when zero', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 100_000,
|
||||
effectiveBalanceMicros: 0,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -365,10 +379,10 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses effective_balance_micros when negative', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 0,
|
||||
effective_balance_micros: -50_000,
|
||||
it('uses effectiveBalanceMicros when negative', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 0,
|
||||
effectiveBalanceMicros: -50_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -385,9 +399,9 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('-500')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to amount_micros when effective_balance_micros is missing', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
it('falls back to amountMicros when effectiveBalanceMicros is missing', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 100_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -404,10 +418,8 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('1000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
currency: 'usd'
|
||||
}
|
||||
it('falls back to 0 when the facade reports no balance', () => {
|
||||
mockBalance.value = null
|
||||
|
||||
renderComponent()
|
||||
|
||||
@@ -466,8 +478,11 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
})
|
||||
|
||||
it('hides subscribe button', () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
renderComponent()
|
||||
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('subscribe-button-mock')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still shows partner nodes menu item', () => {
|
||||
|
||||
@@ -32,12 +32,7 @@
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="authStore.isFetchingBalance"
|
||||
width="4rem"
|
||||
height="1.25rem"
|
||||
class="w-full"
|
||||
/>
|
||||
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
}}</span>
|
||||
@@ -162,16 +157,15 @@ import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useWorkspaceTierLabel } from '@/platform/workspace/composables/useWorkspaceTierLabel'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
@@ -181,25 +175,29 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const authActions = useAuthActions()
|
||||
const authStore = useAuthStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
subscriptionTierName,
|
||||
subscriptionTier,
|
||||
fetchStatus
|
||||
} = useSubscription()
|
||||
tier,
|
||||
subscription,
|
||||
balance,
|
||||
isLoading,
|
||||
fetchStatus,
|
||||
fetchBalance
|
||||
} = useBillingContext()
|
||||
const { formatTierName } = useWorkspaceTierLabel()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
const { locale } = useI18n()
|
||||
|
||||
const subscriptionTierName = computed(() =>
|
||||
formatTierName(tier.value, subscription.value?.duration === 'ANNUAL')
|
||||
)
|
||||
|
||||
const formattedBalance = computed(() => {
|
||||
const cents =
|
||||
authStore.balance?.effective_balance_micros ??
|
||||
authStore.balance?.amount_micros ??
|
||||
0
|
||||
balance.value?.effectiveBalanceMicros ?? balance.value?.amountMicros ?? 0
|
||||
return formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value,
|
||||
@@ -211,12 +209,12 @@ const formattedBalance = computed(() => {
|
||||
})
|
||||
|
||||
const canUpgrade = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
const currentTier = tier.value
|
||||
return (
|
||||
tier === 'FREE' ||
|
||||
tier === 'FOUNDERS_EDITION' ||
|
||||
tier === 'STANDARD' ||
|
||||
tier === 'CREATOR'
|
||||
currentTier === 'FREE' ||
|
||||
currentTier === 'FOUNDERS_EDITION' ||
|
||||
currentTier === 'STANDARD' ||
|
||||
currentTier === 'CREATOR'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -270,6 +268,6 @@ const handleSubscribed = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void authActions.fetchBalance()
|
||||
void fetchBalance()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -22,6 +22,8 @@ export const buttonVariants = cva({
|
||||
link: 'bg-transparent text-muted-foreground hover:text-base-foreground',
|
||||
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
|
||||
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
|
||||
tertiary:
|
||||
'bg-tertiary-background text-base-foreground hover:bg-tertiary-background-hover',
|
||||
gradient:
|
||||
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
|
||||
},
|
||||
@@ -54,6 +56,7 @@ const variants = [
|
||||
'destructive-textonly',
|
||||
'link',
|
||||
'base',
|
||||
'tertiary',
|
||||
'overlay-white',
|
||||
'gradient'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CurrentTeamCreditStop,
|
||||
Plan,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeOptions,
|
||||
SubscribeResponse,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier,
|
||||
@@ -43,8 +44,7 @@ export interface BillingActions {
|
||||
fetchBalance: () => Promise<void>
|
||||
subscribe: (
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
options?: SubscribeOptions
|
||||
) => Promise<SubscribeResponse | void>
|
||||
previewSubscribe: (
|
||||
planSlug: string
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import type {
|
||||
BillingStatusResponse,
|
||||
Plan
|
||||
@@ -20,12 +22,14 @@ const {
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits,
|
||||
mockUpdateActiveWorkspace,
|
||||
mockBillingStatus
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn(),
|
||||
mockUpdateActiveWorkspace: vi.fn(),
|
||||
mockBillingStatus: {
|
||||
value: {
|
||||
is_active: true,
|
||||
@@ -44,15 +48,25 @@ vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
}
|
||||
vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const teamWorkspacesEnabledRef = ref(mockTeamWorkspacesEnabled.value)
|
||||
Object.defineProperty(mockTeamWorkspacesEnabled, 'value', {
|
||||
get: () => teamWorkspacesEnabledRef.value,
|
||||
set: (value: boolean) => {
|
||||
teamWorkspacesEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
}))
|
||||
return {
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
@@ -64,7 +78,7 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
? { id: 'personal-123', type: 'personal' }
|
||||
: { id: 'team-456', type: 'team' }
|
||||
},
|
||||
updateActiveWorkspace: vi.fn()
|
||||
updateActiveWorkspace: mockUpdateActiveWorkspace
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -142,11 +156,28 @@ describe('useBillingContext', () => {
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
})
|
||||
|
||||
it('returns legacy type for personal workspace', () => {
|
||||
it('selects legacy type when team workspaces are disabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('selects workspace type for personal when team workspaces are enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('selects workspace type for team when team workspaces are enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('provides subscription info from legacy billing', () => {
|
||||
const { subscription } = useBillingContext()
|
||||
|
||||
@@ -221,6 +252,42 @@ describe('useBillingContext', () => {
|
||||
expect(() => showSubscriptionDialog()).not.toThrow()
|
||||
})
|
||||
|
||||
it('reinitializes workspace billing when the type flips on after legacy init', async () => {
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type, initialize } = useBillingContext()
|
||||
await initialize()
|
||||
await nextTick()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
|
||||
// Authenticated remote config resolves the flag on for the same workspace
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(workspaceApi.getBillingStatus).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscription mirror to workspace store', () => {
|
||||
it('mirrors subscription for personal workspaces when team workspaces are enabled', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
await initialize()
|
||||
await nextTick()
|
||||
|
||||
expect(mockUpdateActiveWorkspace).toHaveBeenCalledWith({
|
||||
isSubscribed: true,
|
||||
subscriptionPlan: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMaxSeats', () => {
|
||||
it('returns 1 for personal workspaces regardless of tier', () => {
|
||||
const { getMaxSeats } = useBillingContext()
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getTierFeatures
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { SubscribeOptions } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type {
|
||||
@@ -27,11 +28,11 @@ import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspa
|
||||
const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
|
||||
/**
|
||||
* Unified billing context that automatically switches between legacy (user-scoped)
|
||||
* and workspace billing based on the active workspace type.
|
||||
* Unified billing context that selects the billing implementation by build/flag.
|
||||
*
|
||||
* - Personal workspaces use legacy billing via /customers/* endpoints
|
||||
* - Team workspaces use workspace billing via /billing/* endpoints
|
||||
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
@@ -92,16 +93,14 @@ function useBillingContextInternal(): BillingContext {
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Determines which billing type to use:
|
||||
* - If team workspaces feature is disabled: always use legacy (/customers)
|
||||
* - If team workspaces feature is enabled:
|
||||
* - Personal workspace: use legacy (/customers)
|
||||
* - Team workspace: use workspace (/billing)
|
||||
* Determines which billing type to use, keyed only on the build/flag:
|
||||
* - Team workspaces feature disabled (OSS/Desktop): legacy (/customers)
|
||||
* - Team workspaces feature enabled: workspace (/api/billing), for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
*/
|
||||
const type = computed<BillingType>(() => {
|
||||
if (!flags.teamWorkspacesEnabled) return 'legacy'
|
||||
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
|
||||
})
|
||||
const type = computed<BillingType>(() =>
|
||||
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
|
||||
)
|
||||
|
||||
const activeContext = computed(() =>
|
||||
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
|
||||
@@ -173,7 +172,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
watch(
|
||||
subscription,
|
||||
(sub) => {
|
||||
if (!sub || store.isInPersonalWorkspace) return
|
||||
if (!sub) return
|
||||
|
||||
store.updateActiveWorkspace({
|
||||
isSubscribed: sub.isActive && !sub.isCancelled,
|
||||
@@ -183,10 +182,15 @@ function useBillingContextInternal(): BillingContext {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Initialize billing when workspace changes
|
||||
// Reinitialize when the workspace or the resolved billing type changes.
|
||||
// type can flip after setup (e.g. when the team-workspaces flag resolves from
|
||||
// authenticated config), which swaps the active backend and needs a fresh init.
|
||||
watch(
|
||||
() => store.activeWorkspace?.id,
|
||||
async (newWorkspaceId, oldWorkspaceId) => {
|
||||
[() => store.activeWorkspace?.id, () => type.value],
|
||||
async (
|
||||
[newWorkspaceId, newType],
|
||||
[oldWorkspaceId, oldType] = [undefined, undefined]
|
||||
) => {
|
||||
if (!newWorkspaceId) {
|
||||
// No workspace selected - reset state
|
||||
isInitialized.value = false
|
||||
@@ -194,8 +198,8 @@ function useBillingContextInternal(): BillingContext {
|
||||
return
|
||||
}
|
||||
|
||||
if (newWorkspaceId !== oldWorkspaceId) {
|
||||
// Workspace changed - reinitialize
|
||||
if (newWorkspaceId !== oldWorkspaceId || newType !== oldType) {
|
||||
// Workspace or billing type changed - reinitialize
|
||||
isInitialized.value = false
|
||||
try {
|
||||
await initialize()
|
||||
@@ -233,12 +237,8 @@ function useBillingContextInternal(): BillingContext {
|
||||
return activeContext.value.fetchBalance()
|
||||
}
|
||||
|
||||
async function subscribe(
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
) {
|
||||
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
|
||||
async function subscribe(planSlug: string, options?: SubscribeOptions) {
|
||||
return activeContext.value.subscribe(planSlug, options)
|
||||
}
|
||||
|
||||
async function previewSubscribe(planSlug: string) {
|
||||
@@ -258,6 +258,15 @@ function useBillingContextInternal(): BillingContext {
|
||||
}
|
||||
|
||||
async function topup(amountCents: number) {
|
||||
if (
|
||||
!Number.isInteger(amountCents) ||
|
||||
amountCents <= 0 ||
|
||||
amountCents % 100 !== 0
|
||||
) {
|
||||
throw new Error(
|
||||
'Top-up amount must be a positive whole-dollar cent value'
|
||||
)
|
||||
}
|
||||
return activeContext.value.topup(amountCents)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeOptions,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -147,8 +148,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
|
||||
async function subscribe(
|
||||
_planSlug: string,
|
||||
_returnUrl?: string,
|
||||
_cancelUrl?: string
|
||||
_options?: SubscribeOptions
|
||||
): Promise<SubscribeResponse | void> {
|
||||
// Legacy billing uses Stripe checkout flow via useSubscription
|
||||
await legacySubscribe()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, reactive, readonly } from 'vue'
|
||||
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedPersonalWorkspaceBillingReady,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
isAuthenticatedConfigLoaded,
|
||||
remoteConfig
|
||||
@@ -22,6 +23,7 @@ export enum ServerFeatureFlag {
|
||||
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
||||
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
|
||||
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
|
||||
PERSONAL_WORKSPACE_BILLING_READY = 'personal_workspace_billing_ready',
|
||||
USER_SECRETS_ENABLED = 'user_secrets_enabled',
|
||||
NODE_REPLACEMENTS = 'node_replacements',
|
||||
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
|
||||
@@ -117,6 +119,33 @@ export function useFeatureFlags() {
|
||||
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
|
||||
)
|
||||
},
|
||||
/**
|
||||
* Whether the backend is ready to serve personal billing over the
|
||||
* workspace path (/api/billing/*). Gates the personal cutover so personal
|
||||
* subscribers stay on legacy billing until the backend emits this key,
|
||||
* even when team workspaces are already enabled.
|
||||
* IMPORTANT: Defaults false until authenticated remote config is loaded,
|
||||
* matching teamWorkspacesEnabled, so we never route personal billing to a
|
||||
* blank workspace identity before the backend is live.
|
||||
*/
|
||||
get personalWorkspaceBillingReady() {
|
||||
const override = getDevOverride<boolean>(
|
||||
ServerFeatureFlag.PERSONAL_WORKSPACE_BILLING_READY
|
||||
)
|
||||
if (override !== undefined) return override
|
||||
|
||||
if (!isCloud) return false
|
||||
if (!isAuthenticatedConfigLoaded.value)
|
||||
return cachedPersonalWorkspaceBillingReady.value ?? false
|
||||
|
||||
return (
|
||||
remoteConfig.value.personal_workspace_billing_ready ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.PERSONAL_WORKSPACE_BILLING_READY,
|
||||
false
|
||||
)
|
||||
)
|
||||
},
|
||||
get userSecretsEnabled() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.USER_SECRETS_ENABLED,
|
||||
|
||||
@@ -2463,11 +2463,16 @@
|
||||
"creditSliderSave": "Save {percent}% ({amount})",
|
||||
"renewsDate": "Renews {date}",
|
||||
"expiresDate": "Expires {date}",
|
||||
"renewsOnDate": "Renews on {date}",
|
||||
"endsOnDate": "Ends on {date}",
|
||||
"manageSubscription": "Manage subscription",
|
||||
"managePayment": "Manage Payment",
|
||||
"manageBilling": "Manage billing",
|
||||
"changePlan": "Change plan",
|
||||
"cancelSubscription": "Cancel Subscription",
|
||||
"cancelPlan": "Cancel plan",
|
||||
"canceled": "Canceled",
|
||||
"resubscribe": "Resubscribe",
|
||||
"reactivatePlan": "Reactivate plan",
|
||||
"resubscribeTo": "Resubscribe to {plan}",
|
||||
"resubscribeSuccess": "Subscription reactivated successfully",
|
||||
"canceledCard": {
|
||||
@@ -2477,7 +2482,7 @@
|
||||
"cancelSuccess": "Subscription cancelled successfully",
|
||||
"cancelDialog": {
|
||||
"title": "Cancel subscription",
|
||||
"description": "Your access continues until {date}. You won't be charged again, and your workspace and credits will be preserved. You can resubscribe anytime.",
|
||||
"description": "Your plan stays active until {date}. You won't be charged again. Any unused monthly credits will expire, but additional credits are kept. Resubscribe anytime.",
|
||||
"endOfBillingPeriod": "end of billing period",
|
||||
"keepSubscription": "Keep subscription",
|
||||
"confirmCancel": "Cancel subscription",
|
||||
@@ -2492,9 +2497,7 @@
|
||||
"failed": "Failed to change plan",
|
||||
"notAllowed": "This plan change is not available",
|
||||
"paymentMethodRequired": "A payment method is required to change plans",
|
||||
"paymentPageBlocked": "Couldn't open the payment page — please try again",
|
||||
"memberRemovalFailed": "Couldn't remove {email} from the team — some members may already be removed and your plan was not changed",
|
||||
"failedAfterMemberRemoval": "Team members were removed, but the plan change didn't complete — please try again or contact support"
|
||||
"paymentPageBlocked": "Couldn't open the payment page — please try again"
|
||||
},
|
||||
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
|
||||
"partnerNodesDescription": "For running commercial/proprietary models",
|
||||
@@ -2509,10 +2512,34 @@
|
||||
"creditsRemainingThisMonth": "Included (Refills {date})",
|
||||
"creditsRemainingThisYear": "Included (Refills {date})",
|
||||
"creditsYouveAdded": "Additional",
|
||||
"remaining": "remaining",
|
||||
"refillsDate": "Refills {date}",
|
||||
"creditsUsed": "{used} used",
|
||||
"creditsLeftOfTotal": "{remaining} left of {total}",
|
||||
"additionalCredits": "Additional credits",
|
||||
"additionalCreditsInUse": "In use",
|
||||
"usedAfterMonthly": "Used after monthly runs out",
|
||||
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
|
||||
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
|
||||
"outOfCreditsTitle": "You're out of credits. Credits refill {date}",
|
||||
"outOfCreditsDescription": "Add more credits to continue generating.",
|
||||
"additionalCreditsTooltip": "Credits you add on top of your plan. Used after monthly credits run out. Each expires one year after purchase.",
|
||||
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
|
||||
"viewMoreDetailsPlans": "View more details about plans & pricing",
|
||||
"nextBillingCycle": "next billing cycle",
|
||||
"yourPlanIncludes": "Your plan includes:",
|
||||
"whatsIncluded": "What's included:",
|
||||
"teamPlanName": "Team",
|
||||
"teamPlanIncludes": "Your plan includes everything in {plan}, plus:",
|
||||
"teamPerks": {
|
||||
"inviteMembers": "Invite members",
|
||||
"concurrentRuns": "Members can run workflows concurrently",
|
||||
"sharedCreditPool": "Shared credit pool for all members",
|
||||
"rolePermissions": "Role-based permissions"
|
||||
},
|
||||
"freePerks": {
|
||||
"maxRuntime": "{duration} max runtime"
|
||||
},
|
||||
"viewMoreDetails": "View more details",
|
||||
"learnMore": "Learn more",
|
||||
"billedMonthly": "Billed monthly",
|
||||
@@ -2570,6 +2597,7 @@
|
||||
"subscribeToRunFull": "Subscribe to Run",
|
||||
"subscribeForMore": "Upgrade",
|
||||
"upgradeToAddCredits": "Upgrade to add credits",
|
||||
"subscribe": "Subscribe",
|
||||
"subscribeNow": "Subscribe Now",
|
||||
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
|
||||
"workspaceNotSubscribed": "This workspace is not on a subscription",
|
||||
@@ -2606,9 +2634,9 @@
|
||||
"perkProjectAssets": "Project & asset management",
|
||||
"cta": "Subscribe to Team Yearly",
|
||||
"ctaMonthly": "Subscribe to Team Monthly",
|
||||
"unavailable": "This team plan is not available right now.",
|
||||
"changePlan": "Change plan",
|
||||
"currentPlan": "Current plan",
|
||||
"checkoutComingSoon": "Team plan checkout is coming soon."
|
||||
"currentPlan": "Current plan"
|
||||
},
|
||||
"enterprise": {
|
||||
"name": "Enterprise",
|
||||
@@ -2638,6 +2666,7 @@
|
||||
"upgradeCta": "View plans"
|
||||
},
|
||||
"partnerNodesCredits": "Partner nodes pricing",
|
||||
"partnerNodesPricingTable": "Partner Nodes pricing table",
|
||||
"plansAndPricing": "Plans & pricing",
|
||||
"managePlan": "Manage plan",
|
||||
"upgrade": "UPGRADE",
|
||||
@@ -2648,8 +2677,6 @@
|
||||
"monthlyCreditsPerMemberLabel": "Monthly credits / member",
|
||||
"maxMembersLabel": "Max. members",
|
||||
"yearlyCreditsLabel": "Total yearly credits",
|
||||
"membersLabel": "Up to {count} members",
|
||||
"nextMonthInvoice": "Next month invoice",
|
||||
"memberCount": "{count} member | {count} members",
|
||||
"maxDurationLabel": "Max run duration",
|
||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||
@@ -2664,7 +2691,7 @@
|
||||
"upgradeTo": "Upgrade to {plan}",
|
||||
"changeTo": "Change to {plan}",
|
||||
"maxDuration": {
|
||||
"free": "30 min",
|
||||
"free": "10 min",
|
||||
"standard": "30 min",
|
||||
"creator": "30 min",
|
||||
"pro": "1 hr",
|
||||
@@ -2677,10 +2704,11 @@
|
||||
"preview": {
|
||||
"confirmPayment": "Confirm your payment",
|
||||
"confirmPlanChange": "Confirm your plan change",
|
||||
"startingToday": "Starting today",
|
||||
"startingToday": "Starts today",
|
||||
"starting": "Starting {date}",
|
||||
"ends": "Ends {date}",
|
||||
"eachMonthCreditsRefill": "Each month credits refill to",
|
||||
"eachYearCreditsRefill": "Each year credits refill to",
|
||||
"everyMonthStarting": "Every month starting {date}",
|
||||
"creditsRefillTo": "Credits refill to",
|
||||
"youllBeCharged": "You'll be charged",
|
||||
@@ -2691,6 +2719,21 @@
|
||||
"proratedCharge": "Prorated charge for {plan}",
|
||||
"totalDueToday": "Total due today",
|
||||
"nextPaymentDue": "Next payment due {date}. Cancel anytime.",
|
||||
"confirmUpgradeTitle": "Confirm your upgrade",
|
||||
"confirmUpgradeCta": "Confirm upgrade",
|
||||
"confirmChange": "Confirm change",
|
||||
"switchesToday": "Switches today",
|
||||
"startsOn": "Starts {date}",
|
||||
"yearlySubscription": "Yearly subscription",
|
||||
"newMonthlySubscription": "New monthly subscription",
|
||||
"creditFromCurrent": "Credit from current {plan}",
|
||||
"currentMonthly": "monthly",
|
||||
"creditsYoullGetToday": "Credits you'll get today",
|
||||
"refillReplacesNote": "Replaces your monthly refill. Existing balance is kept.",
|
||||
"afterThat": "After that",
|
||||
"creditsRefillMonthlyTo": "Credits refill monthly to",
|
||||
"billedEachMonth": "{amount} billed each month. Cancel anytime.",
|
||||
"stayOnUntil": "You'll stay on {plan} until {date}.",
|
||||
"termsAgreement": "By continuing, you agree to Comfy Org's {terms} and {privacy}.",
|
||||
"terms": "Terms",
|
||||
"privacyPolicy": "Privacy Policy",
|
||||
@@ -2702,8 +2745,12 @@
|
||||
},
|
||||
"success": {
|
||||
"allSet": "You're all set",
|
||||
"inviteEmailsPlaceholder": "Enter emails separated by commas",
|
||||
"inviteSubtext": "You can also invite people later from Settings",
|
||||
"inviteTitle": "Invite your team",
|
||||
"planUpdated": "Your plan has been successfully updated.",
|
||||
"receiptEmailed": "A receipt has been emailed to you."
|
||||
"receiptEmailed": "A receipt has been emailed to you.",
|
||||
"sendInvites": "Send invites"
|
||||
}
|
||||
},
|
||||
"userSettings": {
|
||||
@@ -2719,7 +2766,7 @@
|
||||
"workspacePanel": {
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of 50 members",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"planCredits": "Plan & Credits",
|
||||
@@ -2729,7 +2776,8 @@
|
||||
"placeholder": "Dashboard workspace settings"
|
||||
},
|
||||
"members": {
|
||||
"membersCount": "{count}/{maxSeats} Members",
|
||||
"header": "Members",
|
||||
"membersCount": "{count} of {maxSeats} members",
|
||||
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
|
||||
"tabs": {
|
||||
"active": "Active",
|
||||
@@ -2738,26 +2786,30 @@
|
||||
"columns": {
|
||||
"inviteDate": "Invite date",
|
||||
"expiryDate": "Expiry date",
|
||||
"joinDate": "Join date"
|
||||
"role": "Role"
|
||||
},
|
||||
"actions": {
|
||||
"copyLink": "Copy invite link",
|
||||
"revokeInvite": "Revoke invite",
|
||||
"resendInvite": "Resend invite",
|
||||
"cancelInvite": "Cancel invite",
|
||||
"changeRole": "Change role",
|
||||
"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",
|
||||
"upsellBanner": "To add teammates, upgrade your plan.",
|
||||
"upsellBannerReactivate": "To add more teammates, reactivate your plan.",
|
||||
"upgradeToTeam": "Upgrade to Team",
|
||||
"reactivateTeam": "Reactivate Team",
|
||||
"needMoreMembers": "Need more members?",
|
||||
"contactUs": "Contact us",
|
||||
"noInvites": "No pending invites",
|
||||
"noMembers": "No members",
|
||||
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
|
||||
"createNewWorkspace": "create a new one."
|
||||
"searchPlaceholder": "Search..."
|
||||
},
|
||||
"menu": {
|
||||
"editWorkspace": "Edit workspace details",
|
||||
"leaveWorkspace": "Leave Workspace",
|
||||
"deleteWorkspace": "Delete Workspace",
|
||||
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first"
|
||||
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
|
||||
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
|
||||
},
|
||||
"editWorkspaceDialog": {
|
||||
"title": "Edit workspace details",
|
||||
@@ -2781,32 +2833,38 @@
|
||||
"success": "Member removed",
|
||||
"error": "Failed to remove member"
|
||||
},
|
||||
"changeRoleDialog": {
|
||||
"promoteTitle": "Make {name} an owner?",
|
||||
"promoteIntro": "They'll be able to:",
|
||||
"promotePermissionCredits": "Add additional credits",
|
||||
"promotePermissionManage": "Manage members, payment methods, and workspace settings",
|
||||
"promotePermissionRoles": "Promote and demote other owners (except the workspace creator).",
|
||||
"promoteConfirm": "Make owner",
|
||||
"demoteTitle": "Demote {name} to member?",
|
||||
"demoteMessage": "They'll lose admin access.",
|
||||
"demoteConfirm": "Demote to member",
|
||||
"success": "Role updated",
|
||||
"error": "Failed to update role"
|
||||
},
|
||||
"revokeInviteDialog": {
|
||||
"title": "Uninvite this person?",
|
||||
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
|
||||
"revoke": "Uninvite"
|
||||
},
|
||||
"inviteUpsellDialog": {
|
||||
"titleNotSubscribed": "A subscription is required to invite members",
|
||||
"titleNotSubscribed": "A Team plan 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"
|
||||
"messageNotSubscribed": "To add teammates to this workspace, upgrade to a Team plan.",
|
||||
"messageSingleSeat": "Your current plan includes one seat for the workspace owner. To add teammates, upgrade to a Team plan.",
|
||||
"upgradeToTeam": "Upgrade to Team"
|
||||
},
|
||||
"inviteMemberDialog": {
|
||||
"title": "Invite a person to this workspace",
|
||||
"message": "Create a shareable invite link to send to someone",
|
||||
"placeholder": "Enter the person's email",
|
||||
"createLink": "Create link",
|
||||
"linkStep": {
|
||||
"title": "Send this link to the person",
|
||||
"message": "Make sure their account uses this email.",
|
||||
"copyLink": "Copy Link",
|
||||
"done": "Done"
|
||||
},
|
||||
"linkCopied": "Copied",
|
||||
"linkCopyFailed": "Failed to copy link"
|
||||
"title": "Invite members to this workspace",
|
||||
"placeholder": "Enter emails separated by commas",
|
||||
"invalidEmailCount": "{count} invalid email address | {count} invalid email addresses",
|
||||
"failedCount": "Couldn't send {count} invite. Try again. | Couldn't send {count} invites. Try again.",
|
||||
"invitedMessage": "An invite was emailed to {emails}. | Invites were emailed to {emails}.",
|
||||
"seatLimitReached": "You can invite up to {count} teammate. | You can invite up to {count} teammates."
|
||||
},
|
||||
"createWorkspaceDialog": {
|
||||
"title": "Create a new workspace",
|
||||
@@ -2833,6 +2891,8 @@
|
||||
"title": "Left workspace",
|
||||
"message": "You have left the workspace."
|
||||
},
|
||||
"inviteResent": "Invite resent",
|
||||
"inviteResendFailed": "Failed to resend invite",
|
||||
"failedToUpdateWorkspace": "Failed to update workspace",
|
||||
"failedToCreateWorkspace": "Failed to create workspace",
|
||||
"failedToDeleteWorkspace": "Failed to delete workspace",
|
||||
|
||||
@@ -116,7 +116,9 @@ app
|
||||
modal: 1800,
|
||||
overlay: 1800,
|
||||
menu: 1800,
|
||||
tooltip: 1800
|
||||
// Tooltips sit above modals/menus so a menu-item tooltip isn't hidden
|
||||
// behind a body-portaled dropdown that lifts itself to modal + 1.
|
||||
tooltip: 2000
|
||||
},
|
||||
theme: {
|
||||
preset: ComfyUIPreset,
|
||||
|
||||
@@ -59,6 +59,15 @@ vi.mock('@/platform/cloud/subscription/utils/subscriptionCheckoutUtil', () => ({
|
||||
mockPerformSubscriptionCheckout(...args)
|
||||
}))
|
||||
|
||||
const mockPerformTeamSubscriptionCheckout = vi.fn()
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil',
|
||||
() => ({
|
||||
performTeamSubscriptionCheckout: (...args: unknown[]) =>
|
||||
mockPerformTeamSubscriptionCheckout(...args)
|
||||
})
|
||||
)
|
||||
|
||||
const createI18nInstance = () =>
|
||||
createI18n({
|
||||
legacy: false,
|
||||
@@ -73,6 +82,7 @@ const createI18nInstance = () =>
|
||||
},
|
||||
subscription: {
|
||||
subscribeTo: 'Subscribe to {plan}',
|
||||
teamPlan: { name: 'Team Plan' },
|
||||
tiers: {
|
||||
standard: { name: 'Standard' },
|
||||
creator: { name: 'Creator' },
|
||||
@@ -162,4 +172,24 @@ describe('CloudSubscriptionRedirectView', () => {
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('checks out the team plan via the workspace path with the chosen stop and cycle', async () => {
|
||||
await mountView({ tier: 'team', stop: 'team_700', cycle: 'yearly' })
|
||||
|
||||
expect(mockRouterPush).not.toHaveBeenCalledWith('/')
|
||||
expect(screen.getByText('Subscribe to Team Plan')).toBeInTheDocument()
|
||||
expect(mockPerformTeamSubscriptionCheckout).toHaveBeenCalledWith(
|
||||
'team_700',
|
||||
'yearly'
|
||||
)
|
||||
// Team never goes through the personal checkout path
|
||||
expect(mockPerformSubscriptionCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('redirects to home for a team link with no stop', async () => {
|
||||
await mountView({ tier: 'team', cycle: 'yearly' })
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/')
|
||||
expect(mockPerformTeamSubscriptionCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
|
||||
import { performTeamSubscriptionCheckout } from '@/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil'
|
||||
|
||||
import type { BillingCycle } from '../subscription/utils/subscriptionTierRank'
|
||||
|
||||
@@ -35,6 +36,12 @@ const tierDisplayName = computed(() => {
|
||||
return names[selectedTierKey.value]
|
||||
})
|
||||
|
||||
const isTeamCheckout = ref(false)
|
||||
|
||||
const planLabel = computed(() =>
|
||||
isTeamCheckout.value ? t('subscription.teamPlan.name') : tierDisplayName.value
|
||||
)
|
||||
|
||||
const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
const rawType = route.query.tier
|
||||
const rawCycle = route.query.cycle
|
||||
@@ -58,7 +65,34 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Only paid tiers can be checked out via redirect
|
||||
const validCycles: BillingCycle[] = ['monthly', 'yearly']
|
||||
const billingCycle: BillingCycle = (validCycles as string[]).includes(
|
||||
cycleParam
|
||||
)
|
||||
? (cycleParam as BillingCycle)
|
||||
: 'monthly'
|
||||
|
||||
// Team is a per-credit plan picked on a slider, so it carries a `stop` (the
|
||||
// chosen credit commitment) instead of a tier and checks out through the
|
||||
// workspace billing endpoint rather than the personal one.
|
||||
if (tierKeyParam === 'team') {
|
||||
const rawStop = route.query.stop
|
||||
const stopId =
|
||||
typeof rawStop === 'string'
|
||||
? rawStop
|
||||
: Array.isArray(rawStop)
|
||||
? rawStop[0]
|
||||
: null
|
||||
if (!stopId) {
|
||||
await router.push('/')
|
||||
return
|
||||
}
|
||||
isTeamCheckout.value = true
|
||||
await performTeamSubscriptionCheckout(stopId, billingCycle)
|
||||
return
|
||||
}
|
||||
|
||||
// Only paid personal tiers can be checked out via redirect
|
||||
const validTierKeys: TierKey[] = ['standard', 'creator', 'pro', 'founder']
|
||||
if (!(validTierKeys as string[]).includes(tierKeyParam)) {
|
||||
await router.push('/')
|
||||
@@ -69,11 +103,6 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
|
||||
selectedTierKey.value = tierKey
|
||||
|
||||
const validCycles: BillingCycle[] = ['monthly', 'yearly']
|
||||
if (!cycleParam || !(validCycles as string[]).includes(cycleParam)) {
|
||||
cycleParam = 'monthly'
|
||||
}
|
||||
|
||||
if (!isInitialized.value) {
|
||||
await initialize()
|
||||
}
|
||||
@@ -81,11 +110,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
if (isActiveSubscription.value) {
|
||||
await accessBillingPortal(undefined, false)
|
||||
} else {
|
||||
await performSubscriptionCheckout(
|
||||
tierKey,
|
||||
cycleParam as BillingCycle,
|
||||
false
|
||||
)
|
||||
await performSubscriptionCheckout(tierKey, billingCycle, false)
|
||||
}
|
||||
}, reportError)
|
||||
|
||||
@@ -105,18 +130,18 @@ onMounted(() => {
|
||||
class="size-16"
|
||||
/>
|
||||
<p
|
||||
v-if="selectedTierKey"
|
||||
v-if="planLabel"
|
||||
class="font-inter text-base/normal font-normal text-base-foreground"
|
||||
>
|
||||
{{
|
||||
t('subscription.subscribeTo', {
|
||||
plan: tierDisplayName
|
||||
plan: planLabel
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<ProgressSpinner v-if="selectedTierKey" class="size-8" stroke-width="4" />
|
||||
<ProgressSpinner v-if="planLabel" class="size-8" stroke-width="4" />
|
||||
<Button
|
||||
v-if="selectedTierKey"
|
||||
v-if="planLabel"
|
||||
as="a"
|
||||
href="/"
|
||||
link
|
||||
|
||||
306
src/platform/cloud/subscription/components/CreditsTile.test.ts
Normal file
306
src/platform/cloud/subscription/components/CreditsTile.test.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
|
||||
|
||||
type Balance = {
|
||||
amountMicros: number
|
||||
cloudCreditBalanceMicros?: number
|
||||
prepaidBalanceMicros?: number
|
||||
}
|
||||
type Subscription = {
|
||||
tier: string | null
|
||||
duration: string | null
|
||||
renewalDate: string | null
|
||||
}
|
||||
type TeamStop = {
|
||||
id: string
|
||||
credits_monthly: number
|
||||
stop_usd: number
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
balance: null as Balance | null,
|
||||
subscription: null as Subscription | null,
|
||||
isActiveSubscription: false,
|
||||
isFreeTier: false,
|
||||
currentTeamCreditStop: null as TeamStop | null,
|
||||
isLoading: false,
|
||||
canTopUp: true,
|
||||
fetchBalance: vi.fn(),
|
||||
fetchStatus: vi.fn(),
|
||||
showPricingTable: vi.fn(),
|
||||
showTopUpCreditsDialog: vi.fn(),
|
||||
trackAddApiCreditButtonClicked: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
balance: computed(() => state.balance),
|
||||
subscription: computed(() => state.subscription),
|
||||
isActiveSubscription: computed(() => state.isActiveSubscription),
|
||||
isFreeTier: computed(() => state.isFreeTier),
|
||||
currentTeamCreditStop: computed(() => state.currentTeamCreditStop),
|
||||
isLoading: computed(() => state.isLoading),
|
||||
fetchBalance: state.fetchBalance,
|
||||
fetchStatus: state.fetchStatus
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: computed(() => ({ canTopUp: state.canTopUp }))
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({ showPricingTable: state.showPricingTable })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showTopUpCreditsDialog: state.showTopUpCreditsDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackAddApiCreditButtonClicked: state.trackAddApiCreditButtonClicked
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
subscription: {
|
||||
totalCredits: 'Total credits',
|
||||
remaining: 'remaining',
|
||||
refreshCredits: 'Refresh credits',
|
||||
monthly: 'Monthly',
|
||||
refillsDate: 'Refills {date}',
|
||||
creditsUsed: '{used} used',
|
||||
creditsLeftOfTotal: '{remaining} left of {total}',
|
||||
additionalCredits: 'Additional credits',
|
||||
additionalCreditsInUse: 'In use',
|
||||
usedAfterMonthly: 'Used after monthly runs out',
|
||||
monthlyCreditsUsedUpTitle:
|
||||
'Monthly credits are used up. Refills {date}',
|
||||
monthlyCreditsUsedUpDescription:
|
||||
"You're now spending additional credits.",
|
||||
outOfCreditsTitle: "You're out of credits. Credits refill {date}",
|
||||
outOfCreditsDescription: 'Add more credits to continue generating.',
|
||||
addCredits: 'Add credits',
|
||||
upgradeToAddCredits: 'Upgrade to add credits'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderTile(props: Record<string, unknown> = {}) {
|
||||
return render(CreditsTile, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} },
|
||||
stubs: {
|
||||
Button: {
|
||||
template:
|
||||
'<button v-bind="$attrs" :data-variant="variant" :disabled="loading" @click="$emit(\'click\')"><slot/></button>',
|
||||
props: ['variant', 'size', 'loading'],
|
||||
emits: ['click']
|
||||
},
|
||||
Skeleton: { template: '<div role="status" aria-label="Loading"></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function activeProSubscription() {
|
||||
state.isActiveSubscription = true
|
||||
state.subscription = {
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
renewalDate: '2026-02-20T12:00:00Z'
|
||||
}
|
||||
// amountMicros are cents; centsToCredits multiplies by 2.11.
|
||||
state.balance = {
|
||||
amountMicros: 500, // -> 1,055 total
|
||||
cloudCreditBalanceMicros: 200, // -> 422 monthly remaining
|
||||
prepaidBalanceMicros: 300 // -> 633 additional
|
||||
}
|
||||
}
|
||||
|
||||
describe('CreditsTile', () => {
|
||||
beforeEach(() => {
|
||||
state.balance = null
|
||||
state.subscription = null
|
||||
state.isActiveSubscription = false
|
||||
state.isFreeTier = false
|
||||
state.currentTeamCreditStop = null
|
||||
state.isLoading = false
|
||||
state.canTopUp = true
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the total balance (cents converted to credits) with the remaining suffix', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain('1,055')
|
||||
expect(container.textContent).toContain('remaining')
|
||||
})
|
||||
|
||||
it('renders the monthly usage bar and additional breakdown', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
// PRO monthly allowance = 21,100; remaining 422 -> used 20,678.
|
||||
expect(container.textContent).toContain('Monthly')
|
||||
expect(container.textContent).toMatch(/Refills Feb/)
|
||||
expect(container.textContent).toContain('20,678 used')
|
||||
expect(container.textContent).toContain('422 left of 21,100')
|
||||
expect(container.textContent).toContain('Additional credits')
|
||||
expect(container.textContent).toContain('633')
|
||||
expect(container.textContent).toContain('Used after monthly runs out')
|
||||
})
|
||||
|
||||
it('renders a compact monthly summary for narrow containers', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain('422 left of 21K')
|
||||
})
|
||||
|
||||
it('uses the team credit stop monthly grant for the monthly total', () => {
|
||||
state.isActiveSubscription = true
|
||||
state.subscription = {
|
||||
tier: 'TEAM',
|
||||
duration: 'ANNUAL',
|
||||
renewalDate: '2026-02-20T12:00:00Z'
|
||||
}
|
||||
state.currentTeamCreditStop = {
|
||||
id: 'team_2500',
|
||||
credits_monthly: 527500,
|
||||
stop_usd: 2500
|
||||
}
|
||||
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
|
||||
const { container } = renderTile()
|
||||
// Monthly total is the stop's raw monthly grant, not the tier fallback,
|
||||
// and is not multiplied by 12 for annual billing.
|
||||
expect(container.textContent).toContain('422 left of 527,500')
|
||||
})
|
||||
|
||||
it('hides the breakdown and forces zeros in the zero state', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile({ zeroState: true })
|
||||
expect(container.textContent).toContain('0')
|
||||
expect(container.textContent).not.toContain('left of')
|
||||
expect(container.textContent).not.toContain('Additional credits')
|
||||
expect(screen.queryByText('Add credits')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows only the balance with no breakdown when there is no active subscription', () => {
|
||||
state.isActiveSubscription = false
|
||||
state.balance = { amountMicros: 500 }
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain('1,055')
|
||||
expect(container.textContent).not.toContain('left of')
|
||||
expect(container.textContent).not.toContain('Additional credits')
|
||||
expect(screen.queryByText('Add credits')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no depletion notice or in-use badge while monthly credits remain', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).not.toContain('Monthly credits are used up')
|
||||
expect(container.textContent).not.toContain("You're out of credits")
|
||||
expect(screen.queryByText('In use')).toBeNull()
|
||||
})
|
||||
|
||||
it('flags spending of additional credits once the monthly allowance is depleted', () => {
|
||||
activeProSubscription()
|
||||
state.balance = {
|
||||
amountMicros: 300,
|
||||
cloudCreditBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 300
|
||||
}
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain(
|
||||
'Monthly credits are used up. Refills Feb 20'
|
||||
)
|
||||
expect(container.textContent).toContain(
|
||||
"You're now spending additional credits."
|
||||
)
|
||||
expect(screen.getByText('In use')).toBeTruthy()
|
||||
expect(screen.getByText('Add credits').dataset.variant).toBe('secondary')
|
||||
})
|
||||
|
||||
it('emphasizes add-credits when fully out of credits', () => {
|
||||
activeProSubscription()
|
||||
state.balance = {
|
||||
amountMicros: 0,
|
||||
cloudCreditBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 0
|
||||
}
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain(
|
||||
"You're out of credits. Credits refill Feb 20"
|
||||
)
|
||||
expect(container.textContent).toContain(
|
||||
'Add more credits to continue generating.'
|
||||
)
|
||||
expect(screen.queryByText('In use')).toBeNull()
|
||||
expect(screen.getByText('Add credits').dataset.variant).toBe('inverted')
|
||||
})
|
||||
|
||||
it('suppresses the depletion notice until the balance has loaded', () => {
|
||||
activeProSubscription()
|
||||
state.balance = null
|
||||
state.isLoading = true
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).not.toContain('Monthly credits are used up')
|
||||
expect(container.textContent).not.toContain("You're out of credits")
|
||||
})
|
||||
|
||||
it('routes add-credits through telemetry + the top-up dialog', async () => {
|
||||
activeProSubscription()
|
||||
renderTile()
|
||||
await userEvent.click(screen.getByText('Add credits'))
|
||||
expect(state.trackAddApiCreditButtonClicked).toHaveBeenCalledOnce()
|
||||
expect(state.showTopUpCreditsDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('offers the upgrade path instead of add-credits on the free tier', async () => {
|
||||
activeProSubscription()
|
||||
state.isFreeTier = true
|
||||
renderTile()
|
||||
expect(screen.queryByText('Add credits')).toBeNull()
|
||||
await userEvent.click(screen.getByText('Upgrade to add credits'))
|
||||
expect(state.showPricingTable).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides the action button when the user lacks the top-up permission', () => {
|
||||
activeProSubscription()
|
||||
state.canTopUp = false
|
||||
renderTile()
|
||||
expect(screen.queryByText('Add credits')).toBeNull()
|
||||
expect(screen.queryByText('Upgrade to add credits')).toBeNull()
|
||||
})
|
||||
|
||||
it('refreshes balance and status from the facade on mount and on demand', async () => {
|
||||
activeProSubscription()
|
||||
renderTile()
|
||||
expect(state.fetchBalance).toHaveBeenCalledOnce()
|
||||
expect(state.fetchStatus).toHaveBeenCalledOnce()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'Refresh credits' })
|
||||
)
|
||||
expect(state.fetchBalance).toHaveBeenCalledTimes(2)
|
||||
expect(state.fetchStatus).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
366
src/platform/cloud/subscription/components/CreditsTile.vue
Normal file
366
src/platform/cloud/subscription/components/CreditsTile.vue
Normal file
@@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<div
|
||||
class="@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="absolute top-4 right-4"
|
||||
:loading="isLoadingBalance"
|
||||
:aria-label="$t('subscription.refreshCredits')"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<i class="icon-[lucide--refresh-cw] size-4 text-text-secondary" />
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.totalCredits') }}
|
||||
</div>
|
||||
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
||||
<div v-else class="flex items-baseline gap-2">
|
||||
<i
|
||||
class="icon-[lucide--component] size-4 self-center"
|
||||
:style="creditIconStyle"
|
||||
/>
|
||||
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
|
||||
<span class="text-sm text-muted @max-[300px]:hidden">{{
|
||||
$t('subscription.remaining')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="showBreakdown">
|
||||
<div
|
||||
v-if="emptyStateNotice"
|
||||
class="flex items-start gap-2 rounded-lg bg-base-background p-3 text-sm"
|
||||
>
|
||||
<i
|
||||
class="mt-0.5 icon-[lucide--info] size-4 shrink-0 text-base-foreground"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base-foreground">{{ emptyStateNotice.title }}</span>
|
||||
<span class="text-muted">{{ emptyStateNotice.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showBar"
|
||||
:class="cn('flex flex-col gap-2', isMonthlyDepleted && 'opacity-30')"
|
||||
>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-text-primary">{{
|
||||
$t('subscription.monthly')
|
||||
}}</span>
|
||||
<span class="text-muted">
|
||||
{{ $t('subscription.refillsDate', { date: refillsDateShort }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="h-2 w-full overflow-hidden rounded-full bg-secondary-background-hover"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
:style="{ width: usedBarWidth, backgroundColor: CREDIT_COLOR }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 text-sm">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
class="@max-[300px]:hidden"
|
||||
width="5rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else class="text-muted @max-[300px]:hidden">
|
||||
{{ $t('subscription.creditsUsed', { used: usedDisplay }) }}
|
||||
</span>
|
||||
<Skeleton v-if="isLoadingBalance" width="9rem" height="1rem" />
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center gap-1 font-bold text-text-primary"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--component] size-4"
|
||||
:style="creditIconStyle"
|
||||
/>
|
||||
<span class="@max-[180px]:hidden">
|
||||
{{
|
||||
$t('subscription.creditsLeftOfTotal', {
|
||||
remaining: monthlyBonusCredits,
|
||||
total: monthlyTotalDisplay
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="hidden @max-[180px]:inline">
|
||||
{{
|
||||
$t('subscription.creditsLeftOfTotal', {
|
||||
remaining: monthlyRemainingCompact,
|
||||
total: monthlyTotalCompact
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px w-full bg-interface-stroke" />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 text-sm @max-[300px]:flex-col @max-[300px]:items-start"
|
||||
>
|
||||
<span class="flex items-center gap-1 text-text-primary">
|
||||
{{ $t('subscription.additionalCredits') }}
|
||||
<i
|
||||
v-tooltip="{
|
||||
value: $t('subscription.additionalCreditsTooltip'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="icon-[lucide--info] size-4 text-muted"
|
||||
/>
|
||||
<span
|
||||
v-if="isSpendingAdditional"
|
||||
class="flex h-3.5 items-center rounded-full bg-base-foreground px-1 text-[10px] leading-none font-semibold text-base-background uppercase"
|
||||
>
|
||||
{{ $t('subscription.additionalCreditsInUse') }}
|
||||
</span>
|
||||
</span>
|
||||
<Skeleton v-if="isLoadingBalance" width="3rem" height="1rem" />
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center gap-1 font-bold text-text-primary"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--component] size-4"
|
||||
:style="creditIconStyle"
|
||||
/>
|
||||
{{ displayPrepaid }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted @max-[300px]:hidden">
|
||||
{{ $t('subscription.usedAfterMonthly') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="showActionButton" class="flex flex-col gap-3">
|
||||
<Button
|
||||
v-if="isFreeTier"
|
||||
variant="gradient"
|
||||
size="lg"
|
||||
class="w-full font-normal"
|
||||
@click="handleUpgradeToAddCredits"
|
||||
>
|
||||
{{ $t('subscription.upgradeToAddCredits') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
:variant="isOutOfCredits ? 'inverted' : 'secondary'"
|
||||
size="lg"
|
||||
:class="
|
||||
cn(
|
||||
'w-full font-normal',
|
||||
!isOutOfCredits &&
|
||||
'bg-interface-menu-component-surface-selected text-text-primary'
|
||||
)
|
||||
"
|
||||
@click="handleAddCredits"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onBeforeUnmount, onMounted, toValue } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { zeroState = false } = defineProps<{
|
||||
/** Forces the zero-credit display (e.g. unsubscribed / member view). */
|
||||
zeroState?: boolean
|
||||
}>()
|
||||
|
||||
const CREDIT_COLOR = '#fabc25'
|
||||
const creditIconStyle = { color: CREDIT_COLOR }
|
||||
|
||||
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
|
||||
const TOPUP_EXPIRY_MS = 5 * 60 * 1000
|
||||
|
||||
const { locale, n, t } = useI18n()
|
||||
|
||||
const {
|
||||
subscription,
|
||||
balance,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
currentTeamCreditStop,
|
||||
fetchBalance,
|
||||
fetchStatus
|
||||
} = useBillingContext()
|
||||
const {
|
||||
monthlyBonusCredits,
|
||||
prepaidCredits,
|
||||
totalCredits,
|
||||
monthlyBonusCreditsValue,
|
||||
prepaidCreditsValue,
|
||||
isLoadingBalance
|
||||
} = useSubscriptionCredits()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const isYearly = computed(() => subscription.value?.duration === 'ANNUAL')
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscription.value?.tier
|
||||
if (!tier) return DEFAULT_TIER_KEY
|
||||
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
|
||||
})
|
||||
|
||||
const monthlyTotalCredits = computed<number | null>(() => {
|
||||
const teamStop = currentTeamCreditStop.value
|
||||
if (teamStop) return teamStop.credits_monthly
|
||||
const credits = getTierCredits(tierKey.value)
|
||||
if (credits === null) return null
|
||||
return isYearly.value ? credits * 12 : credits
|
||||
})
|
||||
|
||||
const usage = computed(() =>
|
||||
computeMonthlyUsage(
|
||||
monthlyBonusCreditsValue.value,
|
||||
monthlyTotalCredits.value ?? 0
|
||||
)
|
||||
)
|
||||
|
||||
const refillsDateShort = computed(() => {
|
||||
const raw = subscription.value?.renewalDate
|
||||
if (!raw) return ''
|
||||
const date = new Date(raw)
|
||||
return Number.isNaN(date.getTime())
|
||||
? raw
|
||||
: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
})
|
||||
|
||||
const monthlyTotalDisplay = computed(() => {
|
||||
const total = monthlyTotalCredits.value
|
||||
return total === null ? '—' : n(total)
|
||||
})
|
||||
|
||||
const usedDisplay = computed(() => n(usage.value.used))
|
||||
|
||||
const compactNumber = computed(
|
||||
() => new Intl.NumberFormat(locale.value, { notation: 'compact' })
|
||||
)
|
||||
const monthlyRemainingCompact = computed(() =>
|
||||
compactNumber.value.format(monthlyBonusCreditsValue.value)
|
||||
)
|
||||
const monthlyTotalCompact = computed(() => {
|
||||
const total = monthlyTotalCredits.value
|
||||
return total === null ? '—' : compactNumber.value.format(total)
|
||||
})
|
||||
|
||||
const displayTotal = computed(() => (zeroState ? '0' : totalCredits.value))
|
||||
const displayPrepaid = computed(() => (zeroState ? '0' : prepaidCredits.value))
|
||||
const usedBarWidth = computed(
|
||||
() => `${(usage.value.usedFraction * 100).toFixed(2)}%`
|
||||
)
|
||||
|
||||
const showBreakdown = computed(() => isActiveSubscription.value && !zeroState)
|
||||
const showBar = computed(
|
||||
() =>
|
||||
showBreakdown.value &&
|
||||
monthlyTotalCredits.value !== null &&
|
||||
monthlyTotalCredits.value > 0
|
||||
)
|
||||
const showActionButton = computed(
|
||||
() => isActiveSubscription.value && !zeroState && permissions.value.canTopUp
|
||||
)
|
||||
|
||||
const isMonthlyDepleted = computed(
|
||||
() =>
|
||||
showBar.value &&
|
||||
!isLoadingBalance.value &&
|
||||
toValue(balance) != null &&
|
||||
monthlyBonusCreditsValue.value <= 0
|
||||
)
|
||||
const isOutOfCredits = computed(
|
||||
() => isMonthlyDepleted.value && prepaidCreditsValue.value <= 0
|
||||
)
|
||||
const isSpendingAdditional = computed(
|
||||
() => isMonthlyDepleted.value && prepaidCreditsValue.value > 0
|
||||
)
|
||||
|
||||
const emptyStateNotice = computed(() => {
|
||||
if (isOutOfCredits.value) {
|
||||
return {
|
||||
title: t('subscription.outOfCreditsTitle', {
|
||||
date: refillsDateShort.value
|
||||
}),
|
||||
description: t('subscription.outOfCreditsDescription')
|
||||
}
|
||||
}
|
||||
if (isMonthlyDepleted.value) {
|
||||
return {
|
||||
title: t('subscription.monthlyCreditsUsedUpTitle', {
|
||||
date: refillsDateShort.value
|
||||
}),
|
||||
description: t('subscription.monthlyCreditsUsedUpDescription')
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
async function handleRefresh() {
|
||||
await Promise.all([fetchBalance(), fetchStatus()])
|
||||
}
|
||||
|
||||
function handleAddCredits() {
|
||||
telemetry?.trackAddApiCreditButtonClicked()
|
||||
void dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
function handleUpgradeToAddCredits() {
|
||||
showPricingTable()
|
||||
}
|
||||
|
||||
function handleWindowFocus() {
|
||||
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
|
||||
if (!timestampStr) return
|
||||
|
||||
const timestamp = parseInt(timestampStr, 10)
|
||||
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
void handleRefresh()
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
void handleRefresh()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import FreeTierDialogContent from './FreeTierDialogContent.vue'
|
||||
|
||||
const mockRenewalDate = vi.hoisted(() => ({ value: null as string | null }))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
renewalDate: mockRenewalDate
|
||||
}))
|
||||
}))
|
||||
|
||||
function renderComponent() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return render(FreeTierDialogContent, {
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('FreeTierDialogContent', () => {
|
||||
it('renders the next refresh line formatted from the facade renewalDate', () => {
|
||||
mockRenewalDate.value = '2026-07-15T10:00:00Z'
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByText('Your credits refresh on Jul 15, 2026.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the next refresh line when renewalDate is null', () => {
|
||||
mockRenewalDate.value = null
|
||||
renderComponent()
|
||||
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -102,9 +102,9 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
|
||||
defineProps<{
|
||||
@@ -116,7 +116,17 @@ defineEmits<{
|
||||
upgrade: []
|
||||
}>()
|
||||
|
||||
const { formattedRenewalDate } = useSubscription()
|
||||
const { renewalDate } = useBillingContext()
|
||||
|
||||
const formattedRenewalDate = computed(() => {
|
||||
if (!renewalDate.value) return ''
|
||||
|
||||
return new Date(renewalDate.value).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
})
|
||||
|
||||
const freeTierCredits = computed(() => getTierCredits('free'))
|
||||
</script>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
|
||||
async function flushPromises() {
|
||||
@@ -23,10 +24,8 @@ function createDeferredPromise<T>() {
|
||||
}
|
||||
|
||||
const mockIsActiveSubscription = ref(false)
|
||||
const mockSubscriptionTier = ref<
|
||||
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
|
||||
>(null)
|
||||
const mockIsYearlySubscription = ref(false)
|
||||
const mockSubscriptionTier = ref<SubscriptionTier | null>(null)
|
||||
const mockSubscriptionDuration = ref<'MONTHLY' | 'ANNUAL'>('MONTHLY')
|
||||
const mockAccessBillingPortal = vi.fn()
|
||||
const mockReportError = vi.fn()
|
||||
const mockTrackBeginCheckout = vi.fn()
|
||||
@@ -65,13 +64,25 @@ Object.defineProperty(globalThis, 'localStorage', {
|
||||
writable: true
|
||||
})
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
|
||||
isFreeTier: computed(() => false),
|
||||
subscriptionTier: computed(() => mockSubscriptionTier.value),
|
||||
isYearlySubscription: computed(() => mockIsYearlySubscription.value),
|
||||
subscriptionStatus: ref(null)
|
||||
isFreeTier: computed(() => mockSubscriptionTier.value === 'FREE'),
|
||||
tier: computed(() => mockSubscriptionTier.value),
|
||||
subscription: computed(() =>
|
||||
mockSubscriptionTier.value
|
||||
? {
|
||||
isActive: mockIsActiveSubscription.value,
|
||||
tier: mockSubscriptionTier.value,
|
||||
duration: mockSubscriptionDuration.value,
|
||||
planSlug: null,
|
||||
renewalDate: null,
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true
|
||||
}
|
||||
: null
|
||||
)
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -217,7 +228,7 @@ describe('PricingTable', () => {
|
||||
vi.clearAllMocks()
|
||||
mockIsActiveSubscription.value = false
|
||||
mockSubscriptionTier.value = null
|
||||
mockIsYearlySubscription.value = false
|
||||
mockSubscriptionDuration.value = 'MONTHLY'
|
||||
mockUserId.value = 'user-123'
|
||||
mockAccessBillingPortal.mockReset()
|
||||
mockAccessBillingPortal.mockResolvedValue(true)
|
||||
@@ -362,6 +373,7 @@ describe('PricingTable', () => {
|
||||
it('should not call accessBillingPortal when clicking current plan', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockSubscriptionTier.value = 'CREATOR'
|
||||
mockSubscriptionDuration.value = 'ANNUAL'
|
||||
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
@@ -370,12 +382,29 @@ describe('PricingTable', () => {
|
||||
.getAllByRole('button')
|
||||
.find((b) => b.textContent?.includes('Current Plan'))
|
||||
|
||||
expect(currentPlanButton).toBeDefined()
|
||||
expect(currentPlanButton).toBeDisabled()
|
||||
await userEvent.click(currentPlanButton!)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockAccessBillingPortal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not highlight a current plan when the facade duration differs from the selected cycle', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockSubscriptionTier.value = 'CREATOR'
|
||||
mockSubscriptionDuration.value = 'MONTHLY'
|
||||
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
|
||||
const currentPlanButton = screen
|
||||
.getAllByRole('button')
|
||||
.find((b) => b.textContent?.includes('Current Plan'))
|
||||
|
||||
expect(currentPlanButton).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should initiate checkout instead of billing portal for new subscribers', async () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
|
||||
|
||||
@@ -263,8 +263,8 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import {
|
||||
TIER_PRICING,
|
||||
TIER_TO_KEY
|
||||
@@ -361,9 +361,13 @@ const tiers: PricingTierConfig[] = [
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
subscriptionTier,
|
||||
isYearlySubscription
|
||||
} = useSubscription()
|
||||
tier: subscriptionTier,
|
||||
subscription
|
||||
} = useBillingContext()
|
||||
|
||||
const isYearlySubscription = computed(
|
||||
() => subscription.value?.duration === 'ANNUAL'
|
||||
)
|
||||
const telemetry = useTelemetry()
|
||||
const { userId } = storeToRefs(useAuthStore())
|
||||
const { accessBillingPortal, reportError } = useAuthActions()
|
||||
|
||||
@@ -16,7 +16,6 @@ import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
@@ -38,8 +37,8 @@ const emit = defineEmits<{
|
||||
subscribed: []
|
||||
}>()
|
||||
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
|
||||
const { subscriptionTier } = useSubscription()
|
||||
const { isActiveSubscription, showSubscriptionDialog, tier } =
|
||||
useBillingContext()
|
||||
const isAwaitingStripeSubscription = ref(false)
|
||||
|
||||
watch(
|
||||
@@ -53,9 +52,11 @@ watch(
|
||||
)
|
||||
|
||||
const handleSubscribe = () => {
|
||||
useTelemetry()?.trackSubscription('subscribe_clicked', {
|
||||
current_tier: subscriptionTier.value?.toLowerCase()
|
||||
})
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSubscription('subscribe_clicked', {
|
||||
current_tier: tier.value?.toLowerCase()
|
||||
})
|
||||
}
|
||||
isAwaitingStripeSubscription.value = true
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-interface-stroke pt-3"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="text-xs text-text-secondary"
|
||||
@click="handleLearnMoreClick"
|
||||
>
|
||||
<i class="pi pi-question-circle text-xs text-text-secondary" />
|
||||
{{ $t('subscription.learnMore') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="text-xs text-text-secondary"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
>
|
||||
<i class="pi pi-question-circle text-xs text-text-secondary" />
|
||||
{{ $t('subscription.partnerNodesPricingTable') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="text-xs text-text-secondary"
|
||||
:loading="isLoadingSupport"
|
||||
@click="handleMessageSupport"
|
||||
>
|
||||
<i class="pi pi-comment text-xs text-text-secondary" />
|
||||
{{ $t('subscription.messageSupport') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="text-xs text-text-secondary"
|
||||
@click="handleInvoiceHistory"
|
||||
>
|
||||
{{ $t('subscription.invoiceHistory') }}
|
||||
<i class="pi pi-external-link text-xs text-text-secondary" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { manageSubscription } = useBillingContext()
|
||||
|
||||
const { isLoadingSupport, handleMessageSupport, handleLearnMoreClick } =
|
||||
useSubscriptionActions()
|
||||
|
||||
async function handleInvoiceHistory() {
|
||||
await manageSubscription()
|
||||
}
|
||||
|
||||
function handleOpenPartnerNodesInfo() {
|
||||
window.open(
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -132,6 +132,18 @@ const i18n = createI18n({
|
||||
partnerNodesCredits: 'Partner nodes pricing',
|
||||
renewsDate: 'Renews {date}',
|
||||
expiresDate: 'Expires {date}',
|
||||
monthlyCreditsLabel: 'monthly credits',
|
||||
maxDurationLabel: 'max run duration',
|
||||
maxDuration: {
|
||||
free: '5 min',
|
||||
standard: '30 min',
|
||||
creator: '30 min',
|
||||
pro: '1 hr',
|
||||
founder: '30 min'
|
||||
},
|
||||
gpuLabel: 'RTX 6000 Pro (96GB VRAM)',
|
||||
addCreditsLabel: 'Add more credits whenever',
|
||||
customLoRAsLabel: 'Import your own LoRAs',
|
||||
tiers: {
|
||||
founder: {
|
||||
name: "Founder's Edition",
|
||||
@@ -200,6 +212,7 @@ function createComponent(overrides = {}) {
|
||||
CloudBadge: true,
|
||||
SubscribeButton: true,
|
||||
SubscriptionBenefits: true,
|
||||
CreditsTile: true,
|
||||
Button: {
|
||||
template:
|
||||
'<button v-bind="$attrs" @click="$emit(\'click\')" :disabled="loading" :data-testid="label" :data-icon="icon"><slot/></button>',
|
||||
@@ -240,7 +253,6 @@ describe('SubscriptionPanel', () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
const { container } = createComponent()
|
||||
expect(container.textContent).toContain('Manage Subscription')
|
||||
expect(container.textContent).toContain('Add Credits')
|
||||
})
|
||||
|
||||
it('shows correct UI for inactive subscription', () => {
|
||||
@@ -249,7 +261,6 @@ describe('SubscriptionPanel', () => {
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('subscribe-button-stub')).not.toBeNull()
|
||||
expect(container.textContent).not.toContain('Manage Subscription')
|
||||
expect(container.textContent).not.toContain('Add Credits')
|
||||
})
|
||||
|
||||
it('shows renewal date for active non-cancelled subscription', () => {
|
||||
@@ -266,58 +277,19 @@ describe('SubscriptionPanel', () => {
|
||||
expect(container.textContent).toContain('Expires 2024-12-31')
|
||||
})
|
||||
|
||||
it('displays FOUNDERS_EDITION tier correctly', () => {
|
||||
it('displays FOUNDERS_EDITION tier without the custom-LoRA perk', () => {
|
||||
mockSubscriptionTier.value = 'FOUNDERS_EDITION'
|
||||
const { container } = createComponent()
|
||||
expect(container.textContent).toContain("Founder's Edition")
|
||||
expect(container.textContent).toContain('5,460')
|
||||
expect(container.textContent).toContain('RTX 6000 Pro (96GB VRAM)')
|
||||
expect(container.textContent).not.toContain('Import your own LoRAs')
|
||||
})
|
||||
|
||||
it('displays CREATOR tier correctly', () => {
|
||||
it('displays CREATOR tier with the custom-LoRA perk', () => {
|
||||
mockSubscriptionTier.value = 'CREATOR'
|
||||
const { container } = createComponent()
|
||||
expect(container.textContent).toContain('Creator')
|
||||
expect(container.textContent).toContain('7,400')
|
||||
})
|
||||
})
|
||||
|
||||
describe('credit display functionality', () => {
|
||||
it('displays dynamic credit values correctly', () => {
|
||||
const { container } = createComponent()
|
||||
expect(container.textContent).toContain('10.00 Credits')
|
||||
expect(container.textContent).toContain('5.00 Credits')
|
||||
})
|
||||
|
||||
it('shows loading skeleton when fetching balance', () => {
|
||||
mockCreditsData.isLoadingBalance = true
|
||||
createComponent()
|
||||
expect(
|
||||
screen.getAllByRole('status', { name: 'Loading' }).length
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('hides skeleton when balance loaded', () => {
|
||||
mockCreditsData.isLoadingBalance = false
|
||||
createComponent()
|
||||
expect(screen.queryAllByRole('status', { name: 'Loading' })).toHaveLength(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
it('renders refill date with literal slashes', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubEnv('TZ', 'UTC')
|
||||
try {
|
||||
mockIsActiveSubscription.value = true
|
||||
const { container } = createComponent()
|
||||
expect(container.textContent).toMatch(
|
||||
/Included \(Refills \d{2}\/\d{2}\/\d{2}\)/
|
||||
)
|
||||
expect(container.textContent).not.toContain('/')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
expect(container.textContent).toContain('Import your own LoRAs')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -335,15 +307,6 @@ describe('SubscriptionPanel', () => {
|
||||
await userEvent.click(supportButton)
|
||||
expect(mockActionsData.handleMessageSupport).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should call handleRefresh when refresh button is clicked', async () => {
|
||||
createComponent()
|
||||
const refreshButton = screen.getByRole('button', {
|
||||
name: 'Refresh credits'
|
||||
})
|
||||
await userEvent.click(refreshButton)
|
||||
expect(mockActionsData.handleRefresh).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading states', () => {
|
||||
@@ -353,14 +316,5 @@ describe('SubscriptionPanel', () => {
|
||||
const supportButton = findButtonByText('Message Support')
|
||||
expect(supportButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should show loading state on refresh button when loading balance', () => {
|
||||
mockCreditsData.isLoadingBalance = true
|
||||
createComponent()
|
||||
const refreshButton = screen.getByRole('button', {
|
||||
name: 'Refresh credits'
|
||||
})
|
||||
expect(refreshButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,51 +17,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workspace mode: workspace-aware subscription content -->
|
||||
<!-- Workspace mode: workspace-aware subscription content (renders its own footer) -->
|
||||
<SubscriptionPanelContentWorkspace v-if="teamWorkspacesEnabled" />
|
||||
<!-- Legacy mode: user-level subscription content -->
|
||||
<SubscriptionPanelContentLegacy v-else />
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-interface-stroke pt-3"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="text-xs text-text-secondary"
|
||||
@click="handleLearnMoreClick"
|
||||
>
|
||||
<i class="pi pi-question-circle text-xs text-text-secondary" />
|
||||
{{ $t('subscription.learnMore') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="text-xs text-text-secondary"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
>
|
||||
<i class="pi pi-question-circle text-xs text-text-secondary" />
|
||||
{{ $t('subscription.partnerNodesCredits') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="text-xs text-text-secondary"
|
||||
:loading="isLoadingSupport"
|
||||
@click="handleMessageSupport"
|
||||
>
|
||||
<i class="pi pi-comment text-xs text-text-secondary" />
|
||||
{{ $t('subscription.messageSupport') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="text-xs text-text-secondary"
|
||||
@click="handleInvoiceHistory"
|
||||
>
|
||||
{{ $t('subscription.invoiceHistory') }}
|
||||
<i class="pi pi-external-link text-xs text-text-secondary" />
|
||||
</Button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<SubscriptionPanelContentLegacy />
|
||||
<SubscriptionFooterLinks />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -70,12 +32,10 @@
|
||||
import { computed, defineAsyncComponent } from 'vue'
|
||||
|
||||
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import SubscriptionFooterLinks from '@/platform/cloud/subscription/components/SubscriptionFooterLinks.vue'
|
||||
import SubscriptionPanelContentLegacy from '@/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
const SubscriptionPanelContentWorkspace = defineAsyncComponent(
|
||||
@@ -88,21 +48,5 @@ const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
)
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { isActiveSubscription, manageSubscription } = useBillingContext()
|
||||
|
||||
const { isLoadingSupport, handleMessageSupport, handleLearnMoreClick } =
|
||||
useSubscriptionActions()
|
||||
|
||||
const handleInvoiceHistory = async () => {
|
||||
await manageSubscription()
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
</script>
|
||||
|
||||
@@ -65,100 +65,8 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6 pt-9 lg:flex-row">
|
||||
<div class="flex shrink-0 flex-col">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative flex flex-col gap-6 rounded-2xl p-5',
|
||||
'bg-modal-panel-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="absolute top-4 right-4"
|
||||
:loading="isLoadingBalance"
|
||||
:aria-label="$t('subscription.refreshCredits')"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<i class="pi pi-sync text-sm text-text-secondary" />
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.totalCredits') }}
|
||||
</div>
|
||||
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
||||
<div v-else class="text-2xl font-bold">
|
||||
{{ totalCredits }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit Breakdown -->
|
||||
<table class="text-sm text-muted">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="pr-4 text-left align-middle font-bold">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="5rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{ includedCreditsDisplay }}</span>
|
||||
</td>
|
||||
<td class="align-middle" :title="creditsRemainingLabel">
|
||||
{{ creditsRemainingLabel }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pr-4 text-left align-middle font-bold">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="3rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{ prepaidCredits }}</span>
|
||||
</td>
|
||||
<td
|
||||
class="align-middle"
|
||||
:title="$t('subscription.creditsYouveAdded')"
|
||||
>
|
||||
{{ $t('subscription.creditsYouveAdded') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<a
|
||||
href="https://platform.comfy.org/profile/usage"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-muted underline"
|
||||
>
|
||||
{{ $t('subscription.viewUsageHistory') }}
|
||||
</a>
|
||||
<Button
|
||||
v-if="isActiveSubscription && isFreeTier"
|
||||
variant="gradient"
|
||||
class="min-h-8 w-full rounded-lg p-2 text-sm font-normal"
|
||||
@click="handleUpgradeToAddCredits"
|
||||
>
|
||||
{{ $t('subscription.upgradeToAddCredits') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="isActiveSubscription"
|
||||
variant="secondary"
|
||||
class="min-h-8 rounded-lg bg-interface-menu-component-surface-selected p-2 text-sm font-normal text-text-primary"
|
||||
@click="handleAddApiCredits"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:max-w-md">
|
||||
<CreditsTile />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -207,26 +115,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits,
|
||||
getTierPrice
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierBenefit } from '@/platform/cloud/subscription/utils/tierBenefits'
|
||||
import { getCommonTierBenefits } from '@/platform/cloud/subscription/utils/tierBenefits'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const authActions = useAuthActions()
|
||||
const { t, n } = useI18n()
|
||||
@@ -239,12 +144,10 @@ const {
|
||||
formattedEndDate,
|
||||
subscriptionTier,
|
||||
subscriptionTierName,
|
||||
subscriptionStatus,
|
||||
isYearlySubscription
|
||||
} = useSubscription()
|
||||
|
||||
const { show: showSubscriptionDialog, showPricingTable } =
|
||||
useSubscriptionDialog()
|
||||
const { show: showSubscriptionDialog } = useSubscriptionDialog()
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
@@ -255,89 +158,11 @@ const tierPrice = computed(() =>
|
||||
getTierPrice(tierKey.value, isYearlySubscription.value)
|
||||
)
|
||||
|
||||
const refillsDate = computed(() => {
|
||||
if (!subscriptionStatus.value?.renewal_date) return ''
|
||||
const date = new Date(subscriptionStatus.value.renewal_date)
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = String(date.getFullYear()).slice(-2)
|
||||
return `${month}/${day}/${year}`
|
||||
})
|
||||
|
||||
const creditsRemainingLabel = computed(() =>
|
||||
isYearlySubscription.value
|
||||
? t(
|
||||
'subscription.creditsRemainingThisYear',
|
||||
{
|
||||
date: refillsDate.value
|
||||
},
|
||||
{
|
||||
escapeParameter: false
|
||||
}
|
||||
)
|
||||
: t(
|
||||
'subscription.creditsRemainingThisMonth',
|
||||
{
|
||||
date: refillsDate.value
|
||||
},
|
||||
{
|
||||
escapeParameter: false
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const planTotalCredits = computed(() => {
|
||||
const credits = getTierCredits(tierKey.value)
|
||||
if (credits === null) return '—'
|
||||
const total = isYearlySubscription.value ? credits * 12 : credits
|
||||
return n(total)
|
||||
})
|
||||
|
||||
const includedCreditsDisplay = computed(
|
||||
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
|
||||
)
|
||||
|
||||
const tierBenefits = computed((): TierBenefit[] =>
|
||||
getCommonTierBenefits(tierKey.value, t, n)
|
||||
)
|
||||
|
||||
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||
useSubscriptionCredits()
|
||||
|
||||
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
|
||||
|
||||
function handleUpgradeToAddCredits() {
|
||||
showPricingTable()
|
||||
}
|
||||
|
||||
// Focus-based polling: refresh balance when user returns from Stripe checkout
|
||||
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
|
||||
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
function handleWindowFocus() {
|
||||
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
|
||||
if (!timestampStr) return
|
||||
|
||||
const timestamp = parseInt(timestampStr, 10)
|
||||
|
||||
// Clear expired tracking (older than 5 minutes)
|
||||
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh and clear tracking to prevent repeated calls
|
||||
void handleRefresh()
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
const { handleRefresh } = useSubscriptionActions()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -81,6 +81,42 @@ describe('useBillingPlans', () => {
|
||||
expect(currentPlanSlug.value).toBeNull()
|
||||
})
|
||||
|
||||
it('populates teamCreditStops from the response', async () => {
|
||||
const stops = {
|
||||
default_stop_index: 2,
|
||||
stops: [
|
||||
{
|
||||
id: 'team_700',
|
||||
credits: 147_700,
|
||||
monthly: { list_price_cents: 70_000, price_cents: 66_500 },
|
||||
yearly: { list_price_cents: 70_000, price_cents: 63_000 }
|
||||
}
|
||||
]
|
||||
}
|
||||
mockGetBillingPlans.mockResolvedValue({
|
||||
plans: [buildPlan()],
|
||||
team_credit_stops: stops
|
||||
})
|
||||
|
||||
const useBillingPlans = await importUseBillingPlans()
|
||||
const { fetchPlans, teamCreditStops } = useBillingPlans()
|
||||
|
||||
await fetchPlans()
|
||||
|
||||
expect(teamCreditStops.value).toEqual(stops)
|
||||
})
|
||||
|
||||
it('leaves teamCreditStops null when the response omits it', async () => {
|
||||
mockGetBillingPlans.mockResolvedValue({ plans: [buildPlan()] })
|
||||
|
||||
const useBillingPlans = await importUseBillingPlans()
|
||||
const { fetchPlans, teamCreditStops } = useBillingPlans()
|
||||
|
||||
await fetchPlans()
|
||||
|
||||
expect(teamCreditStops.value).toBeNull()
|
||||
})
|
||||
|
||||
it('dedupes concurrent calls while a fetch is in flight', async () => {
|
||||
let resolveFetch: (value: { plans: Plan[] }) => void = () => {}
|
||||
mockGetBillingPlans.mockImplementation(
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { usePricingTableUrlLoader } from './usePricingTableUrlLoader'
|
||||
|
||||
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 mockShowPricingTable = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({
|
||||
showPricingTable: mockShowPricingTable
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const mockPermissions = vi.hoisted(() => ({
|
||||
value: { canManageSubscriptionLifecycle: true }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({ permissions: mockPermissions })
|
||||
}))
|
||||
|
||||
const mockFetchMembers = vi.hoisted(() => vi.fn().mockResolvedValue([]))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
fetchMembers: mockFetchMembers
|
||||
})
|
||||
}))
|
||||
|
||||
const mockTrackSubscription = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
|
||||
}))
|
||||
|
||||
describe('usePricingTableUrlLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRouteQuery.value = {}
|
||||
mockPermissions.value = { canManageSubscriptionLifecycle: true }
|
||||
// clearAllMocks resets calls, not implementations, so restore the default
|
||||
// (a test overrides fetchMembers to flip the gate mid-await).
|
||||
mockFetchMembers.mockResolvedValue([])
|
||||
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does nothing when no pricing param present', async () => {
|
||||
mockRouteQuery.value = {}
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the pricing table for an original owner', async () => {
|
||||
mockRouteQuery.value = { pricing: '1' }
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).toHaveBeenCalledWith({
|
||||
reason: 'deep_link',
|
||||
planMode: undefined
|
||||
})
|
||||
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
|
||||
reason: 'deep_link'
|
||||
})
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
})
|
||||
|
||||
it('reads the gate only after members finish loading', async () => {
|
||||
mockRouteQuery.value = { pricing: '1' }
|
||||
// The original owner becomes known only once the members list resolves;
|
||||
// proves the loader awaits fetchMembers before reading the gate.
|
||||
mockPermissions.value = { canManageSubscriptionLifecycle: false }
|
||||
mockFetchMembers.mockImplementation(async () => {
|
||||
mockPermissions.value = { canManageSubscriptionLifecycle: true }
|
||||
return []
|
||||
})
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('opens on the team tab for ?pricing=team', async () => {
|
||||
mockRouteQuery.value = { pricing: 'team' }
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).toHaveBeenCalledWith({
|
||||
reason: 'deep_link',
|
||||
planMode: 'team'
|
||||
})
|
||||
})
|
||||
|
||||
it('opens on the personal tab for ?pricing=personal', async () => {
|
||||
mockRouteQuery.value = { pricing: 'personal' }
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).toHaveBeenCalledWith({
|
||||
reason: 'deep_link',
|
||||
planMode: 'personal'
|
||||
})
|
||||
})
|
||||
|
||||
it('is a silent no-op for a member or promoted owner', async () => {
|
||||
mockRouteQuery.value = { pricing: '1' }
|
||||
mockPermissions.value = { canManageSubscriptionLifecycle: false }
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('denies, strips, and clears together when the user is not eligible', async () => {
|
||||
mockRouteQuery.value = { pricing: '1', other: 'param' }
|
||||
mockPermissions.value = { canManageSubscriptionLifecycle: false }
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({
|
||||
query: { other: 'param' }
|
||||
})
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'pricing'
|
||||
)
|
||||
})
|
||||
|
||||
it('restores preserved query and opens the table', async () => {
|
||||
mockRouteQuery.value = {}
|
||||
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue({
|
||||
pricing: '1'
|
||||
})
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(preservedQueryMocks.hydratePreservedQuery).toHaveBeenCalledWith(
|
||||
'pricing'
|
||||
)
|
||||
expect(mockShowPricingTable).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('ignores empty param', async () => {
|
||||
mockRouteQuery.value = { pricing: '' }
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores non-string param', async () => {
|
||||
mockRouteQuery.value = { pricing: fromAny<string, unknown>(['array']) }
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the default tab for an unrecognized pricing value', async () => {
|
||||
mockRouteQuery.value = { pricing: 'garbage' }
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).toHaveBeenCalledWith({
|
||||
reason: 'deep_link',
|
||||
planMode: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('strips and clears, then propagates a members-fetch failure', async () => {
|
||||
mockRouteQuery.value = { pricing: '1' }
|
||||
mockFetchMembers.mockRejectedValue(new Error('listMembers failed'))
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await expect(loadPricingTableFromUrl()).rejects.toThrow(
|
||||
'listMembers failed'
|
||||
)
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'pricing'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import {
|
||||
clearPreservedQuery,
|
||||
hydratePreservedQuery,
|
||||
mergePreservedQueryIntoQuery
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
const NAMESPACE = PRESERVED_QUERY_NAMESPACES.PRICING
|
||||
|
||||
/**
|
||||
* Opens the pricing table from a `?pricing=` deep link, to send pilot users
|
||||
* straight to subscribe. Values: `1` (default tab), `team`, `personal`.
|
||||
*
|
||||
* Gated to the original owner (`canManageSubscriptionLifecycle`); a member or
|
||||
* promoted owner is a silent no-op with the param stripped. Survives the login
|
||||
* redirect via the preserved-query system, like the invite URL loader.
|
||||
*/
|
||||
export function usePricingTableUrlLoader() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
|
||||
/** Reads `?pricing=`, strips it, and opens the table when the gate allows. */
|
||||
async function loadPricingTableFromUrl() {
|
||||
hydratePreservedQuery(NAMESPACE)
|
||||
const query =
|
||||
mergePreservedQueryIntoQuery(NAMESPACE, route.query) ?? route.query
|
||||
const param = query.pricing
|
||||
if (!param || typeof param !== 'string') return
|
||||
|
||||
// Strip the param (even for ineligible users) and write the clean URL in a
|
||||
// single replace before any await, so a clean URL is guaranteed even if the
|
||||
// replace rejects or the gate later denies the user.
|
||||
const cleanQuery = { ...query }
|
||||
delete cleanQuery.pricing
|
||||
router.replace({ query: cleanQuery }).catch((error) => {
|
||||
console.warn(
|
||||
'[usePricingTableUrlLoader] Failed to clean URL params:',
|
||||
error
|
||||
)
|
||||
})
|
||||
clearPreservedQuery(NAMESPACE)
|
||||
|
||||
// Fetch members (no-ops for personal) so the original-owner self-row loads
|
||||
// before the gate; fetchMembers awaits, ensureMembersLoaded can return early.
|
||||
await workspaceStore.fetchMembers()
|
||||
if (!permissions.value.canManageSubscriptionLifecycle) return
|
||||
|
||||
const planMode =
|
||||
param === 'team' || param === 'personal' ? param : undefined
|
||||
|
||||
useTelemetry()?.trackSubscription('modal_opened', { reason: 'deep_link' })
|
||||
subscriptionDialog.showPricingTable({ reason: 'deep_link', planMode })
|
||||
}
|
||||
|
||||
return {
|
||||
loadPricingTableFromUrl
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,7 @@ function useSubscriptionInternal() {
|
||||
|
||||
/**
|
||||
* Whether cloud subscription mode is enabled (cloud distribution with subscription_required config).
|
||||
* Use to determine which UI to show (SubscriptionPanel vs LegacyCreditsPanel).
|
||||
* Use to determine which UI to show (SubscriptionPanel vs CreditsPanel).
|
||||
*/
|
||||
const isSubscriptionEnabled = (): boolean =>
|
||||
Boolean(isCloud && window.__CONFIG__?.subscription_required)
|
||||
|
||||
@@ -2,26 +2,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
|
||||
// Mock dependencies
|
||||
const mockFetchBalance = vi.fn()
|
||||
const mockBillingFetchBalance = vi.fn()
|
||||
const mockAuthFetchBalance = vi.fn()
|
||||
const mockFetchStatus = vi.fn()
|
||||
const mockShowTopUpCreditsDialog = vi.fn()
|
||||
const mockExecute = vi.fn()
|
||||
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: () => ({
|
||||
fetchBalance: mockFetchBalance
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
fetchStatus: mockFetchStatus
|
||||
fetchBalance: mockAuthFetchBalance
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
fetchBalance: mockBillingFetchBalance,
|
||||
fetchStatus: mockFetchStatus
|
||||
})
|
||||
}))
|
||||
@@ -119,19 +114,19 @@ describe('useSubscriptionActions', () => {
|
||||
})
|
||||
|
||||
describe('handleRefresh', () => {
|
||||
it('should call both fetchBalance and fetchStatus', async () => {
|
||||
it('should refresh balance and status through the billing facade', async () => {
|
||||
const { handleRefresh } = useSubscriptionActions()
|
||||
await handleRefresh()
|
||||
|
||||
expect(mockFetchBalance).toHaveBeenCalledOnce()
|
||||
expect(mockBillingFetchBalance).toHaveBeenCalledOnce()
|
||||
expect(mockFetchStatus).toHaveBeenCalledOnce()
|
||||
expect(mockAuthFetchBalance).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
|
||||
mockBillingFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
|
||||
const { handleRefresh } = useSubscriptionActions()
|
||||
|
||||
// Should not throw
|
||||
await expect(handleRefresh()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,6 +102,28 @@ describe('useSubscriptionCredits', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('numeric credit values (micros-as-cents)', () => {
|
||||
it('converts the monthly and prepaid balance fields from cents to credits (×2.11)', () => {
|
||||
mockBillingBalance = {
|
||||
amountMicros: 500,
|
||||
cloudCreditBalanceMicros: 200,
|
||||
prepaidBalanceMicros: 300
|
||||
}
|
||||
const { monthlyBonusCreditsValue, prepaidCreditsValue } =
|
||||
useSubscriptionCredits()
|
||||
expect(monthlyBonusCreditsValue.value).toBe(422)
|
||||
expect(prepaidCreditsValue.value).toBe(633)
|
||||
})
|
||||
|
||||
it('defaults missing fields to zero', () => {
|
||||
mockBillingBalance = { amountMicros: 100 }
|
||||
const { monthlyBonusCreditsValue, prepaidCreditsValue } =
|
||||
useSubscriptionCredits()
|
||||
expect(monthlyBonusCreditsValue.value).toBe(0)
|
||||
expect(prepaidCreditsValue.value).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLoadingBalance', () => {
|
||||
it('should reflect billingContext.isLoading', () => {
|
||||
mockBillingIsLoading = true
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { computed, toValue } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import {
|
||||
centsToCredits,
|
||||
formatCreditsFromCents
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
|
||||
/**
|
||||
@@ -50,10 +53,23 @@ export function useSubscriptionCredits() {
|
||||
|
||||
const isLoadingBalance = computed(() => toValue(billingContext.isLoading))
|
||||
|
||||
const creditsFromMicros = (maybeCents: number | undefined): number =>
|
||||
centsToCredits(maybeCents ?? 0)
|
||||
|
||||
const monthlyBonusCreditsValue = computed(() =>
|
||||
creditsFromMicros(toValue(billingContext.balance)?.cloudCreditBalanceMicros)
|
||||
)
|
||||
|
||||
const prepaidCreditsValue = computed(() =>
|
||||
creditsFromMicros(toValue(billingContext.balance)?.prepaidBalanceMicros)
|
||||
)
|
||||
|
||||
return {
|
||||
totalCredits,
|
||||
monthlyBonusCredits,
|
||||
prepaidCredits,
|
||||
monthlyBonusCreditsValue,
|
||||
prepaidCreditsValue,
|
||||
isLoadingBalance
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,10 +115,8 @@ describe('useSubscriptionDialog', () => {
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the unified table (no onChooseTeam) when team workspaces are enabled', () => {
|
||||
it('does not wire onChooseTeam on the unified table (personal subscribes directly)', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
// Unified table is workspace-type-agnostic (Jun-5 model): same path for
|
||||
// a personal-plan or team-plan workspace.
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
@@ -129,6 +127,21 @@ describe('useSubscriptionDialog', () => {
|
||||
expect(props).not.toHaveProperty('onChooseTeam')
|
||||
})
|
||||
|
||||
it('sizes the unified pricing dialog via the Reka contentClass, not the ignored PrimeVue style', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable()
|
||||
|
||||
const { dialogComponentProps } = mockShowLayoutDialog.mock.calls[0][0]
|
||||
// Reka (the default renderer) sizes via size/contentClass; a PrimeVue
|
||||
// `style` width is silently ignored and collapses the wide table to the
|
||||
// default md (576px) frame.
|
||||
expect(dialogComponentProps).toHaveProperty('contentClass')
|
||||
expect(dialogComponentProps).not.toHaveProperty('style')
|
||||
})
|
||||
|
||||
it('defaults to the personal tab in a personal workspace', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
|
||||
@@ -16,6 +16,7 @@ export type SubscriptionDialogReason =
|
||||
| 'subscription_required'
|
||||
| 'out_of_credits'
|
||||
| 'top_up_blocked'
|
||||
| 'deep_link'
|
||||
|
||||
export interface SubscriptionDialogOptions {
|
||||
reason?: SubscriptionDialogReason
|
||||
@@ -60,9 +61,13 @@ export const useSubscriptionDialog = () => {
|
||||
),
|
||||
props: { onClose: hide },
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
contentClass:
|
||||
'w-[min(360px,95vw)] max-w-[min(360px,95vw)] sm:max-w-[min(360px,95vw)] border-0 bg-transparent shadow-none'
|
||||
style: 'width: min(360px, 95vw);',
|
||||
pt: {
|
||||
root: {
|
||||
class: 'bg-transparent border-none rounded-none shadow-none'
|
||||
},
|
||||
content: { class: '!p-0 bg-transparent border-none shadow-none' }
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
@@ -129,18 +134,15 @@ export const useSubscriptionDialog = () => {
|
||||
(workspaceStore.isInPersonalWorkspace ? 'personal' : 'team')
|
||||
},
|
||||
dialogComponentProps: {
|
||||
// The dialog hugs its content so each step sizes itself: the pricing
|
||||
// table stays wide/fixed (cards fill it, DES QA 2026-06-13) while the
|
||||
// compact confirm/success steps shrink instead of floating in the big
|
||||
// pricing modal. Sizes are set on the content root per checkoutStep.
|
||||
style: 'max-width: 95vw; max-height: 90vh;',
|
||||
pt: {
|
||||
root: { class: 'rounded-2xl bg-transparent' },
|
||||
content: {
|
||||
class:
|
||||
'!p-0 rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
|
||||
}
|
||||
}
|
||||
// Reka (the default renderer) sizes via size/contentClass; a PrimeVue
|
||||
// `style` width is ignored here and collapses the table to the default
|
||||
// `md` frame. `w-fit` lets each step hug its content — the pricing
|
||||
// table fills its 1280px content while the compact confirm/success
|
||||
// steps shrink (the content root sets its own width per checkoutStep).
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass:
|
||||
'w-fit max-w-[min(1280px,95vw)] sm:max-w-[min(1280px,95vw)] max-h-[90vh] rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
|
||||
}
|
||||
})
|
||||
return
|
||||
@@ -180,10 +182,16 @@ export const useSubscriptionDialog = () => {
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass:
|
||||
'w-[min(640px,95vw)] max-w-[min(640px,95vw)] sm:max-w-[min(640px,95vw)] overflow-hidden rounded-2xl border-border-default bg-base-background/60 shadow-[0_25px_80px_rgba(5,6,12,0.45)] backdrop-blur-md'
|
||||
style: 'width: min(640px, 95vw);',
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl bg-transparent'
|
||||
},
|
||||
content: {
|
||||
class:
|
||||
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
TEAM_PLAN_SLUG_BY_CYCLE,
|
||||
mapApiTeamCreditStops
|
||||
} from './teamPlanCreditStops'
|
||||
|
||||
describe('mapApiTeamCreditStops', () => {
|
||||
it('derives usd, credits, discount and carries the backend id', () => {
|
||||
const mapped = mapApiTeamCreditStops([
|
||||
{
|
||||
id: 'team_700',
|
||||
credits: 147_700,
|
||||
yearly: { list_price_cents: 70_000, price_cents: 63_000 }
|
||||
}
|
||||
])
|
||||
|
||||
expect(mapped).toEqual([
|
||||
{
|
||||
id: 'team_700',
|
||||
usd: 700,
|
||||
credits: 147_700,
|
||||
discountPercentYearly: 10
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('returns a 0% discount when the list price is zero', () => {
|
||||
const mapped = mapApiTeamCreditStops([
|
||||
{
|
||||
id: 'team_free',
|
||||
credits: 0,
|
||||
yearly: { list_price_cents: 0, price_cents: 0 }
|
||||
}
|
||||
])
|
||||
|
||||
expect(mapped[0].discountPercentYearly).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TEAM_PLAN_SLUG_BY_CYCLE', () => {
|
||||
it('maps the billing cycle to the per-credit team plan slug', () => {
|
||||
expect(TEAM_PLAN_SLUG_BY_CYCLE.monthly).toBe('team_per_credit_monthly')
|
||||
expect(TEAM_PLAN_SLUG_BY_CYCLE.yearly).toBe('team_per_credit_annual')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,7 @@
|
||||
export interface CreditStop {
|
||||
/** Backend stop identifier (e.g. "team_700"), sent on subscribe. Absent for
|
||||
* the hardcoded fallback stops until the API contract reaches prod. */
|
||||
id?: string
|
||||
/** Monthly subscription price in USD (pre-discount). */
|
||||
usd: number
|
||||
/** Monthly credit grant at this stop. */
|
||||
@@ -17,6 +20,9 @@ export interface CreditStop {
|
||||
|
||||
/** A selected slider stop, as emitted by the pricing table's team column. */
|
||||
export interface TeamPlanSelection {
|
||||
/** Backend stop identifier (e.g. "team_700"), sent on subscribe. Absent for
|
||||
* the hardcoded fallback stops until the API contract reaches prod. */
|
||||
id?: string
|
||||
/** Pre-discount monthly price in USD (the struck-through list price). */
|
||||
usd: number
|
||||
/** Monthly credit grant at this stop. */
|
||||
@@ -49,6 +55,41 @@ export const TEAM_PLAN_CREDIT_STOPS: readonly CreditStop[] = [
|
||||
/** Default stop per DES-197: index 2 = $700 / 147,700 credits. */
|
||||
export const DEFAULT_TEAM_PLAN_STOP_INDEX = 2
|
||||
|
||||
/** Plan slugs for the per-credit Team plan, keyed by billing cycle. */
|
||||
export const TEAM_PLAN_SLUG_BY_CYCLE = {
|
||||
monthly: 'team_per_credit_monthly',
|
||||
yearly: 'team_per_credit_annual'
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Map the backend `team_credit_stops` payload to the slider's `CreditStop[]`.
|
||||
* The pre-discount monthly `usd` is the yearly list price; the yearly discount
|
||||
* percent is derived from the struck (`list_price_cents`) vs discounted
|
||||
* (`price_cents`) yearly figures. The backend `id` is carried so a selected stop
|
||||
* can be sent on subscribe.
|
||||
*/
|
||||
export function mapApiTeamCreditStops(
|
||||
stops: readonly {
|
||||
id: string
|
||||
credits: number
|
||||
yearly: { list_price_cents: number; price_cents: number }
|
||||
}[]
|
||||
): CreditStop[] {
|
||||
return stops.map((stop) => {
|
||||
const listCents = stop.yearly.list_price_cents
|
||||
const discountPercentYearly =
|
||||
listCents > 0
|
||||
? Math.round(((listCents - stop.yearly.price_cents) / listCents) * 100)
|
||||
: 0
|
||||
return {
|
||||
id: stop.id,
|
||||
usd: Math.round(listCents / 100),
|
||||
credits: stop.credits,
|
||||
discountPercentYearly
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Discounted monthly price for a stop's list `usd`, applying the billing-cycle
|
||||
* discount (yearly = full `discountPercentYearly`; monthly halves it). Shared by
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
|
||||
|
||||
describe('computeMonthlyUsage', () => {
|
||||
it('reports the consumed portion of the monthly allowance', () => {
|
||||
expect(computeMonthlyUsage(105_450, 200_000)).toEqual({
|
||||
used: 94_550,
|
||||
usedFraction: 0.47275
|
||||
})
|
||||
})
|
||||
|
||||
it('returns zero usage when the monthly allowance is unknown', () => {
|
||||
expect(computeMonthlyUsage(100, 0)).toEqual({ used: 0, usedFraction: 0 })
|
||||
})
|
||||
|
||||
it('treats a balance above the allowance (rollover) as nothing used', () => {
|
||||
expect(computeMonthlyUsage(503_805, 253_200)).toEqual({
|
||||
used: 0,
|
||||
usedFraction: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('caps the fill at a full bar once the allowance is exhausted', () => {
|
||||
expect(computeMonthlyUsage(0, 200_000)).toEqual({
|
||||
used: 200_000,
|
||||
usedFraction: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
25
src/platform/cloud/subscription/utils/creditsProgress.ts
Normal file
25
src/platform/cloud/subscription/utils/creditsProgress.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface MonthlyCreditsUsage {
|
||||
/** Credits consumed from the monthly allowance (never negative). */
|
||||
used: number
|
||||
/** Fraction (0–1) of the monthly allowance consumed — drives the bar fill. */
|
||||
usedFraction: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes monthly credit usage for the credits bar. The bar fills with the
|
||||
* consumed portion of the monthly allowance; `used` clamps at zero so a balance
|
||||
* that exceeds the nominal allowance (rolled-over credits) reads as nothing used.
|
||||
*/
|
||||
export function computeMonthlyUsage(
|
||||
monthlyRemaining: number,
|
||||
monthlyTotal: number
|
||||
): MonthlyCreditsUsage {
|
||||
if (monthlyTotal <= 0) {
|
||||
return { used: 0, usedFraction: 0 }
|
||||
}
|
||||
|
||||
const used = Math.max(0, monthlyTotal - monthlyRemaining)
|
||||
const usedFraction = Math.min(1, used / monthlyTotal)
|
||||
|
||||
return { used, usedFraction }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockIsCloud, mockSubscribe } = vi.hoisted(() => ({
|
||||
mockIsCloud: { value: true },
|
||||
mockSubscribe: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
vi.mock('@/config/comfyApi', () => ({
|
||||
getComfyPlatformBaseUrl: () => 'https://app.test'
|
||||
}))
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: { subscribe: mockSubscribe }
|
||||
}))
|
||||
|
||||
import { performTeamSubscriptionCheckout } from './teamSubscriptionCheckoutUtil'
|
||||
|
||||
describe('performTeamSubscriptionCheckout', () => {
|
||||
let assignedHref: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
assignedHref = undefined
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
set href(value: string) {
|
||||
assignedHref = value
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('subscribes at the stop with the yearly slug and redirects to the Stripe payment page', async () => {
|
||||
mockSubscribe.mockResolvedValue({
|
||||
status: 'needs_payment_method',
|
||||
payment_method_url: 'https://stripe.test/pay',
|
||||
billing_op_id: 'op_1'
|
||||
})
|
||||
|
||||
await performTeamSubscriptionCheckout('team_700', 'yearly')
|
||||
|
||||
expect(mockSubscribe).toHaveBeenCalledWith('team_per_credit_annual', {
|
||||
returnUrl: 'https://app.test/payment/success',
|
||||
cancelUrl: 'https://app.test/payment/failed',
|
||||
teamCreditStopId: 'team_700',
|
||||
billingCycle: 'yearly'
|
||||
})
|
||||
expect(assignedHref).toBe('https://stripe.test/pay')
|
||||
})
|
||||
|
||||
it('uses the monthly slug and lands in the app when no Stripe step is needed', async () => {
|
||||
mockSubscribe.mockResolvedValue({
|
||||
status: 'subscribed',
|
||||
billing_op_id: 'op_2'
|
||||
})
|
||||
|
||||
await performTeamSubscriptionCheckout('team_1400', 'monthly')
|
||||
|
||||
expect(mockSubscribe).toHaveBeenCalledWith(
|
||||
'team_per_credit_monthly',
|
||||
expect.objectContaining({
|
||||
teamCreditStopId: 'team_1400',
|
||||
billingCycle: 'monthly'
|
||||
})
|
||||
)
|
||||
expect(assignedHref).toBe('/')
|
||||
})
|
||||
|
||||
it('does nothing off cloud', async () => {
|
||||
mockIsCloud.value = false
|
||||
|
||||
await performTeamSubscriptionCheckout('team_700', 'yearly')
|
||||
|
||||
expect(mockSubscribe).not.toHaveBeenCalled()
|
||||
expect(assignedHref).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { TEAM_PLAN_SLUG_BY_CYCLE } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import type { BillingCycle } from './subscriptionTierRank'
|
||||
|
||||
/**
|
||||
* Direct team-plan checkout for the marketing `/cloud/subscribe?tier=team` deep
|
||||
* link: subscribes to the per-credit Team plan at the chosen slider stop and
|
||||
* sends the user straight to the Stripe payment page.
|
||||
*
|
||||
* Mirrors `performSubscriptionCheckout` (personal) but routes through the
|
||||
* workspace billing endpoint (`POST /api/billing/subscribe`), because the
|
||||
* per-credit Team plan lives there and the backend lets any workspace — personal
|
||||
* included — subscribe to it. The slug encodes the cadence; the stop id is
|
||||
* validated and priced server-side.
|
||||
*
|
||||
* Caller guards on `isCloud`, owns loading state, and wraps error handling. A
|
||||
* `needs_payment_method` response is a full-page redirect to Stripe; the other
|
||||
* statuses land back in the app, which polls the billing op to completion.
|
||||
*/
|
||||
export async function performTeamSubscriptionCheckout(
|
||||
teamCreditStopId: string,
|
||||
billingCycle: BillingCycle
|
||||
): Promise<void> {
|
||||
if (!isCloud) return
|
||||
|
||||
const response = await workspaceApi.subscribe(
|
||||
TEAM_PLAN_SLUG_BY_CYCLE[billingCycle],
|
||||
{
|
||||
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`,
|
||||
teamCreditStopId,
|
||||
billingCycle
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
response.status === 'needs_payment_method' &&
|
||||
response.payment_method_url
|
||||
) {
|
||||
globalThis.location.href = response.payment_method_url
|
||||
return
|
||||
}
|
||||
|
||||
globalThis.location.href = '/'
|
||||
}
|
||||
@@ -4,14 +4,13 @@ import {
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
|
||||
type BenefitType = 'metric' | 'feature' | 'icon'
|
||||
type BenefitType = 'metric' | 'feature'
|
||||
|
||||
export interface TierBenefit {
|
||||
key: string
|
||||
type: BenefitType
|
||||
label: string
|
||||
value?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export function getCommonTierBenefits(
|
||||
|
||||
@@ -4,5 +4,6 @@ export const PRESERVED_QUERY_NAMESPACES = {
|
||||
SHARE: 'share',
|
||||
SHARE_AUTH: 'share_auth',
|
||||
CREATE_WORKSPACE: 'create_workspace',
|
||||
OAUTH: 'oauth'
|
||||
OAUTH: 'oauth',
|
||||
PRICING: 'pricing'
|
||||
} as const
|
||||
|
||||
@@ -59,3 +59,10 @@ export const cachedTeamWorkspacesEnabled = useStorage<boolean | undefined>(
|
||||
'team_workspaces_enabled' satisfies `${ServerFeatureFlag.TEAM_WORKSPACES_ENABLED}`,
|
||||
undefined
|
||||
)
|
||||
|
||||
export const cachedPersonalWorkspaceBillingReady = useStorage<
|
||||
boolean | undefined
|
||||
>(
|
||||
'personal_workspace_billing_ready' satisfies `${ServerFeatureFlag.PERSONAL_WORKSPACE_BILLING_READY}`,
|
||||
undefined
|
||||
)
|
||||
|
||||
@@ -102,6 +102,7 @@ export type RemoteConfig = {
|
||||
onboarding_survey?: OnboardingSurvey
|
||||
linear_toggle_enabled?: boolean
|
||||
team_workspaces_enabled?: boolean
|
||||
personal_workspace_billing_ready?: boolean
|
||||
user_secrets_enabled?: boolean
|
||||
node_library_essentials_enabled?: boolean
|
||||
free_tier_credits?: number
|
||||
|
||||
@@ -90,7 +90,7 @@ import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserM
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import NavItem from '@/components/widget/nav/NavItem.vue'
|
||||
import NavTitle from '@/components/widget/nav/NavTitle.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
|
||||
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
||||
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
|
||||
@@ -130,7 +130,7 @@ const {
|
||||
getSearchResults
|
||||
} = useSettingSearch()
|
||||
|
||||
const authActions = useAuthActions()
|
||||
const { fetchBalance } = useBillingContext()
|
||||
|
||||
const navRef = ref<HTMLElement | null>(null)
|
||||
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
|
||||
@@ -238,7 +238,7 @@ watch(activeCategoryKey, (newKey, oldKey) => {
|
||||
activeCategoryKey.value = oldKey
|
||||
}
|
||||
if (newKey === 'credits') {
|
||||
void authActions.fetchBalance()
|
||||
void fetchBalance()
|
||||
}
|
||||
if (newKey) {
|
||||
void nextTick(() => {
|
||||
|
||||
@@ -133,7 +133,7 @@ export function useSettingUI(
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/LegacyCreditsPanel.vue')
|
||||
() => import('@/components/dialog/content/setting/CreditsPanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ import type {
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
WorkflowSavedMetadata,
|
||||
WorkspaceInviteMetadata
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
@@ -98,6 +99,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackMonthlySubscriptionCancelled?.())
|
||||
}
|
||||
|
||||
trackWorkspaceInviteSent(metadata: WorkspaceInviteMetadata): void {
|
||||
this.dispatch((provider) => provider.trackWorkspaceInviteSent?.(metadata))
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(): void {
|
||||
this.dispatch((provider) => provider.trackAddApiCreditButtonClicked?.())
|
||||
}
|
||||
|
||||
@@ -464,6 +464,11 @@ export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
|
||||
ecommerce: EcommerceMetadata
|
||||
}
|
||||
|
||||
export interface WorkspaceInviteMetadata extends Record<string, unknown> {
|
||||
source: 'post_upgrade_success' | 'settings_members'
|
||||
count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Telemetry provider interface for individual providers.
|
||||
* All methods are optional - providers only implement what they need.
|
||||
@@ -484,6 +489,7 @@ export interface TelemetryProvider {
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void
|
||||
trackMonthlySubscriptionCancelled?(): void
|
||||
trackWorkspaceInviteSent?(metadata: WorkspaceInviteMetadata): void
|
||||
trackAddApiCreditButtonClicked?(): void
|
||||
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
|
||||
trackApiCreditTopupSucceeded?(): void
|
||||
|
||||
@@ -334,18 +334,42 @@ describe('workspaceApi', () => {
|
||||
const data = { billing_op_id: 'op-1', status: 'subscribed' }
|
||||
mockAxiosInstance.post.mockResolvedValue({ data })
|
||||
|
||||
const result = await workspaceApi.subscribe(
|
||||
'pro-monthly',
|
||||
'https://return.url',
|
||||
'https://cancel.url'
|
||||
)
|
||||
const result = await workspaceApi.subscribe('pro-monthly', {
|
||||
returnUrl: 'https://return.url',
|
||||
cancelUrl: 'https://cancel.url'
|
||||
})
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'/api/billing/subscribe',
|
||||
{
|
||||
plan_slug: 'pro-monthly',
|
||||
return_url: 'https://return.url',
|
||||
cancel_url: 'https://cancel.url'
|
||||
cancel_url: 'https://cancel.url',
|
||||
team_credit_stop_id: undefined,
|
||||
billing_cycle: undefined
|
||||
},
|
||||
{ headers: AUTH_HEADER }
|
||||
)
|
||||
expect(result).toEqual(data)
|
||||
})
|
||||
|
||||
it('subscribe() sends team_credit_stop_id and billing_cycle for team plans', async () => {
|
||||
const data = { billing_op_id: 'op-1b', status: 'needs_payment_method' }
|
||||
mockAxiosInstance.post.mockResolvedValue({ data })
|
||||
|
||||
const result = await workspaceApi.subscribe('team_per_credit_annual', {
|
||||
teamCreditStopId: 'team_700',
|
||||
billingCycle: 'yearly'
|
||||
})
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'/api/billing/subscribe',
|
||||
{
|
||||
plan_slug: 'team_per_credit_annual',
|
||||
return_url: undefined,
|
||||
cancel_url: undefined,
|
||||
team_credit_stop_id: 'team_700',
|
||||
billing_cycle: 'yearly'
|
||||
},
|
||||
{ headers: AUTH_HEADER }
|
||||
)
|
||||
|
||||
@@ -151,11 +151,22 @@ interface PreviewSubscribeRequest {
|
||||
plan_slug: string
|
||||
}
|
||||
|
||||
type SubscribeBillingCycle = 'monthly' | 'yearly'
|
||||
|
||||
interface SubscribeRequest {
|
||||
plan_slug: string
|
||||
idempotency_key?: string
|
||||
return_url?: string
|
||||
cancel_url?: string
|
||||
team_credit_stop_id?: string
|
||||
billing_cycle?: SubscribeBillingCycle
|
||||
}
|
||||
|
||||
export interface SubscribeOptions {
|
||||
returnUrl?: string
|
||||
cancelUrl?: string
|
||||
teamCreditStopId?: string
|
||||
billingCycle?: SubscribeBillingCycle
|
||||
}
|
||||
|
||||
type SubscribeStatus = 'subscribed' | 'needs_payment_method' | 'pending_payment'
|
||||
@@ -457,6 +468,24 @@ export const workspaceApi = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Change a member's role (member ↔ owner).
|
||||
* PATCH /api/workspace/members/:userId
|
||||
*/
|
||||
async updateMemberRole(userId: UserId, role: WorkspaceRole): Promise<Member> {
|
||||
const headers = await getAuthHeaderOrThrow()
|
||||
try {
|
||||
const response = await workspaceApiClient.patch<Member>(
|
||||
api.apiURL(`/workspace/members/${userId}`),
|
||||
{ role },
|
||||
{ headers }
|
||||
)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
handleAxiosError(err)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* List pending invites for the workspace.
|
||||
* GET /api/workspace/invites
|
||||
@@ -602,8 +631,7 @@ export const workspaceApi = {
|
||||
*/
|
||||
async subscribe(
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
options: SubscribeOptions = {}
|
||||
): Promise<SubscribeResponse> {
|
||||
const headers = await getAuthHeaderOrThrow()
|
||||
try {
|
||||
@@ -611,8 +639,10 @@ export const workspaceApi = {
|
||||
api.apiURL('/billing/subscribe'),
|
||||
{
|
||||
plan_slug: planSlug,
|
||||
return_url: returnUrl,
|
||||
cancel_url: cancelUrl
|
||||
return_url: options.returnUrl,
|
||||
cancel_url: options.cancelUrl,
|
||||
team_credit_stop_id: options.teamCreditStopId,
|
||||
billing_cycle: options.billingCycle
|
||||
} satisfies SubscribeRequest,
|
||||
{ headers }
|
||||
)
|
||||
|
||||
175
src/platform/workspace/components/InviteMembersForm.test.ts
Normal file
175
src/platform/workspace/components/InviteMembersForm.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import InviteMembersForm from './InviteMembersForm.vue'
|
||||
|
||||
import type { PendingInvite } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
const { mockCreateInvite, mockToastAdd, mockTrackInviteSent } = vi.hoisted(
|
||||
() => ({
|
||||
mockCreateInvite: vi.fn(),
|
||||
mockToastAdd: vi.fn(),
|
||||
mockTrackInviteSent: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
createInvite: mockCreateInvite
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackWorkspaceInviteSent: mockTrackInviteSent
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function pendingInviteFor(email: string): PendingInvite {
|
||||
return {
|
||||
id: `inv-${email}`,
|
||||
email,
|
||||
inviteDate: new Date(0),
|
||||
expiryDate: new Date(0)
|
||||
}
|
||||
}
|
||||
|
||||
function renderForm(props: Record<string, unknown> = {}) {
|
||||
const user = userEvent.setup()
|
||||
const result = render(InviteMembersForm, {
|
||||
props: {
|
||||
submitLabel: 'Send invites',
|
||||
placeholder: 'Enter emails',
|
||||
source: 'post_upgrade_success',
|
||||
...props
|
||||
},
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
function emailInput() {
|
||||
return screen.getByRole('textbox')
|
||||
}
|
||||
|
||||
function submitButton() {
|
||||
return screen.getByRole('button', { name: 'Send invites' })
|
||||
}
|
||||
|
||||
describe('InviteMembersForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreateInvite.mockImplementation(async (email: string) =>
|
||||
pendingInviteFor(email)
|
||||
)
|
||||
})
|
||||
|
||||
it('turns comma- and enter-delimited input into chips', async () => {
|
||||
const { user } = renderForm()
|
||||
|
||||
await user.type(emailInput(), 'a@b.com,')
|
||||
await user.type(emailInput(), 'c@d.com{Enter}')
|
||||
|
||||
expect(screen.getByText('a@b.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('c@d.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables submit with no chips and flags invalid emails', async () => {
|
||||
const { user } = renderForm()
|
||||
|
||||
expect(submitButton()).toBeDisabled()
|
||||
|
||||
await user.type(emailInput(), 'not-an-email{Enter}')
|
||||
|
||||
expect(screen.getByText('not-an-email')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.inviteMemberDialog.invalidEmailCount')
|
||||
).toBeInTheDocument()
|
||||
expect(submitButton()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('creates an invite per email, tracks telemetry, and emits submitted', async () => {
|
||||
const { user, emitted } = renderForm()
|
||||
|
||||
await user.type(emailInput(), 'a@b.com,c@d.com{Enter}')
|
||||
await user.click(submitButton())
|
||||
|
||||
await waitFor(() => expect(mockCreateInvite).toHaveBeenCalledTimes(2))
|
||||
expect(mockCreateInvite).toHaveBeenCalledWith('a@b.com')
|
||||
expect(mockCreateInvite).toHaveBeenCalledWith('c@d.com')
|
||||
expect(mockTrackInviteSent).toHaveBeenCalledWith({
|
||||
source: 'post_upgrade_success',
|
||||
count: 2
|
||||
})
|
||||
expect(emitted().submitted).toEqual([[['a@b.com', 'c@d.com']]])
|
||||
})
|
||||
|
||||
it('keeps only failed emails as chips and toasts on partial failure', async () => {
|
||||
mockCreateInvite.mockImplementation(async (email: string) => {
|
||||
if (email === 'fail@x.com') throw new Error('nope')
|
||||
return pendingInviteFor(email)
|
||||
})
|
||||
const { user, emitted } = renderForm()
|
||||
|
||||
await user.type(emailInput(), 'ok@x.com,fail@x.com{Enter}')
|
||||
await user.click(submitButton())
|
||||
|
||||
await waitFor(() => expect(mockCreateInvite).toHaveBeenCalledTimes(2))
|
||||
expect(screen.getByText('fail@x.com')).toBeInTheDocument()
|
||||
expect(screen.queryByText('ok@x.com')).not.toBeInTheDocument()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
expect(emitted().submitted).toBeUndefined()
|
||||
expect(mockTrackInviteSent).toHaveBeenCalledWith({
|
||||
source: 'post_upgrade_success',
|
||||
count: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('caps the number of chips at maxSeats', async () => {
|
||||
const { user } = renderForm({ maxSeats: 2 })
|
||||
|
||||
await user.type(emailInput(), 'a@b.com,b@b.com,c@b.com{Enter}')
|
||||
|
||||
expect(screen.getByText('a@b.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('b@b.com')).toBeInTheDocument()
|
||||
expect(screen.queryByText('c@b.com')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.inviteMemberDialog.seatLimitReached')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits cancel when a cancel label is provided', async () => {
|
||||
const { user, emitted } = renderForm({ cancelLabel: 'Cancel' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(emitted().cancel).toBeTruthy()
|
||||
expect(mockCreateInvite).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the built-in submit row when showSubmit is false', () => {
|
||||
renderForm({ showSubmit: false })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Send invites' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
181
src/platform/workspace/components/InviteMembersForm.vue
Normal file
181
src/platform/workspace/components/InviteMembersForm.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TagsInput
|
||||
always-editing
|
||||
add-on-paste
|
||||
add-on-blur
|
||||
:delimiter="EMAIL_DELIMITER"
|
||||
:convert-value="normalizeEmail"
|
||||
:model-value="emails"
|
||||
class="min-h-10 w-full bg-tertiary-background px-3 focus-within:bg-tertiary-background hover:bg-tertiary-background-hover"
|
||||
@update:model-value="onEmailsUpdate"
|
||||
>
|
||||
<TagsInputItem
|
||||
v-for="email in emails"
|
||||
:key="email"
|
||||
:value="email"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-full',
|
||||
!EMAIL_REGEX.test(email) && 'bg-danger/20 text-danger'
|
||||
)
|
||||
"
|
||||
>
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput
|
||||
auto-focus
|
||||
class="min-w-0 text-sm"
|
||||
:placeholder="emails.length === 0 ? placeholder : undefined"
|
||||
/>
|
||||
</TagsInput>
|
||||
|
||||
<p v-if="invalidEmails.length > 0" class="text-danger m-0 text-xs">
|
||||
{{
|
||||
$t(
|
||||
'workspacePanel.inviteMemberDialog.invalidEmailCount',
|
||||
invalidEmails.length
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p v-else-if="isSeatLimitReached" class="m-0 text-xs text-muted-foreground">
|
||||
{{ $t('workspacePanel.inviteMemberDialog.seatLimitReached', maxSeats) }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="showSubmit"
|
||||
:class="
|
||||
cn('flex', cancelLabel ? 'items-center justify-end gap-4' : 'flex-col')
|
||||
"
|
||||
>
|
||||
<Button
|
||||
v-if="cancelLabel"
|
||||
variant="muted-textonly"
|
||||
size="lg"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
{{ cancelLabel }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:class="cn(!cancelLabel && 'w-full rounded-lg')"
|
||||
:loading
|
||||
:disabled="!canSubmit"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ submitLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TagsInput from '@/components/ui/tags-input/TagsInput.vue'
|
||||
import TagsInputInput from '@/components/ui/tags-input/TagsInputInput.vue'
|
||||
import TagsInputItem from '@/components/ui/tags-input/TagsInputItem.vue'
|
||||
import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.vue'
|
||||
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { WorkspaceInviteMetadata } from '@/platform/telemetry/types'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const EMAIL_DELIMITER = /[,\s]+/
|
||||
|
||||
const {
|
||||
submitLabel,
|
||||
placeholder,
|
||||
source,
|
||||
cancelLabel,
|
||||
maxSeats = Number.POSITIVE_INFINITY,
|
||||
showSubmit = true
|
||||
} = defineProps<{
|
||||
submitLabel: string
|
||||
placeholder: string
|
||||
source: WorkspaceInviteMetadata['source']
|
||||
cancelLabel?: string
|
||||
maxSeats?: number
|
||||
/** Hide the built-in submit row so a parent can place the action elsewhere
|
||||
* (e.g. the team-upgrade success footer); drive it via the exposed submit. */
|
||||
showSubmit?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submitted: [emails: string[]]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const telemetry = useTelemetry()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
|
||||
const emails = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const invalidEmails = computed(() =>
|
||||
emails.value.filter((email) => !EMAIL_REGEX.test(email))
|
||||
)
|
||||
const isSeatLimitReached = computed(() => emails.value.length >= maxSeats)
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
emails.value.length > 0 &&
|
||||
emails.value.length <= maxSeats &&
|
||||
invalidEmails.value.length === 0
|
||||
)
|
||||
|
||||
function normalizeEmail(value: string) {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function onEmailsUpdate(value: string[]) {
|
||||
const nonEmpty = value.filter((email) => email.length > 0)
|
||||
emails.value =
|
||||
nonEmpty.length > maxSeats ? nonEmpty.slice(0, maxSeats) : nonEmpty
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!canSubmit.value || loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const submitted = [...emails.value]
|
||||
const results = await Promise.allSettled(
|
||||
submitted.map((email) => workspaceStore.createInvite(email))
|
||||
)
|
||||
const failedEmails = submitted.filter(
|
||||
(_, index) => results[index].status === 'rejected'
|
||||
)
|
||||
const invited = submitted.filter((email) => !failedEmails.includes(email))
|
||||
|
||||
if (invited.length > 0) {
|
||||
telemetry?.trackWorkspaceInviteSent({ source, count: invited.length })
|
||||
}
|
||||
|
||||
if (failedEmails.length === 0) {
|
||||
emit('submitted', submitted)
|
||||
return
|
||||
}
|
||||
|
||||
emails.value = failedEmails
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t(
|
||||
'workspacePanel.inviteMemberDialog.failedCount',
|
||||
failedEmails.length
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ submit: onSubmit, canSubmit, loading })
|
||||
</script>
|
||||
@@ -2,8 +2,42 @@ import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
PreviewSubscribeResponse,
|
||||
SubscriptionDuration
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
|
||||
|
||||
function previewFixture(
|
||||
duration: SubscriptionDuration,
|
||||
priceCents: number
|
||||
): PreviewSubscribeResponse {
|
||||
return {
|
||||
allowed: true,
|
||||
transition_type: 'new_subscription',
|
||||
effective_at: '2026-06-19T00:00:00Z',
|
||||
is_immediate: true,
|
||||
cost_today_cents: priceCents,
|
||||
cost_next_period_cents: priceCents,
|
||||
credits_today_cents: 0,
|
||||
credits_next_period_cents: 0,
|
||||
new_plan: {
|
||||
slug: 'creator',
|
||||
tier: 'CREATOR',
|
||||
duration,
|
||||
price_cents: priceCents,
|
||||
credits_cents: 0,
|
||||
seat_summary: {
|
||||
seat_count: 1,
|
||||
total_cost_cents: priceCents,
|
||||
total_credits_cents: 0
|
||||
},
|
||||
period_end: '2027-06-19T00:00:00Z'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
@@ -42,6 +76,68 @@ describe('SubscriptionAddPaymentPreviewWorkspace', () => {
|
||||
expect(screen.getByText('$380.00')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the monthly-equivalent price and annual total for a yearly preview', () => {
|
||||
render(SubscriptionAddPaymentPreviewWorkspace, {
|
||||
props: {
|
||||
tierKey: 'creator',
|
||||
billingCycle: 'yearly',
|
||||
previewData: previewFixture('ANNUAL', 33_600)
|
||||
},
|
||||
global: globalOptions
|
||||
})
|
||||
expect(screen.getByText('subscription.usdPerMonth')).toBeTruthy()
|
||||
expect(screen.getByText('$28')).toBeTruthy()
|
||||
expect(screen.getByText('subscription.billedYearly')).toBeTruthy()
|
||||
expect(screen.getByText('$336.00')).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText('subscription.preview.eachYearCreditsRefill')
|
||||
).toBeTruthy()
|
||||
expect(screen.getByText('88,800')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('divides the yearly price by twelve in the fallback path', () => {
|
||||
render(SubscriptionAddPaymentPreviewWorkspace, {
|
||||
props: { tierKey: 'creator', billingCycle: 'yearly' },
|
||||
global: globalOptions
|
||||
})
|
||||
expect(screen.getByText('$28')).toBeTruthy()
|
||||
expect(screen.getByText('subscription.billedYearly')).toBeTruthy()
|
||||
expect(screen.getByText('$336.00')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('omits the billed-yearly note for a monthly subscription', () => {
|
||||
render(SubscriptionAddPaymentPreviewWorkspace, {
|
||||
props: {
|
||||
tierKey: 'creator',
|
||||
billingCycle: 'monthly',
|
||||
previewData: previewFixture('MONTHLY', 3_500)
|
||||
},
|
||||
global: globalOptions
|
||||
})
|
||||
expect(screen.getByText('$35')).toBeTruthy()
|
||||
expect(screen.getByText('subscription.billedMonthly')).toBeTruthy()
|
||||
expect(screen.queryByText('subscription.billedYearly')).toBeNull()
|
||||
expect(
|
||||
screen.getByText('subscription.preview.eachMonthCreditsRefill')
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
screen.queryByText('subscription.preview.eachYearCreditsRefill')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the annual total for a yearly team plan', () => {
|
||||
render(SubscriptionAddPaymentPreviewWorkspace, {
|
||||
props: {
|
||||
billingCycle: 'yearly',
|
||||
teamPlan: { usd: 400, credits: 84_400, discountedUsd: 380 }
|
||||
},
|
||||
global: globalOptions
|
||||
})
|
||||
expect(screen.getByText('$380')).toBeTruthy()
|
||||
expect(screen.getByText('subscription.billedYearly')).toBeTruthy()
|
||||
expect(screen.getByText('$4560.00')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits addCreditCard from the team confirm CTA', async () => {
|
||||
const { emitted } = render(SubscriptionAddPaymentPreviewWorkspace, {
|
||||
props: { teamPlan: { usd: 400, credits: 84_400, discountedUsd: 380 } },
|
||||
|
||||
@@ -19,13 +19,13 @@
|
||||
{{ $t('subscription.usdPerMonth') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="teamPlan"
|
||||
class="flex items-center gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
<i class="icon-[comfy--credits] size-3.5 shrink-0 bg-amber-400" />
|
||||
<span>{{ displayCredits }} {{ $t('subscription.perMonth') }}</span>
|
||||
</div>
|
||||
<span class="text-muted-foreground">
|
||||
{{
|
||||
isYearly
|
||||
? $t('subscription.billedYearly', { total: annualTotalFormatted })
|
||||
: $t('subscription.billedMonthly')
|
||||
}}
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
{{ $t('subscription.preview.startingToday') }}
|
||||
</span>
|
||||
@@ -35,12 +35,12 @@
|
||||
<div class="flex flex-col gap-3 pt-16 pb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base-foreground">
|
||||
{{ $t('subscription.preview.eachMonthCreditsRefill') }}
|
||||
{{ $t(creditsRefillLabelKey) }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
|
||||
<span class="font-bold text-base-foreground">
|
||||
{{ displayCredits }}
|
||||
{{ refillCredits }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@
|
||||
|
||||
<!-- Add Credit Card Button -->
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
class="w-full rounded-lg"
|
||||
:loading="isLoading"
|
||||
@@ -210,16 +210,41 @@ const tierName = computed(() =>
|
||||
: t(`subscription.tiers.${tierKey}.name`)
|
||||
)
|
||||
|
||||
const isYearly = computed(() =>
|
||||
previewData?.new_plan
|
||||
? previewData.new_plan.duration === 'ANNUAL'
|
||||
: billingCycle === 'yearly'
|
||||
)
|
||||
|
||||
const displayPrice = computed(() => {
|
||||
if (teamPlan) return teamPlan.discountedUsd
|
||||
if (previewData?.new_plan) {
|
||||
return (previewData.new_plan.price_cents / 100).toFixed(0)
|
||||
const cents = previewData.new_plan.price_cents
|
||||
return ((isYearly.value ? cents / 12 : cents) / 100).toFixed(0)
|
||||
}
|
||||
return tierKey ? getTierPrice(tierKey, billingCycle === 'yearly') : 0
|
||||
return tierKey ? getTierPrice(tierKey, isYearly.value) : 0
|
||||
})
|
||||
|
||||
const displayCredits = computed(() =>
|
||||
n(teamPlan ? teamPlan.credits : tierKey ? (getTierCredits(tierKey) ?? 0) : 0)
|
||||
const annualTotalUsd = computed(() => {
|
||||
if (teamPlan) return teamPlan.discountedUsd * 12
|
||||
if (previewData?.new_plan) return previewData.new_plan.price_cents / 100
|
||||
return tierKey ? getTierPrice(tierKey, true) * 12 : 0
|
||||
})
|
||||
|
||||
const annualTotalFormatted = computed(() => `$${n(annualTotalUsd.value)}`)
|
||||
|
||||
const monthlyCredits = computed(() =>
|
||||
teamPlan ? teamPlan.credits : tierKey ? (getTierCredits(tierKey) ?? 0) : 0
|
||||
)
|
||||
|
||||
const refillCredits = computed(() =>
|
||||
n(isYearly.value ? monthlyCredits.value * 12 : monthlyCredits.value)
|
||||
)
|
||||
|
||||
const creditsRefillLabelKey = computed(() =>
|
||||
isYearly.value
|
||||
? 'subscription.preview.eachYearCreditsRefill'
|
||||
: 'subscription.preview.eachMonthCreditsRefill'
|
||||
)
|
||||
|
||||
const teamPerks = computed(() => [
|
||||
@@ -235,16 +260,18 @@ const hasCustomLoRAs = computed(() =>
|
||||
const maxDuration = computed(() => t(`subscription.maxDuration.${tierKey}`))
|
||||
|
||||
const totalDueToday = computed(() => {
|
||||
if (teamPlan) return teamPlan.discountedUsd.toFixed(2)
|
||||
if (teamPlan) {
|
||||
const total = isYearly.value
|
||||
? teamPlan.discountedUsd * 12
|
||||
: teamPlan.discountedUsd
|
||||
return total.toFixed(2)
|
||||
}
|
||||
if (previewData) {
|
||||
return (previewData.cost_today_cents / 100).toFixed(2)
|
||||
}
|
||||
if (!tierKey) return '0.00'
|
||||
const priceValue = getTierPrice(tierKey, billingCycle === 'yearly')
|
||||
if (billingCycle === 'yearly') {
|
||||
return (priceValue * 12).toFixed(2)
|
||||
}
|
||||
return priceValue.toFixed(2)
|
||||
const priceValue = getTierPrice(tierKey, isYearly.value)
|
||||
return (isYearly.value ? priceValue * 12 : priceValue).toFixed(2)
|
||||
})
|
||||
|
||||
const nextPaymentDate = computed(() => {
|
||||
|
||||
@@ -10,8 +10,10 @@ type PreviewPlanInfo = PreviewSubscribeResponse['new_plan']
|
||||
|
||||
/**
|
||||
* Checkout steps of the unified subscription dialog (FE-934): the
|
||||
* "Confirm your payment" / "Confirm your plan change" preview screens and the
|
||||
* "You're all set" success screen. Driven by props (no API in Storybook).
|
||||
* "Confirm your payment" (new subscription), the single-plan plan-change
|
||||
* confirm (immediate upgrade / scheduled downgrade), and the success screen.
|
||||
* Driven by props (no API in Storybook). `price_cents` is the full
|
||||
* billing-period total — yearly headlines divide it by 12.
|
||||
*/
|
||||
const meta: Meta = {
|
||||
title: 'Components/SubscriptionCheckoutSteps',
|
||||
@@ -21,38 +23,49 @@ const meta: Meta = {
|
||||
export default meta
|
||||
type Story = StoryObj
|
||||
|
||||
const creatorPlan: PreviewPlanInfo = {
|
||||
slug: 'creator-annual',
|
||||
tier: 'CREATOR',
|
||||
duration: 'ANNUAL',
|
||||
price_cents: 2800,
|
||||
credits_cents: 740000,
|
||||
seat_summary: {
|
||||
seat_count: 1,
|
||||
total_cost_cents: 2800,
|
||||
total_credits_cents: 740000
|
||||
},
|
||||
period_end: '2027-07-10T00:00:00Z'
|
||||
const TODAY = '2026-06-19T00:00:00Z'
|
||||
const NEXT_YEAR = '2027-06-28T00:00:00Z'
|
||||
const PERIOD_END = '2027-06-28T00:00:00Z'
|
||||
|
||||
function plan(
|
||||
tier: PreviewPlanInfo['tier'],
|
||||
duration: PreviewPlanInfo['duration'],
|
||||
priceCents: number,
|
||||
periodEnd: string
|
||||
): PreviewPlanInfo {
|
||||
return {
|
||||
slug: `${tier.toLowerCase()}-${duration.toLowerCase()}`,
|
||||
tier,
|
||||
duration,
|
||||
price_cents: priceCents,
|
||||
credits_cents: 0,
|
||||
seat_summary: {
|
||||
seat_count: 1,
|
||||
total_cost_cents: priceCents,
|
||||
total_credits_cents: 0
|
||||
},
|
||||
period_end: periodEnd
|
||||
}
|
||||
}
|
||||
|
||||
const proPlan: PreviewPlanInfo = {
|
||||
slug: 'pro-annual',
|
||||
tier: 'PRO',
|
||||
duration: 'ANNUAL',
|
||||
price_cents: 8000,
|
||||
credits_cents: 2110000,
|
||||
seat_summary: {
|
||||
seat_count: 1,
|
||||
total_cost_cents: 8000,
|
||||
total_credits_cents: 2110000
|
||||
},
|
||||
period_end: '2026-07-10T00:00:00Z'
|
||||
}
|
||||
const creatorMonthly = plan('CREATOR', 'MONTHLY', 3500, NEXT_YEAR)
|
||||
const creatorAnnual = plan('CREATOR', 'ANNUAL', 33_600, NEXT_YEAR)
|
||||
const proMonthly = plan('PRO', 'MONTHLY', 10_000, PERIOD_END)
|
||||
|
||||
const shell =
|
||||
'<div class="mx-auto flex h-[680px] w-[460px] flex-col rounded-2xl border border-border-default bg-secondary-background p-12">'
|
||||
|
||||
/** New subscription — "Confirm your payment" (AddPayment preview). */
|
||||
function transitionStory(previewData: PreviewSubscribeResponse): Story {
|
||||
return {
|
||||
render: () => ({
|
||||
components: { SubscriptionTransitionPreviewWorkspace },
|
||||
data: () => ({ previewData }),
|
||||
template: `${shell}<SubscriptionTransitionPreviewWorkspace :preview-data="previewData" /></div>`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** New subscription — "Confirm your payment" (AddPayment, Creator yearly). */
|
||||
export const ConfirmNewSubscription: Story = {
|
||||
render: () => ({
|
||||
components: { SubscriptionAddPaymentPreviewWorkspace },
|
||||
@@ -60,13 +73,13 @@ export const ConfirmNewSubscription: Story = {
|
||||
previewData: {
|
||||
allowed: true,
|
||||
transition_type: 'new_subscription',
|
||||
effective_at: '2026-07-10T00:00:00Z',
|
||||
effective_at: TODAY,
|
||||
is_immediate: true,
|
||||
cost_today_cents: 2800,
|
||||
cost_next_period_cents: 2800,
|
||||
credits_today_cents: 740000,
|
||||
credits_next_period_cents: 740000,
|
||||
new_plan: creatorPlan
|
||||
cost_today_cents: 33_600,
|
||||
cost_next_period_cents: 33_600,
|
||||
credits_today_cents: 0,
|
||||
credits_next_period_cents: 0,
|
||||
new_plan: creatorAnnual
|
||||
} satisfies PreviewSubscribeResponse
|
||||
}),
|
||||
template: `${shell}<SubscriptionAddPaymentPreviewWorkspace tier-key="creator" billing-cycle="yearly" :preview-data="previewData" /></div>`
|
||||
@@ -77,32 +90,68 @@ export const ConfirmNewSubscription: Story = {
|
||||
export const ConfirmTeamSubscription: Story = {
|
||||
render: () => ({
|
||||
components: { SubscriptionAddPaymentPreviewWorkspace },
|
||||
data: () => ({ teamPlan: { usd: 400, credits: 84_400 } }),
|
||||
template: `${shell}<SubscriptionAddPaymentPreviewWorkspace :team-plan="teamPlan" /></div>`
|
||||
data: () => ({
|
||||
teamPlan: { usd: 700, credits: 147_700, discountedUsd: 630 }
|
||||
}),
|
||||
template: `${shell}<SubscriptionAddPaymentPreviewWorkspace :team-plan="teamPlan" billing-cycle="yearly" /></div>`
|
||||
})
|
||||
}
|
||||
|
||||
/** Plan change — "Confirm your plan change" (Transition preview, Pro → Creator). */
|
||||
export const ConfirmPlanChange: Story = {
|
||||
render: () => ({
|
||||
components: { SubscriptionTransitionPreviewWorkspace },
|
||||
data: () => ({
|
||||
previewData: {
|
||||
allowed: true,
|
||||
transition_type: 'downgrade',
|
||||
effective_at: '2026-07-10T00:00:00Z',
|
||||
is_immediate: false,
|
||||
cost_today_cents: 0,
|
||||
cost_next_period_cents: 2800,
|
||||
credits_today_cents: 0,
|
||||
credits_next_period_cents: 740000,
|
||||
current_plan: proPlan,
|
||||
new_plan: creatorPlan
|
||||
} satisfies PreviewSubscribeResponse
|
||||
}),
|
||||
template: `${shell}<SubscriptionTransitionPreviewWorkspace :preview-data="previewData" /></div>`
|
||||
})
|
||||
}
|
||||
/** Immediate upgrade — Creator monthly → yearly (cadence change, prorated). */
|
||||
export const UpgradeCadenceYearly: Story = transitionStory({
|
||||
allowed: true,
|
||||
transition_type: 'duration_change',
|
||||
effective_at: TODAY,
|
||||
is_immediate: true,
|
||||
cost_today_cents: 31_850,
|
||||
cost_next_period_cents: 33_600,
|
||||
credits_today_cents: 0,
|
||||
credits_next_period_cents: 0,
|
||||
current_plan: creatorMonthly,
|
||||
new_plan: creatorAnnual
|
||||
} satisfies PreviewSubscribeResponse)
|
||||
|
||||
/** Immediate upgrade — Creator → Pro monthly (tier change, prorated). */
|
||||
export const UpgradeTier: Story = transitionStory({
|
||||
allowed: true,
|
||||
transition_type: 'upgrade',
|
||||
effective_at: TODAY,
|
||||
is_immediate: true,
|
||||
cost_today_cents: 8250,
|
||||
cost_next_period_cents: 10_000,
|
||||
credits_today_cents: 0,
|
||||
credits_next_period_cents: 0,
|
||||
current_plan: creatorMonthly,
|
||||
new_plan: proMonthly
|
||||
} satisfies PreviewSubscribeResponse)
|
||||
|
||||
/** Scheduled downgrade — Pro → Creator monthly (effective at period end). */
|
||||
export const DowngradeTier: Story = transitionStory({
|
||||
allowed: true,
|
||||
transition_type: 'downgrade',
|
||||
effective_at: PERIOD_END,
|
||||
is_immediate: false,
|
||||
cost_today_cents: 0,
|
||||
cost_next_period_cents: 3500,
|
||||
credits_today_cents: 0,
|
||||
credits_next_period_cents: 0,
|
||||
current_plan: proMonthly,
|
||||
new_plan: creatorMonthly
|
||||
} satisfies PreviewSubscribeResponse)
|
||||
|
||||
/** Scheduled downgrade — Creator yearly → monthly (cadence, period end). */
|
||||
export const DowngradeCadenceMonthly: Story = transitionStory({
|
||||
allowed: true,
|
||||
transition_type: 'duration_change',
|
||||
effective_at: PERIOD_END,
|
||||
is_immediate: false,
|
||||
cost_today_cents: 0,
|
||||
cost_next_period_cents: 3500,
|
||||
credits_today_cents: 0,
|
||||
credits_next_period_cents: 0,
|
||||
current_plan: plan('CREATOR', 'ANNUAL', 33_600, PERIOD_END),
|
||||
new_plan: creatorMonthly
|
||||
} satisfies PreviewSubscribeResponse)
|
||||
|
||||
/** Success — "You're all set". */
|
||||
export const SuccessAllSet: Story = {
|
||||
@@ -112,15 +161,27 @@ export const SuccessAllSet: Story = {
|
||||
previewData: {
|
||||
allowed: true,
|
||||
transition_type: 'new_subscription',
|
||||
effective_at: '2026-07-10T00:00:00Z',
|
||||
effective_at: TODAY,
|
||||
is_immediate: true,
|
||||
cost_today_cents: 2800,
|
||||
cost_next_period_cents: 2800,
|
||||
credits_today_cents: 740000,
|
||||
credits_next_period_cents: 740000,
|
||||
new_plan: creatorPlan
|
||||
cost_today_cents: 33_600,
|
||||
cost_next_period_cents: 33_600,
|
||||
credits_today_cents: 0,
|
||||
credits_next_period_cents: 0,
|
||||
new_plan: creatorAnnual
|
||||
} satisfies PreviewSubscribeResponse
|
||||
}),
|
||||
template: `${shell}<SubscriptionSuccessWorkspace tier-key="creator" :preview-data="previewData" /></div>`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Team success — "You're all set" with the inline "Invite your team" block
|
||||
* (FE-965 / DES-394). Team-only: gated on a team plan + teamWorkspacesEnabled.
|
||||
*/
|
||||
export const TeamSuccessWithInvite: Story = {
|
||||
render: () => ({
|
||||
components: { SubscriptionSuccessWorkspace },
|
||||
data: () => ({ previewData: { new_plan: creatorMonthly } }),
|
||||
template: `${shell}<SubscriptionSuccessWorkspace tier-key="creator" is-team :preview-data="previewData" /></div>`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { SubscriptionInfo } from '@/composables/billing/types'
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import type {
|
||||
CurrentTeamCreditStop,
|
||||
TeamCreditStops
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import SubscriptionPanelContentWorkspace from './SubscriptionPanelContentWorkspace.vue'
|
||||
|
||||
const RENEWAL_DATE_ISO = '2026-06-20T12:00:00Z'
|
||||
const END_DATE_ISO = '2026-01-20T12:00:00Z'
|
||||
|
||||
function formatPanelDate(isoDate: string) {
|
||||
return new Date(isoDate).toLocaleDateString('en', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
// The panel displays the subscribed credit stop's per-month price; monthly and
|
||||
// yearly stops are both per-month figures.
|
||||
const teamCreditStops: TeamCreditStops = {
|
||||
default_stop_index: 1,
|
||||
stops: [
|
||||
{
|
||||
id: 'team_700',
|
||||
credits: 147700,
|
||||
monthly: { list_price_cents: 70000, price_cents: 66500 },
|
||||
yearly: { list_price_cents: 70000, price_cents: 63000 }
|
||||
},
|
||||
{
|
||||
id: 'team_2500',
|
||||
credits: 527500,
|
||||
monthly: { list_price_cents: 250000, price_cents: 225000 },
|
||||
yearly: { list_price_cents: 250000, price_cents: 200000 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const mockSubscriptionStatus = ref<'active' | 'canceled'>('active')
|
||||
const mockSubscriptionDuration = ref<'MONTHLY' | 'ANNUAL'>('MONTHLY')
|
||||
const mockHasSubscription = ref(true)
|
||||
const mockIsActiveSubscription = ref(true)
|
||||
const mockIsInPersonalWorkspace = ref(false)
|
||||
const mockIsWorkspaceSubscribed = ref(true)
|
||||
// canManageSubscription = any owner (billing, change plan);
|
||||
// canManageSubscriptionLifecycle = original owner only (cancel, resubscribe).
|
||||
const mockCanManageSubscription = ref(true)
|
||||
const mockCanManageSubscriptionLifecycle = ref(true)
|
||||
const mockIsCurrentUserOriginalOwner = ref(true)
|
||||
const mockMembers = ref([{ id: 'member-1' }, { id: 'member-2' }])
|
||||
const mockTeamCreditStops = ref<TeamCreditStops | null>(teamCreditStops)
|
||||
const mockCurrentTeamCreditStop = ref<CurrentTeamCreditStop | null>({
|
||||
id: 'team_700',
|
||||
credits_monthly: 147700,
|
||||
stop_usd: 700
|
||||
})
|
||||
|
||||
const mockManageSubscription = vi.fn()
|
||||
const mockShowSubscriptionDialog = vi.fn()
|
||||
const mockResubscribe = vi.fn()
|
||||
const mockShowLeaveWorkspaceDialog = vi.fn()
|
||||
const mockShowCancelSubscriptionDialog = vi.fn()
|
||||
const mockShowEditWorkspaceDialog = vi.fn()
|
||||
const mockShowDeleteWorkspaceDialog = vi.fn()
|
||||
|
||||
type MenuUiConfig = {
|
||||
showEditWorkspaceMenuItem: boolean
|
||||
workspaceMenuAction: 'leave' | 'delete' | null
|
||||
workspaceMenuDisabledTooltip: string | null
|
||||
}
|
||||
|
||||
const ownerUiConfig: MenuUiConfig = {
|
||||
showEditWorkspaceMenuItem: true,
|
||||
workspaceMenuAction: 'delete',
|
||||
workspaceMenuDisabledTooltip:
|
||||
'workspacePanel.menu.deleteWorkspaceDisabledTooltip'
|
||||
}
|
||||
const memberUiConfig: MenuUiConfig = {
|
||||
showEditWorkspaceMenuItem: false,
|
||||
workspaceMenuAction: 'leave',
|
||||
workspaceMenuDisabledTooltip: null
|
||||
}
|
||||
const personalUiConfig: MenuUiConfig = {
|
||||
showEditWorkspaceMenuItem: true,
|
||||
workspaceMenuAction: 'delete',
|
||||
workspaceMenuDisabledTooltip:
|
||||
'workspacePanel.menu.deleteWorkspaceDisabledTooltip'
|
||||
}
|
||||
const mockUiConfig = ref<MenuUiConfig>(ownerUiConfig)
|
||||
|
||||
const mockSubscription = computed<SubscriptionInfo | null>(() =>
|
||||
mockHasSubscription.value
|
||||
? {
|
||||
isActive: true,
|
||||
tier: 'PRO',
|
||||
duration: mockSubscriptionDuration.value,
|
||||
planSlug: 'team-monthly',
|
||||
renewalDate: RENEWAL_DATE_ISO,
|
||||
endDate: END_DATE_ISO,
|
||||
isCancelled: mockSubscriptionStatus.value === 'canceled',
|
||||
hasFunds: true
|
||||
}
|
||||
: null
|
||||
)
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
|
||||
isFreeTier: computed(() => false),
|
||||
subscription: mockSubscription,
|
||||
teamCreditStops: mockTeamCreditStops,
|
||||
currentTeamCreditStop: mockCurrentTeamCreditStop,
|
||||
showSubscriptionDialog: mockShowSubscriptionDialog,
|
||||
manageSubscription: mockManageSubscription,
|
||||
resubscribe: mockResubscribe
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
isInPersonalWorkspace: mockIsInPersonalWorkspace,
|
||||
isWorkspaceSubscribed: mockIsWorkspaceSubscribed,
|
||||
members: mockMembers,
|
||||
isCurrentUserOriginalOwner: mockIsCurrentUserOriginalOwner
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: computed(() => ({
|
||||
canManageSubscription: mockCanManageSubscription.value,
|
||||
canManageSubscriptionLifecycle: mockCanManageSubscriptionLifecycle.value
|
||||
})),
|
||||
uiConfig: computed(() => mockUiConfig.value)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
|
||||
useBillingOperationStore: () => ({ isSettingUp: false })
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showCancelSubscriptionDialog: mockShowCancelSubscriptionDialog,
|
||||
showLeaveWorkspaceDialog: mockShowLeaveWorkspaceDialog,
|
||||
showEditWorkspaceDialog: mockShowEditWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog: mockShowDeleteWorkspaceDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({ showPricingTable: vi.fn() })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({ add: vi.fn() })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
const CreditsTileStub = {
|
||||
props: ['zeroState'],
|
||||
template:
|
||||
'<div data-testid="credits-tile" :data-zero-state="String(zeroState)" />'
|
||||
}
|
||||
|
||||
const ButtonStub = {
|
||||
template:
|
||||
'<button v-bind="$attrs" :disabled="loading" @click="$emit(\'click\', $event)"><slot /></button>',
|
||||
props: ['variant', 'size', 'loading'],
|
||||
emits: ['click']
|
||||
}
|
||||
|
||||
const SubscriptionFooterLinksStub = {
|
||||
template: '<div data-testid="subscription-footer-links" />'
|
||||
}
|
||||
|
||||
const DropdownMenuStub = {
|
||||
props: ['entries'],
|
||||
template:
|
||||
'<div data-testid="plan-menu"><slot name="button" /><button v-for="item in (entries || []).filter((e) => !e.separator)" :key="item.label" type="button" :disabled="item.disabled" @click="item.command?.({})">{{ item.label }}</button></div>'
|
||||
}
|
||||
|
||||
function renderComponent() {
|
||||
return render(SubscriptionPanelContentWorkspace, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
|
||||
directives: { tooltip: {} },
|
||||
stubs: {
|
||||
CreditsTile: CreditsTileStub,
|
||||
Button: ButtonStub,
|
||||
SubscriptionFooterLinks: SubscriptionFooterLinksStub,
|
||||
StatusBadge: true,
|
||||
DropdownMenu: DropdownMenuStub
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SubscriptionPanelContentWorkspace', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSubscriptionStatus.value = 'active'
|
||||
mockHasSubscription.value = true
|
||||
mockIsActiveSubscription.value = true
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
mockIsWorkspaceSubscribed.value = true
|
||||
mockCanManageSubscription.value = true
|
||||
mockCanManageSubscriptionLifecycle.value = true
|
||||
mockIsCurrentUserOriginalOwner.value = true
|
||||
mockUiConfig.value = ownerUiConfig
|
||||
mockMembers.value = [{ id: 'member-1' }, { id: 'member-2' }]
|
||||
mockSubscriptionDuration.value = 'MONTHLY'
|
||||
mockTeamCreditStops.value = teamCreditStops
|
||||
mockCurrentTeamCreditStop.value = {
|
||||
id: 'team_700',
|
||||
credits_monthly: 147700,
|
||||
stop_usd: 700
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the subscribed credit stop price and renewal subtitle', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('Team')).toBeInTheDocument()
|
||||
// Monthly subscription on team_700 -> monthly.price_cents 66500 -> $665.
|
||||
expect(screen.getByText('$665')).toBeInTheDocument()
|
||||
expect(screen.getByText('USD / mo')).toBeInTheDocument()
|
||||
expect(screen.queryByText('USD / mo / member')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(`Renews on ${formatPanelDate(RENEWAL_DATE_ISO)}`)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByTestId('subscription-footer-links')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses the yearly stop price for an annual subscription, still shown per month', () => {
|
||||
mockSubscriptionDuration.value = 'ANNUAL'
|
||||
mockCurrentTeamCreditStop.value = {
|
||||
id: 'team_2500',
|
||||
credits_monthly: 527500,
|
||||
stop_usd: 2500
|
||||
}
|
||||
renderComponent()
|
||||
|
||||
// team_2500 yearly.price_cents 200000 -> $2000, labelled per month.
|
||||
expect(screen.getByText('$2000')).toBeInTheDocument()
|
||||
expect(screen.getByText('USD / mo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to the per-member tier price until stops resolve', () => {
|
||||
mockTeamCreditStops.value = null
|
||||
mockCurrentTeamCreditStop.value = null
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('$100')).toBeInTheDocument()
|
||||
expect(screen.getByText('USD / mo / member')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('wires Manage billing and Change plan actions for subscription managers', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Manage billing' }))
|
||||
expect(mockManageSubscription).toHaveBeenCalledOnce()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Change plan' }))
|
||||
expect(mockShowSubscriptionDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides plan management actions from members without permission', () => {
|
||||
mockCanManageSubscription.value = false
|
||||
mockCanManageSubscriptionLifecycle.value = false
|
||||
mockIsCurrentUserOriginalOwner.value = false
|
||||
mockUiConfig.value = memberUiConfig
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Manage billing' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Change plan' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Invite members')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reactivates a cancelled plan as the original owner, keeping Manage billing', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockSubscriptionStatus.value = 'canceled'
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByText(`Ends on ${formatPanelDate(END_DATE_ISO)}`)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText(`Renews on ${formatPanelDate(RENEWAL_DATE_ISO)}`)
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Manage billing' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Change plan' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reactivate plan' }))
|
||||
expect(mockResubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides Reactivate plan from a promoted owner on a cancelled plan', () => {
|
||||
mockSubscriptionStatus.value = 'canceled'
|
||||
mockCanManageSubscriptionLifecycle.value = false
|
||||
mockIsCurrentUserOriginalOwner.value = false
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Manage billing' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Reactivate plan' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the zero-state subscribe prompt to unsubscribed team owners', () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
mockIsWorkspaceSubscribed.value = false
|
||||
mockHasSubscription.value = false
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByText('This workspace is not on a subscription')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Subscribe Now' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByTestId('credits-tile')).toHaveAttribute(
|
||||
'data-zero-state',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the zero-state contact-owner view to unsubscribed members', () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
mockIsWorkspaceSubscribed.value = false
|
||||
mockHasSubscription.value = false
|
||||
mockCanManageSubscription.value = false
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByText('Contact the workspace owner to subscribe')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Subscribe Now' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('credits-tile')).toHaveAttribute(
|
||||
'data-zero-state',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the Free plan header with Subscribe CTA for unsubscribed personal workspaces', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
mockIsActiveSubscription.value = false
|
||||
mockHasSubscription.value = false
|
||||
mockIsWorkspaceSubscribed.value = false
|
||||
mockUiConfig.value = personalUiConfig
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('Free')).toBeInTheDocument()
|
||||
expect(screen.getByText('$0')).toBeInTheDocument()
|
||||
expect(screen.getByText('USD / mo')).toBeInTheDocument()
|
||||
expect(screen.getByText("What's included:")).toBeInTheDocument()
|
||||
expect(screen.getByText('10 min max runtime')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('RTX 6000 Pro (96GB VRAM)')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('credits-tile')).toHaveAttribute(
|
||||
'data-zero-state',
|
||||
'false'
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Subscribe' }))
|
||||
expect(mockShowSubscriptionDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lets a Free personal workspace only rename itself (no Cancel or Delete)', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
mockIsActiveSubscription.value = false
|
||||
mockHasSubscription.value = false
|
||||
mockIsWorkspaceSubscribed.value = false
|
||||
mockUiConfig.value = personalUiConfig
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Cancel plan' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Delete Workspace' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Edit workspace details' })
|
||||
)
|
||||
expect(mockShowEditWorkspaceDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('offers a subscribed personal workspace Edit, Cancel plan, and a locked Delete', () => {
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
mockIsActiveSubscription.value = true
|
||||
mockHasSubscription.value = true
|
||||
mockIsWorkspaceSubscribed.value = false
|
||||
mockUiConfig.value = personalUiConfig
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Edit workspace details' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Cancel plan' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Delete Workspace' })
|
||||
).toBeDisabled()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Leave Workspace' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lists the four team perks under the Pro-inclusive heading', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByText(/Your plan includes everything in/)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Pro')).toBeInTheDocument()
|
||||
expect(screen.getByText('Invite members')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Members can run workflows concurrently')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Shared credit pool for all members')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Role-based permissions')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('RTX 6000 Pro (96GB VRAM)')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers members only Leave Workspace in the menu', () => {
|
||||
mockCanManageSubscription.value = false
|
||||
mockCanManageSubscriptionLifecycle.value = false
|
||||
mockIsCurrentUserOriginalOwner.value = false
|
||||
mockUiConfig.value = memberUiConfig
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Leave Workspace' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Cancel plan' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Edit workspace details' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the leave-workspace dialog from a member menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCanManageSubscription.value = false
|
||||
mockCanManageSubscriptionLifecycle.value = false
|
||||
mockIsCurrentUserOriginalOwner.value = false
|
||||
mockUiConfig.value = memberUiConfig
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Leave Workspace' }))
|
||||
expect(mockShowLeaveWorkspaceDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('offers a promoted owner Edit and Leave (no Cancel or Delete)', () => {
|
||||
mockCanManageSubscriptionLifecycle.value = false
|
||||
mockIsCurrentUserOriginalOwner.value = false
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Edit workspace details' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Leave Workspace' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Cancel plan' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Delete Workspace' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers the original owner Edit, Cancel plan, and a subscription-locked Delete', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Edit workspace details' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Delete Workspace' })
|
||||
).toBeDisabled()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Leave Workspace' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel plan' }))
|
||||
expect(mockShowCancelSubscriptionDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('enables Delete for the original owner once the plan is cancelled', () => {
|
||||
mockSubscriptionStatus.value = 'canceled'
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Delete Workspace' })
|
||||
).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="grow overflow-auto pt-6">
|
||||
<div class="flex grow flex-col overflow-auto pt-6">
|
||||
<!-- Loading state while subscription is being set up -->
|
||||
<div
|
||||
v-if="isSettingUp"
|
||||
@@ -41,8 +41,8 @@
|
||||
<div
|
||||
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
|
||||
>
|
||||
<!-- OWNER Unsubscribed State -->
|
||||
<template v-if="showSubscribePrompt">
|
||||
<!-- OWNER Unsubscribed TEAM workspace -->
|
||||
<template v-if="showTeamSubscribePrompt">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ $t('subscription.workspaceNotSubscribed') }}
|
||||
@@ -73,12 +73,55 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- OWNER personal workspace without subscription (Free plan) -->
|
||||
<template v-else-if="isPersonalFree">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-base font-bold text-text-primary">
|
||||
{{ $t('subscription.tiers.free.name') }}
|
||||
</span>
|
||||
<div class="flex items-baseline gap-1 font-inter">
|
||||
<span class="text-2xl font-semibold">
|
||||
${{ freeTierPrice }}
|
||||
</span>
|
||||
<span class="text-base">
|
||||
{{ $t('subscription.usdPerMonth') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 md:ml-auto">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="rounded-lg px-4 text-sm font-normal"
|
||||
@click="handleSubscribeWorkspace"
|
||||
>
|
||||
{{ $t('subscription.subscribe') }}
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
v-if="planMenuItems.length > 0"
|
||||
:entries="planMenuEntries"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="secondary"
|
||||
size="icon-lg"
|
||||
class="rounded-lg bg-interface-menu-component-surface-selected text-text-primary"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Normal Subscribed State (Owner with subscription, or member viewing subscribed workspace) -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-text-primary">
|
||||
{{ subscriptionTierName }}
|
||||
<span class="text-base font-bold text-text-primary">
|
||||
{{ planDisplayName }}
|
||||
</span>
|
||||
<StatusBadge
|
||||
v-if="isCancelled"
|
||||
@@ -86,37 +129,26 @@
|
||||
severity="warn"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1 font-inter font-semibold">
|
||||
<span class="text-2xl">${{ tierPrice }}</span>
|
||||
<span class="text-base">
|
||||
{{
|
||||
isInPersonalWorkspace
|
||||
? $t('subscription.usdPerMonth')
|
||||
: $t('subscription.usdPerMonthPerMember')
|
||||
}}
|
||||
</span>
|
||||
<div class="flex items-baseline gap-1 font-inter">
|
||||
<span class="text-2xl font-semibold"
|
||||
>${{ displayPrice }}</span
|
||||
>
|
||||
<span class="text-base">{{ priceUnitLabel }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
:class="
|
||||
cn(
|
||||
'text-sm',
|
||||
isCancelled
|
||||
? 'text-warning-background'
|
||||
: 'text-text-secondary'
|
||||
)
|
||||
"
|
||||
class="text-sm text-text-secondary"
|
||||
>
|
||||
<template v-if="isCancelled">
|
||||
{{
|
||||
$t('subscription.expiresDate', {
|
||||
$t('subscription.endsOnDate', {
|
||||
date: formattedEndDate
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
$t('subscription.renewsDate', {
|
||||
$t('subscription.renewsOnDate', {
|
||||
date: formattedRenewalDate
|
||||
})
|
||||
}}
|
||||
@@ -125,159 +157,89 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isActiveSubscription && permissions.canManageSubscription"
|
||||
v-if="isActiveSubscription"
|
||||
class="flex flex-wrap gap-2 md:ml-auto"
|
||||
>
|
||||
<!-- Cancelled state: reactivation is original-owner-only. -->
|
||||
<template v-if="isCancelled">
|
||||
<Button
|
||||
v-if="permissions.canManageSubscriptionLifecycle"
|
||||
size="lg"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 text-sm font-normal"
|
||||
:loading="isResubscribing"
|
||||
@click="handleResubscribe"
|
||||
>
|
||||
{{ $t('subscription.resubscribe') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- Active state: show Manage Payment, Upgrade, and menu -->
|
||||
<template v-else>
|
||||
<Button
|
||||
v-if="!isFreeTierPlan"
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
class="rounded-lg bg-interface-menu-component-surface-selected px-4 text-sm font-normal text-text-primary"
|
||||
@click="manageSubscription"
|
||||
>
|
||||
{{ $t('subscription.managePayment') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 text-sm font-normal text-text-primary"
|
||||
@click="handleUpgrade"
|
||||
>
|
||||
{{ $t('subscription.upgradePlan') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isFreeTierPlan && planMenuItems.length > 0"
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="planMenu?.toggle($event)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
<Menu ref="planMenu" :model="planMenuItems" :popup="true" />
|
||||
</template>
|
||||
<Button
|
||||
v-if="!isFreeTierPlan && permissions.canManageSubscription"
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
class="rounded-lg bg-interface-menu-component-surface-selected px-4 text-sm font-normal text-text-primary"
|
||||
@click="manageSubscription"
|
||||
>
|
||||
{{ $t('subscription.manageBilling') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="
|
||||
isCancelled && permissions.canManageSubscriptionLifecycle
|
||||
"
|
||||
size="lg"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 text-sm font-normal"
|
||||
:loading="isResubscribing"
|
||||
@click="handleResubscribe"
|
||||
>
|
||||
{{ $t('subscription.reactivatePlan') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="!isCancelled && permissions.canManageSubscription"
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
class="rounded-lg bg-interface-menu-component-surface-selected px-4 text-sm font-normal text-text-primary"
|
||||
@click="handleUpgrade"
|
||||
>
|
||||
{{
|
||||
isInPersonalWorkspace
|
||||
? $t('subscription.upgradePlan')
|
||||
: $t('subscription.changePlan')
|
||||
}}
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
v-if="planMenuItems.length > 0"
|
||||
:entries="planMenuEntries"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="secondary"
|
||||
size="icon-lg"
|
||||
class="rounded-lg bg-interface-menu-component-surface-selected text-text-primary"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6 pt-6 lg:flex-row lg:items-stretch">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex h-full flex-col gap-3">
|
||||
<div
|
||||
class="relative flex h-full flex-col justify-between gap-6 rounded-2xl bg-secondary-background p-5"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="absolute top-4 right-4"
|
||||
:loading="isLoadingBalance"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<i class="pi pi-sync text-sm text-text-secondary" />
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.totalCredits') }}
|
||||
</div>
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="8rem"
|
||||
height="2rem"
|
||||
/>
|
||||
<div v-else class="text-2xl font-bold">
|
||||
{{ showZeroState ? '0' : totalCredits }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit Breakdown -->
|
||||
<table class="text-sm text-muted">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="pr-4 text-left align-middle font-bold">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="5rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{
|
||||
showZeroState ? '0 / 0' : includedCreditsDisplay
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="align-middle" :title="creditsRemainingLabel">
|
||||
{{ creditsRemainingLabel }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pr-4 text-left align-middle font-bold">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="3rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{
|
||||
showZeroState ? '0' : prepaidCredits
|
||||
}}</span>
|
||||
</td>
|
||||
<td
|
||||
class="align-middle"
|
||||
:title="$t('subscription.creditsYouveAdded')"
|
||||
>
|
||||
{{ $t('subscription.creditsYouveAdded') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
isActiveSubscription &&
|
||||
!showZeroState &&
|
||||
permissions.canTopUp
|
||||
"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<Button
|
||||
v-if="isFreeTierPlan"
|
||||
variant="gradient"
|
||||
class="min-h-8 w-full rounded-lg p-2 text-sm font-normal"
|
||||
@click="handleUpgradeToAddCredits"
|
||||
>
|
||||
{{ $t('subscription.upgradeToAddCredits') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="secondary"
|
||||
class="min-h-8 rounded-lg bg-interface-menu-component-surface-selected p-2 text-sm font-normal text-text-primary"
|
||||
@click="handleAddApiCredits"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:max-w-md">
|
||||
<CreditsTile :zero-state="showZeroState" />
|
||||
</div>
|
||||
|
||||
<div v-if="isActiveSubscription" class="flex flex-col gap-2">
|
||||
<div class="text-sm text-text-primary">
|
||||
<div
|
||||
v-if="isActiveSubscription || isPersonalFree"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<i18n-t
|
||||
v-if="isTeamActive"
|
||||
keypath="subscription.teamPlanIncludes"
|
||||
tag="div"
|
||||
class="text-sm text-muted"
|
||||
>
|
||||
<template #plan>
|
||||
<span class="text-text-primary">
|
||||
{{ $t('subscription.tiers.pro.name') }}
|
||||
</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<div v-else-if="isPersonalFree" class="text-sm text-muted">
|
||||
{{ $t('subscription.whatsIncluded') }}
|
||||
</div>
|
||||
<div v-else class="text-sm text-text-primary">
|
||||
{{ $t('subscription.yourPlanIncludes') }}
|
||||
</div>
|
||||
|
||||
@@ -291,10 +253,6 @@
|
||||
v-if="benefit.type === 'feature'"
|
||||
class="pi pi-check text-xs text-text-primary"
|
||||
/>
|
||||
<i
|
||||
v-else-if="benefit.type === 'icon' && benefit.icon"
|
||||
:class="[benefit.icon, 'text-xs text-text-primary']"
|
||||
/>
|
||||
<span
|
||||
v-else-if="benefit.type === 'metric' && benefit.value"
|
||||
class="text-sm font-normal whitespace-nowrap text-text-primary"
|
||||
@@ -310,73 +268,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members invoice card -->
|
||||
<div
|
||||
v-if="
|
||||
isActiveSubscription &&
|
||||
!isInPersonalWorkspace &&
|
||||
permissions.canManageSubscription
|
||||
"
|
||||
class="mt-6 flex items-center justify-between gap-1 rounded-2xl border border-interface-stroke p-6 text-sm"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h4 class="m-0 text-sm text-text-primary">
|
||||
{{ $t('subscription.nextMonthInvoice') }}
|
||||
</h4>
|
||||
<span
|
||||
class="cursor-pointer text-muted-foreground underline"
|
||||
@click="manageSubscription"
|
||||
>
|
||||
{{ $t('subscription.invoiceHistory') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<h4 class="m-0 font-bold">${{ nextMonthInvoice }}</h4>
|
||||
<h5 class="m-0 text-muted-foreground">
|
||||
{{ $t('subscription.memberCount', memberCount) }}
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View More Details - Outside main content -->
|
||||
<div
|
||||
v-if="permissions.canManageSubscription"
|
||||
class="flex items-center gap-2 py-6"
|
||||
>
|
||||
<i class="pi pi-external-link text-muted"></i>
|
||||
<a
|
||||
href="https://www.comfy.org/cloud/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-muted underline hover:opacity-80"
|
||||
<div v-if="permissions.canManageSubscription" class="py-6">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="text-sm text-muted"
|
||||
@click="handleViewMoreDetails"
|
||||
>
|
||||
{{ $t('subscription.viewMoreDetailsPlans') }}
|
||||
</a>
|
||||
<i class="pi pi-external-link text-muted" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SubscriptionFooterLinks class="mt-auto pt-6" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Menu from 'primevue/menu'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
|
||||
import SubscriptionFooterLinks from '@/platform/cloud/subscription/components/SubscriptionFooterLinks.vue'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits,
|
||||
getTierPrice
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
@@ -384,13 +311,12 @@ import type { TierBenefit } from '@/platform/cloud/subscription/utils/tierBenefi
|
||||
import { getCommonTierBenefits } from '@/platform/cloud/subscription/utils/tierBenefits'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { isWorkspaceSubscribed, isInPersonalWorkspace, members } =
|
||||
const { isWorkspaceSubscribed, isInPersonalWorkspace } =
|
||||
storeToRefs(workspaceStore)
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { t, n } = useI18n()
|
||||
const { permissions, uiConfig } = useWorkspaceUI()
|
||||
const { t, n, locale } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
@@ -400,15 +326,19 @@ const {
|
||||
isActiveSubscription,
|
||||
isFreeTier: isFreeTierPlan,
|
||||
subscription,
|
||||
teamCreditStops,
|
||||
currentTeamCreditStop,
|
||||
showSubscriptionDialog,
|
||||
manageSubscription,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
getMaxSeats,
|
||||
resubscribe
|
||||
} = useBillingContext()
|
||||
|
||||
const { showCancelSubscriptionDialog } = useDialogService()
|
||||
const {
|
||||
showCancelSubscriptionDialog,
|
||||
showLeaveWorkspaceDialog,
|
||||
showEditWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog
|
||||
} = useDialogService()
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
const isResubscribing = ref(false)
|
||||
@@ -451,6 +381,19 @@ const showSubscribePrompt = computed(() => {
|
||||
return !isWorkspaceSubscribed.value
|
||||
})
|
||||
|
||||
const showTeamSubscribePrompt = computed(
|
||||
() => showSubscribePrompt.value && !isInPersonalWorkspace.value
|
||||
)
|
||||
|
||||
// Personal workspace without subscription renders the Free plan header
|
||||
const isPersonalFree = computed(
|
||||
() => showSubscribePrompt.value && isInPersonalWorkspace.value
|
||||
)
|
||||
|
||||
const isTeamActive = computed(
|
||||
() => !isInPersonalWorkspace.value && isActiveSubscription.value
|
||||
)
|
||||
|
||||
// MEMBER view without subscription - members can't manage subscription
|
||||
const isMemberView = computed(
|
||||
() =>
|
||||
@@ -461,7 +404,7 @@ const isMemberView = computed(
|
||||
|
||||
// Show zero state for credits (no real billing data yet)
|
||||
const showZeroState = computed(
|
||||
() => showSubscribePrompt.value || isMemberView.value
|
||||
() => showTeamSubscribePrompt.value || isMemberView.value
|
||||
)
|
||||
|
||||
// Subscribe workspace - opens the subscription dialog (personal or workspace variant)
|
||||
@@ -474,33 +417,31 @@ function handleUpgrade() {
|
||||
else showSubscriptionDialog()
|
||||
}
|
||||
|
||||
function handleUpgradeToAddCredits() {
|
||||
showPricingTable()
|
||||
function handleViewMoreDetails() {
|
||||
window.open('https://www.comfy.org/cloud/pricing', '_blank')
|
||||
}
|
||||
|
||||
const subscriptionTier = computed(() => subscription.value?.tier ?? null)
|
||||
const isYearlySubscription = computed(
|
||||
() => subscription.value?.duration === 'ANNUAL'
|
||||
)
|
||||
|
||||
const formattedRenewalDate = computed(() => {
|
||||
if (!subscription.value?.renewalDate) return ''
|
||||
const renewalDate = new Date(subscription.value.renewalDate)
|
||||
return renewalDate.toLocaleDateString('en-US', {
|
||||
function formatSubtitleDate(isoDate: string | null | undefined) {
|
||||
if (!isoDate) return ''
|
||||
return new Date(isoDate).toLocaleDateString(locale.value, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const formattedEndDate = computed(() => {
|
||||
if (!subscription.value?.endDate) return ''
|
||||
const endDate = new Date(subscription.value.endDate)
|
||||
return endDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
})
|
||||
const formattedRenewalDate = computed(() =>
|
||||
formatSubtitleDate(subscription.value?.renewalDate)
|
||||
)
|
||||
|
||||
const formattedEndDate = computed(() =>
|
||||
formatSubtitleDate(subscription.value?.endDate)
|
||||
)
|
||||
|
||||
const subscriptionTierName = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
@@ -512,24 +453,90 @@ const subscriptionTierName = computed(() => {
|
||||
: baseName
|
||||
})
|
||||
|
||||
const planMenu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
const planDisplayName = computed(() =>
|
||||
isInPersonalWorkspace.value
|
||||
? subscriptionTierName.value
|
||||
: t('subscription.teamPlanName')
|
||||
)
|
||||
|
||||
// Cancel is original-owner-only (creator); a promoted owner gets no menu items
|
||||
// and the "more options" button is hidden (see template).
|
||||
const planMenuItems = computed(() =>
|
||||
permissions.value.canManageSubscriptionLifecycle
|
||||
? [
|
||||
{
|
||||
label: t('subscription.cancelSubscription'),
|
||||
icon: 'pi pi-times',
|
||||
command: () => {
|
||||
showCancelSubscriptionDialog(
|
||||
subscription.value?.endDate ?? undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
: []
|
||||
// A workspace (team or personal) can't be deleted while its subscription is
|
||||
// active and not yet cancelled — the owner must cancel first.
|
||||
const isDeleteDisabled = computed(
|
||||
() =>
|
||||
isActiveSubscription.value && !(subscription.value?.isCancelled ?? false)
|
||||
)
|
||||
|
||||
const deleteTooltip = computed(() =>
|
||||
isDeleteDisabled.value
|
||||
? t('workspacePanel.menu.deleteWorkspaceDisabledTooltip')
|
||||
: undefined
|
||||
)
|
||||
|
||||
const planMenuItems = computed<MenuItem[]>(() => {
|
||||
const config = uiConfig.value
|
||||
const items: MenuItem[] = []
|
||||
|
||||
if (config.showEditWorkspaceMenuItem) {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.editWorkspace'),
|
||||
command: () => void showEditWorkspaceDialog()
|
||||
})
|
||||
}
|
||||
|
||||
// Cancel plan: the original owner of an active paid plan (team or personal).
|
||||
if (
|
||||
permissions.value.canManageSubscriptionLifecycle &&
|
||||
isActiveSubscription.value &&
|
||||
!isCancelled.value &&
|
||||
!isFreeTierPlan.value
|
||||
) {
|
||||
items.push({
|
||||
label: t('subscription.cancelPlan'),
|
||||
command: () =>
|
||||
showCancelSubscriptionDialog(subscription.value?.endDate ?? undefined)
|
||||
})
|
||||
}
|
||||
|
||||
// Delete: a team's original owner, or a personal owner on a paid plan.
|
||||
if (
|
||||
permissions.value.canManageSubscriptionLifecycle &&
|
||||
(!isInPersonalWorkspace.value || isActiveSubscription.value)
|
||||
) {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.deleteWorkspace'),
|
||||
// Disabled Delete keeps the danger color and stays hoverable so its
|
||||
// "cancel first" tooltip shows (the shared item style would otherwise mute
|
||||
// it and block pointer events).
|
||||
class: isDeleteDisabled.value
|
||||
? 'data-disabled:cursor-not-allowed data-disabled:text-coral-700 data-disabled:pointer-events-auto'
|
||||
: 'text-coral-700',
|
||||
disabled: isDeleteDisabled.value,
|
||||
tooltip: deleteTooltip.value,
|
||||
command: isDeleteDisabled.value
|
||||
? undefined
|
||||
: () => void showDeleteWorkspaceDialog()
|
||||
})
|
||||
}
|
||||
|
||||
// Members and promoted (non-original) owners can leave; the creator cannot.
|
||||
if (
|
||||
!isInPersonalWorkspace.value &&
|
||||
!permissions.value.canManageSubscriptionLifecycle
|
||||
) {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.leaveWorkspace'),
|
||||
command: () => void showLeaveWorkspaceDialog()
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
// Figma 3343-25140 renders a divider between every menu option.
|
||||
const planMenuEntries = computed<MenuItem[]>(() =>
|
||||
planMenuItems.value.flatMap((item, index) =>
|
||||
index === 0 ? [item] : [{ separator: true }, item]
|
||||
)
|
||||
)
|
||||
|
||||
const tierKey = computed(() => {
|
||||
@@ -541,101 +548,61 @@ const tierPrice = computed(() =>
|
||||
getTierPrice(tierKey.value, isYearlySubscription.value)
|
||||
)
|
||||
|
||||
const memberCount = computed(() => members.value.length)
|
||||
const nextMonthInvoice = computed(() => memberCount.value * tierPrice.value)
|
||||
const freeTierPrice = getTierPrice('free')
|
||||
|
||||
const refillsDate = computed(() => {
|
||||
if (!subscription.value?.renewalDate) return ''
|
||||
const date = new Date(subscription.value.renewalDate)
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = String(date.getFullYear()).slice(-2)
|
||||
return `${month}/${day}/${year}`
|
||||
})
|
||||
|
||||
const creditsRemainingLabel = computed(() =>
|
||||
isYearlySubscription.value
|
||||
? t(
|
||||
'subscription.creditsRemainingThisYear',
|
||||
{
|
||||
date: refillsDate.value
|
||||
},
|
||||
{
|
||||
escapeParameter: false
|
||||
}
|
||||
)
|
||||
: t(
|
||||
'subscription.creditsRemainingThisMonth',
|
||||
{
|
||||
date: refillsDate.value
|
||||
},
|
||||
{
|
||||
escapeParameter: false
|
||||
}
|
||||
)
|
||||
const billingCycleKey = computed(() =>
|
||||
subscription.value?.duration === 'ANNUAL' ? 'yearly' : 'monthly'
|
||||
)
|
||||
|
||||
const planTotalCredits = computed(() => {
|
||||
const credits = getTierCredits(tierKey.value)
|
||||
if (credits === null) return '—'
|
||||
const total = isYearlySubscription.value ? credits * 12 : credits
|
||||
return n(total)
|
||||
const currentStop = computed(() => {
|
||||
const id = currentTeamCreditStop.value?.id
|
||||
const stops = teamCreditStops.value?.stops
|
||||
if (!id || !stops) return null
|
||||
return stops.find((stop) => stop.id === id) ?? null
|
||||
})
|
||||
|
||||
const includedCreditsDisplay = computed(
|
||||
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
|
||||
// Team price is the subscribed credit stop's per-month price (both monthly and
|
||||
// yearly stops are per-month); seats no longer drive team pricing. Falls back to
|
||||
// the per-member tier price until stops resolve.
|
||||
const workspacePlanCost = computed(() => {
|
||||
const stop = currentStop.value
|
||||
if (!stop) return null
|
||||
return (stop[billingCycleKey.value].price_cents / 100).toFixed(0)
|
||||
})
|
||||
const displayPrice = computed(() => workspacePlanCost.value ?? tierPrice.value)
|
||||
const priceUnitLabel = computed(() =>
|
||||
workspacePlanCost.value !== null || isInPersonalWorkspace.value
|
||||
? t('subscription.usdPerMonth')
|
||||
: t('subscription.usdPerMonthPerMember')
|
||||
)
|
||||
|
||||
const TEAM_PERK_KEYS = [
|
||||
'inviteMembers',
|
||||
'concurrentRuns',
|
||||
'sharedCreditPool',
|
||||
'rolePermissions'
|
||||
] as const
|
||||
|
||||
const tierBenefits = computed((): TierBenefit[] => {
|
||||
const key = tierKey.value
|
||||
const benefits: TierBenefit[] = []
|
||||
|
||||
if (!isInPersonalWorkspace.value) {
|
||||
benefits.push({
|
||||
key: 'members',
|
||||
type: 'icon',
|
||||
label: t('subscription.membersLabel', { count: getMaxSeats(key) }),
|
||||
icon: 'pi pi-user'
|
||||
})
|
||||
if (isTeamActive.value) {
|
||||
return TEAM_PERK_KEYS.map((key) => ({
|
||||
key,
|
||||
type: 'feature',
|
||||
label: t(`subscription.teamPerks.${key}`)
|
||||
}))
|
||||
}
|
||||
|
||||
benefits.push(...getCommonTierBenefits(key, t, n))
|
||||
return benefits
|
||||
})
|
||||
|
||||
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||
useSubscriptionCredits()
|
||||
|
||||
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
|
||||
|
||||
// Focus-based polling: refresh balance when user returns from Stripe checkout
|
||||
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
|
||||
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
function handleWindowFocus() {
|
||||
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
|
||||
if (!timestampStr) return
|
||||
|
||||
const timestamp = parseInt(timestampStr, 10)
|
||||
|
||||
// Clear expired tracking (older than 5 minutes)
|
||||
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
return
|
||||
if (isPersonalFree.value) {
|
||||
return [
|
||||
{
|
||||
key: 'maxRuntime',
|
||||
type: 'feature',
|
||||
label: t('subscription.freePerks.maxRuntime', {
|
||||
duration: t('subscription.maxDuration.free')
|
||||
})
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Refresh and clear tracking to prevent repeated calls
|
||||
void handleRefresh()
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
void Promise.all([fetchStatus(), fetchBalance()])
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
return getCommonTierBenefits(tierKey.value, t, n)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SubscriptionRequiredDialogContentUnified from './SubscriptionRequiredDialogContentUnified.vue'
|
||||
|
||||
const mockHandleSubscribeTeamClick = vi.fn()
|
||||
const mockIsInPersonalWorkspace = ref(false)
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
|
||||
useSubscriptionCheckout: () => ({
|
||||
checkoutStep: ref('pricing'),
|
||||
isLoadingPreview: ref(false),
|
||||
loadingTier: ref(null),
|
||||
isSubscribing: ref(false),
|
||||
isResubscribing: ref(false),
|
||||
previewData: ref(null),
|
||||
selectedTierKey: ref(null),
|
||||
selectedTeamStop: ref(null),
|
||||
selectedBillingCycle: ref('yearly'),
|
||||
isPolling: ref(false),
|
||||
isTeamCheckout: computed(() => false),
|
||||
handleSubscribeClick: vi.fn(),
|
||||
handleSubscribeTeamClick: mockHandleSubscribeTeamClick,
|
||||
handleBackToPricing: vi.fn(),
|
||||
handleSuccessClose: vi.fn(),
|
||||
handleAddCreditCard: vi.fn(),
|
||||
handleConfirmTransition: vi.fn(),
|
||||
handleTeamSubscribe: vi.fn(),
|
||||
handleResubscribe: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
get isInPersonalWorkspace() {
|
||||
return mockIsInPersonalWorkspace.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { back: 'Back', close: 'Close' },
|
||||
subscription: { descriptionWorkspace: 'Choose your plan' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const TEAM_PAYLOAD = {
|
||||
stop: { id: 'stop_1', usd: 700, credits: 70000, discountedUsd: 560 },
|
||||
billingCycle: 'yearly'
|
||||
}
|
||||
|
||||
const UnifiedPricingTableStub = {
|
||||
name: 'UnifiedPricingTable',
|
||||
emits: ['subscribeTeam'],
|
||||
template: `<div>
|
||||
<button data-testid="subscribe-team-btn" @click="$emit('subscribeTeam', payload)">Team</button>
|
||||
</div>`,
|
||||
setup() {
|
||||
return { payload: TEAM_PAYLOAD }
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent(props: Record<string, unknown> = {}) {
|
||||
return render(SubscriptionRequiredDialogContentUnified, {
|
||||
props: { onClose: vi.fn(), ...props },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
UnifiedPricingTable: UnifiedPricingTableStub,
|
||||
SubscriptionAddPaymentPreviewWorkspace: { template: '<div />' },
|
||||
SubscriptionTransitionPreviewWorkspace: { template: '<div />' },
|
||||
SubscriptionSuccessWorkspace: { template: '<div />' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SubscriptionRequiredDialogContentUnified team-plan subscribe', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
})
|
||||
|
||||
it('advances to team checkout from a team workspace', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByTestId('subscribe-team-btn'))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockHandleSubscribeTeamClick).toHaveBeenCalledWith(TEAM_PAYLOAD)
|
||||
})
|
||||
})
|
||||
|
||||
it('advances to team checkout from a personal workspace (no reroute)', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByTestId('subscribe-team-btn'))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockHandleSubscribeTeamClick).toHaveBeenCalledWith(TEAM_PAYLOAD)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -44,9 +44,11 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Table Step (unified: personal/team plan toggle) -->
|
||||
<!-- Pricing Table Step. v-show (not v-if) keeps it mounted so the plan,
|
||||
billing cycle, and credit-stop selection survive a round trip to the
|
||||
confirm step and back. -->
|
||||
<UnifiedPricingTable
|
||||
v-if="checkoutStep === 'pricing'"
|
||||
v-show="checkoutStep === 'pricing'"
|
||||
class="xl:flex-1"
|
||||
:initial-plan-mode="initialPlanMode"
|
||||
:is-loading="isLoadingPreview || isResubscribing"
|
||||
@@ -56,48 +58,48 @@
|
||||
@subscribe-team="handleSubscribeTeamClick"
|
||||
/>
|
||||
|
||||
<!-- Subscription Preview Step - New Subscription -->
|
||||
<SubscriptionAddPaymentPreviewWorkspace
|
||||
v-else-if="
|
||||
checkoutStep === 'preview' &&
|
||||
previewData &&
|
||||
previewData.transition_type === 'new_subscription'
|
||||
"
|
||||
:preview-data="previewData"
|
||||
:tier-key="selectedTierKey!"
|
||||
:billing-cycle="selectedBillingCycle"
|
||||
:is-loading="isSubscribing || isPolling"
|
||||
@add-credit-card="handleAddCreditCard"
|
||||
@back="handleBackToPricing"
|
||||
/>
|
||||
<template v-if="checkoutStep === 'preview'">
|
||||
<!-- New Subscription -->
|
||||
<SubscriptionAddPaymentPreviewWorkspace
|
||||
v-if="previewData && previewData.transition_type === 'new_subscription'"
|
||||
:preview-data="previewData"
|
||||
:tier-key="selectedTierKey!"
|
||||
:billing-cycle="selectedBillingCycle"
|
||||
:is-loading="isSubscribing || isPolling"
|
||||
@add-credit-card="handleAddCreditCard"
|
||||
@back="handleBackToPricing"
|
||||
/>
|
||||
|
||||
<!-- Subscription Preview Step - Plan Transition -->
|
||||
<SubscriptionTransitionPreviewWorkspace
|
||||
v-else-if="
|
||||
checkoutStep === 'preview' &&
|
||||
previewData &&
|
||||
previewData.transition_type !== 'new_subscription'
|
||||
"
|
||||
:preview-data="previewData"
|
||||
:is-loading="isSubscribing || isPolling"
|
||||
@confirm="handleConfirmTransition"
|
||||
@back="handleBackToPricing"
|
||||
/>
|
||||
<!-- Plan Transition -->
|
||||
<SubscriptionTransitionPreviewWorkspace
|
||||
v-else-if="
|
||||
previewData && previewData.transition_type !== 'new_subscription'
|
||||
"
|
||||
:preview-data="previewData"
|
||||
:is-loading="isSubscribing || isPolling"
|
||||
@confirm="handleConfirmTransition"
|
||||
@back="handleBackToPricing"
|
||||
/>
|
||||
|
||||
<!-- Subscription Preview Step - Team (display-only until the BE slider
|
||||
contract lands; the confirm CTA is stubbed below) -->
|
||||
<SubscriptionAddPaymentPreviewWorkspace
|
||||
v-else-if="checkoutStep === 'preview' && selectedTeamStop"
|
||||
:team-plan="selectedTeamStop"
|
||||
@add-credit-card="handleTeamSubscribe"
|
||||
@back="handleBackToPricing"
|
||||
/>
|
||||
<!-- Team (display-only confirm; the slider stop and active billing cycle
|
||||
drive the real subscribe). -->
|
||||
<SubscriptionAddPaymentPreviewWorkspace
|
||||
v-else-if="selectedTeamStop"
|
||||
:team-plan="selectedTeamStop"
|
||||
:billing-cycle="selectedBillingCycle"
|
||||
:is-loading="isSubscribing || isPolling"
|
||||
@add-credit-card="handleTeamSubscribe"
|
||||
@back="handleBackToPricing"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Success Step - "You're all set" -->
|
||||
<SubscriptionSuccessWorkspace
|
||||
v-else-if="checkoutStep === 'success' && selectedTierKey"
|
||||
v-if="checkoutStep === 'success' && (selectedTierKey || isTeamCheckout)"
|
||||
:tier-key="selectedTierKey"
|
||||
:team-plan="selectedTeamStop"
|
||||
:preview-data="previewData"
|
||||
:is-team="isTeamCheckout"
|
||||
@close="handleSuccessClose"
|
||||
/>
|
||||
</div>
|
||||
@@ -105,8 +107,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
@@ -127,9 +128,6 @@ const emit = defineEmits<{
|
||||
close: [subscribed: boolean]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const {
|
||||
checkoutStep,
|
||||
isLoadingPreview,
|
||||
@@ -141,26 +139,31 @@ const {
|
||||
selectedTeamStop,
|
||||
selectedBillingCycle,
|
||||
isPolling,
|
||||
isTeamCheckout,
|
||||
handleSubscribeClick,
|
||||
handleSubscribeTeamClick,
|
||||
handleBackToPricing,
|
||||
handleSuccessClose,
|
||||
handleAddCreditCard,
|
||||
handleConfirmTransition,
|
||||
handleTeamSubscribe,
|
||||
handleResubscribe
|
||||
} = useSubscriptionCheckout(emit)
|
||||
|
||||
// Personal-tier checkout reuses the full useSubscriptionCheckout flow above.
|
||||
// Team-plan checkout renders the confirm step from the selected slider stop,
|
||||
// but the final subscribe is blocked on the BE discount-breakpoint contract
|
||||
// (FE-934 / doc Open Q#2: the slider stop -> plan-slug / subscribe-request shape
|
||||
// is undefined), so the confirm CTA is stubbed until that lands.
|
||||
function handleTeamSubscribe() {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('subscription.teamPlan.name'),
|
||||
detail: t('subscription.teamPlan.checkoutComingSoon'),
|
||||
life: 4000
|
||||
})
|
||||
}
|
||||
// Backspace mirrors the back arrow on the confirm step, but never while an
|
||||
// editable element is focused (let it delete text there).
|
||||
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Backspace' || checkoutStep.value !== 'preview') return
|
||||
const target = event.target
|
||||
if (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
(target instanceof HTMLElement && target.isContentEditable)
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
handleBackToPricing()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
@@ -17,6 +17,7 @@ const mockHandleResubscribe = vi.fn()
|
||||
const mockHandleSuccessClose = vi.fn()
|
||||
const mockCheckoutStep = ref<'pricing' | 'preview' | 'success'>('pricing')
|
||||
const mockPreviewData = ref<{ transition_type: string } | null>(null)
|
||||
const mockCanManageSubscription = ref(true)
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
|
||||
useSubscriptionCheckout: () => ({
|
||||
@@ -38,6 +39,14 @@ vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: computed(() => ({
|
||||
canManageSubscription: mockCanManageSubscription.value
|
||||
}))
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -46,7 +55,12 @@ const i18n = createI18n({
|
||||
g: { back: 'Back', close: 'Close' },
|
||||
subscription: {
|
||||
plansForWorkspace: 'Plans for {workspace}',
|
||||
teamWorkspace: 'Team'
|
||||
teamWorkspace: 'Team',
|
||||
inactive: {
|
||||
memberTitle: 'This workspace is paused',
|
||||
memberDescription:
|
||||
'Contact your workspace owner to resubscribe and continue running workflows.'
|
||||
}
|
||||
},
|
||||
credits: {
|
||||
topUp: {
|
||||
@@ -115,6 +129,7 @@ describe('SubscriptionRequiredDialogContentWorkspace', () => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckoutStep.value = 'pricing'
|
||||
mockPreviewData.value = null
|
||||
mockCanManageSubscription.value = true
|
||||
})
|
||||
|
||||
it('shows pricing table on pricing step', () => {
|
||||
@@ -206,20 +221,67 @@ describe('SubscriptionRequiredDialogContentWorkspace', () => {
|
||||
expect(mockHandleBackToPricing).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the success screen on the success step', () => {
|
||||
mockCheckoutStep.value = 'success'
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('success')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('pricing-table')).not.toBeInTheDocument()
|
||||
describe('member (cannot manage subscription)', () => {
|
||||
beforeEach(() => {
|
||||
mockCanManageSubscription.value = false
|
||||
})
|
||||
|
||||
it('shows the contact-owner resubscribe message instead of the pricing table', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByTestId('member-resubscribe-message')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Contact your workspace owner to resubscribe and continue running workflows.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not expose any subscribe affordance to members', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.queryByTestId('pricing-table')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('subscribe-btn')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('resubscribe-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to the credits flow when the reason is out_of_credits', () => {
|
||||
renderComponent({ reason: 'out_of_credits' })
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('member-resubscribe-message')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Insufficient Credits')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('wires the success close event to handleSuccessClose', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCheckoutStep.value = 'success'
|
||||
renderComponent()
|
||||
describe('owner (can manage subscription)', () => {
|
||||
it('shows the pricing table and hides the member message', () => {
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByTestId('success-close-btn'))
|
||||
expect(screen.getByTestId('pricing-table')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('member-resubscribe-message')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(mockHandleSuccessClose).toHaveBeenCalled()
|
||||
it('shows the success screen on the success step', () => {
|
||||
mockCheckoutStep.value = 'success'
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('success')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('pricing-table')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('wires the success close event to handleSuccessClose', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCheckoutStep.value = 'success'
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByTestId('success-close-btn'))
|
||||
|
||||
expect(mockHandleSuccessClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class="relative flex h-full flex-col gap-6 overflow-y-auto p-4 pt-8 md:px-16 md:py-8"
|
||||
>
|
||||
<Button
|
||||
v-if="checkoutStep === 'preview'"
|
||||
v-if="checkoutStep === 'preview' && !isMemberView"
|
||||
size="icon"
|
||||
variant="muted-textonly"
|
||||
class="absolute top-2.5 left-2.5 shrink-0 rounded-full text-text-secondary hover:bg-white/10"
|
||||
@@ -23,88 +23,112 @@
|
||||
<i class="pi pi-times text-xl" />
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<!-- Decorative initial for "Team" workspace icon; not user-facing text -->
|
||||
<div
|
||||
v-if="isMemberView"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-4 text-center"
|
||||
data-testid="member-resubscribe-message"
|
||||
>
|
||||
<div
|
||||
class="flex size-10 items-center justify-center rounded-xl bg-primary-background text-lg font-semibold text-white"
|
||||
class="flex size-12 items-center justify-center rounded-full bg-warning-background/20 text-warning-background"
|
||||
aria-hidden="true"
|
||||
>
|
||||
T
|
||||
<i class="pi pi-lock text-2xl" />
|
||||
</div>
|
||||
<i18n-t
|
||||
keypath="subscription.plansForWorkspace"
|
||||
tag="h2"
|
||||
class="m-0 font-inter text-2xl font-semibold text-base-foreground"
|
||||
>
|
||||
<template #workspace>
|
||||
<span class="text-emerald-400">
|
||||
{{ $t('subscription.teamWorkspace') }}
|
||||
</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<div v-if="reason === 'out_of_credits'" class="text-center">
|
||||
<h2 class="m-0 text-xl text-muted-foreground lg:text-2xl">
|
||||
{{ $t('credits.topUp.insufficientTitle') }}
|
||||
<h2 class="m-0 font-inter text-2xl font-semibold text-base-foreground">
|
||||
{{ $t('subscription.inactive.memberTitle') }}
|
||||
</h2>
|
||||
<p class="m-0 mt-2 text-sm text-text-secondary">
|
||||
{{ $t('credits.topUp.insufficientMessage') }}
|
||||
<p class="m-0 max-w-md text-sm text-text-secondary">
|
||||
{{ $t('subscription.inactive.memberDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Table Step -->
|
||||
<PricingTableWorkspace
|
||||
v-if="checkoutStep === 'pricing'"
|
||||
class="flex-1"
|
||||
:is-loading="isLoadingPreview || isResubscribing"
|
||||
:loading-tier="loadingTier"
|
||||
@subscribe="handleSubscribeClick"
|
||||
@resubscribe="handleResubscribe"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<!-- Decorative initial for "Team" workspace icon; not user-facing text -->
|
||||
<div
|
||||
class="flex size-10 items-center justify-center rounded-xl bg-primary-background text-lg font-semibold text-white"
|
||||
aria-hidden="true"
|
||||
>
|
||||
T
|
||||
</div>
|
||||
<i18n-t
|
||||
keypath="subscription.plansForWorkspace"
|
||||
tag="h2"
|
||||
class="m-0 font-inter text-2xl font-semibold text-base-foreground"
|
||||
>
|
||||
<template #workspace>
|
||||
<span class="text-emerald-400">
|
||||
{{ $t('subscription.teamWorkspace') }}
|
||||
</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<!-- Subscription Preview Step - New Subscription -->
|
||||
<SubscriptionAddPaymentPreviewWorkspace
|
||||
v-else-if="
|
||||
checkoutStep === 'preview' &&
|
||||
previewData &&
|
||||
previewData.transition_type === 'new_subscription'
|
||||
"
|
||||
:preview-data="previewData"
|
||||
:tier-key="selectedTierKey!"
|
||||
:billing-cycle="selectedBillingCycle"
|
||||
:is-loading="isSubscribing || isPolling"
|
||||
@add-credit-card="handleAddCreditCard"
|
||||
@back="handleBackToPricing"
|
||||
/>
|
||||
<div v-if="reason === 'out_of_credits'" class="text-center">
|
||||
<h2 class="m-0 text-xl text-muted-foreground lg:text-2xl">
|
||||
{{ $t('credits.topUp.insufficientTitle') }}
|
||||
</h2>
|
||||
<p class="m-0 mt-2 text-sm text-text-secondary">
|
||||
{{ $t('credits.topUp.insufficientMessage') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Subscription Preview Step - Plan Transition -->
|
||||
<SubscriptionTransitionPreviewWorkspace
|
||||
v-else-if="
|
||||
checkoutStep === 'preview' &&
|
||||
previewData &&
|
||||
previewData.transition_type !== 'new_subscription'
|
||||
"
|
||||
:preview-data="previewData"
|
||||
:is-loading="isSubscribing || isPolling"
|
||||
@confirm="handleConfirmTransition"
|
||||
@back="handleBackToPricing"
|
||||
/>
|
||||
<!-- Pricing Table Step -->
|
||||
<PricingTableWorkspace
|
||||
v-if="checkoutStep === 'pricing'"
|
||||
class="flex-1"
|
||||
:is-loading="isLoadingPreview || isResubscribing"
|
||||
:loading-tier="loadingTier"
|
||||
@subscribe="handleSubscribeClick"
|
||||
@resubscribe="handleResubscribe"
|
||||
/>
|
||||
|
||||
<!-- Success Step - subscribe/change-plan confirmation -->
|
||||
<SubscriptionSuccessWorkspace
|
||||
v-else-if="checkoutStep === 'success' && selectedTierKey"
|
||||
:tier-key="selectedTierKey"
|
||||
:preview-data="previewData"
|
||||
@close="handleSuccessClose"
|
||||
/>
|
||||
<!-- Subscription Preview Step - New Subscription -->
|
||||
<SubscriptionAddPaymentPreviewWorkspace
|
||||
v-else-if="
|
||||
checkoutStep === 'preview' &&
|
||||
previewData &&
|
||||
previewData.transition_type === 'new_subscription'
|
||||
"
|
||||
:preview-data="previewData"
|
||||
:tier-key="selectedTierKey!"
|
||||
:billing-cycle="selectedBillingCycle"
|
||||
:is-loading="isSubscribing || isPolling"
|
||||
@add-credit-card="handleAddCreditCard"
|
||||
@back="handleBackToPricing"
|
||||
/>
|
||||
|
||||
<!-- Subscription Preview Step - Plan Transition -->
|
||||
<SubscriptionTransitionPreviewWorkspace
|
||||
v-else-if="
|
||||
checkoutStep === 'preview' &&
|
||||
previewData &&
|
||||
previewData.transition_type !== 'new_subscription'
|
||||
"
|
||||
:preview-data="previewData"
|
||||
:is-loading="isSubscribing || isPolling"
|
||||
@confirm="handleConfirmTransition"
|
||||
@back="handleBackToPricing"
|
||||
/>
|
||||
|
||||
<!-- Success Step - subscribe/change-plan confirmation -->
|
||||
<SubscriptionSuccessWorkspace
|
||||
v-else-if="checkoutStep === 'success' && selectedTierKey"
|
||||
:tier-key="selectedTierKey"
|
||||
:preview-data="previewData"
|
||||
@close="handleSuccessClose"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
|
||||
import PricingTableWorkspace from './PricingTableWorkspace.vue'
|
||||
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
|
||||
@@ -116,6 +140,12 @@ const { onClose, reason } = defineProps<{
|
||||
reason?: SubscriptionDialogReason
|
||||
}>()
|
||||
|
||||
const { permissions } = useWorkspaceUI()
|
||||
|
||||
const isMemberView = computed(
|
||||
() => !permissions.value.canManageSubscription && reason !== 'out_of_credits'
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [subscribed: boolean]
|
||||
}>()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
import { MAX_WORKSPACE_MEMBERS } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import SubscriptionSuccessWorkspace from './SubscriptionSuccessWorkspace.vue'
|
||||
|
||||
@@ -13,36 +14,43 @@ vi.mock('vue-i18n', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
function makePreviewData(priceCents: number): PreviewSubscribeResponse {
|
||||
return {
|
||||
allowed: true,
|
||||
transition_type: 'new_subscription',
|
||||
effective_at: '2026-07-10T00:00:00Z',
|
||||
is_immediate: true,
|
||||
cost_today_cents: priceCents,
|
||||
cost_next_period_cents: priceCents,
|
||||
credits_today_cents: 0,
|
||||
credits_next_period_cents: 0,
|
||||
new_plan: {
|
||||
slug: 'standard-monthly',
|
||||
tier: 'STANDARD',
|
||||
duration: 'MONTHLY',
|
||||
price_cents: priceCents,
|
||||
credits_cents: 0,
|
||||
seat_summary: {
|
||||
seat_count: 1,
|
||||
total_cost_cents: priceCents,
|
||||
total_credits_cents: 0
|
||||
}
|
||||
}
|
||||
// Provide just the seat cap so the component import doesn't drag the team store's
|
||||
// i18n/app chain into this unit test.
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
MAX_WORKSPACE_MEMBERS: 30
|
||||
}))
|
||||
|
||||
const { mockFlags } = vi.hoisted(() => ({
|
||||
mockFlags: { teamWorkspacesEnabled: true }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock('./InviteMembersForm.vue', () => ({
|
||||
default: {
|
||||
name: 'InviteMembersForm',
|
||||
props: ['maxSeats', 'source', 'submitLabel', 'placeholder'],
|
||||
template: '<div data-testid="invite-form">seats:{{ maxSeats }}</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
const TEAM_STOP = {
|
||||
id: 'team_700',
|
||||
usd: 700,
|
||||
credits: 147_700,
|
||||
discountedUsd: 630
|
||||
}
|
||||
|
||||
function renderCard() {
|
||||
function renderCard(props: Record<string, unknown> = {}) {
|
||||
return render(SubscriptionSuccessWorkspace, {
|
||||
props: {
|
||||
tierKey: 'standard',
|
||||
previewData: makePreviewData(1600)
|
||||
tierKey: 'creator',
|
||||
previewData: {
|
||||
new_plan: { price_cents: 1600 }
|
||||
} as unknown as PreviewSubscribeResponse,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
mocks: { $t: (key: string) => key },
|
||||
@@ -55,16 +63,72 @@ function renderCard() {
|
||||
})
|
||||
}
|
||||
|
||||
function renderTeamCard(props: Record<string, unknown> = {}) {
|
||||
return renderCard({
|
||||
tierKey: null,
|
||||
teamPlan: TEAM_STOP,
|
||||
isTeam: true,
|
||||
...props
|
||||
})
|
||||
}
|
||||
|
||||
describe('SubscriptionSuccessWorkspace', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders the all-set heading and plan price', () => {
|
||||
renderCard()
|
||||
expect(screen.getByText('subscription.success.allSet')).toBeTruthy()
|
||||
expect(screen.getByText('$16')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the team plan summary from the selected stop', () => {
|
||||
renderTeamCard()
|
||||
expect(screen.getByText('subscription.teamPlan.name')).toBeTruthy()
|
||||
expect(screen.getByText('$630')).toBeTruthy()
|
||||
expect(screen.getByText(/147700/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits close when the close button is clicked', async () => {
|
||||
const { emitted } = renderCard()
|
||||
const { emitted } = renderCard({ isTeam: false })
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
expect(emitted().close).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the invite block capped at the workspace member limit', () => {
|
||||
renderTeamCard()
|
||||
expect(screen.getByText('subscription.success.inviteTitle')).toBeTruthy()
|
||||
// The buyer holds one of the flat team-member seats, so the rest are invitable.
|
||||
expect(screen.getByTestId('invite-form')).toHaveTextContent(
|
||||
`seats:${MAX_WORKSPACE_MEMBERS - 1}`
|
||||
)
|
||||
})
|
||||
|
||||
it('places the Send invites action in the footer for a team upgrade', () => {
|
||||
renderTeamCard()
|
||||
expect(screen.getByText('subscription.success.sendInvites')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows no Send invites action for a personal upgrade', () => {
|
||||
renderCard({ isTeam: false })
|
||||
expect(screen.queryByText('subscription.success.sendInvites')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not render the invite block for a personal upgrade', () => {
|
||||
renderCard({ isTeam: false })
|
||||
expect(screen.queryByText('subscription.success.inviteTitle')).toBeNull()
|
||||
expect(screen.queryByTestId('invite-form')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the invite block when team workspaces are disabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
renderTeamCard()
|
||||
expect(screen.queryByTestId('invite-form')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class="mx-auto flex h-full max-w-[400px] flex-col items-stretch justify-between text-sm"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4 pt-8">
|
||||
<i class="pi pi-check-circle text-success-foreground text-5xl" />
|
||||
<i class="pi pi-check-circle text-5xl text-success-background" />
|
||||
<h2
|
||||
class="m-0 text-center text-xl font-semibold text-base-foreground lg:text-2xl"
|
||||
>
|
||||
@@ -33,10 +33,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team success "Invite your team" block renders here (FE-965 / DES-394). -->
|
||||
<div v-if="showInviteBlock" class="mt-4 flex w-full flex-col gap-2">
|
||||
<h3 class="m-0 text-base font-semibold text-base-foreground">
|
||||
{{ $t('subscription.success.inviteTitle') }}
|
||||
</h3>
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('subscription.success.inviteSubtext') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="invitedEmails.length > 0"
|
||||
class="text-success-foreground m-0 text-sm"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
'workspacePanel.inviteMemberDialog.invitedMessage',
|
||||
{ emails: invitedEmails.join(', ') },
|
||||
invitedEmails.length
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<InviteMembersForm
|
||||
v-else
|
||||
ref="inviteForm"
|
||||
:show-submit="false"
|
||||
source="post_upgrade_success"
|
||||
:submit-label="$t('subscription.success.sendInvites')"
|
||||
:placeholder="$t('subscription.success.inviteEmailsPlaceholder')"
|
||||
:max-seats="invitableSeats"
|
||||
@submitted="onInvited"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 pt-8">
|
||||
<Button
|
||||
v-if="showInviteBlock && invitedEmails.length === 0"
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
class="w-full rounded-lg"
|
||||
:disabled="!canSendInvites"
|
||||
:loading="isSendingInvites"
|
||||
@click="handleSendInvites"
|
||||
>
|
||||
{{ $t('subscription.success.sendInvites') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@@ -50,17 +90,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
import { MAX_WORKSPACE_MEMBERS } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
const { tierKey, previewData = null } = defineProps<{
|
||||
tierKey: Exclude<TierKey, 'free' | 'founder'>
|
||||
import InviteMembersForm from './InviteMembersForm.vue'
|
||||
|
||||
const {
|
||||
tierKey,
|
||||
previewData = null,
|
||||
teamPlan = null,
|
||||
isTeam = false
|
||||
} = defineProps<{
|
||||
tierKey?: Exclude<TierKey, 'free' | 'founder'> | null
|
||||
previewData?: PreviewSubscribeResponse | null
|
||||
teamPlan?: TeamPlanSelection | null
|
||||
isTeam?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
@@ -68,14 +120,42 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t, n } = useI18n()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const tierName = computed(() => t(`subscription.tiers.${tierKey}.name`))
|
||||
|
||||
const displayPrice = computed(() =>
|
||||
previewData?.new_plan
|
||||
? (previewData.new_plan.price_cents / 100).toFixed(0)
|
||||
: '0'
|
||||
const tierName = computed(() =>
|
||||
teamPlan
|
||||
? t('subscription.teamPlan.name')
|
||||
: t(`subscription.tiers.${tierKey}.name`)
|
||||
)
|
||||
|
||||
const displayCredits = computed(() => n(getTierCredits(tierKey) ?? 0))
|
||||
const displayPrice = computed(() => {
|
||||
if (teamPlan) return String(teamPlan.discountedUsd)
|
||||
return previewData?.new_plan
|
||||
? (previewData.new_plan.price_cents / 100).toFixed(0)
|
||||
: '0'
|
||||
})
|
||||
|
||||
const displayCredits = computed(() =>
|
||||
n(teamPlan ? teamPlan.credits : tierKey ? (getTierCredits(tierKey) ?? 0) : 0)
|
||||
)
|
||||
|
||||
// A team plan caps members at a flat MAX_WORKSPACE_MEMBERS; the buyer already
|
||||
// holds one seat post-upgrade, so invites fill the rest.
|
||||
const invitableSeats = computed(() => MAX_WORKSPACE_MEMBERS - 1)
|
||||
|
||||
const showInviteBlock = computed(() => isTeam && flags.teamWorkspacesEnabled)
|
||||
|
||||
const invitedEmails = ref<string[]>([])
|
||||
|
||||
const inviteForm = ref<InstanceType<typeof InviteMembersForm>>()
|
||||
const canSendInvites = computed(() => inviteForm.value?.canSubmit ?? false)
|
||||
const isSendingInvites = computed(() => inviteForm.value?.loading ?? false)
|
||||
|
||||
function handleSendInvites() {
|
||||
void inviteForm.value?.submit()
|
||||
}
|
||||
|
||||
function onInvited(emails: string[]) {
|
||||
invitedEmails.value = emails
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
PreviewSubscribeResponse,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
n: (value: number) => value.toLocaleString('en-US')
|
||||
})
|
||||
}))
|
||||
|
||||
const globalOptions = {
|
||||
mocks: { $t: (key: string) => key },
|
||||
stubs: {
|
||||
SubscriptionTermsNote: { template: '<div />' },
|
||||
Button: { template: '<button @click="$emit(\'click\')"><slot /></button>' }
|
||||
}
|
||||
}
|
||||
|
||||
function plan(
|
||||
tier: SubscriptionTier,
|
||||
duration: SubscriptionDuration,
|
||||
priceCents: number
|
||||
) {
|
||||
return {
|
||||
slug: `${tier.toLowerCase()}-${duration.toLowerCase()}`,
|
||||
tier,
|
||||
duration,
|
||||
price_cents: priceCents,
|
||||
credits_cents: 0,
|
||||
seat_summary: {
|
||||
seat_count: 1,
|
||||
total_cost_cents: priceCents,
|
||||
total_credits_cents: 0
|
||||
},
|
||||
period_end: '2027-06-28T00:00:00Z'
|
||||
}
|
||||
}
|
||||
|
||||
function preview(
|
||||
overrides: Partial<PreviewSubscribeResponse>
|
||||
): PreviewSubscribeResponse {
|
||||
return {
|
||||
allowed: true,
|
||||
transition_type: 'upgrade',
|
||||
effective_at: '2026-06-19T00:00:00Z',
|
||||
is_immediate: true,
|
||||
cost_today_cents: 0,
|
||||
cost_next_period_cents: 0,
|
||||
credits_today_cents: 0,
|
||||
credits_next_period_cents: 0,
|
||||
new_plan: plan('CREATOR', 'MONTHLY', 3500),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('SubscriptionTransitionPreviewWorkspace', () => {
|
||||
it('renders an immediate yearly upgrade with proration and upfront credits', () => {
|
||||
render(SubscriptionTransitionPreviewWorkspace, {
|
||||
props: {
|
||||
previewData: preview({
|
||||
transition_type: 'duration_change',
|
||||
is_immediate: true,
|
||||
cost_today_cents: 31_850,
|
||||
current_plan: plan('CREATOR', 'MONTHLY', 3500),
|
||||
new_plan: plan('CREATOR', 'ANNUAL', 33_600)
|
||||
})
|
||||
},
|
||||
global: globalOptions
|
||||
})
|
||||
expect(
|
||||
screen.getByText('subscription.preview.confirmUpgradeTitle')
|
||||
).toBeTruthy()
|
||||
expect(screen.getByText('$28')).toBeTruthy()
|
||||
expect(screen.getByText('subscription.billedYearly')).toBeTruthy()
|
||||
expect(screen.getByText('subscription.preview.switchesToday')).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText('subscription.preview.yearlySubscription')
|
||||
).toBeTruthy()
|
||||
expect(screen.getByText('$336.00')).toBeTruthy()
|
||||
expect(screen.getByText('− $17.50')).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText('subscription.preview.creditsYoullGetToday')
|
||||
).toBeTruthy()
|
||||
expect(screen.getByText('88,800')).toBeTruthy()
|
||||
expect(screen.getByText('$318.50')).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText('subscription.preview.confirmUpgradeCta')
|
||||
).toBeTruthy()
|
||||
expect(screen.queryByText('subscription.preview.startsOn')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders an immediate monthly tier upgrade with monthly refill', () => {
|
||||
render(SubscriptionTransitionPreviewWorkspace, {
|
||||
props: {
|
||||
previewData: preview({
|
||||
transition_type: 'upgrade',
|
||||
is_immediate: true,
|
||||
cost_today_cents: 8250,
|
||||
current_plan: plan('CREATOR', 'MONTHLY', 3500),
|
||||
new_plan: plan('PRO', 'MONTHLY', 10_000)
|
||||
})
|
||||
},
|
||||
global: globalOptions
|
||||
})
|
||||
expect(screen.getByText('$100')).toBeTruthy()
|
||||
expect(screen.getByText('subscription.billedMonthly')).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText('subscription.preview.newMonthlySubscription')
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText('subscription.preview.eachMonthCreditsRefill')
|
||||
).toBeTruthy()
|
||||
expect(screen.getByText('21,100')).toBeTruthy()
|
||||
expect(screen.getByText('$82.50')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a scheduled downgrade with the after-that block and no charge', () => {
|
||||
render(SubscriptionTransitionPreviewWorkspace, {
|
||||
props: {
|
||||
previewData: preview({
|
||||
transition_type: 'downgrade',
|
||||
is_immediate: false,
|
||||
cost_today_cents: 0,
|
||||
effective_at: '2027-06-28T00:00:00Z',
|
||||
current_plan: plan('PRO', 'MONTHLY', 10_000),
|
||||
new_plan: plan('CREATOR', 'MONTHLY', 3500)
|
||||
})
|
||||
},
|
||||
global: globalOptions
|
||||
})
|
||||
expect(
|
||||
screen.getAllByText('subscription.preview.confirmChange').length
|
||||
).toBeGreaterThan(0)
|
||||
expect(screen.getByText('$35')).toBeTruthy()
|
||||
expect(screen.getByText('subscription.preview.startsOn')).toBeTruthy()
|
||||
expect(screen.getByText('$0.00')).toBeTruthy()
|
||||
expect(screen.getByText('subscription.preview.afterThat')).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText('subscription.preview.creditsRefillMonthlyTo')
|
||||
).toBeTruthy()
|
||||
expect(screen.getByText('7,400')).toBeTruthy()
|
||||
expect(screen.getByText('subscription.preview.stayOnUntil')).toBeTruthy()
|
||||
expect(screen.queryByText('subscription.preview.switchesToday')).toBeNull()
|
||||
expect(
|
||||
screen.queryByText('subscription.preview.yearlySubscription')
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,144 +1,120 @@
|
||||
<template>
|
||||
<h2 class="m-0 mb-8 text-center text-xl text-muted-foreground lg:text-2xl">
|
||||
{{ $t('subscription.preview.confirmPlanChange') }}
|
||||
{{ confirmTitle }}
|
||||
</h2>
|
||||
<div
|
||||
class="mx-auto flex h-full flex-col items-stretch justify-between text-sm"
|
||||
class="mx-auto flex h-full max-w-[400px] flex-col items-stretch justify-between text-sm"
|
||||
>
|
||||
<div>
|
||||
<!-- Plan Comparison Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Current Plan -->
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ currentTierName }}
|
||||
<!-- Plan Header -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-semibold text-base-foreground">
|
||||
{{ newTierName }}
|
||||
</span>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-4xl font-semibold text-base-foreground">
|
||||
${{ heroPrice }}
|
||||
</span>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-2xl font-semibold text-base-foreground">
|
||||
${{ currentDisplayPrice }}
|
||||
</span>
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.usdPerMonth') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<i class="icon-[comfy--credits] size-3.5 shrink-0 bg-amber-400" />
|
||||
<span
|
||||
>{{ currentDisplayCredits }}
|
||||
{{ $t('subscription.perMonth') }}</span
|
||||
>
|
||||
</div>
|
||||
<span class="inline text-sm text-muted-foreground">
|
||||
<span class="text-xl text-base-foreground">
|
||||
{{ $t('subscription.usdPerMonth') }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="isImmediate">
|
||||
<span class="text-muted-foreground">
|
||||
{{
|
||||
$t('subscription.preview.ends', { date: currentPeriodEndDate })
|
||||
newIsYearly
|
||||
? $t('subscription.billedYearly', {
|
||||
total: annualTotalFormatted
|
||||
})
|
||||
: $t('subscription.billedMonthly')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<i class="pi pi-arrow-right size-8 shrink-0 text-muted-foreground" />
|
||||
|
||||
<!-- New Plan -->
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<span class="text-sm font-semibold text-base-foreground">
|
||||
{{ newTierName }}
|
||||
<span class="text-muted-foreground">
|
||||
{{ $t('subscription.preview.switchesToday') }}
|
||||
</span>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-2xl font-semibold text-base-foreground">
|
||||
${{ newDisplayPrice }}
|
||||
</span>
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.usdPerMonth') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<i class="icon-[comfy--credits] size-3.5 shrink-0 bg-amber-400" />
|
||||
<span
|
||||
>{{ newDisplayCredits }} {{ $t('subscription.perMonth') }}</span
|
||||
>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ $t('subscription.preview.starting', { date: effectiveDate }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next Cycle Section -->
|
||||
<div class="flex flex-col gap-3 pt-12 pb-6">
|
||||
<span class="text-base-foreground">
|
||||
</template>
|
||||
<span v-else class="text-muted-foreground">
|
||||
{{
|
||||
$t('subscription.preview.everyMonthStarting', {
|
||||
date: effectiveDate
|
||||
})
|
||||
$t('subscription.preview.startsOn', { date: effectiveDateLabel })
|
||||
}}
|
||||
</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted-foreground">
|
||||
{{ $t('subscription.preview.creditsRefillTo') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
|
||||
<span class="font-bold text-base-foreground">
|
||||
{{ newDisplayCredits }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted-foreground">
|
||||
{{ $t('subscription.preview.youllBeCharged') }}
|
||||
</span>
|
||||
<span class="text-base-foreground">${{ newMonthlyCharge }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proration Section -->
|
||||
<div
|
||||
v-if="showProration"
|
||||
class="flex flex-col gap-2 border-t border-border-subtle py-6"
|
||||
>
|
||||
<!-- Proration Line Items (immediate changes) -->
|
||||
<div v-if="isImmediate" class="flex flex-col gap-2 pt-10">
|
||||
<div class="flex items-center justify-between text-muted-foreground">
|
||||
<span>{{ subscriptionLineLabel }}</span>
|
||||
<span>{{ money(newPlanPriceUsd) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="proratedRefundCents > 0"
|
||||
class="flex items-center justify-between"
|
||||
v-if="prorationCreditUsd > 0"
|
||||
class="flex items-center justify-between text-muted-foreground"
|
||||
>
|
||||
<span class="text-muted-foreground">
|
||||
<span>
|
||||
{{
|
||||
$t('subscription.preview.proratedRefund', {
|
||||
plan: currentTierName
|
||||
$t('subscription.preview.creditFromCurrent', {
|
||||
plan: creditFromPlanLabel
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="text-muted-foreground">-${{ proratedRefund }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="proratedChargeCents > 0"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<span class="text-muted-foreground">
|
||||
{{
|
||||
$t('subscription.preview.proratedCharge', { plan: newTierName })
|
||||
}}
|
||||
</span>
|
||||
<span class="text-muted-foreground">${{ proratedCharge }}</span>
|
||||
<span>− {{ money(prorationCreditUsd) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Due Section -->
|
||||
<div class="flex flex-col gap-2 border-t border-border-subtle pt-6">
|
||||
<!-- Credits Refill (immediate changes) -->
|
||||
<div v-if="isImmediate" class="flex flex-col gap-2 pt-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base-foreground">{{ refillLabel }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
|
||||
<span class="font-bold text-base-foreground">{{
|
||||
refillCredits
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="newIsYearly" class="text-sm text-muted-foreground">
|
||||
{{ $t('subscription.preview.refillReplacesNote') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- After-That Block (scheduled changes) -->
|
||||
<div v-else class="flex flex-col gap-2 pt-10">
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
{{ $t('subscription.preview.afterThat') }}
|
||||
</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base-foreground">
|
||||
{{ $t('subscription.preview.creditsRefillMonthlyTo') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
|
||||
<span class="font-bold text-base-foreground">{{
|
||||
monthlyRefillCredits
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
$t('subscription.preview.billedEachMonth', {
|
||||
amount: moneyShort(newMonthlyChargeUsd)
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Total Due -->
|
||||
<div class="mt-10 flex flex-col gap-2 border-t border-border-subtle pt-8">
|
||||
<div class="flex items-center justify-between text-base">
|
||||
<span class="text-base-foreground">
|
||||
{{ $t('subscription.preview.totalDueToday') }}
|
||||
</span>
|
||||
<span class="font-bold text-base-foreground">
|
||||
${{ totalDueToday }}
|
||||
{{ money(totalDueTodayUsd) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
$t('subscription.preview.nextPaymentDue', {
|
||||
date: nextPaymentDate
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">{{ totalNote }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,13 +123,13 @@
|
||||
<SubscriptionTermsNote />
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
class="w-full rounded-lg"
|
||||
:loading="isLoading"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ $t('subscription.preview.switchToPlan', { plan: newTierName }) }}
|
||||
{{ confirmCta }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -177,12 +153,12 @@ import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspac
|
||||
|
||||
import SubscriptionTermsNote from './SubscriptionTermsNote.vue'
|
||||
|
||||
interface Props {
|
||||
type PersonalTierKey = 'standard' | 'creator' | 'pro'
|
||||
|
||||
const { previewData, isLoading = false } = defineProps<{
|
||||
previewData: PreviewSubscribeResponse
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const { previewData, isLoading = false } = defineProps<Props>()
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
confirm: []
|
||||
@@ -203,83 +179,112 @@ function formatDate(dateStr: string): string {
|
||||
})
|
||||
}
|
||||
|
||||
const currentTierName = computed(() =>
|
||||
previewData.current_plan ? formatTierName(previewData.current_plan.tier) : ''
|
||||
function money(usd: number): string {
|
||||
return `$${usd.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})}`
|
||||
}
|
||||
|
||||
function moneyShort(usd: number): string {
|
||||
return `$${n(usd)}`
|
||||
}
|
||||
|
||||
function tierMonthlyCredits(tier: string): number {
|
||||
return getTierCredits(tier.toLowerCase() as PersonalTierKey) ?? 0
|
||||
}
|
||||
|
||||
const isImmediate = computed(() => previewData.is_immediate)
|
||||
const newIsYearly = computed(() => previewData.new_plan.duration === 'ANNUAL')
|
||||
const currentIsYearly = computed(
|
||||
() => previewData.current_plan?.duration === 'ANNUAL'
|
||||
)
|
||||
const isCadenceChange = computed(
|
||||
() =>
|
||||
!!previewData.current_plan &&
|
||||
previewData.current_plan.duration !== previewData.new_plan.duration
|
||||
)
|
||||
|
||||
const newTierName = computed(() => formatTierName(previewData.new_plan.tier))
|
||||
|
||||
const currentDisplayPrice = computed(() =>
|
||||
previewData.current_plan
|
||||
? (previewData.current_plan.price_cents / 100).toFixed(0)
|
||||
: '0'
|
||||
const currentTierName = computed(() =>
|
||||
previewData.current_plan ? formatTierName(previewData.current_plan.tier) : ''
|
||||
)
|
||||
const currentPlanLabel = computed(() =>
|
||||
currentIsYearly.value
|
||||
? t('subscription.tierNameYearly', { name: currentTierName.value })
|
||||
: currentTierName.value
|
||||
)
|
||||
|
||||
const newDisplayPrice = computed(() =>
|
||||
(previewData.new_plan.price_cents / 100).toFixed(0)
|
||||
)
|
||||
|
||||
const newMonthlyCharge = computed(() =>
|
||||
(previewData.new_plan.price_cents / 100).toFixed(2)
|
||||
)
|
||||
|
||||
const currentDisplayCredits = computed(() => {
|
||||
if (!previewData.current_plan) return n(0)
|
||||
const tierKey = previewData.current_plan.tier.toLowerCase() as
|
||||
| 'standard'
|
||||
| 'creator'
|
||||
| 'pro'
|
||||
return n(getTierCredits(tierKey) ?? 0)
|
||||
const newMonthlyUsd = computed(() => {
|
||||
const cents = previewData.new_plan.price_cents
|
||||
return (newIsYearly.value ? cents / 12 : cents) / 100
|
||||
})
|
||||
const heroPrice = computed(() => newMonthlyUsd.value.toFixed(0))
|
||||
|
||||
const newDisplayCredits = computed(() => {
|
||||
const tierKey = previewData.new_plan.tier.toLowerCase() as
|
||||
| 'standard'
|
||||
| 'creator'
|
||||
| 'pro'
|
||||
return n(getTierCredits(tierKey) ?? 0)
|
||||
const annualTotalFormatted = computed(
|
||||
() => `$${n(previewData.new_plan.price_cents / 100)}`
|
||||
)
|
||||
|
||||
const newPlanPriceUsd = computed(() => previewData.new_plan.price_cents / 100)
|
||||
const prorationCreditUsd = computed(() => {
|
||||
const credit = previewData.new_plan.price_cents - previewData.cost_today_cents
|
||||
return credit > 0 ? credit / 100 : 0
|
||||
})
|
||||
const totalDueTodayUsd = computed(() => previewData.cost_today_cents / 100)
|
||||
const newMonthlyChargeUsd = computed(() => newMonthlyUsd.value)
|
||||
|
||||
const currentPeriodEndDate = computed(() =>
|
||||
previewData.current_plan?.period_end
|
||||
? formatDate(previewData.current_plan.period_end)
|
||||
: ''
|
||||
const subscriptionLineLabel = computed(() =>
|
||||
newIsYearly.value
|
||||
? t('subscription.preview.yearlySubscription')
|
||||
: t('subscription.preview.newMonthlySubscription')
|
||||
)
|
||||
const creditFromPlanLabel = computed(() =>
|
||||
isCadenceChange.value
|
||||
? t('subscription.preview.currentMonthly')
|
||||
: currentTierName.value
|
||||
)
|
||||
|
||||
const effectiveDate = computed(() => formatDate(previewData.effective_at))
|
||||
|
||||
const showProration = computed(() => previewData.is_immediate)
|
||||
|
||||
const proratedRefundCents = computed(() => {
|
||||
if (!previewData.current_plan || !previewData.is_immediate) return 0
|
||||
const chargeToday = previewData.cost_today_cents
|
||||
const newPlanCost = previewData.new_plan.price_cents
|
||||
if (chargeToday < newPlanCost) {
|
||||
return newPlanCost - chargeToday
|
||||
}
|
||||
return 0
|
||||
const refillCredits = computed(() => {
|
||||
const monthly = tierMonthlyCredits(previewData.new_plan.tier)
|
||||
return n(newIsYearly.value ? monthly * 12 : monthly)
|
||||
})
|
||||
|
||||
const proratedRefund = computed(() =>
|
||||
(proratedRefundCents.value / 100).toFixed(2)
|
||||
)
|
||||
|
||||
const proratedChargeCents = computed(() => {
|
||||
if (!previewData.is_immediate) return 0
|
||||
return previewData.cost_today_cents
|
||||
})
|
||||
|
||||
const proratedCharge = computed(() =>
|
||||
(proratedChargeCents.value / 100).toFixed(2)
|
||||
)
|
||||
|
||||
const totalDueToday = computed(() =>
|
||||
(previewData.cost_today_cents / 100).toFixed(2)
|
||||
const monthlyRefillCredits = computed(() =>
|
||||
n(tierMonthlyCredits(previewData.new_plan.tier))
|
||||
)
|
||||
const refillLabel = computed(() =>
|
||||
newIsYearly.value
|
||||
? t('subscription.preview.creditsYoullGetToday')
|
||||
: t('subscription.preview.eachMonthCreditsRefill')
|
||||
)
|
||||
|
||||
const effectiveDateLabel = computed(() => formatDate(previewData.effective_at))
|
||||
const nextPaymentDate = computed(() =>
|
||||
previewData.new_plan.period_end
|
||||
? formatDate(previewData.new_plan.period_end)
|
||||
: formatDate(previewData.effective_at)
|
||||
: effectiveDateLabel.value
|
||||
)
|
||||
const currentPeriodEnd = computed(() =>
|
||||
previewData.current_plan?.period_end
|
||||
? formatDate(previewData.current_plan.period_end)
|
||||
: effectiveDateLabel.value
|
||||
)
|
||||
|
||||
const confirmTitle = computed(() =>
|
||||
isImmediate.value
|
||||
? t('subscription.preview.confirmUpgradeTitle')
|
||||
: t('subscription.preview.confirmChange')
|
||||
)
|
||||
const confirmCta = computed(() =>
|
||||
isImmediate.value
|
||||
? t('subscription.preview.confirmUpgradeCta')
|
||||
: t('subscription.preview.confirmChange')
|
||||
)
|
||||
const totalNote = computed(() =>
|
||||
isImmediate.value
|
||||
? t('subscription.preview.nextPaymentDue', { date: nextPaymentDate.value })
|
||||
: t('subscription.preview.stayOnUntil', {
|
||||
plan: currentPlanLabel.value,
|
||||
date: currentPeriodEnd.value
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -3,13 +3,20 @@ import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { CreateTopupResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import TopUpCreditsDialogContentWorkspace from './TopUpCreditsDialogContentWorkspace.vue'
|
||||
|
||||
type TopupStatus = 'pending' | 'completed' | 'failed'
|
||||
|
||||
interface CreateTopupResponse {
|
||||
billing_op_id: string
|
||||
topup_id: string
|
||||
status: TopupStatus
|
||||
amount_cents: number
|
||||
}
|
||||
|
||||
const mockFetchBalance = vi.fn()
|
||||
const mockFetchStatus = vi.fn()
|
||||
const mockTopup = vi.fn<(amountCents: number) => Promise<CreateTopupResponse>>()
|
||||
const mockCreateTopup = vi.fn<() => Promise<CreateTopupResponse>>()
|
||||
const mockStartOperation = vi.fn()
|
||||
const mockShowSettings = vi.fn()
|
||||
const mockToastAdd = vi.fn()
|
||||
@@ -17,11 +24,16 @@ const mockToastAdd = vi.fn()
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
fetchBalance: mockFetchBalance,
|
||||
fetchStatus: mockFetchStatus,
|
||||
topup: (amountCents: number) => mockTopup(amountCents)
|
||||
fetchStatus: mockFetchStatus
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: {
|
||||
createTopup: () => mockCreateTopup()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
|
||||
useBillingOperationStore: () => ({
|
||||
hasPendingOperations: false,
|
||||
@@ -132,7 +144,7 @@ describe('TopUpCreditsDialogContentWorkspace', () => {
|
||||
})
|
||||
|
||||
it('refreshes both balance and status after a completed top-up', async () => {
|
||||
mockTopup.mockResolvedValue(topupResponse('completed'))
|
||||
mockCreateTopup.mockResolvedValue(topupResponse('completed'))
|
||||
|
||||
renderDialog()
|
||||
await clickAddCredits()
|
||||
@@ -143,7 +155,7 @@ describe('TopUpCreditsDialogContentWorkspace', () => {
|
||||
})
|
||||
|
||||
it('does not refresh balance or status for a pending top-up', async () => {
|
||||
mockTopup.mockResolvedValue(topupResponse('pending'))
|
||||
mockCreateTopup.mockResolvedValue(topupResponse('pending'))
|
||||
|
||||
renderDialog()
|
||||
await clickAddCredits()
|
||||
@@ -154,7 +166,7 @@ describe('TopUpCreditsDialogContentWorkspace', () => {
|
||||
})
|
||||
|
||||
it('does not refresh balance or status for a failed top-up', async () => {
|
||||
mockTopup.mockResolvedValue(topupResponse('failed'))
|
||||
mockCreateTopup.mockResolvedValue(topupResponse('failed'))
|
||||
|
||||
renderDialog()
|
||||
await clickAddCredits()
|
||||
|
||||
@@ -176,7 +176,7 @@ const settingsDialog = useSettingsDialog()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { fetchBalance, fetchStatus, topup } = useBillingContext()
|
||||
const { fetchBalance, topup, fetchStatus } = useBillingContext()
|
||||
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
||||
@@ -269,7 +269,7 @@ async function handleBuy() {
|
||||
handleClose(false)
|
||||
settingsDialog.show('workspace')
|
||||
} else if (response.status === 'pending') {
|
||||
billingOperationStore.startOperation(response.billing_op_id, 'topup')
|
||||
void billingOperationStore.startOperation(response.billing_op_id, 'topup')
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
|
||||
@@ -55,12 +55,12 @@ function renderComponent(props: Record<string, unknown> = {}) {
|
||||
components: { Button },
|
||||
stubs: {
|
||||
SelectButton: { template: '<div />' },
|
||||
// Clicking emits a change to a different stop ($200) so tests can move
|
||||
// the selection off the current stop.
|
||||
// Clicking moves the v-model selection to a different stop ($200) so
|
||||
// tests can move off the current stop.
|
||||
CreditSlider: {
|
||||
template:
|
||||
'<button data-testid="team-slider" @click="$emit(\'change\', { index: 0, usd: 200, credits: 42200 })" />',
|
||||
emits: ['change', 'update:modelValue']
|
||||
'<button data-testid="team-slider" @click="$emit(\'update:modelValue\', 200)" />',
|
||||
emits: ['update:modelValue']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,8 +235,9 @@
|
||||
cycle halves the yearly discount when monthly. -->
|
||||
<CreditSlider
|
||||
v-model="teamUsd"
|
||||
:stops="teamStops"
|
||||
:default-stop-index="teamDefaultStopIndex"
|
||||
:cycle="currentBillingCycle"
|
||||
@change="onTeamChange"
|
||||
/>
|
||||
|
||||
<!-- Selected credit grant + template-based video estimate -->
|
||||
@@ -425,10 +426,12 @@ import type {
|
||||
TierKey,
|
||||
TierPricing
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
|
||||
import {
|
||||
DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
getDiscountedMonthlyUsd,
|
||||
TEAM_PLAN_CREDIT_STOPS
|
||||
TEAM_PLAN_CREDIT_STOPS,
|
||||
mapApiTeamCreditStops
|
||||
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
@@ -453,14 +456,9 @@ const {
|
||||
const emit = defineEmits<{
|
||||
subscribe: [payload: { tierKey: CheckoutTierKey; billingCycle: BillingCycle }]
|
||||
resubscribe: []
|
||||
// Team-plan checkout. NOTE: the slider stop -> plan-slug mapping is blocked on
|
||||
// the BE discount-breakpoint contract (FE-934 / doc Open Q#2); the host shows
|
||||
// the confirm step but stubs the final subscribe until the contract lands.
|
||||
// TODO(FE-934): once the contract lands, also carry `currentBillingCycle`
|
||||
// (yearly | monthly) so checkout subscribes to the selected cycle, not just
|
||||
// the stop. The pricing-table view already toggles cycle; the confirm/checkout
|
||||
// chain still assumes yearly.
|
||||
subscribeTeam: [payload: TeamPlanSelection]
|
||||
subscribeTeam: [
|
||||
payload: { stop: TeamPlanSelection; billingCycle: BillingCycle }
|
||||
]
|
||||
}>()
|
||||
|
||||
const { t, n } = useI18n()
|
||||
@@ -612,17 +610,41 @@ const {
|
||||
currentTeamCreditStop
|
||||
} = useBillingContext()
|
||||
|
||||
const { teamCreditStops } = useBillingPlans()
|
||||
|
||||
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
|
||||
|
||||
const currentBillingCycle = ref<BillingCycle>('yearly')
|
||||
|
||||
// Team plan selection (slider). Stop -> slug mapping is BE-blocked (see emit).
|
||||
const teamUsd = ref<number>(
|
||||
TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX].usd
|
||||
// Team credit stops: backend-sourced when the API supplies them, otherwise the
|
||||
// hardcoded DES-197 fallback so OSS / pre-deploy still renders.
|
||||
const teamStops = computed(() =>
|
||||
teamCreditStops.value
|
||||
? mapApiTeamCreditStops(teamCreditStops.value.stops)
|
||||
: TEAM_PLAN_CREDIT_STOPS
|
||||
)
|
||||
const teamCredits = ref<number>(
|
||||
TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX].credits
|
||||
const teamDefaultStopIndex = computed(
|
||||
() =>
|
||||
teamCreditStops.value?.default_stop_index ?? DEFAULT_TEAM_PLAN_STOP_INDEX
|
||||
)
|
||||
const defaultTeamStop = computed(
|
||||
() =>
|
||||
teamStops.value[teamDefaultStopIndex.value] ??
|
||||
teamStops.value[0] ??
|
||||
TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX]
|
||||
)
|
||||
|
||||
const teamUsd = ref<number>(defaultTeamStop.value.usd)
|
||||
|
||||
// The selected stop follows the slider's USD value; when it matches none (e.g.
|
||||
// the API stops loaded after mount with different breakpoints) it falls back to
|
||||
// the default stop so the id/credits stay consistent with what's displayed.
|
||||
const selectedTeamStop = computed(
|
||||
() =>
|
||||
teamStops.value.find((stop) => stop.usd === teamUsd.value) ??
|
||||
defaultTeamStop.value
|
||||
)
|
||||
const teamCredits = computed(() => selectedTeamStop.value.credits)
|
||||
const teamVideoEstimate = computed(() =>
|
||||
Math.round(teamCredits.value * VIDEO_PER_CREDIT)
|
||||
)
|
||||
@@ -633,18 +655,16 @@ const isTeamSubscribed = computed(() => currentTeamCreditStop.value !== null)
|
||||
const currentTeamStopIndex = computed(() => {
|
||||
const usd = currentTeamCreditStop.value?.stop_usd
|
||||
if (usd == null) return null
|
||||
const i = TEAM_PLAN_CREDIT_STOPS.findIndex((stop) => stop.usd === usd)
|
||||
const i = teamStops.value.findIndex((stop) => stop.usd === usd)
|
||||
return i === -1 ? null : i
|
||||
})
|
||||
|
||||
// Start the slider on the current stop so an active subscriber sees their plan
|
||||
// (disabled) and must move off it to change.
|
||||
// (disabled) and must move off it to change. teamCredits follows reactively.
|
||||
watch(
|
||||
currentTeamCreditStop,
|
||||
(stop) => {
|
||||
if (!stop) return
|
||||
teamUsd.value = stop.stop_usd
|
||||
teamCredits.value = stop.credits_monthly
|
||||
if (stop) teamUsd.value = stop.stop_usd
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -655,7 +675,7 @@ watch(
|
||||
const isTeamCurrentStopSelected = computed(
|
||||
() =>
|
||||
currentTeamStopIndex.value !== null &&
|
||||
TEAM_PLAN_CREDIT_STOPS[currentTeamStopIndex.value]?.usd === teamUsd.value
|
||||
teamStops.value[currentTeamStopIndex.value]?.usd === teamUsd.value
|
||||
)
|
||||
|
||||
// Yearly and monthly at the same credit stop are distinct plans, so toggling
|
||||
@@ -804,11 +824,6 @@ function handleSubscribe(tierKey: CheckoutTierKey) {
|
||||
emit('subscribe', { tierKey, billingCycle: currentBillingCycle.value })
|
||||
}
|
||||
|
||||
function onTeamChange(stop: { index: number; usd: number; credits: number }) {
|
||||
teamUsd.value = stop.usd
|
||||
teamCredits.value = stop.credits
|
||||
}
|
||||
|
||||
function handleSubscribeTeam() {
|
||||
if (isTeamButtonDisabled.value) return
|
||||
// Re-subscribe only when keeping the exact current plan; any other stop or
|
||||
@@ -817,13 +832,18 @@ function handleSubscribeTeam() {
|
||||
emit('resubscribe')
|
||||
return
|
||||
}
|
||||
const stop = selectedTeamStop.value
|
||||
emit('subscribeTeam', {
|
||||
usd: teamUsd.value,
|
||||
credits: teamCredits.value,
|
||||
discountedUsd: getDiscountedMonthlyUsd(
|
||||
teamUsd.value,
|
||||
currentBillingCycle.value
|
||||
)
|
||||
stop: {
|
||||
id: stop.id,
|
||||
usd: stop.usd,
|
||||
credits: stop.credits,
|
||||
discountedUsd: getDiscountedMonthlyUsd(
|
||||
stop.usd,
|
||||
currentBillingCycle.value
|
||||
)
|
||||
},
|
||||
billingCycle: currentBillingCycle.value
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ChangeMemberRoleDialogContent from './ChangeMemberRoleDialogContent.vue'
|
||||
|
||||
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
const { mockChangeMemberRole, mockCloseDialog, mockToastAdd } = vi.hoisted(
|
||||
() => ({
|
||||
mockChangeMemberRole: vi.fn(),
|
||||
mockCloseDialog: vi.fn(),
|
||||
mockToastAdd: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
changeMemberRole: mockChangeMemberRole
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
closeDialog: mockCloseDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function renderDialog(targetRole: WorkspaceRole) {
|
||||
const user = userEvent.setup()
|
||||
const result = render(ChangeMemberRoleDialogContent, {
|
||||
props: { memberId: 'mem-1', memberName: 'Jane', targetRole },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('ChangeMemberRoleDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockChangeMemberRole.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('shows promote copy and confirms with Make owner', async () => {
|
||||
const { user } = renderDialog('owner')
|
||||
|
||||
expect(
|
||||
screen.getByText('workspacePanel.changeRoleDialog.promoteTitle')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.changeRoleDialog.promoteIntro')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'workspacePanel.changeRoleDialog.promoteConfirm'
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockChangeMemberRole).toHaveBeenCalledWith('mem-1', 'owner')
|
||||
await waitFor(() =>
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'change-member-role'
|
||||
})
|
||||
)
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('shows demote copy and confirms with Demote to member', async () => {
|
||||
const { user } = renderDialog('member')
|
||||
|
||||
expect(
|
||||
screen.getByText('workspacePanel.changeRoleDialog.demoteTitle')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.changeRoleDialog.demoteMessage')
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'workspacePanel.changeRoleDialog.demoteConfirm'
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockChangeMemberRole).toHaveBeenCalledWith('mem-1', 'member')
|
||||
})
|
||||
|
||||
it('keeps the dialog open and toasts on failure', async () => {
|
||||
mockChangeMemberRole.mockRejectedValue(new Error('boom'))
|
||||
const { user } = renderDialog('owner')
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'workspacePanel.changeRoleDialog.promoteConfirm'
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
)
|
||||
expect(mockCloseDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes without changing the role on cancel', async () => {
|
||||
const { user } = renderDialog('owner')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'g.cancel' }))
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: 'change-member-role' })
|
||||
expect(mockChangeMemberRole).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[360px] 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">
|
||||
{{
|
||||
isPromotion
|
||||
? $t('workspacePanel.changeRoleDialog.promoteTitle', {
|
||||
name: memberName
|
||||
})
|
||||
: $t('workspacePanel.changeRoleDialog.demoteTitle', {
|
||||
name: memberName
|
||||
})
|
||||
}}
|
||||
</h2>
|
||||
<button
|
||||
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-4">
|
||||
<template v-if="isPromotion">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.changeRoleDialog.promoteIntro') }}
|
||||
</p>
|
||||
<ul class="m-0 mt-1 list-disc ps-5 text-sm text-muted-foreground">
|
||||
<li v-for="permission in promotePermissions" :key="permission">
|
||||
{{ permission }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<p v-else class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.changeRoleDialog.demoteMessage') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 p-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" :loading @click="onConfirm">
|
||||
{{
|
||||
isPromotion
|
||||
? $t('workspacePanel.changeRoleDialog.promoteConfirm')
|
||||
: $t('workspacePanel.changeRoleDialog.demoteConfirm')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { memberId, targetRole } = defineProps<{
|
||||
memberId: string
|
||||
memberName: string
|
||||
targetRole: WorkspaceRole
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const loading = ref(false)
|
||||
|
||||
const isPromotion = computed(() => targetRole === 'owner')
|
||||
|
||||
const promotePermissions = computed(() => [
|
||||
t('workspacePanel.changeRoleDialog.promotePermissionCredits'),
|
||||
t('workspacePanel.changeRoleDialog.promotePermissionManage'),
|
||||
t('workspacePanel.changeRoleDialog.promotePermissionRoles')
|
||||
])
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'change-member-role' })
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
loading.value = true
|
||||
try {
|
||||
await workspaceStore.changeMemberRole(memberId, targetRole)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspacePanel.changeRoleDialog.success'),
|
||||
life: 2000
|
||||
})
|
||||
dialogStore.closeDialog({ key: 'change-member-role' })
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.changeRoleDialog.error')
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,4 +1,6 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
/* eslint-disable testing-library/no-container */
|
||||
/* eslint-disable testing-library/no-node-access */
|
||||
import { render } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -42,10 +44,24 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
const ButtonStub = {
|
||||
name: 'Button',
|
||||
template:
|
||||
'<button :disabled="disabled" :data-loading="loading" @click="$emit(\'click\')"><slot /></button>',
|
||||
props: ['disabled', 'loading', 'variant', 'size']
|
||||
}
|
||||
|
||||
const InputStub = {
|
||||
name: 'Input',
|
||||
template:
|
||||
'<input :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['modelValue', 'disabled', 'placeholder', 'type']
|
||||
}
|
||||
|
||||
function mountComponent(props: Record<string, unknown> = {}) {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn().mockResolvedValue(undefined)
|
||||
render(DowngradeRemoveMembersDialogContent, {
|
||||
const { container } = render(DowngradeRemoveMembersDialogContent, {
|
||||
props: {
|
||||
planName: 'Founder',
|
||||
planSlug: 'founder-monthly',
|
||||
@@ -53,16 +69,33 @@ function mountComponent(props: Record<string, unknown> = {}) {
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Button: ButtonStub,
|
||||
Input: InputStub
|
||||
}
|
||||
}
|
||||
})
|
||||
return { user, onConfirm }
|
||||
return { container, user, onConfirm }
|
||||
}
|
||||
|
||||
const getPhraseInput = () => screen.getByRole('textbox')
|
||||
const getChangePlanButton = () =>
|
||||
screen.getByRole('button', { name: 'Change plan' })
|
||||
const getCancelButton = () => screen.getByRole('button', { name: 'Cancel' })
|
||||
function getInput(container: Element): HTMLInputElement {
|
||||
return container.querySelector('input') as HTMLInputElement
|
||||
}
|
||||
|
||||
function getChangePlanButton(container: Element): HTMLButtonElement {
|
||||
const buttons = Array.from(container.querySelectorAll('button'))
|
||||
return buttons.find((b) =>
|
||||
b.textContent?.includes('Change plan')
|
||||
) as HTMLButtonElement
|
||||
}
|
||||
|
||||
function getCancelButton(container: Element): HTMLButtonElement {
|
||||
const buttons = Array.from(container.querySelectorAll('button'))
|
||||
return buttons.find(
|
||||
(b) => b.textContent?.trim() === 'Cancel'
|
||||
) as HTMLButtonElement
|
||||
}
|
||||
|
||||
describe('DowngradeRemoveMembersDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
@@ -70,26 +103,27 @@ describe('DowngradeRemoveMembersDialogContent', () => {
|
||||
})
|
||||
|
||||
it('disables Change plan until the exact phrase is typed', async () => {
|
||||
const { user } = mountComponent()
|
||||
expect(getChangePlanButton()).toBeDisabled()
|
||||
const { container, user } = mountComponent()
|
||||
const changePlan = getChangePlanButton(container)
|
||||
expect(changePlan.disabled).toBe(true)
|
||||
|
||||
await user.type(getPhraseInput(), 'I understan')
|
||||
expect(getChangePlanButton()).toBeDisabled()
|
||||
await user.type(getInput(container), 'I understan')
|
||||
expect(changePlan.disabled).toBe(true)
|
||||
|
||||
await user.type(getPhraseInput(), 'd')
|
||||
expect(getChangePlanButton()).toBeEnabled()
|
||||
await user.type(getInput(container), 'd')
|
||||
expect(changePlan.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps Change plan disabled for a case-mismatched phrase', async () => {
|
||||
const { user } = mountComponent()
|
||||
await user.type(getPhraseInput(), 'i understand')
|
||||
expect(getChangePlanButton()).toBeDisabled()
|
||||
const { container, user } = mountComponent()
|
||||
await user.type(getInput(container), 'i understand')
|
||||
expect(getChangePlanButton(container).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('invokes onConfirm with the plan slug and closes when confirmed', async () => {
|
||||
const { user, onConfirm } = mountComponent()
|
||||
await user.type(getPhraseInput(), 'I understand')
|
||||
await user.click(getChangePlanButton())
|
||||
const { container, user, onConfirm } = mountComponent()
|
||||
await user.type(getInput(container), 'I understand')
|
||||
await user.click(getChangePlanButton(container))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith('founder-monthly')
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
@@ -98,9 +132,9 @@ describe('DowngradeRemoveMembersDialogContent', () => {
|
||||
})
|
||||
|
||||
it('closes without calling onConfirm when cancelled', async () => {
|
||||
const { user, onConfirm } = mountComponent()
|
||||
await user.type(getPhraseInput(), 'I understand')
|
||||
await user.click(getCancelButton())
|
||||
const { container, user, onConfirm } = mountComponent()
|
||||
await user.type(getInput(container), 'I understand')
|
||||
await user.click(getCancelButton(container))
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
@@ -110,9 +144,9 @@ describe('DowngradeRemoveMembersDialogContent', () => {
|
||||
|
||||
it('shows an error toast and stays open when onConfirm rejects', async () => {
|
||||
const onConfirm = vi.fn().mockRejectedValue(new Error('boom'))
|
||||
const { user } = mountComponent({ onConfirm })
|
||||
await user.type(getPhraseInput(), 'I understand')
|
||||
await user.click(getChangePlanButton())
|
||||
const { container, user } = mountComponent({ onConfirm })
|
||||
await user.type(getInput(container), 'I understand')
|
||||
await user.click(getChangePlanButton(container))
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import InviteMemberDialogContent from './InviteMemberDialogContent.vue'
|
||||
|
||||
import type { PendingInvite } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
const { mockCreateInvite, mockCloseDialog, mockToastAdd } = vi.hoisted(() => ({
|
||||
mockCreateInvite: vi.fn(),
|
||||
mockCloseDialog: vi.fn(),
|
||||
mockToastAdd: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
createInvite: mockCreateInvite
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
closeDialog: mockCloseDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function pendingInviteFor(email: string): PendingInvite {
|
||||
return {
|
||||
id: `inv-${email}`,
|
||||
email,
|
||||
inviteDate: new Date(0),
|
||||
expiryDate: new Date(0)
|
||||
}
|
||||
}
|
||||
|
||||
function renderDialog() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(InviteMemberDialogContent, {
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
function emailInput() {
|
||||
return screen.getByRole('textbox')
|
||||
}
|
||||
|
||||
function inviteButton() {
|
||||
return screen.getByRole('button', { name: 'workspacePanel.invite' })
|
||||
}
|
||||
|
||||
describe('InviteMemberDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreateInvite.mockImplementation(async (email: string) =>
|
||||
pendingInviteFor(email)
|
||||
)
|
||||
})
|
||||
|
||||
it('turns comma- and enter-delimited input into chips', async () => {
|
||||
const { user } = renderDialog()
|
||||
|
||||
await user.type(emailInput(), 'a@b.com,')
|
||||
await user.type(emailInput(), 'c@d.com{Enter}')
|
||||
|
||||
expect(screen.getByText('a@b.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('c@d.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('splits a pasted comma-separated list into chips', async () => {
|
||||
const { user } = renderDialog()
|
||||
|
||||
await user.click(emailInput())
|
||||
await user.paste('a@b.com, c@d.com')
|
||||
|
||||
expect(screen.getByText('a@b.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('c@d.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables Invite while there are no chips', () => {
|
||||
renderDialog()
|
||||
|
||||
expect(inviteButton()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('flags invalid emails and keeps Invite disabled', async () => {
|
||||
const { user } = renderDialog()
|
||||
|
||||
await user.type(emailInput(), 'not-an-email{Enter}')
|
||||
|
||||
expect(screen.getByText('not-an-email')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.inviteMemberDialog.invalidEmailCount')
|
||||
).toBeInTheDocument()
|
||||
expect(inviteButton()).toBeDisabled()
|
||||
|
||||
await user.type(emailInput(), 'a@b.com{Enter}')
|
||||
|
||||
expect(inviteButton()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('creates an invite per email and shows the success state', async () => {
|
||||
const { user } = renderDialog()
|
||||
|
||||
await user.type(emailInput(), 'a@b.com,c@d.com{Enter}')
|
||||
await user.click(inviteButton())
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'workspacePanel.inviteMemberDialog.invitedMessage'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(mockCreateInvite).toHaveBeenCalledTimes(2)
|
||||
expect(mockCreateInvite).toHaveBeenCalledWith('a@b.com')
|
||||
expect(mockCreateInvite).toHaveBeenCalledWith('c@d.com')
|
||||
|
||||
const closeButton = screen
|
||||
.getAllByRole('button', { name: 'g.close' })
|
||||
.find((button) => button.textContent?.includes('g.close'))
|
||||
await user.click(closeButton!)
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: 'invite-member' })
|
||||
})
|
||||
|
||||
it('keeps only failed emails as chips and toasts on partial failure', async () => {
|
||||
mockCreateInvite.mockImplementation(async (email: string) => {
|
||||
if (email === 'fail@x.com') throw new Error('nope')
|
||||
return pendingInviteFor(email)
|
||||
})
|
||||
const { user } = renderDialog()
|
||||
|
||||
await user.type(emailInput(), 'ok@x.com,fail@x.com{Enter}')
|
||||
await user.click(inviteButton())
|
||||
|
||||
await waitFor(() => expect(mockCreateInvite).toHaveBeenCalledTimes(2))
|
||||
expect(screen.getByText('fail@x.com')).toBeInTheDocument()
|
||||
expect(screen.queryByText('ok@x.com')).not.toBeInTheDocument()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
expect(inviteButton()).toBeEnabled()
|
||||
})
|
||||
|
||||
it('closes without inviting on Cancel', async () => {
|
||||
const { user } = renderDialog()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'g.cancel' }))
|
||||
|
||||
expect(mockCreateInvite).not.toHaveBeenCalled()
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: 'invite-member' })
|
||||
})
|
||||
})
|
||||
@@ -1,97 +1,49 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[512px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
class="flex w-lg max-w-full 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">
|
||||
{{
|
||||
step === 'email'
|
||||
? $t('workspacePanel.inviteMemberDialog.title')
|
||||
: $t('workspacePanel.inviteMemberDialog.linkStep.title')
|
||||
}}
|
||||
{{ $t('workspacePanel.inviteMemberDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
@click="onClose"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body: Email Step -->
|
||||
<template v-if="step === 'email'">
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.inviteMemberDialog.message') }}
|
||||
</p>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
class="focus:ring-secondary-foreground w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:ring-1 focus:outline-none"
|
||||
:placeholder="$t('workspacePanel.inviteMemberDialog.placeholder')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="step === 'form'" class="p-4">
|
||||
<InviteMembersForm
|
||||
source="settings_members"
|
||||
:submit-label="$t('workspacePanel.invite')"
|
||||
:cancel-label="$t('g.cancel')"
|
||||
:placeholder="$t('workspacePanel.inviteMemberDialog.placeholder')"
|
||||
@submitted="onSubmitted"
|
||||
@cancel="onClose"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Footer: Email Step -->
|
||||
<div class="flex items-center justify-end gap-4 p-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading
|
||||
:disabled="!isValidEmail"
|
||||
@click="onCreateLink"
|
||||
>
|
||||
{{ $t('workspacePanel.inviteMemberDialog.createLink') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Body: Link Step -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.inviteMemberDialog.linkStep.message') }}
|
||||
<div class="p-4">
|
||||
<p class="m-0 text-sm/5 text-muted-foreground">
|
||||
{{
|
||||
$t(
|
||||
'workspacePanel.inviteMemberDialog.invitedMessage',
|
||||
{ emails: invitedEmails.join(', ') },
|
||||
invitedEmails.length
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p class="m-0 text-sm font-medium text-base-foreground">
|
||||
{{ email }}
|
||||
</p>
|
||||
<div class="relative">
|
||||
<input
|
||||
:value="generatedLink"
|
||||
readonly
|
||||
class="w-full cursor-pointer rounded-lg border border-border-default bg-transparent px-3 py-2 pr-10 text-sm text-base-foreground focus:outline-none"
|
||||
@click="onSelectLink"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-2.5 right-3 cursor-pointer"
|
||||
@click="onCopyLink"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'pi size-4',
|
||||
justCopied ? 'pi-check text-green-500' : 'pi-copy'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer: Link Step -->
|
||||
<div class="flex items-center justify-end gap-4 p-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="primary" size="lg" @click="onCopyLink">
|
||||
{{ $t('workspacePanel.inviteMemberDialog.linkStep.copyLink') }}
|
||||
<div class="flex items-center justify-end p-4">
|
||||
<Button variant="secondary" size="lg" @click="onClose">
|
||||
{{ $t('g.close') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -99,74 +51,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import InviteMembersForm from '@/platform/workspace/components/InviteMembersForm.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const email = ref('')
|
||||
const step = ref<'email' | 'link'>('email')
|
||||
const generatedLink = ref('')
|
||||
const justCopied = ref(false)
|
||||
const step = ref<'form' | 'invited'>('form')
|
||||
const invitedEmails = ref<string[]>([])
|
||||
|
||||
const isValidEmail = computed(() => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email.value)
|
||||
})
|
||||
function onSubmitted(emails: string[]) {
|
||||
invitedEmails.value = emails
|
||||
step.value = 'invited'
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
function onClose() {
|
||||
dialogStore.closeDialog({ key: 'invite-member' })
|
||||
}
|
||||
|
||||
async function onCreateLink() {
|
||||
if (!isValidEmail.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
generatedLink.value = await workspaceStore.createInviteLink(email.value)
|
||||
step.value = 'link'
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
|
||||
detail: error instanceof Error ? error.message : undefined
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onCopyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedLink.value)
|
||||
justCopied.value = true
|
||||
setTimeout(() => {
|
||||
justCopied.value = false
|
||||
}, 759)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspacePanel.inviteMemberDialog.linkCopied'),
|
||||
life: 2000
|
||||
})
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectLink(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
input.select()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -39,11 +39,7 @@
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="primary" size="lg" @click="onUpgrade">
|
||||
{{
|
||||
isActiveSubscription
|
||||
? $t('workspacePanel.inviteUpsellDialog.upgradeToCreator')
|
||||
: $t('workspacePanel.inviteUpsellDialog.viewPlans')
|
||||
}}
|
||||
{{ $t('workspacePanel.inviteUpsellDialog.upgradeToTeam') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,78 +15,76 @@
|
||||
:pt:icon:class="{ 'text-xl!': !isCurrentUser || !photoUrl }"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ member.name }}
|
||||
<span v-if="isCurrentUser" class="text-muted-foreground">
|
||||
({{ $t('g.you') }})
|
||||
</span>
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ member.name }}
|
||||
<span v-if="isCurrentUser" class="text-muted-foreground">
|
||||
({{ $t('g.you') }})
|
||||
</span>
|
||||
<RoleBadge v-if="showRoleBadge" :role="member.role" />
|
||||
</div>
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ member.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="showDateColumn && !isSingleSeatPlan"
|
||||
v-if="showRoleColumn && !isSingleSeatPlan"
|
||||
class="text-right text-sm text-muted-foreground"
|
||||
>
|
||||
{{ formatDate(member.joinDate) }}
|
||||
{{
|
||||
member.role === 'owner'
|
||||
? $t('workspaceSwitcher.roleOwner')
|
||||
: $t('workspaceSwitcher.roleMember')
|
||||
}}
|
||||
</span>
|
||||
<div
|
||||
v-if="canRemoveMembers && !isSingleSeatPlan"
|
||||
v-if="canManageMembers && !isSingleSeatPlan"
|
||||
class="flex items-center justify-end"
|
||||
>
|
||||
<Button
|
||||
v-if="!isCurrentUser"
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="$emit('showMenu', $event)"
|
||||
<DropdownMenu
|
||||
v-if="!isCurrentUser && !isOriginalOwner"
|
||||
:entries="menuItems"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import RoleBadge from '@/platform/workspace/components/RoleBadge.vue'
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
showRoleBadge = false,
|
||||
showDateColumn = false,
|
||||
canRemoveMembers = false,
|
||||
showRoleColumn = false,
|
||||
canManageMembers = false,
|
||||
isSingleSeatPlan = false,
|
||||
striped = false
|
||||
isOriginalOwner = false,
|
||||
striped = false,
|
||||
menuItems = []
|
||||
} = defineProps<{
|
||||
member: WorkspaceMember
|
||||
isCurrentUser: boolean
|
||||
photoUrl?: string
|
||||
gridCols: string
|
||||
showRoleBadge?: boolean
|
||||
showDateColumn?: boolean
|
||||
canRemoveMembers?: boolean
|
||||
showRoleColumn?: boolean
|
||||
canManageMembers?: boolean
|
||||
isSingleSeatPlan?: boolean
|
||||
isOriginalOwner?: boolean
|
||||
striped?: boolean
|
||||
menuItems?: MenuItem[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
showMenu: [event: Event]
|
||||
}>()
|
||||
|
||||
const { d } = useI18n()
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return d(date, { dateStyle: 'medium' })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import MemberUpsellBanner from './MemberUpsellBanner.vue'
|
||||
|
||||
const meta: Meta<typeof MemberUpsellBanner> = {
|
||||
title: 'Platform/Workspace/MemberUpsellBanner',
|
||||
component: MemberUpsellBanner,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
reactivate: { control: 'boolean' },
|
||||
onShowPlans: { action: 'showPlans' }
|
||||
},
|
||||
args: {
|
||||
reactivate: false
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div class="w-[720px] bg-base-background p-6"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Workspace that never subscribed to a team plan: acquisition copy.
|
||||
export const Upgrade: Story = {
|
||||
args: { reactivate: false }
|
||||
}
|
||||
|
||||
// Team plan that was subscribed and has since been cancelled or ended: win-back
|
||||
// copy (driven by hasLapsedTeamPlan → subscriptionStatus 'canceled' | 'ended').
|
||||
export const Reactivate: Story = {
|
||||
args: { reactivate: true }
|
||||
}
|
||||
|
||||
export const BothStates: Story = {
|
||||
render: () => ({
|
||||
components: { MemberUpsellBanner },
|
||||
template: `
|
||||
<div class="flex flex-col gap-4">
|
||||
<MemberUpsellBanner :reactivate="false" />
|
||||
<MemberUpsellBanner :reactivate="true" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import MemberUpsellBanner from './MemberUpsellBanner.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderBanner(props: { reactivate?: boolean } = {}) {
|
||||
return render(MemberUpsellBanner, {
|
||||
props,
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('MemberUpsellBanner', () => {
|
||||
it('shows upgrade copy when the workspace never subscribed', () => {
|
||||
renderBanner()
|
||||
|
||||
expect(
|
||||
screen.getByText('To add teammates, upgrade your plan.')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Upgrade to Team' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows reactivate copy when the team plan has lapsed', () => {
|
||||
renderBanner({ reactivate: true })
|
||||
|
||||
expect(
|
||||
screen.getByText('To add more teammates, reactivate your plan.')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Reactivate Team' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits showPlans when the CTA is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderBanner({ reactivate: true })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reactivate Team' }))
|
||||
|
||||
expect(emitted()).toHaveProperty('showPlans')
|
||||
})
|
||||
})
|
||||
@@ -1,20 +1,28 @@
|
||||
<template>
|
||||
<div
|
||||
class="mt-4 flex items-center justify-center gap-2 rounded-xl border border-border-default bg-secondary-background px-4 py-3"
|
||||
class="mt-4 flex w-full items-center justify-between gap-4 rounded-2xl border border-interface-stroke bg-secondary-background p-6 max-sm:flex-col max-sm:items-stretch"
|
||||
>
|
||||
<p class="text-foreground m-0 text-sm">
|
||||
{{
|
||||
isActiveSubscription
|
||||
? $t('workspacePanel.members.upsellBannerUpgrade')
|
||||
: $t('workspacePanel.members.upsellBannerSubscribe')
|
||||
}}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--info] size-4 shrink-0 text-muted-foreground" />
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{
|
||||
reactivate
|
||||
? $t('workspacePanel.members.upsellBannerReactivate')
|
||||
: $t('workspacePanel.members.upsellBanner')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="cursor-pointer text-sm underline"
|
||||
variant="inverted"
|
||||
size="lg"
|
||||
class="max-sm:w-full"
|
||||
@click="$emit('showPlans')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.viewPlans') }}
|
||||
{{
|
||||
reactivate
|
||||
? $t('workspacePanel.members.reactivateTeam')
|
||||
: $t('workspacePanel.members.upgradeToTeam')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -22,8 +30,8 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
isActiveSubscription: boolean
|
||||
const { reactivate = false } = defineProps<{
|
||||
reactivate?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Slots } from 'vue'
|
||||
import { computed, h, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import MembersPanelContent from './MembersPanelContent.vue'
|
||||
@@ -11,21 +12,26 @@ import type {
|
||||
WorkspaceMember
|
||||
} from '../../../stores/teamWorkspaceStore'
|
||||
|
||||
const mockHandleCopyInviteLink = vi.fn()
|
||||
const mockHandleResendInvite = vi.fn()
|
||||
const mockHandleRevokeInvite = vi.fn()
|
||||
const mockHandleCreateWorkspace = vi.fn()
|
||||
const mockShowTeamPlans = vi.fn()
|
||||
const mockSelectMember = vi.fn()
|
||||
const mockMemberMenuItems = vi.fn(() => [])
|
||||
const mockToggleSort = vi.fn()
|
||||
const mockHandleInviteMember = vi.fn()
|
||||
|
||||
const {
|
||||
mockMembers,
|
||||
mockPendingInvites,
|
||||
mockOriginalOwnerId,
|
||||
mockFilteredMembers,
|
||||
mockFilteredPendingInvites,
|
||||
mockIsPersonalWorkspace,
|
||||
mockIsSingleSeatPlan,
|
||||
mockIsActiveSubscription,
|
||||
mockIsOnTeamPlan,
|
||||
mockHasMultipleMembers,
|
||||
mockShowSearch,
|
||||
mockShowViewTabs,
|
||||
mockShowInviteButton,
|
||||
mockIsInviteDisabled,
|
||||
mockActiveView,
|
||||
mockSearchQuery,
|
||||
mockPermissions,
|
||||
@@ -37,11 +43,16 @@ const {
|
||||
return {
|
||||
mockMembers: ref<WorkspaceMember[]>([]),
|
||||
mockPendingInvites: ref<PendingInvite[]>([]),
|
||||
mockOriginalOwnerId: ref<string | null>(null),
|
||||
mockHasMultipleMembers: ref(true),
|
||||
mockShowSearch: ref(true),
|
||||
mockShowViewTabs: ref(true),
|
||||
mockShowInviteButton: ref(true),
|
||||
mockIsInviteDisabled: ref(false),
|
||||
mockFilteredMembers: ref<WorkspaceMember[]>([]),
|
||||
mockFilteredPendingInvites: ref<PendingInvite[]>([]),
|
||||
mockIsPersonalWorkspace: ref(false),
|
||||
mockIsSingleSeatPlan: ref(false),
|
||||
mockIsActiveSubscription: ref(true),
|
||||
mockIsOnTeamPlan: ref(true),
|
||||
mockActiveView: ref<'active' | 'pending'>('active'),
|
||||
mockSearchQuery: ref(''),
|
||||
mockPermissions: ref({
|
||||
@@ -59,8 +70,7 @@ const {
|
||||
showMembersList: true,
|
||||
showPendingTab: true,
|
||||
showSearch: true,
|
||||
showDateColumn: true,
|
||||
showRoleBadge: true,
|
||||
showRoleColumn: true,
|
||||
membersGridCols: 'grid-cols-[50%_40%_10%]',
|
||||
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
|
||||
headerGridCols: 'grid-cols-[50%_40%_10%]',
|
||||
@@ -76,7 +86,14 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
|
||||
searchQuery: mockSearchQuery,
|
||||
activeView: mockActiveView,
|
||||
maxSeats: computed(() => 20),
|
||||
isSingleSeatPlan: mockIsSingleSeatPlan,
|
||||
isOnTeamPlan: mockIsOnTeamPlan,
|
||||
hasMultipleMembers: mockHasMultipleMembers,
|
||||
showSearch: mockShowSearch,
|
||||
showViewTabs: mockShowViewTabs,
|
||||
showInviteButton: mockShowInviteButton,
|
||||
isInviteDisabled: mockIsInviteDisabled,
|
||||
inviteTooltip: computed(() => null),
|
||||
handleInviteMember: mockHandleInviteMember,
|
||||
personalWorkspaceMember: computed(() => ({
|
||||
id: 'self',
|
||||
name: 'Owner User',
|
||||
@@ -87,26 +104,30 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
|
||||
})),
|
||||
filteredMembers: mockFilteredMembers,
|
||||
filteredPendingInvites: mockFilteredPendingInvites,
|
||||
memberMenuItems: computed(() => []),
|
||||
memberMenuItems: mockMemberMenuItems,
|
||||
isPersonalWorkspace: mockIsPersonalWorkspace,
|
||||
members: mockMembers,
|
||||
pendingInvites: mockPendingInvites,
|
||||
permissions: mockPermissions,
|
||||
uiConfig: mockUiConfig,
|
||||
isActiveSubscription: mockIsActiveSubscription,
|
||||
userPhotoUrl: ref(null),
|
||||
isCurrentUser: (m: WorkspaceMember) =>
|
||||
m.email.toLowerCase() === 'owner@example.com',
|
||||
selectMember: mockSelectMember,
|
||||
isOriginalOwner: (m: WorkspaceMember) => m.id === mockOriginalOwnerId.value,
|
||||
toggleSort: mockToggleSort,
|
||||
showTeamPlans: mockShowTeamPlans,
|
||||
handleCopyInviteLink: mockHandleCopyInviteLink,
|
||||
handleResendInvite: mockHandleResendInvite,
|
||||
handleRevokeInvite: mockHandleRevokeInvite,
|
||||
handleCreateWorkspace: mockHandleCreateWorkspace,
|
||||
handleRemoveMember: vi.fn()
|
||||
handleRemoveMember: vi.fn(),
|
||||
handleChangeRole: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/button/MoreButton.vue', () => ({
|
||||
default: (_: unknown, { slots }: { slots: Slots }) =>
|
||||
h('div', slots.default?.({ close: () => {} }))
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -138,7 +159,7 @@ function renderComponent() {
|
||||
Button: ButtonStub,
|
||||
SearchInput: SearchInputStub,
|
||||
UserAvatar: true,
|
||||
Menu: { template: '<div />', props: ['model', 'popup'] }
|
||||
WorkspaceMenuButton: true
|
||||
},
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
@@ -163,7 +184,6 @@ function createInvite(overrides: Partial<PendingInvite> = {}): PendingInvite {
|
||||
return {
|
||||
id: 'invite-1',
|
||||
email: 'invitee@example.com',
|
||||
token: 'token-abc',
|
||||
inviteDate: new Date('2025-03-01'),
|
||||
expiryDate: new Date('2025-04-01'),
|
||||
...overrides
|
||||
@@ -173,13 +193,19 @@ function createInvite(overrides: Partial<PendingInvite> = {}): PendingInvite {
|
||||
describe('MembersPanelContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMemberMenuItems.mockReturnValue([])
|
||||
mockMembers.value = []
|
||||
mockPendingInvites.value = []
|
||||
mockOriginalOwnerId.value = null
|
||||
mockFilteredMembers.value = []
|
||||
mockFilteredPendingInvites.value = []
|
||||
mockIsPersonalWorkspace.value = false
|
||||
mockIsSingleSeatPlan.value = false
|
||||
mockIsActiveSubscription.value = true
|
||||
mockIsOnTeamPlan.value = true
|
||||
mockHasMultipleMembers.value = true
|
||||
mockShowSearch.value = true
|
||||
mockShowViewTabs.value = true
|
||||
mockShowInviteButton.value = true
|
||||
mockIsInviteDisabled.value = false
|
||||
mockActiveView.value = 'active'
|
||||
mockSearchQuery.value = ''
|
||||
mockPermissions.value = {
|
||||
@@ -197,8 +223,7 @@ describe('MembersPanelContent', () => {
|
||||
showMembersList: true,
|
||||
showPendingTab: true,
|
||||
showSearch: true,
|
||||
showDateColumn: true,
|
||||
showRoleBadge: true,
|
||||
showRoleColumn: true,
|
||||
membersGridCols: 'grid-cols-[50%_40%_10%]',
|
||||
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
|
||||
headerGridCols: 'grid-cols-[50%_40%_10%]',
|
||||
@@ -211,27 +236,31 @@ describe('MembersPanelContent', () => {
|
||||
describe('personal workspace', () => {
|
||||
beforeEach(() => {
|
||||
mockIsPersonalWorkspace.value = true
|
||||
mockIsOnTeamPlan.value = false
|
||||
mockHasMultipleMembers.value = false
|
||||
mockShowSearch.value = false
|
||||
mockShowViewTabs.value = false
|
||||
mockIsInviteDisabled.value = true
|
||||
mockUiConfig.value.showMembersList = false
|
||||
mockUiConfig.value.showSearch = false
|
||||
mockUiConfig.value.showPendingTab = false
|
||||
})
|
||||
|
||||
it('shows personal workspace message and create workspace button', () => {
|
||||
it('shows the upsell banner below the members card', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.members.personalWorkspaceMessage')
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.members.createNewWorkspace')
|
||||
screen.getByText('workspacePanel.members.upsellBanner')
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('calls handleCreateWorkspace when create button is clicked', async () => {
|
||||
it('opens team plans on upgrade click', async () => {
|
||||
renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByText('workspacePanel.members.createNewWorkspace')
|
||||
screen.getByRole('button', {
|
||||
name: /workspacePanel\.members\.upgradeToTeam/
|
||||
})
|
||||
)
|
||||
expect(mockHandleCreateWorkspace).toHaveBeenCalled()
|
||||
expect(mockShowTeamPlans).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not show search input', () => {
|
||||
@@ -241,6 +270,19 @@ describe('MembersPanelContent', () => {
|
||||
})
|
||||
|
||||
describe('team workspace - member list', () => {
|
||||
it('shows the Role column header and member roles', () => {
|
||||
mockFilteredMembers.value = [
|
||||
createMember({ role: 'owner', email: 'boss@test.com' }),
|
||||
createMember({ id: '2', role: 'member', email: 'peer@test.com' })
|
||||
]
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.members.columns.role')
|
||||
).toBeTruthy()
|
||||
expect(screen.getByText('workspaceSwitcher.roleOwner')).toBeTruthy()
|
||||
expect(screen.getByText('workspaceSwitcher.roleMember')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders filtered members', () => {
|
||||
mockFilteredMembers.value = [
|
||||
createMember({ name: 'Alice', email: 'alice@test.com' }),
|
||||
@@ -274,6 +316,23 @@ describe('MembersPanelContent', () => {
|
||||
screen.queryAllByRole('button', { name: 'g.moreOptions' })
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not show more options on the original owner row', () => {
|
||||
mockOriginalOwnerId.value = 'creator-1'
|
||||
mockFilteredMembers.value = [
|
||||
createMember({
|
||||
id: 'creator-1',
|
||||
name: 'Creator',
|
||||
email: 'creator@test.com',
|
||||
role: 'owner'
|
||||
}),
|
||||
createMember({ id: '2', name: 'Other', email: 'other@test.com' })
|
||||
]
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryAllByRole('button', { name: 'g.moreOptions' })
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pending invites tab', () => {
|
||||
@@ -285,47 +344,107 @@ describe('MembersPanelContent', () => {
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('triggers handleRevokeInvite on revoke click', async () => {
|
||||
it('triggers handleRevokeInvite from the row menu cancel item', async () => {
|
||||
mockActiveView.value = 'pending'
|
||||
mockFilteredPendingInvites.value = [createInvite({ id: 'inv-42' })]
|
||||
renderComponent()
|
||||
const revokeBtn = screen.getByRole('button', {
|
||||
name: 'workspacePanel.members.actions.revokeInvite'
|
||||
})
|
||||
await userEvent.click(revokeBtn)
|
||||
expect(mockHandleRevokeInvite).toHaveBeenCalled()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'workspacePanel.members.actions.cancelInvite'
|
||||
})
|
||||
)
|
||||
expect(mockHandleRevokeInvite).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'inv-42' })
|
||||
)
|
||||
})
|
||||
|
||||
it('triggers handleCopyInviteLink on copy click', async () => {
|
||||
it('triggers handleResendInvite from the row menu resend item', async () => {
|
||||
mockActiveView.value = 'pending'
|
||||
mockFilteredPendingInvites.value = [createInvite({ id: 'inv-42' })]
|
||||
renderComponent()
|
||||
const copyBtn = screen.getByRole('button', {
|
||||
name: 'workspacePanel.members.actions.copyLink'
|
||||
})
|
||||
await userEvent.click(copyBtn)
|
||||
expect(mockHandleCopyInviteLink).toHaveBeenCalled()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'workspacePanel.members.actions.resendInvite'
|
||||
})
|
||||
)
|
||||
expect(mockHandleResendInvite).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'inv-42' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('single seat plan', () => {
|
||||
describe('member role', () => {
|
||||
beforeEach(() => {
|
||||
mockIsSingleSeatPlan.value = true
|
||||
mockPermissions.value = {
|
||||
canViewOtherMembers: true,
|
||||
canViewPendingInvites: false,
|
||||
canInviteMembers: false,
|
||||
canManageInvites: false,
|
||||
canRemoveMembers: false,
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: false,
|
||||
canTopUp: false
|
||||
}
|
||||
mockUiConfig.value.showPendingTab = false
|
||||
})
|
||||
|
||||
it('hides the pending tab button', () => {
|
||||
mockPendingInvites.value = [createInvite()]
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByText(/workspacePanel\.members\.tabs\.pendingCount/)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show the pending invites header', () => {
|
||||
mockActiveView.value = 'pending'
|
||||
mockPendingInvites.value = [createInvite()]
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByText(/workspacePanel\.members\.pendingInvitesCount/)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no action menus on member rows', () => {
|
||||
mockFilteredMembers.value = [
|
||||
createMember({ name: 'Other', email: 'other@test.com' })
|
||||
]
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryAllByRole('button', { name: 'g.moreOptions' })
|
||||
).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('not on team plan', () => {
|
||||
beforeEach(() => {
|
||||
mockIsOnTeamPlan.value = false
|
||||
mockShowSearch.value = false
|
||||
mockShowViewTabs.value = false
|
||||
})
|
||||
|
||||
it('shows upsell banner', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.members.upsellBannerUpgrade')
|
||||
screen.getByText('workspacePanel.members.upsellBanner')
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens team plans on view plans click', async () => {
|
||||
it('hides the upsell banner when on a team plan', () => {
|
||||
mockIsOnTeamPlan.value = true
|
||||
renderComponent()
|
||||
const viewPlansBtn = screen.getByRole('button', {
|
||||
name: /workspacePanel\.members\.viewPlans/
|
||||
expect(
|
||||
screen.queryByText('workspacePanel.members.upsellBanner')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('opens team plans on upgrade click', async () => {
|
||||
renderComponent()
|
||||
const upgradeBtn = screen.getByRole('button', {
|
||||
name: /workspacePanel\.members\.upgradeToTeam/
|
||||
})
|
||||
await userEvent.click(viewPlansBtn)
|
||||
await userEvent.click(upgradeBtn)
|
||||
expect(mockShowTeamPlans).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -333,6 +452,36 @@ describe('MembersPanelContent', () => {
|
||||
renderComponent()
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the contact us footer', () => {
|
||||
renderComponent()
|
||||
expect(screen.queryByText('workspacePanel.members.contactUs')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('contact us footer', () => {
|
||||
it('opens the cloud enterprise page in a new tab for team workspaces on a team plan', async () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.members.needMoreMembers')
|
||||
).toBeTruthy()
|
||||
await userEvent.click(
|
||||
screen.getByText('workspacePanel.members.contactUs')
|
||||
)
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://comfy.org/cloud/enterprise',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('is hidden in personal workspaces', () => {
|
||||
mockIsPersonalWorkspace.value = true
|
||||
renderComponent()
|
||||
expect(screen.queryByText('workspacePanel.members.contactUs')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('member count display', () => {
|
||||
@@ -348,4 +497,42 @@ describe('MembersPanelContent', () => {
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('card header actions', () => {
|
||||
it('invokes the invite flow from the header invite button', async () => {
|
||||
renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'workspacePanel.inviteMember' })
|
||||
)
|
||||
expect(mockHandleInviteMember).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the invite button without invite access', () => {
|
||||
mockShowInviteButton.value = false
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'workspacePanel.inviteMember' })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('disables the invite button when gated', () => {
|
||||
mockIsInviteDisabled.value = true
|
||||
renderComponent()
|
||||
const button = screen.getByRole('button', {
|
||||
name: 'workspacePanel.inviteMember'
|
||||
})
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the view tabs for a lone owner', () => {
|
||||
mockShowViewTabs.value = false
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByText('workspacePanel.members.tabs.active')
|
||||
).toBeNull()
|
||||
expect(
|
||||
screen.queryByText('workspacePanel.members.columns.role')
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,15 +8,17 @@
|
||||
<div class="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span class="text-base font-semibold text-base-foreground">
|
||||
<template v-if="activeView === 'active'">
|
||||
{{
|
||||
$t('workspacePanel.members.membersCount', {
|
||||
count:
|
||||
isSingleSeatPlan || isPersonalWorkspace
|
||||
? 1
|
||||
: members.length,
|
||||
maxSeats: maxSeats
|
||||
})
|
||||
}}
|
||||
<template v-if="isOnTeamPlan && !isPersonalWorkspace">
|
||||
{{
|
||||
$t('workspacePanel.members.membersCount', {
|
||||
count: members.length,
|
||||
maxSeats: maxSeats
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('workspacePanel.members.header') }}
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="permissions.canViewPendingInvites">
|
||||
{{
|
||||
@@ -28,16 +30,31 @@
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="uiConfig.showSearch && !isSingleSeatPlan"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SearchInput
|
||||
v-if="showSearch"
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search')"
|
||||
:placeholder="$t('workspacePanel.members.searchPlaceholder')"
|
||||
size="lg"
|
||||
class="w-64"
|
||||
/>
|
||||
<Button
|
||||
v-if="showInviteButton"
|
||||
v-tooltip="
|
||||
inviteTooltip
|
||||
? { value: inviteTooltip, showDelay: 0 }
|
||||
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
|
||||
"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="isInviteDisabled"
|
||||
:aria-label="$t('workspacePanel.inviteMember')"
|
||||
@click="handleInviteMember"
|
||||
>
|
||||
{{ $t('workspacePanel.invite') }}
|
||||
<i class="pi pi-plus text-sm" />
|
||||
</Button>
|
||||
<WorkspaceMenuButton v-if="permissions.canAccessWorkspaceMenu" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,20 +62,18 @@
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<!-- Table Header with Tab Buttons and Column Headers -->
|
||||
<div
|
||||
v-if="uiConfig.showMembersList"
|
||||
v-if="uiConfig.showMembersList && showViewTabs"
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center py-2',
|
||||
isSingleSeatPlan
|
||||
? 'grid-cols-1 py-0'
|
||||
: activeView === 'pending'
|
||||
? uiConfig.pendingGridCols
|
||||
: uiConfig.headerGridCols
|
||||
activeView === 'pending'
|
||||
? uiConfig.pendingGridCols
|
||||
: uiConfig.headerGridCols
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Tab buttons in first column -->
|
||||
<div v-if="!isSingleSeatPlan" class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:variant="
|
||||
activeView === 'active' ? 'secondary' : 'muted-textonly'
|
||||
@@ -107,19 +122,17 @@
|
||||
<div />
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="!isSingleSeatPlan">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-end"
|
||||
@click="toggleSort('joinDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.joinDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<!-- Empty cell for action column header (OWNER only) -->
|
||||
<div v-if="permissions.canRemoveMembers" />
|
||||
</template>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-end"
|
||||
@click="toggleSort('role')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.role') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<!-- Empty cell for action column header (OWNER only) -->
|
||||
<div v-if="permissions.canRemoveMembers" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -134,7 +147,6 @@
|
||||
:is-current-user="true"
|
||||
:photo-url="userPhotoUrl ?? undefined"
|
||||
:grid-cols="uiConfig.membersGridCols"
|
||||
:show-role-badge="uiConfig.showRoleBadge"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -151,70 +163,78 @@
|
||||
: undefined
|
||||
"
|
||||
:grid-cols="uiConfig.membersGridCols"
|
||||
:show-role-badge="uiConfig.showRoleBadge"
|
||||
:show-date-column="uiConfig.showDateColumn"
|
||||
:can-remove-members="permissions.canRemoveMembers"
|
||||
:is-single-seat-plan="isSingleSeatPlan"
|
||||
:show-role-column="
|
||||
uiConfig.showRoleColumn && hasMultipleMembers
|
||||
"
|
||||
:can-manage-members="permissions.canRemoveMembers"
|
||||
:is-single-seat-plan="!isOnTeamPlan"
|
||||
:is-original-owner="isOriginalOwner(member)"
|
||||
:striped="index % 2 === 1"
|
||||
@show-menu="showMemberMenu($event, member)"
|
||||
:menu-items="memberMenuItems(member)"
|
||||
/>
|
||||
|
||||
<!-- Member actions menu (shared for all members) -->
|
||||
<Menu ref="memberMenu" :model="memberMenuItems" :popup="true" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Upsell Banner -->
|
||||
<MemberUpsellBanner
|
||||
v-if="isSingleSeatPlan"
|
||||
:is-active-subscription="isActiveSubscription"
|
||||
@show-plans="showTeamPlans()"
|
||||
/>
|
||||
|
||||
<!-- Pending Invites -->
|
||||
<PendingInvitesList
|
||||
v-if="activeView === 'pending'"
|
||||
:invites="filteredPendingInvites"
|
||||
:grid-cols="uiConfig.pendingGridCols"
|
||||
@copy-link="handleCopyInviteLink"
|
||||
@resend="handleResendInvite"
|
||||
@revoke="handleRevokeInvite"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Personal Workspace Message -->
|
||||
<div v-if="isPersonalWorkspace" class="flex items-center">
|
||||
<!-- Upsell Banner -->
|
||||
<MemberUpsellBanner
|
||||
v-if="!isOnTeamPlan"
|
||||
:reactivate="hasLapsedTeamPlan"
|
||||
@show-plans="showTeamPlans()"
|
||||
/>
|
||||
<!-- Need More Members Footer -->
|
||||
<div
|
||||
v-if="isOnTeamPlan && !isPersonalWorkspace"
|
||||
class="flex items-center pt-2"
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.members.personalWorkspaceMessage') }}
|
||||
{{ $t('workspacePanel.members.needMoreMembers') }}
|
||||
</p>
|
||||
<button
|
||||
class="cursor-pointer border-none bg-transparent underline"
|
||||
@click="handleCreateWorkspace"
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="text-base-foreground"
|
||||
@click="handleContactUs"
|
||||
>
|
||||
{{ $t('workspacePanel.members.createNewWorkspace') }}
|
||||
</button>
|
||||
{{ $t('workspacePanel.members.contactUs') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Menu from 'primevue/menu'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import MemberListItem from '@/platform/workspace/components/dialogs/settings/MemberListItem.vue'
|
||||
import MemberUpsellBanner from '@/platform/workspace/components/dialogs/settings/MemberUpsellBanner.vue'
|
||||
import PendingInvitesList from '@/platform/workspace/components/dialogs/settings/PendingInvitesList.vue'
|
||||
import WorkspaceMenuButton from '@/platform/workspace/components/dialogs/settings/WorkspaceMenuButton.vue'
|
||||
import { useMembersPanel } from '@/platform/workspace/composables/useMembersPanel'
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
activeView,
|
||||
maxSeats,
|
||||
isSingleSeatPlan,
|
||||
isOnTeamPlan,
|
||||
hasLapsedTeamPlan,
|
||||
hasMultipleMembers,
|
||||
showSearch,
|
||||
showViewTabs,
|
||||
showInviteButton,
|
||||
isInviteDisabled,
|
||||
inviteTooltip,
|
||||
handleInviteMember,
|
||||
personalWorkspaceMember,
|
||||
filteredMembers,
|
||||
filteredPendingInvites,
|
||||
@@ -224,21 +244,20 @@ const {
|
||||
pendingInvites,
|
||||
permissions,
|
||||
uiConfig,
|
||||
isActiveSubscription,
|
||||
userPhotoUrl,
|
||||
isCurrentUser,
|
||||
selectMember,
|
||||
isOriginalOwner,
|
||||
toggleSort,
|
||||
showTeamPlans,
|
||||
handleCopyInviteLink,
|
||||
handleRevokeInvite,
|
||||
handleCreateWorkspace
|
||||
handleResendInvite,
|
||||
handleRevokeInvite
|
||||
} = useMembersPanel()
|
||||
|
||||
const memberMenu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
function showMemberMenu(event: Event, member: WorkspaceMember) {
|
||||
selectMember(member)
|
||||
memberMenu.value?.toggle(event)
|
||||
function handleContactUs() {
|
||||
window.open(
|
||||
'https://comfy.org/cloud/enterprise',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Slots } from 'vue'
|
||||
import { h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import PendingInvitesList from './PendingInvitesList.vue'
|
||||
|
||||
import type { PendingInvite } from '../../../stores/teamWorkspaceStore'
|
||||
|
||||
const mockMenuClose = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/components/button/MoreButton.vue', () => ({
|
||||
default: (_: unknown, { slots }: { slots: Slots }) =>
|
||||
h('div', slots.default?.({ close: mockMenuClose }))
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function createInvite(overrides: Partial<PendingInvite> = {}): PendingInvite {
|
||||
return {
|
||||
id: 'invite-1',
|
||||
email: 'invitee@example.com',
|
||||
inviteDate: new Date('2025-03-01'),
|
||||
expiryDate: new Date('2025-04-01'),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent(invites: PendingInvite[]) {
|
||||
return render(PendingInvitesList, {
|
||||
props: {
|
||||
invites,
|
||||
gridCols: 'grid-cols-[50%_20%_20%_10%]'
|
||||
},
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('PendingInvitesList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows the empty state without action buttons when there are no invites', () => {
|
||||
renderComponent([])
|
||||
|
||||
expect(screen.getByText('workspacePanel.members.noInvites')).toBeTruthy()
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('emits resend with the invite and closes the menu', async () => {
|
||||
const invite = createInvite({ id: 'inv-7' })
|
||||
const { emitted } = renderComponent([invite])
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'workspacePanel.members.actions.resendInvite'
|
||||
})
|
||||
)
|
||||
|
||||
expect(emitted('resend')).toEqual([[invite]])
|
||||
expect(mockMenuClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits revoke with the invite from the cancel item', async () => {
|
||||
const invite = createInvite({ id: 'inv-8' })
|
||||
const { emitted } = renderComponent([invite])
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'workspacePanel.members.actions.cancelInvite'
|
||||
})
|
||||
)
|
||||
|
||||
expect(emitted('revoke')).toEqual([[invite]])
|
||||
})
|
||||
})
|
||||
@@ -34,31 +34,37 @@
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ formatDate(invite.expiryDate) }}
|
||||
</span>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: $t('workspacePanel.members.actions.copyLink'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:aria-label="$t('workspacePanel.members.actions.copyLink')"
|
||||
@click="$emit('copyLink', invite)"
|
||||
>
|
||||
<i class="icon-[lucide--link] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: $t('workspacePanel.members.actions.revokeInvite'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:aria-label="$t('workspacePanel.members.actions.revokeInvite')"
|
||||
@click="$emit('revoke', invite)"
|
||||
>
|
||||
<i class="icon-[lucide--mail-x] size-4" />
|
||||
</Button>
|
||||
<div class="flex items-center justify-end">
|
||||
<MoreButton v-slot="{ close }" :aria-label="$t('g.moreOptions')">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="menuItemClass"
|
||||
@click="
|
||||
() => {
|
||||
close()
|
||||
$emit('resend', invite)
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--mail-plus] size-4" />
|
||||
<span>{{ $t('workspacePanel.members.actions.resendInvite') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="menuItemClass"
|
||||
@click="
|
||||
() => {
|
||||
close()
|
||||
$emit('revoke', invite)
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--mail-x] size-4" />
|
||||
<span>{{ $t('workspacePanel.members.actions.cancelInvite') }}</span>
|
||||
</Button>
|
||||
</MoreButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -73,17 +79,20 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { PendingInvite } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const menuItemClass = 'w-full justify-start rounded-sm px-3 py-2'
|
||||
|
||||
defineProps<{
|
||||
invites: PendingInvite[]
|
||||
gridCols: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
copyLink: [invite: PendingInvite]
|
||||
resend: [invite: PendingInvite]
|
||||
revoke: [invite: PendingInvite]
|
||||
}>()
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import WorkspaceMenuButton from './WorkspaceMenuButton.vue'
|
||||
|
||||
const CREATOR = {
|
||||
id: 'creator',
|
||||
name: 'Creator',
|
||||
email: 'creator@test.com',
|
||||
joinDate: new Date('2026-01-01T00:00:00Z'),
|
||||
role: 'owner' as const,
|
||||
isOriginalOwner: true
|
||||
}
|
||||
const LATER_OWNER = {
|
||||
id: 'owner-2',
|
||||
name: 'Owner Two',
|
||||
email: 'owner2@test.com',
|
||||
joinDate: new Date('2026-02-01T00:00:00Z'),
|
||||
role: 'owner' as const,
|
||||
isOriginalOwner: false
|
||||
}
|
||||
const LATER_MEMBER = {
|
||||
id: 'member-2',
|
||||
name: 'Member Two',
|
||||
email: 'member2@test.com',
|
||||
joinDate: new Date('2026-02-01T00:00:00Z'),
|
||||
role: 'member' as const,
|
||||
isOriginalOwner: false
|
||||
}
|
||||
|
||||
const ownerConfig = {
|
||||
showEditWorkspaceMenuItem: true,
|
||||
workspaceMenuAction: 'delete' as const,
|
||||
workspaceMenuDisabledTooltip:
|
||||
'workspacePanel.menu.deleteWorkspaceDisabledTooltip'
|
||||
}
|
||||
const memberConfig = {
|
||||
showEditWorkspaceMenuItem: false,
|
||||
workspaceMenuAction: 'leave' as const,
|
||||
workspaceMenuDisabledTooltip: null
|
||||
}
|
||||
|
||||
const mockUiConfig = ref<Record<string, unknown>>(ownerConfig)
|
||||
const mockMembers = ref<WorkspaceMember[]>([CREATOR, LATER_OWNER])
|
||||
const mockUserEmail = ref<string | null>('owner2@test.com')
|
||||
const mockIsWorkspaceSubscribed = ref(false)
|
||||
|
||||
const mockShowLeaveWorkspaceDialog = vi.fn()
|
||||
const mockShowDeleteWorkspaceDialog = vi.fn()
|
||||
const mockShowEditWorkspaceDialog = vi.fn()
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({ uiConfig: mockUiConfig })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
isWorkspaceSubscribed: mockIsWorkspaceSubscribed,
|
||||
members: mockMembers
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ userEmail: mockUserEmail })
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showLeaveWorkspaceDialog: mockShowLeaveWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog: mockShowDeleteWorkspaceDialog,
|
||||
showEditWorkspaceDialog: mockShowEditWorkspaceDialog
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
const DropdownMenuStub = {
|
||||
props: ['entries'],
|
||||
template:
|
||||
'<div data-testid="menu"><button v-for="entry in entries" :key="entry.label" type="button" :disabled="entry.disabled" @click="entry.command?.()">{{ entry.label }}</button></div>'
|
||||
}
|
||||
|
||||
function renderComponent() {
|
||||
return render(WorkspaceMenuButton, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: {} },
|
||||
stubs: { DropdownMenu: DropdownMenuStub, Button: true }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkspaceMenuButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUiConfig.value = ownerConfig
|
||||
mockMembers.value = [CREATOR, LATER_OWNER]
|
||||
mockUserEmail.value = 'owner2@test.com'
|
||||
mockIsWorkspaceSubscribed.value = false
|
||||
})
|
||||
|
||||
it('lets a member leave and offers no destructive workspace actions', () => {
|
||||
mockUiConfig.value = memberConfig
|
||||
mockMembers.value = [CREATOR, LATER_MEMBER]
|
||||
mockUserEmail.value = 'member2@test.com'
|
||||
renderComponent()
|
||||
|
||||
const leave = screen.getByRole('button', { name: 'Leave Workspace' })
|
||||
expect(leave).toBeEnabled()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Delete Workspace' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets a non-creator owner leave', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Leave Workspace' })
|
||||
).toBeEnabled()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Delete Workspace' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the creator a disabled Leave option', () => {
|
||||
mockUserEmail.value = 'creator@test.com'
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Leave Workspace' })
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
it('opens the leave dialog when a non-creator owner clicks Leave', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Leave Workspace' }))
|
||||
expect(mockShowLeaveWorkspaceDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not open the leave dialog for the creator', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockUserEmail.value = 'creator@test.com'
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Leave Workspace' }))
|
||||
expect(mockShowLeaveWorkspaceDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<DropdownMenu :entries="menuItems">
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon-lg"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
showLeaveWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog,
|
||||
showEditWorkspaceDialog
|
||||
} = useDialogService()
|
||||
const { isWorkspaceSubscribed, members } = storeToRefs(useTeamWorkspaceStore())
|
||||
const { uiConfig } = useWorkspaceUI()
|
||||
const { userEmail } = useCurrentUser()
|
||||
|
||||
// The creator (earliest-joined member) can't leave their own workspace.
|
||||
const isCurrentUserCreator = computed(() => {
|
||||
const email = userEmail.value?.toLowerCase()
|
||||
if (!email || members.value.length === 0) return false
|
||||
const currentMember = members.value.find(
|
||||
(member) => member.email.toLowerCase() === email
|
||||
)
|
||||
return (
|
||||
!!currentMember &&
|
||||
members.value.every((member) => currentMember.joinDate <= member.joinDate)
|
||||
)
|
||||
})
|
||||
|
||||
// Disable delete when the workspace has an active subscription (prevents
|
||||
// accidental deletion); uses the workspace's own status, not the global one.
|
||||
const isDeleteDisabled = computed(
|
||||
() =>
|
||||
uiConfig.value.workspaceMenuAction === 'delete' &&
|
||||
isWorkspaceSubscribed.value
|
||||
)
|
||||
|
||||
const deleteTooltip = computed(() => {
|
||||
if (!isDeleteDisabled.value) return undefined
|
||||
const tooltipKey = uiConfig.value.workspaceMenuDisabledTooltip
|
||||
return tooltipKey ? t(tooltipKey) : undefined
|
||||
})
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = []
|
||||
|
||||
if (uiConfig.value.showEditWorkspaceMenuItem) {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.editWorkspace'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: () => showEditWorkspaceDialog()
|
||||
})
|
||||
}
|
||||
|
||||
const action = uiConfig.value.workspaceMenuAction
|
||||
if (action === 'delete') {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.deleteWorkspace'),
|
||||
icon: 'pi pi-trash',
|
||||
class: isDeleteDisabled.value ? 'text-danger/50' : 'text-danger',
|
||||
disabled: isDeleteDisabled.value,
|
||||
tooltip: deleteTooltip.value,
|
||||
command: isDeleteDisabled.value
|
||||
? undefined
|
||||
: () => showDeleteWorkspaceDialog()
|
||||
})
|
||||
}
|
||||
|
||||
// Members and non-creator owners can leave; the creator sees it disabled.
|
||||
if (action === 'leave' || action === 'delete') {
|
||||
items.push(
|
||||
isCurrentUserCreator.value
|
||||
? {
|
||||
label: t('workspacePanel.menu.leaveWorkspace'),
|
||||
icon: 'pi pi-sign-out',
|
||||
class: 'opacity-50',
|
||||
disabled: true,
|
||||
tooltip: t('workspacePanel.menu.creatorCannotLeave')
|
||||
}
|
||||
: {
|
||||
label: t('workspacePanel.menu.leaveWorkspace'),
|
||||
icon: 'pi pi-sign-out',
|
||||
command: () => showLeaveWorkspaceDialog()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,129 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import WorkspacePanelContent from './WorkspacePanelContent.vue'
|
||||
|
||||
const mockFetchMembers = vi.fn()
|
||||
const mockFetchPendingInvites = vi.fn()
|
||||
|
||||
const { mockMembers, mockWorkspaceType } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
|
||||
return {
|
||||
mockMembers: ref<WorkspaceMember[]>([]),
|
||||
mockWorkspaceType: ref<'personal' | 'team'>('team')
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
storeToRefs: (store: Record<string, unknown>) => store
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
return {
|
||||
useTeamWorkspaceStore: () => ({
|
||||
workspaceName: ref('Acme Team'),
|
||||
members: mockMembers,
|
||||
fetchMembers: mockFetchMembers,
|
||||
fetchPendingInvites: mockFetchPendingInvites
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
return {
|
||||
useWorkspaceUI: () => ({
|
||||
workspaceType: mockWorkspaceType,
|
||||
workspaceRole: ref('owner')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue',
|
||||
() => ({
|
||||
default: { name: 'SubscriptionPanelContentWorkspace', template: '<div />' }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue',
|
||||
() => ({
|
||||
default: { name: 'MembersPanelContent', template: '<div />' }
|
||||
})
|
||||
)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function createMember(id: string): WorkspaceMember {
|
||||
return {
|
||||
id,
|
||||
name: `Member ${id}`,
|
||||
email: `member${id}@example.com`,
|
||||
joinDate: new Date('2025-01-15'),
|
||||
role: 'member',
|
||||
isOriginalOwner: false
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent() {
|
||||
return render(WorkspacePanelContent, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { WorkspaceProfilePic: true }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkspacePanelContent members tab label', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMembers.value = []
|
||||
mockWorkspaceType.value = 'team'
|
||||
})
|
||||
|
||||
it('shows the counted label for team workspaces with multiple members', () => {
|
||||
mockMembers.value = [createMember('1'), createMember('2')]
|
||||
renderComponent()
|
||||
expect(screen.getByText(/workspacePanel\.tabs\.membersCount/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('drops the count when the owner is the only member', () => {
|
||||
mockMembers.value = [createMember('1')]
|
||||
renderComponent()
|
||||
expect(screen.getByText('workspacePanel.members.header')).toBeTruthy()
|
||||
expect(screen.queryByText(/workspacePanel\.tabs\.membersCount/)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the plain Members label for personal workspaces', () => {
|
||||
mockWorkspaceType.value = 'personal'
|
||||
mockMembers.value = [createMember('1'), createMember('2')]
|
||||
renderComponent()
|
||||
expect(screen.getByText('workspacePanel.members.header')).toBeTruthy()
|
||||
expect(screen.queryByText(/workspacePanel\.tabs\.membersCount/)).toBeNull()
|
||||
})
|
||||
|
||||
it('fetches members and pending invites on mount', () => {
|
||||
renderComponent()
|
||||
expect(mockFetchMembers).toHaveBeenCalled()
|
||||
expect(mockFetchPendingInvites).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,107 +1,73 @@
|
||||
<template>
|
||||
<div class="flex size-full flex-col">
|
||||
<header class="mb-8 flex items-center gap-4">
|
||||
<header class="mb-6 flex items-center gap-4">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 text-3xl!"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<h1 class="text-3xl text-base-foreground">
|
||||
<h1 class="text-3xl font-semibold text-base-foreground">
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
</header>
|
||||
<TabsRoot v-model="activeTab">
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<TabsList class="flex items-center gap-2 pb-1">
|
||||
<TabsTrigger
|
||||
value="plan"
|
||||
:class="
|
||||
cn(
|
||||
tabTriggerBase,
|
||||
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ $t('workspacePanel.tabs.planCredits') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="members"
|
||||
:class="
|
||||
cn(
|
||||
tabTriggerBase,
|
||||
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
$t('workspacePanel.tabs.membersCount', {
|
||||
count: members.length
|
||||
})
|
||||
}}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
v-if="permissions.canInviteMembers"
|
||||
v-tooltip="
|
||||
inviteTooltip
|
||||
? { value: inviteTooltip, showDelay: 0 }
|
||||
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
|
||||
"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="!isSingleSeatPlan && isInviteLimitReached"
|
||||
:class="
|
||||
!isSingleSeatPlan &&
|
||||
isInviteLimitReached &&
|
||||
'cursor-not-allowed opacity-50'
|
||||
"
|
||||
:aria-label="$t('workspacePanel.inviteMember')"
|
||||
@click="handleInviteMember"
|
||||
>
|
||||
<i class="pi pi-plus text-sm" />
|
||||
</Button>
|
||||
<template v-if="permissions.canAccessWorkspaceMenu">
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="menu?.toggle($event)"
|
||||
<TabsList class="flex items-center gap-2 pb-1">
|
||||
<TabsTrigger
|
||||
value="plan"
|
||||
:class="
|
||||
cn(
|
||||
tabTriggerBase,
|
||||
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="grid">
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'col-start-1 row-start-1',
|
||||
activeTab === 'plan' && 'font-bold'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
<Menu ref="menu" :model="menuItems" :popup="true">
|
||||
<template #item="{ item }">
|
||||
<button
|
||||
v-tooltip="
|
||||
item.disabled && deleteTooltip
|
||||
? { value: deleteTooltip, showDelay: 0 }
|
||||
: null
|
||||
"
|
||||
type="button"
|
||||
:disabled="!!item.disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full cursor-pointer items-center gap-2 border-none bg-transparent px-3 py-2',
|
||||
item.class,
|
||||
item.disabled && 'pointer-events-auto cursor-not-allowed'
|
||||
)
|
||||
"
|
||||
@click="
|
||||
item.command?.({
|
||||
originalEvent: $event,
|
||||
item
|
||||
})
|
||||
"
|
||||
>
|
||||
<i :class="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{{ planTabLabel }}
|
||||
</span>
|
||||
<span
|
||||
class="invisible col-start-1 row-start-1 font-bold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ planTabLabel }}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="members"
|
||||
:class="
|
||||
cn(
|
||||
tabTriggerBase,
|
||||
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="grid">
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'col-start-1 row-start-1',
|
||||
activeTab === 'members' && 'font-bold'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ membersTabLabel }}
|
||||
</span>
|
||||
<span
|
||||
class="invisible col-start-1 row-start-1 font-bold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ membersTabLabel }}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="plan" class="mt-4">
|
||||
<SubscriptionPanelContentWorkspace />
|
||||
@@ -115,7 +81,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Menu from 'primevue/menu'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -123,19 +88,15 @@ import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const tabTriggerBase =
|
||||
'flex items-center justify-center shrink-0 px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200 outline-hidden border-none'
|
||||
const tabTriggerActive =
|
||||
'bg-interface-menu-component-surface-hovered text-text-primary font-bold'
|
||||
'bg-interface-menu-component-surface-hovered text-text-primary'
|
||||
const tabTriggerInactive =
|
||||
'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
|
||||
|
||||
@@ -143,108 +104,26 @@ const { defaultTab = 'plan' } = defineProps<{
|
||||
defaultTab?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
showLeaveWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog,
|
||||
showInviteMemberDialog,
|
||||
showInviteMemberUpsellDialog,
|
||||
showEditWorkspaceDialog
|
||||
} = 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 { workspaceName, members, isInviteLimitReached, isWorkspaceSubscribed } =
|
||||
storeToRefs(workspaceStore)
|
||||
const { workspaceName, members } = storeToRefs(workspaceStore)
|
||||
const { fetchMembers, fetchPendingInvites } = workspaceStore
|
||||
|
||||
const { workspaceRole, permissions, uiConfig } = useWorkspaceUI()
|
||||
const { workspaceType, workspaceRole } = useWorkspaceUI()
|
||||
const isPersonalWorkspace = computed(() => workspaceType.value === 'personal')
|
||||
const activeTab = ref(defaultTab)
|
||||
|
||||
const menu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
function handleLeaveWorkspace() {
|
||||
showLeaveWorkspaceDialog()
|
||||
}
|
||||
|
||||
function handleDeleteWorkspace() {
|
||||
showDeleteWorkspaceDialog()
|
||||
}
|
||||
|
||||
function handleEditWorkspace() {
|
||||
showEditWorkspaceDialog()
|
||||
}
|
||||
|
||||
// Disable delete when workspace has an active subscription (to prevent accidental deletion)
|
||||
// Use workspace's own subscription status, not the global isActiveSubscription
|
||||
const isDeleteDisabled = computed(
|
||||
() =>
|
||||
uiConfig.value.workspaceMenuAction === 'delete' &&
|
||||
isWorkspaceSubscribed.value
|
||||
// Per design, the tab counts members only when there is more than the owner
|
||||
const showMembersTabCount = computed(
|
||||
() => !isPersonalWorkspace.value && members.value.length > 1
|
||||
)
|
||||
|
||||
const deleteTooltip = computed(() => {
|
||||
if (!isDeleteDisabled.value) return null
|
||||
const tooltipKey = uiConfig.value.workspaceMenuDisabledTooltip
|
||||
return tooltipKey ? t(tooltipKey) : null
|
||||
})
|
||||
|
||||
const inviteTooltip = computed(() => {
|
||||
if (isSingleSeatPlan.value) return null
|
||||
if (!isInviteLimitReached.value) return null
|
||||
return t('workspacePanel.inviteLimitReached')
|
||||
})
|
||||
|
||||
function handleInviteMember() {
|
||||
if (isSingleSeatPlan.value) {
|
||||
showInviteMemberUpsellDialog()
|
||||
return
|
||||
}
|
||||
if (isInviteLimitReached.value) return
|
||||
showInviteMemberDialog()
|
||||
}
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const items = []
|
||||
|
||||
// Add edit option for owners
|
||||
if (uiConfig.value.showEditWorkspaceMenuItem) {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.editWorkspace'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: handleEditWorkspace
|
||||
})
|
||||
}
|
||||
|
||||
const action = uiConfig.value.workspaceMenuAction
|
||||
if (action === 'delete') {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.deleteWorkspace'),
|
||||
icon: 'pi pi-trash',
|
||||
class: isDeleteDisabled.value
|
||||
? 'text-danger/50 cursor-not-allowed'
|
||||
: 'text-danger',
|
||||
disabled: isDeleteDisabled.value,
|
||||
command: isDeleteDisabled.value ? undefined : handleDeleteWorkspace
|
||||
})
|
||||
} else if (action === 'leave') {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.leaveWorkspace'),
|
||||
icon: 'pi pi-sign-out',
|
||||
command: handleLeaveWorkspace
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
const { t } = useI18n()
|
||||
const planTabLabel = computed(() => t('workspacePanel.tabs.planCredits'))
|
||||
const membersTabLabel = computed(() =>
|
||||
showMembersTabCount.value
|
||||
? t('workspacePanel.tabs.membersCount', { count: members.value.length })
|
||||
: t('workspacePanel.members.header')
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
fetchMembers()
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import { useDowngradeToPersonal } from './useDowngradeToPersonal'
|
||||
|
||||
const mockMembers = ref<WorkspaceMember[]>([])
|
||||
const mockUserEmail = ref<string | null>(null)
|
||||
const mockMembers = vi.hoisted(() => ({
|
||||
value: [] as WorkspaceMember[]
|
||||
}))
|
||||
const mockUserEmail = vi.hoisted(() => ({
|
||||
value: null as string | null
|
||||
}))
|
||||
const mockRemoveMember = vi.hoisted(() => vi.fn())
|
||||
const mockFetchMembers = vi.hoisted(() => vi.fn())
|
||||
const mockSubscribe = vi.hoisted(() => vi.fn())
|
||||
@@ -49,8 +52,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key} ${JSON.stringify(params)}` : key
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/config/comfyApi', () => ({
|
||||
@@ -166,11 +168,10 @@ describe('useDowngradeToPersonal', () => {
|
||||
expect(mockRemoveMember).toHaveBeenCalledWith('m1')
|
||||
expect(mockRemoveMember).toHaveBeenCalledWith('m2')
|
||||
expect(mockRemoveMember).not.toHaveBeenCalledWith('owner')
|
||||
expect(mockSubscribe).toHaveBeenCalledWith(
|
||||
'founder-monthly',
|
||||
'https://platform.test/payment/success',
|
||||
'https://platform.test/payment/failed'
|
||||
)
|
||||
expect(mockSubscribe).toHaveBeenCalledWith('founder-monthly', {
|
||||
returnUrl: 'https://platform.test/payment/success',
|
||||
cancelUrl: 'https://platform.test/payment/failed'
|
||||
})
|
||||
expect(mockStartOperation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -295,39 +296,15 @@ describe('useDowngradeToPersonal', () => {
|
||||
expect(mockStartOperation).toHaveBeenCalledWith('op-4', 'subscription')
|
||||
})
|
||||
|
||||
it('reports the generic failure when subscribe fails and no members were removed', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd()
|
||||
mockSubscribe.mockResolvedValue(undefined)
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
/^subscription\.downgrade\.failed$/
|
||||
)
|
||||
})
|
||||
|
||||
it('reports members were already removed when subscribe fails after removal', async () => {
|
||||
it('throws when subscribe resolves without a response', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockSubscribe.mockResolvedValue(undefined)
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'subscription.downgrade.failedAfterMemberRemoval'
|
||||
'subscription.downgrade.failed'
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces which member failed and skips the plan change when removal throws', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1', 'm2')
|
||||
mockRemoveMember.mockImplementation((id: string) =>
|
||||
id === 'm2' ? Promise.reject(new Error('network')) : Promise.resolve()
|
||||
)
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'm2@example.com'
|
||||
)
|
||||
expect(mockRemoveMember).toHaveBeenCalledWith('m1')
|
||||
expect(mockSubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshMembers', () => {
|
||||
@@ -338,7 +315,6 @@ describe('useDowngradeToPersonal', () => {
|
||||
return Promise.resolve(mockMembers.value)
|
||||
})
|
||||
const { refreshMembers, hasOtherMembers } = useDowngradeToPersonal()
|
||||
expect(hasOtherMembers.value).toBe(false)
|
||||
|
||||
await refreshMembers()
|
||||
|
||||
|
||||
@@ -42,31 +42,16 @@ export function useDowngradeToPersonal() {
|
||||
throw new Error(preview?.reason || t('subscription.downgrade.notAllowed'))
|
||||
}
|
||||
|
||||
const membersToRemove = removableMembers.value
|
||||
for (const member of membersToRemove) {
|
||||
try {
|
||||
await workspaceStore.removeMember(member.id)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
t('subscription.downgrade.memberRemovalFailed', {
|
||||
email: member.email
|
||||
}),
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
for (const member of removableMembers.value) {
|
||||
await workspaceStore.removeMember(member.id)
|
||||
}
|
||||
|
||||
const response = await subscribe(
|
||||
planSlug,
|
||||
`${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
`${getComfyPlatformBaseUrl()}/payment/failed`
|
||||
)
|
||||
const response = await subscribe(planSlug, {
|
||||
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`
|
||||
})
|
||||
if (!response) {
|
||||
throw new Error(
|
||||
membersToRemove.length > 0
|
||||
? t('subscription.downgrade.failedAfterMemberRemoval')
|
||||
: t('subscription.downgrade.failed')
|
||||
)
|
||||
throw new Error(t('subscription.downgrade.failed'))
|
||||
}
|
||||
|
||||
if (response.status === 'needs_payment_method') {
|
||||
|
||||
@@ -30,7 +30,6 @@ function createInvite(overrides: Partial<PendingInvite> = {}): PendingInvite {
|
||||
return {
|
||||
id: 'invite-1',
|
||||
email: 'invitee@example.com',
|
||||
token: 'token-abc',
|
||||
inviteDate: new Date('2025-03-01'),
|
||||
expiryDate: new Date('2025-04-01'),
|
||||
...overrides
|
||||
@@ -38,7 +37,7 @@ function createInvite(overrides: Partial<PendingInvite> = {}): PendingInvite {
|
||||
}
|
||||
|
||||
describe('sortMembers', () => {
|
||||
it('places owners before members', () => {
|
||||
it('places owners before members when sorting descending', () => {
|
||||
const owner = createMember({ id: 'o', role: 'owner', name: 'Owner' })
|
||||
const member = createMember({ id: 'm', role: 'member', name: 'Member' })
|
||||
const result = sortMembers([member, owner], null, 'desc')
|
||||
@@ -46,6 +45,14 @@ describe('sortMembers', () => {
|
||||
expect(result[1].id).toBe('m')
|
||||
})
|
||||
|
||||
it('places members before owners when sorting ascending', () => {
|
||||
const owner = createMember({ id: 'o', role: 'owner', name: 'Owner' })
|
||||
const member = createMember({ id: 'm', role: 'member', name: 'Member' })
|
||||
const result = sortMembers([member, owner], null, 'asc')
|
||||
expect(result[0].id).toBe('m')
|
||||
expect(result[1].id).toBe('o')
|
||||
})
|
||||
|
||||
it('places current user after owners but before others', () => {
|
||||
const owner = createMember({
|
||||
id: 'o',
|
||||
@@ -107,6 +114,43 @@ describe('sortMembers', () => {
|
||||
sortMembers(members, null, 'desc')
|
||||
expect(members).toEqual(original)
|
||||
})
|
||||
|
||||
it('pins the original owner first regardless of sort direction', () => {
|
||||
const creator = createMember({
|
||||
id: 'creator',
|
||||
role: 'owner',
|
||||
email: 'creator@test.com',
|
||||
joinDate: new Date('2025-01-01')
|
||||
})
|
||||
const promoted = createMember({
|
||||
id: 'promoted',
|
||||
role: 'owner',
|
||||
email: 'me@test.com',
|
||||
joinDate: new Date('2025-02-01')
|
||||
})
|
||||
const member = createMember({
|
||||
id: 'm',
|
||||
role: 'member',
|
||||
email: 'other@test.com',
|
||||
joinDate: new Date('2025-03-01')
|
||||
})
|
||||
|
||||
const desc = sortMembers(
|
||||
[member, promoted, creator],
|
||||
'me@test.com',
|
||||
'desc',
|
||||
'creator'
|
||||
)
|
||||
expect(desc.map((m) => m.id)).toEqual(['creator', 'promoted', 'm'])
|
||||
|
||||
const asc = sortMembers(
|
||||
[member, promoted, creator],
|
||||
'me@test.com',
|
||||
'asc',
|
||||
'creator'
|
||||
)
|
||||
expect(asc[0].id).toBe('creator')
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterBySearch', () => {
|
||||
@@ -172,7 +216,7 @@ describe('sortPendingInvites', () => {
|
||||
expect(result[1].id).toBe('l')
|
||||
})
|
||||
|
||||
it('falls back to inviteDate when sortField is joinDate', () => {
|
||||
it('falls back to inviteDate when sortField is role', () => {
|
||||
const early = createInvite({
|
||||
id: 'e',
|
||||
inviteDate: new Date('2025-01-01')
|
||||
@@ -181,7 +225,7 @@ describe('sortPendingInvites', () => {
|
||||
id: 'l',
|
||||
inviteDate: new Date('2025-06-01')
|
||||
})
|
||||
const result = sortPendingInvites([early, late], 'joinDate', 'desc')
|
||||
const result = sortPendingInvites([early, late], 'role', 'desc')
|
||||
expect(result[0].id).toBe('l')
|
||||
})
|
||||
|
||||
@@ -203,16 +247,22 @@ describe('sortPendingInvites', () => {
|
||||
})
|
||||
|
||||
const mockToastAdd = vi.fn()
|
||||
const mockCopyInviteLink = vi.fn()
|
||||
const mockResendInvite = vi.fn()
|
||||
const mockShowRemoveMemberDialog = vi.fn()
|
||||
const mockShowRevokeInviteDialog = vi.fn()
|
||||
const mockShowCreateWorkspaceDialog = vi.fn()
|
||||
const mockShowChangeMemberRoleDialog = vi.fn()
|
||||
const mockShowSubscriptionDialog = vi.fn()
|
||||
const mockShowInviteMemberDialog = vi.fn()
|
||||
const mockShowInviteMemberUpsellDialog = vi.fn()
|
||||
|
||||
const {
|
||||
mockMembers,
|
||||
mockPendingInvites,
|
||||
mockOriginalOwnerId,
|
||||
mockIsInPersonalWorkspace,
|
||||
mockIsWorkspaceSubscribed,
|
||||
mockTotalMemberSlots,
|
||||
mockIsInviteLimitReached,
|
||||
mockPermissions,
|
||||
mockUiConfig,
|
||||
mockIsActiveSubscription,
|
||||
@@ -224,7 +274,11 @@ const {
|
||||
return {
|
||||
mockMembers: ref<WorkspaceMember[]>([]),
|
||||
mockPendingInvites: ref<PendingInvite[]>([]),
|
||||
mockOriginalOwnerId: ref<string | null>(null),
|
||||
mockIsInPersonalWorkspace: ref(false),
|
||||
mockIsWorkspaceSubscribed: ref(true),
|
||||
mockTotalMemberSlots: ref(0),
|
||||
mockIsInviteLimitReached: ref(false),
|
||||
mockPermissions: ref({
|
||||
canViewOtherMembers: true,
|
||||
canViewPendingInvites: true,
|
||||
@@ -240,8 +294,7 @@ const {
|
||||
showMembersList: true,
|
||||
showPendingTab: true,
|
||||
showSearch: true,
|
||||
showDateColumn: true,
|
||||
showRoleBadge: true,
|
||||
showRoleColumn: true,
|
||||
membersGridCols: 'grid-cols-[50%_40%_10%]',
|
||||
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
|
||||
headerGridCols: 'grid-cols-[50%_40%_10%]',
|
||||
@@ -250,7 +303,10 @@ const {
|
||||
workspaceMenuDisabledTooltip: null as string | null
|
||||
}),
|
||||
mockIsActiveSubscription: ref(true),
|
||||
mockSubscription: ref<{ tier: string } | null>({ tier: 'PRO' })
|
||||
mockSubscription: ref<{ tier: string; isCancelled?: boolean } | null>({
|
||||
tier: 'PRO',
|
||||
isCancelled: false
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -271,11 +327,16 @@ vi.mock('pinia', async (importOriginal) => {
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
MAX_WORKSPACE_MEMBERS: 30,
|
||||
useTeamWorkspaceStore: () => ({
|
||||
members: mockMembers,
|
||||
pendingInvites: mockPendingInvites,
|
||||
originalOwnerId: mockOriginalOwnerId,
|
||||
totalMemberSlots: mockTotalMemberSlots,
|
||||
isInviteLimitReached: mockIsInviteLimitReached,
|
||||
isInPersonalWorkspace: mockIsInPersonalWorkspace,
|
||||
copyInviteLink: mockCopyInviteLink
|
||||
isWorkspaceSubscribed: mockIsWorkspaceSubscribed,
|
||||
resendInvite: mockResendInvite
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -294,11 +355,17 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({ show: mockShowSubscriptionDialog })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: mockIsActiveSubscription,
|
||||
subscription: mockSubscription,
|
||||
showSubscriptionDialog: mockShowSubscriptionDialog,
|
||||
getMaxSeats: (tierKey: string) => {
|
||||
const seats: Record<string, number> = {
|
||||
free: 1,
|
||||
@@ -311,28 +378,13 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/constants/tierPricing', () => ({
|
||||
TIER_TO_KEY: {
|
||||
FREE: 'free',
|
||||
STANDARD: 'standard',
|
||||
CREATOR: 'creator',
|
||||
PRO: 'pro',
|
||||
FOUNDERS_EDITION: 'founder'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({ show: vi.fn() })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showRemoveMemberDialog: mockShowRemoveMemberDialog,
|
||||
showRevokeInviteDialog: mockShowRevokeInviteDialog,
|
||||
showCreateWorkspaceDialog: mockShowCreateWorkspaceDialog
|
||||
showChangeMemberRoleDialog: mockShowChangeMemberRoleDialog,
|
||||
showInviteMemberDialog: mockShowInviteMemberDialog,
|
||||
showInviteMemberUpsellDialog: mockShowInviteMemberUpsellDialog
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -341,9 +393,13 @@ describe('useMembersPanel', () => {
|
||||
vi.clearAllMocks()
|
||||
mockMembers.value = []
|
||||
mockPendingInvites.value = []
|
||||
mockOriginalOwnerId.value = null
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
mockIsWorkspaceSubscribed.value = true
|
||||
mockTotalMemberSlots.value = 0
|
||||
mockIsInviteLimitReached.value = false
|
||||
mockIsActiveSubscription.value = true
|
||||
mockSubscription.value = { tier: 'PRO' }
|
||||
mockSubscription.value = { tier: 'PRO', isCancelled: false }
|
||||
})
|
||||
|
||||
// Lazy import so mocks are in place
|
||||
@@ -352,49 +408,28 @@ describe('useMembersPanel', () => {
|
||||
return useMembersPanel()
|
||||
}
|
||||
|
||||
describe('isSingleSeatPlan', () => {
|
||||
it('is false for personal workspace', async () => {
|
||||
describe('team plan detection', () => {
|
||||
it('is on the team plan for a subscribed team workspace', async () => {
|
||||
const panel = await setup()
|
||||
expect(panel.isOnTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is off the team plan in a personal workspace', async () => {
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const panel = await setup()
|
||||
expect(panel.isSingleSeatPlan.value).toBe(false)
|
||||
expect(panel.isOnTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
it('is true when no active subscription', async () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
it('is off the team plan when the team workspace is not subscribed', async () => {
|
||||
mockIsWorkspaceSubscribed.value = false
|
||||
const panel = await setup()
|
||||
expect(panel.isSingleSeatPlan.value).toBe(true)
|
||||
expect(panel.isOnTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
it('is true for standard tier (1 seat)', async () => {
|
||||
mockSubscription.value = { tier: 'STANDARD' }
|
||||
it('caps members at the flat team maximum regardless of tier', async () => {
|
||||
mockSubscription.value = { tier: 'CREATOR', isCancelled: false }
|
||||
const panel = await setup()
|
||||
expect(panel.isSingleSeatPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for pro tier (20 seats)', async () => {
|
||||
mockSubscription.value = { tier: 'PRO' }
|
||||
const panel = await setup()
|
||||
expect(panel.isSingleSeatPlan.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('maxSeats', () => {
|
||||
it('returns 1 for personal workspace', async () => {
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const panel = await setup()
|
||||
expect(panel.maxSeats.value).toBe(1)
|
||||
})
|
||||
|
||||
it('returns tier-appropriate seats', async () => {
|
||||
mockSubscription.value = { tier: 'CREATOR' }
|
||||
const panel = await setup()
|
||||
expect(panel.maxSeats.value).toBe(5)
|
||||
})
|
||||
|
||||
it('returns 1 when no subscription', async () => {
|
||||
mockSubscription.value = null
|
||||
const panel = await setup()
|
||||
expect(panel.maxSeats.value).toBe(1)
|
||||
expect(panel.maxSeats.value).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -462,23 +497,29 @@ describe('useMembersPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleCopyInviteLink', () => {
|
||||
it('shows success toast on success', async () => {
|
||||
mockCopyInviteLink.mockResolvedValue(undefined)
|
||||
describe('handleResendInvite', () => {
|
||||
it('resends the invite and shows a success toast', async () => {
|
||||
mockResendInvite.mockResolvedValue(undefined)
|
||||
const panel = await setup()
|
||||
await panel.handleCopyInviteLink(createInvite({ id: 'inv-1' }))
|
||||
expect(mockCopyInviteLink).toHaveBeenCalledWith('inv-1')
|
||||
await panel.handleResendInvite(createInvite({ id: 'inv-1' }))
|
||||
expect(mockResendInvite).toHaveBeenCalledWith('inv-1')
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
expect.objectContaining({
|
||||
severity: 'success',
|
||||
summary: 'workspacePanel.toast.inviteResent'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('shows error toast on failure', async () => {
|
||||
mockCopyInviteLink.mockRejectedValue(new Error('fail'))
|
||||
mockResendInvite.mockRejectedValue(new Error('fail'))
|
||||
const panel = await setup()
|
||||
await panel.handleCopyInviteLink(createInvite({ id: 'inv-1' }))
|
||||
await panel.handleResendInvite(createInvite({ id: 'inv-1' }))
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
summary: 'workspacePanel.toast.inviteResendFailed'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -491,14 +532,6 @@ describe('useMembersPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleCreateWorkspace', () => {
|
||||
it('calls showCreateWorkspaceDialog', async () => {
|
||||
const panel = await setup()
|
||||
panel.handleCreateWorkspace()
|
||||
expect(mockShowCreateWorkspaceDialog).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleRemoveMember', () => {
|
||||
it('calls showRemoveMemberDialog', async () => {
|
||||
const panel = await setup()
|
||||
@@ -506,4 +539,222 @@ describe('useMembersPanel', () => {
|
||||
expect(mockShowRemoveMemberDialog).toHaveBeenCalledWith('mem-7')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleChangeRole', () => {
|
||||
it('opens the change-role dialog when promoting a member', async () => {
|
||||
const panel = await setup()
|
||||
panel.handleChangeRole(
|
||||
createMember({ id: 'mem-7', name: 'Jane', role: 'member' }),
|
||||
'owner'
|
||||
)
|
||||
expect(mockShowChangeMemberRoleDialog).toHaveBeenCalledWith({
|
||||
memberId: 'mem-7',
|
||||
memberName: 'Jane',
|
||||
targetRole: 'owner'
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the change-role dialog when demoting an owner', async () => {
|
||||
const panel = await setup()
|
||||
panel.handleChangeRole(
|
||||
createMember({ id: 'own-2', name: 'Jane', role: 'owner' }),
|
||||
'member'
|
||||
)
|
||||
expect(mockShowChangeMemberRoleDialog).toHaveBeenCalledWith({
|
||||
memberId: 'own-2',
|
||||
memberName: 'Jane',
|
||||
targetRole: 'member'
|
||||
})
|
||||
})
|
||||
|
||||
it('is a no-op when the member already has the target role', async () => {
|
||||
const panel = await setup()
|
||||
panel.handleChangeRole(createMember({ role: 'member' }), 'member')
|
||||
expect(mockShowChangeMemberRoleDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('memberMenuItems', () => {
|
||||
it('builds a Change role submenu with the current role checked', async () => {
|
||||
const panel = await setup()
|
||||
const items = panel.memberMenuItems(createMember({ role: 'member' }))
|
||||
|
||||
expect(items.map((i) => i.label)).toEqual([
|
||||
'workspacePanel.members.actions.changeRole',
|
||||
'workspacePanel.members.actions.removeMember'
|
||||
])
|
||||
|
||||
const roleItems = items[0].items ?? []
|
||||
expect(roleItems.map((i) => i.label)).toEqual([
|
||||
'workspaceSwitcher.roleOwner',
|
||||
'workspaceSwitcher.roleMember'
|
||||
])
|
||||
expect(roleItems.map((i) => i.checked)).toEqual([false, true])
|
||||
})
|
||||
|
||||
it('checks Owner for owner rows', async () => {
|
||||
const panel = await setup()
|
||||
const items = panel.memberMenuItems(createMember({ role: 'owner' }))
|
||||
const roleItems = items[0].items ?? []
|
||||
expect(roleItems.map((i) => i.checked)).toEqual([true, false])
|
||||
})
|
||||
|
||||
it('routes submenu selection to the change-role dialog', async () => {
|
||||
const panel = await setup()
|
||||
const member = createMember({ id: 'mem-9', role: 'member' })
|
||||
const ownerItem = (panel.memberMenuItems(member)[0].items ?? [])[0]
|
||||
|
||||
ownerItem.command?.({
|
||||
originalEvent: new Event('click'),
|
||||
item: ownerItem
|
||||
})
|
||||
|
||||
expect(mockShowChangeMemberRoleDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ memberId: 'mem-9', targetRole: 'owner' })
|
||||
)
|
||||
})
|
||||
|
||||
it('routes Remove member to the remove dialog', async () => {
|
||||
const panel = await setup()
|
||||
const member = createMember({ id: 'mem-9' })
|
||||
const removeItem = panel.memberMenuItems(member)[1]
|
||||
|
||||
removeItem.command?.({
|
||||
originalEvent: new Event('click'),
|
||||
item: removeItem
|
||||
})
|
||||
|
||||
expect(mockShowRemoveMemberDialog).toHaveBeenCalledWith('mem-9')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOriginalOwner', () => {
|
||||
it('matches against the store originalOwnerId', async () => {
|
||||
mockOriginalOwnerId.value = 'creator-1'
|
||||
const panel = await setup()
|
||||
expect(panel.isOriginalOwner(createMember({ id: 'creator-1' }))).toBe(
|
||||
true
|
||||
)
|
||||
expect(panel.isOriginalOwner(createMember({ id: 'other' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('single-member visibility gating', () => {
|
||||
it('hides search and view tabs when the owner is alone', async () => {
|
||||
mockMembers.value = [
|
||||
createMember({ role: 'owner', email: 'owner@example.com' })
|
||||
]
|
||||
const panel = await setup()
|
||||
expect(panel.showSearch.value).toBe(false)
|
||||
expect(panel.showViewTabs.value).toBe(false)
|
||||
})
|
||||
|
||||
it('shows search and view tabs with more than one member', async () => {
|
||||
mockMembers.value = [createMember(), createMember({ id: '2' })]
|
||||
const panel = await setup()
|
||||
expect(panel.showSearch.value).toBe(true)
|
||||
expect(panel.showViewTabs.value).toBe(true)
|
||||
})
|
||||
|
||||
it('shows view tabs for a lone owner with pending invites', async () => {
|
||||
mockMembers.value = [
|
||||
createMember({ role: 'owner', email: 'owner@example.com' })
|
||||
]
|
||||
mockPendingInvites.value = [createInvite()]
|
||||
const panel = await setup()
|
||||
expect(panel.showViewTabs.value).toBe(true)
|
||||
expect(panel.showSearch.value).toBe(false)
|
||||
})
|
||||
|
||||
it('shows search for >1 member regardless of tier', async () => {
|
||||
mockSubscription.value = { tier: 'STANDARD', isCancelled: false }
|
||||
mockMembers.value = [createMember(), createMember({ id: '2' })]
|
||||
const panel = await setup()
|
||||
expect(panel.showSearch.value).toBe(true)
|
||||
expect(panel.showViewTabs.value).toBe(true)
|
||||
})
|
||||
|
||||
it('hides view tabs when the team workspace is not subscribed', async () => {
|
||||
mockIsWorkspaceSubscribed.value = false
|
||||
mockMembers.value = [createMember(), createMember({ id: '2' })]
|
||||
const panel = await setup()
|
||||
expect(panel.showViewTabs.value).toBe(false)
|
||||
expect(panel.showSearch.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invite gating', () => {
|
||||
it('opens the invite dialog on an active team plan', async () => {
|
||||
const panel = await setup()
|
||||
panel.handleInviteMember()
|
||||
expect(mockShowInviteMemberDialog).toHaveBeenCalled()
|
||||
expect(mockShowInviteMemberUpsellDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the upsell dialog when not on a team plan', async () => {
|
||||
mockIsWorkspaceSubscribed.value = false
|
||||
const panel = await setup()
|
||||
panel.handleInviteMember()
|
||||
expect(mockShowInviteMemberUpsellDialog).toHaveBeenCalled()
|
||||
expect(mockShowInviteMemberDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables the invite button at the member cap (30)', async () => {
|
||||
mockTotalMemberSlots.value = 30
|
||||
const panel = await setup()
|
||||
expect(panel.isInviteDisabled.value).toBe(true)
|
||||
expect(panel.inviteTooltip.value).toBe(
|
||||
'workspacePanel.inviteLimitReached'
|
||||
)
|
||||
panel.handleInviteMember()
|
||||
expect(mockShowInviteMemberDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the invite button enabled below the member cap', async () => {
|
||||
mockTotalMemberSlots.value = 29
|
||||
const panel = await setup()
|
||||
expect(panel.isInviteDisabled.value).toBe(false)
|
||||
expect(panel.inviteTooltip.value).toBeNull()
|
||||
})
|
||||
|
||||
it('disables the invite button at the flat backend member cap', async () => {
|
||||
mockIsInviteLimitReached.value = true
|
||||
const panel = await setup()
|
||||
expect(panel.isInviteDisabled.value).toBe(true)
|
||||
})
|
||||
|
||||
it('disables the invite button when not on a team plan', async () => {
|
||||
mockIsWorkspaceSubscribed.value = false
|
||||
const panel = await setup()
|
||||
expect(panel.isInviteDisabled.value).toBe(true)
|
||||
})
|
||||
|
||||
it('disables the invite button when the team plan is cancelled', async () => {
|
||||
mockSubscription.value = { tier: 'PRO', isCancelled: true }
|
||||
const panel = await setup()
|
||||
expect(panel.isInviteDisabled.value).toBe(true)
|
||||
panel.handleInviteMember()
|
||||
expect(mockShowInviteMemberDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a disabled invite button in a personal workspace', async () => {
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
mockPermissions.value = {
|
||||
...mockPermissions.value,
|
||||
canInviteMembers: false
|
||||
}
|
||||
const panel = await setup()
|
||||
expect(panel.showInviteButton.value).toBe(true)
|
||||
expect(panel.isInviteDisabled.value).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the invite button for members without invite permission', async () => {
|
||||
mockPermissions.value = {
|
||||
...mockPermissions.value,
|
||||
canInviteMembers: false
|
||||
}
|
||||
const panel = await setup()
|
||||
expect(panel.showInviteButton.value).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user