@product7/feedback-sdk 1.2.5 → 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.
@@ -139,6 +139,124 @@
139
139
  },
140
140
  ];
141
141
 
142
+ // Mock conversations for development
143
+ const MOCK_CONVERSATIONS = [
144
+ {
145
+ id: 'conv_1',
146
+ subject: 'Question about pricing',
147
+ status: 'open',
148
+ last_message_at: new Date(Date.now() - 49 * 60 * 1000).toISOString(),
149
+ created_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
150
+ unread: 1,
151
+ assigned_user: {
152
+ id: 'user_1',
153
+ name: 'Sarah',
154
+ avatar: null,
155
+ },
156
+ },
157
+ {
158
+ id: 'conv_2',
159
+ subject: 'Feature request',
160
+ status: 'open',
161
+ last_message_at: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(),
162
+ created_at: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(),
163
+ unread: 0,
164
+ assigned_user: {
165
+ id: 'user_2',
166
+ name: 'Tom',
167
+ avatar: null,
168
+ },
169
+ },
170
+ ];
171
+
172
+ // Mock messages for development
173
+ const MOCK_MESSAGES = {
174
+ conv_1: [
175
+ {
176
+ id: 'msg_1',
177
+ content: "Hi there! 👋 I'm Sarah. How can I help you today?",
178
+ sender_type: 'agent',
179
+ sender_name: 'Sarah',
180
+ sender_avatar: null,
181
+ created_at: new Date(Date.now() - 50 * 60 * 1000).toISOString(),
182
+ },
183
+ {
184
+ id: 'msg_2',
185
+ content: 'Hi! I have a question about your enterprise pricing.',
186
+ sender_type: 'customer',
187
+ created_at: new Date(Date.now() - 49 * 60 * 1000).toISOString(),
188
+ },
189
+ ],
190
+ conv_2: [
191
+ {
192
+ id: 'msg_3',
193
+ content: "Hello! I'm Tom from the product team.",
194
+ sender_type: 'agent',
195
+ sender_name: 'Tom',
196
+ sender_avatar: null,
197
+ created_at: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
198
+ },
199
+ {
200
+ id: 'msg_4',
201
+ content: 'I would love to see a dark mode feature!',
202
+ sender_type: 'customer',
203
+ created_at: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000 - 30 * 60 * 1000).toISOString(),
204
+ },
205
+ {
206
+ id: 'msg_5',
207
+ content: "Great suggestion! That feature will be available next week. I'll let you know when it's ready.",
208
+ sender_type: 'agent',
209
+ sender_name: 'Tom',
210
+ sender_avatar: null,
211
+ created_at: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(),
212
+ },
213
+ ],
214
+ };
215
+
216
+ // Mock help collections for development
217
+ const MOCK_HELP_COLLECTIONS = [
218
+ {
219
+ id: 'collection_1',
220
+ title: 'Product Overview',
221
+ description: 'See how your AI-first customer service solution works.',
222
+ articleCount: 24,
223
+ icon: 'ph-book-open',
224
+ url: '#',
225
+ },
226
+ {
227
+ id: 'collection_2',
228
+ title: 'Getting Started',
229
+ description: 'Everything you need to know to get started with Product7.',
230
+ articleCount: 30,
231
+ icon: 'ph-rocket',
232
+ url: '#',
233
+ },
234
+ {
235
+ id: 'collection_3',
236
+ title: 'AI Agent',
237
+ description: 'Resolving customer questions instantly and accurately—from live chat to email.',
238
+ articleCount: 82,
239
+ icon: 'ph-robot',
240
+ url: '#',
241
+ },
242
+ {
243
+ id: 'collection_4',
244
+ title: 'Channels',
245
+ description: 'Enabling the channels you use to communicate with customers, all from the Inbox.',
246
+ articleCount: 45,
247
+ icon: 'ph-chat-circle',
248
+ url: '#',
249
+ },
250
+ {
251
+ id: 'collection_5',
252
+ title: 'Billing & Payments',
253
+ description: 'Manage your subscription, invoices, and payment methods.',
254
+ articleCount: 12,
255
+ icon: 'ph-credit-card',
256
+ url: '#',
257
+ },
258
+ ];
259
+
142
260
  // Mock surveys for development
143
261
  const MOCK_SURVEYS = [
144
262
  {
@@ -560,6 +678,424 @@
560
678
  }
561
679
  }
562
680
 
