@product7/product7-js 0.3.2 → 0.3.5

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@product7/product7-js",
3
- "version": "0.3.2",
3
+ "version": "0.3.5",
4
4
  "description": "JavaScript SDK for integrating Product7 feedback widgets into any website",
5
5
  "main": "dist/product7-js.js",
6
6
  "module": "src/index.js",
@@ -225,36 +225,7 @@ export class MessengerService {
225
225
  }
226
226
 
227
227
  async identifyContact(data) {
228
- await this.api._ensureSession();
229
-
230
- if (this.api.mock) {
231
- await delay(300);
232
- return {
233
- status: true,
234
- data: {
235
- contact_id: 'mock_contact_' + Date.now(),
236
- email: data.email,
237
- name: data.name || '',
238
- is_new: true,
239
- },
240
- };
241
- }
242
-
243
- return this.api._makeRequest('/widget/messenger/identify', {
244
- method: 'POST',
245
- headers: {
246
- 'Content-Type': 'application/json',
247
- Authorization: `Bearer ${this.api.sessionToken}`,
248
- },
249
- body: JSON.stringify({
250
- email: data.email,
251
- name: data.name || '',
252
- phone: data.phone || '',
253
- company: data.company || '',
254
- avatar_url: data.avatar_url || '',
255
- metadata: data.metadata || {},
256
- }),
257
- });
228
+ return this.api.identify(data);
258
229
  }
259
230
 
