mirror of
https://github.com/langgenius/dify.git
synced 2026-04-16 02:16:57 +08:00
feat(web): unify create_app tracking and persist external attribution (#35241)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
parent
9fd196642d
commit
fb17339d89
197
web/app/components/__tests__/app-initializer.spec.tsx
Normal file
197
web/app/components/__tests__/app-initializer.spec.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import Cookies from 'js-cookie'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
|
||||
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
|
||||
} from '@/app/education-apply/constants'
|
||||
import { resolvePostLoginRedirect } from '@/app/signin/utils/post-login-redirect'
|
||||
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
|
||||
import { AppInitializer } from '../app-initializer'
|
||||
|
||||
const { mockSendGAEvent, mockTrackEvent } = vi.hoisted(() => ({
|
||||
mockSendGAEvent: vi.fn(),
|
||||
mockTrackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: vi.fn(),
|
||||
useRouter: vi.fn(),
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/setup-status', () => ({
|
||||
fetchSetupStatusWithCache: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/signin/utils/post-login-redirect', () => ({
|
||||
resolvePostLoginRedirect: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/gtag', () => ({
|
||||
sendGAEvent: (...args: unknown[]) => mockSendGAEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
const mockUsePathname = vi.mocked(usePathname)
|
||||
const mockUseRouter = vi.mocked(useRouter)
|
||||
const mockUseSearchParams = vi.mocked(useSearchParams)
|
||||
const mockFetchSetupStatusWithCache = vi.mocked(fetchSetupStatusWithCache)
|
||||
const mockResolvePostLoginRedirect = vi.mocked(resolvePostLoginRedirect)
|
||||
const mockReplace = vi.fn()
|
||||
|
||||
describe('AppInitializer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
window.localStorage.clear()
|
||||
window.sessionStorage.clear()
|
||||
Cookies.remove('utm_info')
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockUsePathname.mockReturnValue('/apps')
|
||||
mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType<typeof useRouter>)
|
||||
mockUseSearchParams.mockReturnValue(new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>)
|
||||
mockFetchSetupStatusWithCache.mockResolvedValue({ step: 'finished' })
|
||||
mockResolvePostLoginRedirect.mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('renders children after setup checks finish', async () => {
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
|
||||
|
||||
expect(mockFetchSetupStatusWithCache).toHaveBeenCalledTimes(1)
|
||||
expect(mockReplace).not.toHaveBeenCalledWith('/signin')
|
||||
})
|
||||
|
||||
it('redirects to install when setup status loading fails', async () => {
|
||||
mockFetchSetupStatusWithCache.mockRejectedValue(new Error('unauthorized'))
|
||||
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/install'))
|
||||
expect(screen.queryByText('ready')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not persist create app attribution from the url anymore', async () => {
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
|
||||
|
||||
expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull()
|
||||
})
|
||||
|
||||
it('tracks oauth registration with utm info and clears the cookie', async () => {
|
||||
Cookies.set('utm_info', JSON.stringify({
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
}))
|
||||
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
{ searchParams: 'oauth_new_user=true' },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||
method: 'oauth',
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
})
|
||||
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||
method: 'oauth',
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
})
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps')
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the base registration event when the oauth utm cookie is invalid', async () => {
|
||||
Cookies.set('utm_info', '{invalid-json')
|
||||
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
{ searchParams: 'oauth_new_user=true' },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', {
|
||||
method: 'oauth',
|
||||
})
|
||||
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', {
|
||||
method: 'oauth',
|
||||
})
|
||||
expect(console.error).toHaveBeenCalled()
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stores the education verification flag in localStorage', async () => {
|
||||
mockUseSearchParams.mockReturnValue(
|
||||
new URLSearchParams(`action=${EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION}`) as unknown as ReturnType<typeof useSearchParams>,
|
||||
)
|
||||
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
|
||||
|
||||
expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBe('yes')
|
||||
})
|
||||
|
||||
it('redirects to the resolved post-login url when one exists', async () => {
|
||||
const mockLocationReplace = vi.fn()
|
||||
vi.stubGlobal('location', { ...window.location, replace: mockLocationReplace })
|
||||
mockResolvePostLoginRedirect.mockReturnValue('/explore')
|
||||
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockLocationReplace).toHaveBeenCalledWith('/explore'))
|
||||
expect(screen.queryByText('ready')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('redirects to signin when redirect resolution throws', async () => {
|
||||
mockResolvePostLoginRedirect.mockImplementation(() => {
|
||||
throw new Error('redirect resolution failed')
|
||||
})
|
||||
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/signin'))
|
||||
expect(screen.queryByText('ready')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,97 @@
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { runCreateAppAttributionBootstrap } from '@/utils/create-app-tracking'
|
||||
|
||||
let mockIsProd = false
|
||||
let mockNonce: string | null = 'test-nonce'
|
||||
|
||||
type BootstrapScriptProps = {
|
||||
id?: string
|
||||
strategy?: string
|
||||
nonce?: string
|
||||
children?: string
|
||||
}
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get IS_PROD() { return mockIsProd },
|
||||
}))
|
||||
|
||||
vi.mock('@/next/headers', () => ({
|
||||
headers: vi.fn(() => ({
|
||||
get: vi.fn((name: string) => {
|
||||
if (name === 'x-nonce')
|
||||
return mockNonce
|
||||
return null
|
||||
}),
|
||||
})),
|
||||
}))
|
||||
|
||||
const loadComponent = async () => {
|
||||
const mod = await import('../create-app-attribution-bootstrap')
|
||||
const rawExport = mod.default as unknown
|
||||
const renderer: (() => Promise<ReactNode>) | undefined
|
||||
= typeof rawExport === 'function' ? rawExport as () => Promise<ReactNode> : (rawExport as { type?: () => Promise<ReactNode> }).type
|
||||
|
||||
if (!renderer)
|
||||
throw new Error('CreateAppAttributionBootstrap component is not callable in tests')
|
||||
|
||||
return renderer
|
||||
}
|
||||
|
||||
const runBootstrapScript = () => {
|
||||
runCreateAppAttributionBootstrap()
|
||||
}
|
||||
|
||||
describe('CreateAppAttributionBootstrap', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
mockIsProd = false
|
||||
mockNonce = 'test-nonce'
|
||||
window.sessionStorage.clear()
|
||||
window.history.replaceState({}, '', '/apps')
|
||||
})
|
||||
|
||||
it('renders a beforeInteractive script element', async () => {
|
||||
const renderComponent = await loadComponent()
|
||||
const element = await renderComponent() as ReactElement<BootstrapScriptProps>
|
||||
|
||||
expect(element).toBeTruthy()
|
||||
expect(element.props.id).toBe('create-app-attribution-bootstrap')
|
||||
expect(element.props.strategy).toBe('beforeInteractive')
|
||||
expect(element.props.children).toContain('window.sessionStorage.setItem')
|
||||
})
|
||||
|
||||
it('uses the nonce header in production', async () => {
|
||||
mockIsProd = true
|
||||
mockNonce = 'prod-nonce'
|
||||
|
||||
const renderComponent = await loadComponent()
|
||||
const element = await renderComponent() as ReactElement<BootstrapScriptProps>
|
||||
|
||||
expect(element.props.nonce).toBe('prod-nonce')
|
||||
})
|
||||
|
||||
it('stores external attribution and clears only attribution params from the url', () => {
|
||||
window.history.replaceState({}, '', '/apps?action=keep&utm_source=dify_blog&slug=get-started-with-dif#preview')
|
||||
|
||||
runBootstrapScript()
|
||||
|
||||
expect(window.sessionStorage.getItem('create_app_external_attribution')).toBe(JSON.stringify({
|
||||
utmSource: 'blog',
|
||||
utmCampaign: 'get-started-with-dif',
|
||||
}))
|
||||
expect(window.location.pathname).toBe('/apps')
|
||||
expect(window.location.search).toBe('?action=keep')
|
||||
expect(window.location.hash).toBe('#preview')
|
||||
})
|
||||
|
||||
it('does nothing for invalid external sources', () => {
|
||||
window.history.replaceState({}, '', '/apps?action=keep&utm_source=internal&slug=ignored')
|
||||
|
||||
runBootstrapScript()
|
||||
|
||||
expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull()
|
||||
expect(window.location.search).toBe('?action=keep&utm_source=internal&slug=ignored')
|
||||
})
|
||||
})
|
||||
@ -4,7 +4,6 @@ import { AppModeEnum } from '@/types/app'
|
||||
import Apps from '../index'
|
||||
|
||||
const mockUseExploreAppList = vi.fn()
|
||||
const mockTrackEvent = vi.fn()
|
||||
const mockImportDSL = vi.fn()
|
||||
const mockFetchAppDetail = vi.fn()
|
||||
const mockHandleCheckPluginDependencies = vi.fn()
|
||||
@ -12,6 +11,7 @@ const mockGetRedirection = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
const mockToastSuccess = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockTrackCreateApp = vi.fn()
|
||||
let latestDebounceFn = () => {}
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
@ -92,8 +92,8 @@ vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
vi.mock('@/utils/create-app-tracking', () => ({
|
||||
trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args),
|
||||
}))
|
||||
vi.mock('@/service/apps', () => ({
|
||||
importDSL: (...args: unknown[]) => mockImportDSL(...args),
|
||||
@ -246,10 +246,9 @@ describe('Apps', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('create_app_with_template', expect.objectContaining({
|
||||
template_id: 'Alpha',
|
||||
template_name: 'Alpha',
|
||||
}))
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
appMode: AppModeEnum.CHAT,
|
||||
})
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated')
|
||||
expect(onSuccess).toHaveBeenCalled()
|
||||
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('created-app-id')
|
||||
|
||||
@ -8,7 +8,6 @@ import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppTypeSelector from '@/app/components/app/type-selector'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
@ -25,6 +24,7 @@ import { useExploreAppList } from '@/service/use-explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { trackCreateApp } from '@/utils/create-app-tracking'
|
||||
import AppCard from '../app-card'
|
||||
import Sidebar, { AppCategories, AppCategoryLabel } from './sidebar'
|
||||
|
||||
@ -127,14 +127,7 @@ const Apps = ({
|
||||
icon_background,
|
||||
description,
|
||||
})
|
||||
|
||||
// Track app creation from template
|
||||
trackEvent('create_app_with_template', {
|
||||
app_mode: mode,
|
||||
template_id: currApp?.app.id,
|
||||
template_name: currApp?.app.name,
|
||||
description,
|
||||
})
|
||||
trackCreateApp({ appMode: mode })
|
||||
|
||||
setIsShowCreateModal(false)
|
||||
toast.success(t('newApp.appCreated', { ns: 'app' }))
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
@ -10,6 +9,7 @@ import { useRouter } from '@/next/navigation'
|
||||
import { createApp } from '@/service/apps'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import { trackCreateApp } from '@/utils/create-app-tracking'
|
||||
import CreateAppModal from '../index'
|
||||
|
||||
const ahooksMocks = vi.hoisted(() => ({
|
||||
@ -31,8 +31,8 @@ vi.mock('ahooks', () => ({
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
vi.mock('@/utils/create-app-tracking', () => ({
|
||||
trackCreateApp: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/service/apps', () => ({
|
||||
createApp: vi.fn(),
|
||||
@ -87,7 +87,7 @@ vi.mock('@/hooks/use-theme', () => ({
|
||||
const mockUseRouter = vi.mocked(useRouter)
|
||||
const mockPush = vi.fn()
|
||||
const mockCreateApp = vi.mocked(createApp)
|
||||
const mockTrackEvent = vi.mocked(trackEvent)
|
||||
const mockTrackCreateApp = vi.mocked(trackCreateApp)
|
||||
const mockGetRedirection = vi.mocked(getRedirection)
|
||||
const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
const mockUseAppContext = vi.mocked(useAppContext)
|
||||
@ -178,10 +178,7 @@ describe('CreateAppModal', () => {
|
||||
mode: AppModeEnum.ADVANCED_CHAT,
|
||||
}))
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('create_app', {
|
||||
app_mode: AppModeEnum.ADVANCED_CHAT,
|
||||
description: '',
|
||||
})
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.ADVANCED_CHAT })
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated')
|
||||
expect(onSuccess).toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
|
||||
@ -6,7 +6,6 @@ import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import FullScreenModal from '@/app/components/base/fullscreen-modal'
|
||||
@ -25,6 +24,7 @@ import { createApp } from '@/service/apps'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { trackCreateApp } from '@/utils/create-app-tracking'
|
||||
import { basePath } from '@/utils/var'
|
||||
import AppIconPicker from '../../base/app-icon-picker'
|
||||
import ShortcutsName from '../../workflow/shortcuts-name'
|
||||
@ -80,11 +80,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
mode: appMode,
|
||||
})
|
||||
|
||||
// Track app creation success
|
||||
trackEvent('create_app', {
|
||||
app_mode: appMode,
|
||||
description,
|
||||
})
|
||||
trackCreateApp({ appMode: app.mode })
|
||||
|
||||
toast.success(t('newApp.appCreated', { ns: 'app' }))
|
||||
onSuccess()
|
||||
|
||||
@ -2,12 +2,13 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { DSLImportMode, DSLImportStatus } from '@/models/app'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import CreateFromDSLModal, { CreateFromDSLModalTab } from '../index'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
const mockImportDSL = vi.fn()
|
||||
const mockImportDSLConfirm = vi.fn()
|
||||
const mockTrackEvent = vi.fn()
|
||||
const mockTrackCreateApp = vi.fn()
|
||||
const mockHandleCheckPluginDependencies = vi.fn()
|
||||
const mockGetRedirection = vi.fn()
|
||||
const toastMocks = vi.hoisted(() => ({
|
||||
@ -43,8 +44,8 @@ vi.mock('@/next/navigation', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
vi.mock('@/utils/create-app-tracking', () => ({
|
||||
trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
@ -172,7 +173,7 @@ describe('CreateFromDSLModal', () => {
|
||||
id: 'import-1',
|
||||
status: DSLImportStatus.COMPLETED,
|
||||
app_id: 'app-1',
|
||||
app_mode: 'chat',
|
||||
app_mode: AppModeEnum.CHAT,
|
||||
})
|
||||
|
||||
render(
|
||||
@ -196,10 +197,7 @@ describe('CreateFromDSLModal', () => {
|
||||
mode: DSLImportMode.YAML_URL,
|
||||
yaml_url: 'https://example.com/app.yml',
|
||||
})
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('create_app_with_dsl', expect.objectContaining({
|
||||
creation_method: 'dsl_url',
|
||||
has_warnings: false,
|
||||
}))
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.CHAT })
|
||||
expect(handleSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
expect(localStorage.getItem(NEED_REFRESH_APP_LIST_KEY)).toBe('1')
|
||||
@ -212,7 +210,7 @@ describe('CreateFromDSLModal', () => {
|
||||
id: 'import-2',
|
||||
status: DSLImportStatus.COMPLETED_WITH_WARNINGS,
|
||||
app_id: 'app-2',
|
||||
app_mode: 'chat',
|
||||
app_mode: AppModeEnum.CHAT,
|
||||
})
|
||||
|
||||
render(
|
||||
@ -275,7 +273,7 @@ describe('CreateFromDSLModal', () => {
|
||||
mockImportDSLConfirm.mockResolvedValue({
|
||||
status: DSLImportStatus.COMPLETED,
|
||||
app_id: 'app-3',
|
||||
app_mode: 'workflow',
|
||||
app_mode: AppModeEnum.WORKFLOW,
|
||||
})
|
||||
|
||||
render(
|
||||
@ -305,6 +303,7 @@ describe('CreateFromDSLModal', () => {
|
||||
expect(mockImportDSLConfirm).toHaveBeenCalledWith({
|
||||
import_id: 'import-3',
|
||||
})
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.WORKFLOW })
|
||||
})
|
||||
|
||||
it('should ignore empty import responses and prevent duplicate submissions while a request is in flight', async () => {
|
||||
@ -332,7 +331,7 @@ describe('CreateFromDSLModal', () => {
|
||||
id: 'import-in-flight',
|
||||
status: DSLImportStatus.COMPLETED,
|
||||
app_id: 'app-1',
|
||||
app_mode: 'chat',
|
||||
app_mode: AppModeEnum.CHAT,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -6,7 +6,6 @@ import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { Button } from '@/app/components/base/ui/button'
|
||||
@ -27,6 +26,7 @@ import {
|
||||
} from '@/service/apps'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { trackCreateApp } from '@/utils/create-app-tracking'
|
||||
import ShortcutsName from '../../workflow/shortcuts-name'
|
||||
import Uploader from './uploader'
|
||||
|
||||
@ -112,12 +112,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
return
|
||||
const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response
|
||||
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
|
||||
// Track app creation from DSL import
|
||||
trackEvent('create_app_with_dsl', {
|
||||
app_mode,
|
||||
creation_method: currentTab === CreateFromDSLModalTab.FROM_FILE ? 'dsl_file' : 'dsl_url',
|
||||
has_warnings: status === DSLImportStatus.COMPLETED_WITH_WARNINGS,
|
||||
})
|
||||
trackCreateApp({ appMode: app_mode })
|
||||
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
@ -179,6 +174,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
const { status, app_id, app_mode } = response
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED) {
|
||||
trackCreateApp({ appMode: app_mode })
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
if (onClose)
|
||||
|
||||
@ -1,12 +1,48 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { App } from '@/models/explore'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useContextSelector } from 'use-context-selector'
|
||||
import AppListContext from '@/context/app-list-context'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
import Apps from '../index'
|
||||
|
||||
let documentTitleCalls: string[] = []
|
||||
let educationInitCalls: number = 0
|
||||
const mockHandleImportDSL = vi.fn()
|
||||
const mockHandleImportDSLConfirm = vi.fn()
|
||||
const mockTrackCreateApp = vi.fn()
|
||||
const mockFetchAppDetail = vi.mocked(fetchAppDetail)
|
||||
|
||||
const mockTemplateApp: App = {
|
||||
app_id: 'template-1',
|
||||
category: 'Assistant',
|
||||
app: {
|
||||
id: 'template-1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
icon_url: '',
|
||||
name: 'Sample App',
|
||||
description: 'Sample App',
|
||||
use_icon_as_answer_icon: false,
|
||||
},
|
||||
description: 'Sample App',
|
||||
can_trial: true,
|
||||
copyright: '',
|
||||
privacy_policy: null,
|
||||
custom_disclaimer: null,
|
||||
position: 1,
|
||||
is_listed: true,
|
||||
install_count: 0,
|
||||
installed: false,
|
||||
editable: false,
|
||||
is_agent: false,
|
||||
}
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: (title: string) => {
|
||||
@ -22,17 +58,83 @@ vi.mock('@/app/education-apply/hooks', () => ({
|
||||
|
||||
vi.mock('@/hooks/use-import-dsl', () => ({
|
||||
useImportDSL: () => ({
|
||||
handleImportDSL: vi.fn(),
|
||||
handleImportDSLConfirm: vi.fn(),
|
||||
handleImportDSL: mockHandleImportDSL,
|
||||
handleImportDSLConfirm: mockHandleImportDSLConfirm,
|
||||
versions: [],
|
||||
isFetching: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../list', () => ({
|
||||
default: () => {
|
||||
return React.createElement('div', { 'data-testid': 'apps-list' }, 'Apps List')
|
||||
},
|
||||
vi.mock('../list', () => {
|
||||
const MockList = () => {
|
||||
const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel)
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'apps-list' },
|
||||
React.createElement('span', null, 'Apps List'),
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'open-preview',
|
||||
'onClick': () => setShowTryAppPanel(true, {
|
||||
appId: mockTemplateApp.app_id,
|
||||
app: mockTemplateApp,
|
||||
}),
|
||||
},
|
||||
'Open Preview',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return { default: MockList }
|
||||
})
|
||||
|
||||
vi.mock('../../explore/try-app', () => ({
|
||||
default: ({ onCreate, onClose }: { onCreate: () => void, onClose: () => void }) => (
|
||||
<div data-testid="try-app-panel">
|
||||
<button data-testid="try-app-create" onClick={onCreate}>Create</button>
|
||||
<button data-testid="try-app-close" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../explore/create-app-modal', () => ({
|
||||
default: ({ show, onConfirm, onHide }: { show: boolean, onConfirm: (payload: Record<string, string>) => Promise<void>, onHide: () => void }) => show
|
||||
? (
|
||||
<div data-testid="create-app-modal">
|
||||
<button
|
||||
data-testid="confirm-create"
|
||||
onClick={() => onConfirm({
|
||||
name: 'Created App',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
description: 'created from preview',
|
||||
})}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button data-testid="hide-create" onClick={onHide}>Hide</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
vi.mock('../../app/create-from-dsl-modal/dsl-confirm-modal', () => ({
|
||||
default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => (
|
||||
<div data-testid="dsl-confirm-modal">
|
||||
<button data-testid="confirm-dsl" onClick={onConfirm}>Confirm DSL</button>
|
||||
<button data-testid="cancel-dsl" onClick={onCancel}>Cancel DSL</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/explore', () => ({
|
||||
fetchAppDetail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/create-app-tracking', () => ({
|
||||
trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args),
|
||||
}))
|
||||
|
||||
describe('Apps', () => {
|
||||
@ -59,6 +161,14 @@ describe('Apps', () => {
|
||||
vi.clearAllMocks()
|
||||
documentTitleCalls = []
|
||||
educationInitCalls = 0
|
||||
mockFetchAppDetail.mockResolvedValue({
|
||||
id: 'template-1',
|
||||
name: 'Sample App',
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
mode: AppModeEnum.CHAT,
|
||||
export_data: 'yaml-content',
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@ -116,6 +226,82 @@ describe('Apps', () => {
|
||||
)
|
||||
expect(screen.getByTestId('apps-list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should track template preview creation after a successful import', async () => {
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
})
|
||||
|
||||
renderWithClient(<Apps />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('open-preview'))
|
||||
fireEvent.click(await screen.findByTestId('try-app-create'))
|
||||
fireEvent.click(await screen.findByTestId('confirm-create'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAppDetail).toHaveBeenCalledWith('template-1')
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
appMode: AppModeEnum.CHAT,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should track template preview creation after confirming a pending import', async () => {
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => {
|
||||
options.onPending?.()
|
||||
})
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
})
|
||||
|
||||
renderWithClient(<Apps />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('open-preview'))
|
||||
fireEvent.click(await screen.findByTestId('try-app-create'))
|
||||
fireEvent.click(await screen.findByTestId('confirm-create'))
|
||||
|
||||
fireEvent.click(await screen.findByTestId('confirm-dsl'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
appMode: AppModeEnum.CHAT,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should close the dsl confirm modal when the pending import is canceled', async () => {
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => {
|
||||
options.onPending?.()
|
||||
})
|
||||
|
||||
renderWithClient(<Apps />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('open-preview'))
|
||||
fireEvent.click(await screen.findByTestId('try-app-create'))
|
||||
fireEvent.click(await screen.findByTestId('confirm-create'))
|
||||
|
||||
fireEvent.click(await screen.findByTestId('cancel-dsl'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('dsl-confirm-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(mockTrackCreateApp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should hide the create modal without tracking when the modal closes', async () => {
|
||||
renderWithClient(<Apps />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('open-preview'))
|
||||
fireEvent.click(await screen.findByTestId('try-app-create'))
|
||||
|
||||
fireEvent.click(await screen.findByTestId('hide-create'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(mockTrackCreateApp).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import type { CreateAppModalProps } from '../explore/create-app-modal'
|
||||
import type { TryAppSelection } from '@/types/try-app'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEducationInit } from '@/app/education-apply/hooks'
|
||||
import AppListContext from '@/context/app-list-context'
|
||||
@ -10,6 +10,7 @@ import { useImportDSL } from '@/hooks/use-import-dsl'
|
||||
import { DSLImportMode } from '@/models/app'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
import { trackCreateApp } from '@/utils/create-app-tracking'
|
||||
import List from './list'
|
||||
|
||||
const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false })
|
||||
@ -23,6 +24,7 @@ const Apps = () => {
|
||||
useEducationInit()
|
||||
|
||||
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
|
||||
const currentCreateAppModeRef = useRef<TryAppSelection['app']['app']['mode'] | null>(null)
|
||||
const currApp = currentTryAppParams?.app
|
||||
const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false)
|
||||
const hideTryAppPanel = useCallback(() => {
|
||||
@ -40,6 +42,12 @@ const Apps = () => {
|
||||
const handleShowFromTryApp = useCallback(() => {
|
||||
setIsShowCreateModal(true)
|
||||
}, [])
|
||||
const trackCurrentCreateApp = useCallback(() => {
|
||||
if (!currentCreateAppModeRef.current)
|
||||
return
|
||||
|
||||
trackCreateApp({ appMode: currentCreateAppModeRef.current })
|
||||
}, [])
|
||||
|
||||
const [controlRefreshList, setControlRefreshList] = useState(0)
|
||||
const [controlHideCreateFromTemplatePanel, setControlHideCreateFromTemplatePanel] = useState(0)
|
||||
@ -59,11 +67,14 @@ const Apps = () => {
|
||||
|
||||
const onConfirmDSL = useCallback(async () => {
|
||||
await handleImportDSLConfirm({
|
||||
onSuccess,
|
||||
onSuccess: () => {
|
||||
trackCurrentCreateApp()
|
||||
onSuccess()
|
||||
},
|
||||
})
|
||||
}, [handleImportDSLConfirm, onSuccess])
|
||||
}, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp])
|
||||
|
||||
const onCreate: CreateAppModalProps['onConfirm'] = async ({
|
||||
const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
@ -72,9 +83,10 @@ const Apps = () => {
|
||||
}) => {
|
||||
hideTryAppPanel()
|
||||
|
||||
const { export_data } = await fetchAppDetail(
|
||||
const { export_data, mode } = await fetchAppDetail(
|
||||
currApp?.app.id as string,
|
||||
)
|
||||
currentCreateAppModeRef.current = mode
|
||||
const payload = {
|
||||
mode: DSLImportMode.YAML_CONTENT,
|
||||
yaml_content: export_data,
|
||||
@ -86,13 +98,14 @@ const Apps = () => {
|
||||
}
|
||||
await handleImportDSL(payload, {
|
||||
onSuccess: () => {
|
||||
trackCurrentCreateApp()
|
||||
setIsShowCreateModal(false)
|
||||
},
|
||||
onPending: () => {
|
||||
setShowDSLConfirmModal(true)
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [currApp?.app.id, handleImportDSL, hideTryAppPanel, trackCurrentCreateApp])
|
||||
|
||||
return (
|
||||
<AppListContext.Provider value={{
|
||||
|
||||
23
web/app/components/create-app-attribution-bootstrap.tsx
Normal file
23
web/app/components/create-app-attribution-bootstrap.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { memo } from 'react'
|
||||
import { IS_PROD } from '@/config'
|
||||
import { headers } from '@/next/headers'
|
||||
import Script from '@/next/script'
|
||||
import { buildCreateAppAttributionBootstrapScript } from '@/utils/create-app-tracking'
|
||||
|
||||
const CreateAppAttributionBootstrap = async () => {
|
||||
const nonce = IS_PROD ? (await headers()).get('x-nonce') ?? '' : ''
|
||||
/* v8 ignore next -- `nonce` is always a string (`''` or header value), so nullish fallback is unreachable in runtime. @preserve */
|
||||
const scriptNonce = nonce ?? undefined
|
||||
|
||||
return (
|
||||
<Script
|
||||
id="create-app-attribution-bootstrap"
|
||||
strategy="beforeInteractive"
|
||||
nonce={scriptNonce}
|
||||
>
|
||||
{buildCreateAppAttributionBootstrapScript()}
|
||||
</Script>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CreateAppAttributionBootstrap)
|
||||
@ -15,6 +15,7 @@ let mockIsLoading = false
|
||||
let mockIsError = false
|
||||
const mockHandleImportDSL = vi.fn()
|
||||
const mockHandleImportDSLConfirm = vi.fn()
|
||||
const mockTrackCreateApp = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useExploreAppList: () => ({
|
||||
@ -45,6 +46,9 @@ vi.mock('@/hooks/use-import-dsl', () => ({
|
||||
isFetching: false,
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/utils/create-app-tracking', () => ({
|
||||
trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/explore/create-app-modal', () => ({
|
||||
default: (props: CreateAppModalProps) => {
|
||||
@ -214,7 +218,7 @@ describe('AppList', () => {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
};
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' })
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT })
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => {
|
||||
options.onPending?.()
|
||||
})
|
||||
@ -235,6 +239,9 @@ describe('AppList', () => {
|
||||
fireEvent.click(screen.getByTestId('dsl-confirm'))
|
||||
await waitFor(() => {
|
||||
expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
appMode: AppModeEnum.CHAT,
|
||||
})
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -307,7 +314,7 @@ describe('AppList', () => {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
};
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' })
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT })
|
||||
|
||||
renderAppList(true)
|
||||
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
|
||||
@ -317,6 +324,7 @@ describe('AppList', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(mockTrackCreateApp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close create modal on successful DSL import', async () => {
|
||||
@ -325,7 +333,7 @@ describe('AppList', () => {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
};
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' })
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT })
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
})
|
||||
@ -345,7 +353,7 @@ describe('AppList', () => {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
};
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' })
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT })
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => {
|
||||
options.onPending?.()
|
||||
})
|
||||
@ -385,6 +393,30 @@ describe('AppList', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should track preview source when creation starts from try app details', async () => {
|
||||
vi.useRealTimers()
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
};
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT })
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
})
|
||||
|
||||
renderAppList(true)
|
||||
|
||||
fireEvent.click(screen.getByText('explore.appCard.try'))
|
||||
fireEvent.click(screen.getByTestId('try-app-create'))
|
||||
fireEvent.click(await screen.findByTestId('confirm-create'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
appMode: AppModeEnum.CHAT,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should close try app panel when close is clicked', () => {
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
|
||||
@ -6,7 +6,7 @@ import type { TryAppSelection } from '@/types/try-app'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal'
|
||||
import Input from '@/app/components/base/input'
|
||||
@ -26,6 +26,7 @@ import { fetchAppDetail } from '@/service/explore'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import { useExploreAppList } from '@/service/use-explore'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { trackCreateApp } from '@/utils/create-app-tracking'
|
||||
import TryApp from '../try-app'
|
||||
import s from './style.module.css'
|
||||
|
||||
@ -101,6 +102,7 @@ const Apps = ({
|
||||
const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false)
|
||||
|
||||
const [currentTryApp, setCurrentTryApp] = useState<TryAppSelection | undefined>(undefined)
|
||||
const currentCreateAppModeRef = useRef<App['app']['mode'] | null>(null)
|
||||
const isShowTryAppPanel = !!currentTryApp
|
||||
const hideTryAppPanel = useCallback(() => {
|
||||
setCurrentTryApp(undefined)
|
||||
@ -112,8 +114,14 @@ const Apps = ({
|
||||
setCurrApp(currentTryApp?.app || null)
|
||||
setIsShowCreateModal(true)
|
||||
}, [currentTryApp?.app])
|
||||
const trackCurrentCreateApp = useCallback(() => {
|
||||
if (!currentCreateAppModeRef.current)
|
||||
return
|
||||
|
||||
const onCreate: CreateAppModalProps['onConfirm'] = async ({
|
||||
trackCreateApp({ appMode: currentCreateAppModeRef.current })
|
||||
}, [])
|
||||
|
||||
const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
@ -122,9 +130,10 @@ const Apps = ({
|
||||
}) => {
|
||||
hideTryAppPanel()
|
||||
|
||||
const { export_data } = await fetchAppDetail(
|
||||
const { export_data, mode } = await fetchAppDetail(
|
||||
currApp?.app.id as string,
|
||||
)
|
||||
currentCreateAppModeRef.current = mode
|
||||
const payload = {
|
||||
mode: DSLImportMode.YAML_CONTENT,
|
||||
yaml_content: export_data,
|
||||
@ -136,19 +145,23 @@ const Apps = ({
|
||||
}
|
||||
await handleImportDSL(payload, {
|
||||
onSuccess: () => {
|
||||
trackCurrentCreateApp()
|
||||
setIsShowCreateModal(false)
|
||||
},
|
||||
onPending: () => {
|
||||
setShowDSLConfirmModal(true)
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [currApp?.app.id, handleImportDSL, hideTryAppPanel, trackCurrentCreateApp])
|
||||
|
||||
const onConfirmDSL = useCallback(async () => {
|
||||
await handleImportDSLConfirm({
|
||||
onSuccess,
|
||||
onSuccess: () => {
|
||||
trackCurrentCreateApp()
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
}, [handleImportDSLConfirm, onSuccess])
|
||||
}, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@ -9,6 +9,7 @@ import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
import { ToastHost } from './components/base/ui/toast'
|
||||
import { TooltipProvider } from './components/base/ui/tooltip'
|
||||
import PartnerStackCookieRecorder from './components/billing/partner-stack/cookie-recorder'
|
||||
import CreateAppAttributionBootstrap from './components/create-app-attribution-bootstrap'
|
||||
import { AgentationLoader } from './components/devtools/agentation-loader'
|
||||
import { ReactScanLoader } from './components/devtools/react-scan/loader'
|
||||
import { I18nServerProvider } from './components/provider/i18n-server'
|
||||
@ -47,6 +48,7 @@ const LocaleLayout = async ({
|
||||
<meta name="msapplication-TileColor" content="#1C64F2" />
|
||||
<meta name="msapplication-config" content="/browserconfig.xml" />
|
||||
|
||||
<CreateAppAttributionBootstrap />
|
||||
{/* <ReactGrabLoader /> */}
|
||||
<ReactScanLoader />
|
||||
</head>
|
||||
|
||||
@ -11,6 +11,7 @@ import { validPassword } from '@/config'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useMailRegister } from '@/service/use-common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { rememberCreateAppExternalAttribution } from '@/utils/create-app-tracking'
|
||||
import { sendGAEvent } from '@/utils/gtag'
|
||||
|
||||
const parseUtmInfo = () => {
|
||||
@ -68,6 +69,7 @@ const ChangePasswordForm = () => {
|
||||
const { result } = res as MailRegisterResponse
|
||||
if (result === 'success') {
|
||||
const utmInfo = parseUtmInfo()
|
||||
rememberCreateAppExternalAttribution({ utmInfo })
|
||||
trackEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
|
||||
method: 'email',
|
||||
...utmInfo,
|
||||
|
||||
189
web/utils/__tests__/create-app-tracking.spec.ts
Normal file
189
web/utils/__tests__/create-app-tracking.spec.ts
Normal file
@ -0,0 +1,189 @@
|
||||
import Cookies from 'js-cookie'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as amplitude from '@/app/components/base/amplitude'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import {
|
||||
buildCreateAppEventPayload,
|
||||
extractExternalCreateAppAttribution,
|
||||
rememberCreateAppExternalAttribution,
|
||||
trackCreateApp,
|
||||
} from '../create-app-tracking'
|
||||
|
||||
describe('create-app-tracking', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(amplitude, 'trackEvent').mockImplementation(() => {})
|
||||
window.sessionStorage.clear()
|
||||
window.history.replaceState({}, '', '/apps')
|
||||
})
|
||||
|
||||
describe('extractExternalCreateAppAttribution', () => {
|
||||
it('should map campaign links to external attribution', () => {
|
||||
const attribution = extractExternalCreateAppAttribution({
|
||||
searchParams: new URLSearchParams('utm_source=x&slug=how-to-build-rag-agent'),
|
||||
})
|
||||
|
||||
expect(attribution).toEqual({
|
||||
utmSource: 'twitter/x',
|
||||
utmCampaign: 'how-to-build-rag-agent',
|
||||
})
|
||||
})
|
||||
|
||||
it('should map newsletter and blog sources to blog', () => {
|
||||
expect(extractExternalCreateAppAttribution({
|
||||
searchParams: new URLSearchParams('utm_source=newsletter'),
|
||||
})).toEqual({ utmSource: 'blog' })
|
||||
|
||||
expect(extractExternalCreateAppAttribution({
|
||||
utmInfo: { utm_source: 'dify_blog', slug: 'launch-week' },
|
||||
})).toEqual({
|
||||
utmSource: 'blog',
|
||||
utmCampaign: 'launch-week',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('rememberCreateAppExternalAttribution', () => {
|
||||
it('should ignore malformed utm cookies', () => {
|
||||
vi.spyOn(Cookies, 'get').mockImplementation(((key?: string) => {
|
||||
return key ? 'not-json' : {}
|
||||
}) as typeof Cookies.get)
|
||||
|
||||
expect(rememberCreateAppExternalAttribution()).toBeNull()
|
||||
expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCreateAppEventPayload', () => {
|
||||
it('should build original payloads with normalized app mode and timestamp', () => {
|
||||
expect(buildCreateAppEventPayload({
|
||||
appMode: AppModeEnum.ADVANCED_CHAT,
|
||||
}, null, new Date(2026, 3, 13, 14, 5, 9))).toEqual({
|
||||
source: 'original',
|
||||
app_mode: 'chatflow',
|
||||
time: '04-13-14:05:09',
|
||||
})
|
||||
})
|
||||
|
||||
it('should map agent mode into the canonical app mode bucket', () => {
|
||||
expect(buildCreateAppEventPayload({
|
||||
appMode: AppModeEnum.AGENT_CHAT,
|
||||
}, null, new Date(2026, 3, 13, 9, 8, 7))).toEqual({
|
||||
source: 'original',
|
||||
app_mode: 'agent',
|
||||
time: '04-13-09:08:07',
|
||||
})
|
||||
})
|
||||
|
||||
it('should fold legacy non-agent modes into chatflow', () => {
|
||||
expect(buildCreateAppEventPayload({
|
||||
appMode: AppModeEnum.CHAT,
|
||||
}, null, new Date(2026, 3, 13, 8, 0, 1))).toEqual({
|
||||
source: 'original',
|
||||
app_mode: 'chatflow',
|
||||
time: '04-13-08:00:01',
|
||||
})
|
||||
|
||||
expect(buildCreateAppEventPayload({
|
||||
appMode: AppModeEnum.COMPLETION,
|
||||
}, null, new Date(2026, 3, 13, 8, 0, 2))).toEqual({
|
||||
source: 'original',
|
||||
app_mode: 'chatflow',
|
||||
time: '04-13-08:00:02',
|
||||
})
|
||||
})
|
||||
|
||||
it('should map workflow mode into the workflow bucket', () => {
|
||||
expect(buildCreateAppEventPayload({
|
||||
appMode: AppModeEnum.WORKFLOW,
|
||||
}, null, new Date(2026, 3, 13, 7, 6, 5))).toEqual({
|
||||
source: 'original',
|
||||
app_mode: 'workflow',
|
||||
time: '04-13-07:06:05',
|
||||
})
|
||||
})
|
||||
|
||||
it('should prefer external attribution when present', () => {
|
||||
expect(buildCreateAppEventPayload(
|
||||
{
|
||||
appMode: AppModeEnum.WORKFLOW,
|
||||
},
|
||||
{
|
||||
utmSource: 'linkedin',
|
||||
utmCampaign: 'agent-launch',
|
||||
},
|
||||
)).toEqual({
|
||||
source: 'external',
|
||||
utm_source: 'linkedin',
|
||||
utm_campaign: 'agent-launch',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('trackCreateApp', () => {
|
||||
it('should track remembered external attribution once before falling back to internal source', () => {
|
||||
rememberCreateAppExternalAttribution({
|
||||
searchParams: new URLSearchParams('utm_source=newsletter&slug=how-to-build-rag-agent'),
|
||||
})
|
||||
|
||||
trackCreateApp({ appMode: AppModeEnum.WORKFLOW })
|
||||
|
||||
expect(amplitude.trackEvent).toHaveBeenNthCalledWith(1, 'create_app', {
|
||||
source: 'external',
|
||||
utm_source: 'blog',
|
||||
utm_campaign: 'how-to-build-rag-agent',
|
||||
})
|
||||
|
||||
trackCreateApp({ appMode: AppModeEnum.WORKFLOW })
|
||||
|
||||
expect(amplitude.trackEvent).toHaveBeenNthCalledWith(2, 'create_app', {
|
||||
source: 'original',
|
||||
app_mode: 'workflow',
|
||||
time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep using remembered external attribution after navigating away from the original url', () => {
|
||||
window.history.replaceState({}, '', '/apps?utm_source=linkedin&slug=agent-launch')
|
||||
|
||||
rememberCreateAppExternalAttribution({
|
||||
searchParams: new URLSearchParams(window.location.search),
|
||||
})
|
||||
|
||||
window.history.replaceState({}, '', '/explore')
|
||||
|
||||
trackCreateApp({ appMode: AppModeEnum.CHAT })
|
||||
|
||||
expect(amplitude.trackEvent).toHaveBeenCalledWith('create_app', {
|
||||
source: 'external',
|
||||
utm_source: 'linkedin',
|
||||
utm_campaign: 'agent-launch',
|
||||
})
|
||||
})
|
||||
|
||||
it('should fall back to the original payload when window is unavailable', () => {
|
||||
const originalWindow = globalThis.window
|
||||
|
||||
try {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
|
||||
trackCreateApp({ appMode: AppModeEnum.AGENT_CHAT })
|
||||
|
||||
expect(amplitude.trackEvent).toHaveBeenCalledWith('create_app', {
|
||||
source: 'original',
|
||||
app_mode: 'agent',
|
||||
time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: originalWindow,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
240
web/utils/create-app-tracking.ts
Normal file
240
web/utils/create-app-tracking.ts
Normal file
@ -0,0 +1,240 @@
|
||||
import Cookies from 'js-cookie'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY = 'create_app_external_attribution'
|
||||
const CREATE_APP_EXTERNAL_ATTRIBUTION_QUERY_KEYS = ['utm_source', 'utm_campaign', 'slug'] as const
|
||||
|
||||
const EXTERNAL_UTM_SOURCE_MAP = {
|
||||
blog: 'blog',
|
||||
dify_blog: 'blog',
|
||||
linkedin: 'linkedin',
|
||||
newsletter: 'blog',
|
||||
twitter: 'twitter/x',
|
||||
x: 'twitter/x',
|
||||
} as const
|
||||
|
||||
type SearchParamReader = {
|
||||
get: (name: string) => string | null
|
||||
}
|
||||
|
||||
type OriginalCreateAppMode = 'workflow' | 'chatflow' | 'agent'
|
||||
|
||||
type TrackCreateAppParams = {
|
||||
appMode: AppModeEnum
|
||||
}
|
||||
|
||||
type ExternalCreateAppAttribution = {
|
||||
utmSource: typeof EXTERNAL_UTM_SOURCE_MAP[keyof typeof EXTERNAL_UTM_SOURCE_MAP]
|
||||
utmCampaign?: string
|
||||
}
|
||||
|
||||
const serializeBootstrapValue = (value: unknown) => {
|
||||
return JSON.stringify(value).replace(/</g, '\\u003c')
|
||||
}
|
||||
|
||||
const normalizeString = (value?: string | null) => {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
const getObjectStringValue = (value: unknown) => {
|
||||
return typeof value === 'string' ? normalizeString(value) : undefined
|
||||
}
|
||||
|
||||
const getSearchParamValue = (searchParams?: SearchParamReader | null, key?: string) => {
|
||||
if (!searchParams || !key)
|
||||
return undefined
|
||||
return normalizeString(searchParams.get(key))
|
||||
}
|
||||
|
||||
const parseJSONRecord = (value?: string | null): Record<string, unknown> | null => {
|
||||
if (!value)
|
||||
return null
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : null
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getCookieUtmInfo = () => {
|
||||
return parseJSONRecord(Cookies.get('utm_info'))
|
||||
}
|
||||
|
||||
const mapExternalUtmSource = (value?: string) => {
|
||||
if (!value)
|
||||
return undefined
|
||||
|
||||
const normalized = value.toLowerCase()
|
||||
return EXTERNAL_UTM_SOURCE_MAP[normalized as keyof typeof EXTERNAL_UTM_SOURCE_MAP]
|
||||
}
|
||||
|
||||
const padTimeValue = (value: number) => String(value).padStart(2, '0')
|
||||
|
||||
const formatCreateAppTime = (date: Date) => {
|
||||
return `${padTimeValue(date.getMonth() + 1)}-${padTimeValue(date.getDate())}-${padTimeValue(date.getHours())}:${padTimeValue(date.getMinutes())}:${padTimeValue(date.getSeconds())}`
|
||||
}
|
||||
|
||||
const mapOriginalCreateAppMode = (appMode: AppModeEnum): OriginalCreateAppMode => {
|
||||
if (appMode === AppModeEnum.WORKFLOW)
|
||||
return 'workflow'
|
||||
|
||||
if (appMode === AppModeEnum.AGENT_CHAT)
|
||||
return 'agent'
|
||||
|
||||
return 'chatflow'
|
||||
}
|
||||
|
||||
export const runCreateAppAttributionBootstrap = (
|
||||
sourceMap = EXTERNAL_UTM_SOURCE_MAP,
|
||||
storageKey = CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY,
|
||||
queryKeys = CREATE_APP_EXTERNAL_ATTRIBUTION_QUERY_KEYS,
|
||||
) => {
|
||||
try {
|
||||
if (typeof window === 'undefined' || !window.sessionStorage)
|
||||
return
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const rawSource = searchParams.get('utm_source')
|
||||
|
||||
if (!rawSource)
|
||||
return
|
||||
|
||||
const normalizedSource = rawSource.trim().toLowerCase()
|
||||
const mappedSource = sourceMap[normalizedSource as keyof typeof sourceMap]
|
||||
|
||||
if (!mappedSource)
|
||||
return
|
||||
|
||||
const normalizedSlug = searchParams.get('slug')?.trim()
|
||||
const normalizedCampaign = searchParams.get('utm_campaign')?.trim()
|
||||
const utmCampaign = normalizedSlug || normalizedCampaign
|
||||
const attribution = utmCampaign
|
||||
? { utmSource: mappedSource, utmCampaign }
|
||||
: { utmSource: mappedSource }
|
||||
|
||||
window.sessionStorage.setItem(storageKey, JSON.stringify(attribution))
|
||||
|
||||
const nextSearchParams = new URLSearchParams(window.location.search)
|
||||
let hasChanges = false
|
||||
|
||||
queryKeys.forEach((key) => {
|
||||
if (!nextSearchParams.has(key))
|
||||
return
|
||||
|
||||
nextSearchParams.delete(key)
|
||||
hasChanges = true
|
||||
})
|
||||
|
||||
if (!hasChanges)
|
||||
return
|
||||
|
||||
const nextSearch = nextSearchParams.toString()
|
||||
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}`
|
||||
|
||||
try {
|
||||
window.history.replaceState(window.history.state, '', nextUrl)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
export const buildCreateAppAttributionBootstrapScript = () => {
|
||||
return `(${runCreateAppAttributionBootstrap.toString()})(${serializeBootstrapValue(EXTERNAL_UTM_SOURCE_MAP)}, ${serializeBootstrapValue(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY)}, ${serializeBootstrapValue(CREATE_APP_EXTERNAL_ATTRIBUTION_QUERY_KEYS)});`
|
||||
}
|
||||
|
||||
export const extractExternalCreateAppAttribution = ({
|
||||
searchParams,
|
||||
utmInfo,
|
||||
}: {
|
||||
searchParams?: SearchParamReader | null
|
||||
utmInfo?: Record<string, unknown> | null
|
||||
}) => {
|
||||
const rawSource = getSearchParamValue(searchParams, 'utm_source') ?? getObjectStringValue(utmInfo?.utm_source)
|
||||
const mappedSource = mapExternalUtmSource(rawSource)
|
||||
|
||||
if (!mappedSource)
|
||||
return null
|
||||
|
||||
const utmCampaign = getSearchParamValue(searchParams, 'slug')
|
||||
?? getSearchParamValue(searchParams, 'utm_campaign')
|
||||
?? getObjectStringValue(utmInfo?.slug)
|
||||
?? getObjectStringValue(utmInfo?.utm_campaign)
|
||||
|
||||
return {
|
||||
utmSource: mappedSource,
|
||||
...(utmCampaign ? { utmCampaign } : {}),
|
||||
} satisfies ExternalCreateAppAttribution
|
||||
}
|
||||
|
||||
const readRememberedExternalCreateAppAttribution = (): ExternalCreateAppAttribution | null => {
|
||||
return parseJSONRecord(window.sessionStorage.getItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY)) as ExternalCreateAppAttribution | null
|
||||
}
|
||||
|
||||
const writeRememberedExternalCreateAppAttribution = (attribution: ExternalCreateAppAttribution) => {
|
||||
window.sessionStorage.setItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY, JSON.stringify(attribution))
|
||||
}
|
||||
|
||||
const clearRememberedExternalCreateAppAttribution = () => {
|
||||
window.sessionStorage.removeItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export const rememberCreateAppExternalAttribution = ({
|
||||
searchParams,
|
||||
utmInfo,
|
||||
}: {
|
||||
searchParams?: SearchParamReader | null
|
||||
utmInfo?: Record<string, unknown> | null
|
||||
} = {}) => {
|
||||
const attribution = extractExternalCreateAppAttribution({
|
||||
searchParams,
|
||||
utmInfo: utmInfo ?? getCookieUtmInfo(),
|
||||
})
|
||||
|
||||
if (attribution && typeof window !== 'undefined')
|
||||
writeRememberedExternalCreateAppAttribution(attribution)
|
||||
|
||||
return attribution
|
||||
}
|
||||
|
||||
const resolveCurrentExternalCreateAppAttribution = () => {
|
||||
if (typeof window === 'undefined')
|
||||
return null
|
||||
|
||||
return readRememberedExternalCreateAppAttribution()
|
||||
}
|
||||
|
||||
export const buildCreateAppEventPayload = (
|
||||
params: TrackCreateAppParams,
|
||||
externalAttribution?: ExternalCreateAppAttribution | null,
|
||||
currentTime = new Date(),
|
||||
) => {
|
||||
if (externalAttribution) {
|
||||
return {
|
||||
source: 'external',
|
||||
utm_source: externalAttribution.utmSource,
|
||||
...(externalAttribution.utmCampaign ? { utm_campaign: externalAttribution.utmCampaign } : {}),
|
||||
} satisfies Record<string, string>
|
||||
}
|
||||
|
||||
return {
|
||||
source: 'original',
|
||||
app_mode: mapOriginalCreateAppMode(params.appMode),
|
||||
time: formatCreateAppTime(currentTime),
|
||||
} satisfies Record<string, string>
|
||||
}
|
||||
|
||||
export const trackCreateApp = (params: TrackCreateAppParams) => {
|
||||
const externalAttribution = resolveCurrentExternalCreateAppAttribution()
|
||||
const payload = buildCreateAppEventPayload(params, externalAttribution)
|
||||
|
||||
if (externalAttribution)
|
||||
clearRememberedExternalCreateAppAttribution()
|
||||
|
||||
trackEvent('create_app', payload)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user