681
+ // ==========================================
682
+ // MESSENGER / CHAT ENDPOINTS
683
+ // ==========================================
684
+
685
+ /**
686
+ * Get messenger settings
687
+ * @returns {Promise<Object>} Messenger settings
688
+ */
689
+ async getMessengerSettings() {
690
+ if (!this.isSessionValid()) {
691
+ await this.init();
692
+ }
693
+
694
+ if (this.mock) {
695
+ return {
696
+ status: true,
697
+ data: {
698
+ enabled: true,
699
+ greeting_message: 'Hi there! How can we help you today?',
700
+ team_name: 'Support Team',
701
+ response_time: 'Usually replies within a few minutes',
702
+ },
703
+ };
704
+ }
705
+
706
+ return this._makeRequest('/widget/messenger/settings', {
707
+ method: 'GET',
708
+ headers: { Authorization: `Bearer ${this.sessionToken}` },
709
+ });
710
+ }
711
+
712
+ /**
713
+ * Check if agents are online
714
+ * @returns {Promise<Object>} Agent availability status
715
+ */
716
+ async checkAgentsOnline() {
717
+ if (!this.isSessionValid()) {
718
+ await this.init();
719
+ }
720
+
721
+ if (this.mock) {
722
+ return {
723
+ status: true,
724
+ data: {
725
+ agents_online: true,
726
+ online_count: 2,
727
+ response_time: 'Usually replies within a few minutes',
728
+ },
729
+ };
730
+ }
731
+
732
+ return this._makeRequest('/widget/messenger/agents/online', {
733
+ method: 'GET',
734
+ headers: { Authorization: `Bearer ${this.sessionToken}` },
735
+ });
736
+ }
737
+
738
+ /**
739
+ * Get all conversations for the current contact
740
+ * @param {Object} options - Query options
741
+ * @param {number} options.page - Page number
742
+ * @param {number} options.limit - Items per page
743
+ * @returns {Promise<Object>} Conversations list
744
+ */
745
+ async getConversations(options = {}) {
746
+ if (!this.isSessionValid()) {
747
+ await this.init();
748
+ }
749
+
750
+ if (this.mock) {
751
+ await new Promise((resolve) => setTimeout(resolve, 300));
752
+ return {
753
+ status: true,
754
+ data: MOCK_CONVERSATIONS,
755
+ meta: { total: MOCK_CONVERSATIONS.length, page: 1, limit: 20 },
756
+ };
757
+ }
758
+
759
+ const params = new URLSearchParams();
760
+ if (options.page) params.append('page', options.page);
761
+ if (options.limit) params.append('limit', options.limit);
762
+
763
+ const endpoint = `/widget/messenger/conversations${params.toString() ? '?' + params.toString() : ''}`;
764
+ return this._makeRequest(endpoint, {
765
+ method: 'GET',
766
+ headers: { Authorization: `Bearer ${this.sessionToken}` },
767
+ });
768
+ }
769
+
770
+ /**
771
+ * Get a single conversation with messages
772
+ * @param {string} conversationId - Conversation ID
773
+ * @returns {Promise<Object>} Conversation with messages
774
+ */
775
+ async getConversation(conversationId) {
776
+ if (!this.isSessionValid()) {
777
+ await this.init();
778
+ }
779
+
780
+ if (this.mock) {
781
+ await new Promise((resolve) => setTimeout(resolve, 200));
782
+ const conv = MOCK_CONVERSATIONS.find((c) => c.id === conversationId);
783
+ return {
784
+ status: true,
785
+ data: {
786
+ ...conv,
787
+ messages: MOCK_MESSAGES[conversationId] || [],
788
+ },
789
+ };
790
+ }
791
+
792
+ return this._makeRequest(`/widget/messenger/conversations/${conversationId}`, {
793
+ method: 'GET',
794
+ headers: { Authorization: `Bearer ${this.sessionToken}` },
795
+ });
796
+ }
797
+
798
+ /**
799
+ * Get messages for a conversation
800
+ * @param {string} conversationId - Conversation ID
801
+ * @param {Object} options - Query options
802
+ * @returns {Promise<Object>} Messages list
803
+ */
804
+ async getMessages(conversationId, options = {}) {
805
+ if (!this.isSessionValid()) {
806
+ await this.init();
807
+ }
808
+
809
+ if (this.mock) {
810
+ await new Promise((resolve) => setTimeout(resolve, 200));
811
+ return {
812
+ status: true,
813
+ data: MOCK_MESSAGES[conversationId] || [],
814
+ };
815
+ }
816
+
817
+ const params = new URLSearchParams();
818
+ if (options.page) params.append('page', options.page);
819
+ if (options.limit) params.append('limit', options.limit);
820
+
821
+ const endpoint = `/widget/messenger/conversations/${conversationId}/messages${params.toString() ? '?' + params.toString() : ''}`;
822
+ return this._makeRequest(endpoint, {
823
+ method: 'GET',
824
+ headers: { Authorization: `Bearer ${this.sessionToken}` },
825
+ });
826
+ }
827
+
828
+ /**
829
+ * Start a new conversation
830
+ * @param {Object} data - Conversation data
831
+ * @param {string} data.message - Initial message content
832
+ * @param {string} data.subject - Optional subject
833
+ * @returns {Promise<Object>} Created conversation
834
+ */
835
+ async startConversation(data) {
836
+ if (!this.isSessionValid()) {
837
+ await this.init();
838
+ }
839
+
840
+ if (this.mock) {
841
+ await new Promise((resolve) => setTimeout(resolve, 300));
842
+ const newConv = {
843
+ id: 'conv_' + Date.now(),
844
+ subject: data.subject || 'New conversation',
845
+ status: 'open',
846
+ last_message_at: new Date().toISOString(),
847
+ created_at: new Date().toISOString(),
848
+ messages: [
849
+ {
850
+ id: 'msg_' + Date.now(),
851
+ content: data.message,
852
+ sender_type: 'customer',
853
+ created_at: new Date().toISOString(),
854
+ },
855
+ ],
856
+ };
857
+ MOCK_CONVERSATIONS.unshift(newConv);
858
+ MOCK_MESSAGES[newConv.id] = newConv.messages;
859
+ return { status: true, data: newConv };
860
+ }
861
+
862
+ return this._makeRequest('/widget/messenger/conversations', {
863
+ method: 'POST',
864
+ headers: {
865
+ 'Content-Type': 'application/json',
866
+ Authorization: `Bearer ${this.sessionToken}`,
867
+ },
868
+ body: JSON.stringify({
869
+ message: data.message,
870
+ subject: data.subject || '',
871
+ }),
872
+ });
873
+ }
874
+
875
+ /**
876
+ * Send a message in a conversation
877
+ * @param {string} conversationId - Conversation ID
878
+ * @param {Object} data - Message data
879
+ * @param {string} data.content - Message content
880
+ * @returns {Promise<Object>} Sent message
881
+ */
882
+ async sendMessage(conversationId, data) {
883
+ if (!this.isSessionValid()) {
884
+ await this.init();
885
+ }
886
+
887
+ if (this.mock) {
888
+ await new Promise((resolve) => setTimeout(resolve, 200));
889
+ const newMessage = {
890
+ id: 'msg_' + Date.now(),
891
+ content: data.content,
892
+ sender_type: 'customer',
893
+ created_at: new Date().toISOString(),
894
+ };
895
+ if (!MOCK_MESSAGES[conversationId]) {
896
+ MOCK_MESSAGES[conversationId] = [];
897
+ }
898
+ MOCK_MESSAGES[conversationId].push(newMessage);
899
+ return { status: true, data: newMessage };
900
+ }
901
+
902
+ return this._makeRequest(`/widget/messenger/conversations/${conversationId}/messages`, {
903
+ method: 'POST',
904
+ headers: {
905
+ 'Content-Type': 'application/json',
906
+ Authorization: `Bearer ${this.sessionToken}`,
907
+ },
908
+ body: JSON.stringify({ content: data.content }),
909
+ });
910
+ }
911
+
912
+ /**
913
+ * Send typing indicator
914
+ * @param {string} conversationId - Conversation ID
915
+ * @param {boolean} isTyping - Whether user is typing
916
+ * @returns {Promise<Object>} Response
917
+ */
918
+ async sendTypingIndicator(conversationId, isTyping) {
919
+ if (!this.isSessionValid()) {
920
+ await this.init();
921
+ }
922
+
923
+ if (this.mock) {
924
+ return { status: true };
925
+ }
926
+
927
+ return this._makeRequest(`/widget/messenger/conversations/${conversationId}/typing`, {
928
+ method: 'POST',
929
+ headers: {
930
+ 'Content-Type': 'application/json',
931
+ Authorization: `Bearer ${this.sessionToken}`,
932
+ },
933
+ body: JSON.stringify({ is_typing: isTyping }),
934
+ });
935
+ }
936
+
937
+ /**
938
+ * Mark conversation as read
939
+ * @param {string} conversationId - Conversation ID
940
+ * @returns {Promise<Object>} Response
941
+ */
942
+ async markConversationAsRead(conversationId) {
943
+ if (!this.isSessionValid()) {
944
+ await this.init();
945
+ }
946
+
947
+ if (this.mock) {
948
+ return { status: true };
949
+ }
950
+
951
+ return this._makeRequest(`/widget/messenger/conversations/${conversationId}/read`, {
952
+ method: 'POST',
953
+ headers: { Authorization: `Bearer ${this.sessionToken}` },
954
+ });
955
+ }
956
+
957
+ /**
958
+ * Get unread count
959
+ * @returns {Promise<Object>} Unread count data
960
+ */
961
+ async getUnreadCount() {
962
+ if (!this.isSessionValid()) {
963
+ await this.init();
964
+ }
965
+
966
+ if (this.mock) {
967
+ const count = MOCK_CONVERSATIONS.reduce((sum, c) => sum + (c.unread || 0), 0);
968
+ return {
969
+ status: true,
970
+ data: { unread_count: count, unread_conversations: count > 0 ? 1 : 0 },
971
+ };
972
+ }
973
+
974
+ return this._makeRequest('/widget/messenger/unread', {
975
+ method: 'GET',
976
+ headers: { Authorization: `Bearer ${this.sessionToken}` },
977
+ });
978
+ }
979
+
980
+ /**
981
+ * Submit conversation rating
982
+ * @param {string} conversationId - Conversation ID
983
+ * @param {Object} data - Rating data
984
+ * @param {number} data.rating - Rating (1-5 or thumbs up/down)
985
+ * @param {string} data.comment - Optional comment
986
+ * @returns {Promise<Object>} Response
987
+ */
988
+ async submitRating(conversationId, data) {
989
+ if (!this.isSessionValid()) {
990
+ await this.init();
991
+ }
992
+
993
+ if (this.mock) {
994
+ return { status: true, message: 'Thank you for your feedback!' };
995
+ }
996
+
997
+ return this._makeRequest(`/widget/messenger/conversations/${conversationId}/rate`, {
998
+ method: 'POST',
999
+ headers: {
1000
+ 'Content-Type': 'application/json',
1001
+ Authorization: `Bearer ${this.sessionToken}`,
1002
+ },
1003
+ body: JSON.stringify({
1004
+ rating: data.rating,
1005
+ comment: data.comment || '',
1006
+ }),
1007
+ });
1008
+ }
1009
+
1010
+ /**
1011
+ * Identify contact (for logged-in users)
1012
+ * @param {Object} data - Contact data
1013
+ * @param {string} data.email - Email address
1014
+ * @param {string} data.name - Name
1015
+ * @returns {Promise<Object>} Response
1016
+ */
1017
+ async identifyContact(data) {
1018
+ if (!this.isSessionValid()) {
1019
+ await this.init();
1020
+ }
1021
+
1022
+ if (this.mock) {
1023
+ return { status: true, message: 'Contact identified' };
1024
+ }
1025
+
1026
+ return this._makeRequest('/widget/messenger/identify', {
1027
+ method: 'POST',
1028
+ headers: {
1029
+ 'Content-Type': 'application/json',
1030
+ Authorization: `Bearer ${this.sessionToken}`,
1031
+ },
1032
+ body: JSON.stringify(data),
1033
+ });
1034
+ }
1035
+
1036
+ // ==========================================
1037
+ // HELP ARTICLES ENDPOINTS
1038
+ // ==========================================
1039
+
1040
+ /**
1041
+ * Get help collections
1042
+ * @param {Object} options - Query options
1043
+ * @returns {Promise<Object>} Collections list
1044
+ */
1045
+ async getHelpCollections(options = {}) {
1046
+ if (!this.isSessionValid()) {
1047
+ await this.init();
1048
+ }
1049
+
1050
+ if (this.mock) {
1051
+ await new Promise((resolve) => setTimeout(resolve, 200));
1052
+ return { status: true, data: MOCK_HELP_COLLECTIONS };
1053
+ }
1054
+
1055
+ const params = new URLSearchParams();
1056
+ if (options.limit) params.append('limit', options.limit);
1057
+
1058
+ const endpoint = `/widget/help/collections${params.toString() ? '?' + params.toString() : ''}`;
1059
+ return this._makeRequest(endpoint, {
1060
+ method: 'GET',
1061
+ headers: { Authorization: `Bearer ${this.sessionToken}` },
1062
+ });
1063
+ }
1064
+
1065
+ /**
1066
+ * Search help articles
1067
+ * @param {string} query - Search query
1068
+ * @param {Object} options - Query options
1069
+ * @returns {Promise<Object>} Search results
1070
+ */
1071
+ async searchHelpArticles(query, options = {}) {
1072
+ if (!this.isSessionValid()) {
1073
+ await this.init();
1074
+ }
1075
+
1076
+ if (this.mock) {
1077
+ await new Promise((resolve) => setTimeout(resolve, 200));
1078
+ const filtered = MOCK_HELP_COLLECTIONS.filter(
1079
+ (c) =>
1080
+ c.title.toLowerCase().includes(query.toLowerCase()) ||
1081
+ c.description.toLowerCase().includes(query.toLowerCase())
1082
+ );
1083
+ return { status: true, data: filtered };
1084
+ }
1085
+
1086
+ const params = new URLSearchParams({ q: query });
1087
+ if (options.limit) params.append('limit', options.limit);
1088
+
1089
+ return this._makeRequest(`/widget/help/search?${params.toString()}`, {
1090
+ method: 'GET',
1091
+ headers: { Authorization: `Bearer ${this.sessionToken}` },
1092
+ });
1093
+ }
1094
+
1095
+ // ==========================================
1096
+ // CHANGELOG ENDPOINTS
1097
+ // ==========================================
1098
+
563
1099
  /**
564
1100
  * Get published changelogs
565
1101
  * @param {Object} options - Optional query parameters
@@ -1013,6 +1549,9 @@
1013
1549
  });
1014
1550
 
1015
1551
  class BaseWidget {
1552
+ static STORAGE_KEY = 'feedback_submitted';
1553
+ static DEFAULT_COOLDOWN_DAYS = 30; // Don't show for 30 days after submission
1554
+
1016
1555
  constructor(options = {}) {
1017
1556
  this.id = options.id;
1018
1557
  this.sdk = options.sdk;
@@ -1031,6 +1570,8 @@
1031
1570
  autoShow: false,
1032
1571
  showBackdrop: true,
1033
1572
  customStyles: {},
1573
+ suppressAfterSubmission: true, // Don't show widget again after submission
1574
+ suppressionDays: BaseWidget.DEFAULT_COOLDOWN_DAYS,
1034
1575
  ...options,
1035
1576
  };
1036
1577
 
@@ -1057,6 +1598,15 @@
1057
1598
  mount(container) {
1058
1599
  if (this.mounted || this.destroyed) return this;
1059
1600
 
1601
+ // Check if feedback was recently submitted and should be suppressed
1602
+ if (this.options.suppressAfterSubmission && this._hasRecentlySubmitted()) {
1603
+ this.sdk.eventBus.emit('widget:suppressed', {
1604
+ widget: this,
1605
+ reason: 'recently_submitted'
1606
+ });
1607
+ return this;
1608
+ }
1609
+
1060
1610
  if (typeof container === 'string') {
1061
1611
  container = document.querySelector(container);
1062
1612
  }
@@ -1202,6 +1752,9 @@
1202
1752
 
1203
1753
  const response = await this.apiService.submitFeedback(payload);
1204
1754
 
1755
+ // Track that feedback was submitted
1756
+ this._trackSubmission();
1757
+
1205
1758
  this._showSuccessMessage();
1206
1759
  this.closePanel();
1207
1760
 
@@ -1243,6 +1796,85 @@
1243
1796
  onMount() {}
1244
1797
  onDestroy() {}
1245
1798
 
1799
+ /**
1800
+ * Track that feedback was submitted to localStorage
1801
+ */
1802
+ _trackSubmission() {
1803
+ try {
1804
+ const workspace = this.sdk.config.workspace;
1805
+ const storageKey = `${BaseWidget.STORAGE_KEY}_${workspace}`;
1806
+ const data = {
1807
+ submittedAt: Date.now(),
1808
+ boardId: this.options.boardId,
1809
+ };
1810
+ localStorage.setItem(storageKey, JSON.stringify(data));
1811
+ } catch (e) {
1812
+ // localStorage may not be available
1813
+ console.warn('Failed to track feedback submission:', e);
1814
+ }
1815
+ }
1816
+
1817
+ /**
1818
+ * Check if feedback was recently submitted (within cooldown period)
1819
+ * Uses backend tracking (preferred) with localStorage as fallback
1820
+ * @returns {boolean} true if feedback was submitted within the cooldown period
1821
+ */
1822
+ _hasRecentlySubmitted() {
1823
+ const cooldownMs = this.options.suppressionDays * 24 * 60 * 60 * 1000;
1824
+ const now = Date.now();
1825
+
1826
+ // Check backend tracking first (from init response)
1827
+ if (this.sdk.config.last_feedback_at) {
1828
+ try {
1829
+ const backendTimestamp = new Date(this.sdk.config.last_feedback_at).getTime();
1830
+ if ((now - backendTimestamp) < cooldownMs) {
1831
+ return true;
1832
+ }
1833
+ } catch (e) {
1834
+ // Invalid date format, continue to localStorage check
1835
+ }
1836
+ }
1837
+
1838
+ // Fallback to localStorage
1839
+ try {
1840
+ const workspace = this.sdk.config.workspace;
1841
+ const storageKey = `${BaseWidget.STORAGE_KEY}_${workspace}`;
1842
+ const stored = localStorage.getItem(storageKey);
1843
+
1844
+ if (!stored) return false;
1845
+
1846
+ const data = JSON.parse(stored);
1847
+ const submittedAt = data.submittedAt;
1848
+
1849
+ return (now - submittedAt) < cooldownMs;
1850
+ } catch (e) {
1851
+ // localStorage may not be available or data is corrupted
1852
+ return false;
1853
+ }
1854
+ }
1855
+
1856
+ /**
1857
+ * Clear the submission tracking (allow showing the widget again)
1858
+ */
1859
+ clearSubmissionTracking() {
1860
+ try {
1861
+ const workspace = this.sdk.config.workspace;
1862
+ const storageKey = `${BaseWidget.STORAGE_KEY}_${workspace}`;
1863
+ localStorage.removeItem(storageKey);
1864
+ } catch (e) {
1865
+ console.warn('Failed to clear submission tracking:', e);
1866
+ }
1867
+ }
1868
+
1869
+ /**
1870
+ * Check if the widget should be shown based on submission history
1871
+ * @returns {boolean} true if the widget should be shown
1872
+ */
1873
+ shouldShow() {
1874
+ if (!this.options.suppressAfterSubmission) return true;
1875
+ return !this._hasRecentlySubmitted();
1876
+ }
1877
+
1246
1878
  _render() {
1247
1879
  throw new Error('_render() must be implemented by concrete widget');
1248
1880
  }
