@product7/feedback-sdk 1.0.1

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,338 @@
1
+ import { APIError } from '../utils/errors.js';
2
+
3
+ export class APIService {
4
+ constructor(config = {}) {
5
+ this.workspace = config.workspace;
6
+ this.sessionToken = null;
7
+ this.sessionExpiry = null;
8
+ this.userContext = config.userContext || null;
9
+
10
+ // Construct workspace-specific API URL
11
+ if (config.apiUrl) {
12
+ // If explicitly provided, use it
13
+ this.baseURL = config.apiUrl;
14
+ } else if (this.workspace) {
15
+ // Construct from workspace: workspace.api.staging.product7.io/api/v1
16
+ this.baseURL = `https://${this.workspace}.api.staging.product7.io/api/v1`;
17
+ } else {
18
+ // Fallback to default
19
+ this.baseURL = 'https://api.staging.product7.io/api/v1';
20
+ }
21
+
22
+ console.log('[APIService] Using API URL:', this.baseURL);
23
+
24
+ // Try to load existing session from localStorage
25
+ this._loadStoredSession();
26
+ }
27
+
28
+ async init(userContext = null) {
29
+ console.log('[APIService] Starting initialization...');
30
+
31
+ if (userContext) {
32
+ this.userContext = userContext;
33
+ }
34
+
35
+ // Check if we have a valid session token
36
+ if (this.isSessionValid()) {
37
+ console.log('[APIService] Found valid existing session');
38
+ return { sessionToken: this.sessionToken };
39
+ }
40
+
41
+ // Try to get user context from various sources if not provided
42
+ if (!this.userContext) {
43
+ console.log(
44
+ '[APIService] No user context provided, attempting auto-detection...'
45
+ );
46
+ this.userContext = this._getUserContextFromStorage();
47
+ }
48
+
49
+ if (!this.userContext || !this.workspace) {
50
+ const error = `Missing ${!this.workspace ? 'workspace' : 'user context'} for initialization`;
51
+ console.error('[APIService]', error);
52
+ throw new APIError(400, error);
53
+ }
54
+
55
+ console.log('[APIService] User context detected:', this.userContext);
56
+
57
+ const payload = {
58
+ workspace: this.workspace,
59
+ user: this.userContext,
60
+ };
61
+
62
+ console.log(
63
+ '[APIService] Making init request to:',
64
+ `${this.baseURL}/widget/init`
65
+ );
66
+ console.log('[APIService] Payload:', payload);
67
+
68
+ try {
69
+ const response = await this._makeRequest('/widget/init', {
70
+ method: 'POST',
71
+ body: JSON.stringify(payload),
72
+ headers: {
73
+ 'Content-Type': 'application/json',
74
+ },
75
+ });
76
+
77
+ console.log('[APIService] Init response:', response);
78
+
79
+ // Store session token and expiry
80
+ this.sessionToken = response.session_token;
81
+ this.sessionExpiry = new Date(Date.now() + response.expires_in * 1000);
82
+
83
+ // Store session in localStorage for persistence
84
+ this._storeSession();
85
+
86
+ return {
87
+ sessionToken: this.sessionToken,
88
+ config: response.config || {},
89
+ expiresIn: response.expires_in,
90
+ };
91
+ } catch (error) {
92
+ console.error('[APIService] Init failed:', error);
93
+ throw new APIError(
94
+ error.status || 500,
95
+ `Failed to initialize widget: ${error.message}`,
96
+ error.response
97
+ );
98
+ }
99
+ }
100
+
101
+ async submitFeedback(feedbackData) {
102
+ // Ensure we have a valid session
103
+ if (!this.isSessionValid()) {
104
+ await this.init();
105
+ }
106
+
107
+ if (!this.sessionToken) {
108
+ throw new APIError(401, 'No valid session token available');
109
+ }
110
+
111
+ const payload = {
112
+ board:
113
+ feedbackData.board_id || feedbackData.board || feedbackData.boardId,
114
+ title: feedbackData.title,
115
+ content: feedbackData.content,
116
+ attachments: feedbackData.attachments || [],
117
+ };
118
+
119
+ try {
120
+ const response = await this._makeRequest('/widget/feedback', {
121
+ method: 'POST',
122
+ body: JSON.stringify(payload),
123
+ headers: {
124
+ 'Content-Type': 'application/json',
125
+ Authorization: `Bearer ${this.sessionToken}`,
126
+ },
127
+ });
128
+
129
+ return response;
130
+ } catch (error) {
131
+ // If session expired, try to reinitialize once
132
+ if (error.status === 401) {
133
+ this.sessionToken = null;
134
+ this.sessionExpiry = null;
135
+ await this.init();
136
+
137
+ // Retry the request with new session
138
+ return this.submitFeedback(feedbackData);
139
+ }
140
+
141
+ throw new APIError(
142
+ error.status || 500,
143
+ `Failed to submit feedback: ${error.message}`,
144
+ error.response
145
+ );
146
+ }
147
+ }
148
+
149
+ isSessionValid() {
150
+ return (
151
+ this.sessionToken && this.sessionExpiry && new Date() < this.sessionExpiry
152
+ );
153
+ }
154
+
155
+ setUserContext(userContext) {
156
+ this.userContext = userContext;
157
+ // Store in localStorage for persistence
158
+ if (typeof localStorage !== 'undefined') {
159
+ localStorage.setItem(
160
+ 'feedbackSDK_userContext',
161
+ JSON.stringify(userContext)
162
+ );
163
+ }
164
+ }
165
+
166
+ getUserContext() {
167
+ return this.userContext;
168
+ }
169
+
170
+ clearSession() {
171
+ this.sessionToken = null;
172
+ this.sessionExpiry = null;
173
+ if (typeof localStorage !== 'undefined') {
174
+ localStorage.removeItem('feedbackSDK_session');
175
+ }
176
+ }
177
+
178
+ _getUserContextFromStorage() {
179
+ if (typeof localStorage === 'undefined') return null;
180
+
181
+ console.log('[APIService] Attempting to detect user from storage...');
182
+
183
+ try {
184
+ // Try to get from feedbackSDK specific storage first
185
+ const stored = localStorage.getItem('feedbackSDK_userContext');
186
+ if (stored) {
187
+ console.log('[APIService] Found user context in feedbackSDK storage');
188
+ return JSON.parse(stored);
189
+ }
190
+
191
+ // Try to get from window object (if set by parent application)
192
+ if (typeof window !== 'undefined' && window.FeedbackSDKUserContext) {
193
+ console.log(
194
+ '[APIService] Found user context in window.FeedbackSDKUserContext'
195
+ );
196
+ return window.FeedbackSDKUserContext;
197
+ }
198
+
199
+ // Check window.currentUser
200
+ if (typeof window !== 'undefined' && window.currentUser) {
201
+ console.log('[APIService] Found user context in window.currentUser');
202
+ return this._mapUserData(window.currentUser);
203
+ }
204
+
205
+ // Try to extract from existing session storage
206
+ const authSources = ['auth', 'currentUser', 'userSession', 'user'];
207
+
208
+ for (const key of authSources) {
209
+ const sessionData = localStorage.getItem(key);
210
+ if (sessionData) {
211
+ console.log(`[APIService] Found data in localStorage.${key}`);
212
+ const parsed = JSON.parse(sessionData);
213
+
214
+ // Handle nested user data (like {user: {...}, token: ...})
215
+ const userData = parsed.user || parsed;
216
+
217
+ if (userData && (userData.id || userData.user_id || userData.email)) {
218
+ console.log('[APIService] Successfully mapped user data');
219
+ return this._mapUserData(userData);
220
+ }
221
+ }
222
+ }
223
+
224
+ console.log('[APIService] No user context found in any storage location');
225
+ return null;
226
+ } catch (error) {
227
+ console.warn(
228
+ '[FeedbackSDK] Failed to load user context from storage:',
229
+ error
230
+ );
231
+ return null;
232
+ }
233
+ }
234
+
235
+ _mapUserData(userData) {
236
+ console.log('[APIService] Mapping user data:', userData);
237
+
238
+ const mapped = {
239
+ user_id: userData.id || userData.user_id || userData.userId,
240
+ email: userData.email,
241
+ name: userData.name || userData.displayName || userData.full_name,
242
+ custom_fields: {},
243
+ company: {},
244
+ };
245
+
246
+ // Map company data if available
247
+ if (userData.company || userData.organization) {
248
+ const company = userData.company || userData.organization;
249
+ mapped.company = {
250
+ id: company.id || company.company_id,
251
+ name: company.name || company.company_name,
252
+ monthly_spend: company.monthly_spend || company.spend,
253
+ };
254
+ }
255
+
256
+ // Map any additional custom fields
257
+ const customFieldKeys = ['plan', 'role', 'tier', 'subscription'];
258
+ customFieldKeys.forEach((key) => {
259
+ if (userData[key]) {
260
+ mapped.custom_fields[key] = userData[key];
261
+ }
262
+ });
263
+
264
+ console.log('[APIService] Mapped result:', mapped);
265
+ return mapped;
266
+ }
267
+
268
+ _storeSession() {
269
+ if (typeof localStorage === 'undefined') return;
270
+
271
+ try {
272
+ const sessionData = {
273
+ token: this.sessionToken,
274
+ expiry: this.sessionExpiry.toISOString(),
275
+ workspace: this.workspace,
276
+ };
277
+ localStorage.setItem('feedbackSDK_session', JSON.stringify(sessionData));
278
+ } catch (error) {
279
+ console.warn('[FeedbackSDK] Failed to store session:', error);
280
+ }
281
+ }
282
+
283
+ _loadStoredSession() {
284
+ if (typeof localStorage === 'undefined') return false;
285
+
286
+ try {
287
+ const stored = localStorage.getItem('feedbackSDK_session');
288
+ if (!stored) return false;
289
+
290
+ const sessionData = JSON.parse(stored);
291
+ this.sessionToken = sessionData.token;
292
+ this.sessionExpiry = new Date(sessionData.expiry);
293
+
294
+ return this.isSessionValid();
295
+ } catch (error) {
296
+ console.warn('[FeedbackSDK] Failed to load stored session:', error);
297
+ return false;
298
+ }
299
+ }
300
+
301
+ async _makeRequest(endpoint, options = {}) {
302
+ const url = `${this.baseURL}${endpoint}`;
303
+ console.log('[APIService] Making request to:', url);
304
+
305
+ try {
306
+ const response = await fetch(url, options);
307
+
308
+ if (!response.ok) {
309
+ let errorMessage = `HTTP ${response.status}`;
310
+ let responseData = null;
311
+
312
+ try {
313
+ responseData = await response.json();
314
+ errorMessage =
315
+ responseData.message || responseData.error || errorMessage;
316
+ } catch (e) {
317
+ errorMessage = (await response.text()) || errorMessage;
318
+ }
319
+
320
+ throw new APIError(response.status, errorMessage, responseData);
321
+ }
322
+
323
+ const contentType = response.headers.get('content-type');
324
+ if (contentType && contentType.includes('application/json')) {
325
+ return await response.json();
326
+ }
327
+
328
+ return await response.text();
329
+ } catch (error) {
330
+ if (error instanceof APIError) {
331
+ throw error;
332
+ }
333
+
334
+ // Network or other errors
335
+ throw new APIError(0, error.message, null);
336
+ }
337
+ }
338
+ }
@@ -0,0 +1,54 @@
1
+ export class EventBus {
2
+ constructor() {
3
+ this.events = new Map();
4
+ }
5
+
6
+ on(event, callback) {
7
+ if (!this.events.has(event)) {
8
+ this.events.set(event, []);
9
+ }
10
+ this.events.get(event).push(callback);
11
+
12
+ return () => this.off(event, callback);
13
+ }
14
+
15
+ off(event, callback) {
16
+ const callbacks = this.events.get(event);
17
+ if (callbacks) {
18
+ const index = callbacks.indexOf(callback);
19
+ if (index > -1) {
20
+ callbacks.splice(index, 1);
21
+ }
22
+ }
23
+ }
24
+
25
+ emit(event, data) {
26
+ const callbacks = this.events.get(event);
27
+ if (callbacks) {
28
+ callbacks.forEach((callback) => {
29
+ try {
30
+ callback(data);
31
+ } catch (error) {
32
+ console.error('[FeedbackSDK] Event callback error:', error);
33
+ }
34
+ });
35
+ }
36
+ }
37
+
38
+ once(event, callback) {
39
+ const unsubscribe = this.on(event, (data) => {
40
+ callback(data);
41
+ unsubscribe();
42
+ });
43
+ return unsubscribe;
44
+ }
45
+
46
+ clear() {
47
+ this.events.clear();
48
+ }
49
+
50
+ getListenerCount(event) {
51
+ const callbacks = this.events.get(event);
52
+ return callbacks ? callbacks.length : 0;
53
+ }
54
+ }
@@ -0,0 +1,285 @@
1
+ import { ConfigError, SDKError } from '../utils/errors.js';
2
+ import { deepMerge, generateId } from '../utils/helpers.js';
3
+ import { WidgetFactory } from '../widgets/WidgetFactory.js';
4
+ import { APIService } from './APIService.js';
5
+ import { EventBus } from './EventBus.js';
6
+
7
+ export class FeedbackSDK {
8
+ constructor(config = {}) {
9
+ this.config = this._validateAndMergeConfig(config);
10
+ this.initialized = false;
11
+ this.widgets = new Map();
12
+ this.eventBus = new EventBus();
13
+
14
+ // Initialize API service
15
+ this.apiService = new APIService({
16
+ apiUrl: this.config.apiUrl,
17
+ workspace: this.config.workspace,
18
+ userContext: this.config.userContext,
19
+ });
20
+
21
+ this._bindMethods();
22
+ }
23
+
24
+ async init() {
25
+ if (this.initialized) {
26
+ return { alreadyInitialized: true };
27
+ }
28
+
29
+ try {
30
+ // Initialize the API service (this will handle the /widget/init call)
31
+ const initData = await this.apiService.init(this.config.userContext);
32
+
33
+ // Merge any server-provided config with local config
34
+ if (initData.config) {
35
+ this.config = deepMerge(this.config, initData.config);
36
+ }
37
+
38
+ this.initialized = true;
39
+ this.eventBus.emit('sdk:initialized', {
40
+ config: this.config,
41
+ sessionToken: initData.sessionToken,
42
+ });
43
+
44
+ return {
45
+ initialized: true,
46
+ config: initData.config || {},
47
+ sessionToken: initData.sessionToken,
48
+ expiresIn: initData.expiresIn,
49
+ };
50
+ } catch (error) {
51
+ this.eventBus.emit('sdk:error', { error });
52
+ throw new SDKError(`Failed to initialize SDK: ${error.message}`, error);
53
+ }
54
+ }
55
+
56
+ createWidget(type = 'button', options = {}) {
57
+ if (!this.initialized) {
58
+ throw new SDKError(
59
+ 'SDK must be initialized before creating widgets. Call init() first.'
60
+ );
61
+ }
62
+
63
+ const widgetId = generateId('widget');
64
+ const widgetOptions = {
65
+ id: widgetId,
66
+ sdk: this,
67
+ apiService: this.apiService,
68
+ ...this.config,
69
+ ...options,
70
+ };
71
+
72
+ try {
73
+ const widget = WidgetFactory.create(type, widgetOptions);
74
+ this.widgets.set(widgetId, widget);
75
+
76
+ this.eventBus.emit('widget:created', { widget, type });
77
+ return widget;
78
+ } catch (error) {
79
+ throw new SDKError(`Failed to create widget: ${error.message}`, error);
80
+ }
81
+ }
82
+
83
+ getWidget(id) {
84
+ return this.widgets.get(id);
85
+ }
86
+
87
+ getAllWidgets() {
88
+ return Array.from(this.widgets.values());
89
+ }
90
+
91
+ destroyWidget(id) {
92
+ const widget = this.widgets.get(id);
93
+ if (widget) {
94
+ widget.destroy();
95
+ this.widgets.delete(id);
96
+ this.eventBus.emit('widget:removed', { widgetId: id });
97
+ return true;
98
+ }
99
+ return false;
100
+ }
101
+
102
+ destroyAllWidgets() {
103
+ for (const widget of this.widgets.values()) {
104
+ widget.destroy();
105
+ }
106
+ this.widgets.clear();
107
+ this.eventBus.emit('widgets:cleared');
108
+ }
109
+
110
+ updateConfig(newConfig) {
111
+ const oldConfig = { ...this.config };
112
+ this.config = this._validateAndMergeConfig(newConfig, this.config);
113
+
114
+ // Update all existing widgets with new config
115
+ for (const widget of this.widgets.values()) {
116
+ widget.handleConfigUpdate(this.config);
117
+ }
118
+
119
+ this.eventBus.emit('config:updated', {
120
+ oldConfig,
121
+ newConfig: this.config,
122
+ });
123
+ }
124
+
125
+ setUserContext(userContext) {
126
+ this.config.userContext = userContext;
127
+ if (this.apiService) {
128
+ this.apiService.setUserContext(userContext);
129
+ }
130
+ this.eventBus.emit('user:updated', { userContext });
131
+ }
132
+
133
+ getUserContext() {
134
+ return (
135
+ this.config.userContext ||
136
+ (this.apiService ? this.apiService.getUserContext() : null)
137
+ );
138
+ }
139
+
140
+ async reinitialize(newUserContext = null) {
141
+ // Clear current session
142
+ this.apiService.clearSession();
143
+ this.initialized = false;
144
+
145
+ // Update user context if provided
146
+ if (newUserContext) {
147
+ this.setUserContext(newUserContext);
148
+ }
149
+
150
+ // Reinitialize
151
+ return this.init();
152
+ }
153
+
154
+ on(event, callback) {
155
+ this.eventBus.on(event, callback);
156
+ return this;
157
+ }
158
+
159
+ off(event, callback) {
160
+ this.eventBus.off(event, callback);
161
+ return this;
162
+ }
163
+
164
+ once(event, callback) {
165
+ this.eventBus.once(event, callback);
166
+ return this;
167
+ }
168
+
169
+ emit(event, data) {
170
+ this.eventBus.emit(event, data);
171
+ return this;
172
+ }
173
+
174
+ destroy() {
175
+ this.destroyAllWidgets();
176
+ this.eventBus.removeAllListeners();
177
+ this.apiService.clearSession();
178
+ this.initialized = false;
179
+ this.eventBus.emit('sdk:destroyed');
180
+ }
181
+
182
+ _validateAndMergeConfig(newConfig, existingConfig = {}) {
183
+ const defaultConfig = {
184
+ apiUrl: null,
185
+ workspace: null,
186
+ userContext: null,
187
+ position: 'bottom-right',
188
+ theme: 'light',
189
+ boardId: 'general',
190
+ autoShow: true,
191
+ debug: false,
192
+ };
193
+
194
+ const mergedConfig = deepMerge(
195
+ deepMerge(defaultConfig, existingConfig),
196
+ newConfig
197
+ );
198
+
199
+ // Validate required config
200
+ const requiredFields = ['workspace'];
201
+ const missingFields = requiredFields.filter(
202
+ (field) => !mergedConfig[field]
203
+ );
204
+
205
+ if (missingFields.length > 0) {
206
+ throw new ConfigError(
207
+ `Missing required configuration: ${missingFields.join(', ')}`
208
+ );
209
+ }
210
+
211
+ // Validate userContext structure if provided
212
+ if (mergedConfig.userContext) {
213
+ this._validateUserContext(mergedConfig.userContext);
214
+ }
215
+
216
+ return mergedConfig;
217
+ }
218
+
219
+ _validateUserContext(userContext) {
220
+ if (!userContext.user_id && !userContext.email) {
221
+ throw new ConfigError(
222
+ 'User context must include at least user_id or email'
223
+ );
224
+ }
225
+
226
+ // Validate structure matches expected API format
227
+ const validStructure = {
228
+ user_id: 'string',
229
+ email: 'string',
230
+ name: 'string',
231
+ custom_fields: 'object',
232
+ company: 'object',
233
+ };
234
+
235
+ for (const [key, expectedType] of Object.entries(validStructure)) {
236
+ if (userContext[key] && typeof userContext[key] !== expectedType) {
237
+ throw new ConfigError(
238
+ `User context field '${key}' must be of type '${expectedType}'`
239
+ );
240
+ }
241
+ }
242
+ }
243
+
244
+ _bindMethods() {
245
+ this.createWidget = this.createWidget.bind(this);
246
+ this.destroyWidget = this.destroyWidget.bind(this);
247
+ this.updateConfig = this.updateConfig.bind(this);
248
+ }
249
+
250
+ // Static helper methods
251
+ static create(config) {
252
+ return new FeedbackSDK(config);
253
+ }
254
+
255
+ static async createAndInit(config) {
256
+ const sdk = new FeedbackSDK(config);
257
+ await sdk.init();
258
+ return sdk;
259
+ }
260
+
261
+ // Utility methods for external integrations
262
+ static extractUserContextFromAuth(authData) {
263
+ // Helper method to extract user context from common auth structures
264
+ if (!authData) return null;
265
+
266
+ return {
267
+ user_id: authData.sub || authData.id || authData.user_id,
268
+ email: authData.email,
269
+ name: authData.name || authData.display_name || authData.full_name,
270
+ custom_fields: {
271
+ role: authData.role,
272
+ plan: authData.plan || authData.subscription?.plan,
273
+ ...(authData.custom_fields || {}),
274
+ },
275
+ company:
276
+ authData.company || authData.organization
277
+ ? {
278
+ id: authData.company?.id || authData.organization?.id,
279
+ name: authData.company?.name || authData.organization?.name,
280
+ monthly_spend: authData.company?.monthly_spend,
281
+ }
282
+ : undefined,
283
+ };
284
+ }
285
+ }