@mparticle/web-sdk 3.17.0 → 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.
- package/README.md +11 -6
- package/dist/mparticle.common.js +99 -46
- package/dist/mparticle.esm.js +99 -46
- package/dist/mparticle.js +541 -151
- package/dist/types/src/buttonTracking/buttonClickEvent.d.ts +5 -0
- package/dist/types/src/buttonTracking/buttonDescription.d.ts +14 -0
- package/dist/types/src/buttonTracking/buttonTracker.d.ts +36 -0
- package/dist/types/src/{checkoutIntentClassifier.d.ts → buttonTracking/checkoutIntent.d.ts} +0 -1
- package/dist/types/src/buttonTracking/controls.d.ts +5 -0
- package/dist/types/src/buttonTracking/labelMasking.d.ts +1 -0
- package/dist/types/src/constants.d.ts +1 -1
- package/dist/types/src/mp-instance.d.ts +2 -2
- package/dist/types/src/roktManager.d.ts +1 -1
- package/dist/types/src/store.d.ts +1 -1
- package/package.json +4 -3
- package/src/buttonTracking/buttonClickEvent.ts +49 -0
- package/src/buttonTracking/buttonDescription.ts +244 -0
- package/src/buttonTracking/buttonTracker.ts +376 -0
- package/src/{checkoutIntentClassifier.ts → buttonTracking/checkoutIntent.ts} +38 -69
- package/src/buttonTracking/controls.ts +92 -0
- package/src/buttonTracking/labelMasking.ts +41 -0
- package/src/constants.ts +1 -1
- package/src/mp-instance.ts +22 -27
- package/src/roktManager.ts +2 -2
- package/src/store.ts +3 -3
- package/dist/types/src/checkoutIntentTracker.d.ts +0 -23
- package/src/checkoutIntentTracker.ts +0 -244
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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.
|
|
3
10
|
export type CheckoutIntentKind =
|
|
4
11
|
| 'commit'
|
|
5
12
|
| 'wallet'
|
|
@@ -63,88 +70,62 @@ const frameHosts = new Set([
|
|
|
63
70
|
'payments.amazon.com',
|
|
64
71
|
'pay.shopify.com',
|
|
65
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;
|
|
66
78
|
|
|
67
|
-
|
|
68
|
-
'button, input[type="submit"], input[type="button"], input[type="image"], [role="button"]';
|
|
69
|
-
|
|
70
|
-
export function getIntentRouteClass(
|
|
71
|
-
pathname: string
|
|
72
|
-
): 'checkout_path' | 'other' {
|
|
79
|
+
function getIntentRouteClass(pathname: string): 'checkout_path' | 'other' {
|
|
73
80
|
return checkoutPath.test(pathname) ? 'checkout_path' : 'other';
|
|
74
81
|
}
|
|
75
82
|
|
|
76
|
-
// Rokt's own placements and frames never count as partner checkout controls.
|
|
77
|
-
function isRoktElement(element: Element): boolean {
|
|
78
|
-
if (element.closest('[data-core-type], #rokt-controller-frame')) {
|
|
79
|
-
return true;
|
|
80
|
-
}
|
|
81
|
-
const src = element.getAttribute('src');
|
|
82
|
-
if (src) {
|
|
83
|
-
try {
|
|
84
|
-
if (
|
|
85
|
-
new URL(src, 'https://example.test').pathname.indexOf(
|
|
86
|
-
'/wsdk/'
|
|
87
|
-
) !== -1
|
|
88
|
-
) {
|
|
89
|
-
return true;
|
|
90
|
-
}
|
|
91
|
-
} catch (e) {
|
|
92
|
-
// An unparseable src is not a Rokt element.
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
return false;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function isWalletElement(element: Element): boolean {
|
|
99
|
-
return element.matches(
|
|
100
|
-
'apple-pay-button, shop-pay-button, .gpay-button, #AmazonPayButton, [data-amazon-pay-button]'
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
83
|
function normalize(text: string | null | undefined): string | undefined {
|
|
105
84
|
if (!text || text.length > 128) {
|
|
106
85
|
return undefined;
|
|
107
86
|
}
|
|
108
87
|
return text
|
|
109
88
|
.toLowerCase()
|
|
110
|
-
.replace(
|
|
111
|
-
.replace(
|
|
89
|
+
.replace(TYPOGRAPHIC_APOSTROPHES, "'")
|
|
90
|
+
.replace(CURRENCY_AND_NUMBER_CHARS, '')
|
|
112
91
|
.replace(/\s+/g, ' ')
|
|
113
92
|
.trim();
|
|
114
93
|
}
|
|
115
94
|
|
|
116
95
|
// Labels come from aria-label then visible text. Only submit and button inputs
|
|
117
96
|
// have their value read, so a typed field value is never touched.
|
|
118
|
-
function
|
|
97
|
+
function labelSources(element: Element): Array<string | null> {
|
|
119
98
|
if (
|
|
120
99
|
element.children.length > 4 ||
|
|
121
|
-
element.querySelector('input, select, textarea')
|
|
100
|
+
element.querySelector('input, select, textarea, [data-mp-no-track]')
|
|
122
101
|
) {
|
|
123
102
|
return [];
|
|
124
103
|
}
|
|
125
104
|
let text: string | null = null;
|
|
126
|
-
if (element
|
|
127
|
-
const
|
|
128
|
-
const type = typeAttr ? typeAttr.toLowerCase() : typeAttr;
|
|
105
|
+
if (element instanceof HTMLInputElement) {
|
|
106
|
+
const type = element.type;
|
|
129
107
|
if (type === 'submit' || type === 'button') {
|
|
130
|
-
text =
|
|
108
|
+
text = element.value;
|
|
131
109
|
} else if (type === 'image') {
|
|
132
110
|
text = element.getAttribute('alt');
|
|
133
111
|
}
|
|
134
112
|
} else {
|
|
135
113
|
text = element.textContent;
|
|
136
114
|
}
|
|
137
|
-
return [
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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);
|
|
141
122
|
}
|
|
142
123
|
|
|
143
124
|
function classifyLabel(
|
|
144
125
|
text: string,
|
|
145
126
|
button: boolean,
|
|
146
127
|
pathname: string
|
|
147
|
-
): CheckoutIntentKind | undefined {
|
|
128
|
+
): Exclude<CheckoutIntentKind, 'frame'> | undefined {
|
|
148
129
|
if (
|
|
149
130
|
commits.test(text) ||
|
|
150
131
|
(routeGatedCommits.test(text) &&
|
|
@@ -161,30 +142,18 @@ function classifyLabel(
|
|
|
161
142
|
return undefined;
|
|
162
143
|
}
|
|
163
144
|
|
|
164
|
-
// Walks at most 12 entries of an event's composed path for the nearest control.
|
|
165
145
|
export function classifyIntentControl(
|
|
166
146
|
path: Array<EventTarget | null>,
|
|
167
147
|
pathname: string
|
|
168
148
|
): CheckoutIntentKind | undefined {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (
|
|
178
|
-
!control &&
|
|
179
|
-
(target.matches(CONTROL_SELECTOR + ', a[href]') ||
|
|
180
|
-
isWalletElement(target))
|
|
181
|
-
) {
|
|
182
|
-
control = target;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
if (!control || isRoktElement(control)) {
|
|
186
|
-
return undefined;
|
|
187
|
-
}
|
|
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 {
|
|
188
157
|
const texts = labels(control);
|
|
189
158
|
if (texts.some(text => exclusions.test(text))) {
|
|
190
159
|
return undefined;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Masks a button label before it is logged. Button text can carry personal data,
|
|
2
|
+
// so this is the privacy boundary for every label and aria-label.
|
|
3
|
+
|
|
4
|
+
// Masking needs \p{Nd} to see every script's digits. Browsers without Unicode
|
|
5
|
+
// property escapes send no labels at all.
|
|
6
|
+
function createDigitPatterns(): { run: RegExp; number: RegExp } | undefined {
|
|
7
|
+
try {
|
|
8
|
+
return {
|
|
9
|
+
run: new RegExp('\\p{Nd}{6,}', 'u'),
|
|
10
|
+
number: new RegExp('\\p{Nd}+(?:[.,،٫٬]\\p{Nd}+)*', 'gu'),
|
|
11
|
+
};
|
|
12
|
+
} catch (e) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const digitPatterns = createDigitPatterns();
|
|
18
|
+
|
|
19
|
+
// The label as logged: lower case, at most 64 characters, digits and amounts
|
|
20
|
+
// masked. Dropped when it looks like an email, phone or card number.
|
|
21
|
+
export function maskLabel(raw: string | null | undefined): string | undefined {
|
|
22
|
+
if (!raw || raw.length > 128 || !digitPatterns) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const text = raw
|
|
26
|
+
.replace(/\s+/g, ' ')
|
|
27
|
+
.trim()
|
|
28
|
+
.toLowerCase();
|
|
29
|
+
if (
|
|
30
|
+
!text ||
|
|
31
|
+
text.indexOf('@') !== -1 ||
|
|
32
|
+
digitPatterns.run.test(text.replace(/[\s()\-‐-―]/g, ''))
|
|
33
|
+
) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const masked = text
|
|
37
|
+
.replace(digitPatterns.number, '#')
|
|
38
|
+
.slice(0, 64)
|
|
39
|
+
.trim();
|
|
40
|
+
return masked || undefined;
|
|
41
|
+
}
|
package/src/constants.ts
CHANGED
|
@@ -190,7 +190,7 @@ const Constants = {
|
|
|
190
190
|
CaptureIntegrationSpecificIdsV2: 'captureIntegrationSpecificIds.V2',
|
|
191
191
|
AstBackgroundEvents: 'astBackgroundEvents',
|
|
192
192
|
AutoLogPageView: 'autoLogPageView',
|
|
193
|
-
|
|
193
|
+
AutoButtonTracking: 'autoButtonTracking',
|
|
194
194
|
},
|
|
195
195
|
DefaultInstance: 'default_instance',
|
|
196
196
|
CCPAPurpose: 'data_sale_opt_out',
|