Compare commits

..

1 Commits

Author SHA1 Message Date
bymyself
7ce30f2e03 [i18n] Sync missing translations for rh-test cloud features
Update all locale files with missing translations for subscription
and cloud-related features that were backported to rh-test.

Generated translations using @lobehub/i18n-cli with GPT-4 for:
- Arabic, Chinese (Simplified/Traditional), Spanish, French
- Japanese, Korean, Russian, Turkish

Fixes missing i18n keys that existed in main but not rh-test.
2025-10-29 09:27:06 -07:00
56 changed files with 976 additions and 2368 deletions

View File

@@ -5,7 +5,7 @@ import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescrip
import { importX } from 'eslint-plugin-import-x'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import storybook from 'eslint-plugin-storybook'
// import tailwind from 'eslint-plugin-tailwindcss'
import tailwind from 'eslint-plugin-tailwindcss'
import unusedImports from 'eslint-plugin-unused-imports'
import pluginVue from 'eslint-plugin-vue'
import { defineConfig } from 'eslint/config'
@@ -34,11 +34,11 @@ const settings = {
],
noWarnOnMultipleProjects: true
})
]
// tailwindcss: {
// config: `${import.meta.dirname}/packages/design-system/src/css/style.css`,
// functions: ['cn', 'clsx', 'tw']
// }
],
tailwindcss: {
config: `${import.meta.dirname}/packages/design-system/src/css/style.css`,
functions: ['cn', 'clsx', 'tw']
}
} as const
const commonParserOptions = {
@@ -97,7 +97,7 @@ export default defineConfig([
// Difference in typecheck on CI vs Local
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Bad types in the plugin
// tailwind.configs['flat/recommended'],
tailwind.configs['flat/recommended'],
pluginVue.configs['flat/recommended'],
eslintPluginPrettierRecommended,
storybook.configs['flat/recommended'],
@@ -129,7 +129,7 @@ export default defineConfig([
'import-x/no-relative-packages': 'error',
'unused-imports/no-unused-imports': 'error',
'no-console': ['error', { allow: ['warn', 'error'] }],
// 'tailwindcss/no-custom-classname': 'off', // TODO: fix
'tailwindcss/no-custom-classname': 'off', // TODO: fix
'vue/no-v-html': 'off',
// Enforce dark-theme: instead of dark: prefix
'vue/no-restricted-class': ['error', '/^dark:/'],

1
global.d.ts vendored
View File

@@ -8,7 +8,6 @@ declare const __USE_PROD_CONFIG__: boolean
interface Window {
__CONFIG__: {
mixpanel_token?: string
require_whitelist?: boolean
subscription_required?: boolean
server_health_alert?: {
message: string

View File

@@ -52,12 +52,12 @@
"@nx/vite": "catalog:",
"@pinia/testing": "catalog:",
"@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@storybook/addon-docs": "catalog:",
"@storybook/vue3": "catalog:",
"@storybook/vue3-vite": "catalog:",
"@tailwindcss/vite": "catalog:",
"@trivago/prettier-plugin-sort-imports": "catalog:",
"@types/eslint-plugin-tailwindcss": "catalog:",
"@types/fs-extra": "catalog:",
"@types/jsdom": "catalog:",
"@types/node": "catalog:",
@@ -74,6 +74,7 @@
"eslint-plugin-import-x": "catalog:",
"eslint-plugin-prettier": "catalog:",
"eslint-plugin-storybook": "catalog:",
"eslint-plugin-tailwindcss": "catalog:",
"eslint-plugin-unused-imports": "catalog:",
"eslint-plugin-vue": "catalog:",
"fs-extra": "^11.2.0",

View File

@@ -126,13 +126,6 @@
--content-hover-bg: #adadad;
--content-hover-fg: #000;
--button-surface: var(--color-white);
--button-surface-contrast: var(--color-black);
--subscription-button-gradient: linear-gradient(315deg, rgb(105 230 255 / 0.15) 0%, rgb(99 73 233 / 0.50) 100%), radial-gradient(70.71% 70.71% at 50% 50%, rgb(62 99 222 / 0.15) 0.01%, rgb(66 0 123 / 0.50) 100%), linear-gradient(92deg, #D000FF 0.38%, #B009FE 37.07%, #3E1FFC 65.17%, #009DFF 103.86%), var(--color-button-surface, #2D2E32);
--modal-card-button-surface: var(--color-smoke-300);
/* Code styling colors for help menu*/
--code-text-color: rgb(0 122 255 / 1);
--code-bg-color: rgb(96 165 250 / 0.2);
@@ -175,17 +168,6 @@
.dark-theme {
--accent-primary: var(--color-pure-white);
--backdrop: var(--color-neutral-900);
--button-surface: var(--color-charcoal-600);
--button-surface-contrast: var(--color-white);
--button-hover-surface: var(--color-charcoal-600);
--button-active-surface: var(--color-charcoal-600);
--button-icon: var(--color-smoke-800);
--subscription-button-gradient: linear-gradient(315deg, rgb(105 230 255 / 0.15) 0%, rgb(99 73 233 / 0.50) 100%), radial-gradient(70.71% 70.71% at 50% 50%, rgb(62 99 222 / 0.15) 0.01%, rgb(66 0 123 / 0.50) 100%), linear-gradient(92deg, #D000FF 0.38%, #B009FE 37.07%, #3E1FFC 65.17%, #009DFF 103.86%), var(--color-button-surface, #2D2E32);
--modal-card-button-surface: var(--color-charcoal-300);
--dialog-surface: var(--color-neutral-700);
--node-component-border: var(--color-stone-200);
--node-component-border-error: var(--color-danger-100);
@@ -214,13 +196,6 @@
@theme inline {
--color-backdrop: var(--backdrop);
--color-button-active-surface: var(--button-active-surface);
--color-button-hover-surface: var(--button-hover-surface);
--color-button-icon: var(--button-icon);
--color-button-surface: var(--button-surface);
--color-button-surface-contrast: var(--button-surface-contrast);
--color-subscription-button-gradient: var(--subscription-button-gradient);
--color-modal-card-button-surface: var(--modal-card-button-surface);
--color-dialog-surface: var(--dialog-surface);
--color-node-component-border: var(--node-component-border);
--color-node-component-executing: var(--node-component-executing);

285
pnpm-lock.yaml generated
View File

@@ -66,9 +66,6 @@ catalogs:
'@primevue/themes':
specifier: ^4.2.5
version: 4.2.5
'@sentry/vite-plugin':
specifier: ^4.6.0
version: 4.6.0
'@sentry/vue':
specifier: ^8.48.0
version: 8.48.0
@@ -87,6 +84,9 @@ catalogs:
'@trivago/prettier-plugin-sort-imports':
specifier: ^5.2.0
version: 5.2.2
'@types/eslint-plugin-tailwindcss':
specifier: ^3.17.0
version: 3.17.0
'@types/fs-extra':
specifier: ^11.0.4
version: 11.0.4
@@ -150,6 +150,9 @@ catalogs:
eslint-plugin-storybook:
specifier: ^9.1.6
version: 9.1.6
eslint-plugin-tailwindcss:
specifier: 4.0.0-beta.0
version: 4.0.0-beta.0
eslint-plugin-unused-imports:
specifier: ^4.2.0
version: 4.2.0
@@ -282,7 +285,6 @@ catalogs:
overrides:
'@types/eslint': '-'
'@eslint/core': 0.17.0
importers:
@@ -484,9 +486,6 @@ importers:
'@playwright/test':
specifier: 'catalog:'
version: 1.52.0
'@sentry/vite-plugin':
specifier: 'catalog:'
version: 4.6.0
'@storybook/addon-docs':
specifier: 'catalog:'
version: 9.1.1(@types/react@19.1.9)(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))
@@ -502,6 +501,9 @@ importers:
'@trivago/prettier-plugin-sort-imports':
specifier: 'catalog:'
version: 5.2.2(@vue/compiler-sfc@3.5.13)(prettier@3.6.2)
'@types/eslint-plugin-tailwindcss':
specifier: 'catalog:'
version: 3.17.0
'@types/fs-extra':
specifier: 'catalog:'
version: 11.0.4
@@ -550,6 +552,9 @@ importers:
eslint-plugin-storybook:
specifier: 'catalog:'
version: 9.1.6(eslint@9.35.0(jiti@2.4.2))(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))(typescript@5.9.2)
eslint-plugin-tailwindcss:
specifier: 'catalog:'
version: 4.0.0-beta.0(tailwindcss@4.1.12)
eslint-plugin-unused-imports:
specifier: 'catalog:'
version: 4.2.0(@typescript-eslint/eslint-plugin@8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.35.0(jiti@2.4.2))
@@ -1768,8 +1773,8 @@ packages:
resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/core@0.17.0':
resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
'@eslint/core@0.15.2':
resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/eslintrc@3.3.1':
@@ -2653,78 +2658,14 @@ packages:
resolution: {integrity: sha512-csILVupc5RkrsTrncuUTGmlB56FQSFjXPYWG8I8yBTGlXEJ+o8oTuF6+55R4vbw3EIzBveXWi4kEBbnQlXW/eg==}
engines: {node: '>=14.18'}
'@sentry/babel-plugin-component-annotate@4.6.0':
resolution: {integrity: sha512-3soTX50JPQQ51FSbb4qvNBf4z/yP7jTdn43vMTp9E4IxvJ9HKJR7OEuKkCMszrZmWsVABXl02msqO7QisePdiQ==}
engines: {node: '>= 14'}
'@sentry/browser@8.48.0':
resolution: {integrity: sha512-fuuVULB5/1vI8NoIwXwR3xwhJJqk+y4RdSdajExGF7nnUDBpwUJyXsmYJnOkBO+oLeEs58xaCpotCKiPUNnE3g==}
engines: {node: '>=14.18'}
'@sentry/bundler-plugin-core@4.6.0':
resolution: {integrity: sha512-Fub2XQqrS258jjS8qAxLLU1k1h5UCNJ76i8m4qZJJdogWWaF8t00KnnTyp9TEDJzrVD64tRXS8+HHENxmeUo3g==}
engines: {node: '>= 14'}
'@sentry/cli-darwin@2.57.0':
resolution: {integrity: sha512-v1wYQU3BcCO+Z3OVxxO+EnaW4oQhuOza6CXeYZ0z5ftza9r0QQBLz3bcZKTVta86xraNm0z8GDlREwinyddOxQ==}
engines: {node: '>=10'}
os: [darwin]
'@sentry/cli-linux-arm64@2.57.0':
resolution: {integrity: sha512-Kh1jTsMV5Fy/RvB381N/woXe1qclRMqsG6kM3Gq6m6afEF/+k3PyQdNW3HXAola6d63EptokLtxPG2xjWQ+w9Q==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux, freebsd, android]
'@sentry/cli-linux-arm@2.57.0':
resolution: {integrity: sha512-uNHB8xyygqfMd1/6tFzl9NUkuVefg7jdZtM/vVCQVaF/rJLWZ++Wms+LLhYyKXKN8yd7J9wy7kTEl4Qu4jWbGQ==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux, freebsd, android]
'@sentry/cli-linux-i686@2.57.0':
resolution: {integrity: sha512-EYXghoK/tKd0zqz+KD/ewXXE3u1HLCwG89krweveytBy/qw7M5z58eFvw+iGb1Vnbl1f/fRD0G4E0AbEsPfmpg==}
engines: {node: '>=10'}
cpu: [x86, ia32]
os: [linux, freebsd, android]
'@sentry/cli-linux-x64@2.57.0':
resolution: {integrity: sha512-CyZrP/ssHmAPLSzfd4ydy7icDnwmDD6o3QjhkWwVFmCd+9slSBMQxpIqpamZmrWE6X4R+xBRbSUjmdoJoZ5yMw==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux, freebsd, android]
'@sentry/cli-win32-arm64@2.57.0':
resolution: {integrity: sha512-wji/GGE4Lh5I/dNCsuVbg6fRvttvZRG6db1yPW1BSvQRh8DdnVy1CVp+HMqSq0SRy/S4z60j2u+m4yXMoCL+5g==}
engines: {node: '>=10'}
cpu: [arm64]
os: [win32]
'@sentry/cli-win32-i686@2.57.0':
resolution: {integrity: sha512-hWvzyD7bTPh3b55qvJ1Okg3Wbl0Km8xcL6KvS7gfBl6uss+I6RldmQTP0gJKdHSdf/QlJN1FK0b7bLnCB3wHsg==}
engines: {node: '>=10'}
cpu: [x86, ia32]
os: [win32]
'@sentry/cli-win32-x64@2.57.0':
resolution: {integrity: sha512-QWYV/Y0sbpDSTyA4XQBOTaid4a6H2Iwa1Z8UI+qNxFlk0ADSEgIqo2NrRHDU8iRnghTkecQNX1NTt/7mXN3f/A==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
'@sentry/cli@2.57.0':
resolution: {integrity: sha512-oC4HPrVIX06GvUTgK0i+WbNgIA9Zl5YEcwf9N4eWFJJmjonr2j4SML9Hn2yNENbUWDgwepy4MLod3P8rM4bk/w==}
engines: {node: '>= 10'}
hasBin: true
'@sentry/core@8.48.0':
resolution: {integrity: sha512-VGwYgTfLpvJ5LRO5A+qWo1gpo6SfqaGXL9TOzVgBucAdpzbrYHpZ87sEarDVq/4275uk1b0S293/mfsskFczyw==}
engines: {node: '>=14.18'}
'@sentry/vite-plugin@4.6.0':
resolution: {integrity: sha512-fMR2d+EHwbzBa0S1fp45SNUTProxmyFBp+DeBWWQOSP9IU6AH6ea2rqrpMAnp/skkcdW4z4LSRrOEpMZ5rWXLw==}
engines: {node: '>= 14'}
'@sentry/vue@8.48.0':
resolution: {integrity: sha512-hqm9X7hz1vMQQB1HBYezrDBQihZk6e/MxWIG1wMJoClcBnD1Sh7y+D36UwaQlR4Gr/Ftiz+Bb0DxuAYHoUS4ow==}
engines: {node: '>=14.18'}
@@ -3088,6 +3029,9 @@ packages:
'@types/diff-match-patch@1.0.36':
resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==}
'@types/eslint-plugin-tailwindcss@3.17.0':
resolution: {integrity: sha512-ucQGf2YIdTcndYcxRU3UdZgmhUHsOlbIF4BaRtl0op+7k2JmqM2i3aXZ6XIcfZgVq1ZKov7VM5c/BR81ukmkyg==}
'@types/estree@1.0.5':
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
@@ -3631,10 +3575,6 @@ packages:
resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==}
engines: {node: '>= 10.0.0'}
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
@@ -4634,6 +4574,12 @@ packages:
eslint: '>=8'
storybook: ^9.1.6
eslint-plugin-tailwindcss@4.0.0-beta.0:
resolution: {integrity: sha512-WWCajZgQu38Sd67ZCl2W6i3MRzqB0d+H8s4qV9iB6lBJbsDOIpIlj6R1Fj2FXkoWErbo05pZnZYbCGIU9o/DsA==}
engines: {node: '>=18.12.0'}
peerDependencies:
tailwindcss: ^3.4.0 || ^4.0.0
eslint-plugin-unused-imports@4.2.0:
resolution: {integrity: sha512-hLbJ2/wnjKq4kGA9AUaExVFIbNzyxYdVo49QZmKCnhk5pc9wcYRbfgLHvWJ8tnsdcseGhoUAddm9gn/lt+d74w==}
peerDependencies:
@@ -4908,9 +4854,6 @@ packages:
resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
engines: {node: '>=14.14'}
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -4991,10 +4934,6 @@ packages:
engines: {node: 20 || >=22}
hasBin: true
glob@9.3.5:
resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==}
engines: {node: '>=16 || 14 >=14.17'}
global-directory@4.0.1:
resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}
engines: {node: '>=18'}
@@ -5124,10 +5063,6 @@ packages:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
@@ -5813,10 +5748,6 @@ packages:
magic-string@0.30.19:
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
magic-string@0.30.8:
resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==}
engines: {node: '>=12'}
magicast@0.3.5:
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
@@ -6037,10 +5968,6 @@ packages:
resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==}
engines: {node: '>=10'}
minimatch@8.0.4:
resolution: {integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==}
engines: {node: '>=16 || 14 >=14.17'}
minimatch@9.0.1:
resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -6056,10 +5983,6 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@4.2.8:
resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==}
engines: {node: '>=8'}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -6491,10 +6414,6 @@ packages:
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
promise@7.3.1:
resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==}
@@ -7093,6 +7012,11 @@ packages:
resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==}
engines: {node: '>=10.0.0'}
tailwind-api-utils@1.0.3:
resolution: {integrity: sha512-KpzUHkH1ug1sq4394SLJX38ZtpeTiqQ1RVyFTTSY2XuHsNSTWUkRo108KmyyrMWdDbQrLYkSHaNKj/a3bmA4sQ==}
peerDependencies:
tailwindcss: ^3.3.0 || ^4.0.0 || ^4.0.0-beta
tailwind-merge@2.6.0:
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
@@ -7377,9 +7301,6 @@ packages:
'@nuxt/kit':
optional: true
unplugin@1.0.1:
resolution: {integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==}
unplugin@1.16.1:
resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==}
engines: {node: '>=14.0.0'}
@@ -7554,9 +7475,6 @@ packages:
vue-component-type-helpers@3.1.1:
resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==}
vue-component-type-helpers@3.1.2:
resolution: {integrity: sha512-ch3/SKBtxdZq18vsEntiGCdSszCRNfhX5QaTxjSacCAXLlNQRXfXo+ANjoQEYJMsJOJy1/vHF6Tkc4s85MS+zw==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@@ -7653,13 +7571,6 @@ packages:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'}
webpack-sources@3.3.3:
resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==}
engines: {node: '>=10.13.0'}
webpack-virtual-modules@0.5.0:
resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==}
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
@@ -8940,7 +8851,7 @@ snapshots:
'@eslint/config-helpers@0.3.1': {}
'@eslint/core@0.17.0':
'@eslint/core@0.15.2':
dependencies:
'@types/json-schema': 7.0.15
@@ -8964,7 +8875,7 @@ snapshots:
'@eslint/plugin-kit@0.3.5':
dependencies:
'@eslint/core': 0.17.0
'@eslint/core': 0.15.2
levn: 0.4.1
'@firebase/analytics-compat@0.2.18(@firebase/app-compat@0.2.53)(@firebase/app@0.11.4)':
@@ -10113,8 +10024,6 @@ snapshots:
'@sentry-internal/browser-utils': 8.48.0
'@sentry/core': 8.48.0
'@sentry/babel-plugin-component-annotate@4.6.0': {}
'@sentry/browser@8.48.0':
dependencies:
'@sentry-internal/browser-utils': 8.48.0
@@ -10123,74 +10032,8 @@ snapshots:
'@sentry-internal/replay-canvas': 8.48.0
'@sentry/core': 8.48.0
'@sentry/bundler-plugin-core@4.6.0':
dependencies:
'@babel/core': 7.27.1
'@sentry/babel-plugin-component-annotate': 4.6.0
'@sentry/cli': 2.57.0
dotenv: 16.6.1
find-up: 5.0.0
glob: 9.3.5
magic-string: 0.30.8
unplugin: 1.0.1
transitivePeerDependencies:
- encoding
- supports-color
'@sentry/cli-darwin@2.57.0':
optional: true
'@sentry/cli-linux-arm64@2.57.0':
optional: true
'@sentry/cli-linux-arm@2.57.0':
optional: true
'@sentry/cli-linux-i686@2.57.0':
optional: true
'@sentry/cli-linux-x64@2.57.0':
optional: true
'@sentry/cli-win32-arm64@2.57.0':
optional: true
'@sentry/cli-win32-i686@2.57.0':
optional: true
'@sentry/cli-win32-x64@2.57.0':
optional: true
'@sentry/cli@2.57.0':
dependencies:
https-proxy-agent: 5.0.1
node-fetch: 2.7.0
progress: 2.0.3
proxy-from-env: 1.1.0
which: 2.0.2
optionalDependencies:
'@sentry/cli-darwin': 2.57.0
'@sentry/cli-linux-arm': 2.57.0
'@sentry/cli-linux-arm64': 2.57.0
'@sentry/cli-linux-i686': 2.57.0
'@sentry/cli-linux-x64': 2.57.0
'@sentry/cli-win32-arm64': 2.57.0
'@sentry/cli-win32-i686': 2.57.0
'@sentry/cli-win32-x64': 2.57.0
transitivePeerDependencies:
- encoding
- supports-color
'@sentry/core@8.48.0': {}
'@sentry/vite-plugin@4.6.0':
dependencies:
'@sentry/bundler-plugin-core': 4.6.0
unplugin: 1.0.1
transitivePeerDependencies:
- encoding
- supports-color
'@sentry/vue@8.48.0(pinia@2.2.2(typescript@5.9.2)(vue@3.5.13(typescript@5.9.2)))(vue@3.5.13(typescript@5.9.2))':
dependencies:
'@sentry/browser': 8.48.0
@@ -10261,7 +10104,7 @@ snapshots:
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2)
vue-component-type-helpers: 3.1.2
vue-component-type-helpers: 3.1.1
'@swc/helpers@0.5.17':
dependencies:
@@ -10566,6 +10409,8 @@ snapshots:
'@types/diff-match-patch@1.0.36': {}
'@types/eslint-plugin-tailwindcss@3.17.0': {}
'@types/estree@1.0.5': {}
'@types/estree@1.0.8': {}
@@ -11190,12 +11035,6 @@ snapshots:
address@1.2.2: {}
agent-base@6.0.2:
dependencies:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
agent-base@7.1.4: {}
agentkeepalive@4.6.0:
@@ -12337,6 +12176,14 @@ snapshots:
- supports-color
- typescript
eslint-plugin-tailwindcss@4.0.0-beta.0(tailwindcss@4.1.12):
dependencies:
fast-glob: 3.3.3
postcss: 8.5.6
synckit: 0.11.11
tailwind-api-utils: 1.0.3(tailwindcss@4.1.12)
tailwindcss: 4.1.12
eslint-plugin-unused-imports@4.2.0(@typescript-eslint/eslint-plugin@8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.35.0(jiti@2.4.2)):
dependencies:
eslint: 9.35.0(jiti@2.4.2)
@@ -12371,7 +12218,7 @@ snapshots:
'@eslint-community/regexpp': 4.12.1
'@eslint/config-array': 0.21.0
'@eslint/config-helpers': 0.3.1
'@eslint/core': 0.17.0
'@eslint/core': 0.15.2
'@eslint/eslintrc': 3.3.1
'@eslint/js': 9.35.0
'@eslint/plugin-kit': 0.3.5
@@ -12695,8 +12542,6 @@ snapshots:
jsonfile: 6.2.0
universalify: 2.0.1
fs.realpath@1.0.0: {}
fsevents@2.3.2:
optional: true
@@ -12791,13 +12636,6 @@ snapshots:
package-json-from-dist: 1.0.0
path-scurry: 2.0.0
glob@9.3.5:
dependencies:
fs.realpath: 1.0.0
minimatch: 8.0.4
minipass: 4.2.8
path-scurry: 1.11.1
global-directory@4.0.1:
dependencies:
ini: 4.1.1
@@ -12928,13 +12766,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.3
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
@@ -13624,10 +13455,6 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magic-string@0.30.8:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.3.5:
dependencies:
'@babel/parser': 7.28.4
@@ -14033,10 +13860,6 @@ snapshots:
dependencies:
brace-expansion: 2.0.2
minimatch@8.0.4:
dependencies:
brace-expansion: 2.0.2
minimatch@9.0.1:
dependencies:
brace-expansion: 2.0.2
@@ -14051,8 +13874,6 @@ snapshots:
minimist@1.2.8: {}
minipass@4.2.8: {}
minipass@7.1.2: {}
minizlib@3.0.2:
@@ -14536,8 +14357,6 @@ snapshots:
process-nextick-args@2.0.1: {}
progress@2.0.3: {}
promise@7.3.1:
dependencies:
asap: 2.0.6
@@ -15375,6 +15194,13 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
tailwind-api-utils@1.0.3(tailwindcss@4.1.12):
dependencies:
enhanced-resolve: 5.18.3
jiti: 2.5.1
local-pkg: 1.1.2
tailwindcss: 4.1.12
tailwind-merge@2.6.0: {}
tailwindcss-primeui@0.6.1(tailwindcss@4.1.12):
@@ -15666,13 +15492,6 @@ snapshots:
- rollup
- supports-color
unplugin@1.0.1:
dependencies:
acorn: 8.15.0
chokidar: 3.6.0
webpack-sources: 3.3.3
webpack-virtual-modules: 0.5.0
unplugin@1.16.1:
dependencies:
acorn: 8.15.0
@@ -15927,8 +15746,6 @@ snapshots:
vue-component-type-helpers@3.1.1: {}
vue-component-type-helpers@3.1.2: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies:
vue: 3.5.13(typescript@5.9.2)
@@ -16021,10 +15838,6 @@ snapshots:
webidl-conversions@7.0.0: {}
webpack-sources@3.3.3: {}
webpack-virtual-modules@0.5.0: {}
webpack-virtual-modules@0.6.2: {}
websocket-driver@0.7.4:

