@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.
Files changed (37) hide show
  1. package/dist/mparticle.common.js +1 -1669
  2. package/dist/mparticle.esm.js +1 -1669
  3. package/dist/mparticle.js +4827 -4184
  4. package/dist/types/src/buttonTracking/buttonClickEvent.d.ts +4 -0
  5. package/dist/types/src/buttonTracking/buttonDescription.d.ts +2 -2
  6. package/dist/types/src/buttonTracking/buttonTracker.d.ts +2 -0
  7. package/dist/types/src/buttonTracking/checkoutIntent.d.ts +2 -2
  8. package/dist/types/src/buttonTracking/controls.d.ts +6 -3
  9. package/dist/types/src/buttonTracking/labelMasking.d.ts +1 -0
  10. package/dist/types/src/configAPIClient.d.ts +1 -0
  11. package/dist/types/src/forwarder-utils.d.ts +3 -0
  12. package/dist/types/src/pageUrl.d.ts +10 -0
  13. package/dist/types/src/pageViewTracker.d.ts +21 -3
  14. package/dist/types/src/roktManager.d.ts +8 -2
  15. package/dist/types/src/sdkRuntimeModels.d.ts +3 -0
  16. package/dist/types/src/store.d.ts +2 -0
  17. package/package.json +21 -14
  18. package/src/batchUploader.ts +3 -2
  19. package/src/buttonTracking/buttonClickEvent.ts +76 -1
  20. package/src/buttonTracking/buttonDescription.ts +236 -46
  21. package/src/buttonTracking/buttonTracker.ts +122 -7
  22. package/src/buttonTracking/checkoutIntent.ts +51 -9
  23. package/src/buttonTracking/controls.ts +160 -50
  24. package/src/buttonTracking/labelMasking.ts +4 -1
  25. package/src/configAPIClient.ts +1 -0
  26. package/src/events.ts +9 -3
  27. package/src/forwarder-utils.ts +38 -0
  28. package/src/forwarders.ts +2 -28
  29. package/src/mp-instance.ts +23 -10
  30. package/src/mparticle-instance-manager.ts +3 -0
  31. package/src/pageUrl.ts +191 -0
  32. package/src/pageViewTracker.ts +206 -78
  33. package/src/roktManager.ts +53 -20
  34. package/src/sdkRuntimeModels.ts +3 -0
  35. package/src/serverModel.ts +3 -3
  36. package/src/store.ts +14 -0
  37. 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
- function textOf(element: Element | null): string {
59
- return element && !isUnsafeTextSource(element) && element.textContent
60
- ? element.textContent.trim()
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, for
70
- // inputs their value, and for checkboxes and radios their label's text. The text
71
- // sources are skipped for controls that hold form fields or more than four
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 text = labelledBy
214
+ const parts = labelledBy
82
215
  .split(/\s+/)
83
216
  .map(id =>
84
217
  textOf(root.getElementById ? root.getElementById(id) : null)
85
- )
86
- .filter(part => !!part)
87
- .join(' ');
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 = textOf(control);
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 image = control.querySelector('img[alt]');
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'. Wallet and role buttons are just 'button'.
252
- function controlType(control: Element, element: ButtonElementKind): string {
253
- return element === 'button' || element === 'input'
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 name = accessibleName(control);
317
- const label = maskLabel(name.raw);
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: maskLabel(control.getAttribute('aria-label')),
324
- icon: iconHint(control),
325
- elementId: stableIdentifier(control.getAttribute('id')),
326
- elementName: stableIdentifier(control.getAttribute('name')),
327
- testId: firstIdentifier(control, TEST_ID_ATTRIBUTES),
328
- controls: controlsIdentifier(control),
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, element),
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 control = findControl(
270
- isFunction(event.composedPath)
271
- ? event.composedPath().slice(0, PATH_LIMIT)
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 ? describeButtonClick(control) : undefined;
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, or a form submitted after a
205
- // confirm click.
206
- export type CheckoutIntentSource = 'vendor' | 'label' | 'brand' | 'frame' | 'submit';
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 phrases and steps.
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 (isQuietIntentControl(control, vendor === 'wallet') || vendor === 'none') {
262
+ if (vendor === 'none') {
236
263
  return undefined;
237
264
  }
238
265
  if (vendor) {
239
- return { kind: vendor, source: 'vendor' };
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 undefined;
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 undefined;
308
+ return cart;
267
309
  }
268
310
 
269
311
  export function classifyIntentControl(