@mparticle/web-sdk 3.21.0 → 3.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mparticle.common.js +230 -161
- package/dist/mparticle.esm.js +230 -161
- package/dist/mparticle.js +4536 -4111
- package/dist/types/src/batchUploader.d.ts +2 -0
- package/dist/types/src/buttonTracking/buttonDescription.d.ts +2 -1
- package/dist/types/src/buttonTracking/buttonTracker.d.ts +6 -0
- package/dist/types/src/buttonTracking/checkoutIntent.d.ts +7 -1
- package/dist/types/src/buttonTracking/controls.d.ts +3 -0
- package/dist/types/src/pageUrl.d.ts +10 -0
- package/dist/types/src/pageViewTracker.d.ts +2 -0
- package/dist/types/src/roktManager.d.ts +9 -2
- package/dist/types/src/sdkRuntimeModels.d.ts +2 -0
- package/package.json +1 -1
- package/src/batchUploader.ts +36 -3
- package/src/buttonTracking/buttonClickEvent.ts +3 -0
- package/src/buttonTracking/buttonDescription.ts +24 -3
- package/src/buttonTracking/buttonTracker.ts +92 -8
- package/src/buttonTracking/checkoutIntent.ts +199 -66
- package/src/buttonTracking/controls.ts +150 -32
- package/src/mp-instance.ts +5 -8
- package/src/mparticle-instance-manager.ts +3 -0
- package/src/pageUrl.ts +150 -0
- package/src/pageViewTracker.ts +60 -8
- package/src/roktManager.ts +50 -17
- package/src/sdkRuntimeModels.ts +2 -0
- package/src/serverModel.ts +3 -3
- package/src/utils.ts +1 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CONTROL_SELECTOR,
|
|
3
|
+
hasComposedAncestor,
|
|
4
|
+
isQuietIntentControl,
|
|
3
5
|
isRoktElement,
|
|
4
6
|
isUnsafeTextSource,
|
|
5
|
-
|
|
7
|
+
vendorButtonKind,
|
|
6
8
|
} from './controls';
|
|
7
9
|
|
|
8
10
|
// Classifies a click, submit or frame focus as checkout intent for the Rokt kit.
|
|
@@ -14,31 +16,48 @@ export type CheckoutIntentKind =
|
|
|
14
16
|
| 'frame';
|
|
15
17
|
|
|
16
18
|
const checkoutPath = /(?:^|\/)(?:checkouts?|payment|order|kasse|paiement|commander?|pago)(?:\/|$)/i;
|
|
17
|
-
|
|
19
|
+
// English look-alikes are whole words; the other languages stay substring
|
|
20
|
+
// matches, since several of them do not separate words with spaces.
|
|
21
|
+
const englishExclusions = new RegExp(
|
|
22
|
+
'\\b(?:coupon|promo(?: code)?|newsletter|sign in|sign up|log in|login|add to (?:cart|bag|basket)|' +
|
|
23
|
+
'gift card balance|(?:add|apply|redeem|check|enter|use) (?:a |an |your )?gift card|pay later|' +
|
|
24
|
+
'interest free|as low as|learn more|prequalify|apply (?:now|for)|payment plan|available|accepted|' +
|
|
25
|
+
'we accept|set up|add money|reload|top up|add to (?:apple|google) wallet|connect|link your|unlink|' +
|
|
26
|
+
'remove|delete|edit|change|manage|use a different|switch)\\b|^pay in$|\\bpay in (?:installments|instalments)\\b'
|
|
27
|
+
);
|
|
28
|
+
const otherExclusions = new RegExp(
|
|
18
29
|
[
|
|
19
|
-
'coupon|promo|newsletter|sign in|add to cart|gift card|pay later|pay in|payment plan|learn more',
|
|
20
30
|
'gutschein|anmelden|warenkorb|bon cadeau|connexion|panier|cupón|iniciar sesión|carrito',
|
|
21
31
|
'carrello|codice sconto|accedi|buono regalo|winkelwagen|kortingscode|inloggen|cadeaubon|achteraf betalen',
|
|
22
32
|
'カート|クーポン|ログイン|会員登録|ギフト|後払い|メルマガ',
|
|
23
33
|
].join('|')
|
|
24
34
|
);
|
|
25
|
-
const
|
|
26
|
-
'
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
35
|
+
const englishCommits =
|
|
36
|
+
'(?:(?:agree|accept|review) and )?(?:place|submit|complete|confirm|finali[sz]e)' +
|
|
37
|
+
'(?!(?: [^ ]+){0,2} (?:another|new|next)(?: |$))(?! (?:[^ ]+ )?gift card)' +
|
|
38
|
+
'(?: [^ ]+){0,2} (?:order|purchase|booking|reservation|payment|checkout|transaction)' +
|
|
39
|
+
'(?: now| securely| and pay| (?:with|using) [^ ]+(?: [^ ]+)?)?(?: (?:total|subtotal|items?|tickets?))?' +
|
|
40
|
+
'|pay(?: securely)?(?: now)?|(?:confirm|agree|accept|submit|order|book) and pay' +
|
|
41
|
+
'|pay and place (?:my |your )?order|order with obligation to pay';
|
|
42
|
+
const otherCommits = [
|
|
43
|
+
'(?:jetzt )?(?:zahlungs|kosten)pflichtig (?:bestellen|buchen)|(?:jetzt )?bezahlen|kauf abschließen|bestellung abschließen',
|
|
44
|
+
'commander et payer|payer(?: maintenant)?|(?:confirmer|finaliser) (?:ma |la )?commande',
|
|
45
|
+
"valider et payer|confirmer et payer|commande avec obligation de paiement|finaliser l'achat",
|
|
46
|
+
'(?:realizar|confirmar|completar) (?:el |mi |la )?(?:pedido|compra|reserva)|finalizar (?:el |mi )?pedido|pagar(?: ahora)?',
|
|
47
|
+
'pedido con obligación de pago',
|
|
48
|
+
"(?:effettua|conferma|concludi|completa) (?:l'|il )?(?:ordine|acquisto)|ordine con obbligo di pagamento",
|
|
49
|
+
'paga(?: ora| adesso)?|ordina e paga',
|
|
50
|
+
'(?:nu )?betalen|bestellen en betalen|bestelling plaatsen|plaats bestelling|bestelling afronden',
|
|
51
|
+
'bestelling met betalingsverplichting',
|
|
52
|
+
'(?:この内容で|上記の?内容で)?ご?(?:注文|購入|予約|決済|お?支払い?)を?(?:確定|完了)(?:する)?',
|
|
53
|
+
'(?:この内容で|上記の?内容で)ご?(?:注文|購入|予約)する|ご?注文する|決済する|(?:今すぐ|を)?支払う',
|
|
54
|
+
].join('|');
|
|
55
|
+
const commits = new RegExp('^(?:' + englishCommits + '|' + otherCommits + ')$');
|
|
56
|
+
// A plain link is navigation, so in English only an order or purchase it
|
|
57
|
+
// places, submits or completes counts as the confirm click.
|
|
58
|
+
const linkCommits = new RegExp(
|
|
59
|
+
'^(?:(?:place|submit|complete)(?!(?: [^ ]+){0,2} (?:another|new|next)(?: |$))(?: [^ ]+){0,2} (?:order|purchase)|' +
|
|
60
|
+
otherCommits +
|
|
42
61
|
')$'
|
|
43
62
|
);
|
|
44
63
|
const routeGatedCommits = new RegExp(
|
|
@@ -48,7 +67,9 @@ const routeGatedCommits = new RegExp(
|
|
|
48
67
|
const steps = new RegExp(
|
|
49
68
|
'^(?:' +
|
|
50
69
|
[
|
|
51
|
-
'
|
|
70
|
+
"(?:let's |lets |go to |proceed to |secure )?(?:check ?out|checkout)(?: now| securely| as guest)?",
|
|
71
|
+
'(?:continue|proceed) to (?:payment|review|billing|shipping|delivery|order review)|review (?:my |your )?order',
|
|
72
|
+
'guest checkout|(?:pay|continue|check ?out) (?:with|by) (?:card|credit card|debit card|credit or debit card)',
|
|
52
73
|
'zur kasse|zur kasse gehen|auschecken|weiter zur zahlung',
|
|
53
74
|
'passer à la caisse|continuer vers le paiement|procéder au paiement',
|
|
54
75
|
'ir a caja|continuar al pago|tramitar pedido',
|
|
@@ -58,43 +79,104 @@ const steps = new RegExp(
|
|
|
58
79
|
].join('|') +
|
|
59
80
|
')$'
|
|
60
81
|
);
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
'
|
|
65
|
-
'
|
|
82
|
+
const WALLET_NAMES =
|
|
83
|
+
'apple pay|google pay|paypal|venmo|amazon pay|shop pay|klarna|afterpay|clearpay|cash app(?: pay)?|affirm|sezzle|samsung pay';
|
|
84
|
+
const WALLET_VERBS =
|
|
85
|
+
'pay|buy|purchase|order|book|donate|subscribe|check ?out|checkout|express checkout|continue|proceed|' +
|
|
86
|
+
'complete purchase|place (?:my |your )?order';
|
|
87
|
+
// "Pay with Klarna", "Buy with GPay", "Continue to PayPal".
|
|
88
|
+
const walletPhrases = new RegExp(
|
|
89
|
+
'^(?:(?:' + WALLET_VERBS + ')(?: now| securely)? (?:with|using|via|through|by) (?:' + WALLET_NAMES +
|
|
90
|
+
')(?: express(?: checkout)?| checkout)?|(?:continue|proceed|go) (?:to|with) (?:' + WALLET_NAMES + '))$'
|
|
91
|
+
);
|
|
92
|
+
// A wallet's own name on a button: "PayPal", "Klarna checkout", "Apple Pay logo".
|
|
93
|
+
const walletBrands = new RegExp(
|
|
94
|
+
'^(?:' + WALLET_NAMES + ')(?: (?:checkout|check out|express(?: checkout)?|express pay|buy now|pay now|pay|now)' +
|
|
95
|
+
'| (?:logo|icon|mark|button)(?: (?:logo|icon|mark|button))*)?$'
|
|
96
|
+
);
|
|
97
|
+
// Exact HTTPS hosts of hosted wallet button frames.
|
|
98
|
+
const walletFrameHosts = new Set([
|
|
66
99
|
'pay.google.com',
|
|
67
100
|
'applepay.cdn-apple.com',
|
|
68
|
-
'js.stripe.com',
|
|
69
|
-
'checkoutshopper-live.adyen.com',
|
|
70
101
|
'payments.amazon.com',
|
|
71
102
|
'pay.shopify.com',
|
|
72
103
|
]);
|
|
104
|
+
// PayPal serves its buttons and its card fields and pay-later messages from
|
|
105
|
+
// the same hosts; only the button frames are wallet taps.
|
|
106
|
+
const paypalFrameHosts = new Set(['www.paypal.com', 'www.sandbox.paypal.com']);
|
|
107
|
+
const PAYPAL_BUTTON_FRAME = /^\/smart\/(?:buttons|menu)(?:\/|$)/;
|
|
108
|
+
// Stripe's quick-pay button frames; its other frames are card and form fields.
|
|
109
|
+
const STRIPE_WALLET_FRAME = /\/elements-inner-(?:payment-request|express-checkout)-/;
|
|
110
|
+
// Hosted card-entry and fingerprint frames: focus there is typing card details.
|
|
111
|
+
const CARD_FRAME_DOMAINS = [
|
|
112
|
+
'adyen.com',
|
|
113
|
+
'braintreegateway.com',
|
|
114
|
+
'cybersource.com',
|
|
115
|
+
'squarecdn.com',
|
|
116
|
+
'checkout.com',
|
|
117
|
+
];
|
|
73
118
|
// Keep these classes ASCII-only in source bytes to avoid mojibake parse failures
|
|
74
119
|
// when a host page forces non-UTF-8 decoding before JavaScript parse.
|
|
75
120
|
const TYPOGRAPHIC_APOSTROPHES = /[\u2019\u2018]/g;
|
|
76
121
|
const CURRENCY_AND_NUMBER_CHARS =
|
|
77
122
|
/[0-9\uFF10-\uFF19$\u20AC\u00A3\u00A5\uFFE5\u20B9\u5186.,\uFF0C]/g;
|
|
123
|
+
const BRACKETED = /\([^()]*\)|\[[^[\]]*\]|\uFF08[^\uFF08\uFF09]*\uFF09|\uFF3B[^\uFF3B\uFF3D]*\uFF3D|\u3010[^\u3010\u3011]*\u3011/g;
|
|
124
|
+
// Spaces, punctuation, separators, arrows and symbols, CJK and full-width forms
|
|
125
|
+
// included, plus emoji halves and variation selectors.
|
|
126
|
+
const EDGE =
|
|
127
|
+
'[\\s!-/:-@\\[-`{-~\\u00A1-\\u00BF\\u00D7\\u00F7\\u2000-\\u206F\\u20A0-\\u20CF\\u2190-\\u2BFF' +
|
|
128
|
+
'\\u3000-\\u3003\\u3008-\\u3011\\u3014-\\u301F\\u30FB\\uD800-\\uDFFF\\uFE00-\\uFE0F' +
|
|
129
|
+
'\\uFF01-\\uFF0F\\uFF1A-\\uFF20\\uFF3B-\\uFF40\\uFF5B-\\uFF65]+';
|
|
130
|
+
const EDGE_SYMBOLS = new RegExp('^' + EDGE + '|' + EDGE + '$', 'g');
|
|
78
131
|
|
|
79
132
|
function getIntentRouteClass(pathname: string): 'checkout_path' | 'other' {
|
|
80
133
|
return checkoutPath.test(pathname) ? 'checkout_path' : 'other';
|
|
81
134
|
}
|
|
82
135
|
|
|
136
|
+
const SEPARATORS = /[-_|·•]/g;
|
|
137
|
+
const CURRENCY_CODES = /(?:^| )(?:usd|aud|cad|nzd|gbp|eur|chf|sgd|hkd|jpy|inr|mxn)(?= |$)|\b(?:a|au|ca|c|us|nz|hk|s)\$/g;
|
|
138
|
+
const WALLET_ALIASES: Array<[RegExp, string]> = [
|
|
139
|
+
[/\bapplepay\b/g, 'apple pay'],
|
|
140
|
+
[/\bg ?pay\b/g, 'google pay'],
|
|
141
|
+
[/\bamazonpay\b/g, 'amazon pay'],
|
|
142
|
+
[/\bshoppay\b/g, 'shop pay'],
|
|
143
|
+
[/\bcashapp\b/g, 'cash app'],
|
|
144
|
+
];
|
|
145
|
+
// Labels longer than this are sentences: they neither match nor veto intent.
|
|
146
|
+
const MAX_LABEL_WORDS = 8;
|
|
147
|
+
|
|
83
148
|
function normalize(text: string | null | undefined): string | undefined {
|
|
84
149
|
if (!text || text.length > 128) {
|
|
85
150
|
return undefined;
|
|
86
151
|
}
|
|
87
|
-
|
|
152
|
+
let label = text
|
|
88
153
|
.toLowerCase()
|
|
89
154
|
.replace(TYPOGRAPHIC_APOSTROPHES, "'")
|
|
155
|
+
.replace(/[&+]/g, ' and ')
|
|
156
|
+
.replace(SEPARATORS, ' ')
|
|
157
|
+
.replace(CURRENCY_CODES, ' ')
|
|
90
158
|
.replace(CURRENCY_AND_NUMBER_CHARS, '')
|
|
91
159
|
.replace(/\s+/g, ' ')
|
|
92
160
|
.trim();
|
|
161
|
+
WALLET_ALIASES.forEach(([alias, name]) => {
|
|
162
|
+
label = label.replace(alias, name);
|
|
163
|
+
});
|
|
164
|
+
return label.split(' ').length > MAX_LABEL_WORDS ? undefined : label;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The label without bracketed asides or the separators and symbols left at its
|
|
168
|
+
// edges, so "place order -" and "place order ( items)" both read "place order".
|
|
169
|
+
function coreLabel(text: string): string {
|
|
170
|
+
const core = text
|
|
171
|
+
.replace(BRACKETED, ' ')
|
|
172
|
+
.replace(/\s+/g, ' ')
|
|
173
|
+
.replace(EDGE_SYMBOLS, '');
|
|
174
|
+
return core || text.replace(EDGE_SYMBOLS, '');
|
|
93
175
|
}
|
|
94
176
|
|
|
95
177
|
// Labels come from aria-label then visible text. Only submit and button inputs
|
|
96
|
-
// have their value read, so a typed field value is never touched.
|
|
97
|
-
//
|
|
178
|
+
// have their value read, so a typed field value is never touched. A control
|
|
179
|
+
// holding form fields or more than four children gives only its aria-label.
|
|
98
180
|
function labelSources(element: Element): Array<string | null> {
|
|
99
181
|
if (element instanceof HTMLInputElement) {
|
|
100
182
|
const type = element.type;
|
|
@@ -107,7 +189,7 @@ function labelSources(element: Element): Array<string | null> {
|
|
|
107
189
|
return [element.getAttribute('aria-label'), text];
|
|
108
190
|
}
|
|
109
191
|
if (element.children.length > 4 || isUnsafeTextSource(element)) {
|
|
110
|
-
return [];
|
|
192
|
+
return [element.getAttribute('aria-label')];
|
|
111
193
|
}
|
|
112
194
|
return [element.getAttribute('aria-label'), element.textContent];
|
|
113
195
|
}
|
|
@@ -118,69 +200,120 @@ function labels(element: Element): string[] {
|
|
|
118
200
|
.filter((label): label is string => !!label);
|
|
119
201
|
}
|
|
120
202
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
203
|
+
// Which rule recognised the intent: a vendor's own markup, a phrase in the
|
|
204
|
+
// label, a wallet's bare name, a hosted frame, or a form submitted after a
|
|
205
|
+
// confirm click.
|
|
206
|
+
export type CheckoutIntentSource = 'vendor' | 'label' | 'brand' | 'frame' | 'submit';
|
|
207
|
+
|
|
208
|
+
export interface ICheckoutIntent {
|
|
209
|
+
kind: Exclude<CheckoutIntentKind, 'frame'>;
|
|
210
|
+
source: CheckoutIntentSource;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// "Buy now" and "Purchase" count only on a checkout route, and never on a plain
|
|
214
|
+
// link, which is navigation.
|
|
215
|
+
function isCommitLabel(text: string, isLink: boolean, pathname: string): boolean {
|
|
216
|
+
if (isLink) {
|
|
217
|
+
return linkCommits.test(text);
|
|
218
|
+
}
|
|
219
|
+
return (
|
|
127
220
|
commits.test(text) ||
|
|
128
221
|
(routeGatedCommits.test(text) &&
|
|
129
222
|
getIntentRouteClass(pathname) === 'checkout_path')
|
|
130
|
-
)
|
|
131
|
-
return 'commit';
|
|
132
|
-
}
|
|
133
|
-
if (button && wallets.test(text)) {
|
|
134
|
-
return 'wallet';
|
|
135
|
-
}
|
|
136
|
-
if (steps.test(text)) {
|
|
137
|
-
return 'checkout_step';
|
|
138
|
-
}
|
|
139
|
-
return undefined;
|
|
223
|
+
);
|
|
140
224
|
}
|
|
141
225
|
|
|
142
|
-
// Classifies a clicked or submitting control found by findControl
|
|
143
|
-
|
|
226
|
+
// Classifies a clicked or submitting control found by findControl, in order:
|
|
227
|
+
// pickers, toggles and pay-later messages never count; a vendor's own wallet
|
|
228
|
+
// button counts before any label rule; a confirm label on any source counts
|
|
229
|
+
// before the look-alike exclusions veto; then wallet phrases and steps.
|
|
230
|
+
export function classifyIntent(
|
|
144
231
|
control: Element,
|
|
145
232
|
pathname: string
|
|
146
|
-
):
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
233
|
+
): ICheckoutIntent | undefined {
|
|
234
|
+
const vendor = vendorButtonKind(control);
|
|
235
|
+
if (isQuietIntentControl(control, vendor === 'wallet') || vendor === 'none') {
|
|
149
236
|
return undefined;
|
|
150
237
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
button = control.matches(CONTROL_SELECTOR) || isWalletElement(control);
|
|
238
|
+
if (vendor) {
|
|
239
|
+
return { kind: vendor, source: 'vendor' };
|
|
154
240
|
}
|
|
155
|
-
|
|
156
|
-
|
|
241
|
+
const texts = labels(control);
|
|
242
|
+
const cores = texts.map(coreLabel);
|
|
243
|
+
const isLink = control.matches('a[href]:not([role="button"])');
|
|
244
|
+
if (cores.some(text => isCommitLabel(text, isLink, pathname))) {
|
|
245
|
+
return { kind: 'commit', source: 'label' };
|
|
157
246
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
247
|
+
if (
|
|
248
|
+
texts
|
|
249
|
+
.concat(cores)
|
|
250
|
+
.some(text => englishExclusions.test(text) || otherExclusions.test(text))
|
|
251
|
+
) {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
const isButton = !isLink && control.matches(CONTROL_SELECTOR);
|
|
255
|
+
for (const text of cores) {
|
|
256
|
+
if (isButton && walletPhrases.test(text)) {
|
|
257
|
+
return { kind: 'wallet', source: 'label' };
|
|
258
|
+
}
|
|
259
|
+
if (isButton && walletBrands.test(text)) {
|
|
260
|
+
return { kind: 'wallet', source: 'brand' };
|
|
261
|
+
}
|
|
262
|
+
if (steps.test(text)) {
|
|
263
|
+
return { kind: 'checkout_step', source: 'label' };
|
|
162
264
|
}
|
|
163
265
|
}
|
|
164
266
|
return undefined;
|
|
165
267
|
}
|
|
166
268
|
|
|
167
|
-
|
|
269
|
+
export function classifyIntentControl(
|
|
270
|
+
control: Element,
|
|
271
|
+
pathname: string
|
|
272
|
+
): Exclude<CheckoutIntentKind, 'frame'> | undefined {
|
|
273
|
+
const intent = classifyIntent(control, pathname);
|
|
274
|
+
return intent ? intent.kind : undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The host is the domain or one of its subdomains, never a look-alike suffix.
|
|
278
|
+
function isOnDomain(host: string, domain: string): boolean {
|
|
279
|
+
return (
|
|
280
|
+
host === domain ||
|
|
281
|
+
(host.length > domain.length &&
|
|
282
|
+
host.slice(host.length - domain.length - 1) === '.' + domain)
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Focus moving into a visible hosted wallet button counts as a wallet click;
|
|
287
|
+
// focus into hosted card fields or messages is only a frame.
|
|
168
288
|
export function classifyIntentFrame(
|
|
169
289
|
target: EventTarget | null,
|
|
170
290
|
win: Window
|
|
171
|
-
): 'frame' | undefined {
|
|
291
|
+
): 'wallet' | 'frame' | undefined {
|
|
172
292
|
if (
|
|
173
293
|
win.document.visibilityState !== 'visible' ||
|
|
174
294
|
!(target instanceof HTMLIFrameElement)
|
|
175
295
|
) {
|
|
176
296
|
return undefined;
|
|
177
297
|
}
|
|
178
|
-
if (
|
|
298
|
+
if (
|
|
299
|
+
isRoktElement(target) ||
|
|
300
|
+
hasComposedAncestor(target, '[data-mp-no-track]')
|
|
301
|
+
) {
|
|
179
302
|
return undefined;
|
|
180
303
|
}
|
|
181
304
|
try {
|
|
182
305
|
const url = new URL(target.src);
|
|
183
|
-
|
|
306
|
+
const host = url.protocol === 'https:' ? url.hostname : '';
|
|
307
|
+
if (paypalFrameHosts.has(host)) {
|
|
308
|
+
return PAYPAL_BUTTON_FRAME.test(url.pathname) ? 'wallet' : 'frame';
|
|
309
|
+
}
|
|
310
|
+
if (walletFrameHosts.has(host)) {
|
|
311
|
+
return 'wallet';
|
|
312
|
+
}
|
|
313
|
+
if (host === 'js.stripe.com') {
|
|
314
|
+
return STRIPE_WALLET_FRAME.test(url.pathname) ? 'wallet' : 'frame';
|
|
315
|
+
}
|
|
316
|
+
if (CARD_FRAME_DOMAINS.some(domain => isOnDomain(host, domain))) {
|
|
184
317
|
return 'frame';
|
|
185
318
|
}
|
|
186
319
|
} catch (e) {
|
|
@@ -29,63 +29,166 @@ export function hasComposedAncestor(
|
|
|
29
29
|
return false;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
const ROKT_SELECTOR = '[data-core-type], #rokt-controller-frame';
|
|
33
|
+
|
|
34
|
+
// A Rokt frame or script is served from a /wsdk/ path.
|
|
35
|
+
function hasRoktSrc(element: Element): boolean {
|
|
36
|
+
const src = element.getAttribute('src');
|
|
37
|
+
if (!src) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
return (
|
|
42
|
+
new URL(src, 'https://example.test').pathname.indexOf('/wsdk/') !==
|
|
43
|
+
-1
|
|
44
|
+
);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// An unparseable src is not a Rokt element.
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
32
51
|
// Rokt's own placements and frames never count as partner checkout controls.
|
|
33
52
|
export function isRoktElement(element: Element): boolean {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
53
|
+
return hasComposedAncestor(element, ROKT_SELECTOR) || hasRoktSrc(element);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Wallet and quick-pay buttons as their vendors render them: official custom
|
|
57
|
+
// elements and documented classes, ids and attributes.
|
|
58
|
+
const WALLET_SIGNATURE = [
|
|
59
|
+
'apple-pay-button',
|
|
60
|
+
'.apple-pay-button',
|
|
61
|
+
'.gpay-button',
|
|
62
|
+
'.gpay-card-info-container',
|
|
63
|
+
'#gpay-button-online-api-id',
|
|
64
|
+
'paypal-button',
|
|
65
|
+
'venmo-button',
|
|
66
|
+
'paypal-pay-later-button',
|
|
67
|
+
'paypal-credit-button',
|
|
68
|
+
'shop-pay-button',
|
|
69
|
+
'shop-pay-wallet-button',
|
|
70
|
+
'shopify-accelerated-checkout',
|
|
71
|
+
'shopify-accelerated-checkout-cart',
|
|
72
|
+
'shopify-apple-pay-button',
|
|
73
|
+
'shopify-google-pay-button',
|
|
74
|
+
'shopify-paypal-button',
|
|
75
|
+
'shopify-amazon-pay-button',
|
|
76
|
+
'.amazonpay-button-focusable',
|
|
77
|
+
'#AmazonPayButton',
|
|
78
|
+
'[data-amazon-pay-button]',
|
|
79
|
+
'[data-afterpay-entry-point]',
|
|
80
|
+
].join(', ');
|
|
81
|
+
// PayPal's card button opens a card form, so it is a step rather than a wallet.
|
|
82
|
+
const CARD_FORM_BUTTON = 'paypal-basic-card-button';
|
|
83
|
+
const VENDOR_BUTTON = WALLET_SIGNATURE + ', ' + CARD_FORM_BUTTON;
|
|
84
|
+
// Apple's set-up and top-up buttons manage a wallet rather than pay with it.
|
|
85
|
+
const NOT_A_PAYMENT = ['set-up', 'add-money', 'reload', 'top-up']
|
|
86
|
+
.map(type => 'apple-pay-button[type="' + type + '"]')
|
|
87
|
+
.join(', ');
|
|
88
|
+
|
|
89
|
+
// What a vendor's own button markup says about a control. Only the control or
|
|
90
|
+
// the shadow host that owns it counts, so a merchant wrapper reusing a vendor
|
|
91
|
+
// class never turns its children into wallet buttons.
|
|
92
|
+
export function vendorButtonKind(
|
|
93
|
+
control: Element
|
|
94
|
+
): 'wallet' | 'checkout_step' | 'none' | undefined {
|
|
95
|
+
let host: Element | null = control.matches(VENDOR_BUTTON) ? control : null;
|
|
96
|
+
if (!host) {
|
|
97
|
+
const root = isFunction(control.getRootNode)
|
|
98
|
+
? (control.getRootNode() as ShadowRoot)
|
|
99
|
+
: null;
|
|
100
|
+
host = root && root.host && root.host.matches(VENDOR_BUTTON)
|
|
101
|
+
? root.host
|
|
102
|
+
: null;
|
|
38
103
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
try {
|
|
42
|
-
if (
|
|
43
|
-
new URL(src, 'https://example.test').pathname.indexOf(
|
|
44
|
-
'/wsdk/'
|
|
45
|
-
) !== -1
|
|
46
|
-
) {
|
|
47
|
-
return true;
|
|
48
|
-
}
|
|
49
|
-
} catch (e) {
|
|
50
|
-
// An unparseable src is not a Rokt element.
|
|
51
|
-
}
|
|
104
|
+
if (!host) {
|
|
105
|
+
return undefined;
|
|
52
106
|
}
|
|
53
|
-
|
|
107
|
+
if (host.matches(NOT_A_PAYMENT)) {
|
|
108
|
+
return 'none';
|
|
109
|
+
}
|
|
110
|
+
return host.matches(CARD_FORM_BUTTON) ? 'checkout_step' : 'wallet';
|
|
54
111
|
}
|
|
55
112
|
|
|
56
113
|
export function isWalletElement(element: Element): boolean {
|
|
57
|
-
return element
|
|
58
|
-
'apple-pay-button, shop-pay-button, .gpay-button, #AmazonPayButton, [data-amazon-pay-button]'
|
|
59
|
-
);
|
|
114
|
+
return vendorButtonKind(element) === 'wallet';
|
|
60
115
|
}
|
|
61
116
|
|
|
117
|
+
// Pickers and toggles choose a payment method or open a panel; they never pay.
|
|
118
|
+
const PICKER =
|
|
119
|
+
'input[type="radio"], input[type="checkbox"], label, [role="radio"], [role="tab"], [role="option"], ' +
|
|
120
|
+
'[role="checkbox"], [role="switch"], [role="menuitemradio"], [role="menuitemcheckbox"], [aria-checked], [aria-selected]';
|
|
121
|
+
const PICKER_GROUP = '[role="radiogroup"], [role="tablist"], [role="listbox"]';
|
|
122
|
+
// Pay-later and financing messages link to details rather than pay.
|
|
123
|
+
const MESSAGING =
|
|
124
|
+
'klarna-placement, afterpay-placement, square-placement, .affirm-as-low-as, .affirm-site-modal, ' +
|
|
125
|
+
'.affirm-product-modal, [data-pp-message]';
|
|
126
|
+
|
|
127
|
+
// A control that is still logged as a click but never counts as checkout intent.
|
|
128
|
+
export function isQuietIntentControl(
|
|
129
|
+
control: Element,
|
|
130
|
+
isVendorWallet: boolean
|
|
131
|
+
): boolean {
|
|
132
|
+
if (control.matches(PICKER)) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
if (hasComposedAncestor(control, PICKER_GROUP)) {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
if (
|
|
139
|
+
!isVendorWallet &&
|
|
140
|
+
(control.hasAttribute('aria-expanded') ||
|
|
141
|
+
control.hasAttribute('aria-pressed'))
|
|
142
|
+
) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
return hasComposedAncestor(control, MESSAGING);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A checkbox or radio is a control of its own only when no button or link
|
|
149
|
+
// around it takes the click, so a tappable row keeps its own name.
|
|
150
|
+
const OPTION_INPUT = 'input[type="checkbox"], input[type="radio"]';
|
|
151
|
+
|
|
62
152
|
// Walks at most 12 entries of an event's composed path for the nearest control.
|
|
63
|
-
// Rokt elements
|
|
153
|
+
// One composed walk from the target rules out Rokt elements and anything under
|
|
154
|
+
// data-mp-no-track; disabled controls are skipped.
|
|
64
155
|
export function findControl(
|
|
65
156
|
path: Array<EventTarget | null>
|
|
66
157
|
): Element | undefined {
|
|
158
|
+
const entries = path.slice(0, 12);
|
|
159
|
+
let first: Element | undefined;
|
|
160
|
+
for (const target of entries) {
|
|
161
|
+
if (target instanceof Element) {
|
|
162
|
+
first = target;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (
|
|
167
|
+
!first ||
|
|
168
|
+
hasComposedAncestor(first, ROKT_SELECTOR + ', [data-mp-no-track]')
|
|
169
|
+
) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
67
172
|
let control: Element | undefined;
|
|
68
|
-
|
|
173
|
+
let option: Element | undefined;
|
|
174
|
+
for (const target of entries) {
|
|
69
175
|
if (!(target instanceof Element)) {
|
|
70
176
|
continue;
|
|
71
177
|
}
|
|
72
|
-
if (
|
|
178
|
+
if (hasRoktSrc(target)) {
|
|
73
179
|
return undefined;
|
|
74
180
|
}
|
|
75
181
|
if (
|
|
76
182
|
!control &&
|
|
77
|
-
|
|
78
|
-
isWalletElement(target))
|
|
183
|
+
target.matches(CONTROL_SELECTOR + ', a[href], ' + VENDOR_BUTTON)
|
|
79
184
|
) {
|
|
80
185
|
control = target;
|
|
186
|
+
} else if (!control && !option && target.matches(OPTION_INPUT)) {
|
|
187
|
+
option = target;
|
|
81
188
|
}
|
|
82
189
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
isRoktElement(control) ||
|
|
86
|
-
hasComposedAncestor(control, '[data-mp-no-track]') ||
|
|
87
|
-
control.matches(':disabled, [aria-disabled="true"]')
|
|
88
|
-
) {
|
|
190
|
+
control = control || option;
|
|
191
|
+
if (!control || control.matches(':disabled, [aria-disabled="true"]')) {
|
|
89
192
|
return undefined;
|
|
90
193
|
}
|
|
91
194
|
return control;
|
|
@@ -109,3 +212,18 @@ export function isUnsafeTextSource(element: Element): boolean {
|
|
|
109
212
|
)
|
|
110
213
|
);
|
|
111
214
|
}
|
|
215
|
+
|
|
216
|
+
// A label around a checkbox or radio holds no typed text, so only the other
|
|
217
|
+
// fields rule its text out.
|
|
218
|
+
export function isUnsafeLabelSource(label: Element): boolean {
|
|
219
|
+
return (
|
|
220
|
+
!!label.querySelector(
|
|
221
|
+
'input:not([type="checkbox"]):not([type="radio"]), select, textarea, [data-mp-no-track], ' +
|
|
222
|
+
EDITABLE
|
|
223
|
+
) ||
|
|
224
|
+
hasComposedAncestor(
|
|
225
|
+
label,
|
|
226
|
+
'select, datalist, textarea, [data-mp-no-track], ' + EDITABLE
|
|
227
|
+
)
|
|
228
|
+
);
|
|
229
|
+
}
|
package/src/mp-instance.ts
CHANGED
|
@@ -45,7 +45,7 @@ import { removeExpiredIdentityCacheDates, hasExplicitIdentifier } from './identi
|
|
|
45
45
|
import IntegrationCapture, { isIntegrationCaptureEnabled } from './integrationCapture';
|
|
46
46
|
import { IPreInit, processReadyQueue } from './pre-init-utils';
|
|
47
47
|
import { BaseEvent, MParticleWebSDK, SDKHelpersApi } from './sdkRuntimeModels';
|
|
48
|
-
import { Dictionary, SDKEventAttrs } from '@mparticle/web-sdk';
|
|
48
|
+
import { Dictionary, MPID, SDKEventAttrs } from '@mparticle/web-sdk';
|
|
49
49
|
import { IIdentity } from './identity.interfaces';
|
|
50
50
|
import { IEvents } from './events.interfaces';
|
|
51
51
|
import { IECommerce } from './ecommerce.interfaces';
|
|
@@ -1424,12 +1424,8 @@ function completeSDKInitialization(apiKey, config, mpInstance) {
|
|
|
1424
1424
|
if (roktConfig) {
|
|
1425
1425
|
const accountId = roktConfig.settings?.accountId;
|
|
1426
1426
|
mpInstance._Store.setRoktAccountId(accountId);
|
|
1427
|
-
const
|
|
1428
|
-
|
|
1429
|
-
currentUserMPID,
|
|
1430
|
-
{ userAttributeFilters },
|
|
1431
|
-
mpInstance
|
|
1432
|
-
);
|
|
1427
|
+
const createRoktFilteredUser = (mpid: MPID) =>
|
|
1428
|
+
filteredMparticleUser(mpid, roktConfig, mpInstance, kitBlocker);
|
|
1433
1429
|
const roktOptions: IRoktOptions = {
|
|
1434
1430
|
sandbox: config?.isDevelopmentMode,
|
|
1435
1431
|
launcherOptions: config?.launcherOptions,
|
|
@@ -1438,7 +1434,7 @@ function completeSDKInitialization(apiKey, config, mpInstance) {
|
|
|
1438
1434
|
// https://go.mparticle.com/work/SQDSDKS-7339
|
|
1439
1435
|
mpInstance._RoktManager.init(
|
|
1440
1436
|
roktConfig,
|
|
1441
|
-
|
|
1437
|
+
createRoktFilteredUser(currentUserMPID),
|
|
1442
1438
|
mpInstance.Identity,
|
|
1443
1439
|
mpInstance._Store,
|
|
1444
1440
|
mpInstance.Logger,
|
|
@@ -1447,6 +1443,7 @@ function completeSDKInitialization(apiKey, config, mpInstance) {
|
|
|
1447
1443
|
mpInstance._ErrorReportingDispatcher,
|
|
1448
1444
|
mpInstance._LoggingDispatcher,
|
|
1449
1445
|
mpInstance._IntegrationCapture,
|
|
1446
|
+
createRoktFilteredUser,
|
|
1450
1447
|
);
|
|
1451
1448
|
}
|
|
1452
1449
|
|
|
@@ -7,6 +7,7 @@ import MPSideloadedKit from './sideloadedKit';
|
|
|
7
7
|
import { IMParticleInstanceManager } from './sdkRuntimeModels';
|
|
8
8
|
import { IStore } from './store';
|
|
9
9
|
import { Dictionary } from '@mparticle/web-sdk';
|
|
10
|
+
import { cleanUrl } from './pageUrl';
|
|
10
11
|
|
|
11
12
|
if (!Array.prototype.forEach) {
|
|
12
13
|
Array.prototype.forEach = Polyfill.forEach;
|
|
@@ -449,6 +450,8 @@ function mParticleInstanceManager(this: IMParticleInstanceManager) {
|
|
|
449
450
|
this.generateHash = function(string) {
|
|
450
451
|
return self.getInstance().generateHash(string);
|
|
451
452
|
};
|
|
453
|
+
// Kits clean every URL they send with the same rule as core events.
|
|
454
|
+
this._cleanUrl = cleanUrl;
|
|
452
455
|
this.addForwarder = function(forwarder) {
|
|
453
456
|
self.getInstance().addForwarder(forwarder);
|
|
454
457
|
};
|