@mparticle/web-sdk 3.24.0 → 3.26.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.
@@ -1,12 +1,20 @@
1
1
  import { IMParticleWebSDKInstance } from '../mp-instance';
2
2
  import { isFunction } from '../utils';
3
- import { buildButtonClickEvent } from './buttonClickEvent';
4
- import { describeButtonClick, IButtonClick } from './buttonDescription';
3
+ import { buildButtonClickEvent, buildLinkClickEvent } from './buttonClickEvent';
4
+ import {
5
+ describeButtonClick,
6
+ describePaymentChoice,
7
+ IButtonClick,
8
+ IPaymentChoice,
9
+ isLinkClick,
10
+ isPicked,
11
+ } from './buttonDescription';
5
12
  import {
6
13
  CheckoutIntentKind,
7
14
  CheckoutIntentSource,
8
15
  classifyIntent,
9
- classifyIntentFrame,
16
+ classifyPaymentFrame,
17
+ PaymentFrameFamily,
10
18
  } from './checkoutIntent';
11
19
  import { findControl, isExcludedPath } from './controls';
12
20
 
@@ -37,6 +45,9 @@ type WindowWithBtn = Window & {
37
45
  export interface IButtonTrackerOptions {
38
46
  // The Rokt kit signal only runs when this init configures a Rokt kit.
39
47
  hasRoktKit: boolean;
48
+ // Button clicks and clicks on links that leave the page are logged apart.
49
+ logButtons: boolean;
50
+ logLinks: boolean;
40
51
  }
41
52
 
42
53
  const btnWindow = (): WindowWithBtn | null =>
@@ -137,12 +148,16 @@ const CAPTURE_PASSIVE: AddEventListenerOptions = {
137
148
  };
138
149
  const PASSIVE: AddEventListenerOptions = { passive: true };
139
150
 
140
- // Logs every button click as an mParticle event, and hands checkout intent to the
141
- // Rokt kit as a frozen {kind, t, source, pathname}. Submits and wallet frame focus feed the kit only.
151
+ // Logs button clicks and clicks on links that leave the page as mParticle events, and hands checkout
152
+ // intent to the Rokt kit as a frozen {kind, t, source, pathname}, with adds on an add-to-cart click.
153
+ // Submits and wallet frame focus feed the kit only, and so do payment picks: a payment radio's option
154
+ // names, or the payment family of a focused frame.
142
155
  export class ButtonTracker {
143
156
  private readonly mpInstance: IMParticleWebSDKInstance;
144
157
  private active = false;
145
158
  private hasRoktKit = false;
159
+ private logButtons = false;
160
+ private logLinks = false;
146
161
  // Bumped on reset so classification queued before it is dropped.
147
162
  private generation = 0;
148
163
  private lastCommitClick = -Infinity;
@@ -186,8 +201,13 @@ export class ButtonTracker {
186
201
  this.teardown();
187
202
  }
188
203
  this.hasRoktKit = options.hasRoktKit;
204
+ this.logButtons = options.logButtons;
205
+ this.logLinks = options.logLinks;
189
206
 
190
- if (!this.eventsEnabled() && this.isKitSuppressed()) {
207
+ if (
208
+ (!(this.logButtons || this.logLinks) || !this.eventsEnabled()) &&
209
+ this.isKitSuppressed()
210
+ ) {
191
211
  this.log('opted out, not starting');
192
212
  return;
193
213
  }
@@ -358,7 +378,11 @@ export class ButtonTracker {
358
378
  return;
359
379
  }
360
380
  const layer = event.type;
361
- const logsEvents = layer === 'click' && this.eventsEnabled();
381
+ const logsEvents =
382
+ layer === 'click' &&
383
+ (this.logButtons || this.logLinks) &&
384
+ this.eventsEnabled() &&
385
+ !this.hasTargetingOptOut();
362
386
  const signalsKit = !this.isKitSuppressed();
363
387
  if (!logsEvents && !signalsKit) {
364
388
  return;
@@ -372,9 +396,13 @@ export class ButtonTracker {
372
396
  // button that disables or relabels itself is seen as it was clicked.
373
397
  // Logging and the kit hand-off wait for the next paint.
374
398
  let click: IButtonClick | undefined;
399
+ let asLink = false;
375
400
  let box: HTMLInputElement | undefined;
376
401
  let kind: CheckoutIntentKind | undefined;
377
402
  let source: CheckoutIntentSource | undefined;
403
+ let adds: true | undefined;
404
+ let choice: IPaymentChoice | undefined;
405
+ let family: PaymentFrameFamily | undefined;
378
406
  let unconfirmedCommit = false;
379
407
  if (layer === 'click') {
380
408
  // The composed path is only populated during dispatch.
@@ -384,9 +412,28 @@ export class ButtonTracker {
384
412
  const excluded = isExcludedPath(path);
385
413
  const control = excluded ? undefined : findControl(path, true);
386
414
  if (control) {
387
- click = logsEvents
388
- ? describeButtonClick(control, path)
389
- : undefined;
415
+ // With links alone, only an anchor can be logged.
416
+ click =
417
+ logsEvents &&
418
+ (this.logButtons || control.matches('a[href]'))
419
+ ? describeButtonClick(control, path)
420
+ : undefined;
421
+ if (click) {
422
+ asLink = this.logLinks && isLinkClick(click);
423
+ // Without link tracking, a link that leaves the page stays a
424
+ // button click, except a plain link to another page of the
425
+ // site, which button tracking never logged.
426
+ const asButton =
427
+ this.logButtons &&
428
+ !asLink &&
429
+ !(
430
+ click.linkType === 'internal' &&
431
+ control.matches('a[href]:not([role="button"])')
432
+ );
433
+ if (!asLink && !asButton) {
434
+ click = undefined;
435
+ }
436
+ }
390
437
  if (click && click.checked !== undefined) {
391
438
  box = control as HTMLInputElement;
392
439
  }
@@ -396,6 +443,10 @@ export class ButtonTracker {
396
443
  : undefined;
397
444
  kind = intent ? intent.kind : undefined;
398
445
  source = intent ? intent.source : undefined;
446
+ adds = intent ? intent.adds : undefined;
447
+ choice = signalsKit
448
+ ? describePaymentChoice(control, path)
449
+ : undefined;
399
450
  }
400
451
  this.lastClick = { t, excluded, kind, source };
401
452
  } else if (layer === 'submit') {
@@ -411,6 +462,7 @@ export class ButtonTracker {
411
462
  const intent = classifyIntent(control, pathname);
412
463
  kind = intent ? intent.kind : undefined;
413
464
  source = intent ? intent.source : undefined;
465
+ adds = intent ? intent.adds : undefined;
414
466
  }
415
467
  unconfirmedCommit = !submitter;
416
468
  }
@@ -424,7 +476,12 @@ export class ButtonTracker {
424
476
  if (layer === 'blur') {
425
477
  // Read after the blur settles: some browsers have not moved
426
478
  // focus into the frame yet when the window blurs.
427
- kind = classifyIntentFrame(document.activeElement, window);
479
+ const frame = classifyPaymentFrame(
480
+ document.activeElement,
481
+ window
482
+ );
483
+ kind = frame ? frame.kind : undefined;
484
+ family = frame ? frame.family : undefined;
428
485
  source = 'frame';
429
486
  } else if (
430
487
  unconfirmedCommit &&
@@ -444,10 +501,17 @@ export class ButtonTracker {
444
501
  ? !click.checked
445
502
  : box.checked;
446
503
  }
447
- this.logClick(click, pathname);
504
+ this.logClick(click, pathname, asLink);
505
+ }
506
+ // Ahead of the intent, so a pre-selection it fires goes out
507
+ // with this pick.
508
+ if (choice && isPicked(choice.radio)) {
509
+ this.pickPayment(t, pathname, choice.names);
510
+ } else if (family) {
511
+ this.pickPayment(t, pathname, undefined, family);
448
512
  }
449
513
  if (kind) {
450
- this.emit(kind, source, layer, t, pathname);
514
+ this.emit(kind, source, layer, t, pathname, adds);
451
515
  }
452
516
  } catch (e) {
453
517
  this.log('[error] delivery threw: ' + e);
@@ -456,8 +520,12 @@ export class ButtonTracker {
456
520
  }
457
521
 
458
522
  // Every real click is one event, repeats included, up to a ceiling per page.
459
- private logClick(click: IButtonClick, pathname: string): void {
460
- if (!this.eventsEnabled()) {
523
+ private logClick(
524
+ click: IButtonClick,
525
+ pathname: string,
526
+ asLink: boolean
527
+ ): void {
528
+ if (!this.eventsEnabled() || this.hasTargetingOptOut()) {
461
529
  return;
462
530
  }
463
531
  if (pathname !== this.pagePath) {
@@ -472,7 +540,9 @@ export class ButtonTracker {
472
540
  this.mpInstance._SessionManager.resetSessionTimer();
473
541
  const queued = this.queuedForUpload();
474
542
  this.mpInstance._Events.logEvent(
475
- buildButtonClickEvent(click, pathname)
543
+ asLink
544
+ ? buildLinkClickEvent(click, pathname)
545
+ : buildButtonClickEvent(click, pathname)
476
546
  );
477
547
  // Only a click waiting for the batch interval is sent early: a held
478
548
  // one goes out when released, and with batching off it already has.
@@ -525,7 +595,8 @@ export class ButtonTracker {
525
595
  source: CheckoutIntentSource | undefined,
526
596
  layer: string,
527
597
  t: number,
528
- pathname: string
598
+ pathname: string,
599
+ adds?: true
529
600
  ): void {
530
601
  if (this.isKitSuppressed()) {
531
602
  return;
@@ -534,24 +605,62 @@ export class ButtonTracker {
534
605
  this.lastCommitClick = t;
535
606
  }
536
607
 
537
- const last = this.lastSent[kind];
608
+ // An add-to-cart click throttles apart from a click that opens the
609
+ // cart, so opening the cart first never drops the add.
610
+ const key = adds ? kind + ':adds' : kind;
611
+ const last = this.lastSent[key];
538
612
  if (last && last.pathname === pathname && t - last.t < THROTTLE_MS) {
539
613
  return;
540
614
  }
541
- this.lastSent[kind] = { t, pathname };
615
+ this.lastSent[key] = { t, pathname };
542
616
 
543
617
  this.mpInstance._RoktManager.notifyCheckoutIntent(
544
- Object.freeze({ kind, t, source, pathname }),
618
+ Object.freeze(
619
+ adds
620
+ ? { kind, t, source, pathname, adds }
621
+ : { kind, t, source, pathname }
622
+ ),
545
623
  pathname
546
624
  );
547
625
  }
548
626
 
627
+ private pickPayment(
628
+ t: number,
629
+ pathname: string,
630
+ names?: string[],
631
+ family?: PaymentFrameFamily
632
+ ): void {
633
+ if (this.isKitSuppressed()) {
634
+ return;
635
+ }
636
+ this.mpInstance._RoktManager.notifyPaymentPick(
637
+ Object.freeze({
638
+ t,
639
+ pathname,
640
+ names: names ? Object.freeze(names.slice()) : undefined,
641
+ family,
642
+ })
643
+ );
644
+ }
645
+
549
646
  // Events follow the SDK's opt-out like any other event.
550
647
  private eventsEnabled(): boolean {
551
648
  const store = this.mpInstance._Store;
552
649
  return !!store && store.isEnabled;
553
650
  }
554
651
 
652
+ // Read per click, since the launcher options can arrive after the tracker
653
+ // starts. Only where this init configures a Rokt kit, so Global Privacy
654
+ // Control alone doesn't stop click logging elsewhere.
655
+ private hasTargetingOptOut(): boolean {
656
+ const roktManager = this.mpInstance._RoktManager;
657
+ return (
658
+ this.hasRoktKit &&
659
+ !!roktManager &&
660
+ roktManager.hasTargetingOptOut()
661
+ );
662
+ }
663
+
555
664
  // The Rokt manager owns the kit's privacy gates: flag, opt-out, launcher
556
665
  // options and Global Privacy Control.
557
666
  private isKitSuppressed(): boolean {
@@ -82,11 +82,12 @@ const steps = new RegExp(
82
82
  ')$'
83
83
  );
84
84
  const CART_NOUN = '(?:(?:my|your|the) )?(?:shopping )?(?:cart|bag|basket)';
85
- // "Add to bag", "View cart", "Shopping bag", "Bag", and a count badge such as
86
- // "1 item in bag", which reads "item in bag" once digits are stripped.
87
- const cartLabels = new RegExp(
88
- '^(?:add(?: [^ ]+){0,5} to ' + CART_NOUN +
89
- '|(?:(?:view|open|go to) )?' + CART_NOUN + '(?: items?)?' +
85
+ // "Add to bag" and "Add 2 to cart": a click that puts something in the cart.
86
+ const addToCartLabels = new RegExp('^add(?: [^ ]+){0,5} to ' + CART_NOUN + '$');
87
+ // "View cart", "Shopping bag", "Bag", and a count badge such as "1 item in bag",
88
+ // which reads "item in bag" once digits are stripped: a click that opens the cart.
89
+ const openCartLabels = new RegExp(
90
+ '^(?:(?:(?:view|open|go to) )?' + CART_NOUN + '(?: items?)?' +
90
91
  '|items? in ' + CART_NOUN + ')$'
91
92
  );
92
93
  const WALLET_NAMES =
@@ -104,20 +105,35 @@ const walletBrands = new RegExp(
104
105
  '^(?:' + WALLET_NAMES + ')(?: (?:checkout|check out|express(?: checkout)?|express pay|buy now|pay now|pay|now)' +
105
106
  '| (?:logo|icon|mark|button)(?: (?:logo|icon|mark|button))*)?$'
106
107
  );
108
+ // The payment method a frame's focus picks, in the Rokt kit's family names.
109
+ export type PaymentFrameFamily =
110
+ | 'apple_pay'
111
+ | 'google_pay'
112
+ | 'amazon_pay'
113
+ | 'shop_pay'
114
+ | 'card';
107
115
  // Exact HTTPS hosts of hosted wallet button frames.
108
- const walletFrameHosts = new Set([
109
- 'pay.google.com',
110
- 'applepay.cdn-apple.com',
111
- 'payments.amazon.com',
112
- 'pay.shopify.com',
113
- ]);
116
+ const walletFrameFamilies: { [host: string]: PaymentFrameFamily } = {
117
+ 'pay.google.com': 'google_pay',
118
+ 'applepay.cdn-apple.com': 'apple_pay',
119
+ 'payments.amazon.com': 'amazon_pay',
120
+ 'pay.shopify.com': 'shop_pay',
121
+ };
114
122
  // PayPal serves its buttons and its card fields and pay-later messages from
115
123
  // the same hosts; only the button frames are wallet taps.
116
124
  const paypalFrameHosts = new Set(['www.paypal.com', 'www.sandbox.paypal.com']);
117
125
  const PAYPAL_BUTTON_FRAME = /^\/smart\/(?:buttons|menu)(?:\/|$)/;
126
+ const PAYPAL_CARD_FRAME = /^\/smart\/card-fields?(?:\/|$)/;
127
+ // PayPal's JS SDK, used directly or wrapped by a processor, leaves its frames
128
+ // without a src and names them __zoid__paypal_<component>__ instead.
129
+ const PAYPAL_SDK_FRAME = /^__zoid__paypal_/;
130
+ const PAYPAL_SDK_BUTTON_FRAME = /^__zoid__paypal_(?:buttons|menu)__/;
118
131
  // Stripe's quick-pay button frames; its other frames are card and form fields.
119
132
  const STRIPE_WALLET_FRAME = /\/elements-inner-(?:payment-request|express-checkout)-/;
133
+ const STRIPE_CARD_FRAME = /\/elements-inner-card\b/;
120
134
  // Hosted card-entry and fingerprint frames: focus there is typing card details.
135
+ // Some of these hosts also serve gift card and bank account fields, so none
136
+ // names a family.
121
137
  const CARD_FRAME_DOMAINS = [
122
138
  'adyen.com',
123
139
  'braintreegateway.com',
@@ -211,10 +227,19 @@ function labels(element: Element): string[] {
211
227
  }
212
228
 
213
229
  // A cart button is often a bare icon, so its title names it when nothing else
214
- // does. Only the cart rule reads the title.
215
- function isCartControl(control: Element, cores: string[]): boolean {
230
+ // does. Only the cart rule reads the title. Opening the cart counts only for a
231
+ // control that opens it on the page, such as a flyout: one that leads to the cart
232
+ // page unloads before a call made on the click can return.
233
+ function cartControlAction(control: Element, cores: string[]): 'add' | 'open' | undefined {
216
234
  const title = cores.length ? undefined : normalize(control.getAttribute('title'));
217
- return (title ? [coreLabel(title)] : cores).some(text => cartLabels.test(text));
235
+ const texts = title ? [coreLabel(title)] : cores;
236
+ if (texts.some(text => addToCartLabels.test(text))) {
237
+ return 'add';
238
+ }
239
+ return (control.hasAttribute('aria-expanded') || control.hasAttribute('aria-haspopup')) &&
240
+ texts.some(text => openCartLabels.test(text))
241
+ ? 'open'
242
+ : undefined;
218
243
  }
219
244
 
220
245
  // Which rule recognised the intent: a vendor's own markup, a phrase in the
@@ -233,6 +258,8 @@ export type CheckoutIntentSource =
233
258
  export interface ICheckoutIntent {
234
259
  kind: Exclude<CheckoutIntentKind, 'frame'>;
235
260
  source: CheckoutIntentSource;
261
+ // Set on a cart click that puts something in the cart, not one that opens it.
262
+ adds?: true;
236
263
  }
237
264
 
238
265
  // "Buy now" and "Purchase" count only on a checkout route, and never on a plain
@@ -252,8 +279,8 @@ function isCommitLabel(text: string, isLink: boolean, pathname: string): boolean
252
279
  // pickers, toggles and pay-later messages never count; a vendor's own wallet
253
280
  // button counts before any label rule; a confirm label on any source counts
254
281
  // before the look-alike exclusions veto; then a wallet-named control, wallet
255
- // phrases and steps. A cart label, links included, counts only where none of
256
- // those do.
282
+ // phrases and steps. An add-to-cart label, links included, or a control that
283
+ // opens the cart on the page counts only where none of those do.
257
284
  export function classifyIntent(
258
285
  control: Element,
259
286
  pathname: string
@@ -269,9 +296,13 @@ export function classifyIntent(
269
296
  }
270
297
  const texts = labels(control);
271
298
  const cores = texts.map(coreLabel);
272
- const cart: ICheckoutIntent | undefined = isCartControl(control, cores)
273
- ? { kind: 'cart', source: 'label' }
274
- : undefined;
299
+ const cartAction = cartControlAction(control, cores);
300
+ let cart: ICheckoutIntent | undefined;
301
+ if (cartAction === 'add') {
302
+ cart = { kind: 'cart', source: 'label', adds: true };
303
+ } else if (cartAction === 'open') {
304
+ cart = { kind: 'cart', source: 'label' };
305
+ }
275
306
  if (isQuietIntentControl(control, false, !!cart)) {
276
307
  return undefined;
277
308
  }
@@ -325,12 +356,20 @@ function isOnDomain(host: string, domain: string): boolean {
325
356
  );
326
357
  }
327
358
 
359
+ export interface IPaymentFrame {
360
+ kind: 'wallet' | 'frame';
361
+ family?: PaymentFrameFamily;
362
+ }
363
+
328
364
  // Focus moving into a visible hosted wallet button counts as a wallet click;
329
- // focus into hosted card fields or messages is only a frame.
330
- export function classifyIntentFrame(
365
+ // focus into hosted card fields or messages is only a frame. A family comes
366
+ // only from a frame that can draw no other method: PayPal's button frame also
367
+ // draws Pay Later, Venmo and its card form, Stripe's quick-pay frame draws
368
+ // Apple Pay or Google Pay by browser, and its payment form holds several.
369
+ export function classifyPaymentFrame(
331
370
  target: EventTarget | null,
332
371
  win: Window
333
- ): 'wallet' | 'frame' | undefined {
372
+ ): IPaymentFrame | undefined {
334
373
  if (
335
374
  win.document.visibilityState !== 'visible' ||
336
375
  !(target instanceof HTMLIFrameElement)
@@ -343,20 +382,39 @@ export function classifyIntentFrame(
343
382
  ) {
344
383
  return undefined;
345
384
  }
385
+ // A frame that has its own src is judged by that address, never by its name.
386
+ if (
387
+ (!target.src || target.src === 'about:blank') &&
388
+ PAYPAL_SDK_FRAME.test(target.name)
389
+ ) {
390
+ return PAYPAL_SDK_BUTTON_FRAME.test(target.name)
391
+ ? { kind: 'wallet' }
392
+ : { kind: 'frame' };
393
+ }
346
394
  try {
347
395
  const url = new URL(target.src);
348
396
  const host = url.protocol === 'https:' ? url.hostname : '';
349
397
  if (paypalFrameHosts.has(host)) {
350
- return PAYPAL_BUTTON_FRAME.test(url.pathname) ? 'wallet' : 'frame';
398
+ if (PAYPAL_BUTTON_FRAME.test(url.pathname)) {
399
+ return { kind: 'wallet' };
400
+ }
401
+ return PAYPAL_CARD_FRAME.test(url.pathname)
402
+ ? { kind: 'frame', family: 'card' }
403
+ : { kind: 'frame' };
351
404
  }
352
- if (walletFrameHosts.has(host)) {
353
- return 'wallet';
405
+ if (Object.prototype.hasOwnProperty.call(walletFrameFamilies, host)) {
406
+ return { kind: 'wallet', family: walletFrameFamilies[host] };
354
407
  }
355
408
  if (host === 'js.stripe.com') {
356
- return STRIPE_WALLET_FRAME.test(url.pathname) ? 'wallet' : 'frame';
409
+ if (STRIPE_WALLET_FRAME.test(url.pathname)) {
410
+ return { kind: 'wallet' };
411
+ }
412
+ return STRIPE_CARD_FRAME.test(url.pathname)
413
+ ? { kind: 'frame', family: 'card' }
414
+ : { kind: 'frame' };
357
415
  }
358
416
  if (CARD_FRAME_DOMAINS.some(domain => isOnDomain(host, domain))) {
359
- return 'frame';
417
+ return { kind: 'frame' };
360
418
  }
361
419
  } catch (e) {
362
420
  // An unparseable frame src is not a wallet frame.
@@ -139,7 +139,7 @@ const PAYMENT_IN_NAME = /(?:^| )(?:pay|payment|checkout)(?= |$)/;
139
139
  const BUTTON_IN_NAME = /(?:^| )(?:btn|button)(?= |$)/;
140
140
  const PART_IN_NAME = /(?:^| )(?:logo|icon|image|img|label|text|caption|message|messaging|info|tooltip|help|learn|terms|promo|banner|container|wrapper|wrap|placeholder|form|section|panel|modal|list|group|select|option|tab|toggle|radio|choice|method)(?= |$)/;
141
141
 
142
- function nameWords(value: string | null): string {
142
+ export function nameWords(value: string | null): string {
143
143
  return (value || '')
144
144
  .slice(0, 64)
145
145
  .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
@@ -322,15 +322,19 @@ export function findControl(
322
322
  }
323
323
 
324
324
  // Text that may hold what a shopper typed or what the page opted out: form
325
- // fields, editable regions, and anything under data-mp-no-track.
326
- export function isUnsafeTextSource(element: Element): boolean {
325
+ // fields, editable regions, and anything under data-mp-no-track. `own` is a
326
+ // box the element labels, which holds no typing of its own.
327
+ export function isUnsafeTextSource(element: Element, own?: Element): boolean {
328
+ const inside = 'input, select, textarea, [data-mp-no-track], ' + EDITABLE;
327
329
  return (
328
330
  element.matches(
329
331
  'input, select, textarea, option, optgroup, datalist, ' + EDITABLE
330
332
  ) ||
331
- !!element.querySelector(
332
- 'input, select, textarea, [data-mp-no-track], ' + EDITABLE
333
- ) ||
333
+ (own
334
+ ? Array.from(element.querySelectorAll(inside)).some(
335
+ found => found !== own
336
+ )
337
+ : !!element.querySelector(inside)) ||
334
338
  hasComposedAncestor(
335
339
  element,
336
340
  'select, datalist, textarea, [data-mp-no-track], ' + EDITABLE
package/src/constants.ts CHANGED
@@ -191,6 +191,7 @@ const Constants = {
191
191
  AstBackgroundEvents: 'astBackgroundEvents',
192
192
  AutoLogPageView: 'autoLogPageView',
193
193
  AutoButtonTracking: 'autoButtonTracking',
194
+ AutoLinkTracking: 'autoLinkTracking',
194
195
  },
195
196
  DefaultInstance: 'default_instance',
196
197
  CCPAPurpose: 'data_sale_opt_out',
@@ -19,6 +19,12 @@ export interface ICookieConsentFlags {
19
19
  * Default: false (targeting is allowed)
20
20
  */
21
21
  noTargeting: boolean;
22
+
23
+ /**
24
+ * When true, indicates the user has opted out of the sale or sharing of their data.
25
+ * Default: false (sharing is allowed)
26
+ */
27
+ doNotShareOrSell?: boolean;
22
28
  }
23
29
 
24
30
  export interface ICookieConsentManager {
@@ -32,6 +38,11 @@ export interface ICookieConsentManager {
32
38
  */
33
39
  getNoFunctional: () => boolean;
34
40
 
41
+ /**
42
+ * Sharing is allowed when doNotShareOrSell is false (default)
43
+ */
44
+ getDoNotShareOrSell: () => boolean;
45
+
35
46
  /**
36
47
  * Syncs the $NoTargeting user attribute based on the current noTargeting flag.
37
48
  * Sets the attribute when noTargeting is true, removes it when false.
@@ -40,12 +51,12 @@ export interface ICookieConsentManager {
40
51
  }
41
52
 
42
53
  /**
43
- * CookieConsentManager handles storage and access of consent flags (noFunctional, noTargeting)
44
- * that are passed via launcherOptions during SDK initialization.
54
+ * CookieConsentManager handles storage and access of consent flags (noFunctional, noTargeting,
55
+ * doNotShareOrSell) that are passed via launcherOptions during SDK initialization.
45
56
  *
46
57
  * These flags allow Rokt integration to respect user privacy choices.
47
58
  *
48
- * Default behavior: Both flags default to false, meaning all features are allowed
59
+ * Default behavior: All flags default to false, meaning all features are allowed
49
60
  * unless explicitly opted out by the user.
50
61
  */
51
62
  export default class CookieConsentManager implements ICookieConsentManager {
@@ -53,6 +64,7 @@ export default class CookieConsentManager implements ICookieConsentManager {
53
64
  this.flags = {
54
65
  noFunctional: flags.noFunctional === true,
55
66
  noTargeting: flags.noTargeting === true,
67
+ doNotShareOrSell: flags.doNotShareOrSell === true,
56
68
  };
57
69
  }
58
70
 
@@ -72,6 +84,14 @@ export default class CookieConsentManager implements ICookieConsentManager {
72
84
  return this.flags.noFunctional;
73
85
  }
74
86
 
87
+ /**
88
+ * Returns true if the user has opted out of the sale or sharing of their data.
89
+ * Sharing is allowed when doNotShareOrSell is false (default).
90
+ */
91
+ getDoNotShareOrSell(): boolean {
92
+ return this.flags.doNotShareOrSell;
93
+ }
94
+
75
95
  syncNoTargetingAttribute(user: IMParticleUser | null): void {
76
96
  if (!user) return;
77
97
 
@@ -118,9 +118,17 @@ export default function CookieSyncManager(
118
118
  return;
119
119
  }
120
120
 
121
- // For Rokt, block cookie sync when noTargeting privacy flag is true
122
- if (moduleId === PARTNER_MODULE_IDS.Rokt && mpInstance._CookieConsentManager.getNoTargeting()) {
123
- return;
121
+ // For Rokt, block cookie sync under the noTargeting or doNotShareOrSell privacy flag,
122
+ // or when the browser sends Global Privacy Control
123
+ if (moduleId === PARTNER_MODULE_IDS.Rokt) {
124
+ const nav = typeof navigator === 'undefined' ? undefined : (navigator as Navigator & { globalPrivacyControl?: boolean });
125
+ if (
126
+ mpInstance._CookieConsentManager.getNoTargeting() ||
127
+ mpInstance._CookieConsentManager.getDoNotShareOrSell() ||
128
+ (!!nav && nav.globalPrivacyControl === true)
129
+ ) {
130
+ return;
131
+ }
124
132
  }
125
133
 
126
134
  const { isEnabledForUserConsent } = mpInstance._Consent;
@@ -1363,7 +1363,11 @@ export default function mParticleInstance(this: IMParticleWebSDKInstance, instan
1363
1363
  function completeSDKInitialization(apiKey, config, mpInstance) {
1364
1364
  const kitBlocker = createKitBlocker(config, mpInstance);
1365
1365
  const { getFeatureFlag } = mpInstance._Helpers;
1366
- const { AutoLogPageView, AutoButtonTracking } = Constants.FeatureFlags;
1366
+ const {
1367
+ AutoLogPageView,
1368
+ AutoButtonTracking,
1369
+ AutoLinkTracking,
1370
+ } = Constants.FeatureFlags;
1367
1371
 
1368
1372
  // Destroy previous batch uploader to prevent leaked timers and event listeners
1369
1373
  if (mpInstance._APIClient?.uploader) {
@@ -1453,16 +1457,26 @@ function completeSDKInitialization(apiKey, config, mpInstance) {
1453
1457
  // Rokt kit only when that kit receives them. Set before
1454
1458
  // processForwarders: the kit can read it during this init, from its own
1455
1459
  // init or from a returning shopper's landing page view.
1460
+ const logButtons = !!getFeatureFlag(AutoButtonTracking);
1461
+ const logLinks = !!getFeatureFlag(AutoLinkTracking);
1462
+ // Links feed intent too, so with link tracking on the kit must also
1463
+ // receive Auto Link Click events.
1456
1464
  const buttonClickBlocker = roktConfig
1457
1465
  ? getButtonClickBlocker(roktConfig, kitBlocker)
1458
1466
  : undefined;
1459
- if (buttonClickBlocker && getFeatureFlag(AutoButtonTracking)) {
1467
+ const linkClickBlocker =
1468
+ roktConfig && logLinks
1469
+ ? getButtonClickBlocker(roktConfig, kitBlocker, true)
1470
+ : undefined;
1471
+ if (logButtons && (buttonClickBlocker || linkClickBlocker)) {
1460
1472
  mpInstance.Logger.warning(
1461
- `Checkout intent is off: the ${buttonClickBlocker} keeps some or all Auto Button Click events from the Rokt kit.`
1473
+ buttonClickBlocker
1474
+ ? `Checkout intent is off: the ${buttonClickBlocker} keeps some or all Auto Button Click events from the Rokt kit.`
1475
+ : `Checkout intent is off: the ${linkClickBlocker} keeps some or all Auto Link Click events from the Rokt kit.`
1462
1476
  );
1463
1477
  }
1464
1478
  mpInstance._RoktManager.setAutoButtonClicksForwarded(
1465
- !!roktConfig && !buttonClickBlocker
1479
+ !!roktConfig && !buttonClickBlocker && !linkClickBlocker
1466
1480
  );
1467
1481
 
1468
1482
  mpInstance._Forwarders.processForwarders(
@@ -1509,11 +1523,15 @@ function completeSDKInitialization(apiKey, config, mpInstance) {
1509
1523
  mpInstance._PageViewTracker.teardown();
1510
1524
  }
1511
1525
 
1512
- if (getFeatureFlag(AutoButtonTracking)) {
1526
+ if (logButtons || logLinks) {
1513
1527
  if (!mpInstance._ButtonTracker) {
1514
1528
  mpInstance._ButtonTracker = new ButtonTracker(mpInstance);
1515
1529
  }
1516
- mpInstance._ButtonTracker.init({ hasRoktKit: !!roktConfig });
1530
+ mpInstance._ButtonTracker.init({
1531
+ hasRoktKit: !!roktConfig,
1532
+ logButtons,
1533
+ logLinks,
1534
+ });
1517
1535
  } else {
1518
1536
  // Also retires a tracker left by an earlier bundle load. Like APV,
1519
1537
  // this assumes a single active SDK instance.
@@ -1644,10 +1662,10 @@ function runPreConfigFetchInitialization(mpInstance, apiKey, config) {
1644
1662
  mpInstance.Logger.verbose(StartingInitialization);
1645
1663
 
1646
1664
  // Initialize CookieConsentManager with privacy flags from launcherOptions
1647
- const { noFunctional, noTargeting } = normalizeRoktLauncherOptions(
1665
+ const { noFunctional, noTargeting, doNotShareOrSell } = normalizeRoktLauncherOptions(
1648
1666
  config?.launcherOptions
1649
1667
  );
1650
- mpInstance._CookieConsentManager = new CookieConsentManager({ noFunctional, noTargeting });
1668
+ mpInstance._CookieConsentManager = new CookieConsentManager({ noFunctional, noTargeting, doNotShareOrSell });
1651
1669
 
1652
1670
  // Check to see if localStorage is available before main configuration runs
1653
1671
  // since we will need this for the current implementation of user persistence