@product7/feedback-sdk 1.2.4 → 1.2.6

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.
@@ -192,6 +192,68 @@ export class FeedbackSDK {
192
192
  return surveyWidget;
193
193
  }
194
194
 
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
+ showChangelog(options = {}) {
210
+ if (!this.initialized) {
211
+ throw new SDKError(
212
+ 'SDK must be initialized before showing changelog. Call init() first.'
213
+ );
214
+ }
215
+
216
+ const changelogWidget = this.createWidget('changelog', {
217
+ position: options.position || 'bottom-right',
218
+ theme: options.theme || this.config.theme || 'light',
219
+ title: options.title || "What's New",
220
+ triggerText: options.triggerText || "What's New",
221
+ showBadge: options.showBadge !== false,
222
+ viewButtonText: options.viewButtonText || 'View Update',
223
+ changelogBaseUrl: options.changelogBaseUrl,
224
+ openInNewTab: options.openInNewTab,
225
+ onViewUpdate: options.onViewUpdate,
226
+ });
227
+
228
+ changelogWidget.mount();
229
+ changelogWidget.show();
230
+
231
+ return changelogWidget;
232
+ }
233
+
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
+ async getChangelogs(options = {}) {
242
+ if (!this.initialized) {
243
+ throw new SDKError(
244
+ 'SDK must be initialized before fetching changelogs. Call init() first.'
245
+ );
246
+ }
247
+
248
+ try {
249
+ const result = await this.apiService.getChangelogs(options);
250
+ return result.data || [];
251
+ } catch (error) {
252
+ this.eventBus.emit('sdk:error', { error });
253
+ throw new SDKError(`Failed to fetch changelogs: ${error.message}`, error);
254
+ }
255
+ }
256
+
195
257
  getAllWidgets() {
196
258
  return Array.from(this.widgets.values());
197
259
  }
@@ -283,16 +345,64 @@ export class FeedbackSDK {
283
345
  this.eventBus.emit('sdk:destroyed');
284
346
  }
