@mparticle/web-sdk 3.26.1 → 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.
package/dist/mparticle.js CHANGED
@@ -204,7 +204,7 @@ var mParticle = (function () {
204
204
  Base64: Base64$1
205
205
  };
206
206
 
207
- var version = "3.26.1";
207
+ var version = "3.28.0";
208
208
 
209
209
  var Constants = {
210
210
  sdkVersion: version,
@@ -2732,6 +2732,248 @@ var mParticle = (function () {
2732
2732
  }
2733
2733
  }
2734
2734
 
2735
+ // Masks the parts of a page path that can single out one shopper: pagePath for
2736
+ // the path a click logs, pageUrlPath for the page URL and page views every event
2737
+ // carries.
2738
+ // A token of letters and digits mixed, six or more long, as session ids,
2739
+ // hex and UUID parts and invite codes are: `A1B2C3D4E5F6`, `550e8400`.
2740
+ var MIXED_TOKEN = /^(?=[^0-9]*[0-9])(?=[^A-Za-z]*[A-Za-z])[A-Za-z0-9]{6,}$/;
2741
+ // Letters alone, eight or more long, switching case three times or more, as a
2742
+ // random code does (`xKpQzRtV`) and a camel-case route word rarely does.
2743
+ var CASE_SWITCHING_TOKEN = /^(?=[A-Za-z]{8,}$)(?:[^a-z]*[a-z]+[A-Z]){3}/;
2744
+ // Route words whose next segment names one person or carries a one-time code,
2745
+ // as in `/profile/jane-smith` or `/reset/abcdefgh`.
2746
+ var PERSONAL_ROUTE = /^(?:u|user|users|profile|profiles|member|members|people|reset|reset-password|password|verify|verification|confirm|activate|invite|invitation|token|unsubscribe|magic-link)$/i;
2747
+ // Route words whose next segment is one shopper's order: any with `order` or
2748
+ // `orders` as a whole word part, as in `view-order`, `order-received` or `order_id`.
2749
+ var ORDER_ROUTE = /(?:^|[-_])orders?(?:[-_]|$)/i;
2750
+ // Product words that name a way to buy, not an order: `pre-order`, `on-order`,
2751
+ // `back-order`, `made-to-order`, as the whole route word only, so
2752
+ // `return-to-order` and `pre-order-status` stay routes.
2753
+ var PRODUCT_ORDER_WORD = /^(?:(?:pre|on|back)[-_]|made[-_]to[-_])orders?$/i;
2754
+ // A `;` that starts a matrix parameter, plain or encoded any number of times.
2755
+ var MATRIX_SEPARATOR = /;|%(?:25)*3B/i;
2756
+ // A valid escape left after decoding stopped, as when a literal `%` before it
2757
+ // can't be decoded again. What it hides is unknown, so it counts as sensitive.
2758
+ var LEFTOVER_ESCAPE = /%[0-9A-F]{2}/i;
2759
+ // Pages under a personal route that name no one: the sign-in, sign-up and
2760
+ // account steps sites keep there, as in `/u/login` or `/u/mfa-sms-challenge`.
2761
+ var 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;
2762
+ // Whether a segment after a route goes as `*`: always after an order route, and
2763
+ // after a personal route unless it is one of the account pages kept there.
2764
+ function hidesSegmentAfter(route, segment) {
2765
+ return hidesNextSegment(route) && !(PERSONAL_ROUTE.test(route) && ACCOUNT_PAGE.test(segment));
2766
+ }
2767
+ function hidesNextSegment(route) {
2768
+ return PERSONAL_ROUTE.test(route) || ORDER_ROUTE.test(route) && !PRODUCT_ORDER_WORD.test(route);
2769
+ }
2770
+ // How many times a value is decoded at most, so one encoded twice or more reads
2771
+ // as its plain text.
2772
+ var MAX_DECODES = 5;
2773
+ // Decodes until the text stops changing. Undefined when the first decode fails;
2774
+ // a later failure keeps what decoded so far, as a literal `%` would.
2775
+ function decodeFully(text) {
2776
+ var current = text;
2777
+ for (var i = 0; i < MAX_DECODES; i++) {
2778
+ var next = void 0;
2779
+ try {
2780
+ next = decodeURIComponent(current);
2781
+ } catch (e) {
2782
+ return i === 0 ? undefined : current;
2783
+ }
2784
+ if (next === current) {
2785
+ return current;
2786
+ }
2787
+ current = next;
2788
+ }
2789
+ return current;
2790
+ }
2791
+ // The route word in a raw path segment: its last part behind an encoded slash,
2792
+ // read before any matrix parameter such as `;jsessionid=`, its `;` encoded once,
2793
+ // twice or not at all, so a parameter's own value never hides it. Undefined when
2794
+ // it can't be decoded, or is still encoded after MAX_DECODES.
2795
+ function routeWord(segment) {
2796
+ var text = segment;
2797
+ for (var i = 0; i < MAX_DECODES; i++) {
2798
+ var route = text.split(MATRIX_SEPARATOR)[0];
2799
+ var next = void 0;
2800
+ try {
2801
+ next = decodeURIComponent(route);
2802
+ } catch (e) {
2803
+ return i === 0 ? undefined : route.split('/').pop();
2804
+ }
2805
+ if (next === route) {
2806
+ return route.split('/').pop();
2807
+ }
2808
+ text = next;
2809
+ }
2810
+ return undefined;
2811
+ }
2812
+ // The last segment before index i that isn't empty, so `/orders//12345678`
2813
+ // still reads the order route.
2814
+ function routeSegment(segments, i) {
2815
+ var routeIndex = i - 1;
2816
+ while (routeIndex > 0 && segments[routeIndex] === '') {
2817
+ routeIndex--;
2818
+ }
2819
+ return segments[routeIndex];
2820
+ }
2821
+ // A piece with a lowercase letter right before an uppercase one, as a random
2822
+ // code has and a Title-Case word doesn't.
2823
+ var INNER_CASE_SWITCH = /[a-z][A-Z]/;
2824
+ var isToken = function isToken(text) {
2825
+ return MIXED_TOKEN.test(text) || CASE_SWITCHING_TOKEN.test(text);
2826
+ };
2827
+ var mixesLettersAndDigits = function mixesLettersAndDigits(piece) {
2828
+ return /[0-9]/.test(piece) && /[A-Za-z]/.test(piece);
2829
+ };
2830
+ // `+` sits inside base64 tokens, so it never splits one. A token that `-` or `_`
2831
+ // cuts into short pieces is still one when the pieces read as one joined and
2832
+ // either a piece switches case inside it or two or more pieces mix letters and
2833
+ // digits, as `a1b2-c3d4-e5f6` does. Title-Case-Words and plain slugs such as
2834
+ // `iphone-15-pro` do neither.
2835
+ function isTokenSegment(segment) {
2836
+ var text = segment.replace(/\+/g, '');
2837
+ var pieces = text.split(/[-_.~]/);
2838
+ var joined = pieces.join('');
2839
+ return pieces.some(isToken) || pieces.some(function (piece) {
2840
+ return INNER_CASE_SWITCH.test(piece);
2841
+ }) && isToken(joined) || pieces.filter(mixesLettersAndDigits).length >= 2 && MIXED_TOKEN.test(joined);
2842
+ }
2843
+ // A word a product name is made of: three or more letters, lowercase or
2844
+ // Capitalised. A code's mixed case or capital-letter groups aren't words.
2845
+ var isWord = function isWord(piece) {
2846
+ return /^(?:[a-z]{3,}|[A-Z][a-z]{2,})$/.test(piece);
2847
+ };
2848
+ var UUID_IN_TEXT = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
2849
+ // Route words whose next segment names a product, as in `/p/<name>` or
2850
+ // `/product/<name>`. Only there can words beside a token be a product's name
2851
+ // rather than a person's or part of a code.
2852
+ var PRODUCT_ROUTE = /^(?:p|product|products|item|items|dp|shop|catalog)$/i;
2853
+ // A segment that holds a token goes as `*`. After a product route, only the
2854
+ // token goes, so `/p/trail-runner-gtx-zq7Kd93Lm41` keeps its name as
2855
+ // `/p/trail-runner-gtx-*`.
2856
+ // Every piece that isn't a word in an unbroken run with the token goes with it
2857
+ // as one `*`, so the rest of a UUID or of a code cut into short pieces never
2858
+ // stays. A piece that can't be decoded or decodes to a separator counts as a
2859
+ // token, and so does any piece that mixes letters and digits or switches case
2860
+ // inside, as a code's short pieces do. The segment goes whole as `*` when no
2861
+ // piece is a word or it holds a UUID. Undefined when it holds no token. `segment` is as the page
2862
+ // has it, `text` decoded, `route` the route word before it.
2863
+ function maskTokenSegment(segment, text, route) {
2864
+ if (!isTokenSegment(text)) {
2865
+ return undefined;
2866
+ }
2867
+ // A UUID anywhere in the decoded text, however its hyphens are escaped,
2868
+ // takes the whole segment.
2869
+ if (!route || !PRODUCT_ROUTE.test(route) || UUID_IN_TEXT.test(text)) {
2870
+ return '*';
2871
+ }
2872
+ var parts = segment.split(/([-_.~])/);
2873
+ var raws = parts.filter(function (_, i) {
2874
+ return i % 2 === 0;
2875
+ });
2876
+ var separators = parts.filter(function (_, i) {
2877
+ return i % 2 === 1;
2878
+ });
2879
+ var decoded = raws.map(function (raw) {
2880
+ return decodeFully(raw);
2881
+ });
2882
+ var kinds = decoded.map(function (piece) {
2883
+ if (piece === undefined || /[-_.~]/.test(piece)) {
2884
+ return 'token';
2885
+ }
2886
+ var plain = piece.replace(/\+/g, '');
2887
+ return isToken(plain) || mixesLettersAndDigits(plain) || INNER_CASE_SWITCH.test(plain) ? 'token' : isWord(plain) ? 'word' : 'other';
2888
+ });
2889
+ // Each unbroken run of pieces that aren't words is hidden when it holds a
2890
+ // token.
2891
+ var masked = kinds.map(function () {
2892
+ return false;
2893
+ });
2894
+ for (var start = 0; start < kinds.length;) {
2895
+ if (kinds[start] === 'word') {
2896
+ start++;
2897
+ continue;
2898
+ }
2899
+ var end = start;
2900
+ while (end < kinds.length && kinds[end] !== 'word') {
2901
+ end++;
2902
+ }
2903
+ // A run reads as a token also when only its pieces joined do.
2904
+ if (kinds.slice(start, end).indexOf('token') !== -1 || isToken(raws.slice(start, end).join(''))) {
2905
+ for (var k = start; k < end; k++) {
2906
+ masked[k] = true;
2907
+ }
2908
+ }
2909
+ start = end;
2910
+ }
2911
+ if (masked.indexOf(true) === -1 || kinds.indexOf('word') === -1) {
2912
+ return '*';
2913
+ }
2914
+ var out = '';
2915
+ for (var k = 0; k < raws.length; k++) {
2916
+ if (masked[k] && k > 0 && masked[k - 1]) {
2917
+ continue;
2918
+ }
2919
+ out += (k > 0 ? separators[k - 1] : '') + (masked[k] ? '*' : raws[k]);
2920
+ }
2921
+ return out;
2922
+ }
2923
+ // The path a click logs. A segment that can single out one shopper
2924
+ // goes as `*`: one holding an @, `;` or `=`, an id, or the one after a route
2925
+ // that names a person, carries a code or opens an order. A token inside a name
2926
+ // goes as `*` alone. Kept segments stay exactly as the page has them, plain
2927
+ // numbers included, since on a product page they name the product.
2928
+ function pagePath(pathname) {
2929
+ var segments = pathname.split('/');
2930
+ return segments.map(function (segment, i) {
2931
+ var text = decodeFully(segment);
2932
+ var route = i > 0 ? routeWord(routeSegment(segments, i)) : '';
2933
+ // A route word that can't be decoded may be any route, so the
2934
+ // segment after it is hidden too.
2935
+ var hiddenByRoute = i > 0 && !!text && (route === undefined || hidesSegmentAfter(route, text));
2936
+ // An encoded slash hides several segments in one, so it goes whole.
2937
+ if (text === undefined || /[@;=/]/.test(text) || LEFTOVER_ESCAPE.test(text) || hiddenByRoute) {
2938
+ return '*';
2939
+ }
2940
+ var masked = maskTokenSegment(segment, text, route);
2941
+ return masked === undefined ? segment : masked;
2942
+ }).join('/');
2943
+ }
2944
+ // A token long enough that no page or product name looks like it: 16 or more
2945
+ // letters and digits mixed, as checkout tokens are, 16 or more letters
2946
+ // switching case like a random code, or a UUID.
2947
+ var LONG_MIXED_TOKEN = /^(?=[^0-9]*[0-9])(?=[^A-Za-z]*[A-Za-z])[A-Za-z0-9]{16,}$/;
2948
+ var LONG_CASE_SWITCHING_TOKEN = /^(?=[A-Za-z]{16,}$)(?:[^a-z]*[a-z]+[A-Z]){3}/;
2949
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2950
+ // A token that `-` or `_` cuts into shorter pieces counts when two or more
2951
+ // pieces mix letters and digits, as `a1b2c3d4-e5f6g7h8` does and a product slug
2952
+ // such as `acmephone-15-pro-max-256gb` doesn't.
2953
+ function isLongToken(segment) {
2954
+ var pieces = segment.replace(/\+/g, '').split(/[-_.~]/);
2955
+ return UUID.test(segment) || pieces.some(function (piece) {
2956
+ return LONG_MIXED_TOKEN.test(piece) || LONG_CASE_SWITCHING_TOKEN.test(piece);
2957
+ }) || pieces.filter(mixesLettersAndDigits).length >= 2 && LONG_MIXED_TOKEN.test(pieces.join(''));
2958
+ }
2959
+ // The path every event carries, in its page URL and page views, where a
2960
+ // wrong `*` costs every output the page it names. Only what reliably singles
2961
+ // out one shopper goes as `*`: a segment holding an @, `;` or `=`, one that
2962
+ // can't be decoded or still holds an escape, a long token, and the one after a
2963
+ // route that names a person or opens an order when it holds a digit or a token.
2964
+ // Product codes and pages such as `/profile/edit` or `/orders/history` stay.
2965
+ function pageUrlPath(pathname) {
2966
+ var segments = pathname.split('/');
2967
+ return segments.map(function (segment, i) {
2968
+ var text = decodeFully(segment);
2969
+ var route = i > 0 ? routeWord(routeSegment(segments, i)) : '';
2970
+ var holdsId = !!text && (/[0-9]/.test(text) || isTokenSegment(text));
2971
+ // A route word that can't be decoded may be any route.
2972
+ var hiddenByRoute = i > 0 && !!text && (route === undefined || hidesNextSegment(route) && holdsId);
2973
+ return text === undefined || /[@;=/]/.test(text) || LEFTOVER_ESCAPE.test(text) || isLongToken(text) || hiddenByRoute ? '*' : segment;
2974
+ }).join('/');
2975
+ }
2976
+
2735
2977
  var HISTORY_METHODS = ['pushState', 'replaceState'];
2736
2978
  var WRAPPED_MARKER = '__mpApvWrapped__';
2737
2979
  var supportsHistoryTracking = function supportsHistoryTracking(win) {
@@ -3073,7 +3315,8 @@ var mParticle = (function () {
3073
3315
  data: __assign(__assign(__assign({}, paramsToAttributes(params)), {
3074
3316
  hostname: hostname,
3075
3317
  title: title,
3076
- path: path
3318
+ // Masked here, not in the page snapshot, whose raw path tells one page from the next.
3319
+ path: pageUrlPath(path)
3077
3320
  }), autoPageViewAttribute(isAutoPageView)),
3078
3321
  eventType: EventType.Unknown
3079
3322
  };
@@ -3169,7 +3412,9 @@ var mParticle = (function () {
3169
3412
  var name = _a.name;
3170
3413
  return name;
3171
3414
  });
3172
- return names.length ? "".concat(page.path, " (params: ").concat(names.join(), ")") : page.path;
3415
+ // A log line is read by no output, so it takes the click path's fuller rules.
3416
+ var path = pagePath(page.path);
3417
+ return names.length ? "".concat(path, " (params: ").concat(names.join(), ")") : path;
3173
3418
  };
3174
3419
  // ---------------------------------------------------------------------------
3175
3420
  // History patching
@@ -3343,7 +3588,9 @@ var mParticle = (function () {
3343
3588
  title: title,
3344
3589
  isAutoPageView: this.isAutoPageView
3345
3590
  }));
3346
- this.log("[fire] deferred flush -> _Events.logEvent(PageView) (page: ".concat(describePage(page), ", title: ").concat(title, ")"));
3591
+ // A title can name the shopper or an order, so it stays out of the log
3592
+ // line, for the same reason describePage omits param values.
3593
+ this.log("[fire] deferred flush -> _Events.logEvent(PageView) (page: ".concat(describePage(page), ")"));
3347
3594
  this.mpInstance._Events.logEvent(event);
3348
3595
  };
3349
3596
  PageViewTracker.prototype.log = function (message) {
@@ -3353,12 +3600,16 @@ var mParticle = (function () {
3353
3600
  }();
3354
3601
 
3355
3602
  // The one rule for a URL that leaves the SDK, shared with kits through
3356
- // `mParticle._cleanUrl`. `path` keeps scheme, host and path. `full` adds the
3603
+ // `mParticle._cleanUrl`. `path` keeps scheme, host and path, with any path
3604
+ // segment that can single out one shopper as `*` (see pageUrlPath). `full` adds the
3357
3605
  // query params an auto page view captures by default and the campaign
3358
- // attribution params, then the fragment.
3606
+ // attribution params, then the fragment, a hash route's path masked the same way.
3359
3607
  var UrlMode = {
3360
3608
  Path: 'path',
3361
- Full: 'full'
3609
+ Full: 'full',
3610
+ // A path for a log or an error report, masked with the click path's fuller
3611
+ // rules, since no output reads it as a page.
3612
+ Report: 'report'
3362
3613
  };
3363
3614
  // Campaign attribution and ad-network click ids. URLs keep them; an auto page view
3364
3615
  // carries one as an attribute only when an input configures it.
@@ -3377,6 +3628,31 @@ var mParticle = (function () {
3377
3628
  // Allowlisted params whose value is itself a URL, so it can carry a query.
3378
3629
  var URL_VALUED_QUERY_PARAMS = ['ref', 'referrer'];
3379
3630
  var USERINFO = /^((?:[a-z][a-z0-9+.-]*:)?\/\/)[^/]*@/i;
3631
+ var ORIGIN = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/]*/i;
3632
+ // Only a URL or a path is masked. A bare value, such as `ref=spring2024`, is a
3633
+ // code rather than a page, and stays as written.
3634
+ var maskPath = function maskPath(url, mask) {
3635
+ if (mask === void 0) {
3636
+ mask = pageUrlPath;
3637
+ }
3638
+ var origin = ORIGIN.exec(url);
3639
+ if (origin) {
3640
+ return origin[0] + mask(url.slice(origin[0].length));
3641
+ }
3642
+ return url.charAt(0) === '/' ? mask(url) : url;
3643
+ };
3644
+ // A hash route keeps its `#` or `#!` and has its path masked as the URL's own.
3645
+ // The `&` pairs or flags after the path lose the OAuth/OIDC ones, as a bare
3646
+ // fragment's pairs do.
3647
+ var maskRoute = function maskRoute(route) {
3648
+ var prefix = /^#!?/.exec(route)[0];
3649
+ var pairsStart = route.indexOf('&');
3650
+ var pathEnd = pairsStart === -1 ? route.length : pairsStart;
3651
+ var pairs = route.slice(pathEnd).split('&').slice(1).filter(keepsFragmentPair).map(function (pair) {
3652
+ return "&".concat(pair);
3653
+ }).join('');
3654
+ return prefix + pageUrlPath(route.slice(prefix.length, pathEnd)) + pairs;
3655
+ };
3380
3656
  var decodeQueryComponent = function decodeQueryComponent(component) {
3381
3657
  return decodeURIComponent(component.replace(/\+/g, ' '));
3382
3658
  };
@@ -3424,15 +3700,24 @@ var mParticle = (function () {
3424
3700
  var cleanFragment = function cleanFragment(hash) {
3425
3701
  var queryStart = hash.indexOf('?');
3426
3702
  var route = queryStart === -1 ? hash : hash.slice(0, queryStart);
3427
- var isRoute = /^#[/!]/.test(hash) || queryStart !== -1 && route.indexOf('=') === -1;
3703
+ // `#/\u2026`, `#!\u2026`, or a fragment with a `/` before any `&` or `?`, as
3704
+ // `#orders/12345678` is in routers that drop the leading slash.
3705
+ // A `/` counts only before any `=`, so a bare pair such as
3706
+ // `#access_token=abc/def` is never read as a route.
3707
+ var isHashRoute = /^#!|^#[^?&=]*\//.test(hash);
3708
+ var isRoute = isHashRoute || queryStart !== -1 && route.indexOf('=') === -1;
3428
3709
  if (isRoute && queryStart !== -1) {
3429
3710
  var query = cleanQuery(hash.slice(queryStart + 1));
3430
- return route + (query ? "?".concat(query) : '');
3711
+ return maskRoute(route) + (query ? "?".concat(query) : '');
3431
3712
  }
3713
+ // A plain anchor such as `#reviews` names a part of the page, not a route.
3432
3714
  if (hash.indexOf('=') === -1) {
3433
- return hash;
3715
+ return isHashRoute ? maskRoute(hash) : hash;
3434
3716
  }
3435
- var pairs = hash.slice(1).split('&').filter(keepsFragmentPair).join('&');
3717
+ // A hash route's path leads its pairs, as in `#/cb&tab=2`.
3718
+ var pairs = hash.slice(1).split('&').map(function (pair, i) {
3719
+ return i === 0 && isHashRoute ? maskRoute("#".concat(pair)).slice(1) : pair;
3720
+ }).filter(keepsFragmentPair).join('&');
3436
3721
  return pairs ? "#".concat(pairs) : '';
3437
3722
  };
3438
3723
  // String-based rather than `new URL`, so kept pairs and the fragment go out
@@ -3446,7 +3731,7 @@ var mParticle = (function () {
3446
3731
  var hash = hashStart === -1 ? '' : href.slice(hashStart);
3447
3732
  var beforeHash = hashStart === -1 ? href : href.slice(0, hashStart);
3448
3733
  var queryStart = beforeHash.indexOf('?');
3449
- var path = (queryStart === -1 ? beforeHash : beforeHash.slice(0, queryStart)).replace(USERINFO, '$1');
3734
+ var path = maskPath((queryStart === -1 ? beforeHash : beforeHash.slice(0, queryStart)).replace(USERINFO, '$1'), mode === UrlMode.Report ? pagePath : pageUrlPath);
3450
3735
  if (mode !== UrlMode.Full) {
3451
3736
  return path;
3452
3737
  }
@@ -4079,13 +4364,12 @@ var mParticle = (function () {
4079
4364
  // TODO: https://go.mparticle.com/work/SQDSDKS-5165
4080
4365
  (_d = this.batchesQueuedForProcessing).unshift.apply(_d, batchesThatDidNotUpload);
4081
4366
  }
4082
- // Update Offline Storage with current state of batch queue
4083
- if (!useBeacon && this.offlineStorageEnabled && this.batchVault) {
4084
- // Note: since beacon is "Fire and forget" it will empty `batchesThatDidNotUplod`
4085
- // regardless of whether the batches were successfully uploaded or not. We should
4086
- // therefore NOT overwrite Offline Storage when beacon returns, so that we can retry
4087
- // uploading saved batches at a later time. Batches should only be removed from
4088
- // Local Storage once we can confirm they are successfully uploaded.
4367
+ // Update Offline Storage with current state of batch queue. Storage was
4368
+ // purged before sending. A beacon the browser accepted counts as sent, so
4369
+ // the beacon path writes nothing unless the browser refused a batch: then
4370
+ // `batchesThatDidNotUpload` holds it, and it is stored since the page may
4371
+ // be going away.
4372
+ if (this.offlineStorageEnabled && this.batchVault && (!useBeacon || !isEmpty(batchesThatDidNotUpload))) {
4089
4373
  this.storeBatchesQueuedForProcessing();
4090
4374
  }
4091
4375
  if (triggerFuture && !this.destroyed) {
@@ -4142,7 +4426,7 @@ var mParticle = (function () {
4142
4426
  };
4143
4427
  BatchUploader.prototype.uploadBatches = function (batches, useBeacon) {
4144
4428
  return __awaiter(this, void 0, void 0, function () {
4145
- var uploads, uploadsToLog, i, fetchPayload, blob, response, e_1;
4429
+ var uploads, uploadsToLog, refused, i, fetchPayload, blob, response, e_1;
4146
4430
  return __generator(this, function (_a) {
4147
4431
  switch (_a.label) {
4148
4432
  case 0:
@@ -4157,6 +4441,7 @@ var mParticle = (function () {
4157
4441
  this.mpInstance.Logger.verbose("Uploading batches: ".concat(JSON.stringify(uploadsToLog)));
4158
4442
  this.mpInstance.Logger.verbose("Batch count: ".concat(uploads.length));
4159
4443
  }
4444
+ refused = [];
4160
4445
  i = 0;
4161
4446
  _a.label = 1;
4162
4447
  case 1:
@@ -4173,7 +4458,13 @@ var mParticle = (function () {
4173
4458
  blob = new Blob([fetchPayload.body], {
4174
4459
  type: 'text/plain;charset=UTF-8'
4175
4460
  });
4176
- navigator.sendBeacon(this.uploadUrl, blob);
4461
+ // The browser refuses a beacon it won't send, such as one over its
4462
+ // size limit, which says nothing about the next batch. Keep it and
4463
+ // try the rest. Only false is a refusal: a wrapper that returns
4464
+ // nothing has still sent it.
4465
+ if (navigator.sendBeacon(this.uploadUrl, blob) === false) {
4466
+ refused.push(uploads[i]);
4467
+ }
4177
4468
  return [3 /*break*/, 5];
4178
4469
  case 2:
4179
4470
  _a.trys.push([2, 4,, 5]);
@@ -4204,6 +4495,10 @@ var mParticle = (function () {
4204
4495
  i++;
4205
4496
  return [3 /*break*/, 1];
4206
4497
  case 6:
4498
+ if (!isEmpty(refused)) {
4499
+ this.mpInstance.Logger.warning("The browser refused the exit upload of ".concat(refused.length, " batch(es). Keeping them for the next upload."));
4500
+ return [2 /*return*/, refused];
4501
+ }
4207
4502
  return [2 /*return*/, null];
4208
4503
  }
4209
4504
  });
@@ -11784,7 +12079,7 @@ var mParticle = (function () {
11784
12079
  label_source: click.labelSource,
11785
12080
  element: click.element,
11786
12081
  type: click.type,
11787
- path: pathname,
12082
+ path: pagePath(pathname),
11788
12083
  hostname: window.location.hostname
11789
12084
  };
11790
12085
  // Optional fields are left out when the control has no safe value for them.
@@ -11797,8 +12092,10 @@ var mParticle = (function () {
11797
12092
  test_id: click.testId,
11798
12093
  controls: click.controls,
11799
12094
  form: click.form,
12095
+ group: click.group,
11800
12096
  link_type: click.linkType,
11801
12097
  link_domain: click.linkDomain,
12098
+ link_path: click.linkPath,
11802
12099
  file_extension: click.fileExtension
11803
12100
  };
11804
12101
  for (var _i = 0, _a = Object.keys(optional); _i < _a.length; _i++) {
@@ -11825,8 +12122,9 @@ var mParticle = (function () {
11825
12122
  };
11826
12123
  // One custom event per click on a link that leaves the page. Typed Other, not
11827
12124
  // Navigation, so no kit reads it as a page change. A plain link to another page
11828
- // of the site goes without its text, which can name the shopper ("Hi, Jane");
11829
- // a button or wallet acting as a link keeps its name.
12125
+ // of the site goes without its text, which can name the shopper ("Hi, Jane"), and
12126
+ // with its destination path instead; a button or wallet acting as a link keeps
12127
+ // its name.
11830
12128
  var buildLinkClickEvent = function buildLinkClickEvent(click, pathname) {
11831
12129
  var data = clickAttributes(click.linkType === 'internal' && click.element === 'link' ? __assign(__assign({}, click), {
11832
12130
  label: undefined,
@@ -11844,7 +12142,7 @@ var mParticle = (function () {
11844
12142
  // Every attribute buildButtonClickEvent can set apart from the marker and the
11845
12143
  // hostname, which is the same on every click on this page. A spec checks the
11846
12144
  // two stay in step.
11847
- var CLICK_ATTRIBUTES = ['label_source', 'element', 'type', 'path', 'label', 'aria_label', 'icon', 'element_id', 'element_name', 'test_id', 'controls', 'form', 'link_type', 'link_domain', 'file_extension', 'checked'];
12145
+ var CLICK_ATTRIBUTES = ['label_source', 'element', 'type', 'path', 'label', 'aria_label', 'icon', 'element_id', 'element_name', 'test_id', 'controls', 'form', 'group', 'link_type', 'link_domain', 'link_path', 'file_extension', 'checked'];
11848
12146
  // Which check keeps some or all auto button clicks, or auto link clicks with
11849
12147
  // `link`, from a kit, or undefined when the kit receives every one. A forwarding
11850
12148
  // rule on an attribute whose value differs between clicks lets some through and
@@ -12058,17 +12356,20 @@ var mParticle = (function () {
12058
12356
  // or around it never becomes its tap. Excluded clicks give nothing; disabled controls are skipped, a
12059
12357
  // wallet-named one also by its `disabled` class. A caller that has already
12060
12358
  // found the path not excluded passes `included` so the walk runs once.
12061
- function findControl(path, included) {
12359
+ function findControl(path, included, limit) {
12062
12360
  if (included === void 0) {
12063
12361
  included = false;
12064
12362
  }
12363
+ if (limit === void 0) {
12364
+ limit = 12;
12365
+ }
12065
12366
  if (!included && isExcludedPath(path)) {
12066
12367
  return undefined;
12067
12368
  }
12068
12369
  var control;
12069
12370
  var option;
12070
12371
  var picked = false;
12071
- for (var _i = 0, _a = path.slice(0, 12); _i < _a.length; _i++) {
12372
+ for (var _i = 0, _a = path.slice(0, limit); _i < _a.length; _i++) {
12072
12373
  var target = _a[_i];
12073
12374
  if (control) {
12074
12375
  break;
@@ -12091,6 +12392,39 @@ var mParticle = (function () {
12091
12392
  }
12092
12393
  return control;
12093
12394
  }
12395
+ // Roles that act as controls without being buttons or links: pickers, tabs,
12396
+ // options and links run by script. A role can list fallbacks, so any of its
12397
+ // tokens counts.
12398
+ var ROLE_CONTROL = '[role~="radio"], [role~="menuitemradio"], [role~="checkbox"], [role~="menuitemcheckbox"], ' + '[role~="switch"], [role~="option"], [role~="tab"], [role~="link"]';
12399
+ var TYPING_FIELD = 'input:not([type="submit"]):not([type="button"]):not([type="image"]):not([type="reset"]):not([type="radio"]):not([type="checkbox"]), ' + 'textarea, select, ' + EDITABLE;
12400
+ // The nearest role control on the click path, for logging the click alone:
12401
+ // checkout intent and payment choices only ever read what findControl finds.
12402
+ // None when anything disabled sits on the path, so a role control around or
12403
+ // inside a disabled control never logs the click it suppressed.
12404
+ function findRoleControl(path, limit) {
12405
+ var entries = path.slice(0, limit).filter(function (target) {
12406
+ return target instanceof Element;
12407
+ });
12408
+ if (entries.some(function (target) {
12409
+ return target.matches(':disabled, [aria-disabled="true"]') || target.classList.contains('disabled') && isWalletNamed(target);
12410
+ })) {
12411
+ return undefined;
12412
+ }
12413
+ for (var _i = 0, entries_3 = entries; _i < entries_3.length; _i++) {
12414
+ var target = entries_3[_i];
12415
+ if (target.matches(ROLE_CONTROL)) {
12416
+ // A typing field, or anything in an editable region, takes typing
12417
+ // whatever role it carries.
12418
+ return target.matches(TYPING_FIELD) || hasComposedAncestor(target, EDITABLE) ? undefined : target;
12419
+ }
12420
+ // A click in a typing field belongs to the field, not to the role
12421
+ // control around it.
12422
+ if (target.matches(TYPING_FIELD)) {
12423
+ return undefined;
12424
+ }
12425
+ }
12426
+ return undefined;
12427
+ }
12094
12428
  // Text that may hold what a shopper typed or what the page opted out: form
12095
12429
  // fields, editable regions, and anything under data-mp-no-track. `own` is a
12096
12430
  // box the element labels, which holds no typing of its own.
@@ -12131,6 +12465,11 @@ var mParticle = (function () {
12131
12465
  var masked = text.replace(digitPatterns.number, '#').slice(0, 64).trim();
12132
12466
  return masked || undefined;
12133
12467
  }
12468
+ // Masks numbers and amounts in any script as '#', or undefined where the browser
12469
+ // can't see every script's digits.
12470
+ function maskNumbers(text) {
12471
+ return digitPatterns ? text.replace(digitPatterns.number, '#') : undefined;
12472
+ }
12134
12473
 
12135
12474
  var DOWNLOAD_EXTENSIONS = /\.(pdf|zip|csv|txt|ics|docx?|xlsx?|pptx?|dmg|exe|apk|mp3|mp4)$/i;
12136
12475
  function elementKind(control) {
@@ -12141,7 +12480,9 @@ var mParticle = (function () {
12141
12480
  if (tag === 'button' || tag === 'input') {
12142
12481
  return tag;
12143
12482
  }
12144
- return control.matches('a[href]:not([role="button"])') ? 'link' : 'role';
12483
+ // A link run by script reads as a link, as an anchor does, so whoever reads
12484
+ // the click treats it the same.
12485
+ return control.matches('a[href]:not([role="button"]), [role~="link"]:not([role~="button"])') ? 'link' : 'role';
12145
12486
  }
12146
12487
  var NOT_RENDERED = 'script, style, template, [hidden], [aria-hidden="true"], [inert]';
12147
12488
  // The most nodes read for one label source, and images looked at for one
@@ -12323,7 +12664,7 @@ var mParticle = (function () {
12323
12664
  } else if (readable && isShown(control)) {
12324
12665
  var text = renderedText(control);
12325
12666
  if (text === undefined) {
12326
- return {
12667
+ return headingName(control) || {
12327
12668
  source: 'none'
12328
12669
  };
12329
12670
  }
@@ -12341,6 +12682,12 @@ var mParticle = (function () {
12341
12682
  };
12342
12683
  }
12343
12684
  }
12685
+ if (!(control instanceof HTMLInputElement) && isShown(control)) {
12686
+ var heading = headingName(control);
12687
+ if (heading) {
12688
+ return heading;
12689
+ }
12690
+ }
12344
12691
  var title = nonBlank(control.getAttribute('title'));
12345
12692
  if (title) {
12346
12693
  return {
@@ -12352,6 +12699,25 @@ var mParticle = (function () {
12352
12699
  source: 'none'
12353
12700
  };
12354
12701
  }
12702
+ // A card whose text runs too long to keep, or has too many parts to read, is
12703
+ // named by its first heading, as a product or menu card usually is. Not a card
12704
+ // holding a form field, nor one that takes in slotted content or holds a
12705
+ // shadow root, whose fields the form field check can't see.
12706
+ function headingName(control) {
12707
+ if (isUnsafeTextSource(control) || control.shadowRoot || control.querySelector('slot') ||
12708
+ // A custom element can hide fields in a closed shadow root, which
12709
+ // nothing outside it can see.
12710
+ control.tagName.indexOf('-') !== -1 || Array.from(control.querySelectorAll('*')).some(function (element) {
12711
+ return !!element.shadowRoot || element.tagName.indexOf('-') !== -1;
12712
+ })) {
12713
+ return undefined;
12714
+ }
12715
+ var text = textOf(control.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]'));
12716
+ return text ? {
12717
+ raw: text,
12718
+ source: 'heading'
12719
+ } : undefined;
12720
+ }
12355
12721
  var ICON_CLASS = /^(?:fa|fas|far|fal|icon|bi|glyphicon|ti|mdi|ri|la|lucide|ion|feather|ph)-([a-z][a-z-]{1,31})$/;
12356
12722
  // Size, style and animation classes that share the icon prefixes.
12357
12723
  var ICON_MODIFIER = /^(?:solid|regular|light|thin|duotone|brands|sharp|fw|lg|xs|sm|xl|spin|pulse|border|inverse|stack|flip(?:-[a-z]+)?|rotate(?:-[a-z]+)?|pull-[a-z]+)$/;
@@ -12405,6 +12771,12 @@ var mParticle = (function () {
12405
12771
  var digits = id.replace(/[^0-9]/g, '').length;
12406
12772
  return id.length <= 64 && digits <= 3 && HAND_WRITTEN_ID.test(id) ? id : undefined;
12407
12773
  }
12774
+ // A picker's input name, which names its question. Sites often end it with the
12775
+ // id of the product it belongs to (`color-swatches-48213907`); without that
12776
+ // number it still names the question.
12777
+ function questionName(value) {
12778
+ return stableIdentifier(value && value.trim().replace(/[-_][0-9]{4,}$/, ''));
12779
+ }
12408
12780
  var TEST_ID_ATTRIBUTES = ['data-testid', 'data-test-id', 'data-test', 'data-qa', 'data-cy'];
12409
12781
  // The first of these attributes that holds a developer-written identifier.
12410
12782
  function firstIdentifier(control, attributes) {
@@ -12432,15 +12804,60 @@ var mParticle = (function () {
12432
12804
  }
12433
12805
  // The browser's normalised type: a button defaults to 'submit' and an unknown
12434
12806
  // input type reads as 'text'. A native button or input reports its own type, a
12435
- // wallet-named one included; any other control, such as a role button or a
12436
- // vendor's wallet element, reads as 'button'.
12807
+ // wallet-named one included. A role control that isn't a button reads as its
12808
+ // role: 'radio', 'option', 'tab', 'link' and so on. Any other control, such as
12809
+ // a role button or a vendor's wallet element, reads as 'button'.
12810
+ var CONTROL_ROLES = ['radio', 'menuitemradio', 'checkbox', 'menuitemcheckbox', 'switch', 'option', 'tab', 'link'];
12437
12811
  function controlType(control) {
12438
12812
  var tag = control.tagName.toLowerCase();
12439
- return tag === 'button' || tag === 'input' ? control.type : 'button';
12813
+ if (tag === 'button' || tag === 'input') {
12814
+ return control.type;
12815
+ }
12816
+ if (control.matches('[role~="button"]')) {
12817
+ return 'button';
12818
+ }
12819
+ var roles = (control.getAttribute('role') || '').trim().split(/\s+/);
12820
+ for (var _i = 0, roles_1 = roles; _i < roles_1.length; _i++) {
12821
+ var role = roles_1[_i];
12822
+ if (CONTROL_ROLES.indexOf(role) !== -1) {
12823
+ return role;
12824
+ }
12825
+ }
12826
+ return 'button';
12827
+ }
12828
+ // The page a link to another page of the site opens: its decoded path alone,
12829
+ // since an id in a path can point at one shopper. A segment holding a token,
12830
+ // or following a route word that names a person or carries a code, goes as
12831
+ // `*`, and numbers in any script are masked as in labels. Dropped when it
12832
+ // holds an @, `;` or `=` (session ids and matrix parameters), is too long to
12833
+ // be a route, or the browser can't mask every script's digits.
12834
+ function linkPath(link) {
12835
+ // Each segment decodes on its own, so a literal `%` in one never stops the
12836
+ // next from decoding.
12837
+ var decoded = link.pathname.split('/').map(decodeFully);
12838
+ if (decoded.some(function (segment) {
12839
+ return segment === undefined;
12840
+ })) {
12841
+ return undefined;
12842
+ }
12843
+ var path = decoded.join('/');
12844
+ if (path.length > MAX_RAW_LABEL_LENGTH || /[@;=]/.test(path) || LEFTOVER_ESCAPE.test(path)) {
12845
+ return undefined;
12846
+ }
12847
+ var segments = path.split('/');
12848
+ var masked = segments.map(function (segment, i) {
12849
+ if (i > 0 && segment && hidesSegmentAfter(routeSegment(segments, i), segment)) {
12850
+ return '*';
12851
+ }
12852
+ var tokens = maskTokenSegment(segment, segment, i > 0 ? routeSegment(segments, i) : undefined);
12853
+ return tokens === undefined ? segment : tokens;
12854
+ });
12855
+ return maskNumbers(masked.join('/'));
12440
12856
  }
12441
12857
  // Reads the link's own resolved URL parts, never its full href: only the hostname of
12442
- // a link that leaves the site and the extension of a file it downloads travel.
12443
- // mailto:, tel: and other schemes give undefined.
12858
+ // a link that leaves the site, the path of one to another page of the site and the
12859
+ // extension of a file it downloads travel. mailto:, tel: and other schemes give
12860
+ // undefined.
12444
12861
  function describeLink(link) {
12445
12862
  var href = (link.getAttribute('href') || '').trim();
12446
12863
  if (!href || href.charAt(0) === '#') {
@@ -12478,7 +12895,8 @@ var mParticle = (function () {
12478
12895
  return link.pathname === window.location.pathname && link.search === window.location.search ? {
12479
12896
  type: 'same_page'
12480
12897
  } : {
12481
- type: 'internal'
12898
+ type: 'internal',
12899
+ path: linkPath(link)
12482
12900
  };
12483
12901
  }
12484
12902
  // Whether a described click is on a link that leaves the page, logged as a link
@@ -12488,7 +12906,7 @@ var mParticle = (function () {
12488
12906
  }
12489
12907
  // Checkboxes and radios, and the roles that act as them. A role can list
12490
12908
  // fallbacks, so any of its tokens counts.
12491
- var CHOICE = 'input[type="checkbox"], input[type="radio"], [role~="checkbox"], [role~="radio"], ' + '[role~="switch"], [role~="menuitemcheckbox"], [role~="menuitemradio"]';
12909
+ var CHOICE = 'input[type="checkbox"], input[type="radio"], [role~="checkbox"], [role~="radio"], ' + '[role~="switch"], [role~="menuitemcheckbox"], [role~="menuitemradio"], [role~="option"]';
12492
12910
  var RADIO_ROLE = '[role~="radio"], [role~="menuitemradio"]';
12493
12911
  function boxOf(element) {
12494
12912
  var box = element instanceof HTMLLabelElement ? element.control : element;
@@ -12546,32 +12964,143 @@ var mParticle = (function () {
12546
12964
  // radio's id, test id and the panel it opens name the option picked, so
12547
12965
  // only the name of its input, the question, goes with it. A wrapper's own
12548
12966
  // name, or a role radio's, can name the option instead.
12549
- var radio = isRadio(choice);
12550
- var named = !radio ? control : choice instanceof HTMLInputElement ? choice : null;
12551
- var name = choice ? undefined : accessibleName(control);
12967
+ // An option of a list box, like a radio, names the answer picked.
12968
+ var radio = isRadio(choice) || !!choice && choice.matches('[role~="option"]');
12969
+ var named = !radio ? control : choice instanceof HTMLInputElement && !choice.matches('[role~="option"]') ? choice : null;
12970
+ // A link run by script has no address to say where it goes, so it goes
12971
+ // without its text, as a plain link to another page of the site does: the
12972
+ // text can name the shopper ("Hi, Jane").
12973
+ // A native button keeps its name whatever its role, as it always has.
12974
+ var textless = !!choice || !link && element !== 'wallet' && control.matches('[role~="link"]') && !control.matches(CONTROL_SELECTOR);
12975
+ var name = textless ? undefined : accessibleName(control);
12552
12976
  var label = name ? maskLabel(name.raw) : undefined;
12553
12977
  return {
12554
12978
  label: label,
12555
12979
  labelSource: label ? name.source : 'none',
12556
12980
  // Kept on its own as well: it often says more than the visible text,
12557
12981
  // such as which item a remove button removes.
12558
- ariaLabel: choice ? undefined : maskLabel(control.getAttribute('aria-label')),
12982
+ ariaLabel: textless ? undefined : maskLabel(control.getAttribute('aria-label')),
12559
12983
  icon: choice ? undefined : iconHint(control),
12560
12984
  elementId: radio ? undefined : stableIdentifier(control.getAttribute('id')),
12561
- elementName: stableIdentifier(named && named.getAttribute('name')),
12985
+ elementName: (choice ? questionName : stableIdentifier)(named && named.getAttribute('name')),
12562
12986
  testId: radio ? undefined : firstIdentifier(control, TEST_ID_ATTRIBUTES),
12563
12987
  controls: radio ? undefined : controlsIdentifier(control),
12564
12988
  form: formIdentifier(control),
12989
+ group: choice ? groupName(choice) : undefined,
12565
12990
  element: element,
12566
12991
  type: controlType(control),
12567
12992
  linkType: link ? link.type : undefined,
12568
12993
  linkDomain: link ? link.domain : undefined,
12994
+ linkPath: link ? link.path : undefined,
12569
12995
  fileExtension: link ? link.fileExtension : undefined,
12570
12996
  // The box as the click left it: the browser toggles it before the click
12571
12997
  // reaches listeners, and puts it back if the click is cancelled.
12572
12998
  checked: box ? box.checked : undefined
12573
12999
  };
12574
13000
  }
13001
+ // The question a picker answers, from the group around it: the group's
13002
+ // aria-label, aria-labelledby or legend, masked like a label and cut at its
13003
+ // first colon, since a legend can repeat the pick ("Color: Navy"). Never the
13004
+ // option's own text, which is the shopper's answer.
13005
+ var NAMED_GROUP = '[role="radiogroup"], [role="listbox"], [role="group"], fieldset';
13006
+ // A control such as the button that opens a list box shows the option picked.
13007
+ var NOT_A_QUESTION = CHOICE + ', ' + CONTROL_SELECTOR + ', a[href], input, select, textarea, slot, [aria-haspopup], [role~="button"], ' + '[role~="link"], [role~="tab"], [role~="combobox"]';
13008
+ var OPTION_KINDS = ['radio', 'menuitemradio', 'checkbox', 'menuitemcheckbox', 'switch', 'option'];
13009
+ function optionKind(option) {
13010
+ if (option instanceof HTMLInputElement) {
13011
+ return option.type;
13012
+ }
13013
+ var roles = (option.getAttribute('role') || '').trim().split(/\s+/);
13014
+ return roles.filter(function (role) {
13015
+ return OPTION_KINDS.indexOf(role) !== -1;
13016
+ })[0];
13017
+ }
13018
+ // Whether two options answer the same question. A native box shares its
13019
+ // question only with a box of the same name in the same form, as the browser
13020
+ // groups radios, so separate shipping and billing radios ask two.
13021
+ function sameQuestion(option, choice) {
13022
+ if (!(choice instanceof HTMLInputElement)) {
13023
+ // A role option answers only within its own radio group or list box.
13024
+ var container = '[role="radiogroup"], [role="listbox"]';
13025
+ return !(option instanceof HTMLInputElement) && optionKind(option) === optionKind(choice) && option.closest(container) === choice.closest(container);
13026
+ }
13027
+ var name = choice.getAttribute('name');
13028
+ return option instanceof HTMLInputElement && option.type === choice.type && !!name && option.getAttribute('name') === name && option.form === choice.form;
13029
+ }
13030
+ // Whether a group offers another answer to the question picked: a shown option
13031
+ // to the same question that isn't part of the one picked. A card around one
13032
+ // address radio and a "make default" checkbox, or around a role radio and the
13033
+ // hidden input behind it, offers none.
13034
+ function holdsOptions(group, choice) {
13035
+ var options = group.querySelectorAll(CHOICE);
13036
+ for (var i = 0; i < options.length; i++) {
13037
+ var option = options[i];
13038
+ if (!option.contains(choice) && !choice.contains(option) && sameQuestion(option, choice) && isShown(option, group) && isVisible(option) &&
13039
+ // A fully transparent option, as a native input behind a styled
13040
+ // one often is, shows the shopper no second answer.
13041
+ !someComposedAncestor(option, function (node) {
13042
+ return window.getComputedStyle(node).opacity === '0';
13043
+ }, group)) {
13044
+ return true;
13045
+ }
13046
+ }
13047
+ return false;
13048
+ }
13049
+ // The elements an option is named by: those its aria-labelledby points at and
13050
+ // a native box's labels.
13051
+ function optionLabels(option, root) {
13052
+ var labels = option instanceof HTMLInputElement && option.labels ? Array.from(option.labels) : [];
13053
+ (option.getAttribute('aria-labelledby') || '').trim().split(/\s+/).forEach(function (ref) {
13054
+ var target = ref && root.getElementById ? root.getElementById(ref) : null;
13055
+ if (target) {
13056
+ labels.push(target);
13057
+ }
13058
+ });
13059
+ return labels;
13060
+ }
13061
+ // The group asking the question a pick answers. A role radio's is its
13062
+ // radiogroup and an option's its list box; a role toggle, such as a switch,
13063
+ // stands alone. A native box's is the nearest group offering other answers,
13064
+ // since a group around one option, such as a saved address card, can be
13065
+ // named by that option.
13066
+ function questionGroup(choice) {
13067
+ if (!(choice instanceof HTMLInputElement)) {
13068
+ var container = choice.matches('[role~="option"]') ? choice.closest('[role="listbox"]') : choice.matches('[role~="radio"]') ? choice.closest('[role="radiogroup"]') : null;
13069
+ return container && holdsOptions(container, choice) ? container : null;
13070
+ }
13071
+ var group = choice.closest(NAMED_GROUP);
13072
+ while (group && !holdsOptions(group, choice)) {
13073
+ group = group.parentElement ? group.parentElement.closest(NAMED_GROUP) : null;
13074
+ }
13075
+ return group;
13076
+ }
13077
+ function groupName(choice) {
13078
+ var group = questionGroup(choice);
13079
+ if (!group) {
13080
+ return undefined;
13081
+ }
13082
+ var raw = nonBlank(group.getAttribute('aria-label'));
13083
+ if (!raw) {
13084
+ var ids = group.getAttribute('aria-labelledby');
13085
+ var legend = group.tagName === 'FIELDSET' ? Array.from(group.children).filter(function (child) {
13086
+ return child.tagName === 'LEGEND';
13087
+ })[0] : undefined;
13088
+ var root_2 = isFunction(group.getRootNode) ? group.getRootNode() : document;
13089
+ var source_1 = ids ? root_2.getElementById ? root_2.getElementById(ids.trim().split(/\s+/)[0]) : null : legend || null;
13090
+ // A label that is, holds or sits in an option or a control, or in an
13091
+ // option's label, would name the answer.
13092
+ var holder = source_1 ? source_1.closest('label') : null;
13093
+ // Nor one that holds, or sits in, what an option of the group names
13094
+ // itself by.
13095
+ var labelsOption = !!source_1 && Array.from(group.querySelectorAll(CHOICE)).some(function (option) {
13096
+ return optionLabels(option, root_2).some(function (label) {
13097
+ return source_1.contains(label) || label.contains(source_1);
13098
+ });
13099
+ });
13100
+ raw = source_1 && !labelsOption && !hasComposedAncestor(source_1, NOT_A_QUESTION) && !source_1.querySelector(NOT_A_QUESTION) && !(holder && (holder.control || holder.querySelector(CHOICE))) ? textOf(source_1) : undefined;
13101
+ }
13102
+ return raw ? maskLabel(raw.split(':')[0]) : undefined;
13103
+ }
12575
13104
  // A question about payment, read from the names its author gave it, so
12576
13105
  // "checkout[payment_gateway]", "paymentMethod" and "tender_type" all count and
12577
13106
  // "payee" does not. A radio's input name is its question; its group or
@@ -12621,7 +13150,20 @@ var mParticle = (function () {
12621
13150
  return radio instanceof HTMLInputElement ? radio.checked : radio.getAttribute('aria-checked') === 'true';
12622
13151
  }
12623
13152
 
12624
- var checkoutPath = /(?:^|\/)(?:checkouts?|payment|order|kasse|paiement|commander?|pago)(?:\/|$)/i;
13153
+ // A checkout segment may name its kind or its version, as /express-checkout, /checkout-v2 and /checkout-external do.
13154
+ // Other hyphenated words make it a content slug, such as /help/checkout-faq or /products/self-checkout-kiosk.
13155
+ var CHECKOUT_SEGMENT = '(?:(?:express|guest|quick|secure|complete|one-?page|single-?page|mobile)-)?checkouts?' + '(?:-(?:v\\d+|\\d+|flow|external|process|step(?:-\\d+)?))?';
13156
+ var checkoutPath = new RegExp('(?:^|\\/)(?:' + CHECKOUT_SEGMENT + '|payment|order|kasse|paiement|commander?|pago)(?:\\/|$)', 'i');
13157
+ // A route with a checkout segment itself, not just a payment or order one, which account pages also use.
13158
+ var checkoutSegmentPath = new RegExp('(?:^|\\/)' + CHECKOUT_SEGMENT + '(?:\\/|$)', 'i');
13159
+ // A confirmation page under a checkout route, such as /checkout/thankyou/<order> or /checkouts/<token>/thank_you, comes
13160
+ // after the confirm click. "Complete" and "success" mark one only as the last word of a segment, so /complete-checkout
13161
+ // and /checkout/complete-registration stay checkout routes.
13162
+ var confirmationPath = /(?:^|[\/_-])(?:thank[-_]?you|thanks|confirmation|confirmed|receipt|received)(?:[\/_-]|$)|(?:^|[\/_-])(?:complete|completed|success)(?:\/|$)/i;
13163
+ // A bare "Register" creates an account, rather than placing the order, on a checkout's sign-in or account step or in a
13164
+ // form that asks for a password.
13165
+ var bareRegister = /^register(?: now)?$/;
13166
+ var accountStepPath = /(?:^|\/)(?:register|registration|login|log-in|signin|sign-in|signup|sign-up|account|create-account)(?:\/|$)/i;
12625
13167
  // English look-alikes are whole words; the other languages stay substring
12626
13168
  // matches, since several of them do not separate words with spaces.
12627
13169
  var englishExclusions = new RegExp('\\b(?:coupon|promo(?: code)?|newsletter|sign in|sign up|log in|login|add to (?:cart|bag|basket)|' + 'gift card balance|(?:add|apply|redeem|check|enter|use) (?:a |an |your )?gift card|pay later|' + 'interest free|as low as|learn more|prequalify|apply (?:now|for)|payment plan|available|accepted|' + 'we accept|set up|add money|reload|top up|add to (?:apple|google) wallet|connect|link your|unlink|' + 'remove|delete|edit|change|manage|use a different|switch)\\b|^pay in$|\\bpay in (?:installments|instalments)\\b');
@@ -12632,8 +13174,11 @@ var mParticle = (function () {
12632
13174
  // A plain link is navigation, so in English only an order or purchase it
12633
13175
  // places, submits or completes counts as the confirm click.
12634
13176
  var linkCommits = new RegExp('^(?:(?:place|submit|complete)(?!(?: [^ ]+){0,2} (?:another|new|next)(?: |$))(?: [^ ]+){0,2} (?:order|purchase)|' + otherCommits + ')$');
12635
- var routeGatedCommits = new RegExp('^(?:buy now|purchase|jetzt kaufen|kaufen|commander|(?:passer|valider) (?:la |ma )?commande|finalizar (?:la |mi )?compra|' + 'comprar ahora|acquista ora|nu kopen|\u4ECA\u3059\u3050\u8CFC\u5165|\u8CFC\u5165\u3059\u308B|\u3053\u306E\u5185\u5BB9\u3067\u7533\u3057\u8FBC\u3080)$');
13177
+ var routeGatedCommits = new RegExp('^(?:buy now|purchase|register(?: now)?|complete (?:my |your )?registration|jetzt kaufen|kaufen|commander|(?:passer|valider) (?:la |ma )?commande|finalizar (?:la |mi )?compra|' + 'comprar ahora|acquista ora|nu kopen|\u4ECA\u3059\u3050\u8CFC\u5165|\u8CFC\u5165\u3059\u308B|\u3053\u306E\u5185\u5BB9\u3067\u7533\u3057\u8FBC\u3080)$');
12636
13178
  var steps = new RegExp('^(?:' + ["(?:let's |lets |go to |proceed to |secure )?(?:check ?out|checkout)(?: now| securely| as guest)?", '(?:continue|proceed) to (?:payment|review|billing|shipping|delivery|order review)|review (?:my |your )?order', 'guest checkout|(?:pay|continue|check ?out) (?:with|by) (?:card|credit card|debit card|credit or debit card)', 'zur kasse|zur kasse gehen|auschecken|weiter zur zahlung', 'passer \u00E0 la caisse|continuer vers le paiement|proc\u00E9der au paiement', 'ir a caja|continuar al pago|tramitar pedido', "procedi al checkout|procedi all'acquisto|procedi al pagamento|vai alla cassa", 'afrekenen|verder naar betalen|doorgaan naar betalen', '\u30EC\u30B8\u306B\u9032\u3080|\u3054?(?:\u8CFC\u5165|\u6CE8\u6587)\u624B\u7D9A\u304D(?:\u3078|\u306B)(?:\u9032\u3080)?|\u304A?\u652F\u6255\u3044(?:\u3078|\u306B)\u9032\u3080|(?:\u3054\u6CE8\u6587\u5185\u5BB9\u306E)?\u78BA\u8A8D\u753B\u9762\u3078(?:\u9032\u3080)?'].join('|') + ')$');
13179
+ // "Save and continue" also saves profile, address and card forms, so it is a step only on a checkout segment's route
13180
+ // that isn't an account or confirmation page.
13181
+ var routeGatedSteps = /^save and continue$/;
12637
13182
  var CART_NOUN = '(?:(?:my|your|the) )?(?:shopping )?(?:cart|bag|basket)';
12638
13183
  // "Add to bag" and "Add 2 to cart": a click that puts something in the cart.
12639
13184
  var addToCartLabels = new RegExp('^add(?: [^ ]+){0,5} to ' + CART_NOUN + '$');
@@ -12679,7 +13224,7 @@ var mParticle = (function () {
12679
13224
  var EDGE = "[\\s!-/:-@\\[-`{-~\\u00A1-\\u00BF\\u00D7\\u00F7\\u2000-\\u206F\\u20A0-\\u20CF\\u2190-\\u2BFF" + "\\u3000-\\u3003\\u3008-\\u3011\\u3014-\\u301F\\u30FB\\uD800-\\uDFFF\\uFE00-\\uFE0F" + "\\uFF01-\\uFF0F\\uFF1A-\\uFF20\\uFF3B-\\uFF40\\uFF5B-\\uFF65]+";
12680
13225
  var EDGE_SYMBOLS = new RegExp('^' + EDGE + '|' + EDGE + '$', 'g');
12681
13226
  function getIntentRouteClass(pathname) {
12682
- return checkoutPath.test(pathname) ? 'checkout_path' : 'other';
13227
+ return checkoutPath.test(pathname) && !confirmationPath.test(pathname) ? 'checkout_path' : 'other';
12683
13228
  }
12684
13229
  var SEPARATORS = /[-_|\u00B7\u2022]/g;
12685
13230
  var CURRENCY_CODES = /(?:^| )(?:usd|aud|cad|nzd|gbp|eur|chf|sgd|hkd|jpy|inr|mxn)(?= |$)|\b(?:a|au|ca|c|us|nz|hk|s)\$/g;
@@ -12744,13 +13289,26 @@ var mParticle = (function () {
12744
13289
  return openCartLabels.test(text);
12745
13290
  }) ? 'open' : undefined;
12746
13291
  }
12747
- // "Buy now" and "Purchase" count only on a checkout route, and never on a plain
12748
- // link, which is navigation.
12749
- function isCommitLabel(text, isLink, pathname) {
13292
+ // Every field the browser counts as the form's own, form-attribute fields included. Read through the native getter
13293
+ // where the browser has one, since a field named "elements" shadows the form's own property.
13294
+ function formFields(form) {
13295
+ var descriptor = Object.getOwnPropertyDescriptor(HTMLFormElement.prototype, 'elements');
13296
+ return descriptor && descriptor.get ? descriptor.get.call(form) : form.elements;
13297
+ }
13298
+ // The form a button or input submits, which its form attribute can name from outside it.
13299
+ function createsAccount(control, pathname) {
13300
+ var form = control instanceof HTMLButtonElement || control instanceof HTMLInputElement ? control.form : control.closest('form');
13301
+ return accountStepPath.test(pathname) || !!form && Array.prototype.some.call(formFields(form), function (field) {
13302
+ return field instanceof HTMLInputElement && field.type === 'password';
13303
+ });
13304
+ }
13305
+ // "Buy now", "Purchase" and "Register" count only on a checkout route that isn't a
13306
+ // confirmation page, and never on a plain link, which is navigation.
13307
+ function isCommitLabel(text, isLink, pathname, control) {
12750
13308
  if (isLink) {
12751
13309
  return linkCommits.test(text);
12752
13310
  }
12753
- return commits.test(text) || routeGatedCommits.test(text) && getIntentRouteClass(pathname) === 'checkout_path';
13311
+ return commits.test(text) || routeGatedCommits.test(text) && getIntentRouteClass(pathname) === 'checkout_path' && !(bareRegister.test(text) && createsAccount(control, pathname));
12754
13312
  }
12755
13313
  // Classifies a clicked or submitting control found by findControl, in order:
12756
13314
  // pickers, toggles and pay-later messages never count; a vendor's own wallet
@@ -12794,7 +13352,7 @@ var mParticle = (function () {
12794
13352
  }
12795
13353
  var isLink = control.matches('a[href]:not([role="button"])');
12796
13354
  if (cores.some(function (text) {
12797
- return isCommitLabel(text, isLink, pathname);
13355
+ return isCommitLabel(text, isLink, pathname, control);
12798
13356
  })) {
12799
13357
  return {
12800
13358
  kind: 'commit',
@@ -12827,7 +13385,7 @@ var mParticle = (function () {
12827
13385
  source: 'brand'
12828
13386
  };
12829
13387
  }
12830
- if (steps.test(text)) {
13388
+ if (steps.test(text) || isButton && routeGatedSteps.test(text) && checkoutSegmentPath.test(pathname) && !confirmationPath.test(pathname) && !accountStepPath.test(pathname)) {
12831
13389
  return {
12832
13390
  kind: 'checkout_step',
12833
13391
  source: 'label'
@@ -12915,13 +13473,12 @@ var mParticle = (function () {
12915
13473
  var SUBMIT_AFTER_COMMIT_CLICK_MS = 1000;
12916
13474
  // Apple Pay must begin inside the tap's own handler, so the tap is this recent.
12917
13475
  var APPLE_PAY_AFTER_CLICK_MS = 1000;
12918
- var PATH_LIMIT = 12;
13476
+ // How far up from the clicked element a control is looked for. Checkout intent
13477
+ // keeps findControl's own 12; logging looks further, past deep product cards.
13478
+ var PATH_LIMIT = 20;
12919
13479
  // A ceiling, not a dedupe: a held Enter key or browser automation fires a
12920
13480
  // trusted click per repeat, and each one is forwarded to every kit.
12921
13481
  var PAGE_EVENT_LIMIT = 100;
12922
- // A click uploads at once. Taps that follow it this quickly are held and share
12923
- // one upload once they stop.
12924
- var UPLOAD_DEBOUNCE_MS = 100;
12925
13482
  var btnWindow = function btnWindow() {
12926
13483
  return typeof window === 'undefined' ? null : window;
12927
13484
  };
@@ -13039,10 +13596,6 @@ var mParticle = (function () {
13039
13596
  this.pageEvents = 0;
13040
13597
  // Clicks read at dispatch and waiting for the next paint.
13041
13598
  this.pending = [];
13042
- this.uploadTimerId = null;
13043
- this.uploadHeld = false;
13044
- // Set while the page is left, when the SDK's exit upload sends the queue.
13045
- this.leaving = false;
13046
13599
  this.drain = function () {
13047
13600
  var queue = _this.pending;
13048
13601
  _this.pending = [];
@@ -13056,14 +13609,8 @@ var mParticle = (function () {
13056
13609
  // beforeunload, the first of them.
13057
13610
  this.onHide = function (event) {
13058
13611
  if (event.type === 'beforeunload' || event.type === 'pagehide' || document.visibilityState === 'hidden') {
13059
- _this.leaving = true;
13060
- try {
13061
- _this.drain();
13062
- } finally {
13063
- _this.leaving = false;
13064
- }
13065
- // The SDK's exit upload sends the queue, held taps included.
13066
- _this.cancelUpload();
13612
+ // The SDK's exit upload then sends these clicks with the queue.
13613
+ _this.drain();
13067
13614
  }
13068
13615
  };
13069
13616
  this.onPageShow = function (event) {
@@ -13190,7 +13737,6 @@ var mParticle = (function () {
13190
13737
  this.lastSent = {};
13191
13738
  this.pagePath = null;
13192
13739
  this.pageEvents = 0;
13193
- this.cancelUpload();
13194
13740
  };
13195
13741
  ButtonTracker.prototype.enqueue = function (delivery) {
13196
13742
  this.pending.push(delivery);
@@ -13229,22 +13775,25 @@ var mParticle = (function () {
13229
13775
  var path = isFunction(event.composedPath) ? event.composedPath().slice(0, PATH_LIMIT) : [];
13230
13776
  var excluded = isExcludedPath(path);
13231
13777
  var control = excluded ? undefined : findControl(path, true);
13232
- if (control) {
13778
+ var logged = control || (excluded ? undefined : findControl(path, true, PATH_LIMIT) || findRoleControl(path, PATH_LIMIT));
13779
+ if (logged) {
13233
13780
  // With links alone, only an anchor can be logged.
13234
- click = logsEvents && (this.logButtons || control.matches('a[href]')) ? describeButtonClick(control, path) : undefined;
13781
+ click = logsEvents && (this.logButtons || logged.matches('a[href]')) ? describeButtonClick(logged, path) : undefined;
13235
13782
  if (click) {
13236
13783
  asLink = this.logLinks && isLinkClick(click);
13237
13784
  // Without link tracking, a link that leaves the page stays a
13238
13785
  // button click, except a plain link to another page of the
13239
13786
  // site, which button tracking never logged.
13240
- var asButton = this.logButtons && !asLink && !(click.linkType === 'internal' && control.matches('a[href]:not([role="button"])'));
13787
+ var asButton = this.logButtons && !asLink && !(click.linkType === 'internal' && logged.matches('a[href]:not([role="button"])'));
13241
13788
  if (!asLink && !asButton) {
13242
13789
  click = undefined;
13243
13790
  }
13244
13791
  }
13245
13792
  if (click && click.checked !== undefined) {
13246
- box = control;
13793
+ box = logged;
13247
13794
  }
13795
+ }
13796
+ if (control) {
13248
13797
  // Checkout intent is only worked out when the kit can use it.
13249
13798
  var intent = signalsKit ? classifyIntent(control, pathname) : undefined;
13250
13799
  kind = intent ? intent.kind : undefined;
@@ -13331,46 +13880,9 @@ var mParticle = (function () {
13331
13880
  this.pageEvents++;
13332
13881
  // Restarts an expired session, as public logEvent() and page views do.
13333
13882
  this.mpInstance._SessionManager.resetSessionTimer();
13334
- var queued = this.queuedForUpload();
13883
+ // No upload of its own, which a click that opens another page would
13884
+ // cancel: with batching on, the next batch or the exit upload sends it.
13335
13885
  this.mpInstance._Events.logEvent(asLink ? buildLinkClickEvent(click, pathname) : buildButtonClickEvent(click, pathname));
13336
- // Only a click waiting for the batch interval is sent early: a held
13337
- // one goes out when released, and with batching off it already has.
13338
- // A checkbox or radio waits for the batch, since choosing an option
13339
- // doesn't leave the page the way a confirm click can.
13340
- if (!this.leaving && click.checked === undefined && this.queuedForUpload() > queued) {
13341
- this.uploadNow();
13342
- }
13343
- };
13344
- // Events waiting in the upload queue for the next batch interval.
13345
- ButtonTracker.prototype.queuedForUpload = function () {
13346
- var uploader = this.mpInstance._APIClient.uploader;
13347
- return uploader ? uploader.eventsQueuedForProcessing.length : 0;
13348
- };
13349
- // Uploads a click without waiting for the batch interval. A tap inside the
13350
- // wait after the last one is held, and each restarts the wait, so a burst
13351
- // after the first click goes out in one request once the taps stop.
13352
- ButtonTracker.prototype.uploadNow = function () {
13353
- var _this = this;
13354
- if (this.uploadTimerId === null) {
13355
- this.mpInstance.upload();
13356
- } else {
13357
- clearTimeout(this.uploadTimerId);
13358
- this.uploadHeld = true;
13359
- }
13360
- this.uploadTimerId = setTimeout(function () {
13361
- _this.uploadTimerId = null;
13362
- if (_this.uploadHeld) {
13363
- _this.uploadHeld = false;
13364
- _this.mpInstance.upload();
13365
- }
13366
- }, UPLOAD_DEBOUNCE_MS);
13367
- };
13368
- ButtonTracker.prototype.cancelUpload = function () {
13369
- if (this.uploadTimerId !== null) {
13370
- clearTimeout(this.uploadTimerId);
13371
- this.uploadTimerId = null;
13372
- }
13373
- this.uploadHeld = false;
13374
13886
  };
13375
13887
  ButtonTracker.prototype.emit = function (kind, source, layer, t, pathname, adds) {
13376
13888
  if (this.isKitSuppressed()) {