@mparticle/web-sdk 3.23.0 → 3.25.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 (39) hide show
  1. package/dist/mparticle.common.js +1 -1688
  2. package/dist/mparticle.esm.js +1 -1688
  3. package/dist/mparticle.js +1015 -253
  4. package/dist/types/src/buttonTracking/buttonClickEvent.d.ts +7 -0
  5. package/dist/types/src/buttonTracking/buttonDescription.d.ts +10 -3
  6. package/dist/types/src/buttonTracking/buttonTracker.d.ts +8 -0
  7. package/dist/types/src/buttonTracking/checkoutIntent.d.ts +8 -3
  8. package/dist/types/src/buttonTracking/controls.d.ts +8 -4
  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/constants.d.ts +1 -0
  12. package/dist/types/src/cookieConsentManager.d.ts +17 -3
  13. package/dist/types/src/forwarder-utils.d.ts +3 -0
  14. package/dist/types/src/pageViewTracker.d.ts +19 -3
  15. package/dist/types/src/roktLauncherOptions.d.ts +1 -0
  16. package/dist/types/src/roktManager.d.ts +13 -2
  17. package/dist/types/src/sdkRuntimeModels.d.ts +1 -0
  18. package/dist/types/src/store.d.ts +3 -0
  19. package/package.json +21 -14
  20. package/src/buttonTracking/buttonClickEvent.ts +126 -8
  21. package/src/buttonTracking/buttonDescription.ts +336 -53
  22. package/src/buttonTracking/buttonTracker.ts +231 -19
  23. package/src/buttonTracking/checkoutIntent.ts +117 -23
  24. package/src/buttonTracking/controls.ts +169 -55
  25. package/src/buttonTracking/labelMasking.ts +4 -1
  26. package/src/configAPIClient.ts +1 -0
  27. package/src/constants.ts +1 -0
  28. package/src/cookieConsentManager.ts +23 -3
  29. package/src/cookieSyncManager.ts +11 -3
  30. package/src/events.ts +9 -3
  31. package/src/forwarder-utils.ts +38 -0
  32. package/src/forwarders.ts +2 -28
  33. package/src/mp-instance.ts +41 -7
  34. package/src/pageUrl.ts +43 -2
  35. package/src/pageViewTracker.ts +193 -111
  36. package/src/roktLauncherOptions.ts +1 -0
  37. package/src/roktManager.ts +66 -11
  38. package/src/sdkRuntimeModels.ts +1 -0
  39. package/src/store.ts +17 -0
@@ -1,14 +1,22 @@
1
1
  import { IMParticleWebSDKInstance } from '../mp-instance';
2
2
  import { isFunction } from '../utils';
3
- import { buildButtonClickEvent } from './buttonClickEvent';
4
- import { describeButtonClick, IButtonClick } from './buttonDescription';
3
+ import { buildButtonClickEvent, buildLinkClickEvent } from './buttonClickEvent';
4
+ import {
5
+ describeButtonClick,
6
+ describePaymentChoice,
7
+ IButtonClick,
8
+ IPaymentChoice,
9
+ isLinkClick,
10
+ isPicked,
11
+ } from './buttonDescription';
5
12
  import {
6
13
  CheckoutIntentKind,
7
14
  CheckoutIntentSource,
8
15
  classifyIntent,
9
- classifyIntentFrame,
16
+ classifyPaymentFrame,
17
+ PaymentFrameFamily,
10
18
  } from './checkoutIntent';
11
- import { findControl } from './controls';
19
+ import { findControl, isExcludedPath } from './controls';
12
20
 
13
21
  // State lives on `window`, like APV, so a re-executed bundle never doubles the
14
22
  // listeners. Inspect `window.__mpBtn__` in a console to see if one is live.
@@ -16,6 +24,8 @@ export const WIN_BTN_KEY = '__mpBtn__';
16
24
 
