@mparticle/web-sdk 3.27.0 → 3.29.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.
@@ -3,6 +3,7 @@ import { isFunction } from '../utils';
3
3
  import { buildButtonClickEvent, buildLinkClickEvent } from './buttonClickEvent';
4
4
  import {
5
5
  describeButtonClick,
6
+ describeCheckedPaymentChoice,
6
7
  describePaymentChoice,
7
8
  IButtonClick,
8
9
  IPaymentChoice,
@@ -14,6 +15,7 @@ import {
14
15
  CheckoutIntentSource,
15
16
  classifyIntent,
16
17
  classifyPaymentFrame,
18
+ namesWallet,
17
19
  PaymentFrameFamily,
18
20
  } from './checkoutIntent';
19
21
  import { findControl, findRoleControl, isExcludedPath } from './controls';
@@ -32,9 +34,6 @@ const PATH_LIMIT = 20;
32
34
  // A ceiling, not a dedupe: a held Enter key or browser automation fires a
33
35
  // trusted click per repeat, and each one is forwarded to every kit.
34
36
  const PAGE_EVENT_LIMIT = 100;
35
- // A click uploads at once. Taps that follow it this quickly are held and share
36
- // one upload once they stop.
37
- const UPLOAD_DEBOUNCE_MS = 100;
38
37
 
39
38
  interface IBtnState {
40
39
  tracker?: ButtonTracker;
@@ -176,10 +175,6 @@ export class ButtonTracker {
176
175
  private pageEvents = 0;
177
176
  // Clicks read at dispatch and waiting for the next paint.
178
177
  private pending: Array<() => void> = [];
179
- private uploadTimerId: ReturnType<typeof setTimeout> | null = null;
180
- private uploadHeld = false;
181
- // Set while the page is left, when the SDK's exit upload sends the queue.
182
- private leaving = false;
183
178
 
184
179
  constructor(mpInstance: IMParticleWebSDKInstance) {
185
180
  this.mpInstance = mpInstance;
@@ -323,7 +318,6 @@ export class ButtonTracker {
323
318
  this.lastSent = {};
324
319
  this.pagePath = null;
325
320
  this.pageEvents = 0;
326
- this.cancelUpload();
327
321
  }
328
322
 
329
323
  private enqueue(delivery: () => void): void {
@@ -349,14 +343,8 @@ export class ButtonTracker {
349
343
  event.type === 'pagehide' ||
350
344
  document.visibilityState === 'hidden'
351
345
  ) {
352
- this.leaving = true;
353
- try {
354
- this.drain();
355
- } finally {
356
- this.leaving = false;
357
- }
358
- // The SDK's exit upload sends the queue, held taps included.
359
- this.cancelUpload();
346
+ // The SDK's exit upload then sends these clicks with the queue.
347
+ this.drain();
360
348
  }
361
349
  };
362
350
 
@@ -404,6 +392,7 @@ export class ButtonTracker {
404
392
  let source: CheckoutIntentSource | undefined;
405
393
  let adds: true | undefined;
406
394
  let choice: IPaymentChoice | undefined;
395
+ let checkedNames: string[] | undefined;
407
396
  let family: PaymentFrameFamily | undefined;
408
397
  let unconfirmedCommit = false;
409
398
  if (layer === 'click') {
@@ -457,6 +446,19 @@ export class ButtonTracker {
457
446
  choice = signalsKit
458
447
  ? describePaymentChoice(control, path)
459
448
  : undefined;
449
+ // A confirm or checkout-step click picks no radio itself, so it
450
+ // carries the one the page holds checked as read here. It is not
451
+ // read again at delivery: the page's own handlers may clear or
452
+ // replace it by then. A wallet tap names its own method, and
453
+ // so does a confirm that names a wallet.
454
+ if (
455
+ !choice &&
456
+ (kind === 'commit' || kind === 'checkout_step') &&
457
+ !namesWallet(control, click)
458
+ ) {
459
+ const checked = describeCheckedPaymentChoice();
460
+ checkedNames = checked ? checked.names : undefined;
461
+ }
460
462
  }
461
463
  this.lastClick = { t, excluded, kind, source };
462
464
  } else if (layer === 'submit') {
@@ -517,6 +519,8 @@ export class ButtonTracker {
517
519
  // with this pick.
518
520
  if (choice && isPicked(choice.radio)) {
519
521
  this.pickPayment(t, pathname, choice.names);
522
+ } else if (checkedNames) {
523
+ this.pickPayment(t, pathname, checkedNames);
520
524
  } else if (family) {
521
525
  this.pickPayment(t, pathname, undefined, family);
522
526
  }
@@ -548,57 +552,13 @@ export class ButtonTracker {
548
552
  this.pageEvents++;
549
553
  // Restarts an expired session, as public logEvent() and page views do.
550
554
  this.mpInstance._SessionManager.resetSessionTimer();
551
- const queued = this.queuedForUpload();
555
+ // No upload of its own, which a click that opens another page would
556
+ // cancel: with batching on, the next batch or the exit upload sends it.
552
557
  this.mpInstance._Events.logEvent(
553
558
  asLink
554
559
  ? buildLinkClickEvent(click, pathname)
555
560
  : buildButtonClickEvent(click, pathname)
556
561
  );
557
- // Only a click waiting for the batch interval is sent early: a held
558
- // one goes out when released, and with batching off it already has.
559
- // A pick, such as a checkbox, a radio or a tab, waits for the batch,
560
- // since choosing an option doesn't leave the page the way a confirm
561
- // click can.
562
- if (
563
- !this.leaving &&
564
- !click.pick &&
565
- this.queuedForUpload() > queued
566
- ) {
567
- this.uploadNow();
568
- }
569
- }
570
-
571
- // Events waiting in the upload queue for the next batch interval.
572
- private queuedForUpload(): number {
573
- const uploader = this.mpInstance._APIClient.uploader;
574
- return uploader ? uploader.eventsQueuedForProcessing.length : 0;
575
- }
576
-
577
- // Uploads a click without waiting for the batch interval. A tap inside the
578
- // wait after the last one is held, and each restarts the wait, so a burst
579
- // after the first click goes out in one request once the taps stop.
580
- private uploadNow(): void {
581
- if (this.uploadTimerId === null) {
582
- this.mpInstance.upload();
583
- } else {
584
- clearTimeout(this.uploadTimerId);
585
- this.uploadHeld = true;
586
- }
587
- this.uploadTimerId = setTimeout(() => {
588
- this.uploadTimerId = null;
589
- if (this.uploadHeld) {
590
- this.uploadHeld = false;
591
- this.mpInstance.upload();
592
- }
593
- }, UPLOAD_DEBOUNCE_MS);
594
- }
595
-
596
- private cancelUpload(): void {
597
- if (this.uploadTimerId !== null) {
598
- clearTimeout(this.uploadTimerId);
599
- this.uploadTimerId = null;
600
- }
601
- this.uploadHeld = false;
602
562
  }
603
563
 
604
564
  private emit(
@@ -7,6 +7,7 @@ import {
7
7
  isWalletNamedControl,
8
8
  vendorButtonKind,
9
9
  } from './controls';
10
+ import { IButtonClick, TEST_ID_ATTRIBUTES } from './buttonDescription';
10
11
 
11
12
  // Classifies a click, submit or frame focus as checkout intent for the Rokt kit.
12
13
  // Reads only short control labels, never form field values, and stores nothing.
@@ -19,7 +20,15 @@ export type CheckoutIntentKind =
19
20
 
20
21
  // A checkout segment may name its kind or its version, as /express-checkout, /checkout-v2 and /checkout-external do.
21
22
  // Other hyphenated words make it a content slug, such as /help/checkout-faq or /products/self-checkout-kiosk.
22
- const checkoutPath = /(?:^|\/)(?:(?:(?:express|guest|quick|secure|complete|one-?page|single-?page|mobile)-)?checkouts?(?:-(?:v\d+|\d+|flow|external|process|step(?:-\d+)?))?|payment|order|kasse|paiement|commander?|pago)(?:\/|$)/i;
23
+ const CHECKOUT_SEGMENT =
24
+ '(?:(?:express|guest|quick|secure|complete|one-?page|single-?page|mobile)-)?checkouts?' +
25
+ '(?:-(?:v\\d+|\\d+|flow|external|process|step(?:-\\d+)?))?';
26
+ const checkoutPath = new RegExp(
27
+ '(?:^|\\/)(?:' + CHECKOUT_SEGMENT + '|payment|order|kasse|paiement|commander?|pago)(?:\\/|$)',
28
+ 'i'
29
+ );
30
+ // A route with a checkout segment itself, not just a payment or order one, which account pages also use.
31
+ const checkoutSegmentPath = new RegExp('(?:^|\\/)' + CHECKOUT_SEGMENT + '(?:\\/|$)', 'i');
23
32
  // A confirmation page under a checkout route, such as /checkout/thankyou/<order> or /checkouts/<token>/thank_you, comes
24
33
  // after the confirm click. "Complete" and "success" mark one only as the last word of a segment, so /complete-checkout
25
34
  // and /checkout/complete-registration stay checkout routes.
@@ -91,6 +100,9 @@ const steps = new RegExp(
91
100
  ].join('|') +
92
101
  ')$'
93
102
  );
103
+ // "Save and continue" also saves profile, address and card forms, so it is a step only on a checkout segment's route
104
+ // that isn't an account or confirmation page.
105
+ const routeGatedSteps = /^save and continue$/;
94
106
  const CART_NOUN = '(?:(?:my|your|the) )?(?:shopping )?(?:cart|bag|basket)';
95
107
  // "Add to bag" and "Add 2 to cart": a click that puts something in the cart.
96
108
  const addToCartLabels = new RegExp('^add(?: [^ ]+){0,5} to ' + CART_NOUN + '$');
@@ -115,6 +127,9 @@ const walletBrands = new RegExp(
115
127
  '^(?:' + WALLET_NAMES + ')(?: (?:checkout|check out|express(?: checkout)?|express pay|buy now|pay now|pay|now)' +
116
128
  '| (?:logo|icon|mark|button)(?: (?:logo|icon|mark|button))*)?$'
117
129
  );
130
+ // The wallets the Rokt kit reads from a click's label or identifiers, as words:
131
+ // "Place order with PayPal!" and "paypal-pay-button" both name PayPal.
132
+ const KIT_WALLET_WORDS = /(?:^| )(?:apple ?pay|google ?pay|g ?pay|android ?pay|venmo|pay ?pal|amazon ?pay|shop ?pay|klarna|after ?pay|clear ?pay|affirm|cash ?app|sezzle|samsung ?pay)(?= |$)/;
118
133
  // The payment method a frame's focus picks, in the Rokt kit's family names.
119
134
  export type PaymentFrameFamily =
120
135
  | 'apple_pay'
@@ -368,13 +383,52 @@ export function classifyIntent(
368
383
  if (isButton && walletBrands.test(text)) {
369
384
  return { kind: 'wallet', source: 'brand' };
370
385
  }
371
- if (steps.test(text)) {
386
+ if (
387
+ steps.test(text) ||
388
+ (isButton &&
389
+ routeGatedSteps.test(text) &&
390
+ checkoutSegmentPath.test(pathname) &&
391
+ !confirmationPath.test(pathname) &&
392
+ !accountStepPath.test(pathname))
393
+ ) {
372
394
  return { kind: 'checkout_step', source: 'label' };
373
395
  }
374
396
  }
375
397
  return cart;
376
398
  }
377
399
 
400
+ // The Rokt kit's own word split, so "PayPal!" and "paypal2" both read as
401
+ // "paypal" here as they do there.
402
+ function kitWords(value: string | null | undefined): string {
403
+ return (value || '')
404
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
405
+ .toLowerCase()
406
+ .replace(/[^a-z]+/g, ' ')
407
+ .trim();
408
+ }
409
+
410
+ // A confirm control may name its wallet, so "Place order with PayPal" and a
411
+ // "Place order" button with id paypal-pay-button are confirm clicks that still
412
+ // name their own method. A logged click is judged on the fields the kit reads
413
+ // a method from; a click that isn't logged, on the control's own label and
414
+ // identifiers.
415
+ export function namesWallet(control: Element, click?: IButtonClick): boolean {
416
+ const values = click
417
+ ? [
418
+ click.label,
419
+ click.ariaLabel,
420
+ click.elementId,
421
+ click.elementName,
422
+ click.testId,
423
+ ]
424
+ : labelSources(control).concat(
425
+ ['id', 'name']
426
+ .concat(TEST_ID_ATTRIBUTES)
427
+ .map(attribute => control.getAttribute(attribute))
428
+ );
429
+ return values.some(value => KIT_WALLET_WORDS.test(kitWords(value)));
430
+ }
431
+
378
432
  export function classifyIntentControl(
379
433
  control: Element,
380
434
  pathname: string
@@ -661,10 +661,12 @@ export class PageViewTracker {
661
661
  isAutoPageView: this.isAutoPageView,
662
662
  });
663
663
 
664
+ // A title can name the shopper or an order, so it stays out of the log
665
+ // line, for the same reason describePage omits param values.
664
666
  this.log(
665
667
  `[fire] deferred flush -> _Events.logEvent(PageView) (page: ${describePage(
666
668
  page
667
- )}, title: ${title})`
669
+ )})`
668
670
  );
669
671
 
670
672
  this.mpInstance._Events.logEvent(event);
@@ -25,6 +25,19 @@ const MATRIX_SEPARATOR = /;|%(?:25)*3B/i;
25
25
  // can't be decoded again. What it hides is unknown, so it counts as sensitive.
26
26
  export const LEFTOVER_ESCAPE = /%[0-9A-F]{2}/i;
27
27
 
28
+ // Pages under a personal route that name no one: the sign-in, sign-up and
29
+ // account steps sites keep there, as in `/u/login` or `/u/mfa-sms-challenge`.
30
+ const ACCOUNT_PAGE = /^(?:log-?in|log-?out|sign-?in|sign-?out|sign-?up|register|registration|create-account|account|settings|forgot-password|reset-password|password|challenge|consent|callback|authorize|continue|mfa(?:-[a-z]+)*)$/i;
31
+
32
+ // Whether a segment after a route goes as `*`: always after an order route, and
33
+ // after a personal route unless it is one of the account pages kept there.
34
+ export function hidesSegmentAfter(route: string, segment: string): boolean {
35
+ return (
36
+ hidesNextSegment(route) &&
37
+ !(PERSONAL_ROUTE.test(route) && ACCOUNT_PAGE.test(segment))
38
+ );
39
+ }
40
+
28
41
  export function hidesNextSegment(route: string): boolean {
29
42
  return (
30
43
  PERSONAL_ROUTE.test(route) ||
@@ -115,11 +128,102 @@ export function isTokenSegment(segment: string): boolean {
115
128
  );
116
129
  }
117
130
 
131
+ // A word a product name is made of: three or more letters, lowercase or
132
+ // Capitalised. A code's mixed case or capital-letter groups aren't words.
133
+ const isWord = (piece: string): boolean =>
134
+ /^(?:[a-z]{3,}|[A-Z][a-z]{2,})$/.test(piece);
135
+
136
+ const UUID_IN_TEXT = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
137
+
138
+ // Route words whose next segment names a product, as in `/p/<name>` or
139
+ // `/product/<name>`. Only there can words beside a token be a product's name
140
+ // rather than a person's or part of a code.
141
+ const PRODUCT_ROUTE = /^(?:p|product|products|item|items|dp|shop|catalog)$/i;
142
+
143
+ // A segment that holds a token goes as `*`. After a product route, only the
144
+ // token goes, so `/p/trail-runner-gtx-zq7Kd93Lm41` keeps its name as
145
+ // `/p/trail-runner-gtx-*`.
146
+ // Every piece that isn't a word in an unbroken run with the token goes with it
147
+ // as one `*`, so the rest of a UUID or of a code cut into short pieces never
148
+ // stays. A piece that can't be decoded or decodes to a separator counts as a
149
+ // token, and so does any piece that mixes letters and digits or switches case
150
+ // inside, as a code's short pieces do. The segment goes whole as `*` when no
151
+ // piece is a word or it holds a UUID. Undefined when it holds no token. `segment` is as the page
152
+ // has it, `text` decoded, `route` the route word before it.
153
+ export function maskTokenSegment(
154
+ segment: string,
155
+ text: string,
156
+ route: string | undefined
157
+ ): string | undefined {
158
+ if (!isTokenSegment(text)) {
159
+ return undefined;
160
+ }
161
+ // A UUID anywhere in the decoded text, however its hyphens are escaped,
162
+ // takes the whole segment.
163
+ if (!route || !PRODUCT_ROUTE.test(route) || UUID_IN_TEXT.test(text)) {
164
+ return '*';
165
+ }
166
+ const parts = segment.split(/([-_.~])/);
167
+ const raws = parts.filter((_, i) => i % 2 === 0);
168
+ const separators = parts.filter((_, i) => i % 2 === 1);
169
+ const decoded = raws.map(raw => decodeFully(raw));
170
+ const kinds = decoded.map(piece => {
171
+ if (piece === undefined || /[-_.~]/.test(piece)) {
172
+ return 'token';
173
+ }
174
+ const plain = piece.replace(/\+/g, '');
175
+ return isToken(plain) ||
176
+ mixesLettersAndDigits(plain) ||
177
+ INNER_CASE_SWITCH.test(plain)
178
+ ? 'token'
179
+ : isWord(plain)
180
+ ? 'word'
181
+ : 'other';
182
+ });
183
+ // Each unbroken run of pieces that aren't words is hidden when it holds a
184
+ // token.
185
+ const masked = kinds.map(() => false);
186
+ for (let start = 0; start < kinds.length; ) {
187
+ if (kinds[start] === 'word') {
188
+ start++;
189
+ continue;
190
+ }
191
+ let end = start;
192
+ while (end < kinds.length && kinds[end] !== 'word') {
193
+ end++;
194
+ }
195
+ // A run reads as a token also when only its pieces joined do.
196
+ if (
197
+ kinds.slice(start, end).indexOf('token') !== -1 ||
198
+ isToken(raws.slice(start, end).join(''))
199
+ ) {
200
+ for (let k = start; k < end; k++) {
201
+ masked[k] = true;
202
+ }
203
+ }
204
+ start = end;
205
+ }
206
+ if (
207
+ masked.indexOf(true) === -1 ||
208
+ kinds.indexOf('word') === -1
209
+ ) {
210
+ return '*';
211
+ }
212
+ let out = '';
213
+ for (let k = 0; k < raws.length; k++) {
214
+ if (masked[k] && k > 0 && masked[k - 1]) {
215
+ continue;
216
+ }
217
+ out += (k > 0 ? separators[k - 1] : '') + (masked[k] ? '*' : raws[k]);
218
+ }
219
+ return out;
220
+ }
221
+
118
222
  // The path a click logs. A segment that can single out one shopper
119
- // goes as `*`: one holding an @, `;` or `=`, a token, or the one after a route
120
- // that names a person, carries a code or opens an order. Kept segments stay
121
- // exactly as the page has them, plain numbers included, since on a product page
122
- // they name the product.
223
+ // goes as `*`: one holding an @, `;` or `=`, an id, or the one after a route
224
+ // that names a person, carries a code or opens an order. A token inside a name
225
+ // goes as `*` alone. Kept segments stay exactly as the page has them, plain
226
+ // numbers included, since on a product page they name the product.
123
227
  export function pagePath(pathname: string): string {
124
228
  const segments = pathname.split('/');
125
229
  return segments
@@ -131,15 +235,18 @@ export function pagePath(pathname: string): string {
131
235
  const hiddenByRoute =
132
236
  i > 0 &&
133
237
  !!text &&
134
- (route === undefined || hidesNextSegment(route));
238
+ (route === undefined || hidesSegmentAfter(route, text));
135
239
  // An encoded slash hides several segments in one, so it goes whole.
136
- return text === undefined ||
240
+ if (
241
+ text === undefined ||
137
242
  /[@;=/]/.test(text) ||
138
243
  LEFTOVER_ESCAPE.test(text) ||
139
- isTokenSegment(text) ||
140
244
  hiddenByRoute
141
- ? '*'
142
- : segment;
245
+ ) {
246
+ return '*';
247
+ }
248
+ const masked = maskTokenSegment(segment, text, route);
249
+ return masked === undefined ? segment : masked;
143
250
  })
144
251
  .join('/');
145
252
  }