View File

@@ -23,13 +23,13 @@ catalog:
'@primevue/forms': ^4.2.5
'@primevue/icons': 4.2.5
'@primevue/themes': ^4.2.5
'@sentry/vite-plugin': ^4.6.0
'@sentry/vue': ^8.48.0
'@storybook/addon-docs': ^9.1.1
'@storybook/vue3': ^9.1.1
'@storybook/vue3-vite': ^9.1.1
'@tailwindcss/vite': ^4.1.12
'@trivago/prettier-plugin-sort-imports': ^5.2.0
'@types/eslint-plugin-tailwindcss': ^3.17.0
'@types/fs-extra': ^11.0.4
'@types/jsdom': ^21.1.7
'@types/node': ^20.14.8
@@ -51,6 +51,7 @@ catalog:
eslint-plugin-import-x: ^4.16.1
eslint-plugin-prettier: ^5.5.4
eslint-plugin-storybook: ^9.1.6
eslint-plugin-tailwindcss: 4.0.0-beta.0
eslint-plugin-unused-imports: ^4.2.0
eslint-plugin-vue: ^10.4.0
firebase: ^11.6.0
@@ -61,7 +62,6 @@ catalog:
jsdom: ^26.1.0
knip: ^5.62.0
lint-staged: ^15.2.7
mixpanel-browser: ^2.71.0
nx: 21.4.1
pinia: ^2.1.7
postcss-html: ^1.8.0
@@ -94,9 +94,13 @@ catalog:
zod: ^3.23.8
zod-to-json-schema: ^3.24.1
zod-validation-error: ^3.3.0
mixpanel-browser: ^2.71.0
cleanupUnusedCatalogs: true
overrides:
'@types/eslint': '-'
ignoredBuiltDependencies:
- '@firebase/util'
- protobufjs
@@ -111,7 +115,3 @@ onlyBuiltDependencies:
- esbuild
- nx
- oxc-resolver
overrides:
'@eslint/core': 0.17.0
'@types/eslint': '-'

View File