17
25
  const THROTTLE_MS = 2000;
18
26
  const SUBMIT_AFTER_COMMIT_CLICK_MS = 1000;
27
+ // Apple Pay must begin inside the tap's own handler, so the tap is this recent.
28
+ const APPLE_PAY_AFTER_CLICK_MS = 1000;
19
29
  const PATH_LIMIT = 12;
20
30
  // A ceiling, not a dedupe: a held Enter key or browser automation fires a
21
31
  // trusted click per repeat, and each one is forwarded to every kit.
@@ -35,6 +45,9 @@ type WindowWithBtn = Window & {
35
45
  export interface IButtonTrackerOptions {
36
46
  // The Rokt kit signal only runs when this init configures a Rokt kit.
37
47
  hasRoktKit: boolean;
48
+ // Button clicks and clicks on links that leave the page are logged apart.
49
+ logButtons: boolean;
50
+ logLinks: boolean;
38
51
  }
39
52
 
40
53
  const btnWindow = (): WindowWithBtn | null =>
@@ -74,21 +87,87 @@ const afterNextPaint = (fn: () => void): void => {
74
87
  }
75
88
  };
76
89
 
90
+ // Apple Pay's session opens only from a shopper's tap, whatever button the page
91
+ // draws for it, so its opening is a wallet tap no label or name rule can miss.
92
+ // The wrapper keeps the page's own begin under this key, to put it back.
93
+ const APPLE_PAY_HOOK = '__mpApplePayHook__';
94
+
95
+ const applePayPrototype = (win: Window) => {
96
+ const session = win['ApplePaySession'];
97
+ return session && session.prototype;
98
+ };
99
+
100
+ // Wraps ApplePaySession.prototype.begin once per window, so the active tracker
101
+ // hears that a session began. The wrapper reads no arguments and no payment
102
+ // data, and returns or throws exactly what the original does. Nothing here can
103
+ // throw into SDK init: a throwing getter or a frozen prototype leaves Apple Pay
104
+ // as it was.
105
+ const hookApplePay = (win: Window): void => {
106
+ try {
107
+ const prototype = applePayPrototype(win);
108
+ const begin = prototype && prototype.begin;
109
+ if (!isFunction(begin) || begin[APPLE_PAY_HOOK]) {
110
+ return;
111
+ }
112
+ const opened = function(this: unknown, ...args: unknown[]) {
113
+ const result = begin.apply(this, args);
114
+ try {
115
+ const tracker = getActiveButtonTracker();
116
+ if (tracker) {
117
+ tracker.onApplePayBegin();
118
+ }
119
+ } catch (e) {
120
+ // A session the page began is never disturbed by tracking.
121
+ }
122
+ return result;
123
+ };
124
+ // Not enumerable, so the marker doesn't show among the method's own keys.
125
+ Object.defineProperty(opened, APPLE_PAY_HOOK, { value: begin });
126
+ prototype.begin = opened;
127
+ } catch (e) {
128
+ // Apple Pay stays as the page set it up.
129
+ }
130
+ };
131
+
132
+ // Puts the page's own begin back, unless another script has wrapped it since.
133
+ const unhookApplePay = (win: Window): void => {
134
+ try {
135
+ const prototype = applePayPrototype(win);
136
+ const begin = prototype && prototype.begin;
137
+ if (isFunction(begin) && isFunction(begin[APPLE_PAY_HOOK])) {
138
+ prototype.begin = begin[APPLE_PAY_HOOK];
139
+ }
140
+ } catch (e) {
141
+ // Apple Pay stays as it is.
142
+ }
143
+ };
144
+
77
145
  const CAPTURE_PASSIVE: AddEventListenerOptions = {
78
146
  capture: true,
79
147
  passive: true,
80
148
  };
81
149
  const PASSIVE: AddEventListenerOptions = { passive: true };
82
150
 