@@ -2372,6 +3004,18 @@
2372
3004
  this.enableHelp = options.enableHelp !== false;
2373
3005
  this.enableChangelog = options.enableChangelog !== false;
2374
3006
 
3007
+ // Agent availability
3008
+ this.agentsOnline = false;
3009
+ this.onlineCount = 0;
3010
+ this.responseTime = 'Usually replies within a few minutes';
3011
+
3012
+ // Typing indicators
3013
+ this.typingUsers = {}; // { conversationId: { userName, timestamp } }
3014
+
3015
+ // Loading states
3016
+ this.isLoading = false;
3017
+ this.isLoadingMessages = false;
3018
+
2375
3019
  // Listeners
2376
3020
  this._listeners = new Set();
2377
3021
  }
@@ -3154,6 +3798,9 @@
3154
3798
  this.options = options;
3155
3799
  this.element = null;
3156
3800
  this._unsubscribe = null;
3801
+ this._typingTimeout = null;
3802
+ this._isTyping = false;
3803
+ this._typingIndicator = null;
3157
3804
  }
3158
3805
 
3159
3806
  render() {
@@ -3168,8 +3815,21 @@
3168
3815
  type === 'messageAdded' &&
3169
3816
  data.conversationId === this.state.activeConversationId
3170
3817
  ) {
3818
+ this._hideTypingIndicator();
3171
3819
  this._appendMessage(data.message);
3172
3820
  this._scrollToBottom();
3821
+ } else if (
3822
+ type === 'typingStarted' &&
3823
+ data.conversationId === this.state.activeConversationId
3824
+ ) {
3825
+ this._showTypingIndicator(data.userName);
3826
+ } else if (
3827
+ type === 'typingStopped' &&
3828
+ data.conversationId === this.state.activeConversationId
3829
+ ) {
3830
+ this._hideTypingIndicator();
3831
+ } else if (type === 'messagesUpdate' && data.conversationId === this.state.activeConversationId) {
3832
+ this._updateContent();
3173
3833
  }
3174
3834
  });
3175
3835
 
@@ -3179,13 +3839,20 @@
3179
3839
  _updateContent() {
3180
3840
  const conversation = this.state.getActiveConversation();
3181
3841
  const messages = this.state.getActiveMessages();
3842
+ const isNewConversation = !this.state.activeConversationId;
3182
3843
 
3183
3844
  const messagesHtml =
3184
3845
  messages.length === 0
3185
- ? this._renderEmptyState()
3846
+ ? this._renderEmptyState(isNewConversation)
3186
3847
  : messages.map((msg) => this._renderMessage(msg)).join('');
3187
3848
 
3188
3849
  const avatarHtml = this._renderConversationAvatar(conversation);
3850
+ const title = isNewConversation
3851
+ ? 'New conversation'
3852
+ : conversation?.title || 'Chat with team';
3853
+ const placeholder = isNewConversation
3854
+ ? 'Start typing your message...'
3855
+ : 'Write a message...';
3189
3856
 
3190
3857
  this.element.innerHTML = `
3191
3858
  <div class="messenger-chat-header">
@@ -3194,7 +3861,7 @@
3194
3861
  </button>
3195
3862
  <div class="messenger-chat-header-info">
3196
3863
  ${avatarHtml}
3197
- <span class="messenger-chat-title">${conversation?.title || 'Chat with team'}</span>
3864
+ <span class="messenger-chat-title">${title}</span>
3198
3865
  </div>
3199
3866
  <button class="messenger-close-btn" aria-label="Close">
3200
3867
  <i class="ph ph-x" style="font-size: 20px;"></i>
@@ -3203,11 +3870,17 @@
3203
3870
 
3204
3871
  <div class="messenger-chat-messages">
3205
3872
  ${messagesHtml}
3873
+ <div class="messenger-typing-indicator" style="display: none;">
3874
+ <div class="messenger-typing-dots">
3875
+ <span></span><span></span><span></span>
3876
+ </div>
3877
+ <span class="messenger-typing-text"></span>
3878
+ </div>
3206
3879
  </div>
3207
3880
 
3208
3881
  <div class="messenger-chat-compose">
3209
3882
  <div class="messenger-compose-input-wrapper">
3210
- <textarea class="messenger-compose-input" placeholder="Write a message..." rows="1"></textarea>
3883
+ <textarea class="messenger-compose-input" placeholder="${placeholder}" rows="1"></textarea>
3211
3884
  </div>
3212
3885
  <button class="messenger-compose-send" aria-label="Send" disabled>
3213
3886
  <i class="ph ph-paper-plane-tilt" style="font-size: 20px;"></i>
@@ -3215,17 +3888,25 @@
3215
3888
  </div>
3216
3889
  `;
3217
3890
 
3891
+ this._typingIndicator = this.element.querySelector('.messenger-typing-indicator');
3218
3892
  this._attachEvents();
3219
3893
  this._scrollToBottom();
3220
3894
  }
