@product7/product7-js 0.6.5 → 0.6.8

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.6.5",
3
+ "version": "0.6.8",
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",
@@ -11,11 +11,28 @@ export class WebSocketService {
11
11
 
12
12
  this.ws = null;
13
13
  this.reconnectAttempts = 0;
14
- this.maxReconnectAttempts = 5;
15
- this.reconnectDelay = 1000;
14
+ // Reconnect indefinitely with capped exponential backoff. The
15
+ // previous 5-attempt hard cap meant ~31s of churn on a flaky network
16
+ // permanently killed the live-chat connection until page refresh —
17
+ // the dominant "messages don't arrive" symptom in production.
18
+ this.reconnectBaseDelay = 1000;
19
+ this.reconnectMaxDelay = 30_000;
16
20
  this.pingInterval = null;
17
21
  this.isConnected = false;
18
22
 
23
+ // Heartbeat watchdog. The browser's onclose/onerror only fires when
24
+ // it notices the TCP socket is dead — on stalled-but-not-closed
25
+ // connections (NAT timeouts, sleeping mobile, some intermediaries)
26
+ // that can take many minutes. We track the wall-clock time of the
27
+ // last received frame and force a reconnect if nothing arrives
28
+ // within the timeout. Server's protocol-level pings come every 54s
29
+ // (pkg/websocket/client.go pingPeriod) and our app-level pings/pongs
30
+ // every 30s, so 90s catches a dead pipe with one cycle of headroom.
31
+ this.lastFrameAt = 0;
32
+ this.heartbeatTimeoutMs = 90_000;
33
+ this.heartbeatCheckIntervalMs = 15_000;
34
+ this.heartbeatInterval = null;
35
+
19
36
  // Event listeners
20
37
  this._listeners = new Map();
21
38
 
@@ -39,6 +56,8 @@ export class WebSocketService {
39
56
  return;
40
57
  }
41
58
 
59
+ this._intentionallyClosed = false;
60
+
42
61
  // Mock mode - simulate connection
43
62
  if (this.mock) {
44
63
  this.isConnected = true;
@@ -70,13 +89,15 @@ export class WebSocketService {
70
89
  */
71
90
  disconnect() {
72
91
  this.isConnected = false;
73
- this.reconnectAttempts = this.maxReconnectAttempts; // Prevent reconnection
92
+ this._intentionallyClosed = true; // Prevent reconnection
74
93
 
75
94
  if (this.pingInterval) {
76
95
  clearInterval(this.pingInterval);
77
96
  this.pingInterval = null;
78
97
  }
79
98
 
99
+ this._stopHeartbeat();
100
+
80
101
  if (this.ws) {
81
102
  this.ws.close();
82
103
  this.ws = null;
@@ -88,6 +109,35 @@ export class WebSocketService {
88
109
  }
89
110
  }
90
111
 
112
+ _startHeartbeat() {
113
+ this._stopHeartbeat();
114
+ this.lastFrameAt = Date.now();
115
+ this.heartbeatInterval = setInterval(() => {
116
+ if (Date.now() - this.lastFrameAt > this.heartbeatTimeoutMs) {
117
+ console.warn(
118
+ `[WebSocket] No frames in ${this.heartbeatTimeoutMs}ms, forcing reconnect`
119
+ );
120
+ // Closing the socket fires onclose, which schedules a
121
+ // reconnect through the normal path.
122
+ this._stopHeartbeat();
123
+ if (this.ws) {
124
+ try {
125
+ this.ws.close();
126
+ } catch (_) {
127
+ // ignore
128
+ }
129
+ }
130
+ }
131
+ }, this.heartbeatCheckIntervalMs);
132
+ }
133
+
134
+ _stopHeartbeat() {
135
+ if (this.heartbeatInterval) {
136
+ clearInterval(this.heartbeatInterval);
137
+ this.heartbeatInterval = null;
138
+ }
139
+ }
140
+
91
141
  /**
92
142
  * Subscribe to events
93
143
  * @param {string} event - Event name
@@ -137,6 +187,8 @@ export class WebSocketService {
137
187
  console.log('[WebSocket] Connected');
138
188
  this.isConnected = true;
139
189
  this.reconnectAttempts = 0;
190
+ this._intentionallyClosed = false;
191
+ this._startHeartbeat();
140
192
  this._emit('connected', {});
141
193
 
142
194
  // Start ping interval to keep connection alive
@@ -146,6 +198,9 @@ export class WebSocketService {
146
198
  }
147
199
 
148
200
  _onMessage(event) {
201
+ // Any inbound frame counts as a sign of life for the watchdog —
202
+ // including pongs, message:new, typing, etc.
203
+ this.lastFrameAt = Date.now();
149
204
  try {
150
205
  const data = JSON.parse(event.data);
151
206
  const { type, payload } = data;
@@ -190,8 +245,13 @@ export class WebSocketService {
190
245
  this.pingInterval = null;
191
246
  }
192
247
 
248
+ this._stopHeartbeat();
249
+
193
250
  this._emit('disconnected', { code: event.code, reason: event.reason });
194
- this._scheduleReconnect();
251
+ // Skip reconnect if disconnect() was called intentionally.
252
+ if (!this._intentionallyClosed) {
253
+ this._scheduleReconnect();
254
+ }
195
255
  }
196
256
 
197
257
  _onError(error) {
@@ -200,14 +260,13 @@ export class WebSocketService {
200
260
  }
201
261
 
202
262
  _scheduleReconnect() {
203
- if (this.reconnectAttempts >= this.maxReconnectAttempts) {
204
- console.log('[WebSocket] Max reconnect attempts reached');
205
- this._emit('reconnect_failed', {});
206
- return;
207
- }
263
+ if (this._intentionallyClosed) return;
208
264
 
209
265
  this.reconnectAttempts++;
210
- const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
266
+ const delay = Math.min(
267
+ this.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts - 1),
268
+ this.reconnectMaxDelay
269
+ );
211
270
  console.log(
212
271
  `[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`
213
272
  );
@@ -839,4 +839,163 @@
839
839
  opacity: 0.7;
840
840
  cursor: not-allowed;
841
841
  }
842
+
843
+ /* ========================================
844
+ FEEDBACK FORM VIEW
845
+ ======================================== */
846
+
847
+ .liveChat-feedback-view {
848
+ display: flex;
849
+ flex-direction: column;
850
+ height: 100%;
851
+ overflow: hidden;
852
+ }
853
+
854
+ .liveChat-feedback-header {
855
+ display: flex;
856
+ align-items: center;
857
+ gap: var(--spacing-3);
858
+ padding: var(--spacing-4) var(--spacing-4);
859
+ border-bottom: 1px solid var(--border-color);
860
+ flex-shrink: 0;
861
+ }
862
+
863
+ .liveChat-feedback-title {
864
+ font-size: var(--font-size-base);
865
+ font-weight: var(--font-weight-semibold);
866
+ color: var(--text-primary);
867
+ }
868
+
869
+ .liveChat-feedback-body {
870
+ display: flex;
871
+ flex-direction: column;
872
+ gap: var(--spacing-4);
873
+ padding: var(--spacing-5) var(--spacing-4);
874
+ flex: 1;
875
+ overflow-y: auto;
876
+ }
877
+
878
+ .liveChat-feedback-prompt {
879
+ margin: 0;
880
+ font-size: var(--font-size-sm);
881
+ color: var(--text-secondary);
882
+ }
883
+
884
+
885
+ .liveChat-feedback-input {
886
+ width: 100%;
887
+ border: 1px solid var(--border-color);
888
+ border-radius: var(--radius-md);
889
+ padding: var(--spacing-3);
890
+ font-size: var(--font-size-sm);
891
+ font-family: inherit;
892
+ color: var(--text-primary);
893
+ background: var(--msg-bg);
894
+ box-sizing: border-box;
895
+ transition: border-color var(--transition-fast);
896
+ }
897
+
898
+ .liveChat-feedback-input::placeholder {
899
+ color: var(--msg-text-secondary);
900
+ }
901
+
902
+ .liveChat-feedback-input:focus {
903
+ outline: none;
904
+ border-color: var(--color-primary);
905
+ }
906
+
907
+ .liveChat-feedback-textarea {
908
+ width: 100%;
909
+ resize: none;
910
+ border: 1px solid var(--border-color);
911
+ border-radius: var(--radius-md);
912
+ padding: var(--spacing-3);
913
+ font-size: var(--font-size-sm);
914
+ font-family: inherit;
915
+ color: var(--text-primary);
916
+ background: var(--msg-bg);
917
+ box-sizing: border-box;
918
+ transition: border-color var(--transition-fast);
919
+ }
920
+
921
+ .liveChat-feedback-textarea::placeholder {
922
+ color: var(--msg-text-secondary);
923
+ }
924
+
925
+ .liveChat-feedback-textarea:focus {
926
+ outline: none;
927
+ border-color: var(--color-primary);
928
+ }
929
+
930
+ .liveChat-feedback-submit {
931
+ display: flex;
932
+ align-items: center;
933
+ justify-content: center;
934
+ padding: var(--spacing-3);
935
+ border-radius: var(--radius-md);
936
+ font-size: var(--font-size-sm);
937
+ font-weight: var(--font-weight-medium);
938
+ font-family: inherit;
939
+ cursor: pointer;
940
+ border: none;
941
+ background: var(--color-primary);
942
+ color: #ffffff;
943
+ transition: all var(--transition-fast);
944
+ }
945
+
946
+ .liveChat-feedback-submit:hover:not(:disabled) {
947
+ background: var(--color-primary-hover);
948
+ }
949
+
950
+ .liveChat-feedback-submit:disabled {
951
+ opacity: 0.5;
952
+ cursor: not-allowed;
953
+ }
954
+
955
+ .liveChat-feedback-thankyou {
956
+ display: flex;
957
+ flex-direction: column;
958
+ align-items: center;
959
+ justify-content: center;
960
+ flex: 1;
961
+ padding: var(--spacing-6) var(--spacing-4);
962
+ text-align: center;
963
+ gap: var(--spacing-3);
964
+ }
965
+
966
+ .liveChat-feedback-thankyou-emoji {
967
+ font-size: 48px;
968
+ line-height: 1;
969
+ }
970
+
971
+ .liveChat-feedback-thankyou h3 {
972
+ margin: 0;
973
+ font-size: var(--font-size-lg);
974
+ font-weight: var(--font-weight-semibold);
975
+ color: var(--text-primary);
976
+ }
977
+
978
+ .liveChat-feedback-thankyou p {
979
+ margin: 0;
980
+ font-size: var(--font-size-sm);
981
+ color: var(--text-secondary);
982
+ }
983
+
984
+ .liveChat-feedback-done-btn {
985
+ margin-top: var(--spacing-2);
986
+ padding: var(--spacing-2) var(--spacing-5);
987
+ border-radius: var(--radius-md);
988
+ font-size: var(--font-size-sm);
989
+ font-weight: var(--font-weight-medium);
990
+ font-family: inherit;
991
+ cursor: pointer;
992
+ border: 1px solid var(--border-color);
993
+ background: transparent;
994
+ color: var(--text-primary);
995
+ transition: all var(--transition-fast);
996
+ }
997
+
998
+ .liveChat-feedback-done-btn:hover {
999
+ background: var(--bg-secondary);
1000
+ }
842
1001
  `;
@@ -1,5 +1,9 @@
1
1
  export const liveChatCoreStyles = `
2
2
 
3
+ .liveChat-widget * {
4
+ font-weight: 500;
5
+ }
6
+
3
7
  .liveChat-launcher {
4
8
  position: fixed;
5
9
  z-index: var(--z-modal);
@@ -13,6 +13,7 @@ import { ChatView } from './liveChat/views/ChatView.js';
13
13
  import { ConversationsView } from './liveChat/views/ConversationsView.js';
14
14
  import { HelpView } from './liveChat/views/HelpView.js';
15
15
  import { HomeView } from './liveChat/views/HomeView.js';
16
+ import { FeedbackFormView } from './liveChat/views/FeedbackFormView.js';
16
17
  import { PreChatFormView } from './liveChat/views/PreChatFormView.js';
17
18
 
18
19
  export class LiveChatWidget extends BaseWidget {
@@ -208,6 +209,7 @@ export class LiveChatWidget extends BaseWidget {
208
209
  this.panel.registerView('messages', ConversationsView);
209
210
  this.panel.registerView('chat', ChatView);
210
211
  this.panel.registerView('prechat', PreChatFormView);
212
+ this.panel.registerView('feedback', FeedbackFormView);
211
213
  this.panel.registerView('help', HelpView);
212
214
  this.panel.registerView('changelog', ChangelogView);
213
215
 
@@ -563,13 +565,34 @@ export class LiveChatWidget extends BaseWidget {
563
565
  this._wsUnsubscribers.push(
564
566
  this.wsService.on('conversation_closed', this._handleConversationClosed)
565
567
  );
568
+ // Track first vs reconnect locally so we only backfill on reconnects.
569
+ // First connect is right after _initWebSocket() and the surrounding
570
+ // flow has already loaded fresh data — re-fetching would be wasted.
571
+ let wsHasConnectedBefore = false;
566
572
  this._wsUnsubscribers.push(
567
573
  this.wsService.on('connected', () => {
568
574
  console.log('[LiveChatWidget] WebSocket connected');
575
+ const isReconnect = wsHasConnectedBefore;
576
+ wsHasConnectedBefore = true;
569
577
  if (this.LiveChatState.activeConversationId) {
570
578
  this.wsService.send('conversation:subscribe', {
571
579
  conversation_id: this.LiveChatState.activeConversationId,
572
580
  });
581
+ // On reconnect, refetch the active conversation's messages.
582
+ // The server doesn't replay events that fired during the WS
583
+ // disconnect, so anything broadcast in the gap is otherwise
584
+ // permanently lost from the customer's view until they
585
+ // refresh the page.
586
+ if (isReconnect) {
587
+ this.fetchMessages(this.LiveChatState.activeConversationId).catch(
588
+ (err) => {
589
+ console.error(
590
+ '[LiveChatWidget] Failed to backfill messages on reconnect:',
591
+ err
592
+ );
593
+ }
594
+ );
595
+ }
573
596
  }
574
597
  })
575
598
  );
@@ -95,11 +95,12 @@ export class LiveChatPanel {
95
95
  </div>`;
96
96
  }
97
97
 
98
- // Hide nav in chat and prechat views
98
+ // Hide nav in chat, prechat and feedback views
99
99
  if (navContainer) {
100
100
  const hideNav =
101
101
  this.state.currentView === 'chat' ||
102
- this.state.currentView === 'prechat';
102
+ this.state.currentView === 'prechat' ||
103
+ this.state.currentView === 'feedback';
103
104
  navContainer.style.display = hideNav ? 'none' : '';
104
105
  }
105
106
  }
@@ -0,0 +1,104 @@
1
+ export class FeedbackFormView {
2
+ constructor(state, options = {}) {
3
+ this.state = state;
4
+ this.options = options;
5
+ this.element = null;
6
+ this._isSubmitting = false;
7
+ this._selectedRating = null;
8
+ }
9
+
10
+ render() {
11
+ this.element = document.createElement('div');
12
+ this.element.className = 'liveChat-view liveChat-feedback-view';
13
+ this._renderForm();
14
+ return this.element;
15
+ }
16
+
17
+ _renderForm() {
18
+ this.element.innerHTML = `
19
+ <div class="liveChat-feedback-header">
20
+ <button class="sdk-btn-icon liveChat-feedback-back-btn">
21
+ <iconify-icon icon="ph:arrow-left" width="20" height="20"></iconify-icon>
22
+ </button>
23
+ <span class="liveChat-feedback-title">Leave us feedback</span>
24
+ </div>
25
+ <div class="liveChat-feedback-body">
26
+ <p class="liveChat-feedback-prompt">Share your thoughts with us. We read every message.</p>
27
+ <input
28
+ type="text"
29
+ class="liveChat-feedback-input"
30
+ placeholder="Title"
31
+ />
32
+ <textarea
33
+ class="liveChat-feedback-textarea"
34
+ placeholder="Your feedback..."
35
+ rows="5"
36
+ ></textarea>
37
+ <button class="liveChat-feedback-submit">Send feedback</button>
38
+ </div>
39
+ `;
40
+ this._attachEvents();
41
+ }
42
+
43
+ _renderThankYou() {
44
+ this.element.innerHTML = `
45
+ <div class="liveChat-feedback-header">
46
+ <button class="sdk-btn-icon liveChat-feedback-back-btn">
47
+ <iconify-icon icon="ph:arrow-left" width="20" height="20"></iconify-icon>
48
+ </button>
49
+ <span class="liveChat-feedback-title">Leave us feedback</span>
50
+ </div>
51
+ <div class="liveChat-feedback-thankyou">
52
+ <span class="liveChat-feedback-thankyou-emoji">🙏</span>
53
+ <h3>Thanks for your feedback!</h3>
54
+ <p>We appreciate you taking the time to share your thoughts.</p>
55
+ <button class="liveChat-feedback-done-btn">Done</button>
56
+ </div>
57
+ `;
58
+ this.element.querySelector('.liveChat-feedback-back-btn').addEventListener('click', () => {
59
+ this.state.setView('home');
60
+ });
61
+ this.element.querySelector('.liveChat-feedback-done-btn').addEventListener('click', () => {
62
+ this.state.setView('home');
63
+ });
64
+ }
65
+
66
+ _attachEvents() {
67
+ this.element.querySelector('.liveChat-feedback-back-btn').addEventListener('click', () => {
68
+ this.state.setView('home');
69
+ });
70
+
71
+ const submitBtn = this.element.querySelector('.liveChat-feedback-submit');
72
+ submitBtn.addEventListener('click', async () => {
73
+ if (this._isSubmitting) return;
74
+ const title = this.element.querySelector('.liveChat-feedback-input').value.trim();
75
+ const message = this.element.querySelector('.liveChat-feedback-textarea').value.trim();
76
+ await this._submit(title, message);
77
+ });
78
+ }
79
+
80
+ async _submit(title, message) {
81
+ this._isSubmitting = true;
82
+ const submitBtn = this.element.querySelector('.liveChat-feedback-submit');
83
+ if (submitBtn) {
84
+ submitBtn.disabled = true;
85
+ submitBtn.textContent = 'Sending...';
86
+ }
87
+
88
+ try {
89
+ if (this.options.onSubmitFeedback) {
90
+ await this.options.onSubmitFeedback({ title, message });
91
+ }
92
+ } catch (e) {
93
+ console.warn('[FeedbackFormView] Submit error:', e);
94
+ }
95
+
96
+ this._renderThankYou();
97
+ }
98
+
99
+ destroy() {
100
+ if (this.element && this.element.parentNode) {
101
+ this.element.parentNode.removeChild(this.element);
102
+ }
103
+ }
104
+ }
@@ -136,8 +136,11 @@
136
136
  const sendIcon = `<iconify-icon icon="ph:paper-plane-right" width="20" height="20" style="flex-shrink: 0;"></iconify-icon>`;
137
137
  const caretIcon = `<iconify-icon icon="ph:caret-right" width="20" height="20" style="flex-shrink: 0;"></iconify-icon>`;
138
138
 
139
- const responseTime =
140
- this.state.responseTime || 'We typically reply within a few minutes';
139
+ const isUnavailable = this.state.businessHoursState === 'offline' || this.state.businessHoursState === 'away';
140
+ const buttonLabel = isUnavailable ? 'Leave us a message' : this.state.startButtonText;
141
+ const buttonSubtext = isUnavailable
142
+ ? "We'll get back to you when we're back"
143
+ : (this.state.responseTime || 'We typically reply within a few minutes');
141
144
 
142
145
  const recentCardHtml = openConversation
143
146
  ? this._renderRecentMessageCard(openConversation)
@@ -147,8 +150,8 @@
147
150
  ${recentCardHtml}
148
151
  <button class="liveChat-home-message-btn">
149
152
  <div class="liveChat-home-continue-info">
150
- <span class="liveChat-home-continue-label">${this.state.startButtonText}</span>
151
- <span class="liveChat-home-message-subtext">${responseTime}</span>
153
+ <span class="liveChat-home-continue-label">${buttonLabel}</span>
154
+ <span class="liveChat-home-message-subtext">${buttonSubtext}</span>
152
155
  </div>
153
156
  ${sendIcon}
154
157
  </button>
@@ -312,12 +315,7 @@
312
315
  const feedbackBtn = this.element.querySelector('.liveChat-feedback-btn');
313
316
  if (feedbackBtn) {
314
317
  feedbackBtn.addEventListener('click', () => {
315
- if (this.options.onFeedbackClick) {
316
- this.state.setOpen(false);
317
- this.options.onFeedbackClick();
318
- } else if (this.state.urls?.feedback) {
319
- window.open(this.state.urls.feedback, '_blank');
320
- }
318
+ this.state.setView('feedback');
321
319
  });
322
320
  }
323
321