From 788f50834ce618ede5e4cdc7a5164f210ce458a6 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 27 Jan 2026 12:44:15 -0800 Subject: [PATCH] feat: add cloud gtm injection (#8311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) Screenshot 2026-01-26 at 11 38
11 AM Screenshot 2026-01-26 at 11 38
26 AM --- global.d.ts | 1 + src/main.ts | 3 + .../composables/useSubscription.test.ts | 39 ++++++++++ .../composables/useSubscription.ts | 53 ++++++++++++- .../utils/subscriptionCheckoutUtil.ts | 2 + .../utils/subscriptionPurchaseTracker.ts | 78 +++++++++++++++++++ src/platform/telemetry/gtm.ts | 43 ++++++++++ src/router.ts | 15 ++++ src/stores/firebaseAuthStore.ts | 44 +++++++++++ 9 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts create mode 100644 src/platform/telemetry/gtm.ts diff --git a/global.d.ts b/global.d.ts index 7f7dd832f..ec455707f 100644 --- a/global.d.ts +++ b/global.d.ts @@ -30,6 +30,7 @@ interface Window { badge?: string } } + dataLayer?: Array> } interface Navigator { diff --git a/src/main.ts b/src/main.ts index 4d395dbb6..e0fa4f994 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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, { diff --git a/src/platform/cloud/subscription/composables/useSubscription.test.ts b/src/platform/cloud/subscription/composables/useSubscription.test.ts index ffa370a90..40115d38d 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.test.ts @@ -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, diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index f5948ee69..b5ef7f63b 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -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 } diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts index 746a7d616..8be996ebc 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts @@ -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 { diff --git a/src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts b/src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts new file mode 100644 index 000000000..41bbb3484 --- /dev/null +++ b/src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts @@ -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() +} diff --git a/src/platform/telemetry/gtm.ts b/src/platform/telemetry/gtm.ts new file mode 100644 index 000000000..d5c738ae4 --- /dev/null +++ b/src/platform/telemetry/gtm.ts @@ -0,0 +1,43 @@ +import { isCloud } from '@/platform/distribution/types' + +const GTM_CONTAINER_ID = 'GTM-NP9JM6K7' + +let isInitialized = false +let initPromise: Promise | 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): void { + if (!isCloud || typeof window === 'undefined') return + const dataLayer = window.dataLayer ?? (window.dataLayer = []) + dataLayer.push(event) +} diff --git a/src/router.ts b/src/router.ts index b489d2257..365e7859e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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([ diff --git a/src/stores/firebaseAuthStore.ts b/src/stores/firebaseAuthStore.ts index 6073a4304..c1c81037d 100644 --- a/src/stores/firebaseAuthStore.ts +++ b/src/stores/firebaseAuthStore.ts @@ -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): 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 { + 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