Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ declare const __SENTRY_DSN__: string
declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __USE_PROD_CONFIG__: boolean
declare const __GTM_ENABLED__: boolean

interface Window {
__CONFIG__: {
Expand All @@ -30,6 +31,7 @@ interface Window {
badge?: string
}
}
dataLayer?: Array<Record<string, unknown>>
}

interface Navigator {
Expand Down
3 changes: 3 additions & 0 deletions scripts/vite-define-shim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ declare global {
const __ALGOLIA_APP_ID__: string
const __ALGOLIA_API_KEY__: string
const __USE_PROD_CONFIG__: boolean
const __GTM_ENABLED__: boolean
const __DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
const __IS_NIGHTLY__: boolean
}
Expand All @@ -22,6 +23,7 @@ type GlobalWithDefines = typeof globalThis & {
__ALGOLIA_APP_ID__: string
__ALGOLIA_API_KEY__: string
__USE_PROD_CONFIG__: boolean
__GTM_ENABLED__: boolean
__DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
__IS_NIGHTLY__: boolean
window?: Record<string, unknown>
Expand All @@ -37,6 +39,7 @@ globalWithDefines.__SENTRY_DSN__ = ''
globalWithDefines.__ALGOLIA_APP_ID__ = ''
globalWithDefines.__ALGOLIA_API_KEY__ = ''
globalWithDefines.__USE_PROD_CONFIG__ = false
globalWithDefines.__GTM_ENABLED__ = false
globalWithDefines.__DISTRIBUTION__ = 'localhost'
globalWithDefines.__IS_NIGHTLY__ = false

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,51 @@ describe('useSubscription', () => {
)
})

it('pushes purchase event after a pending subscription completes', async () => {
const originalGtmEnabled = __GTM_ENABLED__
try {
vi.stubGlobal('__GTM_ENABLED__', true)
window.dataLayer = []
localStorage.setItem(
'pending_subscription_purchase',
JSON.stringify({
tierKey: 'creator',
billingCycle: 'monthly',
timestamp: Date.now()
})
)

vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
subscription_tier: 'CREATOR',
subscription_duration: 'MONTHLY'
})
} as Response)

mockIsLoggedIn.value = true
const { fetchStatus } = useSubscription()

await fetchStatus()

expect(window.dataLayer).toHaveLength(1)
expect(window.dataLayer?.[0]).toMatchObject({
event: 'purchase',
transaction_id: 'sub_123',
currency: 'USD',
item_id: 'monthly_creator',
item_variant: 'monthly',
item_category: 'subscription',
quantity: 1
})
expect(localStorage.getItem('pending_subscription_purchase')).toBeNull()
} finally {
vi.stubGlobal('__GTM_ENABLED__', originalGtmEnabled)
}
})

it('should handle fetch errors gracefully', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: false,
Expand Down
53 changes: 51 additions & 2 deletions src/platform/cloud/subscription/composables/useSubscription.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { createSharedComposable } from '@vueuse/core'

import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } 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 { useDialogService } from '@/services/dialogService'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import {
getTierPrice,
TIER_TO_KEY
} from '@/platform/cloud/subscription/constants/tierPricing'
import {
clearPendingSubscriptionPurchase,
getPendingSubscriptionPurchase
} from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker'
import type { operations } from '@/types/comfyRegistryTypes'
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'

Expand All @@ -29,6 +36,7 @@
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
const telemetry = useTelemetry()
const isInitialized = ref(false)
const { t } = useI18n()

Check failure on line 39 in src/platform/cloud/subscription/composables/useSubscription.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/useSubscription.test.ts > useSubscription > computed properties > should return empty string when renewal date is not available

