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:
257
pos/static/js/whatsapp.js
Normal file
257
pos/static/js/whatsapp.js
Normal 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' ? '→ ' : '';
|
||||
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) {}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user