From 563cdbfd267a2736ded26b31d1675196bb04dab9 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 27 Jan 2026 22:51:22 -0800 Subject: [PATCH] fix: restore gtm purchase tracking --- global.d.ts | 1 + .../composables/useSubscription.test.ts | 46 +++++++++++ .../utils/subscriptionCheckoutUtil.ts | 2 + .../utils/subscriptionPurchaseTracker.ts | 78 +++++++++++++++++++ src/platform/telemetry/gtm.ts | 43 ++++++++++ 5 files changed, 170 insertions(+) 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/platform/cloud/subscription/composables/useSubscription.test.ts b/src/platform/cloud/subscription/composables/useSubscription.test.ts index ffa370a90..31a8dd79b 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.test.ts @@ -11,6 +11,7 @@ const mockShowSubscriptionRequiredDialog = vi.fn() const mockGetAuthHeader = vi.fn(() => Promise.resolve({ Authorization: 'Bearer test-token' }) ) +const mockPushDataLayerEvent = vi.fn() const mockTelemetry = { trackSubscription: vi.fn(), trackMonthlySubscriptionCancelled: vi.fn() @@ -24,6 +25,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({ })) vi.mock('@/platform/telemetry', () => ({ + pushDataLayerEvent: mockPushDataLayerEvent, useTelemetry: vi.fn(() => mockTelemetry) })) @@ -78,6 +80,7 @@ describe('useSubscription', () => { mockIsLoggedIn.value = false mockTelemetry.trackSubscription.mockReset() mockTelemetry.trackMonthlySubscriptionCancelled.mockReset() + mockPushDataLayerEvent.mockReset() window.__CONFIG__ = { subscription_required: true } as typeof window.__CONFIG__ @@ -206,6 +209,49 @@ 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', + items: [ + { + 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/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) +}