SyntaxError: Must be called at the top of a `setup` function ❯ createCompileError node_modules/.pnpm/@intlify+message-compiler@9.14.3/node_modules/@intlify/message-compiler/dist/message-compiler.node.mjs:95:19 ❯ createI18nError node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:103:12 ❯ useI18n node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:2325:15 ❯ useSubscriptionInternal src/platform/cloud/subscription/composables/useSubscription.ts:39:17 ❯ node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:31 ❯ EffectScope.run node_modules/.pnpm/@VUE+reactivity@3.5.13/node_modules/@vue/reactivity/dist/reactivity.cjs.js:81:16 ❯ Module.<anonymous> node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:21 ❯ src/platform/cloud/subscription/composables/useSubscription.test.ts:150:40 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 26, domain: undefined }

Check failure on line 39 in src/platform/cloud/subscription/composables/useSubscription.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/useSubscription.test.ts > useSubscription > computed properties > should format renewal date correctly

SyntaxError: Must be called at the top of a `setup` function ❯ createCompileError node_modules/.pnpm/@intlify+message-compiler@9.14.3/node_modules/@intlify/message-compiler/dist/message-compiler.node.mjs:95:19 ❯ createI18nError node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:103:12 ❯ useI18n node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:2325:15 ❯ useSubscriptionInternal src/platform/cloud/subscription/composables/useSubscription.ts:39:17 ❯ node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:31 ❯ EffectScope.run node_modules/.pnpm/@VUE+reactivity@3.5.13/node_modules/@vue/reactivity/dist/reactivity.cjs.js:81:16 ❯ Module.<anonymous> node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:21 ❯ src/platform/cloud/subscription/composables/useSubscription.test.ts:140:53 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 26, domain: undefined }

Check failure on line 39 in src/platform/cloud/subscription/composables/useSubscription.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/useSubscription.test.ts > useSubscription > computed properties > should format renewal date correctly

SyntaxError: Must be called at the top of a `setup` function ❯ createCompileError node_modules/.pnpm/@intlify+message-compiler@9.14.3/node_modules/@intlify/message-compiler/dist/message-compiler.node.mjs:95:19 ❯ createI18nError node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:103:12 ❯ useI18n node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:2325:15 ❯ useSubscriptionInternal src/platform/cloud/subscription/composables/useSubscription.ts:39:17 ❯ node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:31 ❯ EffectScope.run node_modules/.pnpm/@VUE+reactivity@3.5.13/node_modules/@vue/reactivity/dist/reactivity.cjs.js:81:16 ❯ Module.<anonymous> node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:21 ❯ src/platform/cloud/subscription/composables/useSubscription.test.ts:140:53 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 26, domain: undefined }

Check failure on line 39 in src/platform/cloud/subscription/composables/useSubscription.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/useSubscription.test.ts > useSubscription > computed properties > should format renewal date correctly

SyntaxError: Must be called at the top of a `setup` function ❯ createCompileError node_modules/.pnpm/@intlify+message-compiler@9.14.3/node_modules/@intlify/message-compiler/dist/message-compiler.node.mjs:95:19 ❯ createI18nError node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:103:12 ❯ useI18n node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:2325:15 ❯ useSubscriptionInternal src/platform/cloud/subscription/composables/useSubscription.ts:39:17 ❯ node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:31 ❯ EffectScope.run node_modules/.pnpm/@VUE+reactivity@3.5.13/node_modules/@vue/reactivity/dist/reactivity.cjs.js:81:16 ❯ Module.<anonymous> node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:21 ❯ src/platform/cloud/subscription/composables/useSubscription.test.ts:140:53 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 26, domain: undefined }

Check failure on line 39 in src/platform/cloud/subscription/composables/useSubscription.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/useSubscription.test.ts > useSubscription > computed properties > should compute isActiveSubscription as false when subscription is inactive