260
231
  async sendTypingIndicator(conversationId, isTyping) {
@@ -224,8 +224,94 @@ export class APIService extends BaseAPIService {
224
224
  return this.messenger.submitRating(conversationId, data);
225
225
  }
226
226
 
227
+ async identify(metadata) {
228
+ await this._ensureSession();
229
+
230
+ if (this.mock) {
231
+ await new Promise((r) => setTimeout(r, 300));
232
+ const mockResponse = {
233
+ status: true,
234
+ data: {
235
+ contact_id: 'mock_contact_' + Date.now(),
236
+ email: metadata.email,
237
+ name: metadata.name || '',
238
+ is_new: true,
239
+ },
240
+ };
241
+ this._storeContactIdentity(mockResponse.data, metadata);
242
+ return mockResponse;
243
+ }
244
+
245
+ const response = await this._makeRequest('/widget/messenger/identify', {
246
+ method: 'POST',
247
+ headers: {
248
+ 'Content-Type': 'application/json',
249
+ Authorization: `Bearer ${this.sessionToken}`,
250
+ },
251
+ body: JSON.stringify({
252
+ user_id: metadata.user_id || null,
253
+ email: metadata.email || '',
254
+ name: metadata.name || '',
255
+ phone: metadata.phone || '',
256
+ company: metadata.company || '',
257
+ avatar_url: metadata.avatar_url || '',
258
+ metadata: metadata.custom_fields || {},
259
+ }),
260
+ });
261
+
262
+ if (response?.status && response?.data) {
263
+ this._storeContactIdentity(response.data, metadata);
264
+ }
265
+
266
+ return response;
267
+ }
268
+
269
+ _storeContactIdentity(data, metadata = {}) {
270
+ this.contactId = data.contact_id || null;
271
+ this.contactEmail = data.email || metadata.email || null;
272
+ this.contactName = data.name || metadata.name || null;
273
+
274
+ try {
275
+ localStorage.setItem(
276
+ 'product7_contact',
277
+ JSON.stringify({
278
+ contactId: this.contactId,
279
+ contactEmail: this.contactEmail,
280
+ contactName: this.contactName,
281
+ })
282
+ );
283
+ } catch (e) {
284
+ /* silent */
285
+ }
286
+ }
287
+
288
+ getContactIdentity() {
289
+ if (this.contactId) {
290
+ return {
291
+ contactId: this.contactId,
292
+ contactEmail: this.contactEmail,
293
+ contactName: this.contactName,
294
+ };
295
+ }
296
+
297
+ try {
298
+ const stored = localStorage.getItem('product7_contact');
299
+ if (stored) {
300
+ const parsed = JSON.parse(stored);
301
+ this.contactId = parsed.contactId;
302
+ this.contactEmail = parsed.contactEmail;
303
+ this.contactName = parsed.contactName;
304
+ return parsed;
305
+ }
306
+ } catch (e) {
307
+ /* silent */
308
+ }
309
+
310
+ return null;
311
+ }
312
+
227
313
  async identifyContact(data) {
228
- return this.messenger.identifyContact(data);
314
+ return this.identify(data);
229
315
  }
230
316
 
231
317
  async getHelpCollections(options) {
@@ -6,11 +6,16 @@ export class BaseAPIService {
6
6
  this.sessionToken = null;
7
7
  this.sessionExpiry = null;
8
8
  this.metadata = config.metadata || null;
9
+ this.identitySyncedToken = null;
9
10
  this.mock = config.mock || false;
10
11
  this.env = config.env || 'production';
11
12
  this.baseURL = this._getBaseURL(config);
12
13
 
13
14
  this._loadStoredSession();
15
+ this._loadStoredMetadata();
16
+ if (this.isSessionValid() && this.metadata) {
17
+ this.identitySyncedToken = this.sessionToken;
18
+ }
14
19
  }
15
20
 
16
21
  _getBaseURL(config) {
@@ -50,20 +55,17 @@ export class BaseAPIService {
50
55
  : envConfig.base;
51
56
  }
52
57
 
53
- async init(metadata = null) {
54
- if (metadata) {
55
- this.metadata = metadata;
58
+ async init(metadata = undefined) {
59
+ if (metadata !== undefined) {
60
+ this.setMetadata(metadata);
56
61
  }
57
62
 
58
63
  if (this.isSessionValid()) {
59
64
  return { sessionToken: this.sessionToken };
60
65
  }
61
66
 
62
- if (!this.workspace || !this.metadata) {
63
- throw new APIError(
64
- 400,
65
- `Missing ${!this.workspace ? 'workspace' : 'user context'} for initialization`
66
- );
67
+ if (!this.workspace) {
68
+ throw new APIError(400, 'Missing workspace for initialization');
67
69
  }
68
70
 
69
71
  if (this.mock) {
@@ -76,6 +78,7 @@ export class BaseAPIService {
76
78
  async _initMockSession() {
77
79
  this.sessionToken = 'mock_session_' + Date.now();
78
80
  this.sessionExpiry = new Date(Date.now() + 3600 * 1000);
81
+ this.identitySyncedToken = null;
79
82
  this._storeSession();
80
83
  return {
81
84
  sessionToken: this.sessionToken,
@@ -94,7 +97,6 @@ export class BaseAPIService {
94
97
  async _initRealSession() {
95
98
  const payload = {
96
99
  workspace: this.workspace,
97
- user: this.metadata,
98
100
  };
99
101
 
100
102
  try {
@@ -107,6 +109,7 @@ export class BaseAPIService {
107
109
 
108
110
  this.sessionToken = initData.sessionToken;
109
111
  this.sessionExpiry = new Date(Date.now() + initData.expiresIn * 1000);
112
+ this.identitySyncedToken = null;
110
113
  this._storeSession();
111
114
 
112
115
  return {
@@ -173,12 +176,52 @@ export class BaseAPIService {
173
176
  this.sessionToken = null;
174
177
  this.sessionExpiry = null;
175
178
  await this.init();
179
+ await this._restoreIdentity();
176
180
  return await method.apply(this, args);
177
181
  }
178
182
  throw error;
179
183
  }
180
184
  }
181
185
 
186
+ async identify(metadata = this.metadata) {
187
+ if (metadata !== undefined) {
188
+ this.setMetadata(metadata);
189
+ }
190
+
191
+ if (!this.metadata) {
192
+ throw new APIError(400, 'Missing user context for identify');
193
+ }
194
+
195
+ await this._ensureSession();
196
+
197
+ const payload = this._buildIdentifyPayload(this.metadata);
198
+
199
+ if (this.mock) {
200
+ this.identitySyncedToken = this.sessionToken;
201
+ return {
202
+ status: true,
203
+ identified: true,
204
+ data: {
205
+ user_id: payload.user_id || null,
206
+ email: payload.email || null,
207
+ name: payload.name || null,
208
+ },
209
+ };
210
+ }
211
+
212
+ const response = await this._makeRequest('/widget/identify', {
213
+ method: 'POST',
214
+ body: JSON.stringify(payload),
215
+ headers: {
216
+ 'Content-Type': 'application/json',
217
+ Authorization: `Bearer ${this.sessionToken}`,
218
+ },
219
+ });
220
+
221
+ this.identitySyncedToken = this.sessionToken;
222
+ return response;
223
+ }
224
+
182
225
  isSessionValid() {
183
226
  return (
184
227
  this.sessionToken && this.sessionExpiry && new Date() < this.sessionExpiry
@@ -186,8 +229,13 @@ export class BaseAPIService {
186
229
  }
187
230
 
188
231
  setMetadata(metadata) {
189
- this.metadata = metadata;
190
- this._storeData('product7_metadata', metadata);
232
+ this.metadata = metadata || null;
233
+ this.identitySyncedToken = null;
234
+ if (this.metadata) {
235
+ this._storeData('product7_metadata', this.metadata);
236
+ } else {
237
+ this._removeData('product7_metadata');
238
+ }
191
239
  }
192
240
 
193
241
  getMetadata() {
@@ -197,8 +245,13 @@ export class BaseAPIService {
197
245
  clearSession() {
198
246
  this.sessionToken = null;
199
247
  this.sessionExpiry = null;
248
+ this.identitySyncedToken = null;
249
+ this.contactId = null;
250
+ this.contactEmail = null;
251
+ this.contactName = null;
200
252
  this._removeData('product7_session');
201
253
  this._removeData('product7_metadata');
254
+ this._removeData('product7_contact');
202
255
  }
203
256
 
204
257
  _storeSession() {
@@ -222,6 +275,14 @@ export class BaseAPIService {
222
275
  if (!stored) return false;
223
276
 
224
277
  const sessionData = JSON.parse(stored);
278
+ if (
279
+ this.workspace &&
280
+ sessionData.workspace &&
281
+ sessionData.workspace !== this.workspace
282
+ ) {
283
+ localStorage.removeItem('product7_session');
284
+ return false;
285
+ }
225
286
  this.sessionToken = sessionData.token;
226
287
  this.sessionExpiry = new Date(sessionData.expiry);
227
288
 
@@ -231,6 +292,32 @@ export class BaseAPIService {
231
292
  }
232
293
  }
233
294
 
295
+ _loadStoredMetadata() {
296
+ if (this.metadata || typeof localStorage === 'undefined') return false;
297
+
298
+ try {
299
+ const session = localStorage.getItem('product7_session');
300
+ if (session) {
301
+ const sessionData = JSON.parse(session);
302
+ if (
303
+ this.workspace &&
304
+ sessionData.workspace &&
305
+ sessionData.workspace !== this.workspace
306
+ ) {
307
+ return false;
308
+ }
309
+ }
310
+
311
+ const stored = localStorage.getItem('product7_metadata');
312
+ if (!stored) return false;
313
+
314
+ this.metadata = JSON.parse(stored);
315
+ return true;
316
+ } catch (error) {
317
+ return false;
318
+ }
319
+ }
320
+
234
321
  _storeData(key, value) {
235
322
  if (typeof localStorage !== 'undefined') {
236
323
  localStorage.setItem(key, JSON.stringify(value));
@@ -293,4 +380,34 @@ export class BaseAPIService {
293
380
  const queryString = this._buildQueryParams(params);
294
381
  return `${endpoint}${queryString ? '?' + queryString : ''}`;
295
382
  }
383
+
384
+ _buildIdentifyPayload(metadata = {}) {
385
+ const payload = {
386
+ user_id: metadata.user_id,
387
+ email: metadata.email,
388
+ name: metadata.name,
389
+ avatar:
390
+ metadata.profile_picture || metadata.avatar_url || metadata.avatar,
391
+ attributes: metadata.custom_fields || {},
392
+ };
393
+
394
+ if (metadata.company) {
395
+ payload.company = metadata.company;
396
+ }
397
+
398
+ return payload;
399
+ }
400
+
401
+ async _restoreIdentity() {
402
+ if (
403
+ !this.metadata ||
404
+ !this.sessionToken ||
405
+ !this.identitySyncedToken ||
406
+ this.identitySyncedToken === this.sessionToken
407
+ ) {
408
+ return;
409
+ }
410
+
411
+ await this.identify(this.metadata);
412
+ }
296
413
  }
@@ -9,6 +9,7 @@ export class Product7 {
9
9
  constructor(config = {}) {
10
10
  this.config = this._validateAndMergeConfig(config);
11
11
  this.initialized = false;
12
+ this.identified = false;
12
13
  this.widgets = new Map();
13
14
  this.eventBus = new EventBus();
14
15
 
@@ -53,13 +54,14 @@ export class Product7 {
53
54
  this._injectStyles();
54
55
 
55
56
  try {
56
- const initData = await this.apiService.init(this.config.metadata);
57
+ const initData = await this.apiService.init();
57
58
 
58
59
  if (initData.config) {
59
60
  this.config = deepMerge(initData.config, this.config);
60
61
  }
61
62
 
62
63
  this.initialized = true;
64
+ const identifyResult = await this._syncConfiguredMetadataAfterInit();
63
65
  this.eventBus.emit('sdk:initialized', {
64
66
  config: this.config,
65
67
  sessionToken: initData.sessionToken,
@@ -70,6 +72,7 @@ export class Product7 {
70
72
  config: initData.config || {},
71
73
  sessionToken: initData.sessionToken,
72
74
  expiresIn: initData.expiresIn,
75
+ identified: Boolean(identifyResult?.identified),
73
76
  };
74
77
  } catch (error) {
75
78
  this.eventBus.emit('sdk:error', { error });
@@ -84,10 +87,14 @@ export class Product7 {
84
87
  );
85
88
  }
86
89
 
90
+ const requestedType = type || 'button';
91
+ const normalizedType = this._normalizeWidgetType(requestedType);
87
92
  const widgetId = generateId('widget');
88
- const widgetConfig = this._getWidgetTypeConfig(type);
89
- const explicitOptions = this._omitUndefined(options);
90
- const widgetEnabled = this._isWidgetEnabled(type, {
93
+ const widgetConfig = this._getWidgetTypeConfig(normalizedType);
94
+ const explicitOptions = this._omitUndefined(
95
+ this._normalizeWidgetOptions(normalizedType, options)
96
+ );
97
+ const widgetEnabled = this._isWidgetEnabled(normalizedType, {
91
98
  ...widgetConfig,
92
99
  ...explicitOptions,
93
100
  });
@@ -102,15 +109,35 @@ export class Product7 {
102
109
  };
103
110
 
104
111
  try {
105
- const widget = WidgetFactory.create(type, widgetOptions);
112
+ const widget = WidgetFactory.create(normalizedType, widgetOptions);
106
113
  this.widgets.set(widgetId, widget);
107
- this.eventBus.emit('widget:created', { widget, type });
114
+ this.eventBus.emit('widget:created', {
115
+ widget,
116
+ type: requestedType,
117
+ internalType: normalizedType,
118
+ });
108
119
  return widget;
109
120
  } catch (error) {
110
121
  throw new SDKError(`Failed to create widget: ${error.message}`, error);
111
122
  }
112
123
  }
113
124
 
125
+ createFeedbackWidget(options = {}) {
126
+ return this.createWidget('feedback', options);
127
+ }
128
+
129
+ createMessengerWidget(options = {}) {
130
+ return this.createWidget('messenger', options);
131
+ }
132
+
133
+ createChangelogWidget(options = {}) {
134
+ return this.createWidget('changelog', options);
135
+ }
136
+
137
+ createSurveyWidget(options = {}) {
138
+ return this.createWidget('survey', options);
139
+ }
140
+
114
141
  getWidget(id) {
115
142
  return this.widgets.get(id);
116
143
  }
@@ -229,7 +256,7 @@ export class Product7 {
229
256
  return null;
230
257
  }
231
258
 
232
- const surveyWidget = this.createWidget('survey', {
259
+ const surveyWidget = this.createSurveyWidget({
233
260
  surveyId: normalizedOptions.surveyId,
234
261
  surveyType:
235
262
  normalizedOptions.surveyType || normalizedOptions.type || 'nps',
@@ -463,18 +490,34 @@ export class Product7 {
463
490
  ? this.config.widgets
464
491
  : {};
465
492
 
466
- const legacyTypeConfig = this._isPlainObject(this.config?.[type])
467
- ? this.config[type]
468
- : {};
493
+ const mergedTypeConfig = this._getWidgetTypeAliases(type).reduce(
494
+ (config, alias) => {
495
+ const legacyTypeConfig = this._isPlainObject(this.config?.[alias])
496
+ ? this.config[alias]
497
+ : {};
469
498
 
470
- const namespacedTypeConfig = this._isPlainObject(widgetsConfig?.[type])
471
- ? widgetsConfig[type]
472
- : {};
499
+ const namespacedTypeConfig = this._isPlainObject(widgetsConfig?.[alias])
500
+ ? widgetsConfig[alias]
501
+ : {};
473
502
 
474
- const mergedTypeConfig = deepMerge(legacyTypeConfig, namespacedTypeConfig);
503
+ return deepMerge(
504
+ config,
505
+ deepMerge(legacyTypeConfig, namespacedTypeConfig)
506
+ );
507
+ },
508
+ {}
509
+ );
475
510
  return this._toCamelCaseObject(mergedTypeConfig);
476
511
  }
477
512
 
513
+ _getWidgetTypeAliases(type) {
514
+ if (type === 'button') {
515
+ return ['button', 'feedback'];
516
+ }
517
+
518
+ return [type];
519
+ }
520
+
478
521
  _isWidgetEnabled(type, options = {}) {
479
522
  const typeConfig = this._getWidgetTypeConfig(type);
480
523
  if (typeConfig.enabled === false) {
@@ -517,6 +560,31 @@ export class Product7 {
517
560
  return normalized;
518
561
  }
519
562
 
563
+ _normalizeWidgetType(type) {
564
+ if (type === 'feedback') {
565
+ return 'button';
566
+ }
567
+
568
+ return type;
569
+ }
570
+
571
+ _normalizeWidgetOptions(type, options = {}) {
572
+ if (!this._isPlainObject(options)) {
573
+ return options;
574
+ }
575
+
576
+ const normalizedOptions = { ...options };
577
+ if (
578
+ normalizedOptions.headless === true &&
579
+ normalizedOptions.trigger === undefined &&
580
+ type !== 'survey'
581
+ ) {
582
+ normalizedOptions.trigger = false;
583
+ }
584
+
585
+ return normalizedOptions;
586
+ }
587
+
520
588
  _omitUndefined(value) {
521
589
  if (!this._isPlainObject(value)) {
522
590
  return value;
@@ -561,7 +629,7 @@ export class Product7 {
561
629
  return null;
562
630
  }
563
631
 
564
- const changelogWidget = this.createWidget('changelog', {
632
+ const changelogWidget = this.createChangelogWidget({
565
633
  ...defaults,
566
634
  ...configDefaults,
567
635
  ...explicitOptions,
@@ -628,10 +696,15 @@ export class Product7 {
628
696
  }
629
697
 
630
698
  setMetadata(metadata) {
699
+ if (metadata) {
700
+ this._validateMetadata(metadata);
701
+ }
702
+
631
703
  this.config.metadata = metadata;
632
704
  if (this.apiService) {
633
705
  this.apiService.setMetadata(metadata);
634
706
  }
707
+ this.identified = false;
635
708
  this.eventBus.emit('metadata:updated', { metadata });
636
709
  }
637
710
 
@@ -642,11 +715,53 @@ export class Product7 {
642
715
  );
643
716
  }
644
717
 
718
+ async identify(metadata = this.config.metadata) {
719
+ if (!this.initialized) {
720
+ throw new SDKError(
721
+ 'SDK must be initialized before identifying users. Call init() first.'
722
+ );
723
+ }
724
+
725
+ if (!metadata) {
726
+ throw new SDKError(
727
+ 'Identify requires metadata. Provide at least user_id or email.'
728
+ );
729
+ }
730
+
731
+ this._validateMetadata(metadata);
732
+ this.setMetadata(metadata);
733
+
734
+ try {
735
+ const response = await this.apiService.identify(metadata);
736
+ const configPatch = this._extractIdentifyConfig(response);
737
+ if (Object.keys(configPatch).length > 0) {
738
+ this.updateConfig(configPatch);
739
+ }
740
+
741
+ this.identified = true;
742
+ this._applyIdentityToWidgets(metadata);
743
+
744
+ const result = {
745
+ identified: true,
746
+ metadata: this.getMetadata(),
747
+ response,
748
+ };
749
+
750
+ this.eventBus.emit('sdk:identified', result);
751
+ return result;
752
+ } catch (error) {
753
+ this.identified = false;
754
+ this.eventBus.emit('sdk:error', { error, phase: 'identify' });
755
+ throw new SDKError(`Failed to identify user: ${error.message}`, error);
756
+ }
757
+ }
758
+
645
759
  async reinitialize(newMetadata = null) {
646
760
  this.apiService.clearSession();
647
761
  this.initialized = false;
762
+ this.identified = false;
648
763
 
649
- if (newMetadata) {
764
+ if (newMetadata !== null) {
650
765
  this.setMetadata(newMetadata);
651
766
  }
652
767
 
@@ -675,10 +790,11 @@ export class Product7 {
675
790
 
676
791
  destroy() {
677
792
  this.destroyAllWidgets();
678
- this.eventBus.removeAllListeners();
793
+ this.eventBus.emit('sdk:destroyed');
794
+ this.eventBus.clear();
679
795
  this.apiService.clearSession();
680
796
  this.initialized = false;
681
- this.eventBus.emit('sdk:destroyed');
797
+ this.identified = false;
682
798
  }
683
799
 
684
800
  hasFeedbackBeenSubmitted(cooldownDays = 30) {
@@ -746,7 +862,7 @@ export class Product7 {
746
862
  metadata: null,
747
863
  position: 'right',
748
864
  theme: 'light',
749
- boardName: 'general',
865
+ boardName: 'feature-requests',
750
866
  autoShow: true,
751
867
  debug: false,
752
868
  mock: false,
@@ -797,7 +913,12 @@ export class Product7 {
797
913
 
798
914
  _bindMethods() {
799
915
  this.createWidget = this.createWidget.bind(this);
916
+ this.createFeedbackWidget = this.createFeedbackWidget.bind(this);
917
+ this.createMessengerWidget = this.createMessengerWidget.bind(this);
918
+ this.createChangelogWidget = this.createChangelogWidget.bind(this);
919
+ this.createSurveyWidget = this.createSurveyWidget.bind(this);
800
920
  this.destroyWidget = this.destroyWidget.bind(this);
921
+ this.identify = this.identify.bind(this);
801
922
  this.updateConfig = this.updateConfig.bind(this);
802
923
  }
803
924
 
@@ -833,4 +954,47 @@ export class Product7 {
833
954
  : undefined,
834
955
  };
835
956
  }
957
+
958
+ async _syncConfiguredMetadataAfterInit() {
959
+ if (
960
+ !this.config.metadata ||
961
+ this.apiService.identitySyncedToken === this.apiService.sessionToken
962
+ ) {
963
+ return null;
964
+ }
965
+
966
+ try {
967
+ return await this.identify(this.config.metadata);
968
+ } catch (error) {
969
+ if (this.config.debug) {
970
+ console.warn('[Product7] Initial identify failed:', error);
971
+ }
972
+ return null;
973
+ }
974
+ }
975
+
976
+ _extractIdentifyConfig(response) {
977
+ const payload = this._isPlainObject(response?.data)
978
+ ? response.data
979
+ : response || {};
980
+ const configPatch = this._isPlainObject(payload.config)
981
+ ? payload.config
982
+ : {};
983
+
984
+ if (payload.last_feedback_at !== undefined) {
985
+ configPatch.last_feedback_at = payload.last_feedback_at;
986
+ } else if (payload.lastFeedbackAt !== undefined) {
987
+ configPatch.last_feedback_at = payload.lastFeedbackAt;
988
+ }
989
+
990
+ return this._omitUndefined(configPatch);
991
+ }
992
+
993
+ _applyIdentityToWidgets(metadata) {
994
+ for (const widget of this.widgets.values()) {
995
+ if (typeof widget.applyIdentity === 'function') {
996
+ widget.applyIdentity(metadata);
997
+ }
998
+ }
999
+ }
836
1000
  }