83
- // Logs every button click as an mParticle event, and hands checkout intent to the
84
- // Rokt kit as a frozen {kind, t, source, pathname}. Submits and wallet frame focus feed the kit only.
151
+ // Logs button clicks and clicks on links that leave the page as mParticle events, and hands checkout
152
+ // intent to the Rokt kit as a frozen {kind, t, source, pathname}. Submits and wallet frame focus feed the kit only,
153
+ // and so do payment picks: a payment radio's option names, or the payment family of a focused frame.
85
154
  export class ButtonTracker {
86
155
  private readonly mpInstance: IMParticleWebSDKInstance;
87
156
  private active = false;
88
157
  private hasRoktKit = false;
158
+ private logButtons = false;
159
+ private logLinks = false;
89
160
  // Bumped on reset so classification queued before it is dropped.
90
161
  private generation = 0;
91
162
  private lastCommitClick = -Infinity;
163
+ // The latest trusted click: whether the page excluded it, and the intent it
164
+ // already carries to the kit, with its source, if any.
165
+ private lastClick: {
166
+ t: number;
167
+ excluded: boolean;
168
+ kind: CheckoutIntentKind | undefined;
169
+ source: CheckoutIntentSource | undefined;
170
+ } | null = null;
92
171
  private lastSent: { [kind: string]: { t: number; pathname: string } } = {};
93
172
  private pagePath: string | null = null;
94
173
  private pageEvents = 0;
@@ -121,8 +200,13 @@ export class ButtonTracker {
121
200
  this.teardown();
122
201
  }
123
202
  this.hasRoktKit = options.hasRoktKit;
203
+ this.logButtons = options.logButtons;
204
+ this.logLinks = options.logLinks;
124
205
 
125
- if (!this.eventsEnabled() && this.isKitSuppressed()) {
206
+ if (
207
+ (!(this.logButtons || this.logLinks) || !this.eventsEnabled()) &&
208
+ this.isKitSuppressed()
209
+ ) {
126
210
  this.log('opted out, not starting');
127
211
  return;
128
212
  }
@@ -144,9 +228,54 @@ export class ButtonTracker {
144
228
  win[WIN_BTN_KEY] = {};
145
229
  }
146
230
  win[WIN_BTN_KEY].tracker = this;
231
+ // Only where checkout intent can reach the kit; a shopper who opts back in mid-page gets no session
232
+ // signal until the next page load.
233
+ if (!this.isKitSuppressed()) {
234
+ hookApplePay(win);
235
+ }
147
236
  this.log('listening');
148
237
  }
149
238
 
239
+ // An Apple Pay session begun right after a tracked tap counts as a wallet
240
+ // tap for the kit; one begun with no such tap, or from a tap the page
241
+ // excluded, does not. A tap that reads as a confirm tap reaches the kit as
242
+ // one. It is delivered on a timer, not after the next paint, since the
243
+ // payment sheet may hold painting back: a tap that already reads as a
244
+ // wallet goes now under its own rule, and the throttle drops the copy
245
+ // queued for the paint.
246
+ public onApplePayBegin(): void {
247
+ const t = Date.now();
248
+ const click = this.lastClick;
249
+ if (
250
+ !this.active ||
251
+ !click ||
252
+ click.excluded ||
253
+ click.kind === 'commit' ||
254
+ t - click.t > APPLE_PAY_AFTER_CLICK_MS ||
255
+ this.isKitSuppressed()
256
+ ) {
257
+ return;
258
+ }
259
+ const pathname = window.location.pathname;
260
+ const generation = this.generation;
261
+ setTimeout(() => {
262
+ if (generation !== this.generation) {
263
+ return;
264
+ }
265
+ try {
266
+ this.emit(
267
+ 'wallet',
268
+ click.kind === 'wallet' ? click.source : 'api',
269
+ 'apple_pay',
270
+ t,
271
+ pathname
272
+ );
273
+ } catch (e) {
274
+ this.log('[error] Apple Pay delivery threw: ' + e);
275
+ }
276
+ }, 0);
277
+ }
278
+
150
279
  public teardown(): void {
151
280
  const win = btnWindow();
152
281
  if (win) {
@@ -165,6 +294,7 @@ export class ButtonTracker {
165
294
  const state = win[WIN_BTN_KEY];
166
295
  if (state && state.tracker === this) {
167
296
  state.tracker = undefined;
297
+ unhookApplePay(win);
168
298
  }
169
299
  }
170
300
  this.active = false;
@@ -186,6 +316,7 @@ export class ButtonTracker {
186
316
  this.generation++;
187
317
  this.pending = [];
188
318
  this.lastCommitClick = -Infinity;
319
+ this.lastClick = null;
189
320
  this.lastSent = {};
190
321
  this.pagePath = null;
191
322
  this.pageEvents = 0;
@@ -246,7 +377,11 @@ export class ButtonTracker {
246
377
  return;
247
378
  }
248
379
  const layer = event.type;
249
- const logsEvents = layer === 'click' && this.eventsEnabled();
380
+ const logsEvents =
381
+ layer === 'click' &&
382
+ (this.logButtons || this.logLinks) &&
383
+ this.eventsEnabled() &&
384
+ !this.hasTargetingOptOut();
250
385
  const signalsKit = !this.isKitSuppressed();
251
386
  if (!logsEvents && !signalsKit) {
252
387
  return;
@@ -260,19 +395,43 @@ export class ButtonTracker {
260
395
  // button that disables or relabels itself is seen as it was clicked.
261
396
  // Logging and the kit hand-off wait for the next paint.
262
397
  let click: IButtonClick | undefined;
398
+ let asLink = false;
263
399
  let box: HTMLInputElement | undefined;
264
400
  let kind: CheckoutIntentKind | undefined;
265
401
  let source: CheckoutIntentSource | undefined;
402
+ let choice: IPaymentChoice | undefined;
403
+ let family: PaymentFrameFamily | undefined;
266
404
  let unconfirmedCommit = false;
267
405
  if (layer === 'click') {
268
406
  // 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
- );
407
+ const path = isFunction(event.composedPath)
408
+ ? event.composedPath().slice(0, PATH_LIMIT)
409
+ : [];
410
+ const excluded = isExcludedPath(path);
411
+ const control = excluded ? undefined : findControl(path, true);
274
412
  if (control) {
275
- click = logsEvents ? describeButtonClick(control) : undefined;
413
+ // With links alone, only an anchor can be logged.
414
+ click =
415
+ logsEvents &&
416
+ (this.logButtons || control.matches('a[href]'))
417
+ ? describeButtonClick(control, path)
418
+ : undefined;
419
+ if (click) {
420
+ asLink = this.logLinks && isLinkClick(click);
421
+ // Without link tracking, a link that leaves the page stays a
422
+ // button click, except a plain link to another page of the
423
+ // site, which button tracking never logged.
424
+ const asButton =
425
+ this.logButtons &&
426
+ !asLink &&
427
+ !(
428
+ click.linkType === 'internal' &&
429
+ control.matches('a[href]:not([role="button"])')
430
+ );
431
+ if (!asLink && !asButton) {
432
+ click = undefined;
433
+ }
434
+ }
276
435
  if (click && click.checked !== undefined) {
277
436
  box = control as HTMLInputElement;
278
437
  }
@@ -282,7 +441,11 @@ export class ButtonTracker {
282
441
  : undefined;
283
442
  kind = intent ? intent.kind : undefined;
284
443
  source = intent ? intent.source : undefined;
444
+ choice = signalsKit
445
+ ? describePaymentChoice(control, path)
446
+ : undefined;
285
447
  }
448
+ this.lastClick = { t, excluded, kind, source };
286
449
  } else if (layer === 'submit') {
287
450
  const submitter = (event as Event & { submitter?: Element | null })
288
451
  .submitter;
@@ -309,7 +472,12 @@ export class ButtonTracker {
309
472
  if (layer === 'blur') {
310
473
  // Read after the blur settles: some browsers have not moved
311
474
  // focus into the frame yet when the window blurs.
312
- kind = classifyIntentFrame(document.activeElement, window);
475
+ const frame = classifyPaymentFrame(
476
+ document.activeElement,
477
+ window
478
+ );
479
+ kind = frame ? frame.kind : undefined;
480
+ family = frame ? frame.family : undefined;
313
481
  source = 'frame';
314
482
  } else if (
315
483
  unconfirmedCommit &&
@@ -329,7 +497,14 @@ export class ButtonTracker {
329
497
  ? !click.checked
330
498
  : box.checked;
331
499
  }
332
- this.logClick(click, pathname);
500
+ this.logClick(click, pathname, asLink);
501
+ }
502
+ // Ahead of the intent, so a pre-selection it fires goes out
503
+ // with this pick.
504
+ if (choice && isPicked(choice.radio)) {
505
+ this.pickPayment(t, pathname, choice.names);
506
+ } else if (family) {
507
+ this.pickPayment(t, pathname, undefined, family);
333
508
  }
334
509
  if (kind) {
335
510
  this.emit(kind, source, layer, t, pathname);
@@ -341,8 +516,12 @@ export class ButtonTracker {
341
516
  }
342
517
 
343
518
  // Every real click is one event, repeats included, up to a ceiling per page.
344
- private logClick(click: IButtonClick, pathname: string): void {
345
- if (!this.eventsEnabled()) {
519
+ private logClick(
520
+ click: IButtonClick,
521
+ pathname: string,
522
+ asLink: boolean
523
+ ): void {
524
+ if (!this.eventsEnabled() || this.hasTargetingOptOut()) {
346
525
  return;
347
526
  }
348
527
  if (pathname !== this.pagePath) {
@@ -357,7 +536,9 @@ export class ButtonTracker {
357
536
  this.mpInstance._SessionManager.resetSessionTimer();
358
537
  const queued = this.queuedForUpload();
359
538
  this.mpInstance._Events.logEvent(
360
- buildButtonClickEvent(click, pathname)
539
+ asLink
540
+ ? buildLinkClickEvent(click, pathname)
541
+ : buildButtonClickEvent(click, pathname)
361
542
  );
362
543
  // Only a click waiting for the batch interval is sent early: a held
363
544
  // one goes out when released, and with batching off it already has.
@@ -431,12 +612,43 @@ export class ButtonTracker {
431
612
  );
432
613
  }
433
614
 
615
+ private pickPayment(
616
+ t: number,
617
+ pathname: string,
618
+ names?: string[],
619
+ family?: PaymentFrameFamily
620
+ ): void {
621
+ if (this.isKitSuppressed()) {
622
+ return;
623
+ }
624
+ this.mpInstance._RoktManager.notifyPaymentPick(
625
+ Object.freeze({
626
+ t,
627
+ pathname,
628
+ names: names ? Object.freeze(names.slice()) : undefined,
629
+ family,
630
+ })
631
+ );
632
+ }
633
+
434
634
  // Events follow the SDK's opt-out like any other event.
435
635
  private eventsEnabled(): boolean {
436
636
  const store = this.mpInstance._Store;
437
637
  return !!store && store.isEnabled;
438
638
  }
439
639
 
640
+ // Read per click, since the launcher options can arrive after the tracker
641
+ // starts. Only where this init configures a Rokt kit, so Global Privacy
642
+ // Control alone doesn't stop click logging elsewhere.
643
+ private hasTargetingOptOut(): boolean {
644
+ const roktManager = this.mpInstance._RoktManager;
645
+ return (
646
+ this.hasRoktKit &&
647
+ !!roktManager &&
648
+ roktManager.hasTargetingOptOut()
649
+ );
650
+ }
651
+
440
652
  // The Rokt manager owns the kit's privacy gates: flag, opt-out, launcher
441
653
  // options and Global Privacy Control.
442
654
  private isKitSuppressed(): boolean {
@@ -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,15 @@ 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" and "Add 2 to cart": a click that puts something in the cart.
86
+ const addToCartLabels = new RegExp('^add(?: [^ ]+){0,5} to ' + CART_NOUN + '$');
87
+ // "View cart", "Shopping bag", "Bag", and a count badge such as "1 item in bag",
88
+ // which reads "item in bag" once digits are stripped: a click that opens the cart.
89
+ const openCartLabels = new RegExp(
90
+ '^(?:(?:(?:view|open|go to) )?' + CART_NOUN + '(?: items?)?' +
91
+ '|items? in ' + CART_NOUN + ')$'
92
+ );
82
93
  const WALLET_NAMES =
83
94
  'apple pay|google pay|paypal|venmo|amazon pay|shop pay|klarna|afterpay|clearpay|cash app(?: pay)?|affirm|sezzle|samsung pay';
84
95
  const WALLET_VERBS =
@@ -94,20 +105,35 @@ const walletBrands = new RegExp(
94
105
  '^(?:' + WALLET_NAMES + ')(?: (?:checkout|check out|express(?: checkout)?|express pay|buy now|pay now|pay|now)' +
95
106
  '| (?:logo|icon|mark|button)(?: (?:logo|icon|mark|button))*)?$'
96
107
  );
108
+ // The payment method a frame's focus picks, in the Rokt kit's family names.
109
+ export type PaymentFrameFamily =
110
+ | 'apple_pay'
111
+ | 'google_pay'
112
+ | 'amazon_pay'
113
+ | 'shop_pay'
114
+ | 'card';
97
115
  // Exact HTTPS hosts of hosted wallet button frames.
98
- const walletFrameHosts = new Set([
99
- 'pay.google.com',
100
- 'applepay.cdn-apple.com',
101
- 'payments.amazon.com',
102
- 'pay.shopify.com',
103
- ]);
116
+ const walletFrameFamilies: { [host: string]: PaymentFrameFamily } = {
117
+ 'pay.google.com': 'google_pay',
118
+ 'applepay.cdn-apple.com': 'apple_pay',
119
+ 'payments.amazon.com': 'amazon_pay',
120
+ 'pay.shopify.com': 'shop_pay',
121
+ };
104
122
  // PayPal serves its buttons and its card fields and pay-later messages from
105
123
  // the same hosts; only the button frames are wallet taps.
106
124
  const paypalFrameHosts = new Set(['www.paypal.com', 'www.sandbox.paypal.com']);
107
125
  const PAYPAL_BUTTON_FRAME = /^\/smart\/(?:buttons|menu)(?:\/|$)/;
126
+ const PAYPAL_CARD_FRAME = /^\/smart\/card-fields?(?:\/|$)/;
127
+ // PayPal's JS SDK, used directly or wrapped by a processor, leaves its frames
128
+ // without a src and names them __zoid__paypal_<component>__ instead.
129
+ const PAYPAL_SDK_FRAME = /^__zoid__paypal_/;
130
+ const PAYPAL_SDK_BUTTON_FRAME = /^__zoid__paypal_(?:buttons|menu)__/;
108
131
  // Stripe's quick-pay button frames; its other frames are card and form fields.
109
132
  const STRIPE_WALLET_FRAME = /\/elements-inner-(?:payment-request|express-checkout)-/;
133
+ const STRIPE_CARD_FRAME = /\/elements-inner-card\b/;
110
134
  // Hosted card-entry and fingerprint frames: focus there is typing card details.
135
+ // Some of these hosts also serve gift card and bank account fields, so none
136
+ // names a family.
111
137
  const CARD_FRAME_DOMAINS = [
112
138
  'adyen.com',
113
139
  'braintreegateway.com',
@@ -200,10 +226,34 @@ function labels(element: Element): string[] {
200
226
  .filter((label): label is string => !!label);
201
227
  }
202
228
 
229
+ // A cart button is often a bare icon, so its title names it when nothing else
230
+ // does. Only the cart rule reads the title. Opening the cart counts only for a
231
+ // control that opens it on the page, such as a flyout: one that leads to the cart
232
+ // page unloads before a call made on the click can return.
233
+ function isCartControl(control: Element, cores: string[]): boolean {
234
+ const title = cores.length ? undefined : normalize(control.getAttribute('title'));
235
+ const texts = title ? [coreLabel(title)] : cores;
236
+ if (texts.some(text => addToCartLabels.test(text))) {
237
+ return true;
238
+ }
239
+ return (
240
+ (control.hasAttribute('aria-expanded') || control.hasAttribute('aria-haspopup')) &&
241
+ texts.some(text => openCartLabels.test(text))
242
+ );
243
+ }
244
+
203
245
  // 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';
246
+ // label, a wallet's bare name, a hosted frame, a form submitted after a confirm
247
+ // click, a merchant button named after its wallet, or a payment sheet the page
248
+ // opened.
249
+ export type CheckoutIntentSource =
250
+ | 'vendor'
251
+ | 'label'
252
+ | 'brand'
253
+ | 'frame'
254
+ | 'submit'
255
+ | 'name'
256
+ | 'api';
207
257
 
208
258
  export interface ICheckoutIntent {
209
259
  kind: Exclude<CheckoutIntentKind, 'frame'>;
@@ -226,20 +276,34 @@ function isCommitLabel(text: string, isLink: boolean, pathname: string): boolean
226
276
  // Classifies a clicked or submitting control found by findControl, in order:
227
277
  // pickers, toggles and pay-later messages never count; a vendor's own wallet
228
278
  // 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.
279
+ // before the look-alike exclusions veto; then a wallet-named control, wallet
280
+ // phrases and steps. An add-to-cart label, links included, or a control that
281
+ // opens the cart on the page counts only where none of those do.
230
282
  export function classifyIntent(
231
283
  control: Element,
232
284
  pathname: string
233
285
  ): ICheckoutIntent | undefined {
234
286
  const vendor = vendorButtonKind(control);
235
- if (isQuietIntentControl(control, vendor === 'wallet') || vendor === 'none') {
287
+ if (vendor === 'none') {
236
288
  return undefined;
237
289
  }
238
290
  if (vendor) {
239
- return { kind: vendor, source: 'vendor' };
291
+ return isQuietIntentControl(control, vendor === 'wallet')
292
+ ? undefined
293
+ : { kind: vendor, source: 'vendor' };
240
294
  }
241
295
  const texts = labels(control);
242
296
  const cores = texts.map(coreLabel);
297
+ const cart: ICheckoutIntent | undefined = isCartControl(control, cores)
298
+ ? { kind: 'cart', source: 'label' }
299
+ : undefined;
300
+ if (isQuietIntentControl(control, false, !!cart)) {
301
+ return undefined;
302
+ }
303
+ // A bag button that opens a flyout counts only as a cart click.
304
+ if (cart && control.hasAttribute('aria-expanded')) {
305
+ return cart;
306
+ }
243
307
  const isLink = control.matches('a[href]:not([role="button"])');
244
308
  if (cores.some(text => isCommitLabel(text, isLink, pathname))) {
245
309
  return { kind: 'commit', source: 'label' };
@@ -249,7 +313,10 @@ export function classifyIntent(
249
313
  .concat(cores)
250
314
  .some(text => englishExclusions.test(text) || otherExclusions.test(text))
251
315
  ) {
252
- return undefined;
316
+ return cart;
317
+ }
318
+ if (isWalletNamedControl(control)) {
319
+ return { kind: 'wallet', source: 'name' };
253
320
  }
254
321
  const isButton = !isLink && control.matches(CONTROL_SELECTOR);
255
322
  for (const text of cores) {
@@ -263,7 +330,7 @@ export function classifyIntent(
263
330
  return { kind: 'checkout_step', source: 'label' };
264
331
  }
265
332
  }
266
- return undefined;
333
+ return cart;
267
334
  }
268
335
 
269
336
  export function classifyIntentControl(
@@ -283,12 +350,20 @@ function isOnDomain(host: string, domain: string): boolean {
283
350
  );
284
351
  }
285
352
 
353
+ export interface IPaymentFrame {
354
+ kind: 'wallet' | 'frame';
355
+ family?: PaymentFrameFamily;
356
+ }
357
+
286
358
  // 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.
288
- export function classifyIntentFrame(
359
+ // focus into hosted card fields or messages is only a frame. A family comes
360
+ // only from a frame that can draw no other method: PayPal's button frame also
361
+ // draws Pay Later, Venmo and its card form, Stripe's quick-pay frame draws
362
+ // Apple Pay or Google Pay by browser, and its payment form holds several.
363
+ export function classifyPaymentFrame(
289
364
  target: EventTarget | null,
290
365
  win: Window
291
- ): 'wallet' | 'frame' | undefined {
366
+ ): IPaymentFrame | undefined {
292
367
  if (
293
368
  win.document.visibilityState !== 'visible' ||
294
369
  !(target instanceof HTMLIFrameElement)
@@ -301,20 +376,39 @@ export function classifyIntentFrame(
301
376
  ) {
302
377
  return undefined;
303
378
  }
379
+ // A frame that has its own src is judged by that address, never by its name.
380
+ if (
381
+ (!target.src || target.src === 'about:blank') &&
382
+ PAYPAL_SDK_FRAME.test(target.name)
383
+ ) {
384
+ return PAYPAL_SDK_BUTTON_FRAME.test(target.name)
385
+ ? { kind: 'wallet' }
386
+ : { kind: 'frame' };
387
+ }
304
388
  try {
305
389
  const url = new URL(target.src);
306
390
  const host = url.protocol === 'https:' ? url.hostname : '';
307
391
  if (paypalFrameHosts.has(host)) {
308
- return PAYPAL_BUTTON_FRAME.test(url.pathname) ? 'wallet' : 'frame';
392
+ if (PAYPAL_BUTTON_FRAME.test(url.pathname)) {
393
+ return { kind: 'wallet' };
394
+ }
395
+ return PAYPAL_CARD_FRAME.test(url.pathname)
396
+ ? { kind: 'frame', family: 'card' }
397
+ : { kind: 'frame' };
309
398
  }
310
- if (walletFrameHosts.has(host)) {
311
- return 'wallet';
399
+ if (Object.prototype.hasOwnProperty.call(walletFrameFamilies, host)) {
400
+ return { kind: 'wallet', family: walletFrameFamilies[host] };
312
401
  }
313
402
  if (host === 'js.stripe.com') {
314
- return STRIPE_WALLET_FRAME.test(url.pathname) ? 'wallet' : 'frame';
403
+ if (STRIPE_WALLET_FRAME.test(url.pathname)) {
404
+ return { kind: 'wallet' };
405
+ }
406
+ return STRIPE_CARD_FRAME.test(url.pathname)
407
+ ? { kind: 'frame', family: 'card' }
408
+ : { kind: 'frame' };
315
409
  }
316
410
  if (CARD_FRAME_DOMAINS.some(domain => isOnDomain(host, domain))) {
317
- return 'frame';
411
+ return { kind: 'frame' };
318
412
  }
319
413
  } catch (e) {
320
414
  // An unparseable frame src is not a wallet frame.