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 $ = (id: string): T => { const el = document.getElementById(id); if (!el) throw new Error(`Element ${id} not found`); return el as T; }; const ordersList = $('orders-list'); const orderCount = $('order-count'); const selectedOrderInfo = $('selected-order-info'); const infoCustomer = $('info-customer'); const infoOrderId = $('info-order-id'); const infoProduct = $('info-product'); const infoDelivery = $('info-delivery'); const btnStartCall = $('btn-start-call'); const btnEndCall = $('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 = $('btn-whatsapp-fallback'); const callStatusBadge = $('call-status-badge'); const callStatusText = $('call-status-text'); const micIndicator = $('mic-indicator'); const transcript = $('transcript'); const textInput = $('text-input'); const btnSend = $('btn-send'); const serverStatus = $('server-status'); const serverStatusText = $('server-status-text'); const feedbackList = $('feedback-list'); const statTotalCalls = $('stat-total-calls'); const statAvgRating = $('stat-avg-rating'); const btnRefreshFeedback = $('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 = ''; } } function renderOrders() { orderCount.textContent = String(state.orders.length); if (state.orders.length === 0) { ordersList.innerHTML = ''; return; } ordersList.innerHTML = state.orders.map(order => { const isSelected = state.selectedOrderId === order.id; const statusLabel = order.status.replace(/_/g, ' '); return `
${order.id} ${statusLabel}
${escapeHtml(order.customer_name)}
${escapeHtml(order.product_name)}
${order.estimated_delivery_date}
`; }).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 = ' Start Call'; btnStartCall.className = 'btn btn-call'; btnEndCall.innerHTML = ' 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('.order-card').forEach(c => c.style.pointerEvents = 'none'); document.querySelectorAll('.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('.order-card').forEach(c => c.style.pointerEvents = ''); document.querySelectorAll('.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 = `
${roleLabel}
${escapeHtml(text)}
`; 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 = `
${escapeHtml(text)}
`; 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 = ''; 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 ` `; }).join(''); } function renderStars(rating: number) { let html = ''; for (let i = 1; i <= 5; i++) { html += ``; } return html; } function formatAgentType(type: string) { const map: Record = { '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, '"'); } async function triggerWhatsappFallback() { if (!state.selectedOrderId) return; const originalText = btnWhatsappFallback.innerHTML; btnWhatsappFallback.disabled = true; btnWhatsappFallback.innerHTML = ' 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 = ' 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 = ' 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 = ' Incoming Call...'; addSystemMessage(`🔔 Incoming call... (Click 'Answer' to accept or 'Decline' to reject)`); btnStartCall.disabled = false; btnStartCall.innerHTML = ' Answer'; btnStartCall.className = 'btn btn-call btn-green-pulse'; btnEndCall.disabled = false; btnEndCall.innerHTML = ' 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 = ' Start Call'; btnStartCall.className = 'btn btn-call'; btnEndCall.disabled = true; btnEndCall.innerHTML = ' 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); }; }