@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,5 @@
1
+ import { BaseEvent } from '../sdkRuntimeModels';
2
+ import { IButtonClick } from './buttonDescription';
3
+ export declare const AUTO_BUTTON_ATTRIBUTE = "is_auto_button_click";
4
+ export declare const BUTTON_CLICK_EVENT_NAME = "Button Click";
5
+ export declare const buildButtonClickEvent: (click: IButtonClick, pathname: string) => BaseEvent;
@@ -0,0 +1,14 @@
1
+ export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet';
2
+ export type ButtonLabelSource = 'labelledby' | 'aria' | 'text' | 'alt' | 'title' | 'value' | 'none';
3
+ export interface IButtonClick {
4
+ label?: string;
5
+ labelSource: ButtonLabelSource;
6
+ ariaLabel?: string;
7
+ icon?: string;
8
+ elementId?: string;
9
+ elementName?: string;
10
+ testId?: string;
11
+ element: ButtonElementKind;
12
+ type: string;
13
+ }
14
+ export declare function describeButtonClick(path: Array<EventTarget | null>): IButtonClick | undefined;
@@ -0,0 +1,36 @@
1
+ import { IMParticleWebSDKInstance } from '../mp-instance';
2
+ export declare const WIN_BTN_KEY = "__mpBtn__";
3
+ export interface IButtonTrackerOptions {
4
+ hasRoktKit: boolean;
5
+ }
6
+ export declare const getActiveButtonTracker: () => ButtonTracker | undefined;
7
+ export declare const resetButtonTracking: () => void;
8
+ export declare class ButtonTracker {
9
+ private readonly mpInstance;
10
+ private active;
11
+ private hasRoktKit;
12
+ private generation;
13
+ private lastCommitClick;
14
+ private lastSent;
15
+ private pagePath;
16
+ private pageEvents;
17
+ private lastEvent;
18
+ private pending;
19
+ constructor(mpInstance: IMParticleWebSDKInstance);
20
+ get isActive(): boolean;
21
+ init(options: IButtonTrackerOptions): void;
22
+ teardown(): void;
23
+ clearPending(): void;
24
+ private reset;
25
+ private enqueue;
26
+ private readonly drain;
27
+ private readonly onHide;
28
+ private readonly onPageShow;
29
+ private readonly onEvent;
30
+ private observe;
31
+ private logClick;
32
+ private emit;
33
+ private eventsEnabled;
34
+ private isKitSuppressed;
35
+ private log;
36
+ }
@@ -0,0 +1,3 @@
1
+ export type CheckoutIntentKind = 'commit' | 'wallet' | 'checkout_step' | 'frame';
2
+ export declare function classifyIntentControl(path: Array<EventTarget | null>, pathname: string): CheckoutIntentKind | undefined;
3
+ export declare function classifyIntentFrame(target: EventTarget | null, win: Window): 'frame' | undefined;
@@ -0,0 +1,5 @@
1
+ export declare const CONTROL_SELECTOR = "button, input[type=\"submit\"], input[type=\"button\"], input[type=\"image\"], [role=\"button\"]";
2
+ export declare function hasComposedAncestor(element: Element, selector: string): boolean;
3
+ export declare function isRoktElement(element: Element): boolean;
4
+ export declare function isWalletElement(element: Element): boolean;
5
+ export declare function findControl(path: Array<EventTarget | null>): Element | undefined;
@@ -0,0 +1 @@
1
+ export declare function maskLabel(raw: string | null | undefined): string | undefined;
@@ -162,6 +162,7 @@ declare const Constants: {
162
162
  readonly CaptureIntegrationSpecificIdsV2: "captureIntegrationSpecificIds.V2";
163
163
  readonly AstBackgroundEvents: "astBackgroundEvents";
164
164
  readonly AutoLogPageView: "autoLogPageView";
165
+ readonly AutoButtonTracking: "autoButtonTracking";
165
166
  };
166
167
  readonly DefaultInstance: "default_instance";
167
168
  readonly CCPAPurpose: "data_sale_opt_out";
