mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 18:07:35 +00:00
Compare commits
52 Commits
remove-cac
...
cloud/1.38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b7565559a | ||
|
|
4d1645931c | ||
|
|
2279bc0276 | ||
|
|
f9e526e4a8 | ||
|
|
7afe45fdf8 | ||
|
|
450e75ff83 | ||
|
|
e3391c7895 | ||
|
|
6d21aad1f9 | ||
|
|
6b4334759f | ||
|
|
9c8f815b05 | ||
|
|
e2744868a4 | ||
|
|
c12aa37599 | ||
|
|
9d89a405cf | ||
|
|
47d5001833 | ||
|
|
9691f5fd00 | ||
|
|
5c43c933d7 | ||
|
|
155548c1b8 | ||
|
|
a51ba16e5a | ||
|
|
1b3127bb0d | ||
|
|
45a0bf1e89 | ||
|
|
ab96fec96e | ||
|
|
aca98890b2 | ||
|
|
cb86a1c94e | ||
|
|
6eb8aa4820 | ||
|
|
020ad868ce | ||
|
|
02b3bb840e | ||
|
|
942ffbe896 | ||
|
|
d30e1a882e | ||
|
|
7fc0b55bb8 | ||
|
|
7d114577b5 | ||
|
|
26bf6d324e | ||
|
|
77f94a3357 | ||
|
|
29a0071b4e | ||
|
|
99f85d9b04 | ||
|
|
1631f22efb | ||
|
|
a5577a7f45 | ||
|
|
519bd2f166 | ||
|
|
679fa1b354 | ||
|
|
fa2879b523 | ||
|
|
7175663efd | ||
|
|
0367e33a76 | ||
|
|
b057f69501 | ||
|
|
6e5b72a685 | ||
|
|
ecb778dfe1 | ||
|
|
e9dc3c0e41 | ||
|
|
fc9b78fce3 | ||
|
|
0466f16b89 | ||
|
|
3060423e27 | ||
|
|
dd0b2dc9cb | ||
|
|
0c02573ff9 | ||
|
|
0f8925e95a | ||
|
|
c8aac69f6c |
2
.github/workflows/i18n-update-core.yaml
vendored
2
.github/workflows/i18n-update-core.yaml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
env:
|
||||
PLAYWRIGHT_TEST_URL: http://localhost:5173
|
||||
- name: Update translations
|
||||
run: pnpm locale
|
||||
run: pnpm locale && pnpm format
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
- name: Commit updated locales
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -96,3 +96,5 @@ vitest.config.*.timestamp*
|
||||
|
||||
# Weekly docs check output
|
||||
/output.txt
|
||||
|
||||
.amp
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 135 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 80 KiB |
42
index.html
42
index.html
@@ -35,18 +35,6 @@
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
#vue-app:has(#loading-logo) {
|
||||
display: contents;
|
||||
color: var(--fg-color);
|
||||
& #loading-logo {
|
||||
place-self: center;
|
||||
font-size: clamp(2px, 1vw, 6px);
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
max-width: 100vw;
|
||||
border-radius: 20ch;
|
||||
}
|
||||
}
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -65,36 +53,6 @@
|
||||
<body class="litegraph grid">
|
||||
<div id="vue-app">
|
||||
<span class="visually-hidden" role="status">Loading ComfyUI...</span>
|
||||
<svg
|
||||
width="520"
|
||||
height="520"
|
||||
viewBox="0 0 520 520"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
id="loading-logo"
|
||||
>
|
||||
<mask
|
||||
id="mask0_227_285"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="520"
|
||||
height="520"
|
||||
>
|
||||
<path
|
||||
d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z"
|
||||
fill="#EEFF30"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_227_285)">
|
||||
<rect y="0.751831" width="520" height="520" fill="#172DD7" />
|
||||
<path
|
||||
d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z"
|
||||
fill="#F0FF41"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<script type="module" src="src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -10,7 +10,5 @@
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
],
|
||||
"display": "standalone",
|
||||
"background_color": "#172dd7",
|
||||
"theme_color": "#f0ff41"
|
||||
"display": "standalone"
|
||||
}
|
||||
|
||||
17
src/App.vue
17
src/App.vue
@@ -1,13 +1,11 @@
|
||||
<template>
|
||||
<WorkspaceAuthGate>
|
||||
<router-view />
|
||||
<ProgressSpinner
|
||||
v-if="isLoading"
|
||||
class="absolute inset-0 flex h-[unset] items-center justify-center"
|
||||
/>
|
||||
<GlobalDialog />
|
||||
<BlockUI full-screen :blocked="isLoading" />
|
||||
</WorkspaceAuthGate>
|
||||
<router-view />
|
||||
<ProgressSpinner
|
||||
v-if="isLoading"
|
||||
class="absolute inset-0 flex h-[unset] items-center justify-center"
|
||||
/>
|
||||
<GlobalDialog />
|
||||
<BlockUI full-screen :blocked="isLoading" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -16,7 +14,6 @@ import BlockUI from 'primevue/blockui'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import WorkspaceAuthGate from '@/components/auth/WorkspaceAuthGate.vue'
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import {
|
||||
downloadFile,
|
||||
extractFilenameFromContentDisposition
|
||||
} from '@/base/common/downloadUtil'
|
||||
|
||||
let mockIsCloud = false
|
||||
|
||||
@@ -155,10 +158,14 @@ describe('downloadUtil', () => {
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
const headersMock = {
|
||||
get: vi.fn().mockReturnValue(null)
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
@@ -195,5 +202,147 @@ describe('downloadUtil', () => {
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses filename from Content-Disposition header in cloud mode', async () => {
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
const headersMock = {
|
||||
get: vi.fn().mockReturnValue('attachment; filename="user-friendly.png"')
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
expect(headersMock.get).toHaveBeenCalledWith('Content-Disposition')
|
||||
expect(mockLink.download).toBe('user-friendly.png')
|
||||
})
|
||||
|
||||
it('uses RFC 5987 filename from Content-Disposition header', async () => {
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
const headersMock = {
|
||||
get: vi
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%E4%B8%AD%E6%96%87.png'
|
||||
)
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
expect(mockLink.download).toBe('中文.png')
|
||||
})
|
||||
|
||||
it('falls back to provided filename when Content-Disposition is missing', async () => {
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
const headersMock = {
|
||||
get: vi.fn().mockReturnValue(null)
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl, 'my-fallback.png')
|
||||
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
expect(mockLink.download).toBe('my-fallback.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractFilenameFromContentDisposition', () => {
|
||||
it('returns null for null header', () => {
|
||||
expect(extractFilenameFromContentDisposition(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty header', () => {
|
||||
expect(extractFilenameFromContentDisposition('')).toBeNull()
|
||||
})
|
||||
|
||||
it('extracts filename from simple quoted format', () => {
|
||||
const header = 'attachment; filename="test-file.png"'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||
'test-file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('extracts filename from unquoted format', () => {
|
||||
const header = 'attachment; filename=test-file.png'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||
'test-file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('extracts filename from RFC 5987 format', () => {
|
||||
const header = "attachment; filename*=UTF-8''test%20file.png"
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||
'test file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers RFC 5987 format over simple format', () => {
|
||||
const header =
|
||||
'attachment; filename="fallback.png"; filename*=UTF-8\'\'preferred.png'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||
'preferred.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles unicode characters in RFC 5987 format', () => {
|
||||
const header =
|
||||
"attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6.png"
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe('中文文件.png')
|
||||
})
|
||||
|
||||
it('falls back to simple format when RFC 5987 decoding fails', () => {
|
||||
const header =
|
||||
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%invalid'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe('fallback.png')
|
||||
})
|
||||
|
||||
it('handles header with only attachment disposition', () => {
|
||||
const header = 'attachment'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBeNull()
|
||||
})
|
||||
|
||||
it('handles case-insensitive filename parameter', () => {
|
||||
const header = 'attachment; FILENAME="test.png"'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe('test.png')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,14 +75,57 @@ const extractFilenameFromUrl = (url: string): string | null => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract filename from Content-Disposition header
|
||||
* Handles both simple format: attachment; filename="name.png"
|
||||
* And RFC 5987 format: attachment; filename="fallback.png"; filename*=UTF-8''encoded%20name.png
|
||||
* @param header - The Content-Disposition header value
|
||||
* @returns The extracted filename or null if not found
|
||||
*/
|
||||
export function extractFilenameFromContentDisposition(
|
||||
header: string | null
|
||||
): string | null {
|
||||
if (!header) return null
|
||||
|
||||
// Try RFC 5987 extended format first (filename*=UTF-8''...)
|
||||
const extendedMatch = header.match(/filename\*=UTF-8''([^;]+)/i)
|
||||
if (extendedMatch?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(extendedMatch[1])
|
||||
} catch {
|
||||
// Fall through to simple format
|
||||
}
|
||||
}
|
||||
|
||||
// Try simple quoted format: filename="..."
|
||||
const quotedMatch = header.match(/filename="([^"]+)"/i)
|
||||
if (quotedMatch?.[1]) {
|
||||
return quotedMatch[1]
|
||||
}
|
||||
|
||||
// Try unquoted format: filename=...
|
||||
const unquotedMatch = header.match(/filename=([^;\s]+)/i)
|
||||
if (unquotedMatch?.[1]) {
|
||||
return unquotedMatch[1]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const downloadViaBlobFetch = async (
|
||||
href: string,
|
||||
filename: string
|
||||
fallbackFilename: string
|
||||
): Promise<void> => {
|
||||
const response = await fetch(href)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${href}: ${response.status}`)
|
||||
}
|
||||
|
||||
// Try to get filename from Content-Disposition header (set by backend)
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
const headerFilename =
|
||||
extractFilenameFromContentDisposition(contentDisposition)
|
||||
|
||||
const blob = await response.blob()
|
||||
downloadBlob(filename, blob)
|
||||
downloadBlob(headerFilename ?? fallbackFilename, blob)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
|
||||
const currentButton = computed(() =>
|
||||
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
import { promiseTimeout, until } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -120,7 +120,10 @@ async function initializeWorkspaceMode(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Start initialization immediately during component setup
|
||||
// (not in onMounted, so initialization starts before DOM is ready)
|
||||
void initialize()
|
||||
// Initialize on mount. This gate should be placed on the authenticated layout
|
||||
// (LayoutDefault) so it mounts fresh after login and unmounts on logout.
|
||||
// The router guard ensures only authenticated users reach this layout.
|
||||
onMounted(() => {
|
||||
void initialize()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -767,7 +767,7 @@ useIntersectionObserver(loadTrigger, () => {
|
||||
// Reset pagination when filters change
|
||||
watch(
|
||||
[
|
||||
searchQuery,
|
||||
filteredTemplates,
|
||||
selectedNavItem,
|
||||
sortBy,
|
||||
selectedModels,
|
||||
|
||||
@@ -158,6 +158,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
@@ -176,8 +177,9 @@ const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { isSubscriptionEnabled } = useSubscription()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const { isSubscriptionEnabled } = useSubscription()
|
||||
// Constants
|
||||
const PRESET_AMOUNTS = [10, 25, 50, 100]
|
||||
const MIN_AMOUNT = 5
|
||||
@@ -256,9 +258,15 @@ async function handleBuy() {
|
||||
|
||||
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
|
||||
handleClose(false)
|
||||
dialogService.showSettingsDialog(
|
||||
isSubscriptionEnabled() ? 'subscription' : 'credits'
|
||||
)
|
||||
|
||||
// In workspace mode (personal workspace), show workspace settings panel
|
||||
// Otherwise, show legacy subscription/credits panel
|
||||
const settingsPanel = flags.teamWorkspacesEnabled
|
||||
? 'workspace'
|
||||
: isSubscriptionEnabled()
|
||||
? 'subscription'
|
||||
: 'credits'
|
||||
dialogService.showSettingsDialog(settingsPanel)
|
||||
} catch (error) {
|
||||
console.error('Purchase failed:', error)
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex py-8 items-center justify-between px-8">
|
||||
<h2 class="text-lg font-bold text-base-foreground m-0">
|
||||
{{
|
||||
isInsufficientCredits
|
||||
? $t('credits.topUp.addMoreCreditsToRun')
|
||||
: $t('credits.topUp.addMoreCredits')
|
||||
}}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
@click="() => handleClose()"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-6" />
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="isInsufficientCredits"
|
||||
class="text-sm text-muted-foreground m-0 px-8"
|
||||
>
|
||||
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
|
||||
</p>
|
||||
|
||||
<!-- Preset amount buttons -->
|
||||
<div class="px-8">
|
||||
<h3 class="m-0 text-sm font-normal text-muted-foreground">
|
||||
{{ $t('credits.topUp.selectAmount') }}
|
||||
</h3>
|
||||
<div class="flex gap-2 pt-3">
|
||||
<Button
|
||||
v-for="amount in PRESET_AMOUNTS"
|
||||
:key="amount"
|
||||
:autofocus="amount === 50"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
|
||||
selectedPreset === amount && 'bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
@click="handlePresetClick(amount)"
|
||||
>
|
||||
${{ amount }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Amount (USD) / Credits -->
|
||||
<div class="flex gap-2 px-8 pt-8">
|
||||
<!-- You Pay -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youPay') }}
|
||||
</div>
|
||||
<FormattedNumberStepper
|
||||
:model-value="payAmount"
|
||||
:min="0"
|
||||
:max="MAX_AMOUNT"
|
||||
:step="getStepAmount"
|
||||
@update:model-value="handlePayAmountChange"
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="shrink-0 text-base font-semibold text-base-foreground"
|
||||
>$</span
|
||||
>
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
|
||||
<!-- You Get -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youGet') }}
|
||||
</div>
|
||||
<FormattedNumberStepper
|
||||
v-model="creditsModel"
|
||||
:min="0"
|
||||
:max="usdToCredits(MAX_AMOUNT)"
|
||||
:step="getCreditsStepAmount"
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
|
||||
<p
|
||||
v-if="isBelowMin"
|
||||
class="text-sm text-red-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="showCeilingWarning"
|
||||
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
})
|
||||
}}
|
||||
<span>{{ $t('credits.topUp.needMore') }}</span>
|
||||
<a
|
||||
href="https://www.comfy.org/cloud/enterprise"
|
||||
target="_blank"
|
||||
class="ml-1 text-inherit"
|
||||
>{{ $t('credits.topUp.contactUs') }}</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
|
||||
<Button
|
||||
:disabled="!isValidAmount || loading || isPolling"
|
||||
:loading="loading || isPolling"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="h-10 justify-center"
|
||||
@click="handleBuy"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<a
|
||||
:href="pricingUrl"
|
||||
target="_blank"
|
||||
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
|
||||
>
|
||||
{{ $t('credits.topUp.viewPricing') }}
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useBillingOperationStore } from '@/stores/billingOperationStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { isInsufficientCredits = false } = defineProps<{
|
||||
isInsufficientCredits?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { fetchBalance } = useBillingContext()
|
||||
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
||||
|
||||
// Constants
|
||||
const PRESET_AMOUNTS = [10, 25, 50, 100]
|
||||
const MIN_AMOUNT = 5
|
||||
const MAX_AMOUNT = 10000
|
||||
|
||||
// State
|
||||
const selectedPreset = ref<number | null>(50)
|
||||
const payAmount = ref(50)
|
||||
const showCeilingWarning = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
// Computed
|
||||
const pricingUrl = computed(() =>
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
|
||||
)
|
||||
|
||||
const creditsModel = computed({
|
||||
get: () => usdToCredits(payAmount.value),
|
||||
set: (newCredits: number) => {
|
||||
payAmount.value = Math.round(creditsToUsd(newCredits))
|
||||
selectedPreset.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const isValidAmount = computed(
|
||||
() => payAmount.value >= MIN_AMOUNT && payAmount.value <= MAX_AMOUNT
|
||||
)
|
||||
|
||||
const isBelowMin = computed(() => payAmount.value < MIN_AMOUNT)
|
||||
|
||||
// Utility functions
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
// Step amount functions
|
||||
function getStepAmount(currentAmount: number): number {
|
||||
if (currentAmount < 100) return 5
|
||||
if (currentAmount < 1000) return 50
|
||||
return 100
|
||||
}
|
||||
|
||||
function getCreditsStepAmount(currentCredits: number): number {
|
||||
const usdAmount = creditsToUsd(currentCredits)
|
||||
return usdToCredits(getStepAmount(usdAmount))
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
function handlePayAmountChange(value: number) {
|
||||
payAmount.value = value
|
||||
selectedPreset.value = null
|
||||
showCeilingWarning.value = false
|
||||
}
|
||||
|
||||
function handlePresetClick(amount: number) {
|
||||
showCeilingWarning.value = false
|
||||
payAmount.value = amount
|
||||
selectedPreset.value = amount
|
||||
}
|
||||
|
||||
function handleClose(clearTracking = true) {
|
||||
if (clearTracking) {
|
||||
clearTopupTracking()
|
||||
}
|
||||
dialogStore.closeDialog({ key: 'top-up-credits' })
|
||||
}
|
||||
|
||||
async function handleBuy() {
|
||||
if (loading.value || !isValidAmount.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
|
||||
|
||||
const amountCents = payAmount.value * 100
|
||||
const response = await workspaceApi.createTopup(amountCents)
|
||||
|
||||
if (response.status === 'completed') {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('credits.topUp.purchaseSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await fetchBalance()
|
||||
handleClose(false)
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
} else if (response.status === 'pending') {
|
||||
billingOperationStore.startOperation(response.billing_op_id, 'topup')
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('credits.topUp.purchaseError'),
|
||||
detail: t('credits.topUp.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Purchase failed:', error)
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t('credits.topUp.unknownError')
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('credits.topUp.purchaseError'),
|
||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -265,18 +265,15 @@ function cancelEdit() {
|
||||
}
|
||||
|
||||
async function saveKeybinding() {
|
||||
if (currentEditingCommand.value && newBindingKeyCombo.value) {
|
||||
const updated = keybindingStore.updateKeybindingOnCommand(
|
||||
new KeybindingImpl({
|
||||
commandId: currentEditingCommand.value.id,
|
||||
combo: newBindingKeyCombo.value
|
||||
})
|
||||
)
|
||||
if (updated) {
|
||||
await keybindingService.persistUserKeybindings()
|
||||
}
|
||||
}
|
||||
const commandId = currentEditingCommand.value?.id
|
||||
const combo = newBindingKeyCombo.value
|
||||
cancelEdit()
|
||||
if (!combo || commandId == undefined) return
|
||||
|
||||
const updated = keybindingStore.updateKeybindingOnCommand(
|
||||
new KeybindingImpl({ commandId, combo })
|
||||
)
|
||||
if (updated) await keybindingService.persistUserKeybindings()
|
||||
}
|
||||
|
||||
async function resetKeybinding(commandData: ICommandData) {
|
||||
|
||||
@@ -116,9 +116,9 @@ import { computed, ref, watch } from 'vue'
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -138,7 +138,7 @@ const authStore = useFirebaseAuthStore()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
|
||||
@@ -6,14 +6,15 @@
|
||||
<!-- Section Header -->
|
||||
<div class="flex w-full items-center gap-9">
|
||||
<div class="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span
|
||||
v-if="uiConfig.showMembersList"
|
||||
class="text-base font-semibold text-base-foreground"
|
||||
>
|
||||
<span class="text-base font-semibold text-base-foreground">
|
||||
<template v-if="activeView === 'active'">
|
||||
{{
|
||||
$t('workspacePanel.members.membersCount', {
|
||||
count: members.length
|
||||
count:
|
||||
isSingleSeatPlan || isPersonalWorkspace
|
||||
? 1
|
||||
: members.length,
|
||||
maxSeats: maxSeats
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
@@ -27,7 +28,10 @@
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="uiConfig.showSearch" class="flex items-start gap-2">
|
||||
<div
|
||||
v-if="uiConfig.showSearch && !isSingleSeatPlan"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search')"
|
||||
@@ -45,14 +49,16 @@
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center py-2',
|
||||
activeView === 'pending'
|
||||
? uiConfig.pendingGridCols
|
||||
: uiConfig.headerGridCols
|
||||
isSingleSeatPlan
|
||||
? 'grid-cols-1 py-0'
|
||||
: activeView === 'pending'
|
||||
? uiConfig.pendingGridCols
|
||||
: uiConfig.headerGridCols
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Tab buttons in first column -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="!isSingleSeatPlan" class="flex items-center gap-2">
|
||||
<Button
|
||||
:variant="
|
||||
activeView === 'active' ? 'secondary' : 'muted-textonly'
|
||||
@@ -101,17 +107,19 @@
|
||||
<div />
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-end"
|
||||
@click="toggleSort('joinDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.joinDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<!-- Empty cell for action column header (OWNER only) -->
|
||||
<div v-if="permissions.canRemoveMembers" />
|
||||
<template v-if="!isSingleSeatPlan">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-end"
|
||||
@click="toggleSort('joinDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.joinDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<!-- Empty cell for action column header (OWNER only) -->
|
||||
<div v-if="permissions.canRemoveMembers" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -166,7 +174,7 @@
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center rounded-lg p-2',
|
||||
uiConfig.membersGridCols,
|
||||
isSingleSeatPlan ? 'grid-cols-1' : uiConfig.membersGridCols,
|
||||
index % 2 === 1 && 'bg-secondary-background/50'
|
||||
)
|
||||
"
|
||||
@@ -206,14 +214,14 @@
|
||||
</div>
|
||||
<!-- Join date -->
|
||||
<span
|
||||
v-if="uiConfig.showDateColumn"
|
||||
v-if="uiConfig.showDateColumn && !isSingleSeatPlan"
|
||||
class="text-sm text-muted-foreground text-right"
|
||||
>
|
||||
{{ formatDate(member.joinDate) }}
|
||||
</span>
|
||||
<!-- Remove member action (OWNER only, can't remove yourself) -->
|
||||
<div
|
||||
v-if="permissions.canRemoveMembers"
|
||||
v-if="permissions.canRemoveMembers && !isSingleSeatPlan"
|
||||
class="flex items-center justify-end"
|
||||
>
|
||||
<Button
|
||||
@@ -237,8 +245,29 @@
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Upsell Banner -->
|
||||
<div
|
||||
v-if="isSingleSeatPlan"
|
||||
class="flex items-center gap-2 rounded-xl border bg-secondary-background border-border-default px-4 py-3 mt-4 justify-center"
|
||||
>
|
||||
<p class="m-0 text-sm text-foreground">
|
||||
{{
|
||||
isActiveSubscription
|
||||
? $t('workspacePanel.members.upsellBannerUpgrade')
|
||||
: $t('workspacePanel.members.upsellBannerSubscribe')
|
||||
}}
|
||||
</p>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="cursor-pointer underline text-sm"
|
||||
@click="showSubscriptionDialog()"
|
||||
>
|
||||
{{ $t('workspacePanel.members.viewPlans') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Pending Invites -->
|
||||
<template v-else>
|
||||
<template v-if="activeView === 'pending'">
|
||||
<div
|
||||
v-for="(invite, index) in filteredPendingInvites"
|
||||
:key="invite.id"
|
||||
@@ -342,6 +371,8 @@ import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import type {
|
||||
PendingInvite,
|
||||
@@ -367,6 +398,27 @@ const {
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { copyInviteLink } = workspaceStore
|
||||
const { permissions, uiConfig } = useWorkspaceUI()
|
||||
const {
|
||||
isActiveSubscription,
|
||||
subscription,
|
||||
showSubscriptionDialog,
|
||||
getMaxSeats
|
||||
} = useBillingContext()
|
||||
|
||||
const maxSeats = computed(() => {
|
||||
if (isPersonalWorkspace.value) return 1
|
||||
const tier = subscription.value?.tier
|
||||
if (!tier) return 1
|
||||
const tierKey = TIER_TO_KEY[tier]
|
||||
if (!tierKey) return 1
|
||||
return getMaxSeats(tierKey)
|
||||
})
|
||||
|
||||
const isSingleSeatPlan = computed(() => {
|
||||
if (isPersonalWorkspace.value) return false
|
||||
if (!isActiveSubscription.value) return true
|
||||
return maxSeats.value <= 1
|
||||
})
|
||||
|
||||
const searchQuery = ref('')
|
||||
const activeView = ref<'active' | 'pending'>('active')
|
||||
|
||||
@@ -63,14 +63,18 @@
|
||||
<i class="pi pi-sign-out" />
|
||||
{{ $t('auth.signOut.signOut') }}
|
||||
</Button>
|
||||
<Button
|
||||
<i18n-t
|
||||
v-if="!isApiKeyLogin"
|
||||
class="w-fit"
|
||||
variant="destructive-textonly"
|
||||
@click="handleDeleteAccount"
|
||||
keypath="auth.deleteAccount.contactSupport"
|
||||
tag="p"
|
||||
class="text-muted text-sm"
|
||||
>
|
||||
{{ $t('auth.deleteAccount.deleteAccount') }}
|
||||
</Button>
|
||||
<template #email>
|
||||
<a href="mailto:support@comfy.org" class="underline"
|
||||
>support@comfy.org</a
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,7 +120,6 @@ const {
|
||||
providerName,
|
||||
providerIcon,
|
||||
handleSignOut,
|
||||
handleSignIn,
|
||||
handleDeleteAccount
|
||||
handleSignIn
|
||||
} = useCurrentUser()
|
||||
</script>
|
||||
|
||||
@@ -55,8 +55,12 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="isInviteLimitReached"
|
||||
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'"
|
||||
:disabled="!isSingleSeatPlan && isInviteLimitReached"
|
||||
:class="
|
||||
!isSingleSeatPlan &&
|
||||
isInviteLimitReached &&
|
||||
'opacity-50 cursor-not-allowed'
|
||||
"
|
||||
:aria-label="$t('workspacePanel.inviteMember')"
|
||||
@click="handleInviteMember"
|
||||
>
|
||||
@@ -129,6 +133,8 @@ import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { buttonVariants } from '@/components/ui/button/button.variants'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
@@ -144,8 +150,19 @@ const {
|
||||
showLeaveWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog,
|
||||
showInviteMemberDialog,
|
||||
showInviteMemberUpsellDialog,
|
||||
showEditWorkspaceDialog
|
||||
} = useDialogService()
|
||||
const { isActiveSubscription, subscription, getMaxSeats } = useBillingContext()
|
||||
|
||||
const isSingleSeatPlan = computed(() => {
|
||||
if (!isActiveSubscription.value) return true
|
||||
const tier = subscription.value?.tier
|
||||
if (!tier) return true
|
||||
const tierKey = TIER_TO_KEY[tier]
|
||||
if (!tierKey) return true
|
||||
return getMaxSeats(tierKey) <= 1
|
||||
})
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const {
|
||||
workspaceName,
|
||||
@@ -187,11 +204,16 @@ const deleteTooltip = computed(() => {
|
||||
})
|
||||
|
||||
const inviteTooltip = computed(() => {
|
||||
if (isSingleSeatPlan.value) return null
|
||||
if (!isInviteLimitReached.value) return null
|
||||
return t('workspacePanel.inviteLimitReached')
|
||||
})
|
||||
|
||||
function handleInviteMember() {
|
||||
if (isSingleSeatPlan.value) {
|
||||
showInviteMemberUpsellDialog()
|
||||
return
|
||||
}
|
||||
if (isInviteLimitReached.value) return
|
||||
showInviteMemberDialog()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('subscription.cancelDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
:disabled="isLoading"
|
||||
@click="onClose"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" :disabled="isLoading" @click="onClose">
|
||||
{{ $t('subscription.cancelDialog.keepSubscription') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
:loading="isLoading"
|
||||
@click="onConfirmCancel"
|
||||
>
|
||||
{{ $t('subscription.cancelDialog.confirmCancel') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const props = defineProps<{
|
||||
cancelAt?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
const formattedEndDate = computed(() => {
|
||||
const dateStr = props.cancelAt ?? subscription.value?.endDate
|
||||
if (!dateStr) return t('subscription.cancelDialog.endOfBillingPeriod')
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
})
|
||||
|
||||
const description = computed(() =>
|
||||
t('subscription.cancelDialog.description', { date: formattedEndDate.value })
|
||||
)
|
||||
|
||||
function onClose() {
|
||||
if (isLoading.value) return
|
||||
dialogStore.closeDialog({ key: 'cancel-subscription' })
|
||||
}
|
||||
|
||||
async function onConfirmCancel() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await cancelSubscription()
|
||||
await fetchStatus()
|
||||
dialogStore.closeDialog({ key: 'cancel-subscription' })
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.cancelSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.cancelDialog.failed'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -70,31 +70,17 @@
|
||||
@click="onSelectLink"
|
||||
/>
|
||||
<div
|
||||
class="absolute right-4 top-2 cursor-pointer"
|
||||
class="absolute right-3 top-2.5 cursor-pointer"
|
||||
@click="onCopyLink"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
>
|
||||
<g clip-path="url(#clip0_2127_14348)">
|
||||
<path
|
||||
d="M2.66634 10.6666C1.93301 10.6666 1.33301 10.0666 1.33301 9.33325V2.66659C1.33301 1.93325 1.93301 1.33325 2.66634 1.33325H9.33301C10.0663 1.33325 10.6663 1.93325 10.6663 2.66659M6.66634 5.33325H13.333C14.0694 5.33325 14.6663 5.93021 14.6663 6.66658V13.3333C14.6663 14.0696 14.0694 14.6666 13.333 14.6666H6.66634C5.92996 14.6666 5.33301 14.0696 5.33301 13.3333V6.66658C5.33301 5.93021 5.92996 5.33325 6.66634 5.33325Z"
|
||||
stroke="white"
|
||||
stroke-width="1.3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2127_14348">
|
||||
<rect width="16" height="16" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'pi size-4',
|
||||
justCopied ? 'pi-check text-green-500' : 'pi-copy'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,6 +104,7 @@ import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -130,6 +117,7 @@ const loading = ref(false)
|
||||
const email = ref('')
|
||||
const step = ref<'email' | 'link'>('email')
|
||||
const generatedLink = ref('')
|
||||
const justCopied = ref(false)
|
||||
|
||||
const isValidEmail = computed(() => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
@@ -161,6 +149,10 @@ async function onCreateLink() {
|
||||
async function onCopyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedLink.value)
|
||||
justCopied.value = true
|
||||
setTimeout(() => {
|
||||
justCopied.value = false
|
||||
}, 759)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspacePanel.inviteMemberDialog.linkCopied'),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[512px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{
|
||||
isActiveSubscription
|
||||
? $t('workspacePanel.inviteUpsellDialog.titleSingleSeat')
|
||||
: $t('workspacePanel.inviteUpsellDialog.titleNotSubscribed')
|
||||
}}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onDismiss"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{
|
||||
isActiveSubscription
|
||||
? $t('workspacePanel.inviteUpsellDialog.messageSingleSeat')
|
||||
: $t('workspacePanel.inviteUpsellDialog.messageNotSubscribed')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onDismiss">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="primary" size="lg" @click="onUpgrade">
|
||||
{{
|
||||
isActiveSubscription
|
||||
? $t('workspacePanel.inviteUpsellDialog.upgradeToCreator')
|
||||
: $t('workspacePanel.inviteUpsellDialog.viewPlans')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
|
||||
|
||||
function onDismiss() {
|
||||
dialogStore.closeDialog({ key: 'invite-member-upsell' })
|
||||
}
|
||||
|
||||
function onUpgrade() {
|
||||
dialogStore.closeDialog({ key: 'invite-member-upsell' })
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="px-4">
|
||||
<h2 :class="cn(flags.teamWorkspacesEnabled ? 'px-6' : 'px-4')">
|
||||
<i class="pi pi-cog" />
|
||||
<span>{{ $t('g.settings') }}</span>
|
||||
<Tag
|
||||
@@ -16,6 +16,9 @@
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
import { isStaging } from '@/config/staging'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
const { flags } = useFeatureFlags()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<!-- Help Center Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
|
||||
@@ -76,14 +76,6 @@ describe('NodePreview', () => {
|
||||
expect(wrapper.find('._sb_preview_badge').text()).toBe('Preview')
|
||||
})
|
||||
|
||||
it('applies text-ellipsis class to node header for text truncation', () => {
|
||||
const wrapper = mountComponent()
|
||||
const nodeHeader = wrapper.find('.node_header')
|
||||
|
||||
expect(nodeHeader.classes()).toContain('text-ellipsis')
|
||||
expect(nodeHeader.classes()).toContain('mr-4')
|
||||
})
|
||||
|
||||
it('sets title attribute on node header with full display name', () => {
|
||||
const wrapper = mountComponent()
|
||||
const nodeHeader = wrapper.find('.node_header')
|
||||
|
||||
@@ -10,7 +10,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
|
||||
<div v-else class="_sb_node_preview bg-component-node-background">
|
||||
<div class="_sb_table">
|
||||
<div
|
||||
class="node_header mr-4 text-ellipsis"
|
||||
class="node_header text-ellipsis"
|
||||
:title="nodeDef.display_name"
|
||||
:style="{
|
||||
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR,
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
|
||||
<SidebarBottomPanelToggleButton :is-small="isSmall" />
|
||||
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
<ModeToggle
|
||||
@@ -65,6 +65,7 @@ import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPa
|
||||
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
|
||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Active Jobs Grid -->
|
||||
<div
|
||||
v-if="activeJobItems.length"
|
||||
v-if="isQueuePanelV2Enabled && activeJobItems.length"
|
||||
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
|
||||
:style="gridStyle"
|
||||
>
|
||||
@@ -65,6 +65,7 @@ import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const {
|
||||
assets,
|
||||
@@ -90,6 +91,11 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const { jobItems } = useJobList()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
|
||||
type AssetGridItem = { key: string; asset: AssetItem }
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div
|
||||
v-if="activeJobItems.length"
|
||||
v-if="isQueuePanelV2Enabled && activeJobItems.length"
|
||||
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
|
||||
>
|
||||
<AssetsListItem
|
||||
@@ -133,6 +133,7 @@ import {
|
||||
} from '@/utils/formatUtil'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const {
|
||||
assets,
|
||||
@@ -154,6 +155,11 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const { jobItems } = useJobList()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const hoveredAssetId = ref<string | null>(null)
|
||||
|
||||
|
||||
@@ -13,10 +13,7 @@
|
||||
severity="danger"
|
||||
/>
|
||||
</template>
|
||||
<template
|
||||
v-if="nodeDef.name.startsWith(useSubgraphStore().typePrefix)"
|
||||
#actions
|
||||
>
|
||||
<template v-if="isUserBlueprint" #actions>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
@@ -128,8 +125,18 @@ const editBlueprint = async () => {
|
||||
await useSubgraphStore().editBlueprint(props.node.data.name)
|
||||
}
|
||||
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const subgraphStore = useSubgraphStore()
|
||||
const isUserBlueprint = computed(() => {
|
||||
const name = nodeDef.value.name
|
||||
if (!name.startsWith(subgraphStore.typePrefix)) return false
|
||||
return !subgraphStore.isGlobalBlueprint(
|
||||
name.slice(subgraphStore.typePrefix.length)
|
||||
)
|
||||
})
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = [
|
||||
if (!isUserBlueprint.value) return []
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('g.delete'),
|
||||
icon: 'pi pi-trash',
|
||||
@@ -137,15 +144,14 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
command: deleteBlueprint
|
||||
}
|
||||
]
|
||||
return items
|
||||
})
|
||||
function handleContextMenu(event: Event) {
|
||||
if (!nodeDef.value.name.startsWith(useSubgraphStore().typePrefix)) return
|
||||
if (!isUserBlueprint.value) return
|
||||
menu.value?.show(event)
|
||||
}
|
||||
function deleteBlueprint() {
|
||||
if (!props.node.data) return
|
||||
void useSubgraphStore().deleteBlueprint(props.node.data.name)
|
||||
void subgraphStore.deleteBlueprint(props.node.data.name)
|
||||
}
|
||||
|
||||
const nodePreviewStyle = ref<CSSProperties>({
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<template>
|
||||
<Toast />
|
||||
<Toast group="billing-operation" position="top-right">
|
||||
<template #message="slotProps">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-spin pi-spinner text-primary" />
|
||||
<span>{{ slotProps.message.summary }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Toast>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
42
src/components/toast/InviteAcceptedToast.vue
Normal file
42
src/components/toast/InviteAcceptedToast.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<Toast group="invite-accepted" position="top-right">
|
||||
<template #message="slotProps">
|
||||
<div class="flex items-center gap-2 justify-between w-full">
|
||||
<div class="flex flex-col justify-start">
|
||||
<div class="text-base">
|
||||
{{ slotProps.message.summary }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-foreground">
|
||||
{{ slotProps.message.detail.text }} <br />
|
||||
{{ slotProps.message.detail.workspaceName }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="md"
|
||||
variant="inverted"
|
||||
@click="viewWorkspace(slotProps.message.detail.workspaceId)"
|
||||
>
|
||||
{{ t('workspace.viewWorkspace') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Toast>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue'
|
||||
import Toast from 'primevue/toast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||
|
||||
function viewWorkspace(workspaceId: string) {
|
||||
void switchWithConfirmation(workspaceId)
|
||||
toast.removeGroup('invite-accepted')
|
||||
}
|
||||
</script>
|
||||
@@ -64,10 +64,10 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the CurrentUserPopover component
|
||||
vi.mock('./CurrentUserPopover.vue', () => ({
|
||||
// Mock the CurrentUserPopoverLegacy component
|
||||
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
|
||||
default: {
|
||||
name: 'CurrentUserPopoverMock',
|
||||
name: 'CurrentUserPopoverLegacyMock',
|
||||
render() {
|
||||
return h('div', 'Popover Content')
|
||||
},
|
||||
|
||||
@@ -45,14 +45,16 @@
|
||||
class: 'rounded-lg w-80'
|
||||
}
|
||||
}"
|
||||
@show="onPopoverShow"
|
||||
>
|
||||
<!-- Workspace mode: workspace-aware popover (only when ready) -->
|
||||
<CurrentUserPopoverWorkspace
|
||||
v-if="teamWorkspacesEnabled && initState === 'ready'"
|
||||
ref="workspacePopoverContent"
|
||||
@close="closePopover"
|
||||
/>
|
||||
<!-- Legacy mode: original popover -->
|
||||
<CurrentUserPopover
|
||||
<CurrentUserPopoverLegacy
|
||||
v-else-if="!teamWorkspacesEnabled"
|
||||
@close="closePopover"
|
||||
/>
|
||||
@@ -75,7 +77,7 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
|
||||
|
||||
const CurrentUserPopoverWorkspace = defineAsyncComponent(
|
||||
() => import('./CurrentUserPopoverWorkspace.vue')
|
||||
@@ -112,8 +114,15 @@ const workspaceName = computed(() => {
|
||||
})
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const workspacePopoverContent = ref<{
|
||||
refreshBalance: () => void
|
||||
} | null>(null)
|
||||
|
||||
const closePopover = () => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
const onPopoverShow = () => {
|
||||
workspacePopoverContent.value?.refreshBalance()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createI18n } from 'vue-i18n'
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
|
||||
|
||||
// Mock all firebase modules
|
||||
vi.mock('firebase/app', () => ({
|
||||
@@ -172,7 +172,7 @@ vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
describe('CurrentUserPopover', () => {
|
||||
describe('CurrentUserPopoverLegacy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAuthStoreState.balance = {
|
||||
@@ -190,7 +190,7 @@ describe('CurrentUserPopover', () => {
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return mount(CurrentUserPopover, {
|
||||
return mount(CurrentUserPopoverLegacy, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
@@ -55,55 +55,61 @@
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
<!-- Credits Section (PERSONAL and OWNER only) -->
|
||||
<template v-if="showCreditsSection">
|
||||
<div class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="4rem"
|
||||
height="1.25rem"
|
||||
class="w-full"
|
||||
/>
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
displayedCredits
|
||||
}}</span>
|
||||
<i
|
||||
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
|
||||
/>
|
||||
<!-- Subscribed: Show Add Credits button -->
|
||||
<Button
|
||||
v-if="isActiveSubscription && isWorkspaceSubscribed"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="text-base-foreground"
|
||||
data-testid="add-credits-button"
|
||||
@click="handleTopUp"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
<!-- Unsubscribed: Show Subscribe button -->
|
||||
<SubscribeButton
|
||||
v-else-if="isPersonalWorkspace"
|
||||
:fluid="false"
|
||||
:label="$t('workspaceSwitcher.subscribe')"
|
||||
size="sm"
|
||||
variant="gradient"
|
||||
/>
|
||||
<!-- Non-personal workspace: Navigate to workspace settings -->
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="handleOpenPlanAndCreditsSettings"
|
||||
>
|
||||
{{ $t('workspaceSwitcher.subscribe') }}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Credits Section -->
|
||||
|
||||
<Divider class="mx-0 my-2" />
|
||||
</template>
|
||||
<div class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="4rem"
|
||||
height="1.25rem"
|
||||
class="w-full"
|
||||
/>
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
displayedCredits
|
||||
}}</span>
|
||||
<i
|
||||
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
|
||||
/>
|
||||
<!-- Add Credits (subscribed + personal or workspace owner only) -->
|
||||
<Button
|
||||
v-if="isActiveSubscription && permissions.canTopUp"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="text-base-foreground"
|
||||
data-testid="add-credits-button"
|
||||
@click="handleTopUp"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
<!-- Subscribe/Resubscribe (only when not subscribed or cancelled) -->
|
||||
<SubscribeButton
|
||||
v-if="showSubscribeAction && isPersonalWorkspace"
|
||||
:fluid="false"
|
||||
:label="
|
||||
isCancelled
|
||||
? $t('subscription.resubscribe')
|
||||
: $t('workspaceSwitcher.subscribe')
|
||||
"
|
||||
size="sm"
|
||||
variant="gradient"
|
||||
/>
|
||||
<Button
|
||||
v-if="showSubscribeAction && !isPersonalWorkspace"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="handleOpenPlansAndPricing"
|
||||
>
|
||||
{{
|
||||
isCancelled
|
||||
? $t('subscription.resubscribe')
|
||||
: $t('workspaceSwitcher.subscribe')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Divider class="mx-0 my-2" />
|
||||
|
||||
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
|
||||
<div
|
||||
@@ -196,18 +202,19 @@ import { storeToRefs } from 'pinia'
|
||||
import Divider from 'primevue/divider'
|
||||
import Popover from 'primevue/popover'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -219,10 +226,9 @@ const workspaceStore = useTeamWorkspaceStore()
|
||||
const {
|
||||
initState,
|
||||
workspaceName,
|
||||
isInPersonalWorkspace: isPersonalWorkspace,
|
||||
isWorkspaceSubscribed
|
||||
isInPersonalWorkspace: isPersonalWorkspace
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { workspaceRole } = useWorkspaceUI()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -233,22 +239,30 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogService = useDialogService()
|
||||
const { isActiveSubscription, subscriptionStatus } = useSubscription()
|
||||
const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
|
||||
const { isActiveSubscription, subscription, balance, isLoading, fetchBalance } =
|
||||
useBillingContext()
|
||||
|
||||
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
const { locale } = useI18n()
|
||||
const isLoadingBalance = isLoading
|
||||
|
||||
const displayedCredits = computed(() => {
|
||||
if (initState.value !== 'ready') return ''
|
||||
// Only personal workspaces have subscription status from useSubscription()
|
||||
// Team workspaces don't have backend subscription data yet
|
||||
if (isPersonalWorkspace.value) {
|
||||
// Wait for subscription status to load
|
||||
if (subscriptionStatus.value === null) return ''
|
||||
return isActiveSubscription.value ? totalCredits.value : '0'
|
||||
}
|
||||
return '0'
|
||||
|
||||
// API field is named _micros but contains cents (naming inconsistency)
|
||||
const cents =
|
||||
balance.value?.effectiveBalanceMicros ?? balance.value?.amountMicros ?? 0
|
||||
return formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value,
|
||||
numberOptions: {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const canUpgrade = computed(() => {
|
||||
@@ -258,13 +272,15 @@ const canUpgrade = computed(() => {
|
||||
})
|
||||
|
||||
const showPlansAndPricing = computed(
|
||||
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
||||
() => permissions.value.canManageSubscription
|
||||
)
|
||||
const showManagePlan = computed(
|
||||
() => showPlansAndPricing.value && isActiveSubscription.value
|
||||
() => permissions.value.canManageSubscription && isActiveSubscription.value
|
||||
)
|
||||
const showCreditsSection = computed(
|
||||
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
||||
const showSubscribeAction = computed(
|
||||
() =>
|
||||
permissions.value.canManageSubscription &&
|
||||
(!isActiveSubscription.value || isCancelled.value)
|
||||
)
|
||||
|
||||
const handleOpenUserSettings = () => {
|
||||
@@ -322,7 +338,9 @@ const toggleWorkspaceSwitcher = (event: MouseEvent) => {
|
||||
workspaceSwitcherPopover.value?.toggle(event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void authActions.fetchBalance()
|
||||
})
|
||||
const refreshBalance = () => {
|
||||
void fetchBalance()
|
||||
}
|
||||
|
||||
defineExpose({ refreshBalance })
|
||||
</script>
|
||||
|
||||
@@ -113,24 +113,24 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type {
|
||||
SubscriptionTier,
|
||||
WorkspaceRole,
|
||||
WorkspaceType
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
|
||||
|
||||
interface AvailableWorkspace {
|
||||
id: string
|
||||
name: string
|
||||
type: WorkspaceType
|
||||
role: WorkspaceRole
|
||||
isSubscribed: boolean
|
||||
subscriptionPlan: SubscriptionPlan
|
||||
subscriptionPlan: string | null
|
||||
subscriptionTier: SubscriptionTier | null
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -140,7 +140,34 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||
const { subscriptionTierName: userSubscriptionTierName } = useSubscription()
|
||||
const { subscription } = useBillingContext()
|
||||
|
||||
const tierKeyMap: Record<string, string> = {
|
||||
STANDARD: 'standard',
|
||||
CREATOR: 'creator',
|
||||
PRO: 'pro',
|
||||
FOUNDER: 'founder',
|
||||
FOUNDERS_EDITION: 'founder'
|
||||
}
|
||||
|
||||
function formatTierName(
|
||||
tier: string | null | undefined,
|
||||
isYearly: boolean
|
||||
): string {
|
||||
if (!tier) return ''
|
||||
const key = tierKeyMap[tier] ?? 'standard'
|
||||
const baseName = t(`subscription.tiers.${key}.name`)
|
||||
return isYearly
|
||||
? t('subscription.tierNameYearly', { name: baseName })
|
||||
: baseName
|
||||
}
|
||||
|
||||
const currentSubscriptionTierName = computed(() => {
|
||||
const tier = subscription.value?.tier
|
||||
if (!tier) return ''
|
||||
const isYearly = subscription.value?.duration === 'ANNUAL'
|
||||
return formatTierName(tier, isYearly)
|
||||
})
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { workspaceId, workspaces, canCreateWorkspace, isFetchingWorkspaces } =
|
||||
@@ -153,7 +180,8 @@ const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
|
||||
type: w.type,
|
||||
role: w.role,
|
||||
isSubscribed: w.isSubscribed,
|
||||
subscriptionPlan: w.subscriptionPlan
|
||||
subscriptionPlan: w.subscriptionPlan,
|
||||
subscriptionTier: w.subscriptionTier
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -168,19 +196,32 @@ function getRoleLabel(role: AvailableWorkspace['role']): string {
|
||||
}
|
||||
|
||||
function getTierLabel(workspace: AvailableWorkspace): string | null {
|
||||
// Personal workspace: use user's subscription tier
|
||||
if (workspace.type === 'personal') {
|
||||
return userSubscriptionTierName.value || null
|
||||
// For the current/active workspace, use billing context directly
|
||||
// This ensures we always have the most up-to-date subscription info
|
||||
if (isCurrentWorkspace(workspace)) {
|
||||
return currentSubscriptionTierName.value || null
|
||||
}
|
||||
// Team workspace: use workspace subscription plan
|
||||
if (!workspace.isSubscribed || !workspace.subscriptionPlan) return null
|
||||
if (workspace.subscriptionPlan === 'PRO_MONTHLY')
|
||||
return t('subscription.tiers.pro.name')
|
||||
if (workspace.subscriptionPlan === 'PRO_YEARLY')
|
||||
return t('subscription.tierNameYearly', {
|
||||
name: t('subscription.tiers.pro.name')
|
||||
})
|
||||
return null
|
||||
|
||||
// For non-active workspaces, use cached store data
|
||||
if (!workspace.isSubscribed) return null
|
||||
|
||||
if (workspace.subscriptionTier) {
|
||||
return formatTierName(workspace.subscriptionTier, false)
|
||||
}
|
||||
|
||||
if (!workspace.subscriptionPlan) return null
|
||||
|
||||
// Parse plan slug (format: TIER_DURATION, e.g. "CREATOR_MONTHLY", "PRO_YEARLY")
|
||||
const planSlug = workspace.subscriptionPlan
|
||||
|
||||
// Extract tier from plan slug (e.g., "CREATOR_MONTHLY" -> "CREATOR")
|
||||
const tierMatch = Object.keys(tierKeyMap).find((tier) =>
|
||||
planSlug.startsWith(tier)
|
||||
)
|
||||
if (!tierMatch) return null
|
||||
|
||||
const isYearly = planSlug.includes('YEARLY') || planSlug.includes('ANNUAL')
|
||||
return formatTierName(tierMatch, isYearly)
|
||||
}
|
||||
|
||||
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
|
||||
|
||||
@@ -20,9 +20,18 @@ defineOptions({
|
||||
|
||||
const {
|
||||
position = 'popper',
|
||||
// Safari has issues with click events on portaled content inside dialogs.
|
||||
// Set disablePortal to true when using Select inside a Dialog on Safari.
|
||||
// See: https://github.com/chakra-ui/ark/issues/1782
|
||||
disablePortal = false,
|
||||
class: className,
|
||||
...restProps
|
||||
} = defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
} = defineProps<
|
||||
SelectContentProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
disablePortal?: boolean
|
||||
}
|
||||
>()
|
||||
const emits = defineEmits<SelectContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => ({
|
||||
@@ -34,7 +43,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectPortal>
|
||||
<SelectPortal :disabled="disablePortal">
|
||||
<SelectContent
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { t } from '@/i18n'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
@@ -13,8 +10,6 @@ export const useCurrentUser = () => {
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const commandStore = useCommandStore()
|
||||
const apiKeyStore = useApiKeyAuthStore()
|
||||
const dialogService = useDialogService()
|
||||
const { deleteAccount } = useFirebaseAuthActions()
|
||||
|
||||
const firebaseUser = computed(() => authStore.currentUser)
|
||||
const isApiKeyLogin = computed(() => apiKeyStore.isAuthenticated)
|
||||
@@ -116,18 +111,6 @@ export const useCurrentUser = () => {
|
||||
await commandStore.execute('Comfy.User.OpenSignInDialog')
|
||||
}
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
const confirmed = await dialogService.confirm({
|
||||
title: t('auth.deleteAccount.confirmTitle'),
|
||||
message: t('auth.deleteAccount.confirmMessage'),
|
||||
type: 'delete'
|
||||
})
|
||||
|
||||
if (confirmed) {
|
||||
await deleteAccount()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading: authStore.loading,
|
||||
isLoggedIn,
|
||||
@@ -141,7 +124,6 @@ export const useCurrentUser = () => {
|
||||
resolvedUserInfo,
|
||||
handleSignOut,
|
||||
handleSignIn,
|
||||
handleDeleteAccount,
|
||||
onUserResolved,
|
||||
onTokenRefreshed,
|
||||
onUserLogout
|
||||
|
||||
@@ -2,11 +2,11 @@ import { FirebaseError } from 'firebase/app'
|
||||
import { AuthErrorCodes } from 'firebase/auth'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -83,7 +83,7 @@ export const useFirebaseAuthActions = () => {
|
||||
)
|
||||
|
||||
const purchaseCredits = wrapWithErrorHandlingAsync(async (amount: number) => {
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
if (!isActiveSubscription.value) return
|
||||
|
||||
const response = await authStore.initiateCreditPurchase({
|
||||
@@ -206,21 +206,6 @@ export const useFirebaseAuthActions = () => {
|
||||
[createReauthenticationRecovery<[string], void>()]
|
||||
)
|
||||
|
||||
const deleteAccount = wrapWithErrorHandlingAsync(
|
||||
async () => {
|
||||
await authStore.deleteAccount()
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: t('auth.deleteAccount.success'),
|
||||
detail: t('auth.deleteAccount.successDetail'),
|
||||
life: 5000
|
||||
})
|
||||
},
|
||||
reportError,
|
||||
undefined,
|
||||
[createReauthenticationRecovery<[], void>()]
|
||||
)
|
||||
|
||||
return {
|
||||
logout,
|
||||
sendPasswordReset,
|
||||
@@ -232,7 +217,6 @@ export const useFirebaseAuthActions = () => {
|
||||
signInWithEmail,
|
||||
signUpWithEmail,
|
||||
updatePassword,
|
||||
deleteAccount,
|
||||
accessError,
|
||||
reportError
|
||||
}
|
||||
|
||||
78
src/composables/billing/types.ts
Normal file
78
src/composables/billing/types.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
Plan,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
export type BillingType = 'legacy' | 'workspace'
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
isActive: boolean
|
||||
tier: SubscriptionTier | null
|
||||
duration: SubscriptionDuration | null
|
||||
planSlug: string | null
|
||||
renewalDate: string | null
|
||||
endDate: string | null
|
||||
isCancelled: boolean
|
||||
hasFunds: boolean
|
||||
}
|
||||
|
||||
export interface BalanceInfo {
|
||||
amountMicros: number
|
||||
currency: string
|
||||
effectiveBalanceMicros?: number
|
||||
prepaidBalanceMicros?: number
|
||||
cloudCreditBalanceMicros?: number
|
||||
}
|
||||
|
||||
export interface BillingActions {
|
||||
initialize: () => Promise<void>
|
||||
fetchStatus: () => Promise<void>
|
||||
fetchBalance: () => Promise<void>
|
||||
subscribe: (
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
) => Promise<SubscribeResponse | void>
|
||||
previewSubscribe: (
|
||||
planSlug: string
|
||||
) => Promise<PreviewSubscribeResponse | null>
|
||||
manageSubscription: () => Promise<void>
|
||||
cancelSubscription: () => Promise<void>
|
||||
fetchPlans: () => Promise<void>
|
||||
/**
|
||||
* Ensures billing is initialized and subscription is active.
|
||||
* Shows subscription dialog if not subscribed.
|
||||
* Use this in extensions/entry points that require active subscription.
|
||||
*/
|
||||
requireActiveSubscription: () => Promise<void>
|
||||
/**
|
||||
* Shows the subscription dialog.
|
||||
*/
|
||||
showSubscriptionDialog: () => void
|
||||
}
|
||||
|
||||
export interface BillingState {
|
||||
isInitialized: Ref<boolean>
|
||||
subscription: ComputedRef<SubscriptionInfo | null>
|
||||
balance: ComputedRef<BalanceInfo | null>
|
||||
plans: ComputedRef<Plan[]>
|
||||
currentPlanSlug: ComputedRef<string | null>
|
||||
isLoading: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
/**
|
||||
* Convenience computed for checking if subscription is active.
|
||||
* Equivalent to `subscription.value?.isActive ?? false`
|
||||
*/
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
type: ComputedRef<BillingType>
|
||||
getMaxSeats: (tierKey: TierKey) => number
|
||||
}
|
||||
237
src/composables/billing/useBillingContext.test.ts
Normal file
237
src/composables/billing/useBillingContext.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { useBillingContext } from './useBillingContext'
|
||||
|
||||
const { mockTeamWorkspacesEnabled, mockIsPersonal, mockPlans } = vi.hoisted(
|
||||
() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const original = await importOriginal()
|
||||
return {
|
||||
...(original as Record<string, unknown>),
|
||||
createSharedComposable: (fn: (...args: unknown[]) => unknown) => fn
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
get isInPersonalWorkspace() {
|
||||
return mockIsPersonal.value
|
||||
},
|
||||
get activeWorkspace() {
|
||||
return mockIsPersonal.value
|
||||
? { id: 'personal-123', type: 'personal' }
|
||||
: { id: 'team-456', type: 'team' }
|
||||
},
|
||||
updateActiveWorkspace: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isActiveSubscription: { value: true },
|
||||
subscriptionTier: { value: 'PRO' },
|
||||
subscriptionDuration: { value: 'MONTHLY' },
|
||||
formattedRenewalDate: { value: 'Jan 1, 2025' },
|
||||
formattedEndDate: { value: '' },
|
||||
isCancelled: { value: false },
|
||||
fetchStatus: vi.fn().mockResolvedValue(undefined),
|
||||
manageSubscription: vi.fn().mockResolvedValue(undefined),
|
||||
subscribe: vi.fn().mockResolvedValue(undefined),
|
||||
showSubscriptionDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({
|
||||
show: vi.fn(),
|
||||
hide: vi.fn()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: () => ({
|
||||
balance: { amount_micros: 5000000 },
|
||||
fetchBalance: vi.fn().mockResolvedValue({ amount_micros: 5000000 })
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => ({
|
||||
useBillingPlans: () => ({
|
||||
get plans() {
|
||||
return mockPlans
|
||||
},
|
||||
currentPlanSlug: { value: null },
|
||||
isLoading: { value: false },
|
||||
error: { value: null },
|
||||
fetchPlans: vi.fn().mockResolvedValue(undefined),
|
||||
getPlanBySlug: vi.fn().mockReturnValue(null)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: {
|
||||
getBillingStatus: vi.fn().mockResolvedValue({
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY'
|
||||
}),
|
||||
getBillingBalance: vi.fn().mockResolvedValue({
|
||||
amount_micros: 10000000,
|
||||
currency: 'usd'
|
||||
}),
|
||||
subscribe: vi.fn().mockResolvedValue({ status: 'subscribed' }),
|
||||
previewSubscribe: vi.fn().mockResolvedValue({ allowed: true })
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useBillingContext', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
mockPlans.value = []
|
||||
})
|
||||
|
||||
it('returns legacy type for personal workspace', () => {
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('provides subscription info from legacy billing', () => {
|
||||
const { subscription } = useBillingContext()
|
||||
|
||||
expect(subscription.value).toEqual({
|
||||
isActive: true,
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
planSlug: null,
|
||||
renewalDate: 'Jan 1, 2025',
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true
|
||||
})
|
||||
})
|
||||
|
||||
it('provides balance info from legacy billing', () => {
|
||||
const { balance } = useBillingContext()
|
||||
|
||||
expect(balance.value).toEqual({
|
||||
amountMicros: 5000000,
|
||||
currency: 'usd',
|
||||
effectiveBalanceMicros: 5000000,
|
||||
prepaidBalanceMicros: 0,
|
||||
cloudCreditBalanceMicros: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes initialize action', async () => {
|
||||
const { initialize } = useBillingContext()
|
||||
await expect(initialize()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes fetchStatus action', async () => {
|
||||
const { fetchStatus } = useBillingContext()
|
||||
await expect(fetchStatus()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes fetchBalance action', async () => {
|
||||
const { fetchBalance } = useBillingContext()
|
||||
await expect(fetchBalance()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes subscribe action', async () => {
|
||||
const { subscribe } = useBillingContext()
|
||||
await expect(subscribe('pro-monthly')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes manageSubscription action', async () => {
|
||||
const { manageSubscription } = useBillingContext()
|
||||
await expect(manageSubscription()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('provides isActiveSubscription convenience computed', () => {
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
expect(isActiveSubscription.value).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes requireActiveSubscription action', async () => {
|
||||
const { requireActiveSubscription } = useBillingContext()
|
||||
await expect(requireActiveSubscription()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes showSubscriptionDialog action', () => {
|
||||
const { showSubscriptionDialog } = useBillingContext()
|
||||
expect(() => showSubscriptionDialog()).not.toThrow()
|
||||
})
|
||||
|
||||
describe('getMaxSeats', () => {
|
||||
it('returns 1 for personal workspaces regardless of tier', () => {
|
||||
const { getMaxSeats } = useBillingContext()
|
||||
expect(getMaxSeats('standard')).toBe(1)
|
||||
expect(getMaxSeats('creator')).toBe(1)
|
||||
expect(getMaxSeats('pro')).toBe(1)
|
||||
expect(getMaxSeats('founder')).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to hardcoded values when no API plans available', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { getMaxSeats } = useBillingContext()
|
||||
expect(getMaxSeats('standard')).toBe(1)
|
||||
expect(getMaxSeats('creator')).toBe(5)
|
||||
expect(getMaxSeats('pro')).toBe(20)
|
||||
expect(getMaxSeats('founder')).toBe(1)
|
||||
})
|
||||
|
||||
it('prefers API max_seats when plans are loaded', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockPlans.value = [
|
||||
{
|
||||
slug: 'pro-monthly',
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
price_cents: 10000,
|
||||
credits_cents: 2110000,
|
||||
max_seats: 50,
|
||||
availability: { available: true },
|
||||
seat_summary: {
|
||||
seat_count: 1,
|
||||
total_cost_cents: 10000,
|
||||
total_credits_cents: 2110000
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const { getMaxSeats } = useBillingContext()
|
||||
expect(getMaxSeats('pro')).toBe(50)
|
||||
// Tiers without API plans still fall back to hardcoded values
|
||||
expect(getMaxSeats('creator')).toBe(5)
|
||||
})
|
||||
})
|
||||
})
|
||||
258
src/composables/billing/useBillingContext.ts
Normal file
258
src/composables/billing/useBillingContext.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { computed, ref, shallowRef, toValue, watch } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
KEY_TO_TIER,
|
||||
getTierFeatures
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type {
|
||||
BalanceInfo,
|
||||
BillingActions,
|
||||
BillingContext,
|
||||
BillingType,
|
||||
BillingState,
|
||||
SubscriptionInfo
|
||||
} from './types'
|
||||
import { useLegacyBilling } from './useLegacyBilling'
|
||||
import { useWorkspaceBilling } from './useWorkspaceBilling'
|
||||
|
||||
/**
|
||||
* Unified billing context that automatically switches between legacy (user-scoped)
|
||||
* and workspace billing based on the active workspace type.
|
||||
*
|
||||
* - Personal workspaces use legacy billing via /customers/* endpoints
|
||||
* - Team workspaces use workspace billing via /billing/* endpoints
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const {
|
||||
* type,
|
||||
* subscription,
|
||||
* balance,
|
||||
* isInitialized,
|
||||
* initialize,
|
||||
* subscribe
|
||||
* } = useBillingContext()
|
||||
*
|
||||
* // Wait for initialization
|
||||
* await initialize()
|
||||
*
|
||||
* // Check subscription status
|
||||
* if (subscription.value?.isActive) {
|
||||
* console.log(`Tier: ${subscription.value.tier}`)
|
||||
* }
|
||||
*
|
||||
* // Check balance
|
||||
* if (balance.value) {
|
||||
* const dollars = balance.value.amountMicros / 1_000_000
|
||||
* console.log(`Balance: $${dollars.toFixed(2)}`)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
function useBillingContextInternal(): BillingContext {
|
||||
const store = useTeamWorkspaceStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const legacyBillingRef = shallowRef<(BillingState & BillingActions) | null>(
|
||||
null
|
||||
)
|
||||
const workspaceBillingRef = shallowRef<
|
||||
(BillingState & BillingActions) | null
|
||||
>(null)
|
||||
|
||||
const getLegacyBilling = () => {
|
||||
if (!legacyBillingRef.value) {
|
||||
legacyBillingRef.value = useLegacyBilling()
|
||||
}
|
||||
return legacyBillingRef.value
|
||||
}
|
||||
|
||||
const getWorkspaceBilling = () => {
|
||||
if (!workspaceBillingRef.value) {
|
||||
workspaceBillingRef.value = useWorkspaceBilling()
|
||||
}
|
||||
return workspaceBillingRef.value
|
||||
}
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Determines which billing type to use:
|
||||
* - If team workspaces feature is disabled: always use legacy (/customers)
|
||||
* - If team workspaces feature is enabled:
|
||||
* - Personal workspace: use legacy (/customers)
|
||||
* - Team workspace: use workspace (/billing)
|
||||
*/
|
||||
const type = computed<BillingType>(() => {
|
||||
if (!flags.teamWorkspacesEnabled) return 'legacy'
|
||||
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
|
||||
})
|
||||
|
||||
const activeContext = computed(() =>
|
||||
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
|
||||
)
|
||||
|
||||
// Proxy state from active context
|
||||
const subscription = computed<SubscriptionInfo | null>(() =>
|
||||
toValue(activeContext.value.subscription)
|
||||
)
|
||||
|
||||
const balance = computed<BalanceInfo | null>(() =>
|
||||
toValue(activeContext.value.balance)
|
||||
)
|
||||
|
||||
const plans = computed(() => toValue(activeContext.value.plans))
|
||||
|
||||
const currentPlanSlug = computed(() =>
|
||||
toValue(activeContext.value.currentPlanSlug)
|
||||
)
|
||||
|
||||
const isActiveSubscription = computed(() =>
|
||||
toValue(activeContext.value.isActiveSubscription)
|
||||
)
|
||||
|
||||
function getMaxSeats(tierKey: TierKey): number {
|
||||
if (type.value === 'legacy') return 1
|
||||
|
||||
const apiTier = KEY_TO_TIER[tierKey]
|
||||
const plan = plans.value.find(
|
||||
(p) => p.tier === apiTier && p.duration === 'MONTHLY'
|
||||
)
|
||||
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
|
||||
}
|
||||
|
||||
// Sync subscription info to workspace store for display in workspace switcher
|
||||
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
|
||||
// This ensures the delete button is enabled after cancellation, even before the period ends
|
||||
watch(
|
||||
subscription,
|
||||
(sub) => {
|
||||
if (!sub || store.isInPersonalWorkspace) return
|
||||
|
||||
store.updateActiveWorkspace({
|
||||
isSubscribed: sub.isActive && !sub.isCancelled,
|
||||
subscriptionPlan: sub.planSlug
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Initialize billing when workspace changes
|
||||
watch(
|
||||
() => store.activeWorkspace?.id,
|
||||
async (newWorkspaceId, oldWorkspaceId) => {
|
||||
if (!newWorkspaceId) {
|
||||
// No workspace selected - reset state
|
||||
isInitialized.value = false
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (newWorkspaceId !== oldWorkspaceId) {
|
||||
// Workspace changed - reinitialize
|
||||
isInitialized.value = false
|
||||
try {
|
||||
await initialize()
|
||||
} catch (err) {
|
||||
// Error is already captured in error ref
|
||||
console.error('Failed to initialize billing context:', err)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await activeContext.value.initialize()
|
||||
isInitialized.value = true
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to initialize billing'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatus(): Promise<void> {
|
||||
return activeContext.value.fetchStatus()
|
||||
}
|
||||
|
||||
async function fetchBalance(): Promise<void> {
|
||||
return activeContext.value.fetchBalance()
|
||||
}
|
||||
|
||||
async function subscribe(
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
) {
|
||||
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
|
||||
}
|
||||
|
||||
async function previewSubscribe(planSlug: string) {
|
||||
return activeContext.value.previewSubscribe(planSlug)
|
||||
}
|
||||
|
||||
async function manageSubscription() {
|
||||
return activeContext.value.manageSubscription()
|
||||
}
|
||||
|
||||
async function cancelSubscription() {
|
||||
return activeContext.value.cancelSubscription()
|
||||
}
|
||||
|
||||
async function fetchPlans() {
|
||||
return activeContext.value.fetchPlans()
|
||||
}
|
||||
|
||||
async function requireActiveSubscription() {
|
||||
return activeContext.value.requireActiveSubscription()
|
||||
}
|
||||
|
||||
function showSubscriptionDialog() {
|
||||
return activeContext.value.showSubscriptionDialog()
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
isInitialized,
|
||||
subscription,
|
||||
balance,
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
getMaxSeats,
|
||||
|
||||
initialize,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
subscribe,
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
}
|
||||
}
|
||||
|
||||
export const useBillingContext = createSharedComposable(
|
||||
useBillingContextInternal
|
||||
)
|
||||
189
src/composables/billing/useLegacyBilling.ts
Normal file
189
src/composables/billing/useLegacyBilling.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type {
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
import type {
|
||||
BalanceInfo,
|
||||
BillingActions,
|
||||
BillingState,
|
||||
SubscriptionInfo
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Adapter for legacy user-scoped billing via /customers/* endpoints.
|
||||
* Used for personal workspaces.
|
||||
* @internal - Use useBillingContext() instead of importing directly.
|
||||
*/
|
||||
export function useLegacyBilling(): BillingState & BillingActions {
|
||||
const {
|
||||
isActiveSubscription: legacyIsActiveSubscription,
|
||||
subscriptionTier,
|
||||
subscriptionDuration,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
isCancelled,
|
||||
fetchStatus: legacyFetchStatus,
|
||||
manageSubscription: legacyManageSubscription,
|
||||
subscribe: legacySubscribe,
|
||||
showSubscriptionDialog: legacyShowSubscriptionDialog
|
||||
} = useSubscription()
|
||||
|
||||
const firebaseAuthStore = useFirebaseAuthStore()
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const isActiveSubscription = computed(() => legacyIsActiveSubscription.value)
|
||||
|
||||
const subscription = computed<SubscriptionInfo | null>(() => {
|
||||
if (!legacyIsActiveSubscription.value && !subscriptionTier.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
isActive: legacyIsActiveSubscription.value,
|
||||
tier: subscriptionTier.value,
|
||||
duration: subscriptionDuration.value,
|
||||
planSlug: null, // Legacy doesn't use plan slugs
|
||||
renewalDate: formattedRenewalDate.value || null,
|
||||
endDate: formattedEndDate.value || null,
|
||||
isCancelled: isCancelled.value,
|
||||
hasFunds: (firebaseAuthStore.balance?.amount_micros ?? 0) > 0
|
||||
}
|
||||
})
|
||||
|
||||
const balance = computed<BalanceInfo | null>(() => {
|
||||
const legacyBalance = firebaseAuthStore.balance
|
||||
if (!legacyBalance) return null
|
||||
|
||||
return {
|
||||
amountMicros: legacyBalance.amount_micros ?? 0,
|
||||
currency: legacyBalance.currency ?? 'usd',
|
||||
effectiveBalanceMicros:
|
||||
legacyBalance.effective_balance_micros ??
|
||||
legacyBalance.amount_micros ??
|
||||
0,
|
||||
prepaidBalanceMicros: legacyBalance.prepaid_balance_micros ?? 0,
|
||||
cloudCreditBalanceMicros: legacyBalance.cloud_credit_balance_micros ?? 0
|
||||
}
|
||||
})
|
||||
|
||||
// Legacy billing doesn't have workspace-style plans
|
||||
const plans = computed(() => [])
|
||||
const currentPlanSlug = computed(() => null)
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
isInitialized.value = true
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to initialize billing'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatus(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await legacyFetchStatus()
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to fetch subscription'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBalance(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await firebaseAuthStore.fetchBalance()
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to fetch balance'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe(
|
||||
_planSlug: string,
|
||||
_returnUrl?: string,
|
||||
_cancelUrl?: string
|
||||
): Promise<SubscribeResponse | void> {
|
||||
// Legacy billing uses Stripe checkout flow via useSubscription
|
||||
await legacySubscribe()
|
||||
}
|
||||
|
||||
async function previewSubscribe(
|
||||
_planSlug: string
|
||||
): Promise<PreviewSubscribeResponse | null> {
|
||||
// Legacy billing doesn't support preview - returns null
|
||||
return null
|
||||
}
|
||||
|
||||
async function manageSubscription(): Promise<void> {
|
||||
await legacyManageSubscription()
|
||||
}
|
||||
|
||||
async function cancelSubscription(): Promise<void> {
|
||||
await legacyManageSubscription()
|
||||
}
|
||||
|
||||
async function fetchPlans(): Promise<void> {
|
||||
// Legacy billing doesn't have workspace-style plans
|
||||
// Plans are hardcoded in the UI for legacy subscriptions
|
||||
}
|
||||
|
||||
async function requireActiveSubscription(): Promise<void> {
|
||||
await fetchStatus()
|
||||
if (!isActiveSubscription.value) {
|
||||
legacyShowSubscriptionDialog()
|
||||
}
|
||||
}
|
||||
|
||||
function showSubscriptionDialog(): void {
|
||||
legacyShowSubscriptionDialog()
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isInitialized,
|
||||
subscription,
|
||||
balance,
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
subscribe,
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
}
|
||||
}
|
||||
311
src/composables/billing/useWorkspaceBilling.ts
Normal file
311
src/composables/billing/useWorkspaceBilling.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
BillingBalanceResponse,
|
||||
BillingStatusResponse,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type {
|
||||
BalanceInfo,
|
||||
BillingActions,
|
||||
BillingState,
|
||||
SubscriptionInfo
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Adapter for workspace-scoped billing via /billing/* endpoints.
|
||||
* Used for team workspaces.
|
||||
* @internal - Use useBillingContext() instead of importing directly.
|
||||
*/
|
||||
export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
const billingPlans = useBillingPlans()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const statusData = shallowRef<BillingStatusResponse | null>(null)
|
||||
const balanceData = shallowRef<BillingBalanceResponse | null>(null)
|
||||
|
||||
const isActiveSubscription = computed(
|
||||
() => statusData.value?.is_active ?? false
|
||||
)
|
||||
|
||||
const subscription = computed<SubscriptionInfo | null>(() => {
|
||||
const status = statusData.value
|
||||
if (!status) return null
|
||||
|
||||
return {
|
||||
isActive: status.is_active,
|
||||
tier: status.subscription_tier ?? null,
|
||||
duration: status.subscription_duration ?? null,
|
||||
planSlug: status.plan_slug ?? null,
|
||||
renewalDate: status.renewal_date ?? null,
|
||||
endDate: status.cancel_at ?? null,
|
||||
isCancelled: status.subscription_status === 'canceled',
|
||||
hasFunds: status.has_funds
|
||||
}
|
||||
})
|
||||
|
||||
const balance = computed<BalanceInfo | null>(() => {
|
||||
const data = balanceData.value
|
||||
if (!data) return null
|
||||
|
||||
return {
|
||||
amountMicros: data.amount_micros,
|
||||
currency: data.currency,
|
||||
effectiveBalanceMicros: data.effective_balance_micros,
|
||||
prepaidBalanceMicros: data.prepaid_balance_micros,
|
||||
cloudCreditBalanceMicros: data.cloud_credit_balance_micros
|
||||
}
|
||||
})
|
||||
|
||||
const plans = computed(() => billingPlans.plans.value)
|
||||
const currentPlanSlug = computed(
|
||||
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
|
||||
)
|
||||
|
||||
const pendingCancelOpId = ref<string | null>(null)
|
||||
let cancelPollTimeout: number | null = null
|
||||
|
||||
const stopCancelPolling = () => {
|
||||
if (cancelPollTimeout !== null) {
|
||||
window.clearTimeout(cancelPollTimeout)
|
||||
cancelPollTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
async function pollCancelStatus(opId: string): Promise<void> {
|
||||
stopCancelPolling()
|
||||
|
||||
const maxAttempts = 30
|
||||
let attempt = 0
|
||||
const poll = async () => {
|
||||
if (pendingCancelOpId.value !== opId) return
|
||||
|
||||
try {
|
||||
const response = await workspaceApi.getBillingOpStatus(opId)
|
||||
if (response.status === 'succeeded') {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
await fetchStatus()
|
||||
workspaceStore.updateActiveWorkspace({
|
||||
isSubscribed: false
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response.status === 'failed') {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
throw new Error(
|
||||
response.error_message ?? 'Failed to cancel subscription'
|
||||
)
|
||||
}
|
||||
|
||||
attempt += 1
|
||||
if (attempt >= maxAttempts) {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
await fetchStatus()
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
throw err
|
||||
}
|
||||
|
||||
cancelPollTimeout = window.setTimeout(
|
||||
() => {
|
||||
void poll()
|
||||
},
|
||||
Math.min(1000 * 2 ** attempt, 5000)
|
||||
)
|
||||
}
|
||||
|
||||
await poll()
|
||||
}
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await Promise.all([fetchStatus(), fetchBalance(), fetchPlans()])
|
||||
isInitialized.value = true
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to initialize billing'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatus(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
statusData.value = await workspaceApi.getBillingStatus()
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to fetch billing status'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBalance(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
balanceData.value = await workspaceApi.getBillingBalance()
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to fetch balance'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe(
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
): Promise<SubscribeResponse> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await workspaceApi.subscribe(
|
||||
planSlug,
|
||||
returnUrl,
|
||||
cancelUrl
|
||||
)
|
||||
|
||||
// Refresh status and balance after subscription
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
|
||||
return response
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to subscribe'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function previewSubscribe(
|
||||
planSlug: string
|
||||
): Promise<PreviewSubscribeResponse | null> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
return await workspaceApi.previewSubscribe(planSlug)
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to preview subscription'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function manageSubscription(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const returnUrl = window.location.href
|
||||
const response = await workspaceApi.getPaymentPortalUrl(returnUrl)
|
||||
if (response.url) {
|
||||
window.open(response.url, '_blank')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to open billing portal'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelSubscription(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await workspaceApi.cancelSubscription()
|
||||
pendingCancelOpId.value = response.billing_op_id
|
||||
await pollCancelStatus(response.billing_op_id)
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to cancel subscription'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlans(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await billingPlans.fetchPlans()
|
||||
if (billingPlans.error.value) {
|
||||
error.value = billingPlans.error.value
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
async function requireActiveSubscription(): Promise<void> {
|
||||
await fetchStatus()
|
||||
if (!isActiveSubscription.value) {
|
||||
subscriptionDialog.show()
|
||||
}
|
||||
}
|
||||
|
||||
function showSubscriptionDialog(): void {
|
||||
subscriptionDialog.show()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopCancelPolling()
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
isInitialized,
|
||||
subscription,
|
||||
balance,
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
subscribe,
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,27 @@ describe('contextMenuConverter', () => {
|
||||
// Node Info (section 4) should come before or with Color (section 4)
|
||||
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
|
||||
})
|
||||
|
||||
it('should recognize Frame Nodes as a core menu item', () => {
|
||||
const options: MenuOption[] = [
|
||||
{ label: 'Rename', source: 'vue' },
|
||||
{ label: 'Frame Nodes', source: 'vue' },
|
||||
{ label: 'Custom Extension', source: 'vue' }
|
||||
]
|
||||
|
||||
const result = buildStructuredMenu(options)
|
||||
|
||||
// Frame Nodes should appear in the core items section (before Extensions)
|
||||
const frameNodesIndex = result.findIndex(
|
||||
(opt) => opt.label === 'Frame Nodes'
|
||||
)
|
||||
const extensionsCategoryIndex = result.findIndex(
|
||||
(opt) => opt.label === 'Extensions' && opt.type === 'category'
|
||||
)
|
||||
|
||||
// Frame Nodes should come before Extensions category
|
||||
expect(frameNodesIndex).toBeLessThan(extensionsCategoryIndex)
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertContextMenuToOptions', () => {
|
||||
|
||||
@@ -44,6 +44,7 @@ const CORE_MENU_ITEMS = new Set([
|
||||
// Structure operations
|
||||
'Convert to Subgraph',
|
||||
'Frame selection',
|
||||
'Frame Nodes',
|
||||
'Minimize Node',
|
||||
'Expand',
|
||||
'Collapse',
|
||||
@@ -103,7 +104,8 @@ function isDuplicateItem(label: string, existingItems: MenuOption[]): boolean {
|
||||
shape: ['shape', 'shapes'],
|
||||
pin: ['pin', 'unpin'],
|
||||
delete: ['remove', 'delete'],
|
||||
duplicate: ['clone', 'duplicate']
|
||||
duplicate: ['clone', 'duplicate'],
|
||||
frame: ['frame selection', 'frame nodes']
|
||||
}
|
||||
|
||||
return existingItems.some((item) => {
|
||||
@@ -226,6 +228,7 @@ const MENU_ORDER: string[] = [
|
||||
// Section 3: Structure operations
|
||||
'Convert to Subgraph',
|
||||
'Frame selection',
|
||||
'Frame Nodes',
|
||||
'Minimize Node',
|
||||
'Expand',
|
||||
'Collapse',
|
||||
|
||||
@@ -2,10 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSelectionMenuOptions } from '@/composables/graph/useSelectionMenuOptions'
|
||||
|
||||
const subgraphMocks = vi.hoisted(() => ({
|
||||
const mocks = vi.hoisted(() => ({
|
||||
convertToSubgraph: vi.fn(),
|
||||
unpackSubgraph: vi.fn(),
|
||||
addSubgraphToLibrary: vi.fn(),
|
||||
frameNodes: vi.fn(),
|
||||
createI18nMock: vi.fn(() => ({
|
||||
global: {
|
||||
t: vi.fn(),
|
||||
@@ -19,7 +20,7 @@ vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
}),
|
||||
createI18n: subgraphMocks.createI18nMock
|
||||
createI18n: mocks.createI18nMock
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionOperations', () => ({
|
||||
@@ -42,18 +43,46 @@ vi.mock('@/composables/graph/useNodeArrangement', () => ({
|
||||
|
||||
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
|
||||
useSubgraphOperations: () => ({
|
||||
convertToSubgraph: subgraphMocks.convertToSubgraph,
|
||||
unpackSubgraph: subgraphMocks.unpackSubgraph,
|
||||
addSubgraphToLibrary: subgraphMocks.addSubgraphToLibrary
|
||||
convertToSubgraph: mocks.convertToSubgraph,
|
||||
unpackSubgraph: mocks.unpackSubgraph,
|
||||
addSubgraphToLibrary: mocks.addSubgraphToLibrary
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useFrameNodes', () => ({
|
||||
useFrameNodes: () => ({
|
||||
frameNodes: vi.fn()
|
||||
frameNodes: mocks.frameNodes
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useSelectionMenuOptions - multiple nodes options', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns Frame Nodes option that invokes frameNodes when called', () => {
|
||||
const { getMultipleNodesOptions } = useSelectionMenuOptions()
|
||||
const options = getMultipleNodesOptions()
|
||||
|
||||
const frameOption = options.find((opt) => opt.label === 'g.frameNodes')
|
||||
expect(frameOption).toBeDefined()
|
||||
expect(frameOption?.action).toBeDefined()
|
||||
|
||||
frameOption?.action?.()
|
||||
expect(mocks.frameNodes).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns Convert to Group Node option from getMultipleNodesOptions', () => {
|
||||
const { getMultipleNodesOptions } = useSelectionMenuOptions()
|
||||
const options = getMultipleNodesOptions()
|
||||
|
||||
const groupNodeOption = options.find(
|
||||
(opt) => opt.label === 'contextMenu.Convert to Group Node'
|
||||
)
|
||||
expect(groupNodeOption).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSelectionMenuOptions - subgraph options', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -68,7 +97,7 @@ describe('useSelectionMenuOptions - subgraph options', () => {
|
||||
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0]?.label).toBe('contextMenu.Convert to Subgraph')
|
||||
expect(options[0]?.action).toBe(subgraphMocks.convertToSubgraph)
|
||||
expect(options[0]?.action).toBe(mocks.convertToSubgraph)
|
||||
})
|
||||
|
||||
it('includes convert, add to library, and unpack when subgraphs are selected', () => {
|
||||
@@ -86,7 +115,7 @@ describe('useSelectionMenuOptions - subgraph options', () => {
|
||||
const convertOption = options.find(
|
||||
(option) => option.label === 'contextMenu.Convert to Subgraph'
|
||||
)
|
||||
expect(convertOption?.action).toBe(subgraphMocks.convertToSubgraph)
|
||||
expect(convertOption?.action).toBe(mocks.convertToSubgraph)
|
||||
})
|
||||
|
||||
it('hides convert option when only a single subgraph is selected', () => {
|
||||
|
||||
@@ -87,6 +87,25 @@ describe('useSelectionState', () => {
|
||||
const { hasAnySelection } = useSelectionState()
|
||||
expect(hasAnySelection.value).toBe(true)
|
||||
})
|
||||
|
||||
test('hasMultipleSelection should be true when 2+ items selected', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const node1 = createMockLGraphNode({ id: 1 })
|
||||
const node2 = createMockLGraphNode({ id: 2 })
|
||||
canvasStore.$state.selectedItems = [node1, node2]
|
||||
|
||||
const { hasMultipleSelection } = useSelectionState()
|
||||
expect(hasMultipleSelection.value).toBe(true)
|
||||
})
|
||||
|
||||
test('hasMultipleSelection should be false when only 1 item selected', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const node1 = createMockLGraphNode({ id: 1 })
|
||||
canvasStore.$state.selectedItems = [node1]
|
||||
|
||||
const { hasMultipleSelection } = useSelectionState()
|
||||
expect(hasMultipleSelection.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Type Filtering', () => {
|
||||
|
||||
@@ -107,6 +107,13 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
isActiveSubscription: { value: true },
|
||||
showSubscriptionDialog: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('useCoreCommands', () => {
|
||||
const createMockNode = (id: number, comfyClass: string): LGraphNode => {
|
||||
const baseNode = createMockLGraphNode({ id })
|
||||
|
||||
@@ -17,9 +17,9 @@ import {
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildSupportUrl } from '@/platform/support/config'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -69,7 +69,7 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
export function useCoreCommands(): ComfyCommand[] {
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
@@ -15,14 +15,12 @@ export enum ServerFeatureFlag {
|
||||
MAX_UPLOAD_SIZE = 'max_upload_size',
|
||||
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
|
||||
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
|
||||
ASSET_DELETION_ENABLED = 'asset_deletion_enabled',
|
||||
ASSET_RENAME_ENABLED = 'asset_rename_enabled',
|
||||
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
|
||||
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
||||
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
|
||||
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
|
||||
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled',
|
||||
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled'
|
||||
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
|
||||
USER_SECRETS_ENABLED = 'user_secrets_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,7 +38,6 @@ export function useFeatureFlags() {
|
||||
return api.getServerFeature(ServerFeatureFlag.MANAGER_SUPPORTS_V4)
|
||||
},
|
||||
get modelUploadButtonEnabled() {
|
||||
// Check remote config first (from /api/features), fall back to websocket feature flags
|
||||
return (
|
||||
remoteConfig.value.model_upload_button_enabled ??
|
||||
api.getServerFeature(
|
||||
@@ -49,12 +46,6 @@ export function useFeatureFlags() {
|
||||
)
|
||||
)
|
||||
},
|
||||
get assetDeletionEnabled() {
|
||||
return (
|
||||
remoteConfig.value.asset_deletion_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ASSET_DELETION_ENABLED, false)
|
||||
)
|
||||
},
|
||||
get assetRenameEnabled() {
|
||||
return (
|
||||
remoteConfig.value.asset_rename_enabled ??
|
||||
@@ -62,7 +53,6 @@ export function useFeatureFlags() {
|
||||
)
|
||||
},
|
||||
get privateModelsEnabled() {
|
||||
// Check remote config first (from /api/features), fall back to websocket feature flags
|
||||
return (
|
||||
remoteConfig.value.private_models_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.PRIVATE_MODELS_ENABLED, false)
|
||||
@@ -71,16 +61,7 @@ export function useFeatureFlags() {
|
||||
get onboardingSurveyEnabled() {
|
||||
return (
|
||||
remoteConfig.value.onboarding_survey_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, true)
|
||||
)
|
||||
},
|
||||
get huggingfaceModelImportEnabled() {
|
||||
return (
|
||||
remoteConfig.value.huggingface_model_import_enabled ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.HUGGINGFACE_MODEL_IMPORT_ENABLED,
|
||||
false
|
||||
)
|
||||
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, false)
|
||||
)
|
||||
},
|
||||
get linearToggleEnabled() {
|
||||
@@ -89,15 +70,6 @@ export function useFeatureFlags() {
|
||||
api.getServerFeature(ServerFeatureFlag.LINEAR_TOGGLE_ENABLED, false)
|
||||
)
|
||||
},
|
||||
get asyncModelUploadEnabled() {
|
||||
return (
|
||||
remoteConfig.value.async_model_upload_enabled ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.ASYNC_MODEL_UPLOAD_ENABLED,
|
||||
false
|
||||
)
|
||||
)
|
||||
},
|
||||
/**
|
||||
* Whether team workspaces feature is enabled.
|
||||
* IMPORTANT: Returns false until authenticated remote config is loaded.
|
||||
@@ -116,6 +88,12 @@ export function useFeatureFlags() {
|
||||
remoteConfig.value.team_workspaces_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
|
||||
)
|
||||
},
|
||||
get userSecretsEnabled() {
|
||||
return (
|
||||
remoteConfig.value.user_secrets_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.USER_SECRETS_ENABLED, false)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { refThrottled, watchDebounced } from '@vueuse/core'
|
||||
import { refDebounced, watchDebounced } from '@vueuse/core'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
@@ -119,7 +119,7 @@ export function useTemplateFiltering(
|
||||
)
|
||||
})
|
||||
|
||||
const debouncedSearchQuery = refThrottled(searchQuery, 50)
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 150)
|
||||
|
||||
const filteredBySearch = computed(() => {
|
||||
if (!debouncedSearchQuery.value.trim()) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { remove } from 'es-toolkit'
|
||||
import { shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type {
|
||||
@@ -345,7 +344,6 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
|
||||
requestAnimationFrame(() => {
|
||||
const input = node.inputs[index]
|
||||
if (!input) return
|
||||
node.inputs[index] = shallowReactive(input)
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
index,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
@@ -14,17 +14,18 @@ useExtensionService().registerExtension({
|
||||
|
||||
setup: async () => {
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
|
||||
// Refresh config when subscription status changes
|
||||
// Initial auth-aware refresh happens in WorkspaceAuthGate before app renders
|
||||
// Refresh config when auth or subscription status changes
|
||||
// Primary auth refresh is handled by WorkspaceAuthGate on mount
|
||||
// This watcher handles subscription changes and acts as a backup for auth
|
||||
watchDebounced(
|
||||
[isLoggedIn, isActiveSubscription],
|
||||
() => {
|
||||
if (!isLoggedIn.value) return
|
||||
void refreshRemoteConfig()
|
||||
},
|
||||
{ debounce: 256 }
|
||||
{ debounce: 256, immediate: true }
|
||||
)
|
||||
|
||||
// Poll for config updates every 10 minutes (with auth)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
/**
|
||||
@@ -12,7 +12,7 @@ useExtensionService().registerExtension({
|
||||
|
||||
setup: async () => {
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const { requireActiveSubscription } = useSubscription()
|
||||
const { requireActiveSubscription } = useBillingContext()
|
||||
|
||||
const checkSubscriptionStatus = () => {
|
||||
if (!isLoggedIn.value) return
|
||||
|
||||
@@ -13,7 +13,9 @@ import './imageCompare'
|
||||
import './imageCrop'
|
||||
import './load3d'
|
||||
import './maskeditor'
|
||||
import './nodeTemplates'
|
||||
if (!isCloud) {
|
||||
await import('./nodeTemplates')
|
||||
}
|
||||
import './noteNode'
|
||||
import './previewAny'
|
||||
import './rerouteNode'
|
||||
|
||||
@@ -55,6 +55,7 @@ class Load3d {
|
||||
private rightMouseMoved: boolean = false
|
||||
private readonly dragThreshold: number = 5
|
||||
private contextMenuAbortController: AbortController | null = null
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
|
||||
constructor(container: Element | HTMLElement, options: Load3DOptions = {}) {
|
||||
this.clock = new THREE.Clock()
|
||||
@@ -145,6 +146,7 @@ class Load3d {
|
||||
this.STATUS_MOUSE_ON_VIEWER = false
|
||||
|
||||
this.initContextMenu()
|
||||
this.initResizeObserver(container)
|
||||
|
||||
this.handleResize()
|
||||
this.startAnimation()
|
||||
@@ -154,6 +156,14 @@ class Load3d {
|
||||
}, 100)
|
||||
}
|
||||
|
||||
private initResizeObserver(container: Element | HTMLElement): void {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.handleResize()
|
||||
this.forceRender()
|
||||
})
|
||||
this.resizeObserver.observe(container)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize context menu on the Three.js canvas
|
||||
* Detects right-click vs right-drag to show menu only on click
|
||||
@@ -809,6 +819,11 @@ class Load3d {
|
||||
}
|
||||
|
||||
public remove(): void {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect()
|
||||
this.resizeObserver = null
|
||||
}
|
||||
|
||||
if (this.contextMenuAbortController) {
|
||||
this.contextMenuAbortController.abort()
|
||||
this.contextMenuAbortController = null
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
import type {
|
||||
ContextMenuDivElement,
|
||||
IContextMenuOptions,
|
||||
@@ -5,6 +7,38 @@ import type {
|
||||
} from './interfaces'
|
||||
import { LiteGraph } from './litegraph'
|
||||
|
||||
const ALLOWED_TAGS = ['span', 'b', 'i', 'em', 'strong']
|
||||
const ALLOWED_STYLE_PROPS = new Set([
|
||||
'display',
|
||||
'color',
|
||||
'background-color',
|
||||
'padding-left',
|
||||
'border-left'
|
||||
])
|
||||
|
||||
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
|
||||
if (data.attrName === 'style') {
|
||||
const sanitizedStyle = data.attrValue
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => {
|
||||
const colonIdx = s.indexOf(':')
|
||||
if (colonIdx === -1) return false
|
||||
const prop = s.slice(0, colonIdx).trim().toLowerCase()
|
||||
return ALLOWED_STYLE_PROPS.has(prop)
|
||||
})
|
||||
.join('; ')
|
||||
data.attrValue = sanitizedStyle
|
||||
}
|
||||
})
|
||||
|
||||
function sanitizeMenuHTML(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR: ['style']
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Replace this pattern with something more modern.
|
||||
export interface ContextMenu<TValue = unknown> {
|
||||
constructor: new (
|
||||
@@ -123,7 +157,7 @@ export class ContextMenu<TValue = unknown> {
|
||||
if (options.title) {
|
||||
const element = document.createElement('div')
|
||||
element.className = 'litemenu-title'
|
||||
element.innerHTML = options.title
|
||||
element.textContent = options.title
|
||||
root.append(element)
|
||||
}
|
||||
|
||||
@@ -218,11 +252,18 @@ export class ContextMenu<TValue = unknown> {
|
||||
if (value === null) {
|
||||
element.classList.add('separator')
|
||||
} else {
|
||||
const innerHtml = name === null ? '' : String(name)
|
||||
const label = name === null ? '' : String(name)
|
||||
if (typeof value === 'string') {
|
||||
element.innerHTML = innerHtml
|
||||
element.textContent = label
|
||||
} else {
|
||||
element.innerHTML = value?.title ?? innerHtml
|
||||
// Use innerHTML for content that contains HTML tags, textContent otherwise
|
||||
const hasHtmlContent =
|
||||
value?.content !== undefined && /<[a-z][\s\S]*>/i.test(value.content)
|
||||
if (hasHtmlContent) {
|
||||
element.innerHTML = sanitizeMenuHTML(value.content!)
|
||||
} else {
|
||||
element.textContent = value?.title ?? label
|
||||
}
|
||||
|
||||
if (value.disabled) {
|
||||
disabled = true
|
||||
|
||||
210
src/lib/litegraph/src/LGraphCanvas.slotHitDetection.test.ts
Normal file
210
src/lib/litegraph/src/LGraphCanvas.slotHitDetection.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
querySlotAtPoint: vi.fn(),
|
||||
queryRerouteAtPoint: vi.fn(),
|
||||
getNodeLayoutRef: vi.fn(() => ({ value: null })),
|
||||
getSlotLayout: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('LGraphCanvas slot hit detection', () => {
|
||||
let graph: LGraph
|
||||
let canvas: LGraphCanvas
|
||||
let node: LGraphNode
|
||||
let canvasElement: HTMLCanvasElement
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
canvasElement = document.createElement('canvas')
|
||||
canvasElement.width = 800
|
||||
canvasElement.height = 600
|
||||
|
||||
const ctx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn().mockReturnValue({ width: 50 }),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
getTransform: vi
|
||||
.fn()
|
||||
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
globalAlpha: 1,
|
||||
textAlign: 'left' as CanvasTextAlign,
|
||||
textBaseline: 'alphabetic' as CanvasTextBaseline
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
|
||||
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
graph = new LGraph()
|
||||
canvas = new LGraphCanvas(canvasElement, graph, {
|
||||
skip_render: true
|
||||
})
|
||||
|
||||
// Create a test node with an output slot
|
||||
node = new LGraphNode('Test Node')
|
||||
node.pos = [100, 100]
|
||||
node.size = [150, 80]
|
||||
node.addOutput('output', 'number')
|
||||
graph.add(node)
|
||||
|
||||
// Enable Vue nodes mode for the test
|
||||
LiteGraph.vueNodesMode = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LiteGraph.vueNodesMode = false
|
||||
})
|
||||
|
||||
describe('processMouseDown slot fallback in Vue nodes mode', () => {
|
||||
it('should query layoutStore.querySlotAtPoint when clicking outside node bounds', () => {
|
||||
// Click position outside node bounds (node is at 100,100 with size 150x80)
|
||||
// So node covers x: 100-250, y: 100-180
|
||||
// Click at x=255 is outside the right edge
|
||||
const clickX = 255
|
||||
const clickY = 120
|
||||
|
||||
// Verify the click is outside the node bounds
|
||||
expect(node.isPointInside(clickX, clickY)).toBe(false)
|
||||
expect(graph.getNodeOnPos(clickX, clickY)).toBeNull()
|
||||
|
||||
// Mock the slot query to return our node's slot
|
||||
vi.mocked(layoutStore.querySlotAtPoint).mockReturnValue({
|
||||
nodeId: String(node.id),
|
||||
index: 0,
|
||||
type: 'output',
|
||||
position: { x: 252, y: 120 },
|
||||
bounds: { x: 246, y: 110, width: 20, height: 20 }
|
||||
})
|
||||
|
||||
// Call processMouseDown - this should trigger the slot fallback
|
||||
canvas.processMouseDown(
|
||||
new MouseEvent('pointerdown', {
|
||||
button: 1, // Middle button
|
||||
clientX: clickX,
|
||||
clientY: clickY
|
||||
})
|
||||
)
|
||||
|
||||
// The fix should query the layout store when no node is found at click position
|
||||
expect(layoutStore.querySlotAtPoint).toHaveBeenCalledWith({
|
||||
x: clickX,
|
||||
y: clickY
|
||||
})
|
||||
})
|
||||
|
||||
it('should NOT query layoutStore when node is found directly at click position', () => {
|
||||
// Initialize node's bounding rect
|
||||
node.updateArea()
|
||||
|
||||
// Populate visible_nodes (normally done during render)
|
||||
canvas.visible_nodes = [node]
|
||||
|
||||
// Click inside the node bounds
|
||||
const clickX = 150
|
||||
const clickY = 140
|
||||
|
||||
// Verify the click is inside the node bounds
|
||||
expect(node.isPointInside(clickX, clickY)).toBe(true)
|
||||
expect(graph.getNodeOnPos(clickX, clickY)).toBe(node)
|
||||
|
||||
// Call processMouseDown
|
||||
canvas.processMouseDown(
|
||||
new MouseEvent('pointerdown', {
|
||||
button: 1,
|
||||
clientX: clickX,
|
||||
clientY: clickY
|
||||
})
|
||||
)
|
||||
|
||||
// Should NOT query the layout store since node was found directly
|
||||
expect(layoutStore.querySlotAtPoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT query layoutStore when not in Vue nodes mode', () => {
|
||||
LiteGraph.vueNodesMode = false
|
||||
|
||||
const clickX = 255
|
||||
const clickY = 120
|
||||
|
||||
// Call processMouseDown
|
||||
canvas.processMouseDown(
|
||||
new MouseEvent('pointerdown', {
|
||||
button: 1,
|
||||
clientX: clickX,
|
||||
clientY: clickY
|
||||
})
|
||||
)
|
||||
|
||||
// Should NOT query the layout store in non-Vue mode
|
||||
expect(layoutStore.querySlotAtPoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should find node via slot query for input slots extending beyond left edge', () => {
|
||||
node.addInput('input', 'number')
|
||||
|
||||
// Click position left of node (node starts at x=100)
|
||||
const clickX = 95
|
||||
const clickY = 140
|
||||
|
||||
// Verify outside bounds
|
||||
expect(node.isPointInside(clickX, clickY)).toBe(false)
|
||||
|
||||
vi.mocked(layoutStore.querySlotAtPoint).mockReturnValue({
|
||||
nodeId: String(node.id),
|
||||
index: 0,
|
||||
type: 'input',
|
||||
position: { x: 98, y: 140 },
|
||||
bounds: { x: 88, y: 130, width: 20, height: 20 }
|
||||
})
|
||||
|
||||
canvas.processMouseDown(
|
||||
new MouseEvent('pointerdown', {
|
||||
button: 1,
|
||||
clientX: clickX,
|
||||
clientY: clickY
|
||||
})
|
||||
)
|
||||
|
||||
expect(layoutStore.querySlotAtPoint).toHaveBeenCalledWith({
|
||||
x: clickX,
|
||||
y: clickY
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2187,9 +2187,21 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
if (!is_inside) return
|
||||
|
||||
const node =
|
||||
let node =
|
||||
graph.getNodeOnPos(e.canvasX, e.canvasY, this.visible_nodes) ?? undefined
|
||||
|
||||
// In Vue nodes mode, slots extend beyond node bounds due to CSS transforms.
|
||||
// If no node was found, check if the click is on a slot and use its owning node.
|
||||
if (!node && LiteGraph.vueNodesMode) {
|
||||
const slotLayout = layoutStore.querySlotAtPoint({
|
||||
x: e.canvasX,
|
||||
y: e.canvasY
|
||||
})
|
||||
if (slotLayout) {
|
||||
node = graph.getNodeById(slotLayout.nodeId) ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
this.mouse[0] = x
|
||||
this.mouse[1] = y
|
||||
this.graph_mouse[0] = e.canvasX
|
||||
|
||||
@@ -73,6 +73,28 @@ describe('LinkConnector SubgraphInput connection validation', () => {
|
||||
expect(toTargetNode.onConnectionsChange).toHaveBeenCalledTimes(1)
|
||||
expect(fromTargetNode.onConnectionsChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('should allow reconnection to same target', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
const node = new LGraphNode('TargetNode')
|
||||
node.addInput('number_in', 'number')
|
||||
subgraph.add(node)
|
||||
|
||||
const link = subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const renderLink = new ToInputFromIoNodeLink(
|
||||
subgraph,
|
||||
subgraph.inputNode,
|
||||
subgraph.inputNode.slots[0],
|
||||
undefined,
|
||||
LinkDirection.CENTER,
|
||||
link
|
||||
)
|
||||
renderLink.connectToInput(node, node.inputs[0], connector.events)
|
||||
expect(node.inputs[0].link).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MovingOutputLink validation', () => {
|
||||
|
||||
@@ -58,6 +58,12 @@ export class ToInputFromIoNodeLink implements RenderLink {
|
||||
events: CustomEventTarget<LinkConnectorEventMap>
|
||||
) {
|
||||
const { fromSlot, fromReroute, existingLink } = this
|
||||
if (
|
||||
existingLink &&
|
||||
node.id === existingLink.target_id &&
|
||||
node.inputs[existingLink.target_slot] === input
|
||||
)
|
||||
return
|
||||
|
||||
const newLink = fromSlot.connect(input, node, fromReroute?.id)
|
||||
|
||||
|
||||
@@ -195,6 +195,42 @@ describe('contextMenuCompat', () => {
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle multiple items with undefined content correctly', () => {
|
||||
// Setup base method with items that have undefined content
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
return [
|
||||
{ content: undefined, title: 'Separator 1' },
|
||||
{ content: undefined, title: 'Separator 2' },
|
||||
{ content: 'Item 1', callback: () => {} }
|
||||
]
|
||||
}
|
||||
|
||||
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
|
||||
|
||||
// Monkey-patch to add an item with undefined content
|
||||
const original = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions =
|
||||
function (): (IContextMenuValue | null)[] {
|
||||
const items = original.apply(this)
|
||||
items.push({ content: undefined, title: 'Separator 3' })
|
||||
return items
|
||||
}
|
||||
|
||||
// Extract legacy items
|
||||
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
||||
'getCanvasMenuOptions',
|
||||
mockCanvas
|
||||
)
|
||||
|
||||
// Should extract only the newly added item with undefined content
|
||||
// (not collapse with existing undefined content items)
|
||||
expect(legacyItems).toHaveLength(1)
|
||||
expect(legacyItems[0]).toMatchObject({
|
||||
content: undefined,
|
||||
title: 'Separator 3'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration', () => {
|
||||
|
||||
@@ -152,19 +152,51 @@ class LegacyMenuCompat {
|
||||
const patchedItems = methodToCall.apply(context, args) as
|
||||
| (IContextMenuValue | null)[]
|
||||
| undefined
|
||||
if (!patchedItems) return []
|
||||
if (!patchedItems) {
|
||||
return []
|
||||
}
|
||||
// Use content-based diff to detect additions (not reference-based)
|
||||
// Create composite keys from multiple properties to handle undefined content
|
||||
const createItemKey = (item: IContextMenuValue): string => {
|
||||
const parts = [
|
||||
item.content ?? '',
|
||||
item.title ?? '',
|
||||
item.className ?? '',
|
||||
item.property ?? '',
|
||||
item.type ?? ''
|
||||
]
|
||||
return parts.join('|')
|
||||
}
|
||||
|
||||
// Use set-based diff to detect additions by reference
|
||||
const originalSet = new Set<IContextMenuValue | null>(originalItems)
|
||||
const addedItems = patchedItems.filter((item) => !originalSet.has(item))
|
||||
const originalKeys = new Set(
|
||||
originalItems
|
||||
.filter(
|
||||
(item): item is IContextMenuValue =>
|
||||
item !== null && typeof item === 'object' && 'content' in item
|
||||
)
|
||||
.map(createItemKey)
|
||||
)
|
||||
const addedItems = patchedItems.filter((item) => {
|
||||
if (item === null) return false
|
||||
if (typeof item !== 'object' || !('content' in item)) return false
|
||||
return !originalKeys.has(createItemKey(item))
|
||||
})
|
||||
|
||||
// Warn if items were removed (patched has fewer original items than expected)
|
||||
const retainedOriginalCount = patchedItems.filter((item) =>
|
||||
originalSet.has(item)
|
||||
const patchedKeys = new Set(
|
||||
patchedItems
|
||||
.filter(
|
||||
(item): item is IContextMenuValue =>
|
||||
item !== null && typeof item === 'object' && 'content' in item
|
||||
)
|
||||
.map(createItemKey)
|
||||
)
|
||||
const removedCount = [...originalKeys].filter(
|
||||
(key) => !patchedKeys.has(key)
|
||||
).length
|
||||
if (retainedOriginalCount < originalItems.length) {
|
||||
if (removedCount > 0) {
|
||||
console.warn(
|
||||
`[Context Menu Compat] Monkey patch for ${methodName} removed ${originalItems.length - retainedOriginalCount} original menu item(s). ` +
|
||||
`[Context Menu Compat] Monkey patch for ${methodName} removed ${removedCount} original menu item(s). ` +
|
||||
`This may cause unexpected behavior.`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -993,7 +993,8 @@
|
||||
"showAll": "Show all",
|
||||
"hidden": "Hidden / nested parameters",
|
||||
"hideAll": "Hide all",
|
||||
"showRecommended": "Show recommended widgets"
|
||||
"showRecommended": "Show recommended widgets",
|
||||
"cannotDeleteGlobal": "Cannot delete installed blueprints"
|
||||
},
|
||||
"electronFileDownload": {
|
||||
"inProgress": "In Progress",
|
||||
@@ -1303,7 +1304,8 @@
|
||||
"Execution": "Execution",
|
||||
"PLY": "PLY",
|
||||
"Workspace": "Workspace",
|
||||
"Other": "Other"
|
||||
"Other": "Other",
|
||||
"Secrets": "Secrets"
|
||||
},
|
||||
"serverConfigItems": {
|
||||
"listen": {
|
||||
@@ -1922,13 +1924,7 @@
|
||||
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again."
|
||||
},
|
||||
"deleteAccount": {
|
||||
"deleteAccount": "Delete Account",
|
||||
"confirmTitle": "Delete Account",
|
||||
"confirmMessage": "Are you sure you want to delete your account? This action cannot be undone and will permanently remove all your data.",
|
||||
"confirm": "Delete Account",
|
||||
"cancel": "Cancel",
|
||||
"success": "Account Deleted",
|
||||
"successDetail": "Your account has been successfully deleted."
|
||||
"contactSupport": "To delete your account, please contact {email}"
|
||||
},
|
||||
"reauthRequired": {
|
||||
"title": "Re-authentication Required",
|
||||
@@ -1987,6 +1983,7 @@
|
||||
"videosEstimate": "~{count} videos*",
|
||||
"templateNote": "*Generated with Wan Fun Control template",
|
||||
"buy": "Buy",
|
||||
"purchaseSuccess": "Credits added successfully!",
|
||||
"purchaseError": "Purchase Failed",
|
||||
"purchaseErrorDetail": "Failed to purchase credits: {error}",
|
||||
"unknownError": "An unknown error occurred",
|
||||
@@ -2019,19 +2016,49 @@
|
||||
"tooltip": "We've unified payments across Comfy. Everything now runs on Comfy Credits:\n- Partner Nodes (formerly API nodes)\n- Cloud workflows\n\nYour existing Partner node balance has been converted into credits."
|
||||
}
|
||||
},
|
||||
"billingOperation": {
|
||||
"subscriptionProcessing": "Processing payment — setting up your workspace...",
|
||||
"subscriptionSuccess": "Subscription updated successfully",
|
||||
"subscriptionFailed": "Subscription update failed",
|
||||
"subscriptionTimeout": "Subscription verification timed out",
|
||||
"topupProcessing": "Processing payment — adding credits...",
|
||||
"topupSuccess": "Credits added successfully",
|
||||
"topupFailed": "Top-up failed",
|
||||
"topupTimeout": "Top-up verification timed out"
|
||||
},
|
||||
"subscription": {
|
||||
"chooseBestPlanWorkspace": "Choose the best plan for your workspace",
|
||||
"title": "Subscription",
|
||||
"titleUnsubscribed": "Subscribe to Comfy Cloud",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "Comfy Cloud Logo",
|
||||
"beta": "BETA",
|
||||
"perMonth": "/ month",
|
||||
"member": "member",
|
||||
"usdPerMonth": "USD / mo",
|
||||
"usdPerMonthPerMember": "USD / mo / member",
|
||||
"renewsDate": "Renews {date}",
|
||||
"expiresDate": "Expires {date}",
|
||||
"manageSubscription": "Manage subscription",
|
||||
"managePayment": "Manage Payment",
|
||||
"cancelSubscription": "Cancel Subscription",
|
||||
"canceled": "Canceled",
|
||||
"resubscribe": "Resubscribe",
|
||||
"resubscribeTo": "Resubscribe to {plan}",
|
||||
"resubscribeSuccess": "Subscription reactivated successfully",
|
||||
"canceledCard": {
|
||||
"title": "Your subscription has been canceled",
|
||||
"description": "You won't be charged again. Your features remain active until {date}."
|
||||
},
|
||||
"cancelSuccess": "Subscription cancelled successfully",
|
||||
"cancelDialog": {
|
||||
"title": "Cancel subscription",
|
||||
"description": "Your access continues until {date}. You won't be charged again, and your workspace and credits will be preserved. You can resubscribe anytime.",
|
||||
"endOfBillingPeriod": "end of billing period",
|
||||
"keepSubscription": "Keep subscription",
|
||||
"confirmCancel": "Cancel subscription",
|
||||
"failed": "Failed to cancel subscription"
|
||||
},
|
||||
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
|
||||
"partnerNodesDescription": "For running commercial/proprietary models",
|
||||
"totalCredits": "Total credits",
|
||||
@@ -2080,16 +2107,20 @@
|
||||
"required": {
|
||||
"title": "Subscribe to",
|
||||
"waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!",
|
||||
"subscribe": "Subscribe"
|
||||
"subscribe": "Subscribe",
|
||||
"pollingSuccess": "Subscription activated successfully!",
|
||||
"pollingFailed": "Subscription activation failed",
|
||||
"pollingTimeout": "Timed out waiting for subscription. Please refresh and try again."
|
||||
},
|
||||
"subscribeToRun": "Subscribe",
|
||||
"subscribeToRunFull": "Subscribe to Run",
|
||||
"subscribeNow": "Subscribe Now",
|
||||
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
|
||||
"workspaceNotSubscribed": "This workspace is not on a subscription",
|
||||
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud",
|
||||
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud and invite members",
|
||||
"contactOwnerToSubscribe": "Contact the workspace owner to subscribe",
|
||||
"description": "Choose the best plan for you",
|
||||
"descriptionWorkspace": "Choose the best plan for your workspace",
|
||||
"haveQuestions": "Have questions or wondering about enterprise?",
|
||||
"contactUs": "Contact us",
|
||||
"viewEnterprise": "View enterprise",
|
||||
@@ -2101,7 +2132,13 @@
|
||||
"currentPlan": "Current Plan",
|
||||
"subscribeTo": "Subscribe to {plan}",
|
||||
"monthlyCreditsLabel": "Monthly credits",
|
||||
"monthlyCreditsPerMemberLabel": "Monthly credits / member",
|
||||
"maxMembersLabel": "Max. members",
|
||||
"yearlyCreditsLabel": "Total yearly credits",
|
||||
"membersLabel": "Up to {count} members",
|
||||
"nextMonthInvoice": "Next month invoice",
|
||||
"invoiceHistory": "Invoice history",
|
||||
"memberCount": "{count} member | {count} members",
|
||||
"maxDurationLabel": "Max run duration",
|
||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCreditsLabel": "Add more credits whenever",
|
||||
@@ -2123,6 +2160,27 @@
|
||||
"billingComingSoon": {
|
||||
"title": "Coming Soon",
|
||||
"message": "Team billing is coming soon. You'll be able to subscribe to a plan for your workspace with per-seat pricing. Stay tuned for updates."
|
||||
},
|
||||
"preview": {
|
||||
"confirmPayment": "Confirm your payment",
|
||||
"confirmPlanChange": "Confirm your plan change",
|
||||
"startingToday": "Starting today",
|
||||
"starting": "Starting {date}",
|
||||
"ends": "Ends {date}",
|
||||
"eachMonthCreditsRefill": "Each month credits refill to",
|
||||
"perMember": "/ member",
|
||||
"showMoreFeatures": "Show more features",
|
||||
"hideFeatures": "Hide features",
|
||||
"proratedRefund": "Prorated refund for {plan}",
|
||||
"proratedCharge": "Prorated charge for {plan}",
|
||||
"totalDueToday": "Total due today",
|
||||
"nextPaymentDue": "Next payment due {date}. Cancel anytime.",
|
||||
"termsAgreement": "By continuing, you agree to Comfy Org's {terms} and {privacy}.",
|
||||
"terms": "Terms",
|
||||
"privacyPolicy": "Privacy Policy",
|
||||
"addCreditCard": "Add credit card",
|
||||
"confirm": "Confirm",
|
||||
"backToAllPlans": "Back to all plans"
|
||||
}
|
||||
},
|
||||
"userSettings": {
|
||||
@@ -2148,7 +2206,7 @@
|
||||
"placeholder": "Dashboard workspace settings"
|
||||
},
|
||||
"members": {
|
||||
"membersCount": "{count}/50 Members",
|
||||
"membersCount": "{count}/{maxSeats} Members",
|
||||
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
|
||||
"tabs": {
|
||||
"active": "Active",
|
||||
@@ -2164,6 +2222,9 @@
|
||||
"revokeInvite": "Revoke invite",
|
||||
"removeMember": "Remove member"
|
||||
},
|
||||
"upsellBannerSubscribe": "Subscribe to the Creator plan or above to invite team members to this workspace.",
|
||||
"upsellBannerUpgrade": "Upgrade to the Creator plan or above to invite additional team members.",
|
||||
"viewPlans": "View plans",
|
||||
"noInvites": "No pending invites",
|
||||
"noMembers": "No members",
|
||||
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
|
||||
@@ -2202,6 +2263,14 @@
|
||||
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
|
||||
"revoke": "Uninvite"
|
||||
},
|
||||
"inviteUpsellDialog": {
|
||||
"titleNotSubscribed": "A subscription is required to invite members",
|
||||
"titleSingleSeat": "Your current plan supports a single seat",
|
||||
"messageNotSubscribed": "To add team members to this workspace, you need a Creator plan or above. The Standard plan supports only a single seat (the owner).",
|
||||
"messageSingleSeat": "The Standard plan includes one seat for the workspace owner. To invite additional members, upgrade to the Creator plan or above to unlock multiple seats.",
|
||||
"viewPlans": "View Plans",
|
||||
"upgradeToCreator": "Upgrade to Creator"
|
||||
},
|
||||
"inviteMemberDialog": {
|
||||
"title": "Invite a person to this workspace",
|
||||
"message": "Create a shareable invite link to send to someone",
|
||||
@@ -2491,13 +2560,34 @@
|
||||
"civitaiLinkPlaceholder": "Paste link here",
|
||||
"confirmModelDetails": "Confirm Model Details",
|
||||
"connectionError": "Please check your connection and try again",
|
||||
"errorAccessForbidden": "Access to this resource is forbidden.",
|
||||
"errorConnectionRefused": "Unable to connect to the source. Please try again later.",
|
||||
"errorDownloadCancelled": "Download was cancelled.",
|
||||
"errorFileTooLarge": "File exceeds the maximum allowed size limit",
|
||||
"errorFormatNotAllowed": "Only SafeTensor format is allowed",
|
||||
"errorHttpError": "An error occurred while fetching metadata.",
|
||||
"errorInternalError": "An unexpected error occurred. Please try again.",
|
||||
"errorInvalidHost": "The source URL hostname could not be resolved.",
|
||||
"errorInvalidUrl": "Please provide a URL.",
|
||||
"errorInvalidUrlFormat": "The URL format is invalid. Please check and try again.",
|
||||
"errorMetadataFetchFailed": "Failed to fetch file information from the source.",
|
||||
"errorModelTypeNotSupported": "This model type is not supported",
|
||||
"errorNetworkError": "A network error occurred. Please check your connection and try again.",
|
||||
"errorNetworkTimeout": "Request timed out. Please try again.",
|
||||
"errorRateLimited": "Too many requests. Please try again in a few minutes.",
|
||||
"errorRequestCancelled": "Request was cancelled.",
|
||||
"errorResourceNotFound": "The file was not found. Please check the URL and try again.",
|
||||
"errorServiceUnavailable": "Service temporarily unavailable. Please try again later.",
|
||||
"errorSourceServerError": "The source server is experiencing issues. Please try again later.",
|
||||
"errorUnauthorized": "Please sign in to continue.",
|
||||
"errorUnauthorizedSource": "This resource requires authentication. Please add your API token in settings.",
|
||||
"errorUnknown": "An unexpected error occurred",
|
||||
"errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file",
|
||||
"errorUnsafeVirusScan": "CivitAI detected malware or suspicious content in this file",
|
||||
"errorUnsupportedSource": "This URL is not supported. Only Hugging Face and Civitai URLs are allowed.",
|
||||
"errorUploadFailed": "Failed to import asset. Please try again.",
|
||||
"errorUserTokenAccessDenied": "Your API token does not have access to this resource. Please check your token permissions.",
|
||||
"errorUserTokenInvalid": "Your stored API token is invalid or expired. Please update your token in settings.",
|
||||
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
|
||||
"fileFormats": "File formats",
|
||||
"fileName": "File Name",
|
||||
@@ -2549,6 +2639,8 @@
|
||||
"upgradeToUnlockFeature": "Upgrade to unlock this feature",
|
||||
"upload": "Import",
|
||||
"uploadFailed": "Import failed",
|
||||
"apiKeyHint": "Importing private or gated models? {link}.",
|
||||
"apiKeyHintLink": "Add your API keys in Settings",
|
||||
"uploadingModel": "Importing model...",
|
||||
"uploadModel": "Import",
|
||||
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
|
||||
@@ -2807,8 +2899,9 @@
|
||||
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
|
||||
},
|
||||
"inviteAccepted": "Invite Accepted",
|
||||
"addedToWorkspace": "You have been added to {workspaceName}",
|
||||
"inviteFailed": "Failed to Accept Invite"
|
||||
"addedToWorkspace": "You have been added to:",
|
||||
"inviteFailed": "Failed to Accept Invite",
|
||||
"viewWorkspace": "View workspace"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
"errors": {
|
||||
@@ -2824,5 +2917,45 @@
|
||||
"label": "Preview Version",
|
||||
"tooltip": "You are using a nightly version of ComfyUI. Please use the feedback button to share your thoughts about these features."
|
||||
}
|
||||
},
|
||||
"nodeFilters": {
|
||||
"hideDeprecated": "Hide Deprecated Nodes",
|
||||
"hideDeprecatedDescription": "Hides nodes marked as deprecated unless explicitly enabled",
|
||||
"hideExperimental": "Hide Experimental Nodes",
|
||||
"hideExperimentalDescription": "Hides nodes marked as experimental unless explicitly enabled",
|
||||
"hideDevOnly": "Hide Dev-Only Nodes",
|
||||
"hideDevOnlyDescription": "Hides nodes marked as dev-only unless dev mode is enabled",
|
||||
"hideSubgraph": "Hide Subgraph Nodes",
|
||||
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search"
|
||||
},
|
||||
"secrets": {
|
||||
"title": "API Keys & Secrets",
|
||||
"description": "Secrets are encrypted and used for sensitive data like API keys.",
|
||||
"descriptionUsage": "Store your tokens here to enable downloading private and gated models from supported providers.",
|
||||
"modelProviders": "Model Providers",
|
||||
"addSecret": "Add Secret",
|
||||
"editSecret": "Edit Secret",
|
||||
"noSecrets": "No secrets stored. Add your first API key to get started.",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "e.g., My API Key",
|
||||
"provider": "Provider",
|
||||
"providerHint": "Optional. Selecting a provider enables automatic token usage.",
|
||||
"secretValue": "Secret Value",
|
||||
"secretValuePlaceholder": "Enter your API key",
|
||||
"secretValuePlaceholderEdit": "Enter new value to change",
|
||||
"secretValueHint": "This value will be encrypted and cannot be viewed again.",
|
||||
"secretValueHintEdit": "Leave blank to keep the current value.",
|
||||
"createdAt": "Created {date}",
|
||||
"lastUsed": "Last used {date}",
|
||||
"deleteConfirmTitle": "Delete Secret",
|
||||
"deleteConfirmMessage": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
|
||||
"errors": {
|
||||
"nameRequired": "Name is required",
|
||||
"nameTooLong": "Name must be 255 characters or less",
|
||||
"providerRequired": "Provider is required",
|
||||
"secretValueRequired": "Secret value is required",
|
||||
"duplicateName": "A secret with this name already exists",
|
||||
"duplicateProvider": "A secret for this provider already exists"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const createAssetData = (
|
||||
overrides: Partial<AssetDisplayItem> = {}
|
||||
): AssetDisplayItem => ({
|
||||
...baseAsset,
|
||||
description:
|
||||
secondaryText:
|
||||
'High-quality realistic images with perfect detail and natural lighting effects for professional photography',
|
||||
badges: [
|
||||
{ label: 'checkpoints', type: 'type' },
|
||||
@@ -131,20 +131,21 @@ export const EdgeCases: Story = {
|
||||
// Default case for comparison
|
||||
createAssetData({
|
||||
name: 'Complete Data',
|
||||
description: 'Asset with all data present for comparison'
|
||||
secondaryText: 'Asset with all data present for comparison'
|
||||
}),
|
||||
// No badges
|
||||
createAssetData({
|
||||
id: 'no-badges',
|
||||
name: 'No Badges',
|
||||
description: 'Testing graceful handling when badges are not provided',
|
||||
secondaryText:
|
||||
'Testing graceful handling when badges are not provided',
|
||||
badges: []
|
||||
}),
|
||||
// No stars
|
||||
createAssetData({
|
||||
id: 'no-stars',
|
||||
name: 'No Stars',
|
||||
description: 'Testing missing stars data gracefully',
|
||||
secondaryText: 'Testing missing stars data gracefully',
|
||||
stats: {
|
||||
downloadCount: '1.8k',
|
||||
formattedDate: '3/15/25'
|
||||
@@ -154,7 +155,7 @@ export const EdgeCases: Story = {
|
||||
createAssetData({
|
||||
id: 'no-downloads',
|
||||
name: 'No Downloads',
|
||||
description: 'Testing missing downloads data gracefully',
|
||||
secondaryText: 'Testing missing downloads data gracefully',
|
||||
stats: {
|
||||
stars: '4.2k',
|
||||
formattedDate: '3/15/25'
|
||||
@@ -164,7 +165,7 @@ export const EdgeCases: Story = {
|
||||
createAssetData({
|
||||
id: 'no-date',
|
||||
name: 'No Date',
|
||||
description: 'Testing missing date data gracefully',
|
||||
secondaryText: 'Testing missing date data gracefully',
|
||||
stats: {
|
||||
stars: '4.2k',
|
||||
downloadCount: '1.8k'
|
||||
@@ -174,21 +175,21 @@ export const EdgeCases: Story = {
|
||||
createAssetData({
|
||||
id: 'no-stats',
|
||||
name: 'No Stats',
|
||||
description: 'Testing when all stats are missing',
|
||||
secondaryText: 'Testing when all stats are missing',
|
||||
stats: {}
|
||||
}),
|
||||
// Long description
|
||||
// Long secondaryText
|
||||
createAssetData({
|
||||
id: 'long-desc',
|
||||
name: 'Long Description',
|
||||
description:
|
||||
secondaryText:
|
||||
'This is a very long description that should demonstrate how the component handles text overflow and truncation with ellipsis. The description continues with even more content to ensure we test the 2-line clamp behavior properly and see how it renders when there is significantly more text than can fit in the allocated space.'
|
||||
}),
|
||||
// Minimal data
|
||||
createAssetData({
|
||||
id: 'minimal',
|
||||
name: 'Minimal',
|
||||
description: 'Basic model',
|
||||
secondaryText: 'Basic model',
|
||||
tags: ['models'],
|
||||
badges: [],
|
||||
stats: {}
|
||||
|
||||
@@ -54,7 +54,6 @@
|
||||
>
|
||||
<template #default>
|
||||
<Button
|
||||
v-if="flags.assetDeletionEnabled"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="justify-start"
|
||||
@@ -82,14 +81,14 @@
|
||||
</h3>
|
||||
<p
|
||||
:id="descId"
|
||||
v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }"
|
||||
v-tooltip.top="{ value: asset.secondaryText, showDelay: tooltipDelay }"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 text-sm line-clamp-2 [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box] text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ asset.description }}
|
||||
{{ asset.secondaryText }}
|
||||
</p>
|
||||
<div class="flex items-center justify-between gap-2 mt-auto">
|
||||
<div class="flex gap-3 text-xs text-muted-foreground">
|
||||
@@ -141,7 +140,6 @@ import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
@@ -167,7 +165,6 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const { closeDialog } = useDialogStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { isDownloadedThisSession, acknowledgeAsset } = useAssetDownloadStore()
|
||||
|
||||
const dropdownMenuButton = useTemplateRef<InstanceType<typeof MoreButton>>(
|
||||
@@ -181,11 +178,7 @@ const displayName = computed(() => getAssetDisplayName(asset))
|
||||
|
||||
const isNewlyImported = computed(() => isDownloadedThisSession(asset.id))
|
||||
|
||||
const showAssetOptions = computed(
|
||||
() =>
|
||||
(flags.assetDeletionEnabled || flags.assetRenameEnabled) &&
|
||||
!(asset.is_immutable ?? true)
|
||||
)
|
||||
const showAssetOptions = computed(() => !(asset.is_immutable ?? true))
|
||||
|
||||
const tooltipDelay = computed<number>(() =>
|
||||
settingStore.get('LiteGraph.Node.TooltipDelay')
|
||||
|
||||
@@ -6,12 +6,7 @@
|
||||
<div class="min-h-0 flex-auto basis-0 overflow-y-auto">
|
||||
<!-- Step 1: Enter URL -->
|
||||
<UploadModelUrlInput
|
||||
v-if="currentStep === 1 && flags.huggingfaceModelImportEnabled"
|
||||
v-model="wizardData.url"
|
||||
:error="uploadError"
|
||||
/>
|
||||
<UploadModelUrlInputCivitai
|
||||
v-else-if="currentStep === 1"
|
||||
v-if="currentStep === 1"
|
||||
v-model="wizardData.url"
|
||||
:error="uploadError"
|
||||
/>
|
||||
@@ -56,17 +51,14 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
|
||||
import UploadModelFooter from '@/platform/assets/components/UploadModelFooter.vue'
|
||||
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
|
||||
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
|
||||
import UploadModelUrlInputCivitai from '@/platform/assets/components/UploadModelUrlInputCivitai.vue'
|
||||
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import { useUploadModelWizard } from '@/platform/assets/composables/useUploadModelWizard'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const dialogStore = useDialogStore()
|
||||
const { modelTypes, fetchModelTypes } = useModelTypes()
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 p-4 font-bold">
|
||||
<img
|
||||
v-if="!flags.huggingfaceModelImportEnabled"
|
||||
src="/assets/images/civitai.svg"
|
||||
class="size-4"
|
||||
/>
|
||||
<span>{{ $t(titleKey) }}</span>
|
||||
<span>{{ $t('assetBrowser.uploadModelGeneric') }}</span>
|
||||
<span
|
||||
class="rounded-full bg-white px-1.5 py-0 text-xxs font-inter font-semibold uppercase text-black"
|
||||
>
|
||||
@@ -13,17 +8,3 @@
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const titleKey = computed(() => {
|
||||
return flags.huggingfaceModelImportEnabled
|
||||
? 'assetBrowser.uploadModelGeneric'
|
||||
: 'assetBrowser.uploadModelFromCivitai'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<template>
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<div
|
||||
v-if="currentStep === 1 && flags.huggingfaceModelImportEnabled"
|
||||
class="mr-auto flex items-center gap-2"
|
||||
>
|
||||
<div v-if="currentStep === 1" class="mr-auto flex items-center gap-2">
|
||||
<i class="icon-[lucide--circle-question-mark] text-muted-foreground" />
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
@@ -22,17 +19,6 @@
|
||||
{{ $t('assetBrowser.providerHuggingFace') }}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
v-else-if="currentStep === 1"
|
||||
variant="muted-textonly"
|
||||
size="lg"
|
||||
class="mr-auto underline"
|
||||
data-attr="upload-model-step1-help-link"
|
||||
@click="showCivitaiHelp = true"
|
||||
>
|
||||
<i class="icon-[lucide--circle-question-mark]" />
|
||||
<span>{{ $t('assetBrowser.uploadModelHowDoIFindThis') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="currentStep === 1"
|
||||
variant="muted-textonly"
|
||||
@@ -124,11 +110,8 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import VideoHelpDialog from '@/platform/assets/components/VideoHelpDialog.vue'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const showCivitaiHelp = ref(false)
|
||||
const showHuggingFaceHelp = ref(false)
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const { result } = defineProps<{
|
||||
defineProps<{
|
||||
result: 'processing' | 'success' | 'error'
|
||||
error?: string
|
||||
metadata?: AssetMetadata
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import UploadModelUpgradeModalBody from '@/platform/assets/components/UploadModelUpgradeModalBody.vue'
|
||||
import UploadModelUpgradeModalFooter from '@/platform/assets/components/UploadModelUpgradeModalFooter.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const { showSubscriptionDialog } = useSubscription()
|
||||
const { showSubscriptionDialog } = useBillingContext()
|
||||
|
||||
function handleClose() {
|
||||
dialogStore.closeDialog({ key: 'upload-model-upgrade' })
|
||||
|
||||
@@ -45,36 +45,37 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="relative">
|
||||
<InputText
|
||||
v-model="url"
|
||||
autofocus
|
||||
:placeholder="$t('assetBrowser.genericLinkPlaceholder')"
|
||||
class="w-full border-0 bg-secondary-background p-4 pr-10"
|
||||
data-attr="upload-model-step1-url-input"
|
||||
/>
|
||||
<i
|
||||
v-if="isValidUrl"
|
||||
class="icon-[lucide--circle-check-big] absolute top-1/2 right-3 size-5 -translate-y-1/2 text-green-500"
|
||||
/>
|
||||
</div>
|
||||
<InputText
|
||||
v-model="url"
|
||||
autofocus
|
||||
:placeholder="$t('assetBrowser.genericLinkPlaceholder')"
|
||||
class="w-full border-0 bg-secondary-background p-4"
|
||||
data-attr="upload-model-step1-url-input"
|
||||
/>
|
||||
<p v-if="error" class="text-sm text-error">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else-if="!flags.asyncModelUploadEnabled" class="text-foreground">
|
||||
<i18n-t keypath="assetBrowser.maxFileSize" tag="span">
|
||||
<template #size>
|
||||
<span class="font-bold italic">{{
|
||||
$t('assetBrowser.maxFileSizeValue')
|
||||
}}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('assetBrowser.uploadModelHelpFooterText') }}
|
||||
<div class="flex flex-col gap-6 text-sm text-muted-foreground">
|
||||
<div v-if="showSecretsHint">
|
||||
<i18n-t keypath="assetBrowser.apiKeyHint" tag="span">
|
||||
<template #link>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="text-muted-foreground underline p-0"
|
||||
@click="openSecretsSettings"
|
||||
>
|
||||
{{ $t('assetBrowser.apiKeyHintLink') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<div>
|
||||
{{ $t('assetBrowser.uploadModelHelpFooterText') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -83,12 +84,18 @@
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
|
||||
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
|
||||
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
const showSecretsHint = computed(() => flags.userSecretsEnabled)
|
||||
|
||||
function openSecretsSettings() {
|
||||
dialogService.showSettingsDialog('secrets')
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
@@ -104,14 +111,6 @@ const url = computed({
|
||||
set: (value: string) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const importSources = [civitaiImportSource, huggingfaceImportSource]
|
||||
|
||||
const isValidUrl = computed(() => {
|
||||
const trimmedUrl = url.value.trim()
|
||||
if (!trimmedUrl) return false
|
||||
return importSources.some((source) => validateSourceUrl(trimmedUrl, source))
|
||||
})
|
||||
|
||||
const civitaiIcon = '/assets/images/civitai.svg'
|
||||
const civitaiUrl = 'https://civitai.com/models'
|
||||
const huggingFaceIcon = '/assets/images/hf-logo.svg'
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 text-sm text-muted-foreground">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="m-0">
|
||||
{{ $t('assetBrowser.uploadModelDescription1') }}
|
||||
</p>
|
||||
<ul class="list-disc space-y-1 pl-5 mt-0">
|
||||
<li>
|
||||
<i18n-t keypath="assetBrowser.uploadModelDescription2" tag="span">
|
||||
<template #link>
|
||||
<a
|
||||
href="https://civitai.com/models"
|
||||
target="_blank"
|
||||
class="text-muted-foreground underline"
|
||||
>
|
||||
{{ $t('assetBrowser.uploadModelDescription2Link') }}
|
||||
</a>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</li>
|
||||
<li v-if="!flags.asyncModelUploadEnabled">
|
||||
<i18n-t keypath="assetBrowser.uploadModelDescription3" tag="span">
|
||||
<template #size>
|
||||
<span class="font-bold italic">{{
|
||||
$t('assetBrowser.maxFileSizeValue')
|
||||
}}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<i18n-t keypath="assetBrowser.civitaiLinkLabel" tag="label" class="mb-0">
|
||||
<template #download>
|
||||
<span class="font-bold italic">{{
|
||||
$t('assetBrowser.civitaiLinkLabelDownload')
|
||||
}}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<div class="relative">
|
||||
<InputText
|
||||
v-model="url"
|
||||
autofocus
|
||||
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
|
||||
class="w-full border-0 bg-secondary-background p-4 pr-10"
|
||||
data-attr="upload-model-step1-url-input"
|
||||
/>
|
||||
<i
|
||||
v-if="isValidUrl"
|
||||
class="icon-[lucide--circle-check-big] absolute top-1/2 right-3 size-5 -translate-y-1/2 text-green-500"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="error" class="text-sm text-error">
|
||||
{{ error }}
|
||||
</p>
|
||||
<i18n-t
|
||||
v-else
|
||||
keypath="assetBrowser.civitaiLinkExample"
|
||||
tag="p"
|
||||
class="text-sm"
|
||||
>
|
||||
<template #example>
|
||||
<strong>{{ $t('assetBrowser.civitaiLinkExampleStrong') }}</strong>
|
||||
</template>
|
||||
<template #link>
|
||||
<a
|
||||
href="https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295"
|
||||
target="_blank"
|
||||
class="text-muted-foreground underline"
|
||||
>
|
||||
{{ $t('assetBrowser.civitaiLinkExampleUrl') }}
|
||||
</a>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
|
||||
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
defineProps<{
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
const url = defineModel<string>({ required: true })
|
||||
|
||||
const isValidUrl = computed(() => {
|
||||
const trimmedUrl = url.value.trim()
|
||||
if (!trimmedUrl) return false
|
||||
return validateSourceUrl(trimmedUrl, civitaiImportSource)
|
||||
})
|
||||
</script>
|
||||
@@ -34,7 +34,7 @@ describe('ModelInfoPanel', () => {
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
last_access_time: '2024-01-01T00:00:00Z',
|
||||
description: 'A test model description',
|
||||
secondaryText: 'A test model description',
|
||||
badges: [],
|
||||
stats: {},
|
||||
...overrides
|
||||
|
||||
@@ -84,14 +84,14 @@ describe('useAssetBrowser', () => {
|
||||
expect(result.name).toBe(apiAsset.name)
|
||||
|
||||
// Adds display properties
|
||||
expect(result.description).toBe('Test model')
|
||||
expect(result.secondaryText).toBe('test-asset.safetensors')
|
||||
expect(result.badges).toContainEqual({
|
||||
label: 'checkpoints',
|
||||
type: 'type'
|
||||
})
|
||||
})
|
||||
|
||||
it('creates fallback description from tags when metadata missing', () => {
|
||||
it('creates secondaryText from filename when metadata missing', () => {
|
||||
const apiAsset = createApiAsset({
|
||||
tags: ['models', 'loras'],
|
||||
user_metadata: undefined
|
||||
@@ -100,7 +100,7 @@ describe('useAssetBrowser', () => {
|
||||
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
|
||||
const result = filteredAssets.value[0]
|
||||
|
||||
expect(result.description).toBe('loras model')
|
||||
expect(result.secondaryText).toBe('test-asset.safetensors')
|
||||
})
|
||||
|
||||
it('removes category prefix from badge labels', () => {
|
||||
|
||||
@@ -9,8 +9,8 @@ import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vu
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
getAssetBaseModels,
|
||||
getAssetDescription,
|
||||
getAssetDisplayName
|
||||
getAssetDisplayName,
|
||||
getAssetFilename
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
@@ -70,7 +70,7 @@ type AssetBadge = {
|
||||
|
||||
// Display properties for transformed assets
|
||||
export interface AssetDisplayItem extends AssetItem {
|
||||
description: string
|
||||
secondaryText: string
|
||||
badges: AssetBadge[]
|
||||
stats: {
|
||||
formattedDate?: string
|
||||
@@ -116,15 +116,11 @@ export function useAssetBrowser(
|
||||
|
||||
// Transform API asset to display asset
|
||||
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
|
||||
// Extract description from metadata or create from tags
|
||||
const typeTag = asset.tags.find((tag) => tag !== 'models')
|
||||
const description =
|
||||
getAssetDescription(asset) ||
|
||||
`${typeTag || t('assetBrowser.unknown')} model`
|
||||
const secondaryText = getAssetFilename(asset)
|
||||
|
||||
// Create badges from tags and metadata
|
||||
const badges: AssetBadge[] = []
|
||||
|
||||
const typeTag = asset.tags.find((tag) => tag !== 'models')
|
||||
// Type badge from non-root tag
|
||||
if (typeTag) {
|
||||
// Remove category prefix from badge label (e.g. "checkpoint/model" → "model")
|
||||
@@ -152,7 +148,7 @@ export function useAssetBrowser(
|
||||
|
||||
return {
|
||||
...asset,
|
||||
description,
|
||||
secondaryText,
|
||||
badges,
|
||||
stats
|
||||
}
|
||||
|
||||
@@ -65,15 +65,8 @@ export function useMediaAssetActions() {
|
||||
|
||||
try {
|
||||
const filename = targetAsset.name
|
||||
let downloadUrl: string
|
||||
|
||||
// In cloud, use preview_url directly (from cloud storage)
|
||||
// In OSS/localhost, use the /view endpoint
|
||||
if (isCloud && targetAsset.preview_url) {
|
||||
downloadUrl = targetAsset.preview_url
|
||||
} else {
|
||||
downloadUrl = getAssetUrl(targetAsset)
|
||||
}
|
||||
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
|
||||
const downloadUrl = targetAsset.preview_url || getAssetUrl(targetAsset)
|
||||
|
||||
downloadFile(downloadUrl, filename)
|
||||
|
||||
@@ -103,15 +96,8 @@ export function useMediaAssetActions() {
|
||||
try {
|
||||
assets.forEach((asset) => {
|
||||
const filename = asset.name
|
||||
let downloadUrl: string
|
||||
|
||||
// In cloud, use preview_url directly (from GCS or other cloud storage)
|
||||
// In OSS/localhost, use the /view endpoint
|
||||
if (isCloud && asset.preview_url) {
|
||||
downloadUrl = asset.preview_url
|
||||
} else {
|
||||
downloadUrl = getAssetUrl(asset)
|
||||
}
|
||||
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
|
||||
const downloadUrl = asset.preview_url || getAssetUrl(asset)
|
||||
downloadFile(downloadUrl, filename)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vue'
|
||||
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
|
||||
import UploadModelUpgradeModal from '@/platform/assets/components/UploadModelUpgradeModal.vue'
|
||||
import UploadModelUpgradeModalHeader from '@/platform/assets/components/UploadModelUpgradeModalHeader.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export function useModelUpload(
|
||||
onUploadSuccess?: () => Promise<unknown> | void
|
||||
@@ -15,7 +16,6 @@ export function useModelUpload(
|
||||
|
||||
function showUploadDialog() {
|
||||
if (!flags.privateModelsEnabled) {
|
||||
// Show upgrade modal if private models are disabled
|
||||
dialogStore.showDialog({
|
||||
key: 'upload-model-upgrade',
|
||||
headerComponent: UploadModelUpgradeModalHeader,
|
||||
@@ -28,7 +28,6 @@ export function useModelUpload(
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Show regular upload modal
|
||||
dialogStore.showDialog({
|
||||
key: 'upload-model',
|
||||
headerComponent: UploadModelDialogHeader,
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { st } from '@/i18n'
|
||||
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
|
||||
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
|
||||
@@ -32,7 +31,6 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
const assetsStore = useAssetsStore()
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
const currentStep = ref(1)
|
||||
const isFetchingMetadata = ref(false)
|
||||
const isUploading = ref(false)
|
||||
@@ -47,10 +45,10 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
|
||||
const selectedModelType = ref<string>()
|
||||
|
||||
// Available import sources
|
||||
const importSources: ImportSource[] = flags.huggingfaceModelImportEnabled
|
||||
? [civitaiImportSource, huggingfaceImportSource]
|
||||
: [civitaiImportSource]
|
||||
const importSources: ImportSource[] = [
|
||||
civitaiImportSource,
|
||||
huggingfaceImportSource
|
||||
]
|
||||
|
||||
// Detected import source based on URL
|
||||
const detectedSource = computed(() => {
|
||||
@@ -69,9 +67,9 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
}
|
||||
)
|
||||
|
||||
// Validation
|
||||
// Validation - only enable Continue when URL matches a supported source
|
||||
const canFetchMetadata = computed(() => {
|
||||
return wizardData.value.url.trim().length > 0
|
||||
return detectedSource.value !== null
|
||||
})
|
||||
|
||||
const canUploadModel = computed(() => {
|
||||
@@ -233,40 +231,27 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
model_type: selectedModelType.value
|
||||
}
|
||||
|
||||
if (flags.asyncModelUploadEnabled) {
|
||||
const result = await assetService.uploadAssetAsync({
|
||||
source_url: wizardData.value.url,
|
||||
tags,
|
||||
user_metadata: userMetadata,
|
||||
preview_id: previewId
|
||||
})
|
||||
const result = await assetService.uploadAssetAsync({
|
||||
source_url: wizardData.value.url,
|
||||
tags,
|
||||
user_metadata: userMetadata,
|
||||
preview_id: previewId
|
||||
})
|
||||
|
||||
if (result.type === 'async' && result.task.status !== 'completed') {
|
||||
if (selectedModelType.value) {
|
||||
assetDownloadStore.trackDownload(
|
||||
result.task.task_id,
|
||||
selectedModelType.value,
|
||||
filename
|
||||
)
|
||||
}
|
||||
uploadStatus.value = 'processing'
|
||||
} else {
|
||||
uploadStatus.value = 'success'
|
||||
await refreshModelCaches()
|
||||
if (result.type === 'async' && result.task.status !== 'completed') {
|
||||
if (selectedModelType.value) {
|
||||
assetDownloadStore.trackDownload(
|
||||
result.task.task_id,
|
||||
selectedModelType.value,
|
||||
filename
|
||||
)
|
||||
}
|
||||
currentStep.value = 3
|
||||
uploadStatus.value = 'processing'
|
||||
} else {
|
||||
await assetService.uploadAssetFromUrl({
|
||||
url: wizardData.value.url,
|
||||
name: filename,
|
||||
tags,
|
||||
user_metadata: userMetadata,
|
||||
preview_id: previewId
|
||||
})
|
||||
uploadStatus.value = 'success'
|
||||
await refreshModelCaches()
|
||||
currentStep.value = 3
|
||||
}
|
||||
currentStep.value = 3
|
||||
} catch (error) {
|
||||
console.error('Failed to upload asset:', error)
|
||||
uploadStatus.value = 'error'
|
||||
|
||||
@@ -36,6 +36,7 @@ interface AssetRequestOptions extends PaginationOptions {
|
||||
*/
|
||||
function getLocalizedErrorMessage(errorCode: string): string {
|
||||
const errorMessages: Record<string, string> = {
|
||||
// Validation errors
|
||||
FILE_TOO_LARGE: st('assetBrowser.errorFileTooLarge', 'File too large'),
|
||||
FORMAT_NOT_ALLOWED: st(
|
||||
'assetBrowser.errorFormatNotAllowed',
|
||||
@@ -52,6 +53,95 @@ function getLocalizedErrorMessage(errorCode: string): string {
|
||||
MODEL_TYPE_NOT_SUPPORTED: st(
|
||||
'assetBrowser.errorModelTypeNotSupported',
|
||||
'Model type not supported'
|
||||
),
|
||||
|
||||
// HTTP 400 - Bad Request
|
||||
INVALID_URL: st('assetBrowser.errorInvalidUrl', 'Please provide a URL.'),
|
||||
INVALID_URL_FORMAT: st(
|
||||
'assetBrowser.errorInvalidUrlFormat',
|
||||
'The URL format is invalid. Please check and try again.'
|
||||
),
|
||||
UNSUPPORTED_SOURCE: st(
|
||||
'assetBrowser.errorUnsupportedSource',
|
||||
'This URL is not supported. Only Hugging Face and Civitai URLs are allowed.'
|
||||
),
|
||||
|
||||
// HTTP 401 - Unauthorized
|
||||
UNAUTHORIZED: st(
|
||||
'assetBrowser.errorUnauthorized',
|
||||
'Please sign in to continue.'
|
||||
),
|
||||
|
||||
// HTTP 422 - External Source Errors
|
||||
USER_TOKEN_INVALID: st(
|
||||
'assetBrowser.errorUserTokenInvalid',
|
||||
'Your stored API token is invalid or expired. Please update your token in settings.'
|
||||
),
|
||||
USER_TOKEN_ACCESS_DENIED: st(
|
||||
'assetBrowser.errorUserTokenAccessDenied',
|
||||
'Your API token does not have access to this resource. Please check your token permissions.'
|
||||
),
|
||||
UNAUTHORIZED_SOURCE: st(
|
||||
'assetBrowser.errorUnauthorizedSource',
|
||||
'This resource requires authentication. Please add your API token in settings.'
|
||||
),
|
||||
ACCESS_FORBIDDEN: st(
|
||||
'assetBrowser.errorAccessForbidden',
|
||||
'Access to this resource is forbidden.'
|
||||
),
|
||||
RESOURCE_NOT_FOUND: st(
|
||||
'assetBrowser.errorResourceNotFound',
|
||||
'The file was not found. Please check the URL and try again.'
|
||||
),
|
||||
RATE_LIMITED: st(
|
||||
'assetBrowser.errorRateLimited',
|
||||
'Too many requests. Please try again in a few minutes.'
|
||||
),
|
||||
SOURCE_SERVER_ERROR: st(
|
||||
'assetBrowser.errorSourceServerError',
|
||||
'The source server is experiencing issues. Please try again later.'
|
||||
),
|
||||
NETWORK_TIMEOUT: st(
|
||||
'assetBrowser.errorNetworkTimeout',
|
||||
'Request timed out. Please try again.'
|
||||
),
|
||||
CONNECTION_REFUSED: st(
|
||||
'assetBrowser.errorConnectionRefused',
|
||||
'Unable to connect to the source. Please try again later.'
|
||||
),
|
||||
INVALID_HOST: st(
|
||||
'assetBrowser.errorInvalidHost',
|
||||
'The source URL hostname could not be resolved.'
|
||||
),
|
||||
NETWORK_ERROR: st(
|
||||
'assetBrowser.errorNetworkError',
|
||||
'A network error occurred. Please check your connection and try again.'
|
||||
),
|
||||
REQUEST_CANCELLED: st(
|
||||
'assetBrowser.errorRequestCancelled',
|
||||
'Request was cancelled.'
|
||||
),
|
||||
DOWNLOAD_CANCELLED: st(
|
||||
'assetBrowser.errorDownloadCancelled',
|
||||
'Download was cancelled.'
|
||||
),
|
||||
METADATA_FETCH_FAILED: st(
|
||||
'assetBrowser.errorMetadataFetchFailed',
|
||||
'Failed to fetch file information from the source.'
|
||||
),
|
||||
HTTP_ERROR: st(
|
||||
'assetBrowser.errorHttpError',
|
||||
'An error occurred while fetching metadata.'
|
||||
),
|
||||
|
||||
// HTTP 500 - Internal Server Errors
|
||||
SERVICE_UNAVAILABLE: st(
|
||||
'assetBrowser.errorServiceUnavailable',
|
||||
'Service temporarily unavailable. Please try again later.'
|
||||
),
|
||||
INTERNAL_ERROR: st(
|
||||
'assetBrowser.errorInternalError',
|
||||
'An unexpected error occurred. Please try again.'
|
||||
)
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getAssetType } from './assetTypeUtil'
|
||||
|
||||
/**
|
||||
* Get the download/view URL for an asset
|
||||
* Constructs the proper URL with filename encoding and type parameter
|
||||
* Constructs the proper URL with filename encoding, type, and subfolder parameters
|
||||
*
|
||||
* @param asset The asset to get URL for
|
||||
* @param defaultType Default type if asset doesn't have tags (default: 'output')
|
||||
@@ -23,7 +23,12 @@ export function getAssetUrl(
|
||||
defaultType: 'input' | 'output' = 'output'
|
||||
): string {
|
||||
const assetType = getAssetType(asset, defaultType)
|
||||
return api.apiURL(
|
||||
`/view?filename=${encodeURIComponent(asset.name)}&type=${assetType}`
|
||||
)
|
||||
const subfolder = asset.user_metadata?.subfolder
|
||||
const params = new URLSearchParams()
|
||||
params.set('filename', asset.name)
|
||||
params.set('type', assetType)
|
||||
if (typeof subfolder === 'string' && subfolder) {
|
||||
params.set('subfolder', subfolder)
|
||||
}
|
||||
return api.apiURL(`/view?${params}`)
|
||||
}
|
||||
|
||||
@@ -110,13 +110,19 @@ async function createMockNode(overrides?: {
|
||||
widgets: { value: [widget], writable: true }
|
||||
})
|
||||
}
|
||||
function createMockNodeProvider() {
|
||||
function createMockNodeProvider(
|
||||
overrides: {
|
||||
nodeDef?: { name: string; display_name: string }
|
||||
key?: string
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
nodeDef: {
|
||||
name: 'CheckpointLoaderSimple',
|
||||
display_name: 'Load Checkpoint'
|
||||
display_name: 'Load Checkpoint',
|
||||
...overrides.nodeDef
|
||||
},
|
||||
key: 'ckpt_name'
|
||||
key: overrides.key ?? 'ckpt_name'
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -270,6 +276,24 @@ describe('createModelNodeFromAsset', () => {
|
||||
expect(mockSubgraph.add).toHaveBeenCalledWith(mockNode)
|
||||
expect(vi.mocked(app).canvas.graph!.add).not.toHaveBeenCalled()
|
||||
})
|
||||
it('should succeed when provider has empty key (auto-load nodes)', async () => {
|
||||
const asset = createMockAsset({
|
||||
tags: ['models', 'chatterbox/chatterbox_vc'],
|
||||
user_metadata: { filename: 'chatterbox_vc_model.pt' }
|
||||
})
|
||||
const mockNode = await createMockNode({ hasWidgets: false })
|
||||
const nodeProvider = createMockNodeProvider({
|
||||
nodeDef: {
|
||||
name: 'FL_ChatterboxVC',
|
||||
display_name: 'FL Chatterbox VC'
|
||||
},
|
||||
key: ''
|
||||
})
|
||||
await setupMocks({ createdNode: mockNode, nodeProvider })
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
expect(result.success).toBe(true)
|
||||
expect(vi.mocked(app).canvas.graph!.add).toHaveBeenCalledWith(mockNode)
|
||||
})
|
||||
})
|
||||
describe('when asset data is incomplete or invalid', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -171,26 +171,27 @@ export function createModelNodeFromAsset(
|
||||
}
|
||||
}
|
||||
|
||||
const widget = node.widgets?.find((w) => w.name === provider.key)
|
||||
if (!widget) {
|
||||
console.error(
|
||||
`Widget ${provider.key} not found on node ${provider.nodeDef.name}`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'MISSING_WIDGET',
|
||||
message: `Widget ${provider.key} not found on node ${provider.nodeDef.name}`,
|
||||
assetId: validAsset.id,
|
||||
details: { widgetName: provider.key, nodeType: provider.nodeDef.name }
|
||||
// Set widget value if provider specifies a key (some nodes auto-load models without a widget)
|
||||
if (provider.key) {
|
||||
const widget = node.widgets?.find((w) => w.name === provider.key)
|
||||
if (!widget) {
|
||||
console.error(
|
||||
`Widget ${provider.key} not found on node ${provider.nodeDef.name}`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'MISSING_WIDGET',
|
||||
message: `Widget ${provider.key} not found on node ${provider.nodeDef.name}`,
|
||||
assetId: validAsset.id,
|
||||
details: { widgetName: provider.key, nodeType: provider.nodeDef.name }
|
||||
}
|
||||
}
|
||||
}
|
||||
widget.value = filename
|
||||
}
|
||||
|
||||
// Set widget value BEFORE adding to graph so the node is created with correct value
|
||||
widget.value = filename
|
||||
|
||||
// Now add the node to the graph with the correct widget value already set
|
||||
// Add the node to the graph
|
||||
targetGraph.add(node)
|
||||
|
||||
return { success: true, value: node }
|
||||
|
||||
@@ -53,7 +53,9 @@ describe('useWorkspaceSwitch', () => {
|
||||
id: 'workspace-1',
|
||||
name: 'Test Workspace',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
role: 'owner',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
mockModifiedWorkflows.length = 0
|
||||
})
|
||||
|
||||
@@ -47,6 +47,10 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => subscriptionMocks
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => subscriptionMocks
|
||||
}))
|
||||
|
||||
// Avoid real network / isCloud behavior
|
||||
const mockPerformSubscriptionCheckout = vi.fn()
|
||||
vi.mock('@/platform/cloud/subscription/utils/subscriptionCheckoutUtil', () => ({
|
||||
|
||||
@@ -6,9 +6,9 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
|
||||
|
||||
@@ -20,7 +20,7 @@ const router = useRouter()
|
||||
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const { isActiveSubscription, isInitialized } = useSubscription()
|
||||
const { isActiveSubscription, isInitialized } = useBillingContext()
|
||||
|
||||
const selectedTierKey = ref<TierKey | null>(null)
|
||||
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-8">
|
||||
<h2 class="text-xl lg:text-2xl text-muted-foreground m-0 text-center">
|
||||
{{ t('subscription.chooseBestPlanWorkspace') }}
|
||||
</h2>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<SelectButton
|
||||
v-model="currentBillingCycle"
|
||||
:options="billingCycleOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:allow-empty="false"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'flex gap-1 bg-secondary-background rounded-lg p-1.5'
|
||||
},
|
||||
pcToggleButton: {
|
||||
root: ({ context }: ToggleButtonPassThroughMethodOptions) => ({
|
||||
class: [
|
||||
'w-36 h-8 rounded-md transition-colors cursor-pointer border-none outline-none ring-0 text-sm font-medium flex items-center justify-center',
|
||||
context.active
|
||||
? 'bg-base-foreground text-base-background'
|
||||
: 'bg-transparent text-muted-foreground hover:bg-secondary-background-hover'
|
||||
]
|
||||
}),
|
||||
label: { class: 'flex items-center gap-2 ' }
|
||||
}
|
||||
}"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ option.label }}</span>
|
||||
<div
|
||||
v-if="option.value === 'yearly'"
|
||||
class="bg-primary-background text-white text-[11px] px-1 py-0.5 rounded-full flex items-center font-bold"
|
||||
>
|
||||
-20%
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
<div class="flex flex-col xl:flex-row items-stretch gap-6">
|
||||
<div
|
||||
v-for="tier in tiers"
|
||||
:key="tier.id"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 flex flex-col rounded-2xl border border-border-default bg-base-background shadow-[0_0_12px_rgba(0,0,0,0.1)]',
|
||||
tier.isPopular ? 'border-muted-foreground' : ''
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="p-8 pb-0 flex flex-col gap-8">
|
||||
<div class="flex flex-row items-center gap-2 justify-between">
|
||||
<span
|
||||
class="font-inter text-base font-bold leading-normal text-base-foreground"
|
||||
>
|
||||
{{ tier.name }}
|
||||
</span>
|
||||
<div
|
||||
v-if="tier.isPopular"
|
||||
class="rounded-full bg-base-foreground px-1.5 text-[11px] font-bold uppercase text-base-background h-5 tracking-tight flex items-center"
|
||||
>
|
||||
{{ t('subscription.mostPopular') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-row items-baseline gap-2">
|
||||
<span
|
||||
class="font-inter text-[32px] font-semibold leading-normal text-base-foreground"
|
||||
>
|
||||
<span
|
||||
v-show="currentBillingCycle === 'yearly'"
|
||||
class="line-through text-2xl text-muted-foreground"
|
||||
>
|
||||
${{ getMonthlyPrice(tier) }}
|
||||
</span>
|
||||
${{ getPrice(tier) }}
|
||||
</span>
|
||||
<span
|
||||
class="font-inter text-sm leading-normal text-base-foreground"
|
||||
>
|
||||
{{ t('subscription.usdPerMonthPerMember') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
currentBillingCycle === 'yearly'
|
||||
? t('subscription.billedYearly', {
|
||||
total: `$${getAnnualTotal(tier)}`
|
||||
})
|
||||
: t('subscription.billedMonthly')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 pb-0 flex-1">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="text-sm font-normal text-foreground">
|
||||
{{ t('subscription.monthlyCreditsPerMemberLabel') }}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<i class="icon-[lucide--component] text-amber-400 text-sm" />
|
||||
<span
|
||||
class="font-inter text-sm font-bold leading-normal text-base-foreground"
|
||||
>
|
||||
{{ n(getMonthlyCreditsPerMember(tier)) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="text-sm font-normal text-foreground">
|
||||
{{ t('subscription.maxMembersLabel') }}
|
||||
</span>
|
||||
<span
|
||||
class="font-inter text-sm font-bold leading-normal text-base-foreground"
|
||||
>
|
||||
{{ getMaxMembers(tier) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="text-sm font-normal text-foreground">
|
||||
{{ t('subscription.maxDurationLabel') }}
|
||||
</span>
|
||||
<span
|
||||
class="font-inter text-sm font-bold leading-normal text-base-foreground"
|
||||
>
|
||||
{{ tier.maxDuration }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="text-sm font-normal text-foreground">
|
||||
{{ t('subscription.gpuLabel') }}
|
||||
</span>
|
||||
<i class="pi pi-check text-xs text-success-foreground" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="text-sm font-normal text-foreground">
|
||||
{{ t('subscription.addCreditsLabel') }}
|
||||
</span>
|
||||
<i class="pi pi-check text-xs text-success-foreground" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="text-sm font-normal text-foreground">
|
||||
{{ t('subscription.customLoRAsLabel') }}
|
||||
</span>
|
||||
<i
|
||||
v-if="tier.customLoRAs"
|
||||
class="pi pi-check text-xs text-success-foreground"
|
||||
/>
|
||||
<i v-else class="pi pi-times text-xs text-foreground" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-row items-start justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span
|
||||
class="text-sm font-normal text-foreground leading-relaxed"
|
||||
>
|
||||
{{ t('subscription.videoEstimateLabel') }}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-2 group pt-2">
|
||||
<i
|
||||
class="pi pi-question-circle text-xs text-muted-foreground group-hover:text-base-foreground"
|
||||
/>
|
||||
<span
|
||||
class="text-sm font-normal text-muted-foreground cursor-pointer group-hover:text-base-foreground"
|
||||
@click="togglePopover"
|
||||
>
|
||||
{{ t('subscription.videoEstimateHelp') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="font-inter text-sm font-bold leading-normal text-base-foreground"
|
||||
>
|
||||
~{{ n(tier.pricing.videoEstimate) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col p-8">
|
||||
<Button
|
||||
:variant="getButtonSeverity(tier)"
|
||||
:disabled="isButtonDisabled(tier)"
|
||||
:loading="props.loadingTier === tier.key"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 w-full',
|
||||
getButtonTextClass(tier),
|
||||
tier.key === 'creator'
|
||||
? 'bg-base-foreground border-transparent hover:bg-inverted-background-hover'
|
||||
: 'bg-secondary-background border-transparent hover:bg-secondary-background-hover focus:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
@click="() => handleSubscribe(tier.key)"
|
||||
>
|
||||
{{ getButtonLabel(tier) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Estimate Help Popover -->
|
||||
<Popover
|
||||
ref="popover"
|
||||
append-to="body"
|
||||
:auto-z-index="true"
|
||||
:base-z-index="1000"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-base-foreground leading-normal">
|
||||
{{ t('subscription.videoEstimateExplanation') }}
|
||||
</p>
|
||||
<a
|
||||
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
|
||||
>
|
||||
<span class="underline">
|
||||
{{ t('subscription.videoEstimateTryTemplate') }}
|
||||
</span>
|
||||
<span class="no-underline" v-html="'→'"></span>
|
||||
</a>
|
||||
</div>
|
||||
</Popover>
|
||||
<!-- Contact and Enterprise Links -->
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<p class="text-sm text-text-secondary m-0">
|
||||
{{ $t('subscription.haveQuestions') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="h-6 p-1 text-sm text-text-secondary hover:text-base-foreground"
|
||||
@click="handleContactUs"
|
||||
>
|
||||
{{ $t('subscription.contactUs') }}
|
||||
<i class="pi pi-comments" />
|
||||
</Button>
|
||||
<span class="text-sm text-text-secondary">{{ $t('g.or') }}</span>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="h-6 p-1 text-sm text-text-secondary hover:text-base-foreground"
|
||||
@click="handleViewEnterprise"
|
||||
>
|
||||
{{ $t('subscription.viewEnterprise') }}
|
||||
<i class="pi pi-external-link" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import Popover from 'primevue/popover'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { t } from '@/i18n'
|
||||
import {
|
||||
TIER_PRICING,
|
||||
TIER_TO_KEY
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
TierKey,
|
||||
TierPricing
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type SubscriptionTier = components['schemas']['SubscriptionTier']
|
||||
type CheckoutTierKey = Exclude<TierKey, 'founder'>
|
||||
|
||||
interface Props {
|
||||
isLoading?: boolean
|
||||
loadingTier?: CheckoutTierKey | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isLoading: false,
|
||||
loadingTier: null
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
subscribe: [payload: { tierKey: CheckoutTierKey; billingCycle: BillingCycle }]
|
||||
resubscribe: []
|
||||
}>()
|
||||
|
||||
interface BillingCycleOption {
|
||||
label: string
|
||||
value: BillingCycle
|
||||
}
|
||||
|
||||
interface PricingTierConfig {
|
||||
id: SubscriptionTier
|
||||
key: CheckoutTierKey
|
||||
name: string
|
||||
pricing: TierPricing
|
||||
maxDuration: string
|
||||
customLoRAs: boolean
|
||||
maxMembers: number
|
||||
isPopular?: boolean
|
||||
}
|
||||
|
||||
const billingCycleOptions: BillingCycleOption[] = [
|
||||
{ label: t('subscription.yearly'), value: 'yearly' },
|
||||
{ label: t('subscription.monthly'), value: 'monthly' }
|
||||
]
|
||||
|
||||
const tiers: PricingTierConfig[] = [
|
||||
{
|
||||
id: 'STANDARD',
|
||||
key: 'standard',
|
||||
name: t('subscription.tiers.standard.name'),
|
||||
pricing: TIER_PRICING.standard,
|
||||
maxDuration: t('subscription.maxDuration.standard'),
|
||||
customLoRAs: false,
|
||||
maxMembers: 1,
|
||||
isPopular: false
|
||||
},
|
||||
{
|
||||
id: 'CREATOR',
|
||||
key: 'creator',
|
||||
name: t('subscription.tiers.creator.name'),
|
||||
pricing: TIER_PRICING.creator,
|
||||
maxDuration: t('subscription.maxDuration.creator'),
|
||||
customLoRAs: true,
|
||||
maxMembers: 5,
|
||||
isPopular: true
|
||||
},
|
||||
{
|
||||
id: 'PRO',
|
||||
key: 'pro',
|
||||
name: t('subscription.tiers.pro.name'),
|
||||
pricing: TIER_PRICING.pro,
|
||||
maxDuration: t('subscription.maxDuration.pro'),
|
||||
customLoRAs: true,
|
||||
maxMembers: 20,
|
||||
isPopular: false
|
||||
}
|
||||
]
|
||||
|
||||
const { n } = useI18n()
|
||||
const {
|
||||
plans: apiPlans,
|
||||
currentPlanSlug,
|
||||
fetchPlans,
|
||||
subscription,
|
||||
getMaxSeats
|
||||
} = useBillingContext()
|
||||
|
||||
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
|
||||
|
||||
const popover = ref()
|
||||
const currentBillingCycle = ref<BillingCycle>('yearly')
|
||||
|
||||
onMounted(() => {
|
||||
void fetchPlans()
|
||||
})
|
||||
|
||||
function getApiPlanForTier(
|
||||
tierKey: CheckoutTierKey,
|
||||
duration: BillingCycle
|
||||
): Plan | undefined {
|
||||
const apiDuration = duration === 'yearly' ? 'ANNUAL' : 'MONTHLY'
|
||||
const apiTier = tierKey.toUpperCase() as Plan['tier']
|
||||
return apiPlans.value.find(
|
||||
(p) => p.tier === apiTier && p.duration === apiDuration
|
||||
)
|
||||
}
|
||||
|
||||
function getPriceFromApi(tier: PricingTierConfig): number | null {
|
||||
const plan = getApiPlanForTier(tier.key, currentBillingCycle.value)
|
||||
if (!plan) return null
|
||||
const price = plan.price_cents / 100
|
||||
return currentBillingCycle.value === 'yearly' ? price / 12 : price
|
||||
}
|
||||
|
||||
const currentTierKey = computed<TierKey | null>(() =>
|
||||
subscription.value?.tier ? TIER_TO_KEY[subscription.value.tier] : null
|
||||
)
|
||||
|
||||
const isYearlySubscription = computed(
|
||||
() => subscription.value?.duration === 'ANNUAL'
|
||||
)
|
||||
|
||||
const isCurrentPlan = (tierKey: CheckoutTierKey): boolean => {
|
||||
// Use API current_plan_slug if available
|
||||
if (currentPlanSlug.value) {
|
||||
const plan = getApiPlanForTier(tierKey, currentBillingCycle.value)
|
||||
return plan?.slug === currentPlanSlug.value
|
||||
}
|
||||
|
||||
// Fallback to tier-based detection
|
||||
if (!currentTierKey.value) return false
|
||||
|
||||
const selectedIsYearly = currentBillingCycle.value === 'yearly'
|
||||
|
||||
return (
|
||||
currentTierKey.value === tierKey &&
|
||||
isYearlySubscription.value === selectedIsYearly
|
||||
)
|
||||
}
|
||||
|
||||
const togglePopover = (event: Event) => {
|
||||
popover.value.toggle(event)
|
||||
}
|
||||
|
||||
const getButtonLabel = (tier: PricingTierConfig): string => {
|
||||
const planName =
|
||||
currentBillingCycle.value === 'yearly'
|
||||
? t('subscription.tierNameYearly', { name: tier.name })
|
||||
: tier.name
|
||||
|
||||
if (isCurrentPlan(tier.key)) {
|
||||
return isCancelled.value
|
||||
? t('subscription.resubscribeTo', { plan: planName })
|
||||
: t('subscription.currentPlan')
|
||||
}
|
||||
|
||||
return currentTierKey.value
|
||||
? t('subscription.changeTo', { plan: planName })
|
||||
: t('subscription.subscribeTo', { plan: planName })
|
||||
}
|
||||
|
||||
const getButtonSeverity = (
|
||||
tier: PricingTierConfig
|
||||
): 'primary' | 'secondary' => {
|
||||
if (isCurrentPlan(tier.key)) {
|
||||
return isCancelled.value ? 'primary' : 'secondary'
|
||||
}
|
||||
if (tier.key === 'creator') return 'primary'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
const isButtonDisabled = (tier: PricingTierConfig): boolean => {
|
||||
if (props.isLoading) return true
|
||||
if (isCurrentPlan(tier.key)) {
|
||||
// Allow clicking current plan button when cancelled (for resubscribe)
|
||||
return !isCancelled.value
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const getButtonTextClass = (tier: PricingTierConfig): string =>
|
||||
tier.key === 'creator'
|
||||
? 'font-inter text-sm font-bold leading-normal text-base-background'
|
||||
: 'font-inter text-sm font-bold leading-normal text-primary-foreground'
|
||||
|
||||
const getPrice = (tier: PricingTierConfig): number =>
|
||||
getPriceFromApi(tier) ?? tier.pricing[currentBillingCycle.value]
|
||||
|
||||
const getMonthlyPrice = (tier: PricingTierConfig): number => {
|
||||
const plan = getApiPlanForTier(tier.key, 'monthly')
|
||||
return plan ? plan.price_cents / 100 : tier.pricing.monthly
|
||||
}
|
||||
|
||||
const getAnnualTotal = (tier: PricingTierConfig): number => {
|
||||
const plan = getApiPlanForTier(tier.key, 'yearly')
|
||||
return plan ? plan.price_cents / 100 : tier.pricing.yearly * 12
|
||||
}
|
||||
|
||||
const getMaxMembers = (tier: PricingTierConfig): number => getMaxSeats(tier.key)
|
||||
|
||||
const getMonthlyCreditsPerMember = (tier: PricingTierConfig): number =>
|
||||
tier.pricing.credits
|
||||
|
||||
function handleSubscribe(tierKey: CheckoutTierKey) {
|
||||
if (props.isLoading) return
|
||||
|
||||
// Handle resubscribe for cancelled subscription on current plan
|
||||
if (isCurrentPlan(tierKey)) {
|
||||
if (isCancelled.value) {
|
||||
emit('resubscribe')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
emit('subscribe', {
|
||||
tierKey,
|
||||
billingCycle: currentBillingCycle.value
|
||||
})
|
||||
}
|
||||
|
||||
function handleContactUs() {
|
||||
window.open('https://www.comfy.org/discord', '_blank')
|
||||
}
|
||||
|
||||
function handleViewEnterprise() {
|
||||
window.open('https://www.comfy.org/enterprise', '_blank')
|
||||
}
|
||||
</script>
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<Button
|
||||
:size
|
||||
:loading="isLoading"
|
||||
:disabled="disabled || isPolling"
|
||||
:disabled="disabled"
|
||||
variant="primary"
|
||||
:style="
|
||||
variant === 'gradient'
|
||||
@@ -23,7 +22,7 @@
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -46,60 +45,9 @@ const emit = defineEmits<{
|
||||
subscribed: []
|
||||
}>()
|
||||
|
||||
const { subscribe, isActiveSubscription, fetchStatus, showSubscriptionDialog } =
|
||||
useSubscription()
|
||||
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const isPolling = ref(false)
|
||||
let pollInterval: number | null = null
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
|
||||
const isAwaitingStripeSubscription = ref(false)
|
||||
|
||||
const POLL_INTERVAL_MS = 3000 // Poll every 3 seconds
|
||||
const MAX_POLL_DURATION_MS = 5 * 60 * 1000 // Stop polling after 5 minutes
|
||||
|
||||
const startPollingSubscriptionStatus = () => {
|
||||
isPolling.value = true
|
||||
isLoading.value = true
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
if (Date.now() - startTime > MAX_POLL_DURATION_MS) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
|
||||
await fetchStatus()
|
||||
|
||||
if (isActiveSubscription.value) {
|
||||
stopPolling()
|
||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||
emit('subscribed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[SubscribeButton] Error polling subscription status:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
void poll()
|
||||
pollInterval = window.setInterval(poll, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
}
|
||||
isPolling.value = false
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
[isAwaitingStripeSubscription, isActiveSubscription],
|
||||
([awaiting, isActive]) => {
|
||||
@@ -110,27 +58,15 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
const handleSubscribe = () => {
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSubscription('subscribe_clicked')
|
||||
isAwaitingStripeSubscription.value = true
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
await subscribe()
|
||||
|
||||
startPollingSubscriptionStatus()
|
||||
} catch (error) {
|
||||
console.error('[SubscribeButton] Error initiating subscription:', error)
|
||||
isLoading.value = false
|
||||
}
|
||||
isAwaitingStripeSubscription.value = true
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
isAwaitingStripeSubscription.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -26,7 +26,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
@@ -40,7 +40,7 @@ const buttonLabel = computed(() =>
|
||||
: t('subscription.subscribeToRun')
|
||||
)
|
||||
|
||||
const { showSubscriptionDialog } = useSubscription()
|
||||
const { showSubscriptionDialog } = useBillingContext()
|
||||
|
||||
const handleSubscribeToRun = () => {
|
||||
if (isCloud) {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<h2 class="text-xl lg:text-2xl text-muted-foreground m-0 text-center mb-8">
|
||||
{{ $t('subscription.preview.confirmPayment') }}
|
||||
</h2>
|
||||
<div
|
||||
class="flex flex-col justify-between items-stretch max-w-[400px] mx-auto text-sm h-full"
|
||||
>
|
||||
<div class="">
|
||||
<!-- Plan Header -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-base-foreground text-sm">
|
||||
{{ tierName }}
|
||||
</span>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-4xl font-semibold text-base-foreground">
|
||||
${{ displayPrice }}
|
||||
</span>
|
||||
<span class="text-xl text-base-foreground">
|
||||
{{ $t('subscription.usdPerMonthPerMember') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-muted-foreground">
|
||||
{{ $t('subscription.preview.startingToday') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Credits Section -->
|
||||
<div class="flex flex-col gap-3 pt-16 pb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base-foreground">
|
||||
{{ $t('subscription.preview.eachMonthCreditsRefill') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<i class="icon-[lucide--component] text-amber-400 text-sm" />
|
||||
<span class="font-bold text-base-foreground">
|
||||
{{ displayCredits }}
|
||||
</span>
|
||||
<span class="text-base-foreground">
|
||||
{{ $t('subscription.preview.perMember') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expandable Features -->
|
||||
<button
|
||||
class="flex items-center justify-end gap-1 text-sm text-muted-foreground hover:text-base-foreground cursor-pointer bg-transparent border-none p-0"
|
||||
@click="isFeaturesCollapsed = !isFeaturesCollapsed"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
isFeaturesCollapsed
|
||||
? $t('subscription.preview.showMoreFeatures')
|
||||
: $t('subscription.preview.hideFeatures')
|
||||
}}
|
||||
</span>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'pi text-xs',
|
||||
isFeaturesCollapsed ? 'pi-chevron-down' : 'pi-chevron-up'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
<div v-show="!isFeaturesCollapsed" class="flex flex-col gap-2 pt-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.maxDurationLabel') }}
|
||||
</span>
|
||||
<span class="text-sm font-bold text-base-foreground">
|
||||
{{ maxDuration }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.gpuLabel') }}
|
||||
</span>
|
||||
<i class="pi pi-check text-xs text-success-foreground" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.addCreditsLabel') }}
|
||||
</span>
|
||||
<i class="pi pi-check text-xs text-success-foreground" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.customLoRAsLabel') }}
|
||||
</span>
|
||||
<i
|
||||
v-if="hasCustomLoRAs"
|
||||
class="pi pi-check text-xs text-success-foreground"
|
||||
/>
|
||||
<i v-else class="pi pi-times text-xs text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Due Section -->
|
||||
<div class="flex flex-col gap-2 border-t border-border-subtle pt-8">
|
||||
<div class="flex text-base items-center justify-between">
|
||||
<span class="text-base-foreground">
|
||||
{{ $t('subscription.preview.totalDueToday') }}
|
||||
</span>
|
||||
<span class="font-bold text-base-foreground">
|
||||
${{ totalDueToday }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-muted-foreground text-sm">
|
||||
{{
|
||||
$t('subscription.preview.nextPaymentDue', {
|
||||
date: nextPaymentDate
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="flex flex-col gap-2 pt-8">
|
||||
<!-- Terms Agreement -->
|
||||
<p class="text-xs text-muted-foreground text-center">
|
||||
<i18n-t keypath="subscription.preview.termsAgreement" tag="span">
|
||||
<template #terms>
|
||||
<a
|
||||
href="https://www.comfy.org/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-base-foreground"
|
||||
>
|
||||
{{ $t('subscription.preview.terms') }}
|
||||
</a>
|
||||
</template>
|
||||
<template #privacy>
|
||||
<a
|
||||
href="https://www.comfy.org/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-base-foreground"
|
||||
>
|
||||
{{ $t('subscription.preview.privacyPolicy') }}
|
||||
</a>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
|
||||
<!-- Add Credit Card Button -->
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="w-full rounded-lg"
|
||||
:loading="isLoading"
|
||||
@click="$emit('addCreditCard')"
|
||||
>
|
||||
{{ $t('subscription.preview.addCreditCard') }}
|
||||
</Button>
|
||||
|
||||
<!-- Back Link -->
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="text-muted-foreground hover:text-base-foreground hover:bg-none text-center cursor-pointer transition-colors text-xs"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
{{ $t('subscription.preview.backToAllPlans') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import {
|
||||
getTierCredits,
|
||||
getTierFeatures,
|
||||
getTierPrice
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface Props {
|
||||
tierKey: Exclude<TierKey, 'founder'>
|
||||
billingCycle?: BillingCycle
|
||||
isLoading?: boolean
|
||||
previewData?: PreviewSubscribeResponse | null
|
||||
}
|
||||
|
||||
const {
|
||||
tierKey,
|
||||
billingCycle = 'monthly',
|
||||
isLoading = false,
|
||||
previewData = null
|
||||
} = defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
addCreditCard: []
|
||||
back: []
|
||||
}>()
|
||||
|
||||
const { t, n } = useI18n()
|
||||
|
||||
const isFeaturesCollapsed = ref(true)
|
||||
|
||||
const tierName = computed(() => t(`subscription.tiers.${tierKey}.name`))
|
||||
|
||||
const displayPrice = computed(() => {
|
||||
if (previewData?.new_plan) {
|
||||
return (previewData.new_plan.price_cents / 100).toFixed(0)
|
||||
}
|
||||
return getTierPrice(tierKey, billingCycle === 'yearly')
|
||||
})
|
||||
|
||||
const displayCredits = computed(() => n(getTierCredits(tierKey)))
|
||||
|
||||
const hasCustomLoRAs = computed(() => getTierFeatures(tierKey).customLoRAs)
|
||||
const maxDuration = computed(() => t(`subscription.maxDuration.${tierKey}`))
|
||||
|
||||
const totalDueToday = computed(() => {
|
||||
if (previewData) {
|
||||
return (previewData.cost_today_cents / 100).toFixed(2)
|
||||
}
|
||||
const priceValue = getTierPrice(tierKey, billingCycle === 'yearly')
|
||||
if (billingCycle === 'yearly') {
|
||||
return (priceValue * 12).toFixed(2)
|
||||
}
|
||||
return priceValue.toFixed(2)
|
||||
})
|
||||
|
||||
const nextPaymentDate = computed(() => {
|
||||
if (previewData?.new_plan?.period_end) {
|
||||
return new Date(previewData.new_plan.period_end).toLocaleDateString(
|
||||
'en-US',
|
||||
{
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
}
|
||||
)
|
||||
}
|
||||
const date = new Date()
|
||||
if (billingCycle === 'yearly') {
|
||||
date.setFullYear(date.getFullYear() + 1)
|
||||
} else {
|
||||
date.setMonth(date.getMonth() + 1)
|
||||
}
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -90,6 +90,13 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
|
||||
manageSubscription: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
// Create i18n instance for testing
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
|
||||
@@ -72,10 +72,10 @@ import { computed, defineAsyncComponent } from 'vue'
|
||||
|
||||
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import SubscriptionPanelContentLegacy from '@/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
@@ -91,11 +91,15 @@ const teamWorkspacesEnabled = computed(
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { isActiveSubscription, handleInvoiceHistory } = useSubscription()
|
||||
const { isActiveSubscription, manageSubscription } = useBillingContext()
|
||||
|
||||
const { isLoadingSupport, handleMessageSupport, handleLearnMoreClick } =
|
||||
useSubscriptionActions()
|
||||
|
||||
const handleInvoiceHistory = async () => {
|
||||
await manageSubscription()
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||
|
||||
@@ -1,246 +1,347 @@
|
||||
<template>
|
||||
<div class="grow overflow-auto pt-6">
|
||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||
<div>
|
||||
<div
|
||||
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
|
||||
>
|
||||
<!-- OWNER Unsubscribed State -->
|
||||
<template v-if="showSubscribePrompt">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ $t('subscription.workspaceNotSubscribed') }}
|
||||
</div>
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ $t('subscription.subscriptionRequiredMessage') }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal"
|
||||
@click="handleSubscribeWorkspace"
|
||||
>
|
||||
{{ $t('subscription.subscribeNow') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- MEMBER View - read-only, no subscription data yet -->
|
||||
<template v-else-if="isMemberView">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ $t('subscription.workspaceNotSubscribed') }}
|
||||
</div>
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ $t('subscription.contactOwnerToSubscribe') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Normal Subscribed State (Owner with subscription) -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ subscriptionTierName }}
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1 font-inter font-semibold">
|
||||
<span class="text-2xl">${{ tierPrice }}</span>
|
||||
<span class="text-base">{{ $t('subscription.perMonth') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
class="text-sm text-text-secondary"
|
||||
>
|
||||
<template v-if="isCancelled">
|
||||
{{
|
||||
$t('subscription.expiresDate', {
|
||||
date: formattedEndDate
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
$t('subscription.renewsDate', {
|
||||
date: formattedRenewalDate
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isActiveSubscription && permissions.canManageSubscription"
|
||||
class="flex flex-wrap gap-2 md:ml-auto"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
class="rounded-lg px-4 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
@click="
|
||||
async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ $t('subscription.managePayment') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 text-sm font-normal text-text-primary"
|
||||
@click="showSubscriptionDialog"
|
||||
>
|
||||
{{ $t('subscription.upgradePlan') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="planMenu?.toggle($event)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
<Menu ref="planMenu" :model="planMenuItems" :popup="true" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6 pt-9">
|
||||
<div class="flex flex-col shrink-0">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative flex flex-col gap-6 rounded-2xl p-5',
|
||||
'bg-modal-panel-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="absolute top-4 right-4"
|
||||
:loading="isLoadingBalance"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<i class="pi pi-sync text-text-secondary text-sm" />
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.totalCredits') }}
|
||||
</div>
|
||||
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
||||
<div v-else class="text-2xl font-bold">
|
||||
{{ showZeroState ? '0' : totalCredits }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit Breakdown -->
|
||||
<table class="text-sm text-muted">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="pr-4 font-bold text-left align-middle">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="5rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{
|
||||
showZeroState ? '0 / 0' : includedCreditsDisplay
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="align-middle" :title="creditsRemainingLabel">
|
||||
{{ creditsRemainingLabel }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pr-4 font-bold text-left align-middle">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="3rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{
|
||||
showZeroState ? '0' : prepaidCredits
|
||||
}}</span>
|
||||
</td>
|
||||
<td
|
||||
class="align-middle"
|
||||
:title="$t('subscription.creditsYouveAdded')"
|
||||
>
|
||||
{{ $t('subscription.creditsYouveAdded') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<a
|
||||
href="https://platform.comfy.org/profile/usage"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm underline text-center text-muted"
|
||||
>
|
||||
{{ $t('subscription.viewUsageHistory') }}
|
||||
</a>
|
||||
<Button
|
||||
v-if="isActiveSubscription && !showZeroState"
|
||||
variant="secondary"
|
||||
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
@click="handleAddApiCredits"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-text-primary">
|
||||
{{ $t('subscription.yourPlanIncludes') }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0">
|
||||
<div
|
||||
v-for="benefit in tierBenefits"
|
||||
:key="benefit.key"
|
||||
class="flex items-center gap-2 py-2"
|
||||
>
|
||||
<i
|
||||
v-if="benefit.type === 'feature'"
|
||||
class="pi pi-check text-xs text-text-primary"
|
||||
/>
|
||||
<span
|
||||
v-else-if="benefit.type === 'metric' && benefit.value"
|
||||
class="text-sm font-normal whitespace-nowrap text-text-primary"
|
||||
>
|
||||
{{ benefit.value }}
|
||||
</span>
|
||||
<span class="text-sm text-muted">
|
||||
{{ benefit.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Loading state while subscription is being set up -->
|
||||
<div
|
||||
v-if="isSettingUp"
|
||||
class="rounded-2xl border border-interface-stroke p-6"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-muted-foreground py-4">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
<span>{{ $t('billingOperation.subscriptionProcessing') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View More Details - Outside main content -->
|
||||
<div class="flex items-center gap-2 py-4">
|
||||
<i class="pi pi-external-link text-muted"></i>
|
||||
<a
|
||||
href="https://www.comfy.org/cloud/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm underline hover:opacity-80 text-muted"
|
||||
<template v-else>
|
||||
<!-- Cancelled subscription info card -->
|
||||
<div
|
||||
v-if="isCancelled"
|
||||
class="mb-6 flex gap-1 rounded-2xl border border-warning-background bg-warning-background/20 p-4"
|
||||
>
|
||||
{{ $t('subscription.viewMoreDetailsPlans') }}
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-full text-warning-background"
|
||||
>
|
||||
<i class="pi pi-info-circle" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="text-sm font-bold text-text-primary m-0 pt-1.5">
|
||||
{{ $t('subscription.canceledCard.title') }}
|
||||
</h2>
|
||||
<p class="text-sm text-text-secondary m-0">
|
||||
{{
|
||||
$t('subscription.canceledCard.description', {
|
||||
date: formattedEndDate
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||
<div>
|
||||
<div
|
||||
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
|
||||
>
|
||||
<!-- OWNER Unsubscribed State -->
|
||||
<template v-if="showSubscribePrompt">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ $t('subscription.workspaceNotSubscribed') }}
|
||||
</div>
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ $t('subscription.subscriptionRequiredMessage') }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal"
|
||||
@click="handleSubscribeWorkspace"
|
||||
>
|
||||
{{ $t('subscription.subscribeNow') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- MEMBER View - read-only, workspace not subscribed -->
|
||||
<template v-else-if="isMemberView">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ $t('subscription.workspaceNotSubscribed') }}
|
||||
</div>
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ $t('subscription.contactOwnerToSubscribe') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Normal Subscribed State (Owner with subscription, or member viewing subscribed workspace) -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-text-primary">
|
||||
{{ subscriptionTierName }}
|
||||
</span>
|
||||
<StatusBadge
|
||||
v-if="isCancelled"
|
||||
:label="$t('subscription.canceled')"
|
||||
severity="warn"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1 font-inter font-semibold">
|
||||
<span class="text-2xl">${{ tierPrice }}</span>
|
||||
<span class="text-base">
|
||||
{{
|
||||
isInPersonalWorkspace
|
||||
? $t('subscription.usdPerMonth')
|
||||
: $t('subscription.usdPerMonthPerMember')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
:class="
|
||||
cn(
|
||||
'text-sm',
|
||||
isCancelled
|
||||
? 'text-warning-background'
|
||||
: 'text-text-secondary'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template v-if="isCancelled">
|
||||
{{
|
||||
$t('subscription.expiresDate', {
|
||||
date: formattedEndDate
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
$t('subscription.renewsDate', {
|
||||
date: formattedRenewalDate
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isActiveSubscription && permissions.canManageSubscription"
|
||||
class="flex flex-wrap gap-2 md:ml-auto"
|
||||
>
|
||||
<!-- Cancelled state: show only Resubscribe button -->
|
||||
<template v-if="isCancelled">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 text-sm font-normal"
|
||||
:loading="isResubscribing"
|
||||
@click="handleResubscribe"
|
||||
>
|
||||
{{ $t('subscription.resubscribe') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- Active state: show Manage Payment, Upgrade, and menu -->
|
||||
<template v-else>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
class="rounded-lg px-4 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
@click="manageSubscription"
|
||||
>
|
||||
{{ $t('subscription.managePayment') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 text-sm font-normal text-text-primary"
|
||||
@click="showSubscriptionDialog"
|
||||
>
|
||||
{{ $t('subscription.upgradePlan') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="planMenu?.toggle($event)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
<Menu ref="planMenu" :model="planMenuItems" :popup="true" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row lg:items-stretch gap-6 pt-6">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col gap-3 h-full">
|
||||
<div
|
||||
class="relative flex flex-col gap-6 rounded-2xl p-5 bg-secondary-background justify-between h-full"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="absolute top-4 right-4"
|
||||
:loading="isLoadingBalance"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<i class="pi pi-sync text-text-secondary text-sm" />
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.totalCredits') }}
|
||||
</div>
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="8rem"
|
||||
height="2rem"
|
||||
/>
|
||||
<div v-else class="text-2xl font-bold">
|
||||
{{ showZeroState ? '0' : totalCredits }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit Breakdown -->
|
||||
<table class="text-sm text-muted">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="pr-4 font-bold text-left align-middle">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="5rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{
|
||||
showZeroState ? '0 / 0' : includedCreditsDisplay
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="align-middle" :title="creditsRemainingLabel">
|
||||
{{ creditsRemainingLabel }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pr-4 font-bold text-left align-middle">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="3rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{
|
||||
showZeroState ? '0' : prepaidCredits
|
||||
}}</span>
|
||||
</td>
|
||||
<td
|
||||
class="align-middle"
|
||||
:title="$t('subscription.creditsYouveAdded')"
|
||||
>
|
||||
{{ $t('subscription.creditsYouveAdded') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
isActiveSubscription &&
|
||||
!showZeroState &&
|
||||
permissions.canTopUp
|
||||
"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
@click="handleAddApiCredits"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isActiveSubscription" class="flex flex-col gap-2">
|
||||
<div class="text-sm text-text-primary">
|
||||
{{ $t('subscription.yourPlanIncludes') }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0">
|
||||
<div
|
||||
v-for="benefit in tierBenefits"
|
||||
:key="benefit.key"
|
||||
class="flex items-center gap-2 py-2"
|
||||
>
|
||||
<i
|
||||
v-if="benefit.type === 'feature'"
|
||||
class="pi pi-check text-xs text-text-primary"
|
||||
/>
|
||||
<i
|
||||
v-else-if="benefit.type === 'icon' && benefit.icon"
|
||||
:class="[benefit.icon, 'text-xs text-text-primary']"
|
||||
/>
|
||||
<span
|
||||
v-else-if="benefit.type === 'metric' && benefit.value"
|
||||
class="text-sm font-normal whitespace-nowrap text-text-primary"
|
||||
>
|
||||
{{ benefit.value }}
|
||||
</span>
|
||||
<span class="text-sm text-muted">
|
||||
{{ benefit.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members invoice card -->
|
||||
<div
|
||||
v-if="
|
||||
isActiveSubscription &&
|
||||
!isInPersonalWorkspace &&
|
||||
permissions.canManageSubscription
|
||||
"
|
||||
class="mt-6 flex gap-1 rounded-2xl border border-interface-stroke p-6 justify-between items-center text-sm"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h4 class="text-sm text-text-primary m-0">
|
||||
{{ $t('subscription.nextMonthInvoice') }}
|
||||
</h4>
|
||||
<span
|
||||
class="text-muted-foreground underline cursor-pointer"
|
||||
@click="manageSubscription"
|
||||
>
|
||||
{{ $t('subscription.invoiceHistory') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 items-end">
|
||||
<h4 class="m-0 font-bold">${{ nextMonthInvoice }}</h4>
|
||||
<h5 class="m-0 text-muted-foreground">
|
||||
{{ $t('subscription.memberCount', memberCount) }}
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View More Details - Outside main content -->
|
||||
<div
|
||||
v-if="permissions.canManageSubscription"
|
||||
class="flex items-center gap-2 py-6"
|
||||
>
|
||||
<i class="pi pi-external-link text-muted"></i>
|
||||
<a
|
||||
href="https://www.comfy.org/cloud/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm underline hover:opacity-80 text-muted"
|
||||
>
|
||||
{{ $t('subscription.viewMoreDetailsPlans') }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -251,13 +352,16 @@ import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useBillingOperationStore } from '@/stores/billingOperationStore'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
@@ -269,51 +373,121 @@ import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { isWorkspaceSubscribed, isInPersonalWorkspace } =
|
||||
const { isWorkspaceSubscribed, isInPersonalWorkspace, members } =
|
||||
storeToRefs(workspaceStore)
|
||||
const { subscribeWorkspace } = workspaceStore
|
||||
const { permissions, workspaceRole } = useWorkspaceUI()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { t, n } = useI18n()
|
||||
const { showBillingComingSoonDialog } = useDialogService()
|
||||
const toast = useToast()
|
||||
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
const isSettingUp = computed(() => billingOperationStore.isSettingUp)
|
||||
|
||||
const {
|
||||
isActiveSubscription,
|
||||
subscription,
|
||||
showSubscriptionDialog,
|
||||
manageSubscription,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
getMaxSeats
|
||||
} = useBillingContext()
|
||||
|
||||
const { showCancelSubscriptionDialog } = useDialogService()
|
||||
|
||||
const isResubscribing = ref(false)
|
||||
|
||||
async function handleResubscribe() {
|
||||
isResubscribing.value = true
|
||||
try {
|
||||
await workspaceApi.resubscribe()
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.resubscribeSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to resubscribe'
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: message,
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
isResubscribing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Only show cancelled state for team workspaces (workspace billing)
|
||||
// Personal workspaces use legacy billing which has different cancellation semantics
|
||||
const isCancelled = computed(
|
||||
() =>
|
||||
!isInPersonalWorkspace.value && (subscription.value?.isCancelled ?? false)
|
||||
)
|
||||
|
||||
// Show subscribe prompt to owners without active subscription
|
||||
// Don't show if subscription is cancelled (still active until end date)
|
||||
const showSubscribePrompt = computed(() => {
|
||||
if (workspaceRole.value !== 'owner') return false
|
||||
if (!permissions.value.canManageSubscription) return false
|
||||
if (isCancelled.value) return false
|
||||
if (isInPersonalWorkspace.value) return !isActiveSubscription.value
|
||||
return !isWorkspaceSubscribed.value
|
||||
})
|
||||
|
||||
// MEMBER view - members can't manage subscription, show read-only zero state
|
||||
const isMemberView = computed(() => !permissions.value.canManageSubscription)
|
||||
// MEMBER view without subscription - members can't manage subscription
|
||||
const isMemberView = computed(
|
||||
() =>
|
||||
!permissions.value.canManageSubscription &&
|
||||
!isActiveSubscription.value &&
|
||||
!isWorkspaceSubscribed.value
|
||||
)
|
||||
|
||||
// Show zero state for credits (no real billing data yet)
|
||||
const showZeroState = computed(
|
||||
() => showSubscribePrompt.value || isMemberView.value
|
||||
)
|
||||
|
||||
// Subscribe workspace - show billing coming soon dialog for team workspaces
|
||||
// Subscribe workspace - opens the subscription dialog (personal or workspace variant)
|
||||
function handleSubscribeWorkspace() {
|
||||
if (!isInPersonalWorkspace.value) {
|
||||
showBillingComingSoonDialog()
|
||||
return
|
||||
}
|
||||
subscribeWorkspace('PRO_MONTHLY')
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
const subscriptionTier = computed(() => subscription.value?.tier ?? null)
|
||||
const isYearlySubscription = computed(
|
||||
() => subscription.value?.duration === 'ANNUAL'
|
||||
)
|
||||
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isCancelled,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
subscriptionTier,
|
||||
subscriptionTierName,
|
||||
subscriptionStatus,
|
||||
isYearlySubscription
|
||||
} = useSubscription()
|
||||
const formattedRenewalDate = computed(() => {
|
||||
if (!subscription.value?.renewalDate) return ''
|
||||
const renewalDate = new Date(subscription.value.renewalDate)
|
||||
return renewalDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
})
|
||||
|
||||
const { show: showSubscriptionDialog } = useSubscriptionDialog()
|
||||
const formattedEndDate = computed(() => {
|
||||
if (!subscription.value?.endDate) return ''
|
||||
const endDate = new Date(subscription.value.endDate)
|
||||
return endDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
})
|
||||
|
||||
const subscriptionTierName = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
if (!tier) return ''
|
||||
const key = TIER_TO_KEY[tier] ?? 'standard'
|
||||
const baseName = t(`subscription.tiers.${key}.name`)
|
||||
return isYearlySubscription.value
|
||||
? t('subscription.tierNameYearly', { name: baseName })
|
||||
: baseName
|
||||
})
|
||||
|
||||
const planMenu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
@@ -321,8 +495,8 @@ const planMenuItems = computed(() => [
|
||||
{
|
||||
label: t('subscription.cancelSubscription'),
|
||||
icon: 'pi pi-times',
|
||||
command: async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
command: () => {
|
||||
showCancelSubscriptionDialog(subscription.value?.endDate ?? undefined)
|
||||
}
|
||||
}
|
||||
])
|
||||
@@ -336,9 +510,12 @@ const tierPrice = computed(() =>
|
||||
getTierPrice(tierKey.value, isYearlySubscription.value)
|
||||
)
|
||||
|
||||
const memberCount = computed(() => members.value.length)
|
||||
const nextMonthInvoice = computed(() => memberCount.value * tierPrice.value)
|
||||
|
||||
const refillsDate = computed(() => {
|
||||
if (!subscriptionStatus.value?.renewal_date) return ''
|
||||
const date = new Date(subscriptionStatus.value.renewal_date)
|
||||
if (!subscription.value?.renewalDate) return ''
|
||||
const date = new Date(subscription.value.renewalDate)
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = String(date.getFullYear()).slice(-2)
|
||||
@@ -366,19 +543,31 @@ const includedCreditsDisplay = computed(
|
||||
)
|
||||
|
||||
// Tier benefits for v-for loop
|
||||
type BenefitType = 'metric' | 'feature'
|
||||
type BenefitType = 'metric' | 'feature' | 'icon'
|
||||
|
||||
interface Benefit {
|
||||
key: string
|
||||
type: BenefitType
|
||||
label: string
|
||||
value?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
const tierBenefits = computed((): Benefit[] => {
|
||||
const key = tierKey.value
|
||||
|
||||
const benefits: Benefit[] = [
|
||||
const benefits: Benefit[] = []
|
||||
|
||||
if (!isInPersonalWorkspace.value) {
|
||||
benefits.push({
|
||||
key: 'members',
|
||||
type: 'icon',
|
||||
label: t('subscription.membersLabel', { count: getMaxSeats(key) }),
|
||||
icon: 'pi pi-user'
|
||||
})
|
||||
}
|
||||
|
||||
benefits.push(
|
||||
{
|
||||
key: 'maxDuration',
|
||||
type: 'metric',
|
||||
@@ -395,7 +584,7 @@ const tierBenefits = computed((): Benefit[] => {
|
||||
type: 'feature',
|
||||
label: t('subscription.addCreditsLabel')
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
if (getTierFeatures(key).customLoRAs) {
|
||||
benefits.push({
|
||||
@@ -436,6 +625,7 @@ function handleWindowFocus() {
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
void Promise.all([fetchStatus(), fetchBalance()])
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -127,7 +127,8 @@ import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
|
||||
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
@@ -139,8 +140,10 @@ const emit = defineEmits<{
|
||||
close: [subscribed: boolean]
|
||||
}>()
|
||||
|
||||
const { fetchStatus, isActiveSubscription, isSubscriptionEnabled } =
|
||||
useSubscription()
|
||||
const { fetchStatus, isActiveSubscription } = useBillingContext()
|
||||
|
||||
const isSubscriptionEnabled = (): boolean =>
|
||||
Boolean(isCloud && window.__CONFIG__?.subscription_required)
|
||||
|
||||
// Legacy price for non-tier flow with locale-aware formatting
|
||||
const formattedMonthlyPrice = new Intl.NumberFormat(
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative flex flex-col p-4 pt-8 md:p-16 !overflow-y-auto h-full gap-8"
|
||||
>
|
||||
<Button
|
||||
v-if="checkoutStep === 'preview'"
|
||||
size="icon"
|
||||
variant="muted-textonly"
|
||||
class="rounded-full shrink-0 text-text-secondary hover:bg-white/10 absolute left-2.5 top-2.5"
|
||||
:aria-label="$t('g.back')"
|
||||
@click="handleBackToPricing"
|
||||
>
|
||||
<i class="pi pi-arrow-left text-xl" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="muted-textonly"
|
||||
class="rounded-full shrink-0 text-text-secondary hover:bg-white/10 absolute right-2.5 top-2.5"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="handleClose"
|
||||
>
|
||||
<i class="pi pi-times text-xl" />
|
||||
</Button>
|
||||
|
||||
<!-- Pricing Table Step -->
|
||||
<PricingTableWorkspace
|
||||
v-if="checkoutStep === 'pricing'"
|
||||
class="flex-1"
|
||||
:is-loading="isLoadingPreview || isResubscribing"
|
||||
:loading-tier="loadingTier"
|
||||
@subscribe="handleSubscribeClick"
|
||||
@resubscribe="handleResubscribe"
|
||||
/>
|
||||
|
||||
<!-- Subscription Preview Step - New Subscription -->
|
||||
<SubscriptionAddPaymentPreviewWorkspace
|
||||
v-else-if="
|
||||
checkoutStep === 'preview' &&
|
||||
previewData &&
|
||||
previewData.transition_type === 'new_subscription'
|
||||
"
|
||||
:preview-data="previewData"
|
||||
:tier-key="selectedTierKey!"
|
||||
:billing-cycle="selectedBillingCycle"
|
||||
:is-loading="isSubscribing || isPolling"
|
||||
@add-credit-card="handleAddCreditCard"
|
||||
@back="handleBackToPricing"
|
||||
/>
|
||||
|
||||
<!-- Subscription Preview Step - Plan Transition -->
|
||||
<SubscriptionTransitionPreviewWorkspace
|
||||
v-else-if="
|
||||
checkoutStep === 'preview' &&
|
||||
previewData &&
|
||||
previewData.transition_type !== 'new_subscription'
|
||||
"
|
||||
:preview-data="previewData"
|
||||
:is-loading="isSubscribing || isPolling"
|
||||
@confirm="handleConfirmTransition"
|
||||
@back="handleBackToPricing"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useBillingOperationStore } from '@/stores/billingOperationStore'
|
||||
|
||||
import PricingTableWorkspace from './PricingTableWorkspace.vue'
|
||||
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
|
||||
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
|
||||
|
||||
type CheckoutStep = 'pricing' | 'preview'
|
||||
type CheckoutTierKey = Exclude<TierKey, 'founder'>
|
||||
|
||||
const props = defineProps<{
|
||||
onClose: () => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [subscribed: boolean]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } =
|
||||
useBillingContext()
|
||||
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
||||
|
||||
const checkoutStep = ref<CheckoutStep>('pricing')
|
||||
const isLoadingPreview = ref(false)
|
||||
const loadingTier = ref<CheckoutTierKey | null>(null)
|
||||
const isSubscribing = ref(false)
|
||||
const isResubscribing = ref(false)
|
||||
const previewData = ref<PreviewSubscribeResponse | null>(null)
|
||||
const selectedTierKey = ref<CheckoutTierKey | null>(null)
|
||||
const selectedBillingCycle = ref<BillingCycle>('yearly')
|
||||
|
||||
function getApiPlanSlug(
|
||||
tierKey: CheckoutTierKey,
|
||||
billingCycle: BillingCycle
|
||||
): string | null {
|
||||
const apiDuration = billingCycle === 'yearly' ? 'ANNUAL' : 'MONTHLY'
|
||||
const apiTier = tierKey.toUpperCase()
|
||||
const plan = plans.value.find(
|
||||
(p) => p.tier === apiTier && p.duration === apiDuration
|
||||
)
|
||||
return plan?.slug ?? null
|
||||
}
|
||||
|
||||
async function handleSubscribeClick(payload: {
|
||||
tierKey: CheckoutTierKey
|
||||
billingCycle: BillingCycle
|
||||
}) {
|
||||
const { tierKey, billingCycle } = payload
|
||||
|
||||
isLoadingPreview.value = true
|
||||
loadingTier.value = tierKey
|
||||
selectedTierKey.value = tierKey
|
||||
selectedBillingCycle.value = billingCycle
|
||||
|
||||
try {
|
||||
const planSlug = getApiPlanSlug(tierKey, billingCycle)
|
||||
if (!planSlug) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Unable to subscribe',
|
||||
detail: 'This plan is not available',
|
||||
life: 5000
|
||||
})
|
||||
return
|
||||
}
|
||||
const response = await previewSubscribe(planSlug)
|
||||
|
||||
if (!response || !response.allowed) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Unable to subscribe',
|
||||
detail: response?.reason || 'This plan is not available',
|
||||
life: 5000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
previewData.value = response
|
||||
checkoutStep.value = 'preview'
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to load subscription preview'
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: message,
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
isLoadingPreview.value = false
|
||||
loadingTier.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackToPricing() {
|
||||
checkoutStep.value = 'pricing'
|
||||
previewData.value = null
|
||||
}
|
||||
|
||||
async function handleAddCreditCard() {
|
||||
if (!selectedTierKey.value) return
|
||||
|
||||
isSubscribing.value = true
|
||||
try {
|
||||
const planSlug = getApiPlanSlug(
|
||||
selectedTierKey.value,
|
||||
selectedBillingCycle.value
|
||||
)
|
||||
if (!planSlug) return
|
||||
const response = await subscribe(
|
||||
planSlug,
|
||||
'https://www.comfy.org/payment/success',
|
||||
'https://www.comfy.org/payment/failed'
|
||||
)
|
||||
|
||||
if (!response) return
|
||||
|
||||
if (response.status === 'subscribed') {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.required.pollingSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
emit('close', true)
|
||||
} else if (
|
||||
response.status === 'needs_payment_method' &&
|
||||
response.payment_method_url
|
||||
) {
|
||||
window.open(response.payment_method_url, '_blank')
|
||||
billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'subscription'
|
||||
)
|
||||
} else if (response.status === 'pending_payment') {
|
||||
billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'subscription'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to subscribe'
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: message,
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
isSubscribing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmTransition() {
|
||||
if (!selectedTierKey.value) return
|
||||
|
||||
isSubscribing.value = true
|
||||
try {
|
||||
const planSlug = getApiPlanSlug(
|
||||
selectedTierKey.value,
|
||||
selectedBillingCycle.value
|
||||
)
|
||||
if (!planSlug) return
|
||||
const response = await subscribe(
|
||||
planSlug,
|
||||
'https://www.comfy.org/payment/success',
|
||||
'https://www.comfy.org/payment/failed'
|
||||
)
|
||||
|
||||
if (!response) return
|
||||
|
||||
if (response.status === 'subscribed') {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.required.pollingSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
emit('close', true)
|
||||
} else if (
|
||||
response.status === 'needs_payment_method' &&
|
||||
response.payment_method_url
|
||||
) {
|
||||
window.open(response.payment_method_url, '_blank')
|
||||
billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'subscription'
|
||||
)
|
||||
} else if (response.status === 'pending_payment') {
|
||||
billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'subscription'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to update subscription'
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: message,
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
isSubscribing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResubscribe() {
|
||||
isResubscribing.value = true
|
||||
try {
|
||||
await workspaceApi.resubscribe()
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.resubscribeSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
emit('close', true)
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to resubscribe'
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: message,
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
isResubscribing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
props.onClose()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.legacy-dialog :deep(.bg-comfy-menu-secondary) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.legacy-dialog :deep(.p-button) {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<h2 class="text-xl lg:text-2xl text-muted-foreground m-0 text-center mb-8">
|
||||
{{ $t('subscription.preview.confirmPlanChange') }}
|
||||
</h2>
|
||||
<div
|
||||
class="flex flex-col justify-between items-stretch mx-auto text-sm h-full"
|
||||
>
|
||||
<div>
|
||||
<!-- Plan Comparison Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Current Plan -->
|
||||
<div class="flex flex-col gap-1 w-[250px]">
|
||||
<span class="text-base-foreground text-sm">
|
||||
{{ currentTierName }}
|
||||
</span>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-2xl font-semibold text-base-foreground">
|
||||
${{ currentDisplayPrice }}
|
||||
</span>
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.usdPerMonthPerMember') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-muted-foreground text-sm">
|
||||
<i class="icon-[lucide--component] text-amber-400 text-xs" />
|
||||
<span
|
||||
>{{ currentDisplayCredits }}
|
||||
{{ $t('subscription.perMonth') }}</span
|
||||
>
|
||||
</div>
|
||||
<span class="text-muted-foreground text-sm inline">
|
||||
{{
|
||||
$t('subscription.preview.ends', { date: currentPeriodEndDate })
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<i class="pi pi-arrow-right text-muted-foreground w-8 h-8" />
|
||||
|
||||
<!-- New Plan -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base-foreground text-sm font-semibold">
|
||||
{{ newTierName }}
|
||||
</span>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-2xl font-semibold text-base-foreground">
|
||||
${{ newDisplayPrice }}
|
||||
</span>
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('subscription.usdPerMonthPerMember') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-muted-foreground text-sm">
|
||||
<i class="icon-[lucide--component] text-amber-400 text-xs" />
|
||||
<span
|
||||
>{{ newDisplayCredits }} {{ $t('subscription.perMonth') }}</span
|
||||
>
|
||||
</div>
|
||||
<span class="text-muted-foreground text-sm">
|
||||
{{ $t('subscription.preview.starting', { date: effectiveDate }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credits Section -->
|
||||
<div class="flex flex-col gap-3 pt-12 pb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base-foreground">
|
||||
{{ $t('subscription.preview.eachMonthCreditsRefill') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<i class="icon-[lucide--component] text-amber-400 text-sm" />
|
||||
<span class="font-bold text-base-foreground">
|
||||
{{ newDisplayCredits }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proration Section -->
|
||||
<div
|
||||
v-if="showProration"
|
||||
class="flex flex-col gap-2 border-t border-border-subtle pt-6 pb-6"
|
||||
>
|
||||
<div
|
||||
v-if="proratedRefundCents > 0"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<span class="text-muted-foreground">
|
||||
{{
|
||||
$t('subscription.preview.proratedRefund', {
|
||||
plan: currentTierName
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="text-muted-foreground">-${{ proratedRefund }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="proratedChargeCents > 0"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<span class="text-muted-foreground">
|
||||
{{
|
||||
$t('subscription.preview.proratedCharge', { plan: newTierName })
|
||||
}}
|
||||
</span>
|
||||
<span class="text-muted-foreground">${{ proratedCharge }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Due Section -->
|
||||
<div class="flex flex-col gap-2 border-t border-border-subtle pt-6">
|
||||
<div class="flex text-base items-center justify-between">
|
||||
<span class="text-base-foreground">
|
||||
{{ $t('subscription.preview.totalDueToday') }}
|
||||
</span>
|
||||
<span class="font-bold text-base-foreground">
|
||||
${{ totalDueToday }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-muted-foreground text-sm">
|
||||
{{
|
||||
$t('subscription.preview.nextPaymentDue', {
|
||||
date: nextPaymentDate
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex flex-col gap-2 pt-8">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="w-full rounded-lg"
|
||||
:loading="isLoading"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ $t('subscription.preview.confirm') }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="text-muted-foreground hover:text-base-foreground hover:bg-none text-center cursor-pointer transition-colors text-xs"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
{{ $t('subscription.preview.backToAllPlans') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
interface Props {
|
||||
previewData: PreviewSubscribeResponse
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const { previewData, isLoading = false } = defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
confirm: []
|
||||
back: []
|
||||
}>()
|
||||
|
||||
const { t, n } = useI18n()
|
||||
|
||||
function formatTierName(tier: string): string {
|
||||
return t(`subscription.tiers.${tier.toLowerCase()}.name`)
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const currentTierName = computed(() =>
|
||||
previewData.current_plan ? formatTierName(previewData.current_plan.tier) : ''
|
||||
)
|
||||
|
||||
const newTierName = computed(() => formatTierName(previewData.new_plan.tier))
|
||||
|
||||
const currentDisplayPrice = computed(() =>
|
||||
previewData.current_plan
|
||||
? (previewData.current_plan.price_cents / 100).toFixed(0)
|
||||
: '0'
|
||||
)
|
||||
|
||||
const newDisplayPrice = computed(() =>
|
||||
(previewData.new_plan.price_cents / 100).toFixed(0)
|
||||
)
|
||||
|
||||
const currentDisplayCredits = computed(() => {
|
||||
if (!previewData.current_plan) return n(0)
|
||||
const tierKey = previewData.current_plan.tier.toLowerCase() as
|
||||
| 'standard'
|
||||
| 'creator'
|
||||
| 'pro'
|
||||
return n(getTierCredits(tierKey))
|
||||
})
|
||||
|
||||
const newDisplayCredits = computed(() => {
|
||||
const tierKey = previewData.new_plan.tier.toLowerCase() as
|
||||
| 'standard'
|
||||
| 'creator'
|
||||
| 'pro'
|
||||
return n(getTierCredits(tierKey))
|
||||
})
|
||||
|
||||
const currentPeriodEndDate = computed(() =>
|
||||
previewData.current_plan?.period_end
|
||||
? formatDate(previewData.current_plan.period_end)
|
||||
: ''
|
||||
)
|
||||
|
||||
const effectiveDate = computed(() => formatDate(previewData.effective_at))
|
||||
|
||||
const showProration = computed(() => previewData.is_immediate)
|
||||
|
||||
const proratedRefundCents = computed(() => {
|
||||
if (!previewData.current_plan || !previewData.is_immediate) return 0
|
||||
const chargeToday = previewData.cost_today_cents
|
||||
const newPlanCost = previewData.new_plan.price_cents
|
||||
if (chargeToday < newPlanCost) {
|
||||
return newPlanCost - chargeToday
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
const proratedRefund = computed(() =>
|
||||
(proratedRefundCents.value / 100).toFixed(2)
|
||||
)
|
||||
|
||||
const proratedChargeCents = computed(() => {
|
||||
if (!previewData.is_immediate) return 0
|
||||
return previewData.cost_today_cents
|
||||
})
|
||||
|
||||
const proratedCharge = computed(() =>
|
||||
(proratedChargeCents.value / 100).toFixed(2)
|
||||
)
|
||||
|
||||
const totalDueToday = computed(() =>
|
||||
(previewData.cost_today_cents / 100).toFixed(2)
|
||||
)
|
||||
|
||||
const nextPaymentDate = computed(() =>
|
||||
previewData.new_plan.period_end
|
||||
? formatDate(previewData.new_plan.period_end)
|
||||
: formatDate(previewData.effective_at)
|
||||
)
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user