@sprig-technologies/sprig-browser 2.14.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/index.js ADDED
@@ -0,0 +1,207 @@
1
+ /* global window */
2
+
3
+ import { INSTALLATION_METHOD } from './shared/constants';
4
+ class SprigAPI {
5
+ /**
6
+ * Triggers displaying specified survey. Does submit answers!
7
+ * @param {Number} surveyId
8
+ * @returns
9
+ */
10
+ displaySurvey(surveyId) {
11
+ window.Sprig('displaySurvey', surveyId);
12
+ }
13
+
14
+ /**
15
+ * Triggers displaying specified survey template. Does not submit answers
16
+ * @param {String} surveyTemplateId
17
+ */
18
+ previewSurvey(surveyTemplateId) {
19
+ window.Sprig('previewSurvey', surveyTemplateId);
20
+ }
21
+
22
+ /**
23
+ * Triggers displaying specified survey. Does not submit answers
24
+ * @param {Number} surveyId
25
+ */
26
+ reviewSurvey(surveyId) {
27
+ window.Sprig('reviewSurvey', surveyId);
28
+ }
29
+
30
+ /**
31
+ * pauses api interactions
32
+ */
33
+ mute() {
34
+ window.Sprig('mute');
35
+ }
36
+
37
+ /**
38
+ * restart api interactions
39
+ */
40
+ unmute() {
41
+ window.Sprig('unmute');
42
+ }
43
+
44
+ /**
45
+ * Manually dismiss an opened survey
46
+ */
47
+ dismissActiveSurvey() {
48
+ window.Sprig('dismissActiveSurvey');
49
+ }
50
+
51
+ /**
52
+ * Set an arbitrary attribute on the visitor
53
+ * @param {String} attribute
54
+ * @param {String} value
55
+ */
56
+ setAttribute(attribute, value) {
57
+ window.Sprig('setAttribute', attribute, value);
58
+ }
59
+
60
+ /**
61
+ * Set attributes on visitor
62
+ * @param {Object} attributes
63
+ */
64
+ setAttributes(attributes) {
65
+ window.Sprig('setAttributes', attributes);
66
+ }
67
+
68
+ /**
69
+ * Set identifiers and attributes on visitor
70
+ * @param {Object} payload
71
+ * @param {Object} payload.attributes visitor attributes to set
72
+ * @param {String} payload.userId userId (optional)
73
+ * @param {String} payload.anonymousId anonymousId (optional)
74
+ */
75
+ identifyAndSetAttributes(payload) {
76
+ window.Sprig('identifyAndSetAttributes', payload);
77
+ }
78
+
79
+ /**
80
+ * Remove attributes on visitor
81
+ * @param {Object} attributes
82
+ */
83
+ removeAttributes(attributes) {
84
+ window.Sprig('removeAttributes', attributes);
85
+ }
86
+
87
+ /**
88
+ * Add a listener for an event defined in ulEvents
89
+ * @param {String} event
90
+ * @param {Function} listener
91
+ */
92
+ addListener(event, listener) {
93
+ window.Sprig('addListener', event, listener);
94
+ }
95
+
96
+ /**
97
+ * Remove a listener for an event defined in ulEvents
98
+ * @param {String} event
99
+ * @param {Function} listener
100
+ */
101
+ removeListener(event, listener) {
102
+ window.Sprig('removeListener', event, listener);
103
+ }
104
+
105
+ /**
106
+ * Remove all listeners set on Sprig
107
+ */
108
+ removeAllListeners() {
109
+ window.Sprig('removeAllListeners');
110
+ }
111
+
112
+ /**
113
+ * Attach an email address to visitor
114
+ */
115
+ setEmail(email) {
116
+ window.Sprig('setAttribute', '!email', email);
117
+ }
118
+
119
+ /**
120
+ * Attach a user id to the visitor
121
+ */
122
+ setUserId(userId) {
123
+ window.Sprig('setUserId', userId);
124
+ }
125
+
126
+ /**
127
+ * Set a partner anonymous id for future requests.
128
+ */
129
+ setPartnerAnonymousId(partnerAnonymousId) {
130
+ window.Sprig('setPartnerAnonymousId', partnerAnonymousId);
131
+ }
132
+
133
+ /**
134
+ * track an event to show survey if eligible
135
+ * @param {String} eventName
136
+ * @param {Object} metadata
137
+ * @returns
138
+ */
139
+ track(eventName, metadata = {}) {
140
+ window.Sprig('track', eventName, metadata);
141
+ }
142
+
143
+ /**
144
+ * optionally set userId and/or anonymousId, track an event to show survey if eligible
145
+ * @param {Object} payload
146
+ * @param {String} payload.eventName name of event to track
147
+ * @param {Object} payload.metadata event metadata (optional)
148
+ * @param {String} payload.userId userId (optional)
149
+ * @param {String} payload.anonymousId anonymousId (optional)
150
+ * @returns
151
+ */
152
+ identifyAndTrack(payload) {
153
+ window.Sprig('identifyAndTrack', payload);
154
+ }
155
+
156
+ /**
157
+ * @param {String} styleString css string representing the customized styles
158
+ */
159
+ applyStyles(styleString) {
160
+ window.Sprig('applyStyles', styleString);
161
+ }
162
+
163
+ /**
164
+ set viewport dimensions, in int pixels. necessary if Sprig is installed in an iframe/component defaulting to 0 width and height.
165
+ * @param {Number} width
166
+ * @param {Number} height
167
+ */
168
+ setWindowDimensions(width, height) {
169
+ window.Sprig('setWindowDimensions', width, height);
170
+ }
171
+
172
+ /**
173
+ * clears Sprig from window
174
+ */
175
+ teardown() {
176
+ window.Sprig('teardown');
177
+ }
178
+ }
179
+
180
+ export default {
181
+ /**
182
+ * Sets up the sprig api and load the sprig sdk on document load
183
+ * @param {Object} config
184
+ * @returns {SprigAPI} an instance of the sprig api
185
+ */
186
+ configure: (config) => {
187
+ if (!config.envId && !config.environmentId) {
188
+ throw new Error('Initialization Error: Sprig configure requires an environmentId');
189
+ }
190
+ if (!config.envId) config.envId = config.environmentId; // backwards compatible setting for environment id
191
+ config.installationMethod = INSTALLATION_METHOD.NPM;
192
+ if (window.Sprig) return window.Sprig;
193
+ window.Sprig = function() {
194
+ window.Sprig._queue.push(arguments);
195
+ };
196
+ Object.getOwnPropertyNames(SprigAPI.prototype).map((apiMethodName) => {
197
+ if (apiMethodName !== 'constructor') window.Sprig[apiMethodName] = SprigAPI.prototype[apiMethodName];
198
+ });
199
+ const S = window.Sprig;
200
+ S.appId = config.envId;
201
+ S._queue = [];
202
+ window.UserLeap = S;
203
+ const sprigInitializer = require('./controller/controller').default;
204
+ sprigInitializer(config);
205
+ return window.Sprig;
206
+ },
207
+ };
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@sprig-technologies/sprig-browser",
3
+ "version": "2.14.0",
4
+ "description": "npm package for the sprig web sdk",
5
+ "browser": "index.js",
6
+ "scripts": {
7
+ "test": "test"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/UserLeap/userleap-web-sdk.git"
12
+ },
13
+ "keywords": [
14
+ "product",
15
+ "user-research"
16
+ ],
17
+ "bugs": {
18
+ "url": "https://github.com/UserLeap/userleap-web-sdk/issues"
19
+ },
20
+ "homepage": "https://docs.sprig.com/docs/web-javascript",
21
+ "license": "See LICENSE file",
22
+ "dependencies": {
23
+ "autosize": "4.0.2",
24
+ "color": "3.1.2",
25
+ "core-js": "3.5.0",
26
+ "whatwg-fetch": "3.0.0",
27
+ "uuid": "8.3.2"
28
+ },
29
+ "browserslist": [
30
+ ">0.2%",
31
+ "not dead",
32
+ "not ie <= 11",
33
+ "not op_mini all"
34
+ ]
35
+ }
@@ -0,0 +1,13 @@
1
+ import * as Intercom from './intercom';
2
+
3
+ const widgets = [Intercom];
4
+
5
+ export default class ConflictingWidgets {
6
+ static disable() {
7
+ widgets.forEach((w) => w.disable());
8
+ }
9
+
10
+ static enable() {
11
+ widgets.forEach((w) => w.enable());
12
+ }
13
+ }
@@ -0,0 +1,28 @@
1
+ /* global window, document */
2
+ const getIntercom = () => {
3
+ try {
4
+ return window.parent.Intercom;
5
+ } catch (err) {}
6
+ };
7
+
8
+ export const enable = () => {
9
+ const Intercom = getIntercom();
10
+ if (!Intercom) return;
11
+
12
+ if (Intercom.ul_wasVisible) {
13
+ Intercom('update', { hide_default_launcher: false });
14
+ }
15
+
16
+ delete Intercom.ul_wasVisible;
17
+ };
18
+
19
+ export const disable = () => {
20
+ const Intercom = getIntercom();
21
+ if (!Intercom) return;
22
+
23
+ Intercom.ul_wasVisible = !!document.querySelector('iframe.intercom-launcher-frame');
24
+
25
+ if (Intercom.ul_wasVisible) {
26
+ Intercom('update', { hide_default_launcher: true });
27
+ }
28
+ };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Various modes that the app can be in. For now test is the
3
+ * only one that matters
4
+ * @type {Object}
5
+ */
6
+ export const APP_MODES = {
7
+ test: 'test',
8
+ };
9
+
10
+ export const PAGE_URL_EVENT_NAME = 'pageUrl';
11
+
12
+ export const HEADERS = {
13
+ ENVIRONMENT_ID: 'x-ul-environment-id',
14
+ PARTNER_ANONYMOUS_ID: 'x-ul-anonymous-id',
15
+ USER_ID: 'x-ul-user-id',
16
+ VISITOR_ID: 'x-ul-visitor-id',
17
+ INSTALLATION_METHOD: 'x-ul-installation-method',
18
+ };
19
+
20
+ export const INSTALLATION_METHOD = {
21
+ NPM: 'web-npm',
22
+ GTM: 'web-gtm',
23
+ SEGMENT: 'web-segment',
24
+ SNIPPET: 'web-snippet',
25
+ };
26
+
27
+ export const CSS_CONSTANTS = {
28
+ CUSTOM_STYLE_TAG_ID: 'ul-custom-style',
29
+ CARD_CONTAINER_CLASS: 'ul-card__container',
30
+ VIDEO_CARD_CLASS: 'ul-card--video',
31
+ CLOSE_CONTAINER_CLASS: 'close-container',
32
+ CLOSE_BUTTON_CLASS: 'close-btn',
33
+ LIKERT_NUMBER_CLASS: 'likert-number',
34
+ NPS_NUMBER_CLASS: 'nps-number',
35
+ CHOICE_CLASS: 'choice',
36
+ CHOICE_LABEL_CLASS: 'select-label',
37
+ CHOICE_CHECKBOX_CLASS: 'select-checkbox',
38
+ CHOICE_RADIO_CLASS: 'select-radio',
39
+ CHOICE_GROUP_CLASS: 'ul-card__choices',
40
+ OPEN_TEXT_INPUT: 'ul-card-text__input',
41
+ DESKTOP_SUFFIX: '--desktop',
42
+ MOBILE_SUFFIX: '--mobile',
43
+ QUESTION_HEADER_CLASS: 'ul-question',
44
+ CAPTION_CLASS: 'ul-caption',
45
+ };
46
+
47
+ export const EXPERIMENT_FLAGS = {
48
+ SURVEY_MOBILE_STYLING: 'survey-mobile-styling',
49
+ };
50
+
51
+ export const MOBILE_MAX_WIDTH = 500;
52
+
53
+ export const getClasses = (baseClass, useMobileStyling) => {
54
+ const suffix = useMobileStyling ? CSS_CONSTANTS.MOBILE_SUFFIX : CSS_CONSTANTS.DESKTOP_SUFFIX;
55
+
56
+ return [baseClass + suffix, baseClass];
57
+ };
58
+
59
+ export const MOBILE_PLATFORM_HEADERS = ['ios', 'android'];
@@ -0,0 +1,15 @@
1
+ class Deferred {
2
+ constructor(payload) {
3
+ this.promise = new Promise((resolve, reject) => {
4
+ this.payload = payload;
5
+ this.reject = reject;
6
+ this.resolve = resolve;
7
+ });
8
+ }
9
+
10
+ resolveRequest(result) {
11
+ this.resolve(result);
12
+ }
13
+ }
14
+
15
+ export default Deferred;
@@ -0,0 +1,52 @@
1
+ /*global UserLeap*/
2
+ // singleton event emitter instance for UserLeap web events
3
+ // provides basic functionality such as subcribing to and emitting events
4
+ class ULEventEmitter {
5
+ constructor() {
6
+ if (!ULEventEmitter.instance) {
7
+ this._events = {};
8
+ ULEventEmitter.instance = this;
9
+ }
10
+
11
+ return ULEventEmitter.instance;
12
+ }
13
+
14
+ subscribe(name, listener) {
15
+ if (!name || !listener) return;
16
+ if (!this._events[name]) this._events[name] = [];
17
+ this._events[name].push(listener);
18
+ }
19
+
20
+ removeListener(name, listenerToRemove) {
21
+ if (!name || !listenerToRemove) return;
22
+ if (!this._events[name]) {
23
+ if (UserLeap.debugMode) console.log(`[DEBUG] ULEventEmitter: Can't remove a listener. Event "${name}" doesn't exist.`);
24
+ return;
25
+ }
26
+ const filterListeners = (listener) => listener !== listenerToRemove;
27
+ this._events[name] = this._events[name].filter(filterListeners);
28
+ }
29
+ removeAllListeners() {
30
+ Object.keys(this._events).map((key) => {
31
+ this._events[key] = [];
32
+ });
33
+ }
34
+
35
+ emit(name, data) {
36
+ if (!this._events[name] || this._events[name].length == 0) {
37
+ if (UserLeap.debugMode) console.log(`[DEBUG] ULEventEmitter: No listener registered for event "${name}".`, data);
38
+ return;
39
+ }
40
+
41
+ const fireCallbacks = (callback) => {
42
+ callback(data);
43
+ };
44
+
45
+ this._events[name].forEach(fireCallbacks);
46
+ }
47
+ }
48
+
49
+ const instance = new ULEventEmitter();
50
+ Object.freeze(instance);
51
+
52
+ export default instance;
@@ -0,0 +1,130 @@
1
+ /*global fetch */
2
+ import 'whatwg-fetch';
3
+ import { HEADERS, INSTALLATION_METHOD } from './constants';
4
+ import Deferred from './deferred';
5
+ const { delay, NETWORK_CONFIG } = require('./networkHelper');
6
+
7
+ let killswitch = false;
8
+ let killswitchReason = '';
9
+ let isRateLimited = false;
10
+ let pendingRequestQueue = [];
11
+
12
+ function getInstallationMethodHeader(Sprig) {
13
+ if (Sprig._config && Sprig._config.installationMethod) return Sprig._config.installationMethod;
14
+ if (Sprig._gtm) return INSTALLATION_METHOD.GTM;
15
+ if (Sprig._segment) return INSTALLATION_METHOD.SEGMENT;
16
+ return INSTALLATION_METHOD.SNIPPET;
17
+ }
18
+
19
+ export function killNetworkRequests(reason) {
20
+ killswitch = true;
21
+ killswitchReason = reason;
22
+ }
23
+
24
+ export function getHttpHeaders(Sprig = {}) {
25
+ const headers = {
26
+ 'Content-Type': 'application/json',
27
+ 'userleap-platform': 'web',
28
+ 'x-ul-sdk-version': '2.14.0', //version here doesn't matter. it is string-replaced when compiled
29
+ };
30
+ if (Sprig.envId) headers[HEADERS.ENVIRONMENT_ID] = Sprig.envId;
31
+ if (Sprig.token) headers['Authorization'] = 'Bearer ' + Sprig.token;
32
+ if (Sprig.userId) headers[HEADERS.USER_ID] = Sprig.userId;
33
+ if (Sprig.visitorId) headers[HEADERS.VISITOR_ID] = Sprig.visitorId;
34
+ if (Sprig.partnerAnonymousId) headers[HEADERS.PARTNER_ANONYMOUS_ID] = Sprig.partnerAnonymousId;
35
+ if (Sprig.mobileHeadersJSON) {
36
+ const mobileHeaders = JSON.parse(Sprig.mobileHeadersJSON);
37
+ Object.assign(headers, mobileHeaders);
38
+ }
39
+ headers[HEADERS.INSTALLATION_METHOD] = getInstallationMethodHeader(Sprig);
40
+ if (Sprig.locale) headers['accept-language'] = Sprig.locale; // custom set locale overrides original header locales
41
+
42
+ return headers;
43
+ }
44
+
45
+ async function dropOrQueueRequest(shouldDropOnRateLimit, isRateLimitRetry, requestInfo) {
46
+ if (shouldDropOnRateLimit) {
47
+ return { status: 429 };
48
+ } else {
49
+ const deferredRequest = new Deferred(requestInfo);
50
+ pendingRequestQueue.push(deferredRequest);
51
+ return deferredRequest.promise;
52
+ }
53
+ }
54
+
55
+ export async function ulFetch(url, options, attempt = 0, shouldDropOnRateLimit = false, shouldRetryRequest = false) {
56
+ // drop or queue request based on the current rate limit status
57
+ const requestInfo = { url, options, attempt, shouldDropOnRateLimit };
58
+ if (isRateLimited && !shouldRetryRequest) {
59
+ return dropOrQueueRequest(shouldDropOnRateLimit, shouldRetryRequest, requestInfo);
60
+ }
61
+ const killswitchResponse = { ok: false, reportError: false };
62
+ if (killswitch) {
63
+ console.log(`UserLeap - ${killswitchReason}`);
64
+ return killswitchResponse;
65
+ }
66
+
67
+ try {
68
+ options.headers = Object.assign(getHttpHeaders(), options.headers);
69
+ const result = await fetch(url, options);
70
+ if (result.status === 429) {
71
+ // retry if rate limit is not in effect yet and the request is not droppable
72
+ const shouldRetryCurrentRequest = (!isRateLimited && !shouldDropOnRateLimit) || shouldRetryRequest;
73
+ if (shouldRetryCurrentRequest) {
74
+ isRateLimited = true;
75
+ const rateLimitResetTime = result.headers.has('ratelimit-reset')
76
+ ? Number(result.headers.get('ratelimit-reset'))
77
+ : NETWORK_CONFIG.RATELIMIT_RESET_DEFAULT;
78
+ return delay(rateLimitResetTime * 1000).then(async () => {
79
+ return ulFetch(url, options, 0, shouldDropOnRateLimit, true);
80
+ });
81
+ } else {
82
+ return dropOrQueueRequest(shouldDropOnRateLimit, false, requestInfo);
83
+ }
84
+ }
85
+ isRateLimited = false;
86
+ if (pendingRequestQueue.length) {
87
+ pendingRequestQueue.map((deferredRequest) => {
88
+ const { url, options, attempt, shouldDropOnRateLimit } = deferredRequest.payload;
89
+ ulFetch(url, options, attempt, shouldDropOnRateLimit).then((result) => {
90
+ deferredRequest.resolveRequest(result);
91
+ });
92
+ });
93
+ pendingRequestQueue = [];
94
+ }
95
+ if (result.ok) {
96
+ if (result.status === 249) {
97
+ killNetworkRequests();
98
+ return killswitchResponse;
99
+ }
100
+
101
+ const responseText = await result.text();
102
+ try {
103
+ if (responseText && responseText !== 'OK') {
104
+ result.json = JSON.parse(responseText);
105
+ }
106
+ return result;
107
+ } catch (err) {
108
+ return {
109
+ ok: false,
110
+ reportError: true,
111
+ error: new Error(`failed parsing response json for ${url} - ${responseText}`),
112
+ };
113
+ }
114
+ }
115
+ return result;
116
+ } catch (err) {
117
+ //retry
118
+ const newAttempt = attempt + 1;
119
+ if (newAttempt > 2) {
120
+ return {
121
+ ok: false,
122
+ reportError: false,
123
+ error: err,
124
+ };
125
+ }
126
+ return delay(newAttempt * 1000).then(() => {
127
+ return ulFetch(url, options, newAttempt);
128
+ });
129
+ }
130
+ }
@@ -0,0 +1,9 @@
1
+ export function delay(t, v) {
2
+ return new Promise(function(resolve) {
3
+ setTimeout(resolve.bind(null, v), t);
4
+ });
5
+ }
6
+
7
+ export const NETWORK_CONFIG = {
8
+ RATELIMIT_RESET_DEFAULT: 10, // 10s to restart rate limit retry
9
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Get true or false for whether to direct embed from platform headers
3
+ * @param {Object} [options = {}]
4
+ * @return {Object} User meta information
5
+ */
6
+ export default function shouldDirectEmbed({ 'userleap-platform': platform }) {
7
+ return platform !== 'web';
8
+ }
package/shared/tool.js ADDED
@@ -0,0 +1,19 @@
1
+ // for shared functions across the sdk
2
+
3
+ import { CSS_CONSTANTS } from './constants';
4
+
5
+ export const overrideStyles = (document, styleString) => {
6
+ const overrideStyles = document.createElement('style');
7
+ overrideStyles.type = 'text/css';
8
+ overrideStyles.textContent = styleString;
9
+ overrideStyles.id = CSS_CONSTANTS.CUSTOM_STYLE_TAG_ID;
10
+ document.head.appendChild(overrideStyles);
11
+ };
12
+
13
+ export const calculateFrameHeight = (document) => {
14
+ const container = document.querySelector(`.${CSS_CONSTANTS.CARD_CONTAINER_CLASS}`);
15
+ const containerHeight = container ? container.scrollHeight : 600;
16
+ const whiteSpace =
17
+ container && container.parentElement ? container.parentElement.clientHeight - container.clientHeight : 0;
18
+ return containerHeight + whiteSpace;
19
+ };
@@ -0,0 +1,45 @@
1
+ // UserLeap event constants
2
+ // event convention
3
+ // event name: descriptive name of the event that happened
4
+ // payload: {
5
+ // name: 'event name',
6
+ // descriptiveDataName: data
7
+ // }
8
+ export const ulEvents = {
9
+ // passing into controller
10
+ SURVEY_LIFE_CYCLE: 'survey.lifeCycle',
11
+ SURVEY_DIMENSIONS: 'survey.dimensions',
12
+ SURVEY_HEIGHT: 'survey.height',
13
+ SURVEY_PRESENTED: 'survey.presented',
14
+ SURVEY_APPEARED: 'survey.appeared',
15
+ SURVEY_FADING_OUT: 'survey.fadingOut',
16
+ SURVEY_WILL_CLOSE: 'survey.willClose',
17
+ SURVEY_CLOSED: 'survey.closed',
18
+ SDK_READY: 'sdk.ready',
19
+ // passing into view
20
+ CLOSE_SURVEY_ON_OVERLAY_CLICK: 'close.survey.overlayClick',
21
+ VISITOR_ID_UPDATED: 'visitor.id.updated',
22
+ };
23
+
24
+ // events not emitted externally and not in the documentation
25
+ export const internalEvents = {
26
+ name: {
27
+ VERIFY_VIEW_VERSION: 'verify.view.version',
28
+ },
29
+ data: {
30
+ VIEW_VERSION: 'view.version',
31
+ },
32
+ };
33
+
34
+ export const DISMISS_REASONS = {
35
+ CLOSED: 'close.click', // user clicked the close button
36
+ COMPLETE: 'survey.completed', // user answered all questions
37
+ PAGE_CHANGE: 'page.change', // productConfig.dismissOnPageChange == true and we detected a page change (excludes hash/query param changes)
38
+ API: 'api', // JS called Sprig('dismissActiveSurvey')
39
+ OVERRIDE: 'override', // JS called Sprig('displaySurvey', SURVEY_ID)
40
+ };
41
+
42
+ export const SURVEY_STATE = {
43
+ READY: 'ready',
44
+ NO_SURVEY: 'no survey',
45
+ };