mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 10:12:11 +00:00
Compare commits
4 Commits
cloud/1.38
...
backport-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4367aea2f1 | ||
|
|
ebc57e7aad | ||
|
|
d44924a73d | ||
|
|
090da3e2c9 |
52
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
Normal file
52
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: 'CI: Dist Telemetry Scan'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build project
|
||||
run: pnpm build
|
||||
|
||||
- name: Scan dist for telemetry references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e 'Google Tag Manager' \
|
||||
-e '(?i)\bgtm\.js\b' \
|
||||
-e '(?i)googletagmanager\.com/gtm\.js\\?id=' \
|
||||
-e '(?i)googletagmanager\.com/ns\.html\\?id=' \
|
||||
dist; then
|
||||
echo 'Telemetry references found in dist assets.'
|
||||
exit 1
|
||||
fi
|
||||
echo 'No telemetry references found in dist assets.'
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 99 KiB |
14
global.d.ts
vendored
14
global.d.ts
vendored
@@ -5,8 +5,14 @@ declare const __ALGOLIA_APP_ID__: string
|
||||
declare const __ALGOLIA_API_KEY__: string
|
||||
declare const __USE_PROD_CONFIG__: boolean
|
||||
|
||||
interface ImpactQueueFunction {
|
||||
(...args: unknown[]): void
|
||||
a?: unknown[][]
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__CONFIG__: {
|
||||
gtm_container_id?: string
|
||||
mixpanel_token?: string
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
@@ -30,6 +36,14 @@ interface Window {
|
||||
badge?: string
|
||||
}
|
||||
}
|
||||
__ga_identity__?: {
|
||||
client_id?: string
|
||||
session_id?: string
|
||||
session_number?: string
|
||||
}
|
||||
dataLayer?: Array<Record<string, unknown>>
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
|
||||
@@ -6,15 +6,14 @@
|
||||
<!-- Section Header -->
|
||||
<div class="flex w-full items-center gap-9">
|
||||
<div class="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span class="text-base font-semibold text-base-foreground">
|
||||
<span
|
||||
v-if="uiConfig.showMembersList"
|
||||
class="text-base font-semibold text-base-foreground"
|
||||
>
|
||||
<template v-if="activeView === 'active'">
|
||||
{{
|
||||
$t('workspacePanel.members.membersCount', {
|
||||
count:
|
||||
isSingleSeatPlan || isPersonalWorkspace
|
||||
? 1
|
||||
: members.length,
|
||||
maxSeats: maxSeats
|
||||
count: members.length
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
@@ -28,10 +27,7 @@
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="uiConfig.showSearch && !isSingleSeatPlan"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<div v-if="uiConfig.showSearch" class="flex items-start gap-2">
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search')"
|
||||
@@ -49,16 +45,14 @@
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center py-2',
|
||||
isSingleSeatPlan
|
||||
? 'grid-cols-1 py-0'
|
||||
: activeView === 'pending'
|
||||
? uiConfig.pendingGridCols
|
||||
: uiConfig.headerGridCols
|
||||
activeView === 'pending'
|
||||
? uiConfig.pendingGridCols
|
||||
: uiConfig.headerGridCols
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Tab buttons in first column -->
|
||||
<div v-if="!isSingleSeatPlan" class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:variant="
|
||||
activeView === 'active' ? 'secondary' : 'muted-textonly'
|
||||
@@ -107,19 +101,17 @@
|
||||
<div />
|
||||
</template>
|
||||
<template v-else>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -174,7 +166,7 @@
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center rounded-lg p-2',
|
||||
isSingleSeatPlan ? 'grid-cols-1' : uiConfig.membersGridCols,
|
||||
uiConfig.membersGridCols,
|
||||
index % 2 === 1 && 'bg-secondary-background/50'
|
||||
)
|
||||
"
|
||||
@@ -214,14 +206,14 @@
|
||||
</div>
|
||||
<!-- Join date -->
|
||||
<span
|
||||
v-if="uiConfig.showDateColumn && !isSingleSeatPlan"
|
||||
v-if="uiConfig.showDateColumn"
|
||||
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 && !isSingleSeatPlan"
|
||||
v-if="permissions.canRemoveMembers"
|
||||
class="flex items-center justify-end"
|
||||
>
|
||||
<Button
|
||||
@@ -245,29 +237,8 @@
|
||||
</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-if="activeView === 'pending'">
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(invite, index) in filteredPendingInvites"
|
||||
:key="invite.id"
|
||||
@@ -371,8 +342,6 @@ 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,
|
||||
@@ -398,27 +367,6 @@ 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')
|
||||
|
||||
@@ -55,12 +55,8 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="!isSingleSeatPlan && isInviteLimitReached"
|
||||
:class="
|
||||
!isSingleSeatPlan &&
|
||||
isInviteLimitReached &&
|
||||
'opacity-50 cursor-not-allowed'
|
||||
"
|
||||
:disabled="isInviteLimitReached"
|
||||
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'"
|
||||
:aria-label="$t('workspacePanel.inviteMember')"
|
||||
@click="handleInviteMember"
|
||||
>
|
||||
@@ -133,8 +129,6 @@ 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'
|
||||
@@ -150,19 +144,8 @@ 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,
|
||||
@@ -204,16 +187,11 @@ 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()
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<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,42 +0,0 @@
|
||||
<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>
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
Plan,
|
||||
PreviewSubscribeResponse,
|
||||
@@ -74,5 +73,4 @@ export interface BillingState {
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
type: ComputedRef<BillingType>
|
||||
getMaxSeats: (tierKey: TierKey) => number
|
||||
}
|
||||
|
||||
@@ -1,50 +1,25 @@
|
||||
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()
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
|
||||
const isInPersonalWorkspace = { value: true }
|
||||
const activeWorkspace = { value: { id: 'personal-123', type: 'personal' } }
|
||||
return {
|
||||
...(original as Record<string, unknown>),
|
||||
createSharedComposable: (fn: (...args: unknown[]) => unknown) => fn
|
||||
useTeamWorkspaceStore: () => ({
|
||||
isInPersonalWorkspace: isInPersonalWorkspace.value,
|
||||
activeWorkspace: activeWorkspace.value,
|
||||
_setPersonalWorkspace: (value: boolean) => {
|
||||
isInPersonalWorkspace.value = value
|
||||
activeWorkspace.value = value
|
||||
? { id: 'personal-123', type: 'personal' }
|
||||
: { id: 'team-456', type: 'team' }
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
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 },
|
||||
@@ -77,18 +52,20 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
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/cloud/subscription/composables/useBillingPlans', () => {
|
||||
const plans = { value: [] }
|
||||
const currentPlanSlug = { value: null }
|
||||
return {
|
||||
useBillingPlans: () => ({
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
isLoading: { value: false },
|
||||
error: { value: null },
|
||||
fetchPlans: vi.fn().mockResolvedValue(undefined),
|
||||
getPlanBySlug: vi.fn().mockReturnValue(null)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: {
|
||||
@@ -111,9 +88,6 @@ describe('useBillingContext', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
mockPlans.value = []
|
||||
})
|
||||
|
||||
it('returns legacy type for personal workspace', () => {
|
||||
@@ -187,51 +161,4 @@ describe('useBillingContext', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,11 +2,6 @@ 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 {
|
||||
@@ -120,16 +115,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
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
|
||||
@@ -238,7 +223,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
getMaxSeats,
|
||||
|
||||
initialize,
|
||||
fetchStatus,
|
||||
|
||||
@@ -61,7 +61,7 @@ export function useFeatureFlags() {
|
||||
get onboardingSurveyEnabled() {
|
||||
return (
|
||||
remoteConfig.value.onboarding_survey_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, false)
|
||||
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, true)
|
||||
)
|
||||
},
|
||||
get linearToggleEnabled() {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
import type {
|
||||
ContextMenuDivElement,
|
||||
IContextMenuOptions,
|
||||
@@ -7,38 +5,6 @@ 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 (
|
||||
@@ -157,7 +123,7 @@ export class ContextMenu<TValue = unknown> {
|
||||
if (options.title) {
|
||||
const element = document.createElement('div')
|
||||
element.className = 'litemenu-title'
|
||||
element.textContent = options.title
|
||||
element.innerHTML = options.title
|
||||
root.append(element)
|
||||
}
|
||||
|
||||
@@ -252,18 +218,11 @@ export class ContextMenu<TValue = unknown> {
|
||||
if (value === null) {
|
||||
element.classList.add('separator')
|
||||
} else {
|
||||
const label = name === null ? '' : String(name)
|
||||
const innerHtml = name === null ? '' : String(name)
|
||||
if (typeof value === 'string') {
|
||||
element.textContent = label
|
||||
element.innerHTML = innerHtml
|
||||
} else {
|
||||
// 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
|
||||
}
|
||||
element.innerHTML = value?.title ?? innerHtml
|
||||
|
||||
if (value.disabled) {
|
||||
disabled = true
|
||||
|
||||
@@ -2117,7 +2117,7 @@
|
||||
"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 and invite members",
|
||||
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud",
|
||||
"contactOwnerToSubscribe": "Contact the workspace owner to subscribe",
|
||||
"description": "Choose the best plan for you",
|
||||
"descriptionWorkspace": "Choose the best plan for your workspace",
|
||||
@@ -2206,7 +2206,7 @@
|
||||
"placeholder": "Dashboard workspace settings"
|
||||
},
|
||||
"members": {
|
||||
"membersCount": "{count}/{maxSeats} Members",
|
||||
"membersCount": "{count}/50 Members",
|
||||
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
|
||||
"tabs": {
|
||||
"active": "Active",
|
||||
@@ -2222,9 +2222,6 @@
|
||||
"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,",
|
||||
@@ -2263,14 +2260,6 @@
|
||||
"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",
|
||||
@@ -2899,9 +2888,8 @@
|
||||
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
|
||||
},
|
||||
"inviteAccepted": "Invite Accepted",
|
||||
"addedToWorkspace": "You have been added to:",
|
||||
"inviteFailed": "Failed to Accept Invite",
|
||||
"viewWorkspace": "View workspace"
|
||||
"addedToWorkspace": "You have been added to {workspaceName}",
|
||||
"inviteFailed": "Failed to Accept Invite"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
"errors": {
|
||||
|
||||
@@ -25,12 +25,15 @@ import { i18n } from './i18n'
|
||||
* CRITICAL: Load remote config FIRST for cloud builds to ensure
|
||||
* window.__CONFIG__is available for all modules during initialization
|
||||
*/
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
const isCloud = __DISTRIBUTION__ === 'cloud'
|
||||
|
||||
if (isCloud) {
|
||||
const { refreshRemoteConfig } =
|
||||
await import('@/platform/remoteConfig/refreshRemoteConfig')
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
const { initTelemetry } = await import('@/platform/telemetry/initTelemetry')
|
||||
await initTelemetry()
|
||||
}
|
||||
|
||||
const ComfyUIPreset = definePreset(Aura, {
|
||||
|
||||
@@ -40,7 +40,8 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
|
||||
const subscriptionMocks = vi.hoisted(() => ({
|
||||
isActiveSubscription: { value: false },
|
||||
isInitialized: { value: true }
|
||||
isInitialized: { value: true },
|
||||
subscriptionStatus: { value: null }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
|
||||
@@ -14,15 +14,19 @@ const mockSubscriptionTier = ref<
|
||||
const mockIsYearlySubscription = ref(false)
|
||||
const mockAccessBillingPortal = vi.fn()
|
||||
const mockReportError = vi.fn()
|
||||
const mockTrackBeginCheckout = vi.fn()
|
||||
const mockUserId = ref<string | undefined>('user-123')
|
||||
const mockGetFirebaseAuthHeader = vi.fn(() =>
|
||||
Promise.resolve({ Authorization: 'Bearer test-token' })
|
||||
)
|
||||
const mockGetCheckoutAttribution = vi.hoisted(() => vi.fn(() => ({})))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
|
||||
subscriptionTier: computed(() => mockSubscriptionTier.value),
|
||||
isYearlySubscription: computed(() => mockIsYearlySubscription.value)
|
||||
isYearlySubscription: computed(() => mockIsYearlySubscription.value),
|
||||
subscriptionStatus: ref(null)
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -52,12 +56,24 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: () => ({
|
||||
getFirebaseAuthHeader: mockGetFirebaseAuthHeader
|
||||
}),
|
||||
useFirebaseAuthStore: () =>
|
||||
reactive({
|
||||
getFirebaseAuthHeader: mockGetFirebaseAuthHeader,
|
||||
userId: computed(() => mockUserId.value)
|
||||
}),
|
||||
FirebaseAuthStoreError: class extends Error {}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackBeginCheckout: mockTrackBeginCheckout
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({
|
||||
getCheckoutAttribution: mockGetCheckoutAttribution
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
@@ -126,6 +142,8 @@ describe('PricingTable', () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
mockSubscriptionTier.value = null
|
||||
mockIsYearlySubscription.value = false
|
||||
mockUserId.value = 'user-123'
|
||||
mockTrackBeginCheckout.mockReset()
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ checkout_url: 'https://checkout.stripe.com/test' })
|
||||
@@ -148,6 +166,13 @@ describe('PricingTable', () => {
|
||||
await creatorButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockTrackBeginCheckout).toHaveBeenCalledWith({
|
||||
user_id: 'user-123',
|
||||
tier: 'creator',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'change',
|
||||
previous_tier: 'standard'
|
||||
})
|
||||
expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
|
||||
})
|
||||
|
||||
@@ -168,6 +193,33 @@ describe('PricingTable', () => {
|
||||
expect(mockAccessBillingPortal).toHaveBeenCalledWith('pro-yearly')
|
||||
})
|
||||
|
||||
it('should use the latest userId value when it changes after mount', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockSubscriptionTier.value = 'STANDARD'
|
||||
mockUserId.value = 'user-early'
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
mockUserId.value = 'user-late'
|
||||
|
||||
const creatorButton = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) => btn.text().includes('Creator'))
|
||||
|
||||
await creatorButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockTrackBeginCheckout).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackBeginCheckout).toHaveBeenCalledWith({
|
||||
user_id: 'user-late',
|
||||
tier: 'creator',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'change',
|
||||
previous_tier: 'standard'
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call accessBillingPortal when clicking current plan', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockSubscriptionTier.value = 'CREATOR'
|
||||
|
||||
@@ -243,6 +243,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Popover from 'primevue/popover'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton'
|
||||
@@ -266,6 +267,9 @@ import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils
|
||||
import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type SubscriptionTier = components['schemas']['SubscriptionTier']
|
||||
@@ -277,6 +281,19 @@ const getCheckoutTier = (
|
||||
billingCycle: BillingCycle
|
||||
): CheckoutTier => (billingCycle === 'yearly' ? `${tierKey}-yearly` : tierKey)
|
||||
|
||||
const getCheckoutAttributionForCloud =
|
||||
async (): Promise<CheckoutAttributionMetadata> => {
|
||||
// eslint-disable-next-line no-undef
|
||||
if (__DISTRIBUTION__ !== 'cloud') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const { getCheckoutAttribution } =
|
||||
await import('@/platform/telemetry/utils/checkoutAttribution')
|
||||
|
||||
return getCheckoutAttribution()
|
||||
}
|
||||
|
||||
interface BillingCycleOption {
|
||||
label: string
|
||||
value: BillingCycle
|
||||
@@ -330,6 +347,8 @@ const tiers: PricingTierConfig[] = [
|
||||
const { n } = useI18n()
|
||||
const { isActiveSubscription, subscriptionTier, isYearlySubscription } =
|
||||
useSubscription()
|
||||
const telemetry = useTelemetry()
|
||||
const { userId } = storeToRefs(useFirebaseAuthStore())
|
||||
const { accessBillingPortal, reportError } = useFirebaseAuthActions()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
@@ -410,6 +429,19 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
|
||||
|
||||
try {
|
||||
if (isActiveSubscription.value) {
|
||||
const checkoutAttribution = await getCheckoutAttributionForCloud()
|
||||
if (userId.value) {
|
||||
telemetry?.trackBeginCheckout({
|
||||
user_id: userId.value,
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle.value,
|
||||
checkout_type: 'change',
|
||||
...checkoutAttribution,
|
||||
...(currentTierKey.value
|
||||
? { previous_tier: currentTierKey.value }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
// Pass the target tier to create a deep link to subscription update confirmation
|
||||
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value)
|
||||
const targetPlan = {
|
||||
@@ -430,7 +462,11 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
|
||||
await accessBillingPortal(checkoutTier)
|
||||
}
|
||||
} else {
|
||||
await performSubscriptionCheckout(tierKey, currentBillingCycle.value)
|
||||
await performSubscriptionCheckout(
|
||||
tierKey,
|
||||
currentBillingCycle.value,
|
||||
true
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
|
||||
@@ -375,8 +375,7 @@ const {
|
||||
plans: apiPlans,
|
||||
currentPlanSlug,
|
||||
fetchPlans,
|
||||
subscription,
|
||||
getMaxSeats
|
||||
subscription
|
||||
} = useBillingContext()
|
||||
|
||||
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
|
||||
@@ -406,6 +405,11 @@ function getPriceFromApi(tier: PricingTierConfig): number | null {
|
||||
return currentBillingCycle.value === 'yearly' ? price / 12 : price
|
||||
}
|
||||
|
||||
function getMaxSeatsFromApi(tier: PricingTierConfig): number | null {
|
||||
const plan = getApiPlanForTier(tier.key, 'monthly')
|
||||
return plan ? plan.max_seats : null
|
||||
}
|
||||
|
||||
const currentTierKey = computed<TierKey | null>(() =>
|
||||
subscription.value?.tier ? TIER_TO_KEY[subscription.value.tier] : null
|
||||
)
|
||||
@@ -490,7 +494,8 @@ const getAnnualTotal = (tier: PricingTierConfig): number => {
|
||||
return plan ? plan.price_cents / 100 : tier.pricing.yearly * 12
|
||||
}
|
||||
|
||||
const getMaxMembers = (tier: PricingTierConfig): number => getMaxSeats(tier.key)
|
||||
const getMaxMembers = (tier: PricingTierConfig): number =>
|
||||
getMaxSeatsFromApi(tier) ?? tier.maxMembers
|
||||
|
||||
const getMonthlyCreditsPerMember = (tier: PricingTierConfig): number =>
|
||||
tier.pricing.credits
|
||||
|
||||
@@ -88,13 +88,10 @@
|
||||
</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>
|
||||
<span class="text-base"
|
||||
>{{ $t('subscription.perMonth') }} /
|
||||
{{ $t('subscription.member') }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
@@ -179,7 +176,7 @@
|
||||
<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"
|
||||
class="relative flex flex-col gap-6 rounded-2xl p-5 bg-modal-panel-background justify-between h-full"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
@@ -362,6 +359,7 @@ import { useSubscriptionActions } from '@/platform/cloud/subscription/composable
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
@@ -390,7 +388,7 @@ const {
|
||||
manageSubscription,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
getMaxSeats
|
||||
plans: apiPlans
|
||||
} = useBillingContext()
|
||||
|
||||
const { showCancelSubscriptionDialog } = useDialogService()
|
||||
@@ -513,6 +511,23 @@ const tierPrice = computed(() =>
|
||||
const memberCount = computed(() => members.value.length)
|
||||
const nextMonthInvoice = computed(() => memberCount.value * tierPrice.value)
|
||||
|
||||
function getApiPlanForTier(tierKey: TierKey, duration: 'monthly' | 'yearly') {
|
||||
const apiDuration = duration === 'yearly' ? 'ANNUAL' : 'MONTHLY'
|
||||
const apiTier = tierKey.toUpperCase()
|
||||
return apiPlans.value.find(
|
||||
(p) => p.tier === apiTier && p.duration === apiDuration
|
||||
)
|
||||
}
|
||||
|
||||
function getMaxSeatsFromApi(tierKey: TierKey): number | null {
|
||||
const plan = getApiPlanForTier(tierKey, 'monthly')
|
||||
return plan ? plan.max_seats : null
|
||||
}
|
||||
|
||||
function getMaxMembers(tierKey: TierKey): number {
|
||||
return getMaxSeatsFromApi(tierKey) ?? getTierFeatures(tierKey).maxMembers
|
||||
}
|
||||
|
||||
const refillsDate = computed(() => {
|
||||
if (!subscription.value?.renewalDate) return ''
|
||||
const date = new Date(subscription.value.renewalDate)
|
||||
@@ -556,18 +571,13 @@ interface Benefit {
|
||||
const tierBenefits = computed((): Benefit[] => {
|
||||
const key = tierKey.value
|
||||
|
||||
const benefits: Benefit[] = []
|
||||
|
||||
if (!isInPersonalWorkspace.value) {
|
||||
benefits.push({
|
||||
const benefits: Benefit[] = [
|
||||
{
|
||||
key: 'members',
|
||||
type: 'icon',
|
||||
label: t('subscription.membersLabel', { count: getMaxSeats(key) }),
|
||||
label: t('subscription.membersLabel', { count: getMaxMembers(key) }),
|
||||
icon: 'pi pi-user'
|
||||
})
|
||||
}
|
||||
|
||||
benefits.push(
|
||||
},
|
||||
{
|
||||
key: 'maxDuration',
|
||||
type: 'metric',
|
||||
@@ -584,7 +594,7 @@ const tierBenefits = computed((): Benefit[] => {
|
||||
type: 'feature',
|
||||
label: t('subscription.addCreditsLabel')
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
if (getTierFeatures(key).customLoRAs) {
|
||||
benefits.push({
|
||||
|
||||
@@ -1,22 +1,60 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope } from 'vue'
|
||||
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
|
||||
// Create mocks
|
||||
const mockIsLoggedIn = ref(false)
|
||||
const mockReportError = vi.fn()
|
||||
const mockAccessBillingPortal = vi.fn()
|
||||
const mockShowSubscriptionRequiredDialog = vi.fn()
|
||||
const mockGetAuthHeader = vi.fn(() =>
|
||||
Promise.resolve({ Authorization: 'Bearer test-token' })
|
||||
)
|
||||
const mockTelemetry = {
|
||||
trackSubscription: vi.fn(),
|
||||
trackMonthlySubscriptionCancelled: vi.fn()
|
||||
const {
|
||||
mockIsLoggedIn,
|
||||
mockReportError,
|
||||
mockAccessBillingPortal,
|
||||
mockShowSubscriptionRequiredDialog,
|
||||
mockGetAuthHeader,
|
||||
mockGetCheckoutAttribution,
|
||||
mockTelemetry,
|
||||
mockUserId,
|
||||
mockIsCloud
|
||||
} = vi.hoisted(() => ({
|
||||
mockIsLoggedIn: { value: false },
|
||||
mockIsCloud: { value: true },
|
||||
mockReportError: vi.fn(),
|
||||
mockAccessBillingPortal: vi.fn(),
|
||||
mockShowSubscriptionRequiredDialog: vi.fn(),
|
||||
mockGetAuthHeader: vi.fn(() =>
|
||||
Promise.resolve({ Authorization: 'Bearer test-token' })
|
||||
),
|
||||
mockGetCheckoutAttribution: vi.fn(() => ({
|
||||
im_ref: 'impact-click-001',
|
||||
utm_source: 'impact'
|
||||
})),
|
||||
mockTelemetry: {
|
||||
trackSubscription: vi.fn(),
|
||||
trackMonthlySubscriptionCancelled: vi.fn()
|
||||
},
|
||||
mockUserId: { value: 'user-123' }
|
||||
}))
|
||||
|
||||
let scope: ReturnType<typeof effectScope> | undefined
|
||||
type Distribution = 'desktop' | 'localhost' | 'cloud'
|
||||
|
||||
const setDistribution = (distribution: Distribution) => {
|
||||
;(
|
||||
globalThis as typeof globalThis & { __DISTRIBUTION__: Distribution }
|
||||
).__DISTRIBUTION__ = distribution
|
||||
}
|
||||
|
||||
function useSubscriptionWithScope() {
|
||||
if (!scope) {
|
||||
throw new Error('Test scope not initialized')
|
||||
}
|
||||
|
||||
const subscription = scope.run(() => useSubscription())
|
||||
if (!subscription) {
|
||||
throw new Error('Failed to initialize subscription composable')
|
||||
}
|
||||
|
||||
return subscription
|
||||
}
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: vi.fn(() => ({
|
||||
isLoggedIn: mockIsLoggedIn
|
||||
@@ -53,7 +91,13 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({
|
||||
getCheckoutAttribution: mockGetCheckoutAttribution
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
@@ -64,7 +108,10 @@ vi.mock('@/services/dialogService', () => ({
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
getFirebaseAuthHeader: mockGetAuthHeader
|
||||
getFirebaseAuthHeader: mockGetAuthHeader,
|
||||
get userId() {
|
||||
return mockUserId.value
|
||||
}
|
||||
})),
|
||||
FirebaseAuthStoreError: class extends Error {}
|
||||
}))
|
||||
@@ -73,11 +120,23 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
global.fetch = vi.fn()
|
||||
|
||||
describe('useSubscription', () => {
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = undefined
|
||||
setDistribution('localhost')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
scope?.stop()
|
||||
scope = effectScope()
|
||||
setDistribution('cloud')
|
||||
|
||||
vi.clearAllMocks()
|
||||
mockIsLoggedIn.value = false
|
||||
mockTelemetry.trackSubscription.mockReset()
|
||||
mockTelemetry.trackMonthlySubscriptionCancelled.mockReset()
|
||||
mockUserId.value = 'user-123'
|
||||
mockIsCloud.value = true
|
||||
window.__CONFIG__ = {
|
||||
subscription_required: true
|
||||
} as typeof window.__CONFIG__
|
||||
@@ -103,7 +162,7 @@ describe('useSubscription', () => {
|
||||
} as Response)
|
||||
|
||||
mockIsLoggedIn.value = true
|
||||
const { isActiveSubscription, fetchStatus } = useSubscription()
|
||||
const { isActiveSubscription, fetchStatus } = useSubscriptionWithScope()
|
||||
|
||||
await fetchStatus()
|
||||
expect(isActiveSubscription.value).toBe(true)
|
||||
@@ -120,7 +179,7 @@ describe('useSubscription', () => {
|
||||
} as Response)
|
||||
|
||||
mockIsLoggedIn.value = true
|
||||
const { isActiveSubscription, fetchStatus } = useSubscription()
|
||||
const { isActiveSubscription, fetchStatus } = useSubscriptionWithScope()
|
||||
|
||||
await fetchStatus()
|
||||
expect(isActiveSubscription.value).toBe(false)
|
||||
@@ -137,7 +196,7 @@ describe('useSubscription', () => {
|
||||
} as Response)
|
||||
|
||||
mockIsLoggedIn.value = true
|
||||
const { formattedRenewalDate, fetchStatus } = useSubscription()
|
||||
const { formattedRenewalDate, fetchStatus } = useSubscriptionWithScope()
|
||||
|
||||
await fetchStatus()
|
||||
// The date format may vary based on timezone, so we just check it's a valid date string
|
||||
@@ -147,7 +206,7 @@ describe('useSubscription', () => {
|
||||
})
|
||||
|
||||
it('should return empty string when renewal date is not available', () => {
|
||||
const { formattedRenewalDate } = useSubscription()
|
||||
const { formattedRenewalDate } = useSubscriptionWithScope()
|
||||
|
||||
expect(formattedRenewalDate.value).toBe('')
|
||||
})
|
||||
@@ -164,14 +223,14 @@ describe('useSubscription', () => {
|
||||
} as Response)
|
||||
|
||||
mockIsLoggedIn.value = true
|
||||
const { subscriptionTier, fetchStatus } = useSubscription()
|
||||
const { subscriptionTier, fetchStatus } = useSubscriptionWithScope()
|
||||
|
||||
await fetchStatus()
|
||||
expect(subscriptionTier.value).toBe('CREATOR')
|
||||
})
|
||||
|
||||
it('should return null when subscription tier is not available', () => {
|
||||
const { subscriptionTier } = useSubscription()
|
||||
const { subscriptionTier } = useSubscriptionWithScope()
|
||||
|
||||
expect(subscriptionTier.value).toBeNull()
|
||||
})
|
||||
@@ -191,7 +250,7 @@ describe('useSubscription', () => {
|
||||
} as Response)
|
||||
|
||||
mockIsLoggedIn.value = true
|
||||
const { fetchStatus } = useSubscription()
|
||||
const { fetchStatus } = useSubscriptionWithScope()
|
||||
|
||||
await fetchStatus()
|
||||
|
||||
@@ -212,7 +271,7 @@ describe('useSubscription', () => {
|
||||
json: async () => ({ message: 'Subscription not found' })
|
||||
} as Response)
|
||||
|
||||
const { fetchStatus } = useSubscription()
|
||||
const { fetchStatus } = useSubscriptionWithScope()
|
||||
|
||||
await expect(fetchStatus()).rejects.toThrow()
|
||||
})
|
||||
@@ -232,7 +291,7 @@ describe('useSubscription', () => {
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => null)
|
||||
|
||||
const { subscribe } = useSubscription()
|
||||
const { subscribe } = useSubscriptionWithScope()
|
||||
|
||||
await subscribe()
|
||||
|
||||
@@ -243,6 +302,10 @@ describe('useSubscription', () => {
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer test-token',
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
im_ref: 'impact-click-001',
|
||||
utm_source: 'impact'
|
||||
})
|
||||
})
|
||||
)
|
||||
@@ -258,7 +321,7 @@ describe('useSubscription', () => {
|
||||
json: async () => ({})
|
||||
} as Response)
|
||||
|
||||
const { subscribe } = useSubscription()
|
||||
const { subscribe } = useSubscriptionWithScope()
|
||||
|
||||
await expect(subscribe()).rejects.toThrow()
|
||||
})
|
||||
@@ -275,7 +338,7 @@ describe('useSubscription', () => {
|
||||
})
|
||||
} as Response)
|
||||
|
||||
const { requireActiveSubscription } = useSubscription()
|
||||
const { requireActiveSubscription } = useSubscriptionWithScope()
|
||||
|
||||
await requireActiveSubscription()
|
||||
|
||||
@@ -292,7 +355,7 @@ describe('useSubscription', () => {
|
||||
})
|
||||
} as Response)
|
||||
|
||||
const { requireActiveSubscription } = useSubscription()
|
||||
const { requireActiveSubscription } = useSubscriptionWithScope()
|
||||
|
||||
await requireActiveSubscription()
|
||||
|
||||
@@ -306,7 +369,7 @@ describe('useSubscription', () => {
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => null)
|
||||
|
||||
const { handleViewUsageHistory } = useSubscription()
|
||||
const { handleViewUsageHistory } = useSubscriptionWithScope()
|
||||
handleViewUsageHistory()
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||
@@ -322,7 +385,7 @@ describe('useSubscription', () => {
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => null)
|
||||
|
||||
const { handleLearnMore } = useSubscription()
|
||||
const { handleLearnMore } = useSubscriptionWithScope()
|
||||
handleLearnMore()
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||
@@ -334,7 +397,7 @@ describe('useSubscription', () => {
|
||||
})
|
||||
|
||||
it('should call accessBillingPortal for invoice history', async () => {
|
||||
const { handleInvoiceHistory } = useSubscription()
|
||||
const { handleInvoiceHistory } = useSubscriptionWithScope()
|
||||
|
||||
await handleInvoiceHistory()
|
||||
|
||||
@@ -342,7 +405,7 @@ describe('useSubscription', () => {
|
||||
})
|
||||
|
||||
it('should call accessBillingPortal for manage subscription', async () => {
|
||||
const { manageSubscription } = useSubscription()
|
||||
const { manageSubscription } = useSubscriptionWithScope()
|
||||
|
||||
await manageSubscription()
|
||||
|
||||
@@ -378,7 +441,7 @@ describe('useSubscription', () => {
|
||||
.mockResolvedValueOnce(cancelledResponse as Response)
|
||||
|
||||
try {
|
||||
const { fetchStatus, manageSubscription } = useSubscription()
|
||||
const { fetchStatus, manageSubscription } = useSubscriptionWithScope()
|
||||
|
||||
await fetchStatus()
|
||||
await manageSubscription()
|
||||
@@ -422,7 +485,7 @@ describe('useSubscription', () => {
|
||||
.mockResolvedValueOnce(cancelledResponse as Response)
|
||||
|
||||
try {
|
||||
const { fetchStatus, manageSubscription } = useSubscription()
|
||||
const { fetchStatus, manageSubscription } = useSubscriptionWithScope()
|
||||
|
||||
await fetchStatus()
|
||||
await manageSubscription()
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import {
|
||||
FirebaseAuthStoreError,
|
||||
useFirebaseAuthStore
|
||||
@@ -38,7 +39,8 @@ function useSubscriptionInternal() {
|
||||
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
|
||||
const { showSubscriptionRequiredDialog } = useDialogService()
|
||||
|
||||
const { getFirebaseAuthHeader } = useFirebaseAuthStore()
|
||||
const firebaseAuthStore = useFirebaseAuthStore()
|
||||
const { getFirebaseAuthHeader } = firebaseAuthStore
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
@@ -93,7 +95,21 @@ function useSubscriptionInternal() {
|
||||
: baseName
|
||||
})
|
||||
|
||||
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
|
||||
function buildApiUrl(path: string): string {
|
||||
return `${getComfyApiBaseUrl()}${path}`
|
||||
}
|
||||
|
||||
const getCheckoutAttributionForCloud =
|
||||
async (): Promise<CheckoutAttributionMetadata> => {
|
||||
if (__DISTRIBUTION__ !== 'cloud') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const { getCheckoutAttribution } =
|
||||
await import('@/platform/telemetry/utils/checkoutAttribution')
|
||||
|
||||
return getCheckoutAttribution()
|
||||
}
|
||||
|
||||
const fetchStatus = wrapWithErrorHandlingAsync(
|
||||
fetchSubscriptionStatus,
|
||||
@@ -194,6 +210,7 @@ function useSubscriptionInternal() {
|
||||
|
||||
const statusData = await response.json()
|
||||
subscriptionStatus.value = statusData
|
||||
|
||||
return statusData
|
||||
}
|
||||
|
||||
@@ -227,6 +244,7 @@ function useSubscriptionInternal() {
|
||||
t('toastMessages.userNotAuthenticated')
|
||||
)
|
||||
}
|
||||
const checkoutAttribution = await getCheckoutAttributionForCloud()
|
||||
|
||||
const response = await fetch(
|
||||
buildApiUrl('/customers/cloud-subscription-checkout'),
|
||||
@@ -235,7 +253,8 @@ function useSubscriptionInternal() {
|
||||
headers: {
|
||||
...authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
},
|
||||
body: JSON.stringify(checkoutAttribution)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ import type { EffectScope } from 'vue'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionCancellationWatcher } from '@/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher'
|
||||
import type { TelemetryProvider } from '@/platform/telemetry/types'
|
||||
import type { TelemetryDispatcher } from '@/platform/telemetry/types'
|
||||
|
||||
describe('useSubscriptionCancellationWatcher', () => {
|
||||
const trackMonthlySubscriptionCancelled = vi.fn()
|
||||
const telemetryMock: Pick<
|
||||
TelemetryProvider,
|
||||
TelemetryDispatcher,
|
||||
'trackMonthlySubscriptionCancelled'
|
||||
> = {
|
||||
trackMonthlySubscriptionCancelled
|
||||
|
||||
@@ -2,7 +2,7 @@ import { onScopeDispose, ref } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { defaultWindow, useEventListener, useTimeoutFn } from '@vueuse/core'
|
||||
|
||||
import type { TelemetryProvider } from '@/platform/telemetry/types'
|
||||
import type { TelemetryDispatcher } from '@/platform/telemetry/types'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from './useSubscription'
|
||||
|
||||
@@ -14,7 +14,10 @@ type CancellationWatcherOptions = {
|
||||
fetchStatus: () => Promise<CloudSubscriptionStatusResponse | null | void>
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
subscriptionStatus: Ref<CloudSubscriptionStatusResponse | null>
|
||||
telemetry: Pick<TelemetryProvider, 'trackMonthlySubscriptionCancelled'> | null
|
||||
telemetry: Pick<
|
||||
TelemetryDispatcher,
|
||||
'trackMonthlySubscriptionCancelled'
|
||||
> | null
|
||||
shouldWatchCancellation: () => boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,6 @@ export const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
|
||||
FOUNDERS_EDITION: 'founder'
|
||||
}
|
||||
|
||||
export const KEY_TO_TIER: Record<TierKey, SubscriptionTier> = {
|
||||
standard: 'STANDARD',
|
||||
creator: 'CREATOR',
|
||||
pro: 'PRO',
|
||||
founder: 'FOUNDERS_EDITION'
|
||||
}
|
||||
|
||||
export interface TierPricing {
|
||||
monthly: number
|
||||
yearly: number
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
import { performSubscriptionCheckout } from './subscriptionCheckoutUtil'
|
||||
|
||||
const {
|
||||
mockTelemetry,
|
||||
mockGetAuthHeader,
|
||||
mockUserId,
|
||||
mockIsCloud,
|
||||
mockGetCheckoutAttribution
|
||||
} = vi.hoisted(() => ({
|
||||
mockTelemetry: {
|
||||
trackBeginCheckout: vi.fn()
|
||||
},
|
||||
mockGetAuthHeader: vi.fn(() =>
|
||||
Promise.resolve({ Authorization: 'Bearer test-token' })
|
||||
),
|
||||
mockUserId: { value: 'user-123' as string | undefined },
|
||||
mockIsCloud: { value: true },
|
||||
mockGetCheckoutAttribution: vi.fn(() => ({
|
||||
ga_client_id: 'ga-client-id',
|
||||
ga_session_id: 'ga-session-id',
|
||||
ga_session_number: 'ga-session-number',
|
||||
im_ref: 'impact-click-123',
|
||||
utm_source: 'impact',
|
||||
utm_medium: 'affiliate',
|
||||
utm_campaign: 'spring-launch',
|
||||
gclid: 'gclid-123',
|
||||
gbraid: 'gbraid-456',
|
||||
wbraid: 'wbraid-789'
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => mockTelemetry)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() =>
|
||||
reactive({
|
||||
getFirebaseAuthHeader: mockGetAuthHeader,
|
||||
userId: computed(() => mockUserId.value)
|
||||
})
|
||||
),
|
||||
FirebaseAuthStoreError: class extends Error {}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({
|
||||
getCheckoutAttribution: mockGetCheckoutAttribution
|
||||
}))
|
||||
|
||||
global.fetch = vi.fn()
|
||||
|
||||
type Distribution = 'desktop' | 'localhost' | 'cloud'
|
||||
|
||||
const setDistribution = (distribution: Distribution) => {
|
||||
;(
|
||||
globalThis as typeof globalThis & { __DISTRIBUTION__: Distribution }
|
||||
).__DISTRIBUTION__ = distribution
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve: (value: T) => void = () => {}
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('performSubscriptionCheckout', () => {
|
||||
beforeEach(() => {
|
||||
setDistribution('cloud')
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockUserId.value = 'user-123'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setDistribution('localhost')
|
||||
})
|
||||
|
||||
it('tracks begin_checkout with user id and tier metadata', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
await performSubscriptionCheckout('pro', 'yearly', true)
|
||||
|
||||
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith({
|
||||
user_id: 'user-123',
|
||||
tier: 'pro',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new',
|
||||
ga_client_id: 'ga-client-id',
|
||||
ga_session_id: 'ga-session-id',
|
||||
ga_session_number: 'ga-session-number',
|
||||
im_ref: 'impact-click-123',
|
||||
utm_source: 'impact',
|
||||
utm_medium: 'affiliate',
|
||||
utm_campaign: 'spring-launch',
|
||||
gclid: 'gclid-123',
|
||||
gbraid: 'gbraid-456',
|
||||
wbraid: 'wbraid-789'
|
||||
})
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'/customers/cloud-subscription-checkout/pro-yearly'
|
||||
),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
ga_client_id: 'ga-client-id',
|
||||
ga_session_id: 'ga-session-id',
|
||||
ga_session_number: 'ga-session-number',
|
||||
im_ref: 'impact-click-123',
|
||||
utm_source: 'impact',
|
||||
utm_medium: 'affiliate',
|
||||
utm_campaign: 'spring-launch',
|
||||
gclid: 'gclid-123',
|
||||
gbraid: 'gbraid-456',
|
||||
wbraid: 'wbraid-789'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
})
|
||||
|
||||
it('continues checkout when attribution collection fails', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
mockGetCheckoutAttribution.mockRejectedValueOnce(
|
||||
new Error('Attribution failed')
|
||||
)
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
await performSubscriptionCheckout('pro', 'monthly', true)
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[SubscriptionCheckout] Failed to collect checkout attribution',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/customers/cloud-subscription-checkout/pro'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
)
|
||||
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith({
|
||||
user_id: 'user-123',
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
})
|
||||
|
||||
it('uses the latest userId when it changes after checkout starts', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const authHeader = createDeferred<{ Authorization: string }>()
|
||||
|
||||
mockUserId.value = 'user-early'
|
||||
mockGetAuthHeader.mockImplementationOnce(() => authHeader.promise)
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly', true)
|
||||
|
||||
mockUserId.value = 'user-late'
|
||||
authHeader.resolve({ Authorization: 'Bearer test-token' })
|
||||
|
||||
await checkoutPromise
|
||||
|
||||
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledTimes(1)
|
||||
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: 'user-late',
|
||||
tier: 'pro',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
)
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,14 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import {
|
||||
FirebaseAuthStoreError,
|
||||
useFirebaseAuthStore
|
||||
} from '@/stores/firebaseAuthStore'
|
||||
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from './subscriptionTierRank'
|
||||
|
||||
@@ -15,6 +19,18 @@ const getCheckoutTier = (
|
||||
billingCycle: BillingCycle
|
||||
): CheckoutTier => (billingCycle === 'yearly' ? `${tierKey}-yearly` : tierKey)
|
||||
|
||||
const getCheckoutAttributionForCloud =
|
||||
async (): Promise<CheckoutAttributionMetadata> => {
|
||||
if (__DISTRIBUTION__ !== 'cloud') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const { getCheckoutAttribution } =
|
||||
await import('@/platform/telemetry/utils/checkoutAttribution')
|
||||
|
||||
return getCheckoutAttribution()
|
||||
}
|
||||
|
||||
/**
|
||||
* Core subscription checkout logic shared between PricingTable and
|
||||
* SubscriptionRedirectView. Handles:
|
||||
@@ -35,20 +51,33 @@ export async function performSubscriptionCheckout(
|
||||
): Promise<void> {
|
||||
if (!isCloud) return
|
||||
|
||||
const { getFirebaseAuthHeader } = useFirebaseAuthStore()
|
||||
const authHeader = await getFirebaseAuthHeader()
|
||||
const firebaseAuthStore = useFirebaseAuthStore()
|
||||
const { userId } = storeToRefs(firebaseAuthStore)
|
||||
const telemetry = useTelemetry()
|
||||
const authHeader = await firebaseAuthStore.getFirebaseAuthHeader()
|
||||
|
||||
if (!authHeader) {
|
||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
|
||||
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle)
|
||||
let checkoutAttribution: CheckoutAttributionMetadata = {}
|
||||
try {
|
||||
checkoutAttribution = await getCheckoutAttributionForCloud()
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[SubscriptionCheckout] Failed to collect checkout attribution',
|
||||
error
|
||||
)
|
||||
}
|
||||
const checkoutPayload = { ...checkoutAttribution }
|
||||
|
||||
const response = await fetch(
|
||||
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${checkoutTier}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...authHeader, 'Content-Type': 'application/json' }
|
||||
headers: { ...authHeader, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(checkoutPayload)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -78,6 +107,15 @@ export async function performSubscriptionCheckout(
|
||||
const data = await response.json()
|
||||
|
||||
if (data.checkout_url) {
|
||||
if (userId.value) {
|
||||
telemetry?.trackBeginCheckout({
|
||||
user_id: userId.value,
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new',
|
||||
...checkoutAttribution
|
||||
})
|
||||
}
|
||||
if (openInNewTab) {
|
||||
window.open(data.checkout_url, '_blank')
|
||||
} else {
|
||||
|
||||
@@ -26,6 +26,7 @@ type FirebaseRuntimeConfig = {
|
||||
* Configuration fetched from the server at runtime
|
||||
*/
|
||||
export type RemoteConfig = {
|
||||
gtm_container_id?: string
|
||||
mixpanel_token?: string
|
||||
subscription_required?: boolean
|
||||
server_health_alert?: ServerHealthAlert
|
||||
|
||||
220
src/platform/telemetry/TelemetryRegistry.ts
Normal file
220
src/platform/telemetry/TelemetryRegistry.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
|
||||
import type {
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
EnterLinearMetadata,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ExecutionTriggerSource,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
SettingChangedMetadata,
|
||||
SurveyResponses,
|
||||
TabCountMetadata,
|
||||
TelemetryDispatcher,
|
||||
TelemetryProvider,
|
||||
TemplateFilterMetadata,
|
||||
TemplateLibraryClosedMetadata,
|
||||
TemplateLibraryMetadata,
|
||||
TemplateMetadata,
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Registry that holds multiple telemetry providers and dispatches
|
||||
* all tracking calls to each registered provider.
|
||||
*
|
||||
* Implements TelemetryDispatcher (all methods required) while dispatching
|
||||
* to TelemetryProvider instances using optional chaining since providers
|
||||
* only implement the methods they care about.
|
||||
*/
|
||||
export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
private providers: TelemetryProvider[] = []
|
||||
|
||||
registerProvider(provider: TelemetryProvider): void {
|
||||
this.providers.push(provider)
|
||||
}
|
||||
|
||||
private dispatch(action: (provider: TelemetryProvider) => void): void {
|
||||
this.providers.forEach((provider) => {
|
||||
try {
|
||||
action(provider)
|
||||
} catch (error) {
|
||||
console.error('[Telemetry] Provider dispatch failed', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
trackSignupOpened(): void {
|
||||
this.dispatch((provider) => provider.trackSignupOpened?.())
|
||||
}
|
||||
|
||||
trackAuth(metadata: AuthMetadata): void {
|
||||
this.dispatch((provider) => provider.trackAuth?.(metadata))
|
||||
}
|
||||
|
||||
trackUserLoggedIn(): void {
|
||||
this.dispatch((provider) => provider.trackUserLoggedIn?.())
|
||||
}
|
||||
|
||||
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void {
|
||||
this.dispatch((provider) => provider.trackSubscription?.(event))
|
||||
}
|
||||
|
||||
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
|
||||
this.dispatch((provider) => provider.trackBeginCheckout?.(metadata))
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.dispatch((provider) => provider.trackMonthlySubscriptionSucceeded?.())
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionCancelled(): void {
|
||||
this.dispatch((provider) => provider.trackMonthlySubscriptionCancelled?.())
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(): void {
|
||||
this.dispatch((provider) => provider.trackAddApiCreditButtonClicked?.())
|
||||
}
|
||||
|
||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackApiCreditTopupButtonPurchaseClicked?.(amount)
|
||||
)
|
||||
}
|
||||
|
||||
trackApiCreditTopupSucceeded(): void {
|
||||
this.dispatch((provider) => provider.trackApiCreditTopupSucceeded?.())
|
||||
}
|
||||
|
||||
trackRunButton(options?: {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}): void {
|
||||
this.dispatch((provider) => provider.trackRunButton?.(options))
|
||||
}
|
||||
|
||||
startTopupTracking(): void {
|
||||
this.dispatch((provider) => provider.startTopupTracking?.())
|
||||
}
|
||||
|
||||
checkForCompletedTopup(events: AuditLog[] | undefined | null): boolean {
|
||||
return this.providers.some((provider) => {
|
||||
try {
|
||||
return provider.checkForCompletedTopup?.(events) ?? false
|
||||
} catch (error) {
|
||||
console.error('[Telemetry] Provider dispatch failed', error)
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
clearTopupTracking(): void {
|
||||
this.dispatch((provider) => provider.clearTopupTracking?.())
|
||||
}
|
||||
|
||||
trackSurvey(
|
||||
stage: 'opened' | 'submitted',
|
||||
responses?: SurveyResponses
|
||||
): void {
|
||||
this.dispatch((provider) => provider.trackSurvey?.(stage, responses))
|
||||
}
|
||||
|
||||
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
|
||||
this.dispatch((provider) => provider.trackEmailVerification?.(stage))
|
||||
}
|
||||
|
||||
trackTemplate(metadata: TemplateMetadata): void {
|
||||
this.dispatch((provider) => provider.trackTemplate?.(metadata))
|
||||
}
|
||||
|
||||
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
|
||||
this.dispatch((provider) => provider.trackTemplateLibraryOpened?.(metadata))
|
||||
}
|
||||
|
||||
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackTemplateLibraryClosed?.(metadata))
|
||||
}
|
||||
|
||||
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
|
||||
this.dispatch((provider) => provider.trackWorkflowImported?.(metadata))
|
||||
}
|
||||
|
||||
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
|
||||
this.dispatch((provider) => provider.trackWorkflowOpened?.(metadata))
|
||||
}
|
||||
|
||||
trackEnterLinear(metadata: EnterLinearMetadata): void {
|
||||
this.dispatch((provider) => provider.trackEnterLinear?.(metadata))
|
||||
}
|
||||
|
||||
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
|
||||
this.dispatch((provider) => provider.trackPageVisibilityChanged?.(metadata))
|
||||
}
|
||||
|
||||
trackTabCount(metadata: TabCountMetadata): void {
|
||||
this.dispatch((provider) => provider.trackTabCount?.(metadata))
|
||||
}
|
||||
|
||||
trackNodeSearch(metadata: NodeSearchMetadata): void {
|
||||
this.dispatch((provider) => provider.trackNodeSearch?.(metadata))
|
||||
}
|
||||
|
||||
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackNodeSearchResultSelected?.(metadata)
|
||||
)
|
||||
}
|
||||
|
||||
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
|
||||
this.dispatch((provider) => provider.trackTemplateFilterChanged?.(metadata))
|
||||
}
|
||||
|
||||
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackHelpCenterOpened?.(metadata))
|
||||
}
|
||||
|
||||
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackHelpResourceClicked?.(metadata))
|
||||
}
|
||||
|
||||
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackHelpCenterClosed?.(metadata))
|
||||
}
|
||||
|
||||
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackWorkflowCreated?.(metadata))
|
||||
}
|
||||
|
||||
trackWorkflowExecution(): void {
|
||||
this.dispatch((provider) => provider.trackWorkflowExecution?.())
|
||||
}
|
||||
|
||||
trackExecutionError(metadata: ExecutionErrorMetadata): void {
|
||||
this.dispatch((provider) => provider.trackExecutionError?.(metadata))
|
||||
}
|
||||
|
||||
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
|
||||
this.dispatch((provider) => provider.trackExecutionSuccess?.(metadata))
|
||||
}
|
||||
|
||||
trackSettingChanged(metadata: SettingChangedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackSettingChanged?.(metadata))
|
||||
}
|
||||
|
||||
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
|
||||
this.dispatch((provider) => provider.trackUiButtonClicked?.(metadata))
|
||||
}
|
||||
|
||||
trackPageView(pageName: string, properties?: PageViewMetadata): void {
|
||||
this.dispatch((provider) => provider.trackPageView?.(pageName, properties))
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,19 @@
|
||||
/**
|
||||
* Telemetry Provider - OSS Build Safety
|
||||
*
|
||||
* CRITICAL: OSS Build Safety
|
||||
* This module is conditionally compiled based on distribution. When building
|
||||
* the open source version (DISTRIBUTION unset), this entire module and its dependencies
|
||||
* are excluded through via tree-shaking.
|
||||
*
|
||||
* To verify OSS builds exclude this code:
|
||||
* 1. `DISTRIBUTION= pnpm build` (OSS build)
|
||||
* 2. `grep -RinE --include='*.js' 'trackWorkflow|trackEvent|mixpanel' dist/` (should find nothing)
|
||||
* 3. Check dist/assets/*.js files contain no tracking code
|
||||
*
|
||||
* This approach maintains complete separation between cloud and OSS builds
|
||||
* while ensuring the open source version contains no telemetry dependencies.
|
||||
*/
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { TelemetryDispatcher } from './types'
|
||||
|
||||
import { MixpanelTelemetryProvider } from './providers/cloud/MixpanelTelemetryProvider'
|
||||
import type { TelemetryProvider } from './types'
|
||||
|
||||
// Singleton instance
|
||||
let _telemetryProvider: TelemetryProvider | null = null
|
||||
let _telemetryRegistry: TelemetryDispatcher | null = null
|
||||
|
||||
/**
|
||||
* Telemetry factory - conditionally creates provider based on distribution
|
||||
* Returns singleton instance.
|
||||
* Get the telemetry dispatcher for tracking events.
|
||||
* Returns null in OSS builds - all tracking calls become no-ops.
|
||||
*
|
||||
* CRITICAL: This returns undefined in OSS builds. There is no telemetry provider
|
||||
* for OSS builds and all tracking calls are no-ops.
|
||||
* Usage: useTelemetry()?.trackAuth({ method: 'google' })
|
||||
*/
|
||||
export function useTelemetry(): TelemetryProvider | null {
|
||||
if (_telemetryProvider === null) {
|
||||
// Use distribution check for tree-shaking
|
||||
if (isCloud) {
|
||||
_telemetryProvider = new MixpanelTelemetryProvider()
|
||||
}
|
||||
// For OSS builds, _telemetryProvider stays null
|
||||
}
|
||||
|
||||
return _telemetryProvider
|
||||
export function useTelemetry(): TelemetryDispatcher | null {
|
||||
return _telemetryRegistry
|
||||
}
|
||||
|
||||
export function setTelemetryRegistry(
|
||||
registry: TelemetryDispatcher | null
|
||||
): void {
|
||||
_telemetryRegistry = registry
|
||||
}
|
||||
|
||||
44
src/platform/telemetry/initTelemetry.ts
Normal file
44
src/platform/telemetry/initTelemetry.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Telemetry Provider - Cloud Initialization
|
||||
*
|
||||
* This module is only imported in cloud builds to keep
|
||||
* cloud telemetry code out of local/desktop bundles.
|
||||
*/
|
||||
import { setTelemetryRegistry } from './index'
|
||||
|
||||
const IS_CLOUD_BUILD = __DISTRIBUTION__ === 'cloud'
|
||||
|
||||
let _initPromise: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* Initialize telemetry providers for cloud builds.
|
||||
* Must be called early in app startup (e.g., main.ts).
|
||||
* Safe to call multiple times - only initializes once.
|
||||
*/
|
||||
export async function initTelemetry(): Promise<void> {
|
||||
if (!IS_CLOUD_BUILD) return
|
||||
if (_initPromise) return _initPromise
|
||||
|
||||
_initPromise = (async () => {
|
||||
const [
|
||||
{ TelemetryRegistry },
|
||||
{ MixpanelTelemetryProvider },
|
||||
{ GtmTelemetryProvider },
|
||||
{ ImpactTelemetryProvider }
|
||||
] = await Promise.all([
|
||||
import('./TelemetryRegistry'),
|
||||
import('./providers/cloud/MixpanelTelemetryProvider'),
|
||||
import('./providers/cloud/GtmTelemetryProvider'),
|
||||
import('./providers/cloud/ImpactTelemetryProvider')
|
||||
])
|
||||
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider(new MixpanelTelemetryProvider())
|
||||
registry.registerProvider(new GtmTelemetryProvider())
|
||||
registry.registerProvider(new ImpactTelemetryProvider())
|
||||
|
||||
setTelemetryRegistry(registry)
|
||||
})()
|
||||
|
||||
return _initPromise
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type {
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
PageViewMetadata,
|
||||
TelemetryProvider
|
||||
} from '../../types'
|
||||
|
||||
/**
|
||||
* Google Tag Manager telemetry provider.
|
||||
* Pushes events to the GTM dataLayer for GA4 and marketing integrations.
|
||||
*
|
||||
* Only implements events relevant to GTM/GA4 tracking.
|
||||
*/
|
||||
export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
private initialized = false
|
||||
|
||||
constructor() {
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
private initialize(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const gtmId = window.__CONFIG__?.gtm_container_id
|
||||
if (!gtmId) {
|
||||
if (import.meta.env.MODE === 'development') {
|
||||
console.warn('[GTM] No GTM ID configured, skipping initialization')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
window.dataLayer = window.dataLayer || []
|
||||
|
||||
window.dataLayer.push({
|
||||
'gtm.start': new Date().getTime(),
|
||||
event: 'gtm.js'
|
||||
})
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.async = true
|
||||
script.src = `https://www.googletagmanager.com/gtm.js?id=${gtmId}`
|
||||
document.head.insertBefore(script, document.head.firstChild)
|
||||
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
private pushEvent(event: string, properties?: Record<string, unknown>): void {
|
||||
if (!this.initialized) return
|
||||
window.dataLayer?.push({ event, ...properties })
|
||||
}
|
||||
|
||||
trackPageView(pageName: string, properties?: PageViewMetadata): void {
|
||||
this.pushEvent('page_view', {
|
||||
page_title: pageName,
|
||||
page_location: properties?.path,
|
||||
page_referrer: properties?.referrer
|
||||
})
|
||||
}
|
||||
|
||||
trackAuth(metadata: AuthMetadata): void {
|
||||
const basePayload = {
|
||||
method: metadata.method,
|
||||
...(metadata.user_id ? { user_id: metadata.user_id } : {})
|
||||
}
|
||||
|
||||
if (metadata.is_new_user) {
|
||||
this.pushEvent('sign_up', basePayload)
|
||||
return
|
||||
}
|
||||
|
||||
this.pushEvent('login', basePayload)
|
||||
}
|
||||
|
||||
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
|
||||
this.pushEvent('begin_checkout', metadata)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type MockApiKeyUser = {
|
||||
id: string
|
||||
email?: string
|
||||
} | null
|
||||
|
||||
type MockFirebaseUser = {
|
||||
uid: string
|
||||
email?: string | null
|
||||
} | null
|
||||
|
||||
const {
|
||||
mockCaptureCheckoutAttributionFromSearch,
|
||||
mockUseApiKeyAuthStore,
|
||||
mockUseFirebaseAuthStore,
|
||||
mockApiKeyAuthStore,
|
||||
mockFirebaseAuthStore
|
||||
} = vi.hoisted(() => ({
|
||||
mockCaptureCheckoutAttributionFromSearch: vi.fn(),
|
||||
mockUseApiKeyAuthStore: vi.fn(),
|
||||
mockUseFirebaseAuthStore: vi.fn(),
|
||||
mockApiKeyAuthStore: {
|
||||
isAuthenticated: false,
|
||||
currentUser: null as MockApiKeyUser
|
||||
},
|
||||
mockFirebaseAuthStore: {
|
||||
currentUser: null as MockFirebaseUser
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({
|
||||
captureCheckoutAttributionFromSearch: mockCaptureCheckoutAttributionFromSearch
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/apiKeyAuthStore', () => ({
|
||||
useApiKeyAuthStore: mockUseApiKeyAuthStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: mockUseFirebaseAuthStore
|
||||
}))
|
||||
|
||||
import { ImpactTelemetryProvider } from './ImpactTelemetryProvider'
|
||||
|
||||
const IMPACT_SCRIPT_URL =
|
||||
'https://utt.impactcdn.com/A6951770-3747-434a-9ac7-4e582e67d91f1.js'
|
||||
|
||||
async function flushAsyncWork() {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function toUint8Array(data: BufferSource): Uint8Array {
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return new Uint8Array(data)
|
||||
}
|
||||
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
||||
}
|
||||
|
||||
describe('ImpactTelemetryProvider', () => {
|
||||
beforeEach(() => {
|
||||
mockCaptureCheckoutAttributionFromSearch.mockReset()
|
||||
mockUseApiKeyAuthStore.mockReset()
|
||||
mockUseFirebaseAuthStore.mockReset()
|
||||
mockApiKeyAuthStore.isAuthenticated = false
|
||||
mockApiKeyAuthStore.currentUser = null
|
||||
mockFirebaseAuthStore.currentUser = null
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
mockUseApiKeyAuthStore.mockReturnValue(mockApiKeyAuthStore)
|
||||
mockUseFirebaseAuthStore.mockReturnValue(mockFirebaseAuthStore)
|
||||
|
||||
const queueFn: NonNullable<Window['ire']> = (...args: unknown[]) => {
|
||||
;(queueFn.a ??= []).push(args)
|
||||
}
|
||||
window.ire = queueFn
|
||||
window.ire_o = undefined
|
||||
|
||||
vi.spyOn(document, 'querySelector').mockImplementation((selector) => {
|
||||
if (selector === `script[src="${IMPACT_SCRIPT_URL}"]`) {
|
||||
return document.createElement('script')
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('captures attribution and invokes identify with hashed email', async () => {
|
||||
mockFirebaseAuthStore.currentUser = {
|
||||
uid: 'user-123',
|
||||
email: ' User@Example.com '
|
||||
}
|
||||
vi.stubGlobal('crypto', {
|
||||
subtle: {
|
||||
digest: vi.fn(
|
||||
async (_algorithm: AlgorithmIdentifier, data: BufferSource) => {
|
||||
const digest = createHash('sha1')
|
||||
.update(toUint8Array(data))
|
||||
.digest()
|
||||
return Uint8Array.from(digest).buffer
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
const provider = new ImpactTelemetryProvider()
|
||||
provider.trackPageView('pricing', {
|
||||
path: 'https://cloud.comfy.org/pricing?im_ref=impact-123'
|
||||
})
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(window.ire_o).toBe('ire')
|
||||
expect(mockCaptureCheckoutAttributionFromSearch).toHaveBeenCalledWith(
|
||||
'?im_ref=impact-123'
|
||||
)
|
||||
expect(window.ire?.a).toHaveLength(1)
|
||||
expect(window.ire?.a?.[0]?.[0]).toBe('identify')
|
||||
expect(window.ire?.a?.[0]?.[1]).toEqual({
|
||||
customerId: 'user-123',
|
||||
customerEmail: '63a710569261a24b3766275b7000ce8d7b32e2f7'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to current URL search and empty identify values when user is unresolved', async () => {
|
||||
mockUseApiKeyAuthStore.mockImplementation(() => {
|
||||
throw new Error('No active pinia')
|
||||
})
|
||||
window.history.pushState({}, '', '/?im_ref=fallback-123')
|
||||
|
||||
const provider = new ImpactTelemetryProvider()
|
||||
provider.trackPageView('home')
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(mockCaptureCheckoutAttributionFromSearch).toHaveBeenCalledWith(
|
||||
'?im_ref=fallback-123'
|
||||
)
|
||||
expect(window.ire?.a).toHaveLength(1)
|
||||
expect(window.ire?.a?.[0]).toEqual([
|
||||
'identify',
|
||||
{
|
||||
customerId: '',
|
||||
customerEmail: ''
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('invokes identify on each page view even with identical identity payloads', async () => {
|
||||
mockFirebaseAuthStore.currentUser = {
|
||||
uid: 'user-123',
|
||||
email: 'user@example.com'
|
||||
}
|
||||
vi.stubGlobal('crypto', {
|
||||
subtle: {
|
||||
digest: vi.fn(async () => new Uint8Array([16, 32, 48]).buffer)
|
||||
}
|
||||
})
|
||||
const provider = new ImpactTelemetryProvider()
|
||||
provider.trackPageView('home', {
|
||||
path: 'https://cloud.comfy.org/?im_ref=1'
|
||||
})
|
||||
provider.trackPageView('pricing', {
|
||||
path: 'https://cloud.comfy.org/pricing?im_ref=2'
|
||||
})
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(window.ire?.a).toHaveLength(2)
|
||||
expect(window.ire?.a?.[0]?.[0]).toBe('identify')
|
||||
expect(window.ire?.a?.[0]?.[1]).toMatchObject({
|
||||
customerId: 'user-123'
|
||||
})
|
||||
expect(window.ire?.a?.[1]?.[0]).toBe('identify')
|
||||
expect(window.ire?.a?.[1]?.[1]).toMatchObject({
|
||||
customerId: 'user-123'
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers firebase identity when both firebase and API key identity are available', async () => {
|
||||
mockApiKeyAuthStore.isAuthenticated = true
|
||||
mockApiKeyAuthStore.currentUser = {
|
||||
id: 'api-key-user-123',
|
||||
email: 'apikey@example.com'
|
||||
}
|
||||
mockFirebaseAuthStore.currentUser = {
|
||||
uid: 'firebase-user-123',
|
||||
email: 'firebase@example.com'
|
||||
}
|
||||
vi.stubGlobal('crypto', {
|
||||
subtle: {
|
||||
digest: vi.fn(
|
||||
async (_algorithm: AlgorithmIdentifier, data: BufferSource) => {
|
||||
const digest = createHash('sha1')
|
||||
.update(toUint8Array(data))
|
||||
.digest()
|
||||
return Uint8Array.from(digest).buffer
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const provider = new ImpactTelemetryProvider()
|
||||
provider.trackPageView('home', {
|
||||
path: 'https://cloud.comfy.org/?im_ref=impact-123'
|
||||
})
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(window.ire?.a?.[0]).toEqual([
|
||||
'identify',
|
||||
{
|
||||
customerId: 'firebase-user-123',
|
||||
customerEmail: '2a2f2883bb1c5dd4ec5d18d95630834744609a7e'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to API key identity when firebase user is unavailable', async () => {
|
||||
mockApiKeyAuthStore.isAuthenticated = true
|
||||
mockApiKeyAuthStore.currentUser = {
|
||||
id: 'api-key-user-123',
|
||||
email: 'apikey@example.com'
|
||||
}
|
||||
mockFirebaseAuthStore.currentUser = null
|
||||
vi.stubGlobal('crypto', {
|
||||
subtle: {
|
||||
digest: vi.fn(
|
||||
async (_algorithm: AlgorithmIdentifier, data: BufferSource) => {
|
||||
const digest = createHash('sha1')
|
||||
.update(toUint8Array(data))
|
||||
.digest()
|
||||
return Uint8Array.from(digest).buffer
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const provider = new ImpactTelemetryProvider()
|
||||
provider.trackPageView('home', {
|
||||
path: 'https://cloud.comfy.org/?im_ref=impact-123'
|
||||
})
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(window.ire?.a?.[0]).toEqual([
|
||||
'identify',
|
||||
{
|
||||
customerId: 'api-key-user-123',
|
||||
customerEmail: '76ce7ed8519b3ab66d7520bbc3c4efcdff657028'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import { captureCheckoutAttributionFromSearch } from '@/platform/telemetry/utils/checkoutAttribution'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
import type { PageViewMetadata, TelemetryProvider } from '../../types'
|
||||
|
||||
const IMPACT_SCRIPT_URL =
|
||||
'https://utt.impactcdn.com/A6951770-3747-434a-9ac7-4e582e67d91f1.js'
|
||||
const IMPACT_QUEUE_NAME = 'ire'
|
||||
const EMPTY_CUSTOMER_VALUE = ''
|
||||
|
||||
/**
|
||||
* Impact telemetry provider.
|
||||
* Initializes the Impact queue globals and loads the runtime script.
|
||||
*/
|
||||
export class ImpactTelemetryProvider implements TelemetryProvider {
|
||||
private initialized = false
|
||||
private stores: {
|
||||
apiKeyAuthStore: ReturnType<typeof useApiKeyAuthStore>
|
||||
firebaseAuthStore: ReturnType<typeof useFirebaseAuthStore>
|
||||
} | null = null
|
||||
|
||||
constructor() {
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
trackPageView(_pageName: string, properties?: PageViewMetadata): void {
|
||||
const search = this.extractSearchFromPath(properties?.path)
|
||||
|
||||
if (search) {
|
||||
captureCheckoutAttributionFromSearch(search)
|
||||
} else if (typeof window !== 'undefined') {
|
||||
captureCheckoutAttributionFromSearch(window.location.search)
|
||||
}
|
||||
|
||||
void this.identifyCurrentUser()
|
||||
}
|
||||
|
||||
private initialize(): void {
|
||||
if (typeof window === 'undefined' || this.initialized) return
|
||||
|
||||
window.ire_o = IMPACT_QUEUE_NAME
|
||||
|
||||
if (!window.ire) {
|
||||
const queueFn: NonNullable<Window['ire']> = (...args: unknown[]) => {
|
||||
;(queueFn.a ??= []).push(args)
|
||||
}
|
||||
window.ire = queueFn
|
||||
}
|
||||
|
||||
const existingScript = document.querySelector(
|
||||
`script[src="${IMPACT_SCRIPT_URL}"]`
|
||||
)
|
||||
if (existingScript) {
|
||||
this.initialized = true
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.async = true
|
||||
script.src = IMPACT_SCRIPT_URL
|
||||
|
||||
document.head.insertBefore(script, document.head.firstChild)
|
||||
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
private extractSearchFromPath(path?: string): string {
|
||||
if (!path) return ''
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const url = new URL(path, window.location.origin)
|
||||
return url.search
|
||||
} catch {
|
||||
// Fall through to manual parsing.
|
||||
}
|
||||
}
|
||||
|
||||
const queryIndex = path.indexOf('?')
|
||||
return queryIndex >= 0 ? path.slice(queryIndex) : ''
|
||||
}
|
||||
|
||||
private async identifyCurrentUser(): Promise<void> {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const { customerId, customerEmail } = this.resolveCustomerIdentity()
|
||||
const normalizedEmail = customerEmail.trim().toLowerCase()
|
||||
// Impact's Identify spec requires customerEmail to be sent as a SHA1 hash.
|
||||
const hashedEmail = normalizedEmail
|
||||
? await this.hashSha1(normalizedEmail)
|
||||
: EMPTY_CUSTOMER_VALUE
|
||||
|
||||
window.ire?.('identify', {
|
||||
customerId,
|
||||
customerEmail: hashedEmail
|
||||
})
|
||||
}
|
||||
|
||||
private resolveCustomerIdentity(): {
|
||||
customerId: string
|
||||
customerEmail: string
|
||||
} {
|
||||
const stores = this.resolveAuthStores()
|
||||
if (!stores) {
|
||||
return {
|
||||
customerId: EMPTY_CUSTOMER_VALUE,
|
||||
customerEmail: EMPTY_CUSTOMER_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
if (stores.firebaseAuthStore.currentUser) {
|
||||
return {
|
||||
customerId:
|
||||
stores.firebaseAuthStore.currentUser.uid ?? EMPTY_CUSTOMER_VALUE,
|
||||
customerEmail:
|
||||
stores.firebaseAuthStore.currentUser.email ?? EMPTY_CUSTOMER_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
if (stores.apiKeyAuthStore.isAuthenticated) {
|
||||
return {
|
||||
customerId:
|
||||
stores.apiKeyAuthStore.currentUser?.id ?? EMPTY_CUSTOMER_VALUE,
|
||||
customerEmail:
|
||||
stores.apiKeyAuthStore.currentUser?.email ?? EMPTY_CUSTOMER_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
customerId: EMPTY_CUSTOMER_VALUE,
|
||||
customerEmail: EMPTY_CUSTOMER_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAuthStores(): {
|
||||
apiKeyAuthStore: ReturnType<typeof useApiKeyAuthStore>
|
||||
firebaseAuthStore: ReturnType<typeof useFirebaseAuthStore>
|
||||
} | null {
|
||||
if (this.stores) {
|
||||
return this.stores
|
||||
}
|
||||
|
||||
try {
|
||||
const stores = {
|
||||
apiKeyAuthStore: useApiKeyAuthStore(),
|
||||
firebaseAuthStore: useFirebaseAuthStore()
|
||||
}
|
||||
this.stores = stores
|
||||
return stores
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async hashSha1(value: string): Promise<string> {
|
||||
try {
|
||||
if (!globalThis.crypto?.subtle || typeof TextEncoder === 'undefined') {
|
||||
return EMPTY_CUSTOMER_VALUE
|
||||
}
|
||||
|
||||
const digestBuffer = await crypto.subtle.digest(
|
||||
'SHA-1',
|
||||
new TextEncoder().encode(value)
|
||||
)
|
||||
|
||||
return Array.from(new Uint8Array(digestBuffer))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
} catch {
|
||||
return EMPTY_CUSTOMER_VALUE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
* 3. Check dist/assets/*.js files contain no tracking code
|
||||
*/
|
||||
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
|
||||
/**
|
||||
@@ -20,6 +22,7 @@ import type { AuditLog } from '@/services/customerEventsService'
|
||||
export interface AuthMetadata {
|
||||
method?: 'email' | 'google' | 'github'
|
||||
is_new_user?: boolean
|
||||
user_id?: string
|
||||
referrer_url?: string
|
||||
utm_source?: string
|
||||
utm_medium?: string
|
||||
@@ -269,80 +272,126 @@ export interface WorkflowCreatedMetadata {
|
||||
}
|
||||
|
||||
/**
|
||||
* Core telemetry provider interface
|
||||
* Page view metadata for route tracking
|
||||
*/
|
||||
export interface PageViewMetadata {
|
||||
path?: string
|
||||
referrer?: string
|
||||
title?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface CheckoutAttributionMetadata {
|
||||
ga_client_id?: string
|
||||
ga_session_id?: string
|
||||
ga_session_number?: string
|
||||
im_ref?: string
|
||||
utm_source?: string
|
||||
utm_medium?: string
|
||||
utm_campaign?: string
|
||||
utm_term?: string
|
||||
utm_content?: string
|
||||
gclid?: string
|
||||
gbraid?: string
|
||||
wbraid?: string
|
||||
}
|
||||
|
||||
export interface BeginCheckoutMetadata
|
||||
extends Record<string, unknown>, CheckoutAttributionMetadata {
|
||||
user_id: string
|
||||
tier: TierKey
|
||||
cycle: BillingCycle
|
||||
checkout_type: 'new' | 'change'
|
||||
previous_tier?: TierKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Telemetry provider interface for individual providers.
|
||||
* All methods are optional - providers only implement what they need.
|
||||
*/
|
||||
export interface TelemetryProvider {
|
||||
// Authentication flow events
|
||||
trackSignupOpened(): void
|
||||
trackAuth(metadata: AuthMetadata): void
|
||||
trackUserLoggedIn(): void
|
||||
trackSignupOpened?(): void
|
||||
trackAuth?(metadata: AuthMetadata): void
|
||||
trackUserLoggedIn?(): void
|
||||
|
||||
// Subscription flow events
|
||||
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
|
||||
trackMonthlySubscriptionSucceeded(): void
|
||||
trackMonthlySubscriptionCancelled(): void
|
||||
trackAddApiCreditButtonClicked(): void
|
||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void
|
||||
trackApiCreditTopupSucceeded(): void
|
||||
trackRunButton(options?: {
|
||||
trackSubscription?(event: 'modal_opened' | 'subscribe_clicked'): void
|
||||
trackBeginCheckout?(metadata: BeginCheckoutMetadata): void
|
||||
trackMonthlySubscriptionSucceeded?(): void
|
||||
trackMonthlySubscriptionCancelled?(): void
|
||||
trackAddApiCreditButtonClicked?(): void
|
||||
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
|
||||
trackApiCreditTopupSucceeded?(): void
|
||||
trackRunButton?(options?: {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}): void
|
||||
|
||||
// Credit top-up tracking (composition with internal utilities)
|
||||
startTopupTracking(): void
|
||||
checkForCompletedTopup(events: AuditLog[] | undefined | null): boolean
|
||||
clearTopupTracking(): void
|
||||
startTopupTracking?(): void
|
||||
checkForCompletedTopup?(events: AuditLog[] | undefined | null): boolean
|
||||
clearTopupTracking?(): void
|
||||
|
||||
// Survey flow events
|
||||
trackSurvey(stage: 'opened' | 'submitted', responses?: SurveyResponses): void
|
||||
trackSurvey?(stage: 'opened' | 'submitted', responses?: SurveyResponses): void
|
||||
|
||||
// Email verification events
|
||||
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void
|
||||
trackEmailVerification?(stage: 'opened' | 'requested' | 'completed'): void
|
||||
|
||||
// Template workflow events
|
||||
trackTemplate(metadata: TemplateMetadata): void
|
||||
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void
|
||||
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void
|
||||
trackTemplate?(metadata: TemplateMetadata): void
|
||||
trackTemplateLibraryOpened?(metadata: TemplateLibraryMetadata): void
|
||||
trackTemplateLibraryClosed?(metadata: TemplateLibraryClosedMetadata): void
|
||||
|
||||
// Workflow management events
|
||||
trackWorkflowImported(metadata: WorkflowImportMetadata): void
|
||||
trackWorkflowOpened(metadata: WorkflowImportMetadata): void
|
||||
trackEnterLinear(metadata: EnterLinearMetadata): void
|
||||
trackWorkflowImported?(metadata: WorkflowImportMetadata): void
|
||||
trackWorkflowOpened?(metadata: WorkflowImportMetadata): void
|
||||
trackEnterLinear?(metadata: EnterLinearMetadata): void
|
||||
|
||||
// Page visibility events
|
||||
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void
|
||||
trackPageVisibilityChanged?(metadata: PageVisibilityMetadata): void
|
||||
|
||||
// Tab tracking events
|
||||
trackTabCount(metadata: TabCountMetadata): void
|
||||
trackTabCount?(metadata: TabCountMetadata): void
|
||||
|
||||
// Node search analytics events
|
||||
trackNodeSearch(metadata: NodeSearchMetadata): void
|
||||
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void
|
||||
trackNodeSearch?(metadata: NodeSearchMetadata): void
|
||||
trackNodeSearchResultSelected?(metadata: NodeSearchResultMetadata): void
|
||||
|
||||
// Template filter tracking events
|
||||
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void
|
||||
trackTemplateFilterChanged?(metadata: TemplateFilterMetadata): void
|
||||
|
||||
// Help center events
|
||||
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void
|
||||
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void
|
||||
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void
|
||||
trackHelpCenterOpened?(metadata: HelpCenterOpenedMetadata): void
|
||||
trackHelpResourceClicked?(metadata: HelpResourceClickedMetadata): void
|
||||
trackHelpCenterClosed?(metadata: HelpCenterClosedMetadata): void
|
||||
|
||||
// Workflow creation events
|
||||
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void
|
||||
trackWorkflowCreated?(metadata: WorkflowCreatedMetadata): void
|
||||
|
||||
// Workflow execution events
|
||||
trackWorkflowExecution(): void
|
||||
trackExecutionError(metadata: ExecutionErrorMetadata): void
|
||||
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void
|
||||
trackWorkflowExecution?(): void
|
||||
trackExecutionError?(metadata: ExecutionErrorMetadata): void
|
||||
trackExecutionSuccess?(metadata: ExecutionSuccessMetadata): void
|
||||
|
||||
// Settings events
|
||||
trackSettingChanged(metadata: SettingChangedMetadata): void
|
||||
trackSettingChanged?(metadata: SettingChangedMetadata): void
|
||||
|
||||
// Generic UI button click events
|
||||
trackUiButtonClicked(metadata: UiButtonClickMetadata): void
|
||||
trackUiButtonClicked?(metadata: UiButtonClickMetadata): void
|
||||
|
||||
// Page view tracking
|
||||
trackPageView?(pageName: string, properties?: PageViewMetadata): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Telemetry dispatcher interface returned by useTelemetry().
|
||||
* All methods are required - the registry implements all methods and dispatches
|
||||
* to registered providers using optional chaining.
|
||||
*/
|
||||
export type TelemetryDispatcher = Required<TelemetryProvider>
|
||||
|
||||
/**
|
||||
* Telemetry event constants
|
||||
*
|
||||
@@ -415,7 +464,10 @@ export const TelemetryEvents = {
|
||||
EXECUTION_ERROR: 'execution_error',
|
||||
EXECUTION_SUCCESS: 'execution_success',
|
||||
// Generic UI Button Click
|
||||
UI_BUTTON_CLICKED: 'app:ui_button_clicked'
|
||||
UI_BUTTON_CLICKED: 'app:ui_button_clicked',
|
||||
|
||||
// Page View
|
||||
PAGE_VIEW: 'app:page_view'
|
||||
} as const
|
||||
|
||||
export type TelemetryEventName =
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
captureCheckoutAttributionFromSearch,
|
||||
getCheckoutAttribution
|
||||
} from '../checkoutAttribution'
|
||||
|
||||
describe('getCheckoutAttribution', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.localStorage.clear()
|
||||
window.__ga_identity__ = undefined
|
||||
window.ire = undefined
|
||||
window.history.pushState({}, '', '/')
|
||||
})
|
||||
|
||||
it('reads GA identity and URL attribution, and prefers generated click id', async () => {
|
||||
window.__ga_identity__ = {
|
||||
client_id: '123.456',
|
||||
session_id: '1700000000',
|
||||
session_number: '2'
|
||||
}
|
||||
window.history.pushState(
|
||||
{},
|
||||
'',
|
||||
'/?gclid=gclid-123&utm_source=impact&im_ref=url-click-id'
|
||||
)
|
||||
const mockIreCall = vi.fn()
|
||||
window.ire = (...args: unknown[]) => {
|
||||
mockIreCall(...args)
|
||||
const callback = args[1]
|
||||
if (typeof callback === 'function') {
|
||||
;(callback as (value: string) => void)('generated-click-id')
|
||||
}
|
||||
}
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution).toMatchObject({
|
||||
ga_client_id: '123.456',
|
||||
ga_session_id: '1700000000',
|
||||
ga_session_number: '2',
|
||||
gclid: 'gclid-123',
|
||||
utm_source: 'impact',
|
||||
im_ref: 'generated-click-id'
|
||||
})
|
||||
expect(mockIreCall).toHaveBeenCalledWith(
|
||||
'generateClickId',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to URL click id when generateClickId is unavailable', async () => {
|
||||
window.history.pushState(
|
||||
{},
|
||||
'',
|
||||
'/?utm_campaign=launch&im_ref=fallback-from-url'
|
||||
)
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution).toMatchObject({
|
||||
utm_campaign: 'launch',
|
||||
im_ref: 'fallback-from-url'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns URL attribution only when no click id is available', async () => {
|
||||
window.history.pushState({}, '', '/?utm_source=impact&utm_medium=affiliate')
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution).toMatchObject({
|
||||
utm_source: 'impact',
|
||||
utm_medium: 'affiliate'
|
||||
})
|
||||
expect(attribution.im_ref).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to URL im_ref when generateClickId throws', async () => {
|
||||
window.history.pushState({}, '', '/?im_ref=url-fallback')
|
||||
window.ire = () => {
|
||||
throw new Error('Impact unavailable')
|
||||
}
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution.im_ref).toBe('url-fallback')
|
||||
})
|
||||
|
||||
it('persists click and UTM attribution across navigation', async () => {
|
||||
window.history.pushState(
|
||||
{},
|
||||
'',
|
||||
'/?gclid=gclid-123&utm_source=impact&utm_campaign=spring-launch'
|
||||
)
|
||||
|
||||
await getCheckoutAttribution()
|
||||
window.history.pushState({}, '', '/pricing')
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution).toMatchObject({
|
||||
gclid: 'gclid-123',
|
||||
utm_source: 'impact',
|
||||
utm_campaign: 'spring-launch'
|
||||
})
|
||||
})
|
||||
|
||||
it('stores attribution from page-view capture for later checkout', async () => {
|
||||
captureCheckoutAttributionFromSearch(
|
||||
'?gbraid=gbraid-123&utm_medium=affiliate'
|
||||
)
|
||||
window.history.pushState({}, '', '/pricing')
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution).toMatchObject({
|
||||
gbraid: 'gbraid-123',
|
||||
utm_medium: 'affiliate'
|
||||
})
|
||||
})
|
||||
|
||||
it('stores click id from page-view capture for later checkout', async () => {
|
||||
captureCheckoutAttributionFromSearch('?im_ref=impact-123')
|
||||
window.history.pushState({}, '', '/pricing')
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution).toMatchObject({
|
||||
im_ref: 'impact-123'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not rewrite click id when page-view capture value is unchanged', () => {
|
||||
window.localStorage.setItem(
|
||||
'comfy_checkout_attribution',
|
||||
JSON.stringify({
|
||||
im_ref: 'impact-123'
|
||||
})
|
||||
)
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem')
|
||||
|
||||
captureCheckoutAttributionFromSearch('?im_ref=impact-123')
|
||||
|
||||
expect(setItemSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores impact_click_id query param', async () => {
|
||||
window.history.pushState({}, '', '/?impact_click_id=impact-query-id')
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution.im_ref).toBeUndefined()
|
||||
})
|
||||
})
|
||||
181
src/platform/telemetry/utils/checkoutAttribution.ts
Normal file
181
src/platform/telemetry/utils/checkoutAttribution.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
import { withTimeout } from 'es-toolkit/promise'
|
||||
|
||||
import type { CheckoutAttributionMetadata } from '../types'
|
||||
|
||||
type GaIdentity = {
|
||||
client_id?: string
|
||||
session_id?: string
|
||||
session_number?: string
|
||||
}
|
||||
|
||||
const ATTRIBUTION_QUERY_KEYS = [
|
||||
'im_ref',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
'gclid',
|
||||
'gbraid',
|
||||
'wbraid'
|
||||
] as const
|
||||
type AttributionQueryKey = (typeof ATTRIBUTION_QUERY_KEYS)[number]
|
||||
const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution'
|
||||
const GENERATE_CLICK_ID_TIMEOUT_MS = 300
|
||||
|
||||
function readStoredAttribution(): Partial<Record<AttributionQueryKey, string>> {
|
||||
if (typeof window === 'undefined') return {}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(ATTRIBUTION_STORAGE_KEY)
|
||||
if (!stored) return {}
|
||||
|
||||
const parsed: unknown = JSON.parse(stored)
|
||||
if (!isPlainObject(parsed)) return {}
|
||||
|
||||
const result: Partial<Record<AttributionQueryKey, string>> = {}
|
||||
|
||||
for (const key of ATTRIBUTION_QUERY_KEYS) {
|
||||
const value = asNonEmptyString(parsed[key])
|
||||
if (value) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function persistAttribution(
|
||||
payload: Partial<Record<AttributionQueryKey, string>>
|
||||
): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
localStorage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(payload))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function readAttributionFromUrl(
|
||||
search: string
|
||||
): Partial<Record<AttributionQueryKey, string>> {
|
||||
const params = new URLSearchParams(search)
|
||||
|
||||
const result: Partial<Record<AttributionQueryKey, string>> = {}
|
||||
|
||||
for (const key of ATTRIBUTION_QUERY_KEYS) {
|
||||
const value = params.get(key)
|
||||
if (value) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function hasAttributionChanges(
|
||||
existing: Partial<Record<AttributionQueryKey, string>>,
|
||||
incoming: Partial<Record<AttributionQueryKey, string>>
|
||||
): boolean {
|
||||
for (const key of ATTRIBUTION_QUERY_KEYS) {
|
||||
const value = incoming[key]
|
||||
if (value !== undefined && existing[key] !== value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function asNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function getGaIdentity(): GaIdentity | undefined {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
|
||||
const identity = window.__ga_identity__
|
||||
if (!isPlainObject(identity)) return undefined
|
||||
|
||||
return {
|
||||
client_id: asNonEmptyString(identity.client_id),
|
||||
session_id: asNonEmptyString(identity.session_id),
|
||||
session_number: asNonEmptyString(identity.session_number)
|
||||
}
|
||||
}
|
||||
|
||||
async function getGeneratedClickId(): Promise<string | undefined> {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const impactQueue = window.ire
|
||||
if (typeof impactQueue !== 'function') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
return await withTimeout(
|
||||
() =>
|
||||
new Promise<string | undefined>((resolve, reject) => {
|
||||
try {
|
||||
impactQueue('generateClickId', (clickId: unknown) => {
|
||||
resolve(asNonEmptyString(clickId))
|
||||
})
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}),
|
||||
GENERATE_CLICK_ID_TIMEOUT_MS
|
||||
)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function captureCheckoutAttributionFromSearch(search: string): void {
|
||||
const fromUrl = readAttributionFromUrl(search)
|
||||
const storedAttribution = readStoredAttribution()
|
||||
if (Object.keys(fromUrl).length === 0) return
|
||||
|
||||
if (!hasAttributionChanges(storedAttribution, fromUrl)) return
|
||||
|
||||
persistAttribution({
|
||||
...storedAttribution,
|
||||
...fromUrl
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetadata> {
|
||||
if (typeof window === 'undefined') return {}
|
||||
|
||||
const storedAttribution = readStoredAttribution()
|
||||
const fromUrl = readAttributionFromUrl(window.location.search)
|
||||
const generatedClickId = await getGeneratedClickId()
|
||||
const attribution: Partial<Record<AttributionQueryKey, string>> = {
|
||||
...storedAttribution,
|
||||
...fromUrl
|
||||
}
|
||||
|
||||
if (generatedClickId) {
|
||||
attribution.im_ref = generatedClickId
|
||||
}
|
||||
|
||||
if (hasAttributionChanges(storedAttribution, attribution)) {
|
||||
persistAttribution(attribution)
|
||||
}
|
||||
|
||||
const gaIdentity = getGaIdentity()
|
||||
|
||||
return {
|
||||
...attribution,
|
||||
ga_client_id: gaIdentity?.client_id,
|
||||
ga_session_id: gaIdentity?.session_id,
|
||||
ga_session_number: gaIdentity?.session_number
|
||||
}
|
||||
}
|
||||
@@ -215,8 +215,6 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
}
|
||||
|
||||
workflowDraftStore.removeDraft(workflow.path)
|
||||
|
||||
// If this is the last workflow, create a new default temporary workflow
|
||||
if (workflowStore.openWorkflows.length === 1) {
|
||||
await loadDefaultWorkflow()
|
||||
|
||||
157
src/platform/workflow/management/stores/comfyWorkflow.ts
Normal file
157
src/platform/workflow/management/stores/comfyWorkflow.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { UserFile } from '@/stores/userFileStore'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
export class ComfyWorkflow extends UserFile {
|
||||
static readonly basePath: string = 'workflows/'
|
||||
readonly tintCanvasBg?: string
|
||||
|
||||
/**
|
||||
* The change tracker for the workflow. Non-reactive raw object.
|
||||
*/
|
||||
changeTracker: ChangeTracker | null = null
|
||||
/**
|
||||
* Whether the workflow has been modified comparing to the initial state.
|
||||
*/
|
||||
_isModified: boolean = false
|
||||
|
||||
/**
|
||||
* @param options The path, modified, and size of the workflow.
|
||||
* Note: path is the full path, including the 'workflows/' prefix.
|
||||
*/
|
||||
constructor(options: { path: string; modified: number; size: number }) {
|
||||
super(options.path, options.modified, options.size)
|
||||
}
|
||||
|
||||
override get key() {
|
||||
return this.path.substring(ComfyWorkflow.basePath.length)
|
||||
}
|
||||
|
||||
get activeState(): ComfyWorkflowJSON | null {
|
||||
return this.changeTracker?.activeState ?? null
|
||||
}
|
||||
|
||||
get initialState(): ComfyWorkflowJSON | null {
|
||||
return this.changeTracker?.initialState ?? null
|
||||
}
|
||||
|
||||
override get isLoaded(): boolean {
|
||||
return this.changeTracker !== null
|
||||
}
|
||||
|
||||
override get isModified(): boolean {
|
||||
return this._isModified
|
||||
}
|
||||
|
||||
override set isModified(value: boolean) {
|
||||
this._isModified = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the workflow content from remote storage. Directly returns the loaded
|
||||
* workflow if the content is already loaded.
|
||||
*
|
||||
* @param force Whether to force loading the content even if it is already loaded.
|
||||
* @returns this
|
||||
*/
|
||||
override async load({ force = false }: { force?: boolean } = {}): Promise<
|
||||
this & LoadedComfyWorkflow
|
||||
> {
|
||||
const { useWorkflowDraftStore } =
|
||||
await import('@/platform/workflow/persistence/stores/workflowDraftStore')
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
let draft = !force ? draftStore.getDraft(this.path) : undefined
|
||||
let draftState: ComfyWorkflowJSON | null = null
|
||||
let draftContent: string | null = null
|
||||
|
||||
if (draft) {
|
||||
if (draft.updatedAt < this.lastModified) {
|
||||
draftStore.removeDraft(this.path)
|
||||
draft = undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (draft) {
|
||||
try {
|
||||
draftState = JSON.parse(draft.data)
|
||||
draftContent = draft.data
|
||||
} catch (err) {
|
||||
console.warn('Failed to parse workflow draft, clearing it', err)
|
||||
draftStore.removeDraft(this.path)
|
||||
}
|
||||
}
|
||||
|
||||
await super.load({ force })
|
||||
if (!force && this.isLoaded) return this as this & LoadedComfyWorkflow
|
||||
|
||||
if (!this.originalContent) {
|
||||
throw new Error('[ASSERT] Workflow content should be loaded')
|
||||
}
|
||||
|
||||
const initialState = JSON.parse(this.originalContent)
|
||||
const { ChangeTracker } = await import('@/scripts/changeTracker')
|
||||
this.changeTracker = markRaw(new ChangeTracker(this, initialState))
|
||||
if (draftState && draftContent) {
|
||||
this.changeTracker.activeState = draftState
|
||||
this.content = draftContent
|
||||
this._isModified = true
|
||||
draftStore.markDraftUsed(this.path)
|
||||
}
|
||||
return this as this & LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
override unload(): void {
|
||||
this.changeTracker = null
|
||||
super.unload()
|
||||
}
|
||||
|
||||
override async save() {
|
||||
const { useWorkflowDraftStore } =
|
||||
await import('@/platform/workflow/persistence/stores/workflowDraftStore')
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
this.content = JSON.stringify(this.activeState)
|
||||
// Force save to ensure the content is updated in remote storage incase
|
||||
// the isModified state is screwed by changeTracker.
|
||||
const ret = await super.save({ force: true })
|
||||
this.changeTracker?.reset()
|
||||
this.isModified = false
|
||||
draftStore.removeDraft(this.path)
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the workflow as a new file.
|
||||
* @param path The path to save the workflow to. Note: with 'workflows/' prefix.
|
||||
* @returns this
|
||||
*/
|
||||
override async saveAs(path: string) {
|
||||
const { useWorkflowDraftStore } =
|
||||
await import('@/platform/workflow/persistence/stores/workflowDraftStore')
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
this.content = JSON.stringify(this.activeState)
|
||||
const result = await super.saveAs(path)
|
||||
draftStore.removeDraft(path)
|
||||
return result
|
||||
}
|
||||
|
||||
async promptSave(): Promise<string | null> {
|
||||
const { useDialogService } = await import('@/services/dialogService')
|
||||
return await useDialogService().prompt({
|
||||
title: t('workflowService.saveWorkflow'),
|
||||
message: t('workflowService.enterFilename') + ':',
|
||||
defaultValue: this.filename
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoadedComfyWorkflow extends ComfyWorkflow {
|
||||
isLoaded: true
|
||||
originalContent: string
|
||||
content: string
|
||||
changeTracker: ChangeTracker
|
||||
initialState: ComfyWorkflowJSON
|
||||
activeState: ComfyWorkflowJSON
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||
@@ -911,41 +910,4 @@ describe('useWorkflowStore', () => {
|
||||
expect(mostRecent).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeWorkflow draft cleanup', () => {
|
||||
it('should remove draft for persisted workflows on close', async () => {
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
await syncRemoteWorkflows(['a.json'])
|
||||
const workflow = store.getWorkflowByPath('workflows/a.json')!
|
||||
|
||||
draftStore.saveDraft('workflows/a.json', {
|
||||
data: '{"dirty":true}',
|
||||
updatedAt: Date.now(),
|
||||
name: 'a.json',
|
||||
isTemporary: false
|
||||
})
|
||||
expect(draftStore.getDraft('workflows/a.json')).toBeDefined()
|
||||
|
||||
await store.closeWorkflow(workflow)
|
||||
|
||||
expect(draftStore.getDraft('workflows/a.json')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should remove draft for temporary workflows on close', async () => {
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
const workflow = store.createTemporary('temp.json')
|
||||
|
||||
draftStore.saveDraft(workflow.path, {
|
||||
data: '{"dirty":true}',
|
||||
updatedAt: Date.now(),
|
||||
name: 'temp.json',
|
||||
isTemporary: true
|
||||
})
|
||||
expect(draftStore.getDraft(workflow.path)).toBeDefined()
|
||||
|
||||
await store.closeWorkflow(workflow)
|
||||
|
||||
expect(draftStore.getDraft(workflow.path)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { defineStore } from 'pinia'
|
||||
import { computed, markRaw, ref, shallowRef, watch } from 'vue'
|
||||
import type { Raw } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
@@ -18,10 +17,7 @@ import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/wo
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { UserFile } from '@/stores/userFileStore'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import {
|
||||
createNodeExecutionId,
|
||||
@@ -32,149 +28,9 @@ import {
|
||||
import { generateUUID, getPathDetails } from '@/utils/formatUtil'
|
||||
import { syncEntities } from '@/utils/syncUtil'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
export class ComfyWorkflow extends UserFile {
|
||||
static readonly basePath: string = 'workflows/'
|
||||
readonly tintCanvasBg?: string
|
||||
|
||||
/**
|
||||
* The change tracker for the workflow. Non-reactive raw object.
|
||||
*/
|
||||
changeTracker: ChangeTracker | null = null
|
||||
/**
|
||||
* Whether the workflow has been modified comparing to the initial state.
|
||||
*/
|
||||
_isModified: boolean = false
|
||||
|
||||
/**
|
||||
* @param options The path, modified, and size of the workflow.
|
||||
* Note: path is the full path, including the 'workflows/' prefix.
|
||||
*/
|
||||
constructor(options: { path: string; modified: number; size: number }) {
|
||||
super(options.path, options.modified, options.size)
|
||||
}
|
||||
|
||||
override get key() {
|
||||
return this.path.substring(ComfyWorkflow.basePath.length)
|
||||
}
|
||||
|
||||
get activeState(): ComfyWorkflowJSON | null {
|
||||
return this.changeTracker?.activeState ?? null
|
||||
}
|
||||
|
||||
get initialState(): ComfyWorkflowJSON | null {
|
||||
return this.changeTracker?.initialState ?? null
|
||||
}
|
||||
|
||||
override get isLoaded(): boolean {
|
||||
return this.changeTracker !== null
|
||||
}
|
||||
|
||||
override get isModified(): boolean {
|
||||
return this._isModified
|
||||
}
|
||||
|
||||
override set isModified(value: boolean) {
|
||||
this._isModified = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the workflow content from remote storage. Directly returns the loaded
|
||||
* workflow if the content is already loaded.
|
||||
*
|
||||
* @param force Whether to force loading the content even if it is already loaded.
|
||||
* @returns this
|
||||
*/
|
||||
override async load({ force = false }: { force?: boolean } = {}): Promise<
|
||||
this & LoadedComfyWorkflow
|
||||
> {
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
let draft = !force ? draftStore.getDraft(this.path) : undefined
|
||||
let draftState: ComfyWorkflowJSON | null = null
|
||||
let draftContent: string | null = null
|
||||
|
||||
if (draft) {
|
||||
if (draft.updatedAt < this.lastModified) {
|
||||
draftStore.removeDraft(this.path)
|
||||
draft = undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (draft) {
|
||||
try {
|
||||
draftState = JSON.parse(draft.data)
|
||||
draftContent = draft.data
|
||||
} catch (err) {
|
||||
console.warn('Failed to parse workflow draft, clearing it', err)
|
||||
draftStore.removeDraft(this.path)
|
||||
}
|
||||
}
|
||||
|
||||
await super.load({ force })
|
||||
if (!force && this.isLoaded) return this as this & LoadedComfyWorkflow
|
||||
|
||||
if (!this.originalContent) {
|
||||
throw new Error('[ASSERT] Workflow content should be loaded')
|
||||
}
|
||||
|
||||
const initialState = JSON.parse(this.originalContent)
|
||||
this.changeTracker = markRaw(new ChangeTracker(this, initialState))
|
||||
if (draftState && draftContent) {
|
||||
this.changeTracker.activeState = draftState
|
||||
this.content = draftContent
|
||||
this._isModified = true
|
||||
draftStore.markDraftUsed(this.path)
|
||||
}
|
||||
return this as this & LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
override unload(): void {
|
||||
this.changeTracker = null
|
||||
super.unload()
|
||||
}
|
||||
|
||||
override async save() {
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
this.content = JSON.stringify(this.activeState)
|
||||
// Force save to ensure the content is updated in remote storage incase
|
||||
// the isModified state is screwed by changeTracker.
|
||||
const ret = await super.save({ force: true })
|
||||
this.changeTracker?.reset()
|
||||
this.isModified = false
|
||||
draftStore.removeDraft(this.path)
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the workflow as a new file.
|
||||
* @param path The path to save the workflow to. Note: with 'workflows/' prefix.
|
||||
* @returns this
|
||||
*/
|
||||
override async saveAs(path: string) {
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
this.content = JSON.stringify(this.activeState)
|
||||
const result = await super.saveAs(path)
|
||||
draftStore.removeDraft(path)
|
||||
return result
|
||||
}
|
||||
|
||||
async promptSave(): Promise<string | null> {
|
||||
return await useDialogService().prompt({
|
||||
title: t('workflowService.saveWorkflow'),
|
||||
message: t('workflowService.enterFilename') + ':',
|
||||
defaultValue: this.filename
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoadedComfyWorkflow extends ComfyWorkflow {
|
||||
isLoaded: true
|
||||
originalContent: string
|
||||
content: string
|
||||
changeTracker: ChangeTracker
|
||||
initialState: ComfyWorkflowJSON
|
||||
activeState: ComfyWorkflowJSON
|
||||
}
|
||||
import { ComfyWorkflow } from './comfyWorkflow'
|
||||
import type { LoadedComfyWorkflow } from './comfyWorkflow'
|
||||
export { ComfyWorkflow, type LoadedComfyWorkflow }
|
||||
|
||||
/**
|
||||
* Exposed store interface for the workflow store.
|
||||
@@ -222,7 +78,7 @@ interface WorkflowStore {
|
||||
activeSubgraph: Subgraph | undefined
|
||||
/** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */
|
||||
updateActiveGraph: () => void
|
||||
executionIdToCurrentId: (id: string) => any
|
||||
executionIdToCurrentId: (id: string) => string | undefined
|
||||
nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId
|
||||
nodeToNodeLocatorId: (node: LGraphNode) => NodeLocatorId
|
||||
nodeExecutionIdToNodeLocatorId: (
|
||||
@@ -387,11 +243,12 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
) as ComfyWorkflowJSON
|
||||
state.id = id
|
||||
|
||||
const workflow: ComfyWorkflow = new (existingWorkflow.constructor as any)({
|
||||
path,
|
||||
modified: Date.now(),
|
||||
size: -1
|
||||
})
|
||||
const workflow: ComfyWorkflow =
|
||||
new (existingWorkflow.constructor as typeof ComfyWorkflow)({
|
||||
path,
|
||||
modified: Date.now(),
|
||||
size: -1
|
||||
})
|
||||
workflow.originalContent = workflow.content = JSON.stringify(state)
|
||||
workflowLookup.value[workflow.path] = workflow
|
||||
return workflow
|
||||
@@ -463,9 +320,11 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
openWorkflowPaths.value = openWorkflowPaths.value.filter(
|
||||
(path) => path !== workflow.path
|
||||
)
|
||||
useWorkflowDraftStore().removeDraft(workflow.path)
|
||||
if (workflow.isTemporary) {
|
||||
// Clear thumbnail when temporary workflow is closed
|
||||
clearThumbnail(workflow.key)
|
||||
// Clear draft when unsaved workflow tab is closed
|
||||
useWorkflowDraftStore().removeDraft(workflow.path)
|
||||
delete workflowLookup.value[workflow.path]
|
||||
} else {
|
||||
workflow.unload()
|
||||
@@ -715,7 +574,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
}
|
||||
|
||||
//FIXME: use existing util function
|
||||
const executionIdToCurrentId = (id: string) => {
|
||||
const executionIdToCurrentId = (id: string): string | undefined => {
|
||||
const subgraph = activeSubgraph.value
|
||||
|
||||
// Short-circuit: ID belongs to the parent workflow / no active subgraph
|
||||
|
||||
@@ -239,6 +239,15 @@ interface CreateTopupResponse {
|
||||
amount_cents: number
|
||||
}
|
||||
|
||||
interface TopupStatusResponse {
|
||||
topup_id: string
|
||||
status: TopupStatus
|
||||
amount_cents: number
|
||||
error_message?: string
|
||||
created_at: string
|
||||
completed_at?: string
|
||||
}
|
||||
|
||||
type BillingOpStatus = 'pending' | 'succeeded' | 'failed'
|
||||
|
||||
export interface BillingOpStatusResponse {
|
||||
@@ -692,6 +701,23 @@ export const workspaceApi = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get top-up status
|
||||
* GET /api/billing/topup/:id
|
||||
*/
|
||||
async getTopupStatus(topupId: string): Promise<TopupStatusResponse> {
|
||||
const headers = await getAuthHeaderOrThrow()
|
||||
try {
|
||||
const response = await workspaceApiClient.get<TopupStatusResponse>(
|
||||
api.apiURL(`/billing/topup/${topupId}`),
|
||||
{ headers }
|
||||
)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
handleAxiosError(err)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get billing events
|
||||
* GET /api/billing/events
|
||||
|
||||
@@ -130,13 +130,8 @@ describe('useInviteUrlLoader', () => {
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'success',
|
||||
summary: 'Invite Accepted',
|
||||
detail: {
|
||||
text: 'You have been added to Test Workspace',
|
||||
workspaceId: 'ws-123',
|
||||
workspaceName: 'Test Workspace'
|
||||
},
|
||||
group: 'invite-accepted',
|
||||
closable: true
|
||||
detail: 'You have been added to Test Workspace',
|
||||
life: 5000
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -81,17 +81,12 @@ export function useInviteUrlLoader() {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspace.inviteAccepted'),
|
||||
detail: {
|
||||
text: t(
|
||||
'workspace.addedToWorkspace',
|
||||
{ workspaceName: result.workspaceName },
|
||||
{ escapeParameter: false }
|
||||
),
|
||||
workspaceName: result.workspaceName,
|
||||
workspaceId: result.workspaceId
|
||||
},
|
||||
group: 'invite-accepted',
|
||||
closable: true
|
||||
detail: t(
|
||||
'workspace.addedToWorkspace',
|
||||
{ workspaceName: result.workspaceName },
|
||||
{ escapeParameter: false }
|
||||
),
|
||||
life: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
@@ -36,6 +37,14 @@ function getBasePath(): string {
|
||||
|
||||
const basePath = getBasePath()
|
||||
|
||||
function trackPageView(): void {
|
||||
if (!isCloud || typeof window === 'undefined') return
|
||||
|
||||
useTelemetry()?.trackPageView(document.title, {
|
||||
path: window.location.href
|
||||
})
|
||||
}
|
||||
|
||||
const router = createRouter({
|
||||
history: isFileProtocol
|
||||
? createWebHashHistory()
|
||||
@@ -93,6 +102,10 @@ installPreservedQueryTracker(router, [
|
||||
}
|
||||
])
|
||||
|
||||
router.afterEach(() => {
|
||||
trackPageView()
|
||||
})
|
||||
|
||||
if (isCloud) {
|
||||
const { flags } = useFeatureFlags()
|
||||
const PUBLIC_ROUTE_NAMES = new Set([
|
||||
|
||||
@@ -624,22 +624,6 @@ export const useDialogService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function showInviteMemberUpsellDialog() {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/InviteMemberUpsellDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'invite-member-upsell',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt,
|
||||
pt: {
|
||||
...workspaceDialogPt.pt,
|
||||
root: { class: 'rounded-2xl max-w-[512px] w-full' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function showRevokeInviteDialog(inviteId: string) {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/RevokeInviteDialogContent.vue')
|
||||
@@ -711,7 +695,6 @@ export const useDialogService = () => {
|
||||
showRemoveMemberDialog,
|
||||
showRevokeInviteDialog,
|
||||
showInviteMemberDialog,
|
||||
showInviteMemberUpsellDialog,
|
||||
showBillingComingSoonDialog,
|
||||
showCancelSubscriptionDialog
|
||||
}
|
||||
|
||||
@@ -349,7 +349,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackAuth({
|
||||
method: 'email',
|
||||
is_new_user: false
|
||||
is_new_user: false,
|
||||
user_id: result.user.uid
|
||||
})
|
||||
}
|
||||
|
||||
@@ -369,7 +370,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackAuth({
|
||||
method: 'email',
|
||||
is_new_user: true
|
||||
is_new_user: true,
|
||||
user_id: result.user.uid
|
||||
})
|
||||
}
|
||||
|
||||
@@ -387,7 +389,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
const isNewUser = additionalUserInfo?.isNewUser ?? false
|
||||
useTelemetry()?.trackAuth({
|
||||
method: 'google',
|
||||
is_new_user: isNewUser
|
||||
is_new_user: isNewUser,
|
||||
user_id: result.user.uid
|
||||
})
|
||||
}
|
||||
|
||||
@@ -405,7 +408,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
const isNewUser = additionalUserInfo?.isNewUser ?? false
|
||||
useTelemetry()?.trackAuth({
|
||||
method: 'github',
|
||||
is_new_user: isNewUser
|
||||
is_new_user: isNewUser,
|
||||
user_id: result.user.uid
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,9 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type {
|
||||
ComfyNode,
|
||||
ComfyWorkflowJSON,
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
</div>
|
||||
|
||||
<GlobalToast />
|
||||
<InviteAcceptedToast />
|
||||
<RerouteMigrationToast />
|
||||
<ModelImportProgressDialog />
|
||||
<ManagerProgressToast />
|
||||
@@ -45,7 +44,6 @@ import MenuHamburger from '@/components/MenuHamburger.vue'
|
||||
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
|
||||
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
|
||||
import GlobalToast from '@/components/toast/GlobalToast.vue'
|
||||
import InviteAcceptedToast from '@/components/toast/InviteAcceptedToast.vue'
|
||||
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
|
||||
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
|
||||
import { useCoreCommands } from '@/composables/useCoreCommands'
|
||||
|
||||
Reference in New Issue
Block a user