/* ── Socket ──────────────────────────────────────────────────────────────── */ const socket = io(); /* ── State ───────────────────────────────────────────────────────────────── */ let state = { session: 'disconnected', conversations: [], selectedChatId: null, stats: {}, sending: false, aiLoading: false, demoMode: false }; /* ── DOM Refs ─────────────────────────────────────────────────────────────── */ const $ = id => document.getElementById(id); const loadingScreen = $('loading-screen'); const qrScreen = $('qr-screen'); const degradedScreen = $('degraded-screen'); const dashboard = $('dashboard'); const qrImg = $('qr-img'); const qrOverlay = $('qr-overlay'); const qrConnecting = $('qr-connecting'); const inboxList = $('inbox-list'); const convPlaceholder= $('conv-placeholder'); const convView = $('conv-view'); const messageList = $('message-list'); const composeInput = $('compose-input'); const btnSend = $('btn-send'); const btnAiDraft = $('btn-ai-draft'); const aiDraftBar = $('ai-draft-bar'); const aiDraftStatus = $('ai-draft-status'); const charCount = $('char-count'); const opsPanel = $('ops-panel'); const demoBanner = $('demo-banner'); /* ── Utilities ────────────────────────────────────────────────────────────── */ function formatTime(iso) { const d = new Date(iso); return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } function formatDate(iso) { const d = new Date(iso); const today = new Date(); const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1); if (d.toDateString() === today.toDateString()) return 'Today'; if (d.toDateString() === yesterday.toDateString()) return 'Yesterday'; return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); } function formatRelative(iso) { const diff = Date.now() - new Date(iso).getTime(); if (diff < 60000) return 'just now'; if (diff < 3600000) return `${Math.floor(diff/60000)}m ago`; if (diff < 86400000) return `${Math.floor(diff/3600000)}h ago`; return new Date(iso).toLocaleDateString([], { month: 'short', day: 'numeric' }); } function toast(msg, type = 'green') { const el = document.createElement('div'); el.className = `toast ${type}`; el.innerHTML = `${msg}`; $('toasts').appendChild(el); setTimeout(() => el.remove(), 3200); } /* ── Session Badge Helper ────────────────────────────────────────────────── */ const STATE_BADGE = { connected: { cls: 'status-connected', text: 'Connected' }, awaiting_scan:{ cls: 'status-awaiting', text: 'Awaiting Scan' }, connecting: { cls: 'status-reconnecting', text: 'Connecting' }, reconnecting: { cls: 'status-reconnecting', text: 'Reconnecting' }, degraded: { cls: 'status-degraded', text: 'Degraded' }, disconnected: { cls: 'status-disconnected', text: 'Disconnected' } }; function setBadge(el, sessionStr) { const info = STATE_BADGE[sessionStr] || STATE_BADGE.disconnected; el.className = `status-badge ${info.cls}`; el.innerHTML = `${info.text}`; } /* ── Screen Router ────────────────────────────────────────────────────────── */ function route(sessionStr, qrDataUrl) { loadingScreen.classList.add('hidden'); qrScreen.classList.add('hidden'); degradedScreen.classList.add('hidden'); dashboard.classList.add('hidden'); qrConnecting.classList.add('hidden'); if (sessionStr === 'awaiting_scan' || sessionStr === 'connecting') { qrScreen.classList.remove('hidden'); if (qrDataUrl) { qrImg.src = qrDataUrl; qrOverlay.classList.add('hidden'); } else { qrOverlay.classList.remove('hidden'); } if (sessionStr === 'connecting') { qrConnecting.classList.remove('hidden'); } setBadge($('qr-status-badge'), sessionStr); } else if (sessionStr === 'degraded') { degradedScreen.classList.remove('hidden'); } else if (sessionStr === 'connected') { dashboard.classList.remove('hidden'); } else { // disconnected / reconnecting — show QR screen with no QR qrScreen.classList.remove('hidden'); qrOverlay.classList.remove('hidden'); setBadge($('qr-status-badge'), sessionStr); } } /* ── Stats Update ─────────────────────────────────────────────────────────── */ function updateStats(stats) { state.stats = stats; if (!stats) return; $('ops-inbound-depth').textContent = stats.queueDepth ?? 0; $('ops-outbound-depth').textContent = stats.outboundDepth ?? 0; $('ops-send-success').textContent = stats.sendSuccess ?? 0; $('ops-send-failed').textContent = stats.sendFailed ?? 0; $('ops-reconnects').textContent = stats.reconnectCount ?? 0; $('ops-heartbeat').textContent = stats.lastHeartbeat ? formatRelative(stats.lastHeartbeat) : '—'; const workerBusy = stats.workerStatus === 'busy'; const workerBadge = $('ops-worker-badge'); workerBadge.className = `status-badge ${workerBusy ? 'status-busy' : 'status-idle'}`; workerBadge.innerHTML = `${workerBusy ? 'Processing' : 'Idle'}`; } /* ── Inbox Render ─────────────────────────────────────────────────────────── */ function renderInbox(conversations) { state.conversations = conversations; const q = $('inbox-search').value.toLowerCase(); const filtered = q ? conversations.filter(c => c.contact.toLowerCase().includes(q) || c.lastPreview?.toLowerCase().includes(q)) : conversations; const totalUnread = conversations.reduce((s, c) => s + (c.unreadCount || 0), 0); const badge = $('total-unread-badge'); if (totalUnread > 0) { badge.textContent = totalUnread; badge.classList.remove('hidden'); } else { badge.classList.add('hidden'); } if (!filtered.length) { inboxList.innerHTML = '
No conversations
'; return; } inboxList.innerHTML = filtered.map(conv => `
${conv.avatar || conv.contact.charAt(0).toUpperCase()}
${conv.contact} ${conv.lastActivity ? formatRelative(conv.lastActivity) : ''}
${escHtml(conv.lastPreview || '')} ${conv.unreadCount > 0 ? `${conv.unreadCount > 9 ? '9+' : conv.unreadCount}` : ''}
`).join(''); } /* ── Select Chat ──────────────────────────────────────────────────────────── */ async function selectChat(chatId) { state.selectedChatId = chatId; renderInbox(state.conversations); convPlaceholder.classList.add('hidden'); convView.classList.remove('hidden'); const conv = state.conversations.find(c => c.id === chatId); if (!conv) return; $('conv-avatar').textContent = conv.avatar || conv.contact.charAt(0).toUpperCase(); $('conv-name').textContent = conv.contact; $('conv-phone').textContent = conv.phone?.replace('@c.us', '').replace(/(\d{2})(\d{5})(\d{5})/, '+$1 $2 $3'); renderMessages(conv.messages); // Mark read fetch(`/api/mark-read/${encodeURIComponent(chatId)}`, { method: 'POST' }); if (conv.unreadCount > 0) { conv.unreadCount = 0; renderInbox(state.conversations); } composeInput.focus(); } /* ── Message Render ───────────────────────────────────────────────────────── */ function renderMessages(messages) { if (!messages || !messages.length) { messageList.innerHTML = '
No messages yet
'; return; } let html = ''; let lastDate = ''; messages.forEach(msg => { const d = formatDate(msg.timestamp); if (d !== lastDate) { html += `
${d}
`; lastDate = d; } const fromMe = msg.fromMe; html += `
${escHtml(msg.body)}
${formatTime(msg.timestamp)} ${fromMe ? `${msg.state === 'sent' ? '✓✓' : '✓'}` : ''}
`; }); messageList.innerHTML = html; messageList.scrollTop = messageList.scrollHeight; } function escHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } /* ── Compose Handlers ─────────────────────────────────────────────────────── */ composeInput.addEventListener('input', () => { composeInput.style.height = 'auto'; composeInput.style.height = Math.min(composeInput.scrollHeight, 140) + 'px'; const len = composeInput.value.length; charCount.textContent = len; btnSend.disabled = len === 0 || state.sending; }); composeInput.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); } }); btnSend.addEventListener('click', doSend); async function doSend() { const body = composeInput.value.trim(); if (!body || !state.selectedChatId || state.sending) return; state.sending = true; btnSend.disabled = true; btnSend.innerHTML = '
'; const idempotencyKey = `${state.selectedChatId}-${Date.now()}-${Math.random().toString(36).slice(2)}`; try { const res = await fetch('/api/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chatId: state.selectedChatId, body, idempotencyKey }) }); const data = await res.json(); if (data.queued) { // Optimistic UI — add message immediately const conv = state.conversations.find(c => c.id === state.selectedChatId); if (conv) { const msg = { id: idempotencyKey, body, fromMe: true, timestamp: new Date().toISOString(), state: 'sending' }; conv.messages = conv.messages || []; conv.messages.push(msg); conv.lastPreview = body; conv.lastActivity = msg.timestamp; renderMessages(conv.messages); renderInbox(state.conversations); } composeInput.value = ''; composeInput.style.height = 'auto'; charCount.textContent = '0'; aiDraftBar.classList.add('hidden'); toast('Message queued for sending'); } else if (data.duplicate) { toast('Duplicate send prevented', 'purple'); } } catch { toast('Failed to queue message', 'red'); } state.sending = false; btnSend.innerHTML = ' Send'; btnSend.disabled = composeInput.value.length === 0; } /* ── AI Draft ─────────────────────────────────────────────────────────────── */ btnAiDraft.addEventListener('click', doAiDraft); async function doAiDraft() { if (!state.selectedChatId || state.aiLoading) return; state.aiLoading = true; btnAiDraft.disabled = true; aiDraftBar.classList.remove('hidden'); aiDraftStatus.textContent = 'Generating draft…'; $('ops-ai-badge').className = 'status-badge status-busy'; $('ops-ai-badge').innerHTML = 'Generating'; try { const res = await fetch('/api/ai-draft', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chatId: state.selectedChatId }) }); const data = await res.json(); if (data.draft) { composeInput.value = data.draft; composeInput.style.height = 'auto'; composeInput.style.height = Math.min(composeInput.scrollHeight, 140) + 'px'; charCount.textContent = data.draft.length; btnSend.disabled = false; aiDraftStatus.textContent = 'Draft ready — review before sending'; composeInput.focus(); toast('AI draft generated', 'purple'); } else { aiDraftStatus.textContent = data.error || 'Failed to generate draft'; toast(data.error || 'AI draft failed', 'red'); } } catch { aiDraftStatus.textContent = 'AI service unavailable'; toast('AI service unavailable', 'red'); } state.aiLoading = false; btnAiDraft.disabled = false; $('ops-ai-badge').className = 'status-badge status-idle'; $('ops-ai-badge').innerHTML = 'Ready'; } $('btn-dismiss-draft').addEventListener('click', () => { aiDraftBar.classList.add('hidden'); }); /* ── Socket Events ────────────────────────────────────────────────────────── */ socket.on('session_state', ({ state: s, qrDataUrl, stats }) => { setBadge($('sidebar-session-badge'), s); setBadge($('ops-session-badge'), s); $('sidebar-session-text').textContent = STATE_BADGE[s]?.text || s; updateStats(stats); route(s, qrDataUrl); if (s === 'connected' && state.session !== 'connected') { toast('WhatsApp session connected'); } state.session = s; }); socket.on('inbox_update', (inbox) => { renderInbox(inbox); // If current chat got new messages, refresh view if (state.selectedChatId) { const conv = inbox.find(c => c.id === state.selectedChatId); if (conv) renderMessages(conv.messages); } }); socket.on('new_message', ({ chatId, conversation }) => { // Update conversation in local state const idx = state.conversations.findIndex(c => c.id === chatId); if (idx >= 0) { state.conversations[idx] = conversation; } else { state.conversations.unshift(conversation); } if (chatId === state.selectedChatId) { renderMessages(conversation.messages); } toast(`New message from ${conversation.contact}`, 'green'); }); socket.on('message_state', (job) => { // Update optimistic message state const conv = state.conversations.find(c => c.id === job.chatId); if (!conv) return; const msg = conv.messages.find(m => m.id === job.idempotencyKey); if (msg) { msg.state = job.state; if (job.state === 'sent') msg.timestamp = job.sentAt || msg.timestamp; if (job.chatId === state.selectedChatId) renderMessages(conv.messages); } if (job.state === 'failed') toast(`Message failed: ${job.error}`, 'red'); }); socket.on('stats_update', updateStats); /* ── Inbox Search ─────────────────────────────────────────────────────────── */ $('inbox-search').addEventListener('input', () => renderInbox(state.conversations)); /* ── Ops Panel Toggle ─────────────────────────────────────────────────────── */ $('btn-ops-toggle').addEventListener('click', () => { opsPanel.classList.toggle('open'); }); /* ── Auto-resize compose on load ─────────────────────────────────────────── */ window.addEventListener('resize', () => { if (composeInput.value) { composeInput.style.height = 'auto'; composeInput.style.height = Math.min(composeInput.scrollHeight, 140) + 'px'; } }); /* ── Initial Data Load ────────────────────────────────────────────────────── */ (async () => { try { const [sessionRes, inboxRes] = await Promise.all([ fetch('/api/session'), fetch('/api/inbox') ]); const sessionData = await sessionRes.json(); const inbox = await inboxRes.json(); // Detect demo mode if (sessionData.demoMode || (sessionData.state === 'connected' && inbox.length > 0)) { const firstConv = inbox[0]; if (firstConv?.phone?.includes('@c.us')) { demoBanner.classList.remove('hidden'); state.demoMode = true; } } updateStats(sessionData.stats); setBadge($('sidebar-session-badge'), sessionData.state); setBadge($('ops-session-badge'), sessionData.state); state.session = sessionData.state; renderInbox(inbox); route(sessionData.state, sessionData.qrDataUrl); } catch (err) { console.error('Init error', err); route('disconnected', null); } })();