Add full WhatsApp Cloud API integration for Nexus POS: - Service layer (whatsapp_service.py): send text, templates, quotations, order confirmations, stock alerts; process incoming webhooks with AI auto-reply - Blueprint (whatsapp_bp.py): public webhook endpoints for Meta verification + incoming messages; authenticated endpoints for send, send-quote, conversations - Conversation UI (whatsapp.html + whatsapp.js): split-panel messenger with conversation list, chat bubbles, send input, quote sending; both themes - Migration v1.4: whatsapp_messages table with phone/direction/status indexes - Config: WHATSAPP_TOKEN, WHATSAPP_PHONE_ID, WHATSAPP_VERIFY_TOKEN env vars - Sidebar: WhatsApp nav item under Gestion with message-bubble icon - Ready for Meta Business credentials (infrastructure complete, no API keys needed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
258 lines
9.7 KiB
JavaScript
258 lines
9.7 KiB
JavaScript
/**
|
|
* whatsapp.js — WhatsApp Business conversation UI
|
|
*
|
|
* Left panel: conversation list (phone numbers + last message preview)
|
|
* Right panel: chat view with message bubbles
|
|
* Bottom: text input + send button
|
|
* Toolbar: "Enviar Cotizacion" button
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
var token = localStorage.getItem('pos_token');
|
|
if (!token) { window.location.href = '/pos/login'; return; }
|
|
|
|
var API = '/pos/api/whatsapp';
|
|
var activePhone = null;
|
|
var pollTimer = null;
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
function authHeaders() {
|
|
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
|
}
|
|
|
|
function api(method, path, body) {
|
|
var opts = { method: method, headers: authHeaders() };
|
|
if (body) opts.body = JSON.stringify(body);
|
|
return fetch(API + path, opts).then(function (r) {
|
|
if (r.status === 401) { window.location.href = '/pos/login'; }
|
|
return r.json();
|
|
});
|
|
}
|
|
|
|
function escHtml(s) {
|
|
var d = document.createElement('div');
|
|
d.textContent = s || '';
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function fmtTime(iso) {
|
|
if (!iso) return '';
|
|
var d = new Date(iso);
|
|
var now = new Date();
|
|
var isToday = d.toDateString() === now.toDateString();
|
|
if (isToday) {
|
|
return d.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' }) +
|
|
' ' + d.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
function fmtPhone(phone) {
|
|
if (!phone) return '';
|
|
// Format Mexican numbers nicely: 52 1 55 1234 5678
|
|
if (phone.length === 13 && phone.startsWith('521')) {
|
|
return '+52 1 ' + phone.slice(3, 5) + ' ' + phone.slice(5, 9) + ' ' + phone.slice(9);
|
|
}
|
|
if (phone.length === 12 && phone.startsWith('52')) {
|
|
return '+52 ' + phone.slice(2, 4) + ' ' + phone.slice(4, 8) + ' ' + phone.slice(8);
|
|
}
|
|
return '+' + phone;
|
|
}
|
|
|
|
// ── DOM refs ────────────────────────────────────────────────────────
|
|
|
|
var convList = document.getElementById('convList');
|
|
var chatMessages = document.getElementById('chatMessages');
|
|
var chatHeader = document.getElementById('chatHeaderPhone');
|
|
var chatInput = document.getElementById('chatInput');
|
|
var sendBtn = document.getElementById('sendBtn');
|
|
var newChatBtn = document.getElementById('newChatBtn');
|
|
var emptyState = document.getElementById('emptyState');
|
|
var chatPanel = document.getElementById('chatPanel');
|
|
var statusDot = document.getElementById('statusDot');
|
|
var statusText = document.getElementById('statusText');
|
|
|
|
// ── Load conversations ──────────────────────────────────────────────
|
|
|
|
function loadConversations() {
|
|
api('GET', '/conversations').then(function (data) {
|
|
var convs = data.conversations || [];
|
|
if (convs.length === 0) {
|
|
convList.innerHTML = '<div class="conv-empty">No hay conversaciones</div>';
|
|
return;
|
|
}
|
|
var html = '';
|
|
convs.forEach(function (c) {
|
|
var isActive = c.phone === activePhone;
|
|
var dirIcon = c.last_direction === 'outgoing' ? '→ ' : '';
|
|
html += '<div class="conv-item' + (isActive ? ' is-active' : '') + '" data-phone="' + escHtml(c.phone) + '">'
|
|
+ '<div class="conv-item__phone">' + escHtml(fmtPhone(c.phone)) + '</div>'
|
|
+ '<div class="conv-item__preview">' + dirIcon + escHtml(c.last_message) + '</div>'
|
|
+ '<div class="conv-item__time">' + fmtTime(c.last_at) + '</div>'
|
|
+ '</div>';
|
|
});
|
|
convList.innerHTML = html;
|
|
|
|
// Click handlers
|
|
convList.querySelectorAll('.conv-item').forEach(function (el) {
|
|
el.addEventListener('click', function () {
|
|
openConversation(el.getAttribute('data-phone'));
|
|
});
|
|
});
|
|
}).catch(function () {
|
|
convList.innerHTML = '<div class="conv-empty">Error cargando conversaciones</div>';
|
|
});
|
|
}
|
|
|
|
// ── Open a conversation ─────────────────────────────────────────────
|
|
|
|
function openConversation(phone) {
|
|
activePhone = phone;
|
|
chatHeader.textContent = fmtPhone(phone);
|
|
emptyState.style.display = 'none';
|
|
chatPanel.style.display = 'flex';
|
|
|
|
// Highlight in list
|
|
convList.querySelectorAll('.conv-item').forEach(function (el) {
|
|
el.classList.toggle('is-active', el.getAttribute('data-phone') === phone);
|
|
});
|
|
|
|
loadMessages(phone);
|
|
startPolling();
|
|
}
|
|
|
|
function loadMessages(phone) {
|
|
api('GET', '/conversations/' + encodeURIComponent(phone)).then(function (data) {
|
|
var msgs = data.messages || [];
|
|
renderMessages(msgs);
|
|
});
|
|
}
|
|
|
|
function renderMessages(msgs) {
|
|
var html = '';
|
|
msgs.forEach(function (m) {
|
|
var cls = m.direction === 'outgoing' ? 'msg-bubble--out' : 'msg-bubble--in';
|
|
var statusBadge = '';
|
|
if (m.direction === 'outgoing' && m.status) {
|
|
statusBadge = '<span class="msg-status">' + escHtml(m.status) + '</span>';
|
|
}
|
|
html += '<div class="msg-bubble ' + cls + '">'
|
|
+ '<div class="msg-bubble__text">' + escHtml(m.message_text).replace(/\n/g, '<br>') + '</div>'
|
|
+ '<div class="msg-bubble__meta">' + fmtTime(m.created_at) + ' ' + statusBadge + '</div>'
|
|
+ '</div>';
|
|
});
|
|
chatMessages.innerHTML = html || '<div class="chat-empty">Sin mensajes</div>';
|
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
|
}
|
|
|
|
// ── Send message ────────────────────────────────────────────────────
|
|
|
|
function doSend() {
|
|
var text = chatInput.value.trim();
|
|
if (!text || !activePhone) return;
|
|
|
|
chatInput.value = '';
|
|
sendBtn.disabled = true;
|
|
|
|
api('POST', '/send', { phone: activePhone, message: text }).then(function (res) {
|
|
sendBtn.disabled = false;
|
|
if (res.error) {
|
|
alert('Error: ' + res.error);
|
|
} else {
|
|
loadMessages(activePhone);
|
|
loadConversations();
|
|
}
|
|
}).catch(function () {
|
|
sendBtn.disabled = false;
|
|
alert('Error de red al enviar mensaje');
|
|
});
|
|
}
|
|
|
|
sendBtn.addEventListener('click', doSend);
|
|
chatInput.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
doSend();
|
|
}
|
|
});
|
|
|
|
// ── New conversation ────────────────────────────────────────────────
|
|
|
|
newChatBtn.addEventListener('click', function () {
|
|
var phone = prompt('Numero de telefono (formato: 5215512345678):');
|
|
if (phone) {
|
|
phone = phone.replace(/[\s\-\+\(\)]/g, '');
|
|
openConversation(phone);
|
|
loadConversations();
|
|
}
|
|
});
|
|
|
|
// ── Send quotation modal ────────────────────────────────────────────
|
|
|
|
var quoteBtn = document.getElementById('sendQuoteBtn');
|
|
if (quoteBtn) {
|
|
quoteBtn.addEventListener('click', function () {
|
|
if (!activePhone) { alert('Selecciona una conversacion primero'); return; }
|
|
var quoteId = prompt('ID de la cotizacion a enviar:');
|
|
if (!quoteId) return;
|
|
api('POST', '/send-quote/' + quoteId, { phone: activePhone }).then(function (res) {
|
|
if (res.error) {
|
|
alert('Error: ' + res.error);
|
|
} else {
|
|
loadMessages(activePhone);
|
|
loadConversations();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Polling for new messages ────────────────────────────────────────
|
|
|
|
function startPolling() {
|
|
if (pollTimer) clearInterval(pollTimer);
|
|
pollTimer = setInterval(function () {
|
|
if (activePhone) loadMessages(activePhone);
|
|
loadConversations();
|
|
}, 10000); // every 10s
|
|
}
|
|
|
|
// ── Connection status indicator ─────────────────────────────────────
|
|
|
|
function checkStatus() {
|
|
// Check if we can reach the API (proxy for "connected")
|
|
fetch(API + '/conversations', { headers: authHeaders() })
|
|
.then(function (r) {
|
|
if (r.ok) {
|
|
statusDot.className = 'status-dot status-dot--ok';
|
|
statusText.textContent = 'Conectado';
|
|
} else {
|
|
statusDot.className = 'status-dot status-dot--warn';
|
|
statusText.textContent = 'Sin credenciales';
|
|
}
|
|
})
|
|
.catch(function () {
|
|
statusDot.className = 'status-dot status-dot--error';
|
|
statusText.textContent = 'Desconectado';
|
|
});
|
|
}
|
|
|
|
// ── Init ────────────────────────────────────────────────────────────
|
|
|
|
loadConversations();
|
|
checkStatus();
|
|
setInterval(checkStatus, 30000);
|
|
|
|
// ── User info for sidebar ───────────────────────────────────────────
|
|
try {
|
|
var payload = JSON.parse(atob(token.split('.')[1]));
|
|
window.POS_USER = {
|
|
name: payload.name || 'Usuario',
|
|
roleLabel: (payload.role || '').charAt(0).toUpperCase() + (payload.role || '').slice(1),
|
|
initials: (payload.name || 'U').split(' ').map(function(w){return w[0]}).join('').slice(0,2).toUpperCase()
|
|
};
|
|
} catch(e) {}
|
|
|
|
})();
|