3221
3895
 
3222
- _renderEmptyState() {
3896
+ _renderEmptyState(isNewConversation = false) {
3223
3897
  const avatarHtml = this._renderTeamAvatars();
3898
+ const responseTime = this.state.responseTime || 'We typically reply within a few minutes';
3899
+ const isOnline = this.state.agentsOnline;
3900
+
3224
3901
  return `
3225
3902
  <div class="messenger-chat-empty">
3226
3903
  <div class="messenger-chat-empty-avatars">${avatarHtml}</div>
3227
- <h3>Start the conversation</h3>
3904
+ <h3>${isNewConversation ? 'Start a new conversation' : 'Start the conversation'}</h3>
3228
3905
  <p>Send us a message and we'll get back to you as soon as possible.</p>
3906
+ <div class="messenger-chat-availability">
3907
+ <span class="messenger-availability-dot ${isOnline ? 'messenger-availability-online' : 'messenger-availability-away'}"></span>
3908
+ <span>${isOnline ? "We're online now" : responseTime}</span>
3909
+ </div>
3229
3910
  </div>
3230
3911
  `;
3231
3912
  }
@@ -3379,6 +4060,11 @@
3379
4060
 
3380
4061
  // Enable/disable send button
3381
4062
  sendBtn.disabled = !input.value.trim();
4063
+
4064
+ // Send typing indicator
4065
+ if (input.value.trim()) {
4066
+ this._startTyping();
4067
+ }
3382
4068
  });
3383
4069
 
3384
4070
  input.addEventListener('keydown', (e) => {
@@ -3399,24 +4085,83 @@
3399
4085
 
3400
4086
  if (!content) return;
3401
4087
 
3402
- // Add message to state
3403
- const message = {
3404
- id: 'msg_' + Date.now(),
3405
- content: content,
3406
- isOwn: true,
3407
- timestamp: new Date().toISOString(),
3408
- };
4088
+ // Stop typing indicator
4089
+ this._stopTyping();
4090
+
4091
+ const isNewConversation = !this.state.activeConversationId;
3409
4092
 
3410
- this.state.addMessage(this.state.activeConversationId, message);
4093
+ if (isNewConversation) {
4094
+ // Start a new conversation
4095
+ if (this.options.onStartConversation) {
4096
+ this.options.onStartConversation(content);
4097
+ }
4098
+ } else {
4099
+ // Add message to existing conversation
4100
+ const message = {
4101
+ id: 'msg_' + Date.now(),
4102
+ content: content,
4103
+ isOwn: true,
4104
+ timestamp: new Date().toISOString(),
4105
+ };
4106
+
4107
+ this.state.addMessage(this.state.activeConversationId, message);
4108
+
4109
+ // Emit event for API integration
4110
+ if (this.options.onSendMessage) {
4111
+ this.options.onSendMessage(this.state.activeConversationId, message);
4112
+ }
4113
+ }
3411
4114
 
3412
4115
  // Clear input
3413
4116
  input.value = '';
3414
4117
  input.style.height = 'auto';
3415
4118
  this.element.querySelector('.messenger-compose-send').disabled = true;
4119
+ }
4120
+
4121
+ _startTyping() {
4122
+ if (!this._isTyping && this.state.activeConversationId) {
4123
+ this._isTyping = true;
4124
+ if (this.options.onTyping) {
4125
+ this.options.onTyping(this.state.activeConversationId, true);
4126
+ }
4127
+ }
4128
+
4129
+ // Reset typing timeout
4130
+ if (this._typingTimeout) {
4131
+ clearTimeout(this._typingTimeout);
4132
+ }
4133
+ this._typingTimeout = setTimeout(() => {
4134
+ this._stopTyping();
4135
+ }, 3000);
4136
+ }
4137
+
4138
+ _stopTyping() {
4139
+ if (this._isTyping && this.state.activeConversationId) {
4140
+ this._isTyping = false;
4141
+ if (this.options.onTyping) {
4142
+ this.options.onTyping(this.state.activeConversationId, false);
4143
+ }
4144
+ }
4145
+ if (this._typingTimeout) {
4146
+ clearTimeout(this._typingTimeout);
4147
+ this._typingTimeout = null;
4148
+ }
4149
+ }
4150
+
4151
+ _showTypingIndicator(userName) {
4152
+ if (this._typingIndicator) {
4153
+ this._typingIndicator.style.display = 'flex';
4154
+ const textEl = this._typingIndicator.querySelector('.messenger-typing-text');
4155
+ if (textEl) {
4156
+ textEl.textContent = `${userName || 'Support'} is typing...`;
4157
+ }
4158
+ this._scrollToBottom();
4159
+ }
4160
+ }
3416
4161
 
3417
- // Emit event for API integration
3418
- if (this.options.onSendMessage) {
3419
- this.options.onSendMessage(this.state.activeConversationId, message);
4162
+ _hideTypingIndicator() {
4163
+ if (this._typingIndicator) {
4164
+ this._typingIndicator.style.display = 'none';
3420
4165
  }
3421
4166
  }
3422
4167
 
@@ -3424,6 +4169,10 @@
3424
4169
  if (this._unsubscribe) {
3425
4170
  this._unsubscribe();
3426
4171
  }
4172
+ if (this._typingTimeout) {
4173
+ clearTimeout(this._typingTimeout);
4174
+ }
4175
+ this._stopTyping();
3427
4176
  if (this.element && this.element.parentNode) {
3428
4177
  this.element.parentNode.removeChild(this.element);
3429
4178
  }
@@ -3617,6 +4366,11 @@
3617
4366
  this.state.setActiveConversation(convId);
3618
4367
  this.state.markAsRead(convId);
3619
4368
  this.state.setView('chat');
4369
+
4370
+ // Notify widget to fetch messages
4371
+ if (this.options.onSelectConversation) {
4372
+ this.options.onSelectConversation(convId);
4373
+ }
3620
4374
  });
3621
4375
  });
3622
4376
 
@@ -3630,18 +4384,14 @@
3630
4384
  }
3631
4385
 
3632
4386
  _startNewConversation() {
3633
- // Create a new conversation and navigate to chat
3634
- const newConv = {
3635
- id: 'conv_' + Date.now(),
3636
- title: 'New conversation',
3637
- participants: [],
3638
- lastMessage: null,
3639
- lastMessageTime: new Date().toISOString(),
3640
- unread: 0,
3641
- };
3642
- this.state.addConversation(newConv);
3643
- this.state.setActiveConversation(newConv.id);
4387
+ // Set view to chat with no active conversation (new conversation mode)
4388
+ this.state.setActiveConversation(null);
3644
4389
  this.state.setView('chat');
4390
+
4391
+ // Notify widget to handle new conversation flow
4392
+ if (this.options.onStartNewConversation) {
4393
+ this.options.onStartNewConversation();
4394
+ }
3645
4395
  }
3646
4396
 
