/** * Widget de Angel para Recordandote.com * Tema dorado con avatar Angel - Chat flotante embebible * * Soporta: * - Avatar en cada mensaje del bot (foto redondeada arriba-izquierda) * - Deteccion de __SAVE_DATA__: para guardar en sesion de Django * - Guardado temporal via Django Session (patron shopping cart) * - Boton de Google inline en la burbuja del bot cuando pide login * - Reanudacion automatica al volver del login de Google * * Uso: * * * Variables de entorno (opcionales): * window.ANGEL_API_BASE - URL base de Angel (default: https://angel.railway.app) * window.DJANGO_API_BASE - URL base de Django (default: https://recordandote.com/api) * window.GOOGLE_LOGIN_URL - URL de login Google (default: https://recordandote.com/social-auth/login/google-oauth2/) */ (function(){ 'use strict'; // ============================================================ // CONFIGURACION // ============================================================ var CONFIG = { apiBase: window.ANGEL_API_BASE || 'https://angel.railway.app', djangoApiBase: window.DJANGO_API_BASE || 'https://recordandote.com/api', googleLoginUrl: window.GOOGLE_LOGIN_URL || 'https://recordandote.com/social-auth/login/google-oauth2/', autoOpen: false, avatarUrl: (window.ANGEL_API_BASE || 'https://angel.railway.app') + '/static/Angel.jpg', }; // Estado interno var state = { isOpen: false, userId: null, sessionId: null, userToken: null, isAuthenticated: false, firstMsg: true, pendingData: null, loginInProgress: false, }; // Referencias a elementos DOM (se llenan en init) var dom = {}; // ============================================================ // UTILIDADES // ============================================================ function generateId() { return 'angel_' + Math.random().toString(36).substr(2, 9); } function log() { if (window.console && console.log) { var args = ['[Angel Widget]']; for (var i = 0; i < arguments.length; i++) args.push(arguments[i]); console.log.apply(console, args); } } function logError() { if (window.console && console.error) { var args = ['[Angel Widget]']; for (var i = 0; i < arguments.length; i++) args.push(arguments[i]); console.error.apply(console, args); } } function escapeHtml(text) { var map = { '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }; return text.replace(/[&<>"']/g, function(m) { return map[m]; }); } // ============================================================ // API: SESION TEMPORAL EN DJANGO (patron shopping cart) // ============================================================ function saveDataToSession(datos) { return fetch(CONFIG.djangoApiBase + '/sesion-temporal/', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(datos), }).then(function(r) { if (r.ok) log('Datos guardados en sesion Django:', datos); else logError('Error guardando datos, status:', r.status); return r; }).catch(function(e) { logError('Error guardando datos en sesion:', e); }); } function getPendingData() { return fetch(CONFIG.djangoApiBase + '/sesion-temporal/', { method: 'GET', credentials: 'include', headers: { 'Accept': 'application/json' }, }).then(function(r) { if (!r.ok) return null; return r.json().then(function(data) { return data.datos || null; }); }).catch(function(e) { logError('Error recuperando datos pendientes:', e); return null; }); } function clearPendingData() { var headers = {}; if (state.userToken) { headers['Authorization'] = 'Token ' + state.userToken; } return fetch(CONFIG.djangoApiBase + '/sesion-temporal/', { method: 'DELETE', credentials: 'include', headers: headers, }).catch(function(e) { logError('Error limpiando sesion temporal:', e); }); } // ============================================================ // API: AUTENTICACION EN DJANGO // ============================================================ function getAuthToken() { return fetch(CONFIG.djangoApiBase + '/auth/token/', { method: 'GET', credentials: 'include', headers: { 'Accept': 'application/json' }, }).then(function(r) { if (!r.ok) { throw new Error('no auth'); } return r.json(); }).then(function(data) { state.userToken = data.token; state.userId = 'user_' + data.user_id; state.isAuthenticated = true; log('Usuario autenticado:', data.username); return data.token; }).catch(function() { log('Usuario no autenticado (anonimo)'); return null; }); } // ============================================================ // HTML DEL WIDGET // ============================================================ var GOOGLE_SVG = ''; var WIDGET_HTML = [ '
', ' ', ' ', '', ' ', '
', ' ', '
', ' Angel', '
', '
Angel
', '
En línea
', '
', ' ', '
', '', ' ', '
', ' Angel', '
Angel
', '
Asistente virtual de Recordandote.com, una plataforma de perfiles digitales para honrar la memoria de seres queridos.
', '
', '', ' ', '
', ' ', '
', '
', '
', '
', '
', '
', '', ' ', '
', ' ', ' ', '
', '', ' ', ' ', '
', '
' ].join('\n'); // ============================================================ // CSS DEL WIDGET // ============================================================ var WIDGET_CSS = [ '.angel-widget * { box-sizing: border-box; margin: 0; padding: 0; }', '', '/* === TOGGLE BUTTON === */', '.angel-toggle {', ' position: fixed; bottom: 20px; right: 20px;', ' width: 56px; height: 56px; border-radius: 50%;', ' background: linear-gradient(135deg, #c9a227 0%, #e2bf5a 60%, #b8891a 100%);', ' border: none; cursor: pointer;', ' display: flex; align-items: center; justify-content: center;', ' box-shadow: 0 4px 16px rgba(201,162,39,0.45);', ' transition: all 0.3s ease; z-index: 9999;', ' padding: 0; overflow: hidden;', '}', '.angel-toggle:hover { transform: scale(1.1); box-shadow: 0 6px 20px rgba(201,162,39,0.6); }', '.angel-toggle:active { transform: scale(0.95); }', '.angel-toggle-img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; }', '', '/* === WINDOW === */', '.angel-window {', ' position: fixed; bottom: 90px; right: 20px;', ' width: 380px; height: 560px; background: #fff;', ' border-radius: 16px; box-shadow: 0 8px 48px rgba(0,0,0,0.18);', ' display: flex; flex-direction: column; z-index: 9998;', ' animation: angelSlideUp 0.3s ease; overflow: hidden;', '}', '@keyframes angelSlideUp {', ' from { opacity: 0; transform: translateY(24px); }', ' to { opacity: 1; transform: translateY(0); }', '}', '.angel-hidden { display: none !important; }', '', '/* === HEADER DORADO === */', '.angel-header {', ' background: linear-gradient(135deg, #c9a227 0%, #e2bf5a 60%, #b8891a 100%);', ' padding: 12px 14px; display: flex; align-items: center; gap: 10px; flex-shrink: 0;', '}', '.angel-header-avatar { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; border: 2px solid rgba(255,255,255,0.5); flex-shrink: 0; }', '.angel-header-text { flex: 1; }', '.angel-header-name { font-size: 15px; font-weight: 600; color: #fff; }', '.angel-header-status { font-size: 11px; color: rgba(255,255,255,0.8); }', '.angel-close { background: none; border: none; color: #fff; font-size: 20px; cursor: pointer; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: background 0.15s; flex-shrink: 0; }', '.angel-close:hover { background: rgba(255,255,255,0.2); }', '', '/* === INTRO AREA === */', '.angel-intro { padding: 20px 20px 14px; text-align: center; border-bottom: 1px solid #f0ede5; background: #fff; flex-shrink: 0; }', '.angel-intro-avatar { width: 72px; height: 72px; border-radius: 50%; object-fit: cover; margin-bottom: 8px; border: 3px solid #e8d89a; }', '.angel-intro-name { font-size: 16px; font-weight: 600; color: #2a2a2a; margin-bottom: 6px; }', '.angel-intro-desc { font-size: 12.5px; color: #666; line-height: 1.5; }', '', '/* === MESSAGES AREA === */', '.angel-messages { flex: 1; overflow-y: auto; padding: 14px 14px 8px; display: flex; flex-direction: column; gap: 8px; background: #faf9f7; }', '', '/* === FILAS DE MENSAJE === */', '.angel-msg-row { display: flex; gap: 6px; align-items: flex-end; }', '.angel-msg-row.angel-user { flex-direction: row-reverse; }', '', '/* === AVATAR EN MENSAJES DEL BOT === */', '.angel-msg-avatar {', ' width: 28px; height: 28px; border-radius: 50%;', ' object-fit: cover; flex-shrink: 0; align-self: flex-end;', '}', '.angel-msg-row.angel-user .angel-msg-avatar { display: none; }', '', '/* === BURBUJAS === */', '.angel-bubble { max-width: 78%; padding: 9px 14px; font-size: 14px; line-height: 1.5; border-radius: 18px; word-wrap: break-word; }', '.angel-bubble.angel-bot { background: #fff; color: #222; border-bottom-left-radius: 5px; border: 1px solid #eae5d8; }', '.angel-bubble.angel-user { background: linear-gradient(135deg, #c9a227, #ddb94a); color: #fff; border-bottom-right-radius: 5px; }', '', '/* === BOTON GOOGLE INLINE EN BURBUJA === */', '.angel-google-btn {', ' display: inline-flex; align-items: center; gap: 8px;', ' padding: 7px 16px; border: 1px solid #ddd; border-radius: 20px;', ' background: #fff; color: #444; font-size: 13px; font-weight: 500;', ' cursor: pointer; transition: box-shadow 0.15s; margin-top: 8px;', '}', '.angel-google-btn:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.12); background: #fafafa; }', '.angel-google-btn:active { transform: scale(0.97); }', '', '/* === TYPING INDICATOR === */', '.angel-typing { display: flex; gap: 4px; align-items: center; padding: 10px 14px; background: #fff; border-radius: 18px; border-bottom-left-radius: 5px; border: 1px solid #eae5d8; width: fit-content; }', '.angel-dot { width: 7px; height: 7px; border-radius: 50%; background: #c9a227; animation: angelBounce 1.2s infinite; }', '.angel-dot:nth-child(2) { animation-delay: 0.2s; }', '.angel-dot:nth-child(3) { animation-delay: 0.4s; }', '@keyframes angelBounce { 0%,80%,100% { transform: translateY(0); opacity: 0.35; } 40% { transform: translateY(-6px); opacity: 1; } }', '', '/* === INPUT AREA === */', '.angel-input-area { display: flex; align-items: center; gap: 8px; padding: 10px 14px 12px; background: #fff; border-top: 1px solid #ede8db; flex-shrink: 0; }', '.angel-input { flex: 1; border: 1px solid #e0dbd0; border-radius: 24px; padding: 9px 16px; font-size: 14px; outline: none; background: #fff; color: #333; transition: border-color 0.15s; }', '.angel-input:focus { border-color: #c9a227; }', '.angel-input::placeholder { color: #bbb; }', '.angel-send { width: 38px; height: 38px; border-radius: 50%; background: linear-gradient(135deg, #c9a227, #ddb94a); border: none; color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: opacity 0.15s; }', '.angel-send:hover { opacity: 0.85; }', '.angel-send:active { transform: scale(0.95); }', '', '/* === FOOTER === */', '.angel-footer { text-align: center; font-size: 11px; color: #bbb; padding: 6px 0 8px; background: #fff; border-top: 1px solid #f0ede5; flex-shrink: 0; }', '', '/* === MOBILE === */', '@media (max-width: 480px) {', ' .angel-window { width: calc(100vw - 16px); height: calc(100vh - 100px); bottom: 70px; right: 8px; border-radius: 12px; }', '}' ].join('\n'); // ============================================================ // LOGICA: REDIRIGIR A LOGIN GOOGLE // ============================================================ function redirectToGoogleLogin() { var currentUrl = encodeURIComponent(window.location.href); window.location.href = CONFIG.googleLoginUrl + '?next=' + currentUrl; } // ============================================================ // INICIALIZACION // ============================================================ function init() { log('Inicializando...'); state.userId = generateId(); state.sessionId = generateId(); // Inyectar HTML var temp = document.createElement('div'); temp.innerHTML = WIDGET_HTML; document.body.appendChild(temp); // Inyectar CSS var style = document.createElement('style'); style.textContent = WIDGET_CSS; document.head.appendChild(style); // Obtener referencias DOM dom.toggle = document.getElementById('angel-toggle'); dom.close = document.getElementById('angel-close'); dom.window = document.getElementById('angel-window'); dom.messages = document.getElementById('angel-messages'); dom.intro = document.getElementById('angel-intro'); dom.typing = document.getElementById('angel-typing'); dom.input = document.getElementById('angel-input'); dom.send = document.getElementById('angel-send'); // --- Event Listeners --- dom.toggle.addEventListener('click', function() { dom.window.classList.toggle('angel-hidden'); state.isOpen = !state.isOpen; if (state.isOpen) { dom.input.focus(); scrollBottom(dom.messages); } }); dom.close.addEventListener('click', function() { dom.window.classList.add('angel-hidden'); state.isOpen = false; }); dom.send.addEventListener('click', sendMessage); dom.input.addEventListener('keypress', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); // --- Iniciar flujo de autenticacion --- // 1. Obtener token (si ya inicio sesion) getAuthToken().then(function() { // 2. Si esta autenticado, ver si hay datos pendientes en sesion if (state.isAuthenticated) { return getPendingData().then(function(datos) { state.pendingData = datos; }); } }).then(function() { // 3. Si hay datos pendientes, continuar automaticamente if (state.pendingData) { continuarConversacion(state.pendingData); } else { scrollBottom(dom.messages); } }); log('Widget inicializado'); if (CONFIG.autoOpen) { dom.toggle.click(); } } // ============================================================ // FUNCIONES DEL CHAT // ============================================================ function scrollBottom(el) { el.scrollTop = el.scrollHeight; } function addMessage(text, isUser) { // Ocultar intro al primer mensaje del usuario if (state.firstMsg && isUser) { dom.intro.style.display = 'none'; state.firstMsg = false; } var row = document.createElement('div'); row.className = 'angel-msg-row' + (isUser ? ' angel-user' : ''); // Avatar solo en mensajes del bot if (!isUser) { var avatar = document.createElement('img'); avatar.className = 'angel-msg-avatar'; avatar.src = CONFIG.avatarUrl; avatar.alt = 'Angel'; row.appendChild(avatar); } var bubble = document.createElement('div'); bubble.className = 'angel-bubble' + (isUser ? ' angel-user' : ' angel-bot'); bubble.textContent = text; row.appendChild(bubble); dom.messages.insertBefore(row, dom.typing); scrollBottom(dom.messages); } function addBotMessageWithLogin(text, datos) { // Ocultar intro al primer mensaje if (state.firstMsg) { dom.intro.style.display = 'none'; state.firstMsg = false; } var row = document.createElement('div'); row.className = 'angel-msg-row'; // Avatar var avatar = document.createElement('img'); avatar.className = 'angel-msg-avatar'; avatar.src = CONFIG.avatarUrl; avatar.alt = 'Angel'; row.appendChild(avatar); // Burbuja var bubble = document.createElement('div'); bubble.className = 'angel-bubble angel-bot'; // Texto del mensaje var textEl = document.createElement('div'); textEl.textContent = text; bubble.appendChild(textEl); // Boton de Google inline var btn = document.createElement('button'); btn.className = 'angel-google-btn'; btn.innerHTML = GOOGLE_SVG + ' Iniciar sesión con Google'; btn.addEventListener('click', redirectToGoogleLogin); bubble.appendChild(btn); row.appendChild(bubble); dom.messages.insertBefore(row, dom.typing); scrollBottom(dom.messages); } function showTyping() { dom.typing.classList.remove('angel-hidden'); scrollBottom(dom.messages); } function hideTyping() { dom.typing.classList.add('angel-hidden'); } function handleBotResponse(botMessage) { // Detectar __SAVE_DATA__: var match = botMessage.match(/__SAVE_DATA__:(.+)/); if (match) { var jsonStr = match[1].trim(); var datos = null; try { datos = JSON.parse(jsonStr); } catch(e) { logError('Error parseando JSON de __SAVE_DATA__:', e); } if (datos) { log('Angel guarda datos en sesion:', datos); saveDataToSession(datos); } // Limpiar el marcador del texto visible botMessage = botMessage.replace(/__SAVE_DATA__:.+/, '').trim(); // Mostrar el mensaje del bot con el boton de Google inline addBotMessageWithLogin(botMessage, datos); } else { // Mensaje normal del bot (con avatar, sin boton) addMessage(botMessage, false); } } function continuarConversacion(datos) { log('Reanudando conversacion con datos:', datos); // Ocultar intro porque ya hay conversacion dom.intro.style.display = 'none'; state.firstMsg = false; // Extraer nombre para el mensaje var nombre = datos.nombre || ''; // Limpiar la sesion temporal en Django clearPendingData().then(function() { state.pendingData = null; }); // Preparar mensaje con todos los datos acumulados var msg = 'Continuemos con los datos pendientes: ' + JSON.stringify(datos) + '.'; // Mostrar el mensaje del sistema (no del usuario) var row = document.createElement('div'); row.className = 'angel-msg-row angel-user'; var bubble = document.createElement('div'); bubble.className = 'angel-bubble angel-user'; var textoUsuario = 'He iniciado sesión.'; if (nombre) textoUsuario += ' Continuemos con ' + nombre + '.'; bubble.innerHTML = textoUsuario; row.appendChild(bubble); dom.messages.insertBefore(row, dom.typing); showTyping(); // Llamar a Angel con el token y los datos fetch(CONFIG.apiBase + '/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: state.userId, session_id: state.sessionId, message: msg, user_token: state.userToken, }), }).then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }).then(function(data) { hideTyping(); setTimeout(function() { handleBotResponse(data.response); }, 400); }).catch(function(error) { logError('Error al reanudar:', error); hideTyping(); addMessage('Hubo un error al reanudar la conversación. Intenta de nuevo.', false); }); } function validarToken() { // Verifica si el token sigue siendo válido en Django. // Si no es válido, limpia state.userToken y retorna false. if (!state.userToken) return Promise.resolve(false); return fetch(CONFIG.djangoApiBase + '/auth/token/', { method: 'GET', credentials: 'include', headers: { 'Accept': 'application/json' }, }).then(function(r) { if (!r.ok) { log('Token expirado, limpiando'); state.userToken = null; state.isAuthenticated = false; return false; } return true; }).catch(function() { // Error de red, asumimos que el token sigue siendo válido return true; }); } function sendMessage() { var message = dom.input.value.trim(); if (!message) return; addMessage(message, true); dom.input.value = ''; showTyping(); // Verificar token antes de enviar (sin polling, solo en el momento justo) validarToken().then(function() { fetch(CONFIG.apiBase + '/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: state.userId, session_id: state.sessionId, message: message, user_token: state.userToken, }), }).then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }).then(function(data) { hideTyping(); setTimeout(function() { handleBotResponse(data.response); }, 400); }).catch(function(error) { logError('Error:', error); hideTyping(); addMessage('No pude procesar tu mensaje. Intenta de nuevo.', false); }).then(function() { dom.input.focus(); }); }); } // ============================================================ // ARRANQUE // ============================================================ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();