@mparticle/web-sdk 3.22.0 → 3.24.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 +1 -1669
- package/dist/mparticle.esm.js +1 -1669
- package/dist/mparticle.js +4827 -4184
- package/dist/types/src/buttonTracking/buttonClickEvent.d.ts +4 -0
- package/dist/types/src/buttonTracking/buttonDescription.d.ts +2 -2
- package/dist/types/src/buttonTracking/buttonTracker.d.ts +2 -0
- package/dist/types/src/buttonTracking/checkoutIntent.d.ts +2 -2
- package/dist/types/src/buttonTracking/controls.d.ts +6 -3
- package/dist/types/src/buttonTracking/labelMasking.d.ts +1 -0
- package/dist/types/src/configAPIClient.d.ts +1 -0
- package/dist/types/src/forwarder-utils.d.ts +3 -0
- package/dist/types/src/pageUrl.d.ts +10 -0
- package/dist/types/src/pageViewTracker.d.ts +21 -3
- package/dist/types/src/roktManager.d.ts +8 -2
- package/dist/types/src/sdkRuntimeModels.d.ts +3 -0
- package/dist/types/src/store.d.ts +2 -0
- package/package.json +21 -14
- package/src/batchUploader.ts +3 -2
- package/src/buttonTracking/buttonClickEvent.ts +76 -1
- package/src/buttonTracking/buttonDescription.ts +236 -46
- package/src/buttonTracking/buttonTracker.ts +122 -7
- package/src/buttonTracking/checkoutIntent.ts +51 -9
- package/src/buttonTracking/controls.ts +160 -50
- package/src/buttonTracking/labelMasking.ts +4 -1
- package/src/configAPIClient.ts +1 -0
- package/src/events.ts +9 -3
- package/src/forwarder-utils.ts +38 -0
- package/src/forwarders.ts +2 -28
- package/src/mp-instance.ts +23 -10
- package/src/mparticle-instance-manager.ts +3 -0
- package/src/pageUrl.ts +191 -0
- package/src/pageViewTracker.ts +206 -78
- package/src/roktManager.ts +53 -20
- package/src/sdkRuntimeModels.ts +3 -0
- package/src/serverModel.ts +3 -3
- package/src/store.ts +14 -0
- package/src/utils.ts +1 -1
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { isFunction } from '../utils';
|
|
2
2
|
import {
|
|
3
3
|
hasComposedAncestor,
|
|
4
|
-
isUnsafeLabelSource,
|
|
5
4
|
isUnsafeTextSource,
|
|
6
5
|
isWalletElement,
|
|
6
|
+
someComposedAncestor,
|
|
7
7
|
} from './controls';
|
|
8
|
-
import { maskLabel } from './labelMasking';
|
|
8
|
+
import { maskLabel, MAX_RAW_LABEL_LENGTH } from './labelMasking';
|
|
9
9
|
|
|
10
10
|
// Describes a clicked button for auto button tracking: its masked name, icon,
|
|
11
11
|
// hand-written identifiers and kind. Never reads form field values.
|
|
@@ -20,7 +20,6 @@ export type ButtonLabelSource =
|
|
|
20
20
|
| 'labelledby'
|
|
21
21
|
| 'aria'
|
|
22
22
|
| 'text'
|
|
23
|
-
| 'label'
|
|
24
23
|
| 'alt'
|
|
25
24
|
| 'title'
|
|
26
25
|
| 'value'
|
|
@@ -55,21 +54,155 @@ function elementKind(control: Element): ButtonElementKind {
|
|
|
55
54
|
return control.matches('a[href]:not([role="button"])') ? 'link' : 'role';
|
|
56
55
|
}
|
|
57
56
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
const NOT_RENDERED =
|
|
58
|
+
'script, style, template, [hidden], [aria-hidden="true"], [inert]';
|
|
59
|
+
|
|
60
|
+
// The most nodes read for one label source, and images looked at for one
|
|
61
|
+
// button. Each costs a style lookup in the click handler.
|
|
62
|
+
const MAX_READS = 64;
|
|
63
|
+
|
|
64
|
+
function assignedNodes(element: Element): Node[] {
|
|
65
|
+
const slot = element as HTMLSlotElement;
|
|
66
|
+
return isFunction(slot.assignedNodes) ? slot.assignedNodes() : [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Left out of what is drawn by its parent: a light child of an open shadow host
|
|
70
|
+
// that no slot takes, or a slot's own child while nodes are assigned to the
|
|
71
|
+
// slot. A closed shadow root can't be seen, so its host's children count.
|
|
72
|
+
function isUnslotted(node: Element | Text): boolean {
|
|
73
|
+
const parent = node.parentElement;
|
|
74
|
+
if (!parent) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return parent.shadowRoot
|
|
78
|
+
? !node.assignedSlot
|
|
79
|
+
: assignedNodes(parent).length > 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Hides the element and all it holds, whatever their own style says.
|
|
83
|
+
// Screen-reader-only text is clipped rather than hidden, so it is not. An SVG
|
|
84
|
+
// title is never drawn but names its icon, so it is not either.
|
|
85
|
+
function hidesSubtree(element: Element): boolean {
|
|
86
|
+
if (element.matches(NOT_RENDERED) || isUnslotted(element)) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return (
|
|
90
|
+
!(element instanceof SVGTitleElement) &&
|
|
91
|
+
window.getComputedStyle(element).display === 'none'
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Visibility is inherited and a child can set it back to visible, so only the
|
|
96
|
+
// element's own counts.
|
|
97
|
+
function isVisible(element: Element): boolean {
|
|
98
|
+
const visibility = window.getComputedStyle(element).visibility;
|
|
99
|
+
return visibility !== 'hidden' && visibility !== 'collapse';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isShown(element: Element, until?: Element): boolean {
|
|
103
|
+
return !someComposedAncestor(element, hidesSubtree, until);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A shadow host shows a light child only through a slot, and only while
|
|
107
|
+
// nothing from that slot up to the host hides it.
|
|
108
|
+
function isShownThroughSlot(node: Element | Text, host: Element): boolean {
|
|
109
|
+
const slot = node.assignedSlot;
|
|
110
|
+
return !!slot && isShown(slot, host);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Whitespace hides nothing, so a node holding only whitespace is taken as it
|
|
114
|
+
// is, without a style lookup: an icon's paths cost nothing. A slot's text can
|
|
115
|
+
// sit in the nodes assigned to it, so a slot is never blank.
|
|
116
|
+
function isBlank(node: Element | Text): boolean {
|
|
117
|
+
return (
|
|
118
|
+
!/\S/.test(node.textContent || '') &&
|
|
119
|
+
!(
|
|
120
|
+
node instanceof Element &&
|
|
121
|
+
(node.matches('slot') || !!node.querySelector('slot'))
|
|
122
|
+
)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The text a sighted user or a screen reader gets from an element, whitespace
|
|
127
|
+
// collapsed. Undefined when that runs past what maskLabel keeps, takes more
|
|
128
|
+
// than MAX_READS nodes to read, or takes in a form field through a slot, so a
|
|
129
|
+
// label is never cut short at a node boundary.
|
|
130
|
+
function renderedText(root: Element): string | undefined {
|
|
131
|
+
let text = '';
|
|
132
|
+
let reads = 0;
|
|
133
|
+
const append = (value: string) => {
|
|
134
|
+
text = (text + value).replace(/\s+/g, ' ');
|
|
135
|
+
};
|
|
136
|
+
const read = (element: Element): boolean => {
|
|
137
|
+
if (hidesSubtree(element)) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
const assigned = assignedNodes(element);
|
|
141
|
+
const nodes = assigned.length ? assigned : element.childNodes;
|
|
142
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
143
|
+
const node = nodes[i] as Element | Text;
|
|
144
|
+
const isText = node.nodeType === Node.TEXT_NODE;
|
|
145
|
+
if (!isText && !(node instanceof Element)) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (isBlank(node)) {
|
|
149
|
+
append(node.textContent);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (++reads > MAX_READS) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
if (element.shadowRoot && !isShownThroughSlot(node, element)) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (isText) {
|
|
159
|
+
if (isVisible(node.assignedSlot || element)) {
|
|
160
|
+
append(node.nodeValue);
|
|
161
|
+
}
|
|
162
|
+
} else if (
|
|
163
|
+
// Assigned nodes sit outside the label source, where its own
|
|
164
|
+
// form field check never looked.
|
|
165
|
+
(assigned.length && isUnsafeTextSource(node as Element)) ||
|
|
166
|
+
!read(node as Element)
|
|
167
|
+
) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
if (text.trim().length > MAX_RAW_LABEL_LENGTH) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
};
|
|
176
|
+
return read(root) ? text.trim() : undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// '' when the element can't name the button, undefined when its text can't be
|
|
180
|
+
// read in full.
|
|
181
|
+
function textOf(element: Element | null): string | undefined {
|
|
182
|
+
return element && !isUnsafeTextSource(element) && isShown(element)
|
|
183
|
+
? renderedText(element)
|
|
61
184
|
: '';
|
|
62
185
|
}
|
|
63
186
|
|
|
187
|
+
// The control itself is already known to be shown.
|
|
188
|
+
function shownImageAlt(control: Element): string | undefined {
|
|
189
|
+
const images = control.querySelectorAll('img[alt]');
|
|
190
|
+
for (let i = 0; i < images.length && i < MAX_READS; i++) {
|
|
191
|
+
if (isShown(images[i], control) && isVisible(images[i])) {
|
|
192
|
+
return nonBlank(images[i].getAttribute('alt'));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
64
198
|
function nonBlank(value: string | null): string | undefined {
|
|
65
199
|
return value && value.trim() ? value : undefined;
|
|
66
200
|
}
|
|
67
201
|
|
|
68
202
|
// The button's name as a screen reader would read it, simplified: the first of
|
|
69
|
-
// aria-labelledby, aria-label, own text (or an inner image's alt), title,
|
|
70
|
-
// inputs their value
|
|
71
|
-
//
|
|
72
|
-
// children, so a typed value is never read.
|
|
203
|
+
// aria-labelledby, aria-label, own text (or an inner image's alt), title, and
|
|
204
|
+
// for inputs their value. The text sources are skipped for controls that hold
|
|
205
|
+
// form fields or more than four children, so a typed value is never read.
|
|
73
206
|
function accessibleName(
|
|
74
207
|
control: Element
|
|
75
208
|
): { raw?: string; source: ButtonLabelSource } {
|
|
@@ -78,13 +211,15 @@ function accessibleName(
|
|
|
78
211
|
const root = (isFunction(control.getRootNode)
|
|
79
212
|
? control.getRootNode()
|
|
80
213
|
: document) as Document | ShadowRoot;
|
|
81
|
-
const
|
|
214
|
+
const parts = labelledBy
|
|
82
215
|
.split(/\s+/)
|
|
83
216
|
.map(id =>
|
|
84
217
|
textOf(root.getElementById ? root.getElementById(id) : null)
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
|
|
218
|
+
);
|
|
219
|
+
if (parts.indexOf(undefined) !== -1) {
|
|
220
|
+
return { source: 'none' };
|
|
221
|
+
}
|
|
222
|
+
const text = parts.filter(part => !!part).join(' ');
|
|
88
223
|
if (text) {
|
|
89
224
|
return { raw: text, source: 'labelledby' };
|
|
90
225
|
}
|
|
@@ -112,24 +247,16 @@ function accessibleName(
|
|
|
112
247
|
// What the browser shows on an input without a value.
|
|
113
248
|
return { raw: type, source: 'value' };
|
|
114
249
|
}
|
|
115
|
-
} else if (type === 'checkbox' || type === 'radio') {
|
|
116
|
-
// The label the browser ties to the box, never the box's value.
|
|
117
|
-
const label = control.labels ? control.labels[0] : undefined;
|
|
118
|
-
const text =
|
|
119
|
-
label && !isUnsafeLabelSource(label) && label.textContent
|
|
120
|
-
? label.textContent.trim()
|
|
121
|
-
: '';
|
|
122
|
-
if (text) {
|
|
123
|
-
return { raw: text, source: 'label' };
|
|
124
|
-
}
|
|
125
250
|
}
|
|
126
|
-
} else if (readable) {
|
|
127
|
-
const text =
|
|
251
|
+
} else if (readable && isShown(control)) {
|
|
252
|
+
const text = renderedText(control);
|
|
253
|
+
if (text === undefined) {
|
|
254
|
+
return { source: 'none' };
|
|
255
|
+
}
|
|
128
256
|
if (text) {
|
|
129
257
|
return { raw: text, source: 'text' };
|
|
130
258
|
}
|
|
131
|
-
const
|
|
132
|
-
const alt = image ? nonBlank(image.getAttribute('alt')) : undefined;
|
|
259
|
+
const alt = shownImageAlt(control);
|
|
133
260
|
if (alt) {
|
|
134
261
|
return { raw: alt, source: 'alt' };
|
|
135
262
|
}
|
|
@@ -248,9 +375,12 @@ function formIdentifier(control: Element): string | undefined {
|
|
|
248
375
|
}
|
|
249
376
|
|
|
250
377
|
// The browser's normalised type: a button defaults to 'submit' and an unknown
|
|
251
|
-
// input type reads as 'text'.
|
|
252
|
-
|
|
253
|
-
|
|
378
|
+
// input type reads as 'text'. A native button or input reports its own type, a
|
|
379
|
+
// wallet-named one included; any other control, such as a role button or a
|
|
380
|
+
// vendor's wallet element, reads as 'button'.
|
|
381
|
+
function controlType(control: Element): string {
|
|
382
|
+
const tag = control.tagName.toLowerCase();
|
|
383
|
+
return tag === 'button' || tag === 'input'
|
|
254
384
|
? (control as HTMLButtonElement | HTMLInputElement).type
|
|
255
385
|
: 'button';
|
|
256
386
|
}
|
|
@@ -300,10 +430,44 @@ function describeLink(link: Element): ILinkDetails | undefined {
|
|
|
300
430
|
: undefined;
|
|
301
431
|
}
|
|
302
432
|
|
|
433
|
+
// Checkboxes and radios, and the roles that act as them. A role can list
|
|
434
|
+
// fallbacks, so any of its tokens counts.
|
|
435
|
+
const CHOICE =
|
|
436
|
+
'input[type="checkbox"], input[type="radio"], [role~="checkbox"], [role~="radio"], ' +
|
|
437
|
+
'[role~="switch"], [role~="menuitemcheckbox"], [role~="menuitemradio"]';
|
|
438
|
+
const RADIO_ROLE = '[role~="radio"], [role~="menuitemradio"]';
|
|
439
|
+
|
|
440
|
+
function boxOf(element: Element): Element | null {
|
|
441
|
+
const box = element instanceof HTMLLabelElement ? element.control : element;
|
|
442
|
+
return box && box.matches(CHOICE) ? box : null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// The box a click on this control picks. One the click passed through below
|
|
446
|
+
// the control comes first: querySelector can't reach a slotted box, a box in a
|
|
447
|
+
// shadow root or the box of a label inside the control, and a row can hold
|
|
448
|
+
// more than one box. Else the control itself, the box it labels, or the first
|
|
449
|
+
// one it wraps, as on an option row whose name repeats the option's text.
|
|
450
|
+
function choiceOf(
|
|
451
|
+
control: Element,
|
|
452
|
+
path: Array<EventTarget | null>
|
|
453
|
+
): Element | null {
|
|
454
|
+
for (const target of path) {
|
|
455
|
+
if (target === control) {
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
const box = target instanceof Element ? boxOf(target) : null;
|
|
459
|
+
if (box) {
|
|
460
|
+
return box;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return boxOf(control) || control.querySelector(CHOICE);
|
|
464
|
+
}
|
|
465
|
+
|
|
303
466
|
// Describes a clicked control for auto button tracking. A link counts only when page
|
|
304
467
|
// views can't see the click, and carries its link type.
|
|
305
468
|
export function describeButtonClick(
|
|
306
|
-
control: Element
|
|
469
|
+
control: Element,
|
|
470
|
+
path: Array<EventTarget | null> = []
|
|
307
471
|
): IButtonClick | undefined {
|
|
308
472
|
let link: ILinkDetails | undefined;
|
|
309
473
|
if (control.matches('a[href]:not([role="button"])')) {
|
|
@@ -313,31 +477,57 @@ export function describeButtonClick(
|
|
|
313
477
|
}
|
|
314
478
|
}
|
|
315
479
|
const element = elementKind(control);
|
|
316
|
-
const
|
|
317
|
-
|
|
480
|
+
const box =
|
|
481
|
+
control instanceof HTMLInputElement &&
|
|
482
|
+
(control.type === 'checkbox' || control.type === 'radio')
|
|
483
|
+
? control
|
|
484
|
+
: undefined;
|
|
485
|
+
// A box's text sent with its state is the shopper's answer, and the text
|
|
486
|
+
// can be a name, an address or a sensitive choice, so a box goes by its
|
|
487
|
+
// hand-written identifiers alone. So does a control acting as one or
|
|
488
|
+
// wrapping one, and a label, whose click the browser passes on to its box
|
|
489
|
+
// as a second event.
|
|
490
|
+
const choice = box || choiceOf(control, path);
|
|
491
|
+
// A checkbox's identifiers name its question, which its state answers. A
|
|
492
|
+
// radio's id, test id and the panel it opens name the option picked, so
|
|
493
|
+
// only the name of its input, the question, goes with it. A wrapper's own
|
|
494
|
+
// name, or a role radio's, can name the option instead.
|
|
495
|
+
const radio =
|
|
496
|
+
choice instanceof HTMLInputElement
|
|
497
|
+
? choice.type === 'radio'
|
|
498
|
+
: !!choice && choice.matches(RADIO_ROLE);
|
|
499
|
+
const named = !radio
|
|
500
|
+
? control
|
|
501
|
+
: choice instanceof HTMLInputElement
|
|
502
|
+
? choice
|
|
503
|
+
: null;
|
|
504
|
+
const name = choice ? undefined : accessibleName(control);
|
|
505
|
+
const label = name ? maskLabel(name.raw) : undefined;
|
|
318
506
|
return {
|
|
319
507
|
label,
|
|
320
508
|
labelSource: label ? name.source : 'none',
|
|
321
509
|
// Kept on its own as well: it often says more than the visible text,
|
|
322
510
|
// such as which item a remove button removes.
|
|
323
|
-
ariaLabel:
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
511
|
+
ariaLabel: choice
|
|
512
|
+
? undefined
|
|
513
|
+
: maskLabel(control.getAttribute('aria-label')),
|
|
514
|
+
icon: choice ? undefined : iconHint(control),
|
|
515
|
+
elementId: radio
|
|
516
|
+
? undefined
|
|
517
|
+
: stableIdentifier(control.getAttribute('id')),
|
|
518
|
+
elementName: stableIdentifier(named && named.getAttribute('name')),
|
|
519
|
+
testId: radio
|
|
520
|
+
? undefined
|
|
521
|
+
: firstIdentifier(control, TEST_ID_ATTRIBUTES),
|
|
522
|
+
controls: radio ? undefined : controlsIdentifier(control),
|
|
329
523
|
form: formIdentifier(control),
|
|
330
524
|
element,
|
|
331
|
-
type: controlType(control
|
|
525
|
+
type: controlType(control),
|
|
332
526
|
linkType: link ? link.type : undefined,
|
|
333
527
|
linkDomain: link ? link.domain : undefined,
|
|
334
528
|
fileExtension: link ? link.fileExtension : undefined,
|
|
335
529
|
// The box as the click left it: the browser toggles it before the click
|
|
336
530
|
// reaches listeners, and puts it back if the click is cancelled.
|
|
337
|
-
checked:
|
|
338
|
-
control instanceof HTMLInputElement &&
|
|
339
|
-
(control.type === 'checkbox' || control.type === 'radio')
|
|
340
|
-
? control.checked
|
|
341
|
-
: undefined,
|
|
531
|
+
checked: box ? box.checked : undefined,
|
|
342
532
|
};
|
|
343
533
|
}
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
classifyIntent,
|
|
9
9
|
classifyIntentFrame,
|
|
10
10
|
} from './checkoutIntent';
|
|
11
|
-
import { findControl } from './controls';
|
|
11
|
+
import { findControl, isExcludedPath } from './controls';
|
|
12
12
|
|
|
13
13
|
// State lives on `window`, like APV, so a re-executed bundle never doubles the
|
|
14
14
|
// listeners. Inspect `window.__mpBtn__` in a console to see if one is live.
|
|
@@ -16,6 +16,8 @@ export const WIN_BTN_KEY = '__mpBtn__';
|
|
|
16
16
|
|
|
17
17
|
const THROTTLE_MS = 2000;
|
|
18
18
|
const SUBMIT_AFTER_COMMIT_CLICK_MS = 1000;
|
|
19
|
+
// Apple Pay must begin inside the tap's own handler, so the tap is this recent.
|
|
20
|
+
const APPLE_PAY_AFTER_CLICK_MS = 1000;
|
|
19
21
|
const PATH_LIMIT = 12;
|
|
20
22
|
// A ceiling, not a dedupe: a held Enter key or browser automation fires a
|
|
21
23
|
// trusted click per repeat, and each one is forwarded to every kit.
|
|
@@ -74,6 +76,61 @@ const afterNextPaint = (fn: () => void): void => {
|
|
|
74
76
|
}
|
|
75
77
|
};
|
|
76
78
|
|
|
79
|
+
// Apple Pay's session opens only from a shopper's tap, whatever button the page
|
|
80
|
+
// draws for it, so its opening is a wallet tap no label or name rule can miss.
|
|
81
|
+
// The wrapper keeps the page's own begin under this key, to put it back.
|
|
82
|
+
const APPLE_PAY_HOOK = '__mpApplePayHook__';
|
|
83
|
+
|
|
84
|
+
const applePayPrototype = (win: Window) => {
|
|
85
|
+
const session = win['ApplePaySession'];
|
|
86
|
+
return session && session.prototype;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Wraps ApplePaySession.prototype.begin once per window, so the active tracker
|
|
90
|
+
// hears that a session began. The wrapper reads no arguments and no payment
|
|
91
|
+
// data, and returns or throws exactly what the original does. Nothing here can
|
|
92
|
+
// throw into SDK init: a throwing getter or a frozen prototype leaves Apple Pay
|
|
93
|
+
// as it was.
|
|
94
|
+
const hookApplePay = (win: Window): void => {
|
|
95
|
+
try {
|
|
96
|
+
const prototype = applePayPrototype(win);
|
|
97
|
+
const begin = prototype && prototype.begin;
|
|
98
|
+
if (!isFunction(begin) || begin[APPLE_PAY_HOOK]) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const opened = function(this: unknown, ...args: unknown[]) {
|
|
102
|
+
const result = begin.apply(this, args);
|
|
103
|
+
try {
|
|
104
|
+
const tracker = getActiveButtonTracker();
|
|
105
|
+
if (tracker) {
|
|
106
|
+
tracker.onApplePayBegin();
|
|
107
|
+
}
|
|
108
|
+
} catch (e) {
|
|
109
|
+
// A session the page began is never disturbed by tracking.
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
};
|
|
113
|
+
// Not enumerable, so the marker doesn't show among the method's own keys.
|
|
114
|
+
Object.defineProperty(opened, APPLE_PAY_HOOK, { value: begin });
|
|
115
|
+
prototype.begin = opened;
|
|
116
|
+
} catch (e) {
|
|
117
|
+
// Apple Pay stays as the page set it up.
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Puts the page's own begin back, unless another script has wrapped it since.
|
|
122
|
+
const unhookApplePay = (win: Window): void => {
|
|
123
|
+
try {
|
|
124
|
+
const prototype = applePayPrototype(win);
|
|
125
|
+
const begin = prototype && prototype.begin;
|
|
126
|
+
if (isFunction(begin) && isFunction(begin[APPLE_PAY_HOOK])) {
|
|
127
|
+
prototype.begin = begin[APPLE_PAY_HOOK];
|
|
128
|
+
}
|
|
129
|
+
} catch (e) {
|
|
130
|
+
// Apple Pay stays as it is.
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
77
134
|
const CAPTURE_PASSIVE: AddEventListenerOptions = {
|
|
78
135
|
capture: true,
|
|
79
136
|
passive: true,
|
|
@@ -89,6 +146,14 @@ export class ButtonTracker {
|
|
|
89
146
|
// Bumped on reset so classification queued before it is dropped.
|
|
90
147
|
private generation = 0;
|
|
91
148
|
private lastCommitClick = -Infinity;
|
|
149
|
+
// The latest trusted click: whether the page excluded it, and the intent it
|
|
150
|
+
// already carries to the kit, with its source, if any.
|
|
151
|
+
private lastClick: {
|
|
152
|
+
t: number;
|
|
153
|
+
excluded: boolean;
|
|
154
|
+
kind: CheckoutIntentKind | undefined;
|
|
155
|
+
source: CheckoutIntentSource | undefined;
|
|
156
|
+
} | null = null;
|
|
92
157
|
private lastSent: { [kind: string]: { t: number; pathname: string } } = {};
|
|
93
158
|
private pagePath: string | null = null;
|
|
94
159
|
private pageEvents = 0;
|
|
@@ -144,9 +209,54 @@ export class ButtonTracker {
|
|
|
144
209
|
win[WIN_BTN_KEY] = {};
|
|
145
210
|
}
|
|
146
211
|
win[WIN_BTN_KEY].tracker = this;
|
|
212
|
+
// Only where checkout intent can reach the kit; a shopper who opts back in mid-page gets no session
|
|
213
|
+
// signal until the next page load.
|
|
214
|
+
if (!this.isKitSuppressed()) {
|
|
215
|
+
hookApplePay(win);
|
|
216
|
+
}
|
|
147
217
|
this.log('listening');
|
|
148
218
|
}
|
|
149
219
|
|
|
220
|
+
// An Apple Pay session begun right after a tracked tap counts as a wallet
|
|
221
|
+
// tap for the kit; one begun with no such tap, or from a tap the page
|
|
222
|
+
// excluded, does not. A tap that reads as a confirm tap reaches the kit as
|
|
223
|
+
// one. It is delivered on a timer, not after the next paint, since the
|
|
224
|
+
// payment sheet may hold painting back: a tap that already reads as a
|
|
225
|
+
// wallet goes now under its own rule, and the throttle drops the copy
|
|
226
|
+
// queued for the paint.
|
|
227
|
+
public onApplePayBegin(): void {
|
|
228
|
+
const t = Date.now();
|
|
229
|
+
const click = this.lastClick;
|
|
230
|
+
if (
|
|
231
|
+
!this.active ||
|
|
232
|
+
!click ||
|
|
233
|
+
click.excluded ||
|
|
234
|
+
click.kind === 'commit' ||
|
|
235
|
+
t - click.t > APPLE_PAY_AFTER_CLICK_MS ||
|
|
236
|
+
this.isKitSuppressed()
|
|
237
|
+
) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const pathname = window.location.pathname;
|
|
241
|
+
const generation = this.generation;
|
|
242
|
+
setTimeout(() => {
|
|
243
|
+
if (generation !== this.generation) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
this.emit(
|
|
248
|
+
'wallet',
|
|
249
|
+
click.kind === 'wallet' ? click.source : 'api',
|
|
250
|
+
'apple_pay',
|
|
251
|
+
t,
|
|
252
|
+
pathname
|
|
253
|
+
);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
this.log('[error] Apple Pay delivery threw: ' + e);
|
|
256
|
+
}
|
|
257
|
+
}, 0);
|
|
258
|
+
}
|
|
259
|
+
|
|
150
260
|
public teardown(): void {
|
|
151
261
|
const win = btnWindow();
|
|
152
262
|
if (win) {
|
|
@@ -165,6 +275,7 @@ export class ButtonTracker {
|
|
|
165
275
|
const state = win[WIN_BTN_KEY];
|
|
166
276
|
if (state && state.tracker === this) {
|
|
167
277
|
state.tracker = undefined;
|
|
278
|
+
unhookApplePay(win);
|
|
168
279
|
}
|
|
169
280
|
}
|
|
170
281
|
this.active = false;
|
|
@@ -186,6 +297,7 @@ export class ButtonTracker {
|
|
|
186
297
|
this.generation++;
|
|
187
298
|
this.pending = [];
|
|
188
299
|
this.lastCommitClick = -Infinity;
|
|
300
|
+
this.lastClick = null;
|
|
189
301
|
this.lastSent = {};
|
|
190
302
|
this.pagePath = null;
|
|
191
303
|
this.pageEvents = 0;
|
|
@@ -266,13 +378,15 @@ export class ButtonTracker {
|
|
|
266
378
|
let unconfirmedCommit = false;
|
|
267
379
|
if (layer === 'click') {
|
|
268
380
|
// The composed path is only populated during dispatch.
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
);
|
|
381
|
+
const path = isFunction(event.composedPath)
|
|
382
|
+
? event.composedPath().slice(0, PATH_LIMIT)
|
|
383
|
+
: [];
|
|
384
|
+
const excluded = isExcludedPath(path);
|
|
385
|
+
const control = excluded ? undefined : findControl(path, true);
|
|
274
386
|
if (control) {
|
|
275
|
-
click = logsEvents
|
|
387
|
+
click = logsEvents
|
|
388
|
+
? describeButtonClick(control, path)
|
|
389
|
+
: undefined;
|
|
276
390
|
if (click && click.checked !== undefined) {
|
|
277
391
|
box = control as HTMLInputElement;
|
|
278
392
|
}
|
|
@@ -283,6 +397,7 @@ export class ButtonTracker {
|
|
|
283
397
|
kind = intent ? intent.kind : undefined;
|
|
284
398
|
source = intent ? intent.source : undefined;
|
|
285
399
|
}
|
|
400
|
+
this.lastClick = { t, excluded, kind, source };
|
|
286
401
|
} else if (layer === 'submit') {
|
|
287
402
|
const submitter = (event as Event & { submitter?: Element | null })
|
|
288
403
|
.submitter;
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
isQuietIntentControl,
|
|
5
5
|
isRoktElement,
|
|
6
6
|
isUnsafeTextSource,
|
|
7
|
+
isWalletNamedControl,
|
|
7
8
|
vendorButtonKind,
|
|
8
9
|
} from './controls';
|
|
9
10
|
|
|
@@ -13,7 +14,8 @@ export type CheckoutIntentKind =
|
|
|
13
14
|
| 'commit'
|
|
14
15
|
| 'wallet'
|
|
15
16
|
| 'checkout_step'
|
|
16
|
-
| 'frame'
|
|
17
|
+
| 'frame'
|
|
18
|
+
| 'cart';
|
|
17
19
|
|
|
18
20
|
const checkoutPath = /(?:^|\/)(?:checkouts?|payment|order|kasse|paiement|commander?|pago)(?:\/|$)/i;
|
|
19
21
|
// English look-alikes are whole words; the other languages stay substring
|
|
@@ -79,6 +81,14 @@ const steps = new RegExp(
|
|
|
79
81
|
].join('|') +
|
|
80
82
|
')$'
|
|
81
83
|
);
|
|
84
|
+
const CART_NOUN = '(?:(?:my|your|the) )?(?:shopping )?(?:cart|bag|basket)';
|
|
85
|
+
// "Add to bag", "View cart", "Shopping bag", "Bag", and a count badge such as
|
|
86
|
+
// "1 item in bag", which reads "item in bag" once digits are stripped.
|
|
87
|
+
const cartLabels = new RegExp(
|
|
88
|
+
'^(?:add(?: [^ ]+){0,5} to ' + CART_NOUN +
|
|
89
|
+
'|(?:(?:view|open|go to) )?' + CART_NOUN + '(?: items?)?' +
|
|
90
|
+
'|items? in ' + CART_NOUN + ')$'
|
|
91
|
+
);
|
|
82
92
|
const WALLET_NAMES =
|
|
83
93
|
'apple pay|google pay|paypal|venmo|amazon pay|shop pay|klarna|afterpay|clearpay|cash app(?: pay)?|affirm|sezzle|samsung pay';
|
|
84
94
|
const WALLET_VERBS =
|
|
@@ -200,10 +210,25 @@ function labels(element: Element): string[] {
|
|
|
200
210
|
.filter((label): label is string => !!label);
|
|
201
211
|
}
|
|
202
212
|
|
|
213
|
+
// A cart button is often a bare icon, so its title names it when nothing else
|
|
214
|
+
// does. Only the cart rule reads the title.
|
|
215
|
+
function isCartControl(control: Element, cores: string[]): boolean {
|
|
216
|
+
const title = cores.length ? undefined : normalize(control.getAttribute('title'));
|
|
217
|
+
return (title ? [coreLabel(title)] : cores).some(text => cartLabels.test(text));
|
|
218
|
+
}
|
|
219
|
+
|
|
203
220
|
// Which rule recognised the intent: a vendor's own markup, a phrase in the
|
|
204
|
-
// label, a wallet's bare name, a hosted frame,
|
|
205
|
-
//
|
|
206
|
-
|
|
221
|
+
// label, a wallet's bare name, a hosted frame, a form submitted after a confirm
|
|
222
|
+
// click, a merchant button named after its wallet, or a payment sheet the page
|
|
223
|
+
// opened.
|
|
224
|
+
export type CheckoutIntentSource =
|
|
225
|
+
| 'vendor'
|
|
226
|
+
| 'label'
|
|
227
|
+
| 'brand'
|
|
228
|
+
| 'frame'
|
|
229
|
+
| 'submit'
|
|
230
|
+
| 'name'
|
|
231
|
+
| 'api';
|
|
207
232
|
|
|
208
233
|
export interface ICheckoutIntent {
|
|
209
234
|
kind: Exclude<CheckoutIntentKind, 'frame'>;
|
|
@@ -226,20 +251,34 @@ function isCommitLabel(text: string, isLink: boolean, pathname: string): boolean
|
|
|
226
251
|
// Classifies a clicked or submitting control found by findControl, in order:
|
|
227
252
|
// pickers, toggles and pay-later messages never count; a vendor's own wallet
|
|
228
253
|
// button counts before any label rule; a confirm label on any source counts
|
|
229
|
-
// before the look-alike exclusions veto; then wallet
|
|
254
|
+
// before the look-alike exclusions veto; then a wallet-named control, wallet
|
|
255
|
+
// phrases and steps. A cart label, links included, counts only where none of
|
|
256
|
+
// those do.
|
|
230
257
|
export function classifyIntent(
|
|
231
258
|
control: Element,
|
|
232
259
|
pathname: string
|
|
233
260
|
): ICheckoutIntent | undefined {
|
|
234
261
|
const vendor = vendorButtonKind(control);
|
|
235
|
-
if (
|
|
262
|
+
if (vendor === 'none') {
|
|
236
263
|
return undefined;
|
|
237
264
|
}
|
|
238
265
|
if (vendor) {
|
|
239
|
-
return
|
|
266
|
+
return isQuietIntentControl(control, vendor === 'wallet')
|
|
267
|
+
? undefined
|
|
268
|
+
: { kind: vendor, source: 'vendor' };
|
|
240
269
|
}
|
|
241
270
|
const texts = labels(control);
|
|
242
271
|
const cores = texts.map(coreLabel);
|
|
272
|
+
const cart: ICheckoutIntent | undefined = isCartControl(control, cores)
|
|
273
|
+
? { kind: 'cart', source: 'label' }
|
|
274
|
+
: undefined;
|
|
275
|
+
if (isQuietIntentControl(control, false, !!cart)) {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
// A bag button that opens a flyout counts only as a cart click.
|
|
279
|
+
if (cart && control.hasAttribute('aria-expanded')) {
|
|
280
|
+
return cart;
|
|
281
|
+
}
|
|
243
282
|
const isLink = control.matches('a[href]:not([role="button"])');
|
|
244
283
|
if (cores.some(text => isCommitLabel(text, isLink, pathname))) {
|
|
245
284
|
return { kind: 'commit', source: 'label' };
|
|
@@ -249,7 +288,10 @@ export function classifyIntent(
|
|
|
249
288
|
.concat(cores)
|
|
250
289
|
.some(text => englishExclusions.test(text) || otherExclusions.test(text))
|
|
251
290
|
) {
|
|
252
|
-
return
|
|
291
|
+
return cart;
|
|
292
|
+
}
|
|
293
|
+
if (isWalletNamedControl(control)) {
|
|
294
|
+
return { kind: 'wallet', source: 'name' };
|
|
253
295
|
}
|
|
254
296
|
const isButton = !isLink && control.matches(CONTROL_SELECTOR);
|
|
255
297
|
for (const text of cores) {
|
|
@@ -263,7 +305,7 @@ export function classifyIntent(
|
|
|
263
305
|
return { kind: 'checkout_step', source: 'label' };
|
|
264
306
|
}
|
|
265
307
|
}
|
|
266
|
-
return
|
|
308
|
+
return cart;
|
|
267
309
|
}
|
|
268
310
|
|
|
269
311
|
export function classifyIntentControl(
|