@@ -14,7 +14,7 @@
14
14
  */
15
15
  export * from './public-types';
16
16
  export type { KitInterface, } from './forwarders.interfaces';
17
- export type { RoktAttributeValue, RoktAttributeValueType, RoktAttributeValueArray, RoktAttributes, IRoktPartnerExtensionData, IRoktSelectPlacementsOptions, IRoktSelection, IRoktLauncher, IRoktMessage, RoktKitFilterSettings, IRoktKitSettings, IRoktKit, IRoktOptions, IRoktLauncherOptions, } from './roktManager';
17
+ export type { RoktAttributeValue, RoktAttributeValueType, RoktAttributeValueArray, RoktAttributes, IRoktPartnerExtensionData, IRoktSelectPlacementsOptions, IRoktSelection, IRoktLauncher, IRoktMessage, RoktKitFilterSettings, IRoktKitSettings, IRoktKit, ICheckoutIntentSignal, IRoktOptions, IRoktLauncherOptions, } from './roktManager';
18
18
  export type { ErrorCodes, WSDKErrorSeverity, } from './reporting/types';
19
19
  export type { ISDKError, ISDKLogEntry, IErrorReportingService, ILoggingService, } from './reporting/types';
20
20
  export type { SDKEvent, SDKProductAction, SDKProductActionType, SDKPromotionAction, SDKGeoLocation, SDKDataPlan, SDKLoggerApi, SDKConfigApi, IMParticleInstanceManager, } from './sdkRuntimeModels';
@@ -22,6 +22,7 @@ import { ICookieConsentManager } from './cookieConsentManager';
22
22
  import { ErrorReportingDispatcher } from './reporting/errorReportingDispatcher';
23
23
  import { LoggingDispatcher } from './reporting/loggingDispatcher';
24
24
  import { PageViewTracker } from './pageViewTracker';
