@suprsend/web-sdk 1.0.0 → 1.2.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/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@suprsend/web-sdk",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "This is sdk used to integrate suprsend functionality in javascript applications",
5
5
  "main": "dist/cjs_bundle.js",
6
6
  "scripts": {
7
7
  "test": "echo \"Error: no test specified\" && exit 1",
8
8
  "build": "rm -rf dist && webpack --env module_type=commonjs --env filename=cjs_bundle.js && webpack --env module_type=window --env filename=cdn_bundle.js",
9
- "publish-sdk": "npm run build && npm publish"
9
+ "publish_sdk": "npm run build && npm publish"
10
10
  },
11
11
  "keywords": [
12
12
  "suprsend",
@@ -27,10 +27,11 @@
27
27
  "@babel/core": "^7.18.5",
28
28
  "@babel/preset-env": "^7.18.2",
29
29
  "babel-loader": "^8.2.5",
30
- "webpack": "^5.51.1",
30
+ "webpack": "^5.76.0",
31
31
  "webpack-cli": "^4.8.0"
32
32
  },
33
33
  "dependencies": {
34
- "libphonenumber-js": "^1.10.7"
34
+ "libphonenumber-js": "^1.10.7",
35
+ "mitt": "^3.0.0"
35
36
  }
36
37
  }
package/src/config.js CHANGED
@@ -4,9 +4,10 @@ const config = {
4
4
  api_url: "https://hub.suprsend.com",
5
5
  sdk_version: package_data.version,
6
6
  batch_size: 20,
7
- flush_interval: 3000,
7
+ flush_interval: 3000, // in ms
8
8
  service_worker_file: "serviceworker.js",
9
- sw_delay: 5000, //in ms,
9
+ sw_delay: 5000, // in ms,
10
+ preference_debounce: 1000, // in ms
10
11
  };
11
12
 
12
13
  export default config;
package/src/encryption.js CHANGED
@@ -236,15 +236,22 @@ const getUtf8Bytes = (str) =>
236
236
  [...unescape(encodeURIComponent(str))].map((c) => c.charCodeAt(0))
237
237
  );
238
238
 
