mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-07 23:57:53 +00:00
Compare commits
3 Commits
chore/code
...
deepme987/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffd37e82e2 | ||
|
|
f303872748 | ||
|
|
ad6548690b |
@@ -544,6 +544,13 @@ const allTemplates = computed(() => {
|
||||
// Navigation
|
||||
const selectedNavItem = ref<string | null>(initialCategory)
|
||||
|
||||
// Track category/tab switches (e.g. "Getting Started" vs "All") so we can see
|
||||
// which curated entry points users browse before opening a template.
|
||||
watch(selectedNavItem, (to, from) => {
|
||||
if (!to || to === from) return
|
||||
useTelemetry()?.trackTemplateCategorySelected({ category_id: to })
|
||||
})
|
||||
|
||||
// Filter templates based on selected navigation item
|
||||
const navigationFilteredTemplates = computed(() => {
|
||||
if (!selectedNavItem.value) {
|
||||
|
||||
@@ -30,6 +30,8 @@ const mockIsYearlySubscription = ref(false)
|
||||
const mockAccessBillingPortal = vi.fn()
|
||||
const mockReportError = vi.fn()
|
||||
const mockTrackBeginCheckout = vi.fn()
|
||||
const mockTrackSubscription = vi.fn()
|
||||
const mockTrackBillingCycleToggled = vi.fn()
|
||||
const mockUserId = ref<string | undefined>('user-123')
|
||||
const mockGetAuthHeader = vi.fn(() =>
|
||||
Promise.resolve({ Authorization: 'Bearer test-token' })
|
||||
@@ -111,7 +113,9 @@ vi.mock('@/stores/authStore', () => ({
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackBeginCheckout: mockTrackBeginCheckout
|
||||
trackBeginCheckout: mockTrackBeginCheckout,
|
||||
trackSubscription: mockTrackSubscription,
|
||||
trackBillingCycleToggled: mockTrackBillingCycleToggled
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -222,6 +226,8 @@ describe('PricingTable', () => {
|
||||
mockAccessBillingPortal.mockReset()
|
||||
mockAccessBillingPortal.mockResolvedValue(true)
|
||||
mockTrackBeginCheckout.mockReset()
|
||||
mockTrackSubscription.mockReset()
|
||||
mockTrackBillingCycleToggled.mockReset()
|
||||
mockLocalStorage.__reset()
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -439,4 +445,95 @@ describe('PricingTable', () => {
|
||||
expect(onChooseTeamWorkspace).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscribe-now click telemetry', () => {
|
||||
it('fires subscribe_clicked with tier/cycle/source on the new-subscriber path', async () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
|
||||
const windowOpenSpy = vi
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => null)
|
||||
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
|
||||
const creatorButton = screen
|
||||
.getAllByRole('button')
|
||||
.find((b) => b.textContent?.includes('Creator'))
|
||||
|
||||
await userEvent.click(creatorButton!)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockTrackSubscription).toHaveBeenCalledWith('subscribe_clicked', {
|
||||
current_tier: undefined,
|
||||
tier: 'creator',
|
||||
cycle: 'yearly',
|
||||
source: 'pricing_table'
|
||||
})
|
||||
|
||||
windowOpenSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('fires subscribe_clicked on the change path for an existing subscriber', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockSubscriptionTier.value = 'STANDARD'
|
||||
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
|
||||
const proButton = screen
|
||||
.getAllByRole('button')
|
||||
.find((b) => b.textContent?.includes('Pro'))
|
||||
|
||||
await userEvent.click(proButton!)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockTrackSubscription).toHaveBeenCalledWith('subscribe_clicked', {
|
||||
current_tier: 'standard',
|
||||
tier: 'pro',
|
||||
cycle: 'yearly',
|
||||
source: 'pricing_table'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fire subscribe_clicked when clicking the current plan', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockSubscriptionTier.value = 'CREATOR'
|
||||
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
|
||||
const currentPlanButton = screen
|
||||
.getAllByRole('button')
|
||||
.find((b) => b.textContent?.includes('Current Plan'))
|
||||
|
||||
await userEvent.click(currentPlanButton!)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('billing cycle toggle telemetry', () => {
|
||||
it('fires billing_cycle_toggled with from/to when switching to monthly', async () => {
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
|
||||
const monthlyToggle = screen.getByRole('button', { name: 'Monthly' })
|
||||
await userEvent.click(monthlyToggle)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockTrackBillingCycleToggled).toHaveBeenCalledWith({
|
||||
from: 'yearly',
|
||||
to: 'monthly'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fire on initial render when the cycle has not changed', async () => {
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockTrackBillingCycleToggled).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -258,7 +258,7 @@ import { storeToRefs } from 'pinia'
|
||||
import Popover from 'primevue/popover'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -374,6 +374,13 @@ const loadingTier = ref<CheckoutTierKey | null>(null)
|
||||
const popover = ref()
|
||||
const currentBillingCycle = ref<BillingCycle>('yearly')
|
||||
|
||||
// Track monthly/yearly toggles so we can see whether the annual-discount
|
||||
// nudge actually moves the cycle selection before checkout.
|
||||
watch(currentBillingCycle, (to, from) => {
|
||||
if (!isCloud || to === from) return
|
||||
telemetry?.trackBillingCycleToggled({ from, to })
|
||||
})
|
||||
|
||||
const hasPaidSubscription = computed(
|
||||
() => isActiveSubscription.value && !isFreeTier.value
|
||||
)
|
||||
@@ -448,6 +455,16 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
|
||||
isLoading.value = true
|
||||
loadingTier.value = tierKey
|
||||
|
||||
// Fire the subscribe-now click before opening checkout so the funnel
|
||||
// captures intent even if the checkout window is blocked or abandoned.
|
||||
// Covers both the 'change' (existing paid subscriber) and 'new' paths.
|
||||
telemetry?.trackSubscription('subscribe_clicked', {
|
||||
current_tier: subscriptionTier.value?.toLowerCase(),
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle.value,
|
||||
source: 'pricing_table'
|
||||
})
|
||||
|
||||
try {
|
||||
if (hasPaidSubscription.value) {
|
||||
const targetPlan = {
|
||||
|
||||
@@ -55,7 +55,8 @@ watch(
|
||||
const handleSubscribe = () => {
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSubscription('subscribe_clicked', {
|
||||
current_tier: subscriptionTier.value?.toLowerCase()
|
||||
current_tier: subscriptionTier.value?.toLowerCase(),
|
||||
source: 'subscribe_button'
|
||||
})
|
||||
}
|
||||
isAwaitingStripeSubscription.value = true
|
||||
|
||||
@@ -10,6 +10,8 @@ import SubscribeToRun from './SubscribeToRun.vue'
|
||||
const mockShowSubscriptionDialog = vi.fn()
|
||||
const mockCanManageSubscription = ref(true)
|
||||
const mockIsMdOrLarger = ref(true)
|
||||
const mockTrackRunButton = vi.fn()
|
||||
const mockTrackSubscription = vi.fn()
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
@@ -30,7 +32,10 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
useTelemetry: () => ({
|
||||
trackRunButton: mockTrackRunButton,
|
||||
trackSubscription: mockTrackSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
@@ -111,4 +116,15 @@ describe('SubscribeToRun', () => {
|
||||
|
||||
expect(mockShowSubscriptionDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('tracks both the run button and a subscribe-now click on click', async () => {
|
||||
const { user } = renderButton()
|
||||
|
||||
await user.click(screen.getByTestId('subscribe-to-run-button'))
|
||||
|
||||
expect(mockTrackRunButton).toHaveBeenCalledWith({ subscribe_to_run: true })
|
||||
expect(mockTrackSubscription).toHaveBeenCalledWith('subscribe_clicked', {
|
||||
source: 'subscribe_to_run'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,7 +50,14 @@ const buttonTooltip = computed(() =>
|
||||
|
||||
function handleSubscribeToRun() {
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackRunButton({ subscribe_to_run: true })
|
||||
const telemetry = useTelemetry()
|
||||
telemetry?.trackRunButton({ subscribe_to_run: true })
|
||||
// Also count this as a subscribe-now click so the lock-button CTA shows up
|
||||
// in the subscribe-click funnel alongside the pricing table and the
|
||||
// legacy SubscribeButton (previously the only fired surface).
|
||||
telemetry?.trackSubscription('subscribe_clicked', {
|
||||
source: 'subscribe_to_run'
|
||||
})
|
||||
}
|
||||
|
||||
showSubscriptionDialog()
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { AuditLog } from '@/services/customerEventsService'
|
||||
import type {
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
BillingCycleToggledMetadata,
|
||||
AuthErrorMetadata,
|
||||
TemplateCategorySelectedMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
@@ -86,6 +89,22 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackBeginCheckout?.(metadata))
|
||||
}
|
||||
|
||||
trackBillingCycleToggled(metadata: BillingCycleToggledMetadata): void {
|
||||
this.dispatch((provider) => provider.trackBillingCycleToggled?.(metadata))
|
||||
}
|
||||
|
||||
trackAuthError(metadata: AuthErrorMetadata): void {
|
||||
this.dispatch((provider) => provider.trackAuthError?.(metadata))
|
||||
}
|
||||
|
||||
trackTemplateCategorySelected(
|
||||
metadata: TemplateCategorySelectedMetadata
|
||||
): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackTemplateCategorySelected?.(metadata)
|
||||
)
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void {
|
||||
|
||||
@@ -324,6 +324,18 @@ describe('PostHogTelemetryProvider', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('captures billing cycle toggles with from/to', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackBillingCycleToggled({ from: 'yearly', to: 'monthly' })
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.BILLING_CYCLE_TOGGLED,
|
||||
{ from: 'yearly', to: 'monthly' }
|
||||
)
|
||||
})
|
||||
|
||||
it('captures search queries with surface, query, length, and result count', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
@@ -11,6 +11,9 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import type {
|
||||
AuthMetadata,
|
||||
BillingCycleToggledMetadata,
|
||||
AuthErrorMetadata,
|
||||
TemplateCategorySelectedMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
@@ -126,9 +129,14 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
api_host:
|
||||
window.__CONFIG__?.posthog_api_host || 'https://t.comfy.org',
|
||||
ui_host: 'https://us.posthog.com',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
// Web analytics enabled so cloud.comfy.org gets heatmaps + $pageview
|
||||
// (the login/onboarding pages previously had zero coverage). autocapture
|
||||
// does NOT record input *values* (posthog masks them), and these defaults
|
||||
// remain overridable per-environment via `serverConfig` (spread below).
|
||||
autocapture: true,
|
||||
capture_pageview: true,
|
||||
capture_pageleave: true,
|
||||
heatmaps: true,
|
||||
persistence: 'localStorage+cookie',
|
||||
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
|
||||
...serverConfig,
|
||||
@@ -350,6 +358,20 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(eventName, metadata)
|
||||
}
|
||||
|
||||
trackBillingCycleToggled(metadata: BillingCycleToggledMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.BILLING_CYCLE_TOGGLED, metadata)
|
||||
}
|
||||
|
||||
trackAuthError(metadata: AuthErrorMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.AUTH_ERROR, metadata)
|
||||
}
|
||||
|
||||
trackTemplateCategorySelected(
|
||||
metadata: TemplateCategorySelectedMetadata
|
||||
): void {
|
||||
this.trackEvent(TelemetryEvents.TEMPLATE_CATEGORY_SELECTED, metadata)
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(): void {
|
||||
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
|
||||
}
|
||||
|
||||
@@ -423,9 +423,56 @@ export interface CheckoutAttributionMetadata {
|
||||
wbraid?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface that triggered a subscribe-now click. Lets us attribute the
|
||||
* `app:subscribe_now_button_clicked` event to the specific CTA the user
|
||||
* actually clicked, rather than only the legacy SubscribeButton.
|
||||
*/
|
||||
export type SubscribeClickSource =
|
||||
| 'pricing_table'
|
||||
| 'subscribe_to_run'
|
||||
| 'subscribe_button'
|
||||
|
||||
export interface SubscriptionMetadata {
|
||||
current_tier?: string
|
||||
reason?: SubscriptionDialogReason
|
||||
// Populated on subscribe-now clicks so the funnel can split intent by the
|
||||
// tier/cycle selected and the CTA surface that fired the event.
|
||||
tier?: TierKey
|
||||
cycle?: BillingCycle
|
||||
source?: SubscribeClickSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired when the user toggles the monthly/yearly billing cycle on the
|
||||
* pricing table. Lets us see whether the annual-discount nudge actually
|
||||
* moves the cycle selection before checkout.
|
||||
*/
|
||||
export interface BillingCycleToggledMetadata {
|
||||
from: BillingCycle
|
||||
to: BillingCycle
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired when an authentication attempt fails (sign-in or sign-up). Lets the
|
||||
* signup funnel see the error/bounce leak that `app:user_auth_completed`
|
||||
* (success-only) cannot.
|
||||
*/
|
||||
export interface AuthErrorMetadata {
|
||||
method: 'email' | 'google' | 'github'
|
||||
is_sign_up: boolean
|
||||
error_code?: string
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired when the user switches category/tab in the template selector (e.g.
|
||||
* "Getting Started" vs "All"), so we can see which curated entry points get
|
||||
* used before a template is opened.
|
||||
*/
|
||||
export interface TemplateCategorySelectedMetadata {
|
||||
category_id: string
|
||||
category_label?: string
|
||||
}
|
||||
|
||||
export interface BeginCheckoutMetadata
|
||||
@@ -479,6 +526,11 @@ export interface TelemetryProvider {
|
||||
metadata?: SubscriptionMetadata
|
||||
): void
|
||||
trackBeginCheckout?(metadata: BeginCheckoutMetadata): void
|
||||
trackBillingCycleToggled?(metadata: BillingCycleToggledMetadata): void
|
||||
trackAuthError?(metadata: AuthErrorMetadata): void
|
||||
trackTemplateCategorySelected?(
|
||||
metadata: TemplateCategorySelectedMetadata
|
||||
): void
|
||||
trackMonthlySubscriptionSucceeded?(
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void
|
||||
@@ -586,6 +638,9 @@ export const TelemetryEvents = {
|
||||
RUN_BUTTON_CLICKED: 'app:run_button_click',
|
||||
SUBSCRIPTION_REQUIRED_MODAL_OPENED: 'app:subscription_required_modal_opened',
|
||||
SUBSCRIBE_NOW_BUTTON_CLICKED: 'app:subscribe_now_button_clicked',
|
||||
BILLING_CYCLE_TOGGLED: 'app:billing_cycle_toggled',
|
||||
AUTH_ERROR: 'app:auth_error',
|
||||
TEMPLATE_CATEGORY_SELECTED: 'app:template_category_selected',
|
||||
MONTHLY_SUBSCRIPTION_SUCCEEDED: 'app:monthly_subscription_succeeded',
|
||||
MONTHLY_SUBSCRIPTION_CANCELLED: 'app:monthly_subscription_cancelled',
|
||||
ADD_API_CREDIT_BUTTON_CLICKED: 'app:add_api_credit_button_clicked',
|
||||
@@ -703,3 +758,6 @@ export type TelemetryEventProperties =
|
||||
| DefaultViewSetMetadata
|
||||
| SubscriptionMetadata
|
||||
| SubscriptionSuccessMetadata
|
||||
| BillingCycleToggledMetadata
|
||||
| AuthErrorMetadata
|
||||
| TemplateCategorySelectedMetadata
|
||||
|
||||
@@ -329,6 +329,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
action: (auth: Auth) => Promise<T>,
|
||||
options: {
|
||||
createCustomer?: boolean
|
||||
authError?: {
|
||||
method: 'email' | 'google' | 'github'
|
||||
isSignUp: boolean
|
||||
}
|
||||
} = {}
|
||||
): Promise<T> => {
|
||||
loading.value = true
|
||||
@@ -346,6 +350,18 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
// Surface the auth failure leak that trackAuth (success-only) misses.
|
||||
if (isCloud && options?.authError) {
|
||||
useTelemetry()?.trackAuthError({
|
||||
method: options.authError.method,
|
||||
is_sign_up: options.authError.isSignUp,
|
||||
error_code: (error as { code?: string })?.code,
|
||||
error_message:
|
||||
error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -358,7 +374,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const result = await executeAuthAction(
|
||||
(authInstance) =>
|
||||
signInWithEmailAndPassword(authInstance, email, password),
|
||||
{ createCustomer: true }
|
||||
{
|
||||
createCustomer: true,
|
||||
authError: { method: 'email', isSignUp: false }
|
||||
}
|
||||
)
|
||||
|
||||
if (isCloud) {
|
||||
@@ -381,7 +400,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const result = await executeAuthAction(
|
||||
(authInstance) =>
|
||||
createUserWithEmailAndPassword(authInstance, email, password),
|
||||
{ createCustomer: true }
|
||||
{
|
||||
createCustomer: true,
|
||||
authError: { method: 'email', isSignUp: true }
|
||||
}
|
||||
)
|
||||
|
||||
if (isCloud) {
|
||||
@@ -402,7 +424,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}): Promise<UserCredential> => {
|
||||
const result = await executeAuthAction(
|
||||
(authInstance) => signInWithPopup(authInstance, googleProvider),
|
||||
{ createCustomer: true }
|
||||
{
|
||||
createCustomer: true,
|
||||
authError: { method: 'google', isSignUp: options?.isNewUser ?? false }
|
||||
}
|
||||
)
|
||||
|
||||
if (isCloud) {
|
||||
@@ -425,7 +450,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}): Promise<UserCredential> => {
|
||||
const result = await executeAuthAction(
|
||||
(authInstance) => signInWithPopup(authInstance, githubProvider),
|
||||
{ createCustomer: true }
|
||||
{
|
||||
createCustomer: true,
|
||||
authError: { method: 'github', isSignUp: options?.isNewUser ?? false }
|
||||
}
|
||||
)
|
||||
|
||||
if (isCloud) {
|
||||
|
||||
Reference in New Issue
Block a user