SyntaxError: Must be called at the top of a `setup` function ❯ createCompileError node_modules/.pnpm/@intlify+message-compiler@9.14.3/node_modules/@intlify/message-compiler/dist/message-compiler.node.mjs:95:19 ❯ createI18nError node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:103:12 ❯ useI18n node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:2325:15 ❯ useSubscriptionInternal src/platform/cloud/subscription/composables/useSubscription.ts:39:17 ❯ node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:31 ❯ EffectScope.run node_modules/.pnpm/@VUE+reactivity@3.5.13/node_modules/@vue/reactivity/dist/reactivity.cjs.js:81:16 ❯ Module.<anonymous> node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:21 ❯ src/platform/cloud/subscription/composables/useSubscription.test.ts:123:53 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 26, domain: undefined }

Check failure on line 39 in src/platform/cloud/subscription/composables/useSubscription.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/useSubscription.test.ts > useSubscription > computed properties > should compute isActiveSubscription as false when subscription is inactive

SyntaxError: Must be called at the top of a `setup` function ❯ createCompileError node_modules/.pnpm/@intlify+message-compiler@9.14.3/node_modules/@intlify/message-compiler/dist/message-compiler.node.mjs:95:19 ❯ createI18nError node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:103:12 ❯ useI18n node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:2325:15 ❯ useSubscriptionInternal src/platform/cloud/subscription/composables/useSubscription.ts:39:17 ❯ node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:31 ❯ EffectScope.run node_modules/.pnpm/@VUE+reactivity@3.5.13/node_modules/@vue/reactivity/dist/reactivity.cjs.js:81:16 ❯ Module.<anonymous> node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:21 ❯ src/platform/cloud/subscription/composables/useSubscription.test.ts:123:53 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 26, domain: undefined }

Check failure on line 39 in src/platform/cloud/subscription/composables/useSubscription.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/useSubscription.test.ts > useSubscription > computed properties > should compute isActiveSubscription as false when subscription is inactive

SyntaxError: Must be called at the top of a `setup` function ❯ createCompileError node_modules/.pnpm/@intlify+message-compiler@9.14.3/node_modules/@intlify/message-compiler/dist/message-compiler.node.mjs:95:19 ❯ createI18nError node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:103:12 ❯ useI18n node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:2325:15 ❯ useSubscriptionInternal src/platform/cloud/subscription/composables/useSubscription.ts:39:17 ❯ node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:31 ❯ EffectScope.run node_modules/.pnpm/@VUE+reactivity@3.5.13/node_modules/@vue/reactivity/dist/reactivity.cjs.js:81:16 ❯ Module.<anonymous> node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:21 ❯ src/platform/cloud/subscription/composables/useSubscription.test.ts:123:53 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 26, domain: undefined }

Check failure on line 39 in src/platform/cloud/subscription/composables/useSubscription.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/useSubscription.test.ts > useSubscription > computed properties > should compute isActiveSubscription correctly when subscription is active

SyntaxError: Must be called at the top of a `setup` function ❯ createCompileError node_modules/.pnpm/@intlify+message-compiler@9.14.3/node_modules/@intlify/message-compiler/dist/message-compiler.node.mjs:95:19 ❯ createI18nError node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:103:12 ❯ useI18n node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:2325:15 ❯ useSubscriptionInternal src/platform/cloud/subscription/composables/useSubscription.ts:39:17 ❯ node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:31 ❯ EffectScope.run node_modules/.pnpm/@VUE+reactivity@3.5.13/node_modules/@vue/reactivity/dist/reactivity.cjs.js:81:16 ❯ Module.<anonymous> node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:21 ❯ src/platform/cloud/subscription/composables/useSubscription.test.ts:106:53 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 26, domain: undefined }

Check failure on line 39 in src/platform/cloud/subscription/composables/useSubscription.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/useSubscription.test.ts > useSubscription > computed properties > should compute isActiveSubscription correctly when subscription is active

