@mparticle/web-sdk 3.23.0 → 3.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mparticle.common.js +1 -1688
- package/dist/mparticle.esm.js +1 -1688
- package/dist/mparticle.js +1015 -253
- package/dist/types/src/buttonTracking/buttonClickEvent.d.ts +7 -0
- package/dist/types/src/buttonTracking/buttonDescription.d.ts +10 -3
- package/dist/types/src/buttonTracking/buttonTracker.d.ts +8 -0
- package/dist/types/src/buttonTracking/checkoutIntent.d.ts +8 -3
- package/dist/types/src/buttonTracking/controls.d.ts +8 -4
- package/dist/types/src/buttonTracking/labelMasking.d.ts +1 -0
- package/dist/types/src/configAPIClient.d.ts +1 -0
- package/dist/types/src/constants.d.ts +1 -0
- package/dist/types/src/cookieConsentManager.d.ts +17 -3
- package/dist/types/src/forwarder-utils.d.ts +3 -0
- package/dist/types/src/pageViewTracker.d.ts +19 -3
- package/dist/types/src/roktLauncherOptions.d.ts +1 -0
- package/dist/types/src/roktManager.d.ts +13 -2
- package/dist/types/src/sdkRuntimeModels.d.ts +1 -0
- package/dist/types/src/store.d.ts +3 -0
- package/package.json +21 -14
- package/src/buttonTracking/buttonClickEvent.ts +126 -8
- package/src/buttonTracking/buttonDescription.ts +336 -53
- package/src/buttonTracking/buttonTracker.ts +231 -19
- package/src/buttonTracking/checkoutIntent.ts +117 -23
- package/src/buttonTracking/controls.ts +169 -55
- package/src/buttonTracking/labelMasking.ts +4 -1
- package/src/configAPIClient.ts +1 -0
- package/src/constants.ts +1 -0
- package/src/cookieConsentManager.ts +23 -3
- package/src/cookieSyncManager.ts +11 -3
- package/src/events.ts +9 -3
- package/src/forwarder-utils.ts +38 -0
- package/src/forwarders.ts +2 -28
- package/src/mp-instance.ts +41 -7
- package/src/pageUrl.ts +43 -2
- package/src/pageViewTracker.ts +193 -111
- package/src/roktLauncherOptions.ts +1 -0
- package/src/roktManager.ts +66 -11
- package/src/sdkRuntimeModels.ts +1 -0
- package/src/store.ts +17 -0
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import { IMParticleWebSDKInstance } from '../mp-instance';
|
|
2
2
|
import { isFunction } from '../utils';
|
|
3
|
-
import { buildButtonClickEvent } from './buttonClickEvent';
|
|
4
|
-
import {
|
|
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
|
-
|
|
16
|
+
classifyPaymentFrame,
|
|
17
|
+
PaymentFrameFamily,
|
|
10
18
|
} from './checkoutIntent';
|
|
11
|
-
import { findControl } from './controls';
|
|
19
|
+
import { findControl, isExcludedPath } from './controls';
|
|
12
20
|
|
|
13
21
|
// State lives on `window`, like APV, so a re-executed bundle never doubles the
|
|
14
22
|
// listeners. Inspect `window.__mpBtn__` in a console to see if one is live.
|
|
@@ -16,6 +24,8 @@ export const WIN_BTN_KEY = '__mpBtn__';
|
|
|
16
24
|
|
|
17
25
|
const THROTTLE_MS = 2000;
|
|
18
26
|
const SUBMIT_AFTER_COMMIT_CLICK_MS = 1000;
|
|
27
|
+
// Apple Pay must begin inside the tap's own handler, so the tap is this recent.
|
|
28
|
+
const APPLE_PAY_AFTER_CLICK_MS = 1000;
|
|
19
29
|
const PATH_LIMIT = 12;
|
|
20
30
|
// A ceiling, not a dedupe: a held Enter key or browser automation fires a
|
|
21
31
|
// trusted click per repeat, and each one is forwarded to every kit.
|
|
@@ -35,6 +45,9 @@ type WindowWithBtn = Window & {
|
|
|
35
45
|
export interface IButtonTrackerOptions {
|
|
36
46
|
// The Rokt kit signal only runs when this init configures a Rokt kit.
|
|
37
47
|
hasRoktKit: boolean;
|
|
48
|
+
// Button clicks and clicks on links that leave the page are logged apart.
|
|
49
|
+
logButtons: boolean;
|
|
50
|
+
logLinks: boolean;
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
const btnWindow = (): WindowWithBtn | null =>
|
|
@@ -74,21 +87,87 @@ const afterNextPaint = (fn: () => void): void => {
|
|
|
74
87
|
}
|
|
75
88
|
};
|
|
76
89
|
|
|
90
|
+
// Apple Pay's session opens only from a shopper's tap, whatever button the page
|
|
91
|
+
// draws for it, so its opening is a wallet tap no label or name rule can miss.
|
|
92
|
+
// The wrapper keeps the page's own begin under this key, to put it back.
|
|
93
|
+
const APPLE_PAY_HOOK = '__mpApplePayHook__';
|
|
94
|
+
|
|
95
|
+
const applePayPrototype = (win: Window) => {
|
|
96
|
+
const session = win['ApplePaySession'];
|
|
97
|
+
return session && session.prototype;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Wraps ApplePaySession.prototype.begin once per window, so the active tracker
|
|
101
|
+
// hears that a session began. The wrapper reads no arguments and no payment
|
|
102
|
+
// data, and returns or throws exactly what the original does. Nothing here can
|
|
103
|
+
// throw into SDK init: a throwing getter or a frozen prototype leaves Apple Pay
|
|
104
|
+
// as it was.
|
|
105
|
+
const hookApplePay = (win: Window): void => {
|
|
106
|
+
try {
|
|
107
|
+
const prototype = applePayPrototype(win);
|
|
108
|
+
const begin = prototype && prototype.begin;
|
|
109
|
+
if (!isFunction(begin) || begin[APPLE_PAY_HOOK]) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const opened = function(this: unknown, ...args: unknown[]) {
|
|
113
|
+
const result = begin.apply(this, args);
|
|
114
|
+
try {
|
|
115
|
+
const tracker = getActiveButtonTracker();
|
|
116
|
+
if (tracker) {
|
|
117
|
+
tracker.onApplePayBegin();
|
|
118
|
+
}
|
|
119
|
+
} catch (e) {
|
|
120
|
+
// A session the page began is never disturbed by tracking.
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
};
|
|
124
|
+
// Not enumerable, so the marker doesn't show among the method's own keys.
|
|
125
|
+
Object.defineProperty(opened, APPLE_PAY_HOOK, { value: begin });
|
|
126
|
+
prototype.begin = opened;
|
|
127
|
+
} catch (e) {
|
|
128
|
+
// Apple Pay stays as the page set it up.
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Puts the page's own begin back, unless another script has wrapped it since.
|
|
133
|
+
const unhookApplePay = (win: Window): void => {
|
|
134
|
+
try {
|
|
135
|
+
const prototype = applePayPrototype(win);
|
|
136
|
+
const begin = prototype && prototype.begin;
|
|
137
|
+
if (isFunction(begin) && isFunction(begin[APPLE_PAY_HOOK])) {
|
|
138
|
+
prototype.begin = begin[APPLE_PAY_HOOK];
|
|
139
|
+
}
|
|
140
|
+
} catch (e) {
|
|
141
|
+
// Apple Pay stays as it is.
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
77
145
|
const CAPTURE_PASSIVE: AddEventListenerOptions = {
|
|
78
146
|
capture: true,
|
|
79
147
|
passive: true,
|
|
80
148
|
};
|
|
81
149
|
const PASSIVE: AddEventListenerOptions = { passive: true };
|
|
82
150
|
|
|
83
|
-
// Logs
|
|
84
|
-
// 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}. Submits and wallet frame focus feed the kit only,
|
|
153
|
+
// and so do payment picks: a payment radio's option names, or the payment family of a focused frame.
|
|
85
154
|
export class ButtonTracker {
|
|
86
155
|
private readonly mpInstance: IMParticleWebSDKInstance;
|
|
87
156
|
private active = false;
|
|
88
157
|
private hasRoktKit = false;
|
|
158
|
+
private logButtons = false;
|
|
159
|
+
private logLinks = false;
|
|
89
160
|
// Bumped on reset so classification queued before it is dropped.
|
|
90
161
|
private generation = 0;
|
|
91
162
|
private lastCommitClick = -Infinity;
|
|
163
|
+
// The latest trusted click: whether the page excluded it, and the intent it
|
|
164
|
+
// already carries to the kit, with its source, if any.
|
|
165
|
+
private lastClick: {
|
|
166
|
+
t: number;
|
|
167
|
+
excluded: boolean;
|
|
168
|
+
kind: CheckoutIntentKind | undefined;
|
|
169
|
+
source: CheckoutIntentSource | undefined;
|
|
170
|
+
} | null = null;
|
|
92
171
|
private lastSent: { [kind: string]: { t: number; pathname: string } } = {};
|
|
93
172
|
private pagePath: string | null = null;
|
|
94
173
|
private pageEvents = 0;
|
|
@@ -121,8 +200,13 @@ export class ButtonTracker {
|
|
|
121
200
|
this.teardown();
|
|
122
201
|
}
|
|
123
202
|
this.hasRoktKit = options.hasRoktKit;
|
|
203
|
+
this.logButtons = options.logButtons;
|
|
204
|
+
this.logLinks = options.logLinks;
|
|
124
205
|
|
|
125
|
-
if (
|
|
206
|
+
if (
|
|
207
|
+
(!(this.logButtons || this.logLinks) || !this.eventsEnabled()) &&
|
|
208
|
+
this.isKitSuppressed()
|
|
209
|
+
) {
|
|
126
210
|
this.log('opted out, not starting');
|
|
127
211
|
return;
|
|
128
212
|
}
|
|
@@ -144,9 +228,54 @@ export class ButtonTracker {
|
|
|
144
228
|
win[WIN_BTN_KEY] = {};
|
|
145
229
|
}
|
|
146
230
|
win[WIN_BTN_KEY].tracker = this;
|
|
231
|
+
// Only where checkout intent can reach the kit; a shopper who opts back in mid-page gets no session
|
|
232
|
+
// signal until the next page load.
|
|
233
|
+
if (!this.isKitSuppressed()) {
|
|
234
|
+
hookApplePay(win);
|
|
235
|
+
}
|
|
147
236
|
this.log('listening');
|
|
148
237
|
}
|
|
149
238
|
|
|
239
|
+
// An Apple Pay session begun right after a tracked tap counts as a wallet
|
|
240
|
+
// tap for the kit; one begun with no such tap, or from a tap the page
|
|
241
|
+
// excluded, does not. A tap that reads as a confirm tap reaches the kit as
|
|
242
|
+
// one. It is delivered on a timer, not after the next paint, since the
|
|
243
|
+
// payment sheet may hold painting back: a tap that already reads as a
|
|
244
|
+
// wallet goes now under its own rule, and the throttle drops the copy
|
|
245
|
+
// queued for the paint.
|
|
246
|
+
public onApplePayBegin(): void {
|
|
247
|
+
const t = Date.now();
|
|
248
|
+
const click = this.lastClick;
|
|
249
|
+
if (
|
|
250
|
+
!this.active ||
|
|
251
|
+
!click ||
|
|
252
|
+
click.excluded ||
|
|
253
|
+
click.kind === 'commit' ||
|
|
254
|
+
t - click.t > APPLE_PAY_AFTER_CLICK_MS ||
|
|
255
|
+
this.isKitSuppressed()
|
|
256
|
+
) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const pathname = window.location.pathname;
|
|
260
|
+
const generation = this.generation;
|
|
261
|
+
setTimeout(() => {
|
|
262
|
+
if (generation !== this.generation) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
this.emit(
|
|
267
|
+
'wallet',
|
|
268
|
+
click.kind === 'wallet' ? click.source : 'api',
|
|
269
|
+
'apple_pay',
|
|
270
|
+
t,
|
|
271
|
+
pathname
|
|
272
|
+
);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
this.log('[error] Apple Pay delivery threw: ' + e);
|
|
275
|
+
}
|
|
276
|
+
}, 0);
|
|
277
|
+
}
|
|
278
|
+
|
|
150
279
|
public teardown(): void {
|
|
151
280
|
const win = btnWindow();
|
|
152
281
|
if (win) {
|
|
@@ -165,6 +294,7 @@ export class ButtonTracker {
|
|
|
165
294
|
const state = win[WIN_BTN_KEY];
|
|
166
295
|
if (state && state.tracker === this) {
|
|
167
296
|
state.tracker = undefined;
|
|
297
|
+
unhookApplePay(win);
|
|
168
298
|
}
|
|
169
299
|
}
|
|
170
300
|
this.active = false;
|
|
@@ -186,6 +316,7 @@ export class ButtonTracker {
|
|
|
186
316
|
this.generation++;
|
|
187
317
|
this.pending = [];
|
|
188
318
|
this.lastCommitClick = -Infinity;
|
|
319
|
+
this.lastClick = null;
|
|
189
320
|
this.lastSent = {};
|
|
190
321
|
this.pagePath = null;
|
|
191
322
|
this.pageEvents = 0;
|
|
@@ -246,7 +377,11 @@ export class ButtonTracker {
|
|
|
246
377
|
return;
|
|
247
378
|
}
|
|
248
379
|
const layer = event.type;
|
|
249
|
-
const logsEvents =
|
|
380
|
+
const logsEvents =
|
|
381
|
+
layer === 'click' &&
|
|
382
|
+
(this.logButtons || this.logLinks) &&
|
|
383
|
+
this.eventsEnabled() &&
|
|
384
|
+
!this.hasTargetingOptOut();
|
|
250
385
|
const signalsKit = !this.isKitSuppressed();
|
|
251
386
|
if (!logsEvents && !signalsKit) {
|
|
252
387
|
return;
|
|
@@ -260,19 +395,43 @@ export class ButtonTracker {
|
|
|
260
395
|
// button that disables or relabels itself is seen as it was clicked.
|
|
261
396
|
// Logging and the kit hand-off wait for the next paint.
|
|
262
397
|
let click: IButtonClick | undefined;
|
|
398
|
+
let asLink = false;
|
|
263
399
|
let box: HTMLInputElement | undefined;
|
|
264
400
|
let kind: CheckoutIntentKind | undefined;
|
|
265
401
|
let source: CheckoutIntentSource | undefined;
|
|
402
|
+
let choice: IPaymentChoice | undefined;
|
|
403
|
+
let family: PaymentFrameFamily | undefined;
|
|
266
404
|
let unconfirmedCommit = false;
|
|
267
405
|
if (layer === 'click') {
|
|
268
406
|
// The composed path is only populated during dispatch.
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
);
|
|
407
|
+
const path = isFunction(event.composedPath)
|
|
408
|
+
? event.composedPath().slice(0, PATH_LIMIT)
|
|
409
|
+
: [];
|
|
410
|
+
const excluded = isExcludedPath(path);
|
|
411
|
+
const control = excluded ? undefined : findControl(path, true);
|
|
274
412
|
if (control) {
|
|
275
|
-
|
|
413
|
+
// With links alone, only an anchor can be logged.
|
|
414
|
+
click =
|
|
415
|
+
logsEvents &&
|
|
416
|
+
(this.logButtons || control.matches('a[href]'))
|
|
417
|
+
? describeButtonClick(control, path)
|
|
418
|
+
: undefined;
|
|
419
|
+
if (click) {
|
|
420
|
+
asLink = this.logLinks && isLinkClick(click);
|
|
421
|
+
// Without link tracking, a link that leaves the page stays a
|
|
422
|
+
// button click, except a plain link to another page of the
|
|
423
|
+
// site, which button tracking never logged.
|
|
424
|
+
const asButton =
|
|
425
|
+
this.logButtons &&
|
|
426
|
+
!asLink &&
|
|
427
|
+
!(
|
|
428
|
+
click.linkType === 'internal' &&
|
|
429
|
+
control.matches('a[href]:not([role="button"])')
|
|
430
|
+
);
|
|
431
|
+
if (!asLink && !asButton) {
|
|
432
|
+
click = undefined;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
276
435
|
if (click && click.checked !== undefined) {
|
|
277
436
|
box = control as HTMLInputElement;
|
|
278
437
|
}
|
|
@@ -282,7 +441,11 @@ export class ButtonTracker {
|
|
|
282
441
|
: undefined;
|
|
283
442
|
kind = intent ? intent.kind : undefined;
|
|
284
443
|
source = intent ? intent.source : undefined;
|
|
444
|
+
choice = signalsKit
|
|
445
|
+
? describePaymentChoice(control, path)
|
|
446
|
+
: undefined;
|
|
285
447
|
}
|
|
448
|
+
this.lastClick = { t, excluded, kind, source };
|
|
286
449
|
} else if (layer === 'submit') {
|
|
287
450
|
const submitter = (event as Event & { submitter?: Element | null })
|
|
288
451
|
.submitter;
|
|
@@ -309,7 +472,12 @@ export class ButtonTracker {
|
|
|
309
472
|
if (layer === 'blur') {
|
|
310
473
|
// Read after the blur settles: some browsers have not moved
|
|
311
474
|
// focus into the frame yet when the window blurs.
|
|
312
|
-
|
|
475
|
+
const frame = classifyPaymentFrame(
|
|
476
|
+
document.activeElement,
|
|
477
|
+
window
|
|
478
|
+
);
|
|
479
|
+
kind = frame ? frame.kind : undefined;
|
|
480
|
+
family = frame ? frame.family : undefined;
|
|
313
481
|
source = 'frame';
|
|
314
482
|
} else if (
|
|
315
483
|
unconfirmedCommit &&
|
|
@@ -329,7 +497,14 @@ export class ButtonTracker {
|
|
|
329
497
|
? !click.checked
|
|
330
498
|
: box.checked;
|
|
331
499
|
}
|
|
332
|
-
this.logClick(click, pathname);
|
|
500
|
+
this.logClick(click, pathname, asLink);
|
|
501
|
+
}
|
|
502
|
+
// Ahead of the intent, so a pre-selection it fires goes out
|
|
503
|
+
// with this pick.
|
|
504
|
+
if (choice && isPicked(choice.radio)) {
|
|
505
|
+
this.pickPayment(t, pathname, choice.names);
|
|
506
|
+
} else if (family) {
|
|
507
|
+
this.pickPayment(t, pathname, undefined, family);
|
|
333
508
|
}
|
|
334
509
|
if (kind) {
|
|
335
510
|
this.emit(kind, source, layer, t, pathname);
|
|
@@ -341,8 +516,12 @@ export class ButtonTracker {
|
|
|
341
516
|
}
|
|
342
517
|
|
|
343
518
|
// Every real click is one event, repeats included, up to a ceiling per page.
|
|
344
|
-
private logClick(
|
|
345
|
-
|
|
519
|
+
private logClick(
|
|
520
|
+
click: IButtonClick,
|
|
521
|
+
pathname: string,
|
|
522
|
+
asLink: boolean
|
|
523
|
+
): void {
|
|
524
|
+
if (!this.eventsEnabled() || this.hasTargetingOptOut()) {
|
|
346
525
|
return;
|
|
347
526
|
}
|
|
348
527
|
if (pathname !== this.pagePath) {
|
|
@@ -357,7 +536,9 @@ export class ButtonTracker {
|
|
|
357
536
|
this.mpInstance._SessionManager.resetSessionTimer();
|
|
358
537
|
const queued = this.queuedForUpload();
|
|
359
538
|
this.mpInstance._Events.logEvent(
|
|
360
|
-
|
|
539
|
+
asLink
|
|
540
|
+
? buildLinkClickEvent(click, pathname)
|
|
541
|
+
: buildButtonClickEvent(click, pathname)
|
|
361
542
|
);
|
|
362
543
|
// Only a click waiting for the batch interval is sent early: a held
|
|
363
544
|
// one goes out when released, and with batching off it already has.
|
|
@@ -431,12 +612,43 @@ export class ButtonTracker {
|
|
|
431
612
|
);
|
|
432
613
|
}
|
|
433
614
|
|
|
615
|
+
private pickPayment(
|
|
616
|
+
t: number,
|
|
617
|
+
pathname: string,
|
|
618
|
+
names?: string[],
|
|
619
|
+
family?: PaymentFrameFamily
|
|
620
|
+
): void {
|
|
621
|
+
if (this.isKitSuppressed()) {
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
this.mpInstance._RoktManager.notifyPaymentPick(
|
|
625
|
+
Object.freeze({
|
|
626
|
+
t,
|
|
627
|
+
pathname,
|
|
628
|
+
names: names ? Object.freeze(names.slice()) : undefined,
|
|
629
|
+
family,
|
|
630
|
+
})
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
434
634
|
// Events follow the SDK's opt-out like any other event.
|
|
435
635
|
private eventsEnabled(): boolean {
|
|
436
636
|
const store = this.mpInstance._Store;
|
|
437
637
|
return !!store && store.isEnabled;
|
|
438
638
|
}
|
|
439
639
|
|
|
640
|
+
// Read per click, since the launcher options can arrive after the tracker
|
|
641
|
+
// starts. Only where this init configures a Rokt kit, so Global Privacy
|
|
642
|
+
// Control alone doesn't stop click logging elsewhere.
|
|
643
|
+
private hasTargetingOptOut(): boolean {
|
|
644
|
+
const roktManager = this.mpInstance._RoktManager;
|
|
645
|
+
return (
|
|
646
|
+
this.hasRoktKit &&
|
|
647
|
+
!!roktManager &&
|
|
648
|
+
roktManager.hasTargetingOptOut()
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
440
652
|
// The Rokt manager owns the kit's privacy gates: flag, opt-out, launcher
|
|
441
653
|
// options and Global Privacy Control.
|
|
442
654
|
private isKitSuppressed(): boolean {
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
isQuietIntentControl,
|
|
5
5
|
isRoktElement,
|
|
6
6
|
isUnsafeTextSource,
|
|
7
|
+
isWalletNamedControl,
|
|
7
8
|
vendorButtonKind,
|
|
8
9
|
} from './controls';
|
|
9
10
|
|
|
@@ -13,7 +14,8 @@ export type CheckoutIntentKind =
|
|
|
13
14
|
| 'commit'
|
|
14
15
|
| 'wallet'
|
|
15
16
|
| 'checkout_step'
|
|
16
|
-
| 'frame'
|
|
17
|
+
| 'frame'
|
|
18
|
+
| 'cart';
|
|
17
19
|
|
|
18
20
|
const checkoutPath = /(?:^|\/)(?:checkouts?|payment|order|kasse|paiement|commander?|pago)(?:\/|$)/i;
|
|
19
21
|
// English look-alikes are whole words; the other languages stay substring
|
|
@@ -79,6 +81,15 @@ const steps = new RegExp(
|
|
|
79
81
|
].join('|') +
|
|
80
82
|
')$'
|
|
81
83
|
);
|
|
84
|
+
const CART_NOUN = '(?:(?:my|your|the) )?(?:shopping )?(?:cart|bag|basket)';
|
|
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?)?' +
|
|
91
|
+
'|items? in ' + CART_NOUN + ')$'
|
|
92
|
+
);
|
|
82
93
|
const WALLET_NAMES =
|
|
83
94
|
'apple pay|google pay|paypal|venmo|amazon pay|shop pay|klarna|afterpay|clearpay|cash app(?: pay)?|affirm|sezzle|samsung pay';
|
|
84
95
|
const WALLET_VERBS =
|
|
@@ -94,20 +105,35 @@ const walletBrands = new RegExp(
|
|
|
94
105
|
'^(?:' + WALLET_NAMES + ')(?: (?:checkout|check out|express(?: checkout)?|express pay|buy now|pay now|pay|now)' +
|
|
95
106
|
'| (?:logo|icon|mark|button)(?: (?:logo|icon|mark|button))*)?$'
|
|
96
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';
|
|
97
115
|
// Exact HTTPS hosts of hosted wallet button frames.
|
|
98
|
-
const
|
|
99
|
-
'pay.google.com',
|
|
100
|
-
'applepay.cdn-apple.com',
|
|
101
|
-
'payments.amazon.com',
|
|
102
|
-
'pay.shopify.com',
|
|
103
|
-
|
|
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
|
+
};
|
|
104
122
|
// PayPal serves its buttons and its card fields and pay-later messages from
|
|
105
123
|
// the same hosts; only the button frames are wallet taps.
|
|
106
124
|
const paypalFrameHosts = new Set(['www.paypal.com', 'www.sandbox.paypal.com']);
|
|
107
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)__/;
|
|
108
131
|
// Stripe's quick-pay button frames; its other frames are card and form fields.
|
|
109
132
|
const STRIPE_WALLET_FRAME = /\/elements-inner-(?:payment-request|express-checkout)-/;
|
|
133
|
+
const STRIPE_CARD_FRAME = /\/elements-inner-card\b/;
|
|
110
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.
|
|
111
137
|
const CARD_FRAME_DOMAINS = [
|
|
112
138
|
'adyen.com',
|
|
113
139
|
'braintreegateway.com',
|
|
@@ -200,10 +226,34 @@ function labels(element: Element): string[] {
|
|
|
200
226
|
.filter((label): label is string => !!label);
|
|
201
227
|
}
|
|
202
228
|
|
|
229
|
+
// A cart button is often a bare icon, so its title names it when nothing else
|
|
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 isCartControl(control: Element, cores: string[]): boolean {
|
|
234
|
+
const title = cores.length ? undefined : normalize(control.getAttribute('title'));
|
|
235
|
+
const texts = title ? [coreLabel(title)] : cores;
|
|
236
|
+
if (texts.some(text => addToCartLabels.test(text))) {
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
return (
|
|
240
|
+
(control.hasAttribute('aria-expanded') || control.hasAttribute('aria-haspopup')) &&
|
|
241
|
+
texts.some(text => openCartLabels.test(text))
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
203
245
|
// Which rule recognised the intent: a vendor's own markup, a phrase in the
|
|
204
|
-
// label, a wallet's bare name, a hosted frame,
|
|
205
|
-
//
|
|
206
|
-
|
|
246
|
+
// label, a wallet's bare name, a hosted frame, a form submitted after a confirm
|
|
247
|
+
// click, a merchant button named after its wallet, or a payment sheet the page
|
|
248
|
+
// opened.
|
|
249
|
+
export type CheckoutIntentSource =
|
|
250
|
+
| 'vendor'
|
|
251
|
+
| 'label'
|
|
252
|
+
| 'brand'
|
|
253
|
+
| 'frame'
|
|
254
|
+
| 'submit'
|
|
255
|
+
| 'name'
|
|
256
|
+
| 'api';
|
|
207
257
|
|
|
208
258
|
export interface ICheckoutIntent {
|
|
209
259
|
kind: Exclude<CheckoutIntentKind, 'frame'>;
|
|
@@ -226,20 +276,34 @@ function isCommitLabel(text: string, isLink: boolean, pathname: string): boolean
|
|
|
226
276
|
// Classifies a clicked or submitting control found by findControl, in order:
|
|
227
277
|
// pickers, toggles and pay-later messages never count; a vendor's own wallet
|
|
228
278
|
// button counts before any label rule; a confirm label on any source counts
|
|
229
|
-
// before the look-alike exclusions veto; then wallet
|
|
279
|
+
// before the look-alike exclusions veto; then a wallet-named control, wallet
|
|
280
|
+
// phrases and steps. An add-to-cart label, links included, or a control that
|
|
281
|
+
// opens the cart on the page counts only where none of those do.
|
|
230
282
|
export function classifyIntent(
|
|
231
283
|
control: Element,
|
|
232
284
|
pathname: string
|
|
233
285
|
): ICheckoutIntent | undefined {
|
|
234
286
|
const vendor = vendorButtonKind(control);
|
|
235
|
-
if (
|
|
287
|
+
if (vendor === 'none') {
|
|
236
288
|
return undefined;
|
|
237
289
|
}
|
|
238
290
|
if (vendor) {
|
|
239
|
-
return
|
|
291
|
+
return isQuietIntentControl(control, vendor === 'wallet')
|
|
292
|
+
? undefined
|
|
293
|
+
: { kind: vendor, source: 'vendor' };
|
|
240
294
|
}
|
|
241
295
|
const texts = labels(control);
|
|
242
296
|
const cores = texts.map(coreLabel);
|
|
297
|
+
const cart: ICheckoutIntent | undefined = isCartControl(control, cores)
|
|
298
|
+
? { kind: 'cart', source: 'label' }
|
|
299
|
+
: undefined;
|
|
300
|
+
if (isQuietIntentControl(control, false, !!cart)) {
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
// A bag button that opens a flyout counts only as a cart click.
|
|
304
|
+
if (cart && control.hasAttribute('aria-expanded')) {
|
|
305
|
+
return cart;
|
|
306
|
+
}
|
|
243
307
|
const isLink = control.matches('a[href]:not([role="button"])');
|
|
244
308
|
if (cores.some(text => isCommitLabel(text, isLink, pathname))) {
|
|
245
309
|
return { kind: 'commit', source: 'label' };
|
|
@@ -249,7 +313,10 @@ export function classifyIntent(
|
|
|
249
313
|
.concat(cores)
|
|
250
314
|
.some(text => englishExclusions.test(text) || otherExclusions.test(text))
|
|
251
315
|
) {
|
|
252
|
-
return
|
|
316
|
+
return cart;
|
|
317
|
+
}
|
|
318
|
+
if (isWalletNamedControl(control)) {
|
|
319
|
+
return { kind: 'wallet', source: 'name' };
|
|
253
320
|
}
|
|
254
321
|
const isButton = !isLink && control.matches(CONTROL_SELECTOR);
|
|
255
322
|
for (const text of cores) {
|
|
@@ -263,7 +330,7 @@ export function classifyIntent(
|
|
|
263
330
|
return { kind: 'checkout_step', source: 'label' };
|
|
264
331
|
}
|
|
265
332
|
}
|
|
266
|
-
return
|
|
333
|
+
return cart;
|
|
267
334
|
}
|
|
268
335
|
|
|
269
336
|
export function classifyIntentControl(
|
|
@@ -283,12 +350,20 @@ function isOnDomain(host: string, domain: string): boolean {
|
|
|
283
350
|
);
|
|
284
351
|
}
|
|
285
352
|
|
|
353
|
+
export interface IPaymentFrame {
|
|
354
|
+
kind: 'wallet' | 'frame';
|
|
355
|
+
family?: PaymentFrameFamily;
|
|
356
|
+
}
|
|
357
|
+
|
|
286
358
|
// 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.
|
|
288
|
-
|
|
359
|
+
// focus into hosted card fields or messages is only a frame. A family comes
|
|
360
|
+
// only from a frame that can draw no other method: PayPal's button frame also
|
|
361
|
+
// draws Pay Later, Venmo and its card form, Stripe's quick-pay frame draws
|
|
362
|
+
// Apple Pay or Google Pay by browser, and its payment form holds several.
|
|
363
|
+
export function classifyPaymentFrame(
|
|
289
364
|
target: EventTarget | null,
|
|
290
365
|
win: Window
|
|
291
|
-
):
|
|
366
|
+
): IPaymentFrame | undefined {
|
|
292
367
|
if (
|
|
293
368
|
win.document.visibilityState !== 'visible' ||
|
|
294
369
|
!(target instanceof HTMLIFrameElement)
|
|
@@ -301,20 +376,39 @@ export function classifyIntentFrame(
|
|
|
301
376
|
) {
|
|
302
377
|
return undefined;
|
|
303
378
|
}
|
|
379
|
+
// A frame that has its own src is judged by that address, never by its name.
|
|
380
|
+
if (
|
|
381
|
+
(!target.src || target.src === 'about:blank') &&
|
|
382
|
+
PAYPAL_SDK_FRAME.test(target.name)
|
|
383
|
+
) {
|
|
384
|
+
return PAYPAL_SDK_BUTTON_FRAME.test(target.name)
|
|
385
|
+
? { kind: 'wallet' }
|
|
386
|
+
: { kind: 'frame' };
|
|
387
|
+
}
|
|
304
388
|
try {
|
|
305
389
|
const url = new URL(target.src);
|
|
306
390
|
const host = url.protocol === 'https:' ? url.hostname : '';
|
|
307
391
|
if (paypalFrameHosts.has(host)) {
|
|
308
|
-
|
|
392
|
+
if (PAYPAL_BUTTON_FRAME.test(url.pathname)) {
|
|
393
|
+
return { kind: 'wallet' };
|
|
394
|
+
}
|
|
395
|
+
return PAYPAL_CARD_FRAME.test(url.pathname)
|
|
396
|
+
? { kind: 'frame', family: 'card' }
|
|
397
|
+
: { kind: 'frame' };
|
|
309
398
|
}
|
|
310
|
-
if (
|
|
311
|
-
return 'wallet';
|
|
399
|
+
if (Object.prototype.hasOwnProperty.call(walletFrameFamilies, host)) {
|
|
400
|
+
return { kind: 'wallet', family: walletFrameFamilies[host] };
|
|
312
401
|
}
|
|
313
402
|
if (host === 'js.stripe.com') {
|
|
314
|
-
|
|
403
|
+
if (STRIPE_WALLET_FRAME.test(url.pathname)) {
|
|
404
|
+
return { kind: 'wallet' };
|
|
405
|
+
}
|
|
406
|
+
return STRIPE_CARD_FRAME.test(url.pathname)
|
|
407
|
+
? { kind: 'frame', family: 'card' }
|
|
408
|
+
: { kind: 'frame' };
|
|
315
409
|
}
|
|
316
410
|
if (CARD_FRAME_DOMAINS.some(domain => isOnDomain(host, domain))) {
|
|
317
|
-
return 'frame';
|
|
411
|
+
return { kind: 'frame' };
|
|
318
412
|
}
|
|
319
413
|
} catch (e) {
|
|
320
414
|
// An unparseable frame src is not a wallet frame.
|