Skip to content

Commit 563cdbf

Browse files
committed
fix: restore gtm purchase tracking
1 parent c247d7b commit 563cdbf

File tree

5 files changed

+170
-0
lines changed

5 files changed

+170
-0
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/platform/cloud/subscription/composables/useSubscription.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const mockShowSubscriptionRequiredDialog = vi.fn()
1111
const mockGetAuthHeader = vi.fn(() =>
1212
Promise.resolve({ Authorization: 'Bearer test-token' })
1313
)
14+
const mockPushDataLayerEvent = vi.fn()
1415
const mockTelemetry = {
1516
trackSubscription: vi.fn(),
1617
trackMonthlySubscriptionCancelled: vi.fn()
@@ -24,6 +25,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
2425
}))
2526

2627
vi.mock('@/platform/telemetry', () => ({
28+
pushDataLayerEvent: mockPushDataLayerEvent,
2729
useTelemetry: vi.fn(() => mockTelemetry)
2830
}))
2931

@@ -78,6 +80,7 @@ describe('useSubscription', () => {
7880
mockIsLoggedIn.value = false
7981
mockTelemetry.trackSubscription.mockReset()
8082
mockTelemetry.trackMonthlySubscriptionCancelled.mockReset()
83+
mockPushDataLayerEvent.mockReset()
8184
window.__CONFIG__ = {
8285
subscription_required: true
8386
} as typeof window.__CONFIG__
@@ -206,6 +209,49 @@ describe('useSubscription', () => {
206209
)
207210
})
208211

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

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+
}

0 commit comments

Comments
 (0)