SyntaxError: Must be called at the top of a `setup` function ❯ createCompileError node_modules/.pnpm/@intlify+message-compiler@9.14.3/node_modules/@intlify/message-compiler/dist/message-compiler.node.mjs:95:19 ❯ createI18nError node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:103:12 ❯ useI18n node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:2325:15 ❯ useSubscriptionInternal src/platform/cloud/subscription/composables/useSubscription.ts:39:17 ❯ node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:31 ❯ EffectScope.run node_modules/.pnpm/@VUE+reactivity@3.5.13/node_modules/@vue/reactivity/dist/reactivity.cjs.js:81:16 ❯ Module.<anonymous> node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:21 ❯ src/platform/cloud/subscription/composables/useSubscription.test.ts:106:53 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 26, domain: undefined }

Check failure on line 39 in src/platform/cloud/subscription/composables/useSubscription.ts

View workflow job for this annotation

GitHub Actions / test

src/platform/cloud/subscription/composables/useSubscription.test.ts > useSubscription > computed properties > should compute isActiveSubscription correctly when subscription is active

SyntaxError: Must be called at the top of a `setup` function ❯ createCompileError node_modules/.pnpm/@intlify+message-compiler@9.14.3/node_modules/@intlify/message-compiler/dist/message-compiler.node.mjs:95:19 ❯ createI18nError node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:103:12 ❯ useI18n node_modules/.pnpm/vue-i18n@9.14.3_vue@3.5.13_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:2325:15 ❯ useSubscriptionInternal src/platform/cloud/subscription/composables/useSubscription.ts:39:17 ❯ node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:31 ❯ EffectScope.run node_modules/.pnpm/@VUE+reactivity@3.5.13/node_modules/@vue/reactivity/dist/reactivity.cjs.js:81:16 ❯ Module.<anonymous> node_modules/.pnpm/@vueuse+shared@11.0.0_vue@3.5.13_typescript@5.9.3_/node_modules/@vueuse/shared/index.mjs:146:21 ❯ src/platform/cloud/subscription/composables/useSubscription.test.ts:106:53 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 26, domain: undefined }

const isSubscribedOrIsNotCloud = computed(() => {
if (!isCloud || !window.__CONFIG__?.subscription_required) return true
Expand Down Expand Up @@ -94,6 +102,46 @@
})

const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
const isGtmEnabled = __GTM_ENABLED__

const pushDataLayerEvent = (event: Record<string, unknown>) => {
if (!isGtmEnabled || typeof window === 'undefined') return
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
dataLayer.push(event)
}

const trackSubscriptionPurchase = (
status: CloudSubscriptionStatusResponse | null
) => {
if (!status?.is_active || !status.subscription_id) return

const pendingPurchase = getPendingSubscriptionPurchase()
if (!pendingPurchase) return

const { tierKey, billingCycle } = pendingPurchase
const isYearly = billingCycle === 'yearly'
const baseName = t(`subscription.tiers.${tierKey}.name`)
const planName = isYearly
? t('subscription.tierNameYearly', { name: baseName })
: baseName
const unitPrice = getTierPrice(tierKey, isYearly)
const value = isYearly && tierKey !== 'founder' ? unitPrice * 12 : unitPrice

pushDataLayerEvent({
event: 'purchase',
transaction_id: status.subscription_id,
value,
currency: 'USD',
item_id: `${billingCycle}_${tierKey}`,
item_name: planName,
item_category: 'subscription',
item_variant: billingCycle,
price: value,
quantity: 1
})

clearPendingSubscriptionPurchase()
}

