mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-18 22:10:03 +00:00
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"/>
This commit is contained in:
7
global.d.ts
vendored
7
global.d.ts
vendored
@@ -5,6 +5,11 @@ 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
|
||||
@@ -37,6 +42,8 @@ interface Window {
|
||||
session_number?: string
|
||||
}
|
||||
dataLayer?: Array<Record<string, unknown>>
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
|
||||
@@ -267,7 +267,7 @@ import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptio
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { getCheckoutAttribution } from '@/platform/telemetry/utils/checkoutAttribution'
|
||||
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
@@ -280,6 +280,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
|
||||
@@ -415,7 +428,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
|
||||
|
||||
try {
|
||||
if (isActiveSubscription.value) {
|
||||
const checkoutAttribution = getCheckoutAttribution()
|
||||
const checkoutAttribution = await getCheckoutAttributionForCloud()
|
||||
if (userId.value) {
|
||||
telemetry?.trackBeginCheckout({
|
||||
user_id: userId.value,
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
mockAccessBillingPortal,
|
||||
mockShowSubscriptionRequiredDialog,
|
||||
mockGetAuthHeader,
|
||||
mockGetCheckoutAttribution,
|
||||
mockTelemetry,
|
||||
mockUserId,
|
||||
mockIsCloud
|
||||
@@ -21,6 +22,10 @@ const {
|
||||
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()
|
||||
@@ -29,6 +34,13 @@ const {
|
||||
}))
|
||||
|
||||
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) {
|
||||
@@ -84,6 +96,10 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({
|
||||
getCheckoutAttribution: mockGetCheckoutAttribution
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => ({
|
||||
showSubscriptionRequiredDialog: mockShowSubscriptionRequiredDialog
|
||||
@@ -107,11 +123,13 @@ describe('useSubscription', () => {
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = undefined
|
||||
setDistribution('localhost')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
scope?.stop()
|
||||
scope = effectScope()
|
||||
setDistribution('cloud')
|
||||
|
||||
vi.clearAllMocks()
|
||||
mockIsLoggedIn.value = false
|
||||
@@ -284,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'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -98,6 +99,18 @@ function useSubscriptionInternal() {
|
||||
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,
|
||||
reportError
|
||||
@@ -231,6 +244,7 @@ function useSubscriptionInternal() {
|
||||
t('toastMessages.userNotAuthenticated')
|
||||
)
|
||||
}
|
||||
const checkoutAttribution = await getCheckoutAttributionForCloud()
|
||||
|
||||
const response = await fetch(
|
||||
buildApiUrl('/customers/cloud-subscription-checkout'),
|
||||
@@ -239,7 +253,8 @@ function useSubscriptionInternal() {
|
||||
headers: {
|
||||
...authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
},
|
||||
body: JSON.stringify(checkoutAttribution)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ const {
|
||||
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'
|
||||
@@ -54,6 +58,14 @@ vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({
|
||||
|
||||
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) => {
|
||||
@@ -65,6 +77,7 @@ function createDeferred<T>() {
|
||||
|
||||
describe('performSubscriptionCheckout', () => {
|
||||
beforeEach(() => {
|
||||
setDistribution('cloud')
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockUserId.value = 'user-123'
|
||||
@@ -72,6 +85,7 @@ describe('performSubscriptionCheckout', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setDistribution('localhost')
|
||||
})
|
||||
|
||||
it('tracks begin_checkout with user id and tier metadata', async () => {
|
||||
@@ -93,6 +107,10 @@ describe('performSubscriptionCheckout', () => {
|
||||
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'
|
||||
@@ -107,6 +125,10 @@ describe('performSubscriptionCheckout', () => {
|
||||
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'
|
||||
@@ -116,6 +138,41 @@ describe('performSubscriptionCheckout', () => {
|
||||
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)
|
||||
|
||||
@@ -4,11 +4,11 @@ import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { getCheckoutAttribution } from '@/platform/telemetry/utils/checkoutAttribution'
|
||||
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'
|
||||
|
||||
@@ -19,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:
|
||||
@@ -49,7 +61,15 @@ export async function performSubscriptionCheckout(
|
||||
}
|
||||
|
||||
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle)
|
||||
const checkoutAttribution = getCheckoutAttribution()
|
||||
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(
|
||||
|
||||
@@ -23,16 +23,19 @@ export async function initTelemetry(): Promise<void> {
|
||||
const [
|
||||
{ TelemetryRegistry },
|
||||
{ MixpanelTelemetryProvider },
|
||||
{ GtmTelemetryProvider }
|
||||
{ GtmTelemetryProvider },
|
||||
{ ImpactTelemetryProvider }
|
||||
] = await Promise.all([
|
||||
import('./TelemetryRegistry'),
|
||||
import('./providers/cloud/MixpanelTelemetryProvider'),
|
||||
import('./providers/cloud/GtmTelemetryProvider')
|
||||
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)
|
||||
})()
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type MockApiKeyUser = {
|
||||
id: string
|
||||
email?: string
|
||||
} | null
|
||||
|
||||
type MockFirebaseUser = {
|
||||
uid: string
|
||||
email?: string | null
|
||||
} | null
|
||||
|
||||
const {
|
||||
mockCaptureCheckoutAttributionFromSearch,
|
||||
mockUseApiKeyAuthStore,
|
||||
mockUseFirebaseAuthStore,
|
||||
mockApiKeyAuthStore,
|
||||
mockFirebaseAuthStore
|
||||
} = vi.hoisted(() => ({
|
||||
mockCaptureCheckoutAttributionFromSearch: vi.fn(),
|
||||
mockUseApiKeyAuthStore: vi.fn(),
|
||||
mockUseFirebaseAuthStore: vi.fn(),
|
||||
mockApiKeyAuthStore: {
|
||||
isAuthenticated: false,
|
||||
currentUser: null as MockApiKeyUser
|
||||
},
|
||||
mockFirebaseAuthStore: {
|
||||
currentUser: null as MockFirebaseUser
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({
|
||||
captureCheckoutAttributionFromSearch: mockCaptureCheckoutAttributionFromSearch
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/apiKeyAuthStore', () => ({
|
||||
useApiKeyAuthStore: mockUseApiKeyAuthStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: mockUseFirebaseAuthStore
|
||||
}))
|
||||
|
||||
import { ImpactTelemetryProvider } from './ImpactTelemetryProvider'
|
||||
|
||||
const IMPACT_SCRIPT_URL =
|
||||
'https://utt.impactcdn.com/A6951770-3747-434a-9ac7-4e582e67d91f1.js'
|
||||
|
||||
async function flushAsyncWork() {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function toUint8Array(data: BufferSource): Uint8Array {
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return new Uint8Array(data)
|
||||
}
|
||||
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
||||
}
|
||||
|
||||
describe('ImpactTelemetryProvider', () => {
|
||||
beforeEach(() => {
|
||||
mockCaptureCheckoutAttributionFromSearch.mockReset()
|
||||
mockUseApiKeyAuthStore.mockReset()
|
||||
mockUseFirebaseAuthStore.mockReset()
|
||||
mockApiKeyAuthStore.isAuthenticated = false
|
||||
mockApiKeyAuthStore.currentUser = null
|
||||
mockFirebaseAuthStore.currentUser = null
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
mockUseApiKeyAuthStore.mockReturnValue(mockApiKeyAuthStore)
|
||||
mockUseFirebaseAuthStore.mockReturnValue(mockFirebaseAuthStore)
|
||||
|
||||
const queueFn: NonNullable<Window['ire']> = (...args: unknown[]) => {
|
||||
;(queueFn.a ??= []).push(args)
|
||||
}
|
||||
window.ire = queueFn
|
||||
window.ire_o = undefined
|
||||
|
||||
vi.spyOn(document, 'querySelector').mockImplementation((selector) => {
|
||||
if (selector === `script[src="${IMPACT_SCRIPT_URL}"]`) {
|
||||
return document.createElement('script')
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('captures attribution and invokes identify with hashed email', async () => {
|
||||
mockFirebaseAuthStore.currentUser = {
|
||||
uid: 'user-123',
|
||||
email: ' User@Example.com '
|
||||
}
|
||||
vi.stubGlobal('crypto', {
|
||||
subtle: {
|
||||
digest: vi.fn(
|
||||
async (_algorithm: AlgorithmIdentifier, data: BufferSource) => {
|
||||
const digest = createHash('sha1')
|
||||
.update(toUint8Array(data))
|
||||
.digest()
|
||||
return Uint8Array.from(digest).buffer
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
const provider = new ImpactTelemetryProvider()
|
||||
provider.trackPageView('pricing', {
|
||||
path: 'https://cloud.comfy.org/pricing?im_ref=impact-123'
|
||||
})
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(window.ire_o).toBe('ire')
|
||||
expect(mockCaptureCheckoutAttributionFromSearch).toHaveBeenCalledWith(
|
||||
'?im_ref=impact-123'
|
||||
)
|
||||
expect(window.ire?.a).toHaveLength(1)
|
||||
expect(window.ire?.a?.[0]?.[0]).toBe('identify')
|
||||
expect(window.ire?.a?.[0]?.[1]).toEqual({
|
||||
customerId: 'user-123',
|
||||
customerEmail: '63a710569261a24b3766275b7000ce8d7b32e2f7'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to current URL search and empty identify values when user is unresolved', async () => {
|
||||
mockUseApiKeyAuthStore.mockImplementation(() => {
|
||||
throw new Error('No active pinia')
|
||||
})
|
||||
window.history.pushState({}, '', '/?im_ref=fallback-123')
|
||||
|
||||
const provider = new ImpactTelemetryProvider()
|
||||
provider.trackPageView('home')
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(mockCaptureCheckoutAttributionFromSearch).toHaveBeenCalledWith(
|
||||
'?im_ref=fallback-123'
|
||||
)
|
||||
expect(window.ire?.a).toHaveLength(1)
|
||||
expect(window.ire?.a?.[0]).toEqual([
|
||||
'identify',
|
||||
{
|
||||
customerId: '',
|
||||
customerEmail: ''
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('invokes identify on each page view even with identical identity payloads', async () => {
|
||||
mockFirebaseAuthStore.currentUser = {
|
||||
uid: 'user-123',
|
||||
email: 'user@example.com'
|
||||
}
|
||||
vi.stubGlobal('crypto', {
|
||||
subtle: {
|
||||
digest: vi.fn(async () => new Uint8Array([16, 32, 48]).buffer)
|
||||
}
|
||||
})
|
||||
const provider = new ImpactTelemetryProvider()
|
||||
provider.trackPageView('home', {
|
||||
path: 'https://cloud.comfy.org/?im_ref=1'
|
||||
})
|
||||
provider.trackPageView('pricing', {
|
||||
path: 'https://cloud.comfy.org/pricing?im_ref=2'
|
||||
})
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(window.ire?.a).toHaveLength(2)
|
||||
expect(window.ire?.a?.[0]?.[0]).toBe('identify')
|
||||
expect(window.ire?.a?.[0]?.[1]).toMatchObject({
|
||||
customerId: 'user-123'
|
||||
})
|
||||
expect(window.ire?.a?.[1]?.[0]).toBe('identify')
|
||||
expect(window.ire?.a?.[1]?.[1]).toMatchObject({
|
||||
customerId: 'user-123'
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers firebase identity when both firebase and API key identity are available', async () => {
|
||||
mockApiKeyAuthStore.isAuthenticated = true
|
||||
mockApiKeyAuthStore.currentUser = {
|
||||
id: 'api-key-user-123',
|
||||
email: 'apikey@example.com'
|
||||
}
|
||||
mockFirebaseAuthStore.currentUser = {
|
||||
uid: 'firebase-user-123',
|
||||
email: 'firebase@example.com'
|
||||
}
|
||||
vi.stubGlobal('crypto', {
|
||||
subtle: {
|
||||
digest: vi.fn(
|
||||
async (_algorithm: AlgorithmIdentifier, data: BufferSource) => {
|
||||
const digest = createHash('sha1')
|
||||
.update(toUint8Array(data))
|
||||
.digest()
|
||||
return Uint8Array.from(digest).buffer
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const provider = new ImpactTelemetryProvider()
|
||||
provider.trackPageView('home', {
|
||||
path: 'https://cloud.comfy.org/?im_ref=impact-123'
|
||||
})
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(window.ire?.a?.[0]).toEqual([
|
||||
'identify',
|
||||
{
|
||||
customerId: 'firebase-user-123',
|
||||
customerEmail: '2a2f2883bb1c5dd4ec5d18d95630834744609a7e'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to API key identity when firebase user is unavailable', async () => {
|
||||
mockApiKeyAuthStore.isAuthenticated = true
|
||||
mockApiKeyAuthStore.currentUser = {
|
||||
id: 'api-key-user-123',
|
||||
email: 'apikey@example.com'
|
||||
}
|
||||
mockFirebaseAuthStore.currentUser = null
|
||||
vi.stubGlobal('crypto', {
|
||||
subtle: {
|
||||
digest: vi.fn(
|
||||
async (_algorithm: AlgorithmIdentifier, data: BufferSource) => {
|
||||
const digest = createHash('sha1')
|
||||
.update(toUint8Array(data))
|
||||
.digest()
|
||||
return Uint8Array.from(digest).buffer
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const provider = new ImpactTelemetryProvider()
|
||||
provider.trackPageView('home', {
|
||||
path: 'https://cloud.comfy.org/?im_ref=impact-123'
|
||||
})
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(window.ire?.a?.[0]).toEqual([
|
||||
'identify',
|
||||
{
|
||||
customerId: 'api-key-user-123',
|
||||
customerEmail: '76ce7ed8519b3ab66d7520bbc3c4efcdff657028'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import { captureCheckoutAttributionFromSearch } from '@/platform/telemetry/utils/checkoutAttribution'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
import type { PageViewMetadata, TelemetryProvider } from '../../types'
|
||||
|
||||
const IMPACT_SCRIPT_URL =
|
||||
'https://utt.impactcdn.com/A6951770-3747-434a-9ac7-4e582e67d91f1.js'
|
||||
const IMPACT_QUEUE_NAME = 'ire'
|
||||
const EMPTY_CUSTOMER_VALUE = ''
|
||||
|
||||
/**
|
||||
* Impact telemetry provider.
|
||||
* Initializes the Impact queue globals and loads the runtime script.
|
||||
*/
|
||||
export class ImpactTelemetryProvider implements TelemetryProvider {
|
||||
private initialized = false
|
||||
private stores: {
|
||||
apiKeyAuthStore: ReturnType<typeof useApiKeyAuthStore>
|
||||
firebaseAuthStore: ReturnType<typeof useFirebaseAuthStore>
|
||||
} | null = null
|
||||
|
||||
constructor() {
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
trackPageView(_pageName: string, properties?: PageViewMetadata): void {
|
||||
const search = this.extractSearchFromPath(properties?.path)
|
||||
|
||||
if (search) {
|
||||
captureCheckoutAttributionFromSearch(search)
|
||||
} else if (typeof window !== 'undefined') {
|
||||
captureCheckoutAttributionFromSearch(window.location.search)
|
||||
}
|
||||
|
||||
void this.identifyCurrentUser()
|
||||
}
|
||||
|
||||
private initialize(): void {
|
||||
if (typeof window === 'undefined' || this.initialized) return
|
||||
|
||||
window.ire_o = IMPACT_QUEUE_NAME
|
||||
|
||||
if (!window.ire) {
|
||||
const queueFn: NonNullable<Window['ire']> = (...args: unknown[]) => {
|
||||
;(queueFn.a ??= []).push(args)
|
||||
}
|
||||
window.ire = queueFn
|
||||
}
|
||||
|
||||
const existingScript = document.querySelector(
|
||||
`script[src="${IMPACT_SCRIPT_URL}"]`
|
||||
)
|
||||
if (existingScript) {
|
||||
this.initialized = true
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.async = true
|
||||
script.src = IMPACT_SCRIPT_URL
|
||||
|
||||
document.head.insertBefore(script, document.head.firstChild)
|
||||
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
private extractSearchFromPath(path?: string): string {
|
||||
if (!path) return ''
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const url = new URL(path, window.location.origin)
|
||||
return url.search
|
||||
} catch {
|
||||
// Fall through to manual parsing.
|
||||
}
|
||||
}
|
||||
|
||||
const queryIndex = path.indexOf('?')
|
||||
return queryIndex >= 0 ? path.slice(queryIndex) : ''
|
||||
}
|
||||
|
||||
private async identifyCurrentUser(): Promise<void> {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const { customerId, customerEmail } = this.resolveCustomerIdentity()
|
||||
const normalizedEmail = customerEmail.trim().toLowerCase()
|
||||
// Impact's Identify spec requires customerEmail to be sent as a SHA1 hash.
|
||||
const hashedEmail = normalizedEmail
|
||||
? await this.hashSha1(normalizedEmail)
|
||||
: EMPTY_CUSTOMER_VALUE
|
||||
|
||||
window.ire?.('identify', {
|
||||
customerId,
|
||||
customerEmail: hashedEmail
|
||||
})
|
||||
}
|
||||
|
||||
private resolveCustomerIdentity(): {
|
||||
customerId: string
|
||||
customerEmail: string
|
||||
} {
|
||||
const stores = this.resolveAuthStores()
|
||||
if (!stores) {
|
||||
return {
|
||||
customerId: EMPTY_CUSTOMER_VALUE,
|
||||
customerEmail: EMPTY_CUSTOMER_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
if (stores.firebaseAuthStore.currentUser) {
|
||||
return {
|
||||
customerId:
|
||||
stores.firebaseAuthStore.currentUser.uid ?? EMPTY_CUSTOMER_VALUE,
|
||||
customerEmail:
|
||||
stores.firebaseAuthStore.currentUser.email ?? EMPTY_CUSTOMER_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
if (stores.apiKeyAuthStore.isAuthenticated) {
|
||||
return {
|
||||
customerId:
|
||||
stores.apiKeyAuthStore.currentUser?.id ?? EMPTY_CUSTOMER_VALUE,
|
||||
customerEmail:
|
||||
stores.apiKeyAuthStore.currentUser?.email ?? EMPTY_CUSTOMER_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
customerId: EMPTY_CUSTOMER_VALUE,
|
||||
customerEmail: EMPTY_CUSTOMER_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAuthStores(): {
|
||||
apiKeyAuthStore: ReturnType<typeof useApiKeyAuthStore>
|
||||
firebaseAuthStore: ReturnType<typeof useFirebaseAuthStore>
|
||||
} | null {
|
||||
if (this.stores) {
|
||||
return this.stores
|
||||
}
|
||||
|
||||
try {
|
||||
const stores = {
|
||||
apiKeyAuthStore: useApiKeyAuthStore(),
|
||||
firebaseAuthStore: useFirebaseAuthStore()
|
||||
}
|
||||
this.stores = stores
|
||||
return stores
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async hashSha1(value: string): Promise<string> {
|
||||
try {
|
||||
if (!globalThis.crypto?.subtle || typeof TextEncoder === 'undefined') {
|
||||
return EMPTY_CUSTOMER_VALUE
|
||||
}
|
||||
|
||||
const digestBuffer = await crypto.subtle.digest(
|
||||
'SHA-1',
|
||||
new TextEncoder().encode(value)
|
||||
)
|
||||
|
||||
return Array.from(new Uint8Array(digestBuffer))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
} catch {
|
||||
return EMPTY_CUSTOMER_VALUE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,18 +281,28 @@ export interface PageViewMetadata {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface BeginCheckoutMetadata extends Record<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
|
||||
ga_client_id?: string
|
||||
ga_session_id?: string
|
||||
ga_session_number?: string
|
||||
gclid?: string
|
||||
gbraid?: string
|
||||
wbraid?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,65 +1,156 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getCheckoutAttribution } from '../checkoutAttribution'
|
||||
|
||||
const storage = new Map<string, string>()
|
||||
|
||||
const mockLocalStorage = vi.hoisted(() => ({
|
||||
getItem: vi.fn((key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
storage.set(key, value)
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
storage.delete(key)
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
storage.clear()
|
||||
})
|
||||
}))
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
writable: true
|
||||
})
|
||||
import {
|
||||
captureCheckoutAttributionFromSearch,
|
||||
getCheckoutAttribution
|
||||
} from '../checkoutAttribution'
|
||||
|
||||
describe('getCheckoutAttribution', () => {
|
||||
beforeEach(() => {
|
||||
storage.clear()
|
||||
vi.clearAllMocks()
|
||||
window.localStorage.clear()
|
||||
window.__ga_identity__ = undefined
|
||||
window.ire = undefined
|
||||
window.history.pushState({}, '', '/')
|
||||
})
|
||||
|
||||
it('reads GA identity and persists click ids from URL', () => {
|
||||
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')
|
||||
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 = getCheckoutAttribution()
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution).toMatchObject({
|
||||
ga_client_id: '123.456',
|
||||
ga_session_id: '1700000000',
|
||||
ga_session_number: '2',
|
||||
gclid: 'gclid-123'
|
||||
gclid: 'gclid-123',
|
||||
utm_source: 'impact',
|
||||
im_ref: 'generated-click-id'
|
||||
})
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
|
||||
'comfy_checkout_attribution',
|
||||
JSON.stringify({ gclid: 'gclid-123' })
|
||||
expect(mockIreCall).toHaveBeenCalledWith(
|
||||
'generateClickId',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('uses stored click ids when URL is empty', () => {
|
||||
storage.set(
|
||||
'comfy_checkout_attribution',
|
||||
JSON.stringify({ gbraid: 'gbraid-1' })
|
||||
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 = getCheckoutAttribution()
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution.gbraid).toBe('gbraid-1')
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
import { withTimeout } from 'es-toolkit/promise'
|
||||
|
||||
interface CheckoutAttribution {
|
||||
ga_client_id?: string
|
||||
ga_session_id?: string
|
||||
ga_session_number?: string
|
||||
gclid?: string
|
||||
gbraid?: string
|
||||
wbraid?: string
|
||||
}
|
||||
import type { CheckoutAttributionMetadata } from '../types'
|
||||
|
||||
type GaIdentity = {
|
||||
client_id?: string
|
||||
@@ -15,22 +9,36 @@ type GaIdentity = {
|
||||
session_number?: string
|
||||
}
|
||||
|
||||
const CLICK_ID_KEYS = ['gclid', 'gbraid', 'wbraid'] as const
|
||||
type ClickIdKey = (typeof CLICK_ID_KEYS)[number]
|
||||
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 {}
|
||||
|
||||
function readStoredClickIds(): Partial<Record<ClickIdKey, string>> {
|
||||
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<ClickIdKey, string>> = {}
|
||||
|
||||
for (const key of CLICK_ID_KEYS) {
|
||||
const value = parsed[key]
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
const result: Partial<Record<AttributionQueryKey, string>> = {}
|
||||
|
||||
for (const key of ATTRIBUTION_QUERY_KEYS) {
|
||||
const value = asNonEmptyString(parsed[key])
|
||||
if (value) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
@@ -41,7 +49,11 @@ function readStoredClickIds(): Partial<Record<ClickIdKey, string>> {
|
||||
}
|
||||
}
|
||||
|
||||
function persistClickIds(payload: Partial<Record<ClickIdKey, string>>): void {
|
||||
function persistAttribution(
|
||||
payload: Partial<Record<AttributionQueryKey, string>>
|
||||
): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
localStorage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(payload))
|
||||
} catch {
|
||||
@@ -49,14 +61,14 @@ function persistClickIds(payload: Partial<Record<ClickIdKey, string>>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function readClickIdsFromUrl(
|
||||
function readAttributionFromUrl(
|
||||
search: string
|
||||
): Partial<Record<ClickIdKey, string>> {
|
||||
): Partial<Record<AttributionQueryKey, string>> {
|
||||
const params = new URLSearchParams(search)
|
||||
|
||||
const result: Partial<Record<ClickIdKey, string>> = {}
|
||||
const result: Partial<Record<AttributionQueryKey, string>> = {}
|
||||
|
||||
for (const key of CLICK_ID_KEYS) {
|
||||
for (const key of ATTRIBUTION_QUERY_KEYS) {
|
||||
const value = params.get(key)
|
||||
if (value) {
|
||||
result[key] = value
|
||||
@@ -66,6 +78,20 @@ function readClickIdsFromUrl(
|
||||
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
|
||||
}
|
||||
@@ -83,24 +109,71 @@ function getGaIdentity(): GaIdentity | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
export function getCheckoutAttribution(): CheckoutAttribution {
|
||||
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 stored = readStoredClickIds()
|
||||
const fromUrl = readClickIdsFromUrl(window.location.search)
|
||||
const merged: Partial<Record<ClickIdKey, string>> = {
|
||||
...stored,
|
||||
const storedAttribution = readStoredAttribution()
|
||||
const fromUrl = readAttributionFromUrl(window.location.search)
|
||||
const generatedClickId = await getGeneratedClickId()
|
||||
const attribution: Partial<Record<AttributionQueryKey, string>> = {
|
||||
...storedAttribution,
|
||||
...fromUrl
|
||||
}
|
||||
|
||||
if (Object.keys(fromUrl).length > 0) {
|
||||
persistClickIds(merged)
|
||||
if (generatedClickId) {
|
||||
attribution.im_ref = generatedClickId
|
||||
}
|
||||
|
||||
if (hasAttributionChanges(storedAttribution, attribution)) {
|
||||
persistAttribution(attribution)
|
||||
}
|
||||
|
||||
const gaIdentity = getGaIdentity()
|
||||
|
||||
return {
|
||||
...merged,
|
||||
...attribution,
|
||||
ga_client_id: gaIdentity?.client_id,
|
||||
ga_session_id: gaIdentity?.session_id,
|
||||
ga_session_number: gaIdentity?.session_number
|
||||
|
||||
Reference in New Issue
Block a user