feat(pos): WhatsApp Business API integration — send/receive messages, quotations

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>
This commit is contained in:
2026-04-04 02:42:17 +00:00
parent 840790a4d0
commit c645bc03f3
9 changed files with 1451 additions and 0 deletions

257
pos/static/js/whatsapp.js Normal file
View File

@@ -0,0 +1,257 @@
/**
* 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' ? '&rarr; ' : '';
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) {}
})();