mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-04 23:20:07 +00:00
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" />
This commit is contained in:
1
global.d.ts
vendored
1
global.d.ts
vendored
@@ -30,6 +30,7 @@ interface Window {
|
||||
badge?: string
|
||||
}
|
||||
}
|
||||
dataLayer?: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
|
||||
@@ -30,6 +30,9 @@ if (isCloud) {
|
||||
const { refreshRemoteConfig } =
|
||||
await import('@/platform/remoteConfig/refreshRemoteConfig')
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
const { initGtm } = await import('@/platform/telemetry/gtm')
|
||||
initGtm()
|
||||
}
|
||||
|
||||
const ComfyUIPreset = definePreset(Aura, {
|
||||
|
||||
@@ -206,6 +206,45 @@ describe('useSubscription', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('pushes purchase event after a pending subscription completes', async () => {
|
||||
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()
|
||||
})
|
||||
|
||||
it('should handle fetch errors gracefully', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
|
||||
@@ -8,12 +8,20 @@ import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { pushDataLayerEvent } from '@/platform/telemetry/gtm'
|
||||
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'
|
||||
|
||||
@@ -93,7 +101,42 @@ function useSubscriptionInternal() {
|
||||
: baseName
|
||||
})
|
||||
|
||||
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
|
||||
function buildApiUrl(path: string): string {
|
||||
return `${getComfyApiBaseUrl()}${path}`
|
||||
}
|
||||
|
||||
function trackSubscriptionPurchase(
|
||||
status: CloudSubscriptionStatusResponse | null
|
||||
): void {
|
||||
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,
|
||||
@@ -194,6 +237,12 @@ function useSubscriptionInternal() {
|
||||
|
||||
const statusData = await response.json()
|
||||
subscriptionStatus.value = statusData
|
||||
|
||||
try {
|
||||
await trackSubscriptionPurchase(statusData)
|
||||
} catch (error) {
|
||||
console.error('Failed to track subscription purchase', error)
|
||||
}
|
||||
return statusData
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
@@ -78,6 +79,7 @@ export async function performSubscriptionCheckout(
|
||||
const data = await response.json()
|
||||
|
||||
if (data.checkout_url) {
|
||||
startSubscriptionPurchaseTracking(tierKey, currentBillingCycle)
|
||||
if (openInNewTab) {
|
||||
window.open(data.checkout_url, '_blank')
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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']
|
||||
|
||||
const safeRemove = (): void => {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
} catch {
|
||||
// Ignore storage errors (e.g. private browsing mode)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
|
||||
const parsed = JSON.parse(raw) as PendingSubscriptionPurchase
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
safeRemove()
|
||||
return null
|
||||
}
|
||||
|
||||
const { tierKey, billingCycle, timestamp } = parsed
|
||||
if (
|
||||
!VALID_TIERS.includes(tierKey) ||
|
||||
!VALID_CYCLES.includes(billingCycle) ||
|
||||
typeof timestamp !== 'number'
|
||||
) {
|
||||
safeRemove()
|
||||
return null
|
||||
}
|
||||
|
||||
if (Date.now() - timestamp > MAX_AGE_MS) {
|
||||
safeRemove()
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed
|
||||
} catch {
|
||||
safeRemove()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPendingSubscriptionPurchase(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
safeRemove()
|
||||
}
|
||||
43
src/platform/telemetry/gtm.ts
Normal file
43
src/platform/telemetry/gtm.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
const GTM_CONTAINER_ID = 'GTM-NP9JM6K7'
|
||||
|
||||
let isInitialized = false
|
||||
let initPromise: Promise<void> | null = null
|
||||
|
||||
export function initGtm(): void {
|
||||
if (!isCloud || typeof window === 'undefined') return
|
||||
if (typeof document === 'undefined') return
|
||||
if (isInitialized) return
|
||||
|
||||
if (!initPromise) {
|
||||
initPromise = new Promise((resolve) => {
|
||||
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
|
||||
dataLayer.push({
|
||||
'gtm.start': Date.now(),
|
||||
event: 'gtm.js'
|
||||
})
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.async = true
|
||||
script.src = `https://www.googletagmanager.com/gtm.js?id=${GTM_CONTAINER_ID}`
|
||||
|
||||
const finalize = () => {
|
||||
isInitialized = true
|
||||
resolve()
|
||||
}
|
||||
|
||||
script.addEventListener('load', finalize, { once: true })
|
||||
script.addEventListener('error', finalize, { once: true })
|
||||
document.head?.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
void initPromise
|
||||
}
|
||||
|
||||
export function pushDataLayerEvent(event: Record<string, unknown>): void {
|
||||
if (!isCloud || typeof window === 'undefined') return
|
||||
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
|
||||
dataLayer.push(event)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type { RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { pushDataLayerEvent } from '@/platform/telemetry/gtm'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
@@ -36,6 +37,16 @@ function getBasePath(): string {
|
||||
|
||||
const basePath = getBasePath()
|
||||
|
||||
function pushPageView(): void {
|
||||
if (!isCloud || typeof window === 'undefined') return
|
||||
|
||||
pushDataLayerEvent({
|
||||
event: 'page_view',
|
||||
page_location: window.location.href,
|
||||
page_title: document.title
|
||||
})
|
||||
}
|
||||
|
||||
const router = createRouter({
|
||||
history: isFileProtocol
|
||||
? createWebHashHistory()
|
||||
@@ -93,6 +104,10 @@ installPreservedQueryTracker(router, [
|
||||
}
|
||||
])
|
||||
|
||||
router.afterEach(() => {
|
||||
pushPageView()
|
||||
})
|
||||
|
||||
if (isCloud) {
|
||||
const { flags } = useFeatureFlags()
|
||||
const PUBLIC_ROUTE_NAMES = new Set([
|
||||
|
||||
@@ -26,6 +26,7 @@ import { t } from '@/i18n'
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { pushDataLayerEvent as pushDataLayerEventBase } from '@/platform/telemetry/gtm'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
@@ -81,6 +82,42 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
|
||||
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
|
||||
|
||||
function pushDataLayerEvent(event: Record<string, unknown>): void {
|
||||
if (!isCloud || typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
pushDataLayerEventBase(event)
|
||||
} catch (error) {
|
||||
console.warn('Failed to push data layer event', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function hashSha256(value: string): Promise<string | undefined> {
|
||||
if (typeof crypto === 'undefined' || !crypto.subtle) return
|
||||
if (typeof TextEncoder === 'undefined') 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('')
|
||||
}
|
||||
|
||||
async function trackSignUp(method: 'email' | 'google' | 'github') {
|
||||
if (!isCloud || typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
const userId = currentUser.value?.uid
|
||||
const hashedUserId = userId ? await hashSha256(userId) : undefined
|
||||
pushDataLayerEvent({
|
||||
event: 'sign_up',
|
||||
method,
|
||||
...(hashedUserId ? { user_id: hashedUserId } : {})
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Failed to track sign up', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Providers
|
||||
const googleProvider = new GoogleAuthProvider()
|
||||
googleProvider.addScope('email')
|
||||
@@ -347,6 +384,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
method: 'email',
|
||||
is_new_user: true
|
||||
})
|
||||
await trackSignUp('email')
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -365,6 +403,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
method: 'google',
|
||||
is_new_user: isNewUser
|
||||
})
|
||||
if (isNewUser) {
|
||||
await trackSignUp('google')
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -383,6 +424,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
method: 'github',
|
||||
is_new_user: isNewUser
|
||||
})
|
||||
if (isNewUser) {
|
||||
await trackSignUp('github')
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user