mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-15 11:44:10 +00:00
Compare commits
36 Commits
i18n-sync-
...
pr-6544
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
341d8348eb | ||
|
|
672e538f6f | ||
|
|
947b66e60c | ||
|
|
48173615aa | ||
|
|
d447b5df93 | ||
|
|
9182b1a85a | ||
|
|
dcda95d0ef | ||
|
|
a8987396ae | ||
|
|
4658b426a2 | ||
|
|
8df48f912d | ||
|
|
5303b3c70f | ||
|
|
31d4bcbd4e | ||
|
|
52534a562c | ||
|
|
4412ae4bff | ||
|
|
c5acb39c30 | ||
|
|
c23bba2ce2 | ||
|
|
556132d3ff | ||
|
|
2383a38aa0 | ||
|
|
86346f97a8 | ||
|
|
7a7e1d58a2 | ||
|
|
6dbe00d47c | ||
|
|
daa9aff1f3 | ||
|
|
5fa4dcdc67 | ||
|
|
61660a8128 | ||
|
|
b575a8d7a2 | ||
|
|
750a9d882a | ||
|
|
2febb24d6c | ||
|
|
d42e38300d | ||
|
|
20687c2945 | ||
|
|
b32a1e9ce8 | ||
|
|
94cb6bf294 | ||
|
|
379f27a001 | ||
|
|
17ae4cbf53 | ||
|
|
97949c61fb | ||
|
|
84ce6c183d | ||
|
|
9adf0c179f |
@@ -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
1
global.d.ts
vendored
@@ -8,6 +8,7 @@ declare const __USE_PROD_CONFIG__: boolean
|
||||
interface Window {
|
||||
__CONFIG__: {
|
||||
mixpanel_token?: string
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
server_health_alert?: {
|
||||
message: string
|
||||
|
||||
@@ -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,7 +74,6 @@
|
||||
"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",
|
||||
|
||||
@@ -126,6 +126,13 @@
|
||||
--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);
|
||||
@@ -168,6 +175,17 @@
|
||||
.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);
|
||||
@@ -196,6 +214,13 @@
|
||||
|
||||
@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);
|
||||
|
||||
@@ -370,6 +370,10 @@ export function formatMetronomeCurrency(
|
||||
* This conversion is commonly used in financial systems to avoid floating-point precision issues
|
||||
* by representing monetary values as integers.
|
||||
*
|
||||
* Note: Despite the function name and the field names in the API (e.g., `amount_micros`),
|
||||
* some API responses actually return cents (1/100) instead of true micros (1/1,000,000).
|
||||
* Use `formatMetronomeCurrency` for displaying balance amounts from the API.
|
||||
*
|
||||
* @param usd - The amount in US dollars to convert
|
||||
* @returns The amount in microdollars (multiplied by 1,000,000)
|
||||
* @example
|
||||
|
||||
285
pnpm-lock.yaml
generated
285
pnpm-lock.yaml
generated
@@ -66,6 +66,9 @@ 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
|
||||
@@ -84,9 +87,6 @@ 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,9 +150,6 @@ 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
|
||||
@@ -285,6 +282,7 @@ catalogs:
|
||||
|
||||
overrides:
|
||||
'@types/eslint': '-'
|
||||
'@eslint/core': 0.17.0
|
||||
|
||||
importers:
|
||||
|
||||
@@ -486,6 +484,9 @@ 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)))
|
||||
@@ -501,9 +502,6 @@ 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
|
||||
@@ -552,9 +550,6 @@ 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))
|
||||
@@ -1773,8 +1768,8 @@ packages:
|
||||
resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/core@0.15.2':
|
||||
resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==}
|
||||
'@eslint/core@0.17.0':
|
||||
resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/eslintrc@3.3.1':
|
||||
@@ -2658,14 +2653,78 @@ 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'}
|
||||
@@ -3029,9 +3088,6 @@ 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==}
|
||||
|
||||
@@ -3575,6 +3631,10 @@ 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'}
|
||||
@@ -4574,12 +4634,6 @@ 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:
|
||||
@@ -4854,6 +4908,9 @@ 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}
|
||||
@@ -4934,6 +4991,10 @@ 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'}
|
||||
@@ -5063,6 +5124,10 @@ 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'}
|
||||
@@ -5748,6 +5813,10 @@ 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==}
|
||||
|
||||
@@ -5968,6 +6037,10 @@ 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'}
|
||||
@@ -5983,6 +6056,10 @@ 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'}
|
||||
@@ -6414,6 +6491,10 @@ 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==}
|
||||
|
||||
@@ -7012,11 +7093,6 @@ 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==}
|
||||
|
||||
@@ -7301,6 +7377,9 @@ 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'}
|
||||
@@ -7475,6 +7554,9 @@ 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'}
|
||||
@@ -7571,6 +7653,13 @@ 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==}
|
||||
|
||||
@@ -8851,7 +8940,7 @@ snapshots:
|
||||
|
||||
'@eslint/config-helpers@0.3.1': {}
|
||||
|
||||
'@eslint/core@0.15.2':
|
||||
'@eslint/core@0.17.0':
|
||||
dependencies:
|
||||
'@types/json-schema': 7.0.15
|
||||
|
||||
@@ -8875,7 +8964,7 @@ snapshots:
|
||||
|
||||
'@eslint/plugin-kit@0.3.5':
|
||||
dependencies:
|
||||
'@eslint/core': 0.15.2
|
||||
'@eslint/core': 0.17.0
|
||||
levn: 0.4.1
|
||||
|
||||
'@firebase/analytics-compat@0.2.18(@firebase/app-compat@0.2.53)(@firebase/app@0.11.4)':
|
||||
@@ -10024,6 +10113,8 @@ 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
|
||||
@@ -10032,8 +10123,74 @@ 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
|
||||
@@ -10104,7 +10261,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.1
|
||||
vue-component-type-helpers: 3.1.2
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -10409,8 +10566,6 @@ 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': {}
|
||||
@@ -11035,6 +11190,12 @@ 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:
|
||||
@@ -12176,14 +12337,6 @@ 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)
|
||||
@@ -12218,7 +12371,7 @@ snapshots:
|
||||
'@eslint-community/regexpp': 4.12.1
|
||||
'@eslint/config-array': 0.21.0
|
||||
'@eslint/config-helpers': 0.3.1
|
||||
'@eslint/core': 0.15.2
|
||||
'@eslint/core': 0.17.0
|
||||
'@eslint/eslintrc': 3.3.1
|
||||
'@eslint/js': 9.35.0
|
||||
'@eslint/plugin-kit': 0.3.5
|
||||
@@ -12542,6 +12695,8 @@ snapshots:
|
||||
jsonfile: 6.2.0
|
||||
universalify: 2.0.1
|
||||
|
||||
fs.realpath@1.0.0: {}
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
@@ -12636,6 +12791,13 @@ 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
|
||||
@@ -12766,6 +12928,13 @@ 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
|
||||
@@ -13455,6 +13624,10 @@ 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
|
||||
@@ -13860,6 +14033,10 @@ 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
|
||||
@@ -13874,6 +14051,8 @@ snapshots:
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
minipass@4.2.8: {}
|
||||
|
||||
minipass@7.1.2: {}
|
||||
|
||||
minizlib@3.0.2:
|
||||
@@ -14357,6 +14536,8 @@ snapshots:
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
progress@2.0.3: {}
|
||||
|
||||
promise@7.3.1:
|
||||
dependencies:
|
||||
asap: 2.0.6
|
||||
@@ -15194,13 +15375,6 @@ 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):
|
||||
@@ -15492,6 +15666,13 @@ 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
|
||||
@@ -15746,6 +15927,8 @@ 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)
|
||||
@@ -15838,6 +16021,10 @@ 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:
|
||||
|
||||
@@ -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,7 +51,6 @@ 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
|
||||
@@ -62,6 +61,7 @@ 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,13 +94,9 @@ 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 +107,12 @@ onlyBuiltDependencies:
|
||||
- '@playwright/browser-chromium'
|
||||
- '@playwright/browser-firefox'
|
||||
- '@playwright/browser-webkit'
|
||||
- '@sentry/cli'
|
||||
- '@tailwindcss/oxide'
|
||||
- esbuild
|
||||
- nx
|
||||
- oxc-resolver
|
||||
|
||||
overrides:
|
||||
'@eslint/core': 0.17.0
|
||||
'@types/eslint': '-'
|
||||
|
||||
@@ -158,9 +158,7 @@ const queuePrompt = async (e: Event) => {
|
||||
? 'Comfy.QueuePromptFront'
|
||||
: 'Comfy.QueuePrompt'
|
||||
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackRunButton({ subscribe_to_run: false })
|
||||
}
|
||||
useTelemetry()?.trackRunButton({ subscribe_to_run: false })
|
||||
|
||||
await commandStore.execute(commandId)
|
||||
}
|
||||
|
||||
@@ -380,7 +380,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, onBeforeUnmount, provide, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
@@ -401,6 +401,8 @@ 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'
|
||||
@@ -410,10 +412,34 @@ import { createGridStyle } from '@/utils/gridUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { onClose } = defineProps<{
|
||||
const { onClose: originalOnClose } = 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
|
||||
@@ -698,6 +724,7 @@ const onLoadWorkflow = async (template: any) => {
|
||||
template.name,
|
||||
getEffectiveSourceModule(template)
|
||||
)
|
||||
templateWasSelected.value = true
|
||||
onClose()
|
||||
} finally {
|
||||
loadingTemplate.value = null
|
||||
|
||||
@@ -61,6 +61,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -92,12 +93,18 @@ const showReport = () => {
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const title = computed<string>(
|
||||
() => error.nodeType ?? error.exceptionType ?? t('errorDialog.defaultTitle')
|
||||
)
|
||||
|
||||
const showContactSupport = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
await useCommandStore().execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
|
||||
@@ -43,8 +43,10 @@ import Tag from 'primevue/tag'
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const {
|
||||
amount,
|
||||
@@ -61,8 +63,11 @@ const didClickBuyNow = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const handleBuyNow = async () => {
|
||||
const creditAmount = editable ? customAmount.value : amount
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)
|
||||
|
||||
loading.value = true
|
||||
await authActions.purchaseCredits(editable ? customAmount.value : amount)
|
||||
await authActions.purchaseCredits(creditAmount)
|
||||
loading.value = false
|
||||
didClickBuyNow.value = true
|
||||
}
|
||||
|
||||
@@ -92,6 +92,13 @@
|
||||
icon="pi pi-question-circle"
|
||||
@click="handleFaqClick"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('subscription.partnerNodesCredits')"
|
||||
text
|
||||
severity="secondary"
|
||||
icon="pi pi-question-circle"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('credits.messageSupport')"
|
||||
text
|
||||
@@ -116,6 +123,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
@@ -132,6 +140,7 @@ const dialogService = useDialogService()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
@@ -161,6 +170,11 @@ const handleCreditsHistoryClick = async () => {
|
||||
}
|
||||
|
||||
const handleMessageSupport = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'credits_panel'
|
||||
})
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
@@ -168,5 +182,12 @@ const handleFaqClick = () => {
|
||||
window.open('https://docs.comfy.org/tutorials/api-nodes/faq', '_blank')
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const creditHistory = ref<CreditHistoryItemData[]>([])
|
||||
</script>
|
||||
|
||||
@@ -96,6 +96,7 @@ import Message from 'primevue/message'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import {
|
||||
EventType,
|
||||
@@ -159,6 +160,9 @@ const loadEvents = async () => {
|
||||
if (response.totalPages) {
|
||||
pagination.value.totalPages = response.totalPages
|
||||
}
|
||||
|
||||
// Check if a pending top-up has completed
|
||||
useTelemetry()?.checkForCompletedTopup(response.events)
|
||||
} else {
|
||||
error.value = customerEventService.error.value || 'Failed to load events'
|
||||
}
|
||||
|
||||
@@ -130,13 +130,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import type { CSSProperties, Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -188,6 +189,7 @@ const { t, locale } = useI18n()
|
||||
const releaseStore = useReleaseStore()
|
||||
const commandStore = useCommandStore()
|
||||
const settingStore = useSettingStore()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
@@ -199,6 +201,7 @@ const isSubmenuVisible = ref(false)
|
||||
const submenuRef = ref<HTMLElement | null>(null)
|
||||
const submenuStyle = ref<CSSProperties>({})
|
||||
let hoverTimeout: number | null = null
|
||||
const openedAt = ref<number>(Date.now())
|
||||
|
||||
// Computed
|
||||
const hasReleases = computed(() => releaseStore.releases.length > 0)
|
||||
@@ -218,6 +221,7 @@ const moreItems = computed<MenuItem[]>(() => {
|
||||
label: t('helpCenter.desktopUserGuide'),
|
||||
visible: isElectron(),
|
||||
action: () => {
|
||||
trackResourceClick('docs', true)
|
||||
const docsUrl =
|
||||
electronAPI().getPlatform() === 'darwin'
|
||||
? EXTERNAL_LINKS.DESKTOP_GUIDE_MACOS
|
||||
@@ -273,6 +277,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
icon: 'pi pi-book',
|
||||
label: t('helpCenter.docs'),
|
||||
action: () => {
|
||||
trackResourceClick('docs', true)
|
||||
openExternalLink(EXTERNAL_LINKS.DOCS)
|
||||
emit('close')
|
||||
}
|
||||
@@ -283,6 +288,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
icon: 'pi pi-discord',
|
||||
label: 'Discord',
|
||||
action: () => {
|
||||
trackResourceClick('discord', true)
|
||||
openExternalLink(EXTERNAL_LINKS.DISCORD)
|
||||
emit('close')
|
||||
}
|
||||
@@ -293,6 +299,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
icon: 'pi pi-github',
|
||||
label: t('helpCenter.github'),
|
||||
action: () => {
|
||||
trackResourceClick('github', true)
|
||||
openExternalLink(EXTERNAL_LINKS.GITHUB)
|
||||
emit('close')
|
||||
}
|
||||
@@ -303,6 +310,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
icon: 'pi pi-question-circle',
|
||||
label: t('helpCenter.helpFeedback'),
|
||||
action: () => {
|
||||
trackResourceClick('help_feedback', false)
|
||||
void commandStore.execute('Comfy.ContactSupport')
|
||||
emit('close')
|
||||
}
|
||||
@@ -318,6 +326,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
label: t('helpCenter.managerExtension'),
|
||||
showRedDot: shouldShowManagerRedDot.value,
|
||||
action: async () => {
|
||||
trackResourceClick('manager', false)
|
||||
await useManagerState().openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
showToastOnLegacyError: false
|
||||
@@ -341,6 +350,23 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
})
|
||||
|
||||
// Utility Functions
|
||||
const trackResourceClick = (
|
||||
resourceType:
|
||||
| 'docs'
|
||||
| 'discord'
|
||||
| 'github'
|
||||
| 'help_feedback'
|
||||
| 'manager'
|
||||
| 'release_notes',
|
||||
isExternal: boolean
|
||||
): void => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: resourceType,
|
||||
is_external: isExternal,
|
||||
source: 'help_center'
|
||||
})
|
||||
}
|
||||
|
||||
const openExternalLink = (url: string): void => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
@@ -496,6 +522,7 @@ const onReinstall = (): void => {
|
||||
}
|
||||
|
||||
const onReleaseClick = (release: ReleaseNote): void => {
|
||||
trackResourceClick('release_notes', true)
|
||||
void releaseStore.handleShowChangelog(release.version)
|
||||
const versionAnchor = formatVersionAnchor(release.version)
|
||||
const changelogUrl = `${getChangelogUrl()}#${versionAnchor}`
|
||||
@@ -504,6 +531,7 @@ const onReleaseClick = (release: ReleaseNote): void => {
|
||||
}
|
||||
|
||||
const onUpdate = (_: ReleaseNote): void => {
|
||||
trackResourceClick('docs', true)
|
||||
openExternalLink(EXTERNAL_LINKS.UPDATE_GUIDE)
|
||||
emit('close')
|
||||
}
|
||||
@@ -518,10 +546,16 @@ const getChangelogUrl = (): string => {
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
telemetry?.trackHelpCenterOpened({ source: 'sidebar' })
|
||||
if (!hasReleases.value) {
|
||||
await releaseStore.fetchReleases()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const timeSpentSeconds = Math.round((Date.now() - openedAt.value) / 1000)
|
||||
telemetry?.trackHelpCenterClosed({ time_spent_seconds: timeSpentSeconds })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
multiple
|
||||
:option-label="'display_name'"
|
||||
@complete="search($event.query)"
|
||||
@option-select="emit('addNode', $event.value)"
|
||||
@option-select="onAddNode($event.value)"
|
||||
@focused-option-changed="setHoverSuggestion($event)"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
@@ -78,6 +78,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
@@ -88,6 +89,7 @@ import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue
|
||||
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
|
||||
import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
@@ -96,6 +98,7 @@ import SearchFilterChip from '../common/SearchFilterChip.vue'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const enableNodePreview = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
|
||||
@@ -117,6 +120,14 @@ const placeholder = computed(() => {
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeFrequencyStore = useNodeFrequencyStore()
|
||||
|
||||
// Debounced search tracking (500ms as per implementation plan)
|
||||
const debouncedTrackSearch = debounce((query: string) => {
|
||||
if (query.trim()) {
|
||||
telemetry?.trackNodeSearch({ query })
|
||||
}
|
||||
}, 500)
|
||||
|
||||
const search = (query: string) => {
|
||||
const queryIsEmpty = query === '' && filters.length === 0
|
||||
currentQuery.value = query
|
||||
@@ -127,10 +138,22 @@ const search = (query: string) => {
|
||||
limit: searchLimit
|
||||
})
|
||||
]
|
||||
|
||||
// Track search queries with debounce
|
||||
debouncedTrackSearch(query)
|
||||
}
|
||||
|
||||
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
|
||||
|
||||
// Track node selection and emit addNode event
|
||||
const onAddNode = (nodeDef: ComfyNodeDefImpl) => {
|
||||
telemetry?.trackNodeSearchResultSelected({
|
||||
node_type: nodeDef.name,
|
||||
last_query: currentQuery.value
|
||||
})
|
||||
emit('addNode', nodeDef)
|
||||
}
|
||||
|
||||
let inputElement: HTMLInputElement | null = null
|
||||
const reFocusInput = async () => {
|
||||
inputElement ??= document.getElementById(inputId) as HTMLInputElement
|
||||
|
||||
@@ -12,19 +12,18 @@
|
||||
<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 = () => {
|
||||
void commandStore.execute('Comfy.BrowseTemplates')
|
||||
useWorkflowTemplateSelectorDialog().show('sidebar')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
|
||||
<div class="task-item-details">
|
||||
<div class="tag-wrapper status-tag-group">
|
||||
<Tag v-if="isFlatTask && task.isHistory" class="node-name-tag">
|
||||
<Tag
|
||||
v-if="!isCloud && isFlatTask && task.isHistory"
|
||||
class="node-name-tag"
|
||||
>
|
||||
<Button
|
||||
class="task-node-link"
|
||||
:label="`${node?.type} (#${node?.id})`"
|
||||
@@ -78,6 +81,7 @@ import Button from 'primevue/button'
|
||||
import Tag from 'primevue/tag'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
|
||||
@@ -80,6 +80,7 @@ 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'
|
||||
@@ -168,7 +169,7 @@ const extraMenuItems = computed(() => [
|
||||
key: 'browse-templates',
|
||||
label: t('menuLabels.Browse Templates'),
|
||||
icon: 'icon-[comfy--template]',
|
||||
command: () => commandStore.execute('Comfy.BrowseTemplates')
|
||||
command: () => useWorkflowTemplateSelectorDialog().show('menu')
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
|
||||
@@ -79,9 +79,11 @@ 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: vi.fn().mockReturnValue(true)
|
||||
isActiveSubscription: { value: true },
|
||||
fetchStatus: mockFetchStatus
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -105,6 +107,15 @@ 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()
|
||||
@@ -137,9 +148,9 @@ describe('CurrentUserPopover', () => {
|
||||
it('renders logout button with correct props', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the logout button (second one)
|
||||
// Find all buttons and get the logout button (last button)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const logoutButton = buttons[1]
|
||||
const logoutButton = buttons[4]
|
||||
|
||||
// Check that logout button has correct props
|
||||
expect(logoutButton.props('label')).toBe('Log Out')
|
||||
@@ -149,9 +160,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 (first one)
|
||||
// Find all buttons and get the settings button (third button)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const settingsButton = buttons[0]
|
||||
const settingsButton = buttons[2]
|
||||
|
||||
// Click the settings button
|
||||
await settingsButton.trigger('click')
|
||||
@@ -167,9 +178,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 (second one)
|
||||
// Find all buttons and get the logout button (last button)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const logoutButton = buttons[1]
|
||||
const logoutButton = buttons[4]
|
||||
|
||||
// Click the logout button
|
||||
await logoutButton.trigger('click')
|
||||
@@ -185,16 +196,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 API pricing button (third one now)
|
||||
// Find all buttons and get the Partner Nodes info button (first one)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const apiPricingButton = buttons[2]
|
||||
const partnerNodesButton = buttons[0]
|
||||
|
||||
// Click the API pricing button
|
||||
await apiPricingButton.trigger('click')
|
||||
// Click the Partner Nodes button
|
||||
await partnerNodesButton.trigger('click')
|
||||
|
||||
// Verify window.open was called with the correct URL
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://docs.comfy.org/tutorials/api-nodes/pricing',
|
||||
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
|
||||
'_blank'
|
||||
)
|
||||
|
||||
@@ -206,9 +217,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 (last one)
|
||||
// Find all buttons and get the top-up button (second one)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const topUpButton = buttons[buttons.length - 1]
|
||||
const topUpButton = buttons[1]
|
||||
|
||||
// Click the top-up button
|
||||
await topUpButton.trigger('click')
|
||||
|
||||
@@ -23,6 +23,38 @@
|
||||
</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
|
||||
@@ -35,6 +67,17 @@
|
||||
@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
|
||||
@@ -46,34 +89,6 @@
|
||||
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>
|
||||
|
||||
@@ -86,37 +101,60 @@ 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 } = useSubscription()
|
||||
const { isActiveSubscription, fetchStatus } = 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 handleOpenApiPricing = () => {
|
||||
window.open('https://docs.comfy.org/tutorials/api-nodes/pricing', '_blank')
|
||||
emit('close')
|
||||
const handleSubscribed = async () => {
|
||||
await fetchStatus()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<template>
|
||||
<div class="w-auto max-w-full">
|
||||
<div class="flex h-full w-auto max-w-full items-center">
|
||||
<WorkflowTabs />
|
||||
<TopbarBadges />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
</script>
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
>
|
||||
{{ badge.label }}
|
||||
</div>
|
||||
<div class="text-sm font-semibold">{{ badge.text }}</div>
|
||||
<div class="text-sm font-inter">{{ 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-semibold">{{ badge.text }}</div>
|
||||
<div class="text-sm font-inter">{{ 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 font-extrabold" :class="textClasses">
|
||||
<div class="font-inter text-sm" :class="textClasses">
|
||||
{{ badge.text }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,7 +177,7 @@ const textClasses = computed(() => {
|
||||
return 'text-warning-100'
|
||||
case 'info':
|
||||
default:
|
||||
return 'text-slate-100'
|
||||
return 'text-text-primary'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { FirebaseError } from 'firebase/app'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { t } from '@/i18n'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { usdToMicros } from '@/utils/formatUtil'
|
||||
@@ -17,7 +18,6 @@ export const useFirebaseAuthActions = () => {
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const toastStore = useToastStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { wrapWithErrorHandlingAsync, toastErrorHandler } = useErrorHandling()
|
||||
|
||||
const accessError = ref(false)
|
||||
@@ -55,14 +55,19 @@ export const useFirebaseAuthActions = () => {
|
||||
life: 5000
|
||||
})
|
||||
|
||||
// Redirect to login page if we're on cloud domain
|
||||
// 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
|
||||
const hostname = window.location.hostname
|
||||
if (hostname.includes('cloud.comfy.org')) {
|
||||
if (route.query.inviteCode) {
|
||||
const inviteCode = route.query.inviteCode
|
||||
await router.push({ name: 'cloud-login', query: { inviteCode } })
|
||||
const inviteCode = Array.isArray(route.query.inviteCode)
|
||||
? route.query.inviteCode[0]
|
||||
: route.query.inviteCode
|
||||
window.location.href = `/cloud/login?inviteCode=${encodeURIComponent(inviteCode || '')}`
|
||||
} else {
|
||||
await router.push({ name: 'cloud-login' })
|
||||
window.location.href = '/cloud/login'
|
||||
}
|
||||
}
|
||||
}, reportError)
|
||||
@@ -94,7 +99,7 @@ export const useFirebaseAuthActions = () => {
|
||||
)
|
||||
}
|
||||
|
||||
// Go to Stripe checkout page
|
||||
useTelemetry()?.startTopupTracking()
|
||||
window.open(response.checkout_url, '_blank')
|
||||
}, reportError)
|
||||
|
||||
@@ -111,7 +116,9 @@ export const useFirebaseAuthActions = () => {
|
||||
}, reportError)
|
||||
|
||||
const fetchBalance = wrapWithErrorHandlingAsync(async () => {
|
||||
return await authStore.fetchBalance()
|
||||
const result = await authStore.fetchBalance()
|
||||
// Top-up completion tracking happens in UsageLogsTable when events are fetched
|
||||
return result
|
||||
}, reportError)
|
||||
|
||||
const signInWithGoogle = (errorHandler = reportError) =>
|
||||
|
||||
@@ -18,7 +18,6 @@ import type { Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { SUPPORT_URL } from '@/platform/support/config'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -73,6 +72,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const toastStore = useToastStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
|
||||
@@ -101,7 +101,14 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'New Blank Workflow',
|
||||
menubarLabel: 'New',
|
||||
category: 'essentials' as const,
|
||||
function: () => workflowService.loadBlankWorkflow()
|
||||
function: async () => {
|
||||
const previousWorkflowHadNodes = app.graph._nodes.length > 0
|
||||
await workflowService.loadBlankWorkflow()
|
||||
telemetry?.trackWorkflowCreated({
|
||||
workflow_type: 'blank',
|
||||
previous_workflow_had_nodes: previousWorkflowHadNodes
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.OpenWorkflow',
|
||||
@@ -117,7 +124,14 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.LoadDefaultWorkflow',
|
||||
icon: 'pi pi-code',
|
||||
label: 'Load Default Workflow',
|
||||
function: () => workflowService.loadDefaultWorkflow()
|
||||
function: async () => {
|
||||
const previousWorkflowHadNodes = app.graph._nodes.length > 0
|
||||
await workflowService.loadDefaultWorkflow()
|
||||
telemetry?.trackWorkflowCreated({
|
||||
workflow_type: 'default',
|
||||
previous_workflow_had_nodes: previousWorkflowHadNodes
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.SaveWorkflow',
|
||||
@@ -459,9 +473,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
|
||||
const batchCount = useQueueSettingsStore().batchCount
|
||||
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackWorkflowExecution()
|
||||
}
|
||||
useTelemetry()?.trackWorkflowExecution()
|
||||
|
||||
await app.queuePrompt(0, batchCount)
|
||||
}
|
||||
@@ -480,9 +492,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
|
||||
const batchCount = useQueueSettingsStore().batchCount
|
||||
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackWorkflowExecution()
|
||||
}
|
||||
useTelemetry()?.trackWorkflowExecution()
|
||||
|
||||
await app.queuePrompt(-1, batchCount)
|
||||
}
|
||||
@@ -720,6 +730,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
menubarLabel: 'ComfyUI Issues',
|
||||
versionAdded: '1.5.5',
|
||||
function: () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'github',
|
||||
is_external: true,
|
||||
source: 'menu'
|
||||
})
|
||||
window.open(
|
||||
'https://github.com/comfyanonymous/ComfyUI/issues',
|
||||
'_blank'
|
||||
@@ -733,6 +748,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
menubarLabel: 'ComfyUI Docs',
|
||||
versionAdded: '1.5.5',
|
||||
function: () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'docs',
|
||||
is_external: true,
|
||||
source: 'menu'
|
||||
})
|
||||
window.open('https://docs.comfy.org/', '_blank')
|
||||
}
|
||||
},
|
||||
@@ -743,6 +763,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
menubarLabel: 'Comfy-Org Discord',
|
||||
versionAdded: '1.5.5',
|
||||
function: () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'discord',
|
||||
is_external: true,
|
||||
source: 'menu'
|
||||
})
|
||||
window.open('https://www.comfy.org/discord', '_blank')
|
||||
}
|
||||
},
|
||||
@@ -800,6 +825,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
menubarLabel: 'ComfyUI Forum',
|
||||
versionAdded: '1.8.2',
|
||||
function: () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'menu'
|
||||
})
|
||||
window.open('https://forum.comfy.org/', '_blank')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import Fuse from 'fuse.js'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
export function useTemplateFiltering(
|
||||
@@ -189,6 +191,38 @@ export function useTemplateFiltering(
|
||||
const filteredCount = computed(() => filteredTemplates.value.length)
|
||||
const totalCount = computed(() => templatesArray.value.length)
|
||||
|
||||
// Template filter tracking (debounced to avoid excessive events)
|
||||
const debouncedTrackFilterChange = debounce(() => {
|
||||
useTelemetry()?.trackTemplateFilterChanged({
|
||||
search_query: searchQuery.value || undefined,
|
||||
selected_models: selectedModels.value,
|
||||
selected_use_cases: selectedUseCases.value,
|
||||
selected_licenses: selectedLicenses.value,
|
||||
sort_by: sortBy.value,
|
||||
filtered_count: filteredCount.value,
|
||||
total_count: totalCount.value
|
||||
})
|
||||
}, 500)
|
||||
|
||||
// Watch for filter changes and track them
|
||||
watch(
|
||||
[searchQuery, selectedModels, selectedUseCases, selectedLicenses, sortBy],
|
||||
() => {
|
||||
// Only track if at least one filter is active (to avoid tracking initial state)
|
||||
const hasActiveFilters =
|
||||
searchQuery.value.trim() !== '' ||
|
||||
selectedModels.value.length > 0 ||
|
||||
selectedUseCases.value.length > 0 ||
|
||||
selectedLicenses.value.length > 0 ||
|
||||
sortBy.value !== 'default'
|
||||
|
||||
if (hasActiveFilters) {
|
||||
debouncedTrackFilterChange()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
return {
|
||||
// State
|
||||
searchQuery,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -12,7 +13,9 @@ export const useWorkflowTemplateSelectorDialog = () => {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function show() {
|
||||
function show(source: 'sidebar' | 'menu' | 'command' = 'command') {
|
||||
useTelemetry()?.trackTemplateLibraryOpened({ source })
|
||||
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: WorkflowTemplateSelectorDialog,
|
||||
|
||||
@@ -1958,7 +1958,6 @@
|
||||
"cloudClaimInvite_processingTitle": "معالجة رمز الدعوة...",
|
||||
"cloudClaimInvite_claimButton": "استلام الدعوة",
|
||||
"cloudSorryContactSupport_title": "عذراً، اتصل بالدعم",
|
||||
"cloudVerifyEmail_title": "التحقق من البريد الإلكتروني",
|
||||
"cloudPrivateBeta_title": "Cloud حالياً في مرحلة البيتا الخاصة",
|
||||
"cloudPrivateBeta_desc": "سجل الدخول للانضمام إلى قائمة الانتظار. سنخطرك عندما يصبح الوصول للبيتا متاحاً. تم إخطارك بالفعل؟ سجل الدخول لبدء استخدام Comfy Cloud.",
|
||||
"cloudForgotPassword_title": "نسيت كلمة المرور",
|
||||
|
||||
@@ -1925,24 +1925,31 @@
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Subscription",
|
||||
"titleUnsubscribed": "Subscribe to Comfy Cloud",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"beta": "BETA",
|
||||
"perMonth": "USD / month",
|
||||
"renewsDate": "Renews {date}",
|
||||
"expiresDate": "Expires {date}",
|
||||
"manageSubscription": "Manage subscription",
|
||||
"apiNodesBalance": "\"API Nodes\" Credit Balance",
|
||||
"apiNodesDescription": "For running commercial/proprietary models",
|
||||
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
|
||||
"partnerNodesDescription": "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": "$10 in monthly credits for API models — top up when needed",
|
||||
"benefit1": "Monthly credits for Partner Nodes — top up when needed",
|
||||
"benefit2": "Up to 30 min runtime per job"
|
||||
},
|
||||
"required": {
|
||||
@@ -1951,7 +1958,9 @@
|
||||
"subscribe": "Subscribe"
|
||||
},
|
||||
"subscribeToRun": "Subscribe to Run",
|
||||
"subscribeNow": "Subscribe Now"
|
||||
"subscribeNow": "Subscribe Now",
|
||||
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
|
||||
"partnerNodesCredits": "Partner Nodes credits"
|
||||
},
|
||||
"userSettings": {
|
||||
"title": "User Settings",
|
||||
@@ -2114,7 +2123,18 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"cloudFooter_needHelp": "Need Help?",
|
||||
@@ -2152,18 +2172,6 @@
|
||||
"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: Don’t forget to check your spam folder\nif you don’t 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 you’re signed into the account you want to use.",
|
||||
"cloudInvite_switchAccounts": "Switch accounts",
|
||||
|
||||
@@ -1955,7 +1955,6 @@
|
||||
"cloudClaimInvite_processingTitle": "Procesando código de invitación...",
|
||||
"cloudClaimInvite_claimButton": "Reclamar Invitación",
|
||||
"cloudSorryContactSupport_title": "Lo sentimos, contacta al soporte",
|
||||
"cloudVerifyEmail_title": "Verificación de Correo",
|
||||
"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",
|
||||
|
||||
@@ -1949,7 +1949,6 @@
|
||||
"cloudClaimInvite_processingTitle": "Traitement du code d'invitation...",
|
||||
"cloudClaimInvite_claimButton": "Réclamer l'Invitation",
|
||||
"cloudSorryContactSupport_title": "Désolé, contactez le support",
|
||||
"cloudVerifyEmail_title": "Vérification d'Email",
|
||||
"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é",
|
||||
|
||||
@@ -1955,7 +1955,6 @@
|
||||
"cloudClaimInvite_processingTitle": "招待コードを処理中...",
|
||||
"cloudClaimInvite_claimButton": "招待を申請",
|
||||
"cloudSorryContactSupport_title": "申し訳ございません、サポートにお問い合わせください",
|
||||
"cloudVerifyEmail_title": "メール確認",
|
||||
"cloudPrivateBeta_title": "Cloudは現在プライベートベータ版です",
|
||||
"cloudPrivateBeta_desc": "サインインしてウェイトリストに登録してください。ベータアクセスが可能になりましたらお知らせします。すでに通知を受け取りましたか?サインインしてComfy Cloudを始めてください。",
|
||||
"cloudForgotPassword_title": "パスワードを忘れた",
|
||||
|
||||
@@ -1955,7 +1955,6 @@
|
||||
"cloudClaimInvite_processingTitle": "초대 코드 확인중...",
|
||||
"cloudClaimInvite_claimButton": "초대 요청하기",
|
||||
"cloudSorryContactSupport_title": "죄송합니다, 지원팀에 문의해주세요",
|
||||
"cloudVerifyEmail_title": "이메일 확인",
|
||||
"cloudPrivateBeta_title": "Cloud는 현재 비공개 베타 버전입니다",
|
||||
"cloudPrivateBeta_desc": "로그인하여 대기자 명단에 등록하세요. 베타 버전이 오픈될 때 알려드릴게요. 이미 알림을 받으셨다면? 로그인하여 Comfy Cloud를 시작해보세요.",
|
||||
"cloudForgotPassword_title": "비밀번호 찾기",
|
||||
|
||||
@@ -1955,7 +1955,6 @@
|
||||
"cloudClaimInvite_processingTitle": "Обработка кода приглашения...",
|
||||
"cloudClaimInvite_claimButton": "Получить приглашение",
|
||||
"cloudSorryContactSupport_title": "Извините, свяжитесь с поддержкой",
|
||||
"cloudVerifyEmail_title": "Подтверждение email",
|
||||
"cloudPrivateBeta_title": "Cloud сейчас в приватной бете",
|
||||
"cloudPrivateBeta_desc": "Войдите, чтобы присоединиться к списку ожидания. Мы уведомим вас, когда будет доступен бета-доступ. Уже получили уведомление? Войдите и начните работать с Comfy Cloud.",
|
||||
"cloudForgotPassword_title": "Забыли пароль",
|
||||
|
||||
@@ -1955,7 +1955,6 @@
|
||||
"cloudClaimInvite_processingTitle": "處理邀請碼中...",
|
||||
"cloudClaimInvite_claimButton": "領取邀請",
|
||||
"cloudSorryContactSupport_title": "抱歉,請聯繫用戶支援",
|
||||
"cloudVerifyEmail_title": "郵箱驗證",
|
||||
"cloudPrivateBeta_title": "Cloud 目前處於內測階段",
|
||||
"cloudPrivateBeta_desc": "登入即可加入等候名單,當您有內測資格時我們將及時通知。已收到通知?登入即可開始使用 Comfy Cloud。",
|
||||
"cloudForgotPassword_title": "忘記密碼",
|
||||
|
||||
@@ -1958,7 +1958,6 @@
|
||||
"cloudClaimInvite_processingTitle": "处理邀请码中...",
|
||||
"cloudClaimInvite_claimButton": "领取邀请",
|
||||
"cloudSorryContactSupport_title": "抱歉,请联系用户支持",
|
||||
"cloudVerifyEmail_title": "邮箱验证",
|
||||
"cloudPrivateBeta_title": "Cloud 目前处于内测阶段",
|
||||
"cloudPrivateBeta_desc": "登录即可加入等候名单,当您有内测资格时我们将及时通知。已收到通知?登录即可开始使用 Comfy Cloud。",
|
||||
"cloudForgotPassword_title": "忘记密码",
|
||||
|
||||
@@ -16,7 +16,23 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
path: 'login',
|
||||
name: 'cloud-login',
|
||||
component: () =>
|
||||
import('@/platform/onboarding/cloud/CloudLoginView.vue')
|
||||
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()
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'signup',
|
||||
@@ -68,8 +84,10 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'verify-email',
|
||||
name: 'cloud-verify-email',
|
||||
component: () =>
|
||||
import('@/platform/onboarding/cloud/CloudVerifyEmailView.vue')
|
||||
redirect: (to) => ({
|
||||
name: 'cloud-user-check',
|
||||
query: to.query
|
||||
})
|
||||
},
|
||||
{
|
||||
path: 'sorry-contact-support',
|
||||
@@ -81,7 +99,8 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
path: 'auth-timeout',
|
||||
name: 'cloud-auth-timeout',
|
||||
component: () =>
|
||||
import('@/platform/onboarding/cloud/CloudAuthTimeoutView.vue')
|
||||
import('@/platform/onboarding/cloud/CloudAuthTimeoutView.vue'),
|
||||
props: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ const ACRONYM_TAGS = new Set(['VAE', 'CLIP', 'GLIGEN'])
|
||||
export function formatCategoryLabel(raw?: string): string {
|
||||
if (!raw) return 'Models'
|
||||
|
||||
// Special display name mappings
|
||||
if (raw === 'diffusion_models') return 'Diffusion'
|
||||
|
||||
return raw
|
||||
.split('_')
|
||||
.map((segment) => {
|
||||
|
||||
@@ -15,7 +15,7 @@ export const useSessionCookie = () => {
|
||||
if (!isCloud) return
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const authHeader = await authStore.getAuthHeader()
|
||||
const authHeader = await authStore.getAuthHeader(true)
|
||||
|
||||
if (!authHeader) {
|
||||
throw new Error('No auth header available for session creation')
|
||||
|
||||
@@ -2,39 +2,57 @@
|
||||
<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 { onBeforeUnmount, ref } from 'vue'
|
||||
import { computed, 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'
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
size?: 'small' | 'large'
|
||||
buttonClass?: string
|
||||
variant?: 'default' | 'gradient'
|
||||
fluid?: boolean
|
||||
}>(),
|
||||
{
|
||||
size: 'large',
|
||||
buttonClass: 'w-full font-bold'
|
||||
variant: 'default',
|
||||
fluid: true
|
||||
}
|
||||
)
|
||||
|
||||
const rootClass = computed(() => cn('font-bold', props.fluid && 'w-full'))
|
||||
|
||||
const emit = defineEmits<{
|
||||
subscribed: []
|
||||
}>()
|
||||
|
||||
const { subscribe, isActiveSubscription, fetchStatus } = useSubscription()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const isPolling = ref(false)
|
||||
@@ -60,6 +78,7 @@ const startPollingSubscriptionStatus = () => {
|
||||
|
||||
if (isActiveSubscription.value) {
|
||||
stopPolling()
|
||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||
emit('subscribed')
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
icon="pi pi-lock"
|
||||
severity="primary"
|
||||
size="small"
|
||||
:style="{
|
||||
background: 'var(--color-subscription-button-gradient)',
|
||||
color: 'var(--color-white)'
|
||||
}"
|
||||
:pt="{
|
||||
root: {
|
||||
style: {
|
||||
borderColor: 'transparent'
|
||||
}
|
||||
}
|
||||
}"
|
||||
data-testid="subscribe-to-run-button"
|
||||
@click="handleSubscribeToRun"
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col items-start gap-1 self-stretch">
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="pi pi-check mt-1 text-sm" />
|
||||
<span class="text-sm">
|
||||
<i class="pi pi-check mt-1 text-xs text-text-primary" />
|
||||
<span class="text-sm text-text-primary">
|
||||
{{ $t('subscription.benefits.benefit1') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="pi pi-check mt-1 text-sm" />
|
||||
<span class="text-sm">
|
||||
<i class="pi pi-check mt-1 text-xs text-text-primary" />
|
||||
<span class="text-sm text-text-primary">
|
||||
{{ $t('subscription.benefits.benefit2') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -19,8 +19,15 @@
|
||||
text
|
||||
icon="pi pi-external-link"
|
||||
icon-pos="left"
|
||||
size="small"
|
||||
class="self-start !p-0 text-sm hover:!bg-transparent [&]:!text-[inherit]"
|
||||
class="flex h-8 min-h-6 py-2 px-0 items-center gap-2 rounded text-text-secondary"
|
||||
:pt="{
|
||||
icon: {
|
||||
class: 'text-xs text-text-secondary'
|
||||
},
|
||||
label: {
|
||||
class: 'text-sm text-text-secondary'
|
||||
}
|
||||
}"
|
||||
@click="handleViewMoreDetails"
|
||||
/>
|
||||
</div>
|
||||
@@ -30,6 +37,6 @@
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const handleViewMoreDetails = () => {
|
||||
window.open('https://www.comfy.org/cloud', '_blank')
|
||||
window.open('https://www.comfy.org/cloud/pricing', '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<template>
|
||||
<TabPanel value="PlanCredits" class="subscription-container h-full">
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-2xl">
|
||||
{{ $t('subscription.title') }}
|
||||
</h2>
|
||||
<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">
|
||||
{{
|
||||
isActiveSubscription
|
||||
? $t('subscription.title')
|
||||
: $t('subscription.titleUnsubscribed')
|
||||
}}
|
||||
</span>
|
||||
<CloudBadge
|
||||
reverse-order
|
||||
background-color="var(--p-dialog-background)"
|
||||
@@ -12,17 +16,20 @@
|
||||
</div>
|
||||
|
||||
<div class="grow overflow-auto">
|
||||
<div class="rounded-lg border border-charcoal-400 p-4">
|
||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-2xl font-bold">{{
|
||||
formattedMonthlyPrice
|
||||
<div class="flex items-baseline gap-1 font-inter font-semibold">
|
||||
<span class="text-2xl">{{ formattedMonthlyPrice }}</span>
|
||||
<span class="text-base">{{
|
||||
$t('subscription.perMonth')
|
||||
}}</span>
|
||||
<span>{{ $t('subscription.perMonth') }}</span>
|
||||
</div>
|
||||
<div v-if="isActiveSubscription" class="text-xs text-muted">
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
class="text-sm text-text-secondary"
|
||||
>
|
||||
<template v-if="isCancelled">
|
||||
{{
|
||||
$t('subscription.expiresDate', {
|
||||
@@ -43,105 +50,165 @@
|
||||
v-if="isActiveSubscription"
|
||||
:label="$t('subscription.manageSubscription')"
|
||||
severity="secondary"
|
||||
class="text-xs"
|
||||
class="text-xs bg-interface-menu-component-surface-selected"
|
||||
:pt="{
|
||||
root: {
|
||||
style: 'border-radius: 8px; padding: 8px 16px;'
|
||||
},
|
||||
label: {
|
||||
class: 'text-text-primary'
|
||||
}
|
||||
}"
|
||||
@click="manageSubscription"
|
||||
/>
|
||||
<SubscribeButton
|
||||
v-else
|
||||
:label="$t('subscription.subscribeNow')"
|
||||
size="small"
|
||||
button-class="text-xs"
|
||||
:fluid="false"
|
||||
class="text-xs"
|
||||
@subscribed="handleRefresh"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 rounded-lg pt-10 lg:grid-cols-2">
|
||||
<div class="flex flex-col">
|
||||
<div class="grid grid-cols-1 gap-6 pt-9 lg:grid-cols-2">
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col">
|
||||
<div class="text-sm">
|
||||
{{ $t('subscription.apiNodesBalance') }}
|
||||
{{ $t('subscription.partnerNodesBalance') }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="text-xs text-muted">
|
||||
{{ $t('subscription.apiNodesDescription') }}
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.partnerNodesDescription') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-3 rounded-lg border p-4 dark-theme:border-0 dark-theme:bg-charcoal-600"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex flex-col gap-6 rounded-2xl p-5',
|
||||
'bg-smoke-100 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">
|
||||
<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"
|
||||
<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 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"
|
||||
/>
|
||||
{{ $t('subscription.viewUsageHistory') }}
|
||||
</a>
|
||||
<Button
|
||||
v-if="isActiveSubscription"
|
||||
:label="$t('subscription.addApiCredits')"
|
||||
:label="$t('subscription.addCredits')"
|
||||
severity="secondary"
|
||||
class="text-xs"
|
||||
class="p-2 min-h-8 bg-interface-menu-component-surface-selected"
|
||||
:pt="{
|
||||
root: {
|
||||
style: 'border-radius: 8px;'
|
||||
},
|
||||
label: {
|
||||
class: 'text-sm'
|
||||
}
|
||||
}"
|
||||
@click="handleAddApiCredits"
|
||||
/>
|
||||
</div>
|
||||
@@ -149,7 +216,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2 flex-1">
|
||||
<div class="text-sm">
|
||||
{{ $t('subscription.yourPlanIncludes') }}
|
||||
</div>
|
||||
@@ -161,7 +228,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-charcoal-400 pt-3"
|
||||
class="flex items-center justify-between border-t border-interface-stroke pt-3"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
@@ -170,7 +237,31 @@
|
||||
severity="secondary"
|
||||
icon="pi pi-question-circle"
|
||||
class="text-xs"
|
||||
@click="handleLearnMore"
|
||||
:pt="{
|
||||
label: {
|
||||
class: 'text-text-secondary'
|
||||
},
|
||||
icon: {
|
||||
class: 'text-text-secondary text-xs'
|
||||
}
|
||||
}"
|
||||
@click="handleLearnMoreClick"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('subscription.partnerNodesCredits')"
|
||||
text
|
||||
severity="secondary"
|
||||
icon="pi pi-question-circle"
|
||||
class="text-xs"
|
||||
:pt="{
|
||||
label: {
|
||||
class: 'text-text-secondary'
|
||||
},
|
||||
icon: {
|
||||
class: 'text-text-secondary text-xs'
|
||||
}
|
||||
}"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('subscription.messageSupport')"
|
||||
@@ -178,6 +269,15 @@
|
||||
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>
|
||||
@@ -189,6 +289,14 @@
|
||||
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>
|
||||
@@ -198,26 +306,16 @@
|
||||
|
||||
<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 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()
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
isActiveSubscription,
|
||||
@@ -226,53 +324,26 @@ const {
|
||||
formattedEndDate,
|
||||
formattedMonthlyPrice,
|
||||
manageSubscription,
|
||||
handleViewUsageHistory,
|
||||
handleLearnMore,
|
||||
handleInvoiceHistory,
|
||||
fetchStatus
|
||||
handleInvoiceHistory
|
||||
} = useSubscription()
|
||||
|
||||
const latestEvents = ref<AuditLog[]>([])
|
||||
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||
useSubscriptionCredits()
|
||||
|
||||
const totalCredits = computed(() => {
|
||||
if (!authStore.balance) return '0.00'
|
||||
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
|
||||
})
|
||||
const {
|
||||
isLoadingSupport,
|
||||
refreshTooltip,
|
||||
handleAddApiCredits,
|
||||
handleMessageSupport,
|
||||
handleRefresh,
|
||||
handleLearnMoreClick
|
||||
} = useSubscriptionActions()
|
||||
|
||||
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()
|
||||
])
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
<template>
|
||||
<div class="grid h-full grid-cols-5 px-10 pb-10">
|
||||
<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="relative col-span-2 flex items-center justify-center overflow-hidden rounded-sm"
|
||||
>
|
||||
@@ -8,7 +18,7 @@
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
class="h-full min-w-[125%] object-cover"
|
||||
class="h-full min-w-[125%] object-cover p-0"
|
||||
style="margin-left: -20%"
|
||||
>
|
||||
<source
|
||||
@@ -18,17 +28,18 @@
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<div class="col-span-3 flex flex-col justify-between pl-8">
|
||||
<div class="col-span-3 flex flex-col justify-between p-8">
|
||||
<div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<div class="text-sm text-muted">
|
||||
<div class="text-text-primary text-sm text-muted">
|
||||
{{ $t('subscription.required.title') }}
|
||||
</div>
|
||||
<CloudBadge
|
||||
reverse-order
|
||||
no-padding
|
||||
background-color="var(--p-dialog-background)"
|
||||
use-subscription
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -41,19 +52,36 @@
|
||||
<SubscriptionBenefits class="mt-6 text-muted" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<SubscribeButton @subscribed="handleSubscribed" />
|
||||
<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>
|
||||
</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]
|
||||
}>()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
@@ -14,34 +15,30 @@ import {
|
||||
useFirebaseAuthStore
|
||||
} from '@/stores/firebaseAuthStore'
|
||||
|
||||
interface CloudSubscriptionCheckoutResponse {
|
||||
type CloudSubscriptionCheckoutResponse = {
|
||||
checkout_url: string
|
||||
}
|
||||
|
||||
interface CloudSubscriptionStatusResponse {
|
||||
type CloudSubscriptionStatusResponse = {
|
||||
is_active: boolean
|
||||
subscription_id: string
|
||||
renewal_date: string | null
|
||||
end_date?: string | null
|
||||
}
|
||||
|
||||
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
|
||||
function useSubscriptionInternal() {
|
||||
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
|
||||
|
||||
const isActiveSubscription = computed(() => {
|
||||
if (!isCloud || !window.__CONFIG__?.subscription_required) return true
|
||||
const isSubscribedOrIsNotCloud = computed(() => {
|
||||
if (!isCloud || !window.__CONFIG__?.subscription_required) return true
|
||||
|
||||
return subscriptionStatus.value?.is_active ?? false
|
||||
})
|
||||
|
||||
let isWatchSetup = false
|
||||
|
||||
export function useSubscription() {
|
||||
const authActions = useFirebaseAuthActions()
|
||||
return subscriptionStatus.value?.is_active ?? false
|
||||
})
|
||||
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
const { getAuthHeader } = useFirebaseAuthStore()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const { reportError } = useFirebaseAuthActions()
|
||||
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
@@ -54,7 +51,7 @@ export function useSubscription() {
|
||||
|
||||
const renewalDate = new Date(subscriptionStatus.value.renewal_date)
|
||||
|
||||
return renewalDate.toLocaleDateString('en-US', {
|
||||
return renewalDate.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
@@ -66,7 +63,7 @@ export function useSubscription() {
|
||||
|
||||
const endDate = new Date(subscriptionStatus.value.end_date)
|
||||
|
||||
return endDate.toLocaleDateString('en-US', {
|
||||
return endDate.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
@@ -77,9 +74,10 @@ export function useSubscription() {
|
||||
() => `$${MONTHLY_SUBSCRIPTION_PRICE.toFixed(0)}`
|
||||
)
|
||||
|
||||
const fetchStatus = wrapWithErrorHandlingAsync(async () => {
|
||||
return await fetchSubscriptionStatus()
|
||||
}, reportError)
|
||||
const fetchStatus = wrapWithErrorHandlingAsync(
|
||||
fetchSubscriptionStatus,
|
||||
reportError
|
||||
)
|
||||
|
||||
const subscribe = wrapWithErrorHandlingAsync(async () => {
|
||||
const response = await initiateSubscriptionCheckout()
|
||||
@@ -100,17 +98,17 @@ export function useSubscription() {
|
||||
useTelemetry()?.trackSubscription('modal_opened')
|
||||
}
|
||||
|
||||
dialogService.showSubscriptionRequiredDialog()
|
||||
void dialogService.showSubscriptionRequiredDialog()
|
||||
}
|
||||
|
||||
const manageSubscription = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
await accessBillingPortal()
|
||||
}
|
||||
|
||||
const requireActiveSubscription = async (): Promise<void> => {
|
||||
await fetchSubscriptionStatus()
|
||||
|
||||
if (!isActiveSubscription.value) {
|
||||
if (!isSubscribedOrIsNotCloud.value) {
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
}
|
||||
@@ -124,61 +122,55 @@ export function useSubscription() {
|
||||
}
|
||||
|
||||
const handleInvoiceHistory = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
await accessBillingPortal()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current cloud subscription status for the authenticated user
|
||||
* @returns Subscription status or null if no subscription exists
|
||||
*/
|
||||
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'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
|
||||
const authHeader = await getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
|
||||
if (!isWatchSetup) {
|
||||
isWatchSetup = true
|
||||
watch(
|
||||
() => isLoggedIn.value,
|
||||
async (loggedIn) => {
|
||||
if (loggedIn) {
|
||||
await fetchSubscriptionStatus()
|
||||
} else {
|
||||
subscriptionStatus.value = null
|
||||
const response = await fetch(
|
||||
`${COMFY_API_BASE_URL}/customers/cloud-subscription-status`,
|
||||
{
|
||||
headers: {
|
||||
...authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
watch(
|
||||
() => isLoggedIn.value,
|
||||
async (loggedIn) => {
|
||||
if (loggedIn) {
|
||||
await fetchSubscriptionStatus()
|
||||
} else {
|
||||
subscriptionStatus.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const initiateSubscriptionCheckout =
|
||||
async (): Promise<CloudSubscriptionCheckoutResponse> => {
|
||||
const authHeader = await getAuthHeader()
|
||||
@@ -213,7 +205,7 @@ export function useSubscription() {
|
||||
|
||||
return {
|
||||
// State
|
||||
isActiveSubscription,
|
||||
isActiveSubscription: isSubscribedOrIsNotCloud,
|
||||
isCancelled,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
@@ -230,3 +222,5 @@ export function useSubscription() {
|
||||
handleInvoiceHistory
|
||||
}
|
||||
}
|
||||
|
||||
export const useSubscription = createSharedComposable(useSubscriptionInternal)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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 { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
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 telemetry = useTelemetry()
|
||||
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
|
||||
if (isCloud) {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'subscription'
|
||||
})
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,67 @@
|
||||
<template>
|
||||
<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">
|
||||
<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">
|
||||
{{ $t('cloudOnboarding.authTimeout.title') }}
|
||||
</h2>
|
||||
<p class="mb-6 text-gray-600">
|
||||
<p class="mb-5 text-muted">
|
||||
{{ $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')"
|
||||
@@ -21,12 +75,20 @@
|
||||
|
||||
<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()
|
||||
|
||||
@@ -98,7 +98,10 @@ const inviteCode = computed(() => route.query.inviteCode as string)
|
||||
const onSwitchAccounts = () => {
|
||||
void router.push({
|
||||
name: 'cloud-login',
|
||||
query: { inviteCode: inviteCode.value }
|
||||
query: {
|
||||
switchAccount: 'true',
|
||||
inviteCode: inviteCode.value
|
||||
}
|
||||
})
|
||||
}
|
||||
const onClickSupport = () => {
|
||||
|
||||
@@ -17,23 +17,16 @@ onMounted(async () => {
|
||||
const inviteCode = route.params.code as string | undefined
|
||||
|
||||
if (firebaseAuthStore.isAuthenticated) {
|
||||
const { isEmailVerified } = firebaseAuthStore
|
||||
|
||||
if (!isEmailVerified) {
|
||||
// User is logged in but email not verified
|
||||
await router.push({ name: 'cloud-verify-email', query: { inviteCode } })
|
||||
// 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 }
|
||||
})
|
||||
} else {
|
||||
// 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' })
|
||||
}
|
||||
// Normal login flow - go to user check
|
||||
await router.push({ name: 'cloud-user-check' })
|
||||
}
|
||||
} else {
|
||||
// User is not logged in - proceed to login page
|
||||
|
||||
@@ -115,7 +115,6 @@ 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()
|
||||
@@ -140,20 +139,15 @@ const onSuccess = async () => {
|
||||
})
|
||||
// Check if there's an invite code
|
||||
const inviteCode = route.query.inviteCode as string | undefined
|
||||
const { isEmailVerified } = useFirebaseAuthStore()
|
||||
if (!isEmailVerified) {
|
||||
await router.push({ name: 'cloud-verify-email', query: { inviteCode } })
|
||||
if (inviteCode) {
|
||||
// Handle invite code flow - go to invite check
|
||||
await router.push({
|
||||
name: 'cloud-invite-check',
|
||||
query: { inviteCode }
|
||||
})
|
||||
} else {
|
||||
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' })
|
||||
}
|
||||
// Normal login flow - go to user check
|
||||
await router.push({ name: 'cloud-user-check' })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,6 @@ 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'
|
||||
|
||||
@@ -127,20 +126,8 @@ const onSuccess = async () => {
|
||||
summary: 'Sign up Completed',
|
||||
life: 2000
|
||||
})
|
||||
// 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 })
|
||||
}
|
||||
// Direct redirect to main app - email verification removed
|
||||
await router.push({ path: '/', query: route.query })
|
||||
}
|
||||
|
||||
// Custom error handler for inline display
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
<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>
|
||||
@@ -52,7 +52,8 @@ const router = useRouter()
|
||||
|
||||
const onSwitchAccounts = () => {
|
||||
void router.push({
|
||||
name: 'cloud-login'
|
||||
name: 'cloud-login',
|
||||
query: { switchAccount: 'true' }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -61,19 +61,25 @@ const {
|
||||
return
|
||||
}
|
||||
|
||||
// Survey is required for all users
|
||||
if (!surveyStatus) {
|
||||
skeletonType.value = 'survey'
|
||||
await router.replace({ name: 'cloud-survey' })
|
||||
return
|
||||
}
|
||||
|
||||
if (cloudUserStats.status !== 'active') {
|
||||
// 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
|
||||
skeletonType.value = 'waitlist'
|
||||
await router.replace({ name: 'cloud-waitlist' })
|
||||
return
|
||||
}
|
||||
|
||||
// User is fully onboarded
|
||||
// User is fully onboarded (active or whitelist check disabled)
|
||||
window.location.href = '/'
|
||||
}),
|
||||
null,
|
||||
|
||||
@@ -14,6 +14,7 @@ type ServerHealthAlert = {
|
||||
*/
|
||||
export type RemoteConfig = {
|
||||
mixpanel_token?: string
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
server_health_alert?: ServerHealthAlert
|
||||
}
|
||||
|
||||
@@ -1,16 +1,39 @@
|
||||
import type { OverridedMixpanel } from 'mixpanel-browser'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
checkForCompletedTopup as checkTopupUtil,
|
||||
clearTopupTracking as clearTopupUtil,
|
||||
startTopupTracking as startTopupUtil
|
||||
} from '@/platform/telemetry/topupTracker'
|
||||
import { computeNodeMetrics } from '@/platform/telemetry/utils/computeNodeMetrics'
|
||||
import type { NodeMetrics } from '@/platform/telemetry/utils/computeNodeMetrics'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import type {
|
||||
AuthMetadata,
|
||||
CreditTopupMetadata,
|
||||
ExecutionContext,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
PageVisibilityMetadata,
|
||||
RunButtonProperties,
|
||||
SurveyResponses,
|
||||
TabCountMetadata,
|
||||
TelemetryEventName,
|
||||
TelemetryEventProperties,
|
||||
TelemetryProvider,
|
||||
TemplateMetadata
|
||||
TemplateFilterMetadata,
|
||||
TemplateLibraryClosedMetadata,
|
||||
TemplateLibraryMetadata,
|
||||
TemplateMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata
|
||||
} from '../../types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
|
||||
@@ -47,6 +70,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
private _settingStore: any = null
|
||||
private _composablesReady = false
|
||||
|
||||
// Injected dependencies to avoid circular dependency with app.ts
|
||||
private _graph: LGraph | null = null
|
||||
private _nodeDefsByName: Record<string, ComfyNodeDefImpl> = {}
|
||||
|
||||
constructor() {
|
||||
const token = window.__CONFIG__?.mixpanel_token
|
||||
|
||||
@@ -133,6 +160,20 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
void this.initializeComposables()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the graph context for node metrics computation.
|
||||
* Must be called after app.graph is initialized to enable full telemetry context.
|
||||
* @param graph - The LiteGraph instance
|
||||
* @param nodeDefsByName - Map of node type names to their definitions
|
||||
*/
|
||||
setGraphContext(
|
||||
graph: LGraph,
|
||||
nodeDefsByName: Record<string, ComfyNodeDefImpl>
|
||||
): void {
|
||||
this._graph = graph
|
||||
this._nodeDefsByName = nodeDefsByName
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy initialization of Vue composables to avoid circular dependencies during module loading.
|
||||
* Only imports and initializes composables once when app is ready.
|
||||
@@ -265,6 +306,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
|
||||
}
|
||||
|
||||
trackUserLoggedIn(): void {
|
||||
this.trackEvent(TelemetryEvents.USER_LOGGED_IN)
|
||||
}
|
||||
|
||||
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void {
|
||||
const eventName =
|
||||
event === 'modal_opened'
|
||||
@@ -274,13 +319,53 @@ 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)
|
||||
}
|
||||
|
||||
// Credit top-up tracking methods (composition with utility functions)
|
||||
startTopupTracking(): void {
|
||||
startTopupUtil()
|
||||
}
|
||||
|
||||
checkForCompletedTopup(events: any[] | undefined | null): boolean {
|
||||
return checkTopupUtil(events)
|
||||
}
|
||||
|
||||
clearTopupTracking(): void {
|
||||
clearTopupUtil()
|
||||
}
|
||||
|
||||
trackRunButton(options?: { subscribe_to_run?: boolean }): void {
|
||||
if (this.isOnboardingMode) {
|
||||
// During onboarding, track basic run button click without workflow context
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, {
|
||||
subscribe_to_run: options?.subscribe_to_run || false,
|
||||
workflow_type: 'custom',
|
||||
workflow_name: 'untitled'
|
||||
workflow_name: 'untitled',
|
||||
custom_node_count: 0,
|
||||
total_node_count: 0,
|
||||
subgraph_count: 0,
|
||||
has_api_nodes: false,
|
||||
api_node_names: []
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -290,12 +375,25 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
const runButtonProperties: RunButtonProperties = {
|
||||
subscribe_to_run: options?.subscribe_to_run || false,
|
||||
workflow_type: executionContext.is_template ? 'template' : 'custom',
|
||||
workflow_name: executionContext.workflow_name ?? 'untitled'
|
||||
workflow_name: executionContext.workflow_name ?? 'untitled',
|
||||
custom_node_count: executionContext.custom_node_count,
|
||||
total_node_count: executionContext.total_node_count,
|
||||
subgraph_count: executionContext.subgraph_count,
|
||||
has_api_nodes: executionContext.has_api_nodes,
|
||||
api_node_names: executionContext.api_node_names
|
||||
}
|
||||
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
|
||||
}
|
||||
|
||||
trackRunTriggeredViaKeybinding(): void {
|
||||
this.trackEvent(TelemetryEvents.RUN_TRIGGERED_KEYBINDING)
|
||||
}
|
||||
|
||||
trackRunTriggeredViaMenu(): void {
|
||||
this.trackEvent(TelemetryEvents.RUN_TRIGGERED_MENU)
|
||||
}
|
||||
|
||||
trackSurvey(
|
||||
stage: 'opened' | 'submitted',
|
||||
responses?: SurveyResponses
|
||||
@@ -347,34 +445,74 @@ 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)
|
||||
}
|
||||
|
||||
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, 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)
|
||||
}
|
||||
|
||||
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.HELP_CENTER_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.HELP_RESOURCE_CLICKED, metadata)
|
||||
}
|
||||
|
||||
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.HELP_CENTER_CLOSED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowExecution(): void {
|
||||
if (this.isOnboardingMode) {
|
||||
// During onboarding, track basic execution without workflow context
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_START, {
|
||||
is_template: false,
|
||||
workflow_name: undefined
|
||||
workflow_name: undefined,
|
||||
custom_node_count: 0,
|
||||
api_node_count: 0,
|
||||
subgraph_count: 0,
|
||||
total_node_count: 0,
|
||||
has_api_nodes: false,
|
||||
api_node_names: []
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -397,6 +535,18 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
void this.initializeComposables()
|
||||
}
|
||||
|
||||
// Return zero node counts if graph context not injected yet
|
||||
const nodeCounts: NodeMetrics = this._graph
|
||||
? computeNodeMetrics(this._graph, this._nodeDefsByName)
|
||||
: {
|
||||
custom_node_count: 0,
|
||||
api_node_count: 0,
|
||||
subgraph_count: 0,
|
||||
total_node_count: 0,
|
||||
has_api_nodes: false,
|
||||
api_node_names: []
|
||||
}
|
||||
|
||||
if (
|
||||
!this._composablesReady ||
|
||||
!this._workflowStore ||
|
||||
@@ -404,7 +554,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
) {
|
||||
return {
|
||||
is_template: false,
|
||||
workflow_name: undefined
|
||||
workflow_name: undefined,
|
||||
...nodeCounts
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,25 +584,29 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
template_tags: englishMetadata?.tags ?? template?.tags,
|
||||
template_models: englishMetadata?.models ?? template?.models,
|
||||
template_use_case: englishMetadata?.useCase ?? template?.useCase,
|
||||
template_license: englishMetadata?.license ?? template?.license
|
||||
template_license: englishMetadata?.license ?? template?.license,
|
||||
...nodeCounts
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
is_template: false,
|
||||
workflow_name: activeWorkflow.filename
|
||||
workflow_name: activeWorkflow.filename,
|
||||
...nodeCounts
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
is_template: false,
|
||||
workflow_name: undefined
|
||||
workflow_name: undefined,
|
||||
...nodeCounts
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get execution context:', error)
|
||||
return {
|
||||
is_template: false,
|
||||
workflow_name: undefined
|
||||
workflow_name: undefined,
|
||||
...nodeCounts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
61
src/platform/telemetry/topupTracker.ts
Normal file
61
src/platform/telemetry/topupTracker.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
|
||||
const STORAGE_KEY = 'pending_topup_timestamp'
|
||||
const MAX_AGE_MS = 24 * 60 * 60 * 1000 // 24 hours
|
||||
|
||||
/**
|
||||
* Start tracking a credit top-up purchase.
|
||||
* Call this before opening the Stripe checkout window.
|
||||
*/
|
||||
export function startTopupTracking(): void {
|
||||
localStorage.setItem(STORAGE_KEY, Date.now().toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a pending top-up has completed by looking for a credit_added event
|
||||
* that occurred after the tracking started.
|
||||
*
|
||||
* @param events - Array of audit log events to check
|
||||
* @returns true if a completed top-up was detected and telemetry was sent
|
||||
*/
|
||||
export function checkForCompletedTopup(
|
||||
events: AuditLog[] | undefined | null
|
||||
): boolean {
|
||||
const timestampStr = localStorage.getItem(STORAGE_KEY)
|
||||
if (!timestampStr) return false
|
||||
|
||||
const timestamp = parseInt(timestampStr, 10)
|
||||
|
||||
// Auto-cleanup if expired (older than 24 hours)
|
||||
if (Date.now() - timestamp > MAX_AGE_MS) {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!events || events.length === 0) return false
|
||||
|
||||
// Find credit_added event that occurred after our timestamp
|
||||
const completedTopup = events.find(
|
||||
(e) =>
|
||||
e.event_type === 'credit_added' &&
|
||||
e.createdAt &&
|
||||
new Date(e.createdAt).getTime() > timestamp
|
||||
)
|
||||
|
||||
if (completedTopup) {
|
||||
useTelemetry()?.trackApiCreditTopupSucceeded()
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear any pending top-up tracking.
|
||||
* Useful for testing or manual cleanup.
|
||||
*/
|
||||
export function clearTopupTracking(): void {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
* 2. `grep -RinE --include='*.js' 'trackWorkflow|trackEvent|mixpanel' dist/` (should find nothing)
|
||||
* 3. Check dist/assets/*.js files contain no tracking code
|
||||
*/
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
/**
|
||||
* Authentication metadata for sign-up tracking
|
||||
@@ -26,7 +28,6 @@ export interface AuthMetadata {
|
||||
|
||||
/**
|
||||
* Survey response data for user profiling
|
||||
* Maps 1-to-1 with actual survey fields
|
||||
*/
|
||||
export interface SurveyResponses {
|
||||
familiarity?: string
|
||||
@@ -42,6 +43,11 @@ export interface RunButtonProperties {
|
||||
subscribe_to_run: boolean
|
||||
workflow_type: 'template' | 'custom'
|
||||
workflow_name: string
|
||||
total_node_count: number
|
||||
custom_node_count: number
|
||||
subgraph_count: number
|
||||
has_api_nodes: boolean
|
||||
api_node_names: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,6 +63,13 @@ export interface ExecutionContext {
|
||||
template_models?: string[]
|
||||
template_use_case?: string
|
||||
template_license?: string
|
||||
// Node composition metrics
|
||||
custom_node_count: number
|
||||
api_node_count: number
|
||||
subgraph_count: number
|
||||
total_node_count: number
|
||||
has_api_nodes: boolean
|
||||
api_node_names: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +89,13 @@ export interface ExecutionSuccessMetadata {
|
||||
jobId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* API credit top-up purchase metadata
|
||||
*/
|
||||
export interface CreditTopupMetadata {
|
||||
credit_amount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Template metadata for workflow tracking
|
||||
*/
|
||||
@@ -89,6 +109,132 @@ export interface TemplateMetadata {
|
||||
template_license?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow import metadata
|
||||
*/
|
||||
export interface WorkflowImportMetadata {
|
||||
missing_node_count: number
|
||||
missing_node_types: string[]
|
||||
/**
|
||||
* The source of the workflow open/import action
|
||||
*/
|
||||
open_source?: 'file_button' | 'file_drop' | 'template' | 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow open metadata
|
||||
*/
|
||||
/**
|
||||
* Enumerated sources for workflow open/import actions.
|
||||
*/
|
||||
export type WorkflowOpenSource = NonNullable<
|
||||
WorkflowImportMetadata['open_source']
|
||||
>
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Help center opened metadata
|
||||
*/
|
||||
export interface HelpCenterOpenedMetadata {
|
||||
source: 'menu' | 'topbar' | 'sidebar'
|
||||
}
|
||||
|
||||
/**
|
||||
* Help resource clicked metadata
|
||||
*/
|
||||
export interface HelpResourceClickedMetadata {
|
||||
resource_type:
|
||||
| 'docs'
|
||||
| 'discord'
|
||||
| 'github'
|
||||
| 'help_feedback'
|
||||
| 'manager'
|
||||
| 'release_notes'
|
||||
is_external: boolean
|
||||
source:
|
||||
| 'menu'
|
||||
| 'help_center'
|
||||
| 'error_dialog'
|
||||
| 'credits_panel'
|
||||
| 'subscription'
|
||||
}
|
||||
|
||||
/**
|
||||
* Help center closed metadata
|
||||
*/
|
||||
export interface HelpCenterClosedMetadata {
|
||||
time_spent_seconds: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow created metadata
|
||||
*/
|
||||
export interface WorkflowCreatedMetadata {
|
||||
workflow_type: 'blank' | 'default'
|
||||
previous_workflow_had_nodes: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Core telemetry provider interface
|
||||
*/
|
||||
@@ -96,19 +242,55 @@ export interface TelemetryProvider {
|
||||
// Authentication flow events
|
||||
trackSignupOpened(): void
|
||||
trackAuth(metadata: AuthMetadata): void
|
||||
trackUserLoggedIn(): void
|
||||
|
||||
// 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
|
||||
trackRunTriggeredViaKeybinding(): void
|
||||
trackRunTriggeredViaMenu(): void
|
||||
|
||||
// Credit top-up tracking (composition with internal utilities)
|
||||
startTopupTracking(): void
|
||||
checkForCompletedTopup(events: any[] | undefined | null): boolean
|
||||
clearTopupTracking(): 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
|
||||
trackWorkflowOpened(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
|
||||
|
||||
// Help center events
|
||||
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void
|
||||
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void
|
||||
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void
|
||||
|
||||
// Workflow creation events
|
||||
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void
|
||||
|
||||
// Workflow execution events
|
||||
trackWorkflowExecution(): void
|
||||
@@ -117,6 +299,10 @@ export interface TelemetryProvider {
|
||||
|
||||
// App lifecycle management
|
||||
markAppReady?(): void
|
||||
setGraphContext?(
|
||||
graph: LGraph,
|
||||
nodeDefsByName: Record<string, ComfyNodeDefImpl>
|
||||
): void
|
||||
identifyUser?(userId: string): void
|
||||
}
|
||||
|
||||
@@ -131,23 +317,53 @@ export const TelemetryEvents = {
|
||||
// Authentication Flow
|
||||
USER_SIGN_UP_OPENED: 'app:user_sign_up_opened',
|
||||
USER_AUTH_COMPLETED: 'app:user_auth_completed',
|
||||
USER_LOGGED_IN: 'app:user_logged_in',
|
||||
|
||||
// Subscription Flow
|
||||
RUN_BUTTON_CLICKED: 'app:run_button_click',
|
||||
RUN_TRIGGERED_KEYBINDING: 'app:run_triggered_keybinding',
|
||||
RUN_TRIGGERED_MENU: 'app:run_triggered_menu',
|
||||
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',
|
||||
WORKFLOW_OPENED: 'app:workflow_opened',
|
||||
|
||||
// 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',
|
||||
|
||||
// Help Center Analytics
|
||||
HELP_CENTER_OPENED: 'app:help_center_opened',
|
||||
HELP_RESOURCE_CLICKED: 'app:help_resource_clicked',
|
||||
HELP_CENTER_CLOSED: 'app:help_center_closed',
|
||||
|
||||
// Workflow Creation
|
||||
WORKFLOW_CREATED: 'app:workflow_created',
|
||||
|
||||
// Execution Lifecycle
|
||||
EXECUTION_START: 'execution_start',
|
||||
@@ -169,3 +385,16 @@ export type TelemetryEventProperties =
|
||||
| RunButtonProperties
|
||||
| ExecutionErrorMetadata
|
||||
| ExecutionSuccessMetadata
|
||||
| CreditTopupMetadata
|
||||
| WorkflowImportMetadata
|
||||
| TemplateLibraryMetadata
|
||||
| TemplateLibraryClosedMetadata
|
||||
| PageVisibilityMetadata
|
||||
| TabCountMetadata
|
||||
| NodeSearchMetadata
|
||||
| NodeSearchResultMetadata
|
||||
| TemplateFilterMetadata
|
||||
| HelpCenterOpenedMetadata
|
||||
| HelpResourceClickedMetadata
|
||||
| HelpCenterClosedMetadata
|
||||
| WorkflowCreatedMetadata
|
||||
|
||||
@@ -0,0 +1,682 @@
|
||||
/**
|
||||
* Unit tests for survey normalization utilities
|
||||
* Uses real example data from migration script to verify categorization accuracy
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
normalizeIndustry,
|
||||
normalizeSurveyResponses,
|
||||
normalizeUseCase
|
||||
} from '../surveyNormalization'
|
||||
|
||||
describe('normalizeIndustry', () => {
|
||||
describe('Film / TV / Animation category', () => {
|
||||
it('should categorize film and television production', () => {
|
||||
expect(normalizeIndustry('Film and television production')).toBe(
|
||||
'Film / TV / Animation'
|
||||
)
|
||||
expect(normalizeIndustry('film')).toBe('Film / TV / Animation')
|
||||
expect(normalizeIndustry('TV production')).toBe('Film / TV / Animation')
|
||||
expect(normalizeIndustry('animation studio')).toBe(
|
||||
'Film / TV / Animation'
|
||||
)
|
||||
expect(normalizeIndustry('VFX artist')).toBe('Film / TV / Animation')
|
||||
expect(normalizeIndustry('cinema')).toBe('Film / TV / Animation')
|
||||
expect(normalizeIndustry('documentary filmmaker')).toBe(
|
||||
'Film / TV / Animation'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle typos and variations', () => {
|
||||
expect(normalizeIndustry('animtion')).toBe('Film / TV / Animation')
|
||||
expect(normalizeIndustry('film prod')).toBe('Film / TV / Animation')
|
||||
expect(normalizeIndustry('movie production')).toBe(
|
||||
'Film / TV / Animation'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Marketing / Advertising / Social Media category', () => {
|
||||
it('should categorize marketing and social media', () => {
|
||||
expect(normalizeIndustry('Marketing & Social Media')).toBe(
|
||||
'Marketing / Advertising / Social Media'
|
||||
)
|
||||
expect(normalizeIndustry('digital marketing')).toBe(
|
||||
'Marketing / Advertising / Social Media'
|
||||
)
|
||||
expect(normalizeIndustry('YouTube content creation')).toBe(
|
||||
'Marketing / Advertising / Social Media'
|
||||
)
|
||||
expect(normalizeIndustry('TikTok marketing')).toBe(
|
||||
'Marketing / Advertising / Social Media'
|
||||
)
|
||||
expect(normalizeIndustry('brand promotion')).toBe(
|
||||
'Marketing / Advertising / Social Media'
|
||||
)
|
||||
expect(normalizeIndustry('influencer marketing')).toBe(
|
||||
'Marketing / Advertising / Social Media'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle social media variations', () => {
|
||||
expect(normalizeIndustry('social content')).toBe(
|
||||
'Marketing / Advertising / Social Media'
|
||||
)
|
||||
expect(normalizeIndustry('content creation')).toBe(
|
||||
'Marketing / Advertising / Social Media'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Software / IT / AI category', () => {
|
||||
it('should categorize software development', () => {
|
||||
expect(normalizeIndustry('Software Development')).toBe(
|
||||
'Software / IT / AI'
|
||||
)
|
||||
expect(normalizeIndustry('tech startup')).toBe('Software / IT / AI')
|
||||
expect(normalizeIndustry('AI research')).toBe('Software / IT / AI')
|
||||
expect(normalizeIndustry('web development')).toBe('Software / IT / AI')
|
||||
expect(normalizeIndustry('machine learning')).toBe('Software / IT / AI')
|
||||
expect(normalizeIndustry('data science')).toBe('Software / IT / AI')
|
||||
expect(normalizeIndustry('programming')).toBe('Software / IT / AI')
|
||||
})
|
||||
|
||||
it('should handle IT variations', () => {
|
||||
expect(normalizeIndustry('software engineer')).toBe('Software / IT / AI')
|
||||
expect(normalizeIndustry('app developer')).toBe('Software / IT / AI')
|
||||
})
|
||||
|
||||
it('should categorize corporate AI research', () => {
|
||||
expect(normalizeIndustry('corporate AI research')).toBe(
|
||||
'Software / IT / AI'
|
||||
)
|
||||
expect(normalizeIndustry('AI research lab')).toBe('Software / IT / AI')
|
||||
expect(normalizeIndustry('tech company AI research')).toBe(
|
||||
'Software / IT / AI'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Gaming / Interactive Media category', () => {
|
||||
it('should categorize gaming industry', () => {
|
||||
expect(normalizeIndustry('Indie Game Studio')).toBe(
|
||||
'Gaming / Interactive Media'
|
||||
)
|
||||
expect(normalizeIndustry('game development')).toBe(
|
||||
'Gaming / Interactive Media'
|
||||
)
|
||||
expect(normalizeIndustry('VR development')).toBe(
|
||||
'Gaming / Interactive Media'
|
||||
)
|
||||
expect(normalizeIndustry('interactive media')).toBe(
|
||||
'Gaming / Interactive Media'
|
||||
)
|
||||
expect(normalizeIndustry('metaverse')).toBe('Gaming / Interactive Media')
|
||||
expect(normalizeIndustry('Unity developer')).toBe(
|
||||
'Gaming / Interactive Media'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle game dev variations', () => {
|
||||
expect(normalizeIndustry('game dev')).toBe('Gaming / Interactive Media')
|
||||
expect(normalizeIndustry('indie games')).toBe(
|
||||
'Gaming / Interactive Media'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Architecture / Engineering / Construction category', () => {
|
||||
it('should categorize architecture and construction', () => {
|
||||
expect(normalizeIndustry('Architecture firm')).toBe(
|
||||
'Architecture / Engineering / Construction'
|
||||
)
|
||||
expect(normalizeIndustry('construction')).toBe(
|
||||
'Architecture / Engineering / Construction'
|
||||
)
|
||||
expect(normalizeIndustry('civil engineering')).toBe(
|
||||
'Architecture / Engineering / Construction'
|
||||
)
|
||||
expect(normalizeIndustry('interior design')).toBe(
|
||||
'Architecture / Engineering / Construction'
|
||||
)
|
||||
expect(normalizeIndustry('landscape architecture')).toBe(
|
||||
'Architecture / Engineering / Construction'
|
||||
)
|
||||
expect(normalizeIndustry('real estate')).toBe(
|
||||
'Architecture / Engineering / Construction'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Fashion / Beauty / Retail category', () => {
|
||||
it('should categorize fashion and beauty', () => {
|
||||
expect(normalizeIndustry('Custom Jewelry Design')).toBe(
|
||||
'Fashion / Beauty / Retail'
|
||||
)
|
||||
expect(normalizeIndustry('fashion design')).toBe(
|
||||
'Fashion / Beauty / Retail'
|
||||
)
|
||||
expect(normalizeIndustry('beauty industry')).toBe(
|
||||
'Fashion / Beauty / Retail'
|
||||
)
|
||||
expect(normalizeIndustry('retail store')).toBe(
|
||||
'Fashion / Beauty / Retail'
|
||||
)
|
||||
expect(normalizeIndustry('cosmetics')).toBe('Fashion / Beauty / Retail')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Healthcare / Medical / Life Science category', () => {
|
||||
it('should categorize medical and health fields', () => {
|
||||
expect(normalizeIndustry('Medical Research')).toBe(
|
||||
'Healthcare / Medical / Life Science'
|
||||
)
|
||||
expect(normalizeIndustry('healthcare')).toBe(
|
||||
'Healthcare / Medical / Life Science'
|
||||
)
|
||||
expect(normalizeIndustry('biotech')).toBe(
|
||||
'Healthcare / Medical / Life Science'
|
||||
)
|
||||
expect(normalizeIndustry('pharmaceutical')).toBe(
|
||||
'Healthcare / Medical / Life Science'
|
||||
)
|
||||
expect(normalizeIndustry('clinical research')).toBe(
|
||||
'Healthcare / Medical / Life Science'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Education / Research category', () => {
|
||||
it('should categorize education and research', () => {
|
||||
expect(normalizeIndustry('university research')).toBe(
|
||||
'Education / Research'
|
||||
)
|
||||
expect(normalizeIndustry('academic')).toBe('Education / Research')
|
||||
expect(normalizeIndustry('teaching')).toBe('Education / Research')
|
||||
expect(normalizeIndustry('student')).toBe('Education / Research')
|
||||
expect(normalizeIndustry('professor')).toBe('Education / Research')
|
||||
})
|
||||
|
||||
it('should categorize academic AI research', () => {
|
||||
expect(normalizeIndustry('academic AI research')).toBe(
|
||||
'Education / Research'
|
||||
)
|
||||
expect(normalizeIndustry('university AI research')).toBe(
|
||||
'Education / Research'
|
||||
)
|
||||
expect(normalizeIndustry('AI research at university')).toBe(
|
||||
'Education / Research'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Fine Art / Contemporary Art category', () => {
|
||||
it('should categorize art fields', () => {
|
||||
expect(normalizeIndustry('fine art')).toBe('Fine Art / Contemporary Art')
|
||||
expect(normalizeIndustry('contemporary artist')).toBe(
|
||||
'Fine Art / Contemporary Art'
|
||||
)
|
||||
expect(normalizeIndustry('digital art')).toBe(
|
||||
'Fine Art / Contemporary Art'
|
||||
)
|
||||
expect(normalizeIndustry('illustration')).toBe(
|
||||
'Fine Art / Contemporary Art'
|
||||
)
|
||||
expect(normalizeIndustry('gallery')).toBe('Fine Art / Contemporary Art')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Photography / Videography category', () => {
|
||||
it('should categorize photography fields', () => {
|
||||
expect(normalizeIndustry('photography')).toBe('Photography / Videography')
|
||||
expect(normalizeIndustry('wedding photography')).toBe(
|
||||
'Photography / Videography'
|
||||
)
|
||||
expect(normalizeIndustry('commercial photo')).toBe(
|
||||
'Photography / Videography'
|
||||
)
|
||||
expect(normalizeIndustry('videography')).toBe('Photography / Videography')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Product & Industrial Design category', () => {
|
||||
it('should categorize product design', () => {
|
||||
expect(normalizeIndustry('product design')).toBe(
|
||||
'Product & Industrial Design'
|
||||
)
|
||||
expect(normalizeIndustry('industrial design')).toBe(
|
||||
'Product & Industrial Design'
|
||||
)
|
||||
expect(normalizeIndustry('manufacturing')).toBe(
|
||||
'Product & Industrial Design'
|
||||
)
|
||||
expect(normalizeIndustry('3d rendering')).toBe(
|
||||
'Product & Industrial Design'
|
||||
)
|
||||
expect(normalizeIndustry('automotive design')).toBe(
|
||||
'Product & Industrial Design'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Music / Performing Arts category', () => {
|
||||
it('should categorize music and performing arts', () => {
|
||||
expect(normalizeIndustry('music production')).toBe(
|
||||
'Music / Performing Arts'
|
||||
)
|
||||
expect(normalizeIndustry('theater')).toBe('Music / Performing Arts')
|
||||
expect(normalizeIndustry('concert production')).toBe(
|
||||
'Music / Performing Arts'
|
||||
)
|
||||
expect(normalizeIndustry('live events')).toBe('Music / Performing Arts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('E-commerce / Print-on-Demand / Business category', () => {
|
||||
it('should categorize business fields', () => {
|
||||
expect(normalizeIndustry('ecommerce')).toBe(
|
||||
'E-commerce / Print-on-Demand / Business'
|
||||
)
|
||||
expect(normalizeIndustry('print on demand')).toBe(
|
||||
'E-commerce / Print-on-Demand / Business'
|
||||
)
|
||||
expect(normalizeIndustry('startup')).toBe(
|
||||
'E-commerce / Print-on-Demand / Business'
|
||||
)
|
||||
expect(normalizeIndustry('online store')).toBe(
|
||||
'E-commerce / Print-on-Demand / Business'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Nonprofit / Government / Public Sector category', () => {
|
||||
it('should categorize nonprofit and government', () => {
|
||||
expect(normalizeIndustry('nonprofit')).toBe(
|
||||
'Nonprofit / Government / Public Sector'
|
||||
)
|
||||
expect(normalizeIndustry('government agency')).toBe(
|
||||
'Nonprofit / Government / Public Sector'
|
||||
)
|
||||
expect(normalizeIndustry('public service')).toBe(
|
||||
'Nonprofit / Government / Public Sector'
|
||||
)
|
||||
expect(normalizeIndustry('charity')).toBe(
|
||||
'Nonprofit / Government / Public Sector'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Adult / NSFW category', () => {
|
||||
it('should categorize adult content', () => {
|
||||
expect(normalizeIndustry('adult entertainment')).toBe('Adult / NSFW')
|
||||
expect(normalizeIndustry('NSFW content')).toBe('Adult / NSFW')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Other / Undefined category', () => {
|
||||
it('should handle undefined responses', () => {
|
||||
expect(normalizeIndustry('other')).toBe('Other / Undefined')
|
||||
expect(normalizeIndustry('none')).toBe('Other / Undefined')
|
||||
expect(normalizeIndustry('undefined')).toBe('Other / Undefined')
|
||||
expect(normalizeIndustry('unknown')).toBe('Other / Undefined')
|
||||
expect(normalizeIndustry('n/a')).toBe('Other / Undefined')
|
||||
expect(normalizeIndustry('not applicable')).toBe('Other / Undefined')
|
||||
expect(normalizeIndustry('-')).toBe('Other / Undefined')
|
||||
expect(normalizeIndustry('')).toBe('Other / Undefined')
|
||||
})
|
||||
|
||||
it('should handle null and invalid inputs', () => {
|
||||
expect(normalizeIndustry(null as any)).toBe('Other / Undefined')
|
||||
expect(normalizeIndustry(undefined as any)).toBe('Other / Undefined')
|
||||
expect(normalizeIndustry(123 as any)).toBe('Other / Undefined')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Uncategorized responses', () => {
|
||||
it('should preserve unknown creative fields with prefix', () => {
|
||||
expect(normalizeIndustry('Unknown Creative Field')).toBe(
|
||||
'Uncategorized: Unknown Creative Field'
|
||||
)
|
||||
expect(normalizeIndustry('Completely Novel Field')).toBe(
|
||||
'Uncategorized: Completely Novel Field'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeUseCase', () => {
|
||||
describe('Content Creation & Marketing', () => {
|
||||
it('should categorize content creation', () => {
|
||||
expect(normalizeUseCase('YouTube thumbnail generation')).toBe(
|
||||
'Content Creation & Marketing'
|
||||
)
|
||||
expect(normalizeUseCase('social media content')).toBe(
|
||||
'Content Creation & Marketing'
|
||||
)
|
||||
expect(normalizeUseCase('marketing campaigns')).toBe(
|
||||
'Content Creation & Marketing'
|
||||
)
|
||||
expect(normalizeUseCase('TikTok content')).toBe(
|
||||
'Content Creation & Marketing'
|
||||
)
|
||||
expect(normalizeUseCase('brand content creation')).toBe(
|
||||
'Content Creation & Marketing'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Art & Illustration', () => {
|
||||
it('should categorize art and illustration', () => {
|
||||
expect(normalizeUseCase('Creating concept art for movies')).toBe(
|
||||
'Art & Illustration'
|
||||
)
|
||||
expect(normalizeUseCase('digital art')).toBe('Art & Illustration')
|
||||
expect(normalizeUseCase('character design')).toBe('Art & Illustration')
|
||||
expect(normalizeUseCase('illustration work')).toBe('Art & Illustration')
|
||||
expect(normalizeUseCase('fantasy art')).toBe('Art & Illustration')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Product Visualization & Design', () => {
|
||||
it('should categorize product work', () => {
|
||||
expect(normalizeUseCase('Product mockup creation')).toBe(
|
||||
'Product Visualization & Design'
|
||||
)
|
||||
expect(normalizeUseCase('3d product rendering')).toBe(
|
||||
'Product Visualization & Design'
|
||||
)
|
||||
expect(normalizeUseCase('prototype visualization')).toBe(
|
||||
'Product Visualization & Design'
|
||||
)
|
||||
expect(normalizeUseCase('industrial design')).toBe(
|
||||
'Product Visualization & Design'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Gaming & Interactive Media', () => {
|
||||
it('should categorize gaming use cases', () => {
|
||||
expect(normalizeUseCase('Game asset generation')).toBe(
|
||||
'Gaming & Interactive Media'
|
||||
)
|
||||
expect(normalizeUseCase('game development')).toBe(
|
||||
'Gaming & Interactive Media'
|
||||
)
|
||||
expect(normalizeUseCase('VR content creation')).toBe(
|
||||
'Gaming & Interactive Media'
|
||||
)
|
||||
expect(normalizeUseCase('interactive media')).toBe(
|
||||
'Gaming & Interactive Media'
|
||||
)
|
||||
expect(normalizeUseCase('game textures')).toBe(
|
||||
'Gaming & Interactive Media'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Architecture & Construction', () => {
|
||||
it('should categorize architecture use cases', () => {
|
||||
expect(normalizeUseCase('Building visualization')).toBe(
|
||||
'Architecture & Construction'
|
||||
)
|
||||
expect(normalizeUseCase('architectural rendering')).toBe(
|
||||
'Architecture & Construction'
|
||||
)
|
||||
expect(normalizeUseCase('interior design mockups')).toBe(
|
||||
'Architecture & Construction'
|
||||
)
|
||||
expect(normalizeUseCase('real estate visualization')).toBe(
|
||||
'Architecture & Construction'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Photography & Image Processing', () => {
|
||||
it('should categorize photography work', () => {
|
||||
expect(normalizeUseCase('Product photography')).toBe(
|
||||
'Photography & Image Processing'
|
||||
)
|
||||
expect(normalizeUseCase('photo editing')).toBe(
|
||||
'Photography & Image Processing'
|
||||
)
|
||||
expect(normalizeUseCase('image enhancement')).toBe(
|
||||
'Photography & Image Processing'
|
||||
)
|
||||
expect(normalizeUseCase('portrait photography')).toBe(
|
||||
'Photography & Image Processing'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Research & Development', () => {
|
||||
it('should categorize research work', () => {
|
||||
expect(normalizeUseCase('Scientific visualization')).toBe(
|
||||
'Research & Development'
|
||||
)
|
||||
expect(normalizeUseCase('research experiments')).toBe(
|
||||
'Research & Development'
|
||||
)
|
||||
expect(normalizeUseCase('prototype testing')).toBe(
|
||||
'Research & Development'
|
||||
)
|
||||
expect(normalizeUseCase('innovation projects')).toBe(
|
||||
'Research & Development'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Personal & Hobby', () => {
|
||||
it('should categorize personal projects', () => {
|
||||
expect(normalizeUseCase('Personal art projects')).toBe('Personal & Hobby')
|
||||
expect(normalizeUseCase('hobby work')).toBe('Personal & Hobby')
|
||||
expect(normalizeUseCase('creative exploration')).toBe('Personal & Hobby')
|
||||
expect(normalizeUseCase('fun experiments')).toBe('Personal & Hobby')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Film & Video Production', () => {
|
||||
it('should categorize film work', () => {
|
||||
expect(normalizeUseCase('movie production')).toBe(
|
||||
'Film & Video Production'
|
||||
)
|
||||
expect(normalizeUseCase('video editing')).toBe('Film & Video Production')
|
||||
expect(normalizeUseCase('visual effects')).toBe('Film & Video Production')
|
||||
expect(normalizeUseCase('storyboard creation')).toBe(
|
||||
'Film & Video Production'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Education & Training', () => {
|
||||
it('should categorize educational use cases', () => {
|
||||
expect(normalizeUseCase('educational content')).toBe(
|
||||
'Education & Training'
|
||||
)
|
||||
expect(normalizeUseCase('training materials')).toBe(
|
||||
'Education & Training'
|
||||
)
|
||||
expect(normalizeUseCase('tutorial creation')).toBe('Education & Training')
|
||||
expect(normalizeUseCase('academic projects')).toBe('Education & Training')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Other / Undefined category', () => {
|
||||
it('should handle undefined responses', () => {
|
||||
expect(normalizeUseCase('other')).toBe('Other / Undefined')
|
||||
expect(normalizeUseCase('none')).toBe('Other / Undefined')
|
||||
expect(normalizeUseCase('undefined')).toBe('Other / Undefined')
|
||||
expect(normalizeUseCase('')).toBe('Other / Undefined')
|
||||
expect(normalizeUseCase(null as any)).toBe('Other / Undefined')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Uncategorized responses', () => {
|
||||
it('should preserve unknown use cases with prefix', () => {
|
||||
expect(normalizeUseCase('Mysterious Use Case')).toBe(
|
||||
'Uncategorized: Mysterious Use Case'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeSurveyResponses', () => {
|
||||
it('should normalize both industry and use case', () => {
|
||||
const input = {
|
||||
industry: 'Film and television production',
|
||||
useCase: 'Creating concept art for movies',
|
||||
familiarity: 'Expert'
|
||||
}
|
||||
|
||||
const result = normalizeSurveyResponses(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
industry: 'Film and television production',
|
||||
industry_normalized: 'Film / TV / Animation',
|
||||
industry_raw: 'Film and television production',
|
||||
useCase: 'Creating concept art for movies',
|
||||
useCase_normalized: 'Art & Illustration',
|
||||
useCase_raw: 'Creating concept art for movies',
|
||||
familiarity: 'Expert'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle partial responses', () => {
|
||||
const input = {
|
||||
industry: 'Software Development',
|
||||
familiarity: 'Beginner'
|
||||
}
|
||||
|
||||
const result = normalizeSurveyResponses(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
industry: 'Software Development',
|
||||
industry_normalized: 'Software / IT / AI',
|
||||
industry_raw: 'Software Development',
|
||||
familiarity: 'Beginner'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty responses', () => {
|
||||
const input = {
|
||||
familiarity: 'Intermediate'
|
||||
}
|
||||
|
||||
const result = normalizeSurveyResponses(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
familiarity: 'Intermediate'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle uncategorized responses', () => {
|
||||
const input = {
|
||||
industry: 'Unknown Creative Field',
|
||||
useCase: 'Mysterious Use Case'
|
||||
}
|
||||
|
||||
const result = normalizeSurveyResponses(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
industry: 'Unknown Creative Field',
|
||||
industry_normalized: 'Uncategorized: Unknown Creative Field',
|
||||
industry_raw: 'Unknown Creative Field',
|
||||
useCase: 'Mysterious Use Case',
|
||||
useCase_normalized: 'Uncategorized: Mysterious Use Case',
|
||||
useCase_raw: 'Mysterious Use Case'
|
||||
})
|
||||
})
|
||||
|
||||
describe('Migration script example data validation', () => {
|
||||
it('should correctly categorize all migration script examples', () => {
|
||||
const examples = [
|
||||
{
|
||||
input: {
|
||||
industry: 'Film and television production',
|
||||
useCase: 'Creating concept art for movies'
|
||||
},
|
||||
expected: {
|
||||
industry: 'Film / TV / Animation',
|
||||
useCase: 'Art & Illustration'
|
||||
}
|
||||
},
|
||||
{
|
||||
input: {
|
||||
industry: 'Marketing & Social Media',
|
||||
useCase: 'YouTube thumbnail generation'
|
||||
},
|
||||
expected: {
|
||||
industry: 'Marketing / Advertising / Social Media',
|
||||
useCase: 'Content Creation & Marketing'
|
||||
}
|
||||
},
|
||||
{
|
||||
input: {
|
||||
industry: 'Software Development',
|
||||
useCase: 'Product mockup creation'
|
||||
},
|
||||
expected: {
|
||||
industry: 'Software / IT / AI',
|
||||
useCase: 'Product Visualization & Design'
|
||||
}
|
||||
},
|
||||
{
|
||||
input: {
|
||||
industry: 'Indie Game Studio',
|
||||
useCase: 'Game asset generation'
|
||||
},
|
||||
expected: {
|
||||
industry: 'Gaming / Interactive Media',
|
||||
useCase: 'Gaming & Interactive Media'
|
||||
}
|
||||
},
|
||||
{
|
||||
input: {
|
||||
industry: 'Architecture firm',
|
||||
useCase: 'Building visualization'
|
||||
},
|
||||
expected: {
|
||||
industry: 'Architecture / Engineering / Construction',
|
||||
useCase: 'Architecture & Construction'
|
||||
}
|
||||
},
|
||||
{
|
||||
input: {
|
||||
industry: 'Custom Jewelry Design',
|
||||
useCase: 'Product photography'
|
||||
},
|
||||
expected: {
|
||||
industry: 'Fashion / Beauty / Retail',
|
||||
useCase: 'Photography & Image Processing'
|
||||
}
|
||||
},
|
||||
{
|
||||
input: {
|
||||
industry: 'Medical Research',
|
||||
useCase: 'Scientific visualization'
|
||||
},
|
||||
expected: {
|
||||
industry: 'Healthcare / Medical / Life Science',
|
||||
useCase: 'Research & Development'
|
||||
}
|
||||
},
|
||||
{
|
||||
input: {
|
||||
industry: 'Unknown Creative Field',
|
||||
useCase: 'Personal art projects'
|
||||
},
|
||||
expected: {
|
||||
industry: 'Uncategorized: Unknown Creative Field',
|
||||
useCase: 'Personal & Hobby'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
examples.forEach(({ input, expected }) => {
|
||||
const result = normalizeSurveyResponses(input)
|
||||
expect(result.industry_normalized).toBe(expected.industry)
|
||||
expect(result.useCase_normalized).toBe(expected.useCase)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
84
src/platform/telemetry/utils/computeNodeMetrics.ts
Normal file
84
src/platform/telemetry/utils/computeNodeMetrics.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
export interface NodeMetrics {
|
||||
custom_node_count: number
|
||||
api_node_count: number
|
||||
subgraph_count: number
|
||||
total_node_count: number
|
||||
has_api_nodes: boolean
|
||||
api_node_names: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes node composition metrics for a given graph.
|
||||
*
|
||||
* Analyzes the graph to determine:
|
||||
* - Total node count
|
||||
* - Custom node count (from extensions)
|
||||
* - API node count (ComfyUI API nodes)
|
||||
* - Subgraph count (nested workflows)
|
||||
* - API node names
|
||||
*
|
||||
* @param graph - The LiteGraph instance to analyze
|
||||
* @param nodeDefsByName - Map of node type to node definition
|
||||
* @returns Node metrics including counts and API node information
|
||||
*/
|
||||
export function computeNodeMetrics(
|
||||
graph: LGraph | null,
|
||||
nodeDefsByName: Record<string, ComfyNodeDefImpl>
|
||||
): NodeMetrics {
|
||||
const defaultMetrics: NodeMetrics = {
|
||||
custom_node_count: 0,
|
||||
api_node_count: 0,
|
||||
subgraph_count: 0,
|
||||
total_node_count: 0,
|
||||
has_api_nodes: false,
|
||||
api_node_names: []
|
||||
}
|
||||
|
||||
if (!graph) {
|
||||
return defaultMetrics
|
||||
}
|
||||
|
||||
try {
|
||||
const nodes = collectAllNodes(graph)
|
||||
|
||||
let customNodeCount = 0
|
||||
let apiNodeCount = 0
|
||||
let subgraphCount = 0
|
||||
let totalNodeCount = 0
|
||||
let hasApiNodes = false
|
||||
const apiNodeNames = new Set<string>()
|
||||
|
||||
for (const node of nodes) {
|
||||
totalNodeCount += 1
|
||||
const nodeDef = nodeDefsByName[node.type ?? '']
|
||||
const isCustomNode =
|
||||
nodeDef?.nodeSource?.type === NodeSourceType.CustomNodes
|
||||
const isApiNode = nodeDef?.api_node === true
|
||||
const isSubgraph = node.isSubgraphNode?.() === true
|
||||
if (isCustomNode) customNodeCount += 1
|
||||
if (isApiNode) {
|
||||
apiNodeCount += 1
|
||||
hasApiNodes = true
|
||||
if (nodeDef?.name) apiNodeNames.add(nodeDef.name)
|
||||
}
|
||||
if (isSubgraph) subgraphCount += 1
|
||||
}
|
||||
|
||||
return {
|
||||
custom_node_count: customNodeCount,
|
||||
api_node_count: apiNodeCount,
|
||||
subgraph_count: subgraphCount,
|
||||
total_node_count: totalNodeCount,
|
||||
has_api_nodes: hasApiNodes,
|
||||
api_node_names: Array.from(apiNodeNames)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to compute node metrics:', error)
|
||||
return defaultMetrics
|
||||
}
|
||||
}
|
||||
606
src/platform/telemetry/utils/surveyNormalization.ts
Normal file
606
src/platform/telemetry/utils/surveyNormalization.ts
Normal file
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* Survey Response Normalization Utilities
|
||||
*
|
||||
* Smart categorization system to normalize free-text survey responses
|
||||
* into standardized categories for better analytics breakdowns.
|
||||
* Uses Fuse.js for fuzzy matching against category keywords.
|
||||
*/
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
interface CategoryMapping {
|
||||
name: string
|
||||
keywords: string[]
|
||||
userCount?: number // For reference from analysis
|
||||
}
|
||||
|
||||
/**
|
||||
* Industry category mappings based on ~9,000 user analysis
|
||||
*/
|
||||
const INDUSTRY_CATEGORIES: CategoryMapping[] = [
|
||||
{
|
||||
name: 'Film / TV / Animation',
|
||||
userCount: 2885,
|
||||
keywords: [
|
||||
'film',
|
||||
'tv',
|
||||
'television',
|
||||
'animation',
|
||||
'animation studio',
|
||||
'tv production',
|
||||
'film production',
|
||||
'story',
|
||||
'anime',
|
||||
'video',
|
||||
'cinematography',
|
||||
'visual effects',
|
||||
'vfx',
|
||||
'vfx artist',
|
||||
'movie',
|
||||
'cinema',
|
||||
'documentary',
|
||||
'documentary filmmaker',
|
||||
'broadcast',
|
||||
'streaming',
|
||||
'production',
|
||||
'director',
|
||||
'filmmaker',
|
||||
'post-production',
|
||||
'editing'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Marketing / Advertising / Social Media',
|
||||
userCount: 1340,
|
||||
keywords: [
|
||||
'marketing',
|
||||
'advertising',
|
||||
'youtube',
|
||||
'tiktok',
|
||||
'social media',
|
||||
'content creation',
|
||||
'influencer',
|
||||
'brand',
|
||||
'promotion',
|
||||
'digital marketing',
|
||||
'seo',
|
||||
'campaigns',
|
||||
'copywriting',
|
||||
'growth',
|
||||
'engagement'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Software / IT / AI',
|
||||
userCount: 1100,
|
||||
keywords: [
|
||||
'software',
|
||||
'software development',
|
||||
'software engineer',
|
||||
'it',
|
||||
'ai',
|
||||
'ai research',
|
||||
'corporate ai research',
|
||||
'ai research lab',
|
||||
'tech company ai research',
|
||||
'developer',
|
||||
'app developer',
|
||||
'consulting',
|
||||
'tech',
|
||||
'tech startup',
|
||||
'programmer',
|
||||
'data science',
|
||||
'machine learning',
|
||||
'coding',
|
||||
'programming',
|
||||
'web development',
|
||||
'app development',
|
||||
'saas'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Product & Industrial Design',
|
||||
userCount: 1050,
|
||||
keywords: [
|
||||
'product design',
|
||||
'industrial',
|
||||
'manufacturing',
|
||||
'3d rendering',
|
||||
'product visualization',
|
||||
'mechanical',
|
||||
'automotive',
|
||||
'cad',
|
||||
'prototype',
|
||||
'design engineering',
|
||||
'invention'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Fine Art / Contemporary Art',
|
||||
userCount: 780,
|
||||
keywords: [
|
||||
'fine art',
|
||||
'art',
|
||||
'illustration',
|
||||
'contemporary',
|
||||
'artist',
|
||||
'painting',
|
||||
'drawing',
|
||||
'sculpture',
|
||||
'gallery',
|
||||
'canvas',
|
||||
'digital art',
|
||||
'mixed media',
|
||||
'abstract',
|
||||
'portrait'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Education / Research',
|
||||
userCount: 640,
|
||||
keywords: [
|
||||
'education',
|
||||
'student',
|
||||
'teacher',
|
||||
'research',
|
||||
'university research',
|
||||
'academic ai research',
|
||||
'university ai research',
|
||||
'ai research at university',
|
||||
'learning',
|
||||
'university',
|
||||
'school',
|
||||
'academic',
|
||||
'professor',
|
||||
'curriculum',
|
||||
'training',
|
||||
'instruction',
|
||||
'pedagogy'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Architecture / Engineering / Construction',
|
||||
userCount: 420,
|
||||
keywords: [
|
||||
'architecture',
|
||||
'architecture firm',
|
||||
'construction',
|
||||
'engineering',
|
||||
'civil',
|
||||
'civil engineering',
|
||||
'cad',
|
||||
'building',
|
||||
'structural',
|
||||
'landscape',
|
||||
'landscape architecture',
|
||||
'interior design',
|
||||
'real estate',
|
||||
'planning',
|
||||
'blueprints'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Gaming / Interactive Media',
|
||||
userCount: 410,
|
||||
keywords: [
|
||||
'gaming',
|
||||
'game dev',
|
||||
'game development',
|
||||
'indie game studio',
|
||||
'vr development',
|
||||
'roblox',
|
||||
'interactive',
|
||||
'interactive media',
|
||||
'virtual world',
|
||||
'vr',
|
||||
'ar',
|
||||
'metaverse',
|
||||
'simulation',
|
||||
'unity',
|
||||
'unity developer',
|
||||
'unreal',
|
||||
'indie games'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Photography / Videography',
|
||||
userCount: 70,
|
||||
keywords: [
|
||||
'photography',
|
||||
'photo',
|
||||
'videography',
|
||||
'camera',
|
||||
'image',
|
||||
'portrait',
|
||||
'wedding',
|
||||
'commercial photo',
|
||||
'stock photography',
|
||||
'photojournalism',
|
||||
'event photography'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Fashion / Beauty / Retail',
|
||||
userCount: 25,
|
||||
keywords: [
|
||||
'fashion',
|
||||
'fashion design',
|
||||
'beauty',
|
||||
'beauty industry',
|
||||
'jewelry',
|
||||
'jewelry design',
|
||||
'custom jewelry design',
|
||||
'retail',
|
||||
'retail store',
|
||||
'style',
|
||||
'clothing',
|
||||
'cosmetics',
|
||||
'makeup',
|
||||
'accessories',
|
||||
'boutique'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Music / Performing Arts',
|
||||
userCount: 25,
|
||||
keywords: [
|
||||
'music',
|
||||
'music production',
|
||||
'vj',
|
||||
'dance',
|
||||
'projection mapping',
|
||||
'audio visual',
|
||||
'concert',
|
||||
'concert production',
|
||||
'performance',
|
||||
'theater',
|
||||
'stage',
|
||||
'live events'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Healthcare / Medical / Life Science',
|
||||
userCount: 30,
|
||||
keywords: [
|
||||
'healthcare',
|
||||
'medical',
|
||||
'medical research',
|
||||
'doctor',
|
||||
'biotech',
|
||||
'life science',
|
||||
'pharmaceutical',
|
||||
'clinical',
|
||||
'clinical research',
|
||||
'hospital',
|
||||
'medicine',
|
||||
'health'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'E-commerce / Print-on-Demand / Business',
|
||||
userCount: 15,
|
||||
keywords: [
|
||||
'ecommerce',
|
||||
'e-commerce',
|
||||
'print on demand',
|
||||
'shop',
|
||||
'business',
|
||||
'commercial',
|
||||
'startup',
|
||||
'entrepreneur',
|
||||
'sales',
|
||||
'online store'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Nonprofit / Government / Public Sector',
|
||||
userCount: 15,
|
||||
keywords: [
|
||||
'501c3',
|
||||
'ngo',
|
||||
'government',
|
||||
'public service',
|
||||
'policy',
|
||||
'nonprofit',
|
||||
'charity',
|
||||
'civic',
|
||||
'community',
|
||||
'social impact'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Adult / NSFW',
|
||||
userCount: 10,
|
||||
keywords: [
|
||||
'nsfw',
|
||||
'nsfw content',
|
||||
'adult',
|
||||
'adult entertainment',
|
||||
'erotic',
|
||||
'explicit',
|
||||
'xxx',
|
||||
'porn'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Use case category mappings based on common patterns
|
||||
*/
|
||||
const USE_CASE_CATEGORIES: CategoryMapping[] = [
|
||||
{
|
||||
name: 'Content Creation & Marketing',
|
||||
keywords: [
|
||||
'content creation',
|
||||
'social media',
|
||||
'marketing',
|
||||
'marketing campaigns',
|
||||
'advertising',
|
||||
'youtube',
|
||||
'youtube thumbnail',
|
||||
'youtube thumbnail generation',
|
||||
'tiktok',
|
||||
'instagram',
|
||||
'thumbnails',
|
||||
'posts',
|
||||
'campaigns',
|
||||
'brand content'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Art & Illustration',
|
||||
keywords: [
|
||||
'art',
|
||||
'illustration',
|
||||
'drawing',
|
||||
'painting',
|
||||
'concept art',
|
||||
'creating concept art',
|
||||
'character design',
|
||||
'digital art',
|
||||
'fantasy art',
|
||||
'portraits'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Product Visualization & Design',
|
||||
keywords: [
|
||||
'product',
|
||||
'product mockup',
|
||||
'product mockup creation',
|
||||
'visualization',
|
||||
'prototype visualization',
|
||||
'design',
|
||||
'prototype',
|
||||
'mockup',
|
||||
'3d rendering',
|
||||
'industrial design',
|
||||
'product photos'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Film & Video Production',
|
||||
keywords: [
|
||||
'film',
|
||||
'video',
|
||||
'video editing',
|
||||
'movie',
|
||||
'movie production',
|
||||
'animation',
|
||||
'vfx',
|
||||
'visual effects',
|
||||
'storyboard',
|
||||
'storyboard creation',
|
||||
'cinematography',
|
||||
'post production'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Gaming & Interactive Media',
|
||||
keywords: [
|
||||
'game',
|
||||
'gaming',
|
||||
'game asset generation',
|
||||
'game assets',
|
||||
'game development',
|
||||
'game textures',
|
||||
'interactive',
|
||||
'vr',
|
||||
'vr content creation',
|
||||
'ar',
|
||||
'virtual',
|
||||
'simulation',
|
||||
'metaverse',
|
||||
'textures'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Architecture & Construction',
|
||||
keywords: [
|
||||
'architecture',
|
||||
'architectural rendering',
|
||||
'building',
|
||||
'building visualization',
|
||||
'construction',
|
||||
'interior design',
|
||||
'interior design mockups',
|
||||
'landscape',
|
||||
'real estate',
|
||||
'real estate visualization',
|
||||
'floor plans',
|
||||
'renderings'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Education & Training',
|
||||
keywords: [
|
||||
'education',
|
||||
'educational',
|
||||
'educational content',
|
||||
'training',
|
||||
'training materials',
|
||||
'learning',
|
||||
'teaching',
|
||||
'tutorial',
|
||||
'tutorial creation',
|
||||
'course',
|
||||
'academic',
|
||||
'academic projects',
|
||||
'instructional',
|
||||
'workshops'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Research & Development',
|
||||
keywords: [
|
||||
'research',
|
||||
'research experiments',
|
||||
'development',
|
||||
'experiment',
|
||||
'prototype',
|
||||
'prototype testing',
|
||||
'testing',
|
||||
'analysis',
|
||||
'study',
|
||||
'innovation',
|
||||
'innovation projects',
|
||||
'r&d',
|
||||
'scientific visualization'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Personal & Hobby',
|
||||
keywords: [
|
||||
'personal',
|
||||
'personal art projects',
|
||||
'hobby',
|
||||
'hobby work',
|
||||
'fun',
|
||||
'fun experiments',
|
||||
'experiment',
|
||||
'learning',
|
||||
'curiosity',
|
||||
'explore',
|
||||
'creative',
|
||||
'creative exploration',
|
||||
'side project'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Photography & Image Processing',
|
||||
keywords: [
|
||||
'photography',
|
||||
'product photography',
|
||||
'portrait photography',
|
||||
'photo',
|
||||
'photo editing',
|
||||
'image',
|
||||
'image enhancement',
|
||||
'portrait',
|
||||
'editing',
|
||||
'enhancement',
|
||||
'restoration',
|
||||
'photo manipulation'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Fuse.js configuration for category matching
|
||||
*/
|
||||
const FUSE_OPTIONS = {
|
||||
keys: ['keywords'],
|
||||
threshold: 0.53, // Higher = more lenient matching
|
||||
minMatchCharLength: 5,
|
||||
includeScore: true,
|
||||
includeMatches: true,
|
||||
ignoreLocation: true,
|
||||
findAllMatches: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Fuse instances for category matching
|
||||
*/
|
||||
const industryFuse = new Fuse(INDUSTRY_CATEGORIES, FUSE_OPTIONS)
|
||||
const useCaseFuse = new Fuse(USE_CASE_CATEGORIES, FUSE_OPTIONS)
|
||||
|
||||
/**
|
||||
* Normalize industry responses using Fuse.js fuzzy search
|
||||
*/
|
||||
export function normalizeIndustry(rawIndustry: string): string {
|
||||
if (!rawIndustry || typeof rawIndustry !== 'string') {
|
||||
return 'Other / Undefined'
|
||||
}
|
||||
|
||||
const industry = rawIndustry.toLowerCase().trim()
|
||||
|
||||
// Handle common undefined responses
|
||||
if (
|
||||
industry.match(/^(other|none|undefined|unknown|n\/a|not applicable|-|)$/)
|
||||
) {
|
||||
return 'Other / Undefined'
|
||||
}
|
||||
|
||||
// Fuse.js fuzzy search for best category match
|
||||
const results = industryFuse.search(rawIndustry)
|
||||
|
||||
if (results.length > 0) {
|
||||
return results[0].item.name
|
||||
}
|
||||
|
||||
// No good match found - preserve original with prefix
|
||||
return `Uncategorized: ${rawIndustry}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize use case responses using Fuse.js fuzzy search
|
||||
*/
|
||||
export function normalizeUseCase(rawUseCase: string): string {
|
||||
if (!rawUseCase || typeof rawUseCase !== 'string') {
|
||||
return 'Other / Undefined'
|
||||
}
|
||||
|
||||
const useCase = rawUseCase.toLowerCase().trim()
|
||||
|
||||
// Handle common undefined responses
|
||||
if (
|
||||
useCase.match(/^(other|none|undefined|unknown|n\/a|not applicable|-|)$/)
|
||||
) {
|
||||
return 'Other / Undefined'
|
||||
}
|
||||
|
||||
// Fuse.js fuzzy search for best category match
|
||||
const results = useCaseFuse.search(rawUseCase)
|
||||
|
||||
if (results.length > 0) {
|
||||
return results[0].item.name
|
||||
}
|
||||
|
||||
// No good match found - preserve original with prefix
|
||||
return `Uncategorized: ${rawUseCase}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply normalization to survey responses
|
||||
* Creates both normalized and raw versions of responses
|
||||
*/
|
||||
export function normalizeSurveyResponses(responses: {
|
||||
industry?: string
|
||||
useCase?: string
|
||||
[key: string]: any
|
||||
}) {
|
||||
const normalized = { ...responses }
|
||||
|
||||
// Normalize industry
|
||||
if (responses.industry) {
|
||||
normalized.industry_normalized = normalizeIndustry(responses.industry)
|
||||
normalized.industry_raw = responses.industry
|
||||
}
|
||||
|
||||
// Normalize use case
|
||||
if (responses.useCase) {
|
||||
normalized.useCase_normalized = normalizeUseCase(responses.useCase)
|
||||
normalized.useCase_raw = responses.useCase
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
@@ -138,7 +138,9 @@ export function useTemplateWorkflows() {
|
||||
}
|
||||
|
||||
dialogStore.closeDialog()
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
await app.loadGraphData(json, true, true, workflowName, {
|
||||
openSource: 'template'
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -159,7 +161,9 @@ export function useTemplateWorkflows() {
|
||||
}
|
||||
|
||||
dialogStore.closeDialog()
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
await app.loadGraphData(json, true, true, workflowName, {
|
||||
openSource: 'template'
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
|
||||
@@ -129,8 +129,13 @@ router.beforeEach(async (to, _from, next) => {
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Auth initialization failed:', error)
|
||||
// Navigate to auth timeout recovery page
|
||||
return next({ name: 'cloud-auth-timeout' })
|
||||
// Navigate to auth timeout recovery page with error details
|
||||
return next({
|
||||
name: 'cloud-auth-timeout',
|
||||
params: {
|
||||
errorMessage: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,32 +183,28 @@ 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()
|
||||
|
||||
// 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' })
|
||||
}
|
||||
// Survey is required for all users regardless of whitelist status
|
||||
if (!surveyCompleted) {
|
||||
return next({ name: 'cloud-survey' })
|
||||
}
|
||||
// User is active, allow access to root
|
||||
|
||||
// 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
|
||||
} catch (error) {
|
||||
console.error('Failed to check user status:', error)
|
||||
// On error, redirect to user-check as fallback
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ModelFile,
|
||||
ModelFolderInfo
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { type WorkflowTemplates } from '@/platform/workflow/templates/types/template'
|
||||
import type {
|
||||
@@ -542,9 +543,10 @@ 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()
|
||||
authToken = await useFirebaseAuthStore().getIdToken(isReconnect)
|
||||
} catch (error) {
|
||||
// Continue without auth token if there's an error
|
||||
console.warn('Could not get auth token for WebSocket connection:', error)
|
||||
@@ -1268,15 +1270,22 @@ export class ComfyApi extends EventTarget {
|
||||
}
|
||||
|
||||
async getLogs(): Promise<string> {
|
||||
return (await axios.get(this.internalURL('/logs'))).data
|
||||
const url = isCloud ? this.apiURL('/logs') : this.internalURL('/logs')
|
||||
return (await axios.get(url)).data
|
||||
}
|
||||
|
||||
async getRawLogs(): Promise<LogsRawResponse> {
|
||||
return (await axios.get(this.internalURL('/logs/raw'))).data
|
||||
const url = isCloud
|
||||
? this.apiURL('/logs/raw')
|
||||
: this.internalURL('/logs/raw')
|
||||
return (await axios.get(url)).data
|
||||
}
|
||||
|
||||
async subscribeLogs(enabled: boolean): Promise<void> {
|
||||
return await axios.patch(this.internalURL('/logs/subscribe'), {
|
||||
const url = isCloud
|
||||
? this.apiURL('/logs/subscribe')
|
||||
: this.internalURL('/logs/subscribe')
|
||||
return await axios.patch(url, {
|
||||
enabled,
|
||||
clientId: this.clientId
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { WorkflowOpenSource } from '@/platform/telemetry/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -619,7 +620,7 @@ export class ComfyApp {
|
||||
event.dataTransfer.files.length &&
|
||||
event.dataTransfer.files[0].type !== 'image/bmp'
|
||||
) {
|
||||
await this.handleFile(event.dataTransfer.files[0])
|
||||
await this.handleFile(event.dataTransfer.files[0], 'file_drop')
|
||||
} else {
|
||||
// Try loading the first URI in the transfer list
|
||||
const validTypes = ['text/uri-list', 'text/x-moz-url']
|
||||
@@ -630,7 +631,10 @@ export class ComfyApp {
|
||||
const uri = event.dataTransfer.getData(match)?.split('\n')?.[0]
|
||||
if (uri) {
|
||||
const blob = await (await fetch(uri)).blob()
|
||||
await this.handleFile(new File([blob], uri, { type: blob.type }))
|
||||
await this.handleFile(
|
||||
new File([blob], uri, { type: blob.type }),
|
||||
'file_drop'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -934,6 +938,10 @@ export class ComfyApp {
|
||||
|
||||
await useExtensionService().invokeExtensionsAsync('setup')
|
||||
useTelemetry()?.markAppReady?.()
|
||||
useTelemetry()?.setGraphContext?.(
|
||||
this.graph,
|
||||
useNodeDefStore().nodeDefsByName
|
||||
)
|
||||
|
||||
this.positionConversion = useCanvasPositionConversion(
|
||||
this.canvasContainer,
|
||||
@@ -1126,12 +1134,19 @@ export class ComfyApp {
|
||||
clean: boolean = true,
|
||||
restore_view: boolean = true,
|
||||
workflow: string | null | ComfyWorkflow = null,
|
||||
{
|
||||
showMissingNodesDialog = true,
|
||||
showMissingModelsDialog = true,
|
||||
checkForRerouteMigration = false
|
||||
options: {
|
||||
showMissingNodesDialog?: boolean
|
||||
showMissingModelsDialog?: boolean
|
||||
checkForRerouteMigration?: boolean
|
||||
openSource?: WorkflowOpenSource
|
||||
} = {}
|
||||
) {
|
||||
const {
|
||||
showMissingNodesDialog = true,
|
||||
showMissingModelsDialog = true,
|
||||
checkForRerouteMigration = false,
|
||||
openSource
|
||||
} = options
|
||||
useWorkflowService().beforeLoadNewGraph()
|
||||
|
||||
if (clean !== false) {
|
||||
@@ -1361,6 +1376,15 @@ export class ComfyApp {
|
||||
'afterConfigureGraph',
|
||||
missingNodeTypes
|
||||
)
|
||||
const telemetryPayload = {
|
||||
missing_node_count: missingNodeTypes.length,
|
||||
missing_node_types: missingNodeTypes.map((node) =>
|
||||
typeof node === 'string' ? node : node.type
|
||||
),
|
||||
open_source: openSource ?? 'unknown'
|
||||
}
|
||||
useTelemetry()?.trackWorkflowOpened(telemetryPayload)
|
||||
useTelemetry()?.trackWorkflowImported(telemetryPayload)
|
||||
await useWorkflowService().afterLoadNewGraph(
|
||||
workflow,
|
||||
this.graph.serialize() as unknown as ComfyWorkflowJSON
|
||||
@@ -1478,7 +1502,7 @@ export class ComfyApp {
|
||||
* Loads workflow data from the specified file
|
||||
* @param {File} file
|
||||
*/
|
||||
async handleFile(file: File) {
|
||||
async handleFile(file: File, openSource?: WorkflowOpenSource) {
|
||||
const removeExt = (f: string) => {
|
||||
if (!f) return f
|
||||
const p = f.lastIndexOf('.')
|
||||
@@ -1493,7 +1517,8 @@ export class ComfyApp {
|
||||
JSON.parse(pngInfo.workflow),
|
||||
true,
|
||||
true,
|
||||
fileName
|
||||
fileName,
|
||||
{ openSource }
|
||||
)
|
||||
} else if (pngInfo?.prompt) {
|
||||
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName)
|
||||
@@ -1513,7 +1538,9 @@ export class ComfyApp {
|
||||
const { workflow, prompt } = await getAvifMetadata(file)
|
||||
|
||||
if (workflow) {
|
||||
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
|
||||
this.loadGraphData(JSON.parse(workflow), true, true, fileName, {
|
||||
openSource
|
||||
})
|
||||
} else if (prompt) {
|
||||
this.loadApiJson(JSON.parse(prompt), fileName)
|
||||
} else {
|
||||
@@ -1526,7 +1553,9 @@ export class ComfyApp {
|
||||
const prompt = pngInfo?.prompt || pngInfo?.Prompt
|
||||
|
||||
if (workflow) {
|
||||
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
|
||||
this.loadGraphData(JSON.parse(workflow), true, true, fileName, {
|
||||
openSource
|
||||
})
|
||||
} else if (prompt) {
|
||||
this.loadApiJson(JSON.parse(prompt), fileName)
|
||||
} else {
|
||||
@@ -1535,7 +1564,7 @@ export class ComfyApp {
|
||||
} else if (file.type === 'audio/mpeg') {
|
||||
const { workflow, prompt } = await getMp3Metadata(file)
|
||||
if (workflow) {
|
||||
this.loadGraphData(workflow, true, true, fileName)
|
||||
this.loadGraphData(workflow, true, true, fileName, { openSource })
|
||||
} else if (prompt) {
|
||||
this.loadApiJson(prompt, fileName)
|
||||
} else {
|
||||
@@ -1544,7 +1573,7 @@ export class ComfyApp {
|
||||
} else if (file.type === 'audio/ogg') {
|
||||
const { workflow, prompt } = await getOggMetadata(file)
|
||||
if (workflow) {
|
||||
this.loadGraphData(workflow, true, true, fileName)
|
||||
this.loadGraphData(workflow, true, true, fileName, { openSource })
|
||||
} else if (prompt) {
|
||||
this.loadApiJson(prompt, fileName)
|
||||
} else {
|
||||
@@ -1556,7 +1585,9 @@ export class ComfyApp {
|
||||
const prompt = pngInfo?.prompt || pngInfo?.Prompt
|
||||
|
||||
if (workflow) {
|
||||
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
|
||||
this.loadGraphData(JSON.parse(workflow), true, true, fileName, {
|
||||
openSource
|
||||
})
|
||||
} else if (prompt) {
|
||||
this.loadApiJson(JSON.parse(prompt), fileName)
|
||||
} else {
|
||||
@@ -1565,7 +1596,9 @@ export class ComfyApp {
|
||||
} else if (file.type === 'video/webm') {
|
||||
const webmInfo = await getFromWebmFile(file)
|
||||
if (webmInfo.workflow) {
|
||||
this.loadGraphData(webmInfo.workflow, true, true, fileName)
|
||||
this.loadGraphData(webmInfo.workflow, true, true, fileName, {
|
||||
openSource
|
||||
})
|
||||
} else if (webmInfo.prompt) {
|
||||
this.loadApiJson(webmInfo.prompt, fileName)
|
||||
} else {
|
||||
@@ -1581,14 +1614,18 @@ export class ComfyApp {
|
||||
) {
|
||||
const mp4Info = await getFromIsobmffFile(file)
|
||||
if (mp4Info.workflow) {
|
||||
this.loadGraphData(mp4Info.workflow, true, true, fileName)
|
||||
this.loadGraphData(mp4Info.workflow, true, true, fileName, {
|
||||
openSource
|
||||
})
|
||||
} else if (mp4Info.prompt) {
|
||||
this.loadApiJson(mp4Info.prompt, fileName)
|
||||
}
|
||||
} else if (file.type === 'image/svg+xml' || file.name?.endsWith('.svg')) {
|
||||
const svgInfo = await getSvgMetadata(file)
|
||||
if (svgInfo.workflow) {
|
||||
this.loadGraphData(svgInfo.workflow, true, true, fileName)
|
||||
this.loadGraphData(svgInfo.workflow, true, true, fileName, {
|
||||
openSource
|
||||
})
|
||||
} else if (svgInfo.prompt) {
|
||||
this.loadApiJson(svgInfo.prompt, fileName)
|
||||
} else {
|
||||
@@ -1600,7 +1637,9 @@ export class ComfyApp {
|
||||
) {
|
||||
const gltfInfo = await getGltfBinaryMetadata(file)
|
||||
if (gltfInfo.workflow) {
|
||||
this.loadGraphData(gltfInfo.workflow, true, true, fileName)
|
||||
this.loadGraphData(gltfInfo.workflow, true, true, fileName, {
|
||||
openSource
|
||||
})
|
||||
} else if (gltfInfo.prompt) {
|
||||
this.loadApiJson(gltfInfo.prompt, fileName)
|
||||
} else {
|
||||
@@ -1623,7 +1662,8 @@ export class ComfyApp {
|
||||
JSON.parse(readerResult),
|
||||
true,
|
||||
true,
|
||||
fileName
|
||||
fileName,
|
||||
{ openSource }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1641,7 +1681,8 @@ export class ComfyApp {
|
||||
JSON.parse(info.workflow),
|
||||
true,
|
||||
true,
|
||||
fileName
|
||||
fileName,
|
||||
{ openSource }
|
||||
)
|
||||
// @ts-expect-error
|
||||
} else if (info.prompt) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { WORKFLOW_ACCEPT_STRING } from '@/platform/workflow/core/types/formats'
|
||||
import { type StatusWsMessageStatus, type TaskItem } from '@/schemas/apiSchema'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -398,7 +400,7 @@ export class ComfyUI {
|
||||
parent: document.body,
|
||||
onchange: async () => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
await app.handleFile(fileInput.files[0])
|
||||
await app.handleFile(fileInput.files[0], 'file_button')
|
||||
fileInput.value = ''
|
||||
}
|
||||
})
|
||||
@@ -476,7 +478,12 @@ export class ComfyUI {
|
||||
$el('button.comfy-queue-btn', {
|
||||
id: 'queue-button',
|
||||
textContent: 'Queue Prompt',
|
||||
onclick: () => app.queuePrompt(0, this.batchCount)
|
||||
onclick: () => {
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackRunTriggeredViaMenu()
|
||||
}
|
||||
app.queuePrompt(0, this.batchCount)
|
||||
}
|
||||
}),
|
||||
$el('div', {}, [
|
||||
$el('label', { innerHTML: 'Extra options' }, [
|
||||
@@ -578,7 +585,12 @@ export class ComfyUI {
|
||||
$el('button', {
|
||||
id: 'queue-front-button',
|
||||
textContent: 'Queue Front',
|
||||
onclick: () => app.queuePrompt(-1, this.batchCount)
|
||||
onclick: () => {
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackRunTriggeredViaMenu()
|
||||
}
|
||||
app.queuePrompt(-1, this.batchCount)
|
||||
}
|
||||
}),
|
||||
$el('button', {
|
||||
$: (b) => (this.queue.button = b as HTMLButtonElement),
|
||||
|
||||
@@ -179,7 +179,7 @@ export const useCustomerEventsService = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
return executeRequest<CustomerEventsResponse>(
|
||||
const result = await executeRequest<CustomerEventsResponse>(
|
||||
() =>
|
||||
customerApiClient.get('/customers/events', {
|
||||
params: { page, limit },
|
||||
@@ -187,6 +187,8 @@ export const useCustomerEventsService = () => {
|
||||
}),
|
||||
{ errorContext, routeSpecificErrors }
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { merge } from 'es-toolkit/compat'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue'
|
||||
@@ -70,6 +69,7 @@ export const useDialogService = () => {
|
||||
| 'server-config'
|
||||
| 'user'
|
||||
| 'credits'
|
||||
| 'subscription'
|
||||
) {
|
||||
const props = panel ? { props: { defaultPanel: panel } } : undefined
|
||||
|
||||
@@ -487,35 +487,16 @@ export const useDialogService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
function showSubscriptionRequiredDialog() {
|
||||
async function showSubscriptionRequiredDialog() {
|
||||
if (!isCloud || !window.__CONFIG__?.subscription_required) {
|
||||
return
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const { useSubscriptionDialog } = await import(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
)
|
||||
const { show } = useSubscriptionDialog()
|
||||
show()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -64,6 +66,14 @@ export const useKeybindingService = () => {
|
||||
|
||||
// Prevent default browser behavior first, then execute the command
|
||||
event.preventDefault()
|
||||
if (
|
||||
isCloud &&
|
||||
(keybinding.commandId === 'Comfy.QueuePrompt' ||
|
||||
keybinding.commandId === 'Comfy.QueuePromptFront' ||
|
||||
keybinding.commandId === 'Comfy.QueueSelectedOutputNodes')
|
||||
) {
|
||||
useTelemetry()?.trackRunTriggeredViaKeybinding()
|
||||
}
|
||||
await commandStore.execute(keybinding.commandId)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
getAdditionalUserInfo,
|
||||
onAuthStateChanged,
|
||||
onIdTokenChanged,
|
||||
sendEmailVerification,
|
||||
sendPasswordResetEmail,
|
||||
setPersistence,
|
||||
signInWithEmailAndPassword,
|
||||
@@ -82,9 +81,6 @@ 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:
|
||||
@@ -110,10 +106,12 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const getIdToken = async (): Promise<string | undefined> => {
|
||||
const getIdToken = async (
|
||||
forceRefresh = false
|
||||
): Promise<string | undefined> => {
|
||||
if (!currentUser.value) return
|
||||
try {
|
||||
return await currentUser.value.getIdToken()
|
||||
return await currentUser.value.getIdToken(forceRefresh)
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error instanceof FirebaseError &&
|
||||
@@ -144,9 +142,11 @@ 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 (): Promise<AuthHeader | null> => {
|
||||
const getAuthHeader = async (
|
||||
forceRefresh = false
|
||||
): Promise<AuthHeader | null> => {
|
||||
// If available, set header with JWT used to identify the user to Firebase service
|
||||
const token = await getIdToken()
|
||||
const token = await getIdToken(forceRefresh)
|
||||
if (token) {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`
|
||||
@@ -349,14 +349,6 @@ 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) {
|
||||
@@ -440,7 +432,6 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
// State
|
||||
loading,
|
||||
currentUser,
|
||||
isEmailVerified,
|
||||
isInitialized,
|
||||
balance,
|
||||
lastBalanceUpdateTime,
|
||||
@@ -466,7 +457,6 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
sendPasswordReset,
|
||||
updatePassword: _updatePassword,
|
||||
getAuthHeader,
|
||||
verifyEmail,
|
||||
deleteAccount: _deleteAccount
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { MenuItem } from 'primevue/menuitem'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { CORE_MENU_COMMANDS } from '@/constants/coreMenuCommands'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
import { useCommandStore } from './commandStore'
|
||||
@@ -62,7 +64,17 @@ export const useMenuItemStore = defineStore('menuItem', () => {
|
||||
.map(
|
||||
(command) =>
|
||||
({
|
||||
command: () => commandStore.execute(command.id),
|
||||
command: () => {
|
||||
if (
|
||||
isCloud &&
|
||||
(command.id === 'Comfy.QueuePrompt' ||
|
||||
command.id === 'Comfy.QueuePromptFront' ||
|
||||
command.id === 'Comfy.QueueSelectedOutputNodes')
|
||||
) {
|
||||
useTelemetry()?.trackRunTriggeredViaMenu()
|
||||
}
|
||||
return commandStore.execute(command.id)
|
||||
},
|
||||
label: command.menubarLabel,
|
||||
icon: command.icon,
|
||||
tooltip: command.tooltip,
|
||||
|
||||
@@ -46,7 +46,9 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useProgressFavicon } from '@/composables/useProgressFavicon'
|
||||
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
|
||||
import { i18n, loadLocale } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useFrontendVersionMismatchWarning } from '@/platform/updates/common/useFrontendVersionMismatchWarning'
|
||||
import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore'
|
||||
import type { StatusWsMessageStatus } from '@/schemas/apiSchema'
|
||||
@@ -56,6 +58,7 @@ import { setupAutoQueueHandler } from '@/services/autoQueueService'
|
||||
import { useKeybindingService } from '@/services/keybindingService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useModelStore } from '@/stores/modelStore'
|
||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
@@ -89,6 +92,13 @@ const showBottomMenu = computed(
|
||||
() => !isMobile.value && useNewMenu.value === 'Bottom'
|
||||
)
|
||||
|
||||
const telemetry = useTelemetry()
|
||||
const firebaseAuthStore = useFirebaseAuthStore()
|
||||
let hasTrackedLogin = false
|
||||
let visibilityListener: (() => void) | null = null
|
||||
let tabCountInterval: number | null = null
|
||||
let tabCountChannel: BroadcastChannel | null = null
|
||||
|
||||
watch(
|
||||
() => colorPaletteStore.completedActivePalette,
|
||||
(newTheme) => {
|
||||
@@ -244,6 +254,22 @@ onBeforeUnmount(() => {
|
||||
api.removeEventListener('reconnecting', onReconnecting)
|
||||
api.removeEventListener('reconnected', onReconnected)
|
||||
executionStore.unbindExecutionEvents()
|
||||
|
||||
// Clean up page visibility listener
|
||||
if (visibilityListener) {
|
||||
document.removeEventListener('visibilitychange', visibilityListener)
|
||||
visibilityListener = null
|
||||
}
|
||||
|
||||
// Clean up tab count tracking
|
||||
if (tabCountInterval) {
|
||||
window.clearInterval(tabCountInterval)
|
||||
tabCountInterval = null
|
||||
}
|
||||
if (tabCountChannel) {
|
||||
tabCountChannel.close()
|
||||
tabCountChannel = null
|
||||
}
|
||||
})
|
||||
|
||||
useEventListener(window, 'keydown', useKeybindingService().keybindHandler)
|
||||
@@ -262,6 +288,61 @@ void nextTick(() => {
|
||||
|
||||
const onGraphReady = () => {
|
||||
runWhenGlobalIdle(() => {
|
||||
// Track user login when app is ready in graph view (cloud only)
|
||||
if (isCloud && firebaseAuthStore.isAuthenticated && !hasTrackedLogin) {
|
||||
telemetry?.trackUserLoggedIn()
|
||||
hasTrackedLogin = true
|
||||
}
|
||||
|
||||
// Set up page visibility tracking (cloud only)
|
||||
if (isCloud && telemetry && !visibilityListener) {
|
||||
visibilityListener = () => {
|
||||
telemetry.trackPageVisibilityChanged({
|
||||
visibility_state: document.visibilityState as 'visible' | 'hidden'
|
||||
})
|
||||
}
|
||||
document.addEventListener('visibilitychange', visibilityListener)
|
||||
}
|
||||
|
||||
// Set up tab count tracking (cloud only)
|
||||
if (isCloud && telemetry && !tabCountInterval) {
|
||||
tabCountChannel = new BroadcastChannel('comfyui-tab-count')
|
||||
const activeTabs = new Map<string, number>()
|
||||
const currentTabId = crypto.randomUUID()
|
||||
|
||||
// Listen for heartbeats from other tabs
|
||||
tabCountChannel.onmessage = (event) => {
|
||||
if (
|
||||
event.data.type === 'heartbeat' &&
|
||||
event.data.tabId !== currentTabId
|
||||
) {
|
||||
activeTabs.set(event.data.tabId, Date.now())
|
||||
}
|
||||
}
|
||||
|
||||
// 30-second heartbeat interval
|
||||
tabCountInterval = window.setInterval(() => {
|
||||
const now = Date.now()
|
||||
|
||||
// Clean up stale tabs (no heartbeat for 45 seconds)
|
||||
activeTabs.forEach((lastHeartbeat, tabId) => {
|
||||
if (now - lastHeartbeat > 45000) {
|
||||
activeTabs.delete(tabId)
|
||||
}
|
||||
})
|
||||
|
||||
// Broadcast our heartbeat
|
||||
tabCountChannel?.postMessage({ type: 'heartbeat', tabId: currentTabId })
|
||||
|
||||
// Track tab count (include current tab)
|
||||
const tabCount = activeTabs.size + 1
|
||||
telemetry.trackTabCount({ tab_count: tabCount })
|
||||
}, 30000)
|
||||
|
||||
// Send initial heartbeat
|
||||
tabCountChannel.postMessage({ type: 'heartbeat', tabId: currentTabId })
|
||||
}
|
||||
|
||||
// Setting values now available after comfyApp.setup.
|
||||
// Load keybindings.
|
||||
wrapWithErrorHandling(useKeybindingService().registerUserKeybindings)()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-screen w-screen flex-col font-sans"
|
||||
class="flex h-svh w-screen flex-col font-sans"
|
||||
:class="[
|
||||
dark
|
||||
? 'dark-theme bg-neutral-900 text-neutral-300'
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
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', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||
return {
|
||||
...actual,
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
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'
|
||||
|
||||
// Unmock firebaseAuthStore so we get the real Pinia store
|
||||
vi.unmock('@/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((amount: number) => {
|
||||
// Converts cents to dollars (despite the field name containing "micros")
|
||||
return (amount / 100).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: 500 } 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: 500 } 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: 250 } 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: 750 } 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
207
tests-ui/tests/platform/telemetry/topupTracker.test.ts
Normal file
207
tests-ui/tests/platform/telemetry/topupTracker.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
|
||||
// Mock localStorage
|
||||
const mockLocalStorage = vi.hoisted(() => ({
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn()
|
||||
}))
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
writable: true
|
||||
})
|
||||
|
||||
// Mock telemetry
|
||||
const mockTelemetry = vi.hoisted(() => ({
|
||||
trackApiCreditTopupSucceeded: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => mockTelemetry)
|
||||
}))
|
||||
|
||||
describe('topupTracker', () => {
|
||||
let topupTracker: typeof import('@/platform/telemetry/topupTracker')
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
// Dynamically import to ensure fresh module state
|
||||
topupTracker = await import('@/platform/telemetry/topupTracker')
|
||||
})
|
||||
|
||||
describe('startTopupTracking', () => {
|
||||
it('should save current timestamp to localStorage', () => {
|
||||
const beforeTimestamp = Date.now()
|
||||
|
||||
topupTracker.startTopupTracking()
|
||||
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
|
||||
'pending_topup_timestamp',
|
||||
expect.any(String)
|
||||
)
|
||||
|
||||
const savedTimestamp = parseInt(
|
||||
mockLocalStorage.setItem.mock.calls[0][1],
|
||||
10
|
||||
)
|
||||
expect(savedTimestamp).toBeGreaterThanOrEqual(beforeTimestamp)
|
||||
expect(savedTimestamp).toBeLessThanOrEqual(Date.now())
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkForCompletedTopup', () => {
|
||||
it('should return false if no pending topup exists', () => {
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
const result = topupTracker.checkForCompletedTopup([])
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return false if events array is empty', () => {
|
||||
mockLocalStorage.getItem.mockReturnValue(Date.now().toString())
|
||||
|
||||
const result = topupTracker.checkForCompletedTopup([])
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return false if events array is null', () => {
|
||||
mockLocalStorage.getItem.mockReturnValue(Date.now().toString())
|
||||
|
||||
const result = topupTracker.checkForCompletedTopup(null)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should auto-cleanup if timestamp is older than 24 hours', () => {
|
||||
const oldTimestamp = Date.now() - 25 * 60 * 60 * 1000 // 25 hours ago
|
||||
mockLocalStorage.getItem.mockReturnValue(oldTimestamp.toString())
|
||||
|
||||
const events: AuditLog[] = [
|
||||
{
|
||||
event_id: 'test-1',
|
||||
event_type: 'credit_added',
|
||||
createdAt: new Date().toISOString(),
|
||||
params: { amount: 500 }
|
||||
}
|
||||
]
|
||||
|
||||
const result = topupTracker.checkForCompletedTopup(events)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
|
||||
'pending_topup_timestamp'
|
||||
)
|
||||
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should detect completed topup and fire telemetry', () => {
|
||||
const startTimestamp = Date.now() - 5 * 60 * 1000 // 5 minutes ago
|
||||
mockLocalStorage.getItem.mockReturnValue(startTimestamp.toString())
|
||||
|
||||
const events: AuditLog[] = [
|
||||
{
|
||||
event_id: 'test-1',
|
||||
event_type: 'api_usage_completed',
|
||||
createdAt: new Date(startTimestamp - 1000).toISOString(),
|
||||
params: {}
|
||||
},
|
||||
{
|
||||
event_id: 'test-2',
|
||||
event_type: 'credit_added',
|
||||
createdAt: new Date(startTimestamp + 1000).toISOString(),
|
||||
params: { amount: 500 }
|
||||
}
|
||||
]
|
||||
|
||||
const result = topupTracker.checkForCompletedTopup(events)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockTelemetry.trackApiCreditTopupSucceeded).toHaveBeenCalledOnce()
|
||||
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
|
||||
'pending_topup_timestamp'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not detect topup if credit_added event is before tracking started', () => {
|
||||
const startTimestamp = Date.now()
|
||||
mockLocalStorage.getItem.mockReturnValue(startTimestamp.toString())
|
||||
|
||||
const events: AuditLog[] = [
|
||||
{
|
||||
event_id: 'test-1',
|
||||
event_type: 'credit_added',
|
||||
createdAt: new Date(startTimestamp - 1000).toISOString(), // Before tracking
|
||||
params: { amount: 500 }
|
||||
}
|
||||
]
|
||||
|
||||
const result = topupTracker.checkForCompletedTopup(events)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
|
||||
expect(mockLocalStorage.removeItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore events without createdAt timestamp', () => {
|
||||
const startTimestamp = Date.now()
|
||||
mockLocalStorage.getItem.mockReturnValue(startTimestamp.toString())
|
||||
|
||||
const events: AuditLog[] = [
|
||||
{
|
||||
event_id: 'test-1',
|
||||
event_type: 'credit_added',
|
||||
createdAt: undefined,
|
||||
params: { amount: 500 }
|
||||
}
|
||||
]
|
||||
|
||||
const result = topupTracker.checkForCompletedTopup(events)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should only match credit_added events, not other event types', () => {
|
||||
const startTimestamp = Date.now()
|
||||
mockLocalStorage.getItem.mockReturnValue(startTimestamp.toString())
|
||||
|
||||
const events: AuditLog[] = [
|
||||
{
|
||||
event_id: 'test-1',
|
||||
event_type: 'api_usage_completed',
|
||||
createdAt: new Date(startTimestamp + 1000).toISOString(),
|
||||
params: {}
|
||||
},
|
||||
{
|
||||
event_id: 'test-2',
|
||||
event_type: 'account_created',
|
||||
createdAt: new Date(startTimestamp + 2000).toISOString(),
|
||||
params: {}
|
||||
}
|
||||
]
|
||||
|
||||
const result = topupTracker.checkForCompletedTopup(events)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearTopupTracking', () => {
|
||||
it('should remove pending topup from localStorage', () => {
|
||||
topupTracker.clearTopupTracking()
|
||||
|
||||
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
|
||||
'pending_topup_timestamp'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -449,7 +449,8 @@ describe('useModelToNodeStore', () => {
|
||||
const end = performance.now()
|
||||
|
||||
// Should be fast enough for UI responsiveness
|
||||
expect(end - start).toBeLessThan(10)
|
||||
// Increased threshold to 50ms to account for system variability
|
||||
expect(end - start).toBeLessThan(50)
|
||||
})
|
||||
|
||||
it('should handle invalid input types gracefully', () => {
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
createTestSubgraphNode
|
||||
} from '../litegraph/subgraph/fixtures/subgraphHelpers'
|
||||
|
||||
// Mock telemetry to break circular dependency (telemetry → workflowStore → app → telemetry)
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
}))
|
||||
|
||||
// Add mock for api at the top of the file
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sentryVitePlugin } from '@sentry/vite-plugin'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import dotenv from 'dotenv'
|
||||
@@ -226,7 +227,28 @@ 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: {
|
||||
|
||||
Reference in New Issue
Block a user