@mparticle/web-sdk 3.27.0 → 3.28.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.
@@ -19,7 +19,15 @@ export type CheckoutIntentKind =
19
19
 
20
20
  // A checkout segment may name its kind or its version, as /express-checkout, /checkout-v2 and /checkout-external do.
21
21
  // 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;
22
+ const CHECKOUT_SEGMENT =
23
+ '(?:(?:express|guest|quick|secure|complete|one-?page|single-?page|mobile)-)?checkouts?' +
24
+ '(?:-(?:v\\d+|\\d+|flow|external|process|step(?:-\\d+)?))?';
25
+ const checkoutPath = new RegExp(
26
+ '(?:^|\\/)(?:' + CHECKOUT_SEGMENT + '|payment|order|kasse|paiement|commander?|pago)(?:\\/|$)',
27
+ 'i'
28
+ );
29
+ // A route with a checkout segment itself, not just a payment or order one, which account pages also use.
30
+ const checkoutSegmentPath = new RegExp('(?:^|\\/)' + CHECKOUT_SEGMENT + '(?:\\/|$)', 'i');
23
31
  // A confirmation page under a checkout route, such as /checkout/thankyou/<order> or /checkouts/<token>/thank_you, comes
24
32
  // after the confirm click. "Complete" and "success" mark one only as the last word of a segment, so /complete-checkout
25
33
  // and /checkout/complete-registration stay checkout routes.
@@ -91,6 +99,9 @@ const steps = new RegExp(
91
99
  ].join('|') +
92
100
  ')$'
93
101
  );
102
+ // "Save and continue" also saves profile, address and card forms, so it is a step only on a checkout segment's route
103
+ // that isn't an account or confirmation page.
104
+ const routeGatedSteps = /^save and continue$/;
94
105
  const CART_NOUN = '(?:(?:my|your|the) )?(?:shopping )?(?:cart|bag|basket)';
95
106
  // "Add to bag" and "Add 2 to cart": a click that puts something in the cart.
96
107
  const addToCartLabels = new RegExp('^add(?: [^ ]+){0,5} to ' + CART_NOUN + '$');
@@ -368,7 +379,14 @@ export function classifyIntent(
368
379
  if (isButton && walletBrands.test(text)) {
369
380
  return { kind: 'wallet', source: 'brand' };
370
381
  }
371
- if (steps.test(text)) {
382
+ if (
383
+ steps.test(text) ||
384
+ (isButton &&
385
+ routeGatedSteps.test(text) &&
386
+ checkoutSegmentPath.test(pathname) &&
387
+ !confirmationPath.test(pathname) &&
388
+ !accountStepPath.test(pathname))
389
+ ) {
372
390
  return { kind: 'checkout_step', source: 'label' };
373
391
  }
374
392
  }
@@ -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
  }