mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 09:57:33 +00:00
Compare commits
31 Commits
fix/load-a
...
cloud/1.34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62175277fc | ||
|
|
e907545e39 | ||
|
|
44a1c7a194 | ||
|
|
a1086d5df8 | ||
|
|
350044dd91 | ||
|
|
d987d08ac9 | ||
|
|
719ebe1189 | ||
|
|
5c24bb2258 | ||
|
|
5e8cba5559 | ||
|
|
bade95b2c5 | ||
|
|
801ab024e5 | ||
|
|
2b4d3484b8 | ||
|
|
4e68a266a0 | ||
|
|
d3d02d0d4b | ||
|
|
4b008eeaf7 | ||
|
|
3f203002b9 | ||
|
|
cc60dfd910 | ||
|
|
913875d745 | ||
|
|
0751a13df7 | ||
|
|
562db3b0d9 | ||
|
|
432c1e8e33 | ||
|
|
a660c55da9 | ||
|
|
fdda9cc752 | ||
|
|
ab74061bc6 | ||
|
|
c82f4272a7 | ||
|
|
32c525a4a6 | ||
|
|
27dcb152ff | ||
|
|
d2f5f0dce1 | ||
|
|
47d8f022ec | ||
|
|
271c69f261 | ||
|
|
218a7f24a6 |
@@ -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
2
global.d.ts
vendored
@@ -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
|
||||
|
||||
308
packages/registry-types/src/comfyRegistryTypes.ts
generated
308
packages/registry-types/src/comfyRegistryTypes.ts
generated
@@ -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?: {
|
||||
|
||||
125
src/base/credits/comfyCredits.ts
Normal file
125
src/base/credits/comfyCredits.ts
Normal 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))
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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-base-foreground 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-primary-foreground' } }"
|
||||
@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,24 @@
|
||||
|
||||
<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 } 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 +147,61 @@ 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 } = 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)
|
||||
} 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()
|
||||
|
||||
@@ -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-base-foreground">
|
||||
{{ formattedCredits }}
|
||||
</span>
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
{{ 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>
|
||||
|
||||
119
src/components/dialog/content/credit/LegacyCreditTopUpOption.vue
Normal file
119
src/components/dialog/content/credit/LegacyCreditTopUpOption.vue
Normal 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>
|
||||
@@ -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') }}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -40,12 +40,12 @@ const calculateRunwayDurationPrice = (node: LGraphNode): string => {
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget) return '$0.05/second'
|
||||
if (!durationWidget) return '$0.0715/second'
|
||||
|
||||
const duration = Number(durationWidget.value)
|
||||
// If duration is 0 or NaN, don't fall back to 5 seconds - just use 0
|
||||
const validDuration = isNaN(duration) ? 5 : duration
|
||||
const cost = (0.05 * validDuration).toFixed(2)
|
||||
const cost = (0.0715 * validDuration).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
|
||||
@@ -377,11 +377,11 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
(w) => w.name === 'turbo'
|
||||
) as IComboWidget
|
||||
|
||||
if (!numImagesWidget) return '$0.02-0.06 x num_images/Run'
|
||||
if (!numImagesWidget) return '$0.03-0.09 x num_images/Run'
|
||||
|
||||
const numImages = Number(numImagesWidget.value) || 1
|
||||
const turbo = String(turboWidget?.value).toLowerCase() === 'true'
|
||||
const basePrice = turbo ? 0.02 : 0.06
|
||||
const basePrice = turbo ? 0.0286 : 0.0858
|
||||
const cost = (basePrice * numImages).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
@@ -395,11 +395,11 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
(w) => w.name === 'turbo'
|
||||
) as IComboWidget
|
||||
|
||||
if (!numImagesWidget) return '$0.05-0.08 x num_images/Run'
|
||||
if (!numImagesWidget) return '$0.07-0.11 x num_images/Run'
|
||||
|
||||
const numImages = Number(numImagesWidget.value) || 1
|
||||
const turbo = String(turboWidget?.value).toLowerCase() === 'true'
|
||||
const basePrice = turbo ? 0.05 : 0.08
|
||||
const basePrice = turbo ? 0.0715 : 0.1144
|
||||
const cost = (basePrice * numImages).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
@@ -420,29 +420,29 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
characterInput.link != null
|
||||
|
||||
if (!renderingSpeedWidget)
|
||||
return '$0.03-0.08 x num_images/Run (varies with rendering speed & num_images)'
|
||||
return '$0.04-0.11 x num_images/Run (varies with rendering speed & num_images)'
|
||||
|
||||
const numImages = Number(numImagesWidget?.value) || 1
|
||||
let basePrice = 0.06 // default balanced price
|
||||
let basePrice = 0.0858 // default balanced price
|
||||
|
||||
const renderingSpeed = String(renderingSpeedWidget.value)
|
||||
if (renderingSpeed.toLowerCase().includes('quality')) {
|
||||
if (hasCharacter) {
|
||||
basePrice = 0.2
|
||||
basePrice = 0.286
|
||||
} else {
|
||||
basePrice = 0.09
|
||||
basePrice = 0.1287
|
||||
}
|
||||
} else if (renderingSpeed.toLowerCase().includes('default')) {
|
||||
if (hasCharacter) {
|
||||
basePrice = 0.15
|
||||
basePrice = 0.2145
|
||||
} else {
|
||||
basePrice = 0.06
|
||||
basePrice = 0.0858
|
||||
}
|
||||
} else if (renderingSpeed.toLowerCase().includes('turbo')) {
|
||||
if (hasCharacter) {
|
||||
basePrice = 0.1
|
||||
basePrice = 0.143
|
||||
} else {
|
||||
basePrice = 0.03
|
||||
basePrice = 0.0429
|
||||
}
|
||||
}
|
||||
|
||||
@@ -755,7 +755,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !resolutionWidget || !durationWidget) {
|
||||
return '$0.14-11.47/Run (varies with model, resolution & duration)'
|
||||
return '$0.20-16.40/Run (varies with model, resolution & duration)'
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
@@ -764,33 +764,33 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
|
||||
if (model.includes('ray-flash-2')) {
|
||||
if (duration.includes('5s')) {
|
||||
if (resolution.includes('4k')) return '$2.19/Run'
|
||||
if (resolution.includes('1080p')) return '$0.55/Run'
|
||||
if (resolution.includes('720p')) return '$0.24/Run'
|
||||
if (resolution.includes('540p')) return '$0.14/Run'
|
||||
if (resolution.includes('4k')) return '$3.13/Run'
|
||||
if (resolution.includes('1080p')) return '$0.79/Run'
|
||||
if (resolution.includes('720p')) return '$0.34/Run'
|
||||
if (resolution.includes('540p')) return '$0.20/Run'
|
||||
} else if (duration.includes('9s')) {
|
||||
if (resolution.includes('4k')) return '$3.95/Run'
|
||||
if (resolution.includes('1080p')) return '$0.99/Run'
|
||||
if (resolution.includes('720p')) return '$0.43/Run'
|
||||
if (resolution.includes('540p')) return '$0.252/Run'
|
||||
if (resolution.includes('4k')) return '$5.65/Run'
|
||||
if (resolution.includes('1080p')) return '$1.42/Run'
|
||||
if (resolution.includes('720p')) return '$0.61/Run'
|
||||
if (resolution.includes('540p')) return '$0.36/Run'
|
||||
}
|
||||
} else if (model.includes('ray-2')) {
|
||||
if (duration.includes('5s')) {
|
||||
if (resolution.includes('4k')) return '$6.37/Run'
|
||||
if (resolution.includes('1080p')) return '$1.59/Run'
|
||||
if (resolution.includes('720p')) return '$0.71/Run'
|
||||
if (resolution.includes('540p')) return '$0.40/Run'
|
||||
if (resolution.includes('4k')) return '$9.11/Run'
|
||||
if (resolution.includes('1080p')) return '$2.27/Run'
|
||||
if (resolution.includes('720p')) return '$1.02/Run'
|
||||
if (resolution.includes('540p')) return '$0.57/Run'
|
||||
} else if (duration.includes('9s')) {
|
||||
if (resolution.includes('4k')) return '$11.47/Run'
|
||||
if (resolution.includes('1080p')) return '$2.87/Run'
|
||||
if (resolution.includes('720p')) return '$1.28/Run'
|
||||
if (resolution.includes('540p')) return '$0.72/Run'
|
||||
if (resolution.includes('4k')) return '$16.40/Run'
|
||||
if (resolution.includes('1080p')) return '$4.10/Run'
|
||||
if (resolution.includes('720p')) return '$1.83/Run'
|
||||
if (resolution.includes('540p')) return '$1.03/Run'
|
||||
}
|
||||
} else if (model.includes('ray-1.6')) {
|
||||
return '$0.35/Run'
|
||||
} else if (model.includes('ray-1-6')) {
|
||||
return '$0.50/Run'
|
||||
}
|
||||
|
||||
return '$0.55/Run'
|
||||
return '$0.79/Run'
|
||||
}
|
||||
},
|
||||
LumaVideoNode: {
|
||||
@@ -806,7 +806,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !resolutionWidget || !durationWidget) {
|
||||
return '$0.14-11.47/Run (varies with model, resolution & duration)'
|
||||
return '$0.20-16.40/Run (varies with model, resolution & duration)'
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
@@ -815,33 +815,33 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
|
||||
if (model.includes('ray-flash-2')) {
|
||||
if (duration.includes('5s')) {
|
||||
if (resolution.includes('4k')) return '$2.19/Run'
|
||||
if (resolution.includes('1080p')) return '$0.55/Run'
|
||||
if (resolution.includes('720p')) return '$0.24/Run'
|
||||
if (resolution.includes('540p')) return '$0.14/Run'
|
||||
if (resolution.includes('4k')) return '$3.13/Run'
|
||||
if (resolution.includes('1080p')) return '$0.79/Run'
|
||||
if (resolution.includes('720p')) return '$0.34/Run'
|
||||
if (resolution.includes('540p')) return '$0.20/Run'
|
||||
} else if (duration.includes('9s')) {
|
||||
if (resolution.includes('4k')) return '$3.95/Run'
|
||||
if (resolution.includes('1080p')) return '$0.99/Run'
|
||||
if (resolution.includes('720p')) return '$0.43/Run'
|
||||
if (resolution.includes('540p')) return '$0.252/Run'
|
||||
if (resolution.includes('4k')) return '$5.65/Run'
|
||||
if (resolution.includes('1080p')) return '$1.42/Run'
|
||||
if (resolution.includes('720p')) return '$0.61/Run'
|
||||
if (resolution.includes('540p')) return '$0.36/Run'
|
||||
}
|
||||
} else if (model.includes('ray-2')) {
|
||||
if (duration.includes('5s')) {
|
||||
if (resolution.includes('4k')) return '$6.37/Run'
|
||||
if (resolution.includes('1080p')) return '$1.59/Run'
|
||||
if (resolution.includes('720p')) return '$0.71/Run'
|
||||
if (resolution.includes('540p')) return '$0.40/Run'
|
||||
if (resolution.includes('4k')) return '$9.11/Run'
|
||||
if (resolution.includes('1080p')) return '$2.27/Run'
|
||||
if (resolution.includes('720p')) return '$1.02/Run'
|
||||
if (resolution.includes('540p')) return '$0.57/Run'
|
||||
} else if (duration.includes('9s')) {
|
||||
if (resolution.includes('4k')) return '$11.47/Run'
|
||||
if (resolution.includes('1080p')) return '$2.87/Run'
|
||||
if (resolution.includes('720p')) return '$1.28/Run'
|
||||
if (resolution.includes('540p')) return '$0.72/Run'
|
||||
if (resolution.includes('4k')) return '$16.40/Run'
|
||||
if (resolution.includes('1080p')) return '$4.10/Run'
|
||||
if (resolution.includes('720p')) return '$1.83/Run'
|
||||
if (resolution.includes('540p')) return '$1.03/Run'
|
||||
}
|
||||
} else if (model.includes('ray-1-6')) {
|
||||
return '$0.35/Run'
|
||||
return '$0.50/Run'
|
||||
}
|
||||
|
||||
return '$0.55/Run'
|
||||
return '$0.79/Run'
|
||||
}
|
||||
},
|
||||
MinimaxImageToVideoNode: {
|
||||
@@ -1323,18 +1323,18 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !aspectRatioWidget) {
|
||||
return '$0.0045-0.0182/Run (varies with model & aspect ratio)'
|
||||
return '$0.0064-0.026/Run (varies with model & aspect ratio)'
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
|
||||
if (model.includes('photon-flash-1')) {
|
||||
return '$0.0019/Run'
|
||||
return '$0.0027/Run'
|
||||
} else if (model.includes('photon-1')) {
|
||||
return '$0.0073/Run'
|
||||
return '$0.0104/Run'
|
||||
}
|
||||
|
||||
return '$0.0172/Run'
|
||||
return '$0.0246/Run'
|
||||
}
|
||||
},
|
||||
LumaImageModifyNode: {
|
||||
@@ -1344,18 +1344,18 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget) {
|
||||
return '$0.0019-0.0073/Run (varies with model)'
|
||||
return '$0.0027-0.0104/Run (varies with model)'
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
|
||||
if (model.includes('photon-flash-1')) {
|
||||
return '$0.0019/Run'
|
||||
return '$0.0027/Run'
|
||||
} else if (model.includes('photon-1')) {
|
||||
return '$0.0073/Run'
|
||||
return '$0.0104/Run'
|
||||
}
|
||||
|
||||
return '$0.0172/Run'
|
||||
return '$0.0246/Run'
|
||||
}
|
||||
},
|
||||
MoonvalleyTxt2VideoNode: {
|
||||
@@ -1417,7 +1417,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
},
|
||||
// Runway nodes - using actual node names from ComfyUI
|
||||
RunwayTextToImageNode: {
|
||||
displayPrice: '$0.08/Run'
|
||||
displayPrice: '$0.11/Run'
|
||||
},
|
||||
RunwayImageToVideoNodeGen3a: {
|
||||
displayPrice: calculateRunwayDurationPrice
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,32 @@
|
||||
"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",
|
||||
"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,9 +1867,9 @@
|
||||
"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}",
|
||||
"manageSubscription": "Manage subscription",
|
||||
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
|
||||
@@ -1864,6 +1882,10 @@
|
||||
"monthlyBonusDescription": "Monthly credit bonus",
|
||||
"prepaidDescription": "Pre-paid credits",
|
||||
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
|
||||
"creditsRemainingThisMonth": "Credits remaining 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 +1896,110 @@
|
||||
"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?",
|
||||
"videoEstimateExplanation": "These estimates are based on the Wan Fun Control template for 5-second videos.",
|
||||
"videoEstimateTryTemplate": "Try the Wan Fun Control template →",
|
||||
"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",
|
||||
@@ -2097,11 +2210,11 @@
|
||||
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
|
||||
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
|
||||
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
|
||||
"uploadModelDescription2": "Only links from <a href=\"https://civitai.com\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com</a> are supported at the moment",
|
||||
"uploadModelDescription2": "Only links from <a href=\"https://civitai.com/models\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/models</a> are supported at the moment",
|
||||
"uploadModelDescription3": "Max file size: <strong>1 GB</strong>",
|
||||
"civitaiLinkLabel": "Civitai model <span class=\"font-bold italic\">download</span> link",
|
||||
"civitaiLinkPlaceholder": "Paste link here",
|
||||
"civitaiLinkExample": "<strong>Example:</strong> <a href=\"https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor</a>",
|
||||
"civitaiLinkExample": "<strong>Example:</strong> <a href=\"https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295</a>",
|
||||
"confirmModelDetails": "Confirm Model Details",
|
||||
"fileName": "File Name",
|
||||
"fileSize": "File Size",
|
||||
@@ -2121,7 +2234,7 @@
|
||||
"modelTypeSelectorPlaceholder": "Select model type",
|
||||
"selectModelType": "Select model type",
|
||||
"notSureLeaveAsIs": "Not sure? Just leave this as is",
|
||||
"modelUploaded": "Model imported! 🎉",
|
||||
"modelUploaded": "Model successfully imported.",
|
||||
"findInLibrary": "Find it in the {type} section of the models library.",
|
||||
"finish": "Finish",
|
||||
"upgradeToUnlockFeature": "Upgrade to unlock this feature",
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
336
src/platform/cloud/subscription/components/PricingTable.vue
Normal file
336
src/platform/cloud/subscription/components/PricingTable.vue
Normal file
@@ -0,0 +1,336 @@
|
||||
<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-background px-1 text-xs font-semibold uppercase tracking-wide text-foreground 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-success-foreground" />
|
||||
</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-success-foreground" />
|
||||
</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-success-foreground"
|
||||
/>
|
||||
<i v-else class="pi pi-times text-xs text-muted-foreground" />
|
||||
</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 cursor-pointer hover:text-base-foreground"
|
||||
@click="togglePopover"
|
||||
>
|
||||
{{ 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-primary-foreground'
|
||||
}
|
||||
}"
|
||||
@click="() => handleSubscribe(tier.key)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Estimate Help Popover -->
|
||||
<Popover
|
||||
ref="popover"
|
||||
append-to="body"
|
||||
:auto-z-index="true"
|
||||
:base-z-index="1000"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-base-foreground">
|
||||
{{ t('subscription.videoEstimateExplanation') }}
|
||||
</p>
|
||||
<a
|
||||
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-azure-600 hover:text-azure-400 underline"
|
||||
>
|
||||
{{ t('subscription.videoEstimateTryTemplate') }}
|
||||
</a>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
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 popover = ref()
|
||||
|
||||
const currentTierKey = computed<TierKey | null>(() =>
|
||||
subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null
|
||||
)
|
||||
|
||||
const isCurrentPlan = (tierKey: TierKey): boolean =>
|
||||
currentTierKey.value === tierKey
|
||||
|
||||
const togglePopover = (event: Event) => {
|
||||
popover.value.toggle(event)
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,26 +125,19 @@
|
||||
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"
|
||||
icon="pi pi-question-circle"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="h-4 w-4"
|
||||
:pt="{
|
||||
icon: {
|
||||
class: 'text-text-secondary text-xs'
|
||||
}
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -161,12 +146,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 +165,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 +181,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 +206,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,34 +330,115 @@
|
||||
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 = {
|
||||
STANDARD: 'standard',
|
||||
CREATOR: 'creator',
|
||||
PRO: 'pro',
|
||||
FOUNDERS_EDITION: 'founder'
|
||||
} as const satisfies Record<SubscriptionTier, string>
|
||||
|
||||
type TierKey = (typeof TIER_TO_I18N_KEY)[SubscriptionTier]
|
||||
|
||||
const DEFAULT_TIER_KEY: TierKey = '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 BENEFITS_BY_TIER: Record<
|
||||
TierKey,
|
||||
ReadonlyArray<Omit<Benefit, 'label' | 'value'>>
|
||||
> = {
|
||||
standard: [
|
||||
{ key: 'monthlyCredits', type: 'metric' },
|
||||
{ key: 'maxDuration', type: 'metric' },
|
||||
{ key: 'gpu', type: 'feature' },
|
||||
{ key: 'addCredits', type: 'feature' }
|
||||
],
|
||||
creator: [
|
||||
{ key: 'monthlyCredits', type: 'metric' },
|
||||
{ key: 'maxDuration', type: 'metric' },
|
||||
{ key: 'gpu', type: 'feature' },
|
||||
{ key: 'addCredits', type: 'feature' },
|
||||
{ key: 'customLoRAs', type: 'feature' }
|
||||
],
|
||||
pro: [
|
||||
{ key: 'monthlyCredits', type: 'metric' },
|
||||
{ key: 'maxDuration', type: 'metric' },
|
||||
{ key: 'gpu', type: 'feature' },
|
||||
{ key: 'addCredits', type: 'feature' },
|
||||
{ key: 'customLoRAs', type: 'feature' }
|
||||
],
|
||||
founder: [
|
||||
{ key: 'monthlyCredits', type: 'metric' },
|
||||
{ key: 'maxDuration', type: 'metric' },
|
||||
{ key: 'gpu', type: 'feature' },
|
||||
{ key: 'addCredits', type: 'feature' }
|
||||
]
|
||||
}
|
||||
|
||||
const tierBenefits = computed(() => {
|
||||
const key = tierKey.value
|
||||
const benefitConfig = BENEFITS_BY_TIER[key]
|
||||
|
||||
return benefitConfig.map((config) => ({
|
||||
...config,
|
||||
...(config.type === 'metric' && {
|
||||
value: t(`subscription.tiers.${key}.benefits.${config.key}`)
|
||||
}),
|
||||
label: t(`subscription.tiers.${key}.benefits.${config.key}Label`)
|
||||
}))
|
||||
})
|
||||
|
||||
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||
useSubscriptionCredits()
|
||||
|
||||
const {
|
||||
isLoadingSupport,
|
||||
refreshTooltip,
|
||||
handleAddApiCredits,
|
||||
handleMessageSupport,
|
||||
handleRefresh,
|
||||
|
||||
@@ -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-base-foreground"
|
||||
@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-base-foreground"
|
||||
@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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
@@ -8,30 +7,18 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const MONTHLY_CREDIT_BONUS_USD = 10
|
||||
|
||||
/**
|
||||
* Composable for handling subscription panel actions and loading states
|
||||
*/
|
||||
export function useSubscriptionActions() {
|
||||
const { t } = useI18n()
|
||||
const dialogService = useDialogService()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { fetchStatus, formattedRenewalDate } = useSubscription()
|
||||
const { fetchStatus } = useSubscription()
|
||||
|
||||
const isLoadingSupport = ref(false)
|
||||
|
||||
const refreshTooltip = computed(() => {
|
||||
const date =
|
||||
formattedRenewalDate.value || t('subscription.nextBillingCycle')
|
||||
return t('subscription.refreshesOn', {
|
||||
monthlyCreditBonusUsd: MONTHLY_CREDIT_BONUS_USD,
|
||||
date
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void handleRefresh()
|
||||
})
|
||||
@@ -72,7 +59,6 @@ export function useSubscriptionActions() {
|
||||
|
||||
return {
|
||||
isLoadingSupport,
|
||||
refreshTooltip,
|
||||
handleAddApiCredits,
|
||||
handleMessageSupport,
|
||||
handleRefresh,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -25,7 +25,15 @@ export const useSubscriptionDialog = () => {
|
||||
onClose: hide
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: 700px;'
|
||||
style: 'width: min(1200px, 95vw); max-height: 90vh;',
|
||||
pt: {
|
||||
root: {
|
||||
class: '!rounded-[32px] overflow-visible'
|
||||
},
|
||||
content: {
|
||||
class: '!p-0 bg-transparent'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
: []),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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!' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
9
src/vite-env.d.ts
vendored
@@ -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 {}
|
||||
|
||||
46
tests-ui/tests/base/credits/comfyCredits.test.ts
Normal file
46
tests-ui/tests/base/credits/comfyCredits.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -577,32 +577,32 @@ describe('useNodePricing', () => {
|
||||
{
|
||||
rendering_speed: 'Quality',
|
||||
character_image: false,
|
||||
expected: '$0.09/Run'
|
||||
expected: '$0.13/Run'
|
||||
},
|
||||
{
|
||||
rendering_speed: 'Quality',
|
||||
character_image: true,
|
||||
expected: '$0.20/Run'
|
||||
expected: '$0.29/Run'
|
||||
},
|
||||
{
|
||||
rendering_speed: 'Default',
|
||||
character_image: false,
|
||||
expected: '$0.06/Run'
|
||||
expected: '$0.09/Run'
|
||||
},
|
||||
{
|
||||
rendering_speed: 'Default',
|
||||
character_image: true,
|
||||
expected: '$0.15/Run'
|
||||
expected: '$0.21/Run'
|
||||
},
|
||||
{
|
||||
rendering_speed: 'Turbo',
|
||||
character_image: false,
|
||||
expected: '$0.03/Run'
|
||||
expected: '$0.04/Run'
|
||||
},
|
||||
{
|
||||
rendering_speed: 'Turbo',
|
||||
character_image: true,
|
||||
expected: '$0.10/Run'
|
||||
expected: '$0.14/Run'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -623,7 +623,7 @@ describe('useNodePricing', () => {
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(
|
||||
'$0.03-0.08 x num_images/Run (varies with rendering speed & num_images)'
|
||||
'$0.04-0.11 x num_images/Run (varies with rendering speed & num_images)'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -635,7 +635,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.27/Run') // 0.09 * 3
|
||||
expect(price).toBe('$0.39/Run') // 0.09 * 3 * 1.43
|
||||
})
|
||||
|
||||
it('should multiply price by num_images for Turbo rendering speed', () => {
|
||||
@@ -646,7 +646,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.15/Run') // 0.03 * 5
|
||||
expect(price).toBe('$0.21/Run') // 0.03 * 5 * 1.43
|
||||
})
|
||||
})
|
||||
|
||||
@@ -770,7 +770,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$2.19/Run')
|
||||
expect(price).toBe('$3.13/Run')
|
||||
})
|
||||
|
||||
it('should return $6.37 for ray-2 4K 5s', () => {
|
||||
@@ -782,7 +782,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$6.37/Run')
|
||||
expect(price).toBe('$9.11/Run')
|
||||
})
|
||||
|
||||
it('should return $0.35 for ray-1-6 model', () => {
|
||||
@@ -794,7 +794,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.35/Run')
|
||||
expect(price).toBe('$0.50/Run')
|
||||
})
|
||||
|
||||
it('should return range when widgets are missing', () => {
|
||||
@@ -803,7 +803,7 @@ describe('useNodePricing', () => {
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(
|
||||
'$0.14-11.47/Run (varies with model, resolution & duration)'
|
||||
'$0.20-16.40/Run (varies with model, resolution & duration)'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1192,7 +1192,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.18/Run') // 0.06 * 3
|
||||
expect(price).toBe('$0.26/Run') // 0.06 * 3 * 1.43
|
||||
})
|
||||
|
||||
it('should calculate dynamic pricing for IdeogramV2 based on num_images value', () => {
|
||||
@@ -1202,7 +1202,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.32/Run') // 0.08 * 4
|
||||
expect(price).toBe('$0.46/Run') // 0.08 * 4 * 1.43
|
||||
})
|
||||
|
||||
it('should fall back to static display when num_images widget is missing for IdeogramV1', () => {
|
||||
@@ -1210,7 +1210,7 @@ describe('useNodePricing', () => {
|
||||
const node = createMockNode('IdeogramV1', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.02-0.06 x num_images/Run')
|
||||
expect(price).toBe('$0.03-0.09 x num_images/Run')
|
||||
})
|
||||
|
||||
it('should fall back to static display when num_images widget is missing for IdeogramV2', () => {
|
||||
@@ -1218,7 +1218,7 @@ describe('useNodePricing', () => {
|
||||
const node = createMockNode('IdeogramV2', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.05-0.08 x num_images/Run')
|
||||
expect(price).toBe('$0.07-0.11 x num_images/Run')
|
||||
})
|
||||
|
||||
it('should handle edge case when num_images value is 1 for IdeogramV1', () => {
|
||||
@@ -1228,7 +1228,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.06/Run') // 0.06 * 1 (turbo=false by default)
|
||||
expect(price).toBe('$0.09/Run') // 0.06 * 1 * 1.43 (turbo=false by default)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1435,7 +1435,7 @@ describe('useNodePricing', () => {
|
||||
const node = createMockNode('RunwayTextToImageNode')
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.08/Run')
|
||||
expect(price).toBe('$0.11/Run')
|
||||
})
|
||||
|
||||
it('should calculate dynamic pricing for RunwayImageToVideoNodeGen3a', () => {
|
||||
@@ -1445,7 +1445,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.50/Run') // 0.05 * 10
|
||||
expect(price).toBe('$0.71/Run') // 0.05 * 10 * 1.43
|
||||
})
|
||||
|
||||
it('should return fallback for RunwayImageToVideoNodeGen3a without duration', () => {
|
||||
@@ -1453,7 +1453,7 @@ describe('useNodePricing', () => {
|
||||
const node = createMockNode('RunwayImageToVideoNodeGen3a', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.05/second')
|
||||
expect(price).toBe('$0.0715/second')
|
||||
})
|
||||
|
||||
it('should handle zero duration for RunwayImageToVideoNodeGen3a', () => {
|
||||
@@ -1473,7 +1473,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.25/Run') // Falls back to 5 seconds: 0.05 * 5
|
||||
expect(price).toBe('$0.36/Run') // Falls back to 5 seconds: 0.05 * 5 * 1.43
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1810,8 +1810,8 @@ describe('useNodePricing', () => {
|
||||
// Test edge cases
|
||||
const testCases = [
|
||||
{ duration: 0, expected: '$0.00/Run' }, // Now correctly handles 0 duration
|
||||
{ duration: 1, expected: '$0.05/Run' },
|
||||
{ duration: 30, expected: '$1.50/Run' }
|
||||
{ duration: 1, expected: '$0.07/Run' },
|
||||
{ duration: 30, expected: '$2.15/Run' }
|
||||
]
|
||||
|
||||
testCases.forEach(({ duration, expected }) => {
|
||||
@@ -1828,7 +1828,7 @@ describe('useNodePricing', () => {
|
||||
{ name: 'duration', value: 'invalid-string' }
|
||||
])
|
||||
// When Number('invalid-string') returns NaN, it falls back to 5 seconds
|
||||
expect(getNodeDisplayPrice(node)).toBe('$0.25/Run')
|
||||
expect(getNodeDisplayPrice(node)).toBe('$0.36/Run')
|
||||
})
|
||||
|
||||
it('should handle missing duration widget gracefully', () => {
|
||||
@@ -1841,7 +1841,7 @@ describe('useNodePricing', () => {
|
||||
|
||||
nodes.forEach((nodeType) => {
|
||||
const node = createMockNode(nodeType, [])
|
||||
expect(getNodeDisplayPrice(node)).toBe('$0.05/second')
|
||||
expect(getNodeDisplayPrice(node)).toBe('$0.0715/second')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,31 +1,47 @@
|
||||
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
|
||||
}
|
||||
|
||||
const mockActionsData = {
|
||||
isLoadingSupport: false,
|
||||
refreshTooltip: 'Refreshes on 2024-12-31',
|
||||
handleAddApiCredits: vi.fn(),
|
||||
handleMessageSupport: vi.fn(),
|
||||
handleRefresh: vi.fn(),
|
||||
@@ -50,6 +66,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 +83,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 +99,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 +200,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 +225,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', () => {
|
||||
|
||||
@@ -7,23 +7,6 @@ const mockFetchBalance = vi.fn()
|
||||
const mockFetchStatus = vi.fn()
|
||||
const mockShowTopUpCreditsDialog = vi.fn()
|
||||
const mockExecute = vi.fn()
|
||||
const mockT = vi.fn((key: string, values?: any) => {
|
||||
if (key === 'subscription.nextBillingCycle') return 'next billing cycle'
|
||||
if (key === 'subscription.refreshesOn') {
|
||||
return `Refreshes to $${values?.monthlyCreditBonusUsd} on ${values?.date}`
|
||||
}
|
||||
return key
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: mockT
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
useFirebaseAuthActions: () => ({
|
||||
@@ -31,12 +14,9 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockFormattedRenewalDate = { value: '2024-12-31' }
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
fetchStatus: mockFetchStatus,
|
||||
formattedRenewalDate: mockFormattedRenewalDate
|
||||
fetchStatus: mockFetchStatus
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -62,23 +42,6 @@ Object.defineProperty(window, 'open', {
|
||||
describe('useSubscriptionActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFormattedRenewalDate.value = '2024-12-31'
|
||||
})
|
||||
|
||||
describe('refreshTooltip', () => {
|
||||
it('should format tooltip with renewal date', () => {
|
||||
const { refreshTooltip } = useSubscriptionActions()
|
||||
expect(refreshTooltip.value).toBe('Refreshes to $10 on 2024-12-31')
|
||||
})
|
||||
|
||||
it('should use fallback text when no renewal date', () => {
|
||||
mockFormattedRenewalDate.value = ''
|
||||
const { refreshTooltip } = useSubscriptionActions()
|
||||
expect(refreshTooltip.value).toBe(
|
||||
'Refreshes to $10 on next billing cycle'
|
||||
)
|
||||
expect(mockT).toHaveBeenCalledWith('subscription.nextBillingCycle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleAddApiCredits', () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"lib": [
|
||||
"ES2023",
|
||||
"ES2023.Array",
|
||||
"ESNext.Iterator",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user