Spaces:
Paused
Paused
Download src/main.ts from jashp2323/call-voice-agent: direct link, hf CLI and curl.
- Browser
- Download file 28.9 kB
-
https://huggingface.co/spaces/jashp2323/call-voice-agent/resolve/main/src/main.ts
- Command line
-
hf download hf://spaces/jashp2323/call-voice-agent/src/main.ts
-
curl -L -o main.ts https://huggingface.co/spaces/jashp2323/call-voice-agent/resolve/main/src/main.ts
28.9 kB
| import './style.css'; | |
| interface Order { | |
| id: string; | |
| customer_name: string; | |
| product_name: string; | |
| estimated_delivery_date: string; | |
| status: string; | |
| } | |
| interface Feedback { | |
| id: number; | |
| call_id: string; | |
| order_id: string; | |
| rating: number; | |
| feedback_text: string; | |
| category: string; | |
| product_name?: string; | |
| customer_name?: string; | |
| agent_type?: string; | |
| } | |
| interface CallRecord { | |
| id: string; | |
| status: string; | |
| } | |
| const state: { | |
| orders: Order[]; | |
| selectedOrderId: string | null; | |
| selectedAgentType: string; | |
| callActive: boolean; | |
| sessionId: string | null; | |
| ws: WebSocket | null; | |
| agentSpeaking: boolean; | |
| messages: { role: string; text: string; at: number }[]; | |
| feedback: Feedback[]; | |
| mediaStream: MediaStream | null; | |
| audioContext: AudioContext | null; | |
| processor: ScriptProcessorNode | null; | |
| ringingActive: boolean; | |
| ringingOrderId: string | null; | |
| } = { | |
| orders: [], | |
| selectedOrderId: null, | |
| selectedAgentType: 'order_confirmation', | |
| callActive: false, | |
| sessionId: null, | |
| ws: null, | |
| agentSpeaking: false, | |
| messages: [], | |
| feedback: [], | |
| mediaStream: null, | |
| audioContext: null, | |
| processor: null, | |
| ringingActive: false, | |
| ringingOrderId: null, | |
| }; | |
| let ringingTimeout: any = null; | |
| let charts: any = {}; | |
| const $ = <T extends HTMLElement>(id: string): T => { | |
| const el = document.getElementById(id); | |
| if (!el) throw new Error(`Element ${id} not found`); | |
| return el as T; | |
| }; | |
| const ordersList = $<HTMLDivElement>('orders-list'); | |
| const orderCount = $<HTMLSpanElement>('order-count'); | |
| const selectedOrderInfo = $<HTMLDivElement>('selected-order-info'); | |
| const infoCustomer = $<HTMLSpanElement>('info-customer'); | |
| const infoOrderId = $<HTMLSpanElement>('info-order-id'); | |
| const infoProduct = $<HTMLSpanElement>('info-product'); | |
| const infoDelivery = $<HTMLSpanElement>('info-delivery'); | |
| const btnStartCall = $<HTMLButtonElement>('btn-start-call'); | |
| const btnEndCall = $<HTMLButtonElement>('btn-end-call'); | |
| const BACKEND_URL = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' | |
| ? window.location.origin | |
| : 'https://jashp2323-call-voice-agent.hf.space'; | |
| const BACKEND_HOST = BACKEND_URL.replace(/^https?:\/\//, ''); | |
| const WS_PROTOCOL = BACKEND_URL.startsWith('https:') ? 'wss:' : 'ws:'; | |
| const btnWhatsappFallback = $<HTMLButtonElement>('btn-whatsapp-fallback'); | |
| const callStatusBadge = $<HTMLDivElement>('call-status-badge'); | |
| const callStatusText = $<HTMLSpanElement>('call-status-text'); | |
| const micIndicator = $<HTMLDivElement>('mic-indicator'); | |
| const transcript = $<HTMLDivElement>('transcript'); | |
| const textInput = $<HTMLInputElement>('text-input'); | |
| const btnSend = $<HTMLButtonElement>('btn-send'); | |
| const serverStatus = $<HTMLSpanElement>('server-status'); | |
| const serverStatusText = $<HTMLSpanElement>('server-status-text'); | |
| const feedbackList = $<HTMLDivElement>('feedback-list'); | |
| const statTotalCalls = $<HTMLSpanElement>('stat-total-calls'); | |
| const statAvgRating = $<HTMLSpanElement>('stat-avg-rating'); | |
| const btnRefreshFeedback = $<HTMLButtonElement>('btn-refresh-feedback'); | |
| document.addEventListener('DOMContentLoaded', async () => { | |
| document.querySelectorAll('.agent-tab').forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| const target = tab as HTMLButtonElement; | |
| selectAgentType(target.dataset.agent || 'order_confirmation'); | |
| }); | |
| }); | |
| btnStartCall.addEventListener('click', startCall); | |
| btnEndCall.addEventListener('click', endCall); | |
| btnWhatsappFallback.addEventListener('click', triggerWhatsappFallback); | |
| btnSend.addEventListener('click', sendTextMessage); | |
| textInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') sendTextMessage(); | |
| }); | |
| btnRefreshFeedback.addEventListener('click', loadFeedback); | |
| try { | |
| const checkoutForm = $('checkout-form'); | |
| if (checkoutForm) { | |
| checkoutForm.addEventListener('submit', handleCheckoutSubmit); | |
| } | |
| } catch (err) {} | |
| try { | |
| const navConsole = $('nav-console'); | |
| const navAnalytics = $('nav-analytics'); | |
| const panelCall = document.querySelector('.panel-call') as HTMLElement; | |
| const panelFeedback = document.querySelector('.panel-feedback') as HTMLElement; | |
| const panelAnalytics = $('panel-analytics'); | |
| const btnRefreshAnalytics = $('btn-refresh-analytics'); | |
| if (navConsole && navAnalytics && panelCall && panelFeedback && panelAnalytics) { | |
| navConsole.addEventListener('click', () => { | |
| navConsole.classList.add('active'); | |
| navAnalytics.classList.remove('active'); | |
| panelCall.classList.remove('hidden'); | |
| panelFeedback.classList.remove('hidden'); | |
| panelAnalytics.classList.add('hidden'); | |
| }); | |
| navAnalytics.addEventListener('click', () => { | |
| navConsole.classList.remove('active'); | |
| navAnalytics.classList.add('active'); | |
| panelCall.classList.add('hidden'); | |
| panelFeedback.classList.add('hidden'); | |
| panelAnalytics.classList.remove('hidden'); | |
| loadAnalytics(); | |
| }); | |
| } | |
| if (btnRefreshAnalytics) { | |
| btnRefreshAnalytics.addEventListener('click', loadAnalytics); | |
| } | |
| } catch (err) {} | |
| await checkServerStatus(); | |
| await loadOrders(); | |
| await loadFeedback(); | |
| connectDashboardWs(); | |
| }); | |
| async function checkServerStatus() { | |
| try { | |
| const res = await fetch(`${BACKEND_URL}/api/status`); | |
| const data = await res.json(); | |
| if (data.ready) { | |
| serverStatus.className = 'status-dot online'; | |
| serverStatusText.textContent = 'STT + TTS Ready'; | |
| } else { | |
| serverStatus.className = 'status-dot offline'; | |
| serverStatusText.textContent = 'Models loading...'; | |
| } | |
| } catch { | |
| serverStatus.className = 'status-dot offline'; | |
| serverStatusText.textContent = 'Server offline'; | |
| } | |
| } | |
| async function loadOrders() { | |
| try { | |
| const res = await fetch(`${BACKEND_URL}/api/orders`); | |
| state.orders = await res.json(); | |
| renderOrders(); | |
| } catch (e) { | |
| ordersList.innerHTML = '<p class="feedback-empty">Failed to load orders. Is the server running?</p>'; | |
| } | |
| } | |
| function renderOrders() { | |
| orderCount.textContent = String(state.orders.length); | |
| if (state.orders.length === 0) { | |
| ordersList.innerHTML = '<p class="feedback-empty">No orders found.</p>'; | |
| return; | |
| } | |
| ordersList.innerHTML = state.orders.map(order => { | |
| const isSelected = state.selectedOrderId === order.id; | |
| const statusLabel = order.status.replace(/_/g, ' '); | |
| return ` | |
| <div class="order-card ${isSelected ? 'selected' : ''}" | |
| onclick="window.selectOrder('${order.id}')" | |
| data-order-id="${order.id}"> | |
| <div class="order-card-header"> | |
| <span class="order-id">${order.id}</span> | |
| <span class="order-status-badge status-${order.status}">${statusLabel}</span> | |
| </div> | |
| <div class="order-customer">${escapeHtml(order.customer_name)}</div> | |
| <div class="order-product">${escapeHtml(order.product_name)}</div> | |
| <div class="order-delivery-date"> | |
| <i class="fa-regular fa-calendar"></i> | |
| ${order.estimated_delivery_date} | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| } | |
| function selectOrder(orderId: string) { | |
| if (state.callActive) return; | |
| state.selectedOrderId = orderId; | |
| const order = state.orders.find(o => o.id === orderId); | |
| if (!order) return; | |
| renderOrders(); | |
| selectedOrderInfo.classList.remove('hidden'); | |
| infoCustomer.textContent = order.customer_name; | |
| infoOrderId.textContent = order.id; | |
| infoProduct.textContent = order.product_name; | |
| infoDelivery.textContent = order.estimated_delivery_date; | |
| let autoAgent = 'order_confirmation'; | |
| if (order.status === 'out_for_delivery') { | |
| autoAgent = 'delivery_verification'; | |
| } else if (order.status === 'delivered') { | |
| autoAgent = 'feedback_followup'; | |
| } | |
| selectAgentType(autoAgent); | |
| btnStartCall.disabled = false; | |
| btnWhatsappFallback.disabled = false; | |
| callStatusText.textContent = 'Ready to call'; | |
| } | |
| (window as any).selectOrder = selectOrder; | |
| function selectAgentType(agentType: string) { | |
| if (state.callActive) return; | |
| state.selectedAgentType = agentType; | |
| document.querySelectorAll('.agent-tab').forEach(tab => { | |
| const button = tab as HTMLButtonElement; | |
| button.classList.toggle('active', button.dataset.agent === agentType); | |
| }); | |
| } | |
| async function startCall() { | |
| if (state.ringingActive) { | |
| if (ringingTimeout) { | |
| clearTimeout(ringingTimeout); | |
| ringingTimeout = null; | |
| } | |
| state.ringingActive = false; | |
| btnStartCall.innerHTML = '<i class="fa-solid fa-phone"></i> Start Call'; | |
| btnStartCall.className = 'btn btn-call'; | |
| btnEndCall.innerHTML = '<i class="fa-solid fa-phone-slash"></i> End Call'; | |
| btnEndCall.className = 'btn btn-end'; | |
| } | |
| if (!state.selectedOrderId || state.callActive) return; | |
| state.callActive = true; | |
| state.messages = []; | |
| transcript.innerHTML = ''; | |
| btnStartCall.disabled = true; | |
| btnEndCall.disabled = false; | |
| btnWhatsappFallback.disabled = true; | |
| textInput.disabled = false; | |
| btnSend.disabled = false; | |
| callStatusBadge.className = 'call-badge live'; | |
| callStatusText.textContent = 'Connecting...'; | |
| document.querySelectorAll<HTMLDivElement>('.order-card').forEach(c => c.style.pointerEvents = 'none'); | |
| document.querySelectorAll<HTMLButtonElement>('.agent-tab').forEach(t => t.style.pointerEvents = 'none'); | |
| state.ws = new WebSocket(`${WS_PROTOCOL}//${BACKEND_HOST}/ws/call`); | |
| state.ws.onopen = () => { | |
| callStatusText.textContent = 'In Call'; | |
| state.ws?.send(JSON.stringify({ | |
| type: 'start_call', | |
| order_id: state.selectedOrderId, | |
| agent_type: state.selectedAgentType, | |
| })); | |
| }; | |
| state.ws.onmessage = (event) => handleWsMessage(event); | |
| state.ws.onclose = () => { if (state.callActive) endCall(); }; | |
| state.ws.onerror = () => { | |
| addSystemMessage('Connection error. Please try again.'); | |
| endCall(); | |
| }; | |
| } | |
| function endCall() { | |
| if (state.ringingActive) { | |
| if (ringingTimeout) { | |
| clearTimeout(ringingTimeout); | |
| ringingTimeout = null; | |
| } | |
| state.ringingActive = false; | |
| handleNoAnswerFallback(); | |
| return; | |
| } | |
| if (state.ws && state.ws.readyState === WebSocket.OPEN) { | |
| try { state.ws.send(JSON.stringify({ type: 'end_call' })); } catch {} | |
| } | |
| if (state.ws) { | |
| try { state.ws.close(); } catch {} | |
| state.ws = null; | |
| } | |
| stopMicrophone(); | |
| state.callActive = false; | |
| state.sessionId = null; | |
| state.agentSpeaking = false; | |
| btnStartCall.disabled = !state.selectedOrderId; | |
| btnEndCall.disabled = true; | |
| btnWhatsappFallback.disabled = !state.selectedOrderId; | |
| textInput.disabled = true; | |
| btnSend.disabled = true; | |
| micIndicator.classList.add('hidden'); | |
| callStatusBadge.className = 'call-badge ended'; | |
| callStatusText.textContent = 'Call ended'; | |
| document.querySelectorAll<HTMLDivElement>('.order-card').forEach(c => c.style.pointerEvents = ''); | |
| document.querySelectorAll<HTMLButtonElement>('.agent-tab').forEach(t => t.style.pointerEvents = ''); | |
| setTimeout(loadFeedback, 500); | |
| } | |
| function handleWsMessage(event: MessageEvent) { | |
| let msg; | |
| try { msg = JSON.parse(event.data); } catch { return; } | |
| switch (msg.type) { | |
| case 'call_started': | |
| state.sessionId = msg.session_id; | |
| callStatusText.textContent = `In Call — ${formatAgentType(msg.agent_type)}`; | |
| addSystemMessage(`Call started with ${msg.customer_name} about ${msg.product_name}`); | |
| break; | |
| case 'agent_text': | |
| addMessage('agent', msg.text); | |
| break; | |
| case 'user_text': | |
| addMessage('user', msg.text); | |
| break; | |
| case 'tts_stream_start': | |
| state.agentSpeaking = true; | |
| micIndicator.classList.add('hidden'); | |
| break; | |
| case 'agent_audio': | |
| playAudioBase64(msg.data); | |
| break; | |
| case 'tts_stream_end': | |
| state.agentSpeaking = false; | |
| if (state.callActive) { | |
| micIndicator.classList.remove('hidden'); | |
| startMicrophone(); | |
| } | |
| break; | |
| case 'tts_error': | |
| state.agentSpeaking = false; | |
| addSystemMessage('TTS error: ' + msg.error); | |
| if (state.callActive) { | |
| micIndicator.classList.remove('hidden'); | |
| startMicrophone(); | |
| } | |
| break; | |
| case 'call_ended': | |
| const duration = msg.duration ? `${msg.duration}s` : ''; | |
| const feedback = msg.feedback || {}; | |
| let endMsg = `Call ended. Duration: ${duration}`; | |
| if (feedback.rating) { | |
| endMsg += ` | Rating: ${'★'.repeat(feedback.rating)}${'☆'.repeat(5 - feedback.rating)}`; | |
| } | |
| addSystemMessage(endMsg); | |
| endCall(); | |
| break; | |
| case 'error': | |
| addSystemMessage('Error: ' + msg.message); | |
| break; | |
| } | |
| } | |
| function playAudioBase64(base64Data: string) { | |
| const binary = atob(base64Data); | |
| const bytes = new Uint8Array(binary.length); | |
| for (let i = 0; i < binary.length; i++) { | |
| bytes[i] = binary.charCodeAt(i); | |
| } | |
| const blob = new Blob([bytes], { type: 'audio/mpeg' }); | |
| const url = URL.createObjectURL(blob); | |
| const audio = new Audio(url); | |
| audio.onended = () => URL.revokeObjectURL(url); | |
| audio.onerror = () => URL.revokeObjectURL(url); | |
| audio.play().catch(() => {}); | |
| } | |
| async function startMicrophone() { | |
| if (state.mediaStream) return; | |
| if (!state.callActive || !state.ws) return; | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| audio: { echoCancellation: true, noiseSuppression: true } | |
| }); | |
| state.mediaStream = stream; | |
| const audioCtx = new AudioContext({ sampleRate: 16000 }); | |
| state.audioContext = audioCtx; | |
| const source = audioCtx.createMediaStreamSource(stream); | |
| const processor = audioCtx.createScriptProcessor(4096, 1, 1); | |
| state.processor = processor; | |
| processor.onaudioprocess = (e) => { | |
| if (!state.callActive || !state.ws || state.ws.readyState !== WebSocket.OPEN) return; | |
| if (state.agentSpeaking) return; | |
| const float32 = e.inputBuffer.getChannelData(0); | |
| const int16 = new Int16Array(float32.length); | |
| for (let i = 0; i < float32.length; i++) { | |
| const s = Math.max(-1, Math.min(1, float32[i])); | |
| int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; | |
| } | |
| state.ws.send(int16.buffer); | |
| }; | |
| source.connect(processor); | |
| const gain = audioCtx.createGain(); | |
| gain.gain.value = 0; | |
| processor.connect(gain); | |
| gain.connect(audioCtx.destination); | |
| } catch (e) { | |
| addSystemMessage('Microphone access denied. Use text input instead.'); | |
| } | |
| } | |
| function stopMicrophone() { | |
| if (state.processor) { | |
| try { state.processor.disconnect(); } catch {} | |
| state.processor = null; | |
| } | |
| if (state.mediaStream) { | |
| state.mediaStream.getTracks().forEach(t => t.stop()); | |
| state.mediaStream = null; | |
| } | |
| if (state.audioContext) { | |
| try { state.audioContext.close(); } catch {} | |
| state.audioContext = null; | |
| } | |
| } | |
| function sendTextMessage() { | |
| const text = textInput.value.trim(); | |
| if (!text || !state.ws || state.ws.readyState !== WebSocket.OPEN) return; | |
| textInput.value = ''; | |
| state.ws.send(JSON.stringify({ type: 'text', data: text })); | |
| } | |
| function addMessage(role: string, text: string) { | |
| state.messages.push({ role, text, at: Date.now() }); | |
| const empty = transcript.querySelector('.transcript-empty'); | |
| if (empty) empty.remove(); | |
| const div = document.createElement('div'); | |
| div.className = `msg-bubble ${role}`; | |
| const roleLabel = role === 'agent' ? 'Agent' : 'You'; | |
| div.innerHTML = ` | |
| <div class="msg-role">${roleLabel}</div> | |
| <div class="msg-text">${escapeHtml(text)}</div> | |
| `; | |
| transcript.appendChild(div); | |
| transcript.scrollTop = transcript.scrollHeight; | |
| } | |
| function addSystemMessage(text: string) { | |
| const empty = transcript.querySelector('.transcript-empty'); | |
| if (empty) empty.remove(); | |
| const div = document.createElement('div'); | |
| div.className = 'msg-bubble system'; | |
| div.innerHTML = `<div class="msg-text">${escapeHtml(text)}</div>`; | |
| transcript.appendChild(div); | |
| transcript.scrollTop = transcript.scrollHeight; | |
| } | |
| async function loadFeedback() { | |
| try { | |
| const [feedbackRes, callsRes] = await Promise.all([ | |
| fetch(`${BACKEND_URL}/api/feedback`), | |
| fetch(`${BACKEND_URL}/api/calls`), | |
| ]); | |
| const feedback: Feedback[] = await feedbackRes.json(); | |
| const calls: CallRecord[] = await callsRes.json(); | |
| state.feedback = feedback; | |
| const completedCalls = calls.filter(c => c.status === 'completed'); | |
| statTotalCalls.textContent = String(completedCalls.length); | |
| if (feedback.length > 0) { | |
| const ratings = feedback.map(f => f.rating); | |
| if (ratings.length > 0) { | |
| const avg = (ratings.reduce((a, b) => a + b, 0) / ratings.length).toFixed(1); | |
| statAvgRating.textContent = `${avg}★`; | |
| } else { | |
| statAvgRating.textContent = '—'; | |
| } | |
| } else { | |
| statAvgRating.textContent = '—'; | |
| } | |
| renderFeedback(feedback); | |
| } catch (e) { | |
| console.error('Feedback load error:', e); | |
| } | |
| } | |
| function renderFeedback(feedback: Feedback[]) { | |
| if (feedback.length === 0) { | |
| feedbackList.innerHTML = '<p class="feedback-empty">No feedback yet. Complete a call to see ratings here.</p>'; | |
| return; | |
| } | |
| feedbackList.innerHTML = feedback.map(fb => { | |
| const stars = renderStars(fb.rating || 0); | |
| const agentLabel = formatAgentType(fb.agent_type || fb.category); | |
| const categoryClass = fb.category || fb.agent_type || ''; | |
| return ` | |
| <div class="feedback-card"> | |
| <div class="feedback-header"> | |
| <span class="feedback-agent-type ${categoryClass}">${agentLabel}</span> | |
| <div class="feedback-stars">${stars}</div> | |
| </div> | |
| <div class="feedback-product">${escapeHtml(fb.product_name || '')}</div> | |
| <div class="feedback-customer">${escapeHtml(fb.customer_name || '')} • ${fb.order_id}</div> | |
| ${fb.feedback_text ? `<div class="feedback-text">"${escapeHtml(fb.feedback_text)}"</div>` : ''} | |
| </div> | |
| `; | |
| }).join(''); | |
| } | |
| function renderStars(rating: number) { | |
| let html = ''; | |
| for (let i = 1; i <= 5; i++) { | |
| html += `<i class="fa-solid fa-star ${i <= rating ? 'filled' : ''}"></i>`; | |
| } | |
| return html; | |
| } | |
| function formatAgentType(type: string) { | |
| const map: Record<string, string> = { | |
| 'order_confirmation': 'Order Confirm', | |
| 'delivery_verification': 'Delivery Verify', | |
| 'feedback_followup': 'Feedback', | |
| 'order': 'Order Confirm', | |
| 'delivery': 'Delivery Verify', | |
| 'followup': 'Feedback', | |
| }; | |
| return map[type] || type; | |
| } | |
| function escapeHtml(str: string) { | |
| if (!str) return ''; | |
| return str | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"'); | |
| } | |
| async function triggerWhatsappFallback() { | |
| if (!state.selectedOrderId) return; | |
| const originalText = btnWhatsappFallback.innerHTML; | |
| btnWhatsappFallback.disabled = true; | |
| btnWhatsappFallback.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending...'; | |
| try { | |
| let url = `${BACKEND_URL}/api/calls/whatsapp-fallback?order_id=${state.selectedOrderId}`; | |
| if (state.sessionId) { | |
| url = `${BACKEND_URL}/api/calls/whatsapp-fallback?call_id=${state.sessionId}`; | |
| } | |
| const res = await fetch(url, { method: 'POST' }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| addSystemMessage(`✅ WhatsApp fallback sent successfully!`); | |
| alert(data.message || 'WhatsApp notification sent!'); | |
| } else { | |
| addSystemMessage(`❌ WhatsApp fallback failed: ${data.error || 'Unknown error'}`); | |
| alert(`Error: ${data.error || 'Failed to send WhatsApp message'}`); | |
| } | |
| } catch (err: any) { | |
| addSystemMessage(`❌ Network error while sending WhatsApp fallback.`); | |
| alert(`Network error: ${err.message}`); | |
| } finally { | |
| btnWhatsappFallback.disabled = false; | |
| btnWhatsappFallback.innerHTML = originalText; | |
| } | |
| } | |
| async function handleCheckoutSubmit(e: Event) { | |
| e.preventDefault(); | |
| const name = (document.getElementById('checkout-name') as HTMLInputElement).value; | |
| const phone = (document.getElementById('checkout-phone') as HTMLInputElement).value; | |
| const email = (document.getElementById('checkout-email') as HTMLInputElement).value; | |
| const product = (document.getElementById('checkout-product') as HTMLInputElement).value; | |
| const btnSubmit = document.getElementById('btn-submit-checkout') as HTMLButtonElement; | |
| btnSubmit.disabled = true; | |
| btnSubmit.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Processing...'; | |
| try { | |
| const res = await fetch(`${BACKEND_URL}/api/orders`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| customer_name: name, | |
| customer_phone: phone, | |
| customer_email: email, | |
| product_name: product | |
| }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| addSystemMessage(`🛒 Checkout complete! Order placed: ${data.order_id}`); | |
| await loadOrders(); | |
| selectOrder(data.order_id); | |
| } else { | |
| alert(`Error: ${data.error || 'Failed to place order'}`); | |
| } | |
| } catch (err: any) { | |
| alert(`Network Error: ${err.message}`); | |
| } finally { | |
| btnSubmit.disabled = false; | |
| btnSubmit.innerHTML = '<i class="fa-solid fa-cash-register"></i> Place Order'; | |
| } | |
| } | |
| function startRinging(orderId: string, agentType?: string) { | |
| if (state.callActive) return; | |
| if (ringingTimeout) { | |
| clearTimeout(ringingTimeout); | |
| ringingTimeout = null; | |
| } | |
| state.ringingActive = true; | |
| state.ringingOrderId = orderId; | |
| if (state.selectedOrderId !== orderId) { | |
| selectOrder(orderId); | |
| } | |
| if (agentType) { | |
| selectAgentType(agentType); | |
| } | |
| callStatusBadge.className = 'call-badge ringing'; | |
| callStatusText.innerHTML = '<span class="pulse-dot-ringing"></span> Incoming Call...'; | |
| addSystemMessage(`🔔 Incoming call... (Click 'Answer' to accept or 'Decline' to reject)`); | |
| btnStartCall.disabled = false; | |
| btnStartCall.innerHTML = '<i class="fa-solid fa-phone"></i> Answer'; | |
| btnStartCall.className = 'btn btn-call btn-green-pulse'; | |
| btnEndCall.disabled = false; | |
| btnEndCall.innerHTML = '<i class="fa-solid fa-phone-slash"></i> Decline'; | |
| btnEndCall.className = 'btn btn-end'; | |
| ringingTimeout = setTimeout(() => { | |
| handleNoAnswerFallback(); | |
| }, 15000); | |
| } | |
| async function handleNoAnswerFallback() { | |
| state.ringingActive = false; | |
| if (ringingTimeout) { | |
| clearTimeout(ringingTimeout); | |
| ringingTimeout = null; | |
| } | |
| btnStartCall.disabled = false; | |
| btnStartCall.innerHTML = '<i class="fa-solid fa-phone"></i> Start Call'; | |
| btnStartCall.className = 'btn btn-call'; | |
| btnEndCall.disabled = true; | |
| btnEndCall.innerHTML = '<i class="fa-solid fa-phone-slash"></i> End Call'; | |
| btnEndCall.className = 'btn btn-end'; | |
| callStatusBadge.className = 'call-badge ended'; | |
| callStatusText.textContent = 'Unanswered (Declined)'; | |
| addSystemMessage(`⚠️ Call missed. Progressing sequence...`); | |
| try { | |
| await fetch(`${BACKEND_URL}/api/calls/whatsapp-fallback?order_id=${state.ringingOrderId}`, { method: 'POST' }); | |
| } catch (err) { | |
| console.error(err); | |
| } | |
| } | |
| async function loadAnalytics() { | |
| try { | |
| const res = await fetch(`${BACKEND_URL}/api/analytics`); | |
| const data = await res.json(); | |
| $('stat-total-calls-val').textContent = String(data.overview.total_calls); | |
| $('stat-avg-rating-val').textContent = data.overview.avg_rating.toFixed(1); | |
| $('stat-whatsapp-val').textContent = String(data.overview.whatsapp_fallbacks); | |
| $('stat-completed-val').textContent = String(data.overview.completed_calls); | |
| renderCharts(data); | |
| } catch (err) { | |
| console.error("Failed to load analytics data:", err); | |
| } | |
| } | |
| function renderCharts(data: any) { | |
| const Chart = (window as any).Chart; | |
| if (!Chart) return; | |
| // Destroy existing charts to reload cleanly | |
| Object.keys(charts).forEach(key => { | |
| if (charts[key]) charts[key].destroy(); | |
| }); | |
| // Chart 1: Call Outcomes (Doughnut) | |
| const outcomesCtx = ($('chart-outcomes') as HTMLCanvasElement).getContext('2d'); | |
| if (outcomesCtx) { | |
| const outcomeStats = data.status_distribution; | |
| const completed = outcomeStats.completed || 0; | |
| const missed = outcomeStats.whatsapp_fallback || 0; | |
| const failed = outcomeStats.failed || 0; | |
| const inProgress = outcomeStats.in_progress || 0; | |
| charts.outcomes = new Chart(outcomesCtx, { | |
| type: 'doughnut', | |
| data: { | |
| labels: ['Completed', 'Missed (WhatsApp)', 'Failed', 'In Progress'], | |
| datasets: [{ | |
| data: [completed, missed, failed, inProgress], | |
| backgroundColor: ['#10b981', '#f97316', '#ef4444', '#3b82f6'], | |
| borderWidth: 1, | |
| borderColor: '#1e293b' | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { | |
| legend: { position: 'bottom', labels: { color: '#94a3b8' } } | |
| } | |
| } | |
| }); | |
| } | |
| // Chart 2: Daily Call Timeline (Line) | |
| const timelineCanvas = $('chart-timeline') as HTMLCanvasElement; | |
| if (timelineCanvas) { | |
| const timelineCtx = timelineCanvas.getContext('2d'); | |
| if (timelineCtx) { | |
| const labels = data.timeline.map((t: any) => t.date); | |
| const counts = data.timeline.map((t: any) => t.count); | |
| charts.timeline = new Chart(timelineCtx, { | |
| type: 'line', | |
| data: { | |
| labels: labels.length ? labels : ['No Data'], | |
| datasets: [{ | |
| label: 'Call Volume', | |
| data: counts.length ? counts : [0], | |
| borderColor: '#3b82f6', | |
| backgroundColor: 'rgba(59, 130, 246, 0.1)', | |
| tension: 0.3, | |
| fill: true | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| scales: { | |
| x: { ticks: { color: '#94a3b8' }, grid: { color: 'rgba(255,255,255,0.05)' } }, | |
| y: { ticks: { color: '#94a3b8', stepSize: 1 }, grid: { color: 'rgba(255,255,255,0.05)' } } | |
| }, | |
| plugins: { | |
| legend: { display: false } | |
| } | |
| } | |
| }); | |
| } | |
| } | |
| // Chart 3: Satisfaction Rating Distribution (Bar) | |
| const ratingsCanvas = $('chart-ratings') as HTMLCanvasElement; | |
| if (ratingsCanvas) { | |
| const ratingsCtx = ratingsCanvas.getContext('2d'); | |
| if (ratingsCtx) { | |
| const ratingData = [1, 2, 3, 4, 5].map(star => data.rating_distribution[star] || 0); | |
| charts.ratings = new Chart(ratingsCtx, { | |
| type: 'bar', | |
| data: { | |
| labels: ['1 Star', '2 Stars', '3 Stars', '4 Stars', '5 Stars'], | |
| datasets: [{ | |
| label: 'Reviews', | |
| data: ratingData, | |
| backgroundColor: '#10b981', | |
| borderRadius: 4 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| scales: { | |
| x: { ticks: { color: '#94a3b8' }, grid: { display: false } }, | |
| y: { ticks: { color: '#94a3b8', stepSize: 1 }, grid: { color: 'rgba(255,255,255,0.05)' } } | |
| }, | |
| plugins: { | |
| legend: { display: false } | |
| } | |
| } | |
| }); | |
| } | |
| } | |
| // Chart 4: Sentiment Category Breakdown (Horizontal Bar) | |
| const categoriesCanvas = $('chart-categories') as HTMLCanvasElement; | |
| if (categoriesCanvas) { | |
| const categoriesCtx = categoriesCanvas.getContext('2d'); | |
| if (categoriesCtx) { | |
| const catLabels = Object.keys(data.category_distribution); | |
| const catCounts = Object.values(data.category_distribution); | |
| charts.categories = new Chart(categoriesCtx, { | |
| type: 'bar', | |
| data: { | |
| labels: catLabels.length ? catLabels : ['None'], | |
| datasets: [{ | |
| label: 'Comments', | |
| data: catCounts.length ? catCounts : [0], | |
| backgroundColor: '#a855f7', | |
| borderRadius: 4 | |
| }] | |
| }, | |
| options: { | |
| indexAxis: 'y', | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| scales: { | |
| x: { ticks: { color: '#94a3b8', stepSize: 1 }, grid: { color: 'rgba(255,255,255,0.05)' } }, | |
| y: { ticks: { color: '#94a3b8' }, grid: { display: false } } | |
| }, | |
| plugins: { | |
| legend: { display: false } | |
| } | |
| } | |
| }); | |
| } | |
| } | |
| } | |
| function connectDashboardWs() { | |
| const wsUrl = `${WS_PROTOCOL}//${BACKEND_HOST}/ws/dashboard`; | |
| const ws = new WebSocket(wsUrl); | |
| ws.onmessage = (event) => { | |
| let msg; | |
| try { msg = JSON.parse(event.data); } catch { return; } | |
| if (msg.type === 'incoming_call') { | |
| startRinging(msg.order_id, msg.agent_type); | |
| } else if (msg.type === 'new_order_placed') { | |
| addSystemMessage(`🛒 Checkout complete! Order placed: ${msg.order_id}`); | |
| loadOrders(); | |
| selectOrder(msg.order_id); | |
| } else if (msg.type === 'refresh_data') { | |
| loadOrders(); | |
| loadFeedback(); | |
| } | |
| }; | |
| ws.onclose = () => { | |
| setTimeout(connectDashboardWs, 3000); | |
| }; | |
| } | |