@@ -380,7 +380,7 @@
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { computed, onBeforeUnmount, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
@@ -401,8 +401,6 @@ import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
@@ -412,34 +410,10 @@ import { createGridStyle } from '@/utils/gridUtil'
const { t } = useI18n()
const { onClose: originalOnClose } = defineProps<{
const { onClose } = defineProps<{
onClose: () => void
}>()
// Track session time for telemetry
const sessionStartTime = ref<number>(0)
const templateWasSelected = ref(false)
onMounted(() => {
sessionStartTime.value = Date.now()
})
// Wrap onClose to track session end
const onClose = () => {
if (isCloud) {
const timeSpentSeconds = Math.floor(
(Date.now() - sessionStartTime.value) / 1000
)
useTelemetry()?.trackTemplateLibraryClosed({
template_selected: templateWasSelected.value,
time_spent_seconds: timeSpentSeconds
})
}
originalOnClose()
}
provide(OnCloseKey, onClose)
// Workflow templates store and composable
@@ -724,7 +698,6 @@ const onLoadWorkflow = async (template: any) => {
template.name,
getEffectiveSourceModule(template)
)
templateWasSelected.value = true
onClose()
} finally {
loadingTemplate.value = null

View File

@@ -101,14 +101,12 @@ import {
EventType,
useCustomerEventsService
} from '@/services/customerEventsService'
import { useTopupTrackerStore } from '@/stores/topupTrackerStore'
const events = ref<AuditLog[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const customerEventService = useCustomerEventsService()
const topupTracker = useTopupTrackerStore()
const pagination = ref({
page: 1,
@@ -161,8 +159,6 @@ const loadEvents = async () => {
if (response.totalPages) {
pagination.value.totalPages = response.totalPages
}
void topupTracker.reconcileWithEvents(response.events)
} else {
error.value = customerEventService.error.value || 'Failed to load events'
}

View File

@@ -12,18 +12,19 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import SidebarIcon from './SidebarIcon.vue'
const settingStore = useSettingStore()
const commandStore = useCommandStore()
const isSmall = computed(
() => settingStore.get('Comfy.Sidebar.Size') === 'small'
)
const openTemplates = () => {
useWorkflowTemplateSelectorDialog().show('sidebar')
void commandStore.execute('Comfy.BrowseTemplates')
}
</script>

View File

@@ -80,7 +80,6 @@ import { useI18n } from 'vue-i18n'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useColorPaletteService } from '@/services/colorPaletteService'
@@ -169,7 +168,7 @@ const extraMenuItems = computed(() => [
key: 'browse-templates',
label: t('menuLabels.Browse Templates'),
icon: 'icon-[comfy--template]',
command: () => useWorkflowTemplateSelectorDialog().show('menu')
command: () => commandStore.execute('Comfy.BrowseTemplates')
},
{
key: 'settings',

View File

@@ -79,11 +79,9 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
// Mock the useSubscription composable
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: vi.fn(() => ({
isActiveSubscription: { value: true },
fetchStatus: mockFetchStatus
isActiveSubscription: vi.fn().mockReturnValue(true)
}))
}))
@@ -107,15 +105,6 @@ vi.mock('@/components/common/UserCredit.vue', () => ({
}
}))
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
default: {
name: 'SubscribeButtonMock',
render() {
return h('div', 'Subscribe Button')
}
}
}))
describe('CurrentUserPopover', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -148,9 +137,9 @@ describe('CurrentUserPopover', () => {
it('renders logout button with correct props', () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (last button)
// Find all buttons and get the logout button (second one)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[4]
const logoutButton = buttons[1]
// Check that logout button has correct props
expect(logoutButton.props('label')).toBe('Log Out')
@@ -160,9 +149,9 @@ describe('CurrentUserPopover', () => {
it('opens user settings and emits close event when settings button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the settings button (third button)
// Find all buttons and get the settings button (first one)
const buttons = wrapper.findAllComponents(Button)
const settingsButton = buttons[2]
const settingsButton = buttons[0]
// Click the settings button
await settingsButton.trigger('click')
@@ -178,9 +167,9 @@ describe('CurrentUserPopover', () => {
it('calls logout function and emits close event when logout button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (last button)
// Find all buttons and get the logout button (second one)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[4]
const logoutButton = buttons[1]
// Click the logout button
await logoutButton.trigger('click')
@@ -196,16 +185,16 @@ describe('CurrentUserPopover', () => {
it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the Partner Nodes info button (first one)
// Find all buttons and get the API pricing button (third one now)
const buttons = wrapper.findAllComponents(Button)
const partnerNodesButton = buttons[0]
const apiPricingButton = buttons[2]
// Click the Partner Nodes button
await partnerNodesButton.trigger('click')
// Click the API pricing button
await apiPricingButton.trigger('click')
// Verify window.open was called with the correct URL
expect(window.open).toHaveBeenCalledWith(
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
'https://docs.comfy.org/tutorials/api-nodes/pricing',
'_blank'
)
@@ -217,9 +206,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)
// Find all buttons and get the top-up button (last one)
const buttons = wrapper.findAllComponents(Button)
const topUpButton = buttons[1]
const topUpButton = buttons[buttons.length - 1]
// Click the top-up button
await topUpButton.trigger('click')

View File

@@ -23,38 +23,6 @@
</div>
</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>
<Button
:label="$t('credits.topUp.topUp')"
severity="secondary"
size="small"
@click="handleTopUp"
/>
</div>
<SubscribeButton
v-else
:label="$t('subscription.subscribeToComfyCloud')"
size="small"
variant="gradient"
@subscribed="handleSubscribed"
/>
<Divider class="my-2" />
<Button
@@ -67,17 +35,6 @@
@click="handleOpenUserSettings"
/>
<Button
v-if="isActiveSubscription"
class="justify-start"
:label="$t(planSettingsLabel)"
icon="pi pi-receipt"
text
fluid
severity="secondary"
@click="handleOpenPlanAndCreditsSettings"
/>
<Divider class="my-2" />
<Button
@@ -89,6 +46,34 @@
severity="secondary"
@click="handleLogout"
/>
<Divider class="my-2" />
<Button
class="justify-start"
:label="$t('credits.apiPricing')"
icon="pi pi-external-link"
text
fluid
severity="secondary"
@click="handleOpenApiPricing"
/>
<Divider class="my-2" />
<div class="flex w-full flex-col gap-2 p-2">
<div class="text-sm text-muted">
{{ $t('credits.yourCreditBalance') }}
</div>
<div class="flex items-center justify-between">
<UserCredit text-class="text-2xl" />
<Button
v-if="isActiveSubscription"
:label="$t('credits.topUp.topUp')"
@click="handleTopUp"
/>
</div>
</div>
</div>
</template>
@@ -101,60 +86,37 @@ 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 SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useDialogService } from '@/services/dialogService'
const emit = defineEmits<{
close: []
}>()
const planSettingsLabel = isCloud
? 'settingsCategories.PlanCredits'
: 'settingsCategories.Credits'
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
const { isActiveSubscription, fetchStatus } = useSubscription()
const { isActiveSubscription } = useSubscription()
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
emit('close')
}
const handleOpenPlanAndCreditsSettings = () => {
if (isCloud) {
dialogService.showSettingsDialog('subscription')
} else {
dialogService.showSettingsDialog('credits')
}
emit('close')
}
const handleTopUp = () => {
dialogService.showTopUpCreditsDialog()
emit('close')
}
const handleOpenPartnerNodesInfo = () => {
window.open(
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
'_blank'
)
emit('close')
}
const handleLogout = async () => {
await handleSignOut()
emit('close')
}
const handleSubscribed = async () => {
await fetchStatus()
const handleOpenApiPricing = () => {
window.open('https://docs.comfy.org/tutorials/api-nodes/pricing', '_blank')
emit('close')
}
onMounted(() => {

View File

@@ -37,7 +37,7 @@
>
{{ badge.label }}
</div>
<div class="text-sm font-inter">{{ badge.text }}</div>
<div class="text-sm font-semibold">{{ badge.text }}</div>
<div v-if="badge.tooltip" class="text-xs">
{{ badge.tooltip }}
</div>
@@ -90,7 +90,7 @@
>
{{ badge.label }}
</div>
<div class="text-sm font-inter">{{ badge.text }}</div>
<div class="text-sm font-semibold">{{ badge.text }}</div>
<div v-if="badge.tooltip" class="text-xs">
{{ badge.tooltip }}
</div>
@@ -117,7 +117,7 @@
>
{{ badge.label }}
</div>
<div class="font-inter text-sm" :class="textClasses">
<div class="font-inter text-sm font-extrabold" :class="textClasses">
{{ badge.text }}
</div>
</div>
@@ -177,7 +177,7 @@ const textClasses = computed(() => {
return 'text-warning-100'
case 'info':
default:
return 'text-text-primary'
return 'text-slate-100'
}
})

View File

@@ -1,12 +1,11 @@
import { FirebaseError } from 'firebase/app'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useTopupTrackerStore } from '@/stores/topupTrackerStore'
import { usdToMicros } from '@/utils/formatUtil'
/**
@@ -18,6 +17,7 @@ export const useFirebaseAuthActions = () => {
const authStore = useFirebaseAuthStore()
const toastStore = useToastStore()
const route = useRoute()
const router = useRouter()
const { wrapWithErrorHandlingAsync, toastErrorHandler } = useErrorHandling()
const accessError = ref(false)
@@ -55,19 +55,14 @@ export const useFirebaseAuthActions = () => {
life: 5000
})
// CRITICAL: Use full page navigation for logout to prevent stale app state
// Issue: SPA routing during logout can leave extensions loaded with stale auth state
// This causes subscription dialogs to appear incorrectly during re-login onboarding
// Full page reload ensures complete app state reset and proper onboarding flow
// Redirect to login page if we're on cloud domain
const hostname = window.location.hostname
if (hostname.includes('cloud.comfy.org')) {
if (route.query.inviteCode) {
const inviteCode = Array.isArray(route.query.inviteCode)
? route.query.inviteCode[0]
: route.query.inviteCode
window.location.href = `/cloud/login?inviteCode=${encodeURIComponent(inviteCode || '')}`
const inviteCode = route.query.inviteCode
await router.push({ name: 'cloud-login', query: { inviteCode } })
} else {
window.location.href = '/cloud/login'
await router.push({ name: 'cloud-login' })
}
}
}, reportError)
@@ -99,7 +94,7 @@ export const useFirebaseAuthActions = () => {
)
}
useTopupTrackerStore().startTopup(amount)
// Go to Stripe checkout page
window.open(response.checkout_url, '_blank')
}, reportError)
@@ -116,9 +111,7 @@ export const useFirebaseAuthActions = () => {
}, reportError)
const fetchBalance = wrapWithErrorHandlingAsync(async () => {
const result = await authStore.fetchBalance()
void useTopupTrackerStore().reconcileByFetchingEvents()
return result
return await authStore.fetchBalance()
}, reportError)
const signInWithGoogle = (errorHandler = reportError) =>

View File

@@ -1,5 +1,4 @@
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -13,9 +12,7 @@ export const useWorkflowTemplateSelectorDialog = () => {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show(source: 'sidebar' | 'menu' | 'command' = 'command') {
useTelemetry()?.trackTemplateLibraryOpened({ source })
function show() {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: WorkflowTemplateSelectorDialog,

View File

@@ -131,6 +131,38 @@
"errorNotSupported": "API الحافظة غير مدعوم في متصفحك",
"successMessage": "تم النسخ إلى الحافظة"
},
"cloudClaimInvite_claimButton": "استلام الدعوة",
"cloudClaimInvite_processingTitle": "معالجة رمز الدعوة...",
"cloudFooter_needHelp": "تحتاج مساعدة؟",
"cloudForgotPassword_backToLogin": "العودة لتسجيل الدخول",
"cloudForgotPassword_didntReceiveEmail": "لم تستلم بريداً إلكترونياً؟",
"cloudForgotPassword_emailLabel": "البريد الإلكتروني",
"cloudForgotPassword_emailPlaceholder": "أدخل بريدك الإلكتروني",
"cloudForgotPassword_emailRequired": "البريد الإلكتروني حقل إجباري",
"cloudForgotPassword_instructions": "أدخل عنوان بريدك الإلكتروني وسنرسل لك رابطاً لإعادة تعيين كلمة المرور.",
"cloudForgotPassword_passwordResetError": "فشل في إرسال بريد إعادة تعيين كلمة المرور",
"cloudForgotPassword_passwordResetSent": "تم إرسال إعادة تعيين كلمة المرور",
"cloudForgotPassword_sendResetLink": "إرسال رابط إعادة التعيين",
"cloudForgotPassword_title": "نسيت كلمة المرور",
"cloudPrivateBeta_desc": "سجل الدخول للانضمام إلى قائمة الانتظار. سنخطرك عندما يصبح الوصول للبيتا متاحاً. تم إخطارك بالفعل؟ سجل الدخول لبدء استخدام Comfy Cloud.",
"cloudPrivateBeta_title": "Cloud حالياً في مرحلة البيتا الخاصة",
"cloudSorryContactSupport_title": "عذراً، اتصل بالدعم",
"cloudStart_desc": "لا حاجة للإعداد. يعمل على أي جهاز.",
"cloudStart_download": "تحميل ComfyUI",
"cloudStart_explain": "أنتج عدة نتائج في آنٍ واحد. شارك مشاريعك بسهولة.",
"cloudStart_learnAboutButton": "تعلم عن Cloud",
"cloudStart_title": "ابدأ الإبداع فوراً، في ثوانٍ قليلة",
"cloudStart_wantToRun": "تريد تشغيل ComfyUI محلياً بدلاً من ذلك؟",
"cloudSurvey_steps_familiarity": "ما هو مستوى خبرتك مع ComfyUI؟",
"cloudSurvey_steps_industry": "ما هي صناعتك الأساسية؟",
"cloudSurvey_steps_making": "ما نوع المحتوى الذي تخطط لإبداعه؟",
"cloudSurvey_steps_purpose": "ما هو الاستخدام الرئيسي لـ ComfyUI بالنسبة لك؟",
"cloudVerifyEmail_title": "التحقق من البريد الإلكتروني",
"cloudWaitlist_contactLink": "هنا",
"cloudWaitlist_message": "تم تسجيلك في قائمة الانتظار. سنخطرك عندما يصبح الوصول للبيتا متاحاً.",
"cloudWaitlist_questionsText": "لديك استفسارات؟ تواصل معنا",
"cloudWaitlist_titleLine1": "أنت في",
"cloudWaitlist_titleLine2": "قائمة الانتظار 🎉",
"color": {
"black": "أسود",
"blue": "أزرق",
@@ -1942,98 +1974,5 @@
"label": "عناصر التحكم في التكبير",
"showMinimap": "إظهار الخريطة المصغرة",
"zoomToFit": "تكبير لتناسب الشاشة"
},
"cloudFooter_needHelp": "تحتاج مساعدة؟",
"cloudStart_title": "ابدأ الإبداع فوراً، في ثوانٍ قليلة",
"cloudStart_desc": "لا حاجة للإعداد. يعمل على أي جهاز.",
"cloudStart_explain": "أنتج عدة نتائج في آنٍ واحد. شارك مشاريعك بسهولة.",
"cloudStart_learnAboutButton": "تعلم عن Cloud",
"cloudStart_wantToRun": "تريد تشغيل ComfyUI محلياً بدلاً من ذلك؟",
"cloudStart_download": "تحميل ComfyUI",
"cloudWaitlist_titleLine1": "أنت في",
"cloudWaitlist_titleLine2": "قائمة الانتظار 🎉",
"cloudWaitlist_message": "تم تسجيلك في قائمة الانتظار. سنخطرك عندما يصبح الوصول للبيتا متاحاً.",
"cloudWaitlist_questionsText": "لديك استفسارات؟ تواصل معنا",
"cloudWaitlist_contactLink": "هنا",
"cloudClaimInvite_processingTitle": "معالجة رمز الدعوة...",
"cloudClaimInvite_claimButton": "استلام الدعوة",
"cloudSorryContactSupport_title": "عذراً، اتصل بالدعم",
"cloudPrivateBeta_title": "Cloud حالياً في مرحلة البيتا الخاصة",
"cloudPrivateBeta_desc": "سجل الدخول للانضمام إلى قائمة الانتظار. سنخطرك عندما يصبح الوصول للبيتا متاحاً. تم إخطارك بالفعل؟ سجل الدخول لبدء استخدام Comfy Cloud.",
"cloudForgotPassword_title": "نسيت كلمة المرور",
"cloudForgotPassword_instructions": "أدخل عنوان بريدك الإلكتروني وسنرسل لك رابطاً لإعادة تعيين كلمة المرور.",
"cloudForgotPassword_emailLabel": "البريد الإلكتروني",
"cloudForgotPassword_emailPlaceholder": "أدخل بريدك الإلكتروني",
"cloudForgotPassword_sendResetLink": "إرسال رابط إعادة التعيين",
"cloudForgotPassword_backToLogin": "العودة لتسجيل الدخول",
"cloudForgotPassword_didntReceiveEmail": "لم تستلم بريداً إلكترونياً؟",
"cloudForgotPassword_emailRequired": "البريد الإلكتروني حقل إجباري",
"cloudForgotPassword_passwordResetSent": "تم إرسال إعادة تعيين كلمة المرور",
"cloudForgotPassword_passwordResetError": "فشل في إرسال بريد إعادة تعيين كلمة المرور",
"cloudSurvey_steps_familiarity": "ما هو مستوى خبرتك مع ComfyUI؟",
"cloudSurvey_steps_purpose": "ما هو الاستخدام الرئيسي لـ ComfyUI بالنسبة لك؟",
"cloudSurvey_steps_industry": "ما هي صناعتك الأساسية؟",
"cloudSurvey_steps_making": "ما نوع المحتوى الذي تخطط لإبداعه؟",
"desktopStart": {
"initialising": "جارٍ التهيئة..."
},
"shape": {
"default": "افتراضي",
"round": "دائري",
"CARD": "بطاقة",
"circle": "دائرة",
"arrow": "سهم",
"box": "صندوق"
},
"commands": {
"runWorkflow": "تشغيل سير العمل",
"runWorkflowFront": "تشغيل سير العمل (إضافة في المقدمة)",
"run": "تشغيل",
"execute": "تنفيذ",
"interrupt": "إلغاء التشغيل الحالي",
"refresh": "تحديث تعريفات العقد",
"clipspace": "فتح مساحة القص",
"resetView": "إعادة تعيين عرض اللوحة",
"clear": "مسح سير العمل",
"toggleBottomPanel": "تبديل اللوحة السفلية",
"theme": "المظهر",
"dark": "داكن",
"light": "فاتح",
"manageExtensions": "إدارة الإضافات",
"settings": "الإعدادات",
"help": "مساعدة",
"queue": "لوحة قائمة الانتظار"
},
"widgets": {
"selectModel": "اختر نموذج",
"uploadSelect": {
"placeholder": "اختر...",
"placeholderImage": "اختر صورة...",
"placeholderAudio": "اختر صوت...",
"placeholderVideo": "اختر فيديو...",
"placeholderModel": "اختر نموذج...",
"placeholderUnknown": "اختر وسائط..."
}
},
"assetBrowser": {
"assets": "الأصول",
"browseAssets": "تصفح الأصول",
"noAssetsFound": "لم يتم العثور على أصول",
"tryAdjustingFilters": "حاول تعديل البحث أو المرشحات",
"loadingModels": "جارٍ تحميل {type}...",
"connectionError": "يرجى التحقق من اتصالك والمحاولة مرة أخرى",
"failedToCreateNode": "فشل إنشاء العقدة. يرجى المحاولة مرة أخرى أو التحقق من وحدة التحكم للحصول على التفاصيل.",
"noModelsInFolder": "لا توجد {type} متاحة في هذا المجلد",
"searchAssetsPlaceholder": "البحث في الأصول...",
"allModels": "جميع النماذج",
"allCategory": "جميع {category}",
"unknown": "غير معروف",
"fileFormats": "تنسيقات الملفات",
"baseModels": "النماذج الأساسية",
"sortBy": "ترتيب حسب",
"sortAZ": "أ-ي",
"sortZA": "ي-أ",
"sortRecent": "الأحدث",
"sortPopular": "الأكثر شعبية"
}
}

View File

@@ -1931,24 +1931,18 @@
"renewsDate": "Renews {date}",
"expiresDate": "Expires {date}",
"manageSubscription": "Manage subscription",
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
"partnerNodesDescription": "For running commercial/proprietary models",
"apiNodesBalance": "\"API Nodes\" Credit Balance",
"apiNodesDescription": "For running commercial/proprietary models",
"totalCredits": "Total credits",
"viewUsageHistory": "View usage history",
"addApiCredits": "Add API credits",
"addCredits": "Add credits",
"monthlyCreditsRollover": "These credits will rollover to the next month",
"monthlyBonusDescription": "Monthly credit bonus",
"prepaidDescription": "Pre-paid credits",
"prepaidCreditsInfo": "Credits purchased separately that don't expire",
"nextBillingCycle": "next billing cycle",
"yourPlanIncludes": "Your plan includes:",
"viewMoreDetails": "View more details",
"learnMore": "Learn more",
"messageSupport": "Message support",
"invoiceHistory": "Invoice history",
"benefits": {
"benefit1": "Monthly credits for Partner Nodes — top up when needed",
"benefit1": "$10 in monthly credits for API models — top up when needed",
"benefit2": "Up to 30 min runtime per job"
},
"required": {
@@ -1957,9 +1951,7 @@
"subscribe": "Subscribe"
},
"subscribeToRun": "Subscribe to Run",
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"partnerNodesCredits": "Partner Nodes credits"
"subscribeNow": "Subscribe Now"
},
"userSettings": {
"title": "User Settings",
@@ -2122,18 +2114,7 @@
"authTimeout": {
"title": "Connection Taking Too Long",
"message": "We're having trouble connecting to ComfyUI Cloud. This could be due to a slow connection or temporary service issue.",
"restart": "Sign Out & Try Again",
"troubleshooting": "Common causes:",
"causes": [
"Corporate firewall or proxy blocking authentication services",
"VPN or network restrictions",
"Browser extensions interfering with requests",
"Regional network limitations",
"Try a different browser or network"
],
"technicalDetails": "Technical Details",
"helpText": "Need help? Contact",
"supportLink": "support"
"restart": "Sign Out & Try Again"
}
},
"cloudFooter_needHelp": "Need Help?",
@@ -2171,6 +2152,18 @@
"cloudSurvey_steps_purpose": "What will you primarily use ComfyUI for?",
"cloudSurvey_steps_industry": "What's your primary industry?",
"cloudSurvey_steps_making": "What do you plan on making?",
"cloudVerifyEmail_toast_message": "We've sent a verification email to {email}. Please check your inbox and click the link to verify your email address.",
"cloudVerifyEmail_failed_toast_message": "Failed to send verification email. Please contact support.",
"cloudVerifyEmail_title": "Check your email",
"cloudVerifyEmail_back": "Back",
"cloudVerifyEmail_sent": "A verification link was sent to:",
"cloudVerifyEmail_clickToContinue": "Click the link in that email to automatically\ncontinue onto the next steps.",
"cloudVerifyEmail_tip": "Tip: Dont forget to check your spam folder\nif you dont see it.",
"cloudVerifyEmail_didntReceive": "Didn't receive the email?",
"cloudVerifyEmail_resend": "Resend email",
"cloudVerifyEmail_toast_title": "Email sent",
"cloudVerifyEmail_toast_summary": "Check your inbox for a new verification email.",
"cloudVerifyEmail_toast_failed": "Failed to send verification email. Please try again.",
"cloudInvite_title": "YOU'RE INVITED",
"cloudInvite_subtitle": "This invite can only be used once. Double check youre signed into the account you want to use.",
"cloudInvite_switchAccounts": "Switch accounts",

View File

@@ -131,6 +131,38 @@
"errorNotSupported": "API del portapapeles no soportada en su navegador",
"successMessage": "Copiado al portapapeles"
},
"cloudClaimInvite_claimButton": "Reclamar Invitación",
"cloudClaimInvite_processingTitle": "Procesando código de invitación...",
"cloudFooter_needHelp": "¿Necesitas ayuda?",
"cloudForgotPassword_backToLogin": "Volver al inicio de sesión",
"cloudForgotPassword_didntReceiveEmail": "¿No recibiste un correo?",
"cloudForgotPassword_emailLabel": "Correo",
"cloudForgotPassword_emailPlaceholder": "Ingresa tu correo",
"cloudForgotPassword_emailRequired": "El correo es obligatorio",
"cloudForgotPassword_instructions": "Ingresa tu dirección de correo y te enviaremos un enlace para restablecer tu contraseña.",
"cloudForgotPassword_passwordResetError": "Error al enviar correo de restablecimiento",
"cloudForgotPassword_passwordResetSent": "Restablecimiento de contraseña enviado",
"cloudForgotPassword_sendResetLink": "Enviar enlace de restablecimiento",
"cloudForgotPassword_title": "Olvidé mi Contraseña",
"cloudPrivateBeta_desc": "Inicia sesión para unirte a la lista de espera. Te notificaremos cuando tengas acceso al beta. ¿Ya recibiste la notificación? Inicia sesión para empezar a usar Comfy Cloud.",
"cloudPrivateBeta_title": "Cloud está actualmente en beta privada",
"cloudSorryContactSupport_title": "Lo sentimos, contacta al soporte",
"cloudStart_desc": "Sin configuración necesaria. Funciona en cualquier dispositivo.",
"cloudStart_download": "Descargar ComfyUI",
"cloudStart_explain": "Genera múltiples resultados simultáneamente. Comparte proyectos fácilmente.",
"cloudStart_learnAboutButton": "Conoce Cloud",
"cloudStart_title": "Crea al instante, en segundos",
"cloudStart_wantToRun": "¿Prefieres ejecutar ComfyUI localmente?",
"cloudSurvey_steps_familiarity": "¿Cuál es tu nivel de experiencia con ComfyUI?",
"cloudSurvey_steps_industry": "¿Cuál es tu industria principal?",
"cloudSurvey_steps_making": "¿Qué tipo de contenido planeas crear?",
"cloudSurvey_steps_purpose": "¿Cuál será tu uso principal de ComfyUI?",
"cloudVerifyEmail_title": "Verificación de Correo",
"cloudWaitlist_contactLink": "aquí",
"cloudWaitlist_message": "Has sido añadido a la lista de espera. Te notificaremos cuando tengas acceso al beta.",
"cloudWaitlist_questionsText": "¿Dudas? Contáctanos",
"cloudWaitlist_titleLine1": "ESTÁS EN LA",
"cloudWaitlist_titleLine2": "LISTA DE ESPERA 🎉",
"color": {
"black": "Negro",
"blue": "Azul",
@@ -1939,39 +1971,5 @@
"label": "Controles de zoom",
"showMinimap": "Mostrar minimapa",
"zoomToFit": "Ajustar al zoom"
},
"cloudFooter_needHelp": "¿Necesitas ayuda?",
"cloudStart_title": "Crea al instante, en segundos",
"cloudStart_desc": "Sin configuración necesaria. Funciona en cualquier dispositivo.",
"cloudStart_explain": "Genera múltiples resultados simultáneamente. Comparte proyectos fácilmente.",
"cloudStart_learnAboutButton": "Conoce Cloud",
"cloudStart_wantToRun": "¿Prefieres ejecutar ComfyUI localmente?",
"cloudStart_download": "Descargar ComfyUI",
"cloudWaitlist_titleLine1": "ESTÁS EN LA",
"cloudWaitlist_titleLine2": "LISTA DE ESPERA 🎉",
"cloudWaitlist_message": "Has sido añadido a la lista de espera. Te notificaremos cuando tengas acceso al beta.",
"cloudWaitlist_questionsText": "¿Dudas? Contáctanos",
"cloudWaitlist_contactLink": "aquí",
"cloudClaimInvite_processingTitle": "Procesando código de invitación...",
"cloudClaimInvite_claimButton": "Reclamar Invitación",
"cloudSorryContactSupport_title": "Lo sentimos, contacta al soporte",
"cloudPrivateBeta_title": "Cloud está actualmente en beta privada",
"cloudPrivateBeta_desc": "Inicia sesión para unirte a la lista de espera. Te notificaremos cuando tengas acceso al beta. ¿Ya recibiste la notificación? Inicia sesión para empezar a usar Comfy Cloud.",
"cloudForgotPassword_title": "Olvidé mi Contraseña",
"cloudForgotPassword_instructions": "Ingresa tu dirección de correo y te enviaremos un enlace para restablecer tu contraseña.",
"cloudForgotPassword_emailLabel": "Correo",
"cloudForgotPassword_emailPlaceholder": "Ingresa tu correo",
"cloudForgotPassword_sendResetLink": "Enviar enlace de restablecimiento",
"cloudForgotPassword_backToLogin": "Volver al inicio de sesión",
"cloudForgotPassword_didntReceiveEmail": "¿No recibiste un correo?",
"cloudForgotPassword_emailRequired": "El correo es obligatorio",
"cloudForgotPassword_passwordResetSent": "Restablecimiento de contraseña enviado",
"cloudForgotPassword_passwordResetError": "Error al enviar correo de restablecimiento",
"cloudSurvey_steps_familiarity": "¿Cuál es tu nivel de experiencia con ComfyUI?",
"cloudSurvey_steps_purpose": "¿Cuál será tu uso principal de ComfyUI?",
"cloudSurvey_steps_industry": "¿Cuál es tu industria principal?",
"cloudSurvey_steps_making": "¿Qué tipo de contenido planeas crear?",
"desktopStart": {
"initialising": "Inicializando..."
}
}

View File

@@ -131,6 +131,38 @@
"errorNotSupported": "L'API du presse-papiers n'est pas prise en charge par votre navigateur",
"successMessage": "Copié dans le presse-papiers"
},
"cloudClaimInvite_claimButton": "Réclamer l'Invitation",
"cloudClaimInvite_processingTitle": "Traitement du code d'invitation...",
"cloudFooter_needHelp": "Besoin d'aide ?",
"cloudForgotPassword_backToLogin": "Retour à la connexion",
"cloudForgotPassword_didntReceiveEmail": "Vous n'avez pas reçu d'email ?",
"cloudForgotPassword_emailLabel": "Email",
"cloudForgotPassword_emailPlaceholder": "Entrez votre email",
"cloudForgotPassword_emailRequired": "L'email est obligatoire",
"cloudForgotPassword_instructions": "Entrez votre adresse email et nous vous enverrons un lien pour réinitialiser votre mot de passe.",
"cloudForgotPassword_passwordResetError": "Échec de l'envoi de l'email de réinitialisation",
"cloudForgotPassword_passwordResetSent": "Réinitialisation de mot de passe envoyée",
"cloudForgotPassword_sendResetLink": "Envoyer le lien de réinitialisation",
"cloudForgotPassword_title": "Mot de passe oublié",
"cloudPrivateBeta_desc": "Connectez-vous pour rejoindre la liste d'attente. Nous vous préviendrons quand vous aurez accès à la bêta. Déjà notifié ? Connectez-vous pour commencer à utiliser Comfy Cloud.",
"cloudPrivateBeta_title": "Cloud est actuellement en bêta privée",
"cloudSorryContactSupport_title": "Désolé, contactez le support",
"cloudStart_desc": "Aucune configuration requise. Fonctionne sur n'importe quel appareil.",
"cloudStart_download": "Télécharger ComfyUI",
"cloudStart_explain": "Générez plusieurs résultats simultanément. Partagez vos projets facilement.",
"cloudStart_learnAboutButton": "En savoir plus sur Cloud",
"cloudStart_title": "Créez instantanément, en quelques secondes",
"cloudStart_wantToRun": "Vous préférez exécuter ComfyUI localement ?",
"cloudSurvey_steps_familiarity": "Quel est votre niveau d'expérience avec ComfyUI ?",
"cloudSurvey_steps_industry": "Quelle est votre industrie principale ?",
"cloudSurvey_steps_making": "Quel type de contenu prévoyez-vous créer ?",
"cloudSurvey_steps_purpose": "Quelle sera votre utilisation principale de ComfyUI ?",
"cloudVerifyEmail_title": "Vérification d'Email",
"cloudWaitlist_contactLink": "ici",
"cloudWaitlist_message": "Vous avez été ajouté à la liste d'attente. Nous vous préviendrons quand vous aurez accès à la bêta.",
"cloudWaitlist_questionsText": "Des questions ? Contactez-nous",
"cloudWaitlist_titleLine1": "VOUS ÊTES SUR LA",
"cloudWaitlist_titleLine2": "LISTE D'ATTENTE 🎉",
"color": {
"black": "Noir",
"blue": "Bleu",
@@ -1933,39 +1965,5 @@
"enterFilename": "Entrez le nom du fichier",
"exportWorkflow": "Exporter le flux de travail",
"saveWorkflow": "Enregistrer le flux de travail"
},
"cloudFooter_needHelp": "Besoin d'aide ?",
"cloudStart_title": "Créez instantanément, en quelques secondes",
"cloudStart_desc": "Aucune configuration requise. Fonctionne sur n'importe quel appareil.",
"cloudStart_explain": "Générez plusieurs résultats simultanément. Partagez vos projets facilement.",
"cloudStart_learnAboutButton": "En savoir plus sur Cloud",
"cloudStart_wantToRun": "Vous préférez exécuter ComfyUI localement ?",
"cloudStart_download": "Télécharger ComfyUI",
"cloudWaitlist_titleLine1": "VOUS ÊTES SUR LA",
"cloudWaitlist_titleLine2": "LISTE D'ATTENTE 🎉",
"cloudWaitlist_message": "Vous avez été ajouté à la liste d'attente. Nous vous préviendrons quand vous aurez accès à la bêta.",
"cloudWaitlist_questionsText": "Des questions ? Contactez-nous",
"cloudWaitlist_contactLink": "ici",
"cloudClaimInvite_processingTitle": "Traitement du code d'invitation...",
"cloudClaimInvite_claimButton": "Réclamer l'Invitation",
"cloudSorryContactSupport_title": "Désolé, contactez le support",
"cloudPrivateBeta_title": "Cloud est actuellement en bêta privée",
"cloudPrivateBeta_desc": "Connectez-vous pour rejoindre la liste d'attente. Nous vous préviendrons quand vous aurez accès à la bêta. Déjà notifié ? Connectez-vous pour commencer à utiliser Comfy Cloud.",
"cloudForgotPassword_title": "Mot de passe oublié",
"cloudForgotPassword_instructions": "Entrez votre adresse email et nous vous enverrons un lien pour réinitialiser votre mot de passe.",
"cloudForgotPassword_emailLabel": "Email",
"cloudForgotPassword_emailPlaceholder": "Entrez votre email",
"cloudForgotPassword_sendResetLink": "Envoyer le lien de réinitialisation",
"cloudForgotPassword_backToLogin": "Retour à la connexion",
"cloudForgotPassword_didntReceiveEmail": "Vous n'avez pas reçu d'email ?",
"cloudForgotPassword_emailRequired": "L'email est obligatoire",
"cloudForgotPassword_passwordResetSent": "Réinitialisation de mot de passe envoyée",
"cloudForgotPassword_passwordResetError": "Échec de l'envoi de l'email de réinitialisation",
"cloudSurvey_steps_familiarity": "Quel est votre niveau d'expérience avec ComfyUI ?",
"cloudSurvey_steps_purpose": "Quelle sera votre utilisation principale de ComfyUI ?",
"cloudSurvey_steps_industry": "Quelle est votre industrie principale ?",
"cloudSurvey_steps_making": "Quel type de contenu prévoyez-vous créer ?",
"desktopStart": {
"initialising": "Initialisation..."
}
}

View File

@@ -131,6 +131,38 @@
"errorNotSupported": "お使いのブラウザではクリップボードAPIがサポートされていません",
"successMessage": "クリップボードにコピーしました"
},
"cloudClaimInvite_claimButton": "招待を申請",
"cloudClaimInvite_processingTitle": "招待コードを処理中...",
"cloudFooter_needHelp": "ヘルプが必要ですか?",
"cloudForgotPassword_backToLogin": "ログインに戻る",
"cloudForgotPassword_didntReceiveEmail": "メールが届きませんでしたか?",
"cloudForgotPassword_emailLabel": "メール",
"cloudForgotPassword_emailPlaceholder": "メールを入力",
"cloudForgotPassword_emailRequired": "メールアドレスは必須です",
"cloudForgotPassword_instructions": "メールアドレスを入力してください。パスワードリセット用のリンクをお送りします。",
"cloudForgotPassword_passwordResetError": "パスワードリセットメールの送信に失敗しました",
"cloudForgotPassword_passwordResetSent": "パスワードリセットを送信しました",
"cloudForgotPassword_sendResetLink": "リセットリンクを送信",
"cloudForgotPassword_title": "パスワードを忘れた",
"cloudPrivateBeta_desc": "サインインしてウェイトリストに登録してください。ベータアクセスが可能になりましたらお知らせします。すでに通知を受け取りましたかサインインしてComfy Cloudを始めてください。",
"cloudPrivateBeta_title": "Cloudは現在プライベートベータ版です",
"cloudSorryContactSupport_title": "申し訳ございません、サポートにお問い合わせください",
"cloudStart_desc": "セットアップは不要。どのデバイスでも動作します。",
"cloudStart_download": "ComfyUIをダウンロード",
"cloudStart_explain": "複数の結果を同時生成。プロジェクトを簡単共有。",
"cloudStart_learnAboutButton": "Cloudについて学ぶ",
"cloudStart_title": "すぐに創作開始、たった数秒で",
"cloudStart_wantToRun": "代わりにComfyUIをローカルで実行しますか",
"cloudSurvey_steps_familiarity": "ComfyUIの経験レベルはいかがですか",
"cloudSurvey_steps_industry": "あなたの主要な業界は何ですか?",
"cloudSurvey_steps_making": "どのようなコンテンツを創作する予定ですか?",
"cloudSurvey_steps_purpose": "ComfyUIの主な用途は何ですか",
"cloudVerifyEmail_title": "メール確認",
"cloudWaitlist_contactLink": "こちら",
"cloudWaitlist_message": "ウェイトリストに登録されました。ベータアクセスが可能になりましたらお知らせします。",
"cloudWaitlist_questionsText": "ご不明な点がありますか?お問い合わせ",
"cloudWaitlist_titleLine1": "あなたは",
"cloudWaitlist_titleLine2": "ウェイトリストに登録されました 🎉",
"color": {
"black": "黒",
"blue": "青",
@@ -1939,39 +1971,5 @@
"label": "ズームコントロール",
"showMinimap": "ミニマップを表示",
"zoomToFit": "全体表示にズーム"
},
"cloudFooter_needHelp": "ヘルプが必要ですか?",
"cloudStart_title": "すぐに創作開始、たった数秒で",
"cloudStart_desc": "セットアップは不要。どのデバイスでも動作します。",
"cloudStart_explain": "複数の結果を同時生成。プロジェクトを簡単共有。",
"cloudStart_learnAboutButton": "Cloudについて学ぶ",
"cloudStart_wantToRun": "代わりにComfyUIをローカルで実行しますか",
"cloudStart_download": "ComfyUIをダウンロード",
"cloudWaitlist_titleLine1": "あなたは",
"cloudWaitlist_titleLine2": "ウェイトリストに登録されました 🎉",
"cloudWaitlist_message": "ウェイトリストに登録されました。ベータアクセスが可能になりましたらお知らせします。",
"cloudWaitlist_questionsText": "ご不明な点がありますか?お問い合わせ",
"cloudWaitlist_contactLink": "こちら",
"cloudClaimInvite_processingTitle": "招待コードを処理中...",
"cloudClaimInvite_claimButton": "招待を申請",
"cloudSorryContactSupport_title": "申し訳ございません、サポートにお問い合わせください",
"cloudPrivateBeta_title": "Cloudは現在プライベートベータ版です",
"cloudPrivateBeta_desc": "サインインしてウェイトリストに登録してください。ベータアクセスが可能になりましたらお知らせします。すでに通知を受け取りましたかサインインしてComfy Cloudを始めてください。",
"cloudForgotPassword_title": "パスワードを忘れた",
"cloudForgotPassword_instructions": "メールアドレスを入力してください。パスワードリセット用のリンクをお送りします。",
"cloudForgotPassword_emailLabel": "メール",
"cloudForgotPassword_emailPlaceholder": "メールを入力",
"cloudForgotPassword_sendResetLink": "リセットリンクを送信",
"cloudForgotPassword_backToLogin": "ログインに戻る",
"cloudForgotPassword_didntReceiveEmail": "メールが届きませんでしたか?",
"cloudForgotPassword_emailRequired": "メールアドレスは必須です",
"cloudForgotPassword_passwordResetSent": "パスワードリセットを送信しました",
"cloudForgotPassword_passwordResetError": "パスワードリセットメールの送信に失敗しました",
"cloudSurvey_steps_familiarity": "ComfyUIの経験レベルはいかがですか",
"cloudSurvey_steps_purpose": "ComfyUIの主な用途は何ですか",
"cloudSurvey_steps_industry": "あなたの主要な業界は何ですか?",
"cloudSurvey_steps_making": "どのようなコンテンツを創作する予定ですか?",
"desktopStart": {
"initialising": "初期化中..."
}
}

View File

@@ -131,6 +131,38 @@
"errorNotSupported": "브라우저가 클립보드 API를 지원하지 않습니다.",
"successMessage": "클립보드에 복사됨"
},
"cloudClaimInvite_claimButton": "초대 요청하기",
"cloudClaimInvite_processingTitle": "초대 코드 확인중...",
"cloudFooter_needHelp": "도움이 필요하신가요?",
"cloudForgotPassword_backToLogin": "로그인으로 돌아가기",
"cloudForgotPassword_didntReceiveEmail": "이메일을 받지 못하셨나요?",
"cloudForgotPassword_emailLabel": "이메일",
"cloudForgotPassword_emailPlaceholder": "이메일을 입력하세요",
"cloudForgotPassword_emailRequired": "이메일은 필수 입력 항목입니다",
"cloudForgotPassword_instructions": "이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드리겠습니다.",
"cloudForgotPassword_passwordResetError": "비밀번호 재설정 이메일 전송에 실패했습니다",
"cloudForgotPassword_passwordResetSent": "비밀번호 재설정이 전송되었습니다",
"cloudForgotPassword_sendResetLink": "재설정 링크 보내기",
"cloudForgotPassword_title": "비밀번호 찾기",
"cloudPrivateBeta_desc": "로그인하여 대기자 명단에 등록하세요. 베타 버전이 오픈될 때 알려드릴게요. 이미 알림을 받으셨다면? 로그인하여 Comfy Cloud를 시작해보세요.",
"cloudPrivateBeta_title": "Cloud는 현재 비공개 베타 버전입니다",
"cloudSorryContactSupport_title": "죄송합니다, 지원팀에 문의해주세요",
"cloudStart_desc": "설정없이도, 기기의 제약없이",
"cloudStart_download": "ComfyUI 다운로드",
"cloudStart_explain": "한번에 다수의 결과물을, 워크플로우로 쉽게 공유해요.",
"cloudStart_learnAboutButton": "Cloud에 대해 알아보기",
"cloudStart_title": "창작은 단 몇 초면 충분해요",
"cloudStart_wantToRun": "ComfyUI를 로컬에서 실행해보고 싶다면?",
"cloudSurvey_steps_familiarity": "ComfyUI 경험도는 어떻게 되시나요?",
"cloudSurvey_steps_industry": "어떤 업계/업종에 근무하시나요?",
"cloudSurvey_steps_making": "어떤 종류의 콘텐츠를 만들 계획이신가요?",
"cloudSurvey_steps_purpose": "ComfyUI의 주된 목적은 어떻게 되나요?",
"cloudVerifyEmail_title": "이메일 확인",
"cloudWaitlist_contactLink": "여기로",
"cloudWaitlist_message": "대기자 명단에 등록되었습니다. 베타 버전이 오픈되면 알려드릴게요.",
"cloudWaitlist_questionsText": "궁금한 점이 있으신가요? 문의는",
"cloudWaitlist_titleLine1": "방금",
"cloudWaitlist_titleLine2": "대기자 명단에 등록되었습니다 🎉",
"color": {
"black": "검정색",
"blue": "파란색",
@@ -1939,39 +1971,5 @@
"label": "확대/축소 컨트롤",
"showMinimap": "미니맵 표시",
"zoomToFit": "화면에 맞게 확대"
},
"cloudFooter_needHelp": "도움이 필요하신가요?",
"cloudStart_title": "창작은 단 몇 초면 충분해요",
"cloudStart_desc": "설정없이도, 기기의 제약없이",
"cloudStart_explain": "한번에 다수의 결과물을, 워크플로우로 쉽게 공유해요.",
"cloudStart_learnAboutButton": "Cloud에 대해 알아보기",
"cloudStart_wantToRun": "ComfyUI를 로컬에서 실행해보고 싶다면?",
"cloudStart_download": "ComfyUI 다운로드",
"cloudWaitlist_titleLine1": "방금",
"cloudWaitlist_titleLine2": "대기자 명단에 등록되었습니다 🎉",
"cloudWaitlist_message": "대기자 명단에 등록되었습니다. 베타 버전이 오픈되면 알려드릴게요.",
"cloudWaitlist_questionsText": "궁금한 점이 있으신가요? 문의는",
"cloudWaitlist_contactLink": "여기로",
"cloudClaimInvite_processingTitle": "초대 코드 확인중...",
"cloudClaimInvite_claimButton": "초대 요청하기",
"cloudSorryContactSupport_title": "죄송합니다, 지원팀에 문의해주세요",
"cloudPrivateBeta_title": "Cloud는 현재 비공개 베타 버전입니다",
"cloudPrivateBeta_desc": "로그인하여 대기자 명단에 등록하세요. 베타 버전이 오픈될 때 알려드릴게요. 이미 알림을 받으셨다면? 로그인하여 Comfy Cloud를 시작해보세요.",
"cloudForgotPassword_title": "비밀번호 찾기",
"cloudForgotPassword_instructions": "이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드리겠습니다.",
"cloudForgotPassword_emailLabel": "이메일",
"cloudForgotPassword_emailPlaceholder": "이메일을 입력하세요",
"cloudForgotPassword_sendResetLink": "재설정 링크 보내기",
"cloudForgotPassword_backToLogin": "로그인으로 돌아가기",
"cloudForgotPassword_didntReceiveEmail": "이메일을 받지 못하셨나요?",
"cloudForgotPassword_emailRequired": "이메일은 필수 입력 항목입니다",
"cloudForgotPassword_passwordResetSent": "비밀번호 재설정이 전송되었습니다",
"cloudForgotPassword_passwordResetError": "비밀번호 재설정 이메일 전송에 실패했습니다",
"cloudSurvey_steps_familiarity": "ComfyUI 경험도는 어떻게 되시나요?",
"cloudSurvey_steps_purpose": "ComfyUI의 주된 목적은 어떻게 되나요?",
"cloudSurvey_steps_industry": "어떤 업계/업종에 근무하시나요?",
"cloudSurvey_steps_making": "어떤 종류의 콘텐츠를 만들 계획이신가요?",
"desktopStart": {
"initialising": "초기화 중..."
}
}

View File

@@ -131,6 +131,38 @@
"errorNotSupported": "API буфера обмена не поддерживается в вашем браузере",
"successMessage": "Скопировано в буфер обмена"
},
"cloudClaimInvite_claimButton": "Получить приглашение",
"cloudClaimInvite_processingTitle": "Обработка кода приглашения...",
"cloudFooter_needHelp": "Нужна помощь?",
"cloudForgotPassword_backToLogin": "Вернуться к входу",
"cloudForgotPassword_didntReceiveEmail": "Не получили email?",
"cloudForgotPassword_emailLabel": "Email",
"cloudForgotPassword_emailPlaceholder": "Введите ваш email",
"cloudForgotPassword_emailRequired": "Email является обязательным полем",
"cloudForgotPassword_instructions": "Введите ваш email-адрес, и мы отправим вам ссылку для сброса пароля.",
"cloudForgotPassword_passwordResetError": "Не удалось отправить email сброса пароля",
"cloudForgotPassword_passwordResetSent": "Сброс пароля отправлен",
"cloudForgotPassword_sendResetLink": "Отправить ссылку сброса",
"cloudForgotPassword_title": "Забыли пароль",
"cloudPrivateBeta_desc": "Войдите, чтобы присоединиться к списку ожидания. Мы уведомим вас, когда будет доступен бета-доступ. Уже получили уведомление? Войдите и начните работать с Comfy Cloud.",
"cloudPrivateBeta_title": "Cloud сейчас в приватной бете",
"cloudSorryContactSupport_title": "Извините, свяжитесь с поддержкой",
"cloudStart_desc": "Настройка не требуется. Работает на любом устройстве.",
"cloudStart_download": "Скачать ComfyUI",
"cloudStart_explain": "Генерируйте несколько результатов одновременно. Легко делитесь проектами.",
"cloudStart_learnAboutButton": "Узнать о Cloud",
"cloudStart_title": "Творите мгновенно, всего за секунды",
"cloudStart_wantToRun": "Хотите запустить ComfyUI локально?",
"cloudSurvey_steps_familiarity": "Каков ваш уровень опыта с ComfyUI?",
"cloudSurvey_steps_industry": "Какая ваша основная отрасль?",
"cloudSurvey_steps_making": "Какой тип контента вы планируете создавать?",
"cloudSurvey_steps_purpose": "Каково основное назначение ComfyUI для вас?",
"cloudVerifyEmail_title": "Подтверждение email",
"cloudWaitlist_contactLink": "здесь",
"cloudWaitlist_message": "Вы добавлены в список ожидания. Мы уведомим вас, когда будет доступен бета-доступ.",
"cloudWaitlist_questionsText": "Есть вопросы? Обращайтесь",
"cloudWaitlist_titleLine1": "ВЫ В",
"cloudWaitlist_titleLine2": "СПИСКЕ ОЖИДАНИЯ 🎉",
"color": {
"black": "Черный",
"blue": "Синий",
@@ -1939,39 +1971,5 @@
"label": "Элементы управления масштабом",
"showMinimap": "Показать миникарту",
"zoomToFit": "Масштабировать по размеру"
},
"cloudFooter_needHelp": "Нужна помощь?",
"cloudStart_title": "Творите мгновенно, всего за секунды",
"cloudStart_desc": "Настройка не требуется. Работает на любом устройстве.",
"cloudStart_explain": "Генерируйте несколько результатов одновременно. Легко делитесь проектами.",
"cloudStart_learnAboutButton": "Узнать о Cloud",
"cloudStart_wantToRun": "Хотите запустить ComfyUI локально?",
"cloudStart_download": "Скачать ComfyUI",
"cloudWaitlist_titleLine1": "ВЫ В",
"cloudWaitlist_titleLine2": "СПИСКЕ ОЖИДАНИЯ 🎉",
"cloudWaitlist_message": "Вы добавлены в список ожидания. Мы уведомим вас, когда будет доступен бета-доступ.",
"cloudWaitlist_questionsText": "Есть вопросы? Обращайтесь",
"cloudWaitlist_contactLink": "здесь",
"cloudClaimInvite_processingTitle": "Обработка кода приглашения...",
"cloudClaimInvite_claimButton": "Получить приглашение",
"cloudSorryContactSupport_title": "Извините, свяжитесь с поддержкой",
"cloudPrivateBeta_title": "Cloud сейчас в приватной бете",
"cloudPrivateBeta_desc": "Войдите, чтобы присоединиться к списку ожидания. Мы уведомим вас, когда будет доступен бета-доступ. Уже получили уведомление? Войдите и начните работать с Comfy Cloud.",
"cloudForgotPassword_title": "Забыли пароль",
"cloudForgotPassword_instructions": "Введите ваш email-адрес, и мы отправим вам ссылку для сброса пароля.",
"cloudForgotPassword_emailLabel": "Email",
"cloudForgotPassword_emailPlaceholder": "Введите ваш email",
"cloudForgotPassword_sendResetLink": "Отправить ссылку сброса",
"cloudForgotPassword_backToLogin": "Вернуться к входу",
"cloudForgotPassword_didntReceiveEmail": "Не получили email?",
"cloudForgotPassword_emailRequired": "Email является обязательным полем",
"cloudForgotPassword_passwordResetSent": "Сброс пароля отправлен",
"cloudForgotPassword_passwordResetError": "Не удалось отправить email сброса пароля",
"cloudSurvey_steps_familiarity": "Каков ваш уровень опыта с ComfyUI?",
"cloudSurvey_steps_purpose": "Каково основное назначение ComfyUI для вас?",
"cloudSurvey_steps_industry": "Какая ваша основная отрасль?",
"cloudSurvey_steps_making": "Какой тип контента вы планируете создавать?",
"desktopStart": {
"initialising": "Инициализация..."
}
}

View File

@@ -131,6 +131,38 @@
"errorNotSupported": "您的瀏覽器不支援剪貼簿 API",
"successMessage": "已複製到剪貼簿"
},
"cloudClaimInvite_claimButton": "領取邀請",
"cloudClaimInvite_processingTitle": "處理邀請碼中...",
"cloudFooter_needHelp": "需要幫助?",
"cloudForgotPassword_backToLogin": "返回登入",
"cloudForgotPassword_didntReceiveEmail": "沒有收到郵件?",
"cloudForgotPassword_emailLabel": "郵箱",
"cloudForgotPassword_emailPlaceholder": "輸入您的郵箱",
"cloudForgotPassword_emailRequired": "郵箱為必填項",
"cloudForgotPassword_instructions": "輸入您的郵箱地址,我們將發送重置密碼的連結。",
"cloudForgotPassword_passwordResetError": "發送密碼重置郵件失敗",
"cloudForgotPassword_passwordResetSent": "密碼重置已發送",
"cloudForgotPassword_sendResetLink": "發送重置連結",
"cloudForgotPassword_title": "忘記密碼",
"cloudPrivateBeta_desc": "登入即可加入等候名單,當您有內測資格時我們將及時通知。已收到通知?登入即可開始使用 Comfy Cloud。",
"cloudPrivateBeta_title": "Cloud 目前處於內測階段",
"cloudSorryContactSupport_title": "抱歉,請聯繫用戶支援",
"cloudStart_desc": "無需任何部署。可在任何設備上工作。",
"cloudStart_download": "下載 ComfyUI",
"cloudStart_explain": "一次生成多個結果。輕鬆分享工作流。",
"cloudStart_learnAboutButton": "了解 Cloud",
"cloudStart_title": "即刻開始創作,僅需幾秒",
"cloudStart_wantToRun": "想要在本地運行 ComfyUI",
"cloudSurvey_steps_familiarity": "您對 ComfyUI 的熟悉程度如何?",
"cloudSurvey_steps_industry": "您的主要行業是什麼?",
"cloudSurvey_steps_making": "您計劃創作哪些內容?",
"cloudSurvey_steps_purpose": "您使用 ComfyUI 的主要用途是?",
"cloudVerifyEmail_title": "郵箱驗證",
"cloudWaitlist_contactLink": "這裡",
"cloudWaitlist_message": "您已被添加到等候名單。我們會在您獲得內測資格後通知您。",
"cloudWaitlist_questionsText": "有疑問?聯繫我們",
"cloudWaitlist_titleLine1": "您已加入",
"cloudWaitlist_titleLine2": "等候名單 🎉",
"color": {
"black": "黑色",
"blue": "藍色",
@@ -1939,39 +1971,5 @@
"label": "縮放控制",
"showMinimap": "顯示小地圖",
"zoomToFit": "縮放至適合大小"
},
"cloudFooter_needHelp": "需要幫助?",
"cloudStart_title": "即刻開始創作,僅需幾秒",
"cloudStart_desc": "無需任何部署。可在任何設備上工作。",
"cloudStart_explain": "一次生成多個結果。輕鬆分享工作流。",
"cloudStart_learnAboutButton": "了解 Cloud",
"cloudStart_wantToRun": "想要在本地運行 ComfyUI",
"cloudStart_download": "下載 ComfyUI",
"cloudWaitlist_titleLine1": "您已加入",
"cloudWaitlist_titleLine2": "等候名單 🎉",
"cloudWaitlist_message": "您已被添加到等候名單。我們會在您獲得內測資格後通知您。",
"cloudWaitlist_questionsText": "有疑問?聯繫我們",
"cloudWaitlist_contactLink": "這裡",
"cloudClaimInvite_processingTitle": "處理邀請碼中...",
"cloudClaimInvite_claimButton": "領取邀請",
"cloudSorryContactSupport_title": "抱歉,請聯繫用戶支援",
"cloudPrivateBeta_title": "Cloud 目前處於內測階段",
"cloudPrivateBeta_desc": "登入即可加入等候名單,當您有內測資格時我們將及時通知。已收到通知?登入即可開始使用 Comfy Cloud。",
"cloudForgotPassword_title": "忘記密碼",
"cloudForgotPassword_instructions": "輸入您的郵箱地址,我們將發送重置密碼的連結。",
"cloudForgotPassword_emailLabel": "郵箱",
"cloudForgotPassword_emailPlaceholder": "輸入您的郵箱",
"cloudForgotPassword_sendResetLink": "發送重置連結",
"cloudForgotPassword_backToLogin": "返回登入",
"cloudForgotPassword_didntReceiveEmail": "沒有收到郵件?",
"cloudForgotPassword_emailRequired": "郵箱為必填項",
"cloudForgotPassword_passwordResetSent": "密碼重置已發送",
"cloudForgotPassword_passwordResetError": "發送密碼重置郵件失敗",
"cloudSurvey_steps_familiarity": "您對 ComfyUI 的熟悉程度如何?",
"cloudSurvey_steps_purpose": "您使用 ComfyUI 的主要用途是?",
"cloudSurvey_steps_industry": "您的主要行業是什麼?",
"cloudSurvey_steps_making": "您計劃創作哪些內容?",
"desktopStart": {
"initialising": "初始化中..."
}
}

View File

@@ -131,6 +131,38 @@
"errorNotSupported": "您的浏览器不支持剪贴板API",
"successMessage": "已复制到剪贴板"
},
"cloudClaimInvite_claimButton": "领取邀请",
"cloudClaimInvite_processingTitle": "处理邀请码中...",
"cloudFooter_needHelp": "需要帮助?",
"cloudForgotPassword_backToLogin": "返回登录",
"cloudForgotPassword_didntReceiveEmail": "没有收到邮件?",
"cloudForgotPassword_emailLabel": "邮箱",
"cloudForgotPassword_emailPlaceholder": "输入您的邮箱",
"cloudForgotPassword_emailRequired": "邮箱为必填项",
"cloudForgotPassword_instructions": "输入您的邮箱地址,我们将发送重置密码的链接。",
"cloudForgotPassword_passwordResetError": "发送密码重置邮件失败",
"cloudForgotPassword_passwordResetSent": "密码重置已发送",
"cloudForgotPassword_sendResetLink": "发送重置链接",
"cloudForgotPassword_title": "忘记密码",
"cloudPrivateBeta_desc": "登录即可加入等候名单,当您有内测资格时我们将及时通知。已收到通知?登录即可开始使用 Comfy Cloud。",
"cloudPrivateBeta_title": "Cloud 目前处于内测阶段",
"cloudSorryContactSupport_title": "抱歉,请联系用户支持",
"cloudStart_desc": "无需任何部署。可在任何设备上工作。",
"cloudStart_download": "下载 ComfyUI",
"cloudStart_explain": "一次生成多个结果。轻松分享工作流。",
"cloudStart_learnAboutButton": "了解 Cloud",
"cloudStart_title": "即刻开始创作,仅需几秒",
"cloudStart_wantToRun": "想在本地运行 ComfyUI",
"cloudSurvey_steps_familiarity": "您对 ComfyUI 的熟悉程度如何?",
"cloudSurvey_steps_industry": "您的主要行业是?",
"cloudSurvey_steps_making": "您计划创作哪些内容?",
"cloudSurvey_steps_purpose": "您使用 ComfyUI 的主要用途是?",
"cloudVerifyEmail_title": "邮箱验证",
"cloudWaitlist_contactLink": "这里",
"cloudWaitlist_message": "您已被添加到等候名单。我们会在您获得内测资格后通知您。",
"cloudWaitlist_questionsText": "有疑问?联系我们",
"cloudWaitlist_titleLine1": "您已在",
"cloudWaitlist_titleLine2": "等候名单中 :tada:",
"color": {
"black": "黑色",
"blue": "蓝色",
@@ -1942,98 +1974,5 @@
"label": "缩放控制",
"showMinimap": "显示小地图",
"zoomToFit": "适合画面"
},
"cloudFooter_needHelp": "需要帮助?",
"cloudStart_title": "即刻开始创作,仅需几秒",
"cloudStart_desc": "无需任何部署。可在任何设备上工作。",
"cloudStart_explain": "一次生成多个结果。轻松分享工作流。",
"cloudStart_learnAboutButton": "了解 Cloud",
"cloudStart_wantToRun": "想在本地运行 ComfyUI",
"cloudStart_download": "下载 ComfyUI",
"cloudWaitlist_titleLine1": "您已在",
"cloudWaitlist_titleLine2": "等候名单中 :tada:",
"cloudWaitlist_message": "您已被添加到等候名单。我们会在您获得内测资格后通知您。",
"cloudWaitlist_questionsText": "有疑问?联系我们",
"cloudWaitlist_contactLink": "这里",
"cloudClaimInvite_processingTitle": "处理邀请码中...",
"cloudClaimInvite_claimButton": "领取邀请",
"cloudSorryContactSupport_title": "抱歉,请联系用户支持",
"cloudPrivateBeta_title": "Cloud 目前处于内测阶段",
"cloudPrivateBeta_desc": "登录即可加入等候名单,当您有内测资格时我们将及时通知。已收到通知?登录即可开始使用 Comfy Cloud。",
"cloudForgotPassword_title": "忘记密码",
"cloudForgotPassword_instructions": "输入您的邮箱地址,我们将发送重置密码的链接。",
"cloudForgotPassword_emailLabel": "邮箱",
"cloudForgotPassword_emailPlaceholder": "输入您的邮箱",
"cloudForgotPassword_sendResetLink": "发送重置链接",
"cloudForgotPassword_backToLogin": "返回登录",
"cloudForgotPassword_didntReceiveEmail": "没有收到邮件?",
"cloudForgotPassword_emailRequired": "邮箱为必填项",
"cloudForgotPassword_passwordResetSent": "密码重置已发送",
"cloudForgotPassword_passwordResetError": "发送密码重置邮件失败",
"cloudSurvey_steps_familiarity": "您对 ComfyUI 的熟悉程度如何?",
"cloudSurvey_steps_purpose": "您使用 ComfyUI 的主要用途是?",
"cloudSurvey_steps_industry": "您的主要行业是?",
"cloudSurvey_steps_making": "您计划创作哪些内容?",
"desktopStart": {
"initialising": "正在初始化..."
},
"shape": {
"default": "默认",
"round": "圆角",
"CARD": "卡片",
"circle": "圆形",
"arrow": "箭头",
"box": "方框"
},
"commands": {
"runWorkflow": "运行工作流",
"runWorkflowFront": "运行工作流(队列前端)",
"run": "运行",
"execute": "执行",
"interrupt": "取消当前运行",
"refresh": "刷新节点定义",
"clipspace": "打开 Clipspace",
"resetView": "重置画布视图",
"clear": "清空工作流",
"toggleBottomPanel": "切换底部面板",
"theme": "主题",
"dark": "深色",
"light": "浅色",
"manageExtensions": "管理扩展",
"settings": "设置",
"help": "帮助",
"queue": "队列面板"
},
"widgets": {
"selectModel": "选择模型",
"uploadSelect": {
"placeholder": "请选择...",
"placeholderImage": "请选择图片...",
"placeholderAudio": "请选择音频...",
"placeholderVideo": "请选择视频...",
"placeholderModel": "请选择模型...",
"placeholderUnknown": "请选择媒体..."
}
},
"assetBrowser": {
"assets": "资源",
"browseAssets": "浏览资源",
"noAssetsFound": "未找到资源",
"tryAdjustingFilters": "请尝试调整搜索或筛选条件",
"loadingModels": "正在加载{type}...",
"connectionError": "请检查您的网络连接后重试",
"failedToCreateNode": "创建节点失败。请重试或查看控制台获取详细信息。",
"noModelsInFolder": "此文件夹中没有可用的{type}",
"searchAssetsPlaceholder": "搜索资源...",
"allModels": "全部模型",
"allCategory": "全部{category}",
"unknown": "未知",
"fileFormats": "文件格式",
"baseModels": "基础模型",
"sortBy": "排序方式",
"sortAZ": "A-Z",
"sortZA": "Z-A",
"sortRecent": "最近",
"sortPopular": "最受欢迎"
}
}

View File

@@ -16,23 +16,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
path: 'login',
name: 'cloud-login',
component: () =>
import('@/platform/onboarding/cloud/CloudLoginView.vue'),
beforeEnter: async (to, _from, next) => {
// Only redirect if not explicitly switching accounts
if (!to.query.switchAccount) {
const { useCurrentUser } = await import(
'@/composables/auth/useCurrentUser'
)
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {
// User is already logged in, redirect to user-check
// user-check will handle survey, waitlist, or main page routing
return next({ name: 'cloud-user-check' })
}
}
next()
}
import('@/platform/onboarding/cloud/CloudLoginView.vue')
},
{
path: 'signup',
@@ -84,10 +68,8 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
{
path: 'verify-email',
name: 'cloud-verify-email',
redirect: (to) => ({
name: 'cloud-user-check',
query: to.query
})
component: () =>
import('@/platform/onboarding/cloud/CloudVerifyEmailView.vue')
},
{
path: 'sorry-contact-support',
@@ -99,8 +81,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
path: 'auth-timeout',
name: 'cloud-auth-timeout',
component: () =>
import('@/platform/onboarding/cloud/CloudAuthTimeoutView.vue'),
props: true
import('@/platform/onboarding/cloud/CloudAuthTimeoutView.vue')
}
]
}

View File

@@ -15,7 +15,7 @@ export const useSessionCookie = () => {
if (!isCloud) return
const authStore = useFirebaseAuthStore()
const authHeader = await authStore.getAuthHeader(true)
const authHeader = await authStore.getAuthHeader()
if (!authHeader) {
throw new Error('No auth header available for session creation')

View File

@@ -2,51 +2,34 @@
<Button
:label="label || $t('subscription.required.subscribe')"
:size="size"
:class="buttonClass"
:loading="isLoading"
:disabled="isPolling"
severity="primary"
:style="
variant === 'gradient'
? {
background: 'var(--color-subscription-button-gradient)',
color: 'var(--color-white)'
}
: undefined
"
:pt="{
root: {
class: rootClass
}
}"
@click="handleSubscribe"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, onBeforeUnmount, ref } from 'vue'
import { onBeforeUnmount, ref } from 'vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { cn } from '@/utils/tailwindUtil'
const props = withDefaults(
withDefaults(
defineProps<{
label?: string
size?: 'small' | 'large'
variant?: 'default' | 'gradient'
fluid?: boolean
buttonClass?: string
}>(),
{
size: 'large',
variant: 'default',
fluid: true
buttonClass: 'w-full font-bold'
}
)
const rootClass = computed(() => cn('font-bold', props.fluid && 'w-full'))
const emit = defineEmits<{
subscribed: []
}>()

View File

@@ -9,10 +9,6 @@
icon="pi pi-lock"
severity="primary"
size="small"
:style="{
background: 'var(--color-subscription-button-gradient)',
color: 'var(--color-white)'
}"
data-testid="subscribe-to-run-button"
@click="handleSubscribeToRun"
/>

View File

@@ -1,15 +1,15 @@
<template>
<div class="flex flex-col items-start gap-1 self-stretch">
<div class="flex flex-col gap-3">
<div class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-xs text-text-primary" />
<span class="text-sm text-text-primary">
<i class="pi pi-check mt-1 text-sm" />
<span class="text-sm">
{{ $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" />
<span class="text-sm text-text-primary">
<i class="pi pi-check mt-1 text-sm" />
<span class="text-sm">
{{ $t('subscription.benefits.benefit2') }}
</span>
</div>
@@ -19,15 +19,8 @@
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'
}
}"
size="small"
class="self-start !p-0 text-sm hover:!bg-transparent [&]:!text-[inherit]"
@click="handleViewMoreDetails"
/>
</div>
@@ -37,6 +30,6 @@
import Button from 'primevue/button'
const handleViewMoreDetails = () => {
window.open('https://www.comfy.org/cloud/pricing', '_blank')
window.open('https://www.comfy.org/cloud', '_blank')
}
</script>

View File

@@ -1,10 +1,10 @@
<template>
<TabPanel value="PlanCredits" class="subscription-container h-full">
<div class="flex h-full flex-col gap-6">
<div class="flex items-baseline gap-2">
<span class="text-2xl font-inter font-semibold leading-tight">
<div class="flex h-full flex-col">
<div class="flex items-center gap-2">
<h2 class="text-2xl">
{{ $t('subscription.title') }}
</span>
</h2>
<CloudBadge
reverse-order
background-color="var(--p-dialog-background)"
@@ -12,20 +12,17 @@
</div>
<div class="grow overflow-auto">
<div class="rounded-2xl border border-interface-stroke p-6">
<div class="rounded-lg border border-charcoal-400 p-4">
<div>
<div class="flex items-center justify-between">
<div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">{{ formattedMonthlyPrice }}</span>
<span class="text-base">{{
$t('subscription.perMonth')
<div class="flex items-baseline gap-1">
<span class="text-2xl font-bold">{{
formattedMonthlyPrice
}}</span>
<span>{{ $t('subscription.perMonth') }}</span>
</div>
<div
v-if="isActiveSubscription"
class="text-sm text-text-secondary"
>
<div v-if="isActiveSubscription" class="text-xs text-muted">
<template v-if="isCancelled">
{{
$t('subscription.expiresDate', {
@@ -46,165 +43,105 @@
v-if="isActiveSubscription"
:label="$t('subscription.manageSubscription')"
severity="secondary"
class="text-xs bg-interface-menu-component-surface-selected"
:pt="{
root: {
style: 'border-radius: 8px; padding: 8px 16px;'
},
label: {
class: 'text-text-primary'
}
}"
class="text-xs"
@click="manageSubscription"
/>
<SubscribeButton
v-else
:label="$t('subscription.subscribeNow')"
size="small"
:fluid="false"
class="text-xs"
button-class="text-xs"
@subscribed="handleRefresh"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-6 pt-9 lg:grid-cols-2">
<div class="flex flex-col flex-1">
<div class="grid grid-cols-1 gap-6 rounded-lg pt-10 lg:grid-cols-2">
<div class="flex flex-col">
<div class="flex flex-col gap-3">
<div class="flex flex-col">
<div class="text-sm">
{{ $t('subscription.partnerNodesBalance') }}
{{ $t('subscription.apiNodesBalance') }}
</div>
<div class="flex items-center">
<div class="text-sm text-muted">
{{ $t('subscription.partnerNodesDescription') }}
<div class="text-xs text-muted">
{{ $t('subscription.apiNodesDescription') }}
</div>
</div>
</div>
<div
:class="
cn(
'relative flex flex-col gap-6 rounded-2xl p-5',
'bg-smoke-100 dark-theme:bg-charcoal-600'
)
"
class="flex flex-col gap-3 rounded-lg border p-4 dark-theme:border-0 dark-theme:bg-charcoal-600"
>
<Button
v-tooltip="refreshTooltip"
icon="pi pi-sync"
text
size="small"
class="absolute top-0.5 right-0"
:loading="isLoadingBalance"
:pt="{
icon: {
class: 'text-text-secondary text-xs'
},
loadingIcon: {
class: 'text-text-secondary text-xs'
}
}"
@click="handleRefresh"
/>
<div class="flex flex-col gap-2">
<div class="text-sm text-text-secondary">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton
v-if="isLoadingBalance"
width="8rem"
height="2rem"
/>
<div v-else class="text-2xl font-bold">
${{ totalCredits }}
</div>
</div>
<!-- Credit Breakdown -->
<div class="flex flex-col gap-1">
<div class="flex items-center gap-4">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<div v-else class="text-sm text-text-secondary font-bold">
${{ monthlyBonusCredits }}
</div>
<div class="flex items-center gap-1">
<div class="text-sm text-text-secondary">
{{ $t('subscription.monthlyBonusDescription') }}
</div>
<Button
v-tooltip="$t('subscription.monthlyCreditsRollover')"
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">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<div v-else class="text-sm text-text-secondary font-bold">
${{ prepaidCredits }}
</div>
<div class="flex items-center gap-1">
<div class="text-sm text-text-secondary">
{{ $t('subscription.prepaidDescription') }}
</div>
<Button
v-tooltip="$t('subscription.prepaidCreditsInfo')"
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>
<div class="flex items-center justify-between">
<a
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"
<div>
<div class="text-xs text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<div class="text-2xl font-bold">${{ totalCredits }}</div>
</div>
<Button
icon="pi pi-sync"
severity="secondary"
size="small"
:loading="isLoadingBalance"
@click="handleRefresh"
/>
</div>
<div
v-if="latestEvents.length > 0"
class="flex flex-col gap-2 pt-3 text-xs"
>
<div
v-for="event in latestEvents"
:key="event.event_id"
class="flex items-center justify-between py-1"
>
{{ $t('subscription.viewUsageHistory') }}
</a>
<div class="flex flex-col gap-0.5">
<span class="font-medium">
{{
event.event_type
? customerEventService.formatEventType(
event.event_type
)
: ''
}}
</span>
<span class="text-muted">
{{
event.createdAt
? customerEventService.formatDate(event.createdAt)
: ''
}}
</span>
</div>
<div
v-if="event.params?.amount !== undefined"
class="font-bold"
>
${{
customerEventService.formatAmount(
event.params.amount as number
)
}}
</div>
</div>
</div>
<div class="flex items-center justify-between pt-2">
<Button
:label="$t('subscription.viewUsageHistory')"
text
severity="secondary"
class="p-0 text-xs text-muted"
@click="handleViewUsageHistory"
/>
<Button
v-if="isActiveSubscription"
:label="$t('subscription.addCredits')"
:label="$t('subscription.addApiCredits')"
severity="secondary"
class="p-2 min-h-8 bg-interface-menu-component-surface-selected"
:pt="{
root: {
style: 'border-radius: 8px;'
},
label: {
class: 'text-sm'
}
}"
class="text-xs"
@click="handleAddApiCredits"
/>
</div>
@@ -212,7 +149,7 @@
</div>
</div>
<div class="flex flex-col gap-2 flex-1">
<div class="flex flex-col gap-3">
<div class="text-sm">
{{ $t('subscription.yourPlanIncludes') }}
</div>
@@ -224,7 +161,7 @@
</div>
<div
class="flex items-center justify-between border-t border-interface-stroke pt-3"
class="flex items-center justify-between border-t border-charcoal-400 pt-3"
>
<div class="flex gap-2">
<Button
@@ -233,15 +170,7 @@
severity="secondary"
icon="pi pi-question-circle"
class="text-xs"
:pt="{
label: {
class: 'text-text-secondary'
},
icon: {
class: 'text-text-secondary text-xs'
}
}"
@click="handleLearnMoreClick"
@click="handleLearnMore"
/>
<Button
:label="$t('subscription.messageSupport')"
@@ -249,15 +178,6 @@
severity="secondary"
icon="pi pi-comment"
class="text-xs"
:loading="isLoadingSupport"
:pt="{
label: {
class: 'text-text-secondary'
},
icon: {
class: 'text-text-secondary text-xs'
}
}"
@click="handleMessageSupport"
/>
</div>
@@ -269,14 +189,6 @@
icon="pi pi-external-link"
icon-pos="right"
class="text-xs"
:pt="{
label: {
class: 'text-text-secondary'
},
icon: {
class: 'text-text-secondary text-xs'
}
}"
@click="handleInvoiceHistory"
/>
</div>
@@ -286,16 +198,26 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel'
import { computed, onMounted, ref } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
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 { cn } from '@/utils/tailwindUtil'
import type { AuditLog } from '@/services/customerEventsService'
import { useCustomerEventsService } from '@/services/customerEventsService'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
const dialogService = useDialogService()
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const authStore = useFirebaseAuthStore()
const customerEventService = useCustomerEventsService()
const {
isActiveSubscription,
@@ -304,20 +226,54 @@ const {
formattedEndDate,
formattedMonthlyPrice,
manageSubscription,
handleInvoiceHistory
handleViewUsageHistory,
handleLearnMore,
handleInvoiceHistory,
fetchStatus
} = useSubscription()
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()
const latestEvents = ref<AuditLog[]>([])
const {
isLoadingSupport,
refreshTooltip,
handleAddApiCredits,
handleMessageSupport,
handleRefresh,
handleLearnMoreClick
} = useSubscriptionActions()
const totalCredits = computed(() => {
if (!authStore.balance) return '0.00'
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
})
const isLoadingBalance = computed(() => authStore.isFetchingBalance)
const fetchLatestEvents = async () => {
try {
const response = await customerEventService.getMyEvents({
page: 1,
limit: 2
})
if (response?.events) {
latestEvents.value = response.events
}
} catch (error) {
console.error('[SubscriptionPanel] Error fetching latest events:', error)
}
}
onMounted(() => {
void handleRefresh()
})
const handleAddApiCredits = () => {
dialogService.showTopUpCreditsDialog()
}
const handleMessageSupport = async () => {
await commandStore.execute('Comfy.ContactSupport')
}
const handleRefresh = async () => {
await Promise.all([
authActions.fetchBalance(),
fetchStatus(),
fetchLatestEvents()
])
}
</script>
<style scoped>

View File

@@ -1,15 +1,5 @@
<template>
<div class="relative grid h-full grid-cols-5">
<!-- Custom close button -->
<Button
icon="pi pi-times"
text
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"
/>
<div class="grid h-full grid-cols-5 px-10 pb-10">
<div
class="relative col-span-2 flex items-center justify-center overflow-hidden rounded-sm"
>
@@ -18,7 +8,7 @@
loop
muted
playsinline
class="h-full min-w-[125%] object-cover p-0"
class="h-full min-w-[125%] object-cover"
style="margin-left: -20%"
>
<source
@@ -28,18 +18,17 @@
</video>
</div>
<div class="col-span-3 flex flex-col justify-between p-8">
<div class="col-span-3 flex flex-col justify-between pl-8">
<div>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-4">
<div class="inline-flex items-center gap-2">
<div class="text-text-primary text-sm text-muted">
<div class="text-sm text-muted">
{{ $t('subscription.required.title') }}
</div>
<CloudBadge
reverse-order
no-padding
background-color="var(--p-dialog-background)"
use-subscription
/>
</div>
@@ -52,36 +41,19 @@
<SubscriptionBenefits class="mt-6 text-muted" />
</div>
<div class="flex flex-col pt-8">
<SubscribeButton
class="rounded-lg px-4 py-2"
:pt="{
root: {
style: 'background: var(--color-accent-blue, #0B8CE9);'
},
label: {
class: 'font-inter font-[700] text-sm'
}
}"
@subscribed="handleSubscribed"
/>
<div class="flex flex-col">
<SubscribeButton @subscribed="handleSubscribed" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import CloudBadge from '@/components/topbar/CloudBadge.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'
defineProps<{
onClose: () => void
}>()
const emit = defineEmits<{
close: [subscribed: boolean]
}>()

View File

@@ -1,4 +1,3 @@
import { createSharedComposable } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
@@ -15,30 +14,34 @@ import {
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
type CloudSubscriptionCheckoutResponse = {
interface CloudSubscriptionCheckoutResponse {
checkout_url: string
}
type CloudSubscriptionStatusResponse = {
interface CloudSubscriptionStatusResponse {
is_active: boolean
subscription_id: string
renewal_date: string | null
end_date?: string | null
}
function useSubscriptionInternal() {
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
const isSubscribedOrIsNotCloud = computed(() => {
if (!isCloud || !window.__CONFIG__?.subscription_required) return true
const isActiveSubscription = computed(() => {
if (!isCloud || !window.__CONFIG__?.subscription_required) return true
return subscriptionStatus.value?.is_active ?? false
})
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
return subscriptionStatus.value?.is_active ?? false
})
let isWatchSetup = false
export function useSubscription() {
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
const { getAuthHeader } = useFirebaseAuthStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { reportError } = useFirebaseAuthActions()
const { isLoggedIn } = useCurrentUser()
@@ -51,7 +54,7 @@ function useSubscriptionInternal() {
const renewalDate = new Date(subscriptionStatus.value.renewal_date)
return renewalDate.toLocaleDateString(undefined, {
return renewalDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
@@ -63,7 +66,7 @@ function useSubscriptionInternal() {
const endDate = new Date(subscriptionStatus.value.end_date)
return endDate.toLocaleDateString(undefined, {
return endDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
@@ -74,10 +77,9 @@ function useSubscriptionInternal() {
() => `$${MONTHLY_SUBSCRIPTION_PRICE.toFixed(0)}`
)
const fetchStatus = wrapWithErrorHandlingAsync(
fetchSubscriptionStatus,
reportError
)
const fetchStatus = wrapWithErrorHandlingAsync(async () => {
return await fetchSubscriptionStatus()
}, reportError)
const subscribe = wrapWithErrorHandlingAsync(async () => {
const response = await initiateSubscriptionCheckout()
@@ -98,17 +100,17 @@ function useSubscriptionInternal() {
useTelemetry()?.trackSubscription('modal_opened')
}
void dialogService.showSubscriptionRequiredDialog()
dialogService.showSubscriptionRequiredDialog()
}
const manageSubscription = async () => {
await accessBillingPortal()
await authActions.accessBillingPortal()
}
const requireActiveSubscription = async (): Promise<void> => {
await fetchSubscriptionStatus()
if (!isSubscribedOrIsNotCloud.value) {
if (!isActiveSubscription.value) {
showSubscriptionDialog()
}
}
@@ -122,55 +124,61 @@ function useSubscriptionInternal() {
}
const handleInvoiceHistory = async () => {
await accessBillingPortal()
await authActions.accessBillingPortal()
}
/**
* Fetch the current cloud subscription status for the authenticated user
* @returns Subscription status or null if no subscription exists
*/
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const fetchSubscriptionStatus =
async (): Promise<CloudSubscriptionStatusResponse | null> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(
t('toastMessages.userNotAuthenticated')
)
}
const response = await fetch(
`${COMFY_API_BASE_URL}/customers/cloud-subscription-status`,
{
headers: {
...authHeader,
'Content-Type': 'application/json'
const response = await fetch(
`${COMFY_API_BASE_URL}/customers/cloud-subscription-status`,
{
headers: {
...authHeader,
'Content-Type': 'application/json'
}
}
}
)
if (!response.ok) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
t('toastMessages.failedToFetchSubscription', {
error: errorData.message
})
)
if (!response.ok) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
t('toastMessages.failedToFetchSubscription', {
error: errorData.message
})
)
}
const statusData = await response.json()
subscriptionStatus.value = statusData
return statusData
}
const statusData = await response.json()
subscriptionStatus.value = statusData
return statusData
if (!isWatchSetup) {
isWatchSetup = true
watch(
() => isLoggedIn.value,
async (loggedIn) => {
if (loggedIn) {
await fetchSubscriptionStatus()
} else {
subscriptionStatus.value = null
}
},
{ immediate: true }
)
}
watch(
() => isLoggedIn.value,
async (loggedIn) => {
if (loggedIn) {
await fetchSubscriptionStatus()
} else {
subscriptionStatus.value = null
}
},
{ immediate: true }
)
const initiateSubscriptionCheckout =
async (): Promise<CloudSubscriptionCheckoutResponse> => {
const authHeader = await getAuthHeader()
@@ -205,7 +213,7 @@ function useSubscriptionInternal() {
return {
// State
isActiveSubscription: isSubscribedOrIsNotCloud,
isActiveSubscription,
isCancelled,
formattedRenewalDate,
formattedEndDate,
@@ -222,5 +230,3 @@ function useSubscriptionInternal() {
handleInvoiceHistory
}
}
export const useSubscription = createSharedComposable(useSubscriptionInternal)

View File

@@ -1,66 +0,0 @@
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
/**
* 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 { fetchStatus, formattedRenewalDate } = useSubscription()
const isLoadingSupport = ref(false)
const refreshTooltip = computed(() => {
const date =
formattedRenewalDate.value || t('subscription.nextBillingCycle')
return `Refreshes on ${date}`
})
onMounted(() => {
void handleRefresh()
})
const handleAddApiCredits = () => {
dialogService.showTopUpCreditsDialog()
}
const handleMessageSupport = async () => {
try {
isLoadingSupport.value = true
await commandStore.execute('Comfy.ContactSupport')
} catch (error) {
console.error('[useSubscriptionActions] Error contacting support:', error)
} finally {
isLoadingSupport.value = false
}
}
const handleRefresh = async () => {
try {
await Promise.all([authActions.fetchBalance(), fetchStatus()])
} catch (error) {
console.error('[useSubscriptionActions] Error refreshing data:', error)
}
}
const handleLearnMoreClick = () => {
window.open('https://docs.comfy.org/get_started/cloud', '_blank')
}
return {
isLoadingSupport,
refreshTooltip,
handleAddApiCredits,
handleMessageSupport,
handleRefresh,
handleLearnMoreClick
}
}

View File

@@ -1,61 +0,0 @@
import { computed } from 'vue'
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 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 monthlyBonusCredits = computed(() => {
const balance = authStore.balance as any
if (!balance?.cloud_credit_balance_micros) return '0.00'
try {
return formatMetronomeCurrency(balance.cloud_credit_balance_micros, 'usd')
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting monthly bonus credits:',
error
)
return '0.00'
}
})
const prepaidCredits = computed(() => {
const balance = authStore.balance as any
if (!balance?.prepaid_balance_micros) return '0.00'
try {
return formatMetronomeCurrency(balance.prepaid_balance_micros, 'usd')
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting prepaid credits:',
error
)
return '0.00'
}
})
const isLoadingBalance = computed(() => authStore.isFetchingBalance)
return {
totalCredits,
monthlyBonusCredits,
prepaidCredits,
isLoadingBalance
}
}

View File

@@ -1,38 +0,0 @@
import { defineAsyncComponent } from 'vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
const DIALOG_KEY = 'subscription-required'
export const useSubscriptionDialog = () => {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show() {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: defineAsyncComponent(
() =>
import(
'@/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue'
)
),
props: {
onClose: hide
},
dialogComponentProps: {
style: 'width: 700px;'
}
})
}
return {
show,
hide
}
}

View File

@@ -1,67 +1,13 @@
<template>
<div class="flex h-full items-center justify-center p-6">
<div class="max-w-[100vw] text-center lg:w-[500px]">
<h2 class="mb-3 text-xl text-text-primary">
<div class="flex h-full items-center justify-center p-8">
<div class="max-w-[100vw] text-center lg:w-96">
<h2 class="mb-4 text-xl">
{{ $t('cloudOnboarding.authTimeout.title') }}
</h2>
<p class="mb-5 text-muted">
<p class="mb-6 text-gray-600">
{{ $t('cloudOnboarding.authTimeout.message') }}
</p>
<!-- Troubleshooting Section -->
<div
class="mb-4 rounded bg-surface-700 px-3 py-2 text-left dark-theme:bg-surface-800"
>
<h3 class="mb-2 text-sm font-semibold text-text-primary">
{{ $t('cloudOnboarding.authTimeout.troubleshooting') }}
</h3>
<ul class="space-y-1.5 text-sm text-muted">
<li
v-for="(cause, index) in $tm('cloudOnboarding.authTimeout.causes')"
:key="index"
class="flex gap-2"
>
<span></span>
<span>{{ cause }}</span>
</li>
</ul>
</div>
<!-- Technical Details (Collapsible) -->
<div v-if="errorMessage" class="mb-4 text-left">
<button
class="flex w-full items-center justify-between rounded bg-surface-600 px-4 py-2 text-sm text-muted transition-colors hover:bg-surface-500 dark-theme:bg-surface-700 dark-theme:hover:bg-surface-600"
@click="showTechnicalDetails = !showTechnicalDetails"
>
<span>{{ $t('cloudOnboarding.authTimeout.technicalDetails') }}</span>
<i
:class="[
'pi',
showTechnicalDetails ? 'pi-chevron-up' : 'pi-chevron-down'
]"
></i>
</button>
<div
v-if="showTechnicalDetails"
class="mt-2 rounded bg-surface-800 p-4 font-mono text-xs text-muted break-all dark-theme:bg-surface-900"
>
{{ errorMessage }}
</div>
</div>
<!-- Helpful Links -->
<p class="mb-5 text-center text-sm text-gray-600">
{{ $t('cloudOnboarding.authTimeout.helpText') }}
<a
href="https://support.comfy.org"
class="cursor-pointer text-blue-400 no-underline"
target="_blank"
rel="noopener noreferrer"
>
{{ $t('cloudOnboarding.authTimeout.supportLink') }}</a
>.
</p>
<div class="flex flex-col gap-3">
<Button
:label="$t('cloudOnboarding.authTimeout.restart')"
@@ -75,20 +21,12 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
interface Props {
errorMessage?: string
}
defineProps<Props>()
const router = useRouter()
const { logout } = useFirebaseAuthActions()
const showTechnicalDetails = ref(false)
const handleRestart = async () => {
await logout()

View File

@@ -98,10 +98,7 @@ const inviteCode = computed(() => route.query.inviteCode as string)
const onSwitchAccounts = () => {
void router.push({
name: 'cloud-login',
query: {
switchAccount: 'true',
inviteCode: inviteCode.value
}
query: { inviteCode: inviteCode.value }
})
}
const onClickSupport = () => {

View File

@@ -17,16 +17,23 @@ onMounted(async () => {
const inviteCode = route.params.code as string | undefined
if (firebaseAuthStore.isAuthenticated) {
// User is logged in - no email verification check needed
if (inviteCode) {
// Handle invite code flow - go to invite check
await router.push({
name: 'cloud-invite-check',
query: { inviteCode }
})
const { isEmailVerified } = firebaseAuthStore
if (!isEmailVerified) {
// User is logged in but email not verified
await router.push({ name: 'cloud-verify-email', query: { inviteCode } })
} else {
// Normal login flow - go to user check
await router.push({ name: 'cloud-user-check' })
// User is logged in and verified
if (inviteCode) {
// Handle invite code flow - go to invite check
await router.push({
name: 'cloud-invite-check',
query: { inviteCode }
})
} else {
// Normal login flow - go to user check
await router.push({ name: 'cloud-user-check' })
}
}
} else {
// User is not logged in - proceed to login page

View File

@@ -115,6 +115,7 @@ import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthAction
import CloudSignInForm from '@/platform/onboarding/cloud/components/CloudSignInForm.vue'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SignInData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { translateAuthError } from '@/utils/authErrorTranslation'
const { t } = useI18n()
@@ -139,15 +140,20 @@ const onSuccess = async () => {
})
// Check if there's an invite code
const inviteCode = route.query.inviteCode as string | undefined
if (inviteCode) {
// Handle invite code flow - go to invite check
await router.push({
name: 'cloud-invite-check',
query: { inviteCode }
})
const { isEmailVerified } = useFirebaseAuthStore()
if (!isEmailVerified) {
await router.push({ name: 'cloud-verify-email', query: { inviteCode } })
} else {
// Normal login flow - go to user check
await router.push({ name: 'cloud-user-check' })
if (inviteCode) {
// Handle invite code flow - go to invite check
await router.push({
name: 'cloud-invite-check',
query: { inviteCode }
})
} else {
// Normal login flow - go to user check
await router.push({ name: 'cloud-user-check' })
}
}
}

View File

@@ -104,6 +104,7 @@ import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SignUpData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { translateAuthError } from '@/utils/authErrorTranslation'
import { isInChina } from '@/utils/networkUtil'
@@ -126,8 +127,20 @@ const onSuccess = async () => {
summary: 'Sign up Completed',
life: 2000
})
// Direct redirect to main app - email verification removed
await router.push({ path: '/', query: route.query })
// Check if email verification is needed
const { isEmailVerified } = useFirebaseAuthStore()
const inviteCode = route.query.inviteCode as string | undefined
if (!isEmailVerified) {
// Redirect to email verification with fromAuth flag
await router.push({
name: 'cloud-verify-email',
query: { inviteCode, fromAuth: 'true' }
})
} else {
// The invite code will be handled after the user is logged in
await router.push({ path: '/', query: route.query })
}
}
// Custom error handler for inline display

View File

@@ -0,0 +1,191 @@
<template>
<div class="mx-auto max-w-[640px] px-6 py-8">
<!-- Back button -->
<button
type="button"
class="text-foreground/80 flex size-10 items-center justify-center rounded-lg border border-white bg-transparent"
aria-label="{{ t('cloudVerifyEmail_back') }}"
@click="goBack"
>
<i class="pi pi-arrow-left" />
</button>
<!-- Title -->
<h1 class="mt-8 text-2xl font-semibold">
{{ t('cloudVerifyEmail_title') }}
</h1>
<!-- Body copy -->
<p class="text-foreground/80 mt-6 mb-0 text-base">
{{ t('cloudVerifyEmail_sent') }}
</p>
<p class="mt-2 text-base font-medium">{{ authStore.userEmail }}</p>
<p class="text-foreground/80 mt-6 text-base whitespace-pre-line">
{{ t('cloudVerifyEmail_clickToContinue') }}
</p>
<p class="text-foreground/80 mt-6 text-base whitespace-pre-line">
{{ t('cloudVerifyEmail_tip') }}
</p>
<p class="text-foreground/80 mt-6 mb-0 text-base">
{{ t('cloudVerifyEmail_didntReceive') }}
</p>
<p class="text-foreground/80 mt-1 text-base">
<span class="cursor-pointer text-blue-400 no-underline" @click="onSend">
{{ t('cloudVerifyEmail_resend') }}</span
>
</p>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useFirebaseAuth } from 'vuefire'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const auth = useFirebaseAuth()!
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const toastStore = useToastStore()
let intervalId: number | null = null
let timeoutId: number | null = null
const redirectInProgress = ref(false)
function clearPolling(): void {
if (intervalId !== null) {
clearInterval(intervalId)
intervalId = null
}
if (timeoutId !== null) {
clearTimeout(timeoutId)
timeoutId = null
}
}
async function redirectToNextStep(): Promise<void> {
if (redirectInProgress.value) return
redirectInProgress.value = true
clearPolling()
const inviteCode = route.query.inviteCode as string | undefined
if (inviteCode) {
await router.push({
name: 'cloud-invite-check',
query: { inviteCode }
})
} else {
await router.push({ name: 'cloud-user-check' })
}
}
const goBack = async () => {
const inviteCode = route.query.inviteCode as string | undefined
const authStore = useFirebaseAuthStore()
// If the user is already verified (email link already clicked),
// continue to the next step automatically.
if (authStore.isEmailVerified) {
await router.push({
name: 'cloud-invite-check',
query: inviteCode ? { inviteCode } : {}
})
} else {
await router.push({
name: 'cloud-login',
query: {
inviteCode
}
})
}
}
async function onSend() {
try {
await authStore.verifyEmail()
// Track email verification requested
if (isCloud) {
useTelemetry()?.trackEmailVerification('requested')
}
toastStore.add({
severity: 'info',
summary: t('cloudVerifyEmail_toast_title'),
detail: t('cloudVerifyEmail_toast_summary'),
life: 2000
})
} catch (e) {
toastStore.add({
severity: 'error',
summary: t('cloudVerifyEmail_toast_failed'),
life: 2000
})
}
}
onMounted(async () => {
// Track email verification screen opened
if (isCloud) {
useTelemetry()?.trackEmailVerification('opened')
}
// If the user is already verified (email link already clicked),
// continue to the next step automatically.
if (authStore.isEmailVerified) {
return redirectToNextStep()
}
// Only send verification email automatically if coming from signup/login flow
// Check if 'fromAuth' query parameter is present
const fromAuth = route.query.fromAuth === 'true'
if (fromAuth) {
await onSend()
// Remove fromAuth query parameter after sending email to prevent re-sending on refresh
const { fromAuth: _, ...remainingQuery } = route.query
await router.replace({
name: route.name as string,
query: remainingQuery
})
}
// Start polling to check email verification status
intervalId = window.setInterval(async () => {
if (auth.currentUser && !redirectInProgress.value) {
await auth.currentUser.reload()
if (auth.currentUser?.emailVerified) {
// Track email verification completed
if (isCloud) {
useTelemetry()?.trackEmailVerification('completed')
}
void redirectToNextStep()
}
}
}, 5000) // Check every 5 seconds
// Stop polling after 5 minutes
timeoutId = window.setTimeout(
() => {
clearPolling()
},
5 * 60 * 1000
)
})
onUnmounted(() => {
clearPolling()
})
</script>

View File

@@ -52,8 +52,7 @@ const router = useRouter()
const onSwitchAccounts = () => {
void router.push({
name: 'cloud-login',
query: { switchAccount: 'true' }
name: 'cloud-login'
})
}

View File

@@ -61,25 +61,19 @@ const {
return
}
// Survey is required for all users
if (!surveyStatus) {
skeletonType.value = 'survey'
await router.replace({ name: 'cloud-survey' })
return
}
// Check if we should enforce whitelist requirement
const requireWhitelist = window.__CONFIG__?.require_whitelist ?? true
// Check feature flag and redirect non-active users if whitelist is required
if (requireWhitelist && cloudUserStats.status !== 'active') {
// Feature flag ON: Show waitlist page for non-active users
if (cloudUserStats.status !== 'active') {
skeletonType.value = 'waitlist'
await router.replace({ name: 'cloud-waitlist' })
return
}
// User is fully onboarded (active or whitelist check disabled)
// User is fully onboarded
window.location.href = '/'
}),
null,

View File

@@ -14,7 +14,6 @@ type ServerHealthAlert = {
*/
export type RemoteConfig = {
mixpanel_token?: string
require_whitelist?: boolean
subscription_required?: boolean
server_health_alert?: ServerHealthAlert
}

View File

@@ -2,24 +2,15 @@ import type { OverridedMixpanel } from 'mixpanel-browser'
import type {
AuthMetadata,
CreditTopupMetadata,
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SurveyResponses,
TabCountMetadata,
TelemetryEventName,
TelemetryEventProperties,
TelemetryProvider,
TemplateFilterMetadata,
TemplateLibraryClosedMetadata,
TemplateLibraryMetadata,
TemplateMetadata,
WorkflowImportMetadata
TemplateMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
@@ -283,27 +274,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(eventName)
}
trackAddApiCreditButtonClicked(): void {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}
trackMonthlySubscriptionSucceeded(): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
const metadata: CreditTopupMetadata = {
credit_amount: amount
}
this.trackEvent(
TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED,
metadata
)
}
trackApiCreditTopupSucceeded(): void {
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
}
trackRunButton(options?: { subscribe_to_run?: boolean }): void {
if (this.isOnboardingMode) {
// During onboarding, track basic run button click without workflow context
@@ -377,42 +347,28 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
}
}
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
let eventName: TelemetryEventName
switch (stage) {
case 'opened':
eventName = TelemetryEvents.USER_EMAIL_VERIFY_OPENED
break
case 'requested':
eventName = TelemetryEvents.USER_EMAIL_VERIFY_REQUESTED
break
case 'completed':
eventName = TelemetryEvents.USER_EMAIL_VERIFY_COMPLETED
break
}
this.trackEvent(eventName)
}
trackTemplate(metadata: TemplateMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_WORKFLOW_OPENED, metadata)
}
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_LIBRARY_OPENED, metadata)
}
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_LIBRARY_CLOSED, metadata)
}
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_IMPORTED, metadata)
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
}
trackTabCount(metadata: TabCountMetadata): void {
this.trackEvent(TelemetryEvents.TAB_COUNT_TRACKING, metadata)
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.trackEvent(TelemetryEvents.NODE_SEARCH, metadata)
}
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void {
this.trackEvent(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata)
}
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
}
trackWorkflowExecution(): void {
if (this.isOnboardingMode) {
// During onboarding, track basic execution without workflow context

View File

@@ -89,83 +89,6 @@ export interface TemplateMetadata {
template_license?: string
}
/**
* Credit topup metadata
*/
export interface CreditTopupMetadata {
credit_amount: number
}
/**
* Workflow import metadata
*/
export interface WorkflowImportMetadata {
missing_node_count: number
missing_node_types: string[]
}
/**
* Template library metadata
*/
export interface TemplateLibraryMetadata {
source: 'sidebar' | 'menu' | 'command'
}
/**
* Template library closed metadata
*/
export interface TemplateLibraryClosedMetadata {
template_selected: boolean
time_spent_seconds: number
}
/**
* Page visibility metadata
*/
export interface PageVisibilityMetadata {
visibility_state: 'visible' | 'hidden'
}
/**
* Tab count metadata
*/
export interface TabCountMetadata {
tab_count: number
}
/**
* Node search metadata
*/
export interface NodeSearchMetadata {
query: string
}
/**
* Node search result selection metadata
*/
export interface NodeSearchResultMetadata {
node_type: string
last_query: string
}
/**
* Template filter tracking metadata
*/
export interface TemplateFilterMetadata {
search_query?: string
selected_models: string[]
selected_use_cases: string[]
selected_licenses: string[]
sort_by:
| 'default'
| 'alphabetical'
| 'newest'
| 'vram-low-to-high'
| 'model-size-low-to-high'
filtered_count: number
total_count: number
}
/**
* Core telemetry provider interface
*/
@@ -176,35 +99,16 @@ export interface TelemetryProvider {
// Subscription flow events
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
trackMonthlySubscriptionSucceeded(): void
trackAddApiCreditButtonClicked(): void
trackApiCreditTopupButtonPurchaseClicked(amount: number): void
trackApiCreditTopupSucceeded(): void
trackRunButton(options?: { subscribe_to_run?: boolean }): void
// Survey flow events
trackSurvey(stage: 'opened' | 'submitted', responses?: SurveyResponses): void
// Email verification events
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void
// Template workflow events
trackTemplate(metadata: TemplateMetadata): void
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void
// Workflow management events
trackWorkflowImported(metadata: WorkflowImportMetadata): void
// Page visibility events
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void
// Tab tracking events
trackTabCount(metadata: TabCountMetadata): void
// Node search analytics events
trackNodeSearch(metadata: NodeSearchMetadata): void
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void
// Template filter tracking events
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void
// Workflow execution events
trackWorkflowExecution(): void
@@ -232,36 +136,18 @@ export const TelemetryEvents = {
RUN_BUTTON_CLICKED: 'app:run_button_click',
SUBSCRIPTION_REQUIRED_MODAL_OPENED: 'app:subscription_required_modal_opened',
SUBSCRIBE_NOW_BUTTON_CLICKED: 'app:subscribe_now_button_clicked',
MONTHLY_SUBSCRIPTION_SUCCEEDED: 'app:monthly_subscription_succeeded',
ADD_API_CREDIT_BUTTON_CLICKED: 'app:add_api_credit_button_clicked',
API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED:
'app:api_credit_topup_button_purchase_clicked',
API_CREDIT_TOPUP_SUCCEEDED: 'app:api_credit_topup_succeeded',
// Onboarding Survey
USER_SURVEY_OPENED: 'app:user_survey_opened',
USER_SURVEY_SUBMITTED: 'app:user_survey_submitted',
// Email Verification
USER_EMAIL_VERIFY_OPENED: 'app:user_email_verify_opened',
USER_EMAIL_VERIFY_REQUESTED: 'app:user_email_verify_requested',
USER_EMAIL_VERIFY_COMPLETED: 'app:user_email_verify_completed',
// Template Tracking
TEMPLATE_WORKFLOW_OPENED: 'app:template_workflow_opened',
TEMPLATE_LIBRARY_OPENED: 'app:template_library_opened',
TEMPLATE_LIBRARY_CLOSED: 'app:template_library_closed',
// Workflow Management
WORKFLOW_IMPORTED: 'app:workflow_imported',
// Page Visibility
PAGE_VISIBILITY_CHANGED: 'app:page_visibility_changed',
// Tab Tracking
TAB_COUNT_TRACKING: 'app:tab_count_tracking',
// Node Search Analytics
NODE_SEARCH: 'app:node_search',
NODE_SEARCH_RESULT_SELECTED: 'app:node_search_result_selected',
// Template Filter Analytics
TEMPLATE_FILTER_CHANGED: 'app:template_filter_changed',
// Execution Lifecycle
EXECUTION_START: 'execution_start',
@@ -283,12 +169,3 @@ export type TelemetryEventProperties =
| RunButtonProperties
| ExecutionErrorMetadata
| ExecutionSuccessMetadata
| CreditTopupMetadata
| WorkflowImportMetadata
| TemplateLibraryMetadata
| TemplateLibraryClosedMetadata
| PageVisibilityMetadata
| TabCountMetadata
| NodeSearchMetadata
| NodeSearchResultMetadata
| TemplateFilterMetadata

View File

@@ -129,13 +129,8 @@ router.beforeEach(async (to, _from, next) => {
})
} catch (error) {
console.error('Auth initialization failed:', error)
// Navigate to auth timeout recovery page with error details
return next({
name: 'cloud-auth-timeout',
params: {
errorMessage: error instanceof Error ? error.message : String(error)
}
})
// Navigate to auth timeout recovery page
return next({ name: 'cloud-auth-timeout' })
}
}
@@ -183,28 +178,32 @@ router.beforeEach(async (to, _from, next) => {
// User is logged in - check if they need onboarding
// For root path, check actual user status to handle waitlisted users
if (!isElectron() && isLoggedIn && to.path === '/') {
// Import auth functions dynamically to avoid circular dependency
const { getUserCloudStatus, getSurveyCompletedStatus } = await import(
'@/api/auth'
)
try {
// Check email verification first
const authStore = useFirebaseAuthStore()
if (!authStore.isEmailVerified) {
// Don't pass fromAuth here since this is from root navigation, not auth flow
return next({ name: 'cloud-verify-email' })
}
// Import auth functions dynamically to avoid circular dependency
const { getUserCloudStatus, getSurveyCompletedStatus } = await import(
'@/api/auth'
)
// Check user's actual status
const userStatus = await getUserCloudStatus()
const surveyCompleted = await getSurveyCompletedStatus()
// Survey is required for all users regardless of whitelist status
if (!surveyCompleted) {
return next({ name: 'cloud-survey' })
// If user is not active (waitlisted), redirect based on survey status
if (userStatus.status !== 'active') {
if (!surveyCompleted) {
return next({ name: 'cloud-survey' })
} else {
return next({ name: 'cloud-waitlist' })
}
}
// Check if we should enforce whitelist requirement
const requireWhitelist = window.__CONFIG__?.require_whitelist ?? true
// Check feature flag and redirect non-active users if whitelist is required
if (requireWhitelist && userStatus.status !== 'active') {
return next({ name: 'cloud-waitlist' })
}
// User is active or whitelist check disabled: Allow access to root
// User is active, allow access to root
} catch (error) {
console.error('Failed to check user status:', error)
// On error, redirect to user-check as fallback

View File

@@ -542,10 +542,9 @@ export class ComfyApi extends EventTarget {
let existingSession = window.name
// Get auth token if available
// Force refresh on reconnect to avoid stale tokens
let authToken: string | undefined
try {
authToken = await useFirebaseAuthStore().getIdToken(isReconnect)
authToken = await useFirebaseAuthStore().getIdToken()
} catch (error) {
// Continue without auth token if there's an error
console.warn('Could not get auth token for WebSocket connection:', error)

View File

@@ -179,7 +179,7 @@ export const useCustomerEventsService = () => {
return null
}
const result = await executeRequest<CustomerEventsResponse>(
return executeRequest<CustomerEventsResponse>(
() =>
customerApiClient.get('/customers/events', {
params: { page, limit },
@@ -187,8 +187,6 @@ export const useCustomerEventsService = () => {
}),
{ errorContext, routeSpecificErrors }
)
return result
}
return {

View File

@@ -1,4 +1,5 @@
import { merge } from 'es-toolkit/compat'
import { defineAsyncComponent } from 'vue'
import type { Component } from 'vue'
import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue'
@@ -69,7 +70,6 @@ export const useDialogService = () => {
| 'server-config'
| 'user'
| 'credits'
| 'subscription'
) {
const props = panel ? { props: { defaultPanel: panel } } : undefined
@@ -487,16 +487,35 @@ export const useDialogService = () => {
})
}
async function showSubscriptionRequiredDialog() {
function showSubscriptionRequiredDialog() {
if (!isCloud || !window.__CONFIG__?.subscription_required) {
return
}
const { useSubscriptionDialog } = await import(
'@/platform/cloud/subscription/composables/useSubscriptionDialog'
)
const { show } = useSubscriptionDialog()
show()
dialogStore.showDialog({
key: 'subscription-required',
component: defineAsyncComponent(
() =>
import(
'@/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue'
)
),
props: {
onClose: () => {
dialogStore.closeDialog({ key: 'subscription-required' })
}
},
dialogComponentProps: {
closable: true,
style: 'width: 700px;',
pt: {
header: { class: '!p-0 !m-0' },
content: {
class: 'overflow-hidden !p-0 !m-0'
}
}
}
})
}
return {

View File

@@ -9,6 +9,7 @@ import {
getAdditionalUserInfo,
onAuthStateChanged,
onIdTokenChanged,
sendEmailVerification,
sendPasswordResetEmail,
setPersistence,
signInWithEmailAndPassword,
@@ -81,6 +82,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const isAuthenticated = computed(() => !!currentUser.value)
const userEmail = computed(() => currentUser.value?.email)
const userId = computed(() => currentUser.value?.uid)
const isEmailVerified = computed(
() => currentUser.value?.emailVerified ?? false
)
// Get auth from VueFire and listen for auth state changes
// From useFirebaseAuth docs:
@@ -106,12 +110,10 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
}
})
const getIdToken = async (
forceRefresh = false
): Promise<string | undefined> => {
const getIdToken = async (): Promise<string | undefined> => {
if (!currentUser.value) return
try {
return await currentUser.value.getIdToken(forceRefresh)
return await currentUser.value.getIdToken()
} catch (error: unknown) {
if (
error instanceof FirebaseError &&
@@ -142,11 +144,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
* - An ApiKeyAuthHeader with X-API-KEY if API key exists
* - null if neither authentication method is available
*/
const getAuthHeader = async (
forceRefresh = false
): Promise<AuthHeader | null> => {
const getAuthHeader = async (): Promise<AuthHeader | null> => {
// If available, set header with JWT used to identify the user to Firebase service
const token = await getIdToken(forceRefresh)
const token = await getIdToken()
if (token) {
return {
Authorization: `Bearer ${token}`
@@ -349,6 +349,14 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
await updatePassword(currentUser.value, newPassword)
}
/** Send email verification to current user */
const verifyEmail = async (): Promise<void> => {
if (!currentUser.value) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
await sendEmailVerification(currentUser.value)
}
/** Delete the current user account */
const _deleteAccount = async (): Promise<void> => {
if (!currentUser.value) {
@@ -432,6 +440,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
// State
loading,
currentUser,
isEmailVerified,
isInitialized,
balance,
lastBalanceUpdateTime,
@@ -457,6 +466,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
sendPasswordReset,
updatePassword: _updatePassword,
getAuthHeader,
verifyEmail,
deleteAccount: _deleteAccount
}
})

View File

@@ -1,155 +0,0 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { useTelemetry } from '@/platform/telemetry'
import type { AuditLog } from '@/services/customerEventsService'
import {
EventType,
useCustomerEventsService
} from '@/services/customerEventsService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
type PendingTopupRecord = {
startedAtIso: string
amountUsd?: number
expectedCents?: number
}
const storageKeyForUser = (userId: string) => `topupTracker:pending:${userId}`
export const useTopupTrackerStore = defineStore('topupTracker', () => {
const telemetry = useTelemetry()
const authStore = useFirebaseAuthStore()
const pendingTopup = ref<PendingTopupRecord | null>(null)
const storageListenerInitialized = ref(false)
const loadFromStorage = () => {
const userId = authStore.userId
if (!userId) return
try {
const rawValue = localStorage.getItem(storageKeyForUser(userId))
if (!rawValue) return
const parsedValue = JSON.parse(rawValue) as PendingTopupRecord
pendingTopup.value = parsedValue
} catch {
pendingTopup.value = null
}
}
const persistToStorage = () => {
const userId = authStore.userId
if (!userId) return
if (pendingTopup.value) {
localStorage.setItem(
storageKeyForUser(userId),
JSON.stringify(pendingTopup.value)
)
} else {
localStorage.removeItem(storageKeyForUser(userId))
}
}
const initializeStorageSynchronization = () => {
if (storageListenerInitialized.value) return
storageListenerInitialized.value = true
loadFromStorage()
window.addEventListener('storage', (e: StorageEvent) => {
const userId = authStore.userId
if (!userId) return
if (e.key === storageKeyForUser(userId)) {
loadFromStorage()
}
})
watch(
() => authStore.userId,
(newUserId, oldUserId) => {
if (newUserId && newUserId !== oldUserId) {
loadFromStorage()
return
}
if (!newUserId && oldUserId) {
pendingTopup.value = null
}
}
)
}
const startTopup = (amountUsd: number) => {
const userId = authStore.userId
if (!userId) return
const expectedCents = Math.round(amountUsd * 100)
pendingTopup.value = {
startedAtIso: new Date().toISOString(),
amountUsd,
expectedCents
}
persistToStorage()
}
const clearTopup = () => {
pendingTopup.value = null
persistToStorage()
}
const reconcileWithEvents = async (
events: AuditLog[] | undefined | null
): Promise<boolean> => {
if (!events || events.length === 0) return false
if (!pendingTopup.value) return false
const startedAt = new Date(pendingTopup.value.startedAtIso)
if (Number.isNaN(+startedAt)) {
clearTopup()
return false
}
const withinWindow = (createdAt: string) => {
const created = new Date(createdAt)
if (Number.isNaN(+created)) return false
const maxAgeMs = 1000 * 60 * 60 * 24
return (
created >= startedAt &&
created.getTime() - startedAt.getTime() <= maxAgeMs
)
}
let matched = events.filter((e) => {
if (e.event_type !== EventType.CREDIT_ADDED) return false
if (!e.createdAt || !withinWindow(e.createdAt)) return false
return true
})
if (pendingTopup.value.expectedCents != null) {
matched = matched.filter((e) =>
typeof e.params?.amount === 'number'
? e.params.amount === pendingTopup.value?.expectedCents
: true
)
}
if (matched.length === 0) return false
telemetry?.trackApiCreditTopupSucceeded()
await authStore.fetchBalance().catch(() => {})
clearTopup()
return true
}
const reconcileByFetchingEvents = async (): Promise<boolean> => {
const service = useCustomerEventsService()
const response = await service.getMyEvents({ page: 1, limit: 10 })
if (!response) return false
return await reconcileWithEvents(response.events)
}
initializeStorageSynchronization()
return {
pendingTopup,
startTopup,
clearTopup,
reconcileWithEvents,
reconcileByFetchingEvents
}
})

View File

@@ -1,213 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SubscriptionPanel from '@/platform/cloud/subscription/components/SubscriptionPanel.vue'
// Mock composables
const mockSubscriptionData = {
isActiveSubscription: false,
isCancelled: false,
formattedRenewalDate: '2024-12-31',
formattedEndDate: '2024-12-31',
formattedMonthlyPrice: '$9.99',
manageSubscription: vi.fn(),
handleInvoiceHistory: vi.fn()
}
const mockCreditsData = {
totalCredits: '10.00',
monthlyBonusCredits: '5.00',
prepaidCredits: '5.00',
isLoadingBalance: false
}
const mockActionsData = {
isLoadingSupport: false,
refreshTooltip: 'Refreshes on 2024-12-31',
handleAddApiCredits: vi.fn(),
handleMessageSupport: vi.fn(),
handleRefresh: vi.fn(),
handleLearnMoreClick: vi.fn()
}
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => mockSubscriptionData
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionCredits',
() => ({
useSubscriptionCredits: () => mockCreditsData
})
)
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionActions',
() => ({
useSubscriptionActions: () => mockActionsData
})
)
// Create i18n instance for testing
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subscription: {
title: 'Subscription',
perMonth: '/ month',
subscribeNow: 'Subscribe Now',
manageSubscription: 'Manage Subscription',
partnerNodesBalance: 'Partner Nodes Balance',
partnerNodesDescription: 'Credits for partner nodes',
totalCredits: 'Total Credits',
monthlyBonusDescription: 'Monthly bonus',
prepaidDescription: 'Prepaid credits',
monthlyCreditsRollover: 'Monthly credits rollover info',
prepaidCreditsInfo: 'Prepaid credits info',
viewUsageHistory: 'View Usage History',
addCredits: 'Add Credits',
yourPlanIncludes: 'Your plan includes',
learnMore: 'Learn More',
messageSupport: 'Message Support',
invoiceHistory: 'Invoice History',
renewsDate: 'Renews {date}',
expiresDate: 'Expires {date}'
}
}
}
})
function createWrapper(overrides = {}) {
return mount(SubscriptionPanel, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
CloudBadge: true,
SubscribeButton: true,
SubscriptionBenefits: true,
Button: {
template:
'<button @click="$emit(\'click\')" :disabled="loading" :data-testid="label" :data-icon="icon">{{ label }}</button>',
props: [
'loading',
'label',
'icon',
'text',
'severity',
'size',
'iconPos',
'pt'
],
emits: ['click']
},
Skeleton: {
template: '<div class="skeleton"></div>'
}
}
},
...overrides
})
}
describe('SubscriptionPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('subscription state functionality', () => {
it('shows correct UI for active subscription', () => {
mockSubscriptionData.isActiveSubscription = 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
const wrapper = createWrapper()
expect(wrapper.findComponent({ name: 'SubscribeButton' }).exists()).toBe(
true
)
expect(wrapper.text()).not.toContain('Manage Subscription')
expect(wrapper.text()).not.toContain('Add Credits')
})
it('shows renewal date for active non-cancelled subscription', () => {
mockSubscriptionData.isActiveSubscription = true
mockSubscriptionData.isCancelled = 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
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Expires 2024-12-31')
})
})
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
})
it('shows loading skeleton when fetching balance', () => {
mockCreditsData.isLoadingBalance = true
const wrapper = createWrapper()
expect(wrapper.findAll('.skeleton').length).toBeGreaterThan(0)
})
it('hides skeleton when balance loaded', () => {
mockCreditsData.isLoadingBalance = false
const wrapper = createWrapper()
expect(wrapper.findAll('.skeleton').length).toBe(0)
})
})
describe('action buttons', () => {
it('should call handleLearnMoreClick when learn more is clicked', async () => {
const wrapper = createWrapper()
const learnMoreButton = wrapper.find('[data-testid="Learn More"]')
await learnMoreButton.trigger('click')
expect(mockActionsData.handleLearnMoreClick).toHaveBeenCalledOnce()
})
it('should call handleMessageSupport when message support is clicked', async () => {
const wrapper = createWrapper()
const supportButton = wrapper.find('[data-testid="Message Support"]')
await supportButton.trigger('click')
expect(mockActionsData.handleMessageSupport).toHaveBeenCalledOnce()
})
it('should call handleRefresh when refresh button is clicked', async () => {
const wrapper = createWrapper()
// Find the refresh button by icon
const refreshButton = wrapper.find('[data-icon="pi pi-sync"]')
await refreshButton.trigger('click')
expect(mockActionsData.handleRefresh).toHaveBeenCalledOnce()
})
})
describe('loading states', () => {
it('should show loading state on support button when loading', () => {
mockActionsData.isLoadingSupport = true
const wrapper = createWrapper()
const supportButton = wrapper.find('[data-testid="Message Support"]')
expect(supportButton.attributes('disabled')).toBeDefined()
})
it('should show loading state on refresh button when loading balance', () => {
mockCreditsData.isLoadingBalance = true
const wrapper = createWrapper()
const refreshButton = wrapper.find('[data-icon="pi pi-sync"]')
expect(refreshButton.attributes('disabled')).toBeDefined()
})
})
})