239
- export default async function create_signature(str, date, method) {
239
+ export default async function create_signature(
240
+ str,
241
+ date,
242
+ method,
243
+ route = `/${constants.api_events_route}`
244
+ ) {
240
245
  if (!window.crypto) {
241
246
  return;
242
247
  }
243
248
  const key = config.signing_key;
244
- const message = `${method}\n${MD5(str)}\napplication/json\n${date}\n/${
245
- constants.api_events_route
246
- }`;
247
-
249
+ const content_type = "application/json";
250
+ let md5_str = "";
251
+ if (str) {
252
+ md5_str = MD5(str);
253
+ }
254
+ const message = `${method}\n${md5_str}\n${content_type}\n${date}\n${route}`;
248
255
  const keyBytes = getUtf8Bytes(key);
249
256
  const messageBytes = getUtf8Bytes(message);
250
257
 
package/src/index.d.ts CHANGED
@@ -1,8 +1,120 @@
1
+ import { Emitter } from "mitt";
2
+
1
3
  interface Dictionary {
2
4
  [key: string]: any;
3
5
  }
4
6
 
7
+ export enum PreferenceOptions {
8
+ OPT_IN = "opt_in",
9
+ OPT_OUT = "opt_out",
10
+ }
11
+
12
+ export enum ChannelLevelPreferenceOptions {
13
+ ALL = "all",
14
+ REQUIRED = "required",
15
+ }
16
+
17
+ type EmitterEvents = {
18
+ preferences_updated?: null;
19
+ preferences_error: PreferenceErrorData;
20
+ };
21
+
22
+ interface CategoryChannel {
23
+ channel: string;
24
+ preference: PreferenceOptions;
25
+ is_editable: boolean;
26
+ }
27
+
28
+ interface Category {
29
+ name: string;
30
+ category: string;
31
+ description?: string | null;
32
+ preference: PreferenceOptions;
33
+ is_editable: boolean;
34
+ channels?: CategoryChannel[] | null;
35
+ }
36
+
37
+ interface Section {
38
+ name?: string | null;
39
+ description?: string | null;
40
+ subcategories?: Category[] | null;
41
+ }
42
+
43
+ interface ChannelPreference {
44
+ channel: string;
45
+ is_restricted: boolean;
46
+ }
47
+
48
+ interface PreferenceData {
49
+ sections: Section[] | null;
50
+ channel_preferences: ChannelPreference[] | null;
51
+ }
52
+
53
+ interface PreferenceErrorData {
54
+ error: boolean;
55
+ api_error?: boolean;
56
+ message: string;
57
+ status_code?: number | null;
58
+ error_obj?: Error | null;
59
+ }
60
+
61
+ interface GetPreferencesResponse extends PreferenceData, PreferenceErrorData {}
62
+
63
+ interface GetCategoriesResponse extends PreferenceErrorData {
64
+ meta: { count: number; limit: number; offset: number };
65
+ results: Category[] | null;
66
+ }
67
+
68
+ interface GetOverAllChannelPreferencesResponse extends PreferenceErrorData {
69
+ channel_preferences: ChannelPreference[] | null;
70
+ }
71
+
72
+ interface Preferences {
73
+ data: PreferenceData;
74
+
75
+ get_preferences(args?: {
76
+ brand_id?: string;
77
+ }): Promise<GetPreferencesResponse>;
78
+
79
+ get_categories(args?: {
80
+ brand_id?: string;
81
+ limit?: number;
82
+ offset?: number;
83
+ }): Promise<GetCategoriesResponse>;
84
+
85
+ get_category(
86
+ category: string,
87
+ args?: { brand_id?: string }
88
+ ): Promise<Category>;
89
+
90
+ get_overall_channel_preferences(): Promise<GetOverAllChannelPreferencesResponse>;
91
+
92
+ update_category_preference(
93
+ category: string,
94
+ preference: PreferenceOptions,
95
+ args?: {
96
+ brand_id?: string;
97
+ }
98
+ ): void | PreferenceErrorData;
99
+
100
+ update_channel_preference_in_category(
101
+ channel: string,
102
+ preference: PreferenceOptions,
103
+ category: string,
104
+ args?: {
105
+ brand_id?: string;
106
+ }
107
+ ): void | PreferenceErrorData;
108
+
109
+ update_overall_channel_preference(
110
+ channel: string,
111
+ preference: ChannelLevelPreferenceOptions
112
+ ): void | PreferenceErrorData;
113
+ }
114
+
5
115
  interface User {
116
+ preferences: Preferences;
117
+
6
118
  set(key: string, value: any): void;
7
119
  set(prop: Dictionary): void;
8
120
 
@@ -56,6 +168,7 @@ export interface SuprSend {
56
168
 
57
169
  user: User;
58
170
  web_push: WebPush;
171
+ emitter: Emitter<EmitterEvents>;
59
172
  }
60
173
 
61
174
  declare const suprsend: SuprSend;
package/src/index.js CHANGED
@@ -4,6 +4,11 @@ import User from "./user";
4
4
  import WebPush from "./web_push";
5
5
  import { constants, internal_events } from "./constants";
6
6
  import { SSConfigurationError } from "./errors";
7
+ import mitt from "mitt";
8
+ export {
9
+ PreferenceOptions,
10
+ ChannelLevelPreferenceOptions,
11
+ } from "./preferences";
7
12
 
8
13
  var suprSendInstance;
9
14
  export var initialisedAt;
@@ -22,8 +27,11 @@ class SuprSend {
22
27
  utils.set_cookie(constants.distinct_id, distinct_id);
23
28
  }
24
29
  suprSendInstance.distinct_id = distinct_id;
25
- this.user = new User(suprSendInstance);
30
+
31
+ this.emitter = mitt();
32
+ this.user = new User(suprSendInstance, this.emitter);
26
33
  this.web_push = new WebPush(suprSendInstance);
34
+
27
35
  this.web_push.update_subscription();
28
36
  SuprSend._set_env_properties();
29
37
  if (!initialisedAt) {
@@ -165,7 +173,7 @@ class SuprSend {
165
173
  _user_identified: false,
166
174
  };
167
175
  utils.remove_local_storage_item(constants.super_properties_key);
168
- this.user = new User(suprSendInstance);
176
+ this.user = new User(suprSendInstance, this.emitter);
169
177
  this.web_push = new WebPush(suprSendInstance);
170
178
  SuprSend._set_env_properties();
171
179
  }
@@ -0,0 +1,474 @@
1
+ import create_signature from "./encryption";
2
+ import config from "./config";
3
+ import utils from "./utils";
4
+
5
+ export const PreferenceOptions = { OPT_IN: "opt_in", OPT_OUT: "opt_out" };
6
+ export const ChannelLevelPreferenceOptions = {
7
+ ALL: "all",
8
+ REQUIRED: "required",
9
+ };
10
+
11
+ class Preferences {
12
+ constructor(instance, emitter) {
13
+ this.ss_instance = instance;
14
+ this._preference_data;
15
+ this._preference_args;
16
+ this._emitter = emitter;
17
+
18
+ this._debounced_update_category_preferences = utils.debounce_by_type(
19
+ this._update_category_preferences,
20
+ config.preference_debounce
21
+ );
22
+ this._debounced_update_channel_preferences = utils.debounce_by_type(
23
+ this._update_channel_preferences,
24
+ config.preference_debounce
25
+ );
26
+ }
27
+
28
+ _validate_query_params(query_params = {}) {
29
+ let validated_params = {};
30
+ for (let key in query_params) {
31
+ if (query_params[key]) {
32
+ validated_params[key] = query_params[key];
33
+ }
34
+ }
35
+ return validated_params;
36
+ }
37
+
38
+ async _get_request(route = "", query_params = {}) {
39
+ const preference_base_url = `/v1/subscriber/${this.ss_instance.distinct_id}`;
40
+ const validated_query_params = this._validate_query_params(query_params);
41
+ const query_params_string = new URLSearchParams(
42
+ validated_query_params
43
+ ).toString();
44
+
45
+ const full_url_path = query_params_string
46
+ ? `${preference_base_url}/${route}/?${query_params_string}`
47
+ : `${preference_base_url}/${route}`;
48
+
49
+ const requested_date = new Date().toGMTString();
50
+ const signature = await create_signature(
51
+ "",
52
+ requested_date,
53
+ "GET",
54
+ full_url_path
55
+ );
56
+ const authorization = signature
57
+ ? `${config.env_key}:${signature}`
58
+ : config.env_key;
59
+
60
+ try {
61
+ const resp = await fetch(`${config.api_url}${full_url_path}`, {
62
+ headers: {
63
+ "Content-Type": "application/json",
64
+ Authorization: authorization,
65
+ "x-amz-date": requested_date,
66
+ },
67
+ });
68
+ if (resp.ok) {
69
+ const respData = resp.json();
70
+ return respData;
71
+ }
72
+ return {
73
+ error: true,
74
+ api_error: true,
75
+ status_code: resp.status,
76
+ message: resp.statusText,
77
+ error_obj: null,
78
+ };
79
+ } catch (e) {
80
+ return {
81
+ error: true,
82
+ api_error: false,
83
+ status_code: null,
84
+ message: e.message,
85
+ error_obj: e,
86
+ };
87
+ }
88
+ }
89
+
90
+ async _update_request(body, route, query_params) {
91
+ const preference_base_url = `/v1/subscriber/${this.ss_instance.distinct_id}`;
92
+ const validated_query_params = this._validate_query_params(query_params);
93
+ const query_params_string = new URLSearchParams(
94
+ validated_query_params
95
+ ).toString();
96
+
97
+ const full_url_path = query_params_string
98
+ ? `${preference_base_url}/${route}/?${query_params_string}`
99
+ : `${preference_base_url}/${route}`;
100
+
101
+ const requested_date = new Date().toGMTString();
102
+ const bodyString = JSON.stringify(body);
103
+ const signature = await create_signature(
104
+ bodyString,
105
+ requested_date,
106
+ "POST",
107
+ full_url_path
108
+ );
109
+ const authorization = signature
110
+ ? `${config.env_key}:${signature}`
111
+ : config.env_key;
112
+
113
+ try {
114
+ const resp = await fetch(`${config.api_url}${full_url_path}`, {
115
+ method: "POST",
116
+ body: bodyString,
117
+ headers: {
118
+ "Content-Type": "application/json",
119
+ Authorization: authorization,
120
+ "x-amz-date": requested_date,
121
+ },
122
+ });
123
+ if (resp.ok) {
124
+ const respData = resp.json();
125
+ return respData;
126
+ }
127
+ return {
128
+ error: true,
129
+ api_error: true,
130
+ status_code: resp.status,
131
+ message: resp.statusText,
132
+ error_obj: null,
133
+ };
134
+ } catch (e) {
135
+ return {
136
+ error: true,
137
+ api_error: false,
138
+ status_code: null,
139
+ message: e.message,
140
+ error_obj: e,
141
+ };
142
+ }
143
+ }
144
+
145
+ _update_category_preferences = async (
146
+ category = "",
147
+ body = {},
148
+ subcategory,
149
+ args = {}
150
+ ) => {
151
+ let url_path = `category/${category}`;
152
+ const response = await this._update_request(body, url_path, args);
153
+ if (response?.error) {
154
+ this._emitter.emit("preferences_error", response);
155
+ } else {
156
+ Object.assign(subcategory, response);
157
+ this._emitter.emit("preferences_updated");
158
+ }
159
+ return response;
160
+ };
161
+
162
+ _update_channel_preferences = async (body = {}) => {
163
+ let url_path = "channel_preference";
164
+ const response = await this._update_request(body, url_path);
165
+ if (response?.error) {
166
+ this._emitter.emit("preferences_error", response);
167
+ } else {
168
+ await this.get_preferences(this._preference_args);
169
+ this._emitter.emit("preferences_updated");
170
+ }
171
+ return response;
172
+ };
173
+
174
+ set data(value) {
175
+ this._preference_data = value;
176
+ }
177
+
178
+ get data() {
179
+ return this._preference_data;
180
+ }
181
+
182
+ async get_preferences(args = {}) {
183
+ let url_path = "full_preference";
184
+ let query_params = { brand_id: args?.brand_id };
185
+
186
+ const response = await this._get_request(url_path, query_params);
187
+ if (!response?.error) {
188
+ this.data = response;
189
+ }
190
+ return response;
191
+ }
192
+
193
+ async get_categories(args = {}) {
194
+ let url_path = "category";
195
+ const query_params = {
196
+ brand_id: args?.brand_id,
197
+ limit: args?.limit,
198
+ offset: args?.offset,
199
+ };
200
+
201
+ const response = await this._get_request(url_path, query_params);
202
+ return response;
203
+ }
204
+
205
+ async get_category(category = "", args = {}) {
206
+ if (!category) {
207
+ return {
208
+ error: true,
209
+ message: "Category parameter is missing",
210
+ };
211
+ }
212
+
213
+ let url_path = `category/${category}`;
214
+ let query_params = { brand_id: args?.brand_id };
215
+
216
+ const response = await this._get_request(url_path, query_params);
217
+ return response;
218
+ }
219
+
220
+ async get_overall_channel_preferences() {
221
+ let url_path = `channel_preference`;
222
+ const response = await this._get_request(url_path);
223
+ return response;
224
+ }
225
+
226
+ update_category_preference(category = "", preference = "", args = {}) {
227
+ if (
228
+ !category ||
229
+ ![PreferenceOptions.OPT_IN, PreferenceOptions.OPT_OUT].includes(
230
+ preference
231
+ )
232
+ ) {
233
+ return {
234
+ error: true,
235
+ message: !category
236
+ ? "Category parameter is missing"
237
+ : "Preference parameter is invalid",
238
+ };
239
+ }
240
+
241
+ if (!this.data) {
242
+ return {
243
+ error: true,
244
+ message: "Call get_preferences method before performing action",
245
+ };
246
+ }
247
+
248
+ let category_data;
249
+ let data_updated = false;
250
+
251
+ // optimistic update in local store
252
+ for (let section of this.data.sections) {
253
+ let abort = false;
254
+ for (let subcategory of section.subcategories) {
255
+ if (subcategory.category === category) {
256
+ category_data = subcategory;
257
+ if (subcategory.is_editable) {
258
+ if (subcategory.preference !== preference) {
259
+ subcategory.preference = preference;
260
+ data_updated = true;
261
+ abort = true;
262
+ break;
263
+ } else {
264
+ // console.log(`category is already ${status}ed`);
265
+ }
266
+ } else {
267
+ return {
268
+ error: true,
269
+ message: "Category preference is not editable",
270
+ };
271
+ }
272
+ }
273
+ }
274
+ if (abort) break;
275
+ }
276
+
277
+ if (!category_data) {
278
+ return {
279
+ error: true,
280
+ message: "Category is not found",
281
+ };
282
+ }
283
+
284
+ if (!data_updated) {
285
+ return;
286
+ }
287
+
288
+ const opt_out_channels = [];
289
+ category_data.channels.forEach((channel) => {
290
+ if (channel.preference === PreferenceOptions.OPT_OUT) {
291
+ opt_out_channels.push(channel.channel);
292
+ }
293
+ });
294
+
295
+ const request_payload = {
296
+ preference: category_data.preference,
297
+ opt_out_channels,
298
+ };
299
+
300
+ this._debounced_update_category_preferences(
301
+ category,
302
+ category,
303
+ request_payload,
304
+ category_data,
305
+ { brand_id: args?.brand_id }
306
+ );
307
+ }
308
+
309
+ update_channel_preference_in_category(
310
+ channel = "",
311
+ preference = "",
312
+ category = "",
313
+ args = {}
314
+ ) {
315
+ if (!channel || !category) {
316
+ return {
317
+ error: true,
318
+ message: !channel
319
+ ? "Channel parameter is missing"
320
+ : "Category parameter is missing",
321
+ };
322
+ } else if (
323
+ ![PreferenceOptions.OPT_IN, PreferenceOptions.OPT_OUT].includes(
324
+ preference
325
+ )
326
+ ) {
327
+ return {
328
+ error: true,
329
+ message: "Preference parameter is invalid",
330
+ };
331
+ }
332
+
333
+ if (!this.data) {
334
+ return {
335
+ error: true,
336
+ message: "Call get_preferences method before performing action",
337
+ };
338
+ }
339
+
340
+ let category_data;
341
+ let selected_channel_data;
342
+ let data_updated = false;
343
+
344
+ // optimistic update in local store
345
+ for (let section of this.data.sections) {
346
+ let abort = false;
347
+ for (let subcategory of section.subcategories) {
348
+ if (subcategory.category === category) {
349
+ category_data = subcategory;
350
+ for (let channel_data of subcategory.channels) {
351
+ if (channel_data.channel === channel) {
352
+ selected_channel_data = channel_data;
353
+ if (channel_data.is_editable) {
354
+ if (channel_data.preference !== preference) {
355
+ channel_data.preference = preference;
356
+ if (preference === PreferenceOptions.OPT_IN) {
357
+ subcategory.preference = PreferenceOptions.OPT_IN;
358
+ }
359
+ data_updated = true;
360
+ abort = true;
361
+ break;
362
+ } else {
363
+ // console.log(`channel is already ${preference}`);
364
+ }
365
+ } else {
366
+ return {
367
+ error: true,
368
+ message: "Channel preference is not editable",
369
+ };
370
+ }
371
+ }
372
+ }
373
+ }
374
+ if (abort) break;
375
+ }
376
+ if (abort) break;
377
+ }
378
+
379
+ if (!category_data) {
380
+ return {
381
+ error: true,
382
+ message: "Category not found",
383
+ };
384
+ }
385
+
386
+ if (!selected_channel_data) {
387
+ return {
388
+ error: true,
389
+ message: "Category's Channel not found",
390
+ };
391
+ }
392
+
393
+ if (!data_updated) {
394
+ return;
395
+ }
396
+
397
+ const opt_out_channels = [];
398
+ category_data.channels.forEach((channel) => {
399
+ if (channel.preference === PreferenceOptions.OPT_OUT) {
400
+ opt_out_channels.push(channel.channel);
401
+ }
402
+ });
403
+
404
+ const request_payload = {
405
+ preference: category_data.preference,
406
+ opt_out_channels,
407
+ };
408
+
409
+ this._debounced_update_category_preferences(
410
+ category,
411
+ category,
412
+ request_payload,
413
+ category_data,
414
+ { brand_id: args?.brand_id }
415
+ );
416
+ }
417
+
418
+ update_overall_channel_preference(channel = "", preference = "") {
419
+ if (
420
+ !channel ||
421
+ ![
422
+ ChannelLevelPreferenceOptions.ALL,
423
+ ChannelLevelPreferenceOptions.REQUIRED,
424
+ ].includes(preference)
425
+ ) {
426
+ return {
427
+ error: true,
428
+ message: !channel
429
+ ? "Channel parameter is missing"
430
+ : "Preference parameter is invalid",
431
+ };
432
+ }
433
+
434
+ if (!this.data) {
435
+ return {
436
+ error: true,
437
+ message: "Call get_preferences method before performing action",
438
+ };
439
+ }
440
+
441
+ let channel_data;
442
+ let data_updated = false;
443
+ const preference_restricted =
444
+ preference === ChannelLevelPreferenceOptions.REQUIRED;
445
+
446
+ for (let channel_item of this.data.channel_preferences) {
447
+ if (channel_item.channel === channel) {
448
+ channel_data = channel_item;
449
+ if (channel_item.is_restricted !== preference_restricted) {
450
+ channel_item.is_restricted = preference_restricted;
451
+ data_updated = true;
452
+ break;
453
+ }
454
+ }
455
+ }
456
+
457
+ if (!channel_data) {
458
+ return {
459
+ error: true,
460
+ message: "Channel data not found",
461
+ };
462
+ }
463
+
464
+ if (!data_updated) {
465
+ return;
466
+ }
467
+
468
+ this._debounced_update_channel_preferences(channel_data.channel, {
469
+ channel_preferences: [channel_data],
470
+ });
471
+ }
472
+ }
473
+
474
+ export default Preferences;