25
+ import { ButtonTracker } from './buttonTracking/buttonTracker';
25
26
  export interface IErrorLogMessage {
26
27
  message?: string;
27
28
  name?: string;
@@ -47,6 +48,7 @@ export interface IMParticleWebSDKInstance extends MParticleWebSDK {
47
48
  _IntegrationCapture: IntegrationCapture;
48
49
  _NativeSdkHelpers: INativeSdkHelpers;
49
50
  _PageViewTracker?: PageViewTracker;
51
+ _ButtonTracker?: ButtonTracker;
50
52
  _Persistence: IPersistence;
51
53
  _CookieConsentManager: ICookieConsentManager;
52
54
  _ErrorReportingDispatcher: ErrorReportingDispatcher;
@@ -8,6 +8,7 @@ import { IStore, LocalSessionAttributes } from "./store";
8
8
  import { IErrorReportingService, ILoggingService } from "./reporting/types";
9
9
  import { IRoktLauncherOptions } from "./roktLauncherOptions";
10
10
  import IntegrationCapture from "./integrationCapture";
11
+ import type { CheckoutIntentKind } from "./buttonTracking/checkoutIntent";
11
12
  export type RoktAttributeValueArray = Array<string | number | boolean>;
12
13
  export type RoktAttributeValueType = string | number | boolean | undefined | null;
13
14
  export type RoktAttributeValue = RoktAttributeValueType | RoktAttributeValueArray;
@@ -42,6 +43,10 @@ export interface RoktKitFilterSettings {
42
43
  filterUserAttributes?: (userAttributes: Dictionary<string>, filterList: number[]) => Dictionary<string>;
43
44
  filteredUser?: IMParticleUser | null;
44
45
  }
46
+ export interface ICheckoutIntentSignal {
47
+ kind: CheckoutIntentKind;
48
+ t: number;
49
+ }
45
50
  export interface IRoktKitSettings {
46
51
  accountId?: string;
47
52
  placementAttributesMapping?: string;
@@ -59,6 +64,7 @@ export interface IRoktKit {
59
64
  onShoppableAdsReady(callback: () => void): void;
60
65
  terminate?: () => Promise<void>;
61
66
  onRouteChange?: () => void;
67
+ onCheckoutIntent?: (signal: ICheckoutIntentSignal) => void;
62
68
  launcherOptions?: Dictionary<any>;
63
69
  settings?: IRoktKitSettings;
64
70
  integrationName?: string;
@@ -91,6 +97,7 @@ export default class RoktManager {
91
97
  private initialized;
92
98
  private isShoppableAdsLoaded;
93
99
  private stopRouteChangeWatch;
100
+ private pendingCheckoutIntents;
94
101
  constructor(instanceName?: string);
95
102
  /**
96
103
  * Sets a callback to be invoked when RoktManager becomes ready
@@ -115,6 +122,10 @@ export default class RoktManager {
115
122
  attachKit(kit: IRoktKit): void;
116
123
  private watchRouteChanges;
117
124
  private notifyRouteChange;
125
+ isCheckoutIntentSuppressed(): boolean;
126
+ notifyCheckoutIntent(signal: ICheckoutIntentSignal, pathname?: string): void;
127
+ discardCheckoutIntents(): void;
128
+ private flushCheckoutIntents;
118
129
  private isAutoLogPageViewEnabled;
119
130
  /**
120
131
  * Renders ads based on the options provided
@@ -79,6 +79,7 @@ export interface IFeatureFlags {
79
79
  'captureIntegrationSpecificIds.V2'?: string;
80
80
  astBackgroundEvents?: boolean;
81
81
  autoLogPageView?: boolean;
82
+ autoButtonTracking?: boolean;
82
83
  }
83
84
  export interface IStore {
84
85
  isEnabled: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mparticle/web-sdk",
3
- "version": "3.16.1",
3
+ "version": "3.18.0",
4
4
  "description": "mParticle core SDK for web applications",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -32,11 +32,11 @@
32
32
  },
33
33
  "repository": "https://github.com/mParticle/mparticle-web-sdk",
34
34
  "scripts": {
35
- "build": "cross-env ENVIRONMENT=prod BUILDALL=true rollup --config rollup.config.js && npm run build:types",
35
+ "build": "cross-env ENVIRONMENT=prod BUILDALL=true rollup --config rollup.config.js && npm run check:ascii:dist && npm run build:types",
36
36
  "build:types": "rm -rf dist/types && (tsc -p tsconfig.types.json || true) && test -f dist/types/src/public-types.d.ts && test -f dist/types/src/internal-types.d.ts",
37
37
  "build:cbt": "rollup --config test/cross-browser-testing/rollup.CBT.config.js",
38
38
  "build:dev": "cross-env ENVIRONMENT=dev BUILD=iife rollup --config rollup.config.js",
39
- "build:iife": "cross-env ENVIRONMENT=prod BUILD=iife rollup --config rollup.config.js",
39
+ "build:iife": "cross-env ENVIRONMENT=prod BUILD=iife rollup --config rollup.config.js && npm run check:ascii:dist -- dist/mparticle.js",
40
40
  "build:npm": "cross-env ENVIRONMENT=prod BUILD=cjs rollup --config rollup.config.js",
41
41
  "build:esm": "cross-env ENVIRONMENT=prod BUILD=esm rollup --config rollup.config.js",
42
42
  "build:stub": "cross-env ENVIRONMENT=prod BUILD=stub rollup --config rollup.config.js",
@@ -93,6 +93,7 @@
93
93
  "gzip": "gzip -c dist/mparticle.min.js > dist/mparticle.min.js.gz",
94
94
  "bundle": "npm run uglify && npm run gzip",
95
95
  "report:bundled": "ls -l dist/mparticle.min.js.gz | awk '{print $5}'",
96
+ "check:ascii:dist": "node scripts/check-ascii-dist.js",
96
97
  "report:bundled:human": "ls -lh dist/mparticle.min.js.gz | awk '{print $5}'"
97
98
  },
98
99
  "pre-commit": [
@@ -0,0 +1,49 @@
1
+ import { BaseEvent } from '../sdkRuntimeModels';
2
+ import { EventType, MessageType } from '../types';
3
+ import { IButtonClick } from './buttonDescription';
4
+
5
+ // Marks an automatically logged button click, like `is_auto_page_view`.
6
+ export const AUTO_BUTTON_ATTRIBUTE = 'is_auto_button_click';
7
+
8
+ export const BUTTON_CLICK_EVENT_NAME = 'Button Click';
9
+
10
+ // One custom event per click, logged like logLink() with the auto marker and the
11
+ // page fields that auto page views carry. No intent: classifying clicks is left
12
+ // to whoever reads the data.
13
+ export const buildButtonClickEvent = (
14
+ click: IButtonClick,
15
+ pathname: string
16
+ ): BaseEvent => {
17
+ const data: { [key: string]: string | boolean } = {
18
+ label_source: click.labelSource,
19
+ element: click.element,
20
+ type: click.type,
21
+ path: pathname,
22
+ hostname: window.location.hostname,
23
+ [AUTO_BUTTON_ATTRIBUTE]: true,
24
+ };
25
+ if (click.label) {
26
+ data.label = click.label;
27
+ }
28
+ if (click.ariaLabel) {
29
+ data.aria_label = click.ariaLabel;
30
+ }
31
+ if (click.icon) {
32
+ data.icon = click.icon;
33
+ }
34
+ if (click.elementId) {
35
+ data.element_id = click.elementId;
36
+ }
37
+ if (click.elementName) {
38
+ data.element_name = click.elementName;
39
+ }
40
+ if (click.testId) {
41
+ data.test_id = click.testId;
42
+ }
43
+ return {
44
+ messageType: MessageType.PageEvent,
45
+ name: BUTTON_CLICK_EVENT_NAME,
46
+ data,
47
+ eventType: EventType.Other,
48
+ };
49
+ };
@@ -0,0 +1,244 @@
1
+ import { isFunction } from '../utils';
2
+ import { findControl, hasComposedAncestor, isWalletElement } from './controls';
3
+ import { maskLabel } from './labelMasking';
4
+
5
+ // Describes a clicked button for auto button tracking: its masked name, icon,
6
+ // hand-written identifiers and kind. Never reads form field values.
7
+ export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet';
8
+
9
+ export type ButtonLabelSource =
10
+ | 'labelledby'
11
+ | 'aria'
12
+ | 'text'
13
+ | 'alt'
14
+ | 'title'
15
+ | 'value'
16
+ | 'none';
17
+
18
+ export interface IButtonClick {
19
+ label?: string;
20
+ labelSource: ButtonLabelSource;
21
+ ariaLabel?: string;
22
+ icon?: string;
23
+ elementId?: string;
24
+ elementName?: string;
25
+ testId?: string;
26
+ element: ButtonElementKind;
27
+ type: string;
28
+ }
29
+
30
+ function elementKind(control: Element): ButtonElementKind {
31
+ if (isWalletElement(control)) {
32
+ return 'wallet';
33
+ }
34
+ const tag = control.tagName.toLowerCase();
35
+ return tag === 'button' || tag === 'input' ? tag : 'role';
36
+ }
37
+
38
+ const EDITABLE = '[contenteditable]:not([contenteditable="false"])';
39
+
40
+ // Text that may hold what a shopper typed or what the page opted out: form
41
+ // fields, editable regions, and anything under data-mp-no-track.
42
+ function isUnsafeTextSource(element: Element): boolean {
43
+ return (
44
+ element.matches(
45
+ 'input, select, textarea, option, optgroup, datalist, ' + EDITABLE
46
+ ) ||
47
+ !!element.querySelector(
48
+ 'input, select, textarea, [data-mp-no-track], ' + EDITABLE
49
+ ) ||
50
+ hasComposedAncestor(
51
+ element,
52
+ 'select, datalist, textarea, [data-mp-no-track], ' + EDITABLE
53
+ )
54
+ );
55
+ }
56
+
57
+ function textOf(element: Element | null): string {
58
+ return element && !isUnsafeTextSource(element) && element.textContent
59
+ ? element.textContent.trim()
60
+ : '';
61
+ }
62
+
63
+ function nonBlank(value: string | null): string | undefined {
64
+ return value && value.trim() ? value : undefined;
65
+ }
66
+
67
+ // The button's name as a screen reader would read it, simplified: the first of
68
+ // aria-labelledby, aria-label, own text (or an inner image's alt), title, and for
69
+ // inputs their value. The text sources are skipped for controls that hold form
70
+ // fields or more than four children, so a typed value is never read.
71
+ function accessibleName(
72
+ control: Element
73
+ ): { raw?: string; source: ButtonLabelSource } {
74
+ const labelledBy = control.getAttribute('aria-labelledby');
75
+ if (labelledBy) {
76
+ const root = (isFunction(control.getRootNode)
77
+ ? control.getRootNode()
78
+ : document) as Document | ShadowRoot;
79
+ const text = labelledBy
80
+ .split(/\s+/)
81
+ .map(id =>
82
+ textOf(root.getElementById ? root.getElementById(id) : null)
83
+ )
84
+ .filter(part => !!part)
85
+ .join(' ');
86
+ if (text) {
87
+ return { raw: text, source: 'labelledby' };
88
+ }
89
+ }
90
+ const aria = nonBlank(control.getAttribute('aria-label'));
91
+ if (aria) {
92
+ return { raw: aria, source: 'aria' };
93
+ }
94
+ const readable =
95
+ control.children.length <= 4 && !isUnsafeTextSource(control);
96
+ if (control instanceof HTMLInputElement) {
97
+ // The browser's normalised type: an unknown value reads as 'text'.
98
+ const type = control.type;
99
+ if (type === 'image') {
100
+ const alt = nonBlank(control.getAttribute('alt'));
101
+ if (alt) {
102
+ return { raw: alt, source: 'alt' };
103
+ }
104
+ } else if (type === 'submit' || type === 'button' || type === 'reset') {
105
+ const value = nonBlank(control.value);
106
+ if (value) {
107
+ return { raw: value, source: 'value' };
108
+ }
109
+ if (type !== 'button') {
110
+ // What the browser shows on an input without a value.
111
+ return { raw: type, source: 'value' };
112
+ }
113
+ }
114
+ } else if (readable) {
115
+ const text = textOf(control);
116
+ if (text) {
117
+ return { raw: text, source: 'text' };
118
+ }
119
+ const image = control.querySelector('img[alt]');
120
+ const alt = image ? nonBlank(image.getAttribute('alt')) : undefined;
121
+ if (alt) {
122
+ return { raw: alt, source: 'alt' };
123
+ }
124
+ }
125
+ const title = nonBlank(control.getAttribute('title'));
126
+ if (title) {
127
+ return { raw: title, source: 'title' };
128
+ }
129
+ return { source: 'none' };
130
+ }
131
+
132
+ const ICON_CLASS = /^(?:fa|fas|far|fal|icon|bi|glyphicon|ti|mdi|ri|la|lucide|ion|feather|ph)-([a-z][a-z-]{1,31})$/;
133
+ // Size, style and animation classes that share the icon prefixes.
134
+ const ICON_MODIFIER = /^(?:solid|regular|light|thin|duotone|brands|sharp|fw|lg|xs|sm|xl|spin|pulse|border|inverse|stack|flip(?:-[a-z]+)?|rotate(?:-[a-z]+)?|pull-[a-z]+)$/;
135
+
136
+ function iconToken(candidate: string | null): string | undefined {
137
+ if (!candidate) {
138
+ return undefined;
139
+ }
140
+ const token = candidate.toLowerCase().replace(/^icon-/, '');
141
+ return /^[a-z][a-z-]{1,31}$/.test(token) ? token : undefined;
142
+ }
143
+
144
+ // An icon name from an SVG sprite reference or an icon-font class, for buttons
145
+ // whose only content is an icon. Letters and hyphens only.
146
+ function iconHint(control: Element): string | undefined {
147
+ const allowed = (element: Element) =>
148
+ !hasComposedAncestor(element, '[data-mp-no-track]');
149
+ const use = Array.from(control.querySelectorAll('use')).filter(allowed)[0];
150
+ if (use) {
151
+ const href =
152
+ use.getAttribute('href') || use.getAttribute('xlink:href') || '';
153
+ const hash = href.indexOf('#');
154
+ const sprite = iconToken(hash === -1 ? null : href.slice(hash + 1));
155
+ if (sprite) {
156
+ return sprite;
157
+ }
158
+ }
159
+ const candidates = [control]
160
+ .concat(Array.from(control.querySelectorAll('i, span, svg')))
161
+ .filter(allowed)
162
+ .slice(0, 5);
163
+ for (const element of candidates) {
164
+ const classes = (element.getAttribute('class') || '').split(/\s+/);
165
+ for (const name of classes) {
166
+ const match = ICON_CLASS.exec(name.toLowerCase());
167
+ if (match && !ICON_MODIFIER.test(match[1])) {
168
+ return match[1];
169
+ }
170
+ }
171
+ }
172
+ return undefined;
173
+ }
174
+
175
+ // Words of letters, each optionally ending in up to three digits, joined by
176
+ // hyphens or underscores: `add-to-cart`, `btnCheckout`, `step_2`. Generated ids
177
+ // (`ember1234`, `a8f3e2c1`, `radix-:r1:`) and long numbers don't match.
178
+ const HAND_WRITTEN_ID = /^[A-Za-z]+[0-9]{0,3}(?:[-_](?:[A-Za-z]+[0-9]{0,3}|[0-9]{1,3}))*$/;
179
+
180
+ // A developer-written identifier, or nothing.
181
+ function stableIdentifier(value: string | null): string | undefined {
182
+ if (!value) {
183
+ return undefined;
184
+ }
185
+ const id = value.trim();
186
+ // At most three digits in all, so numbers split into groups don't pass.
187
+ const digits = id.replace(/[^0-9]/g, '').length;
188
+ return id.length <= 64 && digits <= 3 && HAND_WRITTEN_ID.test(id)
189
+ ? id
190
+ : undefined;
191
+ }
192
+
193
+ const TEST_ID_ATTRIBUTES = [
194
+ 'data-testid',
195
+ 'data-test-id',
196
+ 'data-test',
197
+ 'data-qa',
198
+ 'data-cy',
199
+ ];
200
+
201
+ function testIdentifier(control: Element): string | undefined {
202
+ for (const attribute of TEST_ID_ATTRIBUTES) {
203
+ const id = stableIdentifier(control.getAttribute(attribute));
204
+ if (id) {
205
+ return id;
206
+ }
207
+ }
208
+ return undefined;
209
+ }
210
+
211
+ // The browser's normalised type: a button defaults to 'submit' and an unknown
212
+ // input type reads as 'text'. Wallet and role buttons are just 'button'.
213
+ function controlType(control: Element, element: ButtonElementKind): string {
214
+ return element === 'button' || element === 'input'
215
+ ? (control as HTMLButtonElement | HTMLInputElement).type
216
+ : 'button';
217
+ }
218
+
219
+ // Describes a clicked button for auto button tracking. Links are left to page
220
+ // views, so a link click is not described.
221
+ export function describeButtonClick(
222
+ path: Array<EventTarget | null>
223
+ ): IButtonClick | undefined {
224
+ const control = findControl(path);
225
+ if (!control || control.matches('a[href]:not([role="button"])')) {
226
+ return undefined;
227
+ }
228
+ const element = elementKind(control);
229
+ const name = accessibleName(control);
230
+ const label = maskLabel(name.raw);
231
+ return {
232
+ label,
233
+ labelSource: label ? name.source : 'none',
234
+ // Kept on its own as well: it often says more than the visible text,
235
+ // such as which item a remove button removes.
236
+ ariaLabel: maskLabel(control.getAttribute('aria-label')),
237
+ icon: iconHint(control),
238
+ elementId: stableIdentifier(control.getAttribute('id')),
239
+ elementName: stableIdentifier(control.getAttribute('name')),
240
+ testId: testIdentifier(control),
241
+ element,
242
+ type: controlType(control, element),
243
+ };
244
+ }