mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +00:00
Compare commits
6 Commits
updates_to
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99674df73b | ||
|
|
cfaf89edea | ||
|
|
a154e6a311 | ||
|
|
46fec1d47d | ||
|
|
1052658a02 | ||
|
|
f524683f3c |
9
.fallowrc.json
Normal file
9
.fallowrc.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
|
||||
"entry": ["src/main.ts", "index.html"],
|
||||
"duplicates": {
|
||||
"minOccurrences": 3,
|
||||
"ignore": ["**/*.generated.*", "**/generatedManagerTypes.ts"]
|
||||
},
|
||||
"rules": {}
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,6 +16,7 @@ yarn.lock
|
||||
.eslintcache
|
||||
.prettiercache
|
||||
.stylelintcache
|
||||
.fallow/
|
||||
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
@@ -88,6 +88,11 @@ const config: StorybookConfig = {
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/composables/useWorkspaceUI',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useWorkspaceUI.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/stores/teamWorkspaceStore',
|
||||
replacement:
|
||||
|
||||
@@ -10,11 +10,13 @@ const PAYMENT_STATUSES = ['success', 'failed'] as const
|
||||
const LOCALE_PREFIXES = LOCALES.map((locale) =>
|
||||
locale === DEFAULT_LOCALE ? '' : `/${locale}`
|
||||
)
|
||||
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
|
||||
LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
const SITEMAP_EXCLUDED_PATHNAMES = new Set([
|
||||
...LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
|
||||
)
|
||||
)
|
||||
),
|
||||
...LOCALE_PREFIXES.map((prefix) => `${prefix}/individual-submission`),
|
||||
...LOCALE_PREFIXES.map((prefix) => `${prefix}/booking-confirmation`)
|
||||
])
|
||||
|
||||
function isExcludedFromSitemap(page: string): boolean {
|
||||
const pathname = new URL(page).pathname.replace(/\/$/, '')
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
const { resources } = defineProps<{
|
||||
resources: { label: string; href: string; display: string }[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="flex flex-col gap-3 text-base font-light lg:text-lg">
|
||||
<li v-for="resource in resources" :key="resource.href">
|
||||
<span class="text-primary-comfy-canvas">{{ resource.label }}</span>
|
||||
<span class="text-primary-warm-gray"> — </span>
|
||||
<a
|
||||
:href="resource.href"
|
||||
class="text-primary-comfy-yellow underline underline-offset-4 hover:no-underline"
|
||||
>
|
||||
{{ resource.display }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '../ui/button/Button.vue'
|
||||
|
||||
const { href, label } = defineProps<{
|
||||
href: string
|
||||
label: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-2 flex justify-center">
|
||||
<Button as="a" :href variant="default" size="lg">
|
||||
{{ label }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,12 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
const { title } = defineProps<{ title: string }>()
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { title, class: className } = defineProps<{
|
||||
title: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex items-center justify-center px-6 pt-20 pb-16 lg:pt-32 lg:pb-24"
|
||||
>
|
||||
<h1 class="text-primary-comfy-canvas text-4xl font-light lg:text-6xl">
|
||||
<h1
|
||||
:class="
|
||||
cn(
|
||||
'text-4xl font-light text-primary-comfy-canvas lg:text-6xl',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
54
apps/website/src/pages/booking-confirmation.astro
Normal file
54
apps/website/src/pages/booking-confirmation.astro
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ResourceList from '../components/booking-confirmation/ResourceList.vue'
|
||||
import HeroSection from '../components/legal/HeroSection.vue'
|
||||
import { externalLinks, getRoutes } from '../config/routes'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
|
||||
const resources = [
|
||||
{
|
||||
label: 'Learning Center',
|
||||
href: routes.learning,
|
||||
display: 'comfy.org/learning'
|
||||
},
|
||||
{
|
||||
label: 'Workflow templates',
|
||||
href: externalLinks.workflows,
|
||||
display: 'comfy.org/workflows'
|
||||
},
|
||||
{
|
||||
label: 'Customer stories',
|
||||
href: routes.customers,
|
||||
display: 'comfy.org/customers'
|
||||
},
|
||||
{
|
||||
label: 'Docs',
|
||||
href: externalLinks.docs,
|
||||
display: 'docs.comfy.org'
|
||||
}
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="You're booked - Comfy"
|
||||
description="Your meeting is booked. Check your email for the calendar invite and meeting link."
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="You're booked." class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>Check your email for the calendar invite and meeting link!</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
|
||||
Resources while you wait
|
||||
</h2>
|
||||
<ResourceList resources={resources} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
60
apps/website/src/pages/individual-submission.astro
Normal file
60
apps/website/src/pages/individual-submission.astro
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import PlansPricingCta from '../components/individual-submission/PlansPricingCta.vue'
|
||||
import HeroSection from '../components/legal/HeroSection.vue'
|
||||
import { getRoutes } from '../config/routes'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Thanks for reaching out - Comfy"
|
||||
description="Thanks for reaching out. Based on what you shared, one of our self-serve plans is probably a better fit."
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="Thanks for reaching out." class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>
|
||||
Based on what you shared, one of our self-serve plans is probably a
|
||||
better fit.
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Standard</strong
|
||||
>,
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Creator</strong
|
||||
>, and
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Pro</strong
|
||||
> for individual creators, plus our new
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Teams</strong
|
||||
> plan for multiple users under shared billing.
|
||||
</p>
|
||||
|
||||
<PlansPricingCta
|
||||
href={routes.cloudPricing}
|
||||
label="See plans and pricing →"
|
||||
/>
|
||||
|
||||
<p class="text-primary-warm-gray mt-8 text-sm">
|
||||
Still think you have an enterprise need?<br /> We're happy to assist. Email: <a
|
||||
href="mailto:gtm-team@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>gtm-team@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<p class="text-primary-warm-gray text-sm">
|
||||
Need help with something else? Email: <a
|
||||
href="mailto:support@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>support@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
54
apps/website/src/pages/zh-CN/booking-confirmation.astro
Normal file
54
apps/website/src/pages/zh-CN/booking-confirmation.astro
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ResourceList from '../../components/booking-confirmation/ResourceList.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
|
||||
const resources = [
|
||||
{
|
||||
label: '学习中心',
|
||||
href: routes.learning,
|
||||
display: 'comfy.org/learning'
|
||||
},
|
||||
{
|
||||
label: '工作流模板',
|
||||
href: externalLinks.workflows,
|
||||
display: 'comfy.org/workflows'
|
||||
},
|
||||
{
|
||||
label: '客户案例',
|
||||
href: routes.customers,
|
||||
display: 'comfy.org/customers'
|
||||
},
|
||||
{
|
||||
label: '文档',
|
||||
href: externalLinks.docs,
|
||||
display: 'docs.comfy.org'
|
||||
}
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="预约成功 - Comfy"
|
||||
description="您的会议已预约成功。请查收邮件中的日历邀请和会议链接。"
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="预约成功。" class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>请查收邮件中的日历邀请和会议链接!</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
|
||||
等待期间的资源
|
||||
</h2>
|
||||
<ResourceList resources={resources} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
57
apps/website/src/pages/zh-CN/individual-submission.astro
Normal file
57
apps/website/src/pages/zh-CN/individual-submission.astro
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import PlansPricingCta from '../../components/individual-submission/PlansPricingCta.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
import { getRoutes } from '../../config/routes'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="感谢您的联系 - Comfy"
|
||||
description="感谢您的联系。根据您提供的信息,我们的自助服务套餐之一可能更适合您。"
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="感谢您的联系。" class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>
|
||||
根据您提供的信息,我们的自助服务套餐之一可能更适合您。面向个人创作者的
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Standard</strong
|
||||
>、
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Creator</strong
|
||||
>
|
||||
和
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Pro</strong
|
||||
>,以及我们全新的
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Teams</strong
|
||||
> 套餐,可让多位用户共享账单。
|
||||
</p>
|
||||
|
||||
<PlansPricingCta href={routes.cloudPricing} label="查看套餐与价格 →" />
|
||||
|
||||
<p class="text-primary-warm-gray mt-8 text-sm">
|
||||
仍然认为您有企业级需求?我们很乐意为您提供帮助。邮箱:<a
|
||||
href="mailto:gtm-team@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>gtm-team@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<p class="text-primary-warm-gray text-sm">
|
||||
需要其他方面的帮助?邮箱:<a
|
||||
href="mailto:support@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>support@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -54,6 +54,11 @@
|
||||
"source": "/press",
|
||||
"destination": "/about",
|
||||
"permanent": true
|
||||
},
|
||||
{
|
||||
"source": "/login",
|
||||
"destination": "https://cloud.comfy.org/login",
|
||||
"permanent": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -28,11 +28,11 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
// consolidated_billing_enabled routes personal workspaces to the unified
|
||||
// pricing table asserted here; without it they fall back to the legacy table.
|
||||
// billing_control_enabled routes personal workspaces to the unified pricing
|
||||
// table asserted here; without it they fall back to the legacy table.
|
||||
const BOOT_FEATURES = {
|
||||
team_workspaces_enabled: true,
|
||||
consolidated_billing_enabled: true
|
||||
billing_control_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
// Disable the experimental Asset API: with it on (cloud default) the unmocked
|
||||
// asset endpoints 403 and workflow restore throws uncaught, aborting the
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"dev:test": "cross-env VITE_USE_LEGACY_DEFAULT_GRAPH=true vite --config vite.config.mts",
|
||||
"dev": "vite --config vite.config.mts",
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||
"fallow": "fallow",
|
||||
"fallow:audit": "fallow audit",
|
||||
"format:check": "oxfmt --check",
|
||||
"format": "oxfmt --write",
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||
@@ -172,6 +174,7 @@
|
||||
"eslint-plugin-testing-library": "catalog:",
|
||||
"eslint-plugin-unused-imports": "catalog:",
|
||||
"eslint-plugin-vue": "catalog:",
|
||||
"fallow": "catalog:",
|
||||
"fast-check": "catalog:",
|
||||
"fs-extra": "^11.2.0",
|
||||
"globals": "catalog:",
|
||||
|
||||
@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
escapeVueI18nMessageSyntax,
|
||||
formatLocalizedMediumDate,
|
||||
formatLocalizedNumber,
|
||||
getFilePathSeparatorVariants,
|
||||
@@ -478,49 +477,4 @@ describe('formatUtil', () => {
|
||||
expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeVueI18nMessageSyntax', () => {
|
||||
it('escapes a literal @ that would break linked-message compilation', () => {
|
||||
expect(
|
||||
escapeVueI18nMessageSyntax('clips (tagged @Audio1-3 in the prompt)')
|
||||
).toBe("clips (tagged {'@'}Audio1-3 in the prompt)")
|
||||
})
|
||||
|
||||
it('escapes @ in an email address', () => {
|
||||
expect(escapeVueI18nMessageSyntax('support@comfy.org')).toBe(
|
||||
"support{'@'}comfy.org"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes interpolation braces', () => {
|
||||
expect(escapeVueI18nMessageSyntax('size {w}x{h}')).toBe(
|
||||
"size {'{'}w{'}'}x{'{'}h{'}'}"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the plural pipe separator', () => {
|
||||
expect(escapeVueI18nMessageSyntax('foreground | background')).toBe(
|
||||
"foreground {'|'} background"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the modulo percent so it cannot re-form %{', () => {
|
||||
expect(escapeVueI18nMessageSyntax('50%{done}')).toBe(
|
||||
"50{'%'}{'{'}done{'}'}"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes every occurrence in a single pass', () => {
|
||||
expect(escapeVueI18nMessageSyntax('@a @b @c')).toBe(
|
||||
"{'@'}a {'@'}b {'@'}c"
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves strings without syntax characters unchanged', () => {
|
||||
expect(escapeVueI18nMessageSyntax('no special chars here')).toBe(
|
||||
'no special chars here'
|
||||
)
|
||||
expect(escapeVueI18nMessageSyntax('')).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -178,40 +178,6 @@ export function normalizeI18nKey(key: string) {
|
||||
return typeof key === 'string' ? key.replace(/\./g, '_') : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Characters that vue-i18n's message compiler treats as syntax in message text,
|
||||
* so plain text has to escape them to render verbatim through `t()`/`st()`:
|
||||
*
|
||||
* - `@` starts a linked-message reference (`@:key`); malformed usage throws
|
||||
* `Invalid linked format`.
|
||||
* - `{` / `}` delimit interpolation (`{name}`, `{'literal'}`); an unbalanced
|
||||
* brace throws `Unterminated/Unbalanced closing brace`.
|
||||
* - `|` separates plural branches, so `a | b` silently renders as one branch.
|
||||
* - `%` forms modulo interpolation when immediately followed by `{` (`%{name}`);
|
||||
* it must be escaped too, otherwise escaping a following `{` re-forms `%{`.
|
||||
*
|
||||
* The set is a build-inlined `const enum` (`TokenChars`) in
|
||||
* `@intlify/message-compiler` and is not exported, so it is hardcoded here.
|
||||
*
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/syntax (Special Characters, Literal interpolation)
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/pluralization
|
||||
*/
|
||||
const VUE_I18N_SYNTAX_CHARS = /[@{}|%]/g
|
||||
|
||||
/**
|
||||
* Escapes vue-i18n message-syntax characters as literal interpolations (`{'x'}`)
|
||||
* so arbitrary text renders verbatim instead of being parsed as syntax. This is
|
||||
* the only escape vue-i18n supports; see {@link VUE_I18N_SYNTAX_CHARS}.
|
||||
*
|
||||
* Only apply to values read through the compiler (`t()`/`st()`). Values read raw
|
||||
* via `tm()`/`stRaw()` (e.g. node tooltips) must be left untouched, or the
|
||||
* literal `{'x'}` would surface to users. Apply exactly once to raw text: the
|
||||
* escape output itself contains `{`/`}`, so it is not idempotent.
|
||||
*/
|
||||
export function escapeVueI18nMessageSyntax(text: string): string {
|
||||
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments.
|
||||
* @param input The dynamic prompt to process
|
||||
|
||||
88
pnpm-lock.yaml
generated
88
pnpm-lock.yaml
generated
@@ -240,6 +240,9 @@ catalogs:
|
||||
eslint-plugin-vue:
|
||||
specifier: ^10.9.1
|
||||
version: 10.9.1
|
||||
fallow:
|
||||
specifier: ^2.102.0
|
||||
version: 2.102.0
|
||||
fast-check:
|
||||
specifier: ^4.5.3
|
||||
version: 4.5.3
|
||||
@@ -763,6 +766,9 @@ importers:
|
||||
eslint-plugin-vue:
|
||||
specifier: 'catalog:'
|
||||
version: 10.9.1(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.4.0(jiti@2.6.1)))
|
||||
fallow:
|
||||
specifier: 'catalog:'
|
||||
version: 2.102.0
|
||||
fast-check:
|
||||
specifier: 'catalog:'
|
||||
version: 4.5.3
|
||||
@@ -1908,6 +1914,46 @@ packages:
|
||||
'@exodus/crypto':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/darwin-arm64@2.102.0':
|
||||
resolution: {integrity: sha512-B8wzfzJgoX6h5Gv2xQ9ZidO5Jb8/PWdssAxYbWs1pb5oJHZ6S5PLwXuUdINmNSIaRGQwTk4DC9/tIMFHFvd9uw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@fallow-cli/darwin-x64@2.102.0':
|
||||
resolution: {integrity: sha512-aLbTWWzQnleKdi56obAPXMJm7YA3qAnkIX9T3eocRHiagYqp8nsf4cslM0rZKvu2WwK34NaBm8x886gl4cl+zg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@fallow-cli/linux-arm64-gnu@2.102.0':
|
||||
resolution: {integrity: sha512-8nYeOSLSewqcKH/KUcKZaCq5QII5VTRX62l60B6UM1iKe/0jcmlQ2mOtx4rkHpeq3LL5emvT/ph4NNgGmWKSBg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/linux-arm64-musl@2.102.0':
|
||||
resolution: {integrity: sha512-2Zi33PXzZxD7HU5sPVwyElZGP7zyEdGo4hK0ewy6gMYgQ9BDfLnFhgaSSOzN1J4paIhYtBmVsLmqakyDKy22Jw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/linux-x64-gnu@2.102.0':
|
||||
resolution: {integrity: sha512-7Hys4X6hKuR/lqUaGXwezRzDrwXwu9KfahUy85WTuiG1to+ZbzDCqdbZ04LtnI8kK8ufrPDcq+ZXdyt5ksOJHA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/linux-x64-musl@2.102.0':
|
||||
resolution: {integrity: sha512-RuDY1jOEPgJOuHBgEpHVl6J7Xf2QLFklnNy6zO1nw8R1fLgWUAAKlFirn1Y93pb9bXzqpn5Gre3YlhgIZ3+LBA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/win32-arm64-msvc@2.102.0':
|
||||
resolution: {integrity: sha512-wsvHjLzWFvsYmCqnQLm1doSEHb9Z038+sFp1RzMcffPUBC5tqS3vCr8J8nolcLlPk7o3QWHQMXAqb1xSJe+doA==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@fallow-cli/win32-x64-msvc@2.102.0':
|
||||
resolution: {integrity: sha512-rH1hd0PD0mm6pCxh1pw5jubpJsvV6f5rjixMoD5AZLzWa6NPJBpPPuzruLGtH/9CYy8B0y7zPrGGTKRO2PCGzg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@firebase/analytics-compat@0.2.18':
|
||||
resolution: {integrity: sha512-Hw9mzsSMZaQu6wrTbi3kYYwGw9nBqOHr47pVLxfr5v8CalsdrG5gfs9XUlPOZjHRVISp3oQrh1j7d3E+ulHPjQ==}
|
||||
peerDependencies:
|
||||
@@ -5682,6 +5728,11 @@ packages:
|
||||
extendable-media-recorder@9.2.27:
|
||||
resolution: {integrity: sha512-2X+Ixi1cxLek0Cj9x9atmhQ+apG+LwJpP2p3ypP8Pxau0poDnicrg7FTfPVQV5PW/3DHFm/eQ16vbgo5Yk3HGQ==}
|
||||
|
||||
fallow@2.102.0:
|
||||
resolution: {integrity: sha512-bkOT58kPVCB12d2apQjIKBw/qSdsGRPQFrN5ff9Yl5WzXRqlDTbT/MVdMXld4sJD5JQW1ftw2bTxJWCINggh6g==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
fast-check@4.5.3:
|
||||
resolution: {integrity: sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==}
|
||||
engines: {node: '>=12.17.0'}
|
||||
@@ -10044,6 +10095,30 @@ snapshots:
|
||||
|
||||
'@exodus/bytes@1.7.0': {}
|
||||
|
||||
'@fallow-cli/darwin-arm64@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/darwin-x64@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-arm64-gnu@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-arm64-musl@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-x64-gnu@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-x64-musl@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/win32-arm64-msvc@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/win32-x64-msvc@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@firebase/analytics-compat@0.2.18(@firebase/app-compat@0.2.53)(@firebase/app@0.11.4)':
|
||||
dependencies:
|
||||
'@firebase/analytics': 0.10.12(@firebase/app@0.11.4)
|
||||
@@ -14095,6 +14170,19 @@ snapshots:
|
||||
subscribable-things: 2.1.53
|
||||
tslib: 2.8.1
|
||||
|
||||
fallow@2.102.0:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
'@fallow-cli/darwin-arm64': 2.102.0
|
||||
'@fallow-cli/darwin-x64': 2.102.0
|
||||
'@fallow-cli/linux-arm64-gnu': 2.102.0
|
||||
'@fallow-cli/linux-arm64-musl': 2.102.0
|
||||
'@fallow-cli/linux-x64-gnu': 2.102.0
|
||||
'@fallow-cli/linux-x64-musl': 2.102.0
|
||||
'@fallow-cli/win32-arm64-msvc': 2.102.0
|
||||
'@fallow-cli/win32-x64-msvc': 2.102.0
|
||||
|
||||
fast-check@4.5.3:
|
||||
dependencies:
|
||||
pure-rand: 7.0.1
|
||||
|
||||
@@ -89,6 +89,7 @@ catalog:
|
||||
eslint-plugin-testing-library: ^7.16.1
|
||||
eslint-plugin-unused-imports: ^4.4.1
|
||||
eslint-plugin-vue: ^10.9.1
|
||||
fallow: ^2.102.0
|
||||
fast-check: ^4.5.3
|
||||
firebase: ^11.6.0
|
||||
glob: ^13.0.6
|
||||
|
||||
@@ -3,11 +3,9 @@ import * as fs from 'fs'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import {
|
||||
escapeVueI18nMessageSyntax,
|
||||
normalizeI18nKey
|
||||
} from '@/utils/formatUtil'
|
||||
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
|
||||
import type { WidgetLabels } from './nodeDefLocaleSerializer'
|
||||
import { serializeNodeDefLocales } from './nodeDefLocaleSerializer'
|
||||
|
||||
const localePath = './src/locales/en/main.json'
|
||||
const nodeDefsPath = './src/locales/en/nodeDefs.json'
|
||||
@@ -17,10 +15,6 @@ interface WidgetInfo {
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface WidgetLabels {
|
||||
[key: string]: Record<string, { name: string }>
|
||||
}
|
||||
|
||||
test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
// Mock view route
|
||||
await comfyPage.page.route('**/view**', async (route) => {
|
||||
@@ -47,26 +41,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
}
|
||||
)
|
||||
|
||||
const allDataTypesLocale = Object.fromEntries(
|
||||
nodeDefs
|
||||
.flatMap((nodeDef) => {
|
||||
const inputDataTypes = Object.values(nodeDef.inputs).map(
|
||||
(inputSpec) => inputSpec.type
|
||||
)
|
||||
const outputDataTypes = nodeDef.outputs.map(
|
||||
(outputSpec) => outputSpec.type
|
||||
)
|
||||
const allDataTypes = [...inputDataTypes, ...outputDataTypes].flatMap(
|
||||
(type: string) => type.split(',')
|
||||
)
|
||||
return allDataTypes.map((dataType) => [
|
||||
normalizeI18nKey(dataType),
|
||||
escapeVueI18nMessageSyntax(dataType)
|
||||
])
|
||||
})
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
)
|
||||
|
||||
async function extractWidgetLabels() {
|
||||
const nodeLabels: WidgetLabels = {}
|
||||
|
||||
@@ -95,14 +69,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
[nodeDef.name, nodeDef.display_name, inputNames]
|
||||
)
|
||||
|
||||
// Format runtime widgets
|
||||
const runtimeWidgets = Object.fromEntries(
|
||||
Object.entries(widgetsMappings)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([key, value]) => [
|
||||
normalizeI18nKey(key),
|
||||
{ name: value ? escapeVueI18nMessageSyntax(value) : value }
|
||||
])
|
||||
.map(([key, name]) => [key, { name }])
|
||||
)
|
||||
|
||||
if (Object.keys(runtimeWidgets).length > 0) {
|
||||
@@ -121,97 +91,8 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
}
|
||||
|
||||
const nodeDefLabels = await extractWidgetLabels()
|
||||
|
||||
function extractInputs(nodeDef: ComfyNodeDefImpl) {
|
||||
const inputs = Object.fromEntries(
|
||||
Object.values(nodeDef.inputs).flatMap((input) => {
|
||||
const name =
|
||||
input.name === undefined
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(input.name)
|
||||
const tooltip = input.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
normalizeI18nKey(input.name),
|
||||
{
|
||||
name,
|
||||
tooltip
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
return Object.keys(inputs).length > 0 ? inputs : undefined
|
||||
}
|
||||
|
||||
function extractOutputs(nodeDef: ComfyNodeDefImpl) {
|
||||
const outputs = Object.fromEntries(
|
||||
nodeDef.outputs.flatMap((output, i) => {
|
||||
// Ignore data types if they are already translated in allDataTypesLocale.
|
||||
const name =
|
||||
output.name === undefined || output.name in allDataTypesLocale
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(output.name)
|
||||
const tooltip = output.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
i.toString(),
|
||||
{
|
||||
name,
|
||||
tooltip
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
return Object.keys(outputs).length > 0 ? outputs : undefined
|
||||
}
|
||||
|
||||
const allNodeDefsLocale = Object.fromEntries(
|
||||
nodeDefs
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((nodeDef) => {
|
||||
const inputs = {
|
||||
...extractInputs(nodeDef),
|
||||
...(nodeDefLabels[nodeDef.name] ?? {})
|
||||
}
|
||||
|
||||
return [
|
||||
normalizeI18nKey(nodeDef.name),
|
||||
{
|
||||
display_name: escapeVueI18nMessageSyntax(
|
||||
nodeDef.display_name ?? nodeDef.name
|
||||
),
|
||||
description: nodeDef.description
|
||||
? escapeVueI18nMessageSyntax(nodeDef.description)
|
||||
: undefined,
|
||||
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
|
||||
outputs: extractOutputs(nodeDef)
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const allNodeCategoriesLocale = Object.fromEntries(
|
||||
nodeDefs.flatMap((nodeDef) =>
|
||||
nodeDef.category
|
||||
.split('/')
|
||||
.map((category) => [
|
||||
normalizeI18nKey(category),
|
||||
escapeVueI18nMessageSyntax(category)
|
||||
])
|
||||
)
|
||||
)
|
||||
const { dataTypes, nodeCategories, nodeDefinitions } =
|
||||
serializeNodeDefLocales(nodeDefs, nodeDefLabels)
|
||||
|
||||
const locale = JSON.parse(fs.readFileSync(localePath, 'utf-8'))
|
||||
fs.writeFileSync(
|
||||
@@ -219,13 +100,13 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
JSON.stringify(
|
||||
{
|
||||
...locale,
|
||||
dataTypes: allDataTypesLocale,
|
||||
nodeCategories: allNodeCategoriesLocale
|
||||
dataTypes,
|
||||
nodeCategories
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
fs.writeFileSync(nodeDefsPath, JSON.stringify(allNodeDefsLocale, null, 2))
|
||||
fs.writeFileSync(nodeDefsPath, JSON.stringify(nodeDefinitions, null, 2))
|
||||
})
|
||||
|
||||
130
scripts/nodeDefLocaleSerializer.test.ts
Normal file
130
scripts/nodeDefLocaleSerializer.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { serializeNodeDefLocales } from './nodeDefLocaleSerializer'
|
||||
|
||||
function render(message: string): string {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { value: message } }
|
||||
})
|
||||
return i18n.global.t('value')
|
||||
}
|
||||
|
||||
describe('serializeNodeDefLocales', () => {
|
||||
it('escapes compiled fields and preserves raw tooltips', () => {
|
||||
const syntax = '@ $ {value} | 50%{done}'
|
||||
const inputName = `Input ${syntax}`
|
||||
const outputName = `Output ${syntax}`
|
||||
const dataType = `TYPE ${syntax}`
|
||||
const category = `Category ${syntax}`
|
||||
const nodeDef = {
|
||||
name: 'Test.Node',
|
||||
display_name: `Display ${syntax}`,
|
||||
description: `Description ${syntax}`,
|
||||
category,
|
||||
inputs: {
|
||||
input: {
|
||||
name: inputName,
|
||||
type: dataType,
|
||||
tooltip: `Input tooltip ${syntax}`
|
||||
}
|
||||
},
|
||||
outputs: [
|
||||
{
|
||||
name: outputName,
|
||||
type: 'OTHER',
|
||||
tooltip: `Output tooltip ${syntax}`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const { dataTypes, nodeCategories, nodeDefinitions } =
|
||||
serializeNodeDefLocales([nodeDef], {
|
||||
'Test.Node': {
|
||||
'Runtime.Widget': { name: `Widget ${syntax}` }
|
||||
}
|
||||
})
|
||||
const serializedNode = nodeDefinitions.Test_Node
|
||||
const serializedInput =
|
||||
serializedNode.inputs['Input @ $ {value} | 50%{done}']
|
||||
const serializedOutput = serializedNode.outputs['0']
|
||||
|
||||
expect(render(serializedNode.display_name)).toBe(nodeDef.display_name)
|
||||
expect(render(serializedNode.description)).toBe(nodeDef.description)
|
||||
expect(render(serializedInput.name)).toBe(inputName)
|
||||
expect(render(serializedOutput.name)).toBe(outputName)
|
||||
expect(render(serializedNode.inputs.Runtime_Widget.name)).toBe(
|
||||
`Widget ${syntax}`
|
||||
)
|
||||
expect(render(dataTypes[dataType])).toBe(dataType)
|
||||
expect(render(nodeCategories[category])).toBe(category)
|
||||
expect(serializedInput.tooltip).toBe(nodeDef.inputs.input.tooltip)
|
||||
expect(serializedOutput.tooltip).toBe(nodeDef.outputs[0].tooltip)
|
||||
})
|
||||
|
||||
it('preserves locale shapes and ordering', () => {
|
||||
const { dataTypes, nodeCategories, nodeDefinitions } =
|
||||
serializeNodeDefLocales(
|
||||
[
|
||||
{
|
||||
name: 'Z.Node',
|
||||
description: '',
|
||||
category: 'group/sub.group',
|
||||
inputs: {
|
||||
omitted: { type: 'Z.TYPE' },
|
||||
tooltipOnly: { type: 'A_TYPE', tooltip: 'raw @ tooltip' }
|
||||
},
|
||||
outputs: [
|
||||
{ name: 'A_TYPE', type: 'A_TYPE' },
|
||||
{ name: 'Custom.Output', type: 'Z.TYPE' },
|
||||
{ tooltip: 'raw output @ tooltip', type: 'Z.TYPE' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'A.Node',
|
||||
category: 'group',
|
||||
inputs: {},
|
||||
outputs: []
|
||||
}
|
||||
],
|
||||
{
|
||||
'Z.Node': {
|
||||
'Runtime.Widget': { name: 'Runtime.Label' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(dataTypes).toEqual({
|
||||
A_TYPE: 'A_TYPE',
|
||||
Z_TYPE: 'Z.TYPE'
|
||||
})
|
||||
expect(nodeCategories).toEqual({
|
||||
group: 'group',
|
||||
sub_group: 'sub.group'
|
||||
})
|
||||
expect(nodeDefinitions).toEqual({
|
||||
A_Node: {
|
||||
display_name: 'A.Node',
|
||||
description: undefined,
|
||||
inputs: undefined,
|
||||
outputs: undefined
|
||||
},
|
||||
Z_Node: {
|
||||
display_name: 'Z.Node',
|
||||
description: undefined,
|
||||
inputs: {
|
||||
'': { name: undefined, tooltip: 'raw @ tooltip' },
|
||||
Runtime_Widget: { name: 'Runtime.Label' }
|
||||
},
|
||||
outputs: {
|
||||
1: { name: 'Custom.Output', tooltip: undefined },
|
||||
2: { name: undefined, tooltip: 'raw output @ tooltip' }
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(Object.keys(dataTypes)).toEqual(['A_TYPE', 'Z_TYPE'])
|
||||
expect(Object.keys(nodeDefinitions)).toEqual(['A_Node', 'Z_Node'])
|
||||
})
|
||||
})
|
||||
127
scripts/nodeDefLocaleSerializer.ts
Normal file
127
scripts/nodeDefLocaleSerializer.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
interface LocalizableInput {
|
||||
type: string
|
||||
name?: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface LocalizableOutput {
|
||||
type: string
|
||||
name?: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface LocalizableNodeDef {
|
||||
category: string
|
||||
inputs: Record<string, LocalizableInput>
|
||||
name: string
|
||||
outputs: LocalizableOutput[]
|
||||
description?: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export type WidgetLabels = Record<
|
||||
string,
|
||||
Record<string, { name: string | undefined }>
|
||||
>
|
||||
|
||||
const VUE_I18N_SYNTAX_CHARS = /[@${}|%]/g
|
||||
|
||||
function escapeMessage(text: string): string {
|
||||
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
|
||||
}
|
||||
|
||||
export function serializeNodeDefLocales(
|
||||
nodeDefs: readonly LocalizableNodeDef[],
|
||||
widgetLabels: WidgetLabels = {}
|
||||
) {
|
||||
const dataTypes = Object.fromEntries(
|
||||
nodeDefs
|
||||
.flatMap((nodeDef) => [
|
||||
...Object.values(nodeDef.inputs).map(({ type }) => type),
|
||||
...nodeDef.outputs.map(({ type }) => type)
|
||||
])
|
||||
.flatMap((type) => type.split(','))
|
||||
.map((dataType) => [normalizeI18nKey(dataType), escapeMessage(dataType)])
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
)
|
||||
|
||||
function serializeInputs(nodeDef: LocalizableNodeDef) {
|
||||
const inputs = Object.fromEntries(
|
||||
Object.values(nodeDef.inputs).flatMap(({ name, tooltip }) => {
|
||||
if (name === undefined && tooltip === undefined) return []
|
||||
|
||||
return [
|
||||
[
|
||||
normalizeI18nKey(name ?? ''),
|
||||
{
|
||||
name: name === undefined ? undefined : escapeMessage(name),
|
||||
tooltip
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
return Object.keys(inputs).length > 0 ? inputs : undefined
|
||||
}
|
||||
|
||||
function serializeOutputs(nodeDef: LocalizableNodeDef) {
|
||||
const outputs = Object.fromEntries(
|
||||
nodeDef.outputs.flatMap(({ name, tooltip }, index) => {
|
||||
const serializedName =
|
||||
name === undefined || name in dataTypes
|
||||
? undefined
|
||||
: escapeMessage(name)
|
||||
if (serializedName === undefined && tooltip === undefined) return []
|
||||
|
||||
return [[index.toString(), { name: serializedName, tooltip }]]
|
||||
})
|
||||
)
|
||||
return Object.keys(outputs).length > 0 ? outputs : undefined
|
||||
}
|
||||
|
||||
function serializeWidgetLabels(nodeName: string) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(widgetLabels[nodeName] ?? {}).map(([name, label]) => [
|
||||
normalizeI18nKey(name),
|
||||
{
|
||||
name: label.name === undefined ? undefined : escapeMessage(label.name)
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
const nodeDefinitions = Object.fromEntries(
|
||||
[...nodeDefs]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((nodeDef) => {
|
||||
const inputs = {
|
||||
...serializeInputs(nodeDef),
|
||||
...serializeWidgetLabels(nodeDef.name)
|
||||
}
|
||||
|
||||
return [
|
||||
normalizeI18nKey(nodeDef.name),
|
||||
{
|
||||
display_name: escapeMessage(nodeDef.display_name ?? nodeDef.name),
|
||||
description: nodeDef.description
|
||||
? escapeMessage(nodeDef.description)
|
||||
: undefined,
|
||||
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
|
||||
outputs: serializeOutputs(nodeDef)
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const nodeCategories = Object.fromEntries(
|
||||
nodeDefs.flatMap(({ category }) =>
|
||||
category
|
||||
.split('/')
|
||||
.map((part) => [normalizeI18nKey(part), escapeMessage(part)])
|
||||
)
|
||||
)
|
||||
|
||||
return { dataTypes, nodeCategories, nodeDefinitions }
|
||||
}
|
||||
@@ -112,5 +112,12 @@ export interface BillingContext extends BillingState, BillingActions {
|
||||
* (legacy) per-member tier plan, which keeps the old team pricing table.
|
||||
*/
|
||||
isLegacyTeamPlan: ComputedRef<boolean>
|
||||
/**
|
||||
* True when the subscription is a team plan of either generation. Unlike
|
||||
* `isLegacyTeamPlan` this does not require an active subscription: the spend
|
||||
* gate folds billing_status into is_active, so a paused or payment-failed team
|
||||
* plan reports is_active=false and must still read as a team plan.
|
||||
*/
|
||||
isTeamPlan: ComputedRef<boolean>
|
||||
getMaxSeats: (tierKey: TierKey) => number
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
|
||||
|
||||
const {
|
||||
mockTeamWorkspacesEnabled,
|
||||
mockConsolidatedBillingEnabled,
|
||||
mockBillingControlEnabled,
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits,
|
||||
@@ -27,7 +27,7 @@ const {
|
||||
mockBillingStatus
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockConsolidatedBillingEnabled: { value: false },
|
||||
mockBillingControlEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn(),
|
||||
@@ -59,13 +59,11 @@ vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
teamWorkspacesEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
const consolidatedBillingEnabledRef = ref(
|
||||
mockConsolidatedBillingEnabled.value
|
||||
)
|
||||
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
|
||||
get: () => consolidatedBillingEnabledRef.value,
|
||||
const billingControlEnabledRef = ref(mockBillingControlEnabled.value)
|
||||
Object.defineProperty(mockBillingControlEnabled, 'value', {
|
||||
get: () => billingControlEnabledRef.value,
|
||||
set: (value: boolean) => {
|
||||
consolidatedBillingEnabledRef.value = value
|
||||
billingControlEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
@@ -74,8 +72,8 @@ vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
},
|
||||
get consolidatedBillingEnabled() {
|
||||
return mockConsolidatedBillingEnabled.value
|
||||
get billingControlEnabled() {
|
||||
return mockBillingControlEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -165,7 +163,7 @@ describe('useBillingContext', () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockBillingControlEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
mockPlans.value = []
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
@@ -177,27 +175,27 @@ describe('useBillingContext', () => {
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
it('keeps personal on legacy when billing control is disabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockBillingControlEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('selects workspace type for personal when consolidated billing is enabled', () => {
|
||||
it('selects workspace type for personal when billing control is enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockBillingControlEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('selects workspace type for team regardless of consolidated billing', () => {
|
||||
it('selects workspace type for team regardless of billing control', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockBillingControlEnabled.value = false
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { type } = useBillingContext()
|
||||
@@ -298,7 +296,7 @@ describe('useBillingContext', () => {
|
||||
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
|
||||
// Authenticated remote config resolves the flag on for the same workspace
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockBillingControlEnabled.value = true
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -307,16 +305,16 @@ describe('useBillingContext', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
|
||||
it('moves a personal workspace to workspace billing when billing control flips on', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockBillingControlEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
await nextTick()
|
||||
expect(type.value).toBe('legacy')
|
||||
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockBillingControlEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(type.value).toBe('workspace')
|
||||
@@ -325,9 +323,9 @@ describe('useBillingContext', () => {
|
||||
})
|
||||
|
||||
describe('subscription mirror to workspace store', () => {
|
||||
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
|
||||
it('mirrors subscription for personal workspaces on the billing control flow', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockBillingControlEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
@@ -555,4 +553,110 @@ describe('useBillingContext', () => {
|
||||
expect(isLegacyTeamPlan.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTeamPlan', () => {
|
||||
it('is false for a personal workspace', () => {
|
||||
const { isTeamPlan } = useBillingContext()
|
||||
expect(isTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
// subscription_tier is omitted throughout: the backend sends 'TEAM' here, but
|
||||
// the FE's SubscriptionTier resolves to the registry spec, which has no TEAM
|
||||
// (tierPricing.ts imports comfyRegistryTypes for what is an ingest field).
|
||||
// isTeamPlan reads the credit stop and the slug, never the tier — which is
|
||||
// what keeps it working despite that divergence.
|
||||
it('is true for a credit-slider team sub, which carries a credit stop', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
plan_slug: 'team_per_credit_monthly',
|
||||
team_credit_stop: {
|
||||
id: 'team_700',
|
||||
credits_monthly: 700,
|
||||
stop_usd: 332
|
||||
}
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for a legacy team sub, identified by slug rather than credit stop', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
plan_slug: 'team-standard-annual'
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
// The banner states that need isTeamPlan most — paused and payment_failed —
|
||||
// are exactly the ones the backend reports with is_active=false, because the
|
||||
// spend gate folds billing_status into it. Coupling isTeamPlan to an active
|
||||
// subscription would blank the banner precisely when it is needed.
|
||||
it('stays true for a paused team plan, which the backend reports inactive', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: false,
|
||||
has_funds: true,
|
||||
billing_status: 'paused',
|
||||
plan_slug: 'team_per_credit_monthly',
|
||||
team_credit_stop: {
|
||||
id: 'team_700',
|
||||
credits_monthly: 700,
|
||||
stop_usd: 332
|
||||
}
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('stays true for a legacy team plan whose payment failed', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: false,
|
||||
has_funds: true,
|
||||
billing_status: 'payment_failed',
|
||||
subscription_tier: 'STANDARD',
|
||||
plan_slug: 'team-standard-annual'
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a team workspace on a personal-tier plan', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
plan_slug: 'pro-monthly'
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,8 +35,8 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
*
|
||||
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for team
|
||||
* workspaces, and for personal workspaces once consolidated billing is
|
||||
* enabled; personal workspaces otherwise stay on legacy billing
|
||||
* workspaces, and for personal workspaces once billing control is enabled;
|
||||
* personal workspaces otherwise stay on legacy billing
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
@@ -141,6 +141,21 @@ function useBillingContextInternal(): BillingContext {
|
||||
false)
|
||||
)
|
||||
|
||||
// Plan identity, independent of subscription health: the per-credit Team plan
|
||||
// carries a credit stop, the retired seat-based ones a `team-` slug. Kept off
|
||||
// isActiveSubscription on purpose — paused and payment_failed both force
|
||||
// is_active=false, which is exactly when callers still need to know this is a
|
||||
// team plan.
|
||||
const isTeamPlan = computed(
|
||||
() =>
|
||||
type.value === 'workspace' &&
|
||||
(currentTeamCreditStop.value !== null ||
|
||||
(currentPlanSlug.value
|
||||
?.toLowerCase()
|
||||
.startsWith(LEGACY_TEAM_PLAN_SLUG_PREFIX) ??
|
||||
false))
|
||||
)
|
||||
|
||||
const billingStatus = computed(() =>
|
||||
toValue(activeContext.value.billingStatus)
|
||||
)
|
||||
@@ -191,9 +206,9 @@ function useBillingContextInternal(): BillingContext {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// type flips when the team-workspaces or consolidated-billing flag resolves
|
||||
// from authenticated config, swapping the active backend. Reset then reinit
|
||||
// on every workspace-id or type change.
|
||||
// type flips when the team-workspaces or billing-control flag resolves from
|
||||
// authenticated config, swapping the active backend. Reset then reinit on
|
||||
// every workspace-id or type change.
|
||||
watch(
|
||||
[() => store.activeWorkspace?.id, () => type.value],
|
||||
async ([newWorkspaceId]) => {
|
||||
@@ -299,6 +314,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
isLegacyTeamPlan,
|
||||
isTeamPlan,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useBillingRouting } from './useBillingRouting'
|
||||
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
|
||||
mockFlags: {
|
||||
teamWorkspacesEnabled: false,
|
||||
consolidatedBillingEnabled: false
|
||||
billingControlEnabled: false
|
||||
},
|
||||
mockActiveWorkspace: {
|
||||
value: null as { id: string; type: 'personal' | 'team' } | null
|
||||
@@ -30,7 +30,7 @@ const team = { id: 'w-team', type: 'team' as const }
|
||||
describe('useBillingRouting', () => {
|
||||
beforeEach(() => {
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockFlags.billingControlEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
})
|
||||
|
||||
@@ -44,9 +44,9 @@ describe('useBillingRouting', () => {
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
it('keeps personal on legacy when billing control is disabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockFlags.billingControlEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
@@ -54,9 +54,9 @@ describe('useBillingRouting', () => {
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('moves personal to workspace billing when consolidated billing is enabled', () => {
|
||||
it('moves personal to workspace billing when billing control is enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockFlags.billingControlEnabled = true
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
@@ -65,9 +65,9 @@ describe('useBillingRouting', () => {
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
|
||||
it('uses workspace billing for team workspaces regardless of billing control', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockFlags.billingControlEnabled = false
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
@@ -76,9 +76,9 @@ describe('useBillingRouting', () => {
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
|
||||
it('uses workspace billing for team workspaces with billing control enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockFlags.billingControlEnabled = true
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
@@ -89,7 +89,7 @@ describe('useBillingRouting', () => {
|
||||
|
||||
it('defaults to legacy while the workspace has not loaded', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockFlags.billingControlEnabled = true
|
||||
mockActiveWorkspace.value = null
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { BillingType } from './types'
|
||||
/**
|
||||
* Selects the billing backend for the active workspace: legacy user-scoped
|
||||
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
|
||||
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
|
||||
* stay legacy until `billingControlEnabled`; team workspaces are always
|
||||
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
|
||||
*/
|
||||
export function useBillingRouting() {
|
||||
@@ -23,7 +23,7 @@ export function useBillingRouting() {
|
||||
const workspaceType = workspaceStore.activeWorkspace?.type
|
||||
if (!workspaceType) return 'legacy'
|
||||
|
||||
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
|
||||
if (workspaceType === 'personal' && !flags.billingControlEnabled) {
|
||||
return 'legacy'
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@/composables/useFeatureFlags'
|
||||
import * as distributionTypes from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedBillingControlEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
remoteConfigState
|
||||
@@ -226,19 +226,19 @@ describe('useFeatureFlags', () => {
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
it('billingControlEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
|
||||
localStorage.setItem('ff:billing_control_enabled', 'true')
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
|
||||
it('billingControlEnabled is false off-cloud even without an override', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
expect(flags.billingControlEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -248,7 +248,7 @@ describe('useFeatureFlags', () => {
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
cachedBillingControlEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
@@ -257,36 +257,36 @@ describe('useFeatureFlags', () => {
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
cachedBillingControlEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns the cached session value during the auth window', () => {
|
||||
cachedTeamWorkspacesEnabled.value = false
|
||||
cachedConsolidatedBillingEnabled.value = true
|
||||
cachedBillingControlEnabled.value = true
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to false during the auth window when nothing is cached', () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
expect(flags.billingControlEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers authenticated remoteConfig over the server feature fallback', () => {
|
||||
remoteConfigState.value = 'authenticated'
|
||||
remoteConfig.value = {
|
||||
team_workspaces_enabled: true,
|
||||
consolidated_billing_enabled: true
|
||||
billing_control_enabled: true
|
||||
}
|
||||
vi.mocked(api.getServerFeature).mockReturnValue(false)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
|
||||
@@ -295,15 +295,14 @@ describe('useFeatureFlags', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
|
||||
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
|
||||
return true
|
||||
if (path === ServerFeatureFlag.BILLING_CONTROL_ENABLED) return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Ref } from 'vue'
|
||||
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedBillingControlEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
isAuthenticatedConfigLoaded,
|
||||
remoteConfig
|
||||
@@ -32,7 +32,7 @@ export enum ServerFeatureFlag {
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
|
||||
BILLING_CONTROL_ENABLED = 'billing_control_enabled',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
}
|
||||
|
||||
@@ -191,15 +191,15 @@ export function useFeatureFlags() {
|
||||
)
|
||||
},
|
||||
/**
|
||||
* Whether personal workspaces use the consolidated (workspace-scoped)
|
||||
* billing flow. While false (default), personal workspaces stay on the
|
||||
* legacy per-user billing flow; team workspaces are unaffected.
|
||||
* Whether personal workspaces use the workspace-scoped billing flow. While
|
||||
* false (default), personal workspaces stay on the legacy per-user billing
|
||||
* flow; team workspaces are unaffected.
|
||||
*/
|
||||
get consolidatedBillingEnabled() {
|
||||
get billingControlEnabled() {
|
||||
return resolveAuthGatedFlag(
|
||||
ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED,
|
||||
remoteConfig.value.consolidated_billing_enabled,
|
||||
cachedConsolidatedBillingEnabled
|
||||
ServerFeatureFlag.BILLING_CONTROL_ENABLED,
|
||||
remoteConfig.value.billing_control_enabled,
|
||||
cachedBillingControlEnabled
|
||||
)
|
||||
},
|
||||
get signupTurnstileMode() {
|
||||
|
||||
@@ -2473,6 +2473,11 @@
|
||||
},
|
||||
"credits": {
|
||||
"activity": "Activity",
|
||||
"insufficient": {
|
||||
"memberTitle": "This workspace is out of credits",
|
||||
"memberDescription": "Your team has used all its credits. Your workspace admins need to add more credits to run workflows.",
|
||||
"memberCta": "Ok, got it"
|
||||
},
|
||||
"credits": "Credits",
|
||||
"yourCreditBalance": "Your credit balance",
|
||||
"purchaseCredits": "Purchase Credits",
|
||||
@@ -2882,6 +2887,32 @@
|
||||
"updatePassword": "Update Password"
|
||||
},
|
||||
"workspacePanel": {
|
||||
"billingStatus": {
|
||||
"warning": {
|
||||
"title": "Payment declined",
|
||||
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
|
||||
"bodyNoDate": "Your last payment didn't go through. Update payment to avoid a pause."
|
||||
},
|
||||
"paused": {
|
||||
"title": "Subscription paused",
|
||||
"body": "This workspace's subscription is paused. Update payment to resume.",
|
||||
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method."
|
||||
},
|
||||
"outOfCredits": {
|
||||
"title": "Out of credits",
|
||||
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
|
||||
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
|
||||
"memberBody": "Your team has used all its credits. Your workspace admins need to add more credits to continue generating.",
|
||||
"addCredits": "Add credits",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"ending": {
|
||||
"title": "Your team plan ends on {date}",
|
||||
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
|
||||
"reactivate": "Reactivate plan"
|
||||
},
|
||||
"updatePayment": "Update payment"
|
||||
},
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { escapeVueI18nMessageSyntax } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
|
||||
/**
|
||||
* Node descriptions are compiled by vue-i18n via `t()`/`st()`, which parses
|
||||
* `@ { } | %` as message syntax — a literal `@` even crashes the compiler with
|
||||
* `Invalid linked format` (this broke the whole app after the 1.47.7 locale
|
||||
* sync). `collect-i18n-node-defs.ts` escapes such values with
|
||||
* `escapeVueI18nMessageSyntax` before writing them; this guards that the escaped
|
||||
* output actually compiles and renders the original literal text.
|
||||
*/
|
||||
describe('escapeVueI18nMessageSyntax output is compiled safely by vue-i18n', () => {
|
||||
const compile = (message: string) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { value: message } }
|
||||
})
|
||||
return i18n.global.t('value')
|
||||
}
|
||||
|
||||
it.for([
|
||||
'clips (tagged @Audio1-3 in the prompt)',
|
||||
'support@comfy.org',
|
||||
'resolution {width}x{height}',
|
||||
'foreground | background',
|
||||
'50%{done}',
|
||||
'all of @ { } | % together',
|
||||
'no special chars here'
|
||||
])('renders %s as the original literal text', (raw) => {
|
||||
expect(compile(escapeVueI18nMessageSyntax(raw))).toBe(raw)
|
||||
})
|
||||
})
|
||||
@@ -8,10 +8,19 @@ const mockDialogService = {
|
||||
showTopUpCreditsDialog: vi.fn()
|
||||
}
|
||||
|
||||
const mockBilling = {
|
||||
fetchStatus: vi.fn(),
|
||||
fetchBalance: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => mockDialogService)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => mockBilling)
|
||||
}))
|
||||
|
||||
describe('useAccountPreconditionDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -55,4 +64,18 @@ describe('useAccountPreconditionDialog', () => {
|
||||
mockDialogService.showSubscriptionRequiredDialog
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes the billing snapshot on a credit precondition so exhausted-state surfaces converge', () => {
|
||||
useAccountPreconditionDialog().open('credits')
|
||||
|
||||
expect(mockBilling.fetchStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockBilling.fetchBalance).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not touch billing state for non-credit preconditions', () => {
|
||||
useAccountPreconditionDialog().open('subscription')
|
||||
|
||||
expect(mockBilling.fetchStatus).not.toHaveBeenCalled()
|
||||
expect(mockBilling.fetchBalance).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import type { AccountPrecondition } from '@/platform/errorCatalog/accountPreconditionRouting'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
@@ -28,11 +29,19 @@ export function useAccountPreconditionDialog() {
|
||||
reason: 'subscription_required'
|
||||
})
|
||||
return
|
||||
case 'credits':
|
||||
case 'credits': {
|
||||
// The server just declared the balance exhausted; there is no push or
|
||||
// polling for billing state, so refresh it here to converge
|
||||
// hasFunds-keyed surfaces such as the credits-exhausted banner. The
|
||||
// refresh is best-effort: allSettled keeps a flaky billing API from
|
||||
// surfacing as unhandled rejections.
|
||||
const { fetchStatus, fetchBalance } = useBillingContext()
|
||||
void Promise.allSettled([fetchStatus(), fetchBalance()])
|
||||
void dialogService.showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,21 @@ describe('useSubscriptionDialog', () => {
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the read-only member dialog for out-of-credits too, not the pricing table', () => {
|
||||
mockShouldUseWorkspaceBilling.value = true
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
mockCanManageSubscription.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable({ reason: 'out_of_credits' })
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
|
||||
const props = mockShowLayoutDialog.mock.calls[0][0].props
|
||||
expect(props).toHaveProperty('onClose')
|
||||
expect(props).not.toHaveProperty('reason')
|
||||
expect(props).not.toHaveProperty('initialPlanMode')
|
||||
})
|
||||
|
||||
it('does not track on non-cloud', () => {
|
||||
mockIsCloud.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
@@ -55,12 +55,12 @@ export const useSubscriptionDialog = () => {
|
||||
|
||||
// Members can't manage the workspace subscription, so a blocked run shows a
|
||||
// small read-only "ask your owner to reactivate" modal instead of the
|
||||
// pricing table. Out-of-credits still routes everyone to the credits flow.
|
||||
// pricing table — including out-of-credits, whose member recovery path is
|
||||
// also owner-only (FE-1246).
|
||||
if (
|
||||
shouldUseWorkspaceBilling.value &&
|
||||
!workspaceStore.isInPersonalWorkspace &&
|
||||
!permissions.value.canManageSubscription &&
|
||||
options?.reason !== 'out_of_credits'
|
||||
!permissions.value.canManageSubscription
|
||||
) {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
@@ -88,9 +88,9 @@ export const useSubscriptionDialog = () => {
|
||||
} as const
|
||||
|
||||
// Jun-5 model: a single unified pricing table (personal/team plan toggle on
|
||||
// one workspace) for workspaces on the consolidated billing flow. Replaces
|
||||
// the old personal-vs-team workspace fork. Personal workspaces still on the
|
||||
// legacy flow (consolidated billing disabled) get the legacy table.
|
||||
// one workspace) for workspaces on the workspace-scoped billing flow.
|
||||
// Replaces the old personal-vs-team workspace fork. Personal workspaces
|
||||
// still on the legacy flow (billing control disabled) get the legacy table.
|
||||
if (shouldUseWorkspaceBilling.value) {
|
||||
// Existing per-member (legacy) team subscribers keep the old tier-based
|
||||
// team table; the unified credit-slider table is for everyone else.
|
||||
|
||||
@@ -73,6 +73,33 @@ describe('resolveAccountPrecondition', () => {
|
||||
).toBe('credits')
|
||||
})
|
||||
|
||||
it('classifies the submit-time 402 body by its insufficient_credits type regardless of message', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'insufficient_credits',
|
||||
exceptionMessage: 'Workspace balance exhausted'
|
||||
})
|
||||
).toBe('credits')
|
||||
})
|
||||
|
||||
it('classifies the team submit-time 429 (PAYMENT_REQUIRED / insufficient credits) as a credits precondition', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'PAYMENT_REQUIRED',
|
||||
exceptionMessage: 'Insufficient credits to queue workflows'
|
||||
})
|
||||
).toBe('credits')
|
||||
})
|
||||
|
||||
it('keeps the team submit-time 429 for an inactive subscription on the subscription precondition', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'PAYMENT_REQUIRED',
|
||||
exceptionMessage: 'Subscription required to queue workflows'
|
||||
})
|
||||
).toBe('subscription')
|
||||
})
|
||||
|
||||
it('returns undefined for an ordinary workflow error', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
|
||||
@@ -33,7 +33,13 @@ const INSUFFICIENT_CREDITS_MESSAGES = new Set([
|
||||
'Payment Required: Please add credits to your account to use this node.'
|
||||
])
|
||||
const WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES = new Set([
|
||||
'Payment Required: Please add credits to your workspace to continue.'
|
||||
// Execution-time (pre-GPU) WebSocket failure for a queued team job.
|
||||
'Payment Required: Please add credits to your workspace to continue.',
|
||||
// Submit-time 429 rejection for a team workspace out of credits
|
||||
// (checkTeamWorkspaceSubscription). It shares the PAYMENT_REQUIRED error type
|
||||
// with the subscription-required rejection, so it can only be told apart by
|
||||
// message.
|
||||
'Insufficient credits to queue workflows'
|
||||
])
|
||||
const SUBSCRIPTION_REQUIRED_MESSAGES = new Set([
|
||||
'Workspace has no active subscription. Please subscribe to a plan to continue.',
|
||||
@@ -243,8 +249,12 @@ const RUNTIME_MATCH_RULES: RuntimeMatchRule[] = [
|
||||
resolve: () => catalogMatch(WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID)
|
||||
},
|
||||
{
|
||||
// 'insufficient_credits' is the error.type BE-2866 will emit on the
|
||||
// personal submit-time 402; the team submit path is a 429 matched by
|
||||
// message in WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES above.
|
||||
matches: (info, message) =>
|
||||
info.exceptionType === 'InsufficientFundsError' ||
|
||||
info.exceptionType === 'insufficient_credits' ||
|
||||
INSUFFICIENT_CREDITS_MESSAGES.has(message),
|
||||
resolve: () => catalogMatch(INSUFFICIENT_CREDITS_CATALOG_ID)
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedBillingControlEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
remoteConfigState
|
||||
@@ -60,8 +60,8 @@ export async function refreshRemoteConfig(
|
||||
cachedTeamWorkspacesEnabled.value = Boolean(
|
||||
config.team_workspaces_enabled
|
||||
)
|
||||
cachedConsolidatedBillingEnabled.value = Boolean(
|
||||
config.consolidated_billing_enabled
|
||||
cachedBillingControlEnabled.value = Boolean(
|
||||
config.billing_control_enabled
|
||||
)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -60,7 +60,7 @@ export const cachedTeamWorkspacesEnabled = useStorage<boolean | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
export const cachedConsolidatedBillingEnabled = useStorage<boolean | undefined>(
|
||||
'consolidated_billing_enabled' satisfies `${ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED}`,
|
||||
export const cachedBillingControlEnabled = useStorage<boolean | undefined>(
|
||||
'billing_control_enabled' satisfies `${ServerFeatureFlag.BILLING_CONTROL_ENABLED}`,
|
||||
undefined
|
||||
)
|
||||
|
||||
@@ -115,7 +115,7 @@ export type RemoteConfig = {
|
||||
comfyhub_upload_enabled?: boolean
|
||||
comfyhub_profile_gate_enabled?: boolean
|
||||
unified_cloud_auth?: boolean
|
||||
consolidated_billing_enabled?: boolean
|
||||
billing_control_enabled?: boolean
|
||||
sentry_dsn?: string
|
||||
turnstile_sitekey?: string
|
||||
// Raw, unvalidated wire value (a server typo like 'enfroce' is possible).
|
||||
|
||||
@@ -250,6 +250,9 @@ export type BillingStatus =
|
||||
| 'pending_payment'
|
||||
| 'paid'
|
||||
| 'payment_failed'
|
||||
// A Stripe-paused subscription stays `active` on the activity axis; the pause
|
||||
// is a payment-lifecycle fact. Not emitted until cloud#5075 ships.
|
||||
| 'paused'
|
||||
| 'inactive'
|
||||
|
||||
export interface CurrentTeamCreditStop {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col overflow-hidden rounded-2xl border border-border-default bg-base-background"
|
||||
data-testid="insufficient-credits-member-message"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 items-center gap-2 border-b border-border-default p-4"
|
||||
>
|
||||
<p class="m-0 min-w-0 flex-1 font-inter text-sm text-base-foreground">
|
||||
{{ $t('credits.insufficient.memberTitle') }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="$t('g.close')"
|
||||
class="flex size-4 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent text-base-foreground"
|
||||
@click="onClose"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<p class="m-0 font-inter text-sm text-muted-foreground">
|
||||
{{ $t('credits.insufficient.memberDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end p-4">
|
||||
<Button variant="secondary" size="lg" @click="onClose">
|
||||
{{ $t('credits.insufficient.memberCta') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { onClose } = defineProps<{
|
||||
onClose: () => void
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { SubscriptionInfo } from '@/composables/billing/types'
|
||||
import { i18n } from '@/i18n'
|
||||
import type { BillingContextMockState } from '@/storybook/mocks/useBillingContext'
|
||||
import { setBillingContextMock } from '@/storybook/mocks/useBillingContext'
|
||||
import type { WorkspaceUIMockState } from '@/storybook/mocks/useWorkspaceUI'
|
||||
import { setWorkspaceUIMock } from '@/storybook/mocks/useWorkspaceUI'
|
||||
|
||||
import BillingStatusBanner from './BillingStatusBanner.vue'
|
||||
|
||||
/**
|
||||
* The single billing banner slot for team workspaces (FE-1246), rendered in
|
||||
* priority order: paused > payment declined > out of credits > ending. At most
|
||||
* one state shows at a time. Each story drives the real `deriveBillingBanner`
|
||||
* through the stubbed billing context, so these are the states the backend can
|
||||
* actually emit — not hand-set banner kinds.
|
||||
*
|
||||
* The amber triangle-alert marks action-needed states; the muted circle-alert is
|
||||
* reserved for the informational "plan ends" notice (DES-380 severity rule).
|
||||
*
|
||||
* RUN WITH `DISTRIBUTION=cloud pnpm storybook`. The banner is cloud-only, and
|
||||
* `isCloud` is compile-time, so under a plain `pnpm storybook` every story below
|
||||
* renders empty.
|
||||
*/
|
||||
const meta: Meta<typeof BillingStatusBanner> = {
|
||||
title: 'Platform/Workspace/BillingStatusBanner',
|
||||
component: BillingStatusBanner,
|
||||
parameters: { layout: 'fullscreen' }
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof BillingStatusBanner>
|
||||
|
||||
const RENEWAL_DATE = '2026-08-01T00:00:00Z'
|
||||
const PLAN_END_DATE = '2026-08-01T00:00:00Z'
|
||||
|
||||
const teamSubscription: SubscriptionInfo = {
|
||||
isActive: true,
|
||||
tier: null,
|
||||
duration: 'MONTHLY',
|
||||
planSlug: 'team-monthly',
|
||||
renewalDate: RENEWAL_DATE,
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true
|
||||
}
|
||||
|
||||
const funded = teamSubscription
|
||||
const exhausted: SubscriptionInfo = { ...teamSubscription, hasFunds: false }
|
||||
const cancelled: SubscriptionInfo = {
|
||||
...teamSubscription,
|
||||
isCancelled: true,
|
||||
endDate: PLAN_END_DATE
|
||||
}
|
||||
|
||||
const owner: Partial<WorkspaceUIMockState> = {}
|
||||
const member: Partial<WorkspaceUIMockState> = {
|
||||
canManageSubscription: false,
|
||||
canManageSubscriptionLifecycle: false,
|
||||
canTopUp: false
|
||||
}
|
||||
|
||||
function story(
|
||||
billing: Partial<BillingContextMockState>,
|
||||
workspace: Partial<WorkspaceUIMockState>
|
||||
): Story {
|
||||
return {
|
||||
beforeEach() {
|
||||
// Dates in the copy go through vue-i18n's `d()`, so pin the locale rather
|
||||
// than inherit the developer's.
|
||||
i18n.global.locale.value = 'en'
|
||||
setBillingContextMock({ isTeamPlan: true, ...billing })
|
||||
setWorkspaceUIMock(workspace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe suspended the subscription. The backend folds billing_status into
|
||||
* is_active, so a paused workspace always reports `is_active: false` — the
|
||||
* pairing these stories pin.
|
||||
*/
|
||||
export const PausedOwner: Story = story(
|
||||
{
|
||||
subscription: funded,
|
||||
isActiveSubscription: false,
|
||||
billingStatus: 'paused',
|
||||
subscriptionStatus: 'active'
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/** Members get an admin-directed notice and no action. */
|
||||
export const PausedMember: Story = story(
|
||||
{
|
||||
subscription: funded,
|
||||
isActiveSubscription: false,
|
||||
billingStatus: 'paused',
|
||||
subscriptionStatus: 'active'
|
||||
},
|
||||
member
|
||||
)
|
||||
|
||||
/**
|
||||
* A failed charge with Stripe still retrying. Owner-only, and `is_active: false`
|
||||
* for the same reason paused is — payment_failed also denies spend.
|
||||
*/
|
||||
export const PaymentDeclined: Story = story(
|
||||
{
|
||||
subscription: funded,
|
||||
isActiveSubscription: false,
|
||||
billingStatus: 'payment_failed',
|
||||
subscriptionStatus: 'active',
|
||||
renewalDate: RENEWAL_DATE
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/** Payment declined before a renewal date is known. */
|
||||
export const PaymentDeclinedNoDate: Story = story(
|
||||
{
|
||||
subscription: funded,
|
||||
isActiveSubscription: false,
|
||||
billingStatus: 'payment_failed',
|
||||
subscriptionStatus: 'active'
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/** Shared team credits exhausted. Session-dismissible. */
|
||||
export const OutOfCreditsOwner: Story = story(
|
||||
{
|
||||
subscription: exhausted,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
subscriptionStatus: 'active',
|
||||
renewalDate: RENEWAL_DATE
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/** Members can't top up, so they get contact-admin copy and no Add credits. */
|
||||
export const OutOfCreditsMember: Story = story(
|
||||
{
|
||||
subscription: exhausted,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
subscriptionStatus: 'active',
|
||||
renewalDate: RENEWAL_DATE
|
||||
},
|
||||
member
|
||||
)
|
||||
|
||||
/** Cancelled but still active until the period end. Informational. */
|
||||
export const EndingOwner: Story = story(
|
||||
{
|
||||
subscription: cancelled,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
subscriptionStatus: 'canceled'
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/**
|
||||
* Reactivate is lifecycle-gated to the original owner, so a non-original owner
|
||||
* sees the notice read-only.
|
||||
*/
|
||||
export const EndingNonOriginalOwner: Story = story(
|
||||
{
|
||||
subscription: cancelled,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
subscriptionStatus: 'canceled'
|
||||
},
|
||||
{ canManageSubscriptionLifecycle: false }
|
||||
)
|
||||
@@ -0,0 +1,317 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
BillingStatus,
|
||||
WorkspaceType
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
|
||||
|
||||
interface Subscription {
|
||||
hasFunds: boolean
|
||||
isCancelled: boolean
|
||||
endDate: string | null
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
billingControlEnabled: true,
|
||||
isActiveSubscription: true,
|
||||
isTeamPlan: true,
|
||||
billingStatus: 'paid' as string | null,
|
||||
subscription: {
|
||||
hasFunds: true,
|
||||
isCancelled: false,
|
||||
endDate: null
|
||||
} as Subscription | null,
|
||||
renewalDate: null as string | null,
|
||||
workspaceType: 'team' as string,
|
||||
canManageSubscription: true,
|
||||
canManageSubscriptionLifecycle: true,
|
||||
canTopUp: true,
|
||||
showTopUpCreditsDialog: vi.fn(),
|
||||
manageSubscription: vi.fn(),
|
||||
handleResubscribe: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get billingControlEnabled() {
|
||||
return state.billingControlEnabled
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: computed(() => state.isActiveSubscription),
|
||||
isTeamPlan: computed(() => state.isTeamPlan),
|
||||
billingStatus: computed(() => state.billingStatus as BillingStatus | null),
|
||||
subscription: computed(() => state.subscription),
|
||||
renewalDate: computed(() => state.renewalDate),
|
||||
manageSubscription: state.manageSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: computed(() => ({
|
||||
canManageSubscription: state.canManageSubscription,
|
||||
canManageSubscriptionLifecycle: state.canManageSubscriptionLifecycle,
|
||||
canTopUp: state.canTopUp
|
||||
})),
|
||||
workspaceType: computed(() => state.workspaceType as WorkspaceType)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useResubscribe', () => ({
|
||||
useResubscribe: () => ({
|
||||
isResubscribing: computed(() => false),
|
||||
handleResubscribe: state.handleResubscribe
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showTopUpCreditsDialog: state.showTopUpCreditsDialog
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
workspacePanel: {
|
||||
billingStatus: {
|
||||
warning: {
|
||||
title: 'Payment declined',
|
||||
body: "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
|
||||
bodyNoDate:
|
||||
"Your last payment didn't go through. Update payment to avoid a pause."
|
||||
},
|
||||
paused: {
|
||||
title: 'Subscription paused',
|
||||
body: "This workspace's subscription is paused. Update payment to resume.",
|
||||
memberBody:
|
||||
"This workspace's subscription is paused. Your workspace admins need to update the payment method."
|
||||
},
|
||||
outOfCredits: {
|
||||
title: 'Out of credits',
|
||||
body: 'Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.',
|
||||
bodyNoDate:
|
||||
'Your team has used all its credits. Add more credits to continue generating.',
|
||||
memberBody:
|
||||
'Your team has used all its credits. Your workspace admins need to add more credits to continue generating.',
|
||||
addCredits: 'Add credits',
|
||||
dismiss: 'Dismiss'
|
||||
},
|
||||
ending: {
|
||||
title: 'Your team plan ends on {date}',
|
||||
body: 'Members keep full access until then. Reactivate to keep your shared credits and seats.',
|
||||
reactivate: 'Reactivate plan'
|
||||
},
|
||||
updatePayment: 'Update payment'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const globalOptions = {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Button: {
|
||||
template:
|
||||
'<button v-bind="$attrs" @click="$emit(\'click\')"><slot/></button>',
|
||||
props: ['variant', 'size', 'loading'],
|
||||
emits: ['click']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderBanner() {
|
||||
return render(BillingStatusBanner, { global: globalOptions })
|
||||
}
|
||||
|
||||
function exhausted() {
|
||||
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
|
||||
}
|
||||
|
||||
// The spend gate folds billing_status into is_active, so the backend never emits
|
||||
// paused alongside an active subscription.
|
||||
function pausedState() {
|
||||
state.billingStatus = 'paused'
|
||||
state.isActiveSubscription = false
|
||||
}
|
||||
|
||||
function paymentFailedState() {
|
||||
state.billingStatus = 'payment_failed'
|
||||
state.isActiveSubscription = false
|
||||
}
|
||||
|
||||
describe('BillingStatusBanner', () => {
|
||||
beforeEach(() => {
|
||||
state.billingControlEnabled = true
|
||||
state.isActiveSubscription = true
|
||||
state.isTeamPlan = true
|
||||
state.billingStatus = 'paid'
|
||||
state.subscription = { hasFunds: true, isCancelled: false, endDate: null }
|
||||
state.renewalDate = null
|
||||
state.workspaceType = 'team'
|
||||
state.canManageSubscription = true
|
||||
state.canManageSubscriptionLifecycle = true
|
||||
state.canTopUp = true
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders nothing for a healthy funded team', () => {
|
||||
renderBanner()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing when billing control is rolled back, even out of credits', () => {
|
||||
state.billingControlEnabled = false
|
||||
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
|
||||
renderBanner()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows out-of-credits with an Add credits action for owners', async () => {
|
||||
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Out of credits')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Add credits' }))
|
||||
expect(state.showTopUpCreditsDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows out-of-credits contact-admin copy without an Add credits action for members', () => {
|
||||
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
|
||||
state.canManageSubscription = false
|
||||
state.canTopUp = false
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Your workspace admins need to add more credits'
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Add credits' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dismisses the out-of-credits banner for the session', async () => {
|
||||
exhausted()
|
||||
renderBanner()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shares one dismiss across instances rather than tracking it per mount', async () => {
|
||||
exhausted()
|
||||
render(
|
||||
{
|
||||
components: { BillingStatusBanner },
|
||||
template: '<div><BillingStatusBanner /><BillingStatusBanner /></div>'
|
||||
},
|
||||
{ global: globalOptions }
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('status')).toHaveLength(2)
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Dismiss' })[0])
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the paused banner with Update payment for owners', async () => {
|
||||
pausedState()
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Subscription paused')
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Update payment to resume'
|
||||
)
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'Update payment' })
|
||||
)
|
||||
expect(state.manageSubscription).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the paused member notice without an action', () => {
|
||||
pausedState()
|
||||
state.canManageSubscription = false
|
||||
state.canTopUp = false
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Your workspace admins need to update the payment method'
|
||||
)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the payment-declined banner with Update payment for owners', () => {
|
||||
paymentFailedState()
|
||||
state.renewalDate = '2026-08-01T00:00:00Z'
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Payment declined')
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/will pause on \S+/)
|
||||
expect(screen.getByRole('status')).not.toHaveTextContent('{date}')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Update payment' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to the no-date payment-declined copy when there is no renewal date', () => {
|
||||
paymentFailedState()
|
||||
state.renewalDate = null
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Update payment to avoid a pause'
|
||||
)
|
||||
expect(screen.getByRole('status')).not.toHaveTextContent('will pause on')
|
||||
expect(screen.getByRole('status')).not.toHaveTextContent('{date}')
|
||||
})
|
||||
|
||||
it('shows the ending banner with a Reactivate action', async () => {
|
||||
state.subscription = {
|
||||
hasFunds: true,
|
||||
isCancelled: true,
|
||||
endDate: '2026-08-01T00:00:00Z'
|
||||
}
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Your team plan ends on'
|
||||
)
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'Reactivate plan' })
|
||||
)
|
||||
expect(state.handleResubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the ending banner read-only to a non-original owner (Reactivate is lifecycle-gated)', () => {
|
||||
state.subscription = {
|
||||
hasFunds: true,
|
||||
isCancelled: true,
|
||||
endDate: '2026-08-01T00:00:00Z'
|
||||
}
|
||||
state.canManageSubscriptionLifecycle = false
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Your team plan ends on'
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Reactivate plan' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div v-if="banner" class="@container">
|
||||
<div
|
||||
role="status"
|
||||
class="flex flex-col gap-3 rounded-2xl border border-interface-stroke/60 bg-base-background p-4 @2xl:flex-row @2xl:items-center @2xl:gap-2"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'size-4 shrink-0',
|
||||
banner.muted
|
||||
? 'icon-[lucide--circle-alert] text-muted-foreground'
|
||||
: 'icon-[lucide--triangle-alert] text-warning-background'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span class="text-sm text-base-foreground">{{ banner.title }}</span>
|
||||
</div>
|
||||
<p class="m-0 pl-6 text-sm text-muted-foreground">{{ banner.body }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="banner.dismissible || banner.action"
|
||||
class="flex shrink-0 flex-wrap items-center gap-2 pl-6 @2xl:pl-0"
|
||||
>
|
||||
<Button
|
||||
v-if="banner.dismissible"
|
||||
variant="textonly"
|
||||
size="lg"
|
||||
@click="dismiss"
|
||||
>
|
||||
{{ $t('workspacePanel.billingStatus.outOfCredits.dismiss') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="banner.action === 'addCredits'"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@click="handleAddCredits"
|
||||
>
|
||||
{{ $t('workspacePanel.billingStatus.outOfCredits.addCredits') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="banner.action === 'reactivate'"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:loading="isResubscribing"
|
||||
@click="handleResubscribe"
|
||||
>
|
||||
{{ $t('workspacePanel.billingStatus.ending.reactivate') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="banner.action === 'updatePayment'"
|
||||
variant="inverted"
|
||||
size="lg"
|
||||
@click="handleUpdatePayment"
|
||||
>
|
||||
{{ $t('workspacePanel.billingStatus.updatePayment') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useBillingBanner } from '@/platform/workspace/composables/useBillingBanner'
|
||||
import { useResubscribe } from '@/platform/workspace/composables/useResubscribe'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
type BannerAction = 'addCredits' | 'reactivate' | 'updatePayment'
|
||||
|
||||
const { t, d } = useI18n()
|
||||
const { renewalDate, subscription, manageSubscription } = useBillingContext()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { kind, dismiss } = useBillingBanner()
|
||||
const { isResubscribing, handleResubscribe } = useResubscribe()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
const canManage = computed(() => permissions.value.canManageSubscription)
|
||||
const canManageLifecycle = computed(
|
||||
() => permissions.value.canManageSubscriptionLifecycle
|
||||
)
|
||||
const canTopUp = computed(() => permissions.value.canTopUp)
|
||||
|
||||
const cycleResetDate = computed(() => {
|
||||
const raw = renewalDate.value
|
||||
return raw ? d(new Date(raw), { month: 'short', day: 'numeric' }) : ''
|
||||
})
|
||||
const planEndDate = computed(() => {
|
||||
const raw = subscription.value?.endDate
|
||||
return raw
|
||||
? d(new Date(raw), { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: ''
|
||||
})
|
||||
|
||||
interface BannerView {
|
||||
muted: boolean
|
||||
title: string
|
||||
body: string
|
||||
action: BannerAction | null
|
||||
dismissible: boolean
|
||||
}
|
||||
|
||||
const banner = computed<BannerView | null>(() => {
|
||||
const bs = 'workspacePanel.billingStatus'
|
||||
switch (kind.value) {
|
||||
case 'paused':
|
||||
return {
|
||||
muted: false,
|
||||
title: t(`${bs}.paused.title`),
|
||||
body: canManage.value
|
||||
? t(`${bs}.paused.body`)
|
||||
: t(`${bs}.paused.memberBody`),
|
||||
action: canManage.value ? 'updatePayment' : null,
|
||||
dismissible: false
|
||||
}
|
||||
case 'paymentFailed':
|
||||
return {
|
||||
muted: false,
|
||||
title: t(`${bs}.warning.title`),
|
||||
body: cycleResetDate.value
|
||||
? t(`${bs}.warning.body`, { date: cycleResetDate.value })
|
||||
: t(`${bs}.warning.bodyNoDate`),
|
||||
action: 'updatePayment',
|
||||
dismissible: false
|
||||
}
|
||||
case 'outOfCredits':
|
||||
return {
|
||||
muted: false,
|
||||
title: t(`${bs}.outOfCredits.title`),
|
||||
body: canTopUp.value
|
||||
? cycleResetDate.value
|
||||
? t(`${bs}.outOfCredits.body`, { date: cycleResetDate.value })
|
||||
: t(`${bs}.outOfCredits.bodyNoDate`)
|
||||
: t(`${bs}.outOfCredits.memberBody`),
|
||||
action: canTopUp.value ? 'addCredits' : null,
|
||||
dismissible: true
|
||||
}
|
||||
case 'ending':
|
||||
return {
|
||||
muted: true,
|
||||
title: t(`${bs}.ending.title`, { date: planEndDate.value }),
|
||||
body: t(`${bs}.ending.body`),
|
||||
action: canManageLifecycle.value ? 'reactivate' : null,
|
||||
dismissible: false
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
function handleAddCredits() {
|
||||
void dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
function handleUpdatePayment() {
|
||||
void manageSubscription()
|
||||
}
|
||||
</script>
|
||||
@@ -65,6 +65,16 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue',
|
||||
() => ({
|
||||
default: {
|
||||
name: 'BillingStatusBanner',
|
||||
template: '<div data-testid="billing-banner" />'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -93,6 +103,25 @@ function renderComponent() {
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkspacePanelContent billing banner', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMembers.value = []
|
||||
mockWorkspaceType.value = 'team'
|
||||
})
|
||||
|
||||
it('hosts a single banner slot above the tab content, so it shows on every tab', () => {
|
||||
renderComponent()
|
||||
|
||||
const banner = screen.getByTestId('billing-banner')
|
||||
const planPanel = screen.getByText('workspacePanel.tabs.planCredits')
|
||||
expect(
|
||||
planPanel.compareDocumentPosition(banner) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspacePanelContent members tab label', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<BillingStatusBanner class="mt-4" />
|
||||
|
||||
<TabsContent value="plan" class="mt-4">
|
||||
<SubscriptionPanelContentWorkspace />
|
||||
</TabsContent>
|
||||
@@ -58,6 +60,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
|
||||
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
|
||||
120
src/platform/workspace/composables/deriveBillingBanner.test.ts
Normal file
120
src/platform/workspace/composables/deriveBillingBanner.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { BillingBannerInputs } from './useBillingBanner'
|
||||
import { deriveBillingBanner } from './useBillingBanner'
|
||||
|
||||
const funded: BillingBannerInputs = {
|
||||
billingControlEnabled: true,
|
||||
isTeamPlan: true,
|
||||
isLoaded: true,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
hasFunds: true,
|
||||
isCancelled: false,
|
||||
endDate: null,
|
||||
canManage: true,
|
||||
outOfCreditsDismissed: false
|
||||
}
|
||||
|
||||
// The backend folds billing_status into is_active, so every spend-denying status
|
||||
// arrives paired with is_active=false. Pinning that pairing is what keeps these
|
||||
// cases honest — spreading `funded` instead would assert an input the backend
|
||||
// cannot emit, and pass no matter where the check sits.
|
||||
const paused: Partial<BillingBannerInputs> = {
|
||||
billingStatus: 'paused',
|
||||
isActiveSubscription: false
|
||||
}
|
||||
|
||||
const paymentFailed: Partial<BillingBannerInputs> = {
|
||||
billingStatus: 'payment_failed',
|
||||
isActiveSubscription: false
|
||||
}
|
||||
|
||||
function derive(overrides: Partial<BillingBannerInputs>) {
|
||||
return deriveBillingBanner({ ...funded, ...overrides })
|
||||
}
|
||||
|
||||
describe('deriveBillingBanner', () => {
|
||||
it('shows no banner for a healthy funded team', () => {
|
||||
expect(derive({})).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no banner outside a team plan', () => {
|
||||
expect(derive({ isTeamPlan: false, hasFunds: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no banner when billing control is rolled back, even out of credits', () => {
|
||||
expect(derive({ billingControlEnabled: false, hasFunds: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no banner until the subscription snapshot has loaded', () => {
|
||||
expect(derive({ isLoaded: false, hasFunds: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces out of credits when the balance is exhausted', () => {
|
||||
expect(derive({ hasFunds: false })).toBe('outOfCredits')
|
||||
})
|
||||
|
||||
it('shows out of credits to members too', () => {
|
||||
expect(derive({ hasFunds: false, canManage: false })).toBe('outOfCredits')
|
||||
})
|
||||
|
||||
it('hides out of credits once dismissed', () => {
|
||||
expect(derive({ hasFunds: false, outOfCreditsDismissed: true })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows payment failed to owners even though the backend reports the plan inactive', () => {
|
||||
expect(derive(paymentFailed)).toBe('paymentFailed')
|
||||
})
|
||||
|
||||
it('prioritizes payment failure over out of credits for owners', () => {
|
||||
expect(derive({ ...paymentFailed, hasFunds: false })).toBe('paymentFailed')
|
||||
})
|
||||
|
||||
it('hides payment failed from members, who get the run-lock modal instead', () => {
|
||||
expect(derive({ ...paymentFailed, canManage: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('prioritizes paused above everything, for owners and members', () => {
|
||||
expect(derive({ ...paused, hasFunds: false })).toBe('paused')
|
||||
expect(derive({ ...paused, canManage: false })).toBe('paused')
|
||||
})
|
||||
|
||||
it('shows paused even though the backend reports the workspace inactive', () => {
|
||||
expect(derive(paused)).toBe('paused')
|
||||
})
|
||||
|
||||
it('surfaces the ending banner for a cancelled-but-active owner', () => {
|
||||
expect(
|
||||
derive({
|
||||
isCancelled: true,
|
||||
endDate: '2026-08-01T00:00:00Z'
|
||||
})
|
||||
).toBe('ending')
|
||||
})
|
||||
|
||||
it('does not show the ending banner until the end date is populated', () => {
|
||||
expect(
|
||||
derive({
|
||||
isCancelled: true,
|
||||
endDate: null
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the ending banner from members', () => {
|
||||
expect(
|
||||
derive({
|
||||
isCancelled: true,
|
||||
endDate: '2026-08-01T00:00:00Z',
|
||||
canManage: false
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no banner for an inactive subscription (that is a run-lock modal)', () => {
|
||||
expect(
|
||||
derive({ isActiveSubscription: false, billingStatus: 'inactive' })
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
99
src/platform/workspace/composables/useBillingBanner.test.ts
Normal file
99
src/platform/workspace/composables/useBillingBanner.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
billing: null as {
|
||||
isActiveSubscription: { value: boolean }
|
||||
isTeamPlan: { value: boolean }
|
||||
billingStatus: { value: string | null }
|
||||
subscription: { value: { hasFunds: boolean } | null }
|
||||
} | null,
|
||||
billingControlEnabled: null as { value: boolean } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const billingControlEnabled = ref(true)
|
||||
mocks.billingControlEnabled = billingControlEnabled
|
||||
return {
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get billingControlEnabled() {
|
||||
return billingControlEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const billing = {
|
||||
isActiveSubscription: ref(true),
|
||||
isTeamPlan: ref(true),
|
||||
billingStatus: ref<string | null>('paid'),
|
||||
subscription: ref<{ hasFunds: boolean } | null>({ hasFunds: true })
|
||||
}
|
||||
mocks.billing = billing
|
||||
return { useBillingContext: () => billing }
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', async () => {
|
||||
const { computed } = await import('vue')
|
||||
return {
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: computed(() => ({
|
||||
canManageSubscription: true,
|
||||
canManageSubscriptionLifecycle: true,
|
||||
canTopUp: true
|
||||
}))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
import { useBillingBanner } from './useBillingBanner'
|
||||
|
||||
describe('useBillingBanner', () => {
|
||||
beforeEach(() => {
|
||||
const b = mocks.billing!
|
||||
b.isActiveSubscription.value = true
|
||||
b.isTeamPlan.value = true
|
||||
b.billingStatus.value = 'paid'
|
||||
b.subscription.value = { hasFunds: true }
|
||||
mocks.billingControlEnabled!.value = true
|
||||
})
|
||||
|
||||
it('suppresses the banner entirely when billing control is rolled back', async () => {
|
||||
const b = mocks.billing!
|
||||
const { kind } = useBillingBanner()
|
||||
|
||||
b.subscription.value = { hasFunds: false }
|
||||
await nextTick()
|
||||
expect(kind.value).toBe('outOfCredits')
|
||||
|
||||
mocks.billingControlEnabled!.value = false
|
||||
await nextTick()
|
||||
expect(kind.value).toBeNull()
|
||||
})
|
||||
|
||||
it('re-shows the out-of-credits banner after a top-up and a later exhaustion', async () => {
|
||||
const b = mocks.billing!
|
||||
const { kind, dismiss } = useBillingBanner()
|
||||
|
||||
b.subscription.value = { hasFunds: false }
|
||||
await nextTick()
|
||||
expect(kind.value).toBe('outOfCredits')
|
||||
|
||||
dismiss()
|
||||
await nextTick()
|
||||
expect(kind.value).toBeNull()
|
||||
|
||||
b.subscription.value = { hasFunds: true }
|
||||
await nextTick()
|
||||
b.subscription.value = { hasFunds: false }
|
||||
await nextTick()
|
||||
expect(kind.value).toBe('outOfCredits')
|
||||
})
|
||||
})
|
||||
106
src/platform/workspace/composables/useBillingBanner.ts
Normal file
106
src/platform/workspace/composables/useBillingBanner.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { BillingStatus } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
|
||||
export type BillingBannerKind =
|
||||
| 'paused'
|
||||
| 'paymentFailed'
|
||||
| 'outOfCredits'
|
||||
| 'ending'
|
||||
|
||||
export interface BillingBannerInputs {
|
||||
billingControlEnabled: boolean
|
||||
isTeamPlan: boolean
|
||||
isLoaded: boolean
|
||||
isActiveSubscription: boolean
|
||||
billingStatus: BillingStatus | null
|
||||
hasFunds: boolean | null
|
||||
isCancelled: boolean
|
||||
endDate: string | null
|
||||
canManage: boolean
|
||||
outOfCreditsDismissed: boolean
|
||||
}
|
||||
|
||||
// The single billing banner slot, in priority order: paused > paymentFailed >
|
||||
// outOfCredits > ending. billingControlEnabled is the FE-1246 kill switch: the
|
||||
// whole banner is behind it so a PostHog rollback hides it for everyone. Then
|
||||
// gated on the team PLAN rather than the workspace type, because personal
|
||||
// workspaces are due to gain team plans (BE-1526) — a workspace-type gate would
|
||||
// then hide the banner from real team subscribers.
|
||||
export function deriveBillingBanner(
|
||||
inputs: BillingBannerInputs
|
||||
): BillingBannerKind | null {
|
||||
if (!inputs.billingControlEnabled || !inputs.isTeamPlan || !inputs.isLoaded) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Both sit above the isActiveSubscription gate because the backend folds
|
||||
// billing_status into is_active: paused and payment_failed each report
|
||||
// is_active=false, so either check would be dead code below it.
|
||||
if (inputs.billingStatus === 'paused') return 'paused'
|
||||
if (inputs.billingStatus === 'payment_failed' && inputs.canManage) {
|
||||
return 'paymentFailed'
|
||||
}
|
||||
|
||||
// Inactive workspaces surface a run-lock modal, not this banner. Members hit
|
||||
// this on payment_failed, which is per design — only billing managers see it.
|
||||
if (!inputs.isActiveSubscription) return null
|
||||
|
||||
if (inputs.hasFunds === false && !inputs.outOfCreditsDismissed) {
|
||||
return 'outOfCredits'
|
||||
}
|
||||
if (inputs.isCancelled && inputs.endDate && inputs.canManage) {
|
||||
return 'ending'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function useBillingBannerInternal() {
|
||||
const { isActiveSubscription, billingStatus, subscription, isTeamPlan } =
|
||||
useBillingContext()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const dismissed = ref(false)
|
||||
|
||||
const kind = computed<BillingBannerKind | null>(() => {
|
||||
if (!isCloud) return null
|
||||
return deriveBillingBanner({
|
||||
billingControlEnabled: flags.billingControlEnabled,
|
||||
isTeamPlan: isTeamPlan.value,
|
||||
isLoaded: subscription.value !== null,
|
||||
isActiveSubscription: isActiveSubscription.value,
|
||||
billingStatus: billingStatus.value,
|
||||
hasFunds: subscription.value?.hasFunds ?? null,
|
||||
isCancelled: subscription.value?.isCancelled ?? false,
|
||||
endDate: subscription.value?.endDate ?? null,
|
||||
canManage: permissions.value.canManageSubscription,
|
||||
outOfCreditsDismissed: dismissed.value
|
||||
})
|
||||
})
|
||||
|
||||
// Dismiss silences only the out-of-credits banner, and only for the current
|
||||
// exhaustion episode: reset once the workspace is funded again so a later
|
||||
// exhaustion re-shows. Shared state, so it survives the settings panel
|
||||
// unmounting when the dialog closes.
|
||||
const hasExhaustedFunds = computed(
|
||||
() => subscription.value?.hasFunds === false
|
||||
)
|
||||
watch(hasExhaustedFunds, (exhausted) => {
|
||||
if (!exhausted) dismissed.value = false
|
||||
})
|
||||
|
||||
function dismiss() {
|
||||
dismissed.value = true
|
||||
}
|
||||
|
||||
return { kind, dismiss }
|
||||
}
|
||||
|
||||
export const useBillingBanner = createSharedComposable(useBillingBannerInternal)
|
||||
123
src/services/dialogService.topUpCredits.test.ts
Normal file
123
src/services/dialogService.topUpCredits.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* showTopUpCreditsDialog must route team members (who cannot top up) to the
|
||||
* read-only contact-admin notice instead of the purchase dialog, while
|
||||
* owners/personal/legacy users keep the purchase flow.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const showDialog = vi.hoisted(() => vi.fn())
|
||||
const closeDialog = vi.hoisted(() => vi.fn())
|
||||
const state = vi.hoisted(() => ({
|
||||
isActiveSubscription: true,
|
||||
isFreeTier: false,
|
||||
type: 'workspace' as 'workspace' | 'legacy',
|
||||
canTopUp: true
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ showDialog, closeDialog })
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackEvent: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: { value: state.isActiveSubscription },
|
||||
isFreeTier: { value: state.isFreeTier },
|
||||
type: { value: state.type }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: { value: { canTopUp: state.canTopUp } }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: vi.fn() })
|
||||
}))
|
||||
|
||||
const showSubscriptionDialog = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({ show: showSubscriptionDialog })
|
||||
})
|
||||
)
|
||||
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
describe('showTopUpCreditsDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
state.isActiveSubscription = true
|
||||
state.isFreeTier = false
|
||||
state.type = 'workspace'
|
||||
state.canTopUp = true
|
||||
})
|
||||
|
||||
it('shows the purchase dialog to users who can top up', async () => {
|
||||
await useDialogService().showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('top-up-credits')
|
||||
})
|
||||
|
||||
it('shows the contact-admin notice to team members instead of the purchase dialog', async () => {
|
||||
state.canTopUp = false
|
||||
|
||||
await useDialogService().showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('insufficient-credits-member')
|
||||
// The member notice draws its own header + close button, so it must open
|
||||
// headless or Reka wraps it in duplicate chrome.
|
||||
expect(args.dialogComponentProps.headless).toBe(true)
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
|
||||
args.props.onClose()
|
||||
expect(closeDialog).toHaveBeenCalledWith({
|
||||
key: 'insufficient-credits-member'
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores workspace permissions on legacy billing', async () => {
|
||||
state.type = 'legacy'
|
||||
state.canTopUp = false
|
||||
|
||||
await useDialogService().showTopUpCreditsDialog()
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('top-up-credits')
|
||||
})
|
||||
|
||||
it('routes a member of an inactive team to the subscription-required flow, not the credits notice', async () => {
|
||||
state.isActiveSubscription = false
|
||||
state.canTopUp = false
|
||||
|
||||
await useDialogService().showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
|
||||
expect(showSubscriptionDialog).toHaveBeenCalledWith({
|
||||
reason: 'out_of_credits'
|
||||
})
|
||||
expect(showDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,9 @@ import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationD
|
||||
import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue'
|
||||
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
|
||||
import TopUpCreditsDialogContentLegacy from '@/components/dialog/content/TopUpCreditsDialogContentLegacy.vue'
|
||||
import InsufficientCreditsMemberDialog from '@/platform/workspace/components/InsufficientCreditsMemberDialog.vue'
|
||||
import TopUpCreditsDialogContentWorkspace from '@/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { t } from '@/i18n'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -339,6 +341,28 @@ export const useDialogService = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Members can't top up a team workspace, so they get a read-only
|
||||
// "ask your workspace admins" notice instead of the purchase dialog.
|
||||
if (
|
||||
type.value === 'workspace' &&
|
||||
!useWorkspaceUI().permissions.value.canTopUp
|
||||
) {
|
||||
return dialogStore.showDialog({
|
||||
key: 'insufficient-credits-member',
|
||||
component: InsufficientCreditsMemberDialog,
|
||||
props: {
|
||||
onClose: () =>
|
||||
dialogStore.closeDialog({ key: 'insufficient-credits-member' })
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
contentClass:
|
||||
'w-[min(360px,95vw)] max-w-[min(360px,95vw)] sm:max-w-[min(360px,95vw)] border-0 bg-transparent shadow-none'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const component =
|
||||
type.value === 'workspace'
|
||||
? TopUpCreditsDialogContentWorkspace
|
||||
|
||||
@@ -2,14 +2,42 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import type { BillingContext } from '@/composables/billing/types'
|
||||
|
||||
type Subscription = BillingContext['subscription']['value']
|
||||
|
||||
/** The billing state a story wants the stub to report. */
|
||||
export interface BillingContextMockState {
|
||||
subscription: Subscription
|
||||
isActiveSubscription: boolean
|
||||
isTeamPlan: boolean
|
||||
billingStatus: BillingContext['billingStatus']['value']
|
||||
subscriptionStatus: BillingContext['subscriptionStatus']['value']
|
||||
renewalDate: string | null
|
||||
}
|
||||
|
||||
const defaultState: BillingContextMockState = {
|
||||
subscription: null,
|
||||
isActiveSubscription: false,
|
||||
isTeamPlan: false,
|
||||
billingStatus: null,
|
||||
subscriptionStatus: null,
|
||||
renewalDate: null
|
||||
}
|
||||
|
||||
const state = ref<BillingContextMockState>({ ...defaultState })
|
||||
|
||||
/** Drives the stub from a story's `beforeEach`. */
|
||||
export function setBillingContextMock(next: Partial<BillingContextMockState>) {
|
||||
state.value = { ...defaultState, ...next }
|
||||
}
|
||||
|
||||
/**
|
||||
* Storybook mock for `useBillingContext`.
|
||||
*
|
||||
* The real facade lazily instantiates the legacy billing adapter, which pulls
|
||||
* in Firebase auth (`setPersistence`) and crashes in the Storybook environment
|
||||
* (no Firebase). This static stub lets presentational billing components — e.g.
|
||||
* UnifiedPricingTable — render against their `TIER_PRICING` / DES-197 fallbacks
|
||||
* without any network or auth.
|
||||
* (no Firebase). This stub lets billing components — e.g. UnifiedPricingTable,
|
||||
* BillingStatusBanner — render without any network or auth. It defaults to the
|
||||
* unsubscribed state; call `setBillingContextMock` to drive a specific one.
|
||||
*
|
||||
* Typed against `BillingContext` so the stub stays in lockstep with the real
|
||||
* composable's return shape: drifted or removed keys fail to compile.
|
||||
@@ -18,7 +46,7 @@ export function useBillingContext(): BillingContext {
|
||||
return {
|
||||
type: computed(() => 'legacy' as const),
|
||||
isInitialized: ref(true),
|
||||
subscription: computed(() => null),
|
||||
subscription: computed(() => state.value.subscription),
|
||||
balance: computed(() => null),
|
||||
plans: computed(() => []),
|
||||
currentPlanSlug: computed(() => null),
|
||||
@@ -26,13 +54,14 @@ export function useBillingContext(): BillingContext {
|
||||
currentTeamCreditStop: computed(() => null),
|
||||
isLoading: ref(false),
|
||||
error: ref<string | null>(null),
|
||||
isActiveSubscription: computed(() => false),
|
||||
isActiveSubscription: computed(() => state.value.isActiveSubscription),
|
||||
isFreeTier: computed(() => false),
|
||||
isLegacyTeamPlan: computed(() => false),
|
||||
billingStatus: computed(() => null),
|
||||
subscriptionStatus: computed(() => null),
|
||||
isTeamPlan: computed(() => state.value.isTeamPlan),
|
||||
billingStatus: computed(() => state.value.billingStatus),
|
||||
subscriptionStatus: computed(() => state.value.subscriptionStatus),
|
||||
tier: computed(() => null),
|
||||
renewalDate: computed(() => null),
|
||||
renewalDate: computed(() => state.value.renewalDate),
|
||||
getMaxSeats: (tierKey: string) => ({ creator: 5, pro: 20 })[tierKey] ?? 1,
|
||||
initialize: async () => {},
|
||||
fetchStatus: async () => {},
|
||||
|
||||
@@ -26,14 +26,14 @@ export enum ServerFeatureFlag {
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled'
|
||||
BILLING_CONTROL_ENABLED = 'billing_control_enabled'
|
||||
}
|
||||
|
||||
export function useFeatureFlags() {
|
||||
return {
|
||||
flags: {
|
||||
teamWorkspacesEnabled: true,
|
||||
consolidatedBillingEnabled: true
|
||||
billingControlEnabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
45
src/storybook/mocks/useWorkspaceUI.ts
Normal file
45
src/storybook/mocks/useWorkspaceUI.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { WorkspaceType } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
/** The workspace role/permission state a story wants the stub to report. */
|
||||
export interface WorkspaceUIMockState {
|
||||
workspaceType: WorkspaceType
|
||||
canManageSubscription: boolean
|
||||
canManageSubscriptionLifecycle: boolean
|
||||
canTopUp: boolean
|
||||
}
|
||||
|
||||
const defaultState: WorkspaceUIMockState = {
|
||||
workspaceType: 'team',
|
||||
canManageSubscription: true,
|
||||
canManageSubscriptionLifecycle: true,
|
||||
canTopUp: true
|
||||
}
|
||||
|
||||
const state = ref<WorkspaceUIMockState>({ ...defaultState })
|
||||
|
||||
/** Drives the stub from a story's `beforeEach`. */
|
||||
export function setWorkspaceUIMock(next: Partial<WorkspaceUIMockState>) {
|
||||
state.value = { ...defaultState, ...next }
|
||||
}
|
||||
|
||||
/**
|
||||
* Storybook mock for `useWorkspaceUI`.
|
||||
*
|
||||
* The real composable derives permissions from `useCurrentUser` (Firebase auth)
|
||||
* and the team workspace store, neither of which is available in Storybook. This
|
||||
* stub exposes only the role surface the billing banner reads; add keys here as
|
||||
* other stories need them.
|
||||
*/
|
||||
export function useWorkspaceUI() {
|
||||
return {
|
||||
workspaceType: computed(() => state.value.workspaceType),
|
||||
permissions: computed(() => ({
|
||||
canManageSubscription: state.value.canManageSubscription,
|
||||
canManageSubscriptionLifecycle:
|
||||
state.value.canManageSubscriptionLifecycle,
|
||||
canTopUp: state.value.canTopUp
|
||||
}))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user