3647
4397
  destroy() {
@@ -3865,7 +4615,7 @@
3865
4615
 
3866
4616
  // Subscribe to state changes to re-render when data loads
3867
4617
  this._unsubscribe = this.state.subscribe((type) => {
3868
- if (type === 'homeChangelogUpdate' || type === 'conversationsUpdate') {
4618
+ if (type === 'homeChangelogUpdate' || type === 'conversationsUpdate' || type === 'availabilityUpdate') {
3869
4619
  this._updateContent();
3870
4620
  }
3871
4621
  });
@@ -3891,6 +4641,7 @@
3891
4641
  <div class="messenger-home-welcome">
3892
4642
  <span class="messenger-home-greeting">Hello there.</span>
3893
4643
  <span class="messenger-home-question">${this.state.welcomeMessage}</span>
4644
+ ${this._renderAvailabilityStatus()}
3894
4645
  </div>
3895
4646
  </div>
3896
4647
 
@@ -3939,6 +4690,27 @@
3939
4690
  return colors[index % colors.length];
3940
4691
  }
3941
4692
 
4693
+ _renderAvailabilityStatus() {
4694
+ const isOnline = this.state.agentsOnline;
4695
+ const responseTime = this.state.responseTime || 'We typically reply within a few minutes';
4696
+
4697
+ if (isOnline) {
4698
+ return `
4699
+ <div class="messenger-home-availability">
4700
+ <span class="messenger-availability-dot messenger-availability-online"></span>
4701
+ <span class="messenger-availability-text">We're online now</span>
4702
+ </div>
4703
+ `;
4704
+ }
4705
+
4706
+ return `
4707
+ <div class="messenger-home-availability">
4708
+ <span class="messenger-availability-dot messenger-availability-away"></span>
4709
+ <span class="messenger-availability-text">${responseTime}</span>
4710
+ </div>
4711
+ `;
4712
+ }
4713
+
3942
4714
  _renderFeaturedCard() {
3943
4715
  // Only show if there's featured content configured
3944
4716
  if (!this.options.featuredContent) {
@@ -4061,12 +4833,286 @@
4061
4833
  }
4062
4834
  }
4063
4835
 
4064
- destroy() {
4065
- if (this._unsubscribe) {
4066
- this._unsubscribe();
4067
- }
4068
- if (this.element && this.element.parentNode) {
4069
- this.element.parentNode.removeChild(this.element);
4836
+ destroy() {
4837
+ if (this._unsubscribe) {
4838
+ this._unsubscribe();
4839
+ }
4840
+ if (this.element && this.element.parentNode) {
4841
+ this.element.parentNode.removeChild(this.element);
4842
+ }
4843
+ }
4844
+ }
4845
+
4846
+ /**
4847
+ * WebSocketService - Real-time communication for messenger widget
4848
+ */
4849
+
4850
+ class WebSocketService {
4851
+ constructor(config = {}) {
4852
+ this.baseURL = config.baseURL || '';
4853
+ this.workspace = config.workspace || '';
4854
+ this.sessionToken = config.sessionToken || null;
4855
+ this.mock = config.mock || false;
4856
+
4857
+ this.ws = null;
4858
+ this.reconnectAttempts = 0;
4859
+ this.maxReconnectAttempts = 5;
4860
+ this.reconnectDelay = 1000;
4861
+ this.pingInterval = null;
4862
+ this.isConnected = false;
4863
+
4864
+ // Event listeners
4865
+ this._listeners = new Map();
4866
+
4867
+ // Bind methods
4868
+ this._onOpen = this._onOpen.bind(this);
4869
+ this._onMessage = this._onMessage.bind(this);
4870
+ this._onClose = this._onClose.bind(this);
4871
+ this._onError = this._onError.bind(this);
4872
+ }
4873
+
4874
+ /**
4875
+ * Connect to WebSocket server
4876
+ */
4877
+ connect(sessionToken = null) {
4878
+ if (sessionToken) {
4879
+ this.sessionToken = sessionToken;
4880
+ }
4881
+
4882
+ if (!this.sessionToken) {
4883
+ console.warn('[WebSocket] No session token provided');
4884
+ return;
4885
+ }
4886
+
4887
+ // Mock mode - simulate connection
4888
+ if (this.mock) {
4889
+ this.isConnected = true;
4890
+ this._emit('connected', {});
4891
+ this._startMockResponses();
4892
+ return;
4893
+ }
4894
+
4895
+ // Build WebSocket URL
4896
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
4897
+ let wsURL = this.baseURL.replace(/^https?:/, wsProtocol);
4898
+ wsURL = wsURL.replace('/api/v1', '');
4899
+ wsURL = `${wsURL}/api/v1/widget/messenger/ws?token=${encodeURIComponent(this.sessionToken)}`;
4900
+
4901
+ try {
4902
+ this.ws = new WebSocket(wsURL);
4903
+ this.ws.onopen = this._onOpen;
4904
+ this.ws.onmessage = this._onMessage;
4905
+ this.ws.onclose = this._onClose;
4906
+ this.ws.onerror = this._onError;
4907
+ } catch (error) {
4908
+ console.error('[WebSocket] Connection error:', error);
4909
+ this._scheduleReconnect();
4910
+ }
4911
+ }
4912
+
4913
+ /**
4914
+ * Disconnect from WebSocket server
4915
+ */
4916
+ disconnect() {
4917
+ this.isConnected = false;
4918
+ this.reconnectAttempts = this.maxReconnectAttempts; // Prevent reconnection
4919
+
4920
+ if (this.pingInterval) {
4921
+ clearInterval(this.pingInterval);
4922
+ this.pingInterval = null;
4923
+ }
4924
+
4925
+ if (this.ws) {
4926
+ this.ws.close();
4927
+ this.ws = null;
4928
+ }
4929
+
4930
+ if (this._mockInterval) {
4931
+ clearInterval(this._mockInterval);
4932
+ this._mockInterval = null;
4933
+ }
4934
+ }
4935
+
4936
+ /**
4937
+ * Subscribe to events
4938
+ * @param {string} event - Event name
4939
+ * @param {Function} callback - Event handler
4940
+ * @returns {Function} Unsubscribe function
4941
+ */
4942
+ on(event, callback) {
4943
+ if (!this._listeners.has(event)) {
4944
+ this._listeners.set(event, new Set());
4945
+ }
4946
+ this._listeners.get(event).add(callback);
4947
+ return () => this._listeners.get(event).delete(callback);
4948
+ }
4949
+
4950
+ /**
4951
+ * Remove event listener
4952
+ */
4953
+ off(event, callback) {
4954
+ if (this._listeners.has(event)) {
4955
+ this._listeners.get(event).delete(callback);
4956
+ }
4957
+ }
4958
+
4959
+ /**
4960
+ * Send message through WebSocket
4961
+ */
4962
+ send(type, payload = {}) {
4963
+ if (!this.isConnected) {
4964
+ console.warn('[WebSocket] Not connected, cannot send message');
4965
+ return;
4966
+ }
4967
+
4968
+ if (this.mock) {
4969
+ // Mock mode - just log
4970
+ console.log('[WebSocket Mock] Sending:', type, payload);
4971
+ return;
4972
+ }
4973
+
4974
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
4975
+ this.ws.send(JSON.stringify({ type, payload }));
4976
+ }
4977
+ }
4978
+
4979
+ // Private methods
4980
+
4981
+ _onOpen() {
4982
+ console.log('[WebSocket] Connected');
4983
+ this.isConnected = true;
4984
+ this.reconnectAttempts = 0;
4985
+ this._emit('connected', {});
4986
+
4987
+ // Start ping interval to keep connection alive
4988
+ this.pingInterval = setInterval(() => {
4989
+ this.send('ping', {});
4990
+ }, 30000);
4991
+ }
4992
+
4993
+ _onMessage(event) {
4994
+ try {
4995
+ const data = JSON.parse(event.data);
4996
+ const { type, payload } = data;
4997
+
4998
+ // Handle different event types
4999
+ switch (type) {
5000
+ case 'message:new':
5001
+ this._emit('message', payload);
5002
+ break;
5003
+ case 'typing:started':
5004
+ this._emit('typing_started', payload);
5005
+ break;
5006
+ case 'typing:stopped':
5007
+ this._emit('typing_stopped', payload);
5008
+ break;
5009
+ case 'conversation:updated':
5010
+ this._emit('conversation_updated', payload);
5011
+ break;
5012
+ case 'conversation:closed':
5013
+ this._emit('conversation_closed', payload);
5014
+ break;
5015
+ case 'availability:changed':
5016
+ this._emit('availability_changed', payload);
5017
+ break;
5018
+ case 'pong':
5019
+ // Ping response, ignore
5020
+ break;
5021
+ default:
5022
+ console.log('[WebSocket] Unknown event:', type, payload);
5023
+ }
5024
+ } catch (error) {
5025
+ console.error('[WebSocket] Failed to parse message:', error);
5026
+ }
5027
+ }
5028
+
5029
+ _onClose(event) {
5030
+ console.log('[WebSocket] Disconnected:', event.code, event.reason);
5031
+ this.isConnected = false;
5032
+
5033
+ if (this.pingInterval) {
5034
+ clearInterval(this.pingInterval);
5035
+ this.pingInterval = null;
5036
+ }
5037
+
5038
+ this._emit('disconnected', { code: event.code, reason: event.reason });
5039
+ this._scheduleReconnect();
5040
+ }
5041
+
5042
+ _onError(error) {
5043
+ console.error('[WebSocket] Error:', error);
5044
+ this._emit('error', { error });
5045
+ }
5046
+
5047
+ _scheduleReconnect() {
5048
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
5049
+ console.log('[WebSocket] Max reconnect attempts reached');
5050
+ this._emit('reconnect_failed', {});
5051
+ return;
5052
+ }
5053
+
5054
+ this.reconnectAttempts++;
5055
+ const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
5056
+ console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
5057
+
5058
+ setTimeout(() => {
5059
+ this.connect();
5060
+ }, delay);
5061
+ }
5062
+
5063
+ _emit(event, data) {
5064
+ if (this._listeners.has(event)) {
5065
+ this._listeners.get(event).forEach((callback) => {
5066
+ try {
5067
+ callback(data);
5068
+ } catch (error) {
5069
+ console.error(`[WebSocket] Error in ${event} handler:`, error);
5070
+ }
5071
+ });
5072
+ }
5073
+ }
5074
+
5075
+ // Mock support for development
5076
+ _startMockResponses() {
5077
+ // Simulate agent typing and responses
5078
+ this._mockInterval = setInterval(() => {
5079
+ // Randomly emit typing or message events for demo
5080
+ const random = Math.random();
5081
+ if (random < 0.1) {
5082
+ this._emit('typing_started', {
5083
+ conversation_id: 'conv_1',
5084
+ user_id: 'agent_1',
5085
+ user_name: 'Sarah',
5086
+ is_agent: true,
5087
+ });
5088
+
5089
+ // Stop typing after 2 seconds
5090
+ setTimeout(() => {
5091
+ this._emit('typing_stopped', {
5092
+ conversation_id: 'conv_1',
5093
+ user_id: 'agent_1',
5094
+ });
5095
+ }, 2000);
5096
+ }
5097
+ }, 10000);
5098
+ }
5099
+
5100
+ /**
5101
+ * Simulate receiving a message (for mock mode)
5102
+ */
5103
+ simulateMessage(conversationId, message) {
5104
+ if (this.mock) {
5105
+ this._emit('message', {
5106
+ conversation_id: conversationId,
5107
+ message: {
5108
+ id: 'msg_' + Date.now(),
5109
+ content: message.content,
5110
+ sender_type: message.sender_type || 'agent',
5111
+ sender_name: message.sender_name || 'Support',
5112
+ sender_avatar: message.sender_avatar || null,
5113
+ created_at: new Date().toISOString(),
5114
+ },
5115
+ });
4070
5116
  }
4071
5117
  }
4072
5118
  }