285
347
 
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
+ hasFeedbackBeenSubmitted(cooldownDays = 30) {
355
+ const cooldownMs = cooldownDays * 24 * 60 * 60 * 1000;
356
+ const now = Date.now();
357
+
358
+ // Check backend tracking first (from init response)
359
+ if (this.config.last_feedback_at) {
360
+ try {
361
+ const backendTimestamp = new Date(this.config.last_feedback_at).getTime();
362
+ if ((now - backendTimestamp) < cooldownMs) {
363
+ return true;
364
+ }
365
+ } catch (e) {
366
+ // Invalid date format, continue to localStorage check
367
+ }
368
+ }
369
+
370
+ // Fallback to localStorage
371
+ try {
372
+ const storageKey = `feedback_submitted_${this.config.workspace}`;
373
+ const stored = localStorage.getItem(storageKey);
374
+ if (!stored) return false;
375
+
376
+ const data = JSON.parse(stored);
377
+ return (now - data.submittedAt) < cooldownMs;
378
+ } catch (e) {
379
+ return false;
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Clear the feedback submission tracking (allow showing widgets again)
385
+ */
386
+ clearFeedbackSubmissionTracking() {
387
+ try {
388
+ const storageKey = `feedback_submitted_${this.config.workspace}`;
389
+ localStorage.removeItem(storageKey);
390
+ this.eventBus.emit('feedback:trackingCleared');
391
+ } catch (e) {
392
+ console.warn('Failed to clear feedback tracking:', e);
393
+ }
394
+ }
395
+
286
396
  _detectEnvironment() {
287
397
  if (typeof window === 'undefined') {
288
398
  return 'production';
289
399
  }
290
400
 
291
401
  const hostname = window.location.hostname.toLowerCase();
292
-
402
+
293
403
  if (
294
- hostname.includes('staging') ||
295
- hostname.includes('localhost') ||
404
+ hostname.includes('staging') ||
405
+ hostname.includes('localhost') ||
296
406
  hostname.includes('127.0.0.1') ||
297
407
  hostname.includes('.local')
298
408
  ) {
@@ -313,7 +423,7 @@ export class FeedbackSDK {
313
423
  autoShow: true,
314
424
  debug: false,
315
425
  mock: false,
316
- env: this._detectEnvironment(),
426
+ env: this._detectEnvironment(),
317
427
  };
318
428
 
319
429
  const mergedConfig = deepMerge(
@@ -398,4 +508,4 @@ export class FeedbackSDK {
398
508
  : undefined,
399
509
  };
400
510
  }
401
- }
511
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * WebSocketService - Real-time communication for messenger widget
3
+ */
4
+
5
+ export class WebSocketService {
6
+ constructor(config = {}) {
7
+ this.baseURL = config.baseURL || '';
8
+ this.workspace = config.workspace || '';
9
+ this.sessionToken = config.sessionToken || null;
10
+ this.mock = config.mock || false;
11
+
12
+ this.ws = null;
13
+ this.reconnectAttempts = 0;
14
+ this.maxReconnectAttempts = 5;
15
+ this.reconnectDelay = 1000;
16
+ this.pingInterval = null;
17
+ this.isConnected = false;
18
+
19
+ // Event listeners
20
+ this._listeners = new Map();
21
+
22
+ // Bind methods
23
+ this._onOpen = this._onOpen.bind(this);
24
+ this._onMessage = this._onMessage.bind(this);
25
+ this._onClose = this._onClose.bind(this);
26
+ this._onError = this._onError.bind(this);
27
+ }
28
+
29
+ /**
30
+ * Connect to WebSocket server
31
+ */
32
+ connect(sessionToken = null) {
33
+ if (sessionToken) {
34
+ this.sessionToken = sessionToken;
35
+ }
36
+
37
+ if (!this.sessionToken) {
38
+ console.warn('[WebSocket] No session token provided');
39
+ return;
40
+ }
41
+
42
+ // Mock mode - simulate connection
43
+ if (this.mock) {
44
+ this.isConnected = true;
45
+ this._emit('connected', {});
46
+ this._startMockResponses();
47
+ return;
48
+ }
49
+
50
+ // Build WebSocket URL
51
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
52
+ let wsURL = this.baseURL.replace(/^https?:/, wsProtocol);
53
+ wsURL = wsURL.replace('/api/v1', '');
54
+ wsURL = `${wsURL}/api/v1/widget/messenger/ws?token=${encodeURIComponent(this.sessionToken)}`;
55
+
56
+ try {
57
+ this.ws = new WebSocket(wsURL);
58
+ this.ws.onopen = this._onOpen;
59
+ this.ws.onmessage = this._onMessage;
60
+ this.ws.onclose = this._onClose;
61
+ this.ws.onerror = this._onError;
62
+ } catch (error) {
63
+ console.error('[WebSocket] Connection error:', error);
64
+ this._scheduleReconnect();
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Disconnect from WebSocket server
70
+ */
71
+ disconnect() {
72
+ this.isConnected = false;
73
+ this.reconnectAttempts = this.maxReconnectAttempts; // Prevent reconnection
74
+
75
+ if (this.pingInterval) {
76
+ clearInterval(this.pingInterval);
77
+ this.pingInterval = null;
78
+ }
79
+
80
+ if (this.ws) {
81
+ this.ws.close();
82
+ this.ws = null;
83
+ }
84
+
85
+ if (this._mockInterval) {
86
+ clearInterval(this._mockInterval);
87
+ this._mockInterval = null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Subscribe to events
93
+ * @param {string} event - Event name
94
+ * @param {Function} callback - Event handler
95
+ * @returns {Function} Unsubscribe function
96
+ */
97
+ on(event, callback) {
98
+ if (!this._listeners.has(event)) {
99
+ this._listeners.set(event, new Set());
100
+ }
101
+ this._listeners.get(event).add(callback);
102
+ return () => this._listeners.get(event).delete(callback);
103
+ }
104
+
105
+ /**
106
+ * Remove event listener
107
+ */
108
+ off(event, callback) {
109
+ if (this._listeners.has(event)) {
110
+ this._listeners.get(event).delete(callback);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Send message through WebSocket
116
+ */
117
+ send(type, payload = {}) {
118
+ if (!this.isConnected) {
119
+ console.warn('[WebSocket] Not connected, cannot send message');
120
+ return;
121
+ }
122
+
123
+ if (this.mock) {
124
+ // Mock mode - just log
125
+ console.log('[WebSocket Mock] Sending:', type, payload);
126
+ return;
127
+ }
128
+
129
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
130
+ this.ws.send(JSON.stringify({ type, payload }));
131
+ }
132
+ }
133
+
134
+ // Private methods
135
+
136
+ _onOpen() {
137
+ console.log('[WebSocket] Connected');
138
+ this.isConnected = true;
139
+ this.reconnectAttempts = 0;
140
+ this._emit('connected', {});
141
+
142
+ // Start ping interval to keep connection alive
143
+ this.pingInterval = setInterval(() => {
144
+ this.send('ping', {});
145
+ }, 30000);
146
+ }
147
+
148
+ _onMessage(event) {
149
+ try {
150
+ const data = JSON.parse(event.data);
151
+ const { type, payload } = data;
152
+
153
+ // Handle different event types
154
+ switch (type) {
155
+ case 'message:new':
156
+ this._emit('message', payload);
157
+ break;
158
+ case 'typing:started':
159
+ this._emit('typing_started', payload);
160
+ break;
161
+ case 'typing:stopped':
162
+ this._emit('typing_stopped', payload);
163
+ break;
164
+ case 'conversation:updated':
165
+ this._emit('conversation_updated', payload);
166
+ break;
167
+ case 'conversation:closed':
168
+ this._emit('conversation_closed', payload);
169
+ break;
170
+ case 'availability:changed':
171
+ this._emit('availability_changed', payload);
172
+ break;
173
+ case 'pong':
174
+ // Ping response, ignore
175
+ break;
176
+ default:
177
+ console.log('[WebSocket] Unknown event:', type, payload);
178
+ }
179
+ } catch (error) {
180
+ console.error('[WebSocket] Failed to parse message:', error);
181
+ }
182
+ }
183
+
184
+ _onClose(event) {
185
+ console.log('[WebSocket] Disconnected:', event.code, event.reason);
186
+ this.isConnected = false;
187
+
188
+ if (this.pingInterval) {
189
+ clearInterval(this.pingInterval);
190
+ this.pingInterval = null;
191
+ }
192
+
193
+ this._emit('disconnected', { code: event.code, reason: event.reason });
194
+ this._scheduleReconnect();
195
+ }
196
+
197
+ _onError(error) {
198
+ console.error('[WebSocket] Error:', error);
199
+ this._emit('error', { error });
200
+ }
201
+
202
+ _scheduleReconnect() {
203
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
204
+ console.log('[WebSocket] Max reconnect attempts reached');
205
+ this._emit('reconnect_failed', {});
206
+ return;
207
+ }
208
+
209
+ this.reconnectAttempts++;
210
+ const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
211
+ console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
212
+
213
+ setTimeout(() => {
214
+ this.connect();
215
+ }, delay);
216
+ }
217
+
218
+ _emit(event, data) {
219
+ if (this._listeners.has(event)) {
220
+ this._listeners.get(event).forEach((callback) => {
221
+ try {
222
+ callback(data);
223
+ } catch (error) {
224
+ console.error(`[WebSocket] Error in ${event} handler:`, error);
225
+ }
226
+ });
227
+ }
228
+ }
229
+
230
+ // Mock support for development
231
+ _startMockResponses() {
232
+ // Simulate agent typing and responses
233
+ this._mockInterval = setInterval(() => {
234
+ // Randomly emit typing or message events for demo
235
+ const random = Math.random();
236
+ if (random < 0.1) {
237
+ this._emit('typing_started', {
238
+ conversation_id: 'conv_1',
239
+ user_id: 'agent_1',
240
+ user_name: 'Sarah',
241
+ is_agent: true,
242
+ });
243
+
244
+ // Stop typing after 2 seconds
245
+ setTimeout(() => {
246
+ this._emit('typing_stopped', {
247
+ conversation_id: 'conv_1',
248
+ user_id: 'agent_1',
249
+ });
250
+ }, 2000);
251
+ }
252
+ }, 10000);
253
+ }
254
+
255
+ /**
256
+ * Simulate receiving a message (for mock mode)
257
+ */
258
+ simulateMessage(conversationId, message) {
259
+ if (this.mock) {
260
+ this._emit('message', {
261
+ conversation_id: conversationId,
262
+ message: {
263
+ id: 'msg_' + Date.now(),
264
+ content: message.content,
265
+ sender_type: message.sender_type || 'agent',
266
+ sender_name: message.sender_name || 'Support',
267
+ sender_avatar: message.sender_avatar || null,
268
+ created_at: new Date().toISOString(),
269
+ },
270
+ });
271
+ }
272
+ }
273
+ }
package/src/index.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  import * as helpers from './utils/helpers.js';
14
14
  import { BaseWidget } from './widgets/BaseWidget.js';
15
15
  import { ButtonWidget } from './widgets/ButtonWidget.js';
16
+ import { ChangelogWidget } from './widgets/ChangelogWidget.js';
16
17
  import { InlineWidget } from './widgets/InlineWidget.js';
17
18
  import { MessengerWidget } from './widgets/MessengerWidget.js';
18
19
  import { SurveyWidget } from './widgets/SurveyWidget.js';
@@ -95,6 +96,7 @@ const FeedbackSDKExport = {
95
96
  FeedbackSDK,
96
97
  BaseWidget,
97
98
  ButtonWidget,
99
+ ChangelogWidget,
98
100
  TabWidget,
99
101
  InlineWidget,
100
102
  SurveyWidget,
@@ -180,6 +182,7 @@ export {
180
182
  APIService,
181
183
  BaseWidget,
182
184
  ButtonWidget,
185
+ ChangelogWidget,
183
186
  ConfigError,
184
187
  EventBus,
185
188
  FeedbackSDK,
@@ -1623,6 +1623,106 @@ export const MESSENGER_STYLES = `
1623
1623
  }
1624
1624
  }
1625
1625
 
1626
+ /* ========================================
1627
+ Availability Status
1628
+ ======================================== */
1629
+
1630
+ .messenger-home-availability,
1631
+ .messenger-chat-availability {
1632
+ display: flex;
1633
+ align-items: center;
1634
+ gap: 6px;
1635
+ margin-top: 8px;
1636
+ font-size: 13px;
1637
+ color: rgba(255, 255, 255, 0.7);
1638
+ }
1639
+
1640
+ .theme-light .messenger-home-availability,
1641
+ .theme-light .messenger-chat-availability {
1642
+ color: #6b7280;
1643
+ }
1644
+
1645
+ .messenger-availability-dot {
1646
+ width: 8px;
1647
+ height: 8px;
1648
+ border-radius: 50%;
1649
+ flex-shrink: 0;
1650
+ }
1651
+
1652
+ .messenger-availability-online {
1653
+ background: #22c55e;
1654
+ box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
1655
+ }
1656
+
1657
+ .messenger-availability-away {
1658
+ background: #9ca3af;
1659
+ }
1660
+
1661
+ .messenger-availability-text {
1662
+ opacity: 0.9;
1663
+ }
1664
+
1665
+ /* ========================================
1666
+ Typing Indicator
1667
+ ======================================== */
1668
+
1669
+ .messenger-typing-indicator {
1670
+ display: flex;
1671
+ align-items: center;
1672
+ gap: 8px;
1673
+ padding: 8px 12px;
1674
+ margin: 4px 0;
1675
+ }
1676
+
1677
+ .messenger-typing-dots {
1678
+ display: flex;
1679
+ align-items: center;
1680
+ gap: 4px;
1681
+ background: #374151;
1682
+ padding: 8px 12px;
1683
+ border-radius: 16px;
1684
+ }
1685
+
1686
+ .theme-light .messenger-typing-dots {
1687
+ background: #e5e7eb;
1688
+ }
1689
+
1690
+ .messenger-typing-dots span {
1691
+ width: 6px;
1692
+ height: 6px;
1693
+ background: #9ca3af;
1694
+ border-radius: 50%;
1695
+ animation: messenger-typing-bounce 1.4s infinite ease-in-out;
1696
+ }
1697
+
1698
+ .messenger-typing-dots span:nth-child(1) {
1699
+ animation-delay: -0.32s;
1700
+ }
1701
+
1702
+ .messenger-typing-dots span:nth-child(2) {
1703
+ animation-delay: -0.16s;
1704
+ }
1705
+
1706
+ .messenger-typing-dots span:nth-child(3) {
1707
+ animation-delay: 0s;
1708
+ }
1709
+
1710
+ .messenger-typing-text {
1711
+ font-size: 12px;
1712
+ color: #9ca3af;
1713
+ }
1714
+
1715
+ @keyframes messenger-typing-bounce {
1716
+ 0%, 80%, 100% {
1717
+ transform: scale(0.8);
1718
+ opacity: 0.5;
1719
+ }
1720
+ 40% {
1721
+ transform: scale(1);
1722
+ opacity: 1;
1723
+ }
1724
+ }
1725
+
1626
1726
  /* ========================================
1627
1727
  Animations
1628
1728
  ======================================== */