@mparticle/web-sdk 3.20.0 → 3.22.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,8 +1,10 @@
1
1
  import {
2
2
  CONTROL_SELECTOR,
3
+ hasComposedAncestor,
4
+ isQuietIntentControl,
3
5
  isRoktElement,
4
6
  isUnsafeTextSource,
5
- isWalletElement,
7
+ vendorButtonKind,
6
8
  } from './controls';
7
9
 
8
10
  // Classifies a click, submit or frame focus as checkout intent for the Rokt kit.
@@ -14,31 +16,48 @@ export type CheckoutIntentKind =
14
16
  | 'frame';
15
17
 
16
18
  const checkoutPath = /(?:^|\/)(?:checkouts?|payment|order|kasse|paiement|commander?|pago)(?:\/|$)/i;
17
- const exclusions = new RegExp(
19
+ // English look-alikes are whole words; the other languages stay substring
20
+ // matches, since several of them do not separate words with spaces.
21
+ const englishExclusions = new RegExp(
22
+ '\\b(?:coupon|promo(?: code)?|newsletter|sign in|sign up|log in|login|add to (?:cart|bag|basket)|' +
23
+ 'gift card balance|(?:add|apply|redeem|check|enter|use) (?:a |an |your )?gift card|pay later|' +
24
+ 'interest free|as low as|learn more|prequalify|apply (?:now|for)|payment plan|available|accepted|' +
25
+ 'we accept|set up|add money|reload|top up|add to (?:apple|google) wallet|connect|link your|unlink|' +
26
+ 'remove|delete|edit|change|manage|use a different|switch)\\b|^pay in$|\\bpay in (?:installments|instalments)\\b'
27
+ );
28
+ const otherExclusions = new RegExp(
18
29
  [
19
- 'coupon|promo|newsletter|sign in|add to cart|gift card|pay later|pay in|payment plan|learn more',
20
30
  'gutschein|anmelden|warenkorb|bon cadeau|connexion|panier|cupón|iniciar sesión|carrito',
21
31
  'carrello|codice sconto|accedi|buono regalo|winkelwagen|kortingscode|inloggen|cadeaubon|achteraf betalen',
22
32
  'カート|クーポン|ログイン|会員登録|ギフト|後払い|メルマガ',
23
33
  ].join('|')
24
34
  );
25
- const commits = new RegExp(
26
- '^(?:' +
27
- [
28
- '(?:place|submit|complete|confirm|finali[sz]e)(?!(?: [^ ]+){0,2} (?:another|new|next)(?: |$))(?: [^ ]+){0,2} (?:order|purchase|booking|reservation|payment|checkout)',
29
- 'pay(?: securely)?(?: now)?|confirm and pay|order and pay|order with obligation to pay',
30
- '(?:jetzt )?(?:zahlungs|kosten)pflichtig (?:bestellen|buchen)|(?:jetzt )?bezahlen|kauf abschließen|bestellung abschließen',
31
- 'commander et payer|payer(?: maintenant)?|(?:confirmer|finaliser) (?:ma |la )?commande',
32
- "valider et payer|confirmer et payer|commande avec obligation de paiement|finaliser l'achat",
33
- '(?:realizar|confirmar|completar) (?:el |mi |la )?(?:pedido|compra|reserva)|finalizar (?:el |mi )?pedido|pagar(?: ahora)?',
34
- 'pedido con obligación de pago',
35
- "(?:effettua|conferma|concludi|completa) (?:l'|il )?(?:ordine|acquisto)|ordine con obbligo di pagamento",
36
- 'paga(?: ora| adesso)?|ordina e paga',
37
- '(?:nu )?betalen|bestellen en betalen|bestelling plaatsen|plaats bestelling|bestelling afronden',
38
- 'bestelling met betalingsverplichting',
39
- '(?:この内容で|上記の?内容で)?ご?(?:注文|購入|予約|決済|お?支払い?)を?(?:確定|完了)(?:する)?',
40
- '(?:この内容で|上記の?内容で)ご?(?:注文|購入|予約)する|ご?注文する|決済する|(?:今すぐ|を)?支払う',
41
- ].join('|') +
35
+ const englishCommits =
36
+ '(?:(?:agree|accept|review) and )?(?:place|submit|complete|confirm|finali[sz]e)' +
37
+ '(?!(?: [^ ]+){0,2} (?:another|new|next)(?: |$))(?! (?:[^ ]+ )?gift card)' +
38
+ '(?: [^ ]+){0,2} (?:order|purchase|booking|reservation|payment|checkout|transaction)' +
39
+ '(?: now| securely| and pay| (?:with|using) [^ ]+(?: [^ ]+)?)?(?: (?:total|subtotal|items?|tickets?))?' +
40
+ '|pay(?: securely)?(?: now)?|(?:confirm|agree|accept|submit|order|book) and pay' +
41
+ '|pay and place (?:my |your )?order|order with obligation to pay';
42
+ const otherCommits = [
43
+ '(?:jetzt )?(?:zahlungs|kosten)pflichtig (?:bestellen|buchen)|(?:jetzt )?bezahlen|kauf abschließen|bestellung abschließen',
44
+ 'commander et payer|payer(?: maintenant)?|(?:confirmer|finaliser) (?:ma |la )?commande',
45
+ "valider et payer|confirmer et payer|commande avec obligation de paiement|finaliser l'achat",
46
+ '(?:realizar|confirmar|completar) (?:el |mi |la )?(?:pedido|compra|reserva)|finalizar (?:el |mi )?pedido|pagar(?: ahora)?',
47
+ 'pedido con obligación de pago',
48
+ "(?:effettua|conferma|concludi|completa) (?:l'|il )?(?:ordine|acquisto)|ordine con obbligo di pagamento",
49
+ 'paga(?: ora| adesso)?|ordina e paga',
50
+ '(?:nu )?betalen|bestellen en betalen|bestelling plaatsen|plaats bestelling|bestelling afronden',
51
+ 'bestelling met betalingsverplichting',
52
+ '(?:この内容で|上記の?内容で)?ご?(?:注文|購入|予約|決済|お?支払い?)を?(?:確定|完了)(?:する)?',
53
+ '(?:この内容で|上記の?内容で)ご?(?:注文|購入|予約)する|ご?注文する|決済する|(?:今すぐ|を)?支払う',
54
+ ].join('|');
55
+ const commits = new RegExp('^(?:' + englishCommits + '|' + otherCommits + ')$');
56
+ // A plain link is navigation, so in English only an order or purchase it
57
+ // places, submits or completes counts as the confirm click.
58
+ const linkCommits = new RegExp(
59
+ '^(?:(?:place|submit|complete)(?!(?: [^ ]+){0,2} (?:another|new|next)(?: |$))(?: [^ ]+){0,2} (?:order|purchase)|' +
60
+ otherCommits +
42
61
  ')$'
43
62
  );
44
63
  const routeGatedCommits = new RegExp(
@@ -48,7 +67,9 @@ const routeGatedCommits = new RegExp(
48
67
  const steps = new RegExp(
49
68
  '^(?:' +
50
69
  [
51
- 'checkout|check out|check-out|continue to payment|proceed to checkout',
70
+ "(?:let's |lets |go to |proceed to |secure )?(?:check ?out|checkout)(?: now| securely| as guest)?",
71
+ '(?:continue|proceed) to (?:payment|review|billing|shipping|delivery|order review)|review (?:my |your )?order',
72
+ 'guest checkout|(?:pay|continue|check ?out) (?:with|by) (?:card|credit card|debit card|credit or debit card)',
52
73
  'zur kasse|zur kasse gehen|auschecken|weiter zur zahlung',
53
74
  'passer à la caisse|continuer vers le paiement|procéder au paiement',
54
75
  'ir a caja|continuar al pago|tramitar pedido',
@@ -58,43 +79,104 @@ const steps = new RegExp(
58
79
  ].join('|') +
59
80
  ')$'
60
81
  );
61
- const wallets = /^(?:apple pay|google pay|paypal|venmo|amazon pay|shop pay|klarna|afterpay|affirm)$/;
62
- // Exact HTTPS hosts of hosted wallet and payment frames.
63
- const frameHosts = new Set([
64
- 'www.paypal.com',
65
- 'www.sandbox.paypal.com',
82
+ const WALLET_NAMES =
83
+ 'apple pay|google pay|paypal|venmo|amazon pay|shop pay|klarna|afterpay|clearpay|cash app(?: pay)?|affirm|sezzle|samsung pay';
84
+ const WALLET_VERBS =
85
+ 'pay|buy|purchase|order|book|donate|subscribe|check ?out|checkout|express checkout|continue|proceed|' +
86
+ 'complete purchase|place (?:my |your )?order';
87
+ // "Pay with Klarna", "Buy with GPay", "Continue to PayPal".
88
+ const walletPhrases = new RegExp(
89
+ '^(?:(?:' + WALLET_VERBS + ')(?: now| securely)? (?:with|using|via|through|by) (?:' + WALLET_NAMES +
90
+ ')(?: express(?: checkout)?| checkout)?|(?:continue|proceed|go) (?:to|with) (?:' + WALLET_NAMES + '))$'
91
+ );
92
+ // A wallet's own name on a button: "PayPal", "Klarna checkout", "Apple Pay logo".
93
+ const walletBrands = new RegExp(
94
+ '^(?:' + WALLET_NAMES + ')(?: (?:checkout|check out|express(?: checkout)?|express pay|buy now|pay now|pay|now)' +
95
+ '| (?:logo|icon|mark|button)(?: (?:logo|icon|mark|button))*)?$'
96
+ );
97
+ // Exact HTTPS hosts of hosted wallet button frames.
98
+ const walletFrameHosts = new Set([
66
99
  'pay.google.com',
67
100
  'applepay.cdn-apple.com',
68
- 'js.stripe.com',
69
- 'checkoutshopper-live.adyen.com',
70
101
  'payments.amazon.com',
71
102
  'pay.shopify.com',
72
103
  ]);
104
+ // PayPal serves its buttons and its card fields and pay-later messages from
105
+ // the same hosts; only the button frames are wallet taps.
106
+ const paypalFrameHosts = new Set(['www.paypal.com', 'www.sandbox.paypal.com']);
107
+ const PAYPAL_BUTTON_FRAME = /^\/smart\/(?:buttons|menu)(?:\/|$)/;
108
+ // Stripe's quick-pay button frames; its other frames are card and form fields.
109
+ const STRIPE_WALLET_FRAME = /\/elements-inner-(?:payment-request|express-checkout)-/;
110
+ // Hosted card-entry and fingerprint frames: focus there is typing card details.
111
+ const CARD_FRAME_DOMAINS = [
112
+ 'adyen.com',
113
+ 'braintreegateway.com',
114
+ 'cybersource.com',
115
+ 'squarecdn.com',
116
+ 'checkout.com',
117
+ ];
73
118
  // Keep these classes ASCII-only in source bytes to avoid mojibake parse failures
74
119
  // when a host page forces non-UTF-8 decoding before JavaScript parse.
75
120
  const TYPOGRAPHIC_APOSTROPHES = /[\u2019\u2018]/g;
76
121
  const CURRENCY_AND_NUMBER_CHARS =
77
122
  /[0-9\uFF10-\uFF19$\u20AC\u00A3\u00A5\uFFE5\u20B9\u5186.,\uFF0C]/g;
123
+ const BRACKETED = /\([^()]*\)|\[[^[\]]*\]|\uFF08[^\uFF08\uFF09]*\uFF09|\uFF3B[^\uFF3B\uFF3D]*\uFF3D|\u3010[^\u3010\u3011]*\u3011/g;
124
+ // Spaces, punctuation, separators, arrows and symbols, CJK and full-width forms
125
+ // included, plus emoji halves and variation selectors.
126
+ const EDGE =
127
+ '[\\s!-/:-@\\[-`{-~\\u00A1-\\u00BF\\u00D7\\u00F7\\u2000-\\u206F\\u20A0-\\u20CF\\u2190-\\u2BFF' +
128
+ '\\u3000-\\u3003\\u3008-\\u3011\\u3014-\\u301F\\u30FB\\uD800-\\uDFFF\\uFE00-\\uFE0F' +
129
+ '\\uFF01-\\uFF0F\\uFF1A-\\uFF20\\uFF3B-\\uFF40\\uFF5B-\\uFF65]+';
130
+ const EDGE_SYMBOLS = new RegExp('^' + EDGE + '|' + EDGE + '$', 'g');
78
131
 
79
132
  function getIntentRouteClass(pathname: string): 'checkout_path' | 'other' {
80
133
  return checkoutPath.test(pathname) ? 'checkout_path' : 'other';
81
134
  }
82
135
 
136
+ const SEPARATORS = /[-_|·•]/g;
137
+ const CURRENCY_CODES = /(?:^| )(?:usd|aud|cad|nzd|gbp|eur|chf|sgd|hkd|jpy|inr|mxn)(?= |$)|\b(?:a|au|ca|c|us|nz|hk|s)\$/g;
138
+ const WALLET_ALIASES: Array<[RegExp, string]> = [
139
+ [/\bapplepay\b/g, 'apple pay'],
140
+ [/\bg ?pay\b/g, 'google pay'],
141
+ [/\bamazonpay\b/g, 'amazon pay'],
142
+ [/\bshoppay\b/g, 'shop pay'],
143
+ [/\bcashapp\b/g, 'cash app'],
144
+ ];
145
+ // Labels longer than this are sentences: they neither match nor veto intent.
146
+ const MAX_LABEL_WORDS = 8;
147
+
83
148
  function normalize(text: string | null | undefined): string | undefined {
84
149
  if (!text || text.length > 128) {
85
150
  return undefined;
86
151
  }
87
- return text
152
+ let label = text
88
153
  .toLowerCase()
89
154
  .replace(TYPOGRAPHIC_APOSTROPHES, "'")
155
+ .replace(/[&+]/g, ' and ')
156
+ .replace(SEPARATORS, ' ')
157
+ .replace(CURRENCY_CODES, ' ')
90
158
  .replace(CURRENCY_AND_NUMBER_CHARS, '')
91
159
  .replace(/\s+/g, ' ')
92
160
  .trim();
161
+ WALLET_ALIASES.forEach(([alias, name]) => {
162
+ label = label.replace(alias, name);
163
+ });
164
+ return label.split(' ').length > MAX_LABEL_WORDS ? undefined : label;
165
+ }
166
+
167
+ // The label without bracketed asides or the separators and symbols left at its
168
+ // edges, so "place order -" and "place order ( items)" both read "place order".
169
+ function coreLabel(text: string): string {
170
+ const core = text
171
+ .replace(BRACKETED, ' ')
172
+ .replace(/\s+/g, ' ')
173
+ .replace(EDGE_SYMBOLS, '');
174
+ return core || text.replace(EDGE_SYMBOLS, '');
93
175
  }
94
176
 
95
177
  // Labels come from aria-label then visible text. Only submit and button inputs
96
- // have their value read, so a typed field value is never touched. Other text
97
- // follows the same rules as button descriptions.
178
+ // have their value read, so a typed field value is never touched. A control
179
+ // holding form fields or more than four children gives only its aria-label.
98
180
  function labelSources(element: Element): Array<string | null> {
99
181
  if (element instanceof HTMLInputElement) {
100
182
  const type = element.type;
@@ -107,7 +189,7 @@ function labelSources(element: Element): Array<string | null> {
107
189
  return [element.getAttribute('aria-label'), text];
108
190
  }
109
191
  if (element.children.length > 4 || isUnsafeTextSource(element)) {
110
- return [];
192
+ return [element.getAttribute('aria-label')];
111
193
  }
112
194
  return [element.getAttribute('aria-label'), element.textContent];
113
195
  }
@@ -118,69 +200,120 @@ function labels(element: Element): string[] {
118
200
  .filter((label): label is string => !!label);
119
201
  }
120
202
 
121
- function classifyLabel(
122
- text: string,
123
- button: boolean,
124
- pathname: string
125
- ): Exclude<CheckoutIntentKind, 'frame'> | undefined {
126
- if (
203
+ // Which rule recognised the intent: a vendor's own markup, a phrase in the
204
+ // label, a wallet's bare name, a hosted frame, or a form submitted after a
205
+ // confirm click.
206
+ export type CheckoutIntentSource = 'vendor' | 'label' | 'brand' | 'frame' | 'submit';
207
+
208
+ export interface ICheckoutIntent {
209
+ kind: Exclude<CheckoutIntentKind, 'frame'>;
210
+ source: CheckoutIntentSource;
211
+ }
212
+
213
+ // "Buy now" and "Purchase" count only on a checkout route, and never on a plain
214
+ // link, which is navigation.
215
+ function isCommitLabel(text: string, isLink: boolean, pathname: string): boolean {
216
+ if (isLink) {
217
+ return linkCommits.test(text);
218
+ }
219
+ return (
127
220
  commits.test(text) ||
128
221
  (routeGatedCommits.test(text) &&
129
222
  getIntentRouteClass(pathname) === 'checkout_path')
130
- ) {
131
- return 'commit';
132
- }
133
- if (button && wallets.test(text)) {
134
- return 'wallet';
135
- }
136
- if (steps.test(text)) {
137
- return 'checkout_step';
138
- }
139
- return undefined;
223
+ );
140
224
  }
141
225
 
142
- // Classifies a clicked or submitting control found by findControl.
143
- export function classifyIntentControl(
226
+ // Classifies a clicked or submitting control found by findControl, in order:
227
+ // pickers, toggles and pay-later messages never count; a vendor's own wallet
228
+ // button counts before any label rule; a confirm label on any source counts
229
+ // before the look-alike exclusions veto; then wallet phrases and steps.
230
+ export function classifyIntent(
144
231
  control: Element,
145
232
  pathname: string
146
- ): Exclude<CheckoutIntentKind, 'frame'> | undefined {
147
- const texts = labels(control);
148
- if (texts.some(text => exclusions.test(text))) {
233
+ ): ICheckoutIntent | undefined {
234
+ const vendor = vendorButtonKind(control);
235
+ if (isQuietIntentControl(control, vendor === 'wallet') || vendor === 'none') {
149
236
  return undefined;
150
237
  }
151
- let button = false;
152
- if (!control.matches('a[href]')) {
153
- button = control.matches(CONTROL_SELECTOR) || isWalletElement(control);
238
+ if (vendor) {
239
+ return { kind: vendor, source: 'vendor' };
154
240
  }
155
- if (button && isWalletElement(control)) {
156
- return 'wallet';
241
+ const texts = labels(control);
242
+ const cores = texts.map(coreLabel);
243
+ const isLink = control.matches('a[href]:not([role="button"])');
244
+ if (cores.some(text => isCommitLabel(text, isLink, pathname))) {
245
+ return { kind: 'commit', source: 'label' };
157
246
  }
158
- for (const text of texts) {
159
- const kind = classifyLabel(text, button, pathname);
160
- if (kind) {
161
- return kind;
247
+ if (
248
+ texts
249
+ .concat(cores)
250
+ .some(text => englishExclusions.test(text) || otherExclusions.test(text))
251
+ ) {
252
+ return undefined;
253
+ }
254
+ const isButton = !isLink && control.matches(CONTROL_SELECTOR);
255
+ for (const text of cores) {
256
+ if (isButton && walletPhrases.test(text)) {
257
+ return { kind: 'wallet', source: 'label' };
258
+ }
259
+ if (isButton && walletBrands.test(text)) {
260
+ return { kind: 'wallet', source: 'brand' };
261
+ }
262
+ if (steps.test(text)) {
263
+ return { kind: 'checkout_step', source: 'label' };
162
264
  }
163
265
  }
164
266
  return undefined;
165
267
  }
166
268
 
167
- // Focus moving into a visible hosted wallet or payment frame.
269
+ export function classifyIntentControl(
270
+ control: Element,
271
+ pathname: string
272
+ ): Exclude<CheckoutIntentKind, 'frame'> | undefined {
273
+ const intent = classifyIntent(control, pathname);
274
+ return intent ? intent.kind : undefined;
275
+ }
276
+
277
+ // The host is the domain or one of its subdomains, never a look-alike suffix.
278
+ function isOnDomain(host: string, domain: string): boolean {
279
+ return (
280
+ host === domain ||
281
+ (host.length > domain.length &&
282
+ host.slice(host.length - domain.length - 1) === '.' + domain)
283
+ );
284
+ }
285
+
286
+ // Focus moving into a visible hosted wallet button counts as a wallet click;
287
+ // focus into hosted card fields or messages is only a frame.
168
288
  export function classifyIntentFrame(
169
289
  target: EventTarget | null,
170
290
  win: Window
171
- ): 'frame' | undefined {
291
+ ): 'wallet' | 'frame' | undefined {
172
292
  if (
173
293
  win.document.visibilityState !== 'visible' ||
174
294
  !(target instanceof HTMLIFrameElement)
175
295
  ) {
176
296
  return undefined;
177
297
  }
178
- if (isRoktElement(target)) {
298
+ if (
299
+ isRoktElement(target) ||
300
+ hasComposedAncestor(target, '[data-mp-no-track]')
301
+ ) {
179
302
  return undefined;
180
303
  }
181
304
  try {
182
305
  const url = new URL(target.src);
183
- if (url.protocol === 'https:' && frameHosts.has(url.hostname)) {
306
+ const host = url.protocol === 'https:' ? url.hostname : '';
307
+ if (paypalFrameHosts.has(host)) {
308
+ return PAYPAL_BUTTON_FRAME.test(url.pathname) ? 'wallet' : 'frame';
309
+ }
310
+ if (walletFrameHosts.has(host)) {
311
+ return 'wallet';
312
+ }
313
+ if (host === 'js.stripe.com') {
314
+ return STRIPE_WALLET_FRAME.test(url.pathname) ? 'wallet' : 'frame';
315
+ }
316
+ if (CARD_FRAME_DOMAINS.some(domain => isOnDomain(host, domain))) {
184
317
  return 'frame';
185
318
  }
186
319
  } catch (e) {
@@ -29,63 +29,166 @@ export function hasComposedAncestor(
29
29
  return false;
30
30
  }
31
31
 
32
+ const ROKT_SELECTOR = '[data-core-type], #rokt-controller-frame';
33
+
34
+ // A Rokt frame or script is served from a /wsdk/ path.
35
+ function hasRoktSrc(element: Element): boolean {
36
+ const src = element.getAttribute('src');
37
+ if (!src) {
38
+ return false;
39
+ }
40
+ try {
41
+ return (
42
+ new URL(src, 'https://example.test').pathname.indexOf('/wsdk/') !==
43
+ -1
44
+ );
45
+ } catch (e) {
46
+ // An unparseable src is not a Rokt element.
47
+ return false;
48
+ }
49
+ }
50
+
32
51
  // Rokt's own placements and frames never count as partner checkout controls.
33
52
  export function isRoktElement(element: Element): boolean {
34
- if (
35
- hasComposedAncestor(element, '[data-core-type], #rokt-controller-frame')
36
- ) {
37
- return true;
53
+ return hasComposedAncestor(element, ROKT_SELECTOR) || hasRoktSrc(element);
54
+ }
55
+
56
+ // Wallet and quick-pay buttons as their vendors render them: official custom
57
+ // elements and documented classes, ids and attributes.
58
+ const WALLET_SIGNATURE = [
59
+ 'apple-pay-button',
60
+ '.apple-pay-button',
61
+ '.gpay-button',
62
+ '.gpay-card-info-container',
63
+ '#gpay-button-online-api-id',
64
+ 'paypal-button',
65
+ 'venmo-button',
66
+ 'paypal-pay-later-button',
67
+ 'paypal-credit-button',
68
+ 'shop-pay-button',
69
+ 'shop-pay-wallet-button',
70
+ 'shopify-accelerated-checkout',
71
+ 'shopify-accelerated-checkout-cart',
72
+ 'shopify-apple-pay-button',
73
+ 'shopify-google-pay-button',
74
+ 'shopify-paypal-button',
75
+ 'shopify-amazon-pay-button',
76
+ '.amazonpay-button-focusable',
77
+ '#AmazonPayButton',
78
+ '[data-amazon-pay-button]',
79
+ '[data-afterpay-entry-point]',
80
+ ].join(', ');
81
+ // PayPal's card button opens a card form, so it is a step rather than a wallet.
82
+ const CARD_FORM_BUTTON = 'paypal-basic-card-button';
83
+ const VENDOR_BUTTON = WALLET_SIGNATURE + ', ' + CARD_FORM_BUTTON;
84
+ // Apple's set-up and top-up buttons manage a wallet rather than pay with it.
85
+ const NOT_A_PAYMENT = ['set-up', 'add-money', 'reload', 'top-up']
86
+ .map(type => 'apple-pay-button[type="' + type + '"]')
87
+ .join(', ');
88
+
89
+ // What a vendor's own button markup says about a control. Only the control or
90
+ // the shadow host that owns it counts, so a merchant wrapper reusing a vendor
91
+ // class never turns its children into wallet buttons.
92
+ export function vendorButtonKind(
93
+ control: Element
94
+ ): 'wallet' | 'checkout_step' | 'none' | undefined {
95
+ let host: Element | null = control.matches(VENDOR_BUTTON) ? control : null;
96
+ if (!host) {
97
+ const root = isFunction(control.getRootNode)
98
+ ? (control.getRootNode() as ShadowRoot)
99
+ : null;
100
+ host = root && root.host && root.host.matches(VENDOR_BUTTON)
101
+ ? root.host
102
+ : null;
38
103
  }
39
- const src = element.getAttribute('src');
40
- if (src) {
41
- try {
42
- if (
43
- new URL(src, 'https://example.test').pathname.indexOf(
44
- '/wsdk/'
45
- ) !== -1
46
- ) {
47
- return true;
48
- }
49
- } catch (e) {
50
- // An unparseable src is not a Rokt element.
51
- }
104
+ if (!host) {
105
+ return undefined;
52
106
  }
53
- return false;
107
+ if (host.matches(NOT_A_PAYMENT)) {
108
+ return 'none';
109
+ }
110
+ return host.matches(CARD_FORM_BUTTON) ? 'checkout_step' : 'wallet';
54
111
  }
55
112
 
56
113
  export function isWalletElement(element: Element): boolean {
57
- return element.matches(
58
- 'apple-pay-button, shop-pay-button, .gpay-button, #AmazonPayButton, [data-amazon-pay-button]'
59
- );
114
+ return vendorButtonKind(element) === 'wallet';
60
115
  }
61
116
 
117
+ // Pickers and toggles choose a payment method or open a panel; they never pay.
118
+ const PICKER =
119
+ 'input[type="radio"], input[type="checkbox"], label, [role="radio"], [role="tab"], [role="option"], ' +
120
+ '[role="checkbox"], [role="switch"], [role="menuitemradio"], [role="menuitemcheckbox"], [aria-checked], [aria-selected]';
121
+ const PICKER_GROUP = '[role="radiogroup"], [role="tablist"], [role="listbox"]';
122
+ // Pay-later and financing messages link to details rather than pay.
123
+ const MESSAGING =
124
+ 'klarna-placement, afterpay-placement, square-placement, .affirm-as-low-as, .affirm-site-modal, ' +
125
+ '.affirm-product-modal, [data-pp-message]';
126
+
127
+ // A control that is still logged as a click but never counts as checkout intent.
128
+ export function isQuietIntentControl(
129
+ control: Element,
130
+ isVendorWallet: boolean
131
+ ): boolean {
132
+ if (control.matches(PICKER)) {
133
+ return true;
134
+ }
135
+ if (hasComposedAncestor(control, PICKER_GROUP)) {
136
+ return true;
137
+ }
138
+ if (
139
+ !isVendorWallet &&
140
+ (control.hasAttribute('aria-expanded') ||
141
+ control.hasAttribute('aria-pressed'))
142
+ ) {
143
+ return true;
144
+ }
145
+ return hasComposedAncestor(control, MESSAGING);
146
+ }
147
+
148
+ // A checkbox or radio is a control of its own only when no button or link
149
+ // around it takes the click, so a tappable row keeps its own name.
150
+ const OPTION_INPUT = 'input[type="checkbox"], input[type="radio"]';
151
+
62
152
  // Walks at most 12 entries of an event's composed path for the nearest control.
63
- // Rokt elements, disabled controls and anything under data-mp-no-track are skipped.
153
+ // One composed walk from the target rules out Rokt elements and anything under
154
+ // data-mp-no-track; disabled controls are skipped.
64
155
  export function findControl(
65
156
  path: Array<EventTarget | null>
66
157
  ): Element | undefined {
158
+ const entries = path.slice(0, 12);
159
+ let first: Element | undefined;
160
+ for (const target of entries) {
161
+ if (target instanceof Element) {
162
+ first = target;
163
+ break;
164
+ }
165
+ }
166
+ if (
167
+ !first ||
168
+ hasComposedAncestor(first, ROKT_SELECTOR + ', [data-mp-no-track]')
169
+ ) {
170
+ return undefined;
171
+ }
67
172
  let control: Element | undefined;
68
- for (const target of path.slice(0, 12)) {
173
+ let option: Element | undefined;
174
+ for (const target of entries) {
69
175
  if (!(target instanceof Element)) {
70
176
  continue;
71
177
  }
72
- if (isRoktElement(target) || target.matches('[data-mp-no-track]')) {
178
+ if (hasRoktSrc(target)) {
73
179
  return undefined;
74
180
  }
75
181
  if (
76
182
  !control &&
77
- (target.matches(CONTROL_SELECTOR + ', a[href]') ||
78
- isWalletElement(target))
183
+ target.matches(CONTROL_SELECTOR + ', a[href], ' + VENDOR_BUTTON)
79
184
  ) {
80
185
  control = target;
186
+ } else if (!control && !option && target.matches(OPTION_INPUT)) {
187
+ option = target;
81
188
  }
82
189
  }
83
- if (
84
- !control ||
85
- isRoktElement(control) ||
86
- hasComposedAncestor(control, '[data-mp-no-track]') ||
87
- control.matches(':disabled, [aria-disabled="true"]')
88
- ) {
190
+ control = control || option;
191
+ if (!control || control.matches(':disabled, [aria-disabled="true"]')) {
89
192
  return undefined;
90
193
  }
91
194
  return control;
@@ -109,3 +212,18 @@ export function isUnsafeTextSource(element: Element): boolean {
109
212
  )
110
213
  );
111
214
  }
215
+
216
+ // A label around a checkbox or radio holds no typed text, so only the other
217
+ // fields rule its text out.
218
+ export function isUnsafeLabelSource(label: Element): boolean {
219
+ return (
220
+ !!label.querySelector(
221
+ 'input:not([type="checkbox"]):not([type="radio"]), select, textarea, [data-mp-no-track], ' +
222
+ EDITABLE
223
+ ) ||
224
+ hasComposedAncestor(
225
+ label,
226
+ 'select, datalist, textarea, [data-mp-no-track], ' + EDITABLE
227
+ )
228
+ );
229
+ }
@@ -84,8 +84,11 @@ export const ALLOWED_QUERY_PARAMS: string[] = [
84
84
  'per_page',
85
85
 
86
86
  // Site search term, as the common platforms spell it. Matching is
87
- // case-insensitive; single-letter names other than `q` are too generic.
87
+ // case-insensitive. `s` is WordPress and WooCommerce search; other
88
+ // single-letter names are too generic. `oq` is a storefront search term.
88
89
  'q',
90
+ 's',
91
+ 'oq',
89
92
  'search',
90
93
  'query',
91
94
  'keyword',
@@ -105,6 +108,9 @@ export const ALLOWED_QUERY_PARAMS: string[] = [
105
108
  'sku',
106
109
  'skuId',
107
110
  'variant',
111
+ 'color',
112
+ 'colour',
113
+ 'size',
108
114
 
109
115
  // Category
110
116
  'category',
@@ -23,7 +23,7 @@ import { PARTNER_MODULE_IDS } from "./cookieSyncManager";
23
23
  import IntegrationCapture from "./integrationCapture";
24
24
  import Constants from "./constants";
25
25
  import { subscribeToRouteChange } from "./routeChangeMonitor";
26
- import type { CheckoutIntentKind } from "./buttonTracking/checkoutIntent";
26
+ import type { CheckoutIntentKind, CheckoutIntentSource } from "./buttonTracking/checkoutIntent";
27
27
 
28
28
  const PASSBACK_CONVERSION_TRACKING_ID = 'passbackconversiontrackingid';
29
29
  const CHECKOUT_INTENT_BUFFER_LIMIT = 4;
@@ -74,9 +74,14 @@ export interface RoktKitFilterSettings {
74
74
  filteredUser?: IMParticleUser | null;
75
75
  }
76
76
 
77
+ // What a kit receives on checkout intent: the kind, when the shopper acted,
78
+ // which rule recognised it and the path it happened on. Older kits read only
79
+ // kind and t.
77
80
  export interface ICheckoutIntentSignal {
78
81
  kind: CheckoutIntentKind;
79
82
  t: number;
83
+ source?: CheckoutIntentSource;
84
+ pathname?: string;
80
85
  }
81
86
 
82
87
  export interface IRoktKitSettings {
@@ -314,6 +319,16 @@ export default class RoktManager {
314
319
  return !!nav && nav.globalPrivacyControl === true;
315
320
  }
316
321
 
322
+ // True when the kit can wait for the shopper's confirm click: signals are not
323
+ // suppressed and the browser exposes the composed path the detector reads.
324
+ // Decided from config alone, so tracker setup and teardown never change it.
325
+ public isCheckoutIntentLive(): boolean {
326
+ if (this.isCheckoutIntentSuppressed()) {
327
+ return false;
328
+ }
329
+ return typeof Event !== 'undefined' && isFunction(Event.prototype.composedPath);
330
+ }
331
+
317
332
  // Reads `this.kit` at call time; before a kit attaches the signal is buffered
318
333
  // with the path it was raised on.
319
334
  public notifyCheckoutIntent(signal: ICheckoutIntentSignal, pathname: string = window.location.pathname): void {