@ihoomanai/chat-widget 2.1.0 → 2.2.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
@@ -87,6 +87,13 @@ let state: WidgetState = {
87
87
  unreadCount: 0,
88
88
  };
89
89
 
90
+ /**
91
+ * Current view state
92
+ */
93
+ type WidgetView = 'chat' | 'ticket';
94
+ let currentView: WidgetView = 'chat';
95
+ let isLiveAgentMode = false;
96
+
90
97
  /**
91
98
  * DOM element references
92
99
  */
@@ -95,6 +102,8 @@ interface WidgetElements {
95
102
  toggle?: HTMLButtonElement;
96
103
  badge?: HTMLElement;
97
104
  window?: HTMLElement;
105
+ chatView?: HTMLElement;
106
+ ticketView?: HTMLElement;
98
107
  messages?: HTMLElement;
99
108
  input?: HTMLTextAreaElement;
100
109
  sendBtn?: HTMLButtonElement;
@@ -102,6 +111,12 @@ interface WidgetElements {
102
111
  fileInput?: HTMLInputElement | null;
103
112
  statusDot?: HTMLElement;
104
113
  statusText?: HTMLElement;
114
+ statusBar?: HTMLElement;
115
+ ticketName?: HTMLInputElement;
116
+ ticketEmail?: HTMLInputElement;
117
+ ticketIssue?: HTMLTextAreaElement;
118
+ ticketSubmitBtn?: HTMLButtonElement;
119
+ ticketBackBtn?: HTMLButtonElement;
105
120
  }
106
121
 
107
122
  let elements: WidgetElements = {};
@@ -310,6 +325,33 @@ function generateStyles(): string {
310
325
  .ihooman-escalation-btn.secondary { background: ${isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'}; color: ${textColor}; border: 1px solid ${borderColor}; }
311
326
  .ihooman-escalation-btn.secondary:hover { background: ${isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.08)'}; }
312
327
  .ihooman-escalation-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
328
+ .ihooman-status-bar { padding: 10px 16px; text-align: center; font-size: 13px; display: none; }
329
+ .ihooman-status-bar.show { display: block; }
330
+ .ihooman-status-bar.waiting { background: #fef3c7; color: #92400e; }
331
+ .ihooman-status-bar.connected { background: #dcfce7; color: #166534; }
332
+ .ihooman-chat-view { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
333
+ .ihooman-chat-view.hidden { display: none; }
334
+ .ihooman-ticket-view { display: none; flex-direction: column; padding: 20px; gap: 16px; background: ${bgColor}; flex: 1; overflow-y: auto; }
335
+ .ihooman-ticket-view.show { display: flex; }
336
+ .ihooman-ticket-title { font-size: 18px; font-weight: 600; color: ${textColor}; margin: 0; display: flex; align-items: center; gap: 8px; }
337
+ .ihooman-ticket-subtitle { font-size: 13px; color: ${mutedColor}; margin: 0; }
338
+ .ihooman-ticket-input { padding: 12px 14px; border: 1px solid ${borderColor}; border-radius: 10px; font-size: 14px; font-family: inherit; background: ${inputBg}; color: ${textColor}; outline: none; transition: border-color 0.2s; }
339
+ .ihooman-ticket-input:focus { border-color: ${primaryColor}; }
340
+ .ihooman-ticket-input::placeholder { color: ${mutedColor}; }
341
+ .ihooman-ticket-textarea { min-height: 100px; resize: vertical; }
342
+ .ihooman-ticket-submit { padding: 14px; background: linear-gradient(135deg, ${gradientFrom}, ${gradientTo}); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
343
+ .ihooman-ticket-submit:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0, 174, 255, 0.3); }
344
+ .ihooman-ticket-submit:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
345
+ .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
+ .ihooman-ticket-back:hover { background: ${isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)'}; }
347
+ .ihooman-action-buttons { display: flex; gap: 8px; padding: 0 16px 12px; }
348
+ .ihooman-action-btn { flex: 1; padding: 10px; border: none; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 6px; transition: all 0.2s; }
349
+ .ihooman-action-btn svg { width: 16px; height: 16px; }
350
+ .ihooman-action-btn.ticket { background: #6366f1; color: white; }
351
+ .ihooman-action-btn.ticket:hover { background: #4f46e5; }
352
+ .ihooman-action-btn.live { background: #10b981; color: white; }
353
+ .ihooman-action-btn.live:hover { background: #059669; }
354
+ .ihooman-action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
313
355
  @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; } }
314
356
  `;
315
357
  }
@@ -352,14 +394,41 @@ function createWidget(): void {
352
394
  <button class="ihooman-header-btn" data-action="minimize" title="Minimize">${icons.minimize}</button>
353
395
  </div>
354
396
  </div>
355
- <div class="ihooman-messages" role="log" aria-live="polite"></div>
356
- <div class="ihooman-input-area">
357
- <div class="ihooman-input-wrapper">
358
- ${config.enableFileUpload ? `<button class="ihooman-input-btn attach" title="Attach file">${icons.attach}</button><input type="file" class="ihooman-file-input">` : ''}
359
- <textarea class="ihooman-input" placeholder="${escapeHtml(config.placeholder || 'Type a message...')}" rows="1" aria-label="Message input"></textarea>
360
- <button class="ihooman-input-btn send" title="Send message" disabled>${icons.send}</button>
397
+
398
+ <!-- Chat View -->
399
+ <div class="ihooman-chat-view">
400
+ <div class="ihooman-status-bar"></div>
401
+ <div class="ihooman-messages" role="log" aria-live="polite"></div>
402
+ <div class="ihooman-action-buttons">
403
+ <button class="ihooman-action-btn ticket" data-action="show-ticket">
404
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>
405
+ Submit Ticket
406
+ </button>
407
+ <button class="ihooman-action-btn live" data-action="live-agent">
408
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
409
+ Live Agent
410
+ </button>
411
+ </div>
412
+ <div class="ihooman-input-area">
413
+ <div class="ihooman-input-wrapper">
414
+ ${config.enableFileUpload ? `<button class="ihooman-input-btn attach" title="Attach file">${icons.attach}</button><input type="file" class="ihooman-file-input">` : ''}
415
+ <textarea class="ihooman-input" placeholder="${escapeHtml(config.placeholder || 'Type a message...')}" rows="1" aria-label="Message input"></textarea>
416
+ <button class="ihooman-input-btn send" title="Send message" disabled>${icons.send}</button>
417
+ </div>
361
418
  </div>
362
419
  </div>
420
+
421
+ <!-- Ticket Form View -->
422
+ <div class="ihooman-ticket-view">
423
+ <h4 class="ihooman-ticket-title">📝 Submit a Ticket</h4>
424
+ <p class="ihooman-ticket-subtitle">We'll get back to you via email</p>
425
+ <input class="ihooman-ticket-input" id="ihooman-ticket-name" placeholder="Your name" required>
426
+ <input class="ihooman-ticket-input" id="ihooman-ticket-email" type="email" placeholder="Your email" required>
427
+ <textarea class="ihooman-ticket-input ihooman-ticket-textarea" id="ihooman-ticket-issue" placeholder="Describe your issue..."></textarea>
428
+ <button class="ihooman-ticket-submit" id="ihooman-ticket-submit">Submit Ticket</button>
429
+ <button class="ihooman-ticket-back" id="ihooman-ticket-back">← Back to Chat</button>
430
+ </div>
431
+
363
432
  ${config.poweredBy ? `<div class="ihooman-powered">Powered by <a href="https://ihooman.ai" target="_blank" rel="noopener">Ihooman AI</a></div>` : ''}
364
433
  </div>
365
434
  </div>
@@ -373,6 +442,8 @@ function createWidget(): void {
373
442
  toggle: widget.querySelector('.ihooman-toggle') as HTMLButtonElement,
374
443
  badge: widget.querySelector('.ihooman-badge') as HTMLElement,
375
444
  window: widget.querySelector('.ihooman-window') as HTMLElement,
445
+ chatView: widget.querySelector('.ihooman-chat-view') as HTMLElement,
446
+ ticketView: widget.querySelector('.ihooman-ticket-view') as HTMLElement,
376
447
  messages: widget.querySelector('.ihooman-messages') as HTMLElement,
377
448
  input: widget.querySelector('.ihooman-input') as HTMLTextAreaElement,
378
449
  sendBtn: widget.querySelector('.ihooman-input-btn.send') as HTMLButtonElement,
@@ -380,6 +451,12 @@ function createWidget(): void {
380
451
  fileInput: widget.querySelector('.ihooman-file-input') as HTMLInputElement | null,
381
452
  statusDot: widget.querySelector('.ihooman-status-dot') as HTMLElement,
382
453
  statusText: widget.querySelector('.ihooman-status-text') as HTMLElement,
454
+ statusBar: widget.querySelector('.ihooman-status-bar') as HTMLElement,
455
+ ticketName: widget.querySelector('#ihooman-ticket-name') as HTMLInputElement,
456
+ ticketEmail: widget.querySelector('#ihooman-ticket-email') as HTMLInputElement,
457
+ ticketIssue: widget.querySelector('#ihooman-ticket-issue') as HTMLTextAreaElement,
458
+ ticketSubmitBtn: widget.querySelector('#ihooman-ticket-submit') as HTMLButtonElement,
459
+ ticketBackBtn: widget.querySelector('#ihooman-ticket-back') as HTMLButtonElement,
383
460
  };
384
461
 
385
462
  // Set up event listeners
@@ -419,6 +496,18 @@ function setupEventListeners(): void {
419
496
 
420
497
  elements.widget?.querySelector('[data-action="refresh"]')?.addEventListener('click', startNewConversation);
421
498
  elements.widget?.querySelector('[data-action="minimize"]')?.addEventListener('click', close);
499
+
500
+ // Action buttons - Submit Ticket and Live Agent
501
+ elements.widget?.querySelector('[data-action="show-ticket"]')?.addEventListener('click', handleShowTicketForm);
502
+ elements.widget?.querySelector('[data-action="live-agent"]')?.addEventListener('click', handleRequestLiveAgent);
503
+
504
+ // Ticket form buttons
505
+ if (elements.ticketSubmitBtn) {
506
+ elements.ticketSubmitBtn.addEventListener('click', handleSubmitTicket);
507
+ }
508
+ if (elements.ticketBackBtn) {
509
+ elements.ticketBackBtn.addEventListener('click', () => showView('chat'));
510
+ }
422
511
  }
423
512
 
424
513
 
@@ -511,15 +600,259 @@ function handleEscalationAction(action: 'live-agent' | 'create-ticket'): void {
511
600
  buttons.forEach(btn => (btn as HTMLButtonElement).disabled = true);
512
601
 
513
602
  if (action === 'live-agent') {
514
- // Send message to request live agent
515
- addMessage('Connect me with a live agent', 'user');
516
- showTyping();
517
- sendMessageToServer('Connect me with a live agent');
603
+ handleRequestLiveAgent();
518
604
  } else if (action === 'create-ticket') {
519
- // Send message to create ticket
520
- addMessage('I would like to create a support ticket', 'user');
521
- showTyping();
522
- sendMessageToServer('I would like to create a support ticket');
605
+ handleShowTicketForm();
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Switch between chat and ticket views
611
+ */
612
+ function showView(view: 'chat' | 'ticket'): void {
613
+ currentView = view;
614
+
615
+ if (elements.chatView) {
616
+ elements.chatView.classList.toggle('hidden', view !== 'chat');
617
+ }
618
+ if (elements.ticketView) {
619
+ elements.ticketView.classList.toggle('show', view === 'ticket');
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Show the ticket form
625
+ */
626
+ function handleShowTicketForm(): void {
627
+ showView('ticket');
628
+ // Focus on name input
629
+ setTimeout(() => elements.ticketName?.focus(), 100);
630
+ }
631
+
632
+ /**
633
+ * Update the status bar display
634
+ */
635
+ function updateStatusBar(status: 'waiting' | 'connected' | 'hidden', message?: string): void {
636
+ if (!elements.statusBar) return;
637
+
638
+ if (status === 'hidden') {
639
+ elements.statusBar.classList.remove('show');
640
+ return;
641
+ }
642
+
643
+ elements.statusBar.classList.add('show');
644
+ elements.statusBar.classList.remove('waiting', 'connected');
645
+ elements.statusBar.classList.add(status);
646
+
647
+ if (message) {
648
+ elements.statusBar.textContent = message;
649
+ }
650
+ }
651
+
652
+ /**
653
+ * Submit a ticket via the API
654
+ */
655
+ async function handleSubmitTicket(): Promise<void> {
656
+ const name = elements.ticketName?.value.trim();
657
+ const email = elements.ticketEmail?.value.trim();
658
+ const issue = elements.ticketIssue?.value.trim();
659
+
660
+ if (!name || !email) {
661
+ alert('Please fill in your name and email');
662
+ return;
663
+ }
664
+
665
+ if (elements.ticketSubmitBtn) {
666
+ elements.ticketSubmitBtn.disabled = true;
667
+ elements.ticketSubmitBtn.textContent = 'Submitting...';
668
+ }
669
+
670
+ try {
671
+ const response = await fetch(`${config.serverUrl}/api/v1/public/submit-ticket`, {
672
+ method: 'POST',
673
+ headers: { 'Content-Type': 'application/json' },
674
+ body: JSON.stringify({
675
+ session_id: state.sessionId,
676
+ user_name: name,
677
+ user_email: email,
678
+ issue: issue || 'No description provided',
679
+ escalation_type: 'ticket',
680
+ widget_id: config.widgetId,
681
+ }),
682
+ });
683
+
684
+ const data = await response.json();
685
+
686
+ // Clear form
687
+ if (elements.ticketName) elements.ticketName.value = '';
688
+ if (elements.ticketEmail) elements.ticketEmail.value = '';
689
+ if (elements.ticketIssue) elements.ticketIssue.value = '';
690
+
691
+ // Switch back to chat view
692
+ showView('chat');
693
+
694
+ // Show success message
695
+ const ticketRef = data.ticket_id ? data.ticket_id.slice(0, 8) : 'submitted';
696
+ addMessage(`✅ Ticket submitted! We'll contact you at ${email}. Reference: #${ticketRef}`, 'bot', { is_system_message: true });
697
+
698
+ } catch (error) {
699
+ console.error('Error submitting ticket:', error);
700
+ addMessage('❌ Error submitting ticket. Please try again.', 'bot', { is_system_message: true });
701
+ }
702
+
703
+ if (elements.ticketSubmitBtn) {
704
+ elements.ticketSubmitBtn.disabled = false;
705
+ elements.ticketSubmitBtn.textContent = 'Submit Ticket';
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Request a live agent via the API
711
+ */
712
+ async function handleRequestLiveAgent(): Promise<void> {
713
+ if (!state.sessionId) {
714
+ addMessage('Please send a message first to start a conversation.', 'bot', { is_system_message: true });
715
+ return;
716
+ }
717
+
718
+ // Disable live agent button
719
+ const liveBtn = elements.widget?.querySelector('[data-action="live-agent"]') as HTMLButtonElement;
720
+ if (liveBtn) {
721
+ liveBtn.disabled = true;
722
+ liveBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg> Connecting...';
723
+ }
724
+
725
+ try {
726
+ const response = await fetch(`${config.serverUrl}/api/v1/public/live-agent`, {
727
+ method: 'POST',
728
+ headers: { 'Content-Type': 'application/json' },
729
+ body: JSON.stringify({
730
+ session_id: state.sessionId,
731
+ visitor_id: state.visitorId,
732
+ widget_id: config.widgetId,
733
+ }),
734
+ });
735
+
736
+ const data = await response.json();
737
+
738
+ isLiveAgentMode = true;
739
+
740
+ // Show status bar
741
+ if (data.position_in_queue && data.position_in_queue > 1) {
742
+ updateStatusBar('waiting', `⏳ Waiting for agent (Position: #${data.position_in_queue})`);
743
+ } else {
744
+ updateStatusBar('connected', '🟢 Connected to live support');
745
+ }
746
+
747
+ // Hide action buttons when in live agent mode
748
+ const actionsDiv = elements.widget?.querySelector('.ihooman-action-buttons') as HTMLElement;
749
+ if (actionsDiv) {
750
+ actionsDiv.style.display = 'none';
751
+ }
752
+
753
+ // Start polling for agent messages
754
+ startLiveAgentPolling();
755
+
756
+ } catch (error) {
757
+ console.error('Error requesting live agent:', error);
758
+ addMessage('❌ Unable to connect to live support. Please try again.', 'bot', { is_system_message: true });
759
+ }
760
+
761
+ // Re-enable button
762
+ if (liveBtn) {
763
+ liveBtn.disabled = false;
764
+ liveBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg> Live Agent';
765
+ }
766
+ }
767
+
768
+ /**
769
+ * Live agent polling interval
770
+ */
771
+ let liveAgentPollInterval: ReturnType<typeof setInterval> | null = null;
772
+ let lastMessageCount = 0;
773
+
774
+ /**
775
+ * Start polling for live agent messages
776
+ */
777
+ function startLiveAgentPolling(): void {
778
+ if (liveAgentPollInterval) return;
779
+
780
+ liveAgentPollInterval = setInterval(async () => {
781
+ if (!state.sessionId || !isLiveAgentMode) {
782
+ stopLiveAgentPolling();
783
+ return;
784
+ }
785
+
786
+ try {
787
+ const response = await fetch(`${config.serverUrl}/api/v1/public/transcript/${state.sessionId}`, {
788
+ headers: { 'Content-Type': 'application/json' },
789
+ });
790
+
791
+ if (!response.ok) return;
792
+
793
+ const data = await response.json();
794
+ const msgCount = data.messages?.length || 0;
795
+
796
+ // Check for new messages
797
+ if (msgCount > lastMessageCount) {
798
+ // Get only new messages
799
+ const newMessages = data.messages.slice(lastMessageCount);
800
+ newMessages.forEach((msg: { sender_type: string; content: string }) => {
801
+ // Only add agent messages (user messages are already shown)
802
+ if (msg.sender_type === 'agent') {
803
+ addMessage(msg.content, 'bot', { agent_name: 'Agent' });
804
+ } else if (msg.sender_type === 'ai' && msg.content.includes('connected to live support')) {
805
+ // System message about connection
806
+ addMessage(msg.content, 'bot', { is_system_message: true });
807
+ }
808
+ });
809
+ lastMessageCount = msgCount;
810
+ }
811
+
812
+ // Check if conversation was closed
813
+ if (data.status === 'closed') {
814
+ isLiveAgentMode = false;
815
+ stopLiveAgentPolling();
816
+ updateStatusBar('hidden');
817
+ addMessage('This conversation has been closed. Thank you for contacting us!', 'bot', { is_system_message: true });
818
+
819
+ // Show action buttons again
820
+ const actionsDiv = elements.widget?.querySelector('.ihooman-action-buttons') as HTMLElement;
821
+ if (actionsDiv) {
822
+ actionsDiv.style.display = 'flex';
823
+ }
824
+ }
825
+
826
+ // Update queue position
827
+ try {
828
+ const escResponse = await fetch(`${config.serverUrl}/api/v1/public/escalation-status/${state.sessionId}`);
829
+ if (escResponse.ok) {
830
+ const escData = await escResponse.json();
831
+ if (escData.escalated) {
832
+ if (escData.ticket_status === 'in_progress') {
833
+ updateStatusBar('connected', '🟢 Connected to live agent');
834
+ } else if (escData.ticket_status === 'open' && escData.position_in_queue > 1) {
835
+ updateStatusBar('waiting', `⏳ Waiting for agent (Position: #${escData.position_in_queue})`);
836
+ }
837
+ }
838
+ }
839
+ } catch {
840
+ // Ignore escalation status errors
841
+ }
842
+
843
+ } catch (error) {
844
+ console.error('Error polling for messages:', error);
845
+ }
846
+ }, 3000);
847
+ }
848
+
849
+ /**
850
+ * Stop live agent polling
851
+ */
852
+ function stopLiveAgentPolling(): void {
853
+ if (liveAgentPollInterval) {
854
+ clearInterval(liveAgentPollInterval);
855
+ liveAgentPollInterval = null;
523
856
  }
524
857
  }
525
858
 
@@ -917,6 +1250,9 @@ function destroy(): void {
917
1250
  // Stop heartbeat
918
1251
  stopHeartbeat();
919
1252
 
1253
+ // Stop live agent polling
1254
+ stopLiveAgentPolling();
1255
+
920
1256
  // Close WebSocket
921
1257
  if (ws) {
922
1258
  ws.close();