const fetchStatus = wrapWithErrorHandlingAsync(
fetchSubscriptionStatus,
Expand Down Expand Up @@ -194,6 +242,7 @@

const statusData = await response.json()
subscriptionStatus.value = statusData
trackSubscriptionPurchase(statusData)
return statusData
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { startSubscriptionPurchaseTracking } from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker'
import type { BillingCycle } from './subscriptionTierRank'

type CheckoutTier = TierKey | `${TierKey}-yearly`
Expand Down Expand Up @@ -78,6 +79,9 @@ export async function performSubscriptionCheckout(
const data = await response.json()

if (data.checkout_url) {
if (__GTM_ENABLED__) {
startSubscriptionPurchaseTracking(tierKey, currentBillingCycle)
}
if (openInNewTab) {
window.open(data.checkout_url, '_blank')
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from './subscriptionTierRank'

type PendingSubscriptionPurchase = {
tierKey: TierKey
billingCycle: BillingCycle
timestamp: number
}

const STORAGE_KEY = 'pending_subscription_purchase'
const MAX_AGE_MS = 24 * 60 * 60 * 1000 // 24 hours
const VALID_TIERS: TierKey[] = ['standard', 'creator', 'pro', 'founder']
const VALID_CYCLES: BillingCycle[] = ['monthly', 'yearly']

export function startSubscriptionPurchaseTracking(
tierKey: TierKey,
billingCycle: BillingCycle
): void {
if (typeof window === 'undefined') return
try {
const payload: PendingSubscriptionPurchase = {
tierKey,
billingCycle,
timestamp: Date.now()
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
} catch {
// Ignore storage errors (e.g. private browsing mode)
}
}

export function getPendingSubscriptionPurchase(): PendingSubscriptionPurchase | null {
if (typeof window === 'undefined') return null
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null

try {
const parsed = JSON.parse(raw) as PendingSubscriptionPurchase
if (!parsed || typeof parsed !== 'object') {
localStorage.removeItem(STORAGE_KEY)
return null
}

const { tierKey, billingCycle, timestamp } = parsed
if (
!VALID_TIERS.includes(tierKey) ||
!VALID_CYCLES.includes(billingCycle) ||
typeof timestamp !== 'number'
) {
localStorage.removeItem(STORAGE_KEY)
return null
}

if (Date.now() - timestamp > MAX_AGE_MS) {
localStorage.removeItem(STORAGE_KEY)
return null
}

return parsed
} catch {
localStorage.removeItem(STORAGE_KEY)
return null
}
}

export function clearPendingSubscriptionPurchase(): void {
if (typeof window === 'undefined') return
localStorage.removeItem(STORAGE_KEY)
}
15 changes: 15 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ function getBasePath(): string {

const basePath = getBasePath()

function pushPageView(): void {
if (!__GTM_ENABLED__ || typeof window === 'undefined') return

const dataLayer = window.dataLayer ?? (window.dataLayer = [])
dataLayer.push({
event: 'page_view',
page_location: window.location.href,
page_title: document.title

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip invite tokens from page_location before GTM push

Because pushPageView sends window.location.href as page_location, any invite tokens in the query string are forwarded to GTM. The cloud invite flow explicitly accepts /?invite=TOKEN and only removes it later in useInviteUrlLoader (so the token is still present on the first navigation), which means the raw invite token will be sent to third‑party analytics on the initial page_view. To avoid leaking sensitive invite codes, consider sanitizing page_location (e.g., remove invite and other preserved query params) before pushing to dataLayer.

Useful? React with 👍 / 👎.

Comment on lines +44 to +46

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid sending user workflow titles to GTM

pushPageView forwards document.title as page_title. In this app, document.title is derived from the active workflow filename and node execution text via useBrowserTabTitle (see src/composables/useBrowserTabTitle.ts), which are user-provided values. On cloud builds this will send workflow names and progress text to GTM/GA on every route change, which can leak sensitive user content into analytics. Consider omitting page_title or replacing it with a sanitized/static value such as the route name before pushing to dataLayer.

Useful? React with 👍 / 👎.

})
}
Comment on lines +40 to +48
Copy link
Contributor

@coderabbitai coderabbitai bot Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential timing issue with document.title.

When pushPageView is called in router.afterEach, document.title may still reflect the previous page's title since Vue Router's navigation completes before the new component is mounted and updates the title.

Consider using to.meta.title if available, or deferring the push with nextTick to allow the DOM to update:

🔧 Suggested fix using nextTick
+import { nextTick } from 'vue'
+
 function pushPageView(): void {
   if (!isCloud) return

-  const dataLayer = window.dataLayer ?? (window.dataLayer = [])
-  dataLayer.push({
-    event: 'page_view',
-    page_location: window.location.href,
-    page_title: document.title
+  nextTick(() => {
+    const dataLayer = window.dataLayer ?? (window.dataLayer = [])
+    dataLayer.push({
+      event: 'page_view',
+      page_location: window.location.href,
+      page_title: document.title
+    })
   })
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function pushPageView(): void {
if (!isCloud) return
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
dataLayer.push({
event: 'page_view',
page_location: window.location.href,
page_title: document.title
})
}
import { nextTick } from 'vue'
function pushPageView(): void {
if (!isCloud) return
nextTick(() => {
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
dataLayer.push({
event: 'page_view',
page_location: window.location.href,
page_title: document.title
})
})
}
🤖 Prompt for AI Agents
In `@src/router.ts` around lines 39 - 48, pushPageView may read document.title too
early when called from router.afterEach causing the old title to be pushed;
change pushPageView (and its caller in router.afterEach) to prefer a provided
title param (e.g., to.meta.title) and/or defer reading document.title via
Vue.nextTick before building the dataLayer object so the new page title is
available. Update the pushPageView signature to accept an optional title and in
router.afterEach pass to.meta.title if present, otherwise call pushPageView
inside nextTick to read document.title.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahh, we'll see what marketing/gtm wants. we pagetitle is practically only ever going to be ComfyUI because we only change it for workflow name, unsaved indicator, and node/progress status.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@benceruleanlu, understood! That makes sense—if the title is mostly static, the timing issue becomes less critical. It's good to verify with your marketing/GTM team whether they need route-specific titles or if the current approach works for their tracking needs. Feel free to follow up if they have specific requirements! 👍


✏️ Learnings added
Learnt from: benceruleanlu
Repo: Comfy-Org/ComfyUI_frontend PR: 8311
File: src/router.ts:39-48
Timestamp: 2026-01-26T20:01:11.087Z
Learning: In ComfyUI, the page title (document.title) is mostly static and remains "ComfyUI" throughout the application. It only changes for workflow name, unsaved indicator, and node/progress status, not for different routes or pages.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


const router = createRouter({
history: isFileProtocol
? createWebHashHistory()
Expand Down Expand Up @@ -93,6 +104,10 @@ installPreservedQueryTracker(router, [
}
])

router.afterEach(() => {
pushPageView()
})

if (isCloud) {
const { flags } = useFeatureFlags()
const PUBLIC_ROUTE_NAMES = new Set([
Expand Down
32 changes: 32 additions & 0 deletions src/stores/firebaseAuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,31 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {

const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`

const pushDataLayerEvent = (event: Record<string, unknown>) => {
if (!__GTM_ENABLED__ || typeof window === 'undefined') return
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
dataLayer.push(event)
}

const hashSha256 = async (value: string): Promise<string | undefined> => {
if (typeof crypto === 'undefined' || !crypto.subtle) return
const data = new TextEncoder().encode(value)
const hash = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}

const trackSignUp = async (method: 'email' | 'google' | 'github') => {
const userId = currentUser.value?.uid
const hashedUserId = userId ? await hashSha256(userId) : undefined
pushDataLayerEvent({
event: 'sign_up',
method,
...(hashedUserId ? { user_id: hashedUserId } : {})
})
}

// Providers
const googleProvider = new GoogleAuthProvider()
googleProvider.addScope('email')
Expand Down Expand Up @@ -347,6 +372,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
method: 'email',
is_new_user: true
})
await trackSignUp('email')
}

return result
Expand All @@ -365,6 +391,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
method: 'google',
is_new_user: isNewUser
})
if (isNewUser) {
await trackSignUp('google')
}
}

return result
Expand All @@ -383,6 +412,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
method: 'github',
is_new_user: isNewUser
})
if (isNewUser) {
await trackSignUp('github')
}
}

return result
Expand Down
Loading
Loading