@@ -4108,9 +5154,14 @@
4108
5154
 
4109
5155
  this.launcher = null;
4110
5156
  this.panel = null;
5157
+ this.wsService = null;
5158
+ this._wsUnsubscribers = [];
4111
5159
 
4112
5160
  // Bind methods
4113
5161
  this._handleOpenChange = this._handleOpenChange.bind(this);
5162
+ this._handleWebSocketMessage = this._handleWebSocketMessage.bind(this);
5163
+ this._handleTypingStarted = this._handleTypingStarted.bind(this);
5164
+ this._handleTypingStopped = this._handleTypingStopped.bind(this);
4114
5165
  }
4115
5166
 
4116
5167
  _render() {
@@ -4126,16 +5177,23 @@
4126
5177
  });
4127
5178
  container.appendChild(this.launcher.render());
4128
5179
 
4129
- // Create panel
5180
+ // Create panel with all callbacks
4130
5181
  this.panel = new MessengerPanel(this.messengerState, {
4131
5182
  position: this.messengerOptions.position,
4132
5183
  theme: this.messengerOptions.theme,
4133
5184
  primaryColor: this.messengerOptions.primaryColor,
4134
5185
  logoUrl: this.messengerOptions.logoUrl,
4135
5186
  featuredContent: this.messengerOptions.featuredContent,
5187
+ // Chat callbacks
4136
5188
  onSendMessage:
4137
5189
  this.messengerOptions.onSendMessage ||
4138
5190
  this._handleSendMessage.bind(this),
5191
+ onStartConversation: this._handleStartConversation.bind(this),
5192
+ onTyping: this.sendTypingIndicator.bind(this),
5193
+ // Conversation list callbacks
5194
+ onSelectConversation: this._handleSelectConversation.bind(this),
5195
+ onStartNewConversation: this._handleNewConversationClick.bind(this),
5196
+ // Article/changelog callbacks
4139
5197
  onArticleClick: this.messengerOptions.onArticleClick,
4140
5198
  onChangelogClick: this.messengerOptions.onChangelogClick,
4141
5199
  });
@@ -4173,30 +5231,179 @@
4173
5231
  }
4174
5232
  }
4175
5233
 
