@ihoomanai/chat-widget 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/widget.ts CHANGED
@@ -72,6 +72,9 @@ const icons = {
72
72
  bot: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="10" rx="2"></rect><circle cx="12" cy="5" r="2"></circle><path d="M12 7v4"></path></svg>`,
73
73
  agent: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>`,
74
74
  ticket: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><line x1="10" y1="9" x2="8" y2="9"></line></svg>`,
75
+ history: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>`,
76
+ back: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>`,
77
+ plus: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>`,
75
78
  };
76
79
 
77
80
  /**
@@ -90,7 +93,7 @@ let state: WidgetState = {
90
93
  /**
91
94
  * Current view state
92
95
  */
93
- type WidgetView = 'chat' | 'ticket';
96
+ type WidgetView = 'chat' | 'ticket' | 'history';
94
97
  let currentView: WidgetView = 'chat';
95
98
  let isLiveAgentMode = false;
96
99
 
@@ -104,6 +107,9 @@ interface WidgetElements {
104
107
  window?: HTMLElement;
105
108
  chatView?: HTMLElement;
106
109
  ticketView?: HTMLElement;
110
+ historyView?: HTMLElement;
111
+ historyList?: HTMLElement;
112
+ historyNewBtn?: HTMLButtonElement;
107
113
  messages?: HTMLElement;
108
114
  input?: HTMLTextAreaElement;
109
115
  sendBtn?: HTMLButtonElement;
@@ -129,6 +135,7 @@ let ws: WebSocket | null = null;
129
135
  let pollInterval: ReturnType<typeof setInterval> | null = null;
130
136
  let reconnectAttempts = 0;
131
137
  const maxReconnectAttempts = 5;
138
+ let intentionalDisconnect = false; // Flag to prevent reconnection on intentional close
132
139
 
133
140
 
134
141
  // ============================================================================
@@ -257,9 +264,10 @@ function generateStyles(): string {
257
264
  .ihooman-toggle:active { transform: scale(0.95); }
258
265
  .ihooman-toggle::before { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, rgba(255,255,255,0.2), transparent); border-radius: 50%; }
259
266
  .ihooman-toggle svg { width: 26px; height: 26px; color: white; transition: transform 0.3s ease, opacity 0.2s ease; position: relative; z-index: 1; }
260
- .ihooman-toggle.open svg.chat-icon { transform: rotate(90deg) scale(0); opacity: 0; }
261
- .ihooman-toggle.open svg.close-icon { transform: rotate(0) scale(1); opacity: 1; }
262
- .ihooman-toggle svg.close-icon { position: absolute; transform: rotate(-90deg) scale(0); opacity: 0; }
267
+ .ihooman-toggle .chat-icon { display: flex; align-items: center; justify-content: center; transition: transform 0.3s ease, opacity 0.2s ease; }
268
+ .ihooman-toggle .close-icon { position: absolute; display: flex; align-items: center; justify-content: center; transform: rotate(-90deg) scale(0); opacity: 0; transition: transform 0.3s ease, opacity 0.2s ease; }
269
+ .ihooman-toggle.open .chat-icon { transform: rotate(90deg) scale(0); opacity: 0; }
270
+ .ihooman-toggle.open .close-icon { transform: rotate(0) scale(1); opacity: 1; }
263
271
  .ihooman-pulse { position: absolute; inset: 0; border-radius: 50%; background: ${primaryColor}; animation: ihooman-pulse 2s ease-out infinite; }
264
272
  @keyframes ihooman-pulse { 0% { transform: scale(1); opacity: 0.5; } 100% { transform: scale(1.6); opacity: 0; } }
265
273
  .ihooman-toggle.open .ihooman-pulse { display: none; }
@@ -344,6 +352,20 @@ function generateStyles(): string {
344
352
  .ihooman-ticket-submit:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
345
353
  .ihooman-ticket-back { padding: 10px; background: transparent; color: ${mutedColor}; border: 1px solid ${borderColor}; border-radius: 10px; font-size: 13px; cursor: pointer; transition: all 0.2s; }
346
354
  .ihooman-ticket-back:hover { background: ${isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)'}; }
355
+ .ihooman-history-view { display: none; flex-direction: column; flex: 1; overflow: hidden; background: ${bgColor}; }
356
+ .ihooman-history-view.show { display: flex; }
357
+ .ihooman-history-header { padding: 12px 16px; border-bottom: 1px solid ${borderColor}; display: flex; justify-content: space-between; align-items: center; }
358
+ .ihooman-history-title { font-size: 14px; font-weight: 600; color: ${textColor}; margin: 0; }
359
+ .ihooman-history-new { display: inline-flex; align-items: center; gap: 4px; background: linear-gradient(135deg, ${gradientFrom}, ${gradientTo}); color: white; border: none; padding: 6px 12px; border-radius: 6px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
360
+ .ihooman-history-new:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0, 174, 255, 0.3); }
361
+ .ihooman-history-new svg { width: 14px; height: 14px; }
362
+ .ihooman-history-list { flex: 1; overflow-y: auto; padding: 8px; }
363
+ .ihooman-history-item { padding: 12px; border: 1px solid ${borderColor}; border-radius: 8px; margin-bottom: 6px; cursor: pointer; transition: all 0.2s; background: ${bgColor}; }
364
+ .ihooman-history-item:hover { background: ${isDark ? 'rgba(255,255,255,0.05)' : '#f8fafc'}; }
365
+ .ihooman-history-item.active { background: ${isDark ? 'rgba(0, 174, 255, 0.1)' : '#eff6ff'}; border-color: ${primaryColor}; }
366
+ .ihooman-history-preview { font-size: 13px; color: ${textColor}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
367
+ .ihooman-history-meta { font-size: 11px; color: ${mutedColor}; margin-top: 4px; display: flex; justify-content: space-between; }
368
+ .ihooman-history-empty { padding: 40px; text-align: center; color: ${mutedColor}; font-size: 14px; }
347
369
  @media (max-width: 480px) { .ihooman-window { width: calc(100vw - 20px); height: calc(100vh - 100px); left: 10px; right: 10px; bottom: 80px; max-height: none; } .ihooman-toggle { ${positionRight ? 'right: 16px' : 'left: 16px'}; bottom: 16px; } }
348
370
  `;
349
371
  }
@@ -382,7 +404,8 @@ function createWidget(): void {
382
404
  <div class="ihooman-header-status"><span class="ihooman-status-dot"></span><span class="ihooman-status-text">Online</span></div>
383
405
  </div>
384
406
  <div class="ihooman-header-actions">
385
- <button class="ihooman-header-btn" data-action="refresh" title="New conversation">${icons.refresh}</button>
407
+ <button class="ihooman-header-btn" data-action="history" title="Chat history">${icons.history}</button>
408
+ <button class="ihooman-header-btn" data-action="refresh" title="New conversation">${icons.plus}</button>
386
409
  <button class="ihooman-header-btn" data-action="minimize" title="Minimize">${icons.minimize}</button>
387
410
  </div>
388
411
  </div>
@@ -411,6 +434,15 @@ function createWidget(): void {
411
434
  <button class="ihooman-ticket-back" id="ihooman-ticket-back">← Back to Chat</button>
412
435
  </div>
413
436
 
437
+ <!-- History View -->
438
+ <div class="ihooman-history-view">
439
+ <div class="ihooman-history-header">
440
+ <span class="ihooman-history-title">Your Conversations</span>
441
+ <button class="ihooman-history-new">${icons.plus} New Chat</button>
442
+ </div>
443
+ <div class="ihooman-history-list"></div>
444
+ </div>
445
+
414
446
  ${config.poweredBy ? `<div class="ihooman-powered">Powered by <a href="https://ihooman.ai" target="_blank" rel="noopener">Ihooman AI</a></div>` : ''}
415
447
  </div>
416
448
  </div>
@@ -426,6 +458,9 @@ function createWidget(): void {
426
458
  window: widget.querySelector('.ihooman-window') as HTMLElement,
427
459
  chatView: widget.querySelector('.ihooman-chat-view') as HTMLElement,
428
460
  ticketView: widget.querySelector('.ihooman-ticket-view') as HTMLElement,
461
+ historyView: widget.querySelector('.ihooman-history-view') as HTMLElement,
462
+ historyList: widget.querySelector('.ihooman-history-list') as HTMLElement,
463
+ historyNewBtn: widget.querySelector('.ihooman-history-new') as HTMLButtonElement,
429
464
  messages: widget.querySelector('.ihooman-messages') as HTMLElement,
430
465
  input: widget.querySelector('.ihooman-input') as HTMLTextAreaElement,
431
466
  sendBtn: widget.querySelector('.ihooman-input-btn.send') as HTMLButtonElement,
@@ -478,6 +513,7 @@ function setupEventListeners(): void {
478
513
 
479
514
  elements.widget?.querySelector('[data-action="refresh"]')?.addEventListener('click', startNewConversation);
480
515
  elements.widget?.querySelector('[data-action="minimize"]')?.addEventListener('click', close);
516
+ elements.widget?.querySelector('[data-action="history"]')?.addEventListener('click', toggleHistoryView);
481
517
 
482
518
  // Ticket form buttons
483
519
  if (elements.ticketSubmitBtn) {
@@ -486,6 +522,14 @@ function setupEventListeners(): void {
486
522
  if (elements.ticketBackBtn) {
487
523
  elements.ticketBackBtn.addEventListener('click', () => showView('chat'));
488
524
  }
525
+
526
+ // History view buttons
527
+ if (elements.historyNewBtn) {
528
+ elements.historyNewBtn.addEventListener('click', () => {
529
+ startNewConversation();
530
+ showView('chat');
531
+ });
532
+ }
489
533
  }
490
534
 
491
535
 
@@ -585,9 +629,9 @@ function handleEscalationAction(action: 'live-agent' | 'create-ticket'): void {
585
629
  }
586
630
 
587
631
  /**
588
- * Switch between chat and ticket views
632
+ * Switch between chat, ticket, and history views
589
633
  */
590
- function showView(view: 'chat' | 'ticket'): void {
634
+ function showView(view: 'chat' | 'ticket' | 'history'): void {
591
635
  currentView = view;
592
636
 
593
637
  if (elements.chatView) {
@@ -596,6 +640,177 @@ function showView(view: 'chat' | 'ticket'): void {
596
640
  if (elements.ticketView) {
597
641
  elements.ticketView.classList.toggle('show', view === 'ticket');
598
642
  }
643
+ if (elements.historyView) {
644
+ elements.historyView.classList.toggle('show', view === 'history');
645
+ }
646
+
647
+ // Load history when showing history view
648
+ if (view === 'history') {
649
+ loadConversationHistory();
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Toggle between chat and history views
655
+ */
656
+ function toggleHistoryView(): void {
657
+ if (currentView === 'history') {
658
+ showView('chat');
659
+ } else {
660
+ showView('history');
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Format time ago string
666
+ */
667
+ function timeAgo(date: Date | string): string {
668
+ const diff = Date.now() - new Date(date).getTime();
669
+ if (diff < 60000) return 'now';
670
+ if (diff < 3600000) return Math.floor(diff / 60000) + 'm';
671
+ if (diff < 86400000) return Math.floor(diff / 3600000) + 'h';
672
+ return Math.floor(diff / 86400000) + 'd';
673
+ }
674
+
675
+ /**
676
+ * Conversation history item interface
677
+ */
678
+ interface ConversationHistoryItem {
679
+ session_id: string;
680
+ preview: string;
681
+ message_count: number;
682
+ status: string;
683
+ updated_at: string;
684
+ }
685
+
686
+ /**
687
+ * Load conversation history from the server
688
+ */
689
+ async function loadConversationHistory(): Promise<void> {
690
+ if (!elements.historyList || !state.visitorId) return;
691
+
692
+ elements.historyList.innerHTML = '<div class="ihooman-history-empty">Loading...</div>';
693
+
694
+ try {
695
+ const response = await fetch(
696
+ `${config.serverUrl}/api/widget/conversations?widget_id=${config.widgetId}&visitor_id=${state.visitorId}&limit=20`
697
+ );
698
+
699
+ if (!response.ok) {
700
+ throw new Error('Failed to load history');
701
+ }
702
+
703
+ const conversations: ConversationHistoryItem[] = await response.json();
704
+
705
+ if (!conversations.length) {
706
+ elements.historyList.innerHTML = '<div class="ihooman-history-empty">No conversations yet</div>';
707
+ return;
708
+ }
709
+
710
+ elements.historyList.innerHTML = conversations.map(conv => `
711
+ <div class="ihooman-history-item ${conv.session_id === state.sessionId ? 'active' : ''}" data-session-id="${conv.session_id}">
712
+ <div class="ihooman-history-preview">${escapeHtml(conv.preview || 'New conversation')}</div>
713
+ <div class="ihooman-history-meta">
714
+ <span>${conv.message_count} msgs</span>
715
+ <span>${conv.status === 'pending' ? '🟡 Pending' : conv.status === 'closed' ? '✓ Closed' : ''} ${timeAgo(conv.updated_at)}</span>
716
+ </div>
717
+ </div>
718
+ `).join('');
719
+
720
+ // Add click handlers to history items
721
+ elements.historyList.querySelectorAll('.ihooman-history-item').forEach(item => {
722
+ item.addEventListener('click', () => {
723
+ const sessionId = (item as HTMLElement).dataset.sessionId;
724
+ if (sessionId) {
725
+ switchToConversation(sessionId);
726
+ }
727
+ });
728
+ });
729
+
730
+ } catch (error) {
731
+ console.error('Error loading conversation history:', error);
732
+ elements.historyList.innerHTML = '<div class="ihooman-history-empty">Failed to load history</div>';
733
+ }
734
+ }
735
+
736
+ /**
737
+ * Switch to a specific conversation
738
+ */
739
+ async function switchToConversation(sessionId: string): Promise<void> {
740
+ state.sessionId = sessionId;
741
+ if (config.persistSession) {
742
+ storage('session_id', sessionId);
743
+ }
744
+
745
+ // Clear current messages
746
+ if (elements.messages) {
747
+ elements.messages.innerHTML = '';
748
+ }
749
+
750
+ // Reset live agent mode
751
+ isLiveAgentMode = false;
752
+ stopLiveAgentPolling();
753
+ updateStatusBar('hidden');
754
+
755
+ // Reconnect WebSocket with new session
756
+ intentionalDisconnect = true;
757
+ if (ws) {
758
+ ws.close();
759
+ ws = null;
760
+ }
761
+ connectWebSocket();
762
+
763
+ // Switch to chat view
764
+ showView('chat');
765
+
766
+ // Load conversation messages
767
+ await loadConversationMessages(sessionId);
768
+ }
769
+
770
+ /**
771
+ * Load messages for a specific conversation
772
+ */
773
+ async function loadConversationMessages(sessionId: string): Promise<void> {
774
+ try {
775
+ const response = await fetch(
776
+ `${config.serverUrl}/api/widget/transcript/${sessionId}?widget_id=${config.widgetId}`
777
+ );
778
+
779
+ if (!response.ok) {
780
+ throw new Error('Failed to load messages');
781
+ }
782
+
783
+ const data = await response.json();
784
+
785
+ if (elements.messages) {
786
+ elements.messages.innerHTML = '';
787
+ }
788
+
789
+ // Add messages to the chat
790
+ if (data.messages && data.messages.length > 0) {
791
+ data.messages.forEach((msg: { content: string; sender_type: string; extra_data?: Record<string, unknown> }) => {
792
+ const sender = msg.sender_type === 'user' ? 'user' : 'bot';
793
+ addMessage(msg.content, sender, msg.extra_data || {});
794
+ });
795
+ } else if (config.welcomeMessage) {
796
+ addMessage(config.welcomeMessage, 'bot');
797
+ }
798
+
799
+ // Check conversation status
800
+ if (data.status === 'pending') {
801
+ isLiveAgentMode = true;
802
+ startLiveAgentPolling();
803
+ updateStatusBar('waiting', '⏳ Waiting for agent...');
804
+ } else if (data.status === 'closed') {
805
+ updateStatusBar('hidden');
806
+ }
807
+
808
+ } catch (error) {
809
+ console.error('Error loading conversation messages:', error);
810
+ if (config.welcomeMessage) {
811
+ addMessage(config.welcomeMessage, 'bot');
812
+ }
813
+ }
599
814
  }
600
815
 
601
816
  /**
@@ -991,6 +1206,12 @@ function connectWebSocket(chatEndpoint?: string): void {
991
1206
  updateStatus(false);
992
1207
  emit('disconnected');
993
1208
 
1209
+ // Don't reconnect if this was an intentional disconnect (e.g., starting new conversation)
1210
+ if (intentionalDisconnect) {
1211
+ intentionalDisconnect = false;
1212
+ return;
1213
+ }
1214
+
994
1215
  // Attempt reconnection with exponential backoff
995
1216
  if (reconnectAttempts < maxReconnectAttempts) {
996
1217
  reconnectAttempts++;
@@ -1181,17 +1402,48 @@ function toggle(): void {
1181
1402
  * Start a new conversation
1182
1403
  */
1183
1404
  function startNewConversation(): void {
1405
+ // Clear session and visitor to force a completely new conversation
1184
1406
  state.sessionId = null;
1407
+ state.visitorId = null;
1185
1408
  state.messages = [];
1409
+ isLiveAgentMode = false;
1410
+
1411
+ // Clear stored session
1186
1412
  storage('session_id', null);
1413
+ // Generate new visitor ID
1414
+ state.visitorId = generateId('v_');
1415
+ storage('visitor_id', state.visitorId);
1187
1416
 
1417
+ // Clear UI
1188
1418
  if (elements.messages) {
1189
1419
  elements.messages.innerHTML = '';
1190
1420
  }
1191
1421
 
1422
+ // Hide status bar
1423
+ updateStatusBar('hidden');
1424
+
1425
+ // Stop any live agent polling
1426
+ stopLiveAgentPolling();
1427
+
1428
+ // Reset reconnect attempts for fresh connection
1429
+ reconnectAttempts = 0;
1430
+
1431
+ // Close existing WebSocket with intentional flag to prevent auto-reconnect
1432
+ if (ws) {
1433
+ intentionalDisconnect = true;
1434
+ ws.close();
1435
+ ws = null;
1436
+ }
1437
+
1438
+ // Connect with new visitor ID
1439
+ connectWebSocket();
1440
+
1441
+ // Show welcome message
1192
1442
  if (config.welcomeMessage) {
1193
1443
  addMessage(config.welcomeMessage, 'bot');
1194
1444
  }
1445
+
1446
+ emit('newConversation');
1195
1447
  }
1196
1448
 
1197
1449
  /**
@@ -1204,7 +1456,8 @@ function destroy(): void {
1204
1456
  // Stop live agent polling
1205
1457
  stopLiveAgentPolling();
1206
1458
 
1207
- // Close WebSocket
1459
+ // Close WebSocket (intentionally, don't reconnect)
1460
+ intentionalDisconnect = true;
1208
1461
  if (ws) {
1209
1462
  ws.close();
1210
1463
  ws = null;
@@ -1237,6 +1490,7 @@ function destroy(): void {
1237
1490
  };
1238
1491
  elements = {};
1239
1492
  reconnectAttempts = 0;
1493
+ intentionalDisconnect = false;
1240
1494
  }
1241
1495
 
1242
1496
  /**