1 2// Shared HTML-escaper for user-controlled output (F5 stored-XSS hardening). 3// Escapes the five chars that matter in text + attribute contexts. 4// UNIQUELY NAMED on purpose: coachbot loads classic <script>s into one global 5// namespace, and other files (utils.js, site-plan-ui.js) define a weaker 6// `escHtml` that would shadow a bare global by load order. The IIFE below aliases 7// this to a closure-local `escHtml`, which lexical scope makes un-shadowable. 8function escHtmlHdr(s) { 9 return String(s == null ? '' : s) 10 .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') 11 .replace(/"/g, '"').replace(/'/g, '''); 12} 13 14// Refresh coach badge in header 15function refreshCoachBadge(coachName) { 16 // Update the header coach pill 17 var pill = document.getElementById('coachPill'); 18 if (pill) { 19 pill.innerHTML = '\uD83E\uDD16 ' + escHtmlHdr(coachName); 20 // Flash animation to draw attention 21 pill.style.transition = 'all 0.3s'; 22 pill.style.background = '#c7d2fe'; 23 setTimeout(function() { pill.style.background = '#e3f2fd'; }, 1000); 24 } 25} 26 27// Admin API helper â uses cookie auth, hits /admin-api/ prefix 28async function adminApi(path, opts = {}) { 29 var headers = Object.assign({}, opts.headers || {}); 30 if (opts.body && typeof opts.body === 'string') { 31 headers['Content-Type'] = 'application/json'; 32 } 33 var res = await fetch('/admin-api' + path, Object.assign({}, opts, { headers: headers, credentials: 'include' })); 34 return res.json(); 35} 36 37// messages.js â Unified Messaging Inbox (all channels, all coaches) 38(function() { 39 // Closure-local alias of the escaper. Lexical scope makes every escHtml(...) 40 // call below resolve to THIS (the strong, quote-safe version) â a weaker 41 // global escHtml from another script cannot shadow it. See escHtmlHdr above. 42 var escHtml = escHtmlHdr; 43 var conversations = []; 44 var tzFilter = null; 45 var currentFilter = 'all'; 46 var channelFilter = 'all'; 47 var selectedConvId = null; 48 var selectedMessages = []; 49 var coachFilterId = null; 50 var coachesList = []; 51 var funnelData = null; 52 var pipelineMode = false; 53 var stageFilter = null; 54 var pipelineData = []; 55 var pipelineMetrics = null; 56 var pipelinePollTimer = null; 57 58 window._msgCoachFilter = function(coachId) { 59 coachFilterId = coachId; 60 if (pipelineMode) { 61 if (channelFilter === 'pipeline') { 62 loadPipelineView(); 63 } else if (channelFilter === 'activity') { 64 loadActivityView(); 65 } else { 66 renderInbox(); 67 } 68 } else { 69 renderInbox(); 70 } 71 if (coachId) { 72 loadMsgFunnel(coachId); 73 } 74 }; 75 76 async function loadMsgFunnel(coachId) { 77 var el = document.getElementById('msgFunnelBar'); 78 if (!el) return; 79 try { 80 var data = await api('/coaches/' + coachId + '/funnel'); 81 if (!data || !data.stages || data.stages.length === 0) { el.innerHTML = ''; return; } 82 funnelData = data; 83 var stages = data.stages; 84 var maxCount = Math.max(1, Math.max.apply(null, stages.map(function(s) { return s.count; }))); 85 86 var html = '<div style="padding:12px 16px;border-bottom:1px solid #e0e0e0;background:#fafafa;">' + 87 '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">' + 88 '<span style="font-size:12px;font-weight:600;color:#312e81;">Pipeline</span>' + 89 '<button onclick="window._suggestFunnelSteps(' + coachId + ')" style="background:none;border:1px solid #ddd;border-radius:4px;padding:2px 8px;font-size:10px;color:#666;cursor:pointer;">Update Steps</button>' + 90 ' <button onclick="window._reassessFunnel(' + coachId + ')" style="background:none;border:1px solid #ddd;border-radius:4px;padding:2px 8px;font-size:10px;color:#666;cursor:pointer;">Reassess</button>' + 91 ' <button onclick="window._editFunnelSteps(' + coachId + ')" style="background:none;border:1px solid #ddd;border-radius:4px;padding:2px 8px;font-size:10px;color:#666;cursor:pointer;">Edit</button>' + 92 '</div>' + 93 '<div style="display:flex;gap:3px;align-items:flex-end;height:48px;">'; 94
95 stages.forEach(function(stage) { 96 var barH = Math.max(6, Math.round((stage.count / maxCount) * 40)); 97 var isFiltered = (stageFilter === stage.id); 98 html += '<div style="flex:1;text-align:center;cursor:pointer;' + (isFiltered ? 'background:#f0f0ff;border-radius:6px;' : '') + '" onclick="window._msgStageFilter(\'' + stage.id + '\')" title="' + escHtml(stage.name) + '">' + 99 '<div style="font-size:10px;font-weight:700;color:' + stage.color + ';">' + stage.count + '</div>' + 100 '<div style="height:' + barH + 'px;background:' + stage.color + ';border-radius:3px 3px 0 0;margin:0 2px;"></div>' + 101 '<div style="font-size:8px;color:#888;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + escHtml(stage.name) + '</div>' + 102 '</div>'; 103 }); 104 105 if (data.unassessed > 0) { 106 html += '<div style="flex:0.5;text-align:center;" title="Not assessed">' + 107 '<div style="font-size:10px;color:#999;">' + data.unassessed + '</div>' + 108 '<div style="height:6px;background:#ddd;border-radius:3px 3px 0 0;margin:0 2px;"></div>' + 109 '<div style="font-size:8px;color:#999;">?</div>' + 110 '</div>'; 111 } 112 113 html += '</div></div>'; 114 el.innerHTML = html; 115 } catch (e) { 116 el.innerHTML = ''; 117 } 118 } 119 120 window._reassessFunnel = async function(coachId) { 121 var btn = event.target.closest('button'); 122 if (btn) { btn.disabled = true; btn.textContent = 'Assessing...'; } 123 try { 124 var result = await api('/coaches/' + coachId + '/funnel/assess', { method: 'POST' }); 125 if (result && btn) btn.textContent = result.updated + '/' + result.total + ' updated'; 126 if (pipelineMode) { await loadPipelineView(); } else { loadMsgFunnel(coachId); } 127 } catch (e) { 128 if (btn) btn.textContent = 'Error'; 129 } 130 setTimeout(function() { if (btn) { btn.disabled = false; btn.textContent = 'Reassess'; } }, 3000); 131 }; 132 133 // ââ Update Funnel Steps (AI suggest â review â approve) ââ 134 window._suggestFunnelSteps = async function(coachId) { 135 var btn = event.target.closest('button'); 136 if (btn) { btn.disabled = true; btn.textContent = 'Generating...'; } 137 try { 138 var result = await api('/coaches/' + coachId + '/funnel/generate', { method: 'POST' }); 139 if (result && result.stages) { 140 showStageReviewModal(coachId, result.stages); 141 } 142 } catch (e) { 143 App.dialog.notice({ title: 'We could not create the steps', body: 'Please try again in a moment.' }); 144 } 145 if (btn) { btn.disabled = false; btn.textContent = 'Update Funnel Steps'; } 146 }; 147 148 // ââ Edit existing stages ââ 149 window._editFunnelSteps = function(coachId) { 150 if (!funnelData || !funnelData.stages) return; 151 var existing = funnelData.stages.map(function(s) { 152 return { id: s.id, name: s.name, color: s.color, order: s.order, description: s.description }; 153 }); 154 showStageEditorModal(coachId, existing); 155 }; 156 157 function showStageReviewModal(coachId, suggestedStages) { 158 159 var existing = document.getElementById('funnelModal'); 160 if (existing) existing.remove(); 161 162 var modal = document.createElement('div'); 163 modal.id = 'funnelModal'; 164 modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center;'; 165 166 var stageRows = suggestedStages.map(function(s, i) { 167 return '<div style="display:flex;align-items:center;gap:10px;padding:10px;background:#f9f9f9;border-radius:8px;margin-bottom:6px;">' + 168 '<div style="width:16px;height:16px;border-radius:50%;background:' + s.color + ';flex-shrink:0;"></div>' + 169 '<div style="flex:1;">' + 170 '<div style="font-weight:600;font-size:13px;">' + s.order + '. ' + escHtml(s.name) + '</div>' + 171 '<div style="font-size:11px;color:#666;">' + escHtml(s.description || '') + '</div>' + 172 '</div>' + 173 '</div>'; 174 }).join(''); 175 176 modal.innerHTML = '<div style="background:#fff;border-radius:12px;padding:24px;max-width:500px;width:90%;max-height:80vh;overflow-y:auto;">' + 177 '<h3 style="margin:0 0 4px;font-size:16px;">Suggested Funnel Steps</h3>' + 178 '<p style="color:#666;font-size:12px;margin:0 0 16px;">AI generated these based on the coach\'s goal and prompt. Review and approve or edit.</p>' + 179 stageRows + 180 '<div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end;">' + 181 '<button onclick="document.getElementById(\'funnelModal\').remove()" style="padding:8px 16px;border:1px solid #ddd;border-radius:6px;background:#fff;cursor:pointer;font-size:13px;">Cancel</button>' + 182 '<button onclick="window._editSuggested(' + coachId + ')" style="padding:8px 16px;border:1px solid #ddd;border-radius:6px;background:#f0f0f0;cursor:pointer;font-size:13px;">Edit First</button>' + 183 '<button onclick="window._approveFunnelSteps(' + coachId + ')" style="padding:8px 16px;border:none;border-radius:6px;background:#4f46e5;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">Approve & Save</button>' + 184 '</div>' + 185 '</div>'; 186 187 modal._stages = suggestedStages; 188 document.body.appendChild(modal); 189 modal.addEventListener('click', function(e) { if (e.target === modal) modal.remove(); }); 190 } 191
192 window._approveFunnelSteps = async function(coachId) { 193 var modal = document.getElementById('funnelModal'); 194 if (!modal || !modal._stages) return; 195 try { 196 await api('/coaches/' + coachId + '/funnel/stages', { 197 method: 'PUT', 198 headers: { 'Content-Type': 'application/json' }, 199 body: JSON.stringify({ stages: modal._stages }) 200 }); 201 modal.remove(); 202 loadMsgFunnel(coachId); 203 } catch (e) { 204 App.dialog.notice({ title: 'The steps were not saved', body: 'Please try again in a moment.' }); 205 } 206 }; 207 208 window._editSuggested = function(coachId) { 209 var modal = document.getElementById('funnelModal'); 210 if (!modal || !modal._stages) return; 211 var stages = modal._stages; 212 modal.remove(); 213 showStageEditorModal(coachId, stages); 214 }; 215 216 function showStageEditorModal(coachId, stages) { 217 var existing = document.getElementById('funnelModal'); 218 if (existing) existing.remove(); 219 220 var modal = document.createElement('div'); 221 modal.id = 'funnelModal'; 222 modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center;'; 223 modal._stages = JSON.parse(JSON.stringify(stages)); 224 225 renderEditorContent(modal, coachId); 226 document.body.appendChild(modal); 227 modal.addEventListener('click', function(e) { if (e.target === modal) modal.remove(); }); 228 } 229 230 function renderEditorContent(modal, coachId) { 231 var stages = modal._stages; 232 var rows = stages.map(function(s, i) { 233 return '<div style="display:flex;align-items:center;gap:8px;padding:8px;background:#f9f9f9;border-radius:8px;margin-bottom:6px;">' + 234 '<input type="color" value="' + (s.color || '#6366f1') + '" data-idx="' + i + '" data-field="color" style="width:28px;height:28px;border:none;cursor:pointer;">' + 235 '<div style="flex:1;">' + 236 '<input type="text" value="' + escHtml(s.name || '') + '" data-idx="' + i + '" data-field="name" placeholder="Stage name" style="width:100%;border:1px solid #ddd;border-radius:4px;padding:4px 8px;font-size:13px;font-weight:600;margin-bottom:4px;">' + 237 '<input type="text" value="' + escHtml(s.description || '') + '" data-idx="' + i + '" data-field="description" placeholder="Description" style="width:100%;border:1px solid #ddd;border-radius:4px;padding:4px 8px;font-size:11px;color:#666;">' + 238 '</div>' + 239 '<div style="display:flex;flex-direction:column;gap:2px;">' + 240 (i > 0 ? '<button data-idx="' + i + '" data-action="up" style="background:none;border:1px solid #ddd;border-radius:4px;padding:2px 6px;cursor:pointer;font-size:10px;">â</button>' : '<div style="height:22px;"></div>') + 241 (i < stages.length - 1 ? '<button data-idx="' + i + '" data-action="down" style="background:none;border:1px solid #ddd;border-radius:4px;padding:2px 6px;cursor:pointer;font-size:10px;">â</button>' : '<div style="height:22px;"></div>') + 242 '</div>' + 243 '<button data-idx="' + i + '" data-action="delete" style="background:none;border:none;color:#ef4444;cursor:pointer;font-size:16px;padding:4px;" title="Remove">Ã</button>' + 244 '</div>'; 245 }).join(''); 246 247 var inner = modal.querySelector('.funnel-editor-inner'); 248 if (!inner) { 249 inner = document.createElement('div'); 250 inner.className = 'funnel-editor-inner'; 251 inner.style.cssText = 'background:#fff;border-radius:12px;padding:24px;max-width:520px;width:90%;max-height:80vh;overflow-y:auto;'; 252 modal.innerHTML = ''; 253 modal.appendChild(inner); 254 } 255 256 inner.innerHTML = '<h3 style="margin:0 0 16px;font-size:16px;">Edit Funnel Steps</h3>' + 257 rows + 258 '<button onclick="window._addFunnelStep()" style="width:100%;padding:8px;border:1px dashed #ccc;border-radius:8px;background:none;color:#666;cursor:pointer;font-size:12px;margin-top:4px;">+ Add Step</button>' + 259 '<div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end;">' + 260 '<button onclick="document.getElementById(\'funnelModal\').remove()" style="padding:8px 16px;border:1px solid #ddd;border-radius:6px;background:#fff;cursor:pointer;font-size:13px;">Cancel</button>' + 261 '<button onclick="window._saveFunnelEdits(' + coachId + ')" style="padding:8px 16px;border:none;border-radius:6px;background:#4f46e5;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">
261Save Changes</button>' + 262 '</div>'; 263 264 // Wire up change handlers 265 inner.querySelectorAll('input').forEach(function(inp) { 266 inp.addEventListener('change', function() { 267 var idx = parseInt(this.dataset.idx); 268 var field = this.dataset.field; 269 modal._stages[idx][field] = this.value; 270 }); 271 }); 272 273 inner.querySelectorAll('button[data-action]').forEach(function(btn) { 274 btn.addEventListener('click', function() { 275 var idx = parseInt(this.dataset.idx); 276 var action = this.dataset.action; 277 if (action === 'delete' && modal._stages.length > 2) { 278 modal._stages.splice(idx, 1); 279 modal._stages.forEach(function(s, i) { s.order = i + 1; }); 280 renderEditorContent(modal, coachId); 281 } else if (action === 'up' && idx > 0) { 282 var tmp = modal._stages[idx]; 283 modal._stages[idx] = modal._stages[idx - 1]; 284 modal._stages[idx - 1] = tmp; 285 modal._stages.forEach(function(s, i) { s.order = i + 1; }); 286 renderEditorContent(modal, coachId); 287 } else if (action === 'down' && idx < modal._stages.length - 1) { 288 var tmp2 = modal._stages[idx]; 289 modal._stages[idx] = modal._stages[idx + 1]; 290 modal._stages[idx + 1] = tmp2;
291 modal._stages.forEach(function(s, i) { s.order = i + 1; }); 292 renderEditorContent(modal, coachId); 293 } 294 }); 295 }); 296 } 297 298 window._addFunnelStep = function() { 299 var modal = document.getElementById('funnelModal'); 300 if (!modal) return; 301 var newOrder = modal._stages.length + 1; 302 modal._stages.push({ 303 id: 'stage_' + newOrder, 304 name: '', 305 color: '#6366f1', 306 order: newOrder, 307 description: '' 308 }); 309 renderEditorContent(modal, coachFilterId); 310 }; 311 312 window._saveFunnelEdits = async function(coachId) { 313 var modal = document.getElementById('funnelModal'); 314 if (!modal || !modal._stages) return; 315 // Update ids from names 316 modal._stages.forEach(function(s) { 317 if (!s.id || s.id.startsWith('stage_')) { 318 s.id = s.name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/_+$/, '') || 'stage_' + s.order; 319 } 320 }); 321 try { 322 await api('/coaches/' + coachId + '/funnel/stages', { 323 method: 'PUT', 324 headers: { 'Content-Type': 'application/json' }, 325 body: JSON.stringify({ stages: modal._stages }) 326 }); 327 modal.remove(); 328 loadMsgFunnel(coachId); 329 } catch (e) { 330 App.dialog.notice({ title: 'That was not saved', body: 'Please try again in a moment.' }); 331 } 332 }; 333 334 window._msgStageFilter = function(stageId) { 335 stageFilter = (stageFilter === stageId) ? null : stageId; 336 if (pipelineMode) { 337 renderPipelineList(); 338 // Re-render funnel bar to update highlight 339 var fb = document.getElementById('msgFunnelBar'); 340 if (fb && funnelData) { 341 var cId = coachFilterId || (coachesList.find(function(c) { return c.status === 'active'; }) || {}).id; 342 renderFunnelBarFromData(fb, funnelData, cId); 343 } 344 } 345 }; 346 347 window.loadMessages = async function() { 348 var container = document.getElementById('page-messages-home'); 349 console.log('[MSG-DIAG] loadMessages called, container:', !!container, 'currentUser:', currentUser ? { id: currentUser.id, customer_id: currentUser.customer_id, email: currentUser.email } : 'NULL'); 350 if (!container) { console.warn('[MSG-DIAG] No #page-messages-home container â aborting'); return; } 351 container.innerHTML = '<div style="text-align:center;padding:60px;color:#999;"><i class="fas fa-spinner fa-spin"></i> Loading conversations...</div>'; 352 353 try { 354 var cid = currentUser.customer_id || currentUser.id; 355 var inboxUrl = '/customers/' + cid + '/inbox?channel=all'; 356 console.log('[MSG-DIAG] Fetching inbox:', inboxUrl); 357 var [items, coaches] = await Promise.all([api(inboxUrl), api('/coaches')]); 358 console.log('[MSG-DIAG] Inbox response:', Array.isArray(items) ? items.length + ' conversations' : typeof items, items && items.error ? 'ERROR: ' + items.error : ''); 359 console.log('[MSG-DIAG] Coaches response:', Array.isArray(coaches) ? coaches.length + ' coaches' : typeof coaches); 360 if (Array.isArray(items) && items.length > 0) { 361 console.log('[MSG-DIAG] First conversation:', JSON.stringify({ profile_id: items[0].profile_id, name: items[0].display_name || items[0].profile_name, channel: items[0].channel, coach: items[0].coach_name, msgs: items[0].message_count })); 362 var channels = {}; 363 items.forEach(function(c) { channels[c.channel] = (channels[c.channel] || 0) + 1; }); 364 console.log('[MSG-DIAG] Channel breakdown:', JSON.stringify(channels)); 365 } 366 coachesList = Array.isArray(coaches) ? coaches : []; 367 conversations = Array.isArray(items) ? items : []; 368 } catch (e) { 369 conversations = []; 370 console.error('[MSG-DIAG] loadMessages EXCEPTION:', e.message, e.stack); 371 } 372 373 console.log('[MSG-DIAG] Loaded', conversations.length, 'conversations, rendering inbox'); 374 selectedConvId = null; 375 selectedMessages = []; 376 renderInbox(); 377 }; 378 379 var dashSocket = null; 380 381 function connectDashSocket() { 382 if (dashSocket) return; 383 /* The connection itself now lives in dash-socket.js â ONE socket per tab, 384 shared with the sidebar's live community-DM counts. It authenticates with 385 the same JWT the HTTP API uses (R-2) and emits the `join` on connect AND 386 on every reconnect, so there is no connect handler to write here. 387 Returns null when signed out or before socket.io loaded; the next render 388 simply tries again. */ 389 dashSocket = (window.DashSocket && window.DashSocket.get()) || null; 390 if (!dashSocket) return; 391 if (dashSocket._cbInboxBound) return; 392 dashSocket._cbInboxBound = true; 393 394 // Real-time: new message from widget or AI 395 dashSocket.on('new_message', function(data) { 396 console.log('[Dashboard] New message:', data); 397 398 // Refresh inbox list 399 refreshInbox(); 400 401 // If we're viewing this contact's thread, update it 402 if (selectedConvId === data.profile_id) { 403 hideTypingIndicator(); 404 refreshThread(data.profile_id); 405 } 406 }); 407 408 // Typing indicator 409 dashSocket.on('typing', function(data) { 410 if (selectedConvId === data.profile_id) { 411 showTypingIndicator(data.coach_name || 'AI Coach'); 412 } 413 }); 414 dashSocket.on('typing_stop', function(data) { 415 if (selectedConvId === data.profile_id) { 416 hideTypingIndicator(); 417 } 418 }); 419 } 420 421 async function refreshInbox() { 422 try { 423 var cid = currentUser.customer_id || currentUser.id; 424 var items = await api('/customers/' + cid + '/inbox?channel=all'); 425 if (items) { 426 conversations = items; 427 renderInboxList(); 428 } 429 } catch (e) {} 430 } 431 432 // ââ Typing indicator ââ 433 function showTypingIndicator(coachName) { 434 hideTypingIndicator(); 435 var container = document.getElementById('msgThreadPane'); 436 if (!container) return; 437 var dot = document.createElement('div'); 438 dot.id = 'typingIndicator'; 439 dot.style.cssText = 'display:flex;gap:10px;margin-bottom:12px;justify-content:flex-end;align-items:flex-end;'; 440 dot.innerHTML = '<div style="max-width:70%;">' + 441 '<div style="font-size:10px;font-weight:600;color:#aaa;margin-bottom:3px;text-align:right;">' + escHtml(coachName) + '</div>' + 442 '<div style="background:#6c63ff;border-radius:12px;border-top-right-radius:4px;padding:12px 18px;display:flex;gap:4px;align-items:center;">' + 443 '<span class="typing-dot" style="width:6px;height:6px;background:rgba(255,255,255,0.7);border-radius:50%;animation:typingBounce 1.4s infinite ease-in-out both;animation-delay:0s;"></span>' + 444 '<span class="typing-dot" style="width:6px;height:6px;background:rgba(255,255,255,0.7);border-radius:50%;animation:typingBounce 1.4s infinite ease-in-out both;animation-delay:0.2s;"></span>' + 445 '<span class="typing-dot" style="width:6px;height:6px;background:rgba(255,255,255,0.7);border-radius:50%;animation:typingBounce 1.4s infinite ease-in-out both;animation-delay:0.4s;"></span>' + 446 '</div>' + 447 '</div>' + 448 '<div style="width:28px;height:28px;border-radius:50%;background:#6c63ff;display:flex;align-items:center;justify-content:center;font-size:11px;flex-shrink:0;">\uD83E\uDD16</div>'; 449 container.appendChild(dot); 450 container.scrollTop = container.scrollHeight; 451 } 452 453 function hideTypingIndicator() { 454 var el = document.getElementById('typingIndicator'); 455 if (el) el.remove(); 456 } 457 458 // Add typing animation keyframes 459 if (!document.getElementById('typingAnimStyle')) { 460 var typingStyle = document.createElement('style'); 461 typingStyle.id = 'typingAnimStyle'; 462 typingStyle.textContent = '@keyframes typingBounce { 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }'; 463 document.head.appendChild(typingStyle); 464 } 465 466 async function refreshThread(contactId) { 467 try { 468 var msgs = await api('/contacts/' + contactId + '/messages'); 469 if (msgs) { 470 selectedMessages = Array.isArray(msgs) ? msgs : []; 471 var conv = conversations.find(function(c) { return c.profile_id === contactId; }); 472 var threadEl = document.getElementById('msgThread'); 473 if (threadEl) { 474 threadEl.innerHTML = renderThread(msgs, conv); 475 threadEl.scrollTop = threadEl.scrollHeight; 476 } 477 } 478 } catch (e) {} 479 } 480 481 function startInboxPoll() { 482 connectDashSocket(); 483 } 484 485 function renderInbox() { 486 var container = document.getElementById('page-messages-home'); 487 if (!container) return; 488 startInboxPoll(); 489 490 var filtered = conversations; 491 if (!document.getElementById('msg-row-style')) { var s = document.createElement('style'); s.id = 'msg-row-style'; s.textContent = '.msg-row:hover { background: #ebebeb !important; }'; document.head.appendChild(s); } 492 console.log('[MSG-DIAG] renderInbox â total:', conversations.length, 'channelFilter:', channelFilter, 'coachFilter:', coachFilterId); 493 494 // Channel filter
495 if (channelFilter !== 'all') { 496 var beforeFilter = filtered.length; 497 filtered = filtered.filter(function(c) { return c.channel === channelFilter; }); 498 console.log('[MSG-DIAG] Channel filter "' + channelFilter + '": ' + beforeFilter + ' -> ' + filtered.length); 499 if (filtered.length === 0 && beforeFilter > 0) { 500 console.warn('[MSG-DIAG] Channel filter removed ALL conversations. Available channels:', conversations.map(function(c) { return c.channel; }).filter(function(v,i,a) { return a.indexOf(v) === i; })); 501 } 502 } 503 504 // Coach filter 505 if (coachFilterId) { 506 var beforeCoach = filtered.length; 507 filtered = filtered.filter(function(c) { return c.coach_id === coachFilterId; }); 508 console.log('[MSG-DIAG] Coach filter ' + coachFilterId + ': ' + beforeCoach + ' -> ' + filtered.length); 509 } 510 511 // Status filter 512 if (currentFilter === 'unreviewed') { 513 filtered = filtered.filter(function(c) { return c.review_status !== 'reviewed'; }); 514 } else if (currentFilter === 'reviewed') { 515 filtered = filtered.filter(function(c) { return c.review_status === 'reviewed'; }); 516 } 517 518 var totalCount = conversations.length; 519 var waCount = conversations.filter(function(c) { return c.channel === 'whatsapp'; }).length; 520 var webCount = conversations.filter(function(c) { return c.channel === 'webchat'; }).length; 521 var smsCount = conversations.filter(function(c) { return c.channel === 'sms'; }).length; 522 523 container.innerHTML = 524 '<div style="position:relative;height:100%;display:flex;flex-direction:column;">' + 525 526 // Training mode banner 527 '<div style="background:#fff9e6;border-bottom:1px solid #ffe082;padding:14px 24px;display:flex;align-items:center;gap:12px;">' + 528 '<span style="font-size:20px;">\uD83D\uDCEC</span>' + 529 '<div style="flex:1;"><strong style="font-size:14px;">Messaging</strong> <span style="font-size:13px;color:#666;">\u2014 All conversations across channels. Review and train your AI coaches.</span></div>' + 530 '<div style="background:#6c63ff;color:#fff;border-radius:12px;padding:2px 10px;font-size:12px;font-weight:600;">' + totalCount + ' conversations</div>' + 531 '</div>' + 532 533 // Channel filter tabs 534 '<div style="display:flex;border-bottom:1px solid #e0e0e0;gap:0;">' + 535 channelTab('pipeline', 'ð Pipeline', '') + channelTab('activity', 'ð Activity', '') + channelTab('all', 'All', totalCount) + 536 channelTab('whatsapp', '\uD83D\uDFE2 WhatsApp', waCount) + 537 channelTab('webchat', '\uD83D\uDCAC Web Chat', webCount) + 538 (smsCount > 0 ? channelTab('sms', '\uD83D\uDCF1 SMS', smsCount) : '') + 539 '</div>' + 540 541 // Coach filter pills 542 (coachesList.length > 0 ? '<div style="display:flex;gap:6px;padding:8px 16px;border-bottom:1px solid #e0e0e0;flex-wrap:wrap;background:#fafafa;">' + 543 '<span onclick="window._msgCoachFilter(null)" style="padding:4px 12px;border-radius:16px;font-size:11px;font-weight:600;cursor:pointer;' + (!coachFilterId ? 'background:#4f46e5;color:#fff;' : 'background:#f0f0f0;color:#666;') + '">All Coaches</span>' + 544 coachesList.filter(function(c) { return c.status === 'active'; }).map(function(c) { 545 return '<span onclick="window._msgCoachFilter(' + c.id + ')" style="padding:4px 12px;border-radius:16px;font-size:11px;font-weight:600;cursor:pointer;' + (coachFilterId === c.id ? 'background:#4f46e5;color:#fff;' : 'background:#f0f0f0;color:#666;') + '">' + escHtml(c.name) + '</span>'; 546 }).join('') + 547 '</div>' : '') + 548 549 // Funnel bar (shown when coach selected) 550 '<div id="msgFunnelBar"></div>' + 551 552 // Split pane 553 '<div style="display:flex;flex:1;min-height:0;overflow:hidden;">' + 554 555 // Left: conversation list 556 '<div style="width:380px;min-width:300px;border-right:1px solid #e0e0e0;overflow-y:auto;" id="msgListPane"></div>' + 557 558 // Right: preview 559 '<div style="flex:1;overflow-y:auto;background:#fafafa;" id="msgPreviewPane">' + 560 '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#ccc;font-size:14px;">' + 561 '<div style="text-align:center;"><div style="font-size:32px;margin-bottom:8px;">\uD83D\uDCAC</div>Select a conversation</div>' + 562 '</div>' + 563 '</div>' + 564 565 '</div>' + 566 '</div>'; 567 renderInboxList(); 568 } 569 570 function renderInboxList() { 571 var listPane = document.getElementById('msgListPane'); 572 if (!listPane) return; 573 574 var filtered = conversations;
575 if (channelFilter !== 'all') { 576 filtered = filtered.filter(function(c) { return c.channel === channelFilter; }); 577 } 578 if (tzFilter) { 579 filtered = filtered.filter(function(c) { return c.tz_color === tzFilter; }); 580 } 581 582 // Timezone pill filters 583 var tzCounts = {green:0, amber:0, red:0}; 584 var base = channelFilter !== 'all' ? conversations.filter(function(c) { return c.channel === channelFilter; }) : conversations; 585 base.forEach(function(c) { if (tzCounts.hasOwnProperty(c.tz_color)) tzCounts[c.tz_color]++; }); 586 var pillsHtml = '<div style="padding:6px 16px;display:flex;gap:6px;flex-wrap:wrap;">'; 587 var pillDefs = [{key:'green',label:'\u{1F7E2} Active',color:'#22c55e',tip:'Available now \u2014 it\u2019s between 10am and 5pm in their timezone'},{key:'amber',label:'\u{1F7E1} Closing',color:'#f59e0b',tip:'Closing soon \u2014 it\u2019s between 5pm and 6pm in their timezone, last chance today'},{key:'red',label:'\u{1F534} Outside',color:'#ef4444',tip:'Outside hours \u2014 it\u2019s before 10am or after 6pm in their timezone, not a good time to reach out'}]; 588 pillDefs.forEach(function(p) { 589 var isOn = tzFilter === p.key; 590 pillsHtml += '<button onclick="setTzMsgFilter(\'' + p.key + '\')" title="' + p.tip + '" style="padding:3px 10px;border-radius:12px;font-size:11px;cursor:pointer;border:1px solid ' + p.color + ';background:' + (isOn ? p.color : 'transparent') + ';color:' + (isOn ? '#fff' : p.color) + ';font-family:Inter,sans-serif;">' + p.label + ' (' + tzCounts[p.key] + ')</button>'; 591 }); 592 if (tzFilter) pillsHtml += '<button onclick="setTzMsgFilter(null)" style="padding:3px 10px;border-radius:12px;font-size:11px;cursor:pointer;border:1px solid #ccc;background:transparent;color:#888;font-family:Inter,sans-serif;">\u2715 Clear</button>'; 593 pillsHtml += '</div>'; 594 595 if (filtered.length === 0) { 596 listPane.innerHTML = pillsHtml + '<div style="text-align:center;padding:60px 20px;color:#999;font-size:13px;"><div style="font-size:32px;margin-bottom:12px;">\uD83D\uDCED</div>No conversations match</div>'; 597 } else { 598 listPane.innerHTML = pillsHtml + filtered.map(function(conv) { return renderConvRow(conv); }).join(''); 599 } 600 601 // Update counts in channel tabs 602 var totalCount = conversations.length; 603 var tabBar = listPane.parentElement.parentElement.querySelector('[style*="border-bottom"]'); 604 // Skip complex tab update - full re-render handles it 605 } 606 607 function channelTab(key, label, count) { 608 var active = channelFilter === key; 609 return '<button onclick="setChannelFilter(\'' + key + '\')" style="padding:10px 18px;font-size:13px;font-weight:' + 610 (active ? '600' : '400') + ';color:' + (active ? '#1a1a1a' : '#999') + 611 ';background:none;border:none;border-bottom:2px solid ' + (active ? '#1a1a1a' : 'transparent') + 612 ';cursor:pointer;font-family:Inter,sans-serif;transition:all 0.15s;">' + label + 613 (count > 0 ? ' <span style="font-size:11px;color:#999;">(' + count + ')</span>' : '') + '</button>'; 614 } 615 616 function channelIcon(channel) { 617 if (channel === 'whatsapp') return '<i class="fab fa-whatsapp" style="color:#25D366;font-size:11px;"></i>'; 618 if (channel === 'webchat') return '<i class="fas fa-comment-dots" style="color:#6366f1;font-size:11px;"></i>'; 619 if (channel === 'sms') return '<i class="fas fa-sms" style="color:#f59e0b;font-size:11px;"></i>'; 620 return '<i class="fas fa-envelope" style="color:#999;font-size:11px;"></i>'; 621 } 622 623 function getContactName(conv) { 624 if (conv.first_name && conv.last_name) return conv.first_name + ' ' + conv.last_name; 625 if (conv.first_name) return conv.first_name; 626 if (conv.profile_name) return conv.profile_name; 627 if (conv.phone) return conv.phone; 628 return 'Unknown'; 629 } 630 631 function getInitial(conv) { 632 var name = getContactName(conv); 633 return name.charAt(0).toUpperCase(); 634 } 635 636 function timeAgo(dateStr) { 637 if (!dateStr) return ''; 638 var diff = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000); 639 if (diff < 60) return 'just now'; 640 if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; 641 if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; 642 if (diff < 604800) return Math.floor(diff / 86400) + 'd ago'; 643 return App.Dates.short(dateStr); 644 } 645 646 // escHtml is defined at module scope (top of file) â see F5 hardening. 647 648 function renderConvRow(conv) { 649 var tzColors = {green:'#22c55e',amber:'#f59e0b',red:'#ef4444',grey:'#d1d5db'}; 650 var tzBgs = {green:'rgba(34,197,94,0.08)',amber:'rgba(245,158,11,0.08)',red:'rgba(239,68,68,0.06)',grey:'#fff'}; 651 var tzC = conv.tz_color || 'grey'; 652 var isSelected = conv.profile_id === selectedConvId;
653 var baseBg = isSelected ? '#f0f0f0' : (tzBgs[tzC] || '#fff'); 654 var borderLeft = tzC !== 'grey' ? 'border-left:3px solid ' + tzColors[tzC] + ';' : 'border-left:3px solid transparent;'; 655 var name = getContactName(conv); 656 var preview = escHtml((conv.last_message || '').substring(0, 80)); 657 var dirIcon = conv.last_direction === 'outbound' ? '<span style="color:#999;font-size:11px;">You: </span>' : ''; 658 659 return '<div onclick="openConversation(' + conv.profile_id + ')" data-base-bg="' + baseBg + '" style="display:flex;align-items:center;padding:14px 20px;border-bottom:1px solid #f0f0f0;cursor:pointer;transition:background 0.1s;background:' + baseBg + ';' + borderLeft + ' msg-row">' + 660 '<div style="width:40px;height:40px;border-radius:50%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:15px;font-weight:600;color:#666;margin-right:14px;flex-shrink:0;">' + getInitial(conv) + '</div>' + 661 '<div style="flex:1;min-width:0;">' + 662 '<div style="display:flex;align-items:center;gap:8px;margin-bottom:3px;">' + 663 '<span style="font-weight:600;font-size:14px;color:#1e1b4b;">' + escHtml(name) + '</span>' + 664 '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;margin-left:6px;background:' + ({green:'#22c55e',amber:'#f59e0b',red:'#ef4444',grey:'#d1d5db'}[conv.tz_color||'grey']) + ';" title="' + escHtml(conv.tz_label || 'Timezone unknown') + '"></span>' + 665 channelIcon(conv.channel) + 666 (conv.coach_name ? ' <span data-coach-list="' + conv.profile_id + '" style="font-size:10px;padding:1px 6px;border-radius:8px;background:#e3f2fd;color:#1565c0;">\uD83E\uDD16 ' + escHtml(conv.coach_name) + '</span>' : '') + 667 '</div>' + 668 '<div style="font-size:13px;color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + dirIcon + preview + '</div>' + 669 '</div>' + 670 '<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;margin-left:14px;flex-shrink:0;">' + 671 '<span style="font-size:11px;color:#bbb;">' + timeAgo(conv.last_message_at) + '</span>' + 672 '<span style="font-size:11px;color:#999;">' + (conv.message_count || 0) + ' msgs</span>' + 673 '</div>' + 674 '</div>'; 675 } 676 677 window.setTzMsgFilter = function(f) { 678 tzFilter = (tzFilter === f) ? null : f; 679 renderInboxList(); 680 }; 681 682 window.setChannelFilter = function(filter) { 683 if (filter === 'pipeline') { 684 window._switchTab('pipeline'); 685 return; 686 } 687 window._switchTab(filter); 688 }; 689 690 window.openConversation = async function(contactId) { 691 console.log('[MSG-DIAG] openConversation called, contactId:', contactId); 692 selectedConvId = contactId; 693 694 // Update list selection 695 var listPane = document.getElementById('msgListPane'); 696 if (listPane) { 697 listPane.querySelectorAll('div[onclick]').forEach(function(r) { 698 r.dataset.selected = ''; 699 r.style.background = r.dataset.baseBg || '#fff'; 700 if (r.getAttribute('onclick') && r.getAttribute('onclick').indexOf('(' + contactId + ')') !== -1) { 701 r.dataset.selected = '1'; 702 r.style.background = '#e8e8e8'; 703 } 704 }); 705 } 706 707 var previewPane = document.getElementById('msgPreviewPane'); 708 if (!previewPane) return; 709 previewPane.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#999;"><i class="fas fa-spinner fa-spin"></i> Loading...</div>'; 710 711 try { 712 console.log('[MSG-DIAG] Fetching messages for contact', contactId); 713 var msgs = await api('/contacts/' + contactId + '/messages'); 714 console.log('[MSG-DIAG] Messages response:', Array.isArray(msgs) ? msgs.length + ' messages' : typeof msgs, msgs && msgs.error ? 'ERROR: ' + msgs.error : ''); 715 selectedMessages = msgs || []; 716 } catch (e) { 717 selectedMessages = []; 718 console.error('[MSG-DIAG] openConversation EXCEPTION:', e.message); 719 } 720 721 var conv = conversations.find(function(c) { return c.profile_id === contactId; }); 722 console.log('[MSG-DIAG] Conv found:', !!conv, conv ? { profile_id: conv.profile_id, channel: conv.channel, coach: conv.coach_name } : 'NOT FOUND'); 723 var name = conv ? getContactName(conv) : 'Unknown'; 724 var channel = conv ? conv.channel : ''; 725 726 previewPane.innerHTML = 727 // Header 728 '<div style="padding:16px 24px;border-bottom:1px solid #e0e0e0;display:flex;align-items:center;gap:10px;background:#fff;position:sticky;top:0;z-index:5;">' + 729 '<div style="width:36px;height:36px;border-radius:50%;background:#f0f0f0;
729display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:600;color:#666;">' + (conv ? getInitial(conv) : '?') + '</div>' + 730 '<div style="flex:1;">' + 731 '<div style="font-weight:600;font-size:15px;color:#1e1b4b;">' + escHtml(name) + '</div>' + 732 '<div style="font-size:12px;color:#999;">' + channelIcon(channel) + ' ' + (channel || 'unknown') + (conv && conv.phone ? ' \u00B7 ' + escHtml(conv.phone) : '') + (conv && conv.tz_label ? ' \u00B7 <span style="color:' + ({green:'#22c55e',amber:'#f59e0b',red:'#ef4444',grey:'#999'}[conv.tz_color||'grey']) + ';">' + escHtml(conv.tz_label) + '</span>' : '') + '</div>' + 733 '</div>' + 734 '<div style="display:flex;gap:8px;align-items:center;">' + 735 '<span id="aiStatusBadge" style="font-size:12px;padding:4px 12px;border-radius:12px;' + (conv.agent_paused ? 'background:#fff3cd;color:#856404;' : 'background:#e8f5e9;color:#2e7d32;') + '">' + (conv.agent_paused ? '\u23F8 AI Paused' : '\u2713 AI Active') + '</span>' + 736 (conv.coach_name ? '<span id="coachPill" style="font-size:11px;padding:3px 10px;border-radius:12px;background:#e3f2fd;color:#1565c0;">\uD83E\uDD16 ' + escHtml(conv.coach_name) + '</span>' : '') + 737 (conv.agent_paused ? '<button onclick="resumeAI(' + contactId + ')" style="padding:6px 14px;background:#10b981;color:#fff;border:none;border-radius:8px;font-size:12px;cursor:pointer;font-family:Inter,sans-serif;">Resume AI</button>' : '') + 738 '<button onclick="restartAI(' + contactId + ')" style="padding:6px 14px;background:#6366f1;color:#fff;border:none;border-radius:8px;font-size:12px;cursor:pointer;font-family:Inter,sans-serif;">\u{1F504} Restart AI</button>' + 739 '<button onclick="approveConversation(' + contactId + ')" style="padding:8px 16px;background:#fff;color:#1e1b4b;border:1px solid #e0e0e0;border-radius:8px;font-size:13px;cursor:pointer;font-family:Inter,sans-serif;">\u2713 Approve</button>' + 740 '<button onclick="feedbackConversation(' + contactId + ')" style="padding:8px 16px;background:#6c63ff;color:#fff;border:none;border-radius:8px;font-size:13px;cursor:pointer;font-family:Inter,sans-serif;">\uD83C\uDFA4 Feedback</button>' + 741 '</div>' + 742 '</div>' + 743 744 // Messages 745 '<div style="padding:20px 24px;max-height:calc(100vh - 250px);overflow-y:auto;" id="msgThread">' + 746 renderThread(selectedMessages, conv) + 747 '</div>' + 748 749 // Reply box 750 '<div style="padding:12px 24px;border-top:1px solid #e0e0e0;background:#fff;display:flex;gap:8px;align-items:center;">' + 751 '<input type="text" id="replyInput" placeholder="Type a reply..." onkeydown="if(event.key===\'Enter\'&&!event.shiftKey){event.preventDefault();sendReply(' + contactId + ');}" style="flex:1;padding:10px 14px;border:1px solid #e0e0e0;border-radius:8px;font-size:14px;font-family:Inter,sans-serif;outline:none;">' + 752 '<button onclick="sendReply(' + contactId + ')" style="padding:10px 20px;background:#6c63ff;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:500;cursor:pointer;font-family:Inter,sans-serif;">Send</button>' + 753 '</div>'; 754 755 // Scroll to bottom 756 var thread = document.getElementById('msgThread'); 757 if (thread) thread.scrollTop = thread.scrollHeight; 758 759 // Socket will handle real-time updates via new_message event 760 }; 761 762 function renderThread(messages, conv) { 763 if (!messages || messages.length === 0) { 764 return '<div style="text-align:center;padding:40px;color:#999;font-size:13px;">No messages yet</div>'; 765 } 766 767 var contactName = conv ? getContactName(conv) : 'Contact'; 768 var lastDate = ''; 769 770 return messages.map(function(msg) { 771 var html = ''; 772 773 // Date separator 774 var msgDate = msg.sent_at || msg.created_at; 775 var dateStr = msgDate ? App.Dates.weekday(msgDate, { year: 'auto' }) : ''; 776 if (dateStr && dateStr !== lastDate) { 777 lastDate = dateStr; 778 html += '<div style="text-align:center;margin:16px 0 12px;"><span style="font-size:11px;color:#bbb;background:#f5f5f5;padding:2px 12px;border-radius:10px;">' + dateStr + '</span></div>'; 779 } 780 781 // Skip system messages 782 if (msg.direction === 'system') { 783 html += '<div style="text-align:center;margin:8px 0;font-size:12px;color:#bbb;font-style:italic;">' + escHtml(msg.message) + '</div>'; 784 return html; 785 } 786
787 if (msg.direction === 'inbound') { 788 html += '<div style="display:flex;gap:10px;margin-bottom:12px;">' + 789 '<div style="width:28px;height:28px;border-radius:50%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;color:#666;flex-shrink:0;">' + contactName.charAt(0) + '</div>' + 790 '<div style="max-width:70%;">' + 791 '<div style="font-size:10px;font-weight:600;color:#999;margin-bottom:3px;">' + escHtml(contactName) + '</div>' + 792 '<div style="background:#f5f5f5;border-radius:12px;border-top-left-radius:4px;padding:10px 14px;">' + 793 '<div style="font-size:13px;line-height:1.5;color:#1e1b4b;">' + escHtml(msg.message) + '</div>' + 794 '</div>' + 795 (function() { 796 if (!msg.media_url) return ''; 797 var isAudio = (msg.media_type || '').startsWith('audio/'); 798 var transcription = msg.metadata && msg.metadata.voice_transcription; 799 if (isAudio) { 800 var html = '<div style="margin-top:8px">'; 801 html += '<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px"><span style="font-size:11px;background:#6366f1;color:#fff;padding:1px 6px;border-radius:4px">ð¤ Voice Note</span></div>'; 802 html += '<audio controls preload="none" style="width:100%;max-width:280px;height:32px"><source src="' + escHtml(msg.media_url) + '" type="' + escHtml(msg.media_type || 'audio/ogg') + '"></audio>'; 803 if (transcription) { 804 html += '<details style="margin-top:6px"><summary style="font-size:11px;color:#94a3b8;cursor:pointer">ð Transcription</summary>'; 805 html += '<div style="font-size:12px;color:#cbd5e1;background:rgba(255,255,255,0.04);padding:8px;border-radius:6px;margin-top:4px;font-style:italic">' + escHtml(transcription) + '</div>'; 806 html += '</details>'; 807 } 808 html += '</div>'; 809 return html; 810 } 811 return '<div style="margin-top:6px;"><img src="' + escHtml(msg.media_url) + '" style="max-width:200px;border-radius:8px;cursor:pointer" onclick="window.open(this.src)"></div>'; 812 })() + 813 '<div style="font-size:10px;color:#ccc;margin-top:3px;">' + formatTime(msgDate) + '</div>' + 814 '</div>' + 815 '</div>'; 816 } else if (msg.direction === 'outbound') { 817 var isAdmin = msg.metadata && msg.metadata.source === 'admin'; 818 var senderLabel = isAdmin ? 'You' : 'AI Coach'; 819 var senderColor = isAdmin ? '#10b981' : '#aaa'; 820 html += '<div style="display:flex;gap:10px;margin-bottom:12px;justify-content:flex-end;">' + 821 '<div style="max-width:70%;">' + 822 '<div style="font-size:10px;font-weight:600;color:' + senderColor + ';margin-bottom:3px;text-align:right;">' + senderLabel + '</div>' + 823 (function() { 824 var isT = (msg.message || '').indexOf('[template:') === 0 || (msg.metadata && msg.metadata.is_template) || msg.is_template; 825 var displayText = msg.message || ''; 826 // Template SIDs now resolved by backend 827 return '<div style="' + (isT ? 'background:#dbeafe;color:#1e40af;border:1px solid #93c5fd;' : (isAdmin ? 'background:#059669;color:#fff;' : 'background:#6c63ff;color:#fff;')) + 'border-radius:12px;border-top-right-radius:4px;padding:10px 14px;"' + (isT ? ' title="Sent via WhatsApp template"' : '') + '>' + 828 '<div style="font-size:13px;line-height:1.5;">' + escHtml(displayText) + '</div>' + 829 (isT ? '<div style="font-size:10px;margin-top:4px;opacity:0.6;">ð Template</div>' : '') + 830 '</div>'; 831 })() + 832 '<div style="font-size:10px;color:#ccc;margin-top:3px;text-align:right;">' + formatTime(msgDate) + '</div>' + 833 '</div>' + 834 '<div style="width:28px;height:28px;border-radius:50%;background:' + (isAdmin ? '#059669' : '#6c63ff') + ';display:flex;align-items:center;justify-content:center;font-size:11px;flex-shrink:0;">
834' + (isAdmin ? '\uD83D\uDC64' : '\uD83E\uDD16') + '</div>' + 835 '</div>'; 836 } 837 838 return html; 839 }).join(''); 840 841 // Event delegation for pipeline action buttons 842 list.addEventListener('click', function(e) { 843 var btn = e.target.closest('button[data-action]'); 844 if (btn) { 845 e.stopPropagation(); 846 window._pipeAction(parseInt(btn.dataset.aid), btn.dataset.action); 847 } 848 }); 849 } 850 851 function formatTime(dateStr) { 852 if (!dateStr) return ''; 853 var d = new Date(dateStr); 854 return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); 855 } 856 857 window.sendReply = async function(contactId) { 858 var input = document.getElementById('replyInput'); 859 if (!input || !input.value.trim()) return; 860 var msg = input.value.trim(); 861 input.value = ''; 862 863 var conv = conversations.find(function(c) { return c.profile_id === contactId; }); 864 if (!conv) { showToast('Conversation not found', 'error'); return; } 865 866 try { 867 var cid = currentUser.customer_id || currentUser.id; 868 await api('/contacts/' + contactId + '/reply', { 869 method: 'POST', 870 headers: { 'Content-Type': 'application/json' }, 871 body: JSON.stringify({ message: msg, channel: conv.channel || 'webchat', customer_id: cid }) 872 }); 873 // Mark locally so the header updates immediately 874 if (conv) conv.agent_paused = true; 875 showToast('Sent â AI paused for this contact'); 876 openConversation(contactId); 877 } catch (e) { 878 showToast('Failed to send: ' + e.message, 'error'); 879 } 880 }; 881 882 window.approveConversation = async function(contactId) { 883 // Find which coach handled this conversation and save as approved training 884 var conv = conversations.find(function(c) { return c.profile_id === contactId; }); 885 var msgs = selectedMessages.filter(function(m) { return m.direction !== 'system'; }); 886 887 var trainingData = msgs.map(function(m) { 888 return { role: m.direction === 'inbound' ? 'user' : 'coach', content: m.message }; 889 }); 890 891 try { 892 // Try to save to the first active coach's training 893 var cid = currentUser.customer_id || currentUser.id; 894 var coaches = await api('/coaches'); 895 var activeCoach = coaches.find(function(c) { return c.status === 'active'; }); 896 897 if (activeCoach) { 898 await api('/coaches/' + activeCoach.id + '/training', { 899 method: 'POST', 900 headers: { 'Content-Type': 'application/json' }, 901 body: JSON.stringify({ conversation: trainingData, feedback: 'Approved â good conversation', corrections: [] }) 902 }); 903 } 904 showToast('Conversation approved \u2713'); 905 } catch (e) { 906 showToast('Approved (training save failed)', 'error'); 907 } 908 }; 909 910 window.feedbackConversation = function(contactId) { 911 var overlay = document.createElement('div'); 912 overlay.id = 'feedbackOverlay'; 913 overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:100000;display:flex;align-items:center;justify-content:center;'; 914 overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); }; 915 916 overlay.innerHTML = 917 '<div style="background:#fff;border-radius:14px;width:500px;max-width:90vw;box-shadow:0 8px 32px rgba(0,0,0,0.2);overflow:hidden;">' + 918 '<div style="padding:20px 24px;border-bottom:1px solid #e0e0e0;display:flex;align-items:center;justify-content:space-between;">' + 919 '<h3 style="margin:0;font-size:16px;font-weight:600;">Give Feedback</h3>' + 920 '<button onclick="document.getElementById(\'feedbackOverlay\').remove()" style="border:none;background:none;font-size:18px;cursor:pointer;color:#999;">\u2715</button>' + 921 '</div>' + 922 '<div style="padding:24px;">' + 923 '<p style="margin:0 0 12px;font-size:13px;color:#666;">How should the AI coach improve? What would you change about this conversation?</p>' + 924 '<textarea id="feedbackTextarea" rows="5" placeholder="e.g. The coach was too pushy, should have asked about their budget first..." style="width:100%;padding:12px;border:1px solid #e0e0e0;border-radius:8px;font-size:14px;font-family:Inter,sans-serif;resize:vertical;box-sizing:border-box;outline:none;"></textarea>' + 925 '</div>' + 926 '<div style="padding:16px 24px;border-top:1px solid #e0e0e0;display:flex;gap:10px;justify-content:flex-end;">' + 927 '<button onclick="document.getElementById(\'feedbackOverlay\').remove()" style="padding:10px 20px;background:#f0f0f0;color:#666;border:none;border-radius:8px;font-size:13px;
927cursor:pointer;font-family:Inter,sans-serif;">Cancel</button>' + 928 '<button onclick="submitConvFeedback(' + contactId + ')" style="padding:10px 20px;background:#6c63ff;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:500;cursor:pointer;font-family:Inter,sans-serif;">Submit Feedback</button>' + 929 '</div>' + 930 '</div>'; 931 932 document.body.appendChild(overlay); 933 setTimeout(function() { document.getElementById('feedbackTextarea').focus(); }, 100); 934 }; 935 936 window.submitConvFeedback = async function(contactId) { 937 var ta = document.getElementById('feedbackTextarea'); 938 if (!ta || !ta.value.trim()) { showToast('Please provide feedback first'); return; } 939 var feedback = ta.value.trim(); 940 941 var msgs = selectedMessages.filter(function(m) { return m.direction !== 'system'; }); 942 var trainingData = msgs.map(function(m) { 943 return { role: m.direction === 'inbound' ? 'user' : 'coach', content: m.message }; 944 }); 945 946 try { 947 var coaches = await api('/coaches'); 948 var activeCoach = coaches.find(function(c) { return c.status === 'active'; }); 949 950 if (activeCoach) { 951 await api('/coaches/' + activeCoach.id + '/training', { 952 method: 'POST', 953 headers: { 'Content-Type': 'application/json' }, 954 body: JSON.stringify({ conversation: trainingData, feedback: feedback, corrections: [] }) 955 }); 956 } 957 document.getElementById('feedbackOverlay').remove(); 958 showToast('Feedback submitted \u2014 your coach is learning!'); 959 } catch (e) { 960 document.getElementById('feedbackOverlay').remove(); 961 showToast('Feedback saved locally (sync failed)', 'error'); 962 } 963 }; 964 965 function showToast(msg, type) { 966 var tc = document.getElementById('toastContainer'); 967 if (!tc) return; 968 var t = document.createElement('div'); 969 t.style.cssText = 'background:' + (type === 'error' ? '#ef4444' : '#1a1a1a') + ';color:#fff;padding:10px 20px;border-radius:8px;font-size:13px;margin-top:8px;animation:fadeIn 0.3s;'; 970 t.textContent = msg; 971 tc.appendChild(t); 972 setTimeout(function() { t.remove(); }, 3000); 973 } 974 975 976// ââ Restart AI â send template/message and activate coach ââ 977window.restartAI = async function(contactId) { 978 try { 979 // Fetch brain data and window status in parallel 980 var [statusR, brainResp] = await Promise.all([ 981 api('/contacts/' + contactId + '/window-status'), 982 api('/contacts/' + contactId + '/brain-data') 983 ]); 984 window._restartBrainData = (brainResp && brainResp.data) || {}; 985 window._restartContactName = (brainResp && brainResp.displayName) || ''; 986 var within24h = statusR.within24h; 987 var savedMessages = statusR.savedMessages || []; 988 var lastAt = statusR.lastInboundAt ? App.Dates.shortDateTime(statusR.lastInboundAt) : 'never'; 989 990 // Build modal 991 var modal = document.createElement('div'); 992 modal.id = 'restartAIModal'; 993 modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000;'; 994 995 var windowBadge = within24h 996 ? '<span style="background:#e8f5e9;color:#2e7d32;padding:3px 10px;border-radius:12px;font-size:12px;">\u2705 Within 24h window</span>' 997 : '<span style="background:#fce4ec;color:#c62828;padding:3px 10px;border-radius:12px;font-size:12px;">\u26A0\uFE0F Outside 24h â template required</span>'; 998 999 var savedOptions = ''; 1000 // Build templates dynamically based on brain data 1001 var websiteUrl = (window._restartBrainData || {}).website_url || ''; 1002 var firstName = (window._restartContactName || 'there').split(' ')[0]; 1003 var defaultMessages = websiteUrl ? [ 1004 { name: 'Confirm URL & Start', body: "Hi " + firstName + ", great news \u2014 we're ready to start building your website! Just want to confirm you'd like us to work with " + websiteUrl + ". Reply YES to get started! \uD83D\uDE80", coachId: 10, coachName: 'Page Builder Coach', templateKey: 'page_builder_confirm_url' }, 1005 { name: 'Follow up - general', body: "Hey! Just checking in \u2014 we'd love to get started on your website. Let me know when you're ready and we'll take it from there \uD83D\uDC4D", coachId: 10, coachName: 'Page Builder Coach', templateKey: 'coach_beta_welcome' }, 1006 { name: 'Welcome back', body: "Welcome back! \uD83D\uDC4B Ready to pick up where we left off? Just let me know what you need.", coachId: 11, coachName: 'Client Support', templateKey: 'coach_beta_welcome' } 1007 ] : [ 1008 { name: 'Ready to build - need URL', body: "Great news! We're ready to start building your website. If you've got a URL you'd like us to use as inspiration, send it over and we'll get started right away! \uD83D\uDE80", coachId: 10, coachName: 'Page Builder Coach', templateKey: 'coach_beta_welcome' }, 1009 { name: 'Follow up - URL needed', body: "Hey! Just checking in \u2014 we'd love to get started on your website. Do you have a URL of a site you like that we can use as inspiration? Just drop it here and we'll take it from there \uD83D\uDC4D", coachId: 10, coachName: 'Page Builder Coach', templateKey: 'coach_beta_welcome' }, 1010 { name: 'Welcome back', body: "Welcome back! \uD83D\uDC4B Ready to pick up where we left off? Just let me know what you need.", coachId: 11, coachName: 'Client Support', templateKey: 'coach_beta_welcome' } 1011 ] 1012 var allMessages = defaultMessages.concat(savedMessages); 1013 for (var i = 0; i < allMessages.length; i++) { 1014 var m = allMessages[i]; 1015 var coachTag = m.coachName ? ' <span style="background:#eef2ff;color:#4f46e5;padding:1px 6px;border-radius:4px;font-size:10px;font-weight:500;margin-left:6px;">' + escHtml(m.coachName) + '</span>' : ''; 1016 savedOptions += '<div onclick="selectRestartMsg(this)" data-body="' + escHtml(m.body || '') + '" data-coachid="' + (m.coachId || '') + '" data-coachname="' + escHtml(m.coachName || '') + '" data-template
1016key="' + escHtml(m.templateKey || '') + '" style="padding:10px 14px;border:1px solid #e0e0e0;border-radius:8px;cursor:pointer;margin-bottom:6px;font-size:13px;transition:all 0.15s;" onmouseover="this.style.borderColor=\'#6366f1\'" onmouseout="if(!this.classList.contains(\'selected\'))this.style.borderColor=\'#e0e0e0\'">' + 1017 '<div style="font-weight:600;font-size:13px;color:#1e1b4b;">' + escHtml(m.name || 'Message') + coachTag + '</div>' + 1018 '<div style="color:#666;font-size:12px;margin-top:2px;">' + escHtml((m.body || '').substring(0, 80)) + '...</div>' + 1019 '</div>'; 1020 } 1021 1022 modal.innerHTML = 1023 '<div style="background:#fff;border-radius:16px;padding:24px;max-width:500px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,0.3);">' + 1024 '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">' + 1025 '<h3 style="margin:0;font-size:18px;color:#1e1b4b;">\uD83D\uDD04 Restart AI Coach</h3>' + 1026 '<button onclick="document.getElementById(\'restartAIModal\').remove()" style="background:none;border:none;font-size:20px;cursor:pointer;color:#999;">\u2715</button>' + 1027 '</div>' + 1028 '<div style="margin-bottom:12px;">' + windowBadge + ' <span style="font-size:11px;color:#999;margin-left:8px;">Last message: ' + lastAt + '</span></div>' + 1029 '<div id="restartCoachBadge" style="margin-bottom:12px;min-height:22px;"></div>' + 1030 '<div id="restartCoachSwitcher" style="margin-bottom:12px;"></div>' + 1031 1032 '<div style="margin-bottom:12px;font-weight:600;font-size:13px;color:#444;">Pick a message to send:</div>' + 1033 '<div id="restartMsgList">' + savedOptions + '</div>' + 1034 '<div id="restartMsgPreview" style="display:none;margin-top:12px;padding:12px;background:#f9fafb;border-radius:10px;border:1px solid #e5e7eb;"></div>' + 1035 1036 (within24h ? '<div style="margin-top:12px;"><div style="font-weight:600;font-size:13px;color:#444;margin-bottom:6px;">Or write a custom message:</div>' + 1037 '<textarea id="restartCustomMsg" rows="3" style="width:100%;padding:10px;border:1px solid #e0e0e0;border-radius:8px;font-size:13px;font-family:Inter,sans-serif;resize:vertical;" placeholder="Type your message..."></textarea></div>' : '') + 1038 1039 (!within24h ? '<div style="margin-top:12px;padding:10px;background:#fff3cd;border-radius:8px;font-size:12px;color:#856404;">\u26A0\uFE0F This contact hasn\'t messaged in over 24 hours. WhatsApp requires sending an approved template first. The AI will activate once they reply.</div>' : '') + 1040 1041 '<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end;">' + 1042 '<button onclick="document.getElementById(\'restartAIModal\').remove()" style="padding:10px 20px;background:#f5f5f5;color:#666;border:none;border-radius:8px;font-size:13px;cursor:pointer;">Cancel</button>' + 1043 '<button data-send-btn onclick="sendRestartAI(' + contactId + ',' + within24h + ')" style="padding:10px 20px;background:#6366f1;color:#fff;border:none;border-radius:8px;font-size:13px;cursor:pointer;font-weight:600;transition:all 0.2s;">Send & Activate AI</button>' + 1044 '</div>' + 1045 '</div>'; 1046 1047 document.body.appendChild(modal); 1048 1049 // Populate current coach info + coach switcher 1050 try { 1051 var coachList = await api('/coaches'); 1052 var currentAssign = conversations.find(function(c) { return c.profile_id === contactId; }); 1053 var currentCoachName = (currentAssign && currentAssign.coach_name) || 'Unknown'; 1054 var switcherEl = document.getElementById('restartCoachSwitcher'); 1055 if (switcherEl && coachList && coachList.length > 0) { 1056 var coachBadgeHtml = '<div style="background:#eef2ff;border-radius:8px;padding:10px 14px;display:flex;align-items:center;justify-content:space-between;">' + 1057 '<div style="display:flex;align-items:center;gap:8px;">' + 1058 '<span style="font-size:14px;">\uD83E\uDD16</span>' + 1059 '<span style="font-size:12px;font-weight:600;color:#4f46e5;">Current coach: ' + escHtml(currentCoachName) + '</span>' + 1060 '</div>' + 1061 '</div>'; 1062 switcherEl.innerHTML = coachBadgeHtml; 1063 } 1064 } catch(e) { console.warn('[Restart] Coach list fetch failed:', e); } 1065 1066 } catch (err) { 1067 App.dialog.notice({ title: 'That didnât work', body: 'Please try again in a moment. If it keeps happening, tell us and we will sort it out.\n\nDetails: ' + (err.message) }); 1068 } 1069} 1070 1071window.selectRestartMsg = function(el) { 1072 // Reset send button confirmation state 1073 var sendBtn = document.querySelector('#restartAIModal [data-send-btn]'); 1074 if (sendBtn) { sendBtn.dataset.confirmed = ''; sendBtn.textContent = 'Send & Activate AI'; sendBtn.style.background = '#6366f1'; } 1075 1076 var items = document.querySelectorAll('#restartMsgList > div');
1077 items.forEach(function(item) { item.style.borderColor = '#e0e0e0'; item.style.background = ''; item.classList.remove('selected'); }); 1078 el.style.borderColor = '#6366f1'; 1079 el.style.background = '#f0f0ff'; 1080 el.classList.add('selected'); 1081 var ta = document.getElementById('restartCustomMsg'); 1082 if (ta) ta.value = el.dataset.body; 1083 // Show coach assignment info 1084 var coachBadge = document.getElementById('restartCoachBadge'); 1085 if (coachBadge && el.dataset.coachname) { 1086 coachBadge.innerHTML = '<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 10px;background:#eef2ff;color:#4f46e5;border-radius:6px;font-size:11px;font-weight:600;">\u2192 Will assign to: ' + escHtml(el.dataset.coachname) + '</span>'; 1087 } 1088 // Show message preview 1089 var preview = document.getElementById('restartMsgPreview'); 1090 if (preview) { 1091 preview.style.display = 'block'; 1092 preview.innerHTML = '<div style="font-weight:600;font-size:11px;color:#666;margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px;">Message preview</div>' + 1093 '<div style="background:#dcf8c6;padding:10px 14px;border-radius:0 12px 12px 12px;font-size:13px;line-height:1.5;color:#1e1b4b;max-width:85%;white-space:pre-wrap;">' + escHtml(el.dataset.body || '') + '</div>'; 1094 } 1095} 1096 1097window.sendRestartAI = async function(contactId, within24h) { 1098 var customMsg = document.getElementById('restartCustomMsg'); 1099 var selectedEl = document.querySelector('#restartMsgList .selected'); 1100 var messageText = customMsg ? customMsg.value.trim() : (selectedEl ? selectedEl.dataset.body : ''); 1101 1102 var selectedEl2 = document.querySelector('#restartMsgList .selected'); 1103 var coachId = selectedEl2 ? selectedEl2.dataset.coachid : ''; 1104 var coachName = selectedEl2 ? selectedEl2.dataset.coachname : ''; 1105 var templateKey = selectedEl2 ? (selectedEl2.dataset.templatekey || 'coach_beta_welcome') : 'coach_beta_welcome'; 1106 1107 if (!within24h) { 1108 if (!messageText) { showToast('Please select a message template', 'warning'); return; } 1109 // Confirmation with coach assignment 1110 // Show inline confirmation instead of browser dialog 1111 var sendBtn = document.querySelector('#restartAIModal [data-send-btn]'); 1112 if (sendBtn && !sendBtn.dataset.confirmed) { 1113 sendBtn.dataset.confirmed = 'true'; 1114 sendBtn.textContent = coachName ? '\u2705 Confirm â assign to ' + coachName + ' & send' : '\u2705 Confirm & Send'; 1115 sendBtn.style.background = '#059669'; 1116 showToast('Click again to confirm', 'info'); 1117 return; 1118 } 1119 try { 1120 var result = await api('/contacts/' + contactId + '/restart-ai', { 1121 method: 'POST', 1122 body: JSON.stringify({ messageType: 'template', templateKey: templateKey, bodyText: messageText, coachId: coachId ? parseInt(coachId) : null }) 1123 }); 1124 document.getElementById('restartAIModal').remove(); 1125 showToast('Template sent! AI will activate when they reply.', 'success'); 1126 if (result && result.coach) { refreshCoachBadge(result.coach.coach_name); } 1127 // Update coach badge in header 1128 if (result && result.coach) { 1129 var badge = document.querySelector('.coach-badge, [data-coach-badge]'); 1130 if (badge) { badge.textContent = result.coach.coach_name; badge.style.background = '#eef2ff'; badge.style.color = '#4f46e5'; } 1131 } 1132 if (typeof openConversation === 'function') openConversation(contactId); 1133 } catch (err) { 1134 showToast('Error: ' + (err.message || 'Send failed'), 'error'); 1135 } 1136 return; 1137 } 1138 1139 if (!messageText) { 1140 showToast('Please select or type a message', 'warning'); 1141 return; 1142 } 1143 1144 try { 1145 var result2 = await api('/contacts/' + contactId + '/restart-ai', { 1146 method: 'POST', 1147 body: JSON.stringify({ messageType: 'freeform', messageText: messageText, coachId: coachId ? parseInt(coachId) : null }) 1148 }); 1149 document.getElementById('restartAIModal').remove(); 1150 showToast('Message sent! AI coach activated.', 'success'); 1151 if (result2 && result2.coach) { refreshCoachBadge(result2.coach.coach_name); } 1152 if (result2 && result2.coach) { 1153 var badge2 = document.querySelector('.coach-badge, [data-coach-badge]'); 1154 if (badge2) { badge2.textContent = result2.coach.coach_name; badge2.style.background = '#eef2ff'; badge2.style.color = '#4f46e5'; } 1155 } 1156 if (typeof openConversation === 'function') openConversation(contactId); 1157 } catch (err) { 1158 showToast('Error: ' + (err.message || 'Send failed'), 'error'); 1159 } 1160} 1161 1162window.resumeAI = async function(contactId) {
1163 var overlay = document.createElement('div'); 1164 overlay.id = 'resumeAIOverlay'; 1165 overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center;'; 1166 1167 var lastInbound = selectedMessages.filter(function(m) { return m.direction === 'inbound'; }).pop(); 1168 var preview = lastInbound ? '\u201c' + escHtml((lastInbound.message || '').substring(0, 80)) + '\u201d' : 'No pending message'; 1169 1170 // Fetch current coach assignment + coach list 1171 var resumeCoachInfo = ''; 1172 var resumeCoachSelect = ''; 1173 try { 1174 var aiStatus = await api('/contacts/' + contactId + '/ai-status'); 1175 var coachList = await api('/coaches'); 1176 var currentAssign = conversations.find(function(c) { return c.profile_id === contactId; }); 1177 var currentCoachName = (currentAssign && currentAssign.coach_name) || (aiStatus && aiStatus.coach_name) || 'Unknown'; 1178 var currentCoachId = (currentAssign && currentAssign.coach_id) || ''; 1179 resumeCoachInfo = '<div style="background:#eef2ff;border-radius:8px;padding:10px 14px;margin-bottom:16px;display:flex;align-items:center;gap:8px;">' + 1180 '<span style="font-size:14px;">\uD83E\uDD16</span>' + 1181 '<span style="font-size:12px;font-weight:600;color:#4f46e5;">Current coach: ' + escHtml(currentCoachName) + '</span>' + 1182 '</div>'; 1183 if (coachList && coachList.length > 0) { 1184 resumeCoachSelect = '<div style="margin-bottom:16px;">' + 1185 '<label style="font-size:11px;font-weight:600;color:#666;display:block;margin-bottom:4px;">Change coach (optional):</label>' + 1186 '<select id="resumeCoachSelect" style="width:100%;padding:8px 10px;border:1px solid #e0e0e0;border-radius:8px;font-size:13px;font-family:Inter,sans-serif;background:#fff;">' + 1187 '<option value="">Keep current (' + escHtml(currentCoachName) + ')</option>'; 1188 for (var ci = 0; ci < coachList.length; ci++) { 1189 resumeCoachSelect += '<option value="' + coachList[ci].id + '">' + escHtml(coachList[ci].name) + '</option>'; 1190 } 1191 resumeCoachSelect += '</select></div>'; 1192 } 1193 } catch(e) { console.warn('[Resume] Could not fetch coach info:', e); } 1194 1195 overlay.innerHTML = '<div style="background:#fff;border-radius:16px;padding:28px 32px;max-width:440px;width:90%;box-shadow:0 20px 60px rgba(0,0,0,0.3);">
1195' + 1196 '<h3 style="margin:0 0 8px;font-size:18px;color:#1e1b4b;">Resume AI Coach</h3>' + 1197 '<p style="margin:0 0 12px;font-size:13px;color:#666;">How should the AI coach resume?</p>' + 1198 resumeCoachInfo + 1199 resumeCoachSelect + 1200 '<div style="background:#f8f9fa;border-radius:10px;padding:14px 16px;margin-bottom:16px;border:1px solid #e0e0e0;">' + 1201 '<div style="font-size:11px;color:#999;margin-bottom:4px;">Last customer message:</div>' + 1202 '<div style="font-size:13px;color:#312e81;font-style:italic;">' + preview + '</div>' + 1203 '</div>' + 1204 '<button data-cid="' + contactId + '" data-mode="reply_now" class="resumeBtn" style="width:100%;padding:12px;background:#6c63ff;color:#fff;border:none;border-radius:10px;font-size:14px;font-weight:500;cursor:pointer;margin-bottom:10px;font-family:Inter,sans-serif;">Reply Now \u2014 Answer their last message</button>' + 1205 '<button data-cid="' + contactId + '" data-mode="wait" class="resumeBtn" style="width:100%;padding:12px;background:#fff;color:#1e1b4b;border:1px solid #e0e0e0;border-radius:10px;font-size:14px;font-weight:500;cursor:pointer;margin-bottom:10px;font-family:Inter,sans-serif;">Wait \u2014 Respond to next message only</button>' + 1206 '<button class="resumeCancelBtn" style="width:100%;padding:10px;background:transparent;color:#999;border:none;font-size:13px;cursor:pointer;font-family:Inter,sans-serif;">Cancel</button>' + 1207 '</div>'; 1208 1209 document.body.appendChild(overlay); 1210 overlay.addEventListener('click', function(e) { 1211 if (e.target === overlay) overlay.remove(); 1212 if (e.target.classList.contains('resumeCancelBtn')) overlay.remove(); 1213 if (e.target.classList.contains('resumeBtn')) { 1214 var cid = parseInt(e.target.dataset.cid); 1215 var mode = e.target.dataset.mode; 1216 // Read coach select BEFORE removing overlay from DOM 1217 var coachSelect = document.getElementById('resumeCoachSelect'); 1218 var selectedCoachId = (coachSelect && coachSelect.value) ? parseInt(coachSelect.value) : null; 1219 overlay.remove(); 1220 doResumeAI(cid, mode, selectedCoachId); 1221 } 1222 }); 1223 }; 1224 1225 window.doResumeAI = async function(contactId, mode, newCoachId) { 1226 try { 1227 await api('/contacts/' + contactId + '/resume-ai', { 1228 method: 'POST', 1229 headers: { 'Content-Type': 'application/json' }, 1230 body: JSON.stringify({ mode: mode, coachId: newCoachId }) 1231 }); 1232 var rConv = conversations.find(function(c) { return c.profile_id === contactId; }); 1233 if (rConv) rConv.agent_paused = false; 1234 showToast(mode === 'reply_now' ? 'AI coach resumed \u2014 replying now...' : 'AI coach resumed \u2014 waiting for next message'); 1235 setTimeout(function() { openConversation(contactId); }, mode === 'reply_now' ? 3000 : 500); 1236 } catch(e) { showToast('Failed: ' + e.message, 'error'); } 1237 }; 1238 1239 1240 1241 1242 // ââ Pipeline Mode ââ 1243 window._switchTab = function(tab) { 1244 if (tab === 'pipeline') { 1245 pipelineMode = true; 1246 channelFilter = 'pipeline'; 1247 loadPipelineView(); 1248 } else if (tab === 'activity') { 1249 pipelineMode = true; 1250 channelFilter = 'activity'; 1251 loadActivityView(); 1252 } else { 1253 pipelineMode = false; 1254 if (pipelinePollTimer) { clearInterval(pipelinePollTimer); pipelinePollTimer = null; } 1255 channelFilter = tab; 1256 console.log('[MSG-DIAG] _switchTab to "' + tab + '", conversations loaded:', conversations.length); 1257 renderInbox(); 1258 } 1259 }; 1260 1261 async function loadPipelineView() { 1262 var container = document.getElementById('page-messages-home'); 1263 if (!container) return; 1264 1265 var coachParam = coachFilterId ? '?coach_id=' + coachFilterId : ''; 1266 try { 1267 var viewData = await api('/pipeline/view' + coachParam); 1268 pipelineData = (viewData && viewData.contacts) || []; 1269 pipelineMetrics = { stages: (viewData && viewData.stages) || [], in_hours_now: viewData && viewData.metrics ? viewData.metrics.in_hours_now : 0 }; 1270 // Also update funnelData so the funnel bar renders from same source 1271 if (viewData && viewData.stages) { 1272 funnelData = { stages: viewData.stages, totalContacts: viewData.metrics ? viewData.metrics.total : 0, unassessed: viewData.metrics ? viewData.metrics.unassessed : 0 }; 1273 } 1274 } catch (e) { 1275 pipelineData = []; 1276 pipelineMetrics = {}; 1277 } 1278 renderPipelineView(container); 1279 1280 // Render funnel bar with action buttons from same data 1281 var funnelEl = document.getElementById('msgFunnelBar'); 1282 if (funnelEl && funnelData && funnelData.stages && funnelData.stages.length > 0) { 1283 renderFunnelBarFromData(funnelEl, funnelData, coachFilterId || (coachesList.find(function(c) { return c.status === 'active'; }) || {}).id); 1284 } 1285 1286 // Auto-refresh every 30s from same endpoint 1287 if (pipelinePollTimer) clearInterval(pipelinePollTimer); 1288 pipelinePollTimer = setInterval(async function() {
1289 try { 1290 var r = await api('/pipeline/view' + coachParam); 1291 if (r && r.contacts) { 1292 pipelineData = r.contacts; 1293 pipelineMetrics = { stages: r.stages || [], in_hours_now: r.metrics ? r.metrics.in_hours_now : 0 }; 1294 if (r.stages) funnelData = { stages: r.stages, totalContacts: r.metrics ? r.metrics.total : 0, unassessed: r.metrics ? r.metrics.unassessed : 0 }; 1295 renderPipelineList(); 1296 // Also update health bar and funnel bar 1297 var hb = document.querySelector('[style*="fff9e6"]'); 1298 if (hb) hb.outerHTML = renderHealthBar(); 1299 var fb = document.getElementById('msgFunnelBar'); 1300 if (fb && funnelData) renderFunnelBarFromData(fb, funnelData, coachFilterId || (coachesList.find(function(c) { return c.status === 'active'; }) || {}).id); 1301 } 1302 } catch(e) {} 1303 }, 30000); 1304 } 1305 1306 // Render funnel bar from pre-fetched data (no separate API call) 1307 function renderFunnelBarFromData(el, data, coachId) { 1308 if (!data || !data.stages || data.stages.length === 0) { el.innerHTML = ''; return; } 1309 var stages = data.stages; 1310 var maxCount = Math.max(1, Math.max.apply(null, stages.map(function(s) { return s.count; }))); 1311 var html = '<div style="padding:12px 16px;border-bottom:1px solid #e0e0e0;background:#fafafa;">' + 1312 '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">' + 1313 '<span style="font-size:13px;font-weight:600;">Pipeline</span>'; 1314 if (coachId) { 1315 html += ' <button onclick="window._suggestFunnelSteps(' + coachId + ')" style="background:none;border:1px solid #ddd;border-radius:4px;padding:2px 8px;font-size:10px;color:#666;cursor:pointer;">Update Steps</button>' + 1316 ' <button onclick="window._reassessFunnel(' + coachId + ')" style="background:none;border:1px solid #ddd;border-radius:4px;padding:2px 8px;font-size:10px;color:#666;cursor:pointer;">Reassess</button>' + 1317 ' <button onclick="window._editFunnelSteps(' + coachId + ')" style="background:none;border:1px solid #ddd;border-radius:4px;padding:2px 8px;font-size:10px;color:#666;cursor:pointer;">Edit</button>'; 1318 } 1319 html += '</div><div style="display:flex;align-items:flex-end;gap:2px;height:60px;">';
1320 stages.forEach(function(stage) { 1321 var barH = Math.max(6, Math.round((stage.count / maxCount) * 40)); 1322 var isFiltered = (stageFilter === stage.id); 1323 html += '<div style="flex:1;text-align:center;cursor:pointer;' + (isFiltered ? 'background:#f0f0ff;border-radius:6px;' : '') + '" onclick="window._msgStageFilter(\'' + stage.id + '\')" title="' + escHtml(stage.name) + '">' + 1324 '<div style="font-size:10px;font-weight:700;color:' + stage.color + ';">' + stage.count + '</div>' + 1325 '<div style="height:' + barH + 'px;background:' + stage.color + ';border-radius:3px 3px 0 0;margin:0 2px;"></div>' + 1326 '<div style="font-size:8px;color:#888;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + escHtml(stage.name) + '</div>' + 1327 '</div>'; 1328 }); 1329 html += '</div></div>'; 1330 el.innerHTML = html; 1331 } 1332 1333 function renderPipelineView(container) { 1334 var totalCount = conversations.length; 1335 var waCount = conversations.filter(function(c) { return c.channel === 'whatsapp'; }).length; 1336 var webCount = conversations.filter(function(c) { return c.channel === 'webchat'; }).length; 1337 1338 container.innerHTML = 1339 '<div style="position:relative;height:100%;display:flex;flex-direction:column;">' + 1340 1341 renderHealthBar() + 1342 1343 '<div style="display:flex;border-bottom:1px solid #e0e0e0;gap:0;">' + 1344 channelTab('pipeline', 'ð Pipeline', '') + 1345 channelTab('activity', 'ð Activity', '') + 1346 channelTab('all', 'All', totalCount) + 1347 channelTab('whatsapp', 'ð¢ WhatsApp', waCount) + 1348 channelTab('webchat', 'ð¬ Web Chat', webCount) + 1349 '</div>' + 1350 1351 (coachesList.length > 0 ? '<div style="display:flex;gap:6px;padding:8px 16px;border-bottom:1px solid #e0e0e0;flex-wrap:wrap;background:#fafafa;">' + 1352 '<span onclick="window._msgCoachFilter(null)" style="padding:4px 12px;border-radius:16px;font-size:11px;font-weight:600;cursor:pointer;' + (!coachFilterId ? 'background:#4f46e5;color:#fff;' : 'background:#f0f0f0;color:#666;') + '">All Coaches</span>' + 1353 coachesList.filter(function(c) { return c.status === 'active'; }).map(function(c) { 1354 return '<span onclick="window._msgCoachFilter(' + c.id + ')" style="padding:4px 12px;border-radius:16px;font-size:11px;font-weight:600;cursor:pointer;' + (coachFilterId === c.id ? 'background:#4f46e5;color:#fff;' : 'background:#f0f0f0;color:#666;') + '">' + escHtml(c.name) + '</span>'; 1355 }).join('') + 1356 '</div>' : '') + 1357 1358 '<div id="msgFunnelBar"></div>' + 1359 '<div style="display:flex;flex:1;min-height:0;overflow:hidden;">' + 1360 '<div style="width:420px;min-width:340px;border-right:1px solid #e0e0e0;overflow-y:auto;" id="pipelineListPane">' + 1361 '</div>' + 1362 '<div style="flex:1;overflow-y:auto;background:#fafafa;" id="msgPreviewPane">' + 1363 '<div style="text-align:center;padding:60px;color:#999;">Select a contact to view conversation</div>' + 1364 '</div>' + 1365 '</div>' + 1366 '</div>'; 1367 1368 renderPipelineList(); 1369 } 1370 1371 var priorityColors = { 1372 'PENDING_APPROVAL': '#3b82f6', 'OVERDUE': '#ef4444', 'DUE_SOON': '#f59e0b', 1373 'SLEEPING': '#8b5cf6', 'WAITING': '#9ca3af', 'STALE': '#4b5563', 'TAKEN_OVER': '#f97316' 1374 }; 1375 1376 var tzDots = { 'green': 'ð¢', 'amber': 'ð¡', 'red': 'ð´', 'grey': 'âª' }; 1377 1378 function renderPipelineList() { 1379 var list = document.getElementById('pipelineListPane'); 1380 if (!list) return; 1381 1382 var displayData = pipelineData; 1383 if (stageFilter) { 1384 displayData = pipelineData.filter(function(item) { return item.funnel_stage === stageFilter; }); 1385 } 1386 1387 if (displayData.length === 0) { 1388 var msg = stageFilter ? 'No contacts in this stage' : 'No active pipeline items'; 1389 list.innerHTML = (stageFilter ? '<div style="padding:8px 16px;"><button onclick="window._msgStageFilter(null)" style="padding:4px 12px;border:1px solid #ccc;border-radius:12px;font-size:11px;cursor:pointer;background:transparent;">â Clear filter</button></div>' : '') + 1390 '<div style="text-align:center;padding:60px 20px;color:#999;"><div style="font-size:32px;margin-bottom:12px;">ð</div>' + msg + '</div>'; 1391 return; 1392 } 1393 1394 list.innerHTML = (stageFilter ? '<div style="padding:8px 16px;"><button onclick="window._msgStageFilter(null)" style="padding:4px 12px;border:1px solid #ccc;border-radius:12px;font-size:11px;cursor:pointer;background:transparent;">â Clear filter</button></div>' : '') + displayData.map(function(item) { 1395 var silenceText = item.silence_hours !== null 1396 ? (item.silence_hours < 1 ? Math.round(item.silence_hours * 60) + 'm ago' : Math.round(item.silence_hours) + 'h ago') 1397 : 'No messages'; 1398 var windowText = item.wa_window_open 1399 ? '<span style="color:#22c55e;">ð¢ Window open (' + item.wa_window_remaining_hours + 'h)</span>' 1400 : '<span style="color:#ef4444;">ð´ Template only</span>'; 1401 var pColor = priorityColors[item.priority] || '#999'; 1402 1403 return '<div data-assignment="' + item.assignment_id + '" data-profile="' + item.profile_id + '" onclick="window._selectPipelineItem(this)" style="padding:12px 16px;
1403border-bottom:1px solid #f0f0f0;cursor:pointer;transition:background 0.15s;" onmouseenter="this.style.background=\"#f5f5f5\"" onmouseleave="this.style.background=\"\"" >' + 1404 '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">' + 1405 '<span>' + (tzDots[item.tz_status] || 'âª') + '</span>' + 1406 '<strong style="font-size:13px;">' + escHtml(item.display_name || 'Unknown') + '</strong>' + 1407 '<span style="font-size:11px;color:#888;">' + escHtml(item.primary_phone || '') + '</span>' + 1408 (item.coach_name ? '<span style="font-size:10px;background:#f0f0f0;padding:1px 6px;border-radius:8px;color:#666;">' + escHtml(item.coach_name) + '</span>' : '') + 1409 '</div>' + 1410 '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">' + 1411 '<span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600;color:#fff;background:' + (item.stage_color || '#999') + ';">' + escHtml(item.stage_name) + '</span>' + 1412 '<span style="font-size:11px;color:#888;">Last heard: ' + silenceText + '</span>' + 1413 '<span style="font-size:10px;">' + windowText + '</span>' + 1414 '</div>' + 1415 '<div style="display:flex;align-items:center;gap:8px;">' + 1416 '<span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600;color:#fff;background:' + pColor + ';">' + item.priority.replace('_', ' ') + '</span>' + 1417 '<span style="font-size:11px;color:#666;">' + escHtml(item.action) + '</span>' + 1418 '</div>' + 1419 '<div style="display:flex;gap:6px;margin-top:6px;">' + 1420 '<button data-aid="' + item.assignment_id + '" data-action="takeover" style="padding:3px 8px;border:1px solid #ddd;border-radius:4px;background:#fff;font-size:10px;cursor:pointer;">â¸ï¸ Take over</button>' + 1421 (item.manager_override ? '<button data-aid="' + item.assignment_id + '" data-action="handback" style="padding:3px 8px;border:1px solid #ddd;border-radius:4px;background:#fff;font-size:10px;cursor:pointer;">ð Hand back</button>' : '') + 1422 '<button data-aid="' + item.assignment_id + '" data-action="archive" style="padding:3px 8px;border:1px solid #ddd;border-radius:4px;background:#fff;font-size:10px;cursor:pointer;">ðï¸ Archive</button>' + 1423 '</div>' + 1424 '</div>'; 1425 }).join(''); 1426 } 1427 1428 // _selectPipelineItem defined below with full summary card 1429 1430 window._pipeAction = async function(assignmentId, action) { 1431 try { 1432 await api('/pipeline/' + assignmentId + '/' + action, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); 1433 loadPipelineView(); 1434 } catch (e) { 1435 console.error('[Pipeline] Action failed:', e.message); 1436 } 1437 }; 1438 1439 1440// ââ Activity Feed ââ 1441 var activityFilter = 'all'; 1442 1443 1444 // Template cache for follow-up selector 1445 var _templateCache = null; 1446 async function loadTemplates() { 1447 if (_templateCache) return _templateCache; 1448 try { 1449 var result = await api('/pipeline/templates'); 1450 _templateCache = (result && result.templates) || []; 1451 return _templateCache; 1452 } catch (e) { 1453 return []; 1454 } 1455 } 1456 1457 async function loadActivityView() { 1458 var container = document.getElementById('page-messages-home'); 1459 if (!container) return; 1460 1461 var filterParam = activityFilter !== 'all' ? '&filter=' + activityFilter : ''; 1462 var statsResult, actResult, pendingResult; 1463 try { 1464 statsResult = await api('/pipeline/stats'); 1465 actResult = await api('/pipeline/activity?limit=100' + filterParam); 1466 } catch (e) { 1467 statsResult = { stats: {} }; 1468 actResult = { activity: [] }; 1469 } 1470 1471 // Fetch pending follow-ups for approval section 1472 try { 1473 pendingResult = await api('/pipeline/follow-ups/pending'); 1474 } catch (e) { 1475 pendingResult = { pending: [] }; 1476 } 1477 1478 var st = statsResult.stats || {}; 1479 var items = (actResult && actResult.activity) || []; 1480 var pendingItems = (pendingResult && pendingResult.pending) || []; 1481 1482 container.innerHTML = 1483 '<div style="position:relative;height:100%;display:flex;flex-direction:column;">' + 1484 1485 '<div style="background:#f0f4ff;border-bottom:1px solid #c7d2fe;padding:14px 24px;display:flex;align-items:center;gap:16px;">' + 1486 '<span style="font-size:20px;">ð</span>' + 1487 '<div style="display:flex;gap:16px;font-size:13px;">' + 1488 '<span><strong>' + (st.active || 0) + '</strong> active</span>' + 1489 '<span><strong>' + pendingItems.length + '</strong> follow-ups pending</span>' + 1490 '<span><strong>' + (st.stale || 0) + '</strong> stale</span>' + 1491 '<span><strong>' + (st.stopped || 0) + '</strong> stopped</span>' + 1492 '</div>' + 1493 '</div>' + 1494 1495 '<div style="display:flex;border-bottom:1px solid #e0e0e0;gap:0;">' + 1496 channelTab('pipeline', 'ð Pipeline', '') + 1497 channelTab('activity', 'ð Activity', '') + 1498 channelTab('all', 'All', conversations.length) + 1499 '</div>' + 1500 1501 (pendingItems.length > 0 ? renderPendingApprovals(pendingItems) : '') + 1502 1503 '<div style="display:flex;gap:6px;padding:8px 16px;border-bottom:1px solid #e0e0e0;background:#fafafa;">
1503' + 1504 actFilterBtn('all', 'All') + 1505 actFilterBtn('followups', 'Follow-ups') + 1506 actFilterBtn('stages', 'Stage changes') + 1507 actFilterBtn('escalations', 'Escalations') + 1508 '</div>' + 1509 1510 '<div style="flex:1;overflow-y:auto;padding:0;" id="activityList">' + 1511 renderActivityItems(items) + 1512 '</div>' + 1513 '</div>'; 1514 } 1515 1516 function renderPendingApprovals(items) { 1517 var html = '<div style="border-bottom:2px solid #f59e0b;background:#fffbeb;">' + 1518 '<div style="padding:10px 16px;display:flex;align-items:center;gap:8px;border-bottom:1px solid #fde68a;">' + 1519 '<span style="font-size:16px;">â³</span>' + 1520 '<span style="font-size:13px;font-weight:700;color:#92400e;">Pending Approvals (' + items.length + ')</span>' + 1521 '</div>'; 1522 1523 items.forEach(function(item) { 1524 var silenceText = item.silence_hours ? Math.round(item.silence_hours) + 'h silent' : ''; 1525 var stageText = item.funnel_stage ? item.funnel_stage.replace(/_/g, ' ') : ''; 1526 var attemptText = 'Attempt ' + item.attempt_number + '/' + item.max_attempts; 1527 var typeIcon = item.message_type === 'freeform' ? 'ð¬' : 'ð'; 1528 var d = new Date(item.created_at); 1529 var timeStr = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }); 1530 1531 html += '<div style="border-bottom:1px solid #fde68a;" data-fq-id="' + item.id + '">' + 1532 '<div style="padding:12px 16px;cursor:pointer;" data-expand-pending="' + item.profile_id + '" data-fq-row="' + item.id + '">' + 1533 '<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px;">' + 1534 '<div>' + 1535 '<strong style="font-size:13px;">' + escHtml(item.display_name || 'Unknown') + '</strong> ' + 1536 '<span style="font-size:11px;color:#92400e;background:#fef3c7;padding:2px 6px;border-radius:8px;margin-left:4px;">' + attemptText + '</span>' + 1537 '<span style="font-size:11px;color:#666;margin-left:6px;">' + typeIcon + ' ' + (item.message_type || '') + '</span>' + 1538 '</div>' + 1539 '<span style="font-size:11px;color:#999;">' + timeStr + '</span>' + 1540 '</div>' + 1541 '<div style="font-size:11px;color:#666;margin-bottom:8px;">' + 1542 (stageText ? 'ð ' + stageText + ' · ' : '') + (silenceText ? 'ð ' + silenceText : '') + 1543 (item.coach_name ? ' · ð¤ ' + escHtml(item.coach_name) : '') + 1544 '</div>' + 1545 '<div style="background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:10px 12px;margin-bottom:10px;font-size:13px;color:#374151;line-height:1.5;font-style:italic;">' + 1546 '"' + escHtml(item.draft_message || '[template message]') + '"' + 1547 '</div>' + 1548 '</div>' + 1549 '<div id="convo-preview-' + item.id + '" style="display:none;"></div>' + 1550 '<div style="display:flex;gap:8px;align-items:center;padding:0 16px 12px;">' + 1551 '<button data-fq-approve="' + item.id + '" style="padding:6px 14px;border:none;border-radius:6px;background:#059669;color:#fff;cursor:pointer;font-size:12px;font-weight:600;">â Send</button>' + 1552 '<button data-fq-skip="' + item.id + '" style="padding:6px 14px;border:none;border-radius:6px;background:#f0f0f0;color:#666;cursor:pointer;font-size:12px;font-weight:600;">Skip</button>' + 1553 '<button data-fq-edit="' + item.id + '" data-draft="' + encodeURIComponent(item.draft_message || '') + '" style="padding:6px 14px;border:none;border-radius:6px;background:#f0f0f0;color:#666;cursor:pointer;font-size:12px;font-weight:600;">âï¸ Edit & Send</button>' + 1554 '<button data-fq-template="' + item.id + '" style="padding:6px 14px;border:none;border-radius:6px;background:#dbeafe;color:#1e40af;cursor:pointer;font-size:12px;font-weight:600;">ð Use Template</button>' + 1555 '<div style="flex:1;"></div>' + 1556 '<button data-fq-voice="' + item.id + '" data-profile-id="' + item.profile_id + '" data-coach-id="' + (item.coach_id || '') + '" style="padding:6px 10px;border:none;border-radius:6px;background:#f0f0f0;color:#666;cursor:pointer;font-size:14px;" title="Voice feedback to AI">ð¤</button>' + 1557 '</div>' + 1558 '</div>'; 1559 }); 1560 1561 html += '</div>'; 1562 return html; 1563 } 1564 1565 function renderConvoPreview(messages) { 1566 if (!messages || messages.length === 0) { 1567 return '<div style="padding:12px 16px;color:#999;font-size:12px;font-style:italic;">No conversation history</div>'; 1568 } 1569 var html = '<div style="padding:8px 16px 12px;max-height:250px;overflow-y:auto;background:#f9fafb;border-top:1px solid #e5e7eb;border-bottom:1px solid #e5e7eb;">';
1570 messages.forEach(function(m) { 1571 var isInbound = m.direction === 'inbound'; 1572 var d = new Date(m.sent_at); 1573 var time = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }); 1574 var date = App.Dates.short(d, { year: false }); 1575 var isTemplate = (m.message || '').indexOf('[template:') === 0 || (m.metadata && m.metadata.is_template); 1576 var displayMsg = m.message || ''; 1577 // Template SIDs now resolved by backend 1578 html += '<div style="display:flex;flex-direction:column;align-items:' + (isInbound ? 'flex-start' : 'flex-end') + ';margin-bottom:6px;">' + 1579 '<div style="max-width:80%;padding:8px 12px;border-radius:12px;font-size:12px;line-height:1.4;' + 1580 (isInbound ? 'background:#e5e7eb;color:#1f2937;' : 1581 (isTemplate ? 'background:#dbeafe;color:#1e40af;border:1px solid #93c5fd;' : 'background:#4f46e5;color:#fff;')) + '"' + 1582 (isTemplate ? ' title="Sent via WhatsApp template"' : '') + '>' + 1583 escHtml(displayMsg) + 1584 (isTemplate ? '<div style="font-size:10px;margin-top:4px;opacity:0.7;">ð Template</div>' : '') + 1585 '</div>' + 1586 '<span style="font-size:10px;color:#999;margin-top:2px;">' + date + ' ' + time + '</span>' + 1587 '</div>'; 1588 }); 1589 html += '</div>'; 1590 return html; 1591 } 1592 1593 // Expand/collapse conversation preview 1594 document.addEventListener('click', async function(e) { 1595 var expandBtn = e.target.closest('[data-expand-pending]'); 1596 if (expandBtn) { 1597 var profileId = expandBtn.dataset.expandPending; 1598 var fqId = expandBtn.dataset.fqRow; 1599 var preview = document.getElementById('convo-preview-' + fqId); 1600 if (!preview) return; 1601 1602 if (preview.style.display !== 'none') { 1603 preview.style.display = 'none'; 1604 return; 1605 } 1606 1607 preview.innerHTML = '<div style="padding:12px 16px;color:#999;font-size:12px;">Loading conversation...</div>'; 1608 preview.style.display = 'block'; 1609 1610 try { 1611 var result = await api('/pipeline/conversation-preview/' + profileId); 1612 preview.innerHTML = renderConvoPreview(result.messages); 1613 } catch (err) { 1614 preview.innerHTML = '<div style="padding:12px 16px;color:#ef4444;font-size:12px;">Failed to load</div>'; 1615 } 1616 } 1617 }); 1618 1619 // Voice feedback recording 1620 var _voiceRecorder = null; 1621 document.addEventListener('click', async function(e) { 1622 var voiceBtn = e.target.closest('[data-fq-voice]'); 1623 if (!voiceBtn) return; 1624 1625 var fqId = voiceBtn.dataset.fqVoice; 1626 var profileId = voiceBtn.dataset.profileId; 1627 var coachId = voiceBtn.dataset.coachId; 1628 1629 if (_voiceRecorder) { 1630 // Stop recording 1631 _voiceRecorder.stop(); 1632 voiceBtn.textContent = 'ð¤'; 1633 voiceBtn.style.background = '#f0f0f0'; 1634 return; 1635 } 1636 1637 try { 1638 var stream = await navigator.mediaDevices.getUserMedia({ audio: true }); 1639 var mediaRecorder = new MediaRecorder(stream); 1640 var chunks = []; 1641 1642 mediaRecorder.ondataavailable = function(e) { chunks.push(e.data); }; 1643 mediaRecorder.onstop = async function() { 1644 _voiceRecorder = null; 1645 stream.getTracks().forEach(function(t) { t.stop(); }); 1646 voiceBtn.textContent = 'â³'; 1647 1648 var blob = new Blob(chunks, { type: 'audio/webm' }); 1649 var formData = new FormData(); 1650 formData.append('file', blob, 'feedback.webm'); 1651 1652 try { 1653 // Transcribe via Whisper 1654 var transcribeResult = await fetch('/api/builder/transcribe', { 1655 method: 'POST', 1656 body: formData, 1657 headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('token') || '') } 1658 }); 1659 var transcription = await transcribeResult.json(); 1660 var text = transcription.text || transcription.transcript || ''; 1661 1662 if (text) { 1663 // Save as feedback event 1664 await api('/pipeline/feedback', { 1665 method: 'POST', 1666 headers: { 'Content-Type': 'application/json' }, 1667 body: JSON.stringify({ 1668 profile_id: parseInt(profileId), 1669 coach_id: parseInt(coachId), 1670 follow_up_id: parseInt(fqId), 1671 feedback_text: text 1672 }) 1673 }); 1674 showToast('ð¤ Feedback saved: "' + text.substring(0, 60) + '..."'); 1675 } else { 1676 showToast('â Could not transcribe audio'); 1677 } 1678 } catch (err) { 1679 showToast('â Transcription failed: ' + (err.message || 'error')); 1680 } 1681 1682 voiceBtn.textContent = 'ð¤'; 1683 voiceBtn.style.background = '#f0f0f0'; 1684 }; 1685 1686 mediaRecorder.start(); 1687 _voiceRecorder = mediaRecorder; 1688 voiceBtn.textContent = 'â¹ï¸'; 1689 voiceBtn.style.background = '#fee2e2'; 1690 showToast('ð¤ Recording... click again to stop'); 1691 } catch (err) { 1692 showToast('â Microphone access denied'); 1693 } 1694 }); 1695 1696
1697 // Event delegation for follow-up approval actions 1698 document.addEventListener('click', async function(e) { 1699 var approveBtn = e.target.closest('[data-fq-approve]'); 1700 var skipBtn = e.target.closest('[data-fq-skip]'); 1701 var editBtn = e.target.closest('[data-fq-edit]'); 1702 1703 if (approveBtn) { 1704 var id = approveBtn.dataset.fqApprove; 1705 approveBtn.disabled = true; 1706 approveBtn.textContent = 'Sending...'; 1707 try { 1708 var result = await api('/pipeline/follow-up/' + id + '/approve', { method: 'POST' }); 1709 if (result.status === 'expired') { 1710 showToast('â ' + (result.reason || 'Follow-up expired')); 1711 } else { 1712 showToast('â Sent to ' + (result.contact || 'contact')); 1713 } 1714 loadActivityView(); 1715 } catch (err) { 1716 showToast('â ' + (err.message || 'Failed')); 1717 approveBtn.disabled = false; 1718 approveBtn.textContent = 'â Send'; 1719 } 1720 } 1721 1722 if (skipBtn) { 1723 var id = skipBtn.dataset.fqSkip; 1724 skipBtn.disabled = true; 1725 skipBtn.textContent = 'Skipping...'; 1726 try { 1727 await api('/pipeline/follow-up/' + id + '/skip', { method: 'POST' }); 1728 showToast('âï¸ Skipped'); 1729 loadActivityView(); 1730 } catch (err) { 1731 showToast('â ' + (err.message || 'Failed')); 1732 skipBtn.disabled = false; 1733 skipBtn.textContent = 'Skip'; 1734 } 1735 } 1736 1737 if (editBtn) { 1738 var id = editBtn.dataset.fqEdit; 1739 var draft = decodeURIComponent(editBtn.dataset.draft || ''); 1740 var newMsg = await App.dialog.ask({ title: 'Edit the follow-up', body: 'Change the message, then send it.', label: 'Message', value: draft, multiline: true, confirmLabel: 'Send this message' }); 1741 if (newMsg && newMsg.trim()) { 1742 editBtn.disabled = true; 1743 editBtn.textContent = 'Sending...'; 1744 try { 1745 var result = await api('/pipeline/follow-up/' + id + '/approve', { 1746 method: 'POST', 1747 headers: { 'Content-Type': 'application/json' }, 1748 body: JSON.stringify({ message: newMsg.trim() }) 1749 }); 1750 showToast('â Custom message sent to ' + (result.contact || 'contact')); 1751 loadActivityView(); 1752 } catch (err) { 1753 showToast('â ' + (err.message || 'Failed')); 1754 editBtn.disabled = false; 1755 editBtn.textContent = 'âï¸ Edit & Send'; 1756 } 1757 } 1758 } 1759 }); 1760 1761 1762 function actFilterBtn(id, label) { 1763 var active = activityFilter === id; 1764 return '<span data-actfilter="' + id + '" style="padding:4px 12px;border-radius:16px;font-size:11px;font-weight:600;cursor:pointer;' + 1765 (active ? 'background:#4f46e5;color:#fff;' : 'background:#f0f0f0;color:#666;') + 1766 '">' + label + '</span>'; 1767 } 1768 1769 var eventIcons = { 1770 'reply': 'ð¢', 'ai_response': 'ð¤', 'follow_up_sent': 'ð±', 'follow_up_queued': 'â³', 1771 'follow_up_skipped': 'âï¸', 'follow_up_expired': 'â', 'stage_change': 'ð', 1772 'stop_signal': 'ð', 'escalation': 'â ï¸', 'manager_takeover': 'ð¤', 'manager_handback': 'ð', 1773 'pipeline_stale': 'ð¤', 'signup': 'ð' 1774 }; 1775 1776 function renderActivityItems(items) { 1777 if (items.length === 0) { 1778 return '<div style="text-align:center;padding:60px;color:#999;"><div style="font-size:32px;margin-bottom:12px;">ð</div>No activity in the last 7 days</div>'; 1779 } 1780 1781 var lastDate = ''; 1782 return items.map(function(item) { 1783 var d = new Date(item.created_at); 1784 var dateStr = App.Dates.weekdayShort(d); 1785 var timeStr = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }); 1786 var icon = eventIcons[item.event_type] || eventIcons[item.event] || 'ð'; 1787 var summary = item.summary || item.event_type || item.event || ''; 1788 var profileId = item.profile_id || ''; 1789 1790 var dateDivider = ''; 1791 if (dateStr !== lastDate) { 1792 lastDate = dateStr; 1793 dateDivider = '<div style="padding:8px 16px;background:#f5f5f5;font-size:11px;font-weight:600;color:#888;border-bottom:1px solid #e0e0e0;">' + dateStr + '</div>'; 1794 } 1795 1796 var uniqueId = 'evt-' + (item.id || Math.random().toString(36).substr(2, 6)); 1797 1798 return dateDivider + 1799 '<div style="cursor:pointer;
1799border-bottom:1px solid #f0f0f0;" data-expand-event="' + profileId + '" data-evt-id="' + uniqueId + '">' + 1800 '<div style="padding:10px 16px;display:flex;align-items:flex-start;gap:10px;">' + 1801 '<span style="font-size:14px;margin-top:1px;">' + icon + '</span>' + 1802 '<div style="flex:1;min-width:0;">' + 1803 '<div style="font-size:13px;">' + 1804 '<strong>' + escHtml(item.display_name || 'Unknown') + '</strong> ' + 1805 '<span style="color:#666;">' + escHtml(summary) + '</span>' + 1806 '</div>' + 1807 '</div>' + 1808 '<span style="font-size:11px;color:#999;white-space:nowrap;">' + timeStr + '</span>' + 1809 '</div>' + 1810 '<div id="' + uniqueId + '" style="display:none;"></div>' + 1811 '</div>'; 1812 }).join(''); 1813 } 1814 1815 // Expand/collapse activity event conversation 1816 document.addEventListener('click', async function(e) { 1817 var eventRow = e.target.closest('[data-expand-event]'); 1818 if (!eventRow) return; 1819 // Don't trigger if clicking a button inside 1820 if (e.target.closest('button') || e.target.closest('[data-fq-approve]') || e.target.closest('[data-fq-skip]')) return; 1821 1822 var profileId = eventRow.dataset.expandEvent; 1823 if (!profileId) return; 1824 var evtId = eventRow.dataset.evtId; 1825 var preview = document.getElementById(evtId); 1826 if (!preview) return; 1827 1828 if (preview.style.display !== 'none') { 1829 preview.style.display = 'none'; 1830 return; 1831 } 1832 1833 preview.innerHTML = '<div style="padding:12px 16px;color:#999;font-size:12px;">Loading conversation...</div>'; 1834 preview.style.display = 'block'; 1835 1836 try { 1837 var result = await api('/pipeline/conversation-preview/' + profileId); 1838 preview.innerHTML = renderConvoPreview(result.messages); 1839 } catch (err) { 1840 preview.innerHTML = '<div style="padding:12px 16px;color:#ef4444;font-size:12px;">Failed to load</div>'; 1841 } 1842 }); 1843 1844 1845 // Delegation for activity filter buttons 1846 document.addEventListener('click', function(e) { 1847 var btn = e.target.closest('[data-actfilter]'); 1848 if (btn) { 1849 activityFilter = btn.dataset.actfilter; 1850 loadActivityView(); 1851 } 1852 }); 1853 1854 1855 1856 // Template selector for follow-ups 1857 document.addEventListener('click', async function(e) { 1858 var tplBtn = e.target.closest('[data-fq-template]'); 1859 if (!tplBtn) return; 1860 1861 var fqId = tplBtn.dataset.fqTemplate; 1862 tplBtn.disabled = true; 1863 tplBtn.textContent = 'Loading...'; 1864 1865 var templates = await loadTemplates(); 1866 if (templates.length === 0) { 1867 showToast('No templates available'); 1868 tplBtn.disabled = false; 1869 tplBtn.textContent = 'ð Use Template'; 1870 return; 1871 } 1872 1873 // Build template picker modal 1874 var overlay = document.createElement('div'); 1875 overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center;'; 1876 1877 var modal = document.createElement('div'); 1878 modal.style.cssText = 'background:#fff;border-radius:12px;max-width:500px;width:90%;max-height:80vh;overflow-y:auto;padding:0;'; 1879 1880 var html = '<div style="padding:16px;border-bottom:1px solid #e0e0e0;">' + 1881 '<div style="display:flex;justify-content:space-between;align-items:center;">' + 1882 '<h3 style="margin:0;font-size:16px;">Select Template</h3>' + 1883 '<button id="close-tpl-modal" style="background:none;border:none;font-size:20px;cursor:pointer;color:#666;">Ã</button>' + 1884 '</div>' + 1885 '</div>'; 1886
1887 templates.forEach(function(t) { 1888 html += '<div data-select-tpl="' + t.sid + '" data-fq-target="' + fqId + '" style="padding:12px 16px;border-bottom:1px solid #f0f0f0;cursor:pointer;transition:background 0.15s;" onmouseover="this.style.background=\'#f5f5ff\'" onmouseout="this.style.background=\'\'">'+ 1889 '<div style="font-size:13px;font-weight:600;color:#1e40af;margin-bottom:4px;">' + escHtml(t.name || t.sid) + '</div>' + 1890 '<div style="font-size:12px;color:#666;line-height:1.4;">' + escHtml(t.body || '[no preview]') + '</div>' + 1891 '</div>'; 1892 }); 1893 1894 modal.innerHTML = html; 1895 overlay.appendChild(modal); 1896 document.body.appendChild(overlay); 1897 1898 tplBtn.disabled = false; 1899 tplBtn.textContent = 'ð Use Template'; 1900 1901 // Close modal 1902 overlay.addEventListener('click', function(ev) { 1903 if (ev.target === overlay || ev.target.id === 'close-tpl-modal') { 1904 document.body.removeChild(overlay); 1905 } 1906 }); 1907 document.getElementById('close-tpl-modal').addEventListener('click', function() { 1908 document.body.removeChild(overlay); 1909 }); 1910 1911 // Select template 1912 overlay.addEventListener('click', async function(ev) { 1913 var sel = ev.target.closest('[data-select-tpl]'); 1914 if (!sel) return; 1915 var sid = sel.dataset.selectTpl; 1916 var targetFqId = sel.dataset.fqTarget; 1917 1918 sel.style.background = '#dbeafe'; 1919 sel.innerHTML = '<div style="padding:8px;text-align:center;color:#1e40af;font-weight:600;">Sending...</div>'; 1920 1921 try { 1922 var result = await api('/pipeline/follow-up/' + targetFqId + '/send-template', { 1923 method: 'POST', 1924 headers: { 'Content-Type': 'application/json' }, 1925 body: JSON.stringify({ template_sid: sid }) 1926 }); 1927 document.body.removeChild(overlay); 1928 showToast('â Template sent to ' + (result.contact || 'contact')); 1929 loadActivityView(); 1930 } catch (err) { 1931 showToast('â ' + (err.message || 'Failed')); 1932 sel.innerHTML = '<div style="padding:8px;text-align:center;color:#ef4444;">Failed â try again</div>'; 1933 } 1934 }); 1935 }); 1936 1937 1938 // ââ Health Bar ââ 1939 function renderHealthBar() { 1940 var m = pipelineMetrics || {}; 1941 var stages = m.stages || []; 1942 var total = stages.reduce(function(sum, s) { return sum + s.count; }, 0) || 1; 1943 1944 var statsLine = '<div style="display:flex;gap:16px;font-size:12px;color:#666;">' + 1945 '<span><strong>' + pipelineData.length + '</strong> active</span>' + 1946 '<span><strong>' + (m.in_hours_now || 0) + '</strong> in hours now</span>' + 1947 (m.follow_up_success_pct !== null ? '<span>Follow-up success: <strong>' + m.follow_up_success_pct + '%</strong></span>' : '') + 1948 '</div>'; 1949 1950 var bar = stages.length > 0 ? '<div style="display:flex;height:24px;border-radius:6px;overflow:hidden;margin-top:8px;cursor:pointer;">' + 1951 stages.map(function(s) { 1952 var pct = Math.max((s.count / total) * 100, 3); 1953 return '<div title="' + escHtml(s.name) + ': ' + s.count + '" style="width:' + pct + '%;background:' + (s.color || '#999') + ';display:flex;align-items:center;justify-content:center;color:#fff;font-size:10px;font-weight:600;min-width:20px;">' + 1954 (pct > 8 ? s.count : '') + '</div>'; 1955 }).join('') + 1956 '</div>' + 1957 '<div style="display:flex;gap:12px;margin-top:4px;flex-wrap:wrap;">' + 1958 stages.map(function(s) { 1959 return '<span style="font-size:10px;color:#666;"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + (s.color || '#999') + ';margin-right:3px;"></span>' + escHtml(s.name) + ' (' + s.count + ')</span>'; 1960 }).join('') + 1961 '</div>' : ''; 1962 1963 return '<div style="background:#fff9e6;border-bottom:1px solid #ffe082;padding:12px 24px;">' + 1964 '<div style="display:flex;align-items:center;gap:12px;margin-bottom:4px;">' + 1965 '<span style="font-size:18px;">ð</span>' + 1966 '<strong style="font-size:14px;">Pipeline</strong>' + 1967 statsLine + 1968 '</div>' + 1969 bar + 1970 '</div>'; 1971 } 1972 1973 // ââ Contact Summary Card ââ 1974 window._selectPipelineItem = async function(el) { 1975 var profileId = parseInt(el.dataset.profile); 1976 var assignmentId = parseInt(el.dataset.assignment); 1977 var preview = document.getElementById('msgPreviewPane'); 1978 if (!preview) return; 1979 1980 // Highlight selected row 1981 var rows = document.querySelectorAll('#pipelineListPane > div[data-assignment]'); 1982 rows.forEach(function(r) { r.style.background = ''; }); 1983 el.style.background = '#e8f4ff'; 1984 1985 preview.innerHTML = '<div style="text-align:center;padding:40px;color:#999;">Loading...</div>'; 1986 1987 try { 1988 var [summaryResult, convoResult] = await Promise.all([ 1989 api('/pipeline/' + assignmentId + '/summary'), 1990 api('/contacts/' + profileId + '/messages?limit=20').catch(function() { return { messages: [] }; }) 1991 ]); 1992 1993 var a = (summaryResult && summaryResult.assignment) || {}; 1994 var events = (summaryResult && summaryResult.events) || []; 1995 var followUps = (summaryResult && summaryResult.follow_ups) || []; 1996 var messages = Array.isArray(convoResult) ? convoResult : (convoResult && convoResult.messages) || []; 1997 1998 var tzDot = { green: 'ð¢', amber: 'ð¡', red: 'ð´', grey: 'âª' }; 1999 2000 // Header card 2001 var header = '<div style="padding:16px 20px;border-bottom:1px solid #e0e0e0;background:#fff;">' + 2002 '<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">' + 2003 '<strong style="font-size:16px;">' + escHtml(a.display_name || 'Unknown') + '</strong>' + 2004 '<span style="font-size:12px;color:#888;">' + escHtml(a.primary_phone || '') + '</span>' + 2005 (a.primary_email ? '<span style="font-size:12px;color:#888;">' + escHtml(a.primary_email) + '</span>' : '') + 2006 '</div>' + 2007 '<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">' + 2008 '<span style="padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;color:#fff;background:' + (a.stage_color || '#999') + ';">' + escHtml(a.stage_name || '?') + '</span>' + 2009 '<span style="font-size:12px;">' + (tzDot[a.tz_status] || 'âª') + ' ' + (a.tz_local_time || '') + '</span>' + 2010 (a.wa_window_open ? '<span style="font-size:11px;color:#22c55e;">ð¢ WA window open</span>' : '<span style="font-size:11px;color:#ef4444;">ð´ Template only</span>') + 2011 '<span style="font-size:11px;color:#888;">Silent ' + (a.silence_hours || '?') + 'h</span>' + 2012 '<span style="font-size:11px;color:#888;">Nudges: ' + (a.nudge_count || 0) + '/3</span>' + 2013 '<span style="font-size:11px;color:#888;">Coach: ' + escHtml(a.coach_name || '?') + '</span>' + 2014 '</div>' + 2015 '</div>'; 2016 2017 // Follow-up history 2018 var fuHtml = ''; 2019 if (followUps.length > 0) { 2020 fuHtml = '<div style="padding:12px 20px;border-bottom:1px solid #e0e0e0;background:#fefce8;">' + 2021 '<strong style="font-size:12px;">Follow-up History</strong>' + 2022 '<div style="margin-top:6px;">' + 2023 followUps.map(function(fu) { 2024 var statusColors = { sent: '#22c55e', pending: '#3b82f6', skipped: '#9ca3af', expired: '#f59e0b' }; 2025 var d = new Date(fu.created_at); 2026 return '<div style="font-size:11px;padding:3px 0;color:#555;">' + 2027 '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + (statusColors[fu.status] || '#999') + ';margin-right:4px;"></span>' + 2028 'Attempt ' + fu.attempt_number + ' â ' + fu.status + ' (' + fu.message_type + ') ' + 2029 App.Dates.short(d, { year: false }) + ' ' + 2030 d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) + 2031 '</div>'; 2032 }).join('') + 2033 '</div></div>'; 2034 } 2035 2036 // Recent activity timeline 2037 var actHtml = ''; 2038 if (events.length > 0) { 2039 var eIcons = { reply: 'ð¢', ai_response: 'ð¤', follow_up_sent: 'ð±', follow_up_queued: 'â³', stage_change: 'ð', stop_signal: 'ð', manager_takeover: 'ð¤' }; 2040 actHtml = '<div style="padding:12px 20px;border-bottom:1px solid #e0e0e0;">' + 2041 '<strong style="font-size:12px;">Recent Activity</strong>' + 2042 '<div style="margin-top:6px;">' + 2043 events.slice(0, 8).map(function(ev) { 2044 var d = new Date(ev.created_at); 2045 return '<div style="font-size:11px;padding:2px 0;color:#555;">' + 2046 (eIcons[ev.event_type] || 'ð') + ' ' + 2047 escHtml(ev.summary || ev.event_type) + ' â ' + 2048 d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) + 2049 '</div>'; 2050 }).join('') + 2051 '</div></div>'; 2052 } 2053 2054 // Action buttons 2055 var actions = '<div style="padding:10px 20px;border-bottom:1px solid #e0e0e0;display:flex;gap:8px;background:#fafafa;">
2055' + 2056 '<button data-aid="' + assignmentId + '" data-action="takeover" style="padding:5px 12px;border:1px solid #ddd;border-radius:6px;background:#fff;font-size:11px;cursor:pointer;">â¸ï¸ Take over</button>' + 2057 '<button data-aid="' + assignmentId + '" data-action="handback" style="padding:5px 12px;border:1px solid #ddd;border-radius:6px;background:#fff;font-size:11px;cursor:pointer;">ð Hand back</button>' + 2058 '<button data-aid="' + assignmentId + '" data-action="archive" style="padding:5px 12px;border:1px solid #ddd;border-radius:6px;background:#fff;font-size:11px;cursor:pointer;">ðï¸ Archive</button>' + 2059 '<button onclick="loadConvo(' + profileId + ')" style="padding:5px 12px;border:1px solid #4f46e5;border-radius:6px;background:#4f46e5;color:#fff;font-size:11px;cursor:pointer;">ð¬ Open Chat</button>' + 2060 '</div>'; 2061 2062 preview.innerHTML = header + fuHtml + actHtml + actions + 2063 '<div style="padding:12px 20px;overflow-y:auto;flex:1;">' + 2064 '<strong style="font-size:12px;">Conversation</strong>' + 2065 '<div style="margin-top:8px;">' + 2066 (messages.length > 0 ? messages.map(function(m) { 2067 var isIn = m.direction === 'inbound'; 2068 var statusIcon = ''; 2069 if (!isIn) { 2070 var st = (m.status || '').toLowerCase(); 2071 if (st === 'read') statusIcon = ' <span title="Read" style="color:#34b7f1;">\u2713\u2713</span>'; 2072 else if (st === 'delivered') statusIcon = ' <span title="Delivered" style="color:#999;">\u2713\u2713</span>'; 2073 else if (st === 'sent') statusIcon = ' <span title="Sent" style="color:#999;">\u2713</span>'; 2074 else if (st === 'failed' || st === 'undelivered') statusIcon = ' <span title="Failed" style="color:#ef4444;">\u2715</span>'; 2075 else if (st === 'queued') statusIcon = ' <span title="Queued" style="color:#f59e0b;">\u23F3</span>'; 2076 else statusIcon = ' <span title="' + st + '" style="color:#ccc;">\u2713</span>'; 2077 } 2078 return '<div style="padding:6px 10px;margin:4px 0;border-radius:8px;max-width:80%;font-size:12px;' + 2079 (isIn ? 'background:#e8f4ff;margin-right:auto;' : 'background:#f0f0f0;margin-left:auto;') + '">' + 2080 escHtml(m.message || '') + 2081 '<div style="font-size:9px;color:#999;margin-top:2px;">' + 2082 new Date(m.sent_at || m.created_at).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) + 2083 statusIcon + 2084 '</div></div>'; 2085 }).join('') : '<div style="color:#999;font-size:12px;">No messages yet</div>') + 2086 '</div>' + 2087 '</div>'; 2088 2089 // Wire action button delegation on preview pane 2090 preview.addEventListener('click', function handler(e) { 2091 var btn = e.target.closest('button[data-action]'); 2092 if (btn) { 2093 e.stopPropagation(); 2094 window._pipeAction(parseInt(btn.dataset.aid), btn.dataset.action); 2095 } 2096 }); 2097 2098 } catch (err) { 2099 preview.innerHTML = '<div style="text-align:center;padding:40px;color:#c00;">Error loading summary</div>'; 2100 console.error('[Pipeline] Summary load error:', err); 2101 } 2102 }; 2103 2104})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.