@product7/product7-js 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,13 +20,19 @@ export class ChatView {
20
20
  this._updateContent();
21
21
 
22
22
  this._unsubscribe = this.state.subscribe((type, data) => {
23
+ if (type === 'connectionChange') {
24
+ const banner = this.element?.querySelector('.messenger-connection-banner');
25
+ if (banner) {
26
+ banner.style.display = data.connected ? 'none' : 'flex';
27
+ }
28
+ return;
29
+ }
23
30
  if (
24
31
  type === 'messageAdded' &&
25
32
  data.conversationId === this.state.activeConversationId
26
33
  ) {
27
34
  this._hideTypingIndicator();
28
35
  this._appendMessage(data.message);
29
- this._scrollToBottom();
30
36
  } else if (
31
37
  type === 'typingStarted' &&
32
38
  data.conversationId === this.state.activeConversationId
@@ -63,16 +69,20 @@ export class ChatView {
63
69
  const messagesHtml =
64
70
  messages.length === 0
65
71
  ? this._renderEmptyState(isNewConversation)
66
- : messages.map((msg) => this._renderMessage(msg)).join('');
72
+ : this._renderGroupedMessages(messages);
67
73
 
68
- const title = isNewConversation
69
- ? 'New conversation'
70
- : conversation?.title || 'Chat with team';
74
+ const defaultPlaceholder = this.options.composePlaceholder || 'Write a message...';
71
75
  const placeholder = isNewConversation
72
- ? 'Start typing your message...'
76
+ ? (this.options.composePlaceholder || 'Start typing your message...')
73
77
  : isClosed
74
78
  ? 'Conversation closed'
75
- : 'Write a message...';
79
+ : defaultPlaceholder;
80
+
81
+ const logoUrl = this.options.logoUrl;
82
+ const teamName = this.state.teamName || 'Support';
83
+ const headerAvatarHtml = logoUrl
84
+ ? `<img src="${this._escapeHtml(logoUrl)}" alt="${this._escapeHtml(teamName)}" />`
85
+ : `<iconify-icon icon="ph:chats-circle-duotone" width="20" height="20"></iconify-icon>`;
76
86
 
77
87
  this.element.innerHTML = `
78
88
  <div class="messenger-chat-header">
@@ -80,11 +90,11 @@ export class ChatView {
80
90
  <iconify-icon icon="ph:arrow-left" width="20" height="20"></iconify-icon>
81
91
  </button>
82
92
  <div class="messenger-chat-header-avatar">
83
- <iconify-icon icon="ph:lightbulb-duotone" width="20" height="20"></iconify-icon>
93
+ ${headerAvatarHtml}
84
94
  </div>
85
95
  <div class="messenger-chat-header-info">
86
- <span class="messenger-chat-title">${title}</span>
87
- <span class="messenger-chat-subtitle">The team can also help</span>
96
+ <span class="messenger-chat-title">${this._escapeHtml(teamName)}</span>
97
+ <span class="messenger-chat-subtitle">${isClosed ? 'Conversation resolved' : (this.state.responseTime || 'Typically replies within minutes')}</span>
88
98
  </div>
89
99
  <div class="messenger-chat-header-actions">
90
100
  <button class="sdk-btn-icon sdk-close-btn messenger-mobile-close-btn" aria-label="Close">
@@ -93,6 +103,11 @@ export class ChatView {
93
103
  </div>
94
104
  </div>
95
105
 
106
+ <div class="messenger-connection-banner" style="display:none;">
107
+ <iconify-icon icon="ph:wifi-slash" width="14" height="14"></iconify-icon>
108
+ <span>Reconnecting…</span>
109
+ </div>
110
+
96
111
  <div class="messenger-chat-messages">
97
112
  ${messagesHtml}
98
113
  ${
@@ -113,6 +128,11 @@ export class ChatView {
113
128
  </div>
114
129
  </div>
115
130
 
131
+ <div class="messenger-scroll-pill" style="display:none;">
132
+ <iconify-icon icon="ph:arrow-down" width="14" height="14"></iconify-icon>
133
+ <span>New message</span>
134
+ </div>
135
+
116
136
  ${
117
137
  isClosed
118
138
  ? ''
@@ -148,23 +168,20 @@ export class ChatView {
148
168
  this._attachEvents();
149
169
  this._scrollToBottom();
150
170
  this._renderAttachmentPreviews();
171
+ this._setupScrollPill();
151
172
  }
152
173
 
153
174
  _renderEmptyState(isNewConversation = false) {
154
- const avatarHtml = this._renderTeamAvatars();
155
- const responseTime =
156
- this.state.responseTime || 'We typically reply within a few minutes';
157
- const isOnline = this.state.agentsOnline;
175
+ const logoUrl = this.options.logoUrl;
176
+
177
+ const logoHtml = logoUrl
178
+ ? `<div class="messenger-chat-empty-logo"><img src="${this._escapeHtml(logoUrl)}" alt="${this._escapeHtml(this.state.teamName)}" /></div>`
179
+ : '';
158
180
 
159
181
  return `
160
182
  <div class="messenger-chat-empty">
161
- <div class="messenger-chat-empty-avatars">${avatarHtml}</div>
183
+ ${logoHtml}
162
184
  <h3>${isNewConversation ? 'Start a new conversation' : 'Start the conversation'}</h3>
163
- <p>Send us a message and we'll get back to you as soon as possible.</p>
164
- <div class="messenger-chat-availability">
165
- <span class="messenger-availability-dot ${isOnline ? 'messenger-availability-online' : 'messenger-availability-away'}"></span>
166
- <span>${isOnline ? "We're online now" : responseTime}</span>
167
- </div>
168
185
  </div>
169
186
  `;
170
187
  }
@@ -185,7 +202,7 @@ export class ChatView {
185
202
  .join('');
186
203
  }
187
204
 
188
- _renderMessage(message) {
205
+ _renderMessage(message, isLastInGroup = true) {
189
206
  if (message.isSystem) {
190
207
  return this._renderSystemMessage(message);
191
208
  }
@@ -194,8 +211,9 @@ export class ChatView {
194
211
  const messageClass = isOwn
195
212
  ? 'messenger-message-own'
196
213
  : 'messenger-message-received';
197
- const timeStr = this._formatMessageTime(message.timestamp);
214
+ const timeStr = isLastInGroup ? this._formatMessageTime(message.timestamp) : '';
198
215
  const attachmentsHtml = this._renderMessageAttachments(message.attachments);
216
+ const isOptimistic = message.isOptimistic;
199
217
 
200
218
  const contentHtml = message.content
201
219
  ? `<div class="messenger-message-content">${this._formatMessageContent(message.content)}</div>`
@@ -205,11 +223,20 @@ export class ChatView {
205
223
  : '';
206
224
 
207
225
  if (isOwn) {
226
+ const sentIndicator = isLastInGroup
227
+ ? `<div class="messenger-message-meta messenger-message-meta-own">
228
+ ${isOptimistic
229
+ ? `<span class="messenger-message-sent-status">Sending…</span>`
230
+ : `<span class="messenger-message-sent-status">Sent</span>`
231
+ }
232
+ ${timeStr ? `<span>·</span><span>${timeStr}</span>` : ''}
233
+ </div>`
234
+ : '';
208
235
  return `
209
- <div class="messenger-message ${messageClass}">
236
+ <div class="messenger-message ${messageClass}${isOptimistic ? ' messenger-message-optimistic' : ''}">
210
237
  ${bubbleHtml}
211
238
  ${attachmentsHtml}
212
- ${timeStr ? `<div class="messenger-message-meta messenger-message-meta-own"><span>${timeStr}</span></div>` : ''}
239
+ ${sentIndicator}
213
240
  </div>
214
241
  `;
215
242
  }
@@ -238,25 +265,23 @@ export class ChatView {
238
265
  content.includes('left the conversation');
239
266
 
240
267
  if (isJoinLeave && message.sender) {
241
- const name = message.sender.name || '';
242
- const avatarUrl = message.sender.avatarUrl;
243
- const initial = name.charAt(0).toUpperCase() || '?';
244
- const colors = ['#5856d6', '#007aff', '#34c759', '#ff9500'];
245
- const colorIndex = name.charCodeAt(0) % colors.length;
246
- const avatarHtml = avatarUrl
247
- ? `<img src="${this._escapeHtml(avatarUrl)}" alt="${this._escapeHtml(name)}" />`
248
- : initial;
249
- const avatarStyle = avatarUrl
250
- ? ''
251
- : `style="background: ${colors[colorIndex]};"`;
268
+ const rawName = message.sender.name || '';
269
+ const name = rawName
270
+ .split(' ')
271
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
272
+ .join(' ');
273
+ const logoUrl = this.options.logoUrl;
274
+ const logoHtml = logoUrl
275
+ ? `<img src="${this._escapeHtml(logoUrl)}" alt="logo" />`
276
+ : name.charAt(0).toUpperCase();
252
277
  const timeStr = this._formatMessageTime(message.timestamp);
253
278
 
254
- // Split "Name joined the chat" → name part already in sender, extract action
255
- const action = content.replace(name, '').trim();
279
+ const rawAction = content.replace(rawName, '').trim();
280
+ const action = rawAction.charAt(0).toUpperCase() + rawAction.slice(1);
256
281
 
257
282
  return `
258
283
  <div class="messenger-message-system-event">
259
- <div class="messenger-message-system-event-avatar" ${avatarStyle}>${avatarHtml}</div>
284
+ <div class="messenger-message-system-event-avatar">${logoHtml}</div>
260
285
  <span class="messenger-message-system-event-name">${this._escapeHtml(name)}</span>
261
286
  <span class="messenger-message-system-event-action">${this._escapeHtml(action)}</span>
262
287
  ${timeStr ? `<span class="messenger-message-system-event-time">${timeStr}</span>` : ''}
@@ -336,6 +361,20 @@ export class ChatView {
336
361
  const tempDiv = document.createElement('div');
337
362
  tempDiv.innerHTML = messageHtml;
338
363
  messagesContainer.appendChild(tempDiv.firstElementChild);
364
+
365
+ // Show scroll pill if user is scrolled up, otherwise scroll to bottom
366
+ const isNearBottom =
367
+ messagesContainer.scrollHeight -
368
+ messagesContainer.scrollTop -
369
+ messagesContainer.clientHeight <
370
+ 80;
371
+
372
+ if (isNearBottom) {
373
+ this._scrollToBottom();
374
+ } else if (!message.isOwn) {
375
+ const pill = this.element.querySelector('.messenger-scroll-pill');
376
+ if (pill) pill.style.display = 'flex';
377
+ }
339
378
  }
340
379
 
341
380
  _scrollToBottom() {
@@ -349,6 +388,64 @@ export class ChatView {
349
388
  }
350
389
  }
351
390
 
391
+ _setupScrollPill() {
392
+ const messagesContainer = this.element.querySelector(
393
+ '.messenger-chat-messages'
394
+ );
395
+ const pill = this.element.querySelector('.messenger-scroll-pill');
396
+ if (!messagesContainer || !pill) return;
397
+
398
+ pill.addEventListener('click', () => {
399
+ this._scrollToBottom();
400
+ pill.style.display = 'none';
401
+ });
402
+
403
+ messagesContainer.addEventListener('scroll', () => {
404
+ const isNearBottom =
405
+ messagesContainer.scrollHeight -
406
+ messagesContainer.scrollTop -
407
+ messagesContainer.clientHeight <
408
+ 80;
409
+ if (isNearBottom) {
410
+ pill.style.display = 'none';
411
+ }
412
+ });
413
+ }
414
+
415
+ _renderGroupedMessages(messages) {
416
+ const GROUP_TIME_GAP = 5 * 60 * 1000; // 5 minutes
417
+ let html = '';
418
+
419
+ messages.forEach((msg, index) => {
420
+ const isLast = index === messages.length - 1;
421
+ const nextMsg = messages[index + 1];
422
+ const currentTime = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
423
+ const nextTime = nextMsg?.timestamp
424
+ ? new Date(nextMsg.timestamp).getTime()
425
+ : null;
426
+ const senderKey = msg.isSystem
427
+ ? 'system'
428
+ : msg.isOwn
429
+ ? 'own'
430
+ : msg.sender?.name || 'agent';
431
+ const nextSenderKey = nextMsg
432
+ ? nextMsg.isSystem
433
+ ? 'system'
434
+ : nextMsg.isOwn
435
+ ? 'own'
436
+ : nextMsg.sender?.name || 'agent'
437
+ : null;
438
+
439
+ const timeGapAfter = nextTime && nextTime - currentTime > GROUP_TIME_GAP;
440
+ const senderChanges = nextMsg && senderKey !== nextSenderKey;
441
+ const isLastInGroup = isLast || timeGapAfter || senderChanges;
442
+
443
+ html += this._renderMessage(msg, isLastInGroup);
444
+ });
445
+
446
+ return html;
447
+ }
448
+
352
449
  _updateSendButtonState() {
353
450
  const input = this.element.querySelector('.messenger-compose-input');
354
451
  const sendBtn = this.element.querySelector('.messenger-compose-send');
@@ -78,8 +78,9 @@ export class ConversationsView {
78
78
 
79
79
  <div class="messenger-conversations-footer">
80
80
  <button class="messenger-new-message-btn">
81
- <span>Send us a message</span>
82
- <iconify-icon icon="ph:paper-plane-right" width="20" height="20" style="flex-shrink: 0;"></iconify-icon>
81
+ <iconify-icon icon="ph:pencil-simple" width="16" height="16" style="flex-shrink: 0; color: var(--msg-text-secondary);"></iconify-icon>
82
+ <span style="flex: 1;">New conversation</span>
83
+ <iconify-icon icon="ph:caret-right" width="16" height="16" style="flex-shrink: 0; color: var(--msg-text-tertiary);"></iconify-icon>
83
84
  </button>
84
85
  </div>
85
86
  `;
@@ -88,14 +89,16 @@ export class ConversationsView {
88
89
  }
89
90
 
90
91
  _renderConversationItem(conversation) {
92
+ const isClosed = conversation.status === 'closed';
91
93
  const unreadClass = conversation.unread > 0 ? 'unread' : '';
94
+ const closedClass = isClosed ? 'closed' : '';
92
95
  const timeAgo = this._formatTimeAgo(conversation.lastMessageTime);
93
96
  const avatarsHtml = this._renderConversationAvatars(
94
97
  conversation.participants
95
98
  );
96
99
 
97
100
  return `
98
- <div class="messenger-conversation-item ${unreadClass}" data-conversation-id="${conversation.id}">
101
+ <div class="messenger-conversation-item ${unreadClass} ${closedClass}" data-conversation-id="${conversation.id}">
99
102
  <div class="messenger-conversation-avatars">
100
103
  ${avatarsHtml}
101
104
  </div>
@@ -106,6 +109,7 @@ export class ConversationsView {
106
109
  </div>
107
110
  <div class="messenger-conversation-preview">
108
111
  ${conversation.unread > 0 ? '<span class="messenger-unread-dot"></span>' : ''}
112
+ ${isClosed ? '<span class="messenger-conversation-resolved-badge">Resolved</span>' : ''}
109
113
  <span class="messenger-conversation-message">${this._truncateMessage(conversation.lastMessage)}</span>
110
114
  </div>
111
115
  </div>
@@ -143,7 +147,7 @@ export class ConversationsView {
143
147
 
144
148
  const avatarItems = avatars
145
149
  .slice(0, 2)
146
- .map((avatar, index) => {
150
+ .map((avatar) => {
147
151
  if (typeof avatar === 'string' && avatar.startsWith('http')) {
148
152
  return `<div class="sdk-avatar sdk-avatar-sm"><img src="${avatar}" alt="Team member" /></div>`;
149
153
  }
@@ -41,7 +41,6 @@ export class HomeView {
41
41
  <div class="messenger-home-welcome">
42
42
  <span class="messenger-home-greeting">${this.state.greetingMessage}</span>
43
43
  <span class="messenger-home-question">${this.state.welcomeMessage}</span>
44
- ${this._renderAvailabilityStatus()}
45
44
  </div>
46
45
  </div>
47
46
 
@@ -105,24 +104,20 @@ export class HomeView {
105
104
  const sendIcon = `<iconify-icon icon="ph:paper-plane-right" width="20" height="20" style="flex-shrink: 0;"></iconify-icon>`;
106
105
  const caretIcon = `<iconify-icon icon="ph:caret-right" width="20" height="20" style="flex-shrink: 0;"></iconify-icon>`;
107
106
 
108
- if (openConversation) {
109
- return `
110
- <button class="messenger-home-message-btn messenger-home-continue-btn" data-conversation-id="${openConversation.id}">
111
- <div class="messenger-home-continue-info">
112
- <span class="messenger-home-continue-label">Continue conversation</span>
113
- </div>
114
- ${sendIcon}
115
- </button>
116
- <button class="messenger-home-message-btn messenger-feedback-btn" data-action="feedback">
117
- <span class="messenger-home-continue-label">Leave us feedback</span>
118
- ${caretIcon}
119
- </button>
120
- `;
121
- }
107
+ const responseTime =
108
+ this.state.responseTime || 'We typically reply within a few minutes';
109
+
110
+ const recentCardHtml = openConversation
111
+ ? this._renderRecentMessageCard(openConversation)
112
+ : '';
122
113
 
123
114
  return `
115
+ ${recentCardHtml}
124
116
  <button class="messenger-home-message-btn">
125
- <span>Start a conversation</span>
117
+ <div class="messenger-home-continue-info">
118
+ <span class="messenger-home-continue-label">Send us a message</span>
119
+ <span class="messenger-home-message-subtext">${responseTime}</span>
120
+ </div>
126
121
  ${sendIcon}
127
122
  </button>
128
123
  <button class="messenger-home-message-btn messenger-feedback-btn" data-action="feedback">
@@ -132,6 +127,55 @@ export class HomeView {
132
127
  `;
133
128
  }
134
129
 
130
+ _renderRecentMessageCard(conversation) {
131
+ const logoUrl = this.options.logoUrl;
132
+ const teamName = this.state.teamName || 'Support';
133
+ const logoHtml = logoUrl
134
+ ? `<div class="messenger-home-recent-avatar messenger-home-recent-avatar-logo"><img src="${logoUrl}" alt="${teamName}" /></div>`
135
+ : `<div class="messenger-home-recent-avatar" style="background: var(--color-primary);">${teamName.charAt(0).toUpperCase()}</div>`;
136
+
137
+ const title = conversation.title || teamName;
138
+ const timeAgo = this._formatTimeAgo(conversation.lastMessageTime);
139
+ const preview = conversation.lastMessage
140
+ ? conversation.lastMessage.substring(0, 48) + (conversation.lastMessage.length > 48 ? '...' : '')
141
+ : '';
142
+ const hasUnread = conversation.unread > 0;
143
+
144
+ return `
145
+ <div class="messenger-home-recent-card" data-conversation-id="${conversation.id}">
146
+ <div class="messenger-home-recent-card-label">Recent message</div>
147
+ <div class="messenger-home-recent-card-row">
148
+ ${logoHtml}
149
+ <div class="messenger-home-recent-card-content">
150
+ <div class="messenger-home-recent-card-header">
151
+ <span class="messenger-home-recent-card-name">${title}</span>
152
+ <span class="messenger-home-recent-card-time">${timeAgo}</span>
153
+ </div>
154
+ <div class="messenger-home-recent-card-preview">
155
+ <span class="messenger-home-recent-card-message">${preview}</span>
156
+ ${hasUnread ? '<span class="messenger-home-recent-unread-dot"></span>' : ''}
157
+ </div>
158
+ </div>
159
+ </div>
160
+ </div>
161
+ `;
162
+ }
163
+
164
+ _formatTimeAgo(timestamp) {
165
+ if (!timestamp) return '';
166
+ const date = new Date(timestamp);
167
+ const now = new Date();
168
+ const diffMs = now - date;
169
+ const diffMins = Math.floor(diffMs / 60000);
170
+ const diffHours = Math.floor(diffMs / 3600000);
171
+ const diffDays = Math.floor(diffMs / 86400000);
172
+ if (diffMins < 1) return 'now';
173
+ if (diffMins < 60) return `${diffMins}m`;
174
+ if (diffHours < 24) return `${diffHours}h`;
175
+ if (diffDays < 7) return `${diffDays}d`;
176
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
177
+ }
178
+
135
179
  _renderFeaturedCard() {
136
180
  if (!this.options.featuredContent) {
137
181
  return '';
@@ -209,22 +253,26 @@ export class HomeView {
209
253
  }
210
254
 
211
255
  _attachEvents() {
256
+ const recentCard = this.element.querySelector('.messenger-home-recent-card');
257
+ if (recentCard) {
258
+ recentCard.addEventListener('click', () => {
259
+ const convId = recentCard.dataset.conversationId;
260
+ this.state.setActiveConversation(convId);
261
+ this.state.markAsRead(convId);
262
+ this.state.setView('chat');
263
+ if (this.options.onSelectConversation) {
264
+ this.options.onSelectConversation(convId);
265
+ }
266
+ });
267
+ }
268
+
212
269
  const msgBtn = this.element.querySelector(
213
270
  '.messenger-home-message-btn:not(.messenger-feedback-btn)'
214
271
  );
215
272
  if (msgBtn) {
216
273
  msgBtn.addEventListener('click', () => {
217
- const convId = msgBtn.dataset.conversationId;
218
- if (convId) {
219
- this.state.setActiveConversation(convId);
220
- this.state.setView('chat');
221
- if (this.options.onSelectConversation) {
222
- this.options.onSelectConversation(convId);
223
- }
224
- } else {
225
- this.state.setActiveConversation(null);
226
- this.state.setView('chat');
227
- }
274
+ this.state.setActiveConversation(null);
275
+ this.state.setView('chat');
228
276
  });
229
277
  }
230
278
 
@@ -232,9 +280,8 @@ export class HomeView {
232
280
  if (feedbackBtn) {
233
281
  feedbackBtn.addEventListener('click', () => {
234
282
  if (this.options.onFeedbackClick) {
235
- // Close the messenger panel first, then open the feedback modal
236
283
  this.state.setOpen(false);
237
- setTimeout(() => this.options.onFeedbackClick(), 200);
284
+ this.options.onFeedbackClick();
238
285
  } else if (this.state.urls?.feedback) {
239
286
  window.open(this.state.urls.feedback, '_blank');
240
287
  }