mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 13:48:49 +00:00
Compare commits
1 Commits
codex/cove
...
bl/gtm-184
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97a7c3d52d |
@@ -8,6 +8,7 @@ import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
|
||||
import { st, t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { getCheckoutPlatformSource } from '@/platform/telemetry/utils/platformSource'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -136,7 +137,8 @@ export const useAuthActions = () => {
|
||||
|
||||
const response = await authStore.initiateCreditPurchase({
|
||||
amount_micros: usdToMicros(amount),
|
||||
currency: 'usd'
|
||||
currency: 'usd',
|
||||
platform_source: getCheckoutPlatformSource()
|
||||
})
|
||||
|
||||
if (!response.checkout_url) {
|
||||
|
||||
@@ -409,6 +409,7 @@ export interface PageViewMetadata {
|
||||
}
|
||||
|
||||
export interface CheckoutAttributionMetadata {
|
||||
platform_source?: PlatformSource
|
||||
ga_client_id?: string
|
||||
ga_session_id?: string
|
||||
ga_session_number?: string
|
||||
@@ -424,6 +425,8 @@ export interface CheckoutAttributionMetadata {
|
||||
wbraid?: string
|
||||
}
|
||||
|
||||
export type PlatformSource = 'cloud' | 'desktop_cloud' | 'desktop_local'
|
||||
|
||||
export interface SubscriptionMetadata {
|
||||
current_tier?: string
|
||||
reason?: SubscriptionDialogReason
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const distribution = vi.hoisted(() => ({
|
||||
isCloud: false,
|
||||
isDesktop: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return distribution.isDesktop
|
||||
}
|
||||
}))
|
||||
|
||||
import { getCheckoutPlatformSource } from '../platformSource'
|
||||
|
||||
describe('getCheckoutPlatformSource', () => {
|
||||
beforeEach(() => {
|
||||
distribution.isCloud = false
|
||||
distribution.isDesktop = false
|
||||
window.localStorage.clear()
|
||||
window.history.pushState({}, '', '/')
|
||||
})
|
||||
|
||||
it('classifies cloud checkout launched from desktop', () => {
|
||||
distribution.isCloud = true
|
||||
window.history.pushState({}, '', '/pricing?utm_source=comfy.desktop')
|
||||
|
||||
expect(getCheckoutPlatformSource()).toBe('desktop_cloud')
|
||||
})
|
||||
|
||||
it('classifies direct cloud checkout', () => {
|
||||
distribution.isCloud = true
|
||||
|
||||
expect(getCheckoutPlatformSource()).toBe('cloud')
|
||||
})
|
||||
|
||||
it('classifies cloud checkout from persisted desktop attribution', () => {
|
||||
distribution.isCloud = true
|
||||
window.localStorage.setItem(
|
||||
'comfy_checkout_attribution',
|
||||
JSON.stringify({ utm_source: 'comfy.desktop' })
|
||||
)
|
||||
|
||||
expect(getCheckoutPlatformSource()).toBe('desktop_cloud')
|
||||
})
|
||||
|
||||
it('prefers current URL attribution over stored attribution', () => {
|
||||
distribution.isCloud = true
|
||||
window.localStorage.setItem(
|
||||
'comfy_checkout_attribution',
|
||||
JSON.stringify({ utm_source: 'comfy.desktop' })
|
||||
)
|
||||
window.history.pushState({}, '', '/pricing?utm_source=direct')
|
||||
|
||||
expect(getCheckoutPlatformSource()).toBe('cloud')
|
||||
})
|
||||
|
||||
it('classifies desktop local checkout', () => {
|
||||
distribution.isDesktop = true
|
||||
|
||||
expect(getCheckoutPlatformSource()).toBe('desktop_local')
|
||||
})
|
||||
|
||||
it('does not classify OSS browser checkout', () => {
|
||||
expect(getCheckoutPlatformSource()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,15 @@
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
import { withTimeout } from 'es-toolkit/promise'
|
||||
|
||||
import type { CheckoutAttributionMetadata } from '../types'
|
||||
import type { AttributionQueryKey } from './checkoutAttributionStorage'
|
||||
import {
|
||||
asNonEmptyString,
|
||||
hasAttributionChanges,
|
||||
persistAttribution,
|
||||
readAttributionFromUrl,
|
||||
readStoredAttribution
|
||||
} from './checkoutAttributionStorage'
|
||||
import { getCheckoutPlatformSource } from './platformSource'
|
||||
|
||||
type GaIdentity = {
|
||||
client_id?: string
|
||||
@@ -16,99 +24,10 @@ const GA_IDENTITY_FIELDS = [
|
||||
] as const satisfies ReadonlyArray<GtagGetFieldName>
|
||||
type GaIdentityField = GtagGetFieldName
|
||||
|
||||
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
|
||||
const GET_GA_IDENTITY_TIMEOUT_MS = 300
|
||||
const GET_REWARDFUL_REFERRAL_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 {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
async function getGaIdentityField(
|
||||
measurementId: string,
|
||||
fieldName: GaIdentityField
|
||||
@@ -245,6 +164,7 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
|
||||
|
||||
return {
|
||||
...attribution,
|
||||
platform_source: getCheckoutPlatformSource(),
|
||||
ga_client_id: gaIdentity?.client_id,
|
||||
ga_session_id: gaIdentity?.session_id,
|
||||
ga_session_number: gaIdentity?.session_number,
|
||||
|
||||
95
src/platform/telemetry/utils/checkoutAttributionStorage.ts
Normal file
95
src/platform/telemetry/utils/checkoutAttributionStorage.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
|
||||
const ATTRIBUTION_QUERY_KEYS = [
|
||||
'im_ref',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
'gclid',
|
||||
'gbraid',
|
||||
'wbraid'
|
||||
] as const
|
||||
|
||||
export type AttributionQueryKey = (typeof ATTRIBUTION_QUERY_KEYS)[number]
|
||||
|
||||
const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution'
|
||||
|
||||
export function asNonEmptyString(value: unknown): string | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
export 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 {}
|
||||
}
|
||||
}
|
||||
|
||||
export function persistAttribution(
|
||||
payload: Partial<Record<AttributionQueryKey, string>>
|
||||
): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
localStorage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(payload))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
20
src/platform/telemetry/utils/platformSource.ts
Normal file
20
src/platform/telemetry/utils/platformSource.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { isCloud, isDesktop } from '@/platform/distribution/types'
|
||||
|
||||
import type { PlatformSource } from '../types'
|
||||
import {
|
||||
readAttributionFromUrl,
|
||||
readStoredAttribution
|
||||
} from './checkoutAttributionStorage'
|
||||
|
||||
export function getCheckoutPlatformSource(): PlatformSource | undefined {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
|
||||
if (isCloud) {
|
||||
const fromUrl = readAttributionFromUrl(window.location.search)
|
||||
const source = fromUrl.utm_source ?? readStoredAttribution().utm_source
|
||||
|
||||
return source === 'comfy.desktop' ? 'desktop_cloud' : 'cloud'
|
||||
}
|
||||
|
||||
return isDesktop ? 'desktop_local' : undefined
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const {
|
||||
mockAxiosInstance,
|
||||
mockGetAuthHeaderOrThrow,
|
||||
mockGetFirebaseAuthHeaderOrThrow
|
||||
mockGetFirebaseAuthHeaderOrThrow,
|
||||
mockGetCheckoutPlatformSource
|
||||
} = vi.hoisted(() => ({
|
||||
mockAxiosInstance: {
|
||||
get: vi.fn(),
|
||||
@@ -13,7 +14,8 @@ const {
|
||||
interceptors: { response: { use: vi.fn() } }
|
||||
},
|
||||
mockGetAuthHeaderOrThrow: vi.fn(),
|
||||
mockGetFirebaseAuthHeaderOrThrow: vi.fn()
|
||||
mockGetFirebaseAuthHeaderOrThrow: vi.fn(),
|
||||
mockGetCheckoutPlatformSource: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
@@ -47,6 +49,10 @@ vi.mock('@/stores/authStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/utils/platformSource', () => ({
|
||||
getCheckoutPlatformSource: mockGetCheckoutPlatformSource
|
||||
}))
|
||||
|
||||
import { workspaceApi } from './workspaceApi'
|
||||
|
||||
const AUTH_HEADER = { Authorization: 'Bearer test-token' }
|
||||
@@ -56,6 +62,7 @@ describe('workspaceApi', () => {
|
||||
vi.clearAllMocks()
|
||||
mockGetAuthHeaderOrThrow.mockResolvedValue(AUTH_HEADER)
|
||||
mockGetFirebaseAuthHeaderOrThrow.mockResolvedValue(AUTH_HEADER)
|
||||
mockGetCheckoutPlatformSource.mockReturnValue('desktop_cloud')
|
||||
})
|
||||
|
||||
describe('authentication', () => {
|
||||
@@ -367,6 +374,7 @@ describe('workspaceApi', () => {
|
||||
plan_slug: 'pro-monthly',
|
||||
return_url: 'https://return.url',
|
||||
cancel_url: 'https://cancel.url',
|
||||
platform_source: 'desktop_cloud',
|
||||
team_credit_stop_id: undefined,
|
||||
billing_cycle: undefined
|
||||
},
|
||||
@@ -390,6 +398,7 @@ describe('workspaceApi', () => {
|
||||
plan_slug: 'team_per_credit_annual',
|
||||
return_url: undefined,
|
||||
cancel_url: undefined,
|
||||
platform_source: 'desktop_cloud',
|
||||
team_credit_stop_id: 'team_700',
|
||||
billing_cycle: 'yearly'
|
||||
},
|
||||
@@ -457,7 +466,11 @@ describe('workspaceApi', () => {
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'/api/billing/topup',
|
||||
{ amount_cents: 1000, idempotency_key: 'key-3' },
|
||||
{
|
||||
amount_cents: 1000,
|
||||
idempotency_key: 'key-3',
|
||||
platform_source: 'desktop_cloud'
|
||||
},
|
||||
{ headers: AUTH_HEADER }
|
||||
)
|
||||
expect(result).toEqual(data)
|
||||
|
||||
@@ -2,6 +2,8 @@ import axios from 'axios'
|
||||
|
||||
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
|
||||
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { PlatformSource } from '@/platform/telemetry/types'
|
||||
import { getCheckoutPlatformSource } from '@/platform/telemetry/utils/platformSource'
|
||||
import type {
|
||||
WorkspaceId,
|
||||
WorkspaceInviteId
|
||||
@@ -161,6 +163,7 @@ interface SubscribeRequest {
|
||||
idempotency_key?: string
|
||||
return_url?: string
|
||||
cancel_url?: string
|
||||
platform_source?: PlatformSource
|
||||
/** Required for the per-credit Team plan; selects the slider stop. */
|
||||
team_credit_stop_id?: string
|
||||
billing_cycle?: SubscribeBillingCycle
|
||||
@@ -283,6 +286,7 @@ export interface BillingBalanceResponse {
|
||||
interface CreateTopupRequest {
|
||||
amount_cents: number
|
||||
idempotency_key?: string
|
||||
platform_source?: PlatformSource
|
||||
}
|
||||
|
||||
type TopupStatus = 'pending' | 'completed' | 'failed'
|
||||
@@ -660,6 +664,7 @@ export const workspaceApi = {
|
||||
plan_slug: planSlug,
|
||||
return_url: options.returnUrl,
|
||||
cancel_url: options.cancelUrl,
|
||||
platform_source: getCheckoutPlatformSource(),
|
||||
team_credit_stop_id: options.teamCreditStopId,
|
||||
billing_cycle: options.billingCycle
|
||||
} satisfies SubscribeRequest,
|
||||
@@ -746,7 +751,8 @@ export const workspaceApi = {
|
||||
api.apiURL('/billing/topup'),
|
||||
{
|
||||
amount_cents: amountCents,
|
||||
idempotency_key: idempotencyKey
|
||||
idempotency_key: idempotencyKey,
|
||||
platform_source: getCheckoutPlatformSource()
|
||||
} satisfies CreateTopupRequest,
|
||||
{ headers }
|
||||
)
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { PlatformSource } from '@/platform/telemetry/types'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
@@ -40,7 +41,9 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
type CreditPurchaseResponse =
|
||||
operations['InitiateCreditPurchase']['responses']['201']['content']['application/json']
|
||||
type CreditPurchasePayload =
|
||||
operations['InitiateCreditPurchase']['requestBody']['content']['application/json']
|
||||
operations['InitiateCreditPurchase']['requestBody']['content']['application/json'] & {
|
||||
platform_source?: PlatformSource
|
||||
}
|
||||
type CreateCustomerResponse =
|
||||
operations['createCustomer']['responses']['201']['content']['application/json']
|
||||
|
||||
|
||||
Reference in New Issue
Block a user