@ihoomanai/chat-widget 2.4.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++;
@@ -1204,11 +1425,17 @@ function startNewConversation(): void {
1204
1425
  // Stop any live agent polling
1205
1426
  stopLiveAgentPolling();
1206
1427
 
1207
- // Reconnect WebSocket with new visitor ID (no session_id)
1428
+ // Reset reconnect attempts for fresh connection
1429
+ reconnectAttempts = 0;
1430
+
1431
+ // Close existing WebSocket with intentional flag to prevent auto-reconnect
1208
1432
  if (ws) {
1433
+ intentionalDisconnect = true;
1209
1434
  ws.close();
1210
1435
  ws = null;
1211
1436
  }
1437
+
1438
+ // Connect with new visitor ID
1212
1439
  connectWebSocket();
1213
1440
 
1214
1441
  // Show welcome message
@@ -1229,7 +1456,8 @@ function destroy(): void {
1229
1456
  // Stop live agent polling
1230
1457
  stopLiveAgentPolling();
1231
1458
 
1232
- // Close WebSocket
1459
+ // Close WebSocket (intentionally, don't reconnect)
1460
+ intentionalDisconnect = true;
1233
1461
  if (ws) {
1234
1462
  ws.close();
1235
1463
  ws = null;
@@ -1262,6 +1490,7 @@ function destroy(): void {
1262
1490
  };
1263
1491
  elements = {};
1264
1492
  reconnectAttempts = 0;
1493
+ intentionalDisconnect = false;
1265
1494
  }
1266
1495
 
1267
1496
  /**