View File

@@ -1,137 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
// Mock dependencies
const mockFetchBalance = vi.fn()
const mockFetchStatus = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockExecute = vi.fn()
const mockT = vi.fn((key: string) => {
if (key === 'subscription.nextBillingCycle') return 'next billing cycle'
return key
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: mockT
})
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => ({
fetchBalance: mockFetchBalance
})
}))
const mockFormattedRenewalDate = { value: '2024-12-31' }
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
fetchStatus: mockFetchStatus,
formattedRenewalDate: mockFormattedRenewalDate
})
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mockExecute
})
}))
// Mock window.open
const mockOpen = vi.fn()
Object.defineProperty(window, 'open', {
writable: true,
value: mockOpen
})
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 on 2024-12-31')
})
it('should use fallback text when no renewal date', () => {
mockFormattedRenewalDate.value = ''
const { refreshTooltip } = useSubscriptionActions()
expect(refreshTooltip.value).toBe('Refreshes on next billing cycle')
expect(mockT).toHaveBeenCalledWith('subscription.nextBillingCycle')
})
})
describe('handleAddApiCredits', () => {
it('should call showTopUpCreditsDialog', () => {
const { handleAddApiCredits } = useSubscriptionActions()
handleAddApiCredits()
expect(mockShowTopUpCreditsDialog).toHaveBeenCalledOnce()
})
})
describe('handleMessageSupport', () => {
it('should execute support command and manage loading state', async () => {
const { handleMessageSupport, isLoadingSupport } =
useSubscriptionActions()
expect(isLoadingSupport.value).toBe(false)
const promise = handleMessageSupport()
expect(isLoadingSupport.value).toBe(true)
await promise
expect(mockExecute).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(isLoadingSupport.value).toBe(false)
})
it('should handle errors gracefully', async () => {
mockExecute.mockRejectedValueOnce(new Error('Command failed'))
const { handleMessageSupport, isLoadingSupport } =
useSubscriptionActions()
await handleMessageSupport()
expect(isLoadingSupport.value).toBe(false)
})
})
describe('handleRefresh', () => {
it('should call both fetchBalance and fetchStatus', async () => {
const { handleRefresh } = useSubscriptionActions()
await handleRefresh()
expect(mockFetchBalance).toHaveBeenCalledOnce()
expect(mockFetchStatus).toHaveBeenCalledOnce()
})
it('should handle errors gracefully', async () => {
mockFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
const { handleRefresh } = useSubscriptionActions()
// Should not throw
await expect(handleRefresh()).resolves.toBeUndefined()
})
})
describe('handleLearnMoreClick', () => {
it('should open learn more URL', () => {
const { handleLearnMoreClick } = useSubscriptionActions()
handleLearnMoreClick()
expect(mockOpen).toHaveBeenCalledWith(
'https://docs.comfy.org/get_started/cloud',
'_blank'
)
})
})
})

