@mparticle/web-sdk 3.16.1 → 3.18.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.
@@ -0,0 +1,376 @@
1
+ import { IMParticleWebSDKInstance } from '../mp-instance';
2
+ import { isFunction } from '../utils';
3
+ import { buildButtonClickEvent } from './buttonClickEvent';
4
+ import { describeButtonClick, IButtonClick } from './buttonDescription';
5
+ import {
6
+ CheckoutIntentKind,
7
+ classifyIntentControl,
8
+ classifyIntentFrame,
9
+ } from './checkoutIntent';
10
+
11
+ // State lives on `window`, like APV, so a re-executed bundle never doubles the
12
+ // listeners. Inspect `window.__mpBtn__` in a console to see if one is live.
13
+ export const WIN_BTN_KEY = '__mpBtn__';
14
+
15
+ const THROTTLE_MS = 2000;
16
+ const SUBMIT_AFTER_COMMIT_CLICK_MS = 1000;
17
+ const PATH_LIMIT = 12;
18
+ const EVENT_DEDUP_MS = 1000;
19
+ const PAGE_EVENT_LIMIT = 50;
20
+
21
+ interface IBtnState {
22
+ tracker?: ButtonTracker;
23
+ }
24
+
25
+ type WindowWithBtn = Window & {
26
+ [WIN_BTN_KEY]?: IBtnState;
27
+ };
28
+
29
+ export interface IButtonTrackerOptions {
30
+ // The Rokt kit signal only runs when this init configures a Rokt kit.
31
+ hasRoktKit: boolean;
32
+ }
33
+
34
+ const btnWindow = (): WindowWithBtn | null =>
35
+ typeof window === 'undefined' ? null : (window as WindowWithBtn);
36
+
37
+ export const getActiveButtonTracker = (): ButtonTracker | undefined => {
38
+ const win = btnWindow();
39
+ const state = win ? win[WIN_BTN_KEY] : undefined;
40
+ return state ? state.tracker : undefined;
41
+ };
42
+
43
+ // Stops whichever tracker is active, including one from an earlier bundle load.
44
+ export const resetButtonTracking = (): void => {
45
+ const win = btnWindow();
46
+ if (!win) {
47
+ return;
48
+ }
49
+ const active = getActiveButtonTracker();
50
+ if (active) {
51
+ active.teardown();
52
+ }
53
+ win[WIN_BTN_KEY] = {};
54
+ };
55
+
56
+ // Runs once the browser has painted the page's response to the interaction, so
57
+ // describing and logging a click never delays that paint.
58
+ const afterNextPaint = (fn: () => void): void => {
59
+ if (
60
+ isFunction(window.requestAnimationFrame) &&
61
+ document.visibilityState !== 'hidden'
62
+ ) {
63
+ requestAnimationFrame(() => {
64
+ setTimeout(fn, 0);
65
+ });
66
+ } else {
67
+ setTimeout(fn, 0);
68
+ }
69
+ };
70
+
71
+ const CAPTURE_PASSIVE: AddEventListenerOptions = {
72
+ capture: true,
73
+ passive: true,
74
+ };
75
+ const PASSIVE: AddEventListenerOptions = { passive: true };
76
+
77
+ // Logs every button click as an mParticle event, and hands checkout intent to the
78
+ // Rokt kit as a frozen {kind, t}. Submits and wallet frame focus feed the kit only.
79
+ export class ButtonTracker {
80
+ private readonly mpInstance: IMParticleWebSDKInstance;
81
+ private active = false;
82
+ private hasRoktKit = false;
83
+ // Bumped on reset so classification queued before it is dropped.
84
+ private generation = 0;
85
+ private lastCommitClick = -Infinity;
86
+ private lastSent: { [kind: string]: { t: number; pathname: string } } = {};
87
+ private pagePath: string | null = null;
88
+ private pageEvents = 0;
89
+ private lastEvent: { [key: string]: number } = {};
90
+ // Clicks read at dispatch and waiting for the next paint.
91
+ private pending: Array<() => void> = [];
92
+
93
+ constructor(mpInstance: IMParticleWebSDKInstance) {
94
+ this.mpInstance = mpInstance;
95
+ }
96
+
97
+ public get isActive(): boolean {
98
+ return this.active;
99
+ }
100
+
101
+ public init(options: IButtonTrackerOptions): void {
102
+ const win = btnWindow();
103
+ if (!win) {
104
+ return;
105
+ }
106
+
107
+ const previous = getActiveButtonTracker();
108
+ if (previous) {
109
+ previous.teardown();
110
+ }
111
+ if (this.active) {
112
+ this.teardown();
113
+ }
114
+ this.hasRoktKit = options.hasRoktKit;
115
+
116
+ if (!this.eventsEnabled() && this.isKitSuppressed()) {
117
+ this.log('opted out, not starting');
118
+ return;
119
+ }
120
+
121
+ win.document.addEventListener('click', this.onEvent, CAPTURE_PASSIVE);
122
+ win.document.addEventListener('submit', this.onEvent, CAPTURE_PASSIVE);
123
+ win.addEventListener('blur', this.onEvent, PASSIVE);
124
+ win.addEventListener('pageshow', this.onPageShow, PASSIVE);
125
+ win.addEventListener('beforeunload', this.onHide, CAPTURE_PASSIVE);
126
+ win.addEventListener('pagehide', this.onHide, CAPTURE_PASSIVE);
127
+ win.document.addEventListener(
128
+ 'visibilitychange',
129
+ this.onHide,
130
+ CAPTURE_PASSIVE
131
+ );
132
+
133
+ this.active = true;
134
+ if (!win[WIN_BTN_KEY]) {
135
+ win[WIN_BTN_KEY] = {};
136
+ }
137
+ win[WIN_BTN_KEY].tracker = this;
138
+ this.log('listening');
139
+ }
140
+
141
+ public teardown(): void {
142
+ const win = btnWindow();
143
+ if (win) {
144
+ win.document.removeEventListener('click', this.onEvent, true);
145
+ win.document.removeEventListener('submit', this.onEvent, true);
146
+ win.removeEventListener('blur', this.onEvent);
147
+ win.removeEventListener('pageshow', this.onPageShow);
148
+ win.removeEventListener('beforeunload', this.onHide, true);
149
+ win.removeEventListener('pagehide', this.onHide, true);
150
+ win.document.removeEventListener(
151
+ 'visibilitychange',
152
+ this.onHide,
153
+ true
154
+ );
155
+
156
+ const state = win[WIN_BTN_KEY];
157
+ if (state && state.tracker === this) {
158
+ state.tracker = undefined;
159
+ }
160
+ }
161
+ this.active = false;
162
+ // Signals not yet handed to a kit go with the tracker.
163
+ this.clearPending();
164
+ }
165
+
166
+ // Drops queued classification, commit timing and buffered signals, so
167
+ // nothing captured before an opt-out is delivered after an opt-in.
168
+ public clearPending(): void {
169
+ this.reset();
170
+ const roktManager = this.mpInstance._RoktManager;
171
+ if (roktManager) {
172
+ roktManager.discardCheckoutIntents();
173
+ }
174
+ }
175
+
176
+ private reset(): void {
177
+ this.generation++;
178
+ this.pending = [];
179
+ this.lastCommitClick = -Infinity;
180
+ this.lastSent = {};
181
+ this.pagePath = null;
182
+ this.pageEvents = 0;
183
+ this.lastEvent = {};
184
+ }
185
+
186
+ private enqueue(delivery: () => void): void {
187
+ this.pending.push(delivery);
188
+ if (this.pending.length === 1) {
189
+ afterNextPaint(this.drain);
190
+ }
191
+ }
192
+
193
+ private readonly drain = (): void => {
194
+ const queue = this.pending;
195
+ this.pending = [];
196
+ queue.forEach(delivery => delivery());
197
+ };
198
+
199
+ // A click that navigates away can unload the page before the next paint, so
200
+ // anything waiting is delivered as the page is hidden or left. The capture
201
+ // listeners run before the SDK's own upload on the same events, including
202
+ // beforeunload, the first of them.
203
+ private readonly onHide = (event: Event): void => {
204
+ if (
205
+ event.type === 'beforeunload' ||
206
+ event.type === 'pagehide' ||
207
+ document.visibilityState === 'hidden'
208
+ ) {
209
+ this.drain();
210
+ }
211
+ };
212
+
213
+ private readonly onPageShow = (event: PageTransitionEvent): void => {
214
+ // A page restored from the back-forward cache starts fresh.
215
+ if (event.persisted) {
216
+ this.reset();
217
+ }
218
+ };
219
+
220
+ private readonly onEvent = (event: Event): void => {
221
+ try {
222
+ this.observe(event);
223
+ } catch (e) {
224
+ this.log('[error] event handler threw: ' + e);
225
+ }
226
+ };
227
+
228
+ private observe(event: Event): void {
229
+ if (!this.active || !event.isTrusted) {
230
+ return;
231
+ }
232
+ const layer = event.type;
233
+ const logsEvents = layer === 'click' && this.eventsEnabled();
234
+ if (!logsEvents && this.isKitSuppressed()) {
235
+ return;
236
+ }
237
+
238
+ const t = Date.now();
239
+ const pathname = window.location.pathname;
240
+
241
+ // The button is read now, while the click is still being dispatched:
242
+ // this capture listener runs before the page's own handlers, so a
243
+ // button that disables or relabels itself is seen as it was clicked.
244
+ // Logging and the kit hand-off wait for the next paint.
245
+ let click: IButtonClick | undefined;
246
+ let kind: CheckoutIntentKind | undefined;
247
+ let unconfirmedCommit = false;
248
+ if (layer === 'click') {
249
+ // The composed path is only populated during dispatch.
250
+ const path = isFunction(event.composedPath)
251
+ ? event.composedPath().slice(0, PATH_LIMIT)
252
+ : [];
253
+ click = logsEvents ? describeButtonClick(path) : undefined;
254
+ kind = classifyIntentControl(path, pathname);
255
+ } else if (layer === 'submit') {
256
+ const submitter = (event as Event & { submitter?: Element | null })
257
+ .submitter;
258
+ // A form submitted without a submitter only counts right after a
259
+ // commit click, which is known once that click is delivered.
260
+ kind = submitter
261
+ ? classifyIntentControl([submitter], pathname)
262
+ : 'commit';
263
+ unconfirmedCommit = !submitter;
264
+ }
265
+
266
+ const generation = this.generation;
267
+ this.enqueue(() => {
268
+ if (generation !== this.generation) {
269
+ return;
270
+ }
271
+ try {
272
+ if (layer === 'blur') {
273
+ // Read after the blur settles: some browsers have not moved
274
+ // focus into the frame yet when the window blurs.
275
+ kind = classifyIntentFrame(document.activeElement, window);
276
+ } else if (
277
+ unconfirmedCommit &&
278
+ t - this.lastCommitClick > SUBMIT_AFTER_COMMIT_CLICK_MS
279
+ ) {
280
+ kind = undefined;
281
+ }
282
+ if (click && logsEvents) {
283
+ this.logClick(click, t, pathname);
284
+ }
285
+ if (kind) {
286
+ this.emit(kind, layer, t, pathname);
287
+ }
288
+ } catch (e) {
289
+ this.log('[error] delivery threw: ' + e);
290
+ }
291
+ });
292
+ }
293
+
294
+ // One event per identical button every second, and at most 50 per page.
295
+ private logClick(click: IButtonClick, t: number, pathname: string): void {
296
+ if (!this.eventsEnabled()) {
297
+ return;
298
+ }
299
+ if (pathname !== this.pagePath) {
300
+ this.pagePath = pathname;
301
+ this.pageEvents = 0;
302
+ this.lastEvent = {};
303
+ }
304
+ if (this.pageEvents >= PAGE_EVENT_LIMIT) {
305
+ return;
306
+ }
307
+ const key = [
308
+ click.label,
309
+ click.ariaLabel,
310
+ click.icon,
311
+ click.elementId,
312
+ click.elementName,
313
+ click.testId,
314
+ click.element,
315
+ click.type,
316
+ ].join('|');
317
+ const last = this.lastEvent[key];
318
+ if (last !== undefined && t - last < EVENT_DEDUP_MS) {
319
+ return;
320
+ }
321
+ this.lastEvent[key] = t;
322
+ this.pageEvents++;
323
+
324
+ // Restarts an expired session, as public logEvent() and page views do.
325
+ this.mpInstance._SessionManager.resetSessionTimer();
326
+ this.mpInstance._Events.logEvent(
327
+ buildButtonClickEvent(click, pathname)
328
+ );
329
+ }
330
+
331
+ private emit(
332
+ kind: CheckoutIntentKind,
333
+ layer: string,
334
+ t: number,
335
+ pathname: string
336
+ ): void {
337
+ if (this.isKitSuppressed()) {
338
+ return;
339
+ }
340
+ if (kind === 'commit' && layer === 'click') {
341
+ this.lastCommitClick = t;
342
+ }
343
+
344
+ const last = this.lastSent[kind];
345
+ if (last && last.pathname === pathname && t - last.t < THROTTLE_MS) {
346
+ return;
347
+ }
348
+ this.lastSent[kind] = { t, pathname };
349
+
350
+ this.mpInstance._RoktManager.notifyCheckoutIntent(
351
+ Object.freeze({ kind, t }),
352
+ pathname
353
+ );
354
+ }
355
+
356
+ // Events follow the SDK's opt-out like any other event.
357
+ private eventsEnabled(): boolean {
358
+ const store = this.mpInstance._Store;
359
+ return !!store && store.isEnabled;
360
+ }
361
+
362
+ // The Rokt manager owns the kit's privacy gates: flag, opt-out, launcher
363
+ // options and Global Privacy Control.
364
+ private isKitSuppressed(): boolean {
365
+ const roktManager = this.mpInstance._RoktManager;
366
+ return (
367
+ !this.hasRoktKit ||
368
+ !roktManager ||
369
+ roktManager.isCheckoutIntentSuppressed()
370
+ );
371
+ }
372
+
373
+ private log(message: string): void {
374
+ this.mpInstance.Logger.verbose('mParticle button tracking: ' + message);
375
+ }
376
+ }
@@ -0,0 +1,200 @@
1
+ import {
2
+ CONTROL_SELECTOR,
3
+ findControl,
4
+ isRoktElement,
5
+ isWalletElement,
6
+ } from './controls';
7
+
8
+ // Classifies a click, submit or frame focus as checkout intent for the Rokt kit.
9
+ // Reads only short control labels, never form field values, and stores nothing.
10
+ export type CheckoutIntentKind =
11
+ | 'commit'
12
+ | 'wallet'
13
+ | 'checkout_step'
14
+ | 'frame';
15
+
16
+ const checkoutPath = /(?:^|\/)(?:checkouts?|payment|order|kasse|paiement|commander?|pago)(?:\/|$)/i;
17
+ const exclusions = new RegExp(
18
+ [
19
+ 'coupon|promo|newsletter|sign in|add to cart|gift card|pay later|pay in|payment plan|learn more',
20
+ 'gutschein|anmelden|warenkorb|bon cadeau|connexion|panier|cupón|iniciar sesión|carrito',
21
+ 'carrello|codice sconto|accedi|buono regalo|winkelwagen|kortingscode|inloggen|cadeaubon|achteraf betalen',
22
+ 'カート|クーポン|ログイン|会員登録|ギフト|後払い|メルマガ',
23
+ ].join('|')
24
+ );
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('|') +
42
+ ')$'
43
+ );
44
+ const routeGatedCommits = new RegExp(
45
+ '^(?:buy now|purchase|jetzt kaufen|kaufen|commander|(?:passer|valider) (?:la |ma )?commande|finalizar (?:la |mi )?compra|' +
46
+ 'comprar ahora|acquista ora|nu kopen|今すぐ購入|購入する|この内容で申し込む)$'
47
+ );
48
+ const steps = new RegExp(
49
+ '^(?:' +
50
+ [
51
+ 'checkout|check out|check-out|continue to payment|proceed to checkout',
52
+ 'zur kasse|zur kasse gehen|auschecken|weiter zur zahlung',
53
+ 'passer à la caisse|continuer vers le paiement|procéder au paiement',
54
+ 'ir a caja|continuar al pago|tramitar pedido',
55
+ "procedi al checkout|procedi all'acquisto|procedi al pagamento|vai alla cassa",
56
+ 'afrekenen|verder naar betalen|doorgaan naar betalen',
57
+ 'レジに進む|ご?(?:購入|注文)手続き(?:へ|に)(?:進む)?|お?支払い(?:へ|に)進む|(?:ご注文内容の)?確認画面へ(?:進む)?',
58
+ ].join('|') +
59
+ ')$'
60
+ );
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',
66
+ 'pay.google.com',
67
+ 'applepay.cdn-apple.com',
68
+ 'js.stripe.com',
69
+ 'checkoutshopper-live.adyen.com',
70
+ 'payments.amazon.com',
71
+ 'pay.shopify.com',
72
+ ]);
73
+ // Keep these classes ASCII-only in source bytes to avoid mojibake parse failures
74
+ // when a host page forces non-UTF-8 decoding before JavaScript parse.
75
+ const TYPOGRAPHIC_APOSTROPHES = /[\u2019\u2018]/g;
76
+ const CURRENCY_AND_NUMBER_CHARS =
77
+ /[0-9\uFF10-\uFF19$\u20AC\u00A3\u00A5\uFFE5\u20B9\u5186.,\uFF0C]/g;
78
+
79
+ function getIntentRouteClass(pathname: string): 'checkout_path' | 'other' {
80
+ return checkoutPath.test(pathname) ? 'checkout_path' : 'other';
81
+ }
82
+
83
+ function normalize(text: string | null | undefined): string | undefined {
84
+ if (!text || text.length > 128) {
85
+ return undefined;
86
+ }
87
+ return text
88
+ .toLowerCase()
89
+ .replace(TYPOGRAPHIC_APOSTROPHES, "'")
90
+ .replace(CURRENCY_AND_NUMBER_CHARS, '')
91
+ .replace(/\s+/g, ' ')
92
+ .trim();
93
+ }
94
+
95
+ // 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.
97
+ function labelSources(element: Element): Array<string | null> {
98
+ if (
99
+ element.children.length > 4 ||
100
+ element.querySelector('input, select, textarea, [data-mp-no-track]')
101
+ ) {
102
+ return [];
103
+ }
104
+ let text: string | null = null;
105
+ if (element instanceof HTMLInputElement) {
106
+ const type = element.type;
107
+ if (type === 'submit' || type === 'button') {
108
+ text = element.value;
109
+ } else if (type === 'image') {
110
+ text = element.getAttribute('alt');
111
+ }
112
+ } else {
113
+ text = element.textContent;
114
+ }
115
+ return [element.getAttribute('aria-label'), text];
116
+ }
117
+
118
+ function labels(element: Element): string[] {
119
+ return labelSources(element)
120
+ .map(normalize)
121
+ .filter((label): label is string => !!label);
122
+ }
123
+
124
+ function classifyLabel(
125
+ text: string,
126
+ button: boolean,
127
+ pathname: string
128
+ ): Exclude<CheckoutIntentKind, 'frame'> | undefined {
129
+ if (
130
+ commits.test(text) ||
131
+ (routeGatedCommits.test(text) &&
132
+ getIntentRouteClass(pathname) === 'checkout_path')
133
+ ) {
134
+ return 'commit';
135
+ }
136
+ if (button && wallets.test(text)) {
137
+ return 'wallet';
138
+ }
139
+ if (steps.test(text)) {
140
+ return 'checkout_step';
141
+ }
142
+ return undefined;
143
+ }
144
+
145
+ export function classifyIntentControl(
146
+ path: Array<EventTarget | null>,
147
+ pathname: string
148
+ ): CheckoutIntentKind | undefined {
149
+ const control = findControl(path);
150
+ return control ? classifyControl(control, pathname) : undefined;
151
+ }
152
+
153
+ function classifyControl(
154
+ control: Element,
155
+ pathname: string
156
+ ): Exclude<CheckoutIntentKind, 'frame'> | undefined {
157
+ const texts = labels(control);
158
+ if (texts.some(text => exclusions.test(text))) {
159
+ return undefined;
160
+ }
161
+ let button = false;
162
+ if (!control.matches('a[href]')) {
163
+ button = control.matches(CONTROL_SELECTOR) || isWalletElement(control);
164
+ }
165
+ if (button && isWalletElement(control)) {
166
+ return 'wallet';
167
+ }
168
+ for (const text of texts) {
169
+ const kind = classifyLabel(text, button, pathname);
170
+ if (kind) {
171
+ return kind;
172
+ }
173
+ }
174
+ return undefined;
175
+ }
176
+
177
+ // Focus moving into a visible hosted wallet or payment frame.
178
+ export function classifyIntentFrame(
179
+ target: EventTarget | null,
180
+ win: Window
181
+ ): 'frame' | undefined {
182
+ if (
183
+ win.document.visibilityState !== 'visible' ||
184
+ !(target instanceof HTMLIFrameElement)
185
+ ) {
186
+ return undefined;
187
+ }
188
+ if (isRoktElement(target)) {
189
+ return undefined;
190
+ }
191
+ try {
192
+ const url = new URL(target.src);
193
+ if (url.protocol === 'https:' && frameHosts.has(url.hostname)) {
194
+ return 'frame';
195
+ }
196
+ } catch (e) {
197
+ // An unparseable frame src is not a wallet frame.
198
+ }
199
+ return undefined;
200
+ }
@@ -0,0 +1,92 @@
1
+ import { isFunction } from '../utils';
2
+
3
+ // Finds the control a click landed on, for both checkout intent and button
4
+ // descriptions. Rokt's own elements, disabled controls and anything under
5
+ // data-mp-no-track are never returned.
6
+ export const CONTROL_SELECTOR =
7
+ 'button, input[type="submit"], input[type="button"], input[type="image"], [role="button"]';
8
+
9
+ // closest() over the composed tree: slots, parents, then shadow hosts.
10
+ export function hasComposedAncestor(
11
+ element: Element,
12
+ selector: string
13
+ ): boolean {
14
+ let node: Element | null = element;
15
+ while (node) {
16
+ if (node.matches(selector)) {
17
+ return true;
18
+ }
19
+ const root = isFunction(node.getRootNode)
20
+ ? (node.getRootNode() as ShadowRoot)
21
+ : null;
22
+ node =
23
+ (node as Element & { assignedSlot?: Element | null })
24
+ .assignedSlot ||
25
+ node.parentElement ||
26
+ (root && root.host) ||
27
+ null;
28
+ }
29
+ return false;
30
+ }
31
+
32
+ // Rokt's own placements and frames never count as partner checkout controls.
33
+ export function isRoktElement(element: Element): boolean {
34
+ if (
35
+ hasComposedAncestor(element, '[data-core-type], #rokt-controller-frame')
36
+ ) {
37
+ return true;
38
+ }
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
+ }
52
+ }
53
+ return false;
54
+ }
55
+
56
+ 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
+ );
60
+ }
61
+
62
+ // 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.
64
+ export function findControl(
65
+ path: Array<EventTarget | null>
66
+ ): Element | undefined {
67
+ let control: Element | undefined;
68
+ for (const target of path.slice(0, 12)) {
69
+ if (!(target instanceof Element)) {
70
+ continue;
71
+ }
72
+ if (isRoktElement(target) || target.matches('[data-mp-no-track]')) {
73
+ return undefined;
74
+ }
75
+ if (
76
+ !control &&
77
+ (target.matches(CONTROL_SELECTOR + ', a[href]') ||
78
+ isWalletElement(target))
79
+ ) {
80
+ control = target;
81
+ }
82
+ }
83
+ if (
84
+ !control ||
85
+ isRoktElement(control) ||
86
+ hasComposedAncestor(control, '[data-mp-no-track]') ||
87
+ control.matches(':disabled, [aria-disabled="true"]')
88
+ ) {
89
+ return undefined;
90
+ }
91
+ return control;
92
+ }