3076 lines
148 KiB
PHP
3076 lines
148 KiB
PHP
<!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: rgba(255,255,255,0.96);
|
||
border: none;
|
||
box-shadow: 0 6px 18px rgba(2,6,23,0.04);
|
||
padding: 6px;
|
||
border-radius: 6px;
|
||
max-height: 180px;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
.quick-replies-panel .btn { min-width: 72px; max-width: 200px; font-size: 13px; padding:6px 10px; }
|
||
#quick-replies-toggle { background: transparent; border-radius: 20px; }
|
||
@media (max-width: 768px) {
|
||
.quick-replies-panel { left: 8px; right: 8px; bottom: 56px; min-width: auto; }
|
||
}
|
||
|
||
/* Improve audio control visibility and color in supporting browsers */
|
||
.message-media audio { background: #fff; border-radius: 8px; padding: 4px; accent-color: var(--whatsapp-green); }
|
||
/* Ensure mic icon contrast */
|
||
#mic-btn i { color: white; font-size: 14px; }
|
||
|
||
/* 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; }
|
||
|
||
/* Attach '+' style and menu */
|
||
#attach-btn { background: transparent; border-radius: 6px; padding: 4px 10px; border: 1px solid transparent; font-weight:700; }
|
||
#attach-btn:hover { background: rgba(0,0,0,0.03); border-color: rgba(0,0,0,0.06); }
|
||
.attach-menu { background:#fff; border:1px solid #ddd; box-shadow:0 6px 18px rgba(0,0,0,0.08); border-radius:6px; padding:6px; display:none; position:absolute; z-index:1300; }
|
||
.attach-menu .attach-option { display:block; width:100%; text-align:left; padding:6px 10px; border:none; background:transparent; font-size:14px; }
|
||
.attach-menu .attach-option:hover { background:#f6f6f6; }
|
||
|
||
/* Show mic in place of send when input is empty */
|
||
#send-btn { display: inline-block; }
|
||
#mic-btn { display: none; }
|
||
|
||
.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 thumbnails and players */
|
||
.message-media img, .message-media .message-media-image { max-width: 320px; border-radius: 8px; display: block; height: auto; }
|
||
@media (max-width: 768px) {
|
||
.message-media img, .message-media .message-media-image { max-width: 60vw; }
|
||
}
|
||
.message-media video, .message-media audio { max-width: 100%; border-radius: 8px; }
|
||
.message-media { margin-bottom: 6px; }
|
||
|
||
/* Quick replies: make them rectangular, full-width in panel, readable */
|
||
.quick-replies-panel { background: #fff; border-radius: 8px; padding: 8px; box-shadow: 0 6px 18px rgba(0,0,0,0.08); }
|
||
.quick-replies-panel .btn {
|
||
border-radius: 6px;
|
||
display: block;
|
||
text-align: left;
|
||
width: 100%;
|
||
padding: 8px 10px;
|
||
margin-bottom: 6px;
|
||
white-space: normal;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
/* Recording controls: rectangular buttons and clearer contrast */
|
||
#recording-indicator .btn {
|
||
border-radius: 6px;
|
||
padding: 6px 10px;
|
||
min-width: 84px;
|
||
}
|
||
|
||
/* Audio control visibility: stronger border and accent color */
|
||
.message-media audio {
|
||
background: #fff;
|
||
border: 1px solid rgba(0,0,0,0.06);
|
||
padding: 6px;
|
||
border-radius: 8px;
|
||
accent-color: var(--whatsapp-green);
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||
height: 40px;
|
||
}
|
||
|
||
/* Ensure mic button contrast */
|
||
#mic-btn { background: var(--whatsapp-green); color: #fff; border-radius: 8px; padding: 8px 10px; }
|
||
#mic-btn.recording { background: #c0392b; color: white; }
|
||
|
||
@media (max-width: 768px) {
|
||
/* Fullscreen sidebar that can slide in/out */
|
||
.chat-container { padding: 0; gap: 0; }
|
||
.chat-sidebar {
|
||
width: 100%;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
z-index: 1100;
|
||
height: 100vh;
|
||
transform: translateX(0);
|
||
transition: transform 240ms ease;
|
||
box-shadow: none;
|
||
}
|
||
|
||
/* Hidden by default on mobile. .open will show it */
|
||
.chat-sidebar.mobile-hidden { transform: translateX(-110%); }
|
||
|
||
.chat-main {
|
||
width: 100%;
|
||
margin-left: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100vh;
|
||
}
|
||
|
||
/* Header adjustment */
|
||
.chat-header { padding: 10px 12px; gap: 8px; }
|
||
.chat-header-avatar { width: 38px; height: 38px; }
|
||
|
||
/* Fix input to bottom to emulate mobile chat apps */
|
||
.chat-input {
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
padding: 10px;
|
||
background: #fff;
|
||
z-index: 1200;
|
||
border-top: 1px solid #e9eef2;
|
||
}
|
||
|
||
/* Give chat area bottom padding so messages are not hidden under input */
|
||
.chat-conversations { padding-bottom: 120px; }
|
||
|
||
/* Compact avatars and message bubble width */
|
||
.conversation-avatar { width: 40px; height: 40px; }
|
||
.message-bubble { max-width: 85%; font-size: 15px; }
|
||
|
||
/* Quick replies panel adapt */
|
||
.quick-replies-panel { left: 8px; right: 8px; bottom: 70px; max-height: 160px; }
|
||
|
||
/* Show a small back button in header to open sidebar */
|
||
#sidebar-toggle { display: inline-flex; }
|
||
}
|
||
|
||
.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 { background:#fff; border:1px solid rgba(0,0,0,0.06); display:inline-flex; align-items:center; justify-content:center; padding:4px 6px; border-radius:14px; font-size:13px; margin-top:6px; }
|
||
|
||
/* Reaction picker */
|
||
#reaction-picker { position:fixed; display:none; z-index:2000; background:#fff; border-radius:10px; box-shadow:0 6px 24px rgba(0,0,0,0.16); padding:8px; min-width:160px; transition:transform .12s ease, opacity .12s ease; transform:scale(.96); opacity:0; }
|
||
#reaction-picker.show { transform:scale(1); opacity:1; }
|
||
#reaction-picker .emoji { font-size:18px; padding:6px; cursor:pointer; border-radius:6px; margin:4px; display:inline-flex; align-items:center; justify-content:center; }
|
||
#reaction-picker .emoji:hover { background: rgba(0,0,0,0.04); }
|
||
|
||
.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">
|
||
<button id="sidebar-toggle" class="btn btn-sm btn-outline-light d-md-none me-2" style="border-radius:8px; padding:6px 8px;" title="Volver a conversaciones"><i class="fas fa-bars"></i></button>
|
||
<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" style="display:none;"><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" style="position:relative;">
|
||
<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="Opciones">
|
||
<i class="fas fa-plus"></i>
|
||
</button>
|
||
<div id="attach-menu" class="attach-menu" style="display:none;">
|
||
<button class="attach-option" data-action="file">Enviar archivo</button>
|
||
<button class="attach-option" data-action="template">Enviar plantilla</button>
|
||
</div>
|
||
<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">⚡️</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; 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();
|
||
// Track notifications the user dismissed/read locally so they don't reappear
|
||
this._dismissedNotifications = new Set();
|
||
// Map of pending ack notifications to retry marking as read (nid => notification)
|
||
this._pendingAck = new Map();
|
||
// 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();
|
||
// start background retry loop to ack dismissed notifications on server
|
||
this.setupNotificationAckRetry && this.setupNotificationAckRetry();
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
// Try to mark a notification as read on the server. Returns true on success.
|
||
async ackNotification(notification) {
|
||
if (!notification || !notification.id) return true; // nothing to ack on server
|
||
try {
|
||
const resp = await fetch('api/mark_notification_read.php', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ id: notification.id })
|
||
});
|
||
if (!resp.ok) {
|
||
console.warn('ackNotification: server responded with', resp.status);
|
||
return false;
|
||
}
|
||
const j = await resp.json().catch(() => null);
|
||
return !!(j && j.success) || resp.ok;
|
||
} catch (e) {
|
||
console.warn('ackNotification failed', e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
setupNotificationAckRetry() {
|
||
if (this._ackInterval) return;
|
||
this._ackInterval = setInterval(async () => {
|
||
if (this._pendingAck.size === 0) return;
|
||
for (const [nid, notification] of Array.from(this._pendingAck.entries())) {
|
||
try {
|
||
const ok = await this.ackNotification(notification);
|
||
if (ok) {
|
||
this._pendingAck.delete(nid);
|
||
this._dismissedNotifications.add(nid);
|
||
console.debug('Ack retry succeeded for', nid);
|
||
} else {
|
||
console.debug('Ack retry still failing for', nid);
|
||
}
|
||
} catch (e) {
|
||
console.warn('Ack retry error for', nid, e);
|
||
}
|
||
}
|
||
}, 30000); // every 30s
|
||
}
|
||
|
||
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 the user already dismissed/acked this notification earlier, skip it
|
||
if (this._dismissedNotifications.has(nid)) {
|
||
console.debug('Notification previously dismissed, skipping', nid);
|
||
return;
|
||
}
|
||
if (this._shownNotifications.has(nid)) {
|
||
// already shown in this session
|
||
console.debug('Notification already shown, skipping', nid);
|
||
return;
|
||
}
|
||
|
||
// mark as shown immediately and try to ack on server so it doesn't reappear
|
||
this._shownNotifications.add(nid);
|
||
try {
|
||
const ok = await this.ackNotification(notification);
|
||
if (ok) {
|
||
this._dismissedNotifications.add(nid);
|
||
this._pendingAck.delete(nid);
|
||
} else {
|
||
// schedule for retry
|
||
this._pendingAck.set(nid, notification);
|
||
}
|
||
} catch (e) {
|
||
this._pendingAck.set(nid, notification);
|
||
}
|
||
|
||
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 removeToastLocal = () => {
|
||
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 ack on server; if fails, schedule retry. Always mark as dismissed locally so it won't reappear.
|
||
try {
|
||
const ok = await this.ackNotification(notification);
|
||
if (ok) {
|
||
this._dismissedNotifications.add(nid);
|
||
this._pendingAck.delete(nid);
|
||
} else {
|
||
this._pendingAck.set(nid, notification);
|
||
}
|
||
} catch (e) { this._pendingAck.set(nid, notification); }
|
||
removeToastLocal();
|
||
// navigate to conv if present
|
||
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', '');
|
||
}
|
||
}
|
||
};
|
||
|
||
const dismiss = document.createElement('button');
|
||
dismiss.className = 'btn btn-sm btn-outline-secondary';
|
||
dismiss.textContent = '×';
|
||
dismiss.title = 'Descartar';
|
||
dismiss.onclick = async () => {
|
||
try {
|
||
const ok = await this.ackNotification(notification);
|
||
if (ok) {
|
||
this._dismissedNotifications.add(nid);
|
||
this._pendingAck.delete(nid);
|
||
} else {
|
||
this._pendingAck.set(nid, notification);
|
||
}
|
||
} catch(e) { this._pendingAck.set(nid, notification); }
|
||
removeToastLocal();
|
||
};
|
||
|
||
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(() => {
|
||
// on automatic timeout, attempt ack and schedule retry if needed
|
||
(async () => {
|
||
try {
|
||
const ok = await this.ackNotification(notification);
|
||
if (ok) {
|
||
this._dismissedNotifications.add(nid);
|
||
this._pendingAck.delete(nid);
|
||
} else {
|
||
this._pendingAck.set(nid, notification);
|
||
}
|
||
} catch (e) { this._pendingAck.set(nid, notification); }
|
||
removeToastLocal();
|
||
})();
|
||
}, 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();
|
||
});
|
||
|
||
// Forward handler: open preview when user chooses to forward an existing message
|
||
this.forwardMessage = function(messageId) {
|
||
try {
|
||
const input = document.getElementById('message-input');
|
||
if (input) {
|
||
input.dataset.forwardTo = messageId;
|
||
}
|
||
this.showReplyPreview(messageId, 'forward');
|
||
// Optionally focus the recipient selection by showing a hint or opening the sidebar
|
||
} catch (e) {
|
||
console.warn('forwardMessage failed', e);
|
||
}
|
||
};
|
||
|
||
// 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;
|
||
input.dataset.forwardTo = null;
|
||
this.showReplyPreview(messageId, 'reply');
|
||
input.focus();
|
||
};
|
||
|
||
// Reaction picker: open a small emoji palette near the click
|
||
this.openReactionPicker = function(ev, messageId) {
|
||
if (ev && ev.stopPropagation) ev.stopPropagation();
|
||
const picker = document.getElementById('reaction-picker');
|
||
if (!picker) return;
|
||
this._pendingReactionMessage = messageId;
|
||
|
||
// position picker near event or center
|
||
picker.style.display = 'block';
|
||
picker.classList.remove('show');
|
||
// compute position
|
||
let left = (window.innerWidth / 2) - (picker.offsetWidth / 2);
|
||
let top = (window.innerHeight / 2) - (picker.offsetHeight / 2);
|
||
if (ev && ev.clientX) {
|
||
left = Math.min(Math.max(8, ev.clientX - 40), window.innerWidth - picker.offsetWidth - 8);
|
||
top = Math.min(Math.max(8, ev.clientY - 24), window.innerHeight - picker.offsetHeight - 8);
|
||
}
|
||
picker.style.left = left + 'px';
|
||
picker.style.top = top + 'px';
|
||
|
||
// small delay to allow CSS transition
|
||
setTimeout(() => picker.classList.add('show'), 10);
|
||
};
|
||
|
||
// Hide reaction picker on document click
|
||
document.addEventListener('click', (ev) => {
|
||
const picker = document.getElementById('reaction-picker');
|
||
if (picker && picker.style.display === 'block' && !picker.contains(ev.target)) {
|
||
picker.classList.remove('show');
|
||
setTimeout(()=>{ picker.style.display='none'; },160);
|
||
}
|
||
});
|
||
|
||
// Attach click handler for emojis (event delegation)
|
||
document.addEventListener('click', async (ev) => {
|
||
const t = ev.target;
|
||
if (t && t.dataset && t.dataset.emoji) {
|
||
ev.stopPropagation();
|
||
const emoji = t.dataset.emoji;
|
||
const mid = this._pendingReactionMessage;
|
||
if (!mid) return;
|
||
try {
|
||
const resp = await fetch('api/react_message.php', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ message_id: mid, emoji: emoji }) });
|
||
const j = await resp.json();
|
||
if (j && j.success) {
|
||
// update local model and UI
|
||
const m = this.conversations.find(x => x.message_id == mid || x.id == mid);
|
||
if (m) m.reaction_emoji = emoji;
|
||
this.updateMessageReactionInView && this.updateMessageReactionInView(mid, emoji);
|
||
} else {
|
||
showAlert && showAlert('Error aplicando reacción', 'danger');
|
||
}
|
||
} catch (err) {
|
||
console.error('Reaction error', err);
|
||
showAlert && showAlert('Error aplicando reacción', 'danger');
|
||
} finally {
|
||
const picker = document.getElementById('reaction-picker');
|
||
if (picker) {
|
||
picker.classList.remove('show');
|
||
setTimeout(()=>{ picker.style.display='none'; },160);
|
||
}
|
||
this._pendingReactionMessage = null;
|
||
}
|
||
}
|
||
});
|
||
|
||
// 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';
|
||
if (typeof updateSendMicVisibility === 'function') updateSendMicVisibility();
|
||
});
|
||
}
|
||
|
||
// 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');
|
||
const sendBtn = document.getElementById('send-btn');
|
||
const messageInput = document.getElementById('message-input');
|
||
|
||
// Move mic button to be next to send (so it appears in the send slot)
|
||
try {
|
||
if (micBtn && sendBtn && micBtn.parentNode) {
|
||
sendBtn.parentNode.insertBefore(micBtn, sendBtn);
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
|
||
if (micBtn) {
|
||
micBtn.addEventListener('click', () => {
|
||
if (this._mediaRecorder && this._mediaRecorder.state === 'recording') {
|
||
this.stopRecording();
|
||
} else {
|
||
this.startRecording();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Toggle visibility: show mic when input empty, show send when there's text, a file selected, or template mode
|
||
const updateSendMicVisibility = () => {
|
||
const text = (messageInput && messageInput.value) ? messageInput.value.trim() : '';
|
||
const typeSelect = document.getElementById('message-type');
|
||
const isTemplate = typeSelect && typeSelect.value === 'template';
|
||
const fileSelected = !!this.selectedFile;
|
||
// If currently recording, show mic and don't switch to send
|
||
if (this._mediaRecorder && this._mediaRecorder.state === 'recording') {
|
||
if (sendBtn) sendBtn.style.display = 'none';
|
||
if (micBtn) micBtn.style.display = 'inline-flex';
|
||
return;
|
||
}
|
||
if (text.length > 0 || isTemplate || fileSelected) {
|
||
if (sendBtn) sendBtn.style.display = 'inline-block';
|
||
if (micBtn) micBtn.style.display = 'none';
|
||
} else {
|
||
if (sendBtn) sendBtn.style.display = 'none';
|
||
if (micBtn) micBtn.style.display = 'inline-flex';
|
||
}
|
||
};
|
||
|
||
if (messageInput) {
|
||
messageInput.addEventListener('input', updateSendMicVisibility);
|
||
}
|
||
|
||
// Expose small helper to other methods
|
||
this._updateSendMicVisibility = updateSendMicVisibility;
|
||
|
||
|
||
// File input selected -> show send button
|
||
const fileInput = document.getElementById('file-input');
|
||
if (fileInput) {
|
||
fileInput.addEventListener('change', (ev) => {
|
||
if (ev.target.files && ev.target.files.length) {
|
||
// existing showMediaPreview handles storing this.selectedFile
|
||
this.showMediaPreview(ev.target.files[0]);
|
||
}
|
||
updateSendMicVisibility();
|
||
});
|
||
}
|
||
|
||
// Initialize visibility
|
||
setTimeout(updateSendMicVisibility, 10);
|
||
|
||
// Attach '+' menu handling
|
||
const attachBtn = document.getElementById('attach-btn');
|
||
const attachMenu = document.getElementById('attach-menu');
|
||
if (attachBtn && attachMenu) {
|
||
attachBtn.addEventListener('click', (ev) => {
|
||
ev.stopPropagation();
|
||
const isOpen = attachMenu.style.display === 'block';
|
||
// position menu under the button
|
||
const rect = attachBtn.getBoundingClientRect();
|
||
attachMenu.style.left = (rect.left) + 'px';
|
||
attachMenu.style.top = (rect.bottom + 8) + 'px';
|
||
attachMenu.style.display = isOpen ? 'none' : 'block';
|
||
});
|
||
|
||
// Option clicks
|
||
attachMenu.addEventListener('click', (ev) => {
|
||
const action = ev.target && ev.target.dataset ? ev.target.dataset.action : null;
|
||
if (!action) return;
|
||
ev.stopPropagation();
|
||
attachMenu.style.display = 'none';
|
||
if (action === 'file') {
|
||
if (fileInput) fileInput.click();
|
||
} else if (action === 'template') {
|
||
// switch to template mode and focus selector
|
||
const typeSelect = document.getElementById('message-type');
|
||
if (typeSelect) {
|
||
typeSelect.value = 'template';
|
||
const evt = new Event('change');
|
||
typeSelect.dispatchEvent(evt);
|
||
const tplSel = document.getElementById('templateSelect');
|
||
if (tplSel) tplSel.focus();
|
||
updateSendMicVisibility();
|
||
}
|
||
}
|
||
});
|
||
|
||
// close when clicking outside
|
||
document.addEventListener('click', (ev) => {
|
||
if (attachMenu && attachMenu.style.display === 'block' && !attachMenu.contains(ev.target) && ev.target !== attachBtn) {
|
||
attachMenu.style.display = 'none';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Mobile: sidebar toggle
|
||
const sidebar = document.querySelector('.chat-sidebar');
|
||
const sidebarToggle = document.getElementById('sidebar-toggle');
|
||
if (sidebarToggle && sidebar) {
|
||
sidebarToggle.addEventListener('click', (ev) => {
|
||
ev.preventDefault();
|
||
// toggle mobile-hidden class
|
||
sidebar.classList.toggle('mobile-hidden');
|
||
});
|
||
// ensure sidebar hidden by default on small screens
|
||
const applyInitialMobile = () => {
|
||
const chatAreaEl = document.getElementById('chat-area');
|
||
const sidebarToggleBtn = document.getElementById('sidebar-toggle');
|
||
if (window.innerWidth <= 768) {
|
||
// On small screens, show the conversations list by default
|
||
sidebar.classList.remove('mobile-hidden');
|
||
// hide chat area if no conversation selected; otherwise show chat and hide sidebar
|
||
if (chatAreaEl) {
|
||
if (!this.currentUserId) {
|
||
chatAreaEl.style.display = 'none';
|
||
} else {
|
||
chatAreaEl.style.display = 'flex';
|
||
sidebar.classList.add('mobile-hidden');
|
||
}
|
||
}
|
||
if (sidebarToggleBtn) sidebarToggleBtn.style.display = 'inline-block';
|
||
} else {
|
||
// Desktop: show both panels
|
||
sidebar.classList.remove('mobile-hidden');
|
||
if (chatAreaEl) chatAreaEl.style.display = this.currentUserId ? 'flex' : 'none';
|
||
if (sidebarToggleBtn) sidebarToggleBtn.style.display = 'none';
|
||
}
|
||
};
|
||
applyInitialMobile();
|
||
window.addEventListener('resize', applyInitialMobile);
|
||
}
|
||
|
||
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');
|
||
|
||
// On mobile, hide the sidebar when opening a conversation so chat occupies full screen
|
||
const sidebar = document.querySelector('.chat-sidebar');
|
||
if (sidebar && window.innerWidth <= 768) {
|
||
sidebar.classList.add('mobile-hidden');
|
||
}
|
||
|
||
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) {
|
||
// Mostrar como acciones de Atender / Finalizar
|
||
btn.textContent = conv.bot_enabled ? 'Finalizar' : 'Atender';
|
||
btn.title = conv.bot_enabled ? 'Finalizar atención' : 'Atender';
|
||
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;
|
||
// update button label to match new state
|
||
btn.textContent = conv.bot_enabled ? 'Finalizar' : 'Atender';
|
||
btn.title = conv.bot_enabled ? 'Finalizar atención' : 'Atender';
|
||
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');
|
||
// Ensure UI shows mic and hides send while recording
|
||
if (this._updateSendMicVisibility) this._updateSendMicVisibility();
|
||
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');
|
||
if (this._updateSendMicVisibility) this._updateSendMicVisibility();
|
||
};
|
||
|
||
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');
|
||
if (this._updateSendMicVisibility) this._updateSendMicVisibility();
|
||
};
|
||
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// Improved dedupe: pick the most "readable" message when duplicates exist
|
||
try {
|
||
const best = new Map(); // key => { index, score }
|
||
const toRemove = [];
|
||
|
||
const scoreFor = (m) => {
|
||
let s = 0;
|
||
if (!m) return s;
|
||
// Prefer explicit media type messages
|
||
if (m.message_type && m.message_type !== 'text') s += 50;
|
||
// Prefer messages with thumbnails or local files
|
||
if (m.local_thumb || m.local_file || m.media_url_external || m.media_url) s += 30;
|
||
// Prefer human text content (avoid raw JSON blobs)
|
||
if (m.content) {
|
||
const c = String(m.content).trim();
|
||
const looksJson = c.startsWith('{') && c.endsWith('}');
|
||
if (looksJson) {
|
||
s -= 20; // penalize raw JSON-looking content
|
||
} else {
|
||
s += 10;
|
||
if (c.length < 200) s += 5;
|
||
}
|
||
}
|
||
if (m.message_id) s += 3;
|
||
if (m.reply_to_message_id) s += 2;
|
||
return s;
|
||
};
|
||
|
||
for (let i = this.conversations.length - 1; i >= 0; i--) {
|
||
const m = this.conversations[i];
|
||
if (!m) continue;
|
||
const mid = m.message_id || m.id || null;
|
||
let key = null;
|
||
if (mid) key = String(mid);
|
||
else {
|
||
const partMedia = m.media_url || m.media_url_external || m.local_file || m.local_thumb || m.content || '';
|
||
const ts = m.created_at ? String(Math.floor(new Date(m.created_at).getTime() / 1000)) : '';
|
||
key = `${m.direction||'?'}|${m.message_type||m.media_type||'text'}|${partMedia}|${ts}`;
|
||
}
|
||
|
||
const s = scoreFor(m);
|
||
if (!best.has(key)) {
|
||
best.set(key, { index: i, score: s });
|
||
} else {
|
||
const prev = best.get(key);
|
||
if (s > prev.score) {
|
||
// keep current, remove previous
|
||
toRemove.push(prev.index);
|
||
best.set(key, { index: i, score: s });
|
||
} else {
|
||
// remove current
|
||
toRemove.push(i);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (toRemove.length) {
|
||
// dedupe unique indices
|
||
const uniq = Array.from(new Set(toRemove)).sort((a,b) => a - b);
|
||
// remove from highest index down
|
||
for (let j = uniq.length - 1; j >= 0; j--) {
|
||
const idx = uniq[j];
|
||
const removed = this.conversations.splice(idx, 1)[0];
|
||
console.debug('Dedupe removed message at index', idx, 'removed=', removed && (removed.message_id || removed.id || removed.content || removed.media_url) );
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('Dedupe failed', e);
|
||
}
|
||
|
||
// Actualizar estado de paginación
|
||
this.hasMoreMessages = hasMore;
|
||
if (earliest) this.earliestMessage = earliest;
|
||
|
||
// Renderizar incrementalmente para evitar parpadeos y preservar reproducción de media
|
||
// Si cargamos paginación (initial=false), pasar la cantidad de mensajes recién obtenidos para insertarlos al inicio
|
||
const newlyFetched = (!initial && Array.isArray(messages)) ? messages.length : 0;
|
||
this.renderMessagesIncremental(initial, newlyFetched);
|
||
|
||
// Replace media rendering uses window.renderMediaMessage inside element creation/update
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
// Incremental rendering to avoid DOM replacement that interrupts media playback or scroll
|
||
renderMessagesIncremental(initial = false, prependCount = 0) {
|
||
const container = document.getElementById('chat-conversations');
|
||
if (!container) return;
|
||
|
||
if (initial) {
|
||
container.innerHTML = '';
|
||
}
|
||
|
||
// Map existing nodes
|
||
const existing = new Map();
|
||
Array.from(container.querySelectorAll('[data-message-id]')).forEach(el => {
|
||
existing.set(String(el.dataset.messageId), el);
|
||
});
|
||
|
||
const prependEls = []; // elements to insert at top if we loaded older messages
|
||
|
||
const wasNearBottom = (container.scrollHeight - container.scrollTop - container.clientHeight) < 150;
|
||
|
||
for (let i = 0; i < this.conversations.length; i++) {
|
||
const msg = this.conversations[i];
|
||
const mid = String(msg.message_id || msg.id || ('local_' + i));
|
||
const existingEl = existing.get(mid);
|
||
|
||
if (existingEl) {
|
||
// update in place
|
||
try {
|
||
const rp = existingEl.querySelector('.reply-preview');
|
||
if (msg.reply_to_message_id) {
|
||
const previewSrc = (this.conversations.find(m => m.message_id == msg.reply_to_message_id) || {}).content || ('Mensaje ' + msg.reply_to_message_id);
|
||
if (rp) rp.textContent = 'En respuesta a: ' + previewSrc.substring(0,140);
|
||
else {
|
||
const div = document.createElement('div'); div.className = 'reply-preview'; div.textContent = 'En respuesta a: ' + previewSrc.substring(0,140); existingEl.insertBefore(div, existingEl.firstChild);
|
||
}
|
||
} else if (rp) rp.remove();
|
||
|
||
const body = existingEl.querySelector('.message-content');
|
||
if (body) {
|
||
const mediaPresent = (msg.local_thumb || msg.local_file || msg.media_url_external || msg.media_url);
|
||
const existingMedia = body.querySelector('audio,video,img');
|
||
let existingSrc = null;
|
||
if (existingMedia) existingSrc = existingMedia.getAttribute('src') || existingMedia.getAttribute('data-src');
|
||
const newMediaUrl = mediaPresent ? (msg.local_thumb || msg.local_file || msg.media_url_external || msg.media_url) : null;
|
||
if (!(existingSrc && newMediaUrl && String(existingSrc).includes(newMediaUrl))) {
|
||
const newContent = mediaPresent ? (window.renderMediaMessage ? window.renderMediaMessage(msg) : (msg.content || '[Mensaje]')) : (msg.content || '[Mensaje vacío]');
|
||
body.innerHTML = newContent;
|
||
}
|
||
}
|
||
|
||
const timeEl = existingEl.querySelector('.message-time');
|
||
if (timeEl) timeEl.textContent = msg.created_at ? new Date(msg.created_at).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' }) : '';
|
||
const statusEl = existingEl.querySelector('.message-status');
|
||
if (statusEl) statusEl.innerHTML = this.getStatusIcon(msg.status);
|
||
|
||
const reactBadge = existingEl.querySelector('.reaction-badge');
|
||
if (msg.reaction_emoji) {
|
||
if (reactBadge) reactBadge.textContent = msg.reaction_emoji; else {
|
||
const b = document.createElement('div'); b.className = 'reaction-badge'; b.textContent = msg.reaction_emoji; const bubble = existingEl.querySelector('.message-bubble') || existingEl; bubble.insertBefore(b, bubble.querySelector('.message-actions'));
|
||
}
|
||
} else if (reactBadge) reactBadge.remove();
|
||
|
||
} catch (e) { console.warn('update message failed', e); }
|
||
existing.delete(mid);
|
||
} else {
|
||
// create new element
|
||
try {
|
||
const div = document.createElement('div');
|
||
div.className = 'message ' + (msg.direction || 'incoming');
|
||
div.dataset.messageId = mid;
|
||
|
||
let replyHtml = '';
|
||
if (msg.reply_to_message_id) {
|
||
const target = this.conversations.find(m => m.message_id == msg.reply_to_message_id || m.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: ${window.escapeHtml(previewText)}</div>`;
|
||
}
|
||
const mediaPresent = (msg.local_thumb || msg.local_file || msg.media_url_external || msg.media_url);
|
||
const content = mediaPresent ? (window.renderMediaMessage ? window.renderMediaMessage(msg) : (window.escapeHtml(msg.content || '[Mensaje]'))) : window.escapeHtml(msg.content || '[Mensaje vacío]');
|
||
const time = msg.created_at ? new Date(msg.created_at).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' }) : '';
|
||
const statusIcon = this.getStatusIcon(msg.status);
|
||
const reactionHtml = msg.reaction_emoji ? `<div class="reaction-badge">${msg.reaction_emoji}</div>` : '';
|
||
|
||
div.innerHTML = `
|
||
<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('${window.escapeHtml(msg.message_id || msg.id || '')}')" title="Responder"><i class="fas fa-reply"></i></button>
|
||
<button class="btn btn-sm btn-link" onclick="chat.openReactionPicker(event, '${window.escapeHtml(msg.message_id || msg.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>
|
||
`;
|
||
|
||
// For prepended older messages, collect to insert at top later
|
||
if (prependCount && i < prependCount) {
|
||
prependEls.push(div);
|
||
} else {
|
||
container.appendChild(div);
|
||
}
|
||
} catch (e) { console.warn('create message failed', e); }
|
||
}
|
||
}
|
||
|
||
// insert prepended elements (keep order)
|
||
if (prependEls.length) {
|
||
const frag = document.createDocumentFragment();
|
||
prependEls.forEach(e => frag.appendChild(e));
|
||
container.insertBefore(frag, container.firstChild);
|
||
}
|
||
|
||
// remove any remaining old elements that weren't updated
|
||
existing.forEach((el) => el.remove());
|
||
|
||
// If user was near bottom before update, keep it at bottom to follow conversation
|
||
if (wasNearBottom) this.scrollToBottom();
|
||
}
|
||
|
||
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>';
|
||
}
|
||
}
|
||
|
||
// Update reaction badge for a single message in the DOM
|
||
updateMessageReactionInView(messageId, emoji) {
|
||
try {
|
||
const container = document.getElementById('chat-conversations');
|
||
if (!container) return;
|
||
const el = container.querySelector(`[data-message-id="${messageId}"]`);
|
||
if (!el) return;
|
||
let badge = el.querySelector('.reaction-badge');
|
||
if (emoji) {
|
||
if (badge) {
|
||
badge.textContent = emoji;
|
||
} else {
|
||
const div = document.createElement('div');
|
||
div.className = 'reaction-badge';
|
||
div.textContent = emoji;
|
||
const bubble = el.querySelector('.message-bubble');
|
||
if (bubble) bubble.insertBefore(div, bubble.querySelector('.message-actions'));
|
||
}
|
||
} else {
|
||
if (badge && badge.parentNode) badge.parentNode.removeChild(badge);
|
||
}
|
||
} catch (e) {
|
||
console.warn('updateMessageReactionInView failed', e);
|
||
}
|
||
}
|
||
|
||
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';
|
||
let text = (r.response_text || '').replace(/\n/g,' ');
|
||
text = this.stripEmoji(text);
|
||
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';
|
||
let text = (t.body_text || t.name || t.template_name || '').replace(/\n/g,' ');
|
||
text = this.stripEmoji(text);
|
||
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) {
|
||
const replyToTemplate = document.getElementById('message-input').dataset.replyTo || null;
|
||
this.addMessageToView(`Plantilla: ${templateName}`, 'outgoing', { reply_to_message_id: replyToTemplate });
|
||
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', options = {}) {
|
||
const msg = {
|
||
message_id: 'local_' + Date.now(),
|
||
direction: type === 'outgoing' ? 'outgoing' : 'incoming',
|
||
content: content,
|
||
created_at: new Date().toISOString(),
|
||
status: 'sent'
|
||
};
|
||
// Soportar reply preview inmediato
|
||
if (options && options.reply_to_message_id) {
|
||
msg.reply_to_message_id = options.reply_to_message_id;
|
||
}
|
||
// Soportar attachments/meta si es necesario
|
||
if (options && options.message_type) {
|
||
msg.message_type = options.message_type;
|
||
}
|
||
|
||
this.conversations.push(msg);
|
||
this.renderMessagesIncremental();
|
||
this.scrollToBottom();
|
||
}
|
||
|
||
async showReplyPreview(messageId, type = 'reply') {
|
||
try {
|
||
const preview = document.getElementById('reply-preview');
|
||
const previewText = document.getElementById('reply-preview-text');
|
||
const cancelBtn = document.getElementById('cancel-reply-btn');
|
||
let msg = this.conversations.find(m => m.message_id == messageId || m.id == messageId);
|
||
|
||
const setPreviewFromMsg = (m) => {
|
||
const text = m ? (m.content || m.message_text || (m.caption || '[Mensaje]')) : ('Mensaje ' + messageId);
|
||
if (preview && previewText) {
|
||
if (type === 'reply') previewText.textContent = 'En respuesta a: ' + (String(text).replace(/\n/g,' ')).substring(0,140);
|
||
else if (type === 'forward') previewText.textContent = 'Reenviando: ' + (String(text).replace(/\n/g,' ')).substring(0,140);
|
||
preview.style.display = 'flex';
|
||
}
|
||
};
|
||
|
||
if (!msg) {
|
||
// intentar obtener mensaje por API
|
||
try {
|
||
const resp = await fetch(`api/get_message.php?message_id=${encodeURIComponent(messageId)}`);
|
||
if (resp.ok) {
|
||
const j = await resp.json();
|
||
if (j && j.success && j.data) {
|
||
msg = j.data;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.warn('Could not fetch referenced message', err);
|
||
}
|
||
}
|
||
|
||
setPreviewFromMsg(msg);
|
||
|
||
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;
|
||
input.dataset.forwardTo = null;
|
||
}
|
||
} catch (e) {
|
||
console.warn('hideReplyPreview failed', e);
|
||
}
|
||
}
|
||
|
||
async sendMessage() {
|
||
// If a file is selected (media preview active), delegate to sendMediaMessage to avoid duplicate sends
|
||
if (this.selectedFile) {
|
||
await this.sendMediaMessage();
|
||
return;
|
||
}
|
||
|
||
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', { reply_to_message_id: replyTo });
|
||
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';
|
||
|
||
// Use the main send handler: `sendMessage` will delegate to media send when a file is selected
|
||
const sendBtn = document.getElementById('send-btn');
|
||
// ensure send button is enabled
|
||
if (sendBtn) sendBtn.disabled = false;
|
||
// Update mic/send visibility (hide mic when a file is selected)
|
||
if (this._updateSendMicVisibility) this._updateSendMicVisibility();
|
||
}
|
||
|
||
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];
|
||
}
|
||
|
||
// Remove common emoji/pictograph characters for minimal quick reply labels
|
||
stripEmoji(text) {
|
||
if (!text) return '';
|
||
try {
|
||
return String(text).replace(/[\u{1F300}-\u{1FAFF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu, '').trim();
|
||
} catch (e) {
|
||
return String(text);
|
||
}
|
||
}
|
||
|
||
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) return '';
|
||
const mediaType = message.message_type || message.media_type || 'text';
|
||
const mediaUrl = message.media_url || message.media_url_external || '';
|
||
const caption = message.content || '';
|
||
const localFile = message.local_file || '';
|
||
const localThumb = message.local_thumb || '';
|
||
const thumb = localThumb ? (`/${localThumb}`) : (localFile ? (`/${localFile}`) : (message.media_url_external || mediaUrl));
|
||
|
||
let full = '';
|
||
if (localFile) {
|
||
full = `/api/version/media-url.php?local=${encodeURIComponent(localFile)}`;
|
||
} else if (message.media_url_external) {
|
||
if (message.media_url_external.indexOf('api/get_media.php?id=') !== -1) {
|
||
const parts = message.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(message.media_url_external)) {
|
||
full = message.media_url_external;
|
||
} else {
|
||
full = `/api/version/media-url.php?url=${encodeURIComponent(message.media_url_external)}`;
|
||
}
|
||
} else {
|
||
full = mediaUrl || '';
|
||
}
|
||
|
||
switch (mediaType) {
|
||
case 'image':
|
||
return `
|
||
<div class="message-media">
|
||
<a href="#" data-full="${escapeHtml(full)}" data-type="image" data-caption="${escapeHtml(caption)}" onclick="openMediaLightbox(this.dataset.full, this.dataset.type, this.dataset.caption); return false;" title="Abrir imagen">
|
||
<img src="${escapeHtml(thumb)}" alt="Imagen" style="cursor:zoom-in">
|
||
</a>
|
||
</div>
|
||
${caption ? `<div>${escapeHtml(caption)}</div>` : ''}
|
||
`;
|
||
case 'video':
|
||
return `
|
||
<div class="message-media">
|
||
<a href="#" data-full="${escapeHtml(full)}" data-type="video" data-caption="${escapeHtml(caption)}" onclick="openMediaLightbox(this.dataset.full, this.dataset.type, this.dataset.caption); return false;" title="Abrir video">
|
||
<div style="position:relative; display:inline-block;">
|
||
<img src="${escapeHtml(thumb)}" alt="Video" style="cursor:zoom-in; max-width:100%; border-radius:6px;">
|
||
<div style="position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); font-size:28px; color:white; text-shadow:0 1px 6px rgba(0,0,0,0.6);"><i class="fas fa-play-circle"></i></div>
|
||
</div>
|
||
</a>
|
||
</div>
|
||
${caption ? `<div>${escapeHtml(caption)}</div>` : ''}
|
||
`;
|
||
case 'audio': {
|
||
let audioSrc = '';
|
||
if (localFile) {
|
||
audioSrc = `/${localFile}`;
|
||
} else if (message.media_url_external) {
|
||
if (message.media_url_external.indexOf('api/get_media.php?id=') !== -1) {
|
||
const parts = message.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(message.media_url_external)) {
|
||
audioSrc = message.media_url_external;
|
||
} else {
|
||
audioSrc = `/api/version/media-url.php?url=${encodeURIComponent(message.media_url_external)}`;
|
||
}
|
||
} else {
|
||
audioSrc = mediaUrl;
|
||
}
|
||
return `
|
||
<div class="message-media">
|
||
<audio controls>
|
||
<source src="${escapeHtml(audioSrc)}" type="audio/mpeg">
|
||
Tu navegador no soporta audio.
|
||
</audio>
|
||
</div>
|
||
`;
|
||
}
|
||
case 'document': {
|
||
let docUrl = '';
|
||
if (localFile) {
|
||
docUrl = `/api/version/media-url.php?local=${encodeURIComponent(localFile)}&download=1`;
|
||
} else if (message.media_url_external) {
|
||
if (message.media_url_external.indexOf('api/get_media.php?id=') !== -1) {
|
||
const parts = message.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(message.media_url_external)) {
|
||
docUrl = `/api/version/media-url.php?url=${encodeURIComponent(message.media_url_external)}&download=1`;
|
||
} else {
|
||
docUrl = `/api/version/media-url.php?url=${encodeURIComponent(message.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">${escapeHtml(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">${escapeHtml(caption || 'Documento')}</div>
|
||
<div class="document-size">
|
||
<a href="${escapeHtml(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 escapeHtml(caption) || '';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Función global para cancelar
|
||
function cancelMediaUpload() {
|
||
if (window.chatApp) {
|
||
window.chatApp.cancelMediaUpload();
|
||
}
|
||
}
|
||
|
||
// Helper para iconos de archivos (copiado de chat_window)
|
||
function getFileIcon(filename) {
|
||
if (!filename) return 'fas fa-file text-muted';
|
||
const ext = String(filename).split('.').pop().toLowerCase();
|
||
const iconMap = {
|
||
'pdf': 'fas fa-file-pdf text-danger',
|
||
'doc': 'fas fa-file-word text-primary',
|
||
'docx': 'fas fa-file-word text-primary',
|
||
'xls': 'fas fa-file-excel text-success',
|
||
'xlsx': 'fas fa-file-excel text-success',
|
||
'ppt': 'fas fa-file-powerpoint text-warning',
|
||
'pptx': 'fas fa-file-powerpoint text-warning',
|
||
'zip': 'fas fa-file-archive text-secondary',
|
||
'rar': 'fas fa-file-archive text-secondary',
|
||
'txt': 'fas fa-file-alt text-muted',
|
||
'csv': 'fas fa-file-csv text-success'
|
||
};
|
||
return iconMap[ext] || 'fas fa-file text-muted';
|
||
}
|
||
|
||
// Media lightbox helper (image/video with download)
|
||
function openMediaLightbox(url, type = 'image', caption = '') {
|
||
if (!url) {
|
||
showAlert && showAlert('Media no disponible', 'warning');
|
||
return;
|
||
}
|
||
const body = document.getElementById('imageLightboxBody');
|
||
const cap = document.getElementById('imageLightboxCaption');
|
||
const downloadBtn = document.getElementById('imageLightboxDownload');
|
||
if (!body || !cap) return;
|
||
// clear existing content
|
||
body.innerHTML = '';
|
||
|
||
// Decide rendering by type or by URL extension
|
||
const isVideo = (type === 'video') || /\.(mp4|webm|ogg)(\?|$)/i.test(url);
|
||
if (isVideo) {
|
||
body.innerHTML = `<video controls class="w-100" style="border-radius:10px;"><source src="${escapeHtml(url)}" type="video/mp4">Tu navegador no soporta video.</video>`;
|
||
} else {
|
||
body.innerHTML = `<img src="${escapeHtml(url)}" class="img-fluid w-100" style="border-radius:10px;">`;
|
||
}
|
||
|
||
cap.textContent = caption || '';
|
||
if (downloadBtn) {
|
||
downloadBtn.href = url;
|
||
downloadBtn.style.display = 'inline-block';
|
||
}
|
||
|
||
const modalEl = document.getElementById('imageLightboxModal');
|
||
const bsModal = new bootstrap.Modal(modalEl);
|
||
bsModal.show();
|
||
}
|
||
|
||
// backward compatibility wrapper
|
||
function openImageLightbox(url, caption = '') {
|
||
openMediaLightbox(url, 'image', caption);
|
||
}
|
||
|
||
// Inicializar cuando la página cargue
|
||
let chat;
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
chat = new WhatsAppChat();
|
||
window.chatApp = chat; // Exponer globalmente para funciones auxiliares
|
||
});
|
||
</script>
|
||
|
||
<!-- Lightbox Modal -->
|
||
<div class="modal fade" id="imageLightboxModal" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||
<div class="modal-content bg-transparent border-0">
|
||
<div class="modal-body p-0 position-relative">
|
||
<button type="button" class="btn-close btn-close-white position-absolute top-0 end-0 m-3" data-bs-dismiss="modal" aria-label="Cerrar" style="z-index:10;"></button>
|
||
<div id="imageLightboxBody" style="max-height:75vh; overflow:auto;"></div>
|
||
<div id="imageLightboxCaption" class="mt-2 text-center text-white small"></div>
|
||
<div class="position-absolute bottom-0 end-0 m-3">
|
||
<a id="imageLightboxDownload" class="btn btn-sm btn-light" href="#" target="_blank" download style="display:none;"><i class="fas fa-download"></i> Descargar</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Reaction picker -->
|
||
<div id="reaction-picker" aria-hidden="true">
|
||
<div style="display:flex; flex-wrap:wrap; gap:6px;">
|
||
<button class="emoji" data-emoji="👍">👍</button>
|
||
<button class="emoji" data-emoji="❤️">❤️</button>
|
||
<button class="emoji" data-emoji="😂">😂</button>
|
||
<button class="emoji" data-emoji="😮">😮</button>
|
||
<button class="emoji" data-emoji="😢">😢</button>
|
||
<button class="emoji" data-emoji="👏">👏</button>
|
||
<button class="emoji" data-emoji="🎉">🎉</button>
|
||
<button class="emoji" data-emoji="🔥">🔥</button>
|
||
<button class="emoji" data-emoji="🙏">🙏</button>
|
||
</div>
|
||
</div>
|
||
|
||
</body>
|
||
</html>
|