Files
whatsapp/conversations.php
T

2378 lines
109 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>💬 Conversaciones - WhatsApp Bot</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
:root {
--whatsapp-green: #25d366;
--whatsapp-green-dark: #128c7e;
--whatsapp-light: #dcf8c6;
--whatsapp-dark: #075e54;
--message-bg: #f0f0f0;
--sidebar-bg: #f8f9fa;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
background: linear-gradient(135deg, #f0f4f7 0%, #e5ddd5 100%);
margin: 0;
min-height: 100vh;
overflow: hidden;
color: #222;
font-size: 14px;
}
.chat-container {
display: flex;
height: 100vh;
background: transparent;
gap: 12px;
padding: 12px;
}
.chat-sidebar {
width: 360px;
background: var(--sidebar-bg);
border-radius: 8px;
border: 1px solid #e9eef2;
display: flex;
flex-direction: column;
box-shadow: 0 4px 14px rgba(16,24,40,0.04);
overflow: hidden;
}
.sidebar-header {
padding: 14px 16px;
background: linear-gradient(180deg, var(--whatsapp-dark) 0%, #054f45 100%);
color: white;
display: flex;
align-items: center;
justify-content: space-between;
}
.sidebar-search {
padding: 10px 12px;
background: white;
border-bottom: 1px solid #eef2f5;
}
.conversation-list {
flex: 1;
overflow-y: auto;
padding: 6px;
}
.conversation-item {
padding: 12px 14px;
border-bottom: 1px solid #f1f5f8;
cursor: pointer;
transition: background-color 0.12s, transform 0.08s;
display: flex;
align-items: center;
}
.conversation-item:hover {
background: #f6fbff;
transform: translateY(-1px);
}
.conversation-item.active {
background: #e9fbf1;
border-right: 3px solid var(--whatsapp-green);
}
.conversation-avatar {
width: 46px;
height: 46px;
border-radius: 50%;
background: var(--whatsapp-green);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
margin-right: 12px;
flex-shrink: 0;
}
.conversation-info {
flex: 1;
min-width: 0;
}
.conversation-name {
font-weight: 700;
font-size: 15px;
color: #262626;
margin-bottom: 4px;
}
.conversation-preview {
color: #6b7280;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conversation-time {
color: #9aa4af;
font-size: 12px;
text-align: right;
flex-shrink: 0;
margin-left: 10px;
}
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
background: transparent;
}
.chat-header {
padding: 12px 18px;
background: linear-gradient(180deg, var(--whatsapp-dark) 0%, #0a5347 100%);
color: white;
border-radius: 8px;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 2px 6px rgba(15,23,42,0.06);
}
.chat-header-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--whatsapp-green);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
margin-right: 12px;
}
.chat-header-info h6 { margin: 0; font-size: 15px; }
.chat-header-info small { color: rgba(255,255,255,0.85); }
.chat-conversations {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
background: #f8f9fb;
border: 1px solid #eef2f5;
border-radius: 0 0 8px 8px;
}
.message {
width: 100%;
margin-bottom: 8px;
display: flex;
align-items: flex-end;
}
.message.outgoing {
justify-content: flex-end;
}
.message.incoming {
justify-content: flex-start;
}
.message-bubble {
padding: 8px 12px;
border-radius: 18px;
position: relative;
word-wrap: break-word;
box-shadow: 0 3px 10px rgba(2,6,23,0.06);
max-width: 72%;
line-height: 1.35;
transition: transform 0.08s ease, box-shadow 0.12s ease;
}
.message.outgoing .message-bubble {
background: var(--whatsapp-light);
color: #222;
border-bottom-right-radius: 6px;
}
.message.incoming .message-bubble {
background: white;
color: #222;
border-bottom-left-radius: 6px;
}
/* Cola tipo WhatsApp */
.message.outgoing .message-bubble::after {
content: '';
position: absolute;
right: -6px;
bottom: 6px;
width: 12px;
height: 12px;
background: var(--whatsapp-light);
transform: rotate(45deg);
z-index: 0;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.message.incoming .message-bubble::after {
content: '';
position: absolute;
left: -6px;
bottom: 6px;
width: 12px;
height: 12px;
background: white;
transform: rotate(45deg);
z-index: 0;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.message-bubble .message-time {
position: absolute;
right: 8px;
bottom: 4px;
font-size: 10px;
color: #666;
opacity: 0;
transition: opacity 0.12s ease-in-out;
white-space: nowrap;
}
.message-bubble:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(2,6,23,0.06);
}
.message-bubble:hover .message-time,
.message-bubble:focus-within .message-time {
opacity: 1;
}
.message-status {
display: inline-block;
margin-left: 5px;
font-size: 12px;
}
.status-sent { color: #999; }
.status-delivered { color: #999; }
.status-read { color: var(--whatsapp-green); }
.chat-input {
padding: 15px 20px;
background: #f0f0f0;
border-top: 1px solid #ddd;
display: flex;
align-items: center;
gap: 10px;
}
.chat-input input[type="text"] {
flex: 1;
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 25px;
outline: none;
font-size: 14px;
}
.chat-input input:focus {
border-color: var(--whatsapp-green);
}
.chat-input .btn {
border-radius: 50%;
width: 45px;
height: 45px;
display: flex;
align-items: center;
justify-content: center;
background: var(--whatsapp-green);
border: none;
color: white;
}
.chat-input .btn:hover {
background: var(--whatsapp-green-dark);
}
#attach-btn {
background: #075E54;
}
#attach-btn:hover {
background: #128C7E;
}
/* Estilos para mensajes multimedia */
.message-media {
max-width: 300px;
border-radius: 8px;
overflow: hidden;
margin-bottom: 5px;
}
.message-media img,
.message-media video {
width: 100%;
display: block;
cursor: pointer;
}
.message-media audio {
width: 100%;
}
.message-document {
background: rgba(0,0,0,0.05);
padding: 10px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.message-document:hover {
background: rgba(0,0,0,0.1);
}
.message-document i {
font-size: 24px;
color: var(--whatsapp-green);
}
.document-info {
flex: 1;
}
.document-name {
font-weight: 500;
font-size: 14px;
}
.document-size {
font-size: 12px;
color: #666;
}
#media-preview {
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.upload-progress {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--whatsapp-green);
transform-origin: left;
transition: transform 0.3s;
}
.no-conversation {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
text-align: center;
}
.no-conversation i {
font-size: 64px;
margin-bottom: 20px;
opacity: 0.3;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
/* Notification toasts (compact by default) */
#notification-toasts {
position: fixed;
top: 12px;
right: 12px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
pointer-events: none;
}
.notification-toast {
pointer-events: auto;
background: #fff;
color: #222;
padding: 8px 10px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
display: flex;
align-items: center;
gap: 10px;
min-width: 160px;
max-width: 260px;
font-size: 13px;
}
.notification-toast.small {
padding: 6px 8px;
font-size: 12px;
min-width: 140px;
max-width: 220px;
}
.notification-toast.cool {
background: linear-gradient(90deg,#6a11cb,#2575fc);
color: white;
box-shadow: 0 6px 16px rgba(37,117,252,0.28);
transform-origin: right;
animation: pop 320ms ease;
}
@keyframes pop { from { transform: translateY(-6px) scale(0.98); opacity:0 } to { transform: translateY(0) scale(1); opacity:1 } }
.notification-toast .nt-icon { font-size: 16px; margin-right: 6px; opacity: 0.95; }
.notification-toast .nt-actions { margin-left: auto; display:flex; gap:6px; }
.notification-toast .btn { font-size: 12px; padding: 4px 8px; }
/* UI improvements for chat area */
.quick-replies .btn {
border-radius: 20px;
padding: 6px 10px;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Quick replies compact panel */
.quick-replies-wrapper { position: relative; }
.quick-replies-panel {
display: none;
background: #fff;
border: 1px solid #e6eef5;
box-shadow: 0 8px 20px rgba(2,6,23,0.06);
padding: 8px;
border-radius: 8px;
max-height: 220px;
overflow-y: auto;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.quick-replies-panel .btn { min-width: 80px; max-width: 180px; font-size: 12px; }
#quick-replies-toggle { background: transparent; border-radius: 20px; }
@media (max-width: 768px) {
.quick-replies-panel { left: 8px; right: 8px; bottom: 56px; min-width: auto; }
}
/* Make mic button more visible */
#mic-btn {
margin-left: 6px;
background: var(--whatsapp-green);
border: none;
color: white;
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(3, 102, 80, 0.12);
}
#mic-btn.recording { background: #c0392b; color: white; }
#mic-btn i { font-size: 14px; }
.chat-conversations {
background-repeat: repeat;
background-color: #e9efe9;
padding-bottom: 20px; /* space for reply preview and input */
}
.message-bubble { transition: transform 0.12s ease, box-shadow 0.12s ease; }
.message-bubble:hover { transform: translateY(-1px); box-shadow: 0 4px 10px rgba(0,0,0,0.06); }
#reply-preview { box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
#reply-preview button { border: none; color: #888; }
#reply-preview button:hover { color: #333; }
.message.incoming .message-bubble::before,
.message.outgoing .message-bubble::after { content: ''; }
.notification-toast.urgent { border-left: 4px solid #e74c3c; }
.message-template {
background: #fffacd;
border: 1px solid #f0e68c;
padding: 10px;
border-radius: 8px;
margin-bottom: 10px;
font-size: 13px;
}
.unread-count {
background: var(--whatsapp-green);
color: white;
border-radius: 50%;
font-size: 12px;
min-width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
}
@media (max-width: 768px) {
.chat-sidebar {
width: 100%;
position: absolute;
z-index: 1000;
height: 100vh;
}
.chat-main {
width: 100%;
}
.chat-sidebar.show-chat {
transform: translateX(-100%);
}
}
.reply-preview {
background: #f4f6f8;
border-left: 3px solid #d0d7de;
padding: 6px 8px;
margin-bottom: 6px;
font-size: 12px;
color: #555;
border-radius: 4px;
}
.reaction-badge {
display: inline-block;
background: rgba(0,0,0,0.06);
padding: 2px 6px;
border-radius: 12px;
font-size: 12px;
margin-top: 8px;
}
.conversation-avatar img, .conversation-avatar-img { max-width: 44px; max-height:44px; border-radius:50%; }
.conversation-avatar-wrapper { width:48px; display:flex; align-items:center; justify-content:center }
.conversation-item.unread { background: rgba(255, 248, 220, 0.9); }
.unread-badge { margin-left: 8px; font-size: 12px; }
</style>
</head>
<body>
<div class="chat-container">
<!-- Sidebar con lista de conversaciones -->
<div class="chat-sidebar">
<div class="sidebar-header">
<div>
<h5 class="mb-0">💬 Conversaciones</h5>
</div>
<div>
<a href="index.php" class="text-white text-decoration-none">
<i class="fas fa-arrow-left"></i>
</a>
</div>
</div>
<div class="sidebar-search">
<div class="input-group">
<span class="input-group-text bg-transparent border-0">
<i class="fas fa-search text-muted"></i>
</span>
<input type="text" class="form-control border-0" placeholder="Buscar conversaciones..." id="search-input">
</div>
</div>
<!-- Filtro de conversaciones: Todos / No leídos -->
<div style="padding:8px 12px; display:flex; gap:8px; align-items:center;">
<select id="conversation-filter" class="form-select form-select-sm" style="width:auto;">
<option value="all">Todos</option>
<option value="unread">No leídos</option>
</select>
<small class="text-muted">Mostrar sólo conversaciones con mensajes no leídos</small>
</div>
<div class="conversation-list" id="conversation-list">
<div class="loading">
<i class="fas fa-spinner fa-spin"></i>
<div>Cargando conversaciones...</div>
</div>
</div>
<div id="load-more-container" style="padding:10px; text-align:center; display:none;">
<button id="load-more-btn" class="btn btn-sm btn-outline-primary">Cargar más</button>
</div>
</div>
<!-- Área principal del chat -->
<div class="chat-main">
<div id="no-conversation" class="no-conversation">
<i class="fab fa-whatsapp"></i>
<h4>WhatsApp Bot Manager</h4>
<p>Selecciona una conversación para comenzar a chatear</p>
</div>
<div id="chat-area" style="display: none; height: 100%; flex-direction: column;">
<div class="chat-header">
<div class="chat-header-avatar" id="chat-avatar">
<i class="fas fa-user"></i>
</div>
<div class="chat-header-info">
<h6 id="chat-name"><span id="chat-name-text">Usuario</span> <button class="btn btn-sm btn-link" id="edit-user-btn" title="Editar usuario"><i class="fas fa-user-edit"></i></button>
<span id="hold-indicator" style="display:none; margin-left:8px; color:#b85; font-weight:600">EN ESPERA</span>
</h6>
<small id="chat-phone">+1234567890</small>
<div style="font-size:12px; margin-top:4px;">
<button id="attend-toggle-btn" class="btn btn-sm btn-outline-light" style="display:none; margin-right:6px;">Atender</button>
<small id="attend-status" class="text-white me-3" style="font-size:0.9rem;"></small>
<button id="release-hold-btn" class="btn btn-sm btn-outline-primary" style="display:none">Liberar espera</button>
</div>
</div>
<div style="margin-left: auto; display:flex; gap:8px; align-items:center;">
<div id="bot-toggle-container" style="display:flex;align-items:center;gap:6px;margin-right:8px;">
<button class="btn btn-sm btn-outline-secondary" id="bot-toggle">Bot: On</button>
</div>
<button class="btn btn-sm btn-outline-secondary" id="mark-unread-btn" title="Marcar conversación como no leída" style="display:none;">Marcar no leído</button>
<button class="btn btn-sm btn-outline-danger" id="delete-conversation-btn" title="Eliminar conversación"><i class="fas fa-trash"></i></button>
</div>
</div>
<div class="chat-conversations" id="chat-conversations">
<!-- Los mensajes se cargan aquí -->
</div>
<!-- Preview de archivo multimedia -->
<div id="media-preview" style="display: none; padding: 10px; background: #f0f0f0; border-top: 1px solid #ddd;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 10px;">
<img id="preview-thumbnail" style="max-width: 60px; max-height: 60px; border-radius: 5px; display: none;">
<div>
<div id="preview-filename" style="font-weight: bold; font-size: 14px;"></div>
<div id="preview-filesize" style="font-size: 12px; color: #666;"></div>
</div>
</div>
<button class="btn btn-sm btn-danger" onclick="cancelMediaUpload()">
<i class="fas fa-times"></i>
</button>
</div>
<input type="text" id="media-caption" class="form-control mt-2" placeholder="Agregar un comentario (opcional)" maxlength="1024">
</div>
<div id="reply-preview" class="reply-preview" style="display:none; align-items:center; justify-content:space-between;">
<div style="flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" id="reply-preview-text">En respuesta a: (selecciona un mensaje)</div>
<button class="btn btn-sm btn-link" id="cancel-reply-btn" title="Cancelar respuesta"></button>
</div>
<div class="chat-input">
<input type="file" id="file-input" accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx" style="display: none;">
<button class="btn" id="attach-btn" title="Adjuntar archivo">
<i class="fas fa-paperclip"></i>
</button>
<div style="display:flex; gap:8px; align-items:center;">
<select id="message-type" class="form-select form-select-sm" style="width:auto; margin-right:8px;">
<option value="text">Texto</option>
<option value="template">Plantilla</option>
</select>
<div id="templateSelectContainer" style="display:none; margin-right:8px;">
<select id="templateSelect" class="form-select form-select-sm">
<option value="">Seleccionar plantilla...</option>
</select>
</div>
<div class="quick-replies-wrapper" style="position:relative;">
<button id="quick-replies-toggle" class="btn btn-sm btn-outline-secondary" title="Respuestas rápidas" aria-expanded="false">💡 Respuestas</button>
<div id="quick-replies-panel" class="quick-replies-panel" style="display:none; position:absolute; bottom:48px; left:0; z-index:1200; min-width:220px; max-width:420px;">
<!-- Quick replies buttons inserted dynamically -->
</div>
</div>
<button class="btn" id="mic-btn" title="Grabar audio" style="margin-left:6px; background:#f8f9fa; border:1px solid #ddd;">
<i class="fas fa-microphone"></i>
</button>
</div>
<div id="recording-indicator" style="display:none; padding:8px; background:#fff3cd; border-radius:6px; margin-top:8px;">
<i class="fas fa-microphone text-danger"></i> Grabando... <span id="recording-time">0:00</span>
<button class="btn btn-sm btn-danger" id="stop-recording" style="margin-left:8px;">Detener</button>
<button class="btn btn-sm btn-secondary" id="cancel-recording" style="margin-left:6px;">Cancelar</button>
</div>
<input type="text" placeholder="Escribe un mensaje..." id="message-input" maxlength="4096">
<button class="btn" id="send-btn">
<i class="fas fa-paper-plane"></i>
</button>
</div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/js/bootstrap.bundle.min.js"></script>
<?php $asset_v = file_exists(__DIR__ . '/assets/js/chat-common.js') ? filemtime(__DIR__ . '/assets/js/chat-common.js') : time(); ?>
<script src="assets/js/chat-common.js?v=<?php echo $asset_v; ?>"></script>
<script>
// Fallback ligero para showAlert (si no existe una implementación global)
if (typeof showAlert === 'undefined') {
function showAlert(message, type = 'info') {
try {
let container = document.getElementById('notification-toasts');
if (!container) {
container = document.createElement('div');
container.id = 'notification-toasts';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = 'notification-toast small' + (type === 'success' ? ' cool' : '');
const icon = type === 'success' ? '✅' : (type === 'danger' ? '⚠️' : (type === 'warning' ? '⚠' : '️'));
toast.innerHTML = `<span class="nt-icon">${icon}</span><div style="flex:1; font-size:13px">${message}</div>`;
container.appendChild(toast);
// Ensure a sensible minimum duration (3s) so very short toasts don't disappear instantly
const _minToastDuration = 3000;
let _dur = (type === 'success') ? 4000 : 8000;
_dur = Math.max(_dur, _minToastDuration);
setTimeout(() => { if (toast.parentNode) toast.remove(); }, _dur);
} catch (e) {
// fallback to alert if DOM fails
try { alert(message); } catch (e2) { /* ignore */ }
}
}
}
class WhatsAppChat {
constructor() {
this.currentConversationId = null;
this.currentUserId = null;
this.conversations = [];
// Track shown notifications to avoid duplicates from polling
this._shownNotifications = new Set();
// Message pagination / loading state
this.messageLimit = 50;
this.loadingMessages = false;
this.hasMoreMessages = false;
this.earliestMessage = null; // timestamp of earliest loaded message
// Filter state: 'all' or 'unread'
this.conversationFilter = 'all';
// Pagination state for conversation list
this.conversationsPage = 1;
this.conversationsLimit = 50;
this.hasMoreConversations = true;
this.loadingConversations = false;
this.init();
}
init() {
this.loadConversations();
this.setupEventListeners();
this.setupAutoRefresh();
this.setupNotificationPolling();
}
setupNotificationPolling() {
// Poll every 7 seconds for unread notifications
this.loadNotifications();
setInterval(() => this.loadNotifications(), 7000);
}
async loadNotifications() {
try {
const resp = await fetch('api/get_notifications.php', { credentials: 'same-origin' });
if (resp.status === 401) {
console.warn('Notifications fetch: unauthorized (session may have expired)');
return;
}
const json = await resp.json();
console.debug('loadNotifications response:', json);
if (json && json.success && Array.isArray(json.data)) {
json.data.forEach(n => this.showNotificationToast(n));
} else if (json && json.success === false) {
console.warn('get_notifications returned error:', json.error || json);
}
} catch (e) {
console.error('Error loading notifications', e);
}
}
async showNotificationToast(notification) {
// Create or reuse container
const containerId = 'notification-toasts';
let container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
container.id = containerId;
document.body.appendChild(container);
}
// Determine an id to dedupe notifications (prefer server id)
const nid = notification && notification.id ? String(notification.id) : ('msg:' + String((notification && notification.message) || '').slice(0,200));
if (this._shownNotifications.has(nid)) {
// already shown
console.debug('Notification already shown, skipping', nid);
return;
}
this._shownNotifications.add(nid);
const toast = document.createElement('div');
// compact by default
toast.className = 'notification-toast small';
if (notification.cool || notification.type === 'cool') toast.classList.add('cool');
if (notification.level === 'urgent' || notification.urgent) toast.classList.add('urgent');
const iconSpan = document.createElement('span');
iconSpan.className = 'nt-icon';
iconSpan.textContent = notification.icon || (notification.cool ? '✨' : (notification.level === 'urgent' ? '⚠️' : '🔔'));
toast.appendChild(iconSpan);
const msg = document.createElement('div');
msg.style.flex = '1';
msg.style.fontSize = '13px';
msg.style.lineHeight = '1.2';
msg.textContent = notification.message || 'Nuevo evento';
toast.appendChild(msg);
const actions = document.createElement('div');
actions.className = 'nt-actions';
const removeToast = () => {
if (toast.parentNode) toast.remove();
if (this._shownNotifications.has(nid)) this._shownNotifications.delete(nid);
};
const openBtn = document.createElement('button');
openBtn.className = notification.cool ? 'btn btn-sm btn-light' : 'btn btn-sm btn-primary';
openBtn.textContent = 'Abrir';
openBtn.onclick = async () => {
try {
await fetch('api/mark_notification_read.php', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id: notification.id})});
} catch(e) { console.warn('mark_notification_read failed', e); }
let data = {};
try { data = notification.data ? JSON.parse(notification.data) : {}; } catch(e) {}
const userId = data.user_id || notification.user_id;
if (userId) {
const conv = this.conversations.find(c => c.user_id == userId);
if (conv) {
this.openConversation(conv.user_id, conv.name, conv.phone_number);
} else {
this.openConversation(userId, 'Usuario', '');
}
}
removeToast();
};
const dismiss = document.createElement('button');
dismiss.className = 'btn btn-sm btn-outline-secondary';
dismiss.textContent = '×';
dismiss.title = 'Descartar';
dismiss.onclick = async () => {
try {
await fetch('api/mark_notification_read.php', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id: notification.id})});
} catch(e) { console.warn('mark_notification_read failed', e); }
removeToast();
};
actions.appendChild(openBtn);
actions.appendChild(dismiss);
toast.appendChild(actions);
container.appendChild(toast);
// Auto remove (shorter for compact notifications) with minimum enforced
const _minToastDuration = 3000;
let timeout = notification.duration ? Number(notification.duration) : (notification.cool ? 15000 : 8000);
if (!isFinite(timeout) || timeout <= 0) timeout = (notification.cool ? 15000 : 8000);
timeout = Math.max(timeout, _minToastDuration);
setTimeout(removeToast, timeout);
}
setupEventListeners() {
// Búsqueda de conversaciones
document.getElementById('search-input').addEventListener('input', (e) => {
this.searchConversations(e.target.value);
});
// Envío de mensajes
document.getElementById('send-btn').addEventListener('click', () => {
this.sendMessage();
});
// Edit user name button
const editUserBtn = document.getElementById('edit-user-btn');
if (editUserBtn) editUserBtn.addEventListener('click', () => this.promptEditUser());
// Reply prompt helper: when user clicks reply on a message
this.promptReply = function(messageId) {
const input = document.getElementById('message-input');
if (!input) return;
input.dataset.replyTo = messageId;
this.showReplyPreview(messageId);
input.focus();
};
// React to message placeholder
this.reactToMessage = function(messageId) {
// simple emoji picker or quick reaction; for now show a prompt
const e = prompt('Reaccionar con emoji (ej: 👍):');
if (!e) return;
fetch('api/react_message.php', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ message_id: messageId, emoji: e }) })
.then(r => r.json()).then(j => { if (j && j.success) { this.loadconversations(this.currentUserId, false); } else { alert('Error reaccionando'); } }).catch(err => { console.error(err); alert('Error reaccionando'); });
};
// Message type (text/template)
const typeSelect = document.getElementById('message-type');
if (typeSelect) {
typeSelect.addEventListener('change', (e) => {
const tplContainer = document.getElementById('templateSelectContainer');
tplContainer.style.display = (e.target.value === 'template') ? 'inline-block' : 'none';
});
}
// Quick replies toggle behavior: close on outside click
const qrToggle = document.getElementById('quick-replies-toggle');
const qrPanel = document.getElementById('quick-replies-panel');
if (qrToggle && qrPanel) {
qrToggle.addEventListener('click', (ev) => {
ev.stopPropagation();
const isOpen = qrPanel.style.display === 'block';
qrPanel.style.display = isOpen ? 'none' : 'block';
qrToggle.setAttribute('aria-expanded', String(!isOpen));
});
qrPanel.addEventListener('click', (ev) => { ev.stopPropagation(); });
document.addEventListener('click', () => { qrPanel.style.display = 'none'; qrToggle.setAttribute('aria-expanded','false'); });
}
// Mic / recording
const micBtn = document.getElementById('mic-btn');
if (micBtn) {
micBtn.addEventListener('click', () => {
if (this._mediaRecorder && this._mediaRecorder.state === 'recording') {
this.stopRecording();
} else {
this.startRecording();
}
});
}
const stopRecBtn = document.getElementById('stop-recording');
if (stopRecBtn) stopRecBtn.addEventListener('click', () => this.stopRecording());
const cancelRecBtn = document.getElementById('cancel-recording');
if (cancelRecBtn) cancelRecBtn.addEventListener('click', () => this.cancelRecording());
document.getElementById('message-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.sendMessage();
}
});
// Adjuntar archivos
document.getElementById('attach-btn').addEventListener('click', () => {
document.getElementById('file-input').click();
});
document.getElementById('file-input').addEventListener('change', (e) => {
this.handleFileSelect(e);
});
// Carga paginada: botón "Cargar más" y scroll infinito
const loadMoreBtn = document.getElementById('load-more-btn');
if (loadMoreBtn) {
loadMoreBtn.addEventListener('click', () => this.loadMoreConversations());
}
const convList = document.getElementById('conversation-list');
if (convList) {
convList.addEventListener('scroll', () => {
if (this.hasMoreConversations && !this.loadingConversations && (convList.scrollTop + convList.clientHeight >= convList.scrollHeight - 60)) {
this.loadMoreConversations();
}
});
}
// Filtro: Todos / No leídos
const filterSelect = document.getElementById('conversation-filter');
if (filterSelect) {
filterSelect.value = this.conversationFilter;
filterSelect.addEventListener('change', (e) => {
this.conversationFilter = e.target.value || 'all';
// reload conversations from first page
this.loadConversations(1, false);
});
}
}
setupAutoRefresh() {
// Actualizar conversaciones cada 30 segundos
setInterval(() => {
this.loadConversations();
}, 30000);
// Actualizar mensajes del chat activo cada 10 segundos
setInterval(() => {
if (this.currentUserId) {
this.loadconversations(this.currentUserId, false);
}
}, 10000);
}
// Helper para llamadas a la API desde esta clase
async apiCall(endpoint, options = {}) {
const defaultOptions = {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
};
// Si se pasa body, asumimos POST (y serializamos)
const finalOptions = { ...defaultOptions, ...options };
if (options.body && typeof options.body === 'object') {
finalOptions.method = 'POST';
finalOptions.body = JSON.stringify(options.body);
}
// Normalizar endpoint: prefijar 'api/' si no es URL completa y no empieza con 'api/'
let url = endpoint;
if (!/^https?:\/\//i.test(url) && !url.startsWith('api/')) {
url = 'api/' + url;
}
const response = await fetch(url, finalOptions);
if (response.status === 401) {
// Sesión expirada: redirigir al login
console.warn('apiCall: unauthorized, redirecting to login');
window.location.href = 'login.php';
return null;
}
const text = await response.text();
if (!response.ok) {
// Incluir el cuerpo de la respuesta (truncado) para diagnóstico
const snippet = text && text.length ? (text.length > 2000 ? text.substr(0, 2000) + '... (truncated)' : text) : '<no body>';
console.error('apiCall HTTP error', response.status, url, snippet);
throw new Error('HTTP error! status: ' + response.status + ' -- ' + snippet);
}
if (!text || text.trim() === '') return null;
try {
return JSON.parse(text);
} catch (err) {
console.error('apiCall parse error for', url, err);
const snippet = text.length > 1000 ? text.substr(0, 1000) : text;
throw new Error('Invalid JSON response from ' + url + ': ' + err.message + ' -- response snippet: ' + snippet);
}
}
async loadConversations(page = 1, append = false) {
if (this.loadingConversations) return;
this.loadingConversations = true;
const loadMoreContainer = document.getElementById('load-more-container');
const loadMoreBtn = document.getElementById('load-more-btn');
if (loadMoreBtn) loadMoreBtn.disabled = true;
try {
const url = `api/get_conversations.php?page=${page}&limit=${this.conversationsLimit}&filter=${encodeURIComponent(this.conversationFilter)}`;
const resp = await fetch(url);
const data = await resp.json();
console.log('Respuesta get_conversations:', data); // Debug
let items = [];
let hasMore = false;
if (data && data.success && Array.isArray(data.data)) {
items = data.data;
hasMore = !!data.has_more || (page * this.conversationsLimit) < (data.total || 0);
} else if (Array.isArray(data)) {
items = data;
hasMore = items.length === this.conversationsLimit;
} else {
console.error('Error cargando conversaciones: formato de datos inválido', data);
alert('Error cargando conversaciones: ' + (data.error || 'formato de datos inválido'));
this.loadingConversations = false;
if (loadMoreBtn) loadMoreBtn.disabled = false;
return;
}
if (append) {
this.conversations = this.conversations.concat(items);
} else {
this.conversations = items;
}
this.hasMoreConversations = hasMore;
this.conversationsPage = page;
this.renderConversations();
if (loadMoreContainer) {
loadMoreContainer.style.display = this.hasMoreConversations ? 'block' : 'none';
}
} catch (error) {
console.error('Error loading conversations:', error);
alert('Error cargando conversaciones: ' + error.message);
} finally {
this.loadingConversations = false;
if (loadMoreBtn) loadMoreBtn.disabled = false;
}
}
async loadMoreConversations() {
if (!this.hasMoreConversations || this.loadingConversations) return;
await this.loadConversations(this.conversationsPage + 1, true);
}
renderConversations() {
const container = document.getElementById('conversation-list');
if (this.conversations.length === 0) {
const emptyMsg = this.conversationFilter === 'unread' ? 'No hay conversaciones no leídas' : 'No hay conversaciones aún';
container.innerHTML = `
<div class="text-center p-4">
<i class="fas fa-comments fa-3x text-muted mb-3"></i>
<p class="text-muted">${emptyMsg}</p>
</div>
`;
return;
}
// Ahora el backend devuelve por usuario: last_message, last_time, unread_count y avatar_url
const html = this.conversations.map(conv => {
const isActive = conv.user_id == this.currentUserId ? 'active' : '';
const time = this.formatTime(conv.last_time);
const preview = this.truncateText(conv.last_message || 'Sin mensajes', 50);
const avatar = conv.avatar_url ? `<img src="${conv.avatar_url}" alt="avatar" class="conversation-avatar-img">` : `<div class="conversation-avatar">${this.getInitials(conv.name)}</div>`;
const unreadBadge = conv.unread_count && conv.unread_count > 0 ? `<span class="badge bg-danger unread-badge">${conv.unread_count}</span>` : '';
const attentionBadge = conv.advisor_requested ? `<span class="badge bg-danger ms-1">¡Atención!</span>` : '';
// different color if has unread and not active
const unreadClass = (conv.unread_count && conv.unread_count > 0 && !isActive) ? 'unread' : '';
const attentionClass = conv.advisor_requested ? ' attention' : '';
return `
<div class="conversation-item ${isActive} ${unreadClass}${attentionClass}" data-user-id="${conv.user_id}" onclick="chat.openConversation(${conv.user_id}, '${conv.name}', '${conv.phone_number}')">
<div class="conversation-avatar-wrapper">
${avatar}
</div>
<div class="conversation-info">
<div class="conversation-name">${conv.name} ${unreadBadge} ${attentionBadge}</div>
<div class="conversation-preview">
${conv.direction === 'outgoing' ? '✓ ' : ''}${preview}
</div>
</div>
<div class="conversation-time">
${time}
</div>
</div>
`;
}).join('');
// Detectar nuevas notificaciones: comparar unread counts previos
if (!this._prevConversations) this._prevConversations = {};
this.conversations.forEach(c => {
const prev = this._prevConversations[c.user_id] || { unread_count: 0 };
if (c.unread_count > prev.unread_count && c.user_id != this.currentUserId) {
// nueva notificación
this.playNotificationSound();
}
this._prevConversations[c.user_id] = { unread_count: c.unread_count };
});
container.innerHTML = html;
}
getInitials(name) {
if (!name) return '?';
const parts = name.split(' ');
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
formatTime(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffInHours = (now - date) / (1000 * 60 * 60);
if (diffInHours < 24) {
return date.toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit'
});
} else if (diffInHours < 48) {
return 'Ayer';
} else {
return date.toLocaleDateString('es-ES', {
day: '2-digit',
month: '2-digit'
});
}
}
truncateText(text, maxLength) {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
}
getConversationPhone(userId) {
const conv = this.conversations.find(c => c.user_id === userId);
if (conv) return conv.phone_number || conv.phone || conv.user_phone || null;
const el = document.getElementById('chat-phone');
return el ? el.textContent.trim() : null;
}
playNotificationSound() {
try {
// Small beep using Web Audio API (no external file needed)
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = 'sine';
o.frequency.value = 880;
o.connect(g);
g.connect(ctx.destination);
g.gain.value = 0.05;
o.start();
setTimeout(() => { o.stop(); ctx.close(); }, 180);
} catch (e) {
console.warn('Notification sound failed', e);
}
}
async openConversation(userId, userName, phoneNumber) {
this.currentUserId = userId;
// Actualizar UI
document.getElementById('no-conversation').style.display = 'none';
document.getElementById('chat-area').style.display = 'flex';
// Actualizar header
const nameEl = document.getElementById('chat-name-text') || document.getElementById('chat-name');
nameEl.textContent = userName;
document.getElementById('chat-phone').textContent = phoneNumber;
document.getElementById('chat-avatar').textContent = this.getInitials(userName);
// Marcar conversación como activa
document.querySelectorAll('.conversation-item').forEach(item => {
item.classList.remove('active');
});
document.querySelector(`[data-user-id="${userId}"]`)?.classList.add('active');
// Actualizar estado del toggle del bot según datos de la conversación
const conv = this.conversations.find(c => c.user_id === userId);
if (conv) {
const btn = document.getElementById('bot-toggle');
const holdIndicator = document.getElementById('hold-indicator');
const releaseBtn = document.getElementById('release-hold-btn');
const markUnreadBtn = document.getElementById('mark-unread-btn');
if (markUnreadBtn) {
markUnreadBtn.style.display = 'inline-block';
markUnreadBtn.onclick = async () => {
try {
const resp = await fetch('api/mark_conversation_unread.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId })
});
if (resp.status === 401) {
alert('Sesión expirada. Por favor inicie sesión de nuevo.');
return;
}
const json = await resp.json();
if (json && json.success) {
// Recargar lista de conversaciones para reflejar cambios
await this.loadConversations();
alert('Conversación marcada como NO leída.');
} else {
alert('Error marcando conversación como no leída');
}
} catch (e) {
console.error('Error marking conversation unread', e);
alert('Error marcando conversación como no leída');
}
};
}
if (holdIndicator) {
if (conv.on_hold) {
holdIndicator.textContent = 'EN ESPERA';
holdIndicator.style.color = '#b85';
holdIndicator.style.display = 'inline';
} else if (conv.advisor_requested) {
holdIndicator.textContent = 'SOLICITUD PENDIENTE';
holdIndicator.style.color = '#f39c12';
holdIndicator.style.display = 'inline';
} else {
holdIndicator.style.display = 'none';
}
} else {
console.warn('holdIndicator element not found in DOM');
}
if (releaseBtn) {
// show button when on_hold or advisor_requested
releaseBtn.style.display = (conv.on_hold || conv.advisor_requested) ? 'inline-block' : 'none';
releaseBtn.onclick = async () => {
try {
const resp = await fetch('api/release_hold.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId })
});
if (resp.status === 401) {
alert('Sesión expirada. Por favor inicie sesión de nuevo.');
return;
}
const json = await resp.json();
if (json && json.success) {
conv.on_hold = false;
conv.advisor_requested = 0;
if (holdIndicator) {
holdIndicator.style.display = 'none';
} else {
console.warn('holdIndicator missing when releasing hold');
}
releaseBtn.style.display = 'none';
// show bot toggle again
if (btn) btn.style.display = 'inline-block';
await this.loadConversations();
} else {
alert('Error al liberar la espera');
}
} catch (e) {
console.error('Error releasing hold', e);
alert('Error liberando espera');
}
};
// Render attend controls (single toggle + status) — compatible con comportamiento de chat_window.php
const attendToggleBtn = document.getElementById('attend-toggle-btn');
const attendStatus = document.getElementById('attend-status');
const renderAttendControls = () => {
if (!attendToggleBtn || !attendStatus) return;
// refresh conv reference
const c = this.conversations.find(x => x.user_id === userId) || conv;
if (c.in_service) {
attendToggleBtn.innerHTML = '<i class="fas fa-user-times"></i> Finalizar';
attendToggleBtn.classList.remove('btn-outline-light');
attendToggleBtn.classList.add('btn-light','text-dark');
attendToggleBtn.style.display = 'inline-block';
attendStatus.textContent = `Atendido por ${c.in_service_by_name || 'un asesor'}`;
} else if (c.advisor_requested) {
attendToggleBtn.innerHTML = '<i class="fas fa-user-check"></i> Atender';
attendToggleBtn.classList.remove('btn-light','text-dark');
attendToggleBtn.classList.add('btn-outline-light');
attendToggleBtn.style.display = 'inline-block';
attendStatus.textContent = '';
} else {
attendToggleBtn.style.display = 'none';
attendStatus.textContent = '';
}
};
const toggleAttend = async () => {
try {
const c = this.conversations.find(x => x.user_id === userId) || conv;
if (c.in_service) {
const resp = await this.apiCall('finish_attend.php', { body: { user_id: userId } });
if (resp && resp.success) {
showAlert('Finalizada la atención', 'success');
c.in_service = false;
c.advisor_requested = 0;
// hide hold indicator
if (holdIndicator) holdIndicator.style.display = 'none';
await this.loadConversations();
await this.loadMessages(userId, true);
} else {
throw new Error(resp && resp.error ? resp.error : 'Error finalizando');
}
} else {
const resp = await this.apiCall('attend.php', { body: { user_id: userId } });
if (resp && resp.success) {
showAlert('Atención iniciada', 'success');
c.in_service = true;
c.advisor_requested = 0;
if (holdIndicator) {
holdIndicator.textContent = 'EN SERVICIO';
holdIndicator.style.color = '#28a745';
holdIndicator.style.display = 'inline';
}
// hide bot toggle while in service
if (btn) btn.style.display = 'none';
await this.loadConversations();
await this.loadMessages(userId, true);
} else {
throw new Error(resp && resp.error ? resp.error : 'Error iniciando atención');
}
}
} catch (err) {
console.error('Error toggling attend', err);
showAlert('Error al cambiar estado de atención: ' + (err.message || err), 'danger');
} finally {
// re-render controls after any operation
renderAttendControls();
}
};
if (attendToggleBtn) {
attendToggleBtn.onclick = toggleAttend;
}
// inicializar estado
renderAttendControls();
}
if (btn) {
btn.textContent = conv.bot_enabled ? 'Bot: On' : 'Bot: Off';
btn.classList.toggle('btn-outline-danger', !conv.bot_enabled);
btn.classList.toggle('btn-outline-secondary', conv.bot_enabled);
// Hide bot toggle when advisor requested or in service or on hold (we expect attend/finish actions)
if (conv.advisor_requested || conv.in_service || conv.on_hold) {
btn.style.display = 'none';
} else {
btn.style.display = 'inline-block';
}
btn.onclick = async () => {
try {
const resp = await fetch('api/set_bot_enabled.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, enabled: conv.bot_enabled ? 0 : 1 })
});
const json = await resp.json();
if (json && json.success) {
conv.bot_enabled = !conv.bot_enabled;
btn.textContent = conv.bot_enabled ? 'Bot: On' : 'Bot: Off';
btn.classList.toggle('btn-outline-danger', !conv.bot_enabled);
btn.classList.toggle('btn-outline-secondary', conv.bot_enabled);
} else {
alert('Error al cambiar el estado del bot');
}
} catch (e) {
console.error('Error toggling bot', e);
}
};
}
}
// Cargar mensajes (con paginación)
await this.loadMessages(userId, true);
// Añadir listener para scroll arriba (cargar más historial)
const chatContainer = document.getElementById('chat-conversations');
if (chatContainer) {
if (!chatContainer._infiniteScrollAdded) {
chatContainer.addEventListener('scroll', async () => {
if (chatContainer.scrollTop <= 60 && this.hasMoreMessages && !this.loadingMessages && this.currentUserId == userId) {
await this.loadMessages(userId, false);
}
});
chatContainer._infiniteScrollAdded = true;
}
}
// Cargar respuestas rápidas y plantillas
await this.loadQuickReplies();
// templates loaded into select by loadQuickReplies
// show template container only if templates exist
const tplContainer = document.getElementById('templateSelectContainer');
if (tplContainer && document.getElementById('templateSelect').children.length) {
tplContainer.style.display = (document.getElementById('message-type').value === 'template') ? 'inline-block' : 'none';
}
// Recording support state
this._mediaRecorder = null;
this._recordingInterval = null;
this._recordingStart = null;
// Recording handlers
this.startRecording = async function() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this._mediaRecorder = new MediaRecorder(stream);
const chunks = [];
this._mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
this._mediaRecorder.onstop = async () => {
const blob = new Blob(chunks, { type: 'audio/webm' });
let file = new File([blob], `record_${Date.now()}.webm`, { type: 'audio/webm' });
// Try convert if FFmpeg available
try {
if (window && window.ensureFFmpeg) {
await window.ensureFFmpeg();
const converted = await window.convertWebmToOgg(file);
file = converted;
}
} catch (convErr) { console.warn('conversion failed', convErr); }
// set as selected file and show preview
this.selectedFile = file;
this.showMediaPreview(file);
};
this._mediaRecorder.start();
this._recordingStart = Date.now();
document.getElementById('recording-indicator').style.display = 'block';
document.getElementById('mic-btn').classList.add('recording');
const update = () => {
if (!this._recordingStart) return;
const elapsed = Math.floor((Date.now() - this._recordingStart) / 1000);
const m = Math.floor(elapsed/60); const s = elapsed % 60;
document.getElementById('recording-time').textContent = `${m}:${String(s).padStart(2,'0')}`;
};
update();
this._recordingInterval = setInterval(update, 1000);
} catch (err) {
console.error('startRecording error', err);
alert('No se pudo acceder al micrófono: ' + (err.message||err));
}
};
this.stopRecording = function() {
if (this._mediaRecorder && this._mediaRecorder.state !== 'inactive') {
this._mediaRecorder.stop();
}
if (this._recordingInterval) clearInterval(this._recordingInterval);
this._recordingInterval = null;
this._recordingStart = null;
document.getElementById('recording-indicator').style.display = 'none';
document.getElementById('mic-btn').classList.remove('recording');
};
this.cancelRecording = function() {
if (this._mediaRecorder && this._mediaRecorder.state !== 'inactive') {
this._mediaRecorder.stop();
}
this._mediaRecorder = null;
this.selectedFile = null;
if (this._recordingInterval) clearInterval(this._recordingInterval);
this._recordingInterval = null;
this._recordingStart = null;
document.getElementById('recording-indicator').style.display = 'none';
document.getElementById('mic-btn').classList.remove('recording');
};
}
async loadconversations(userId, showLoading = true) {
// Compatibilidad: ahora delegamos en loadMessages
if (showLoading) {
document.getElementById('chat-conversations').innerHTML = `
<div class="loading">
<i class="fas fa-spinner fa-spin"></i> Cargando mensajes...
</div>
`;
}
try {
await this.loadMessages(userId, true);
} catch (error) {
console.error('Error loading conversations via loadMessages:', error);
document.getElementById('chat-conversations').innerHTML = `
<div class="text-center p-4">
<i class="fas fa-exclamation-triangle text-warning"></i>
<p>Error al cargar los mensajes</p>
</div>
`;
}
}
/**
* Cargar mensajes con paginación. Si initial=true, carga el bloque más reciente; si initial=false, carga mensajes anteriores (before=this.earliestMessage).
*/
async loadMessages(userId, initial = false) {
if (this.loadingMessages) return;
this.loadingMessages = true;
const container = document.getElementById('chat-conversations');
// si cargamos más antiguos, preservar scroll
let prevScrollHeight = container ? container.scrollHeight : 0;
let prevScrollTop = container ? container.scrollTop : 0;
// indicador top cuando cargamos anteriores
let topLoader = null;
if (!initial && container) {
container.insertAdjacentHTML('afterbegin', `<div class="loading loading-top" style="text-align:center; padding:8px; font-size:12px;">Cargando mensajes anteriores...</div>`);
topLoader = container.querySelector('.loading-top');
}
try {
let url = `get_user_messages.php?user_id=${userId}&limit=${this.messageLimit}`;
if (!initial && this.earliestMessage) {
url += `&before=${encodeURIComponent(this.earliestMessage)}`;
}
const data = await this.apiCall(url);
if (!data) throw new Error('No autorizado o error en la petición');
let messages = [];
let hasMore = false;
let earliest = null;
if (data && data.success && Array.isArray(data.data)) {
messages = data.data;
hasMore = !!data.has_more;
earliest = data.earliest || (messages[0] && messages[0].created_at) || null;
} else if (Array.isArray(data)) {
messages = data;
}
if (initial) {
this.conversations = messages;
} else {
// Prepend mensajes antiguos
this.conversations = messages.concat(this.conversations);
}
// Actualizar estado de paginación
this.hasMoreMessages = hasMore;
if (earliest) this.earliestMessage = earliest;
// Renderizar y ajustar scroll
this.renderconversations();
// Replace media rendering using shared helper for consistency
// (renderconversations will include the HTML from renderMediaMessage in content)
if (initial) {
this.scrollToBottom();
} else if (container) {
// Mantener posición: desplazar por la diferencia de heights
const newScrollHeight = container.scrollHeight;
container.scrollTop = newScrollHeight - prevScrollHeight + prevScrollTop;
}
// remover loader top si existía
if (topLoader && topLoader.parentNode) topLoader.remove();
// Marcar como leídos (comportamiento previo: marcar todos los entrantes como leídos al abrir)
if (initial) {
try {
await this.apiCall('mark_conversation_read.php', { method: 'POST', body: { user_id: userId } });
// Recargar lista de conversaciones para refrescar contadores
await this.loadConversations();
} catch (e) {
console.error('Error marking conversation as read', e);
}
}
} catch (error) {
console.error('Error en loadMessages:', error);
if (container) {
container.innerHTML = `
<div class="text-center p-4">
<i class="fas fa-exclamation-triangle text-warning fa-2x"></i>
<p class="mt-3">Error al cargar los mensajes: ${error.message || error}</p>
<button class="btn btn-sm btn-primary" id="retry-load-messages">Reintentar</button>
</div>
`;
const retryBtn = document.getElementById('retry-load-messages');
if (retryBtn) retryBtn.addEventListener('click', async () => { await this.loadMessages(userId, true); });
}
} finally {
this.loadingMessages = false;
}
}
renderconversations() {
const container = document.getElementById('chat-conversations');
if (this.conversations.length === 0) {
container.innerHTML = `
<div class="text-center p-4">
<i class="far fa-comment fa-3x text-muted mb-3"></i>
<p class="text-muted">No hay mensajes en esta conversación</p>
</div>
`;
return;
}
const html = this.conversations.map(msg => {
const time = new Date(msg.created_at).toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit'
});
const statusIcon = this.getStatusIcon(msg.status);
// Renderizar contenido (texto o multimedia) usando helper común
const mediaPresent = (msg.local_thumb || msg.local_file || msg.media_url_external || msg.media_url);
const content = mediaPresent ? (window.renderMediaMessage ? window.renderMediaMessage(msg) : (msg.content || '[Mensaje]')) : (msg.content || '[Mensaje vacío]');
// Mostrar preview si es reply/context
let replyHtml = '';
if (msg.reply_to_message_id) {
const target = this.conversations.find(m => m.message_id == msg.reply_to_message_id);
const previewText = target ? (target.content || target.message_text || '').substring(0,140) : ('Mensaje ' + msg.reply_to_message_id);
replyHtml = `<div class="reply-preview">En respuesta a: ${previewText}</div>`;
}
// Mostrar reacción si existe
let reactionHtml = '';
if (msg.reaction_emoji) {
reactionHtml = `<div class="reaction-badge">${msg.reaction_emoji}</div>`;
}
return `
<div class="message ${msg.direction}">
<div class="message-bubble">
${replyHtml}
<div class="message-content">${content}</div>
${reactionHtml}
<div class="message-actions mt-1">
<button class="btn btn-sm btn-link" onclick="chat.promptReply('${msg.message_id}')" title="Responder"><i class="fas fa-reply"></i></button>
<button class="btn btn-sm btn-link" onclick="chat.reactToMessage('${msg.message_id}')" title="Reaccionar"><i class="far fa-grin"></i></button>
</div>
<div class="message-time">
${time}
${msg.direction === 'outgoing' ? `<span class="message-status">${statusIcon}</span>` : ''}
</div>
</div>
</div>
`;
}).join('');
container.innerHTML = html;
}
getStatusIcon(status) {
switch (status) {
case 'sent': return '<i class="fas fa-check status-sent"></i>';
case 'delivered': return '<i class="fas fa-check-double status-delivered"></i>';
case 'read': return '<i class="fas fa-check-double status-read"></i>';
default: return '<i class="fas fa-clock status-sent"></i>';
}
}
async loadQuickReplies() {
const panel = document.getElementById('quick-replies-panel');
if (!panel) return;
panel.innerHTML = '';
try {
// Load autoresponses (text quick replies)
const respText = await apiCall('get_autoresponses.php');
if (respText && respText.success && Array.isArray(respText.data)) {
respText.data.slice(0,6).forEach(r => {
if (!r.is_active) return;
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-outline-primary';
const text = (r.response_text || '').replace(/\n/g,' ');
btn.textContent = text.length > 30 ? text.substring(0,30) + '...' : text;
btn.title = r.response_text;
btn.addEventListener('click', () => {
// send as text message and close panel
this.sendQuickReply(r.response_text);
panel.style.display = 'none';
document.getElementById('quick-replies-toggle').setAttribute('aria-expanded','false');
});
panel.appendChild(btn);
});
}
} catch (e) {
console.error('Error loading quick replies (text)', e);
}
try {
const resp = await apiCall('get_templates.php?approved_only=1&limit=10');
if (resp && resp.success && Array.isArray(resp.data)) {
resp.data.slice(0,4).forEach(t => {
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-outline-secondary';
const text = (t.body_text || t.name || t.template_name || '').replace(/\n/g,' ');
btn.textContent = text.length > 30 ? text.substring(0,30) + '...' : text;
btn.title = t.name;
btn.addEventListener('click', () => { this.sendTemplateQuick(t.template_name, t.language_code || 'es'); panel.style.display='none'; document.getElementById('quick-replies-toggle').setAttribute('aria-expanded','false'); });
panel.appendChild(btn);
});
}
} catch (e) {
console.error('Error loading quick replies (templates)', e);
}
// Also populate the template select
try { await loadTemplatesInto('#templateSelect'); } catch(e){console.warn('Could not load templates into select', e);}
// Show a small hint if there are no quick replies
if (!panel.children.length) {
panel.innerHTML = '<div class="text-muted" style="padding:8px; font-size:13px;">No hay respuestas rápidas</div>';
}
}
}
async sendTemplateQuick(templateName, language) {
// delegate to unified sendTemplateMessage helper
await this.sendTemplateMessage(templateName, language, null);
}
async sendTemplateMessage(templateName, language = 'es', parameters = null) {
if (!this.currentUserId) return;
const recipient = this.getConversationPhone(this.currentUserId);
if (!recipient) {
alert('No se pudo determinar el número de destino para la plantilla');
console.error('sendTemplateMessage: missing recipient for user', this.currentUserId);
return;
}
try {
const requestBody = {
recipient: recipient,
type: 'template',
template: templateName,
language: language || 'es',
parameters: parameters || []
};
const resp = await this.apiCall('send_message.php', { body: requestBody });
// hide reply preview if any
this.hideReplyPreview();
if (resp && resp.success) {
this.addMessageToView(`Plantilla: ${templateName}`, 'outgoing');
typeof showAlert !== 'undefined' && showAlert('Mensaje de plantilla enviado correctamente', 'success');
await this.loadconversations(this.currentUserId, false);
await this.loadConversations();
} else {
throw new Error(resp && resp.error ? resp.error : 'Error enviando plantilla');
}
} catch (e) {
console.error('Error sending template message', e);
alert('Error enviando plantilla: ' + (e.message || e));
}
}
async sendQuickReply(text) {
if (!text) return;
const input = document.getElementById('message-input');
if (!input) return;
input.value = text;
await this.sendMessage();
}
scrollToBottom() {
const container = document.getElementById('chat-conversations');
container.scrollTop = container.scrollHeight;
}
// Insertar mensaje saliente en la vista inmediatamente (simular envío)
addMessageToView(content, type = 'outgoing') {
const msg = {
message_id: 'local_' + Date.now(),
direction: type === 'outgoing' ? 'outgoing' : 'incoming',
content: content,
created_at: new Date().toISOString(),
status: 'sent'
};
this.conversations.push(msg);
this.renderconversations();
this.scrollToBottom();
}
showReplyPreview(messageId) {
try {
const preview = document.getElementById('reply-preview');
const previewText = document.getElementById('reply-preview-text');
const cancelBtn = document.getElementById('cancel-reply-btn');
const msg = this.conversations.find(m => m.message_id == messageId);
const text = msg ? (msg.content || msg.message_text || '[Mensaje]') : ('Mensaje ' + messageId);
if (preview && previewText) {
previewText.textContent = 'En respuesta a: ' + (String(text).replace(/\n/g,' ')).substring(0,140);
preview.style.display = 'flex';
}
if (cancelBtn) {
cancelBtn.onclick = () => this.hideReplyPreview();
}
} catch (e) {
console.warn('showReplyPreview failed', e);
}
}
hideReplyPreview() {
try {
const preview = document.getElementById('reply-preview');
if (preview) preview.style.display = 'none';
const input = document.getElementById('message-input');
if (input) input.dataset.replyTo = null;
} catch (e) {
console.warn('hideReplyPreview failed', e);
}
}
async sendMessage() {
const input = document.getElementById('message-input');
const message = input.value.trim();
const messageType = document.getElementById('message-type') ? document.getElementById('message-type').value : 'text';
if (!message && messageType === 'text') return;
if (!this.currentUserId) return;
// Deshabilitar input
input.disabled = true;
document.getElementById('send-btn').disabled = true;
try {
const replyTo = input.dataset.replyTo || null;
if (messageType === 'template') {
const template = document.getElementById('templateSelect').value;
if (!template) {
alert('Seleccione una plantilla');
return;
}
const recipient = this.getConversationPhone(this.currentUserId);
if (!recipient) {
alert('No se pudo determinar el número de destino para la plantilla');
console.error('sendMessage(template): missing recipient for user', this.currentUserId);
return;
}
// send template via helper (match chat_window format)
const templateName = template;
const language = (document.getElementById('templateSelect').selectedOptions[0] ? document.getElementById('templateSelect').selectedOptions[0].dataset.language : 'es');
await this.sendTemplateMessage(templateName, language, null);
} else {
// send text message using recipient (phone number) to match chat_window behavior
const recipient = this.getConversationPhone(this.currentUserId);
if (!recipient) {
alert('No se pudo determinar el número de destino');
console.error('sendMessage(text): missing recipient for user', this.currentUserId);
return;
}
// prepare request body similar to chat_window
const requestBody = {
recipient: recipient,
type: 'text',
message: message
};
if (replyTo) requestBody.reply_to = replyTo;
try {
const result = await this.apiCall('send_message.php', { body: requestBody });
// Clear reply data attribute
input.dataset.replyTo = null;
this.hideReplyPreview();
if (result && result.success) {
input.value = '';
// Mostrar inmediatamente en la vista
this.addMessageToView(message, 'outgoing');
typeof showAlert !== 'undefined' && showAlert('Mensaje enviado correctamente', 'success');
// Recargar mensajes y conversaciones en background
this.loadconversations(this.currentUserId, false).catch(e=>console.warn(e));
this.loadConversations().catch(e=>console.warn(e));
this.loadQuickReplies().catch(e=>console.warn(e));
} else {
throw new Error(result && result.error ? result.error : 'Error desconocido');
}
} catch (err) {
console.error('Error sending text message', err);
alert('Error al enviar mensaje: ' + (err.message || err));
}
}
} catch (error) {
console.error('Error sending message:', error);
alert('Error al enviar mensaje: ' + (error.message||error));
} finally {
input.disabled = false;
document.getElementById('send-btn').disabled = false;
input.focus();
}
}
// loadQuickReplies is implemented earlier (combined autoresponses & templates)
// kept here as a no-op to avoid accidentally overriding the real implementation.
async promptEditUser() {
const nameEl = document.getElementById('chat-name-text') || document.getElementById('chat-name');
const currentName = nameEl ? nameEl.textContent.trim() : '';
const newName = prompt('Nombre del usuario:', currentName);
if (!newName) return;
try {
const resp = await fetch('api/update_user.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: this.currentUserId, name: newName })
});
const json = await resp.json();
if (json && json.success) {
if (nameEl) nameEl.textContent = newName;
this.showSuccess && this.showSuccess('Usuario actualizado');
this.loadConversations();
} else {
this.showError && this.showError('No se pudo actualizar usuario');
}
} catch (err) {
console.error(err);
this.showError && this.showError('Error actualizando usuario');
}
}
async deleteCurrentConversation() {
if (!confirm('¿Eliminar esta conversación? Se borrará todo el historial.')) return;
try {
const resp = await fetch('api/delete_conversation.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: this.currentUserId })
});
const json = await resp.json();
if (json && json.success) {
this.showSuccess('Conversación eliminada');
// reset UI
document.getElementById('chat-area').style.display = 'none';
document.getElementById('no-conversation').style.display = 'block';
this.currentUserId = null;
this.loadConversations();
} else {
this.showError('No se pudo eliminar la conversación');
}
} catch (err) {
console.error(err);
this.showError('Error eliminando la conversación');
}
}
searchConversations(query) {
const items = document.querySelectorAll('.conversation-item');
items.forEach(item => {
const name = item.querySelector('.conversation-name').textContent.toLowerCase();
const preview = item.querySelector('.conversation-preview').textContent.toLowerCase();
const matches = name.includes(query.toLowerCase()) || preview.includes(query.toLowerCase());
item.style.display = matches ? 'flex' : 'none';
});
}
// ========== FUNCIONES MULTIMEDIA ==========
handleFileSelect(event) {
const file = event.target.files[0];
if (!file) return;
// Validar tamaño
const maxSize = this.getMaxFileSize(file.type);
if (file.size > maxSize) {
alert(`El archivo es demasiado grande. Máximo: ${(maxSize / 1024 / 1024).toFixed(0)}MB`);
return;
}
// Mostrar preview
this.showMediaPreview(file);
}
getMaxFileSize(fileType) {
if (fileType.startsWith('image/')) return 5 * 1024 * 1024; // 5MB
if (fileType.startsWith('application/')) return 100 * 1024 * 1024; // 100MB
return 16 * 1024 * 1024; // 16MB para video/audio
}
showMediaPreview(file) {
const preview = document.getElementById('media-preview');
const thumbnail = document.getElementById('preview-thumbnail');
const filename = document.getElementById('preview-filename');
const filesize = document.getElementById('preview-filesize');
// Establecer información del archivo
filename.textContent = file.name;
filesize.textContent = this.formatFileSize(file.size);
// Mostrar thumbnail para imágenes
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
thumbnail.src = e.target.result;
thumbnail.style.display = 'block';
};
reader.readAsDataURL(file);
} else {
thumbnail.style.display = 'none';
}
// Guardar archivo temporalmente
this.selectedFile = file;
// Mostrar preview
preview.style.display = 'block';
// Cambiar comportamiento del botón enviar
const sendBtn = document.getElementById('send-btn');
const originalHandler = sendBtn.onclick;
sendBtn.onclick = () => this.sendMediaMessage();
}
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
async sendMediaMessage() {
if (!this.selectedFile || !this.currentUserId) return;
const caption = document.getElementById('media-caption').value.trim();
const sendBtn = document.getElementById('send-btn');
const attachBtn = document.getElementById('attach-btn');
// Deshabilitar botones
sendBtn.disabled = true;
attachBtn.disabled = true;
sendBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
// 1. Subir archivo
const formData = new FormData();
formData.append('file', this.selectedFile);
const uploadResponse = await fetch('api/upload_media.php', {
method: 'POST',
body: formData
});
const uploadResult = await uploadResponse.json();
if (!uploadResult.success) {
throw new Error(uploadResult.error || 'Error subiendo archivo');
}
// 2. Enviar mensaje con el archivo
const user = this.conversations.find(c => c.user_id === this.currentUserId);
const phone = user ? user.phone_number : null;
if (!phone) {
throw new Error('No se encontró el número de teléfono');
}
const sendResponse = await fetch('api/send_media_message.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
recipient: phone,
media_url: uploadResult.data.url,
media_type: uploadResult.data.type,
caption: caption || null,
filename: this.selectedFile.name
})
});
const sendResult = await sendResponse.json();
if (!sendResult.success) {
throw new Error(sendResult.error || 'Error enviando mensaje');
}
// Éxito
this.cancelMediaUpload();
await this.loadconversations(this.currentUserId, false);
this.loadConversations();
} catch (error) {
console.error('Error sending media:', error);
alert('Error al enviar archivo: ' + error.message);
} finally {
sendBtn.disabled = false;
attachBtn.disabled = false;
sendBtn.innerHTML = '<i class="fas fa-paper-plane"></i>';
}
}
cancelMediaUpload() {
document.getElementById('media-preview').style.display = 'none';
document.getElementById('media-caption').value = '';
document.getElementById('file-input').value = '';
this.selectedFile = null;
// Restaurar botón enviar
const sendBtn = document.getElementById('send-btn');
sendBtn.onclick = null;
}
renderMediaMessage(message) {
if (!message.media_url) return '';
const mediaType = message.message_type;
const mediaUrl = message.media_url;
const caption = message.content;
switch (mediaType) {
case 'image':
// Usar miniatura local si existe, al abrir expandir con el archivo local completo o con media-url redirect
const thumb = msg.local_thumb ? (`/${msg.local_thumb}`) : (msg.local_file ? (`/${msg.local_file}`) : (msg.media_url_external || mediaUrl));
let full = '';
if (msg.local_file) {
full = `/api/version/media-url.php?local=${encodeURIComponent(msg.local_file)}`;
} else if (msg.media_url_external) {
// Si viene una ruta del tipo api/get_media.php?id=XXX extraer id
if (msg.media_url_external.indexOf('api/get_media.php?id=') !== -1) {
const parts = msg.media_url_external.split('id=');
const mid = parts[1] ? decodeURIComponent(parts[1]) : '';
full = `/api/version/media-url.php?id=${encodeURIComponent(mid)}`;
} else if (/^https?:\/\//i.test(msg.media_url_external)) {
// URL directa
full = msg.media_url_external;
} else {
// fallback: pasar como url
full = `/api/version/media-url.php?url=${encodeURIComponent(msg.media_url_external)}`;
}
} else {
full = mediaUrl || '';
}
return `
<div class="message-media">
<a href="#" onclick="openImageLightbox(${JSON.stringify(full)}); return false;" title="Abrir imagen">
<img src="${thumb}" alt="Imagen" style="cursor:zoom-in">
</a>
</div>
${caption ? `<div>${caption}</div>` : ''}
`;
case 'video':
let videoSrc = '';
if (msg.local_file) {
videoSrc = `/${msg.local_file}`;
} else if (msg.media_url_external) {
if (msg.media_url_external.indexOf('api/get_media.php?id=') !== -1) {
const parts = msg.media_url_external.split('id=');
const mid = parts[1] ? decodeURIComponent(parts[1]) : '';
videoSrc = `/api/version/media-url.php?id=${encodeURIComponent(mid)}`;
} else if (/^https?:\/\//i.test(msg.media_url_external)) {
videoSrc = msg.media_url_external;
} else {
videoSrc = `/api/version/media-url.php?url=${encodeURIComponent(msg.media_url_external)}`;
}
} else {
videoSrc = mediaUrl;
}
return `
<div class="message-media">
<video controls>
<source src="${videoSrc}" type="video/mp4">
Tu navegador no soporta video.
</video>
</div>
${caption ? `<div>${caption}</div>` : ''}
`;
case 'audio':
let audioSrc = '';
if (msg.local_file) {
audioSrc = `/${msg.local_file}`;
} else if (msg.media_url_external) {
if (msg.media_url_external.indexOf('api/get_media.php?id=') !== -1) {
const parts = msg.media_url_external.split('id=');
const mid = parts[1] ? decodeURIComponent(parts[1]) : '';
audioSrc = `/api/version/media-url.php?id=${encodeURIComponent(mid)}`;
} else if (/^https?:\/\//i.test(msg.media_url_external)) {
audioSrc = msg.media_url_external;
} else {
audioSrc = `/api/version/media-url.php?url=${encodeURIComponent(msg.media_url_external)}`;
}
} else {
audioSrc = mediaUrl;
}
return `
<div class="message-media">
<audio controls>
<source src="${audioSrc}" type="audio/mpeg">
Tu navegador no soporta audio.
</audio>
</div>
`;
case 'document':
// Para documentos no descargamos automáticamente; abrir la URL externa (o la redirección por media id) para descargar
let docUrl = '';
if (msg.local_file) {
docUrl = `/api/version/media-url.php?local=${encodeURIComponent(msg.local_file)}&download=1`;
} else if (msg.media_url_external) {
if (msg.media_url_external.indexOf('api/get_media.php?id=') !== -1) {
const parts = msg.media_url_external.split('id=');
const mid = parts[1] ? decodeURIComponent(parts[1]) : '';
docUrl = `/api/version/media-url.php?id=${encodeURIComponent(mid)}&download=1`;
} else if (/^https?:\/\//i.test(msg.media_url_external)) {
docUrl = `/api/version/media-url.php?url=${encodeURIComponent(msg.media_url_external)}&download=1`;
} else {
// fallback
docUrl = `/api/version/media-url.php?url=${encodeURIComponent(msg.media_url_external)}&download=1`;
}
}
if (!docUrl) {
return `
<div class="message-document disabled">
<i class="fas fa-file-pdf"></i>
<div class="document-info">
<div class="document-name">${caption || 'Documento'}</div>
<div class="document-size text-muted">No disponible para descargar</div>
</div>
</div>
`;
}
return `
<div class="message-document">
<i class="fas fa-file-pdf"></i>
<div class="document-info">
<div class="document-name">${caption || 'Documento'}</div>
<div class="document-size">
<a href="${docUrl}" target="_blank" rel="noopener noreferrer" ${docUrl && docUrl.indexOf('download=1') !== -1 ? 'download' : ''}>Descargar</a>
</div>
</div>
<i class="fas fa-download"></i>
</div>
`;
default:
return caption || '';
}
}
}
// Función global para cancelar
function cancelMediaUpload() {
if (window.chatApp) {
window.chatApp.cancelMediaUpload();
}
}
// Inicializar cuando la página cargue
let chat;
document.addEventListener('DOMContentLoaded', () => {
chat = new WhatsAppChat();
window.chatApp = chat; // Exponer globalmente para funciones auxiliares
});
</script>
</body>
</html>