Compare commits

..

4 Commits

Author SHA1 Message Date
Benjamin Lu
4367aea2f1 [backport cloud/1.38] fix: use existing workflow save prompt i18n key 2026-02-11 16:38:52 -08:00
Benjamin Lu
ebc57e7aad feat: integrate Impact telemetry with checkout attribution for subscriptions (#8688)
Implement Impact telemetry and checkout attribution through cloud
subscription checkout flows.

This PR adds Impact.com tracking support and carries attribution context
from landing-page visits into subscription checkout requests so
conversion attribution can be validated end-to-end.

- Register a new `ImpactTelemetryProvider` during cloud telemetry
initialization.
- Initialize the Impact queue/runtime (`ire`) and load the Universal
Tracking Tag script once.
- Invoke `ire('identify', ...)` on page views with dynamic `customerId`
and SHA-1 `customerEmail` (or empty strings when unknown).
- Expand checkout attribution capture to include `im_ref`, UTM fields,
and Google click IDs, with local persistence across navigation.
- Attempt `ire('generateClickId')` with a timeout and fall back to
URL/local attribution when unavailable.
- Include attribution payloads in checkout creation requests for both:
  - `/customers/cloud-subscription-checkout`
  - `/customers/cloud-subscription-checkout/{tier}`
- Extend begin-checkout telemetry metadata typing to include attribution
fields.
- Add focused unit coverage for provider behavior, attribution
persistence/fallback logic, and checkout request payloads.

Tradeoffs / constraints:
- Attribution collection is treated as best-effort in tiered checkout
flow to avoid blocking purchases.
- Backend checkout handlers must accept and process the additional JSON
attribution fields.

## Screenshots

<img width="908" height="208" alt="image"
src="https://github.com/user-attachments/assets/03c16d60-ffda-40c9-9bd6-8914d841be50"/>
<img width="1144" height="460" alt="image"
src="https://github.com/user-attachments/assets/74b97fde-ce0a-43e6-838e-9a4aba484488"/>
<img width="1432" height="320" alt="image"
src="https://github.com/user-attachments/assets/30c22a9f-7bd8-409f-b0ef-e4d02343780a"/>
<img width="341" height="135" alt="image"
src="https://github.com/user-attachments/assets/f6d918ae-5f80-45e0-855a-601abea61dec"/>

(cherry picked from commit da56c9e554)
2026-02-11 16:19:40 -08:00
Benjamin Lu
d44924a73d fix: keep begin_checkout user_id reactive in subscription flows (#8726)
## Summary

Use reactive `userId` reads for `begin_checkout` telemetry so delayed
auth state updates are reflected at event time instead of using a stale
snapshot.

## Changes

- **What**: switched subscription checkout telemetry paths to
`storeToRefs(useFirebaseAuthStore())` and read `userId.value` when
dispatching `trackBeginCheckout`.
- **What**: added regression tests that mutate `userId` after setup /
after checkout starts and assert telemetry uses the updated ID.

## Review Focus

- Verify `PricingTable` and `performSubscriptionCheckout` still emit
exactly one `begin_checkout` event per action, with `checkout_type:
change` and `checkout_type: new` in their respective paths.
- Verify the new tests would fail with stale store destructuring
(manually validated during development).

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8726-fix-keep-begin_checkout-user_id-reactive-in-subscription-flows-3006d73d365081888c84c0335ab52e09)
by [Unito](https://www.unito.io)

(cherry picked from commit 815be49112)
2026-02-11 16:19:36 -08:00
Benjamin Lu
090da3e2c9 [backport cloud/1.38] fix: route gtm through telemetry entrypoint (#8354) 2026-02-11 16:17:37 -08:00
48 changed files with 2083 additions and 786 deletions

View 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
View File

@@ -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 {

View File

@@ -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')

View File

@@ -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()
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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)
})
})
})

View File

@@ -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,

View File

@@ -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() {

View File

@@ -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

View File

@@ -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": {

View File

@@ -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, {

View File

@@ -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', () => ({

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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({

View File

@@ -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()

View File

@@ -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)
}
)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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')
})
})

View File

@@ -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 {

View File

@@ -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

View 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))
}
}

View File

@@ -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
}

View 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
}

View File

@@ -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)
}
}

View File

@@ -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'
}
])
})
})

View File

@@ -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
}
}
}

View File

@@ -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 =

View File

@@ -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()
})
})

View 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
}
}

View File

@@ -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()

View 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
}

View File

@@ -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()
})
})
})

View File

@@ -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

View File

@@ -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

View File

@@ -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
})
})

View File

@@ -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({

View File

@@ -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([

View File

@@ -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
}

View File

@@ -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
})
}

View File

@@ -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,

View File

@@ -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'