Skip to content

Commit 8c400fb

Browse files
authored
feat: add partner attribution and desktop-vs-store split for /download (#654)
* feat: add partner attribution and desktop-vs-store split for /download * fix: harden download attribution paths per PR review
1 parent 073d78a commit 8c400fb

23 files changed

Lines changed: 1216 additions & 202 deletions

src/components/DownloadOptions/DownloadOptions.spec.tsx

Lines changed: 301 additions & 99 deletions
Large diffs are not rendered by default.

src/components/DownloadOptions/DownloadOptions.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import { useDownloadClick } from '../../hooks/useDownloadClick'
77
import { useGetIdentityId } from '../../hooks/useGetIdentityId'
88
import appleLogo from '../../images/apple-logo.svg'
99
import microsoftLogo from '../../images/microsoft-logo.svg'
10+
import { collectCampaignParams } from '../../modules/campaignParams'
1011
import { DOWNLOAD_URLS } from '../../modules/downloadConstants'
1112
import { getDownloadLinkWithIdentity } from '../../modules/downloadWithIdentity'
1213
import { ExplorerDownloads } from '../../modules/explorerDownloads'
1314
import { formatToShorthand } from '../../modules/number'
14-
import { DownloadPlace, SectionViewedTrack, SegmentEvent } from '../../modules/segment'
15+
import { DownloadPlace, DownloadTarget, SectionViewedTrack, SegmentEvent } from '../../modules/segment'
1516
import { buildDownloadSuccessHref, sanitizeCDNReleaseLinks } from '../../modules/url'
1617
import { Architecture, DownloadOptionProps, OperativeSystem } from '../../types/download.types'
1718
import { assetUrl } from '../../utils/assetUrl'
@@ -143,9 +144,14 @@ const DownloadOptions = memo(({ hideDownloadCounts, downloadOnClick }: DownloadO
143144
})
144145
}
145146

