Skip to content

Commit 5eeb014

Browse files
authored
fix: carry incoming campaign attribution into the Google Play Store link (#678)
* fix: carry incoming campaign attribution into the Google Play Store link * fix: mirror campaign attribution into the Play Install Referrer param
1 parent fce9f4f commit 5eeb014

12 files changed

Lines changed: 161 additions & 28 deletions

File tree

src/components/DownloadOptions/DownloadOptions.spec.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { DOWNLOAD_URLS } from '../../modules/downloadConstants'
66
import { getDownloadLinkWithIdentity } from '../../modules/downloadWithIdentity'
77
import { postSegmentEvent } from '../../modules/segmentBeacon'
88
import type { DownloadOptionProps } from '../../types/download.types'
9-
import { GOOGLE_PLAY_DESKTOP_URL } from '../Home/shared/googlePlay'
109
import { DownloadOptions, handleDownloadOptionClick } from './DownloadOptions'
1110

1211
// Keep ../../modules/url REAL so the actual /download_success URL is built and
@@ -253,7 +252,7 @@ describe('DownloadOptions', () => {
253252
const googlePlay = screen.getByLabelText('Google Play')
254253
expect(googlePlay).toHaveAttribute('data-download-target', 'google_play')
255254
expect(googlePlay).toHaveAttribute('data-os', 'Android')
256-
expect(googlePlay).toHaveAttribute('href', GOOGLE_PLAY_DESKTOP_URL)
255+
expect(googlePlay).toHaveAttribute('href', DOWNLOAD_URLS.googlePlay)
257256
})
258257

259258
it('should fire the tracker when a store badge is clicked (unload-safe path handled by useDownloadClick)', async () => {

src/components/DownloadOptions/DownloadOptions.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import { Architecture, DownloadOptionProps, OperativeSystem } from '../../types/
2020
import { assetUrl } from '../../utils/assetUrl'
2121
import { DownloadButton, EpicButton } from '../Home/Hero/Hero.styled'
2222
import { EPIC_GAMES_URL } from '../Home/shared/epicGames'
23-
import { GOOGLE_PLAY_DESKTOP_URL } from '../Home/shared/googlePlay'
2423
import { VerifiedIcon } from '../Icon/VerifiedIcon'
2524
import {
2625
AlternativeButton,
@@ -302,7 +301,7 @@ const DownloadOptions = memo(({ hideDownloadCounts, downloadOnClick }: DownloadO
302301
<AlternativeButton
303302
variant="text"
304303
color="inherit"
305-
href={GOOGLE_PLAY_DESKTOP_URL}
304+
href={DOWNLOAD_URLS.googlePlay}
306305
{...{ target: '_blank', rel: 'noopener noreferrer' }}
307306
aria-label="Google Play"
308307
data-place={DownloadPlace.DOWNLOAD_PAGE}

src/components/Home/ComeHangOut/ComeHangOut.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { OperativeSystem } from '../../../types/download.types'
1717
import { assetUrl } from '../../../utils/assetUrl'
1818
import { VerifiedIcon } from '../../Icon/VerifiedIcon'
1919
import { DownloadButton, EpicButton } from '../Hero/Hero.styled'
20-
import { GOOGLE_PLAY_MOBILE_URL, googlePlayBadge } from '../shared/googlePlay'
20+
import { googlePlayBadge } from '../shared/googlePlay'
2121
import {
2222
AvatarsImage,
2323
ComeHangOutContainer,
@@ -58,7 +58,7 @@ const ComeHangOut = memo(() => {
5858
if (isMobileAndroid) {
5959
return (
6060
<GooglePlayButton
61-
href={GOOGLE_PLAY_MOBILE_URL}
61+
href={DOWNLOAD_URLS.googlePlay}
6262
target="_blank"
6363
rel="noopener noreferrer"
6464
data-event={SegmentEvent.DOWNLOAD}

src/components/Home/Hero/Hero.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { OperativeSystem } from '../../../types/download.types'
1818
import { assetUrl } from '../../../utils/assetUrl'
1919
import { type ScheduledHandle, cancelScheduledIdleCall, scheduleWhenIdle } from '../../../utils/scheduleWhenIdle'
2020
import { VerifiedIcon } from '../../Icon/VerifiedIcon'
21-
import { GOOGLE_PLAY_MOBILE_URL, googlePlayBadge } from '../shared/googlePlay'
21+
import { googlePlayBadge } from '../shared/googlePlay'
2222
import {
2323
DownloadButton,
2424
EpicButton,
@@ -204,7 +204,7 @@ const Hero = memo(({ isDesktop }: { isDesktop: boolean }) => {
204204
<MobileHeroTitle>{l('page.home.hero.mobile_android_title')}</MobileHeroTitle>
205205
<MobileHeroSubtitle>{l('page.home.hero.mobile_android_subtitle')}</MobileHeroSubtitle>
206206
<GooglePlayButton
207-
href={GOOGLE_PLAY_MOBILE_URL}
207+
href={DOWNLOAD_URLS.googlePlay}
208208
target="_blank"
209209
rel="noopener noreferrer"
210210
data-event={SegmentEvent.DOWNLOAD}
Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
import { assetUrl } from '../../../utils/assetUrl'
22

3-
const GOOGLE_PLAY_MOBILE_URL =
4-
'https://play.google.com/store/apps/details?id=org.decentraland.godotexplorer&utm_org=dclrgl&utm_source=fdn&utm_medium=qr&utm_campaign=dclpage&utm_content=android'
5-
6-
const GOOGLE_PLAY_DESKTOP_URL =
7-
'https://play.google.com/store/apps/details?id=org.decentraland.godotexplorer&utm_org=dclrgl&utm_source=fdn&utm_medium=qr&utm_campaign=dclpage&utm_content=android'
8-
93
const googlePlayBadge = assetUrl('/google_play_cta.svg')
104

11-
export { GOOGLE_PLAY_DESKTOP_URL, GOOGLE_PLAY_MOBILE_URL, googlePlayBadge }
5+
export { googlePlayBadge }

src/components/Layout/DownloadLayout.spec.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { render, screen, waitFor } from '@testing-library/react'
33
import userEvent from '@testing-library/user-event'
44
import { useAdvancedUserAgentData } from '@dcl/hooks'
55
import { launchDesktopApp } from 'decentraland-ui2'
6-
import { GOOGLE_PLAY_MOBILE_URL } from '../../components/Home/shared/googlePlay'
76
import { useGetProfileQuery } from '../../features/profile/profile.client'
87
import { useDownloadPageExit } from '../../hooks/useDownloadPageExit'
98
import { useWalletAddress } from '../../hooks/useWalletAddress'
@@ -347,7 +346,7 @@ describe('DownloadLayout', () => {
347346
>)
348347
render(<DownloadLayout title={TITLE} />)
349348
const anchor = screen.getByAltText('Get it on Google Play').closest('a') as HTMLAnchorElement
350-
expect(anchor).toHaveAttribute('href', GOOGLE_PLAY_MOBILE_URL)
349+
expect(anchor).toHaveAttribute('href', DOWNLOAD_URLS.googlePlay)
351350
expect(anchor.getAttribute('href')).not.toContain('/download_success')
352351
})
353352

src/components/Layout/DownloadLayout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { DOWNLOAD_URLS } from '../../modules/downloadConstants'
1717
import { DownloadPlace, DownloadTarget, SegmentEvent } from '../../modules/segment'
1818
import { assetUrl } from '../../utils/assetUrl'
1919
import { DownloadOptions } from '../DownloadOptions'
20-
import { GOOGLE_PLAY_MOBILE_URL, googlePlayBadge } from '../Home/shared/googlePlay'
20+
import { googlePlayBadge } from '../Home/shared/googlePlay'
2121
import { GooglePlayButton, GooglePlayImage } from '../Home/shared/MobileCTA.styled'
2222
import { LandingFooter } from '../LandingFooter'
2323
import { LandingNavbarConnected } from '../LandingNavbar'
@@ -236,7 +236,7 @@ const DownloadLayout = memo((props: DownloadLayoutProps) => {
236236
historical from when they were Google-Play–only. Reused here for both OSes. */}
237237
{isMobileAndroid ? (
238238
<GooglePlayButton
239-
href={GOOGLE_PLAY_MOBILE_URL}
239+
href={DOWNLOAD_URLS.googlePlay}
240240
target="_blank"
241241
rel="noopener noreferrer"
242242
data-place={DownloadPlace.DOWNLOAD_PAGE}

src/modules/campaignParams.spec.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { collectCampaignParams, withCampaignParams } from './campaignParams'
1+
import { collectCampaignParams, withCampaignParams, withCampaignParamsOverlay } from './campaignParams'
22

33
// Mirrors the module-private cap in campaignParams.ts; asserted here as the
44
// documented, stable public behavior (a partner cannot flood the warehouse).
@@ -98,3 +98,37 @@ describe('withCampaignParams', () => {
9898
})
9999
})
100100
})
101+
102+
describe('withCampaignParamsOverlay', () => {
103+
afterEach(() => {
104+
window.history.pushState({}, '', '/')
105+
})
106+
107+
describe('when the current URL has no campaign params', () => {
108+
it('should return the base URL unchanged', () => {
109+
const baseUrl = 'https://play.google.com/store/apps/details?id=org.decentraland.godotexplorer&utm_source=fdn'
110+
expect(withCampaignParamsOverlay(baseUrl)).toBe(baseUrl)
111+
})
112+
})
113+
114+
describe('when the current URL carries campaign params', () => {
115+
beforeEach(() => {
116+
window.history.pushState({}, '', '/?utm_source=x&utm_medium=paid&utm_campaign=ad')
117+
})
118+
119+
it('should override same-named params already present on the base URL', () => {
120+
const baseUrl =
121+
'https://play.google.com/store/apps/details?id=org.decentraland.godotexplorer&utm_org=dclrgl&utm_source=fdn&utm_medium=qr&utm_campaign=dclpage&utm_content=android'
122+
const result = new URL(withCampaignParamsOverlay(baseUrl))
123+
expect(result.searchParams.get('utm_source')).toBe('x')
124+
expect(result.searchParams.get('utm_medium')).toBe('paid')
125+
expect(result.searchParams.get('utm_campaign')).toBe('ad')
126+
})
127+
128+
it('should preserve params not part of the campaign allowlist', () => {
129+
const baseUrl = 'https://play.google.com/store/apps/details?id=org.decentraland.godotexplorer&utm_source=fdn'
130+
const result = new URL(withCampaignParamsOverlay(baseUrl))
131+
expect(result.searchParams.get('id')).toBe('org.decentraland.godotexplorer')
132+
})
133+
})
134+
})

src/modules/campaignParams.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ function collectCampaignParams(source?: URLSearchParams): Record<string, string>
6161
return collected
6262
}
6363

64+
/** Shared empty-guard for the two `with*` builders below. */
65+
function collectNonEmptyCampaignParams(): Record<string, string> | null {
66+
const collected = collectCampaignParams()
67+
return Object.keys(collected).length === 0 ? null : collected
68+
}
69+
6470
/**
6571
* Appends the current URL's campaign params to an internal path, preserving
6672
* partner attribution across a plain navigation. Used by the `'/download'`
@@ -69,10 +75,28 @@ function collectCampaignParams(source?: URLSearchParams): Record<string, string>
6975
* with the utm params stripped, silently losing the whole funnel attribution.
7076
*/
7177
function withCampaignParams(path: string): string {
72-
const collected = collectCampaignParams()
73-
if (Object.keys(collected).length === 0) return path
78+
const collected = collectNonEmptyCampaignParams()
79+
if (!collected) return path
7480
const params = new URLSearchParams(collected)
7581
return `${path}${path.includes('?') ? '&' : '?'}${params.toString()}`
7682
}
7783

78-
export { collectCampaignParams, withCampaignParams }
84+
/**
85+
* Overlays the visitor's incoming campaign params (if any) onto a base URL's
86+
* own query string, overriding same-named params the base URL already
87+
* carries. For URLs that ship a baked-in default attribution (e.g. the Play
88+
* Store listing's own "QR code" campaign tag), this lets a live incoming
89+
* campaign win instead of being silently discarded at the store handoff.
90+
* Falls back to the base URL untouched when no campaign params are present.
91+
*/
92+
function withCampaignParamsOverlay(baseUrl: string): string {
93+
const collected = collectNonEmptyCampaignParams()
94+
if (!collected) return baseUrl
95+
const url = new URL(baseUrl)
96+
for (const [key, value] of Object.entries(collected)) {
97+
url.searchParams.set(key, value)
98+
}
99+
return url.toString()
100+
}
101+
102+
export { collectCampaignParams, withCampaignParams, withCampaignParamsOverlay }

src/modules/downloadConstants.spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,44 @@ describe('downloadConstants', () => {
1010
expect(DOWNLOAD_URLS.googlePlay).toContain('play.google.com')
1111
expect(DOWNLOAD_URLS.appStore).toContain('apps.apple.com')
1212
})
13+
14+
describe('googlePlay', () => {
15+
afterEach(() => {
16+
window.history.pushState({}, '', '/')
17+
})
18+
19+
it('should default to the static QR-code campaign tag when no campaign params are present', () => {
20+
expect(DOWNLOAD_URLS.googlePlay).toContain('utm_source=fdn')
21+
expect(DOWNLOAD_URLS.googlePlay).toContain('utm_medium=qr')
22+
expect(DOWNLOAD_URLS.googlePlay).toContain('utm_campaign=dclpage')
23+
})
24+
25+
it('should carry the visitor incoming campaign instead of the default tag when present', () => {
26+
window.history.pushState({}, '', '/?utm_source=x&utm_medium=paid&utm_campaign=ad')
27+
const url = new URL(DOWNLOAD_URLS.googlePlay)
28+
expect(url.searchParams.get('utm_source')).toBe('x')
29+
expect(url.searchParams.get('utm_medium')).toBe('paid')
30+
expect(url.searchParams.get('utm_campaign')).toBe('ad')
31+
expect(url.searchParams.get('id')).toBe('org.decentraland.godotexplorer')
32+
})
33+
34+
it('should mirror the default utm set into the Install Referrer param when no campaign is present', () => {
35+
const referrer = new URLSearchParams(new URL(DOWNLOAD_URLS.googlePlay).searchParams.get('referrer') ?? '')
36+
expect(referrer.get('utm_source')).toBe('fdn')
37+
expect(referrer.get('utm_medium')).toBe('qr')
38+
expect(referrer.get('utm_campaign')).toBe('dclpage')
39+
})
40+
41+
it('should mirror the incoming campaign into the Install Referrer param when present', () => {
42+
window.history.pushState({}, '', '/?utm_source=x&utm_medium=paid&utm_campaign=ad')
43+
const referrer = new URLSearchParams(new URL(DOWNLOAD_URLS.googlePlay).searchParams.get('referrer') ?? '')
44+
expect(referrer.get('utm_source')).toBe('x')
45+
expect(referrer.get('utm_medium')).toBe('paid')
46+
expect(referrer.get('utm_campaign')).toBe('ad')
47+
// Non-utm routing params never leak into the referrer payload.
48+
expect(referrer.get('id')).toBeNull()
49+
})
50+
})
1351
})
1452

1553
describe('detectDownloadOS', () => {

0 commit comments

Comments
 (0)