/* ── Socket ──────────────────────────────────────────────────────────────── */
const socket = io({
path: window.location.pathname.replace(/\/$/, '') + '/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 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() {}
/* ── 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(window.location.pathname + '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(window.location.pathname + '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(window.location.pathname + 'api/session'),
fetch(window.location.pathname + '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);
}
})();