Skip to content

Commit 9275a2a

Browse files
authored
UTM install time injection & GTM Data Layer push (#14130)
1 parent 0dac7a1 commit 9275a2a

26 files changed

Lines changed: 628 additions & 161 deletions

src/amo/components/AddonSuggestions/index.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,6 @@ export class AddonSuggestionsBase extends React.Component<Props> {
261261
pathname: `/collections/${config.get('mozillaUserId')}/${
262262
category.collection
263263
}/`,
264-
query: { addonInstallSource: INSTALL_SOURCE_SUGGESTIONS },
265264
}
266265
: null;
267266

src/amo/components/HeroRecommendation/index.js

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from 'amo/constants';
2020
import translate from 'amo/i18n/translate';
2121
import log from 'amo/logger';
22+
import { setAddonInstallSource } from 'amo/reducers/addonInstallSource';
2223
import tracking, { getAddonEventParams } from 'amo/tracking';
2324
import { getPromotedCategory } from 'amo/utils/addons';
2425
import { addQueryParams } from 'amo/utils/url';
@@ -55,6 +56,7 @@ export type InternalProps = {|
5556
...Props,
5657
...PropsFromState,
5758
...DefaultProps,
59+
dispatch: (action: Object) => void,
5860
i18n: I18nType,
5961
|};
6062

@@ -72,14 +74,14 @@ export class HeroRecommendationBase extends React.Component<InternalProps> {
7274
const { addon, external } = shelfData;
7375

7476
if (addon) {
75-
return addQueryParams(getAddonURL(addon.slug), {
76-
utm_source: DEFAULT_UTM_SOURCE,
77-
utm_medium: DEFAULT_UTM_MEDIUM,
78-
utm_content: PRIMARY_HERO_SRC,
79-
});
77+
// Internal addon link: clean URL without UTM params. The install source
78+
// is dispatched to Redux in onHeroClick() and injected into the page URL
79+
// at install time. See utils/installAttribution.js.
80+
return getAddonURL(addon.slug);
8081
}
8182

8283
invariant(external, 'Either an addon or an external is required');
84+
// External link: keep UTM params (these go to third-party sites).
8385
return external.homepage
8486
? addQueryParams(external.homepage.url, {
8587
utm_source: DEFAULT_UTM_SOURCE,
@@ -90,13 +92,18 @@ export class HeroRecommendationBase extends React.Component<InternalProps> {
9092
};
9193

9294
onHeroClick: () => void = () => {
93-
const { _tracking, _getPromotedCategory, clientApp, shelfData } =
95+
const { _tracking, _getPromotedCategory, clientApp, dispatch, shelfData } =
9496
this.props;
9597

9698
invariant(shelfData, 'The shelfData property is required');
9799

98100
const { addon } = shelfData;
99101

102+
// Store install source in Redux for install-time UTM injection.
103+
if (addon) {
104+
dispatch(setAddonInstallSource(PRIMARY_HERO_SRC));
105+
}
106+
100107
_tracking.sendEvent({
101108
category: PRIMARY_HERO_CLICK_CATEGORY,
102109
params: addon

src/amo/components/SearchResult/index.js

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,12 @@ import { compose } from 'redux';
88
import Link from 'amo/components/Link';
99
import { getAddonURL, nl2br, sanitizeHTML } from 'amo/utils';
1010
import { getPromotedProps } from 'amo/utils/promoted';
11-
import {
12-
ADDON_TYPE_STATIC_THEME,
13-
DEFAULT_UTM_SOURCE,
14-
DEFAULT_UTM_MEDIUM,
15-
} from 'amo/constants';
11+
import { ADDON_TYPE_STATIC_THEME } from 'amo/constants';
1612
import translate from 'amo/i18n/translate';
1713
import { getAddonIconUrl, getPreviewImage } from 'amo/imageUtils';
1814
import { isRecentAddon } from 'amo/reducers/addons';
15+
import { setAddonInstallSource } from 'amo/reducers/addonInstallSource';
1916
import { getPromotedCategory } from 'amo/utils/addons';
20-
import { addQueryParams } from 'amo/utils/url';
2117
import Icon from 'amo/components/Icon';
2218
import LoadingText from 'amo/components/LoadingText';
2319
import Rating from 'amo/components/Rating';
@@ -55,6 +51,7 @@ type InternalProps = {|
5551
...Props,
5652
...PropsFromState,
5753
_getPromotedCategory: typeof getPromotedCategory,
54+
dispatch: (action: Object) => void,
5855
history: ReactRouterHistoryType,
5956
i18n: I18nType,
6057
|};
@@ -72,25 +69,19 @@ export class SearchResultBase extends React.Component<InternalProps> {
7269
useThemePlaceholder: false,
7370
};
7471

75-
getAddonLink(
76-
addon: AddonType | CollectionAddonType,
77-
addonInstallSource?: string,
78-
): string {
79-
let linkTo = getAddonURL(addon.slug);
80-
81-
if (addonInstallSource) {
82-
linkTo = addQueryParams(linkTo, {
83-
utm_source: DEFAULT_UTM_SOURCE,
84-
utm_medium: DEFAULT_UTM_MEDIUM,
85-
utm_content: addonInstallSource,
86-
});
87-
}
88-
89-
return linkTo;
72+
// Returns a clean addon URL without UTM params. The install source is
73+
// dispatched to Redux on click (see onClickResult) and injected into the
74+
// page URL at install time instead. See utils/installAttribution.js.
75+
getAddonLink(addon: AddonType | CollectionAddonType): string {
76+
return getAddonURL(addon.slug);
9077
}
9178

9279
onClickAddon: HTMLElementEventHandler = (e: ElementEvent) => {
93-
const { addon, onClick } = this.props;
80+
const { addon, addonInstallSource, dispatch, onClick } = this.props;
81+
82+
if (addon && addonInstallSource) {
83+
dispatch(setAddonInstallSource(addonInstallSource));
84+
}
9485

9586
e.stopPropagation();
9687
if (addon && onClick) {
@@ -129,7 +120,6 @@ export class SearchResultBase extends React.Component<InternalProps> {
129120
renderResult(): React.Node {
130121
const {
131122
addon,
132-
addonInstallSource,
133123
i18n,
134124
onImpression,
135125
showFullSizePreview,
@@ -154,7 +144,7 @@ export class SearchResultBase extends React.Component<InternalProps> {
154144
addonTitle = (
155145
<Link
156146
className="SearchResult-link"
157-
to={this.getAddonLink(addon, addonInstallSource)}
147+
to={this.getAddonLink(addon)}
158148
onClick={this.onClickAddon}
159149
>
160150
{addon.name}
@@ -275,13 +265,25 @@ export class SearchResultBase extends React.Component<InternalProps> {
275265
}
276266

277267
onClickResult: () => void = () => {
278-
const { addon, addonInstallSource, clientApp, history, lang, onClick } =
279-
this.props;
268+
const {
269+
addon,
270+
addonInstallSource,
271+
clientApp,
272+
dispatch,
273+
history,
274+
lang,
275+
onClick,
276+
} = this.props;
280277

281278
if (addon) {
282-
history.push(
283-
`/${lang}/${clientApp}${this.getAddonLink(addon, addonInstallSource)}`,
284-
);
279+
// Store the install source in Redux so it survives navigation to the
280+
// addon detail page. It will be used at install time to inject UTM
281+
// params into the URL for Firefox attribution.
282+
if (addonInstallSource) {
283+
dispatch(setAddonInstallSource(addonInstallSource));
284+
}
285+
286+
history.push(`/${lang}/${clientApp}${this.getAddonLink(addon)}`);
285287

286288
if (onClick) {
287289
onClick(addon);

src/amo/components/SecondaryHero/index.js

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
/* @flow */
22
/* global window */
33
import * as React from 'react';
4+
import { connect } from 'react-redux';
5+
import { compose } from 'redux';
46

57
import Link from 'amo/components/Link';
68
import { checkInternalURL } from 'amo/utils';
79
import tracking from 'amo/tracking';
810
import { DEFAULT_UTM_SOURCE, DEFAULT_UTM_MEDIUM } from 'amo/constants';
11+
import { setAddonInstallSource } from 'amo/reducers/addonInstallSource';
912
import { addQueryParams } from 'amo/utils/url';
1013
import LoadingText from 'amo/components/LoadingText';
1114
import type {
@@ -25,6 +28,7 @@ type InternalProps = {|
2528
...Props,
2629
_checkInternalURL: typeof checkInternalURL,
2730
_tracking: typeof tracking,
31+
dispatch: (action: Object) => void,
2832
|};
2933

3034
export const makeCallToActionURL = (urlString: string): string => {
@@ -38,6 +42,7 @@ export const makeCallToActionURL = (urlString: string): string => {
3842
export const SecondaryHeroBase = ({
3943
_checkInternalURL = checkInternalURL,
4044
_tracking = tracking,
45+
dispatch,
4146
shelfData,
4247
}: InternalProps): null | React.Node => {
4348
if (shelfData === null) {
@@ -65,15 +70,24 @@ export const SecondaryHeroBase = ({
6570
});
6671
};
6772

73+
// Store install source in Redux for install-time UTM injection.
74+
const onInternalLinkClick = () => {
75+
dispatch(setAddonInstallSource(SECONDARY_HERO_SRC));
76+
onHeroClick();
77+
};
78+
6879
const getLinkProps = (link: LinkWithTextType | null) => {
69-
const props = { onClick: onHeroClick };
7080
if (link) {
7181
const urlInfo = _checkInternalURL({ urlString: link.url });
7282
if (urlInfo.isInternal) {
73-
return { ...props, to: makeCallToActionURL(urlInfo.relativeURL) };
83+
// Internal link: clean URL without UTM params. Install source is
84+
// dispatched to Redux on click and injected at install time.
85+
// See utils/installAttribution.js.
86+
return { onClick: onInternalLinkClick, to: urlInfo.relativeURL };
7487
}
88+
// External link: keep UTM params (these go to third-party sites).
7589
return {
76-
...props,
90+
onClick: onHeroClick,
7791
href: makeCallToActionURL(link.url),
7892
prependClientApp: false,
7993
prependLang: false,
@@ -155,4 +169,7 @@ export const SecondaryHeroBase = ({
155169
);
156170
};
157171

158-
export default SecondaryHeroBase;
172+
const SecondaryHero: React.ComponentType<Props> =
173+
compose(connect())(SecondaryHeroBase);
174+
175+
export default SecondaryHero;

src/amo/constants.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,11 @@ export const CLICK_CATEGORY = 'amo_addon_theme_clicks';
236236

237237
export const SUGGESTIONS_CLICK_CATEGORY = 'amo_suggested_addon_clicks';
238238

239+
export const SET_ADDON_INSTALL_SOURCE: 'SET_ADDON_INSTALL_SOURCE' =
240+
'SET_ADDON_INSTALL_SOURCE';
241+
export const CLEAR_ADDON_INSTALL_SOURCE: 'CLEAR_ADDON_INSTALL_SOURCE' =
242+
'CLEAR_ADDON_INSTALL_SOURCE';
243+
239244
// Collection tracking event categories
240245
export const COLLECTION_CREATE_STARTED_CATEGORY =
241246
'amo_create_collection_started';

src/amo/installAddon.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ import * as addonManager from 'amo/addonManager';
3838
import { getVersionById } from 'amo/reducers/versions';
3939
import { getDisplayName } from 'amo/utils';
4040
import { getFileHash, getPromotedCategory } from 'amo/utils/addons';
41+
import {
42+
injectUTMParams as defaultInjectUTMParams,
43+
removeUTMParams as defaultRemoveUTMParams,
44+
} from 'amo/utils/installAttribution';
4145
import type { AppState } from 'amo/store';
4246
import type { AddonVersionType } from 'amo/reducers/versions';
4347
import type { AddonType } from 'amo/types/addons';
@@ -57,6 +61,7 @@ type EventType = {|
5761
|};
5862

5963
type MakeProgressHandlerParams = {|
64+
_removeUTMParams: typeof defaultRemoveUTMParams,
6065
_tracking: typeof tracking,
6166
addon: AddonType,
6267
dispatch: DispatchFunc,
@@ -65,6 +70,7 @@ type MakeProgressHandlerParams = {|
6570
|};
6671

6772
export function makeProgressHandler({
73+
_removeUTMParams,
6874
_tracking,
6975
addon,
7076
dispatch,
@@ -98,6 +104,7 @@ export function makeProgressHandler({
98104
params: getAddonEventParams(addon, window.location.pathname),
99105
});
100106
}
107+
_removeUTMParams();
101108
} else if (event.type === 'onInstallCancelled') {
102109
dispatch({
103110
type: INSTALL_CANCELLED,
@@ -108,8 +115,10 @@ export function makeProgressHandler({
108115
category: getAddonEventCategory(type, INSTALL_CANCELLED_ACTION),
109116
params: getAddonEventParams(addon, window.location.pathname),
110117
});
118+
_removeUTMParams();
111119
} else if (event.type === 'onInstallFailed') {
112120
dispatch(setInstallError({ guid, error: INSTALL_FAILED }));
121+
_removeUTMParams();
113122
}
114123
};
115124
}
@@ -123,12 +132,15 @@ type WithInstallHelpersPropsFromState = {|
123132
WrappedComponent: React.ComponentType<any>,
124133
clientApp: string,
125134
currentVersion: AddonVersionType | null,
135+
installSource: string | null,
126136
|};
127137

128138
type WithInstallHelpersDefaultProps = {|
129139
_addonManager: typeof addonManager,
130140
_getPromotedCategory: typeof getPromotedCategory,
141+
_injectUTMParams: typeof defaultInjectUTMParams,
131142
_log: typeof log,
143+
_removeUTMParams: typeof defaultRemoveUTMParams,
132144
_tracking: typeof tracking,
133145
|};
134146

@@ -158,7 +170,9 @@ export class WithInstallHelpers extends React.Component<WithInstallHelpersIntern
158170
static defaultProps: WithInstallHelpersDefaultProps = {
159171
_addonManager: addonManager,
160172
_getPromotedCategory: getPromotedCategory,
173+
_injectUTMParams: defaultInjectUTMParams,
161174
_log: log,
175+
_removeUTMParams: defaultRemoveUTMParams,
162176
_tracking: tracking,
163177
};
164178

@@ -268,12 +282,15 @@ export class WithInstallHelpers extends React.Component<WithInstallHelpersIntern
268282
const {
269283
_addonManager,
270284
_getPromotedCategory,
285+
_injectUTMParams,
271286
_log,
287+
_removeUTMParams,
272288
_tracking,
273289
addon,
274290
clientApp,
275291
currentVersion,
276292
dispatch,
293+
installSource,
277294
} = this.props;
278295

279296
invariant(addon, 'need an addon to call install()');
@@ -287,6 +304,12 @@ export class WithInstallHelpers extends React.Component<WithInstallHelpersIntern
287304
return Promise.resolve();
288305
}
289306

307+
// Inject UTM params into the page URL so Firefox can read them at
308+
// install time for attribution.
309+
if (installSource) {
310+
_injectUTMParams(installSource);
311+
}
312+
290313
return new Promise((resolve) => {
291314
dispatch({ type: START_DOWNLOAD, payload: { guid } });
292315
_tracking.sendEvent({
@@ -311,6 +334,7 @@ export class WithInstallHelpers extends React.Component<WithInstallHelpersIntern
311334
return _addonManager.install(
312335
installURL || '',
313336
makeProgressHandler({
337+
_removeUTMParams,
314338
_tracking,
315339
addon,
316340
dispatch,
@@ -341,10 +365,16 @@ export class WithInstallHelpers extends React.Component<WithInstallHelpersIntern
341365
},
342366
});
343367
}
368+
369+
// Clean up UTM params injected before install. This is called on
370+
// every exit path (success, failure, cancel) to ensure the URL is
371+
// always restored. removeUTMParams() is idempotent.
372+
_removeUTMParams();
344373
})
345374
.catch((error) => {
346375
_log.error(`Install error: ${error}`);
347376

377+
_removeUTMParams();
348378
dispatch(setInstallError({ guid, error: FATAL_INSTALL_ERROR }));
349379
});
350380
}
@@ -419,6 +449,7 @@ export const withInstallHelpers = (
419449
WrappedComponent,
420450
clientApp: state.api.clientApp,
421451
currentVersion,
452+
installSource: state.addonInstallSource.installSource,
422453
};
423454
};
424455

0 commit comments

Comments
 (0)