View File

@@ -1,146 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
// Mock Firebase Auth and related modules
vi.mock('vuefire', () => ({
useFirebaseAuth: vi.fn(() => ({
onAuthStateChanged: vi.fn(),
setPersistence: vi.fn()
}))
}))
vi.mock('firebase/auth', () => ({
onAuthStateChanged: vi.fn(() => {
// Mock the callback to be called immediately for testing
return vi.fn()
}),
onIdTokenChanged: vi.fn(),
setPersistence: vi.fn().mockResolvedValue(undefined),
browserLocalPersistence: {},
GoogleAuthProvider: class {
addScope = vi.fn()
setCustomParameters = vi.fn()
},
GithubAuthProvider: class {
addScope = vi.fn()
setCustomParameters = vi.fn()
}
}))
// Mock other dependencies
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showDialog: vi.fn()
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
track: vi.fn()
})
}))
vi.mock('@/stores/toastStore', () => ({
useToastStore: () => ({
add: vi.fn()
})
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => ({
headers: {}
})
}))
// 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>
beforeEach(() => {
setActivePinia(createPinia())
authStore = useFirebaseAuthStore()
vi.clearAllMocks()
})
describe('totalCredits', () => {
it('should return "0.00" when balance is null', () => {
authStore.balance = null
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00')
})
it('should return "0.00" when amount_micros is missing', () => {
authStore.balance = {} as any
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00')
})
it('should format amount_micros correctly', () => {
authStore.balance = { amount_micros: 5000000 } as any
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('5.00')
})
it('should handle formatting errors gracefully', async () => {
const mockFormatMetronomeCurrency = vi.mocked(
await import('@/utils/formatUtil')
).formatMetronomeCurrency
mockFormatMetronomeCurrency.mockImplementationOnce(() => {
throw new Error('Formatting error')
})
authStore.balance = { amount_micros: 5000000 } as any
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00')
})
})
describe('monthlyBonusCredits', () => {
it('should return "0.00" when cloud_credit_balance_micros is missing', () => {
authStore.balance = {} as any
const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('0.00')
})
it('should format cloud_credit_balance_micros correctly', () => {
authStore.balance = { cloud_credit_balance_micros: 2500000 } as any
const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('2.50')
})
})
describe('prepaidCredits', () => {
it('should return "0.00" when prepaid_balance_micros is missing', () => {
authStore.balance = {} as any
const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('0.00')
})
it('should format prepaid_balance_micros correctly', () => {
authStore.balance = { prepaid_balance_micros: 7500000 } as any
const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('7.50')
})
})
describe('isLoadingBalance', () => {
it('should reflect authStore.isFetchingBalance', () => {
authStore.isFetchingBalance = true
const { isLoadingBalance } = useSubscriptionCredits()
expect(isLoadingBalance.value).toBe(true)
authStore.isFetchingBalance = false
expect(isLoadingBalance.value).toBe(false)
})
})
})

View File

@@ -1,4 +1,3 @@
import { sentryVitePlugin } from '@sentry/vite-plugin'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import dotenv from 'dotenv'
@@ -227,28 +226,7 @@ export default defineConfig({
deep: true,
extensions: ['vue'],
directoryAsNamespace: true
}),
// Sentry sourcemap upload plugin
// Only runs during cloud production builds when all Sentry env vars are present
// Requires: SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT env vars
...(DISTRIBUTION === 'cloud' &&
process.env.SENTRY_AUTH_TOKEN &&
process.env.SENTRY_ORG &&
process.env.SENTRY_PROJECT &&
!IS_DEV
? [
sentryVitePlugin({
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
sourcemaps: {
// Delete source maps after upload to prevent public access
filesToDeleteAfterUpload: ['**/*.map']
}
})
]
: [])
})
],
build: {