Files
whatsapp/modules/turnero/views/chat.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 17703f0157 fix(chat-turnero): prevent messages mixing between contacts on fast switching
Capture activeUserId at the start of each async function (fetchMessages,
loadMoreMessages, pollMessages) and discard the response if the user changed
contacts while the request was in-flight.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 08:19:52 -05:00

968 lines
42 KiB
PHP

<?php
/**
* modules/turnero/views/chat.php
* Inbox del número WhatsApp del Turnero.
* Permite ver y responder mensajes de pacientes que escriben al número turnero.
*/
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
$turneroPhoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', '');
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
<title>Chat Turnero — WhatsApp</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; }
.chat-shell { display: flex; height: 100%; }
/* ── Sidebar ── */
.sidebar {
width: 340px; min-width: 280px; max-width: 380px;
background: #fff; border-right: 1px solid #e9ecef;
display: flex; flex-direction: column;
}
.sidebar-header {
padding: 16px; background: #25d366; color: #fff;
display: flex; align-items: center; gap: 10px;
}
.sidebar-header h2 { font-size: 1rem; flex: 1; }
.sidebar-header .badge-config {
font-size: .7rem; background: rgba(0,0,0,.2); padding: 3px 8px; border-radius: 12px;
}
.search-box { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; }
.search-box input {
width: 100%; padding: 8px 12px; border: 1px solid #e0e0e0;
border-radius: 20px; font-size: .85rem; outline: none;
background: #f5f5f5;
}
.search-box input:focus { border-color: #25d366; background: #fff; }
.contact-list { flex: 1; overflow-y: auto; }
.contact-item {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; cursor: pointer; border-bottom: 1px solid #f7f7f7;
transition: background .15s;
}
.contact-item:hover { background: #f5f5f5; }
.contact-item.active { background: #e7fce8; }
.contact-avatar {
width: 44px; height: 44px; border-radius: 50%;
background: #25d366; color: #fff; display: flex; align-items: center;
justify-content: center; font-weight: 700; font-size: 1.1rem; flex-shrink: 0;
}
.contact-info { flex: 1; min-width: 0; }
.contact-name { font-weight: 600; font-size: .9rem; color: #111; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.contact-last { font-size: .78rem; color: #666; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
.contact-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
.contact-time { font-size: .7rem; color: #aaa; }
.badge-unread {
background: #25d366; color: #fff; font-size: .65rem; font-weight: 700;
padding: 2px 6px; border-radius: 10px; min-width: 18px; text-align: center;
}
.contact-empty { text-align: center; padding: 40px 16px; color: #aaa; font-size: .85rem; }
/* ── Chat window ── */
.chat-window {
flex: 1; display: flex; flex-direction: column;
background: #efeae2;
}
.chat-empty-state {
flex: 1; display: flex; flex-direction: column; align-items: center;
justify-content: center; color: #aaa; gap: 12px;
}
.chat-empty-state i { font-size: 3rem; color: #25d366; }
.chat-empty-state p { font-size: .95rem; }
.chat-topbar {
background: #fff; padding: 12px 16px;
display: flex; align-items: center; gap: 12px;
border-bottom: 1px solid #e9ecef; min-height: 60px;
position: sticky; top: 0; z-index: 10; flex-shrink: 0;
}
.chat-topbar-avatar {
width: 38px; height: 38px; border-radius: 50%;
background: #25d366; color: #fff; display: flex;
align-items: center; justify-content: center; font-weight: 700;
}
.chat-topbar-info h3 { font-size: .95rem; font-weight: 600; }
.chat-topbar-info span { font-size: .78rem; color: #888; }
.messages-area {
flex: 1; overflow-y: auto; padding: 12px 16px;
display: flex; flex-direction: column; gap: 6px;
min-height: 0; -webkit-overflow-scrolling: touch;
}
.msg-bubble {
max-width: 65%; padding: 8px 12px; border-radius: 8px;
font-size: .88rem; line-height: 1.4; position: relative;
word-break: break-word;
}
.msg-bubble.incoming {
background: #fff; align-self: flex-start;
border-bottom-left-radius: 2px; box-shadow: 0 1px 2px rgba(0,0,0,.08);
}
.msg-bubble.outgoing {
background: #d9fdd3; align-self: flex-end;
border-bottom-right-radius: 2px; box-shadow: 0 1px 2px rgba(0,0,0,.08);
}
.msg-time {
font-size: .68rem; color: #aaa; text-align: right; margin-top: 3px;
}
.msg-type-badge {
font-size: .7rem; color: #888; font-style: italic;
}
.msg-media-thumb {
max-width: 220px; max-height: 180px; border-radius: 6px;
display: block; margin-bottom: 4px; cursor: pointer;
}
.msg-media-link {
display: flex; align-items: center; gap: 6px; font-size: .82rem; color: #1a73e8;
text-decoration: none; padding: 4px 0;
}
.msg-media-link:hover { text-decoration: underline; }
.load-more-btn {
align-self: center; padding: 6px 18px; font-size: .8rem;
background: #fff; border: 1px solid #ddd; border-radius: 16px;
cursor: pointer; color: #555;
}
.load-more-btn:hover { background: #f5f5f5; }
/* ── Input area ── */
.chat-input-area {
background: #f0f2f5; padding: 10px 16px;
padding-bottom: max(10px, env(safe-area-inset-bottom));
display: flex; align-items: flex-end; gap: 10px;
border-top: 1px solid #e0e0e0; flex-shrink: 0;
}
.chat-input-area textarea {
flex: 1; padding: 10px 14px; border: none; border-radius: 22px;
background: #fff; font-size: .9rem; resize: none; outline: none;
max-height: 120px; line-height: 1.4;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.btn-send {
width: 42px; height: 42px; border-radius: 50%; border: none;
background: #25d366; color: #fff; font-size: 1.1rem;
cursor: pointer; display: flex; align-items: center; justify-content: center;
flex-shrink: 0; transition: background .2s;
}
.btn-send:hover { background: #1ebe5c; }
.btn-send:disabled { background: #ccc; cursor: default; }
/* ── Alertas ── */
.alert-no-config {
background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px;
padding: 12px 16px; margin: 16px; font-size: .85rem; color: #856404;
display: flex; align-items: center; gap: 8px;
}
.typing-indicator { font-size: .75rem; color: #888; padding: 2px 8px; }
/* ── Botones extra en input ── */
.input-extra-btn {
background: none; border: none; color: #888; font-size: 1.15rem;
cursor: pointer; padding: 6px; border-radius: 50%; transition: background .15s, color .15s;
flex-shrink: 0;
}
.input-extra-btn:hover { background: rgba(0,0,0,.06); color: #555; }
/* ── Panel emoji ── */
.emoji-panel {
position: absolute; bottom: 64px; left: 0;
background: #fff; border: 1px solid #e0e0e0; border-radius: 12px;
box-shadow: 0 6px 24px rgba(0,0,0,.15); padding: 10px;
display: none; flex-wrap: wrap; gap: 2px; width: 280px; z-index: 50;
}
.emoji-panel.show { display: flex; }
.emoji-panel span { font-size: 1.3rem; cursor: pointer; padding: 4px; border-radius: 6px; }
.emoji-panel span:hover { background: #f0f2f5; }
/* ── Reaction picker ── */
#reaction-picker {
position: fixed; display: none; z-index: 9000;
background: #fff; border-radius: 30px; padding: 6px 10px;
box-shadow: 0 4px 20px rgba(0,0,0,.18);
gap: 4px; align-items: center;
}
#reaction-picker.show { display: flex; }
#reaction-picker span { font-size: 1.4rem; cursor: pointer; padding: 4px 6px; border-radius: 50%; transition: transform .1s; }
#reaction-picker span:hover { transform: scale(1.3); }
.msg-bubble { position: relative; }
.msg-bubble:hover .react-btn { opacity: 1; }
.react-btn {
position: absolute; top: 4px; opacity: 0; transition: opacity .15s;
background: #fff; border: 1px solid #e0e0e0; border-radius: 50%;
width: 24px; height: 24px; font-size: .75rem; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 1px 4px rgba(0,0,0,.12);
}
.msg-bubble.incoming .react-btn { right: -28px; }
.msg-bubble.outgoing .react-btn { left: -28px; }
.msg-reaction { font-size: .95rem; margin-top: 2px; }
/* ── Preview adjunto ── */
#attach-preview {
display: none; align-items: center; gap: 8px;
padding: 8px 12px; background: #e9f5fe;
border-top: 1px solid #bee3f8; font-size: .85rem;
}
#attach-preview img { width: 48px; height: 48px; object-fit: cover; border-radius: 6px; }
#attach-preview .attach-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#attach-preview .attach-remove { background: none; border: none; color: #e53e3e; cursor: pointer; font-size: 1rem; }
/* ── Recording indicator ── */
#recording-bar {
display: none; align-items: center; gap: 10px;
padding: 8px 14px; background: #fff0f0; border-top: 1px solid #fed7d7;
font-size: .85rem; color: #e53e3e;
}
#recording-bar .rec-dot { width: 10px; height: 10px; border-radius: 50%; background: #e53e3e; animation: blink 1s infinite; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.2} }
/* ── Modal plantilla ── */
#template-modal {
display: none; position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,.4); align-items: center; justify-content: center;
}
#template-modal.show { display: flex; }
.tpl-card { background: #fff; border-radius: 14px; padding: 24px; width: 340px; box-shadow: 0 8px 32px rgba(0,0,0,.2); }
.tpl-card h3 { font-size: 1rem; font-weight: 700; margin-bottom: 16px; }
.tpl-field { margin-bottom: 12px; }
.tpl-field label { font-size: .8rem; color: #555; font-weight: 600; display: block; margin-bottom: 4px; }
.tpl-field input { width: 100%; padding: 8px 10px; border: 1.5px solid #e0e0e0; border-radius: 8px; font-size: .9rem; outline: none; }
.tpl-field input:focus { border-color: #25d366; }
.tpl-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }
.btn-tpl-cancel { padding: 8px 16px; background: #f5f5f5; border: none; border-radius: 8px; cursor: pointer; font-size: .88rem; }
.btn-tpl-send { padding: 8px 16px; background: #25d366; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-size: .88rem; font-weight: 600; }
/* ── Media en burbuja ── */
.msg-img { max-width: 220px; max-height: 200px; border-radius: 8px; cursor: pointer; display: block; margin-bottom: 3px; }
.msg-video { max-width: 220px; border-radius: 8px; display: block; margin-bottom: 3px; }
.msg-audio { width: 200px; margin-bottom: 3px; }
.msg-doc-link { display: flex; align-items: center; gap: 7px; font-size: .83rem; color: #1a73e8; text-decoration: none; padding: 4px 0; }
.msg-doc-link:hover { text-decoration: underline; }
.back-btn { display: none; background: none; border: none; color: #fff; font-size: 1.1rem; cursor: pointer; padding: 4px 8px; }
@media (max-width: 680px) {
.chat-shell { position: relative; overflow: hidden; }
.sidebar { position: absolute; inset: 0; z-index: 10; transition: transform .2s ease; }
.sidebar.mobile-hidden { transform: translateX(-100%); pointer-events: none; }
.chat-window { position: absolute; inset: 0; z-index: 5; transform: translateX(100%); height: 100%; transition: transform .2s ease; }
.chat-window.mobile-active { transform: translateX(0); }
.back-btn { display: flex; }
}
</style>
</head>
<body>
<div class="chat-shell">
<!-- ── Sidebar: lista de contactos ── -->
<div class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="<?= BASE_URL ?>erp.php?m=turnero&v=dashboard"
style="color:rgba(255,255,255,.7);font-size:.85rem;text-decoration:none;display:flex;align-items:center;gap:5px;margin-bottom:8px">
<i class="fas fa-arrow-left"></i> Menú
</a>
<i class="fab fa-whatsapp" style="font-size:1.4rem"></i>
<h2>Chat Turnero</h2>
<?php if ($turneroPhoneId): ?>
<span class="badge-config" title="Número turnero configurado">
<i class="fas fa-check-circle"></i> Activo
</span>
<?php else: ?>
<span class="badge-config" style="background:rgba(220,53,69,.6)" title="Sin configurar">
<i class="fas fa-exclamation-circle"></i> Sin config
</span>
<?php endif; ?>
</div>
<div class="search-box">
<input type="text" id="searchInput" placeholder="Buscar contacto…" autocomplete="off">
</div>
<div class="contact-list" id="contactList">
<div class="contact-empty"><i class="fas fa-spinner fa-spin"></i><br>Cargando…</div>
</div>
</div>
<!-- ── Ventana de chat ── -->
<div class="chat-window" id="chatWindow">
<?php if (!$turneroPhoneId): ?>
<div class="alert-no-config">
<i class="fas fa-exclamation-triangle"></i>
El número de WhatsApp del Turnero no está configurado.
<a href="/erp.php?m=turnero&v=configuracion" style="margin-left:8px;color:#856404;font-weight:600;">Configurar ahora</a>
</div>
<?php endif; ?>
<div class="chat-empty-state" id="emptyState">
<i class="fab fa-whatsapp"></i>
<p>Selecciona un contacto para ver la conversación</p>
</div>
<div id="activeChat" style="display:none; flex:1; flex-direction:column; overflow:hidden;">
<div class="chat-topbar" id="chatTopbar">
<button class="back-btn" onclick="closeChatMobile()"><i class="fas fa-arrow-left"></i></button>
<div class="chat-topbar-avatar" id="topbarAvatar">?</div>
<div class="chat-topbar-info">
<h3 id="topbarName">—</h3>
<span id="topbarPhone">—</span>
</div>
</div>
<div class="messages-area" id="messagesArea">
<button class="load-more-btn" id="loadMoreBtn" style="display:none" onclick="loadMoreMessages()">
<i class="fas fa-chevron-up"></i> Ver mensajes anteriores
</button>
</div>
<div id="typingIndicator" class="typing-indicator" style="display:none">Escribiendo…</div>
<!-- Preview adjunto -->
<div id="attach-preview">
<img id="attachThumb" src="" alt="" style="display:none">
<span class="attach-name" id="attachName"></span>
<button class="attach-remove" onclick="clearAttach()">✕</button>
</div>
<!-- Barra de grabación -->
<div id="recording-bar">
<div class="rec-dot"></div>
<span>Grabando…</span>
<span id="recTimer" style="font-weight:700;min-width:32px">0:00</span>
<button onclick="stopRecording()" style="margin-left:auto;background:#e53e3e;color:#fff;border:none;border-radius:6px;padding:4px 12px;cursor:pointer;font-size:.82rem">Enviar</button>
<button onclick="cancelRecording()" style="background:#f5f5f5;border:none;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:.82rem">Cancelar</button>
</div>
<input type="file" id="fileInput" style="display:none"
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.csv"
onchange="onFileSelected(event)">
<div class="chat-input-area" style="position:relative">
<div class="emoji-panel" id="emojiPanel"></div>
<button class="input-extra-btn" onclick="toggleEmojiPanel()" title="Emoji"><i class="fas fa-smile"></i></button>
<button class="input-extra-btn" onclick="document.getElementById('fileInput').click()" title="Adjuntar"><i class="fas fa-paperclip"></i></button>
<button class="input-extra-btn" id="micBtn" onclick="toggleRecording()" title="Grabar audio"><i class="fas fa-microphone"></i></button>
<textarea id="msgInput" rows="1" placeholder="Escribe un mensaje…"
onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
<button class="input-extra-btn" onclick="openTemplatePicker()" title="Plantilla"><i class="fas fa-file-alt"></i></button>
<button class="btn-send" id="btnSend" onclick="sendMessage()" <?= $turneroPhoneId ? '' : 'disabled' ?>>
<i class="fas fa-paper-plane"></i>
</button>
</div>
</div>
</div><!-- /chat-window -->
</div><!-- /chat-shell -->
<!-- ── Reaction picker ── -->
<div id="reaction-picker">
<?php foreach (['👍','❤️','😂','😮','😢','🙏','🔥','👏'] as $em): ?>
<span onclick="sendReactionEmoji('<?= $em ?>')"><?= $em ?></span>
<?php endforeach; ?>
</div>
<!-- ── Modal plantilla ── -->
<div id="template-modal">
<div class="tpl-card">
<h3><i class="fas fa-file-alt" style="color:#25d366;margin-right:6px"></i>Enviar Plantilla</h3>
<div class="tpl-field">
<label>Nombre de plantilla</label>
<input type="text" id="tplName" placeholder="ej: consentimiento_turno">
</div>
<div class="tpl-field">
<label>Idioma</label>
<input type="text" id="tplLang" value="es_CO" placeholder="es_CO">
</div>
<div class="tpl-field">
<label>Parámetros (JSON, opcional)</label>
<input type="text" id="tplParams" placeholder='[{"type":"text","text":"valor"}]'>
</div>
<div class="tpl-actions">
<button class="btn-tpl-cancel" onclick="closeTemplatePicker()">Cancelar</button>
<button class="btn-tpl-send" onclick="sendTemplate()">Enviar</button>
</div>
</div>
</div>
<script>
const API_LIST = 'modules/turnero/api/chat_get_list.php';
const API_MESSAGES = 'modules/turnero/api/chat_get_messages.php';
const API_SEND = 'modules/turnero/api/chat_send_message.php';
const API_READ = 'modules/turnero/api/chat_mark_read.php';
const API_MEDIA = 'modules/turnero/api/chat_upload_media.php';
const API_REACT = 'modules/turnero/api/chat_react.php';
const BASE = (function() {
const s = window.location.pathname;
return s.replace(/\/erp\.php.*$/, '/') || '/';
})();
let state = {
contacts: [],
activeUserId: null,
activeUser: null,
messages: [],
earliest: null,
earliestId: null,
hasMore: false,
polling: null,
lastPollTime: null,
};
// ── Init ─────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadContacts();
document.getElementById('searchInput').addEventListener('input', debounce(() => {
loadContacts(document.getElementById('searchInput').value.trim());
}, 300));
setInterval(pollMessages, 4000);
setInterval(() => loadContacts(document.getElementById('searchInput').value.trim(), true), 12000);
});
function debounce(fn, ms) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
// ── Contactos ─────────────────────────────────────────────────────────────────
async function loadContacts(search = '', silent = false) {
const url = BASE + API_LIST + '?limit=100' + (search ? '&search=' + encodeURIComponent(search) : '');
try {
const res = await fetch(url);
const json = await res.json();
if (!json.success) return;
state.contacts = json.data;
renderContacts(json.data);
} catch(e) {
if (!silent) console.error('loadContacts:', e);
}
}
function renderContacts(contacts) {
const el = document.getElementById('contactList');
if (!contacts.length) {
el.innerHTML = '<div class="contact-empty"><i class="fas fa-comment-slash"></i><br>Sin conversaciones aún</div>';
return;
}
el.innerHTML = contacts.map(c => {
const initials = (c.name || c.phone_number).charAt(0).toUpperCase();
const preview = escHtml(c.last_message || '—');
const time = c.last_time ? formatTime(c.last_time) : '';
const unread = c.unread_count > 0 ? `<span class="badge-unread">${c.unread_count}</span>` : '';
const active = state.activeUserId === c.user_id ? ' active' : '';
return `<div class="contact-item${active}" onclick="openChat(${c.user_id})">
<div class="contact-avatar">${initials}</div>
<div class="contact-info">
<div class="contact-name">${escHtml(c.name || c.phone_number)}</div>
<div class="contact-last">${preview}</div>
</div>
<div class="contact-meta">
<span class="contact-time">${time}</span>
${unread}
</div>
</div>`;
}).join('');
}
// ── Abrir chat ────────────────────────────────────────────────────────────────
async function openChat(userId) {
state.activeUserId = userId;
state.messages = [];
state.earliest = null;
state.earliestId = null;
state.hasMore = false;
state.lastPollTime = null;
const contact = state.contacts.find(c => c.user_id === userId);
state.activeUser = contact;
document.getElementById('emptyState').style.display = 'none';
const activeEl = document.getElementById('activeChat');
activeEl.style.display = 'flex';
activeEl.style.flexDirection = 'column';
activeEl.style.overflow = 'hidden';
activeEl.style.flex = '1';
const initials = contact ? (contact.name || contact.phone_number).charAt(0).toUpperCase() : '?';
document.getElementById('topbarAvatar').textContent = initials;
document.getElementById('topbarName').textContent = contact ? (contact.name || contact.phone_number) : '…';
document.getElementById('topbarPhone').textContent = contact ? contact.phone_number : '';
document.querySelectorAll('.contact-item').forEach(el => el.classList.remove('active'));
const items = document.querySelectorAll('.contact-item');
items.forEach(el => { if (el.onclick.toString().includes(`openChat(${userId})`)) el.classList.add('active'); });
const area = document.getElementById('messagesArea');
area.innerHTML = '<div style="text-align:center;padding:20px;color:#aaa"><i class="fas fa-spinner fa-spin"></i></div>';
await fetchMessages();
markRead(userId);
loadContacts(document.getElementById('searchInput').value.trim(), true);
openChatMobile();
}
function isMobile() { return window.innerWidth <= 680; }
function openChatMobile() {
if (!isMobile()) return;
document.getElementById('sidebar').classList.add('mobile-hidden');
document.getElementById('chatWindow').classList.add('mobile-active');
}
function closeChatMobile() {
if (!isMobile()) return;
document.getElementById('sidebar').classList.remove('mobile-hidden');
document.getElementById('chatWindow').classList.remove('mobile-active');
}
// ── Mensajes ──────────────────────────────────────────────────────────────────
async function fetchMessages() {
if (!state.activeUserId) return;
const forUser = state.activeUserId; // captura antes del await
const url = BASE + API_MESSAGES + '?user_id=' + forUser + '&limit=50';
try {
const res = await fetch(url);
const json = await res.json();
if (forUser !== state.activeUserId) return; // usuario cambió mientras esperaba
if (!json.success) return;
state.messages = json.data;
state.hasMore = json.has_more;
state.earliest = json.earliest;
state.earliestId = json.earliest_id;
state.lastPollTime = json.data.length ? json.data[json.data.length - 1].created_at : null;
renderMessages(json.data, false);
} catch(e) { console.error('fetchMessages:', e); }
}
async function loadMoreMessages() {
if (!state.activeUserId || !state.earliest) return;
const forUser = state.activeUserId;
const url = BASE + API_MESSAGES
+ '?user_id=' + forUser
+ '&limit=50'
+ '&before=' + encodeURIComponent(state.earliest)
+ (state.earliestId ? '&before_id=' + state.earliestId : '');
try {
const res = await fetch(url);
const json = await res.json();
if (forUser !== state.activeUserId) return;
if (!json.success || !json.data.length) {
document.getElementById('loadMoreBtn').style.display = 'none';
return;
}
state.messages = [...json.data, ...state.messages];
state.hasMore = json.has_more;
state.earliest = json.earliest;
state.earliestId = json.earliest_id;
prependMessages(json.data);
} catch(e) { console.error('loadMoreMessages:', e); }
}
async function pollMessages() {
if (!state.activeUserId || !state.lastPollTime) return;
const forUser = state.activeUserId;
const sinceTime = state.lastPollTime;
const url = BASE + API_MESSAGES
+ '?user_id=' + forUser
+ '&limit=50'
+ '&since=' + encodeURIComponent(sinceTime);
try {
const res = await fetch(url);
const json = await res.json();
if (forUser !== state.activeUserId) return; // usuario cambió mientras esperaba
if (!json.success || !json.data.length) return;
json.data.forEach(m => {
if (!state.messages.find(x => x.id === m.id)) {
state.messages.push(m);
appendMessage(m);
}
});
state.lastPollTime = json.data[json.data.length - 1].created_at;
markRead(forUser);
loadContacts(document.getElementById('searchInput').value.trim(), true);
} catch(e) {}
}
// ── Render mensajes ───────────────────────────────────────────────────────────
function renderMessages(msgs, prepend) {
const area = document.getElementById('messagesArea');
area.innerHTML = '';
const loadBtn = document.createElement('button');
loadBtn.className = 'load-more-btn';
loadBtn.id = 'loadMoreBtn';
loadBtn.style.display = state.hasMore ? 'block' : 'none';
loadBtn.onclick = loadMoreMessages;
loadBtn.innerHTML = '<i class="fas fa-chevron-up"></i> Ver mensajes anteriores';
area.appendChild(loadBtn);
msgs.forEach(m => area.appendChild(buildBubble(m)));
area.scrollTop = area.scrollHeight;
}
function prependMessages(msgs) {
const area = document.getElementById('messagesArea');
const oldTop = area.scrollHeight - area.scrollTop;
const loadBtn = document.getElementById('loadMoreBtn');
msgs.forEach(m => area.insertBefore(buildBubble(m), loadBtn.nextSibling));
if (!state.hasMore) loadBtn.style.display = 'none';
area.scrollTop = area.scrollHeight - oldTop;
}
function appendMessage(msg) {
const area = document.getElementById('messagesArea');
const bubble = buildBubble(msg);
area.appendChild(bubble);
area.scrollTop = area.scrollHeight;
}
function buildBubble(msg) {
const div = document.createElement('div');
div.className = 'msg-bubble ' + (msg.direction === 'outgoing' ? 'outgoing' : 'incoming');
div.dataset.msgId = msg.id;
div.dataset.waId = msg.message_id || '';
const mediaSrc = msg.local_file ? ('../' + msg.local_file) : (msg.media_url_external || null);
let content = '';
if (msg.message_type === 'image' && mediaSrc) {
content = `<img class="msg-img" src="${escHtml(mediaSrc)}" loading="lazy"
onclick="window.open('${escHtml(mediaSrc)}','_blank')" alt="imagen">`;
if (msg.content) content += `<div style="font-size:.82rem;margin-top:2px">${escHtml(msg.content)}</div>`;
} else if (msg.message_type === 'video' && mediaSrc) {
content = `<video class="msg-video" src="${escHtml(mediaSrc)}" controls preload="metadata"></video>`;
if (msg.content) content += `<div style="font-size:.82rem;margin-top:2px">${escHtml(msg.content)}</div>`;
} else if (msg.message_type === 'audio' && mediaSrc) {
content = `<audio class="msg-audio" src="${escHtml(mediaSrc)}" controls preload="metadata"></audio>`;
} else if (msg.message_type === 'document' && mediaSrc) {
const icon = (msg.mime_type || '').includes('pdf') ? 'fa-file-pdf' :
(msg.mime_type || '').includes('spreadsheet') || (msg.mime_type || '').includes('excel') ? 'fa-file-excel' :
(msg.mime_type || '').includes('word') ? 'fa-file-word' : 'fa-file-alt';
content = `<a class="msg-doc-link" href="${escHtml(mediaSrc)}" target="_blank">
<i class="fas ${icon}" style="font-size:1.3rem"></i>
<span>${escHtml(msg.filename || 'Documento')}</span>
</a>`;
} else {
content = nl2br(escHtml(msg.content || ''));
}
const time = msg.created_at ? formatTimeFull(msg.created_at) : '';
const statusIcon = msg.direction === 'outgoing'
? (msg.status === 'read' ? ' ✓✓' : msg.status === 'delivered' ? ' ✓✓' : ' ✓')
: '';
const reactBtn = msg.message_id
? `<button class="react-btn" onclick="showReactionPicker(event, '${escHtml(msg.message_id)}')">😊</button>`
: '';
div.innerHTML = content
+ `<div class="msg-time">${time}<span style="opacity:.7;font-size:.7rem">${statusIcon}</span></div>`
+ (msg.reaction_emoji ? `<div class="msg-reaction">${escHtml(msg.reaction_emoji)}</div>` : '')
+ reactBtn;
return div;
}
// ── Enviar texto ─────────────────────────────────────────────────────────────
async function sendMessage() {
// Si hay archivo adjunto, enviar como media
if (attachFile) { await sendMedia(); return; }
const input = document.getElementById('msgInput');
const message = input.value.trim();
if (!message || !state.activeUserId) return;
const btn = document.getElementById('btnSend');
btn.disabled = true;
input.value = '';
autoResize(input);
try {
const res = await fetch(BASE + API_SEND, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: state.activeUserId, message })
});
const json = await res.json();
if (!json.success) {
alert('Error al enviar: ' + (json.error || 'desconocido'));
input.value = message;
} else {
await pollMessages();
}
} catch(e) {
alert('Error de red al enviar el mensaje');
input.value = message;
} finally {
btn.disabled = false;
input.focus();
}
}
// ── Adjuntar archivo ──────────────────────────────────────────────────────────
let attachFile = null;
function onFileSelected(e) {
const file = e.target.files[0];
if (!file) return;
attachFile = file;
const prev = document.getElementById('attach-preview');
const thumb = document.getElementById('attachThumb');
const name = document.getElementById('attachName');
name.textContent = file.name + ' (' + (file.size > 1048576 ? (file.size/1048576).toFixed(1) + ' MB' : Math.round(file.size/1024) + ' KB') + ')';
if (file.type.startsWith('image/')) {
thumb.src = URL.createObjectURL(file);
thumb.style.display = 'block';
} else {
thumb.style.display = 'none';
}
prev.style.display = 'flex';
e.target.value = '';
}
function clearAttach() {
attachFile = null;
document.getElementById('attach-preview').style.display = 'none';
document.getElementById('attachThumb').src = '';
}
async function sendMedia() {
if (!attachFile || !state.activeUserId) return;
const btn = document.getElementById('btnSend');
btn.disabled = true;
const caption = document.getElementById('msgInput').value.trim();
const fd = new FormData();
fd.append('user_id', state.activeUserId);
fd.append('file', attachFile);
if (caption) fd.append('caption', caption);
try {
const res = await fetch(BASE + API_MEDIA, { method: 'POST', body: fd });
const json = await res.json();
if (!json.success) {
alert('Error al enviar archivo: ' + (json.error || 'desconocido'));
} else {
clearAttach();
document.getElementById('msgInput').value = '';
autoResize(document.getElementById('msgInput'));
await pollMessages();
}
} catch(e) {
alert('Error de red al enviar el archivo');
} finally {
btn.disabled = false;
}
}
// ── Emoji picker ──────────────────────────────────────────────────────────────
const EMOJI_LIST = ['😀','😂','😍','😎','😢','😡','🤔','👍','👎','❤️','🔥','✅','⭐','🎉','🙏','💪','🤣','😅','😊','🥰','😘','🤗','😴','🤒','😷','💊','🏥','👨‍⚕️','👩‍⚕️','📋','📞','📅','⏰','💉','🩺'];
function toggleEmojiPanel() {
const panel = document.getElementById('emojiPanel');
if (!panel.classList.contains('show')) {
panel.innerHTML = EMOJI_LIST.map(e => `<span onclick="insertEmoji('${e}')">${e}</span>`).join('');
}
panel.classList.toggle('show');
}
function insertEmoji(em) {
const inp = document.getElementById('msgInput');
const pos = inp.selectionStart;
inp.value = inp.value.slice(0, pos) + em + inp.value.slice(pos);
inp.selectionStart = inp.selectionEnd = pos + em.length;
inp.focus();
autoResize(inp);
document.getElementById('emojiPanel').classList.remove('show');
}
document.addEventListener('click', e => {
const panel = document.getElementById('emojiPanel');
if (panel.classList.contains('show') && !e.target.closest('.chat-input-area')) {
panel.classList.remove('show');
}
const rp = document.getElementById('reaction-picker');
if (rp.classList.contains('show') && !e.target.closest('#reaction-picker') && !e.target.closest('.react-btn')) {
rp.classList.remove('show');
}
});
// ── Reacciones ────────────────────────────────────────────────────────────────
let _reactionTarget = null;
function showReactionPicker(e, waId) {
e.stopPropagation();
_reactionTarget = waId;
const rp = document.getElementById('reaction-picker');
rp.classList.add('show');
const rect = e.target.getBoundingClientRect();
rp.style.top = (rect.top - 60) + 'px';
rp.style.left = Math.max(4, rect.left - 80) + 'px';
}
async function sendReactionEmoji(emoji) {
document.getElementById('reaction-picker').classList.remove('show');
if (!_reactionTarget || !state.activeUserId) return;
try {
await fetch(BASE + API_REACT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: state.activeUserId, message_id: _reactionTarget, emoji })
});
await pollMessages();
} catch(e) { console.error('reaction:', e); }
}
// ── Grabación de audio ────────────────────────────────────────────────────────
let mediaRecorder = null, audioChunks = [], recInterval = null, recSeconds = 0;
async function toggleRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') {
stopRecording();
} else {
await startRecording();
}
}
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioChunks = [];
mediaRecorder = new MediaRecorder(stream, { mimeType: MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/ogg' });
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
mediaRecorder.start(100);
recSeconds = 0;
document.getElementById('recording-bar').style.display = 'flex';
document.getElementById('recTimer').textContent = '0:00';
document.getElementById('micBtn').style.color = '#e53e3e';
recInterval = setInterval(() => {
recSeconds++;
const m = Math.floor(recSeconds/60), s = recSeconds%60;
document.getElementById('recTimer').textContent = m + ':' + String(s).padStart(2,'0');
}, 1000);
} catch(e) {
alert('No se pudo acceder al micrófono: ' + e.message);
}
}
async function stopRecording() {
if (!mediaRecorder) return;
clearInterval(recInterval);
document.getElementById('recording-bar').style.display = 'none';
document.getElementById('micBtn').style.color = '';
return new Promise(resolve => {
mediaRecorder.onstop = async () => {
const mime = mediaRecorder.mimeType || 'audio/webm';
const blob = new Blob(audioChunks, { type: mime });
const ext = mime.includes('ogg') ? 'ogg' : 'webm';
const file = new File([blob], `audio_${Date.now()}.${ext}`, { type: mime });
mediaRecorder.stream.getTracks().forEach(t => t.stop());
mediaRecorder = null;
if (!state.activeUserId) { resolve(); return; }
const fd = new FormData();
fd.append('user_id', state.activeUserId);
fd.append('file', file);
fd.append('is_voice', '1');
try {
const res = await fetch(BASE + API_MEDIA, { method: 'POST', body: fd });
const json = await res.json();
if (json.success) await pollMessages();
else alert('Error al enviar audio: ' + (json.error || ''));
} catch(e) { alert('Error de red al enviar audio'); }
resolve();
};
mediaRecorder.stop();
});
}
function cancelRecording() {
if (!mediaRecorder) return;
clearInterval(recInterval);
mediaRecorder.stream.getTracks().forEach(t => t.stop());
mediaRecorder = null;
audioChunks = [];
document.getElementById('recording-bar').style.display = 'none';
document.getElementById('micBtn').style.color = '';
}
// ── Plantilla ─────────────────────────────────────────────────────────────────
function openTemplatePicker() {
if (!state.activeUserId) return;
document.getElementById('template-modal').classList.add('show');
document.getElementById('tplName').focus();
}
function closeTemplatePicker() {
document.getElementById('template-modal').classList.remove('show');
}
async function sendTemplate() {
const name = document.getElementById('tplName').value.trim();
const lang = document.getElementById('tplLang').value.trim() || 'es_CO';
const rawP = document.getElementById('tplParams').value.trim();
if (!name || !state.activeUserId) return alert('Ingresa el nombre de la plantilla');
let params = [];
if (rawP) {
try { params = JSON.parse(rawP); } catch(e) { return alert('Los parámetros no son JSON válido'); }
}
closeTemplatePicker();
try {
const res = await fetch(BASE + API_SEND, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: state.activeUserId, type: 'template', template: name, lang, params })
});
const json = await res.json();
if (!json.success) alert('Error al enviar plantilla: ' + (json.error || ''));
else await pollMessages();
} catch(e) { alert('Error de red al enviar plantilla'); }
}
async function markRead(userId) {
try {
await fetch(BASE + API_READ, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId })
});
} catch(e) {}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function handleKey(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
}
function autoResize(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
}
function escHtml(str) {
return String(str ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function nl2br(str) { return str.replace(/\n/g, '<br>'); }
function formatTime(ts) {
const d = new Date(ts.replace(' ', 'T'));
const now = new Date();
if (d.toDateString() === now.toDateString()) {
return d.toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleDateString('es-CO', { day: '2-digit', month: '2-digit' });
}
function formatTimeFull(ts) {
const d = new Date(ts.replace(' ', 'T'));
return d.toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit' });
}
</script>
</body>
</html>