5234
+ /**
5235
+ * Handle starting a new conversation
5236
+ */
5237
+ async _handleStartConversation(messageContent) {
5238
+ try {
5239
+ await this.startNewConversation(messageContent);
5240
+ } catch (error) {
5241
+ console.error('[MessengerWidget] Failed to start conversation:', error);
5242
+ }
5243
+ }
5244
+
5245
+ /**
5246
+ * Handle selecting a conversation from the list
5247
+ */
5248
+ async _handleSelectConversation(conversationId) {
5249
+ try {
5250
+ await this.fetchMessages(conversationId);
5251
+ } catch (error) {
5252
+ console.error('[MessengerWidget] Failed to fetch messages:', error);
5253
+ }
5254
+ }
5255
+
5256
+ /**
5257
+ * Handle clicking "new conversation" button
5258
+ */
5259
+ _handleNewConversationClick() {
5260
+ // View is already changed by ConversationsView
5261
+ // This is for any additional setup needed
5262
+ }
5263
+
4176
5264
  async _handleSendMessage(conversationId, message) {
4177
- // Default message handler - can be overridden
5265
+ // Emit event for external listeners
4178
5266
  this.sdk.eventBus.emit('messenger:messageSent', {
4179
5267
  widget: this,
4180
5268
  conversationId,
4181
5269
  message,
4182
5270
  });
4183
5271
 
4184
- // In mock mode, simulate a response
4185
- if (this.apiService?.mock) {
4186
- setTimeout(() => {
4187
- const response = {
4188
- id: 'msg_' + Date.now(),
4189
- content: "Thanks for your message! We'll get back to you soon.",
4190
- isOwn: false,
4191
- timestamp: new Date().toISOString(),
4192
- sender: {
4193
- name: 'Support Team',
4194
- avatarUrl: null,
4195
- },
4196
- };
4197
- this.messengerState.addMessage(conversationId, response);
4198
- }, 1500);
5272
+ try {
5273
+ // Send message through API
5274
+ const response = await this.apiService.sendMessage(conversationId, {
5275
+ content: message.content,
5276
+ });
5277
+
5278
+ if (response.status && response.data) {
5279
+ // Update the message ID with server-assigned ID
5280
+ // Message is already added to state optimistically in ChatView
5281
+ console.log('[MessengerWidget] Message sent:', response.data.id);
5282
+ }
5283
+
5284
+ // In mock mode, simulate an agent response after a delay
5285
+ if (this.apiService?.mock) {
5286
+ setTimeout(() => {
5287
+ const mockResponse = {
5288
+ id: 'msg_' + Date.now(),
5289
+ content: "Thanks for your message! We'll get back to you soon.",
5290
+ isOwn: false,
5291
+ timestamp: new Date().toISOString(),
5292
+ sender: {
5293
+ name: 'Support Team',
5294
+ avatarUrl: null,
5295
+ },
5296
+ };
5297
+ this.messengerState.addMessage(conversationId, mockResponse);
5298
+ }, 1500);
5299
+ }
5300
+ } catch (error) {
5301
+ console.error('[MessengerWidget] Failed to send message:', error);
5302
+ // Could add error handling UI here
5303
+ }
5304
+ }
5305
+
5306
+ /**
5307
+ * Handle incoming WebSocket message
5308
+ */
5309
+ _handleWebSocketMessage(data) {
5310
+ const { conversation_id, message } = data;
5311
+
5312
+ // Transform message to local format
5313
+ const localMessage = {
5314
+ id: message.id,
5315
+ content: message.content,
5316
+ isOwn: message.sender_type === 'customer',
5317
+ timestamp: message.created_at,
5318
+ sender: {
5319
+ name: message.sender_name || 'Support',
5320
+ avatarUrl: message.sender_avatar || null,
5321
+ },
5322
+ };
5323
+
5324
+ // Add message to state
5325
+ this.messengerState.addMessage(conversation_id, localMessage);
5326
+
5327
+ // Update unread count if panel is closed or viewing different conversation
5328
+ if (!this.messengerState.isOpen || this.messengerState.activeConversationId !== conversation_id) {
5329
+ this._updateUnreadCount();
5330
+ }
5331
+ }
5332
+
5333
+ /**
5334
+ * Handle typing started event
5335
+ */
5336
+ _handleTypingStarted(data) {
5337
+ if (data.is_agent) {
5338
+ this.messengerState._notify('typingStarted', {
5339
+ conversationId: data.conversation_id,
5340
+ userName: data.user_name,
5341
+ });
5342
+ }
5343
+ }
5344
+
5345
+ /**
5346
+ * Handle typing stopped event
5347
+ */
5348
+ _handleTypingStopped(data) {
5349
+ this.messengerState._notify('typingStopped', {
5350
+ conversationId: data.conversation_id,
5351
+ });
5352
+ }
5353
+
5354
+ /**
5355
+ * Update unread count from API
5356
+ */
5357
+ async _updateUnreadCount() {
5358
+ try {
5359
+ const response = await this.apiService.getUnreadCount();
5360
+ if (response.status && response.data) {
5361
+ this.messengerState.unreadCount = response.data.unread_count || 0;
5362
+ this.messengerState._notify('unreadCountChange', { count: this.messengerState.unreadCount });
5363
+ }
5364
+ } catch (error) {
5365
+ console.error('[MessengerWidget] Failed to get unread count:', error);
5366
+ }
5367
+ }
5368
+
5369
+ /**
5370
+ * Initialize WebSocket connection
5371
+ */
5372
+ _initWebSocket() {
5373
+ if (this.wsService) {
5374
+ this.wsService.disconnect();
4199
5375
  }
5376
+
5377
+ this.wsService = new WebSocketService({
5378
+ baseURL: this.apiService.baseURL,
5379
+ workspace: this.apiService.workspace,
5380
+ sessionToken: this.apiService.sessionToken,
5381
+ mock: this.apiService.mock,
5382
+ });
5383
+
5384
+ // Subscribe to WebSocket events
5385
+ this._wsUnsubscribers.push(
5386
+ this.wsService.on('message', this._handleWebSocketMessage)
5387
+ );
5388
+ this._wsUnsubscribers.push(
5389
+ this.wsService.on('typing_started', this._handleTypingStarted)
5390
+ );
5391
+ this._wsUnsubscribers.push(
5392
+ this.wsService.on('typing_stopped', this._handleTypingStopped)
5393
+ );
5394
+ this._wsUnsubscribers.push(
5395
+ this.wsService.on('connected', () => {
5396
+ console.log('[MessengerWidget] WebSocket connected');
5397
+ })
5398
+ );
5399
+ this._wsUnsubscribers.push(
5400
+ this.wsService.on('disconnected', () => {
5401
+ console.log('[MessengerWidget] WebSocket disconnected');
5402
+ })
5403
+ );
5404
+
5405
+ // Connect
5406
+ this.wsService.connect();
4200
5407
  }
4201
5408
 
4202
5409
  /**
@@ -4320,82 +5527,159 @@
4320
5527
  }
4321
5528
 
4322
5529
  async _fetchConversations() {
4323
- // Mock data for now
4324
- if (this.apiService?.mock) {
4325
- return [
4326
- {
4327
- id: 'conv_1',
4328
- title: 'Chat with Sarah',
4329
- participants: [{ name: 'Sarah', avatarUrl: null }],
4330
- lastMessage: "Sarah: Hi 👋 I'm Sarah. How can I help today?",
4331
- lastMessageTime: new Date(Date.now() - 49 * 60 * 1000).toISOString(),
4332
- unread: 1,
4333
- },
4334
- {
4335
- id: 'conv_2',
4336
- title: 'Chat with Tom',
4337
- participants: [{ name: 'Tom', avatarUrl: null }],
4338
- lastMessage: 'Tom: The feature will be available next week.',
4339
- lastMessageTime: new Date(
4340
- Date.now() - 6 * 24 * 60 * 60 * 1000
4341
- ).toISOString(),
4342
- unread: 0,
4343
- },
4344
- ];
5530
+ try {
5531
+ const response = await this.apiService.getConversations();
5532
+ if (response.status && response.data) {
5533
+ // Transform API response to local format
5534
+ return response.data.map((conv) => ({
5535
+ id: conv.id,
5536
+ title: conv.subject || `Chat with ${conv.assigned_user?.name || 'Support'}`,
5537
+ participants: conv.assigned_user
5538
+ ? [{ name: conv.assigned_user.name, avatarUrl: conv.assigned_user.avatar }]
5539
+ : [{ name: 'Support', avatarUrl: null }],
5540
+ lastMessage: conv.preview || conv.snippet || '',
5541
+ lastMessageTime: conv.last_message_at,
5542
+ unread: conv.unread || 0,
5543
+ status: conv.status,
5544
+ }));
5545
+ }
5546
+ return [];
5547
+ } catch (error) {
5548
+ console.error('[MessengerWidget] Failed to fetch conversations:', error);
5549
+ return [];
4345
5550
  }
4346
-
4347
- // TODO: Implement API call
4348
- return [];
4349
5551
  }
4350
5552
 
4351
5553
  async _fetchHelpArticles() {
4352
- // Mock data for collections
4353
- if (this.apiService?.mock) {
4354
- return [
4355
- {
4356
- id: 'collection_1',
4357
- title: 'Product Overview',
4358
- description: 'See how your AI-first customer service solution works.',
4359
- articleCount: 24,
4360
- url: '#',
4361
- },
4362
- {
4363
- id: 'collection_2',
4364
- title: 'Getting Started',
4365
- description:
4366
- 'Everything you need to know to get started with Product7.',
4367
- articleCount: 30,
4368
- url: '#',
4369
- },
4370
- {
4371
- id: 'collection_3',
4372
- title: 'AI Agent',
4373
- description:
4374
- 'Resolving customer questions instantly and accurately—from live chat to email.',
4375
- articleCount: 82,
4376
- url: '#',
4377
- },
4378
- {
4379
- id: 'collection_4',
4380
- title: 'Channels',
4381
- description:
4382
- 'Enabling the channels you use to communicate with customers, all from the Inbox.',
4383
- articleCount: 45,
4384
- url: '#',
4385
- },
4386
- {
4387
- id: 'collection_5',
4388
- title: 'Billing & Payments',
4389
- description:
4390
- 'Manage your subscription, invoices, and payment methods.',
4391
- articleCount: 12,
4392
- url: '#',
4393
- },
4394
- ];
5554
+ try {
5555
+ const response = await this.apiService.getHelpCollections();
5556
+ if (response.status && response.data) {
5557
+ // Transform API response to local format
5558
+ return response.data.map((collection) => ({
5559
+ id: collection.id,
5560
+ title: collection.title || collection.name,
5561
+ description: collection.description || '',
5562
+ articleCount: collection.article_count || collection.articleCount || 0,
5563
+ icon: collection.icon || 'ph-book-open',
5564
+ url: collection.url || `#/help/${collection.slug || collection.id}`,
5565
+ }));
5566
+ }
5567
+ return [];
5568
+ } catch (error) {
5569
+ console.error('[MessengerWidget] Failed to fetch help articles:', error);
5570
+ return [];
5571
+ }
5572
+ }
5573
+
5574
+ /**
5575
+ * Fetch messages for a conversation
5576
+ */
5577
+ async fetchMessages(conversationId) {
5578
+ try {
5579
+ const response = await this.apiService.getConversation(conversationId);
5580
+ if (response.status && response.data) {
5581
+ const messages = (response.data.messages || []).map((msg) => ({
5582
+ id: msg.id,
5583
+ content: msg.content,
5584
+ isOwn: msg.sender_type === 'customer',
5585
+ timestamp: msg.created_at,
5586
+ sender: {
5587
+ name: msg.sender_name || (msg.sender_type === 'customer' ? 'You' : 'Support'),
5588
+ avatarUrl: msg.sender_avatar || null,
5589
+ },
5590
+ }));
5591
+ this.messengerState.setMessages(conversationId, messages);
5592
+
5593
+ // Mark as read
5594
+ await this.apiService.markConversationAsRead(conversationId);
5595
+ this.messengerState.markAsRead(conversationId);
5596
+
5597
+ return messages;
5598
+ }
5599
+ return [];
5600
+ } catch (error) {
5601
+ console.error('[MessengerWidget] Failed to fetch messages:', error);
5602
+ return [];
5603
+ }
5604
+ }
5605
+
5606
+ /**
5607
+ * Start a new conversation
5608
+ */
5609
+ async startNewConversation(message, subject = '') {
5610
+ try {
5611
+ const response = await this.apiService.startConversation({
5612
+ message,
5613
+ subject,
5614
+ });
5615
+
5616
+ if (response.status && response.data) {
5617
+ const conv = response.data;
5618
+ const newConversation = {
5619
+ id: conv.id,
5620
+ title: conv.subject || 'New conversation',
5621
+ participants: [{ name: 'Support', avatarUrl: null }],
5622
+ lastMessage: message,
5623
+ lastMessageTime: conv.created_at || new Date().toISOString(),
5624
+ unread: 0,
5625
+ status: 'open',
5626
+ };
5627
+
5628
+ // Add to state
5629
+ this.messengerState.addConversation(newConversation);
5630
+
5631
+ // Set initial message in messages cache
5632
+ this.messengerState.setMessages(conv.id, [
5633
+ {
5634
+ id: 'msg_' + Date.now(),
5635
+ content: message,
5636
+ isOwn: true,
5637
+ timestamp: new Date().toISOString(),
5638
+ },
5639
+ ]);
5640
+
5641
+ // Navigate to chat
5642
+ this.messengerState.setActiveConversation(conv.id);
5643
+ this.messengerState.setView('chat');
5644
+
5645
+ return conv;
5646
+ }
5647
+ return null;
5648
+ } catch (error) {
5649
+ console.error('[MessengerWidget] Failed to start conversation:', error);
5650
+ return null;
4395
5651
  }
5652
+ }
5653
+
5654
+ /**
5655
+ * Send typing indicator
5656
+ */
5657
+ async sendTypingIndicator(conversationId, isTyping) {
5658
+ try {
5659
+ await this.apiService.sendTypingIndicator(conversationId, isTyping);
5660
+ } catch (error) {
5661
+ // Silently fail - typing indicators are not critical
5662
+ }
5663
+ }
4396
5664
 
4397
- // TODO: Implement API call
4398
- return [];
5665
+ /**
5666
+ * Check agent availability
5667
+ */
5668
+ async checkAgentAvailability() {
5669
+ try {
5670
+ const response = await this.apiService.checkAgentsOnline();
5671
+ if (response.status && response.data) {
5672
+ this.messengerState.agentsOnline = response.data.agents_online;
5673
+ this.messengerState.onlineCount = response.data.online_count || 0;
5674
+ this.messengerState.responseTime = response.data.response_time || '';
5675
+ this.messengerState._notify('availabilityUpdate', response.data);
5676
+ return response.data;
5677
+ }
5678
+ return { agents_online: false, online_count: 0 };
5679
+ } catch (error) {
5680
+ console.error('[MessengerWidget] Failed to check availability:', error);
5681
+ return { agents_online: false, online_count: 0 };
5682
+ }
4399
5683
  }
4400
5684
 
4401
5685
  async _fetchChangelog() {
@@ -4478,19 +5762,64 @@
4478
5762
  };
4479
5763
  }
