Compare commits

...

23 Commits

Author SHA1 Message Date
GitHub Action
fc336bd9b5 [automated] Apply ESLint and Prettier fixes 2025-12-11 09:14:47 +00:00
Christian Byrne
a693f40064 feat: replace Stripe pricing table with custom implementation (#7359)
## Summary
- Replace StripePricingTable with CustomPricingTable component
- Add intelligent subscription tier detection and button logic
- Remove Stripe dependencies and feature flags
- Clean up unused Stripe-related files and configurations

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7359-feat-replace-Stripe-pricing-table-with-custom-implementation-2c66d73d365081f684d4ec81c7cc6790)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-11 09:12:37 +00:00
Comfy Org PR Bot
801ab024e5 [backport cloud/1.34] feat: show subscription tier below name on cloud (#7360)
Backport of #7356 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7360-backport-cloud-1-34-feat-show-subscription-tier-below-name-on-cloud-2c66d73d3650817093b2ed141f477629)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-11 02:01:28 -07:00
Christian Byrne
2b4d3484b8 [backport cloud/1.34] fix: make subscription panel reactive to actual tier (#7357)
## Summary
Backport of #7354 to cloud/1.34

- Update CloudSubscriptionStatusResponse to use generated types from
comfyRegistryTypes which includes subscription_tier
- Add subscriptionTier computed to useSubscription composable
- Make SubscriptionPanel tierName, tierPrice, and tierBenefits reactive
to actual subscription tier from API
- Normalize i18n tier structure with consistent value/label format
- Add FOUNDERS_EDITION tier support

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7357-fix-make-subscription-panel-reactive-to-actual-tier-backport-to-cloud-1-34-2c66d73d365081a99695fa1fb7901120)
by [Unito](https://www.unito.io)

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-11 01:18:33 -07:00
bymyself
4e68a266a0 Revert "fix: make subscription panel reactive to actual tier (#7354)"
This reverts commit d3d02d0d4b.
2025-12-10 23:54:22 -08:00
Christian Byrne
d3d02d0d4b fix: make subscription panel reactive to actual tier (#7354)
Was previously hard-coded, now is actually reactive to value returned
from server

- Update CloudSubscriptionStatusResponse to use generated types from
comfyRegistryTypes which includes subscription_tier
- Add subscriptionTier computed to useSubscription composable
- Make SubscriptionPanel tierName, tierPrice, and tierBenefits reactive
to actual subscription tier from API
- Normalize i18n tier structure with consistent value/label format
- Add FOUNDERS_EDITION tier support

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7354-fix-make-subscription-panel-reactive-to-actual-tier-2c66d73d365081059a7be875c13fdd0c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-10 23:50:40 -08:00
Comfy Org PR Bot
4b008eeaf7 [backport cloud/1.34] fix: credits loading skeleton in user popover (#7350)
Backport of #7347 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7350-backport-cloud-1-34-fix-credits-loading-skeleton-in-user-popover-2c66d73d36508130b251f38bc8a8f66d)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-10 23:20:59 -07:00
Comfy Org PR Bot
3f203002b9 [backport cloud/1.34] fix: subscribe button overflow on cloud (#7346)
Backport of #7343 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7346-backport-cloud-1-34-fix-subscribe-button-overflow-on-cloud-2c66d73d36508195920ce922265c0a0d)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-10 23:20:50 -07:00
Comfy Org PR Bot
cc60dfd910 [backport cloud/1.34] remove fraction digits on topup credit number (#7349)
Backport of #7345 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7349-backport-cloud-1-34-remove-fraction-digits-on-topup-credit-number-2c66d73d365081f1a635e7a4b55cfaf0)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-10 23:08:07 -07:00
Comfy Org PR Bot
913875d745 [backport cloud/1.34] [chore] Update Comfy Registry API types from comfy-api@e1e32b5 (#7348)
Backport of #7344 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7348-backport-cloud-1-34-chore-Update-Comfy-Registry-API-types-from-comfy-api-e1e32b5-2c66d73d3650816d8565df2c3df17215)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-12-10 22:59:51 -07:00
Comfy Org PR Bot
0751a13df7 [backport cloud/1.34] Improve subscription dialog width for laptop screens (#7329)
Backport of #7324 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7329-backport-cloud-1-34-Improve-subscription-dialog-width-for-laptop-screens-2c66d73d36508160884be66e159c2ad2)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2025-12-10 17:45:55 -07:00
Comfy Org PR Bot
562db3b0d9 [backport cloud/1.34] fix: allow dots in template URL parameter for version numbers (#7328)
Backport of #7325 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7328-backport-cloud-1-34-fix-allow-dots-in-template-URL-parameter-for-version-numbers-2c56d73d36508192b2b6f90a0562029d)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-12-10 17:13:06 -07:00
Comfy Org PR Bot
432c1e8e33 [backport cloud/1.34] Fix compatibility with older browsers (#7314)
Backport of #7205 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7314-backport-cloud-1-34-Fix-compatibility-with-older-browsers-2c56d73d3650816cbe75dcdca276edb2)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-12-09 23:31:15 -07:00
Comfy Org PR Bot
a660c55da9 [backport cloud/1.34] feat: display and upload Civitai preview images in model upload flow (#7301)
Backport of #7274 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7301-backport-cloud-1-34-feat-display-and-upload-Civitai-preview-images-in-model-upload-flo-2c56d73d3650814caedbc0b64480cb9c)
by [Unito](https://www.unito.io)

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-09 23:30:58 -07:00
Comfy Org PR Bot
fdda9cc752 [backport cloud/1.34] feat: update subscription panel with tier-based design and improved UX (#7312)
Backport of #7307 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7312-backport-cloud-1-34-feat-update-subscription-panel-with-tier-based-design-and-improved-2c56d73d365081e28400d30170266e85)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-09 22:30:12 -07:00
Comfy Org PR Bot
ab74061bc6 [backport cloud/1.34] style: redesign TopUpCredits dialog (#7313)
Backport of #7305 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7313-backport-cloud-1-34-style-redesign-TopUpCredits-dialog-2c56d73d36508172b633f5602a8b967d)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-09 22:30:05 -07:00
Comfy Org PR Bot
c82f4272a7 [backport cloud/1.34] style: redesign user popover with improved layout and integration with design system (#7311)
Backport of #7303 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7311-backport-cloud-1-34-style-redesign-user-popover-with-improved-layout-and-integration-w-2c56d73d365081019e0beea29f06e362)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-09 21:30:20 -07:00
Comfy Org PR Bot
32c525a4a6 [backport cloud/1.34] add shared comfy credit conversion helpers (#7293)
Backport of #7061 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7293-backport-cloud-1-34-add-shared-comfy-credit-conversion-helpers-2c46d73d365081a9b1bcfb82462c3d7f)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-09 05:26:00 -07:00
Comfy Org PR Bot
27dcb152ff [backport cloud/1.34] feat: add Stripe pricing table integration for subscription dialog (conditional on feature flag) (#7292)
Backport of #7288 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7292-backport-cloud-1-34-feat-add-Stripe-pricing-table-integration-for-subscription-dialog--2c46d73d36508111869ddf32de921b29)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-09 05:03:59 -07:00
Comfy Org PR Bot
d2f5f0dce1 [backport cloud/1.34] feat: Enable system notifications on cloud (#7287)
Backport of #7277 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7287-backport-cloud-1-34-feat-Enable-system-notifications-on-cloud-2c46d73d365081aaaf90d4ff96d0ca52)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-09 03:41:29 -07:00
Comfy Org PR Bot
47d8f022ec [backport cloud/1.34] change credits icons and tooltips (conditional on feature flag) (#7291)
Backport of #7276 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7291-backport-cloud-1-34-change-credits-icons-and-tooltips-conditional-on-feature-flag-2c46d73d36508163b2d6cad792078e4c)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-09 03:41:09 -07:00
Comfy Org PR Bot
271c69f261 [backport cloud/1.34] Add label to open subgraph button (#7290)
Backport of #7244 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7290-backport-cloud-1-34-Add-label-to-open-subgraph-button-2c46d73d3650813f914ae58916047178)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-12-09 03:22:11 -07:00
Comfy Org PR Bot
218a7f24a6 [backport cloud/1.34] Fix cloud queue cancel to target specific jobs (#7283)
Backport of #7176 to `cloud/1.34`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7283-backport-cloud-1-34-Fix-cloud-queue-cancel-to-target-specific-jobs-2c46d73d365081e99fbddbd615e858b3)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2025-12-09 01:20:54 -07:00
50 changed files with 2497 additions and 502 deletions

View File

@@ -42,3 +42,7 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org
# SENTRY_PROJECT=cloud-frontend-staging
# Stripe pricing table configuration (used by feature-flagged subscription tiers UI)
# VITE_STRIPE_PUBLISHABLE_KEY=pk_test_123
# VITE_STRIPE_PRICING_TABLE_ID=prctbl_123

2
global.d.ts vendored
View File

@@ -13,6 +13,8 @@ interface Window {
max_upload_size?: number
comfy_api_base_url?: string
comfy_platform_base_url?: string
stripe_publishable_key?: string
stripe_pricing_table_id?: string
firebase_config?: {
apiKey: string
authDomain: string

View File

@@ -1945,6 +1945,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/proxy/kling/v1/images/omni-image": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** KlingAI Create Omni-Image Task */
post: operations["klingCreateOmniImage"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/proxy/kling/v1/images/omni-image/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** KlingAI Query Single Omni-Image Task */
get: operations["klingOmniImageQuerySingleTask"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/proxy/kling/v1/images/kolors-virtual-try-on": {
parameters: {
query?: never;
@@ -3876,7 +3910,7 @@ export interface components {
* @description The subscription tier level
* @enum {string}
*/
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO";
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
FeaturesResponse: {
/**
* @description The conversion rate for partner nodes
@@ -5096,6 +5130,71 @@ export interface components {
};
};
};
KlingOmniImageRequest: {
/**
* @description Model Name
* @default kling-image-o1
* @enum {string}
*/
model_name: "kling-image-o1";
/** @description Text prompt words, which can include positive and negative descriptions. Must not exceed 2,500 characters. The Omni model can achieve various capabilities through Prompt with elements and images. Specify an image in the format of <<<>>>, such as <<<image_1>>>. */
prompt: string;
/** @description Reference Image List. Supports inputting image Base64 encoding or image URL (ensure accessibility). Supported formats include .jpg/.jpeg/.png. File size cannot exceed 10MB. Width and height dimensions shall not be less than 300px, aspect ratio between 1:2.5 ~ 2.5:1. Maximum 10 images. */
image_list?: {
/** @description Image Base64 encoding or image URL (ensure accessibility) */
image?: string;
}[];
/**
* @description Image generation resolution. 1k is 1K standard, 2k is 2K high-res, 4k is 4K high-res.
* @default 1k
* @enum {string}
*/
resolution: "1k" | "2k" | "4k";
/**
* @description Number of generated images. Value range [1,9].
* @default 1
*/
n: number;
/**
* @description Aspect ratio of the generated images (width:height). auto is to intelligently generate images based on incoming content.
* @default auto
* @enum {string}
*/
aspect_ratio: "16:9" | "9:16" | "1:1" | "4:3" | "3:4" | "3:2" | "2:3" | "21:9" | "auto";
/**
* Format: uri
* @description The callback notification address for the result of this task. If configured, the server will actively notify when the task status changes.
*/
callback_url?: string;
/** @description Customized Task ID. Must be unique within a single user account. */
external_task_id?: string;
};
KlingOmniImageResponse: {
/** @description Error code */
code?: number;
/** @description Error message */
message?: string;
/** @description Request ID */
request_id?: string;
data?: {
/** @description Task ID */
task_id?: string;
task_status?: components["schemas"]["KlingTaskStatus"];
/** @description Task status information, displaying the failure reason when the task fails (such as triggering the content risk control of the platform, etc.) */
task_status_msg?: string;
task_info?: {
/** @description Customer-defined task ID */
external_task_id?: string;
};
/** @description Task creation time, Unix timestamp in milliseconds */
created_at?: number;
/** @description Task update time, Unix timestamp in milliseconds */
updated_at?: number;
task_result?: {
images?: components["schemas"]["KlingImageResult"][];
};
};
};
KlingLipSyncInputObject: {
/** @description The ID of the video generated by Kling AI. Only supports 5-second and 10-second videos generated within the last 30 days. */
video_id?: string;
@@ -10065,7 +10164,7 @@ export interface components {
};
BytePlusImageGenerationRequest: {
/** @enum {string} */
model: "seedream-3-0-t2i-250415" | "seededit-3-0-i2i-250628" | "seedream-4-0-250828";
model: "seedream-3-0-t2i-250415" | "seededit-3-0-i2i-250628" | "seedream-4-0-250828" | "seedream-4-5-251128";
/** @description Text description for image generation or transformation */
prompt: string;
/**
@@ -10170,10 +10269,10 @@ export interface components {
};
BytePlusVideoGenerationRequest: {
/**
* @description The ID of the model to call. Available models include seedance-1-0-pro-250528, seedance-1-0-lite-t2v-250428, seedance-1-0-lite-i2v-250428
* @description The ID of the model to call. Available models include seedance-1-0-pro-250528, seedance-1-0-pro-fast-251015, seedance-1-0-lite-t2v-250428, seedance-1-0-lite-i2v-250428
* @enum {string}
*/
model: "seedance-1-0-pro-250528" | "seedance-1-0-lite-t2v-250428" | "seedance-1-0-lite-i2v-250428";
model: "seedance-1-0-pro-250528" | "seedance-1-0-lite-t2v-250428" | "seedance-1-0-lite-i2v-250428" | "seedance-1-0-pro-fast-251015";
/** @description The input content for the model to generate a video */
content: components["schemas"]["BytePlusVideoGenerationContent"][];
/**
@@ -13947,6 +14046,15 @@ export interface operations {
"application/json": components["schemas"]["Node"];
};
};
/** @description Redirect to node with normalized name match */
302: {
headers: {
/** @description URL of the node with the correct ID */
Location?: string;
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
@@ -18345,6 +18453,198 @@ export interface operations {
};
};
};
klingCreateOmniImage: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description Create task for generating omni-image */
requestBody: {
content: {
"application/json": components["schemas"]["KlingOmniImageRequest"];
};
};
responses: {
/** @description Successful response (Request successful) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingOmniImageResponse"];
};
};
/** @description Invalid request parameters */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Authentication failed */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Unauthorized access to requested resource */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Account exception or Rate limit exceeded */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Service temporarily unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Server timeout */
504: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
};
};
klingOmniImageQuerySingleTask: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Task ID or External Task ID. Can query by either task_id (generated by system) or external_task_id (customized task ID) */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful response (Request successful) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingOmniImageResponse"];
};
};
/** @description Invalid request parameters */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Authentication failed */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Unauthorized access to requested resource */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Account exception or Rate limit exceeded */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Service temporarily unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Server timeout */
504: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
};
};
klingVirtualTryOnQueryTaskList: {
parameters: {
query?: {

View File

@@ -0,0 +1,125 @@
const DEFAULT_NUMBER_FORMAT: Intl.NumberFormatOptions = {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}
const formatNumber = ({
value,
locale,
options
}: {
value: number
locale?: string
options?: Intl.NumberFormatOptions
}): string => {
const merged: Intl.NumberFormatOptions = {
...DEFAULT_NUMBER_FORMAT,
...options
}
if (
typeof merged.maximumFractionDigits === 'number' &&
typeof merged.minimumFractionDigits === 'number' &&
merged.maximumFractionDigits < merged.minimumFractionDigits
) {
merged.minimumFractionDigits = merged.maximumFractionDigits
}
return new Intl.NumberFormat(locale, merged).format(value)
}
export const CREDITS_PER_USD = 211
export const COMFY_CREDIT_RATE_CENTS = CREDITS_PER_USD / 100 // credits per cent
export const usdToCents = (usd: number): number => Math.round(usd * 100)
export const centsToCredits = (cents: number): number =>
Math.round(cents * COMFY_CREDIT_RATE_CENTS)
export const creditsToCents = (credits: number): number =>
Math.round(credits / COMFY_CREDIT_RATE_CENTS)
export const usdToCredits = (usd: number): number =>
Math.round(usd * CREDITS_PER_USD)
export const creditsToUsd = (credits: number): number =>
Math.round((credits / CREDITS_PER_USD) * 100) / 100
export type FormatOptions = {
value: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}
export type FormatFromCentsOptions = {
cents: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}
export type FormatFromUsdOptions = {
usd: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}
export const formatCredits = ({
value,
locale,
numberOptions
}: FormatOptions): string =>
formatNumber({ value, locale, options: numberOptions })
export const formatCreditsFromCents = ({
cents,
locale,
numberOptions
}: FormatFromCentsOptions): string =>
formatCredits({
value: centsToCredits(cents),
locale,
numberOptions
})
export const formatCreditsFromUsd = ({
usd,
locale,
numberOptions
}: FormatFromUsdOptions): string =>
formatCredits({
value: usdToCredits(usd),
locale,
numberOptions
})
export const formatUsd = ({
value,
locale,
numberOptions
}: FormatOptions): string =>
formatNumber({
value,
locale,
options: numberOptions
})
export const formatUsdFromCents = ({
cents,
locale,
numberOptions
}: FormatFromCentsOptions): string =>
formatUsd({
value: cents / 100,
locale,
numberOptions
})
/**
* Clamps a USD value to the allowed range for credit purchases
* @param value - The USD amount to clamp
* @returns The clamped value between $1 and $1000, or 0 if NaN
*/
export const clampUsd = (value: number): number => {
if (Number.isNaN(value)) return 0
return Math.min(1000, Math.max(1, value))
}

View File

@@ -8,12 +8,24 @@
</div>
<div v-else class="flex items-center gap-1">
<Tag
v-if="!showCreditsOnly"
severity="secondary"
icon="pi pi-dollar"
rounded
class="p-1 text-amber-400"
/>
<div :class="textClass">{{ formattedBalance }}</div>
>
<template #icon>
<i
:class="
flags.subscriptionTiersEnabled
? 'icon-[lucide--component]'
: 'pi pi-dollar'
"
/>
</template>
</Tag>
<div :class="textClass">
{{ showCreditsOnly ? formattedCreditsOnly : formattedBalance }}
</div>
</div>
</template>
@@ -21,19 +33,40 @@
import Skeleton from 'primevue/skeleton'
import Tag from 'primevue/tag'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
const { textClass } = defineProps<{
const { textClass, showCreditsOnly } = defineProps<{
textClass?: string
showCreditsOnly?: boolean
}>()
const authStore = useFirebaseAuthStore()
const { flags } = useFeatureFlags()
const balanceLoading = computed(() => authStore.isFetchingBalance)
const { t, locale } = useI18n()
const formattedBalance = computed(() => {
if (!authStore.balance) return '0.00'
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
// Backend returns cents despite the *_micros naming convention.
const cents = authStore.balance?.amount_micros ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value
})
return `${amount} ${t('credits.credits')}`
})
const formattedCreditsOnly = computed(() => {
// Backend returns cents despite the *_micros naming convention.
const cents = authStore.balance?.amount_micros ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value,
numberOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 }
})
return amount
})
</script>

View File

@@ -1,5 +1,74 @@
<template>
<div class="flex w-96 flex-col gap-10 p-2">
<!-- New Credits Design (default) -->
<div v-if="useNewDesign" class="flex w-112 flex-col gap-8 p-8">
<!-- Header -->
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-semibold text-white m-0">
{{
isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun')
: $t('credits.topUp.addMoreCredits')
}}
</h1>
<div v-if="isInsufficientCredits" class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0 w-96">
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>
</div>
<div v-else class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0">
{{ $t('credits.topUp.creditsDescription') }}
</p>
</div>
</div>
<!-- Current Balance Section -->
<div class="flex flex-col gap-4">
<div class="flex items-baseline gap-2">
<UserCredit text-class="text-3xl font-bold" show-credits-only />
<span class="text-sm text-muted-foreground">{{
$t('credits.creditsAvailable')
}}</span>
</div>
<div v-if="formattedRenewalDate" class="text-sm text-muted-foreground">
{{ $t('credits.refreshes', { date: formattedRenewalDate }) }}
</div>
</div>
<!-- Credit Options Section -->
<div class="flex flex-col gap-4">
<span class="text-sm text-muted-foreground">
{{ $t('credits.topUp.howManyCredits') }}
</span>
<div class="flex flex-col gap-2">
<CreditTopUpOption
v-for="option in creditOptions"
:key="option.credits"
:credits="option.credits"
:description="option.description"
:selected="selectedCredits === option.credits"
@select="selectedCredits = option.credits"
/>
</div>
<div class="text-xs text-muted-foreground w-96">
{{ $t('credits.topUp.templateNote') }}
</div>
</div>
<!-- Buy Button -->
<Button
:disabled="!selectedCredits || loading"
:loading="loading"
severity="primary"
:label="$t('credits.topUp.buy')"
:class="['w-full', { 'opacity-30': !selectedCredits || loading }]"
:pt="{ label: { class: 'text-white' } }"
@click="handleBuy"
/>
</div>
<!-- Legacy Design -->
<div v-else class="flex w-96 flex-col gap-10 p-2">
<div v-if="isInsufficientCredits" class="flex flex-col gap-4">
<h1 class="my-0 text-2xl leading-normal font-medium">
{{ $t('credits.topUp.insufficientTitle') }}
@@ -34,14 +103,14 @@
>{{ $t('credits.topUp.quickPurchase') }}:</span
>
<div class="grid grid-cols-[2fr_1fr] gap-2">
<CreditTopUpOption
<LegacyCreditTopUpOption
v-for="amount in amountOptions"
:key="amount"
:amount="amount"
:preselected="amount === preselectedAmountOption"
/>
<CreditTopUpOption :amount="100" :preselected="false" editable />
<LegacyCreditTopUpOption :amount="100" :preselected="false" editable />
</div>
</div>
</div>
@@ -49,11 +118,28 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
creditsToUsd,
formatCredits,
formatUsd
} from '@/base/credits/comfyCredits'
import UserCredit from '@/components/common/UserCredit.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
import LegacyCreditTopUpOption from './credit/LegacyCreditTopUpOption.vue'
interface CreditOption {
credits: number
description: string
}
const {
isInsufficientCredits = false,
@@ -65,7 +151,74 @@ const {
preselectedAmountOption?: number
}>()
const { flags } = useFeatureFlags()
const { formattedRenewalDate } = useSubscription()
// Use feature flag to determine design - defaults to true (new design)
const useNewDesign = computed(() => flags.subscriptionTiersEnabled)
const { t, locale } = useI18n()
const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const toast = useToast()
const selectedCredits = ref<number | null>(null)
const loading = ref(false)
const creditOptions: CreditOption[] = [
{
credits: 1055, // $5.00
description: t('credits.topUp.videosEstimate', { count: 41 })
},
{
credits: 2110, // $10.00
description: t('credits.topUp.videosEstimate', { count: 82 })
},
{
credits: 4220, // $20.00
description: t('credits.topUp.videosEstimate', { count: 184 })
},
{
credits: 10550, // $50.00
description: t('credits.topUp.videosEstimate', { count: 412 })
}
]
const handleBuy = async () => {
if (!selectedCredits.value) return
loading.value = true
try {
const usdAmount = creditsToUsd(selectedCredits.value)
telemetry?.trackApiCreditTopupButtonPurchaseClicked(usdAmount)
await authActions.purchaseCredits(usdAmount)
toast.add({
severity: 'success',
summary: t('credits.topUp.purchaseSuccess'),
detail: t('credits.topUp.purchaseSuccessDetail', {
credits: formatCredits({
value: selectedCredits.value,
locale: locale.value
}),
amount: `$${formatUsd({ value: usdAmount, locale: locale.value })}`
}),
life: 3000
})
} catch (error) {
console.error('Purchase failed:', error)
const errorMessage =
error instanceof Error ? error.message : t('credits.topUp.unknownError')
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
life: 5000
})
} finally {
loading.value = false
}
}
const handleSeeDetails = async () => {
await authActions.accessBillingPortal()

View File

@@ -1,81 +1,45 @@
<template>
<div class="flex items-center gap-2">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="p-1 text-amber-400"
/>
<InputNumber
v-if="editable"
v-model="customAmount"
:min="1"
:max="1000"
:step="1"
show-buttons
:allow-empty="false"
:highlight-on-focus="true"
pt:pc-input-text:root="w-24"
@blur="(e: InputNumberBlurEvent) => (customAmount = Number(e.value))"
@input="(e: InputNumberInputEvent) => (customAmount = Number(e.value))"
/>
<span v-else class="text-xl">{{ amount }}</span>
<div
class="flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all duration-200"
:class="[
selected
? 'bg-secondary-background border-2 border-border-default'
: 'bg-component-node-disabled hover:bg-secondary-background border-2 border-transparent'
]"
@click="$emit('select')"
>
<span class="text-base font-bold text-white">
{{ formattedCredits }}
</span>
<span class="text-sm font-normal text-white">
{{ description }}
</span>
</div>
<ProgressSpinner v-if="loading" class="h-8 w-8" />
<Button
v-else
:severity="preselected ? 'primary' : 'secondary'"
:outlined="!preselected"
:label="$t('credits.topUp.buyNow')"
@click="handleBuyNow"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import InputNumber from 'primevue/inputnumber'
import type {
InputNumberBlurEvent,
InputNumberInputEvent
} from 'primevue/inputnumber'
import ProgressSpinner from 'primevue/progressspinner'
import Tag from 'primevue/tag'
import { onBeforeUnmount, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useTelemetry } from '@/platform/telemetry'
import { formatCredits } from '@/base/credits/comfyCredits'
const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const {
amount,
preselected,
editable = false
} = defineProps<{
amount: number
preselected: boolean
editable?: boolean
const { credits, description, selected } = defineProps<{
credits: number
description: string
selected: boolean
}>()
const customAmount = ref(amount)
const didClickBuyNow = ref(false)
const loading = ref(false)
defineEmits<{
select: []
}>()
const handleBuyNow = async () => {
const creditAmount = editable ? customAmount.value : amount
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)
const { locale } = useI18n()
loading.value = true
await authActions.purchaseCredits(creditAmount)
loading.value = false
didClickBuyNow.value = true
}
onBeforeUnmount(() => {
if (didClickBuyNow.value) {
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
void authActions.fetchBalance()
}
const formattedCredits = computed(() => {
return formatCredits({
value: credits,
locale: locale.value,
numberOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 }
})
})
</script>

View File

@@ -0,0 +1,119 @@
<template>
<div class="flex items-center gap-2">
<Tag
severity="secondary"
icon="pi pi-wallet"
rounded
class="p-1 text-amber-400"
/>
<div v-if="editable" class="flex items-center gap-2">
<InputNumber
v-model="customAmount"
:min="1"
:max="1000"
:step="1"
show-buttons
:allow-empty="false"
:highlight-on-focus="true"
prefix="$"
pt:pc-input-text:root="w-28"
@blur="
(e: InputNumberBlurEvent) =>
(customAmount = clampUsd(Number(e.value)))
"
@input="
(e: InputNumberInputEvent) =>
(customAmount = clampUsd(Number(e.value)))
"
/>
<span class="text-xs text-muted">{{ formattedCredits }}</span>
</div>
<div v-else class="flex flex-col leading-tight">
<span class="text-xl font-semibold">{{ formattedCredits }}</span>
<span class="text-xs text-muted">{{ formattedUsd }}</span>
</div>
</div>
<ProgressSpinner v-if="loading" class="h-8 w-8" />
<Button
v-else
:severity="preselected ? 'primary' : 'secondary'"
:outlined="!preselected"
:label="$t('credits.topUp.buyNow')"
@click="handleBuyNow"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import InputNumber from 'primevue/inputnumber'
import type {
InputNumberBlurEvent,
InputNumberInputEvent
} from 'primevue/inputnumber'
import ProgressSpinner from 'primevue/progressspinner'
import Tag from 'primevue/tag'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
clampUsd,
formatCreditsFromUsd,
formatUsd
} from '@/base/credits/comfyCredits'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useTelemetry } from '@/platform/telemetry'
const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const {
amount,
preselected,
editable = false
} = defineProps<{
amount: number
preselected: boolean
editable?: boolean
}>()
const customAmount = ref(amount)
const didClickBuyNow = ref(false)
const loading = ref(false)
const { t, locale } = useI18n()
const displayUsdAmount = computed(() =>
editable ? clampUsd(Number(customAmount.value)) : clampUsd(amount)
)
const formattedCredits = computed(
() =>
`${formatCreditsFromUsd({
usd: displayUsdAmount.value,
locale: locale.value
})} ${t('credits.credits')}`
)
const formattedUsd = computed(
() => `$${formatUsd({ value: displayUsdAmount.value, locale: locale.value })}`
)
const handleBuyNow = async () => {
const creditAmount = displayUsdAmount.value
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)
loading.value = true
try {
await authActions.purchaseCredits(creditAmount)
didClickBuyNow.value = true
} finally {
loading.value = false
}
}
onBeforeUnmount(() => {
if (didClickBuyNow.value) {
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
void authActions.fetchBalance()
}
})
</script>

View File

@@ -1,5 +1,6 @@
<template>
<TabPanel value="Credits" class="credits-container h-full">
<!-- Legacy Design -->
<div class="flex h-full flex-col">
<h2 class="mb-2 text-2xl font-bold">
{{ $t('credits.credits') }}

View File

@@ -75,6 +75,7 @@ import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -263,11 +264,21 @@ const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
const interruptAll = wrapWithErrorHandlingAsync(async () => {
const tasks = queueStore.runningTasks
await Promise.all(
tasks
.filter((task) => task.promptId != null)
.map((task) => api.interrupt(task.promptId))
)
const promptIds = tasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
if (!promptIds.length) return
// Cloud backend supports cancelling specific jobs via /queue delete,
// while /interrupt always targets the "first" job. Use the targeted API
// on cloud to ensure we cancel the workflow the user clicked.
if (isCloud) {
await Promise.all(promptIds.map((id) => api.deleteItem('queue', id)))
return
}
await Promise.all(promptIds.map((id) => api.interrupt(id)))
})
const showClearHistoryDialog = () => {

View File

@@ -1,10 +1,10 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import Button from 'primevue/button'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserPopover from './CurrentUserPopover.vue'
@@ -74,7 +74,9 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
getAuthHeader: vi
.fn()
.mockResolvedValue({ Authorization: 'Bearer mock-token' })
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
balance: { amount_micros: 100_000 }, // 100,000 cents = ~211,000 credits
isFetchingBalance: false
}))
}))
@@ -107,6 +109,39 @@ vi.mock('@/components/common/UserCredit.vue', () => ({
}
}))
// Mock formatCreditsFromCents
vi.mock('@/base/credits/comfyCredits', () => ({
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
}))
// Mock useExternalLink
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
buildDocsUrl: vi.fn((path) => `https://docs.comfy.org${path}`)
}))
}))
// Mock useFeatureFlags
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => ({
flags: {
subscriptionTiersEnabled: true
}
}))
}))
// Mock useTelemetry
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackAddApiCreditButtonClicked: vi.fn()
}))
}))
// Mock isCloud
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
default: {
name: 'SubscribeButtonMock',
@@ -145,27 +180,37 @@ describe('CurrentUserPopover', () => {
expect(wrapper.text()).toContain('test@example.com')
})
it('renders logout button with correct props', () => {
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (last button)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[4]
expect(formatCreditsFromCents).toHaveBeenCalledWith({
cents: 100_000,
locale: 'en',
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}
})
// Check that logout button has correct props
expect(logoutButton.props('label')).toBe('Log Out')
expect(logoutButton.props('icon')).toBe('pi pi-sign-out')
// Verify the formatted credit string (1000) is rendered in the DOM
expect(wrapper.text()).toContain('1000')
})
it('opens user settings and emits close event when settings button is clicked', async () => {
it('renders logout menu item with correct text', () => {
const wrapper = mountComponent()
// Find all buttons and get the settings button (third button)
const buttons = wrapper.findAllComponents(Button)
const settingsButton = buttons[2]
const logoutItem = wrapper.find('[data-testid="logout-menu-item"]')
expect(logoutItem.exists()).toBe(true)
expect(wrapper.text()).toContain('Log Out')
})
// Click the settings button
await settingsButton.trigger('click')
it('opens user settings and emits close event when settings item is clicked', async () => {
const wrapper = mountComponent()
const settingsItem = wrapper.find('[data-testid="user-settings-menu-item"]')
expect(settingsItem.exists()).toBe(true)
await settingsItem.trigger('click')
// Verify showSettingsDialog was called with 'user'
expect(mockShowSettingsDialog).toHaveBeenCalledWith('user')
@@ -175,15 +220,13 @@ describe('CurrentUserPopover', () => {
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('calls logout function and emits close event when logout button is clicked', async () => {
it('calls logout function and emits close event when logout item is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (last button)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[4]
const logoutItem = wrapper.find('[data-testid="logout-menu-item"]')
expect(logoutItem.exists()).toBe(true)
// Click the logout button
await logoutButton.trigger('click')
await logoutItem.trigger('click')
// Verify handleSignOut was called
expect(mockHandleSignOut).toHaveBeenCalled()
@@ -193,15 +236,15 @@ describe('CurrentUserPopover', () => {
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
it('opens API pricing docs and emits close event when partner nodes item is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the Partner Nodes info button (first one)
const buttons = wrapper.findAllComponents(Button)
const partnerNodesButton = buttons[0]
const partnerNodesItem = wrapper.find(
'[data-testid="partner-nodes-menu-item"]'
)
expect(partnerNodesItem.exists()).toBe(true)
// Click the Partner Nodes button
await partnerNodesButton.trigger('click')
await partnerNodesItem.trigger('click')
// Verify window.open was called with the correct URL
expect(window.open).toHaveBeenCalledWith(
@@ -217,11 +260,9 @@ describe('CurrentUserPopover', () => {
it('opens top-up dialog and emits close event when top-up button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the top-up button (second one)
const buttons = wrapper.findAllComponents(Button)
const topUpButton = buttons[1]
const topUpButton = wrapper.find('[data-testid="add-credits-button"]')
expect(topUpButton.exists()).toBe(true)
// Click the top-up button
await topUpButton.trigger('click')
// Verify showTopUpCreditsDialog was called

View File

@@ -1,112 +1,152 @@
<!-- A popover that shows current user information and actions -->
<template>
<div class="current-user-popover w-72">
<div
class="current-user-popover w-80 -m-3 p-2 rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- User Info Section -->
<div class="p-3">
<div class="flex flex-col items-center">
<UserAvatar
class="mb-3"
:photo-url="userPhotoUrl"
:pt:icon:class="{
'text-2xl!': !userPhotoUrl
}"
size="large"
/>
<div class="flex flex-col items-center px-0 py-3 mb-4">
<UserAvatar
class="mb-1"
:photo-url="userPhotoUrl"
:pt:icon:class="{
'text-2xl!': !userPhotoUrl
}"
size="large"
/>
<!-- User Details -->
<h3 class="my-0 mb-1 truncate text-lg font-semibold">
{{ userDisplayName || $t('g.user') }}
</h3>
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
{{ userEmail }}
</p>
</div>
<!-- User Details -->
<h3 class="my-0 mb-1 truncate text-base font-bold text-base-foreground">
{{ userDisplayName || $t('g.user') }}
</h3>
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
{{ userEmail }}
</p>
<p v-if="subscriptionTierName" class="my-0 truncate text-sm text-muted">
{{ subscriptionTierName }}
</p>
</div>
<div v-if="isActiveSubscription" class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<UserCredit text-class="text-2xl" />
<Button
:label="$t('subscription.partnerNodesCredits')"
severity="secondary"
text
size="small"
class="pl-6 p-0 h-auto justify-start"
:pt="{
root: {
class: 'hover:bg-transparent active:bg-transparent'
}
}"
@click="handleOpenPartnerNodesInfo"
/>
</div>
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
<Skeleton
v-if="authStore.isFetchingBalance"
width="4rem"
height="1.25rem"
class="flex-1"
/>
<span v-else class="text-base font-normal text-base-foreground flex-1">{{
formattedBalance
}}</span>
<Button
:label="$t('credits.topUp.topUp')"
:label="$t('subscription.addCredits')"
severity="secondary"
size="small"
class="text-base-foreground"
data-testid="add-credits-button"
@click="handleTopUp"
/>
</div>
<SubscribeButton
v-else
:label="$t('subscription.subscribeToComfyCloud')"
size="small"
variant="gradient"
@subscribed="handleSubscribed"
/>
<Divider class="my-2" />
<div v-else class="flex justify-center px-4">
<SubscribeButton
:fluid="false"
:label="$t('subscription.subscribeToComfyCloud')"
size="small"
variant="gradient"
@subscribed="handleSubscribed"
/>
</div>
<Button
class="justify-start"
:label="$t('userSettings.title')"
icon="pi pi-cog"
text
fluid
severity="secondary"
@click="handleOpenUserSettings"
/>
<!-- Credits info row -->
<div
v-if="flags.subscriptionTiersEnabled && isActiveSubscription"
class="flex items-center gap-2 px-4 py-0"
>
<i
v-tooltip="{
value: $t('credits.unified.tooltip'),
showDelay: 300,
hideDelay: 300
}"
class="icon-[lucide--circle-help] cursor-help text-xs text-muted-foreground"
/>
<span class="text-sm text-muted-foreground">{{
$t('credits.unified.message')
}}</span>
</div>
<Button
<Divider class="my-2 mx-0" />
<div
v-if="isActiveSubscription"
class="justify-start"
:label="$t(planSettingsLabel)"
icon="pi pi-receipt"
text
fluid
severity="secondary"
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
data-testid="partner-nodes-menu-item"
@click="handleOpenPartnerNodesInfo"
>
<i class="icon-[lucide--tag] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
$t('subscription.partnerNodesCredits')
}}</span>
</div>
<div
v-if="isActiveSubscription"
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
data-testid="plan-credits-menu-item"
@click="handleOpenPlanAndCreditsSettings"
/>
>
<i class="icon-[lucide--receipt-text] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
$t(planSettingsLabel)
}}</span>
</div>
<Divider class="my-2" />
<div
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
data-testid="user-settings-menu-item"
@click="handleOpenUserSettings"
>
<i class="icon-[lucide--settings-2] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
$t('userSettings.title')
}}</span>
</div>
<Button
class="justify-start"
:label="$t('auth.signOut.signOut')"
icon="pi pi-sign-out"
text
fluid
severity="secondary"
<Divider class="my-2 mx-0" />
<div
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
data-testid="logout-menu-item"
@click="handleLogout"
/>
>
<i class="icon-[lucide--log-out] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
$t('auth.signOut.signOut')
}}</span>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import { onMounted } from 'vue'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import UserCredit from '@/components/common/UserCredit.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const emit = defineEmits<{
close: []
@@ -121,8 +161,25 @@ const planSettingsLabel = isCloud
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const authStore = useFirebaseAuthStore()
const dialogService = useDialogService()
const { isActiveSubscription, fetchStatus } = useSubscription()
const { isActiveSubscription, subscriptionTierName, fetchStatus } =
useSubscription()
const { flags } = useFeatureFlags()
const { locale } = useI18n()
const formattedBalance = computed(() => {
// Backend returns cents despite the *_micros naming convention.
const cents = authStore.balance?.amount_micros ?? 0
return formatCreditsFromCents({
cents,
locale: locale.value,
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}
})
})
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')

View File

@@ -1,10 +1,16 @@
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
const componentIconSvg = new Image()
componentIconSvg.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='oklch(83.01%25 0.163 83.16)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0zm-13.239 0a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0zm6.619 6.619a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0zm0-13.238a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z'/%3E%3C/svg%3E"
export const usePriceBadge = () => {
const { flags } = useFeatureFlags()
function updateSubgraphCredits(node: LGraphNode) {
if (!node.isSubgraphNode()) return
node.badges = node.badges.filter((b) => !isCreditsBadge(b))
@@ -33,34 +39,54 @@ export const usePriceBadge = () => {
}
function isCreditsBadge(badge: LGraphBadge | (() => LGraphBadge)): boolean {
return (
(typeof badge === 'function' ? badge() : badge).icon?.unicode === '\ue96b'
)
const badgeInstance = typeof badge === 'function' ? badge() : badge
if (flags.subscriptionTiersEnabled) {
return badgeInstance.icon?.image === componentIconSvg
} else {
return badgeInstance.icon?.unicode === '\ue96b'
}
}
const colorPaletteStore = useColorPaletteStore()
function getCreditsBadge(price: string): LGraphBadge {
const isLightTheme = colorPaletteStore.completedActivePalette.light_theme
return new LGraphBadge({
text: price,
iconOptions: {
unicode: '\ue96b',
fontFamily: 'PrimeIcons',
color: isLightTheme
? adjustColor('#FABC25', { lightness: 0.5 })
: '#FABC25',
if (flags.subscriptionTiersEnabled) {
return new LGraphBadge({
text: price,
iconOptions: {
image: componentIconSvg,
size: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: isLightTheme
? adjustColor('#654020', { lightness: 0.5 })
: '#654020',
fontSize: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: isLightTheme
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
} else {
return new LGraphBadge({
text: price,
iconOptions: {
unicode: '\ue96b',
fontFamily: 'PrimeIcons',
color: isLightTheme
? adjustColor('#FABC25', { lightness: 0.5 })
: '#FABC25',
bgColor: isLightTheme
? adjustColor('#654020', { lightness: 0.5 })
: '#654020',
fontSize: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: isLightTheme
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
}
}
return {
getCreditsBadge,

View File

@@ -12,7 +12,8 @@ export enum ServerFeatureFlag {
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled',
PRIVATE_MODELS_ENABLED = 'private_models_enabled'
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
SUBSCRIPTION_TIERS_ENABLED = 'subscription_tiers_enabled'
}
/**
@@ -55,6 +56,16 @@ export function useFeatureFlags() {
remoteConfig.value.private_models_enabled ??
api.getServerFeature(ServerFeatureFlag.PRIVATE_MODELS_ENABLED, false)
)
},
get subscriptionTiersEnabled() {
// Check remote config first (from /api/features), fall back to websocket feature flags
return (
remoteConfig.value.subscription_tiers_enabled ??
api.getServerFeature(
ServerFeatureFlag.SUBSCRIPTION_TIERS_ENABLED,
true // Default to true (new design)
)
)
}
})

View File

@@ -66,8 +66,12 @@ export class LGraphBadge {
const { font } = ctx
let iconWidth = 0
if (this.icon) {
ctx.font = `${this.icon.fontSize}px '${this.icon.fontFamily}'`
iconWidth = ctx.measureText(this.icon.unicode).width + this.padding
if (this.icon.image) {
iconWidth = this.icon.size + this.padding
} else if (this.icon.unicode) {
ctx.font = `${this.icon.fontSize}px '${this.icon.fontFamily}'`
iconWidth = ctx.measureText(this.icon.unicode).width + this.padding
}
}
ctx.font = `${this.fontSize}px sans-serif`
const textWidth = this.text ? ctx.measureText(this.text).width : 0
@@ -104,7 +108,8 @@ export class LGraphBadge {
// Draw icon if present
if (this.icon) {
this.icon.draw(ctx, drawX, centerY)
drawX += this.icon.fontSize + this.padding / 2 + 4
const iconWidth = this.icon.image ? this.icon.size : this.icon.fontSize
drawX += iconWidth + this.padding / 2 + 4
}
// Draw badge text

View File

@@ -1,20 +1,24 @@
export interface LGraphIconOptions {
unicode: string
unicode?: string
fontFamily?: string
image?: HTMLImageElement
color?: string
bgColor?: string
fontSize?: number
size?: number
circlePadding?: number
xOffset?: number
yOffset?: number
}
export class LGraphIcon {
unicode: string
unicode?: string
fontFamily: string
image?: HTMLImageElement
color: string
bgColor?: string
fontSize: number
size: number
circlePadding: number
xOffset: number
yOffset: number
@@ -22,18 +26,22 @@ export class LGraphIcon {
constructor({
unicode,
fontFamily = 'PrimeIcons',
image,
color = '#e6c200',
bgColor,
fontSize = 16,
size,
circlePadding = 2,
xOffset = 0,
yOffset = 0
}: LGraphIconOptions) {
this.unicode = unicode
this.fontFamily = fontFamily
this.image = image
this.color = color
this.bgColor = bgColor
this.fontSize = fontSize
this.size = size ?? fontSize
this.circlePadding = circlePadding
this.xOffset = xOffset
this.yOffset = yOffset
@@ -43,26 +51,44 @@ export class LGraphIcon {
x += this.xOffset
y += this.yOffset
const { font, textBaseline, textAlign, fillStyle } = ctx
if (this.image) {
const iconSize = this.size
const iconRadius = iconSize / 2 + this.circlePadding
ctx.font = `${this.fontSize}px '${this.fontFamily}'`
ctx.textBaseline = 'middle'
ctx.textAlign = 'center'
const iconRadius = this.fontSize / 2 + this.circlePadding
// Draw icon background circle if bgColor is set
if (this.bgColor) {
ctx.beginPath()
ctx.arc(x + iconRadius, y, iconRadius, 0, 2 * Math.PI)
ctx.fillStyle = this.bgColor
ctx.fill()
if (this.bgColor) {
const { fillStyle } = ctx
ctx.beginPath()
ctx.arc(x + iconRadius, y, iconRadius, 0, 2 * Math.PI)
ctx.fillStyle = this.bgColor
ctx.fill()
ctx.fillStyle = fillStyle
}
const imageX = x + this.circlePadding
const imageY = y - iconSize / 2
ctx.drawImage(this.image, imageX, imageY, iconSize, iconSize)
} else if (this.unicode) {
const { font, textBaseline, textAlign, fillStyle } = ctx
ctx.font = `${this.fontSize}px '${this.fontFamily}'`
ctx.textBaseline = 'middle'
ctx.textAlign = 'center'
const iconRadius = this.fontSize / 2 + this.circlePadding
if (this.bgColor) {
ctx.beginPath()
ctx.arc(x + iconRadius, y, iconRadius, 0, 2 * Math.PI)
ctx.fillStyle = this.bgColor
ctx.fill()
}
ctx.fillStyle = this.color
ctx.fillText(this.unicode, x + iconRadius, y)
ctx.font = font
ctx.textBaseline = textBaseline
ctx.textAlign = textAlign
ctx.fillStyle = fillStyle
}
// Draw icon
ctx.fillStyle = this.color
ctx.fillText(this.unicode, x + iconRadius, y)
ctx.font = font
ctx.textBaseline = textBaseline
ctx.textAlign = textAlign
ctx.fillStyle = fillStyle
}
}

View File

@@ -851,13 +851,12 @@ export class LGraphNode
}
if (info.widgets_values) {
const widgetsWithValue = this.widgets
.values()
.filter((w) => w.serialize !== false)
.filter((_w, idx) => idx < info.widgets_values!.length)
widgetsWithValue.forEach(
(widget, i) => (widget.value = info.widgets_values![i])
)
let i = 0
for (const widget of this.widgets ?? []) {
if (widget.serialize === false) continue
if (i >= info.widgets_values.length) break
widget.value = info.widgets_values[i++]
}
}
}

View File

@@ -97,6 +97,7 @@
"no": "No",
"cancel": "Cancel",
"close": "Close",
"or": "or",
"pressKeysForNewBinding": "Press keys for new binding",
"defaultBanner": "default banner",
"enableOrDisablePack": "Enable or disable pack",
@@ -1833,15 +1834,34 @@
"maxAmount": "(Max. $1,000 USD)",
"buyNow": "Buy now",
"seeDetails": "See details",
"topUp": "Top Up"
"topUp": "Top Up",
"addMoreCredits": "Add more credits",
"addMoreCreditsToRun": "Add more credits to run",
"insufficientWorkflowMessage": "You don't have enough credits to run this workflow.",
"creditsDescription": "Credits are used to run workflows or partner nodes.",
"howManyCredits": "How many credits would you like to add?",
"videosEstimate": "~{count} videos*",
"templateNote": "*Generated with Wan Fun Control template",
"buy": "Buy",
"purchaseSuccess": "Purchase Successful",
"purchaseSuccessDetail": "Successfully purchased {credits} credits for {amount}",
"purchaseError": "Purchase Failed",
"purchaseErrorDetail": "Failed to purchase credits: {error}",
"unknownError": "An unknown error occurred"
},
"creditsAvailable": "Credits available",
"refreshes": "Refreshes {date}",
"eventType": "Event Type",
"details": "Details",
"time": "Time",
"additionalInfo": "Additional Info",
"model": "Model",
"added": "Added",
"accountInitialized": "Account initialized"
"accountInitialized": "Account initialized",
"unified": {
"message": "Credits have been unified",
"tooltip": "We've unified payments across Comfy. Everything now runs on Comfy Credits:\n- Partner Nodes (formerly API nodes)\n- Cloud workflows\n\nYour existing Partner node balance has been converted into credits."
}
},
"subscription": {
"title": "Subscription",
@@ -1849,7 +1869,8 @@
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Comfy Cloud Logo",
"beta": "BETA",
"perMonth": "USD / month",
"perMonth": "/ month",
"usdPerMonth": "USD / month",
"renewsDate": "Renews {date}",
"refreshesOn": "Refreshes to ${monthlyCreditBonusUsd} on {date}",
"expiresDate": "Expires {date}",
@@ -1864,6 +1885,10 @@
"monthlyBonusDescription": "Monthly credit bonus",
"prepaidDescription": "Pre-paid credits",
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
"creditsRemainingThisMonth": "Credits remaining for this month",
"creditsYouveAdded": "Credits you've added",
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
"viewMoreDetailsPlans": "View more details about plans & pricing",
"nextBillingCycle": "next billing cycle",
"yourPlanIncludes": "Your plan includes:",
"viewMoreDetails": "View more details",
@@ -1874,19 +1899,108 @@
"benefit1": "$10 in monthly credits for Partner Nodes — top up when needed",
"benefit2": "Up to 30 min runtime per job"
},
"tiers": {
"founder": {
"name": "Founder's Edition",
"price": "20.00",
"benefits": {
"monthlyCredits": "5,460",
"monthlyCreditsLabel": "monthly credits",
"maxDuration": "30 min",
"maxDurationLabel": "max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs"
}
},
"standard": {
"name": "Standard",
"price": "20.00",
"benefits": {
"monthlyCredits": "4,200",
"monthlyCreditsLabel": "monthly credits",
"maxDuration": "30 min",
"maxDurationLabel": "max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs",
"videoEstimate": "120"
}
},
"creator": {
"name": "Creator",
"price": "35.00",
"benefits": {
"monthlyCredits": "7,400",
"monthlyCreditsLabel": "monthly credits",
"maxDuration": "30 min",
"maxDurationLabel": "max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs",
"videoEstimate": "288"
}
},
"pro": {
"name": "Pro",
"price": "100.00",
"benefits": {
"monthlyCredits": "21,100",
"monthlyCreditsLabel": "monthly credits",
"maxDuration": "1 hr",
"maxDurationLabel": "max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs",
"videoEstimate": "815"
}
}
},
"required": {
"title": "Subscribe to",
"waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!",
"subscribe": "Subscribe"
},
"pricingTable": {
"description": "Access cloud-powered ComfyUI workflows with straightforward, usage-based pricing.",
"loading": "Loading pricing options...",
"loadError": "We couldn't load the pricing table. Please refresh and try again.",
"missingConfig": "Stripe pricing table configuration missing. Provide the publishable key and pricing table ID via remote config or .env."
},
"subscribeToRun": "Subscribe",
"subscribeToRunFull": "Subscribe to Run",
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"partnerNodesCredits": "Partner Nodes pricing table"
"description": "Choose the best plan for you",
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
"viewEnterprise": "view enterprise",
"partnerNodesCredits": "Partner nodes pricing",
"mostPopular": "Most popular",
"currentPlan": "Current Plan",
"subscribeTo": "Subscribe to {plan}",
"monthlyCreditsLabel": "Monthly credits",
"maxDurationLabel": "Max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs",
"videoEstimateLabel": "Approx. amount of 5s videos generated with Wan Fun Control template",
"videoEstimateHelp": "What is this?",
"upgradeTo": "Upgrade to {plan}",
"changeTo": "Change to {plan}",
"credits": {
"standard": "4,200",
"creator": "7,400",
"pro": "21,100"
},
"maxDuration": {
"standard": "30 min",
"creator": "30 min",
"pro": "1 hr"
}
},
"userSettings": {
"title": "User Settings",
"title": "My Account Settings",
"name": "Name",
"email": "Email",
"provider": "Sign-in Provider",

View File

@@ -1,13 +1,22 @@
<template>
<div class="flex flex-col gap-4 text-sm text-muted-foreground">
<!-- Model Info Section -->
<div class="flex flex-col gap-2">
<p class="m-0">
{{ $t('assetBrowser.modelAssociatedWithLink') }}
</p>
<p class="mt-0 text-base-foreground rounded-lg">
{{ metadata?.filename || metadata?.name }}
</p>
<div
class="flex items-center gap-3 bg-secondary-background p-3 rounded-lg"
>
<img
v-if="previewImage"
:src="previewImage"
:alt="metadata?.filename || metadata?.name || 'Model preview'"
class="w-14 h-14 rounded object-cover flex-shrink-0"
/>
<p class="m-0 text-base-foreground">
{{ metadata?.filename || metadata?.name }}
</p>
</div>
</div>
<!-- Model Type Selection -->
@@ -40,7 +49,8 @@ import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
defineProps<{
metadata: AssetMetadata | null
metadata?: AssetMetadata
previewImage?: string
}>()
const modelValue = defineModel<string | undefined>()

View File

@@ -14,6 +14,7 @@
v-else-if="currentStep === 2"
v-model="selectedModelType"
:metadata="wizardData.metadata"
:preview-image="wizardData.previewImage"
/>
<!-- Step 3: Upload Progress -->
@@ -23,6 +24,7 @@
:error="uploadError"
:metadata="wizardData.metadata"
:model-type="selectedModelType"
:preview-image="wizardData.previewImage"
/>
<!-- Navigation Footer -->

View File

@@ -25,8 +25,14 @@
</p>
<div
class="flex flex-row items-start p-4 bg-modal-card-background rounded-lg"
class="flex flex-row items-center gap-3 p-4 bg-modal-card-background rounded-lg"
>
<img
v-if="previewImage"
:src="previewImage"
:alt="metadata?.filename || metadata?.name || 'Model preview'"
class="w-14 h-14 rounded object-cover flex-shrink-0"
/>
<div class="flex flex-col justify-center items-start gap-1 flex-1">
<p class="text-base-foreground m-0">
{{ metadata?.filename || metadata?.name }}
@@ -63,7 +69,8 @@ import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
defineProps<{
status: 'idle' | 'uploading' | 'success' | 'error'
error?: string
metadata: AssetMetadata | null
modelType: string | undefined
metadata?: AssetMetadata
modelType?: string
previewImage?: string
}>()
</script>

View File

@@ -9,9 +9,10 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
interface WizardData {
url: string
metadata: AssetMetadata | null
metadata?: AssetMetadata
name: string
tags: string[]
previewImage?: string
}
interface ModelTypeOption {
@@ -30,7 +31,6 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const wizardData = ref<WizardData>({
url: '',
metadata: null,
name: '',
tags: []
})
@@ -91,6 +91,9 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
// Pre-fill name from metadata
wizardData.value.name = metadata.filename || metadata.name || ''
// Store preview image if available
wizardData.value.previewImage = metadata.preview_image
// Pre-fill model type from metadata tags if available
if (metadata.tags && metadata.tags.length > 0) {
wizardData.value.tags = metadata.tags
@@ -134,6 +137,34 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
wizardData.value.metadata?.name ||
'model'
let previewId: string | undefined
// Upload preview image first if available
if (wizardData.value.previewImage) {
try {
const baseFilename = filename.split('.')[0]
// Extract extension from data URL MIME type
let extension = 'png'
const mimeMatch = wizardData.value.previewImage.match(
/^data:image\/([^;]+);/
)
if (mimeMatch) {
extension = mimeMatch[1] === 'jpeg' ? 'jpg' : mimeMatch[1]
}
const previewAsset = await assetService.uploadAssetFromBase64({
data: wizardData.value.previewImage,
name: `${baseFilename}_preview.${extension}`,
tags: ['preview']
})
previewId = previewAsset.id
} catch (error) {
console.error('Failed to upload preview image:', error)
// Continue with model upload even if preview fails
}
}
await assetService.uploadAssetFromUrl({
url: wizardData.value.url,
name: filename,
@@ -142,7 +173,8 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
source: 'civitai',
source_url: wizardData.value.url,
model_type: selectedModelType.value
}
},
preview_id: previewId
})
uploadStatus.value = 'success'

View File

@@ -54,6 +54,7 @@ const zAssetMetadata = z.object({
name: z.string().optional(),
tags: z.array(z.string()).optional(),
preview_url: z.string().optional(),
preview_image: z.string().optional(),
validation: zValidationResult.optional()
})

View File

@@ -392,6 +392,59 @@ function createAssetService() {
return await res.json()
}
/**
* Uploads an asset from base64 data
*
* @param params - Upload parameters
* @param params.data - Base64 data URL (e.g., "data:image/png;base64,...")
* @param params.name - Display name (determines extension)
* @param params.tags - Optional freeform tags
* @param params.user_metadata - Optional custom metadata object
* @returns Promise<AssetItem & { created_new: boolean }> - Asset object with created_new flag
* @throws Error if upload fails
*/
async function uploadAssetFromBase64(params: {
data: string
name: string
tags?: string[]
user_metadata?: Record<string, any>
}): Promise<AssetItem & { created_new: boolean }> {
// Validate that data is a data URL
if (!params.data || !params.data.startsWith('data:')) {
throw new Error(
'Invalid data URL: expected a string starting with "data:"'
)
}
// Convert base64 data URL to Blob
const blob = await fetch(params.data).then((r) => r.blob())
// Create FormData and append the blob
const formData = new FormData()
formData.append('file', blob, params.name)
if (params.tags) {
formData.append('tags', JSON.stringify(params.tags))
}
if (params.user_metadata) {
formData.append('user_metadata', JSON.stringify(params.user_metadata))
}
const res = await api.fetchApi(ASSETS_ENDPOINT, {
method: 'POST',
body: formData
})
if (!res.ok) {
throw new Error(
`Failed to upload asset from base64: ${res.status} ${res.statusText}`
)
}
return await res.json()
}
return {
getAssetModelFolders,
getAssetModels,
@@ -402,7 +455,8 @@ function createAssetService() {
deleteAsset,
updateAsset,
getAssetMetadata,
uploadAssetFromUrl
uploadAssetFromUrl,
uploadAssetFromBase64
}
}

View File

@@ -0,0 +1,292 @@
<template>
<div class="flex flex-row items-stretch gap-6">
<div
v-for="tier in tiers"
:key="tier.id"
class="flex-1 flex flex-col rounded-2xl border border-interface-stroke bg-interface-panel-surface shadow-[0_0_12px_rgba(0,0,0,0.1)]"
>
<div class="flex flex-col gap-6 p-8">
<div class="flex flex-row items-center gap-2">
<span
class="font-inter text-base font-bold leading-normal text-base-foreground"
>
{{ tier.name }}
</span>
<div
v-if="tier.isPopular"
class="rounded-full bg-white px-1 text-xs font-semibold uppercase tracking-wide text-black h-[13px] leading-[13px]"
>
{{ t('subscription.mostPopular') }}
</div>
</div>
<div class="flex flex-row items-baseline gap-2">
<span
class="font-inter text-[32px] font-semibold leading-normal text-base-foreground"
>
${{ tier.price }}
</span>
<span
class="font-inter text-base font-normal leading-normal text-base-foreground"
>
{{ t('subscription.usdPerMonth') }}
</span>
</div>
</div>
<div class="flex flex-col gap-4 px-8 pb-0 flex-1">
<div class="flex flex-row items-center justify-between">
<span
class="font-inter text-sm font-normal leading-normal text-muted-foreground"
>
{{ t('subscription.monthlyCreditsLabel') }}
</span>
<div class="flex flex-row items-center gap-1">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ tier.credits }}
</span>
</div>
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.maxDurationLabel') }}
</span>
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ tier.maxDuration }}
</span>
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.gpuLabel') }}
</span>
<i class="pi pi-check text-xs text-white" />
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.addCreditsLabel') }}
</span>
<i class="pi pi-check text-xs text-white" />
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.customLoRAsLabel') }}
</span>
<i v-if="tier.customLoRAs" class="pi pi-check text-xs text-white" />
<i v-else class="pi pi-times text-xs text-muted" />
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-row items-start justify-between">
<div class="flex flex-col gap-2">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.videoEstimateLabel') }}
</span>
<div class="flex flex-row items-center gap-2 opacity-50">
<i
class="pi pi-question-circle text-xs text-muted-foreground"
/>
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.videoEstimateHelp') }}
</span>
</div>
</div>
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ tier.videoEstimate }}
</span>
</div>
</div>
</div>
<div class="flex flex-col p-8">
<Button
:label="getButtonLabel(tier)"
:severity="getButtonSeverity(tier)"
:disabled="isLoading || isCurrentPlan(tier.key)"
:loading="loadingTier === tier.key"
class="h-10 w-full"
:pt="{
label: {
class: 'font-inter text-sm font-bold leading-normal text-white'
}
}"
@click="() => handleSubscribe(tier.key)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import type { components } from '@/types/comfyRegistryTypes'
type SubscriptionTier = components['schemas']['SubscriptionTier']
type TierKey = 'standard' | 'creator' | 'pro'
interface PricingTierConfig {
id: SubscriptionTier
key: TierKey
name: string
price: string
credits: string
maxDuration: string
customLoRAs: boolean
videoEstimate: string
isPopular?: boolean
}
const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
STANDARD: 'standard',
CREATOR: 'creator',
PRO: 'pro',
FOUNDERS_EDITION: 'standard'
}
const tiers: PricingTierConfig[] = [
{
id: 'STANDARD',
key: 'standard',
name: t('subscription.tiers.standard.name'),
price: t('subscription.tiers.standard.price'),
credits: t('subscription.credits.standard'),
maxDuration: t('subscription.maxDuration.standard'),
customLoRAs: false,
videoEstimate: t('subscription.tiers.standard.benefits.videoEstimate'),
isPopular: false
},
{
id: 'CREATOR',
key: 'creator',
name: t('subscription.tiers.creator.name'),
price: t('subscription.tiers.creator.price'),
credits: t('subscription.credits.creator'),
maxDuration: t('subscription.maxDuration.creator'),
customLoRAs: true,
videoEstimate: t('subscription.tiers.creator.benefits.videoEstimate'),
isPopular: true
},
{
id: 'PRO',
key: 'pro',
name: t('subscription.tiers.pro.name'),
price: t('subscription.tiers.pro.price'),
credits: t('subscription.credits.pro'),
maxDuration: t('subscription.maxDuration.pro'),
customLoRAs: true,
videoEstimate: t('subscription.tiers.pro.benefits.videoEstimate'),
isPopular: false
}
]
const { getAuthHeader } = useFirebaseAuthStore()
const { isActiveSubscription, subscriptionTier } = useSubscription()
const { accessBillingPortal, reportError } = useFirebaseAuthActions()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isLoading = ref(false)
const loadingTier = ref<TierKey | null>(null)
const currentTierKey = computed<TierKey | null>(() =>
subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null
)
const isCurrentPlan = (tierKey: TierKey): boolean =>
currentTierKey.value === tierKey
const getButtonLabel = (tier: PricingTierConfig): string => {
if (isCurrentPlan(tier.key)) return t('subscription.currentPlan')
if (!isActiveSubscription.value)
return t('subscription.subscribeTo', { plan: tier.name })
return t('subscription.changeTo', { plan: tier.name })
}
const getButtonSeverity = (tier: PricingTierConfig): 'primary' | 'secondary' =>
isCurrentPlan(tier.key)
? 'secondary'
: tier.key === 'creator'
? 'primary'
: 'secondary'
const initiateCheckout = async (tierKey: TierKey) => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${tierKey}`,
{
method: 'POST',
headers: { ...authHeader, 'Content-Type': 'application/json' }
}
)
if (!response.ok) {
let errorMessage = 'Failed to initiate checkout'
try {
const errorData = await response.json()
errorMessage = errorData.message || errorMessage
} catch {
// If JSON parsing fails, try to get text response or use HTTP status
try {
const errorText = await response.text()
errorMessage =
errorText || `HTTP ${response.status} ${response.statusText}`
} catch {
errorMessage = `HTTP ${response.status} ${response.statusText}`
}
}
throw new FirebaseAuthStoreError(
t('toastMessages.failedToInitiateSubscription', {
error: errorMessage
})
)
}
return await response.json()
}
const handleSubscribe = wrapWithErrorHandlingAsync(async (tierKey: TierKey) => {
if (!isCloud || isLoading.value || isCurrentPlan(tierKey)) return
isLoading.value = true
loadingTier.value = tierKey
try {
if (isActiveSubscription.value) {
await accessBillingPortal()
} else {
const response = await initiateCheckout(tierKey)
if (response.checkout_url) {
window.open(response.checkout_url, '_blank')
}
}
} finally {
isLoading.value = false
loadingTier.value = null
}
}, reportError)
</script>

View File

@@ -24,8 +24,9 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, onBeforeUnmount, ref } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -51,12 +52,18 @@ const emit = defineEmits<{
subscribed: []
}>()
const { subscribe, isActiveSubscription, fetchStatus } = useSubscription()
const { subscribe, isActiveSubscription, fetchStatus, showSubscriptionDialog } =
useSubscription()
const { flags } = useFeatureFlags()
const shouldUseStripePricing = computed(
() => isCloud && Boolean(flags.subscriptionTiersEnabled)
)
const telemetry = useTelemetry()
const isLoading = ref(false)
const isPolling = ref(false)
let pollInterval: number | null = null
const isAwaitingStripeSubscription = ref(false)
const POLL_INTERVAL_MS = 3000 // Poll every 3 seconds
const MAX_POLL_DURATION_MS = 5 * 60 * 1000 // Stop polling after 5 minutes
@@ -102,11 +109,27 @@ const stopPolling = () => {
isLoading.value = false
}
watch(
[isAwaitingStripeSubscription, isActiveSubscription],
([awaiting, isActive]) => {
if (shouldUseStripePricing.value && awaiting && isActive) {
emit('subscribed')
isAwaitingStripeSubscription.value = false
}
}
)
const handleSubscribe = async () => {
if (isCloud) {
useTelemetry()?.trackSubscription('subscribe_clicked')
}
if (shouldUseStripePricing.value) {
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()
return
}
isLoading.value = true
try {
await subscribe()
@@ -120,5 +143,6 @@ const handleSubscribe = async () => {
onBeforeUnmount(() => {
stopPolling()
isAwaitingStripeSubscription.value = false
})
</script>

View File

@@ -1,42 +1,19 @@
<template>
<div class="flex flex-col items-start gap-1 self-stretch">
<div class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-xs text-text-primary" />
<div class="flex flex-col items-start gap-0 self-stretch">
<div class="flex items-center gap-2 py-2">
<i class="pi pi-check text-xs text-text-primary" />
<span class="text-sm text-text-primary">
{{ $t('subscription.benefits.benefit1') }}
</span>
</div>
<div class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-xs text-text-primary" />
<div class="flex items-center gap-2 py-2">
<i class="pi pi-check text-xs text-text-primary" />
<span class="text-sm text-text-primary">
{{ $t('subscription.benefits.benefit2') }}
</span>
</div>
<Button
:label="$t('subscription.viewMoreDetails')"
text
icon="pi pi-external-link"
icon-pos="left"
class="flex h-8 min-h-6 py-2 px-0 items-center gap-2 rounded text-text-secondary"
:pt="{
icon: {
class: 'text-xs text-text-secondary'
},
label: {
class: 'text-sm text-text-secondary'
}
}"
@click="handleViewMoreDetails"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
const handleViewMoreDetails = () => {
window.open('https://www.comfy.org/cloud/pricing', '_blank')
}
</script>
<script setup lang="ts"></script>

View File

@@ -19,9 +19,12 @@
<div class="rounded-2xl border border-interface-stroke p-6">
<div>
<div class="flex items-center justify-between">
<div>
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ subscriptionTierName }}
</div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">{{ formattedMonthlyPrice }}</span>
<span class="text-2xl">${{ tierPrice }}</span>
<span class="text-base">{{
$t('subscription.perMonth')
}}</span>
@@ -59,7 +62,7 @@
class: 'text-text-primary'
}
}"
@click="manageSubscription"
@click="showSubscriptionDialog"
/>
<SubscribeButton
v-else
@@ -75,17 +78,6 @@
<div class="grid grid-cols-1 gap-6 pt-9 lg:grid-cols-2">
<div class="flex flex-col flex-1">
<div class="flex flex-col gap-3">
<div class="flex flex-col">
<div class="text-sm">
{{ $t('subscription.partnerNodesBalance') }}
</div>
<div class="flex items-center">
<div class="text-sm text-muted">
{{ $t('subscription.partnerNodesDescription') }}
</div>
</div>
</div>
<div
:class="
cn(
@@ -112,7 +104,7 @@
/>
<div class="flex flex-col gap-2">
<div class="text-sm text-text-secondary">
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton
@@ -121,7 +113,7 @@
height="2rem"
/>
<div v-else class="text-2xl font-bold">
${{ totalCredits }}
{{ totalCredits }}
</div>
</div>
@@ -133,12 +125,18 @@
width="3rem"
height="1rem"
/>
<div v-else class="text-sm text-text-secondary font-bold">
${{ monthlyBonusCredits }}
<div
v-else
class="text-sm font-bold w-12 shrink-0 text-left text-muted"
>
{{ monthlyBonusCredits }}
</div>
<div class="flex items-center gap-1">
<div class="text-sm text-text-secondary">
{{ $t('subscription.monthlyBonusDescription') }}
<div class="flex items-center gap-1 min-w-0">
<div
class="text-sm truncate text-muted"
:title="$t('subscription.creditsRemainingThisMonth')"
>
{{ $t('subscription.creditsRemainingThisMonth') }}
</div>
<Button
v-tooltip="refreshTooltip"
@@ -146,7 +144,7 @@
text
rounded
size="small"
class="h-4 w-4"
class="h-4 w-4 shrink-0"
:pt="{
icon: {
class: 'text-text-secondary text-xs'
@@ -161,12 +159,18 @@
width="3rem"
height="1rem"
/>
<div v-else class="text-sm text-text-secondary font-bold">
${{ prepaidCredits }}
<div
v-else
class="text-sm font-bold w-12 shrink-0 text-left text-muted"
>
{{ prepaidCredits }}
</div>
<div class="flex items-center gap-1">
<div class="text-sm text-text-secondary">
{{ $t('subscription.prepaidDescription') }}
<div class="flex items-center gap-1 min-w-0">
<div
class="text-sm truncate text-muted"
:title="$t('subscription.creditsYouveAdded')"
>
{{ $t('subscription.creditsYouveAdded') }}
</div>
<Button
v-tooltip="$t('subscription.prepaidCreditsInfo')"
@@ -174,7 +178,7 @@
text
rounded
size="small"
class="h-4 w-4"
class="h-4 w-4 shrink-0"
:pt="{
icon: {
class: 'text-text-secondary text-xs'
@@ -190,8 +194,7 @@
href="https://platform.comfy.org/profile/usage"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-text-secondary underline hover:text-text-secondary"
style="text-decoration: underline"
class="text-sm underline text-center text-muted"
>
{{ $t('subscription.viewUsageHistory') }}
</a>
@@ -216,14 +219,47 @@
</div>
<div class="flex flex-col gap-2 flex-1">
<div class="text-sm">
<div class="text-sm text-text-primary">
{{ $t('subscription.yourPlanIncludes') }}
</div>
<SubscriptionBenefits />
<div class="flex flex-col gap-0">
<div
v-for="benefit in tierBenefits"
:key="benefit.key"
class="flex items-center gap-2 py-2"
>
<i
v-if="benefit.type === 'feature'"
class="pi pi-check text-xs text-text-primary"
/>
<span
v-else-if="benefit.type === 'metric' && benefit.value"
class="text-sm font-normal whitespace-nowrap text-text-primary"
>
{{ benefit.value }}
</span>
<span class="text-sm text-muted">
{{ benefit.label }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- View More Details - Outside main content -->
<div class="flex items-center gap-2 py-4">
<i class="pi pi-external-link text-muted"></i>
<a
href="https://www.comfy.org/cloud/pricing"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline hover:opacity-80 text-muted"
>
{{ $t('subscription.viewMoreDetailsPlans') }}
</a>
</div>
</div>
<div
@@ -307,28 +343,98 @@
import Button from 'primevue/button'
import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { components } from '@/types/comfyRegistryTypes'
import { cn } from '@/utils/tailwindUtil'
type SubscriptionTier = components['schemas']['SubscriptionTier']
/** Maps API subscription tier values to i18n translation keys */
const TIER_TO_I18N_KEY: Record<SubscriptionTier, string> = {
STANDARD: 'standard',
CREATOR: 'creator',
PRO: 'pro',
FOUNDERS_EDITION: 'founder'
}
const DEFAULT_TIER_KEY = 'standard'
const { buildDocsUrl } = useExternalLink()
const { t } = useI18n()
const {
isActiveSubscription,
isCancelled,
formattedRenewalDate,
formattedEndDate,
formattedMonthlyPrice,
manageSubscription,
subscriptionTier,
subscriptionTierName,
handleInvoiceHistory
} = useSubscription()
const { show: showSubscriptionDialog } = useSubscriptionDialog()
const tierKey = computed(() => {
const tier = subscriptionTier.value
if (!tier) return DEFAULT_TIER_KEY
return TIER_TO_I18N_KEY[tier] ?? DEFAULT_TIER_KEY
})
const tierPrice = computed(() => t(`subscription.tiers.${tierKey.value}.price`))
// Tier benefits for v-for loop
type BenefitType = 'metric' | 'feature'
interface Benefit {
key: string
type: BenefitType
label: string
value?: string
}
const tierBenefits = computed(() => {
const key = tierKey.value
const baseBenefits: Benefit[] = [
{
key: 'monthlyCredits',
type: 'metric',
value: t(`subscription.tiers.${key}.benefits.monthlyCredits`),
label: t(`subscription.tiers.${key}.benefits.monthlyCreditsLabel`)
},
{
key: 'maxDuration',
type: 'metric',
value: t(`subscription.tiers.${key}.benefits.maxDuration`),
label: t(`subscription.tiers.${key}.benefits.maxDurationLabel`)
},
{
key: 'gpu',
type: 'feature',
label: t(`subscription.tiers.${key}.benefits.gpuLabel`)
},
{
key: 'addCredits',
type: 'feature',
label: t(`subscription.tiers.${key}.benefits.addCreditsLabel`)
},
{
key: 'customLoRAs',
type: 'feature',
label: t(`subscription.tiers.${key}.benefits.customLoRAsLabel`)
}
]
return baseBenefits
})
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()

View File

@@ -1,5 +1,68 @@
<template>
<div class="relative grid h-full grid-cols-5">
<div
v-if="showCustomPricingTable"
class="flex flex-col gap-6 rounded-[24px] border border-interface-stroke bg-[var(--p-dialog-background)] p-4 shadow-[0_25px_80px_rgba(5,6,12,0.45)] md:p-6"
>
<div
class="flex flex-col gap-6 md:flex-row md:items-start md:justify-between"
>
<div class="flex flex-col gap-2 text-left md:max-w-2xl">
<div
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.3em] text-text-secondary"
>
{{ $t('subscription.required.title') }}
<CloudBadge
reverse-order
no-padding
background-color="var(--p-dialog-background)"
use-subscription
/>
</div>
<div class="text-3xl font-semibold leading-tight md:text-4xl">
{{ $t('subscription.description') }}
</div>
</div>
<Button
icon="pi pi-times"
text
rounded
class="h-10 w-10 shrink-0 text-text-secondary hover:bg-white/10"
:aria-label="$t('g.close')"
@click="handleClose"
/>
</div>
<PricingTable class="flex-1" />
<!-- Contact and Enterprise Links -->
<div class="flex flex-col items-center">
<p class="text-sm text-text-secondary">
{{ $t('subscription.haveQuestions') }}
</p>
<div class="flex items-center gap-2">
<Button
:label="$t('subscription.contactUs')"
text
severity="secondary"
icon="pi pi-comments"
icon-pos="right"
class="h-6 p-1 text-sm text-text-secondary hover:text-white"
@click="handleContactUs"
/>
<span class="text-sm text-text-secondary">{{ $t('g.or') }}</span>
<Button
:label="$t('subscription.viewEnterprise')"
text
severity="secondary"
icon="pi pi-external-link"
icon-pos="right"
class="h-6 p-1 text-sm text-text-secondary hover:text-white"
@click="handleViewEnterprise"
/>
</div>
</div>
</div>
<div v-else class="legacy-dialog relative grid h-full grid-cols-5">
<!-- Custom close button -->
<Button
icon="pi pi-times"
@@ -7,7 +70,7 @@
rounded
class="absolute top-2.5 right-2.5 z-10 h-8 w-8 p-0 text-white hover:bg-white/20"
:aria-label="$t('g.close')"
@click="onClose"
@click="handleClose"
/>
<div
@@ -72,13 +135,19 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, onBeforeUnmount, watch } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
defineProps<{
const props = defineProps<{
onClose: () => void
}>()
@@ -86,19 +155,132 @@ const emit = defineEmits<{
close: [subscribed: boolean]
}>()
const { formattedMonthlyPrice } = useSubscription()
const { fetchStatus, isActiveSubscription } = useSubscription()
// Legacy price for non-tier flow with locale-aware formatting
const formattedMonthlyPrice = new Intl.NumberFormat(
navigator.language || 'en-US',
{
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}
).format(MONTHLY_SUBSCRIPTION_PRICE)
const commandStore = useCommandStore()
const telemetry = useTelemetry()
// Always show custom pricing table for cloud subscriptions
const showCustomPricingTable = computed(
() => isCloud && window.__CONFIG__?.subscription_required
)
const POLL_INTERVAL_MS = 3000
const MAX_POLL_ATTEMPTS = 3
let pollInterval: number | null = null
let pollAttempts = 0
const stopPolling = () => {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
const startPolling = () => {
stopPolling()
pollAttempts = 0
const poll = async () => {
try {
await fetchStatus()
pollAttempts++
if (pollAttempts >= MAX_POLL_ATTEMPTS) {
stopPolling()
}
} catch (error) {
console.error(
'[SubscriptionDialog] Failed to poll subscription status',
error
)
stopPolling()
}
}
void poll()
pollInterval = window.setInterval(() => {
void poll()
}, POLL_INTERVAL_MS)
}
const handleWindowFocus = () => {
if (showCustomPricingTable.value) {
startPolling()
}
}
watch(
showCustomPricingTable,
(enabled) => {
if (enabled) {
window.addEventListener('focus', handleWindowFocus)
} else {
window.removeEventListener('focus', handleWindowFocus)
stopPolling()
}
},
{ immediate: true }
)
watch(
() => isActiveSubscription.value,
(isActive) => {
if (isActive && showCustomPricingTable.value) {
emit('close', true)
}
}
)
const handleSubscribed = () => {
emit('close', true)
}
const handleClose = () => {
stopPolling()
props.onClose()
}
const handleContactUs = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
await commandStore.execute('Comfy.ContactSupport')
}
const handleViewEnterprise = () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'docs',
is_external: true,
source: 'subscription'
})
window.open('https://www.comfy.org/cloud/enterprise', '_blank')
}
onBeforeUnmount(() => {
stopPolling()
window.removeEventListener('focus', handleWindowFocus)
})
</script>
<style scoped>
:deep(.bg-comfy-menu-secondary) {
.legacy-dialog :deep(.bg-comfy-menu-secondary) {
background-color: transparent;
}
:deep(.p-button) {
.legacy-dialog :deep(.p-button) {
color: white;
}
</style>

View File

@@ -5,26 +5,32 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import { useDialogService } from '@/services/dialogService'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
type CloudSubscriptionCheckoutResponse = {
checkout_url: string
}
export type CloudSubscriptionStatusResponse = {
is_active: boolean
subscription_id: string
renewal_date: string | null
end_date?: string | null
export type CloudSubscriptionStatusResponse = NonNullable<
operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json']
>
type SubscriptionTier = components['schemas']['SubscriptionTier']
const TIER_TO_I18N_KEY: Record<SubscriptionTier, string> = {
STANDARD: 'standard',
CREATOR: 'creator',
PRO: 'pro',
FOUNDERS_EDITION: 'founder'
}
function useSubscriptionInternal() {
@@ -37,7 +43,7 @@ function useSubscriptionInternal() {
return subscriptionStatus.value?.is_active ?? false
})
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
const dialogService = useDialogService()
const { showSubscriptionRequiredDialog } = useDialogService()
const { getAuthHeader } = useFirebaseAuthStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
@@ -72,10 +78,17 @@ function useSubscriptionInternal() {
})
})
const formattedMonthlyPrice = computed(
() => `$${MONTHLY_SUBSCRIPTION_PRICE.toFixed(0)}`
const subscriptionTier = computed(
() => subscriptionStatus.value?.subscription_tier ?? null
)
const subscriptionTierName = computed(() => {
const tier = subscriptionTier.value
if (!tier) return ''
const key = TIER_TO_I18N_KEY[tier] ?? 'standard'
return t(`subscription.tiers.${key}.name`)
})
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
const fetchStatus = wrapWithErrorHandlingAsync(
@@ -102,7 +115,7 @@ function useSubscriptionInternal() {
useTelemetry()?.trackSubscription('modal_opened')
}
void dialogService.showSubscriptionRequiredDialog()
void showSubscriptionRequiredDialog()
}
const shouldWatchCancellation = (): boolean =>
@@ -227,7 +240,9 @@ function useSubscriptionInternal() {
isCancelled,
formattedRenewalDate,
formattedEndDate,
formattedMonthlyPrice,
subscriptionTier,
subscriptionTierName,
subscriptionStatus,
// Actions
subscribe,

View File

@@ -1,58 +1,41 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
/**
* Composable for handling subscription credit calculations and formatting
*/
export function useSubscriptionCredits() {
const authStore = useFirebaseAuthStore()
const { locale } = useI18n()
const totalCredits = computed(() => {
if (!authStore.balance?.amount_micros) return '0.00'
try {
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting total credits:',
error
)
return '0.00'
}
})
const formatBalance = (maybeCents?: number) => {
// Backend returns cents despite the *_micros naming convention.
const cents = maybeCents ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value,
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 0
}
})
return amount
}
const monthlyBonusCredits = computed(() => {
if (!authStore.balance?.cloud_credit_balance_micros) return '0.00'
try {
return formatMetronomeCurrency(
authStore.balance.cloud_credit_balance_micros,
'usd'
)
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting monthly bonus credits:',
error
)
return '0.00'
}
})
const totalCredits = computed(() =>
formatBalance(authStore.balance?.amount_micros)
)
const prepaidCredits = computed(() => {
if (!authStore.balance?.prepaid_balance_micros) return '0.00'
try {
return formatMetronomeCurrency(
authStore.balance.prepaid_balance_micros,
'usd'
)
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting prepaid credits:',
error
)
return '0.00'
}
})
const monthlyBonusCredits = computed(() =>
formatBalance(authStore.balance?.cloud_credit_balance_micros)
)
const prepaidCredits = computed(() =>
formatBalance(authStore.balance?.prepaid_balance_micros)
)
const isLoadingBalance = computed(() => authStore.isFetchingBalance)

View File

@@ -1,4 +1,7 @@
import { defineAsyncComponent } from 'vue'
import { computed, defineAsyncComponent } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -7,6 +10,14 @@ const DIALOG_KEY = 'subscription-required'
export const useSubscriptionDialog = () => {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const { flags } = useFeatureFlags()
const showStripeDialog = computed(
() =>
flags.subscriptionTiersEnabled &&
isCloud &&
window.__CONFIG__?.subscription_required
)
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
@@ -25,7 +36,19 @@ export const useSubscriptionDialog = () => {
onClose: hide
},
dialogComponentProps: {
style: 'width: 700px;'
style: showStripeDialog.value
? 'width: min(1200px, 95vw); max-height: 90vh;'
: 'width: 700px;',
pt: showStripeDialog.value
? {
root: {
class: '!rounded-[32px] overflow-visible'
},
content: {
class: '!p-0 bg-transparent'
}
}
: undefined
}
})
}

View File

@@ -37,4 +37,7 @@ export type RemoteConfig = {
model_upload_button_enabled?: boolean
asset_update_options_enabled?: boolean
private_models_enabled?: boolean
subscription_tiers_enabled?: boolean
stripe_publishable_key?: string
stripe_pricing_table_id?: string
}

View File

@@ -3,6 +3,7 @@ import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -11,6 +12,7 @@ import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
interface SettingPanelItem {
node: SettingTreeNode
@@ -33,6 +35,8 @@ export function useSettingUI(
const activeCategory = ref<SettingTreeNode | null>(null)
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { isActiveSubscription } = useSubscription()
const { flags } = useFeatureFlags()
const settingRoot = computed<SettingTreeNode>(() => {
const root = buildTree(
@@ -81,7 +85,7 @@ export function useSettingUI(
children: []
},
component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/CreditsPanel.vue')
() => import('@/components/dialog/content/setting/LegacyCreditsPanel.vue')
)
}
@@ -102,6 +106,12 @@ export function useSettingUI(
)
}
const shouldShowPlanCreditsPanel = computed(() => {
if (!subscriptionPanel) return false
if (!flags.subscriptionTiersEnabled) return true
return isActiveSubscription.value
})
const userPanel: SettingPanelItem = {
node: {
key: 'user',
@@ -154,9 +164,7 @@ export function useSettingUI(
keybindingPanel,
extensionPanel,
...(isElectron() ? [serverConfigPanel] : []),
...(isCloud &&
window.__CONFIG__?.subscription_required &&
subscriptionPanel
...(shouldShowPlanCreditsPanel.value && subscriptionPanel
? [subscriptionPanel]
: [])
].filter((panel) => panel.component)
@@ -191,8 +199,7 @@ export function useSettingUI(
children: [
userPanel.node,
...(isLoggedIn.value &&
isCloud &&
window.__CONFIG__?.subscription_required &&
shouldShowPlanCreditsPanel.value &&
subscriptionPanel
? [subscriptionPanel.node]
: []),

View File

@@ -1,6 +1,6 @@
import { until } from '@vueuse/core'
import { defineStore } from 'pinia'
import { compare } from 'semver'
import { compare, valid } from 'semver'
import { computed, ref } from 'vue'
import { isCloud } from '@/platform/distribution/types'
@@ -24,10 +24,12 @@ export const useReleaseStore = defineStore('release', () => {
const systemStatsStore = useSystemStatsStore()
const settingStore = useSettingStore()
// Current ComfyUI version
const currentComfyUIVersion = computed(
() => systemStatsStore?.systemStats?.system?.comfyui_version ?? ''
)
const currentVersion = computed(() => {
if (isCloud) {
return systemStatsStore?.systemStats?.system?.cloud_version ?? ''
}
return systemStatsStore?.systemStats?.system?.comfyui_version ?? ''
})
// Release data from settings
const locale = computed(() => settingStore.get('Comfy.Locale'))
@@ -55,22 +57,33 @@ export const useReleaseStore = defineStore('release', () => {
// Helper constants
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000 // 3 days
const compareVersions = (
releaseVersion: string,
currentVer: string
): number => {
if (valid(releaseVersion) && valid(currentVer)) {
return compare(releaseVersion, currentVer)
}
// Non-semver (e.g. git hash): assume different = newer
return releaseVersion === currentVer ? 0 : 1
}
// New version available?
const isNewVersionAvailable = computed(
() =>
!!recentRelease.value &&
compare(
compareVersions(
recentRelease.value.version,
currentComfyUIVersion.value || '0.0.0'
currentVersion.value || '0.0.0'
) > 0
)
const isLatestVersion = computed(
() =>
!!recentRelease.value &&
compare(
compareVersions(
recentRelease.value.version,
currentComfyUIVersion.value || '0.0.0'
currentVersion.value || '0.0.0'
) === 0
)
@@ -158,23 +171,25 @@ export const useReleaseStore = defineStore('release', () => {
return true
})
// Show "What's New" popup
const shouldShowPopup = computed(() => {
// Only show on desktop version
if (!isElectron() || isCloud) {
if (!isElectron() && !isCloud) {
return false
}
// Skip if notifications are disabled
if (!showVersionUpdates.value) {
return false
}
if (!isLatestVersion.value) {
if (!recentRelease.value) {
return false
}
// Skip version check if current version isn't semver (e.g. git hash)
const skipVersionCheck = !valid(currentVersion.value)
if (!skipVersionCheck && !isLatestVersion.value) {
return false
}
// Hide if already seen
if (
releaseVersion.value === recentRelease.value.version &&
releaseStatus.value === "what's new seen"
@@ -225,8 +240,7 @@ export const useReleaseStore = defineStore('release', () => {
return
}
// Skip fetching if notifications are disabled
if (!showVersionUpdates.value) {
if (!isCloud && !showVersionUpdates.value) {
return
}
@@ -248,8 +262,8 @@ export const useReleaseStore = defineStore('release', () => {
}
const fetchedReleases = await releaseService.getReleases({
project: 'comfyui',
current_version: currentComfyUIVersion.value,
project: isCloud ? 'cloud' : 'comfyui',
current_version: currentVersion.value,
form_factor: systemStatsStore.getFormFactor(),
locale: stringToLocale(locale.value)
})

View File

@@ -33,9 +33,11 @@ export function useTemplateUrlLoader() {
/**
* Validates parameter format to prevent path traversal and injection attacks
* Allows: letters, numbers, underscores, hyphens, and dots (for version numbers)
* Blocks: path separators (/, \), special chars that could enable injection
*/
const isValidParameter = (param: string): boolean => {
return /^[a-zA-Z0-9_-]+$/.test(param)
return /^[a-zA-Z0-9_.-]+$/.test(param)
}
/**

View File

@@ -39,7 +39,15 @@
</div>
<div v-if="isSubgraphNode" class="icon-[comfy--workflow] size-4" />
<div v-if="isApiNode" class="icon-[lucide--dollar-sign] size-4" />
<div
v-if="isApiNode"
:class="
flags.subscriptionTiersEnabled
? 'icon-[lucide--component]'
: 'icon-[lucide--dollar-sign]'
"
class="size-4"
/>
<!-- Node Title -->
<div
@@ -76,13 +84,16 @@
v-tooltip.top="enterSubgraphTooltipConfig"
type="transparent"
data-testid="subgraph-enter-button"
class="size-5"
class="ml-2 text-node-component-header h-5"
@click.stop="handleEnterSubgraph"
@dblclick.stop
>
<i
class="icon-[lucide--picture-in-picture] size-5 text-node-component-header-icon"
></i>
<div
class="min-w-max rounded-sm bg-node-component-surface px-1 py-0.5 text-xs flex items-center gap-1"
>
{{ $t('g.edit') }}
<i class="icon-[lucide--scaling] size-5"></i>
</div>
</IconButton>
</div>
</div>
@@ -96,6 +107,7 @@ import IconButton from '@/components/button/IconButton.vue'
import EditableText from '@/components/common/EditableText.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { st } from '@/i18n'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -126,6 +138,8 @@ const emit = defineEmits<{
'enter-subgraph': []
}>()
const { flags } = useFeatureFlags()
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()

View File

@@ -386,11 +386,12 @@ export const useDialogService = () => {
return dialogStore.showDialog({
key: 'top-up-credits',
component: TopUpCreditsDialogContent,
headerComponent: ComfyOrgHeader,
props: options,
dialogComponentProps: {
headless: true,
pt: {
header: { class: 'p-3!' }
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0!' }
}
}
})

View File

@@ -1,6 +1,7 @@
import { useAsyncState } from '@vueuse/core'
import { defineStore } from 'pinia'
import { isCloud } from '@/platform/distribution/types'
import type { SystemStats } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { isElectron } from '@/utils/envUtil'
@@ -30,6 +31,10 @@ export const useSystemStatsStore = defineStore('systemStats', () => {
)
function getFormFactor(): string {
if (isCloud) {
return 'cloud'
}
if (!systemStats.value?.system?.os) {
return 'other'
}

9
src/vite-env.d.ts vendored
View File

@@ -16,6 +16,15 @@ declare global {
interface Window {
__COMFYUI_FRONTEND_VERSION__: string
}
interface ImportMetaEnv {
readonly VITE_STRIPE_PUBLISHABLE_KEY?: string
readonly VITE_STRIPE_PRICING_TABLE_ID?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
}
export {}

View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from 'vitest'
import {
CREDITS_PER_USD,
COMFY_CREDIT_RATE_CENTS,
centsToCredits,
creditsToCents,
creditsToUsd,
formatCredits,
formatCreditsFromCents,
formatCreditsFromUsd,
formatUsd,
formatUsdFromCents,
usdToCents,
usdToCredits
} from '@/base/credits/comfyCredits'
describe('comfyCredits helpers', () => {
test('exposes the fixed conversion rate', () => {
expect(CREDITS_PER_USD).toBe(211)
expect(COMFY_CREDIT_RATE_CENTS).toBeCloseTo(2.11) // credits per cent
})
test('converts between USD and cents', () => {
expect(usdToCents(1.23)).toBe(123)
expect(formatUsdFromCents({ cents: 123, locale: 'en-US' })).toBe('1.23')
})
test('converts cents to credits and back', () => {
expect(centsToCredits(100)).toBe(211) // 100 cents = 211 credits
expect(creditsToCents(211)).toBe(100) // 211 credits = 100 cents
})
test('converts USD to credits and back', () => {
expect(usdToCredits(1)).toBe(211) // 1 USD = 211 credits
expect(creditsToUsd(211)).toBe(1) // 211 credits = 1 USD
})
test('formats credits and USD values using en-US locale', () => {
const locale = 'en-US'
expect(formatCredits({ value: 1234.567, locale })).toBe('1,234.57')
expect(formatCreditsFromCents({ cents: 100, locale })).toBe('211.00')
expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00')
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
})
})

View File

@@ -0,0 +1,48 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import CreditTopUpOption from '@/components/dialog/content/credit/CreditTopUpOption.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
const mountOption = (
props?: Partial<{ credits: number; description: string; selected: boolean }>
) =>
mount(CreditTopUpOption, {
props: {
credits: 1000,
description: '~100 videos*',
selected: false,
...props
},
global: {
plugins: [i18n]
}
})
describe('CreditTopUpOption', () => {
it('renders credit amount and description', () => {
const wrapper = mountOption({ credits: 5000, description: '~500 videos*' })
expect(wrapper.text()).toContain('5,000')
expect(wrapper.text()).toContain('~500 videos*')
})
it('applies unselected styling when not selected', () => {
const wrapper = mountOption({ selected: false })
expect(wrapper.find('div').classes()).toContain(
'bg-component-node-disabled'
)
expect(wrapper.find('div').classes()).toContain('border-transparent')
})
it('emits select event when clicked', async () => {
const wrapper = mountOption()
await wrapper.find('div').trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
})
})

View File

@@ -17,6 +17,14 @@ vi.mock('@/stores/workspace/colorPaletteStore', () => ({
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
subscriptionTiersEnabled: false // Test legacy badge behavior
}
})
}))
const { updateSubgraphCredits } = usePriceBadge()
const mockNode = new LGraphNode('mock node')

View File

@@ -1,25 +1,42 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import SubscriptionPanel from '@/platform/cloud/subscription/components/SubscriptionPanel.vue'
// Mock composables
// Mock state refs that can be modified between tests
const mockIsActiveSubscription = ref(false)
const mockIsCancelled = ref(false)
const mockSubscriptionTier = ref<
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
>('CREATOR')
const TIER_TO_NAME: Record<string, string> = {
STANDARD: 'Standard',
CREATOR: 'Creator',
PRO: 'Pro',
FOUNDERS_EDITION: "Founder's Edition"
}
// Mock composables - using computed to match composable return types
const mockSubscriptionData = {
isActiveSubscription: false,
isCancelled: false,
formattedRenewalDate: '2024-12-31',
formattedEndDate: '2024-12-31',
formattedMonthlyPrice: '$9.99',
manageSubscription: vi.fn(),
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
isCancelled: computed(() => mockIsCancelled.value),
formattedRenewalDate: computed(() => '2024-12-31'),
formattedEndDate: computed(() => '2024-12-31'),
subscriptionTier: computed(() => mockSubscriptionTier.value),
subscriptionTierName: computed(() =>
mockSubscriptionTier.value ? TIER_TO_NAME[mockSubscriptionTier.value] : ''
),
handleInvoiceHistory: vi.fn()
}
const mockCreditsData = {
totalCredits: '10.00',
monthlyBonusCredits: '5.00',
prepaidCredits: '5.00',
totalCredits: '10.00 Credits',
monthlyBonusCredits: '5.00 Credits',
prepaidCredits: '5.00 Credits',
isLoadingBalance: false
}
@@ -50,6 +67,15 @@ vi.mock(
})
)
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({
show: vi.fn()
})
})
)
// Create i18n instance for testing
const i18n = createI18n({
legacy: false,
@@ -58,12 +84,15 @@ const i18n = createI18n({
en: {
subscription: {
title: 'Subscription',
titleUnsubscribed: 'Subscribe',
perMonth: '/ month',
subscribeNow: 'Subscribe Now',
manageSubscription: 'Manage Subscription',
partnerNodesBalance: 'Partner Nodes Balance',
partnerNodesDescription: 'Credits for partner nodes',
totalCredits: 'Total Credits',
creditsRemainingThisMonth: 'Credits remaining this month',
creditsYouveAdded: "Credits you've added",
monthlyBonusDescription: 'Monthly bonus',
prepaidDescription: 'Prepaid credits',
monthlyCreditsRollover: 'Monthly credits rollover info',
@@ -71,11 +100,67 @@ const i18n = createI18n({
viewUsageHistory: 'View Usage History',
addCredits: 'Add Credits',
yourPlanIncludes: 'Your plan includes',
viewMoreDetailsPlans: 'View more details about plans & pricing',
learnMore: 'Learn More',
messageSupport: 'Message Support',
invoiceHistory: 'Invoice History',
partnerNodesCredits: 'Partner nodes pricing',
renewsDate: 'Renews {date}',
expiresDate: 'Expires {date}'
expiresDate: 'Expires {date}',
tiers: {
founder: {
name: "Founder's Edition",
price: '20.00',
benefits: {
monthlyCredits: '5,460',
monthlyCreditsLabel: 'monthly credits',
maxDuration: '30 min',
maxDurationLabel: 'max duration of each workflow run',
gpuLabel: 'RTX 6000 Pro (96GB VRAM)',
addCreditsLabel: 'Add more credits whenever',
customLoRAsLabel: 'Import your own LoRAs'
}
},
standard: {
name: 'Standard',
price: '20.00',
benefits: {
monthlyCredits: '4,200',
monthlyCreditsLabel: 'monthly credits',
maxDuration: '30 min',
maxDurationLabel: 'max duration of each workflow run',
gpuLabel: 'RTX 6000 Pro (96GB VRAM)',
addCreditsLabel: 'Add more credits whenever',
customLoRAsLabel: 'Import your own LoRAs'
}
},
creator: {
name: 'Creator',
price: '35.00',
benefits: {
monthlyCredits: '7,400',
monthlyCreditsLabel: 'monthly credits',
maxDuration: '30 min',
maxDurationLabel: 'max duration of each workflow run',
gpuLabel: 'RTX 6000 Pro (96GB VRAM)',
addCreditsLabel: 'Add more credits whenever',
customLoRAsLabel: 'Import your own LoRAs'
}
},
pro: {
name: 'Pro',
price: '100.00',
benefits: {
monthlyCredits: '21,100',
monthlyCreditsLabel: 'monthly credits',
maxDuration: '1 hr',
maxDurationLabel: 'max duration of each workflow run',
gpuLabel: 'RTX 6000 Pro (96GB VRAM)',
addCreditsLabel: 'Add more credits whenever',
customLoRAsLabel: 'Import your own LoRAs'
}
}
}
}
}
}
@@ -116,18 +201,22 @@ function createWrapper(overrides = {}) {
describe('SubscriptionPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset mock state
mockIsActiveSubscription.value = false
mockIsCancelled.value = false
mockSubscriptionTier.value = 'CREATOR'
})
describe('subscription state functionality', () => {
it('shows correct UI for active subscription', () => {
mockSubscriptionData.isActiveSubscription = true
mockIsActiveSubscription.value = true
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Manage Subscription')
expect(wrapper.text()).toContain('Add Credits')
})
it('shows correct UI for inactive subscription', () => {
mockSubscriptionData.isActiveSubscription = false
mockIsActiveSubscription.value = false
const wrapper = createWrapper()
expect(wrapper.findComponent({ name: 'SubscribeButton' }).exists()).toBe(
true
@@ -137,25 +226,39 @@ describe('SubscriptionPanel', () => {
})
it('shows renewal date for active non-cancelled subscription', () => {
mockSubscriptionData.isActiveSubscription = true
mockSubscriptionData.isCancelled = false
mockIsActiveSubscription.value = true
mockIsCancelled.value = false
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Renews 2024-12-31')
})
it('shows expiry date for cancelled subscription', () => {
mockSubscriptionData.isActiveSubscription = true
mockSubscriptionData.isCancelled = true
mockIsActiveSubscription.value = true
mockIsCancelled.value = true
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Expires 2024-12-31')
})
it('displays FOUNDERS_EDITION tier correctly', () => {
mockSubscriptionTier.value = 'FOUNDERS_EDITION'
const wrapper = createWrapper()
expect(wrapper.text()).toContain("Founder's Edition")
expect(wrapper.text()).toContain('5,460')
})
it('displays CREATOR tier correctly', () => {
mockSubscriptionTier.value = 'CREATOR'
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Creator')
expect(wrapper.text()).toContain('7,400')
})
})
describe('credit display functionality', () => {
it('displays dynamic credit values correctly', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('$10.00') // totalCredits
expect(wrapper.text()).toContain('$5.00') // both monthlyBonus and prepaid
expect(wrapper.text()).toContain('10.00 Credits')
expect(wrapper.text()).toContain('5.00 Credits')
})
it('shows loading skeleton when fetching balance', () => {

View File

@@ -1,8 +1,27 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as comfyCredits from '@/base/credits/comfyCredits'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { operations } from '@/types/comfyRegistryTypes'
type GetCustomerBalanceResponse =
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
vi.mock(
'vue-i18n',
async (importOriginal: () => Promise<typeof import('vue-i18n')>) => {
const actual = await importOriginal()
return {
...actual,
useI18n: () => ({
t: () => 'Credits',
locale: { value: 'en-US' }
})
}
}
)
// Mock Firebase Auth and related modules
vi.mock('vuefire', () => ({
@@ -55,14 +74,6 @@ vi.mock('@/stores/apiKeyAuthStore', () => ({
})
}))
// Mock formatMetronomeCurrency
vi.mock('@/utils/formatUtil', () => ({
formatMetronomeCurrency: vi.fn((micros: number) => {
// Simple mock that converts micros to dollars
return (micros / 1000000).toFixed(2)
})
}))
describe('useSubscriptionCredits', () => {
let authStore: ReturnType<typeof useFirebaseAuthStore>
@@ -73,63 +84,66 @@ describe('useSubscriptionCredits', () => {
})
describe('totalCredits', () => {
it('should return "0.00" when balance is null', () => {
it('should return "0" when balance is null', () => {
authStore.balance = null
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00')
expect(totalCredits.value).toBe('0')
})
it('should return "0.00" when amount_micros is missing', () => {
authStore.balance = {} as any
it('should return "0" when amount_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00')
expect(totalCredits.value).toBe('0')
})
it('should format amount_micros correctly', () => {
authStore.balance = { amount_micros: 5000000 } as any
authStore.balance = { amount_micros: 100 } as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('5.00')
expect(totalCredits.value).toBe('211')
})
it('should handle formatting errors gracefully', async () => {
const mockFormatMetronomeCurrency = vi.mocked(
await import('@/utils/formatUtil')
).formatMetronomeCurrency
mockFormatMetronomeCurrency.mockImplementationOnce(() => {
it('should handle formatting errors by throwing', async () => {
const formatSpy = vi.spyOn(comfyCredits, 'formatCreditsFromCents')
formatSpy.mockImplementationOnce(() => {
throw new Error('Formatting error')
})
authStore.balance = { amount_micros: 5000000 } as any
authStore.balance = { amount_micros: 100 } as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00')
expect(() => totalCredits.value).toThrow('Formatting error')
formatSpy.mockRestore()
})
})
describe('monthlyBonusCredits', () => {
it('should return "0.00" when cloud_credit_balance_micros is missing', () => {
authStore.balance = {} as any
it('should return "0" when cloud_credit_balance_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse
const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('0.00')
expect(monthlyBonusCredits.value).toBe('0')
})
it('should format cloud_credit_balance_micros correctly', () => {
authStore.balance = { cloud_credit_balance_micros: 2500000 } as any
authStore.balance = {
cloud_credit_balance_micros: 200
} as GetCustomerBalanceResponse
const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('2.50')
expect(monthlyBonusCredits.value).toBe('422')
})
})
describe('prepaidCredits', () => {
it('should return "0.00" when prepaid_balance_micros is missing', () => {
authStore.balance = {} as any
it('should return "0" when prepaid_balance_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse
const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('0.00')
expect(prepaidCredits.value).toBe('0')
})
it('should format prepaid_balance_micros correctly', () => {
authStore.balance = { prepaid_balance_micros: 7500000 } as any
authStore.balance = {
prepaid_balance_micros: 300
} as GetCustomerBalanceResponse
const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('7.50')
expect(prepaidCredits.value).toBe('633')
})
})

View File

@@ -152,10 +152,28 @@ describe('useSubscription', () => {
expect(formattedRenewalDate.value).toBe('')
})
it('should format monthly price correctly', () => {
const { formattedMonthlyPrice } = useSubscription()
it('should return subscription tier from status', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
subscription_tier: 'CREATOR',
renewal_date: '2025-11-16T12:00:00Z'
})
} as Response)
expect(formattedMonthlyPrice.value).toBe('$20')
mockIsLoggedIn.value = true
const { subscriptionTier, fetchStatus } = useSubscription()
await fetchStatus()
expect(subscriptionTier.value).toBe('CREATOR')
})
it('should return null when subscription tier is not available', () => {
const { subscriptionTier } = useSubscription()
expect(subscriptionTier.value).toBeNull()
})
})

View File

@@ -187,7 +187,8 @@ describe('useTemplateUrlLoader', () => {
'flux_simple',
'flux-kontext-dev',
'template123',
'My_Template-2'
'My_Template-2',
'templates-1_click_multiple_scene_angles-v1.0' // template with version number containing dot
]
for (const template of validTemplates) {

View File

@@ -1,5 +1,5 @@
import { createPinia, setActivePinia } from 'pinia'
import { compare as semverCompare } from 'semver'
import { compare, valid } from 'semver'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
@@ -7,6 +7,7 @@ import { useReleaseStore } from '@/platform/updates/common/releaseStore'
// Mock the dependencies
vi.mock('semver')
vi.mock('@/utils/envUtil')
vi.mock('@/platform/distribution/types', () => ({ isCloud: false }))
vi.mock('@/platform/updates/common/releaseService')
vi.mock('@/platform/settings/settingStore')
vi.mock('@/stores/systemStatsStore')
@@ -72,6 +73,7 @@ describe('useReleaseStore', () => {
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore)
vi.mocked(useSystemStatsStore).mockReturnValue(mockSystemStatsStore)
vi.mocked(isElectron).mockReturnValue(true)
vi.mocked(valid).mockReturnValue('1.0.0')
// Default showVersionUpdates to true
mockSettingStore.get.mockImplementation((key: string) => {
@@ -116,14 +118,14 @@ describe('useReleaseStore', () => {
})
it('should show update button (shouldShowUpdateButton)', () => {
vi.mocked(semverCompare).mockReturnValue(1) // newer version available
vi.mocked(compare).mockReturnValue(1) // newer version available
store.releases = [mockRelease]
expect(store.shouldShowUpdateButton).toBe(true)
})
it('should not show update button when no new version', () => {
vi.mocked(semverCompare).mockReturnValue(-1) // current version is newer
vi.mocked(compare).mockReturnValue(-1) // current version is newer
store.releases = [mockRelease]
expect(store.shouldShowUpdateButton).toBe(false)
@@ -144,14 +146,14 @@ describe('useReleaseStore', () => {
})
it('should show toast for medium/high attention releases', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
store.releases = [mockRelease]
expect(store.shouldShowToast).toBe(true)
})
it('should not show toast for low attention releases', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
const lowAttentionRelease = {
...mockRelease,
@@ -164,7 +166,7 @@ describe('useReleaseStore', () => {
})
it('should show red dot for new versions', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(true)
})
@@ -172,7 +174,7 @@ describe('useReleaseStore', () => {
it('should show popup for latest version', () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
vi.mocked(semverCompare).mockReturnValue(0)
vi.mocked(compare).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(true)
})
@@ -200,13 +202,13 @@ describe('useReleaseStore', () => {
})
it('should not show toast even with new version available', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowToast).toBe(false)
})
it('should not show red dot even with new version available', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(false)
})
@@ -214,7 +216,7 @@ describe('useReleaseStore', () => {
it('should not show popup even for latest version', () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
vi.mocked(semverCompare).mockReturnValue(0)
vi.mocked(compare).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(false)
})
@@ -494,7 +496,7 @@ describe('useReleaseStore', () => {
return null
})
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
store.releases = [mockRelease]
@@ -502,7 +504,7 @@ describe('useReleaseStore', () => {
})
it('should show red dot for new versions', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
@@ -520,7 +522,7 @@ describe('useReleaseStore', () => {
return null
})
vi.mocked(semverCompare).mockReturnValue(0) // versions are equal (latest version)
vi.mocked(compare).mockReturnValue(0) // versions are equal (latest version)
store.releases = [mockRelease]
@@ -578,14 +580,14 @@ describe('useReleaseStore', () => {
})
it('should show toast when conditions are met', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
store.releases = [mockRelease]
expect(store.shouldShowToast).toBe(true)
})
it('should show red dot when new version available', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(true)
})
@@ -593,7 +595,7 @@ describe('useReleaseStore', () => {
it('should show popup for latest version', () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
vi.mocked(semverCompare).mockReturnValue(0)
vi.mocked(compare).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(true)
})
@@ -606,7 +608,7 @@ describe('useReleaseStore', () => {
})
it('should NOT show toast even when all other conditions are met', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
// Set up all conditions that would normally show toast
store.releases = [mockRelease]
@@ -615,13 +617,13 @@ describe('useReleaseStore', () => {
})
it('should NOT show red dot even when new version available', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(false)
})
it('should NOT show toast regardless of attention level', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
// Test with high attention releases
const highRelease = {
@@ -640,7 +642,7 @@ describe('useReleaseStore', () => {
})
it('should NOT show red dot even with high attention release', () => {
vi.mocked(semverCompare).mockReturnValue(1)
vi.mocked(compare).mockReturnValue(1)
store.releases = [{ ...mockRelease, attention: 'high' as const }]
@@ -650,7 +652,7 @@ describe('useReleaseStore', () => {
it('should NOT show popup even for latest version', () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
vi.mocked(semverCompare).mockReturnValue(0)
vi.mocked(compare).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(false)
})

View File

@@ -17,6 +17,8 @@ vi.mock('@/utils/envUtil', () => ({
isElectron: vi.fn()
}))
vi.mock('@/platform/distribution/types', () => ({ isCloud: false }))
describe('useSystemStatsStore', () => {
let store: ReturnType<typeof useSystemStatsStore>

View File

@@ -6,7 +6,6 @@
"lib": [
"ES2023",
"ES2023.Array",
"ESNext.Iterator",
"DOM",
"DOM.Iterable"
],