Skip to content

Commit 788f508

Browse files
feat: add cloud gtm injection (#8311)
## Summary Add GTM injection for cloud distribution builds and push SPA page view + signup events. ## Changes - **What**: Inject GTM script into head-prepend and noscript iframe into body-prepend for cloud builds - **What**: Push `page_view` to `dataLayer` on cloud route changes (page_location + page_title) - **What**: Push `sign_up` to `dataLayer` after successful account creation (email/google/github) - **Dependencies**: None ## Review Focus - Placement order for head-prepend/body-prepend and cloud-only gating - Route-change page_view payload shape - Signup event emission only for new users ## Screenshots (if applicable) <img width="1512" height="860" alt="Screenshot 2026-01-26 at 11 38 11 AM" src="https://github.com/user-attachments/assets/03fb61db-5ca4-4432-9704-bbdcc4c6c1b7" /> <img width="1512" height="862" alt="Screenshot 2026-01-26 at 11 38 26 AM" src="https://github.com/user-attachments/assets/6e46c855-a552-4e52-9800-17898a512d4d" />
1 parent 75fd4f0 commit 788f508

File tree

9 files changed

+276
-2
lines changed

9 files changed

+276
-2
lines changed

global.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ interface Window {
3030
badge?: string
3131
}
3232
}
33+
dataLayer?: Array<Record<string, unknown>>
3334
}
3435

