@product7/feedback-sdk 1.2.7 → 1.3.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.
@@ -0,0 +1,245 @@
1
+ import { APIError } from '../utils/errors.js';
2
+
3
+ export class BaseAPIService {
4
+ constructor(config = {}) {
5
+ this.workspace = config.workspace;
6
+ this.sessionToken = null;
7
+ this.sessionExpiry = null;
8
+ this.userContext = config.userContext || null;
9
+ this.mock = config.mock || false;
10
+ this.env = config.env || 'production';
11
+ this.baseURL = this._getBaseURL(config);
12
+
13
+ this._loadStoredSession();
14
+ }
15
+
16
+ _getBaseURL(config) {
17
+ if (config.apiUrl) return config.apiUrl;
18
+
19
+ const ENV_URLS = {
20
+ production: {
21
+ base: 'https://api.product7.io/api/v1',
22
+ withWorkspace: (ws) => `https://${ws}.api.product7.io/api/v1`,
23
+ },
24
+ staging: {
25
+ base: 'https://staging.api.product7.io/api/v1',
26
+ withWorkspace: (ws) => `https://${ws}.staging.api.product7.io/api/v1`,
27
+ },
28
+ };
29
+
30
+ const envConfig = ENV_URLS[this.env] || ENV_URLS.production;
31
+ return this.workspace
32
+ ? envConfig.withWorkspace(this.workspace)
33
+ : envConfig.base;
34
+ }
35
+
36
+ async init(userContext = null) {
37
+ if (userContext) {
38
+ this.userContext = userContext;
39
+ }
40
+
41
+ if (this.isSessionValid()) {
42
+ return { sessionToken: this.sessionToken };
43
+ }
44
+
45
+ if (!this.workspace || !this.userContext) {
46
+ throw new APIError(
47
+ 400,
48
+ `Missing ${!this.workspace ? 'workspace' : 'user context'} for initialization`
49
+ );
50
+ }
51
+
52
+ if (this.mock) {
53
+ return this._initMockSession();
54
+ }
55
+
56
+ return this._initRealSession();
57
+ }
58
+
59
+ async _initMockSession() {
60
+ this.sessionToken = 'mock_session_' + Date.now();
61
+ this.sessionExpiry = new Date(Date.now() + 3600 * 1000);
62
+ this._storeSession();
63
+ return {
64
+ sessionToken: this.sessionToken,
65
+ config: {
66
+ primaryColor: '#21244A',
67
+ backgroundColor: '#ffffff',
68
+ textColor: '#1F2937',
69
+ boardId: 'feature-requests',
70
+ size: 'medium',
71
+ displayMode: 'modal',
72
+ },
73
+ expiresIn: 3600,
74
+ };
75
+ }
76
+
77
+ async _initRealSession() {
78
+ const payload = {
79
+ workspace: this.workspace,
80
+ user: this.userContext,
81
+ };
82
+
83
+ try {
84
+ const response = await this._makeRequest('/widget/init', {
85
+ method: 'POST',
86
+ body: JSON.stringify(payload),
87
+ headers: { 'Content-Type': 'application/json' },
88
+ });
89
+
90
+ this.sessionToken = response.session_token;
91
+ this.sessionExpiry = new Date(Date.now() + response.expires_in * 1000);
92
+ this._storeSession();
93
+
94
+ return {
95
+ sessionToken: this.sessionToken,
96
+ config: response.config || {},
97
+ expiresIn: response.expires_in,
98
+ };
99
+ } catch (error) {
100
+ throw new APIError(
101
+ error.status || 500,
102
+ `Failed to initialize widget: ${error.message}`,
103
+ error.response
104
+ );
105
+ }
106
+ }
107
+
108
+ async _ensureSession() {
109
+ if (!this.isSessionValid()) {
110
+ await this.init();
111
+ }
112
+ if (!this.sessionToken) {
113
+ throw new APIError(401, 'No valid session token available');
114
+ }
115
+ }
116
+
117
+ async _handleAuthRetry(method, ...args) {
118
+ try {
119
+ return await method.apply(this, args);
120
+ } catch (error) {
121
+ if (error.status === 401) {
122
+ this.sessionToken = null;
123
+ this.sessionExpiry = null;
124
+ await this.init();
125
+ return await method.apply(this, args);
126
+ }
127
+ throw error;
128
+ }
129
+ }
130
+
131
+ isSessionValid() {
132
+ return (
133
+ this.sessionToken && this.sessionExpiry && new Date() < this.sessionExpiry
134
+ );
135
+ }
136
+
137
+ setUserContext(userContext) {
138
+ this.userContext = userContext;
139
+ this._storeData('feedbackSDK_userContext', userContext);
140
+ }
141
+
142
+ getUserContext() {
143
+ return this.userContext;
144
+ }
145
+
146
+ clearSession() {
147
+ this.sessionToken = null;
148
+ this.sessionExpiry = null;
149
+ this._removeData('feedbackSDK_session');
150
+ this._removeData('feedbackSDK_userContext');
151
+ }
152
+
153
+ _storeSession() {
154
+ if (typeof localStorage === 'undefined') return;
155
+ try {
156
+ const sessionData = {
157
+ token: this.sessionToken,
158
+ expiry: this.sessionExpiry.toISOString(),
159
+ workspace: this.workspace,
160
+ };
161
+ this._storeData('feedbackSDK_session', sessionData);
162
+ } catch (error) {
163
+ // Silent fail
164
+ }
165
+ }
166
+
167
+ _loadStoredSession() {
168
+ if (typeof localStorage === 'undefined') return false;
169
+ try {
170
+ const stored = localStorage.getItem('feedbackSDK_session');
171
+ if (!stored) return false;
172
+
173
+ const sessionData = JSON.parse(stored);
174
+ this.sessionToken = sessionData.token;
175
+ this.sessionExpiry = new Date(sessionData.expiry);
176
+
177
+ return this.isSessionValid();
178
+ } catch (error) {
179
+ return false;
180
+ }
181
+ }
182
+
183
+ _storeData(key, value) {
184
+ if (typeof localStorage !== 'undefined') {
185
+ localStorage.setItem(key, JSON.stringify(value));
186
+ }
187
+ }
188
+
189
+ _removeData(key) {
190
+ if (typeof localStorage !== 'undefined') {
191
+ localStorage.removeItem(key);
192
+ }
193
+ }
194
+
195
+ async _makeRequest(endpoint, options = {}) {
196
+ const url = `${this.baseURL}${endpoint}`;
197
+
198
+ try {
199
+ const response = await fetch(url, options);
200
+
201
+ if (!response.ok) {
202
+ let errorMessage = `HTTP ${response.status}`;
203
+ let responseData = null;
204
+
205
+ try {
206
+ responseData = await response.json();
207
+ errorMessage =
208
+ responseData.message || responseData.error || errorMessage;
209
+ } catch (e) {
210
+ errorMessage = (await response.text()) || errorMessage;
211
+ }
212
+
213
+ throw new APIError(response.status, errorMessage, responseData);
214
+ }
215
+
216
+ const contentType = response.headers.get('content-type');
217
+ if (contentType && contentType.includes('application/json')) {
218
+ return await response.json();
219
+ }
220
+
221
+ return await response.text();
222
+ } catch (error) {
223
+ if (error instanceof APIError) throw error;
224
+ throw new APIError(0, error.message, null);
225
+ }
226
+ }
227
+
228
+ _buildQueryParams(params) {
229
+ const queryParams = new URLSearchParams();
230
+ Object.entries(params).forEach(([key, value]) => {
231
+ if (value !== undefined && value !== null) {
232
+ queryParams.append(
233
+ key,
234
+ typeof value === 'object' ? JSON.stringify(value) : value
235
+ );
236
+ }
237
+ });
238
+ return queryParams.toString();
239
+ }
240
+
241
+ _getEndpointWithParams(endpoint, params) {
242
+ const queryString = this._buildQueryParams(params);
243
+ return `${endpoint}${queryString ? '?' + queryString : ''}`;
244
+ }
245
+ }
@@ -30,7 +30,6 @@ export class FeedbackSDK {
30
30
  try {
31
31
  const initData = await this.apiService.init(this.config.userContext);
32
32
 
33
- // Merge backend config as base, local config overrides
34
33
  if (initData.config) {
35
34
  this.config = deepMerge(initData.config, this.config);
36
35
  }
@@ -83,13 +82,6 @@ export class FeedbackSDK {
83
82
  return this.widgets.get(id);
84
83
  }
85
84
 
86
- /**
87
- * Fetch active surveys from the backend
88
- * @param {Object} context - Optional context for targeting
89
- * @param {string} context.page - Current page/route
90
- * @param {string} context.event - Event trigger name
91
- * @returns {Promise<Array>} Array of active survey configurations
92
- */
93
85
  async getActiveSurveys(context = {}) {
94
86
  if (!this.initialized) {
95
87
  throw new SDKError(
@@ -109,17 +101,6 @@ export class FeedbackSDK {
109
101
  }
110
102
  }
111
103
 
112
- /**
113
- * Show a survey by its backend ID
114
- * Fetches survey configuration from the backend and displays it
115
- * @param {string} surveyId - The backend survey ID
116
- * @param {Object} options - Additional display options
117
- * @param {string} options.position - Position override
118
- * @param {string} options.theme - Theme override
119
- * @param {Function} options.onSubmit - Callback when survey is submitted
120
- * @param {Function} options.onDismiss - Callback when survey is dismissed
121
- * @returns {Promise<SurveyWidget>} The survey widget instance
122
- */
123
104
  async showSurveyById(surveyId, options = {}) {
124
105
  if (!this.initialized) {
125
106
  throw new SDKError(
@@ -127,7 +108,6 @@ export class FeedbackSDK {
127
108
  );
128
109
  }
129
110
 
130
- // Fetch active surveys to find the one with matching ID
131
111
  const surveys = await this.getActiveSurveys();
132
112
  const surveyConfig = surveys.find((s) => s.id === surveyId);
133
113
 
@@ -149,22 +129,6 @@ export class FeedbackSDK {
149
129
  });
150
130
  }
151
131
 
152
- /**
153
- * Show a survey widget (local/manual mode)
154
- * For backend-driven surveys, use showSurveyById() instead
155
- * @param {Object} options - Survey options
156
- * @param {string} options.surveyId - Backend survey ID (for API tracking)
157
- * @param {string} options.surveyType - Type of survey: 'nps', 'csat', 'ces', 'custom'
158
- * @param {string} options.position - Position: 'bottom-right', 'bottom-left', 'center', 'bottom'
159
- * @param {string} options.theme - Theme: 'light', 'dark'
160
- * @param {string} options.title - Custom title
161
- * @param {string} options.description - Custom description
162
- * @param {string} options.lowLabel - Low end label
163
- * @param {string} options.highLabel - High end label
164
- * @param {Function} options.onSubmit - Callback when survey is submitted
165
- * @param {Function} options.onDismiss - Callback when survey is dismissed
166
- * @returns {SurveyWidget} The survey widget instance
167
- */
168
132
  showSurvey(options = {}) {
169
133
  if (!this.initialized) {
170
134
  throw new SDKError(
@@ -192,20 +156,6 @@ export class FeedbackSDK {
192
156
  return surveyWidget;
193
157
  }
194
158
 
195
- /**
196
- * Show a changelog widget with sidebar
197
- * @param {Object} options - Changelog widget options
198
- * @param {string} options.position - Position: 'bottom-right', 'bottom-left', 'top-right', 'top-left'
199
- * @param {string} options.theme - Theme: 'light', 'dark'
200
- * @param {string} options.title - Sidebar title
201
- * @param {string} options.triggerText - Text on the trigger button
202
- * @param {boolean} options.showBadge - Show notification badge
203
- * @param {string} options.viewButtonText - Text for the view update button
204
- * @param {string} options.changelogBaseUrl - Base URL for changelog links
205
- * @param {boolean} options.openInNewTab - Open changelog links in new tab (default: true)
206
- * @param {Function} options.onViewUpdate - Callback when user clicks view update
207
- * @returns {ChangelogWidget} The changelog widget instance
208
- */
209
159
  showChangelog(options = {}) {
210
160
  if (!this.initialized) {
211
161
  throw new SDKError(
@@ -231,13 +181,6 @@ export class FeedbackSDK {
231
181
  return changelogWidget;
232
182
  }
233
183
 
234
- /**
235
- * Get changelogs from the backend
236
- * @param {Object} options - Optional query parameters
237
- * @param {number} options.limit - Number of changelogs to fetch
238
- * @param {number} options.offset - Offset for pagination
239
- * @returns {Promise<Array>} Array of changelog entries
240
- */
241
184
  async getChangelogs(options = {}) {
242
185
  if (!this.initialized) {
243
186
  throw new SDKError(
@@ -345,17 +288,10 @@ export class FeedbackSDK {
345
288
  this.eventBus.emit('sdk:destroyed');
346
289
  }
347
290
 
348
- /**
349
- * Check if feedback was recently submitted for this workspace
350
- * Uses backend tracking (preferred) with localStorage as fallback
351
- * @param {number} cooldownDays - Days to consider as "recently" (default: 30)
352
- * @returns {boolean} true if feedback was submitted within the cooldown period
353
- */
354
291
  hasFeedbackBeenSubmitted(cooldownDays = 30) {
355
292
  const cooldownMs = cooldownDays * 24 * 60 * 60 * 1000;
356
293
  const now = Date.now();
357
294
 
358
- // Check backend tracking first (from init response)
359
295
  if (this.config.last_feedback_at) {
360
296
  try {
361
297
  const backendTimestamp = new Date(
@@ -369,7 +305,6 @@ export class FeedbackSDK {
369
305
  }
370
306
  }
371
307
 
372
- // Fallback to localStorage
373
308
  try {
374
309
  const storageKey = `feedback_submitted_${this.config.workspace}`;
375
310
  const stored = localStorage.getItem(storageKey);
@@ -382,9 +317,6 @@ export class FeedbackSDK {
382
317
  }
383
318
  }
384
319
 
385
- /**
386
- * Clear the feedback submission tracking (allow showing widgets again)
387
- */
388
320
  clearFeedbackSubmissionTracking() {
389
321
  try {
390
322
  const storageKey = `feedback_submitted_${this.config.workspace}`;
@@ -0,0 +1,75 @@
1
+ export const baseStyles = `
2
+ .feedback-widget {
3
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', Oxygen, Ubuntu, Cantarell, sans-serif;
4
+ font-size: 14px;
5
+ line-height: 1.4;
6
+ z-index: 999999;
7
+ box-sizing: border-box;
8
+ }
9
+
10
+ .feedback-widget *,
11
+ .feedback-widget *::before,
12
+ .feedback-widget *::after {
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ /* Animations */
17
+ @keyframes fadeIn {
18
+ from { opacity: 0; }
19
+ to { opacity: 1; }
20
+ }
21
+
22
+ @keyframes slideInRight {
23
+ from {
24
+ transform: translateX(400px);
25
+ opacity: 0;
26
+ }
27
+ to {
28
+ transform: translateX(0);
29
+ opacity: 1;
30
+ }
31
+ }
32
+
33
+ @keyframes confettiFall {
34
+ 0% {
35
+ opacity: 1;
36
+ transform: translateY(0) rotate(0deg) scale(1);
37
+ }
38
+ 10% {
39
+ opacity: 1;
40
+ }
41
+ 100% {
42
+ opacity: 0;
43
+ transform: translateY(100vh) rotate(720deg) scale(0.5);
44
+ }
45
+ }
46
+
47
+ @keyframes changelogSpin {
48
+ to {
49
+ transform: rotate(360deg);
50
+ }
51
+ }
52
+
53
+ /* Accessibility */
54
+ @media (prefers-reduced-motion: reduce) {
55
+ * {
56
+ transition: none !important;
57
+ animation: none !important;
58
+ }
59
+ }
60
+
61
+ /* Print */
62
+ @media print {
63
+ .feedback-widget,
64
+ .feedback-panel,
65
+ .feedback-panel-backdrop,
66
+ .feedback-success-notification,
67
+ .changelog-widget,
68
+ .changelog-modal,
69
+ .changelog-modal-backdrop,
70
+ .changelog-list-modal,
71
+ .changelog-list-modal-backdrop {
72
+ display: none !important;
73
+ }
74
+ }
75
+ `;