botui/ui/suite/attendant/attendant.js

426 lines
13 KiB
JavaScript
Raw Normal View History

/**
* Attendant Module JavaScript
* Human agent interface for live chat support
*/
(function() {
'use strict';
// State
const state = {
activeConversation: null,
quickReplies: [],
typing: false
};
// DOM Elements
const elements = {
queueList: document.querySelector('.queue-list'),
conversationArea: document.querySelector('.conversation-area'),
conversationMessages: document.querySelector('.conversation-messages'),
messageInput: document.querySelector('.message-input'),
userPanel: document.querySelector('.user-panel')
};
/**
* Initialize attendant module
*/
function init() {
setupQueueHandlers();
setupMessageHandlers();
setupKeyboardShortcuts();
setupQuickReplies();
setupWebSocket();
}
/**
* Setup queue item click handlers
*/
function setupQueueHandlers() {
if (!elements.queueList) return;
elements.queueList.addEventListener('click', function(e) {
const queueItem = e.target.closest('.queue-item');
if (!queueItem) return;
// Update active state
document.querySelectorAll('.queue-item').forEach(item => {
item.classList.remove('active');
});
queueItem.classList.add('active');
// Load conversation
const conversationId = queueItem.dataset.conversationId;
if (conversationId) {
loadConversation(conversationId);
}
});
}
/**
* Setup message input handlers
*/
function setupMessageHandlers() {
const input = elements.messageInput;
if (!input) return;
// Handle Enter to send
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Auto-resize textarea
input.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
// Send button
const sendBtn = document.querySelector('.send-btn');
if (sendBtn) {
sendBtn.addEventListener('click', sendMessage);
}
}
/**
* Setup keyboard shortcuts
*/
function setupKeyboardShortcuts() {
document.addEventListener('keydown', function(e) {
// Ctrl+Enter to send
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
sendMessage();
return;
}
// Escape to close panels
if (e.key === 'Escape') {
closeModals();
}
// Ctrl+K to focus search
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
const searchInput = document.querySelector('.queue-search input');
if (searchInput) searchInput.focus();
}
// Number keys (1-9) for quick replies
if (e.altKey && e.key >= '1' && e.key <= '9') {
const index = parseInt(e.key) - 1;
const quickReply = document.querySelectorAll('.quick-reply')[index];
if (quickReply) {
e.preventDefault();
insertQuickReply(quickReply.textContent);
}
}
});
}
/**
* Setup quick reply buttons
*/
function setupQuickReplies() {
document.querySelectorAll('.quick-reply').forEach(btn => {
btn.addEventListener('click', function() {
insertQuickReply(this.textContent);
});
});
}
/**
* Insert quick reply into message input
*/
function insertQuickReply(text) {
const input = elements.messageInput;
if (!input) return;
input.value = text;
input.focus();
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
}
/**
* Load conversation by ID
*/
function loadConversation(conversationId) {
state.activeConversation = conversationId;
// HTMX will handle the actual loading
// This is for any additional state management
updateUserPanel(conversationId);
}
/**
* Update user info panel
*/
function updateUserPanel(conversationId) {
// User panel is updated via HTMX
// Add any additional logic here
}
/**
* Send message
*/
function sendMessage() {
const input = elements.messageInput;
if (!input || !input.value.trim()) return;
const message = input.value.trim();
// Add message to UI immediately (optimistic update)
appendMessage({
type: 'agent',
content: message,
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
});
// Clear input
input.value = '';
input.style.height = 'auto';
// Send via HTMX or WebSocket
if (window.attendantSocket && window.attendantSocket.readyState === WebSocket.OPEN) {
window.attendantSocket.send(JSON.stringify({
type: 'message',
conversationId: state.activeConversation,
content: message
}));
}
}
/**
* Append message to conversation
*/
function appendMessage(msg) {
const container = elements.conversationMessages;
if (!container) return;
const messageDiv = document.createElement('div');
messageDiv.className = `message ${msg.type}`;
messageDiv.innerHTML = `
<div class="message-bubble">${escapeHtml(msg.content)}</div>
<div class="message-time">${msg.time}</div>
`;
container.appendChild(messageDiv);
scrollToBottom();
}
/**
* Scroll messages to bottom
*/
function scrollToBottom() {
const container = elements.conversationMessages;
if (container) {
container.scrollTop = container.scrollHeight;
}
}
/**
* Setup WebSocket connection
*/
function setupWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/attendant`;
try {
window.attendantSocket = new WebSocket(wsUrl);
window.attendantSocket.onopen = function() {
console.log('Attendant WebSocket connected');
updateConnectionStatus('online');
};
window.attendantSocket.onmessage = function(event) {
handleWebSocketMessage(JSON.parse(event.data));
};
window.attendantSocket.onclose = function() {
console.log('Attendant WebSocket disconnected');
updateConnectionStatus('offline');
// Attempt reconnection
setTimeout(setupWebSocket, 5000);
};
window.attendantSocket.onerror = function(error) {
console.error('WebSocket error:', error);
updateConnectionStatus('error');
};
} catch (e) {
console.warn('WebSocket not available:', e);
}
}
/**
* Handle incoming WebSocket messages
*/
function handleWebSocketMessage(data) {
switch (data.type) {
case 'new_message':
if (data.conversationId === state.activeConversation) {
appendMessage({
type: 'user',
content: data.content,
time: data.time || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
});
}
// Update queue item preview
updateQueuePreview(data.conversationId, data.content);
break;
case 'new_conversation':
// Refresh queue list
htmx.trigger('.queue-list', 'refresh');
showNotification('New conversation', data.userName || 'New user');
break;
case 'typing':
if (data.conversationId === state.activeConversation) {
showTypingIndicator(data.isTyping);
}
break;
case 'conversation_closed':
if (data.conversationId === state.activeConversation) {
showConversationClosed();
}
break;
}
}
/**
* Update queue item preview text
*/
function updateQueuePreview(conversationId, text) {
const item = document.querySelector(`.queue-item[data-conversation-id="${conversationId}"]`);
if (item) {
const preview = item.querySelector('.queue-preview');
if (preview) {
preview.textContent = text.substring(0, 50) + (text.length > 50 ? '...' : '');
}
// Update time
const time = item.querySelector('.queue-time');
if (time) {
time.textContent = 'Just now';
}
// Add unread badge if not active
if (conversationId !== state.activeConversation) {
let badge = item.querySelector('.queue-badge');
if (!badge) {
badge = document.createElement('span');
badge.className = 'queue-badge';
badge.textContent = '1';
item.appendChild(badge);
} else {
badge.textContent = parseInt(badge.textContent || 0) + 1;
}
}
}
}
/**
* Show typing indicator
*/
function showTypingIndicator(isTyping) {
let indicator = document.querySelector('.typing-indicator');
if (isTyping) {
if (!indicator) {
indicator = document.createElement('div');
indicator.className = 'typing-indicator';
indicator.innerHTML = `
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
`;
elements.conversationMessages?.appendChild(indicator);
}
} else if (indicator) {
indicator.remove();
}
}
/**
* Show conversation closed message
*/
function showConversationClosed() {
const container = elements.conversationMessages;
if (!container) return;
const closedDiv = document.createElement('div');
closedDiv.className = 'conversation-closed';
closedDiv.textContent = 'This conversation has been closed';
container.appendChild(closedDiv);
// Disable input
if (elements.messageInput) {
elements.messageInput.disabled = true;
elements.messageInput.placeholder = 'Conversation closed';
}
}
/**
* Update connection status indicator
*/
function updateConnectionStatus(status) {
const indicator = document.querySelector('.connection-status');
if (indicator) {
indicator.className = `connection-status ${status}`;
indicator.title = status.charAt(0).toUpperCase() + status.slice(1);
}
}
/**
* Show browser notification
*/
function showNotification(title, body) {
if (!('Notification' in window)) return;
if (Notification.permission === 'granted') {
new Notification(title, { body, icon: '/icons/notification.png' });
} else if (Notification.permission !== 'denied') {
Notification.requestPermission().then(permission => {
if (permission === 'granted') {
new Notification(title, { body, icon: '/icons/notification.png' });
}
});
}
}
/**
* Close all modals
*/
function closeModals() {
document.querySelectorAll('.modal').forEach(modal => {
modal.classList.add('hidden');
});
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Expose for external use
window.Attendant = {
sendMessage,
loadConversation,
insertQuickReply
};
})();