3536
interface Navigator {

src/main.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ if (isCloud) {
3030
const { refreshRemoteConfig } =
3131
await import('@/platform/remoteConfig/refreshRemoteConfig')
3232
await refreshRemoteConfig({ useAuth: false })
33+
34+
const { initGtm } = await import('@/platform/telemetry/gtm')
35+
initGtm()
3336
}
3437

3538
const ComfyUIPreset = definePreset(Aura, {

src/platform/cloud/subscription/composables/useSubscription.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,45 @@ describe('useSubscription', () => {
206206
)
207207
})
208208

209+
it('pushes purchase event after a pending subscription completes', async () => {
210+
window.dataLayer = []
211+
localStorage.setItem(
212+
'pending_subscription_purchase',
213+
JSON.stringify({
214+
tierKey: 'creator',
215+
billingCycle: 'monthly',
216+
timestamp: Date.now()
217+
})
218+
)
219+
220+
vi.mocked(global.fetch).mockResolvedValue({
221+
ok: true,
222+
json: async () => ({
223+
is_active: true,
224+
subscription_id: 'sub_123',
225+
subscription_tier: 'CREATOR',
226+
subscription_duration: 'MONTHLY'
227+
})
228+
} as Response)
229+
230+
mockIsLoggedIn.value = true
231+
const { fetchStatus } = useSubscription()
232+
233+
await fetchStatus()
234+
235+
expect(window.dataLayer).toHaveLength(1)
236+
expect(window.dataLayer?.[0]).toMatchObject({
237+
event: 'purchase',
238+
transaction_id: 'sub_123',
239+
currency: 'USD',
240+
item_id: 'monthly_creator',
241+
item_variant: 'monthly',
242+
item_category: 'subscription',
243+
quantity: 1
244+
})
245+
expect(localStorage.getItem('pending_subscription_purchase')).toBeNull()
246+
})
247+
209248
it('should handle fetch errors gracefully', async () => {
210249
vi.mocked(global.fetch).mockResolvedValue({
211250
ok: false,

src/platform/cloud/subscription/composables/useSubscription.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,20 @@ import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
88
import { t } from '@/i18n'
99
import { isCloud } from '@/platform/distribution/types'
1010
import { useTelemetry } from '@/platform/telemetry'
11+
import { pushDataLayerEvent } from '@/platform/telemetry/gtm'
1112
import {
1213
FirebaseAuthStoreError,
1314
useFirebaseAuthStore
1415
} from '@/stores/firebaseAuthStore'
1516
import { useDialogService } from '@/services/dialogService'
16-
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
17+
import {
18+
getTierPrice,
19+
TIER_TO_KEY
20+
} from '@/platform/cloud/subscription/constants/tierPricing'
21+
import {
22+
clearPendingSubscriptionPurchase,
23+
getPendingSubscriptionPurchase
24+
} from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker'
1725
import type { operations } from '@/types/comfyRegistryTypes'
1826
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
1927

@@ -93,7 +101,42 @@ function useSubscriptionInternal() {
93101
: baseName
94102
})
95103

96-
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
104+
function buildApiUrl(path: string): string {
105+
return `${getComfyApiBaseUrl()}${path}`
106+
}
107+
108+
function trackSubscriptionPurchase(
109+
status: CloudSubscriptionStatusResponse | null
110+
): void {
111+
if (!status?.is_active || !status.subscription_id) return
112+
113+
const pendingPurchase = getPendingSubscriptionPurchase()
114+
if (!pendingPurchase) return
115+
116+
const { tierKey, billingCycle } = pendingPurchase
117+
const isYearly = billingCycle === 'yearly'
118+
const baseName = t(`subscription.tiers.${tierKey}.name`)
119+
const planName = isYearly
120+
? t('subscription.tierNameYearly', { name: baseName })
121+
: baseName
122+
const unitPrice = getTierPrice(tierKey, isYearly)
123+
const value = isYearly && tierKey !== 'founder' ? unitPrice * 12 : unitPrice
124+
125+
pushDataLayerEvent({
126+
event: 'purchase',
127+
transaction_id: status.subscription_id,
128+
value,
129+
currency: 'USD',
130+
item_id: `${billingCycle}_${tierKey}`,
131+
item_name: planName,
132+
item_category: 'subscription',
133+
item_variant: billingCycle,
134+
price: value,
135+
quantity: 1
136+
})
137+
138+
clearPendingSubscriptionPurchase()
139+
}
97140

98141
const fetchStatus = wrapWithErrorHandlingAsync(
99142
fetchSubscriptionStatus,
@@ -194,6 +237,12 @@ function useSubscriptionInternal() {
194237

195238
const statusData = await response.json()
196239
subscriptionStatus.value = statusData
240+
241+
try {
242+
await trackSubscriptionPurchase(statusData)
243+
} catch (error) {
244+
console.error('Failed to track subscription purchase', error)
245+
}
197246
return statusData
198247
}
199248

src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
useFirebaseAuthStore
77
} from '@/stores/firebaseAuthStore'
88
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
9+
import { startSubscriptionPurchaseTracking } from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker'
910
import type { BillingCycle } from './subscriptionTierRank'
1011

1112
type CheckoutTier = TierKey | `${TierKey}-yearly`
@@ -78,6 +79,7 @@ export async function performSubscriptionCheckout(
7879
const data = await response.json()
7980

8081
if (data.checkout_url) {
82+
startSubscriptionPurchaseTracking(tierKey, currentBillingCycle)
8183
if (openInNewTab) {
8284
window.open(data.checkout_url, '_blank')
8385
} else {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
2+
import type { BillingCycle } from './subscriptionTierRank'
3+
4+
type PendingSubscriptionPurchase = {
5+
tierKey: TierKey
6+
billingCycle: BillingCycle
7+
timestamp: number
8+
}
9+
10+
const STORAGE_KEY = 'pending_subscription_purchase'
11+
const MAX_AGE_MS = 24 * 60 * 60 * 1000 // 24 hours
12+
const VALID_TIERS: TierKey[] = ['standard', 'creator', 'pro', 'founder']
13+
const VALID_CYCLES: BillingCycle[] = ['monthly', 'yearly']
14+
15+
const safeRemove = (): void => {
16+
try {
17+
localStorage.removeItem(STORAGE_KEY)
18+
} catch {
19+
// Ignore storage errors (e.g. private browsing mode)
20+
}
21+
}
22+
23+
export function startSubscriptionPurchaseTracking(
24+
tierKey: TierKey,
25+
billingCycle: BillingCycle
26+
): void {
27+
if (typeof window === 'undefined') return
28+
try {
29+
const payload: PendingSubscriptionPurchase = {
30+
tierKey,
31+
billingCycle,
32+
timestamp: Date.now()
33+
}
34+
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
35+
} catch {
36+
// Ignore storage errors (e.g. private browsing mode)
37+
}
38+
}
39+
40+
export function getPendingSubscriptionPurchase(): PendingSubscriptionPurchase | null {
41+
if (typeof window === 'undefined') return null
42+
43+
try {
44+
const raw = localStorage.getItem(STORAGE_KEY)
45+
if (!raw) return null
46+
47+
const parsed = JSON.parse(raw) as PendingSubscriptionPurchase
48+
if (!parsed || typeof parsed !== 'object') {
49+
safeRemove()
50+
return null
51+
}
52+
53+
const { tierKey, billingCycle, timestamp } = parsed
54+
if (
55+
!VALID_TIERS.includes(tierKey) ||
56+
!VALID_CYCLES.includes(billingCycle) ||
57+
typeof timestamp !== 'number'
58+
) {
59+
safeRemove()
60+
return null
61+
}
62+
63+
if (Date.now() - timestamp > MAX_AGE_MS) {
64+
safeRemove()
65+
return null
66+
}
67+
68+
return parsed
69+
} catch {
70+
safeRemove()
71+
return null
72+
}
73+
}
74+
75+
export function clearPendingSubscriptionPurchase(): void {
76+
if (typeof window === 'undefined') return
77+
safeRemove()
78+
}

src/platform/telemetry/gtm.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { isCloud } from '@/platform/distribution/types'
2+
3+
const GTM_CONTAINER_ID = 'GTM-NP9JM6K7'
4+
5+
let isInitialized = false
6+
let initPromise: Promise<void> | null = null
7+
8+
export function initGtm(): void {
9+
if (!isCloud || typeof window === 'undefined') return
10+
if (typeof document === 'undefined') return
11+
if (isInitialized) return
12+
13+
if (!initPromise) {
14+
initPromise = new Promise((resolve) => {
15+
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
16+
dataLayer.push({
17+
'gtm.start': Date.now(),
18+
event: 'gtm.js'
19+
})
20+
21+
const script = document.createElement('script')
22+
script.async = true
23+
script.src = `https://www.googletagmanager.com/gtm.js?id=${GTM_CONTAINER_ID}`
24+
25+
const finalize = () => {
26+
isInitialized = true
27+
resolve()
28+
}
29+
30+
script.addEventListener('load', finalize, { once: true })
31+
script.addEventListener('error', finalize, { once: true })
32+
document.head?.appendChild(script)
33+
})
34+
}
35+
36+
void initPromise
37+
}
38+
39+
export function pushDataLayerEvent(event: Record<string, unknown>): void {
40+
if (!isCloud || typeof window === 'undefined') return
41+
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
42+
dataLayer.push(event)
43+
}

src/router.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { RouteLocationNormalized } from 'vue-router'
99

1010
import { useFeatureFlags } from '@/composables/useFeatureFlags'
1111
import { isCloud } from '@/platform/distribution/types'
12+
import { pushDataLayerEvent } from '@/platform/telemetry/gtm'
1213
import { useDialogService } from '@/services/dialogService'
1314
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
1415
import { useUserStore } from '@/stores/userStore'
@@ -36,6 +37,16 @@ function getBasePath(): string {
3637

3738
const basePath = getBasePath()
3839

40+
function pushPageView(): void {
41+
if (!isCloud || typeof window === 'undefined') return
42+
43+
pushDataLayerEvent({
44+
event: 'page_view',
45+
page_location: window.location.href,
46+
page_title: document.title
47+
})
48+
}
49+
3950
const router = createRouter({
4051
history: isFileProtocol
4152
? createWebHashHistory()
@@ -93,6 +104,10 @@ installPreservedQueryTracker(router, [
93104
}
94105
])
95106

107+
router.afterEach(() => {
108+
pushPageView()
109+
})
110+
96111
if (isCloud) {
97112
const { flags } = useFeatureFlags()
98113
const PUBLIC_ROUTE_NAMES = new Set([

src/stores/firebaseAuthStore.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { t } from '@/i18n'
2626
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
2727
import { isCloud } from '@/platform/distribution/types'
2828
import { useTelemetry } from '@/platform/telemetry'
29+
import { pushDataLayerEvent as pushDataLayerEventBase } from '@/platform/telemetry/gtm'
2930
import { useDialogService } from '@/services/dialogService'
3031
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
3132
import type { AuthHeader } from '@/types/authTypes'
@@ -81,6 +82,42 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
8182

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

85+
function pushDataLayerEvent(event: Record<string, unknown>): void {
86+
if (!isCloud || typeof window === 'undefined') return
87+
88+
try {
89+
pushDataLayerEventBase(event)
90+
} catch (error) {
91+
console.warn('Failed to push data layer event', error)
92+
}
93+
}
94+
95+
async function hashSha256(value: string): Promise<string | undefined> {
96+
if (typeof crypto === 'undefined' || !crypto.subtle) return
97+
if (typeof TextEncoder === 'undefined') return
98+
const data = new TextEncoder().encode(value)
99+
const hash = await crypto.subtle.digest('SHA-256', data)
100+
return Array.from(new Uint8Array(hash))
101+
.map((b) => b.toString(16).padStart(2, '0'))
102+
.join('')
103+
}
104+
105+
async function trackSignUp(method: 'email' | 'google' | 'github') {
106+
if (!isCloud || typeof window === 'undefined') return
107+
108+
try {
109+
const userId = currentUser.value?.uid
110+
const hashedUserId = userId ? await hashSha256(userId) : undefined
111+
pushDataLayerEvent({
112+
event: 'sign_up',
113+
method,
114+
...(hashedUserId ? { user_id: hashedUserId } : {})
115+
})
116+
} catch (error) {
117+
console.warn('Failed to track sign up', error)
118+
}
119+
}
120+
84121
// Providers
85122
const googleProvider = new GoogleAuthProvider()
86123
googleProvider.addScope('email')
@@ -347,6 +384,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
347384
method: 'email',
348385
is_new_user: true
349386
})
387+
await trackSignUp('email')
350388
}
351389

352390
return result
@@ -365,6 +403,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
365403
method: 'google',
366404
is_new_user: isNewUser
367405
})
406+
if (isNewUser) {
407+
await trackSignUp('google')
408+
}
368409
}
369410

370411
return result
@@ -383,6 +424,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
383424
method: 'github',
384425
is_new_user: isNewUser
385426
})
427+
if (isNewUser) {
428+
await trackSignUp('github')
429+
}
386430
}
387431

388432
return result

0 commit comments

Comments
 (0)