147+
// Forward the partner campaign params into /download_success (through
148+
// `buildDownloadSuccessHref`) so the desktop installer funnel
149+
// (download_started/_success/_failed) carries the same attribution the
150+
// landing click had.
146151
const finalUrl = buildDownloadSuccessHref(option.text, DownloadPlace.DOWNLOAD_PAGE, {
147152
anonUserId,
148-
arch: option.arch
153+
arch: option.arch,
154+
campaignParams: collectCampaignParams()
149155
})
150156
setTimeout(
151157
() => {
@@ -173,6 +179,7 @@ const DownloadOptions = memo(({ hideDownloadCounts, downloadOnClick }: DownloadO
173179
href={option.link}
174180
data-place={SectionViewedTrack.DOWNLOAD}
175181
data-event={SegmentEvent.DOWNLOAD}
182+
data-download-target={DownloadTarget.DESKTOP_INSTALLER}
176183
onClick={event => {
177184
event.preventDefault()
178185
trackDownloadClick(event)
@@ -184,12 +191,16 @@ const DownloadOptions = memo(({ hideDownloadCounts, downloadOnClick }: DownloadO
184191
</DownloadButton>
185192
) : null
186193
)}
194+
{/* Epic delivers the same desktop client, just via the Epic Games
195+
Store — it sits on the desktop side of the split (not a mobile
196+
store exit), so it shares the desktop_installer target. */}
187197
<EpicButton
188198
href={EPIC_GAMES_URL}
189199
target="_blank"
190200
rel="noopener noreferrer"
191201
data-place={DownloadPlace.DOWNLOAD_PAGE}
192202
data-event={SegmentEvent.DOWNLOAD}
203+
data-download-target={DownloadTarget.DESKTOP_INSTALLER}
193204
onClick={trackDownloadClick}
194205
>
195206
{l('page.download.download_on')}
@@ -207,6 +218,9 @@ const DownloadOptions = memo(({ hideDownloadCounts, downloadOnClick }: DownloadO
207218
<AlternativeButton
208219
variant="text"
209220
color="inherit"
221+
data-place={DownloadPlace.DOWNLOAD_PAGE}
222+
data-event={SegmentEvent.DOWNLOAD}
223+
data-download-target={DownloadTarget.DESKTOP_INSTALLER}
210224
onClick={event => {
211225
event.preventDefault()
212226
trackDownloadClick(event)
@@ -218,12 +232,22 @@ const DownloadOptions = memo(({ hideDownloadCounts, downloadOnClick }: DownloadO
218232
startIcon={<AlternativeButtonImage src={option.image} />}
219233
/>
220234
))}
235+
{/* Store badges exit /download to the App Store / Google Play (new
236+
tab), never through /download_success — so they can't produce a
237+
download_started and won't inflate desktop installer activations.
238+
Tracked as store exits so partner attribution still lands.
239+
`useDownloadClick` merges the campaign params from the URL. */}
221240
<AlternativeButton
222241
variant="text"
223242
color="inherit"
224243
href={DOWNLOAD_URLS.appStore}
225244
{...{ target: '_blank', rel: 'noopener noreferrer' }}
226245
aria-label="iOS"
246+
data-place={DownloadPlace.DOWNLOAD_PAGE}
247+
data-event={SegmentEvent.DOWNLOAD}
248+
data-os="iOS"
249+
data-download-target={DownloadTarget.APP_STORE}
250+
onClick={trackDownloadClick}
227251
startIcon={<AlternativeButtonImage src={assetUrl('/ios-logo.svg')} />}
228252
/>
229253
<AlternativeButton
@@ -232,6 +256,11 @@ const DownloadOptions = memo(({ hideDownloadCounts, downloadOnClick }: DownloadO
232256
href={GOOGLE_PLAY_DESKTOP_URL}
233257
{...{ target: '_blank', rel: 'noopener noreferrer' }}
234258
aria-label="Google Play"
259+
data-place={DownloadPlace.DOWNLOAD_PAGE}
260+
data-event={SegmentEvent.DOWNLOAD}
261+
data-os="Android"
262+
data-download-target={DownloadTarget.GOOGLE_PLAY}
263+
onClick={trackDownloadClick}
235264
startIcon={<AlternativeButtonImage src={assetUrl('/google_play_icon.svg')} />}
236265
/>
237266
</AlternativeButtonsWrapper>

src/components/Home/ComeHangOut/ComeHangOut.spec.tsx

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { fireEvent, render, screen } from '@testing-library/react'
22
import { useAdvancedUserAgentData, useAsyncMemo } from '@dcl/hooks'
3-
import { useAnonUserId } from '../../../hooks/useAnonUserId'
43
import { useDownloadClick } from '../../../hooks/useDownloadClick'
4+
import { useDownloadSuccessHref } from '../../../hooks/useDownloadSuccessHref'
55
import { useHangOutAction } from '../../../hooks/useHangOutAction'
66
import { DownloadPlace, SectionViewedTrack, SegmentEvent } from '../../../modules/segment'
77
import { ComeHangOut } from './ComeHangOut'
@@ -39,10 +39,11 @@ jest.mock('../../../hooks/useDownloadClick', () => ({
3939
useDownloadClick: jest.fn()
4040
}))
4141

42-
jest.mock('../../../hooks/useAnonUserId', () => ({
43-
ANON_USER_ID_PARAM: 'anon_user_id',
44-
useAnonUserId: jest.fn(() => undefined)
45-
}))
42+
// The hook's own UTM-forwarding / anon_user_id behavior is covered by
43+
// useDownloadSuccessHref.spec.ts — mock it here as a plain (os, place) => href
44+
// builder so this spec only asserts ComeHangOut's wiring (which CTAs call it,
45+
// what data-* attributes they carry).
46+
jest.mock('../../../hooks/useDownloadSuccessHref', () => ({ useDownloadSuccessHref: jest.fn() }))
4647

4748
jest.mock('../../../hooks/useHangOutAction', () => ({
4849
useHangOutAction: jest.fn()
@@ -56,30 +57,29 @@ jest.mock('../../Icon/VerifiedIcon', () => ({
5657
VerifiedIcon: () => <span data-testid="verified-icon" />
5758
}))
5859

59-
jest.mock('../../../modules/url', () => ({
60-
buildDownloadSuccessHref: (os: string, place: string, options?: { anonUserId?: string }) =>
61-
`/download_success?os=${os}&place=${place}${options?.anonUserId ? `&anon_user_id=${options.anonUserId}` : ''}`
62-
}))
63-
6460
const mockUserAgent = jest.mocked(useAdvancedUserAgentData)
6561
const mockAsyncMemo = jest.mocked(useAsyncMemo)
6662
const mockDownloadClick = jest.mocked(useDownloadClick)
63+
const mockDownloadSuccessHref = jest.mocked(useDownloadSuccessHref)
6764
const mockHangOut = jest.mocked(useHangOutAction)
68-
const mockAnonUserId = jest.mocked(useAnonUserId)
6965

7066
const trackDownloadClick = jest.fn()
67+
const downloadSuccessHref = jest.fn()
7168

7269
describe('ComeHangOut', () => {
7370
beforeEach(() => {
7471
mockDownloadClick.mockReturnValue(trackDownloadClick)
72+
// Re-armed every test: jest.resetAllMocks() in afterEach wipes the
73+
// implementation, not just the return value.
74+
downloadSuccessHref.mockImplementation((os: string, place: string) => `/download_success?os=${os}&place=${place}`)
75+
mockDownloadSuccessHref.mockReturnValue(downloadSuccessHref)
7576
mockHangOut.mockReturnValue({
7677
isDownloadModalOpen: false,
7778
closeDownloadModal: jest.fn(),
7879
downloadModalProps: {},
7980
totalDownloads: '+400K'
8081
} as unknown as ReturnType<typeof useHangOutAction>)
8182
mockAsyncMemo.mockReturnValue([500000, { loading: false, loaded: true }] as unknown as ReturnType<typeof useAsyncMemo>)
82-
mockAnonUserId.mockReturnValue(undefined)
8383
})
8484

8585
afterEach(() => {
@@ -126,21 +126,38 @@ describe('ComeHangOut', () => {
126126
expect(trackDownloadClick).toHaveBeenCalledTimes(1)
127127
})
128128

129-
it('should bake anon_user_id into the download and platform-switch hrefs so attribution survives the redirect', () => {
130-
mockAnonUserId.mockReturnValue('11111111-1111-4111-8111-111111111111')
129+
it('should call the shared useDownloadSuccessHref builder for the main CTA and platform-switch icon', () => {
130+
// UTM/anon_user_id forwarding is a useDownloadSuccessHref concern, covered
131+
// in useDownloadSuccessHref.spec.ts — this asserts ComeHangOut wires
132+
// os/place through to it correctly for both call sites.
131133
render(<ComeHangOut />)
132134

133-
const downloadButton = screen.getByText('page.download.download_for_short').closest('a') as HTMLAnchorElement
134-
expect(downloadButton).toHaveAttribute(
135-
'href',
136-
`/download_success?os=Windows&place=${DownloadPlace.COME_HANG_OUT}&anon_user_id=11111111-1111-4111-8111-111111111111`
137-
)
135+
expect(downloadSuccessHref).toHaveBeenCalledWith('Windows', DownloadPlace.COME_HANG_OUT)
136+
expect(downloadSuccessHref).toHaveBeenCalledWith('macOS', DownloadPlace.COME_HANG_OUT_PLATFORM_SWITCH)
137+
})
138+
139+
it('should preserve campaign params on the /download fallback href when the user agent has not resolved', () => {
140+
mockUserAgent.mockReturnValue([false, undefined] as unknown as ReturnType<typeof useAdvancedUserAgentData>)
141+
window.history.pushState({}, '', '/?utm_source=shefi')
142+
try {
143+
render(<ComeHangOut />)
144+
const downloadButton = screen.getByText('page.download.download_for_short').closest('a') as HTMLAnchorElement
145+
expect(downloadButton).toHaveAttribute('href', '/download?utm_source=shefi')
146+
} finally {
147+
window.history.pushState({}, '', '/')
148+
}
149+
})
150+
151+
it('should tag the main CTA, Epic button, and platform-switch icon as desktop_installer', () => {
152+
render(<ComeHangOut />)
138153

154+
const downloadButton = screen.getByText('page.download.download_for_short').closest('a') as HTMLAnchorElement
155+
const epicButton = screen.getByText('page.download.download_on').closest('a') as HTMLAnchorElement
139156
const macIcon = screen.getByAltText('macOS').closest('a') as HTMLAnchorElement
140-
expect(macIcon).toHaveAttribute(
141-
'href',
142-
`/download_success?os=macOS&place=${DownloadPlace.COME_HANG_OUT_PLATFORM_SWITCH}&anon_user_id=11111111-1111-4111-8111-111111111111`
143-
)
157+
158+
expect(downloadButton).toHaveAttribute('data-download-target', 'desktop_installer')
159+
expect(epicButton).toHaveAttribute('data-download-target', 'desktop_installer')
160+
expect(macIcon).toHaveAttribute('data-download-target', 'desktop_installer')
144161
})
145162

146163
it('should track the iOS QR icon click and still open the QR modal', () => {
@@ -209,6 +226,7 @@ describe('ComeHangOut', () => {
209226
expect(playButton).toHaveAttribute('data-event', SegmentEvent.DOWNLOAD)
210227
expect(playButton).toHaveAttribute('data-os', 'Android')
211228
expect(playButton).toHaveAttribute('data-place', SectionViewedTrack.LANDING_COME_HANG_OUT)
229+
expect(playButton).toHaveAttribute('data-download-target', 'google_play')
212230

213231
fireEvent.click(playButton)
214232

@@ -230,6 +248,7 @@ describe('ComeHangOut', () => {
230248
expect(appStoreButton).toHaveAttribute('data-event', SegmentEvent.DOWNLOAD)
231249
expect(appStoreButton).toHaveAttribute('data-os', 'iOS')
232250
expect(appStoreButton).toHaveAttribute('data-place', SectionViewedTrack.LANDING_COME_HANG_OUT)
251+
expect(appStoreButton).toHaveAttribute('data-download-target', 'app_store')
233252

234253
fireEvent.click(appStoreButton)
235254

src/components/Home/ComeHangOut/ComeHangOut.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,16 @@ import { useAdvancedUserAgentData, useAsyncMemo } from '@dcl/hooks'
33
import { AnimatedBackground, DownloadModal, DownloadQRModal } from 'decentraland-ui2'
44
import { useFormatMessage } from '../../../hooks/adapters/useFormatMessage'
55
import { useAnimatedCounter } from '../../../hooks/useAnimatedCounter'
6-
import { useAnonUserId } from '../../../hooks/useAnonUserId'
76
import { useDownloadClick } from '../../../hooks/useDownloadClick'
7+
import { useDownloadSuccessHref } from '../../../hooks/useDownloadSuccessHref'
88
import { useHangOutAction } from '../../../hooks/useHangOutAction'
99
import appleLogo from '../../../images/apple-logo.svg'
1010
import microsoftLogo from '../../../images/microsoft-logo.svg'
11+
import { withCampaignParams } from '../../../modules/campaignParams'
1112
import { DOWNLOAD_URLS } from '../../../modules/downloadConstants'
1213
import { ExplorerDownloads } from '../../../modules/explorerDownloads'
1314
import { formatToShorthand } from '../../../modules/number'
14-
import { DownloadPlace, SectionViewedTrack, SegmentEvent } from '../../../modules/segment'
15-
import { buildDownloadSuccessHref } from '../../../modules/url'
15+
import { DownloadPlace, DownloadTarget, SectionViewedTrack, SegmentEvent } from '../../../modules/segment'
1616
import { OperativeSystem } from '../../../types/download.types'
1717
import { assetUrl } from '../../../utils/assetUrl'
1818
import { VerifiedIcon } from '../../Icon/VerifiedIcon'
@@ -37,12 +37,10 @@ let cachedDownloadCounts: string | null = null
3737
const ComeHangOut = memo(() => {
3838
const l = useFormatMessage()
3939
const trackDownloadClick = useDownloadClick()
40-
const anonUserId = useAnonUserId()
4140
const { isDownloadModalOpen, closeDownloadModal, downloadModalProps, totalDownloads } = useHangOutAction()
4241
const [, userAgentData] = useAdvancedUserAgentData()
4342
const [rawDownloads, rawDownloadsStatus] = useAsyncMemo(async () => ExplorerDownloads.get().getTotalDownloads(), [])
44-
45-
const downloadSuccessHref = useCallback((os: string, place: string) => buildDownloadSuccessHref(os, place, { anonUserId }), [anonUserId])
43+
const downloadSuccessHref = useDownloadSuccessHref()
4644

4745
const targetDownloads = !rawDownloadsStatus.loading && rawDownloadsStatus.loaded && rawDownloads ? rawDownloads : null
4846
if (targetDownloads) cachedDownloadCounts = formatToShorthand(targetDownloads)
@@ -66,6 +64,7 @@ const ComeHangOut = memo(() => {
6664
data-event={SegmentEvent.DOWNLOAD}
6765
data-os="Android"
6866
data-place={SectionViewedTrack.LANDING_COME_HANG_OUT}
67+
data-download-target={DownloadTarget.GOOGLE_PLAY}
6968
onClick={trackDownloadClick}
7069
>
7170
<GooglePlayImage src={googlePlayBadge} alt="Get it on Google Play" />
@@ -81,6 +80,7 @@ const ComeHangOut = memo(() => {
8180
data-event={SegmentEvent.DOWNLOAD}
8281
data-os="iOS"
8382
data-place={SectionViewedTrack.LANDING_COME_HANG_OUT}
83+
data-download-target={DownloadTarget.APP_STORE}
8484
onClick={trackDownloadClick}
8585
>
8686
<GooglePlayImage src={assetUrl('/download-on-the-app-store.svg')} alt="Download on the App Store" />
@@ -106,9 +106,10 @@ const ComeHangOut = memo(() => {
106106
<>
107107
<div style={{ display: 'flex', gap: 24, justifyContent: 'center' }}>
108108
<DownloadButton
109-
href={userAgentData ? downloadSuccessHref(userAgentData.os.name, DownloadPlace.COME_HANG_OUT) : '/download'}
109+
href={userAgentData ? downloadSuccessHref(userAgentData.os.name, DownloadPlace.COME_HANG_OUT) : withCampaignParams('/download')}
110110
data-place={DownloadPlace.COME_HANG_OUT}
111111
data-event={SegmentEvent.CLICK}
112+
data-download-target={DownloadTarget.DESKTOP_INSTALLER}
112113
onClick={handleDownloadClick}
113114
>
114115
{l('page.download.download_for_short')}
@@ -124,6 +125,7 @@ const ComeHangOut = memo(() => {
124125
rel="noopener noreferrer"
125126
data-event={SegmentEvent.DOWNLOAD}
126127
data-place={DownloadPlace.COME_HANG_OUT}
128+
data-download-target={DownloadTarget.DESKTOP_INSTALLER}
127129
onClick={trackDownloadClick}
128130
>
129131
{l('page.download.download_on')}
@@ -142,6 +144,7 @@ const ComeHangOut = memo(() => {
142144
data-event={SegmentEvent.DOWNLOAD}
143145
data-os={OperativeSystem.WINDOWS}
144146
data-place={DownloadPlace.COME_HANG_OUT_PLATFORM_SWITCH}
147+
data-download-target={DownloadTarget.DESKTOP_INSTALLER}
145148
onClick={trackDownloadClick}
146149
>
147150
<PlatformIcon src={microsoftLogo} alt="Windows" />
@@ -153,6 +156,7 @@ const ComeHangOut = memo(() => {
153156
data-event={SegmentEvent.DOWNLOAD}
154157
data-os={OperativeSystem.MACOS}
155158
data-place={DownloadPlace.COME_HANG_OUT_PLATFORM_SWITCH}
159+
data-download-target={DownloadTarget.DESKTOP_INSTALLER}
156160
onClick={trackDownloadClick}
157161
>
158162
<PlatformIcon src={appleLogo} alt="macOS" />

0 commit comments

Comments
 (0)