feat: enhance banner tracking with impression and click events (#33926)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
Coding On Star 2026-03-23 17:29:50 +08:00 committed by GitHub
parent fdc880bc67
commit 2c8322c7b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 213 additions and 203 deletions

View File

@ -1,3 +1,4 @@
import type { ComponentProps } from 'react'
import type { Banner } from '@/models/app'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@ -5,6 +6,11 @@ import { BannerItem } from '../banner-item'
const mockScrollTo = vi.fn()
const mockSlideNodes = vi.fn()
const mockTrackEvent = vi.fn()
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
vi.mock('@/app/components/base/carousel', () => ({
useCarousel: () => ({
@ -48,19 +54,34 @@ class MockResizeObserver {
}
}
const renderBannerItem = (
banner: Banner = createMockBanner(),
props: Partial<ComponentProps<typeof BannerItem>> = {},
) => {
return render(
<BannerItem
banner={banner}
autoplayDelay={5000}
sort={1}
language="en-US"
{...props}
/>,
)
}
describe('BannerItem', () => {
let mockWindowOpen: ReturnType<typeof vi.spyOn>
beforeEach(() => {
mockWindowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
mockSlideNodes.mockReturnValue([{}, {}, {}]) // 3 slides
mockSlideNodes.mockReturnValue([{}, {}, {}])
vi.stubGlobal('ResizeObserver', MockResizeObserver)
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1400, // Above RESPONSIVE_BREAKPOINT (1200)
value: 1400,
})
})
@ -73,81 +94,51 @@ describe('BannerItem', () => {
describe('basic rendering', () => {
it('renders banner category', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
renderBannerItem()
expect(screen.getByText('Featured')).toBeInTheDocument()
})
it('renders banner title', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
renderBannerItem()
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
})
it('renders banner description', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
renderBannerItem()
expect(screen.getByText('Test banner description text')).toBeInTheDocument()
})
it('renders banner image with correct src and alt', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
renderBannerItem()
const image = screen.getByRole('img')
expect(image).toHaveAttribute('src', 'https://example.com/image.png')
expect(image).toHaveAttribute('alt', 'Test Banner Title')
})
it('renders view more text', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
renderBannerItem()
expect(screen.getByText('explore.banner.viewMore')).toBeInTheDocument()
})
})
describe('click handling', () => {
it('opens banner link in new tab when clicked', () => {
it('opens banner link in new tab and tracks click when clicked', () => {
const banner = createMockBanner({ link: 'https://test-link.com' })
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
renderBannerItem(banner, { sort: 2, language: 'zh-Hans', accountId: 'account-123' })
const bannerElement = screen.getByText('Test Banner Title').closest('div[class*="cursor-pointer"]')
fireEvent.click(bannerElement!)
expect(mockTrackEvent).toHaveBeenCalledWith('explore_banner_click', expect.objectContaining({
banner_id: 'banner-1',
title: 'Test Banner Title',
sort: 2,
link: 'https://test-link.com',
page: 'explore',
language: 'zh-Hans',
account_id: 'account-123',
event_time: expect.any(Number),
}))
expect(mockWindowOpen).toHaveBeenCalledWith(
'https://test-link.com',
'_blank',
@ -155,18 +146,16 @@ describe('BannerItem', () => {
)
})
it('does not open window when banner has no link', () => {
it('tracks click even when banner has no link', () => {
const banner = createMockBanner({ link: '' })
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
renderBannerItem(banner)
const bannerElement = screen.getByText('Test Banner Title').closest('div[class*="cursor-pointer"]')
fireEvent.click(bannerElement!)
expect(mockTrackEvent).toHaveBeenCalledWith('explore_banner_click', expect.objectContaining({
link: '',
}))
expect(mockWindowOpen).not.toHaveBeenCalled()
})
})
@ -174,28 +163,13 @@ describe('BannerItem', () => {
describe('slide indicators', () => {
it('renders correct number of indicator buttons', () => {
mockSlideNodes.mockReturnValue([{}, {}, {}])
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(3)
renderBannerItem()
expect(screen.getAllByRole('button')).toHaveLength(3)
})
it('renders indicator buttons with correct numbers', () => {
mockSlideNodes.mockReturnValue([{}, {}, {}])
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
renderBannerItem()
expect(screen.getByText('01')).toBeInTheDocument()
expect(screen.getByText('02')).toBeInTheDocument()
expect(screen.getByText('03')).toBeInTheDocument()
@ -203,13 +177,7 @@ describe('BannerItem', () => {
it('calls scrollTo when indicator is clicked', () => {
mockSlideNodes.mockReturnValue([{}, {}, {}])
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
renderBannerItem()
const secondIndicator = screen.getByText('02').closest('button')
fireEvent.click(secondIndicator!)
@ -219,81 +187,39 @@ describe('BannerItem', () => {
it('renders no indicators when no slides', () => {
mockSlideNodes.mockReturnValue([])
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
renderBannerItem()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
describe('isPaused prop', () => {
it('defaults isPaused to false', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
renderBannerItem()
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
})
it('accepts isPaused prop', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
isPaused={true}
/>,
)
renderBannerItem(createMockBanner(), { isPaused: true })
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
})
})
describe('responsive behavior', () => {
it('sets up ResizeObserver on mount', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
renderBannerItem()
expect(mockResizeObserverObserve).toHaveBeenCalled()
})
it('adds resize event listener on mount', () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
renderBannerItem()
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
addEventListenerSpy.mockRestore()
})
it('removes resize event listener on unmount', () => {
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
const banner = createMockBanner()
const { unmount } = render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const { unmount } = renderBannerItem()
unmount()
@ -308,14 +234,7 @@ describe('BannerItem', () => {
value: 1000,
})
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
renderBannerItem()
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
})
@ -326,14 +245,7 @@ describe('BannerItem', () => {
value: 800,
})
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
renderBannerItem()
expect(screen.getByText('explore.banner.viewMore')).toBeInTheDocument()
})
})
@ -348,13 +260,8 @@ describe('BannerItem', () => {
'img-src': 'https://example.com/img.png',
},
} as Partial<Banner>)
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
renderBannerItem(banner)
expect(screen.getByText('Very Long Category Name')).toBeInTheDocument()
})
@ -367,13 +274,8 @@ describe('BannerItem', () => {
'img-src': 'https://example.com/img.png',
},
} as Partial<Banner>)
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
renderBannerItem(banner)
const titleElement = screen.getByText('A Very Long Title That Should Be Truncated Eventually')
expect(titleElement).toHaveClass('line-clamp-2')
})
@ -387,13 +289,8 @@ describe('BannerItem', () => {
'img-src': 'https://example.com/img.png',
},
} as Partial<Banner>)
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
renderBannerItem(banner)
const descriptionElement = screen.getByText(/A very long description/)
expect(descriptionElement).toHaveClass('line-clamp-4')
})
@ -402,56 +299,26 @@ describe('BannerItem', () => {
describe('slide calculation', () => {
it('calculates next index correctly for first slide', () => {
mockSlideNodes.mockReturnValue([{}, {}, {}])
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(3)
renderBannerItem()
expect(screen.getAllByRole('button')).toHaveLength(3)
})
it('handles single slide case', () => {
mockSlideNodes.mockReturnValue([{}])
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(1)
renderBannerItem()
expect(screen.getAllByRole('button')).toHaveLength(1)
})
})
describe('wrapper styling', () => {
it('has cursor-pointer class', () => {
const banner = createMockBanner()
const { container } = render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const { container } = renderBannerItem()
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('cursor-pointer')
})
it('has rounded-2xl class', () => {
const banner = createMockBanner()
const { container } = render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const { container } = renderBannerItem()
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('rounded-2xl')
})

View File

@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Banner from '../banner'
const mockUseGetBanners = vi.fn()
const mockUseSelector = vi.fn()
const mockTrackEvent = vi.fn()
vi.mock('@/service/use-explore', () => ({
useGetBanners: (...args: unknown[]) => mockUseGetBanners(...args),
@ -15,6 +17,14 @@ vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
vi.mock('@/context/app-context', () => ({
useSelector: (...args: unknown[]) => mockUseSelector(...args),
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
vi.mock('@/app/components/base/carousel', () => ({
Carousel: Object.assign(
({ children, onMouseEnter, onMouseLeave, className }: {
@ -54,9 +64,12 @@ vi.mock('@/app/components/base/carousel', () => ({
}))
vi.mock('../banner-item', () => ({
BannerItem: ({ banner, autoplayDelay, isPaused }: {
BannerItem: ({ banner, autoplayDelay, isPaused, sort, language, accountId }: {
banner: BannerType
autoplayDelay: number
sort: number
language: string
accountId?: string
isPaused?: boolean
}) => (
<div
@ -64,6 +77,9 @@ vi.mock('../banner-item', () => ({
data-banner-id={banner.id}
data-autoplay-delay={autoplayDelay}
data-is-paused={isPaused}
data-sort={sort}
data-language={language}
data-account-id={accountId}
>
BannerItem:
{' '}
@ -87,6 +103,11 @@ const createMockBanner = (id: string, status: string = 'enabled', title: string
describe('Banner', () => {
beforeEach(() => {
vi.useFakeTimers()
mockUseSelector.mockImplementation(selector => selector({
userProfile: {
id: 'account-123',
},
}))
})
afterEach(() => {
@ -235,6 +256,59 @@ describe('Banner', () => {
expect(screen.getByTestId('carousel')).toHaveClass('rounded-2xl')
})
it('tracks enabled banner impressions with expected payload', () => {
mockUseGetBanners.mockReturnValue({
data: [
createMockBanner('1', 'enabled', 'Enabled Banner 1'),
createMockBanner('2', 'disabled', 'Disabled Banner'),
createMockBanner('3', 'enabled', 'Enabled Banner 2'),
],
isLoading: false,
isError: false,
})
render(<Banner />)
expect(mockTrackEvent).toHaveBeenCalledTimes(2)
expect(mockTrackEvent).toHaveBeenNthCalledWith(1, 'explore_banner_impression', expect.objectContaining({
banner_id: '1',
title: 'Enabled Banner 1',
sort: 1,
link: 'https://example.com',
page: 'explore',
language: 'en-US',
account_id: 'account-123',
event_time: expect.any(Number),
}))
expect(mockTrackEvent).toHaveBeenNthCalledWith(2, 'explore_banner_impression', expect.objectContaining({
banner_id: '3',
title: 'Enabled Banner 2',
sort: 2,
link: 'https://example.com',
page: 'explore',
language: 'en-US',
account_id: 'account-123',
event_time: expect.any(Number),
}))
})
it('does not track impressions when account id is unavailable', () => {
mockUseSelector.mockImplementation(selector => selector({
userProfile: {
id: '',
},
}))
mockUseGetBanners.mockReturnValue({
data: [createMockBanner('1', 'enabled', 'Enabled Banner 1')],
isLoading: false,
isError: false,
})
render(<Banner />)
expect(mockTrackEvent).not.toHaveBeenCalled()
})
})
describe('hover behavior', () => {
@ -435,8 +509,25 @@ describe('Banner', () => {
const bannerItems = screen.getAllByTestId('banner-item')
expect(bannerItems[0]).toHaveAttribute('data-banner-id', '1')
expect(bannerItems[0]).toHaveAttribute('data-sort', '1')
expect(bannerItems[1]).toHaveAttribute('data-banner-id', '2')
expect(bannerItems[1]).toHaveAttribute('data-sort', '2')
expect(bannerItems[2]).toHaveAttribute('data-banner-id', '3')
expect(bannerItems[2]).toHaveAttribute('data-sort', '3')
})
it('passes tracking context to banner item', () => {
mockUseGetBanners.mockReturnValue({
data: [createMockBanner('1', 'enabled', 'Banner 1')],
isLoading: false,
isError: false,
})
render(<Banner />)
const bannerItem = screen.getByTestId('banner-item')
expect(bannerItem).toHaveAttribute('data-language', 'en-US')
expect(bannerItem).toHaveAttribute('data-account-id', 'account-123')
})
})

View File

@ -4,6 +4,7 @@ import type { Banner } from '@/models/app'
import { RiArrowRightLine } from '@remixicon/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import { useCarousel } from '@/app/components/base/carousel'
import { cn } from '@/utils/classnames'
import { IndicatorButton } from './indicator-button'
@ -11,6 +12,9 @@ import { IndicatorButton } from './indicator-button'
type BannerItemProps = {
banner: Banner
autoplayDelay: number
sort: number
language: string
accountId?: string
isPaused?: boolean
}
@ -20,7 +24,14 @@ const INDICATOR_WIDTH = 20
const INDICATOR_GAP = 8
const MIN_VIEW_MORE_WIDTH = 480
export const BannerItem: FC<BannerItemProps> = ({ banner, autoplayDelay, isPaused = false }) => {
export const BannerItem: FC<BannerItemProps> = ({
banner,
autoplayDelay,
sort,
language,
accountId,
isPaused = false,
}) => {
const { t } = useTranslation()
const { api, selectedIndex } = useCarousel()
const { category, title, description, 'img-src': imgSrc } = banner.content
@ -91,9 +102,21 @@ export const BannerItem: FC<BannerItemProps> = ({ banner, autoplayDelay, isPause
const handleBannerClick = useCallback(() => {
incrementResetKey()
trackEvent('explore_banner_click', {
banner_id: banner.id,
title: banner.content.title,
sort,
link: banner.link,
page: 'explore',
language,
account_id: accountId,
event_time: Date.now(),
})
if (banner.link)
window.open(banner.link, '_blank', 'noopener,noreferrer')
}, [banner.link, incrementResetKey])
}, [accountId, banner, incrementResetKey, language, sort])
const handleIndicatorClick = useCallback((index: number) => {
incrementResetKey()

View File

@ -1,7 +1,9 @@
import type { FC } from 'react'
import * as React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { trackEvent } from '@/app/components/base/amplitude'
import { Carousel } from '@/app/components/base/carousel'
import { useSelector } from '@/context/app-context'
import { useLocale } from '@/context/i18n'
import { useGetBanners } from '@/service/use-explore'
import Loading from '../../base/loading'
@ -23,9 +25,11 @@ const LoadingState: FC = () => (
const Banner: FC = () => {
const locale = useLocale()
const { data: banners, isLoading, isError } = useGetBanners(locale)
const accountId = useSelector(s => s.userProfile.id)
const [isHovered, setIsHovered] = useState(false)
const [isResizing, setIsResizing] = useState(false)
const resizeTimerRef = useRef<NodeJS.Timeout | null>(null)
const trackedBannerIdsRef = useRef<Set<string>>(new Set())
const enabledBanners = useMemo(
() => banners?.filter(banner => banner.status === 'enabled') ?? [],
@ -56,6 +60,28 @@ const Banner: FC = () => {
}
}, [])
useEffect(() => {
if (!accountId)
return
enabledBanners.forEach((banner, index) => {
if (trackedBannerIdsRef.current.has(banner.id))
return
trackEvent('explore_banner_impression', {
banner_id: banner.id,
title: banner.content.title,
sort: index + 1,
link: banner.link,
page: 'explore',
language: locale,
account_id: accountId,
event_time: Date.now(),
})
trackedBannerIdsRef.current.add(banner.id)
})
}, [accountId, enabledBanners, locale])
if (isLoading)
return <LoadingState />
@ -77,12 +103,15 @@ const Banner: FC = () => {
onMouseLeave={() => setIsHovered(false)}
>
<Carousel.Content>
{enabledBanners.map(banner => (
{enabledBanners.map((banner, index) => (
<Carousel.Item key={banner.id}>
<BannerItem
banner={banner}
autoplayDelay={AUTOPLAY_DELAY}
isPaused={isPaused}
sort={index + 1}
language={locale}
accountId={accountId}
/>
</Carousel.Item>
))}