4480
5764
 
4481
- // TODO: Implement API call for changelogs
4482
- return { homeItems: [], changelogItems: [] };
5765
+ // Fetch changelogs from API
5766
+ const response = await this.apiService.getChangelogs({ limit: 20 });
5767
+ const changelogs = response.data || [];
5768
+
5769
+ // Map API response to expected format
5770
+ const mappedItems = changelogs.map((item) => ({
5771
+ id: item.id,
5772
+ title: item.title,
5773
+ description: item.excerpt || item.description || '',
5774
+ tags: item.labels ? item.labels.map((label) => label.name) : [],
5775
+ coverImage: item.cover_image || null,
5776
+ coverText: null,
5777
+ publishedAt: item.published_at,
5778
+ url: item.slug ? `/changelog/${item.slug}` : '#',
5779
+ }));
5780
+
5781
+ return {
5782
+ homeItems: mappedItems.slice(0, 3),
5783
+ changelogItems: mappedItems,
5784
+ };
4483
5785
  }
4484
5786
 
4485
5787
  onMount() {
4486
5788
  // Load initial data after mounting
4487
5789
  this.loadInitialData();
5790
+
5791
+ // Initialize WebSocket for real-time updates
5792
+ if (this.apiService?.sessionToken) {
5793
+ this._initWebSocket();
5794
+ }
5795
+
5796
+ // Check agent availability
5797
+ this.checkAgentAvailability();
5798
+
5799
+ // Periodically check availability (every 60 seconds)
5800
+ this._availabilityInterval = setInterval(() => {
5801
+ this.checkAgentAvailability();
5802
+ }, 60000);
4488
5803
  }
4489
5804
 
4490
5805
  onDestroy() {
4491
5806
  if (this._stateUnsubscribe) {
4492
5807
  this._stateUnsubscribe();
4493
5808
  }
5809
+
5810
+ // Clean up WebSocket
5811
+ if (this.wsService) {
5812
+ this.wsService.disconnect();
5813
+ }
5814
+
5815
+ // Clean up WebSocket event listeners
5816
+ this._wsUnsubscribers.forEach((unsub) => unsub());
5817
+ this._wsUnsubscribers = [];
5818
+
5819
+ // Clean up availability interval
5820
+ if (this._availabilityInterval) {
5821
+ clearInterval(this._availabilityInterval);
5822
+ }
4494
5823
  }
4495
5824
 
4496
5825
  destroy() {
@@ -4500,6 +5829,7 @@
4500
5829
  if (this.panel) {
4501
5830
  this.panel.destroy();
4502
5831
  }
5832
+ this.onDestroy();
4503
5833
  super.destroy();
4504
5834
  }
4505
5835
  }
@@ -5586,6 +6916,54 @@
5586
6916
  this.eventBus.emit('sdk:destroyed');
5587
6917
  }
5588
6918
 
6919
+ /**
6920
+ * Check if feedback was recently submitted for this workspace
6921
+ * Uses backend tracking (preferred) with localStorage as fallback
6922
+ * @param {number} cooldownDays - Days to consider as "recently" (default: 30)
6923
+ * @returns {boolean} true if feedback was submitted within the cooldown period
6924
+ */
6925
+ hasFeedbackBeenSubmitted(cooldownDays = 30) {
6926
+ const cooldownMs = cooldownDays * 24 * 60 * 60 * 1000;
6927
+ const now = Date.now();
6928
+
6929
+ // Check backend tracking first (from init response)
6930
+ if (this.config.last_feedback_at) {
6931
+ try {
6932
+ const backendTimestamp = new Date(this.config.last_feedback_at).getTime();
6933
+ if ((now - backendTimestamp) < cooldownMs) {
6934
+ return true;
6935
+ }
6936
+ } catch (e) {
6937
+ // Invalid date format, continue to localStorage check
6938
+ }
6939
+ }
6940
+
6941
+ // Fallback to localStorage
6942
+ try {
6943
+ const storageKey = `feedback_submitted_${this.config.workspace}`;
6944
+ const stored = localStorage.getItem(storageKey);
6945
+ if (!stored) return false;
6946
+
6947
+ const data = JSON.parse(stored);
6948
+ return (now - data.submittedAt) < cooldownMs;
6949
+ } catch (e) {
6950
+ return false;
6951
+ }
6952
+ }
6953
+
6954
+ /**
6955
+ * Clear the feedback submission tracking (allow showing widgets again)
6956
+ */
6957
+ clearFeedbackSubmissionTracking() {
6958
+ try {
6959
+ const storageKey = `feedback_submitted_${this.config.workspace}`;
6960
+ localStorage.removeItem(storageKey);
6961
+ this.eventBus.emit('feedback:trackingCleared');
6962
+ } catch (e) {
6963
+ console.warn('Failed to clear feedback tracking:', e);
6964
+ }
6965
+ }
6966
+
5589
6967
  _detectEnvironment() {
5590
6968
  if (typeof window === 'undefined') {
5591
6969
  return 'production';
@@ -7328,6 +8706,106 @@
7328
8706
  }
7329
8707
  }
7330
8708
 
8709
+ /* ========================================
8710
+ Availability Status
8711
+ ======================================== */
8712
+
8713
+ .messenger-home-availability,
8714
+ .messenger-chat-availability {
8715
+ display: flex;
8716
+ align-items: center;
8717
+ gap: 6px;
8718
+ margin-top: 8px;
8719
+ font-size: 13px;
8720
+ color: rgba(255, 255, 255, 0.7);
8721
+ }
8722
+
8723
+ .theme-light .messenger-home-availability,
8724
+ .theme-light .messenger-chat-availability {
8725
+ color: #6b7280;
8726
+ }
8727
+
8728
+ .messenger-availability-dot {
8729
+ width: 8px;
8730
+ height: 8px;
8731
+ border-radius: 50%;
8732
+ flex-shrink: 0;
8733
+ }
8734
+
8735
+ .messenger-availability-online {
8736
+ background: #22c55e;
8737
+ box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
8738
+ }
8739
+
8740
+ .messenger-availability-away {
8741
+ background: #9ca3af;
8742
+ }
8743
+
8744
+ .messenger-availability-text {
8745
+ opacity: 0.9;
8746
+ }
8747
+
8748
+ /* ========================================
8749
+ Typing Indicator
8750
+ ======================================== */
8751
+
8752
+ .messenger-typing-indicator {
8753
+ display: flex;
8754
+ align-items: center;
8755
+ gap: 8px;
8756
+ padding: 8px 12px;
8757
+ margin: 4px 0;
8758
+ }
8759
+
8760
+ .messenger-typing-dots {
8761
+ display: flex;
8762
+ align-items: center;
8763
+ gap: 4px;
8764
+ background: #374151;
8765
+ padding: 8px 12px;
8766
+ border-radius: 16px;
8767
+ }
8768
+
8769
+ .theme-light .messenger-typing-dots {
8770
+ background: #e5e7eb;
8771
+ }
8772
+
8773
+ .messenger-typing-dots span {
8774
+ width: 6px;
8775
+ height: 6px;
8776
+ background: #9ca3af;
8777
+ border-radius: 50%;
8778
+ animation: messenger-typing-bounce 1.4s infinite ease-in-out;
8779
+ }
8780
+
8781
+ .messenger-typing-dots span:nth-child(1) {
8782
+ animation-delay: -0.32s;
8783
+ }
8784
+
8785
+ .messenger-typing-dots span:nth-child(2) {
8786
+ animation-delay: -0.16s;
8787
+ }
8788
+
8789
+ .messenger-typing-dots span:nth-child(3) {
8790
+ animation-delay: 0s;
8791
+ }
8792
+
8793
+ .messenger-typing-text {
8794
+ font-size: 12px;
8795
+ color: #9ca3af;
8796
+ }
8797
+
8798
+ @keyframes messenger-typing-bounce {
8799
+ 0%, 80%, 100% {
8800
+ transform: scale(0.8);
8801
+ opacity: 0.5;
8802
+ }
8803
+ 40% {
8804
+ transform: scale(1);
8805
+ opacity: 1;
8806
+ }
8807
+ }
8808
+
7331
8809
  /* ========================================
7332
8810
  Animations
7333
8811
  ======================================== */
@@ -8286,8 +9764,8 @@
8286
9764
 
8287
9765
  .changelog-popup-title {
8288
9766
  margin: 0 0 16px;
8289
- font-size: 28px;
8290
- font-weight: 700;
9767
+ font-size: 18px;
9768
+ font-weight: 600;
8291
9769
  line-height: 1.3;
8292
9770
  color: #111827;
8293
9771
  }