Skip to content

Commit e210d01

Browse files
Add extra properties bucket to send to Shepherd Pro (#2664)
* Add extra `properties` bucket to send to Shepherd Pro * Fix lint * Remove redundant dataRequester setup, pass context * Add test for context and extra props
1 parent 7aa78de commit e210d01

File tree

5 files changed

+287
-21
lines changed

5 files changed

+287
-21
lines changed

shepherd.js/src/tour.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Evented } from './evented.ts';
22
import { Step, type StepOptions } from './step.ts';
33
import autoBind from './utils/auto-bind.ts';
4+
import { getContext } from './utils/context.ts';
45
import {
56
isHTMLElement,
67
isFunction,
@@ -97,22 +98,45 @@ const SHEPHERD_DEFAULT_API = 'https://shepherdpro.com' as const;
9798
const SHEPHERD_USER_ID = 'shepherdPro:userId' as const;
9899

99100
export class ShepherdPro extends Evented {
100-
activeTour?: Tour | null;
101+
// Shepherd Pro fields
101102
apiKey?: string;
102103
apiPath?: string;
103104
dataRequester?: DataRequest;
105+
/**
106+
* Extra properties to pass to Shepherd Pro
107+
*/
108+
properties?: { [key: string]: unknown };
109+
110+
// Vanilla Shepherd
111+
activeTour?: Tour | null;
104112
declare Step: StepNoOp | Step;
105113
declare Tour: TourNoOp | Tour;
106114

107-
init(apiKey?: string, apiPath?: string) {
115+
/**
116+
* Call init to take full advantage of ShepherdPro functionality
117+
* @param {string} apiKey The API key for your ShepherdPro account
118+
* @param {string} apiPath
119+
* @param {object} properties Extra properties to be passed to Shepherd Pro
120+
*/
121+
init(
122+
apiKey?: string,
123+
apiPath?: string,
124+
properties?: { [key: string]: unknown }
125+
) {
108126
if (!apiKey) {
109127
throw new Error('Shepherd Pro: Missing required apiKey option.');
110128
}
111129
this.apiKey = apiKey;
112130
this.apiPath = apiPath ?? SHEPHERD_DEFAULT_API;
131+
this.properties = properties ?? {};
132+
this.properties['context'] = getContext(window);
113133

114134
if (this.apiKey) {
115-
this.dataRequester = new DataRequest(this.apiKey, this.apiPath);
135+
this.dataRequester = new DataRequest(
136+
this.apiKey,
137+
this.apiPath,
138+
this.properties
139+
);
116140
// Setup actor before first tour is loaded if none exists
117141
const shepherdProId = localStorage.getItem(SHEPHERD_USER_ID);
118142

@@ -142,11 +166,8 @@ export class ShepherdPro extends Evented {
142166
* @extends {Evented}
143167
*/
144168
export class Tour extends Evented {
145-
dataRequester;
146169
trackedEvents = ['active', 'cancel', 'complete', 'show'];
147170

148-
private currentUserId: string | null = null;
149-
150171
classPrefix: string;
151172
currentStep?: Step | null;
152173
focusedElBeforeOpen?: HTMLElement | null;
@@ -191,13 +212,9 @@ export class Tour extends Evented {
191212

192213
this._setTourID(options.id);
193214

194-
const { apiKey, apiPath } = Shepherd;
215+
const { dataRequester } = Shepherd;
195216
// If we have an API key, then setup Pro features
196-
if (apiKey && apiPath) {
197-
this.dataRequester = new DataRequest(apiKey, apiPath);
198-
199-
this.currentUserId = localStorage.getItem(SHEPHERD_USER_ID);
200-
217+
if (dataRequester) {
201218
this.trackedEvents.forEach((event) =>
202219
this.on(event, (opts: EventOptions) => {
203220
const { tour } = opts;
@@ -223,7 +240,7 @@ export class Tour extends Evented {
223240
tourOptions: tour.options
224241
}
225242
};
226-
this.dataRequester?.sendEvents({ data });
243+
dataRequester.sendEvents({ data });
227244
})
228245
);
229246
}

shepherd.js/src/utils/context.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
declare global {
2+
interface Window {
3+
opera: unknown;
4+
}
5+
}
6+
7+
export function getContext(window: Window) {
8+
let context = {};
9+
if (window.navigator) {
10+
const userAgent = window.navigator.userAgent;
11+
context = {
12+
...context,
13+
$os: os(window),
14+
$browser: browser(userAgent, window.navigator.vendor, !!window.opera),
15+
$referrer: window.document.referrer,
16+
$referring_domain: referringDomain(window.document.referrer),
17+
$device: device(userAgent),
18+
$current_url: window.location.href,
19+
$host: window.location.host,
20+
$pathname: window.location.pathname,
21+
$browser_version: browserVersion(
22+
userAgent,
23+
window.navigator.vendor,
24+
!!window.opera
25+
),
26+
$screen_height: window.screen.height,
27+
$screen_width: window.screen.width,
28+
$screen_dpr: window.devicePixelRatio
29+
};
30+
}
31+
context = {
32+
...context,
33+
$lib: 'js',
34+
// $lib_version: version,
35+
$insert_id:
36+
Math.random().toString(36).substring(2, 10) +
37+
Math.random().toString(36).substring(2, 10),
38+
$time: new Date().getTime() / 1000 // epoch time in seconds
39+
};
40+
return context; // TODO: strip empty props?
41+
}
42+
43+
function includes(haystack: string, needle: string): boolean {
44+
return haystack.indexOf(needle) >= 0;
45+
}
46+
47+
function browser(userAgent: string, vendor: string, opera: boolean): string {
48+
vendor = vendor || ''; // vendor is undefined for at least IE9
49+
if (opera || includes(userAgent, ' OPR/')) {
50+
if (includes(userAgent, 'Mini')) {
51+
return 'Opera Mini';
52+
}
53+
return 'Opera';
54+
} else if (/(BlackBerry|PlayBook|BB10)/i.test(userAgent)) {
55+
return 'BlackBerry';
56+
} else if (
57+
includes(userAgent, 'IEMobile') ||
58+
includes(userAgent, 'WPDesktop')
59+
) {
60+
return 'Internet Explorer Mobile';
61+
} else if (includes(userAgent, 'SamsungBrowser/')) {
62+
// https://developer.samsung.com/internet/user-agent-string-format
63+
return 'Samsung Internet';
64+
} else if (includes(userAgent, 'Edge') || includes(userAgent, 'Edg/')) {
65+
return 'Microsoft Edge';
66+
} else if (includes(userAgent, 'FBIOS')) {
67+
return 'Facebook Mobile';
68+
} else if (includes(userAgent, 'Chrome')) {
69+
return 'Chrome';
70+
} else if (includes(userAgent, 'CriOS')) {
71+
return 'Chrome iOS';
72+
} else if (includes(userAgent, 'UCWEB') || includes(userAgent, 'UCBrowser')) {
73+
return 'UC Browser';
74+
} else if (includes(userAgent, 'FxiOS')) {
75+
return 'Firefox iOS';
76+
} else if (includes(vendor, 'Apple')) {
77+
if (includes(userAgent, 'Mobile')) {
78+
return 'Mobile Safari';
79+
}
80+
return 'Safari';
81+
} else if (includes(userAgent, 'Android')) {
82+
return 'Android Mobile';
83+
} else if (includes(userAgent, 'Konqueror')) {
84+
return 'Konqueror';
85+
} else if (includes(userAgent, 'Firefox')) {
86+
return 'Firefox';
87+
} else if (includes(userAgent, 'MSIE') || includes(userAgent, 'Trident/')) {
88+
return 'Internet Explorer';
89+
} else if (includes(userAgent, 'Gecko')) {
90+
return 'Mozilla';
91+
} else {
92+
return '';
93+
}
94+
}
95+
96+
function browserVersion(
97+
userAgent: string,
98+
vendor: string,
99+
opera: boolean
100+
): number | null {
101+
const regexList = {
102+
'Internet Explorer Mobile': /rv:(\d+(\.\d+)?)/,
103+
'Microsoft Edge': /Edge?\/(\d+(\.\d+)?)/,
104+
Chrome: /Chrome\/(\d+(\.\d+)?)/,
105+
'Chrome iOS': /CriOS\/(\d+(\.\d+)?)/,
106+
'UC Browser': /(UCBrowser|UCWEB)\/(\d+(\.\d+)?)/,
107+
Safari: /Version\/(\d+(\.\d+)?)/,
108+
'Mobile Safari': /Version\/(\d+(\.\d+)?)/,
109+
Opera: /(Opera|OPR)\/(\d+(\.\d+)?)/,
110+
Firefox: /Firefox\/(\d+(\.\d+)?)/,
111+
'Firefox iOS': /FxiOS\/(\d+(\.\d+)?)/,
112+
Konqueror: /Konqueror:(\d+(\.\d+)?)/,
113+
BlackBerry: /BlackBerry (\d+(\.\d+)?)/,
114+
'Android Mobile': /android\s(\d+(\.\d+)?)/,
115+
'Samsung Internet': /SamsungBrowser\/(\d+(\.\d+)?)/,
116+
'Internet Explorer': /(rv:|MSIE )(\d+(\.\d+)?)/,
117+
Mozilla: /rv:(\d+(\.\d+)?)/
118+
};
119+
120+
const browserString = browser(
121+
userAgent,
122+
vendor,
123+
opera
124+
) as keyof typeof regexList;
125+
const regex: RegExp = regexList[browserString] || undefined;
126+
127+
if (regex === undefined) {
128+
return null;
129+
}
130+
const matches = userAgent.match(regex);
131+
if (!matches) {
132+
return null;
133+
}
134+
return parseFloat(matches[matches.length - 2] as string);
135+
}
136+
137+
function os(window: Window): string {
138+
const a = window.navigator.userAgent;
139+
if (/Windows/i.test(a)) {
140+
if (/Phone/.test(a) || /WPDesktop/.test(a)) {
141+
return 'Windows Phone';
142+
}
143+
return 'Windows';
144+
} else if (/(iPhone|iPad|iPod)/.test(a)) {
145+
return 'iOS';
146+
} else if (/Android/.test(a)) {
147+
return 'Android';
148+
} else if (/(BlackBerry|PlayBook|BB10)/i.test(a)) {
149+
return 'BlackBerry';
150+
} else if (/Mac/i.test(a)) {
151+
return 'Mac OS X';
152+
} else if (/Linux/.test(a)) {
153+
return 'Linux';
154+
} else if (/CrOS/.test(a)) {
155+
return 'Chrome OS';
156+
} else {
157+
return '';
158+
}
159+
}
160+
161+
function device(userAgent: string): string {
162+
if (/Windows Phone/i.test(userAgent) || /WPDesktop/.test(userAgent)) {
163+
return 'Windows Phone';
164+
} else if (/iPad/.test(userAgent)) {
165+
return 'iPad';
166+
} else if (/iPod/.test(userAgent)) {
167+
return 'iPod Touch';
168+
} else if (/iPhone/.test(userAgent)) {
169+
return 'iPhone';
170+
} else if (/(BlackBerry|PlayBook|BB10)/i.test(userAgent)) {
171+
return 'BlackBerry';
172+
} else if (/Android/.test(userAgent)) {
173+
return 'Android';
174+
} else {
175+
return '';
176+
}
177+
}
178+
179+
function referringDomain(referrer: string): string {
180+
const split = referrer.split('/');
181+
if (split.length >= 3) {
182+
return split[2] as string;
183+
}
184+
return '';
185+
}

shepherd.js/src/utils/datarequest.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,13 @@ interface ActorResponse {
55
class DataRequest {
66
private apiKey: string;
77
private apiPath: string;
8+
private properties?: { [key: string]: unknown };
89

9-
constructor(apiKey?: string, apiPath?: string) {
10+
constructor(
11+
apiKey?: string,
12+
apiPath?: string,
13+
properties?: { [key: string]: unknown }
14+
) {
1015
if (!apiKey) {
1116
throw new Error('Shepherd Pro: Missing required apiKey option.');
1217
}
@@ -16,9 +21,12 @@ class DataRequest {
1621

1722
this.apiKey = apiKey;
1823
this.apiPath = apiPath;
24+
this.properties = properties;
1925
}
2026

2127
async sendEvents(body: Record<string, unknown>) {
28+
body['properties'] = this.properties;
29+
2230
try {
2331
const response = await fetch(`${this.apiPath}/api/v1/actor`, {
2432
headers: {

test/unit/pro.spec.js

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,42 @@
11
import Shepherd from '../../shepherd.js/src/shepherd';
22
import DataRequest from '../../shepherd.js/src/utils/datarequest';
33

4+
const windowProps = {
5+
document: {
6+
referrer: ''
7+
},
8+
location: {
9+
ancestorOrigins: {},
10+
href: 'https://shepherdjs.dev/',
11+
origin: 'https://shepherdjs.dev',
12+
protocol: 'https:',
13+
host: 'shepherdjs.dev',
14+
hostname: 'shepherdjs.dev',
15+
port: '',
16+
pathname: '/',
17+
search: '',
18+
hash: ''
19+
},
20+
navigator: {
21+
userAgent:
22+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
23+
vendor: 'Google Inc.'
24+
},
25+
screen: {
26+
height: 1080,
27+
width: 1920
28+
}
29+
};
30+
431
describe('Shepherd Pro', function () {
5-
const dataRequesterMock = jest
32+
const sendEventsMock = jest
633
.spyOn(DataRequest.prototype, 'sendEvents')
734
.mockImplementation(() => Promise.resolve({ actorId: 1 }));
835

936
afterAll(() => {
10-
dataRequesterMock.mockReset();
37+
sendEventsMock.mockReset();
1138
});
39+
1240
it('exists and creates an instance', () => {
1341
const proInstance = new Shepherd.Tour();
1442

@@ -22,14 +50,36 @@ describe('Shepherd Pro', function () {
2250
);
2351
});
2452

25-
it('adds the event listeners expected', () => {
26-
Shepherd.init('api_123');
53+
it('sends events and passes properties and context', () => {
54+
const windowSpy = jest.spyOn(global, 'window', 'get');
55+
windowSpy.mockImplementation(() => windowProps);
56+
57+
Shepherd.init('api_123', 'https://api.shepherdpro.com', { extra: 'stuff' });
2758

2859
expect(typeof Shepherd.trigger).toBe('function');
60+
expect(Shepherd.dataRequester.properties).toMatchObject({
61+
context: {
62+
$browser: 'Chrome',
63+
$browser_version: 123,
64+
$current_url: 'https://shepherdjs.dev/',
65+
$device: '',
66+
$host: 'shepherdjs.dev',
67+
$lib: 'js',
68+
$os: 'Mac OS X',
69+
$pathname: '/',
70+
$referrer: '',
71+
$referring_domain: '',
72+
$screen_height: 1080,
73+
$screen_width: 1920
74+
},
75+
extra: 'stuff'
76+
});
2977

3078
Shepherd.trigger('show');
3179

32-
expect(dataRequesterMock).toHaveBeenCalled();
80+
expect(sendEventsMock).toHaveBeenCalled();
81+
82+
windowSpy.mockRestore();
3383
});
3484

3585
it('creates a Tour instance', () => {

0 commit comments

Comments
 (0)