mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
feat: make subscription success backend-driven
This commit is contained in:
@@ -155,6 +155,7 @@ import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeB
|
||||
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
@@ -169,7 +170,8 @@ const emit = defineEmits<{
|
||||
close: [subscribed: boolean]
|
||||
}>()
|
||||
|
||||
const { fetchStatus, isActiveSubscription } = useBillingContext()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const { syncStatusAfterCheckout } = useSubscription()
|
||||
|
||||
const isSubscriptionEnabled = (): boolean =>
|
||||
Boolean(isCloud && window.__CONFIG__?.subscription_required)
|
||||
@@ -190,48 +192,20 @@ const telemetry = useTelemetry()
|
||||
// Always show custom pricing table for cloud subscriptions
|
||||
const showCustomPricingTable = computed(() => isSubscriptionEnabled())
|
||||
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
const MAX_POLL_ATTEMPTS = 3
|
||||
let pollInterval: number | null = null
|
||||
let pollAttempts = 0
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
const refreshSubscriptionStatus = async () => {
|
||||
try {
|
||||
await syncStatusAfterCheckout()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[SubscriptionDialog] Failed to refresh subscription status',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling()
|
||||
pollAttempts = 0
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
await fetchStatus()
|
||||
pollAttempts++
|
||||
|
||||
if (pollAttempts >= MAX_POLL_ATTEMPTS) {
|
||||
stopPolling()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[SubscriptionDialog] Failed to poll subscription status',
|
||||
error
|
||||
)
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
void poll()
|
||||
pollInterval = window.setInterval(() => {
|
||||
void poll()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
if (showCustomPricingTable.value) {
|
||||
startPolling()
|
||||
void refreshSubscriptionStatus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +216,6 @@ watch(
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
} else {
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
stopPolling()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -252,7 +225,6 @@ watch(
|
||||
() => isActiveSubscription.value,
|
||||
(isActive) => {
|
||||
if (isActive && showCustomPricingTable.value) {
|
||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||
emit('close', true)
|
||||
}
|
||||
}
|
||||
@@ -263,7 +235,6 @@ const handleSubscribed = () => {
|
||||
}
|
||||
|
||||
const handleChooseTeam = () => {
|
||||
stopPolling()
|
||||
if (onChooseTeam) {
|
||||
onChooseTeam()
|
||||
} else {
|
||||
@@ -272,7 +243,6 @@ const handleChooseTeam = () => {
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
stopPolling()
|
||||
onClose()
|
||||
}
|
||||
|
||||
@@ -295,7 +265,6 @@ const handleViewEnterprise = () => {
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -28,6 +28,7 @@ const {
|
||||
})),
|
||||
mockTelemetry: {
|
||||
trackSubscription: vi.fn(),
|
||||
trackMonthlySubscriptionSucceeded: vi.fn(),
|
||||
trackMonthlySubscriptionCancelled: vi.fn()
|
||||
},
|
||||
mockUserId: { value: 'user-123' }
|
||||
@@ -134,20 +135,40 @@ describe('useSubscription', () => {
|
||||
vi.clearAllMocks()
|
||||
mockIsLoggedIn.value = false
|
||||
mockTelemetry.trackSubscription.mockReset()
|
||||
mockTelemetry.trackMonthlySubscriptionSucceeded.mockReset()
|
||||
mockTelemetry.trackMonthlySubscriptionCancelled.mockReset()
|
||||
mockUserId.value = 'user-123'
|
||||
mockIsCloud.value = true
|
||||
window.__CONFIG__ = {
|
||||
subscription_required: true
|
||||
} as typeof window.__CONFIG__
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: false,
|
||||
subscription_id: '',
|
||||
renewal_date: ''
|
||||
})
|
||||
} as Response)
|
||||
localStorage.clear()
|
||||
vi.mocked(global.fetch).mockImplementation(async (input) => {
|
||||
const url = String(input)
|
||||
|
||||
if (url.includes('/customers/pending-subscription-success/')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 204
|
||||
} as Response
|
||||
}
|
||||
|
||||
if (url.includes('/customers/pending-subscription-success')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 204
|
||||
} as Response
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: false,
|
||||
subscription_id: '',
|
||||
renewal_date: ''
|
||||
})
|
||||
} as Response
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed properties', () => {
|
||||
@@ -275,6 +296,146 @@ describe('useSubscription', () => {
|
||||
|
||||
await expect(fetchStatus()).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('syncs and consumes pending subscription success when requested', async () => {
|
||||
vi.mocked(global.fetch).mockImplementation(async (input) => {
|
||||
const url = String(input)
|
||||
|
||||
if (url.includes('/customers/cloud-subscription-status')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_123',
|
||||
renewal_date: '2025-11-16'
|
||||
})
|
||||
} as Response
|
||||
}
|
||||
|
||||
if (url.endsWith('/customers/pending-subscription-success')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
id: 'event-123',
|
||||
transaction_id: 'stripe-event-123',
|
||||
value: 35,
|
||||
currency: 'USD',
|
||||
tier: 'creator',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'change',
|
||||
previous_tier: 'standard'
|
||||
})
|
||||
} as Response
|
||||
}
|
||||
|
||||
if (
|
||||
url.endsWith(
|
||||
'/customers/pending-subscription-success/event-123/consume'
|
||||
)
|
||||
) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 204
|
||||
} as Response
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch URL: ${url}`)
|
||||
})
|
||||
|
||||
const { syncStatusAfterCheckout } = useSubscriptionWithScope()
|
||||
|
||||
await syncStatusAfterCheckout()
|
||||
|
||||
expect(
|
||||
mockTelemetry.trackMonthlySubscriptionSucceeded
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
transaction_id: 'stripe-event-123',
|
||||
value: 35,
|
||||
currency: 'USD',
|
||||
tier: 'creator',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'change',
|
||||
previous_tier: 'standard'
|
||||
})
|
||||
)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'/customers/pending-subscription-success/event-123/consume'
|
||||
),
|
||||
expect.objectContaining({
|
||||
method: 'POST'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not retrack a subscription success already delivered in this browser', async () => {
|
||||
localStorage.setItem(
|
||||
'comfy.subscription_success.delivered_transactions',
|
||||
JSON.stringify(['stripe-event-123'])
|
||||
)
|
||||
|
||||
vi.mocked(global.fetch).mockImplementation(async (input) => {
|
||||
const url = String(input)
|
||||
|
||||
if (url.includes('/customers/cloud-subscription-status')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_123',
|
||||
renewal_date: '2025-11-16'
|
||||
})
|
||||
} as Response
|
||||
}
|
||||
|
||||
if (url.endsWith('/customers/pending-subscription-success')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
id: 'event-123',
|
||||
transaction_id: 'stripe-event-123',
|
||||
value: 20,
|
||||
currency: 'USD',
|
||||
tier: 'standard',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
} as Response
|
||||
}
|
||||
|
||||
if (
|
||||
url.endsWith(
|
||||
'/customers/pending-subscription-success/event-123/consume'
|
||||
)
|
||||
) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 204
|
||||
} as Response
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch URL: ${url}`)
|
||||
})
|
||||
|
||||
const { syncStatusAfterCheckout } = useSubscriptionWithScope()
|
||||
|
||||
await syncStatusAfterCheckout()
|
||||
|
||||
expect(
|
||||
mockTelemetry.trackMonthlySubscriptionSucceeded
|
||||
).not.toHaveBeenCalled()
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'/customers/pending-subscription-success/event-123/consume'
|
||||
),
|
||||
expect.objectContaining({
|
||||
method: 'POST'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscribe', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
@@ -13,6 +13,7 @@ import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
|
||||
|
||||
@@ -24,6 +25,82 @@ export type CloudSubscriptionStatusResponse = NonNullable<
|
||||
operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json']
|
||||
>
|
||||
|
||||
type TrackedSubscriptionTierKey = Exclude<TierKey, 'free'>
|
||||
|
||||
type PendingSubscriptionSuccessResponse = {
|
||||
id: string
|
||||
transaction_id: string
|
||||
value: number
|
||||
currency: string
|
||||
tier: TrackedSubscriptionTierKey
|
||||
cycle: 'monthly' | 'yearly'
|
||||
checkout_type: 'new' | 'change'
|
||||
previous_tier?: TrackedSubscriptionTierKey | null
|
||||
}
|
||||
|
||||
type FetchSubscriptionStatusOptions = {
|
||||
syncPendingSuccess?: boolean
|
||||
}
|
||||
|
||||
const SUBSCRIPTION_SUCCESS_DELIVERED_STORAGE_KEY =
|
||||
'comfy.subscription_success.delivered_transactions'
|
||||
|
||||
function readDeliveredSubscriptionSuccessTransactions(): string[] {
|
||||
if (typeof window === 'undefined') {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(
|
||||
SUBSCRIPTION_SUCCESS_DELIVERED_STORAGE_KEY
|
||||
)
|
||||
if (!rawValue) {
|
||||
return []
|
||||
}
|
||||
|
||||
const parsedValue = JSON.parse(rawValue)
|
||||
if (!Array.isArray(parsedValue)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return parsedValue.filter(
|
||||
(transactionId): transactionId is string =>
|
||||
typeof transactionId === 'string' && transactionId.length > 0
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function hasDeliveredSubscriptionSuccess(transactionId: string): boolean {
|
||||
return readDeliveredSubscriptionSuccessTransactions().includes(transactionId)
|
||||
}
|
||||
|
||||
function markSubscriptionSuccessAsDelivered(transactionId: string): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const nextTransactions = [
|
||||
transactionId,
|
||||
...readDeliveredSubscriptionSuccessTransactions().filter(
|
||||
(existingTransactionId) => existingTransactionId !== transactionId
|
||||
)
|
||||
].slice(0, 20)
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
SUBSCRIPTION_SUCCESS_DELIVERED_STORAGE_KEY,
|
||||
JSON.stringify(nextTransactions)
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[Subscription] Failed to persist delivered subscription success transaction',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function useSubscriptionInternal() {
|
||||
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
|
||||
const telemetry = useTelemetry()
|
||||
@@ -111,8 +188,111 @@ function useSubscriptionInternal() {
|
||||
return getCheckoutAttribution()
|
||||
}
|
||||
|
||||
const buildAuthHeaders = async (): Promise<Record<string, string>> => {
|
||||
const authHeader = await getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
|
||||
return {
|
||||
...authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPendingSubscriptionSuccess = async (
|
||||
headers: Record<string, string>
|
||||
): Promise<PendingSubscriptionSuccessResponse | null> => {
|
||||
const response = await fetch(
|
||||
buildApiUrl('/customers/pending-subscription-success'),
|
||||
{
|
||||
headers
|
||||
}
|
||||
)
|
||||
|
||||
if (response.status === 204) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch pending subscription success: ${response.status}`
|
||||
)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const consumePendingSubscriptionSuccess = async (
|
||||
headers: Record<string, string>,
|
||||
id: string
|
||||
): Promise<void> => {
|
||||
const response = await fetch(
|
||||
buildApiUrl(`/customers/pending-subscription-success/${id}/consume`),
|
||||
{
|
||||
method: 'POST',
|
||||
headers
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(
|
||||
`Failed to consume pending subscription success: ${response.status}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const syncPendingSubscriptionSuccess = async (
|
||||
headers: Record<string, string>
|
||||
): Promise<void> => {
|
||||
const pendingSuccess = await fetchPendingSubscriptionSuccess(headers)
|
||||
if (!pendingSuccess) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasDeliveredSubscriptionSuccess(pendingSuccess.transaction_id)) {
|
||||
await consumePendingSubscriptionSuccess(headers, pendingSuccess.id)
|
||||
return
|
||||
}
|
||||
|
||||
telemetry?.trackMonthlySubscriptionSucceeded({
|
||||
...(authStore.userId ? { user_id: authStore.userId } : {}),
|
||||
transaction_id: pendingSuccess.transaction_id,
|
||||
value: pendingSuccess.value,
|
||||
currency: pendingSuccess.currency,
|
||||
tier: pendingSuccess.tier,
|
||||
cycle: pendingSuccess.cycle,
|
||||
checkout_type: pendingSuccess.checkout_type,
|
||||
...(pendingSuccess.previous_tier
|
||||
? { previous_tier: pendingSuccess.previous_tier }
|
||||
: {}),
|
||||
ecommerce: {
|
||||
transaction_id: pendingSuccess.transaction_id,
|
||||
value: pendingSuccess.value,
|
||||
currency: pendingSuccess.currency,
|
||||
items: [
|
||||
{
|
||||
item_name: pendingSuccess.tier,
|
||||
item_category: 'subscription',
|
||||
item_variant: pendingSuccess.cycle,
|
||||
price: pendingSuccess.value,
|
||||
quantity: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
markSubscriptionSuccessAsDelivered(pendingSuccess.transaction_id)
|
||||
await consumePendingSubscriptionSuccess(headers, pendingSuccess.id)
|
||||
}
|
||||
|
||||
const fetchStatus = wrapWithErrorHandlingAsync(
|
||||
fetchSubscriptionStatus,
|
||||
() => fetchSubscriptionStatus(),
|
||||
reportError
|
||||
)
|
||||
|
||||
const syncStatusAfterCheckout = wrapWithErrorHandlingAsync(
|
||||
() => fetchSubscriptionStatus({ syncPendingSuccess: true }),
|
||||
reportError
|
||||
)
|
||||
|
||||
@@ -188,19 +368,15 @@ function useSubscriptionInternal() {
|
||||
* Fetch the current cloud subscription status for the authenticated user
|
||||
* @returns Subscription status or null if no subscription exists
|
||||
*/
|
||||
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
|
||||
const authHeader = await getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
async function fetchSubscriptionStatus(
|
||||
options?: FetchSubscriptionStatusOptions
|
||||
): Promise<CloudSubscriptionStatusResponse | null> {
|
||||
const headers = await buildAuthHeaders()
|
||||
|
||||
const response = await fetch(
|
||||
buildApiUrl('/customers/cloud-subscription-status'),
|
||||
{
|
||||
headers: {
|
||||
...authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
headers
|
||||
}
|
||||
)
|
||||
|
||||
@@ -216,15 +392,47 @@ function useSubscriptionInternal() {
|
||||
const statusData = await response.json()
|
||||
subscriptionStatus.value = statusData
|
||||
|
||||
if (options?.syncPendingSuccess && statusData.is_active) {
|
||||
await syncPendingSubscriptionSuccess(headers)
|
||||
}
|
||||
|
||||
return statusData
|
||||
}
|
||||
|
||||
const handleDeliveredSubscriptionSuccessChange = (event: StorageEvent) => {
|
||||
if (
|
||||
event.key !== SUBSCRIPTION_SUCCESS_DELIVERED_STORAGE_KEY ||
|
||||
!isCloud ||
|
||||
!isLoggedIn.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
void fetchSubscriptionStatus().catch((error) => {
|
||||
console.error(
|
||||
'[Subscription] Failed to refresh subscription status after cross-tab success delivery:',
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', handleDeliveredSubscriptionSuccessChange)
|
||||
|
||||
onScopeDispose(() => {
|
||||
window.removeEventListener(
|
||||
'storage',
|
||||
handleDeliveredSubscriptionSuccessChange
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => isLoggedIn.value,
|
||||
async (loggedIn) => {
|
||||
if (loggedIn && isCloud) {
|
||||
try {
|
||||
await fetchSubscriptionStatus()
|
||||
await fetchSubscriptionStatus({ syncPendingSuccess: true })
|
||||
} catch (error) {
|
||||
// Network errors are expected during navigation/component unmount
|
||||
// and when offline - log for debugging but don't surface to user
|
||||
@@ -243,20 +451,14 @@ function useSubscriptionInternal() {
|
||||
|
||||
const initiateSubscriptionCheckout =
|
||||
async (): Promise<CloudSubscriptionCheckoutResponse> => {
|
||||
const authHeader = await getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
const headers = await buildAuthHeaders()
|
||||
const checkoutAttribution = await getCheckoutAttributionForCloud()
|
||||
|
||||
const response = await fetch(
|
||||
buildApiUrl('/customers/cloud-subscription-checkout'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify(checkoutAttribution)
|
||||
}
|
||||
)
|
||||
@@ -293,6 +495,7 @@ function useSubscriptionInternal() {
|
||||
// Actions
|
||||
subscribe,
|
||||
fetchStatus,
|
||||
syncStatusAfterCheckout,
|
||||
showSubscriptionDialog,
|
||||
manageSubscription,
|
||||
requireActiveSubscription,
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
PageVisibilityMetadata,
|
||||
SettingChangedMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
TabCountMetadata,
|
||||
TelemetryDispatcher,
|
||||
@@ -80,8 +81,12 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackBeginCheckout?.(metadata))
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.dispatch((provider) => provider.trackMonthlySubscriptionSucceeded?.())
|
||||
trackMonthlySubscriptionSucceeded(
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackMonthlySubscriptionSucceeded?.(metadata)
|
||||
)
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionCancelled(): void {
|
||||
|
||||
@@ -92,9 +92,34 @@ describe('GtmTelemetryProvider', () => {
|
||||
|
||||
it('pushes subscription_success for subscription activation', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackMonthlySubscriptionSucceeded()
|
||||
provider.trackMonthlySubscriptionSucceeded({
|
||||
transaction_id: 'stripe-event-123',
|
||||
value: 35,
|
||||
currency: 'USD',
|
||||
tier: 'creator',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'change',
|
||||
previous_tier: 'standard',
|
||||
ecommerce: {
|
||||
transaction_id: 'stripe-event-123',
|
||||
value: 35,
|
||||
currency: 'USD',
|
||||
items: [
|
||||
{
|
||||
item_name: 'creator',
|
||||
item_category: 'subscription',
|
||||
item_variant: 'monthly',
|
||||
price: 35,
|
||||
quantity: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'subscription_success'
|
||||
event: 'subscription_success',
|
||||
transaction_id: 'stripe-event-123',
|
||||
tier: 'creator',
|
||||
checkout_type: 'change'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
SettingChangedMetadata,
|
||||
ShareFlowMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
TabCountMetadata,
|
||||
TelemetryProvider,
|
||||
@@ -167,8 +168,17 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
this.pushEvent('signup_opened')
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.pushEvent('subscription_success')
|
||||
trackMonthlySubscriptionSucceeded(
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void {
|
||||
if (metadata?.ecommerce) {
|
||||
window.dataLayer?.push({ ecommerce: null })
|
||||
}
|
||||
|
||||
this.pushEvent(
|
||||
'subscription_success',
|
||||
metadata ? { ...metadata } : undefined
|
||||
)
|
||||
}
|
||||
|
||||
trackRunButton(options?: {
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
TabCountMetadata,
|
||||
TelemetryEventName,
|
||||
@@ -235,8 +236,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
|
||||
trackMonthlySubscriptionSucceeded(
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED, metadata)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
TabCountMetadata,
|
||||
TelemetryEventName,
|
||||
@@ -255,8 +256,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
|
||||
trackMonthlySubscriptionSucceeded(
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED, metadata)
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionCancelled(): void {
|
||||
|
||||
@@ -344,6 +344,29 @@ export interface BeginCheckoutMetadata
|
||||
previous_tier?: TierKey
|
||||
}
|
||||
|
||||
export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
|
||||
user_id?: string
|
||||
transaction_id: string
|
||||
value: number
|
||||
currency: string
|
||||
tier: Exclude<TierKey, 'free'>
|
||||
cycle: BillingCycle
|
||||
checkout_type: 'new' | 'change'
|
||||
previous_tier?: Exclude<TierKey, 'free'>
|
||||
ecommerce: {
|
||||
transaction_id: string
|
||||
value: number
|
||||
currency: string
|
||||
items: Array<{
|
||||
item_name: string
|
||||
item_category: 'subscription'
|
||||
item_variant: BillingCycle
|
||||
price: number
|
||||
quantity: 1
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Telemetry provider interface for individual providers.
|
||||
* All methods are optional - providers only implement what they need.
|
||||
@@ -360,7 +383,9 @@ export interface TelemetryProvider {
|
||||
metadata?: SubscriptionMetadata
|
||||
): void
|
||||
trackBeginCheckout?(metadata: BeginCheckoutMetadata): void
|
||||
trackMonthlySubscriptionSucceeded?(): void
|
||||
trackMonthlySubscriptionSucceeded?(
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void
|
||||
trackMonthlySubscriptionCancelled?(): void
|
||||
trackAddApiCreditButtonClicked?(): void
|
||||
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
|
||||
@@ -559,3 +584,4 @@ export type TelemetryEventProperties =
|
||||
| WorkflowSavedMetadata
|
||||
| DefaultViewSetMetadata
|
||||
| SubscriptionMetadata
|
||||
| SubscriptionSuccessMetadata
|
||||
|
||||
Reference in New Issue
Block a user