1// inbox.js â Extracted from app.html monolith 2// â ï¸ DORMANT â reads archived `contacts`, repoint to `profiles` before reuse. 3// The WhatsApp Inbox is demoted to a hidden, feature-gated tool (WS5): it only 4// appears in the Subscribers subnav when features.inbox is set. The data path 5// here still expects the legacy contacts table â repoint to profiles (see 6// SUBSCRIBER-REMEDIATION-SPEC) before the Inbox is reinstated. 7let currentCoachFilter = 'all'; 8let _inboxData = []; 9let _tzFilter = null; 10 11// Quote-safe HTML escaper for user-controlled output (F5 stored-XSS hardening). 12// The global escapeHtml() from utils.js does NOT escape quotes, so it is unsafe 13// for attribute values â this one covers text AND double-quoted attribute contexts. 14function _escInbox(s) { 15 return String(s == null ? '' : s) 16 .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') 17 .replace(/"/g, '"').replace(/'/g, '''); 18} 19 20async function loadInbox() { 21 const customerId = currentUser.customer_id || 1; 22 const coachParam = currentCoachFilter !== 'all' ? '?coach_id=' + currentCoachFilter : ''; 23 const [stats, inbox, coaches] = await Promise.all([ 24 api('/stats'), 25 api('/customers/' + customerId + '/inbox' + coachParam), 26 api('/coaches') 27 ]); 28 29 // Render coach filter bar 30 renderCoachFilter(coaches || []); 31 32 // Load funnel bar if a specific coach is selected 33 if (currentCoachFilter !== 'all') { 34 loadFunnelBar(currentCoachFilter); 35 } else { 36 const funnelEl = document.getElementById('funnelBar'); 37 if (funnelEl) funnelEl.innerHTML = ''; 38 } 39 40 if (stats) { 41 document.getElementById('engageStats').innerHTML = ` 42 <div class="stat-card"><div class="stat-value">${stats.conversations_today}</div><div class="stat-label">Conversations Today</div></div> 43 <div class="stat-card"><div class="stat-value">${stats.active_clients_7d}</div><div class="stat-label">Active Clients (7d)</div></div> 44 <div class="stat-card"><div class="stat-value">${stats.conversations_total}</div><div class="stat-label">Total Messages</div></div> 45 <div class="stat-card"><div class="stat-value">${stats.unresolved_alerts}</div><div class="stat-label">Alerts</div></div> 46 `; 47 } 48 49 const list = document.getElementById('inboxList'); 50 if (!inbox || inbox.length === 0) { 51 list.innerHTML = '<div class="empty-state" style="padding:40px;"><i class="fas fa-inbox"></i><h3>No conversations yet</h3></div>'; 52 return; 53 } 54 _inboxData = inbox; 55 renderInboxList(inbox); 56} 57 58function renderInboxList(data) { 59 const filtered = _tzFilter ? data.filter(function(c) { return c.tz_color === _tzFilter; }) : data; 60 const list = document.getElementById('inboxList'); 61 if (!filtered || filtered.length === 0) { 62 list.innerHTML = renderTzPills() + '<div class="empty-state" style="padding:40px;"><i class="fas fa-inbox"></i><h3>No conversations</h3></div>'; 63 return; 64 } 65 list.innerHTML = renderTzPills() + filtered.map(c => { 66 const channelIcon = c.channel === 'webchat' ? '<i class="fas fa-globe" title="Web Chat" style="color:#6366f1;font-size:11px;"></i>' : 67 c.channel === 'whatsapp' ? '<i class="fab fa-whatsapp" title="WhatsApp" style="color:#25D366;font-size:11px;"></i>' : 68 c.channel === 'email' ? '<i class="fas fa-envelope" title="Email" style="color:#888;font-size:11px;"></i>' : 69 '<i class="fas fa-comment" title="' + (c.channel || 'unknown') + '" style="color:#888;font-size:11px;"></i>'; 70 const coachBadge = c.coach_name 71 ? '<span class="coach-badge" title="' + _escInbox(c.coach_name) + '">' + _escInbox(getCoachInitials(c.coach_name)) + '</span>' 72 : '<span class="coach-badge unassigned" title="No coach assigned">?</span>'; 73 const pausedBadge = c.agent_paused ? '<span class="paused-badge" title="AI paused - human mode"><i class="fas fa-hand-paper"></i></span>' : ''; 74 var tzBg = {green:'rgba(34,197,94,0.08)',amber:'rgba(245,158,11,0.08)',red:'rgba(239,68,68,0.06)',grey:'transparent'}[c.tz_color||'grey']; 75 var tzBorder = {green:'#22c55e',amber:'#f59e0b',red:'#ef4444',grey:'transparent'}[c.tz_color||'grey']; 76 return '<div class="inbox-item" style="background:' + tzBg + ';border-left:3px solid ' + tzBorder + ';" onclick="loadConvo(' + c.contact_id + ', this)">' + 77 '<div class="inbox-avatar">' + _escInbox((c.first_name || c.profile_name || '?')[0].toUpperCase()) + '</div>' + 78 '<div class="inbox-info">' + 79 '<div class="inbox-name">' + channelIcon + ' ' + _escInbox(c.first_name || c.profile_name || c.phone || 'Unknown') + ' ' + coachBadge + pausedBadge + '<span style="display:inline-block;width:10px;height:10px;border-radius:50%;margin-left:6px;vertical-align:middle;background:' + ({green:'#22c55e',amber:'#f59e0b',red:'#ef4444',grey:'rgba(255,255,255,0.2)'}[c.tz_color || 'grey']) + ';box-shadow:0 0 0 1px rgba(255,255,255,0.1);" title="' + _escInbox(c.tz_label || 'No timezone') + '"></span>' + '</div>' + 80 '<div class="inbox-preview">' + _escInbox(c.last_message || '') + '</div>' + 81 '</div>' + 82 '</div>'; 83 }).join(''); 84 85 // Init step wizard after DOM is populated 86} 87 88async function loadConvo(contactId, el) { 89 document.querySelectorAll('.inbox-item').forEach(i => i.classList.remove('active')); 90 if (el) el.classList.add('active'); 91 currentContactId = contactId; 92 93 const [msgs, feedback] = await Promise.all([ 94 api('/contacts/' + contactId + '/messages'), 95 api('/feedback/contact/' + contactId) 96 ]); 97 98 conversationFeedback = {}; 99 if (feedback) { 100 for (const fb of feedback) { 101 if (!conversationFeedback[fb.conversation_id]) conversationFeedback[fb.conversation_id] = []; 102 conversationFeedback[fb.conversation_id].push(fb); 103 } 104 } 105 106 const chat = document.getElementById('inboxChat'); 107 var contactData = _inboxData.find(function(c) { return c.contact_id === contactId || c.profile_id === contactId; }); 108 var tzInfo = contactData && contactData.tz_label ? '<span style="margin-left:12px;font-size:12px;color:#94a3b8;"><i class="fas fa-clock" style="margin-right:4px;color:' + ({green:'#22c55e',amber:'#f59e0b',red:'#ef4444',grey:'#6b7280'}[contactData.tz_color||'grey']) + ';"></i>' + _escInbox(contactData.tz_label) + '</span>' : ''; 109 var cName = contactData ? (contactData.first_name || contactData.profile_name || contactData.phone || 'Unknown') : 'Unknown'; 110 var chIcon = contactData ? (contactData.channel === 'whatsapp' ? '<i class="fab fa-whatsapp" style="color:#25D366;margin-right:6px;"></i>' : contactData.channel === 'email' ? '<i class="fas fa-envelope" style="color:#888;margin-right:6px;"></i>' : '') : ''; 111 var headerBar = '<div style="padding:12px 16px;border-bottom:1px solid rgba(255,255,255,0.08);display:flex;align-items:center;"><div style="display:flex;align-items:center;">' + chIcon + '<strong style="font-size:15px;">' + _escInbox(cName) + '</strong>' + tzInfo + '</div></div>'; 112 if (!msgs || msgs.length === 0) { chat.innerHTML = '<div class="inbox-empty">No messages</div>'; return; } 113 114 chat.innerHTML = headerBar + '<div class="inbox-messages">' + msgs.map(m => {
115 let content = ''; 116 117 // Voice note from client 118 if (m.media_url && m.media_type?.startsWith('audio/')) { 119 content += `<div class="voice-note"><audio controls preload="none" src="/api/media/proxy?url=${encodeURIComponent(m.media_url)}"></audio></div>`; 120 if (m.voice_transcript) { 121 content += `<div class="voice-transcript">ð "${escapeHtml(m.voice_transcript)}"</div>`; 122 } 123 } else if (m.media_url && m.media_type?.startsWith('image/')) { 124 content += `<img src="/api/media/proxy?url=${encodeURIComponent(m.media_url)}" style="max-width:200px;border-radius:8px;margin:4px 0;">`; 125 } 126 127 if (m.message && m.message !== '[Voice note]') { 128 content += `<div>${escapeHtml(m.message)}</div>`; 129 } 130 131 // Existing feedback 132 const fbs = conversationFeedback[m.id] || []; 133 let feedbackHtml = fbs.map(fb => ` 134 <div class="existing-feedback"> 135 <span class="fb-type">${fb.correction_type || 'note'}</span>: ${escapeHtml(fb.feedback_text || '')} 136 ${fb.suggested_response ? `<br><em style="color:#666;">â "${escapeHtml(fb.suggested_response)}"</em>` : ''} 137 </div> 138 `).join(''); 139 140 // Init step wizard after DOM is populated 141 142 // Train button (only on AI outbound messages) 143 const trainBtn = m.direction === 'outbound' ? 144 `<button class="train-btn ${fbs.length > 0 ? 'trained' : ''}" onclick="openTrainPanel(${m.id}, ${contactId})"> 145 <i class="fas ${fbs.length > 0 ? 'fa-check-circle' : 'fa-graduation-cap'}"></i> 146 ${fbs.length > 0 ? 'Trained (' + fbs.length + ')' : 'Train your AI'} 147 </button>` : ''; 148 149 return ` 150 <div class="msg ${m.direction}"> 151 ${content} 152 <div class="msg-time">${App.Dates.shortDateTime(m.sent_at)}</div> 153 ${feedbackHtml} 154 </div> 155 ${trainBtn} 156 <div id="train-panel-${m.id}" style="display:none;"></div> 157 `; 158 }).join('') + '</div>'; 159 160 chat.querySelector('.inbox-messages').scrollTop = 999999; 161} 162 163function openTrainPanel(msgId, contactId) { 164 const panel = document.getElementById('train-panel-' + msgId); 165 if (panel.style.display !== 'none') { panel.style.display = 'none'; return; } 166 167 // Close any other open panels 168 document.querySelectorAll('[id^="train-panel-"]').forEach(p => p.style.display = 'none'); 169 panel.style.display = 'block'; 170 171 panel.innerHTML = ` 172 <div class="train-panel"> 173 <h4><i class="fas fa-graduation-cap"></i> Train Your AI</h4> 174 <p class="train-hint">Tell your coach what it should do differently here. Record a quick voice note â it's the fastest way.</p> 175 176 <div class="category-pills"> 177 <div class="cat-pill active" onclick="selectCat(this)" data-type="tone">ð Wrong tone</div> 178 <div class="cat-pill" onclick="selectCat(this)" data-type="close">ð° Should close/sell</div> 179 <div class="cat-pill" onclick="selectCat(this)" data-type="offer">ð Suggest a service</div> 180 <div class="cat-pill" onclick="selectCat(this)" data-type="knowledge">ð Missing info</div> 181 <div class="cat-pill" onclick="selectCat(this)" data-type="action">Wrong approach</div> 182 <div class="cat-pill" onclick="selectCat(this)" data-type="other">ð¡ Other</div> 183 </div> 184 185 <div class="record-area"> 186 <button class="record-btn idle" id="rec-btn-${msgId}" onclick="toggleRecording(${msgId})"> 187 <i class="fas fa-microphone"></i> 188 </button> 189 <div> 190 <div class="record-status" id="rec-status-${msgId}">Tap to record your feedback</div> 191 <div style="font-size:11px;color:#aaa;margin-top:2px;">Your voice note will be transcribed automatically</div> 192 </div> 193 </div> 194 <div id="rec-playback-${msgId}"></div> 195 <div id="ai-summary-${msgId}"></div> 196 197 <div class="or-divider">or type it</div> 198 199 <textarea class="train-text-area" id="train-text-${msgId}" placeholder="E.g. 'At this point you should offer our webinar setup service â the client clearly needs help with their funnel'"></textarea> 200 201 <div style="display:flex;gap:8px;margin-top:14px;"> 202 <button class="btn btn-primary" style="background:#FF9500;" onclick="submitTraining(${msgId}, ${contactId})"><i class="fas fa-paper-plane"></i> Submit Training</button> 203 <button class="btn btn-outline" onclick="document.getElementById('train-panel-${msgId}').style.display='none'">Cancel</button> 204 </div> 205 </div> 206 `; 207} 208 209function selectCat(el) { 210 el.parentElement.querySelectorAll('.cat-pill').forEach(b => b.classList.remove('active')); 211 el.classList.add('active'); 212} 213 214async function toggleRecording(msgId) { 215 const btn = document.getElementById('rec-btn-' + msgId); 216 const status = document.getElementById('rec-status-' + msgId); 217 218 if (mediaRecorder && mediaRecorder.state === 'recording') { 219 // Stop 220 mediaRecorder.stop();
221 btn.className = 'record-btn idle'; 222 btn.innerHTML = '<i class="fas fa-microphone"></i>'; 223 status.textContent = 'Processing...'; 224 status.className = 'record-status'; 225 return; 226 } 227 228 // Start recording 229 try { 230 const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); 231 audioChunks = []; 232 recordingMsgId = msgId; 233 mediaRecorder = new MediaRecorder(stream); 234 235 mediaRecorder.ondataavailable = (e) => audioChunks.push(e.data); 236 mediaRecorder.onstop = async () => { 237 stream.getTracks().forEach(t => t.stop()); 238 const blob = new Blob(audioChunks, { type: 'audio/webm' }); 239 const url = URL.createObjectURL(blob); 240 241 // Show playback 242 document.getElementById('rec-playback-' + msgId).innerHTML = ` 243 <div class="record-playback"><audio controls src="${url}"></audio></div> 244 `; 245 status.textContent = 'Transcribing with AI...'; 246 247 // Upload and transcribe 248 const formData = new FormData(); 249 formData.append('audio', blob, 'feedback.webm'); 250 formData.append('msgId', msgId); 251 252 try { 253 const result = await fetch('/api/feedback/transcribe', { 254 method: 'POST', body: formData, 255 headers: { 'Authorization': 'Bearer ' + getToken() } 256 }).then(r => r.json()); 257 258 if (result.transcript) { 259 // Show AI summary 260 document.getElementById('ai-summary-' + msgId).innerHTML = ` 261 <div class="ai-summary"> 262 <div class="summary-label">ð¤ AI understood your feedback as:</div> 263 <div class="summary-text">${escapeHtml(result.summary || result.transcript)}</div> 264 <div class="summary-actions"> 265 <button class="btn btn-primary" style="font-size:12px;padding:5px 12px;background:#34C759;" onclick="approveTranscript(${msgId})">â Correct</button> 266 <button class="btn btn-outline" style="font-size:12px;padding:5px 12px;" onclick="editTranscript(${msgId})">âï¸ Edit</button> 267 </div> 268 </div> 269 `; 270 // Pre-fill the text area with the transcript 271 document.getElementById('train-text-' + msgId).value = result.summary || result.transcript; 272 status.textContent = 'Feedback captured! Review and submit below.'; 273 } else { 274 status.textContent = 'Could not transcribe. Please type your feedback instead.'; 275 } 276 } catch(e) { 277 status.textContent = 'Transcription failed. Type your feedback instead.'; 278 console.error('Transcribe error:', e); 279 } 280 }; 281 282 mediaRecorder.start(); 283 btn.className = 'record-btn recording'; 284 btn.innerHTML = '<i class="fas fa-stop"></i>'; 285 status.textContent = 'ð´ Recording... tap to stop'; 286 status.className = 'record-status active'; 287 } catch(e) { 288 status.textContent = 'Microphone access denied. Please type your feedback.'; 289 console.error('Mic error:', e); 290 } 291} 292 293function approveTranscript(msgId) { 294 document.getElementById('ai-summary-' + msgId).querySelector('.summary-label').textContent = 'â Approved'; 295 document.getElementById('ai-summary-' + msgId).querySelector('.summary-actions').innerHTML = '<span style="color:#34C759;font-size:12px;font-weight:500;">â Ready to submit</span>'; 296} 297 298function editTranscript(msgId) { 299 const textarea = document.getElementById('train-text-' + msgId); 300 textarea.focus(); 301 textarea.scrollIntoView({ behavior: 'smooth', block: 'center' }); 302} 303 304async function submitTraining(msgId, contactId) { 305 const type = document.querySelector(`#train-panel-${msgId} .cat-pill.active`)?.dataset.type || 'other'; 306 const text = document.getElementById('train-text-' + msgId)?.value; 307 if (!text) { showToast('Please record or type your feedback first'); return; } 308 309 const result = await api('/feedback', { 310 method: 'POST', 311 body: { conversation_id: msgId, contact_id: contactId, feedback_type: 'correction', feedback_text: text, correction_type: type } 312 }); 313 314 document.getElementById('train-panel-' + msgId).style.display = 'none'; 315 loadConvo(contactId); 316} 317 318 319// --- Additional extractions --- 320 321let currentContactId = null; 322 323let conversationFeedback = {}; 324 325 326 327function getCoachInitials(name) { 328 if (!name) return '?';
329 return name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2); 330} 331 332function renderCoachFilter(coaches) { 333 let bar = document.getElementById('coachFilterBar'); 334 if (!bar) { 335 bar = document.createElement('div'); 336 bar.id = 'coachFilterBar'; 337 bar.className = 'coach-filter-bar'; 338 const inboxList = document.getElementById('inboxList'); 339 inboxList.parentNode.insertBefore(bar, inboxList); 340 } 341 342 const pills = ['<div class="coach-pill' + (currentCoachFilter === 'all' ? ' active' : '') + '" onclick="filterByCoach(\'all\')">All</div>']; 343 for (const c of coaches) { 344 const initials = getCoachInitials(c.name); 345 const isActive = currentCoachFilter == c.id ? ' active' : ''; 346 pills.push('<div class="coach-pill' + isActive + '" onclick="filterByCoach(' + c.id + ')" title="' + _escInbox(c.name) + '">' + _escInbox(initials) + ' ' + _escInbox(c.name) + '</div>'); 347 } 348 bar.innerHTML = pills.join(''); 349} 350 351function filterByCoach(coachId) { 352 currentCoachFilter = coachId; 353 loadInbox(); 354} 355 356 357// ââ Funnel Pipeline Bar ââ 358let _funnelStageFilter = null; 359 360async function loadFunnelBar(coachId) { 361 let container = document.getElementById('funnelBar'); 362 if (!container) { 363 // Create container above inbox list 364 const inboxList = document.getElementById('inboxList'); 365 if (inboxList) { 366 container = document.createElement('div'); 367 container.id = 'funnelBar'; 368 container.style.cssText = 'margin-bottom:16px;'; 369 inboxList.parentNode.insertBefore(container, inboxList); 370 } else return; 371 } 372 373 try { 374 const data = await api('/coaches/' + coachId + '/funnel'); 375 if (!data || !data.stages || data.stages.length === 0) { 376 container.innerHTML = ''; 377 return; 378 } 379 renderFunnelBar(container, data, coachId); 380 } catch (e) { 381 container.innerHTML = ''; 382 } 383} 384 385function renderFunnelBar(container, data, coachId) { 386 const stages = data.stages; 387 const maxCount = Math.max(1, ...stages.map(function(s) { return s.count; })); 388 389 let html = '<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:16px;margin-bottom:8px;">' + 390 '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">' + 391 '<span style="font-weight:600;font-size:14px;color:rgba(255,255,255,0.9);">Funnel Pipeline</span>' + 392 '<div style="display:flex;gap:8px;">' + 393 '<button onclick="reassessFunnel(' + coachId + ')" style="background:rgba(99,102,241,0.2);border:1px solid rgba(99,102,241,0.3);border-radius:6px;color:#a78bfa;padding:4px 10px;font-size:11px;cursor:pointer;" title="Re-assess all contacts"><i class="fas fa-sync-alt"></i> Refresh</button>' + 394 (_funnelStageFilter ? '<button onclick="clearFunnelFilter()" style="background:rgba(239,68,68,0.2);border:1px solid rgba(239,68,68,0.3);border-radius:6px;color:#ef4444;padding:4px 10px;font-size:11px;cursor:pointer;">Clear Filter</button>' : '') + 395 '</div>' + 396 '</div>' + 397 '<div style="display:flex;gap:2px;align-items:flex-end;height:60px;margin-bottom:8px;">'; 398 399 // Bar chart
400 stages.forEach(function(stage) { 401 const barHeight = Math.max(8, Math.round((stage.count / maxCount) * 52)); 402 const isActive = _funnelStageFilter === stage.id; 403 const opacity = _funnelStageFilter && !isActive ? '0.3' : '1'; 404 html += '<div data-stage="' + stage.id + '" onclick="filterByFunnelStage(this.dataset.stage)" style="flex:1;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;opacity:' + opacity + ';transition:opacity 0.2s;" title="' + _escInbox(stage.name) + ': ' + stage.count + ' contacts">' + 405 '<div style="font-size:11px;font-weight:700;color:' + stage.color + ';margin-bottom:2px;">' + stage.count + '</div>' + 406 '<div style="width:100%;height:' + barHeight + 'px;background:' + stage.color + ';border-radius:4px 4px 0 0;' + (isActive ? 'box-shadow:0 0 8px ' + stage.color + ';' : '') + '"></div>' + 407 '</div>'; 408 }); 409 410 html += '</div>' + 411 '<div style="display:flex;gap:2px;">'; 412 413 // Labels + conversion rates 414 stages.forEach(function(stage, i) { 415 html += '<div style="flex:1;text-align:center;">' + 416 '<div style="font-size:9px;color:rgba(255,255,255,0.5);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="' + _escInbox(stage.name) + '">' + _escInbox(stage.name) + '</div>' + 417 (i > 0 ? '<div style="font-size:9px;color:' + (stage.conversionRate > 50 ? '#22c55e' : stage.conversionRate > 25 ? '#f59e0b' : '#ef4444') + ';">' + stage.conversionRate + '%</div>' : '<div style="font-size:9px;color:rgba(255,255,255,0.3);">â</div>') + 418 '</div>'; 419 }); 420 421 html += '</div>'; 422 423 // Unassessed count 424 if (data.unassessed > 0) { 425 html += '<div style="margin-top:8px;font-size:11px;color:rgba(255,255,255,0.4);">' + data.unassessed + ' contact' + (data.unassessed !== 1 ? 's' : '') + ' not yet assessed</div>'; 426 } 427 428 html += '</div>'; 429 container.innerHTML = html; 430} 431 432window.filterByFunnelStage = function(stageId) { 433 if (_funnelStageFilter === stageId) { 434 _funnelStageFilter = null; 435 } else { 436 _funnelStageFilter = stageId; 437 } 438 // Re-render funnel bar with active state 439 if (currentCoachFilter !== 'all') loadFunnelBar(currentCoachFilter); 440 // Filter inbox list 441 filterInboxByStage(); 442}; 443 444window.clearFunnelFilter = function() { 445 _funnelStageFilter = null; 446 if (currentCoachFilter !== 'all') loadFunnelBar(currentCoachFilter); 447 filterInboxByStage(); 448}; 449 450window.reassessFunnel = async function(coachId) { 451 const btn = event.target.closest('button'); 452 if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Assessing...'; } 453 try { 454 const result = await api('/coaches/' + coachId + '/funnel/assess', { method: 'POST' }); 455 if (result && result.updated > 0) { 456 loadFunnelBar(coachId); 457 } 458 } catch (e) { 459 console.error('[Funnel] Reassess failed:', e.message); 460 } 461 if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-sync-alt"></i> Refresh'; } 462}; 463 464function filterInboxByStage() { 465 const items = document.querySelectorAll('.inbox-item'); 466 // For now show/hide based on data attribute â we'll need to add stage to inbox items 467 // TODO: add funnel_stage to inbox query response 468 // For now, clicking a stage just sets the filter visually 469} 470 471 472function renderTzPills() { 473 var counts = {green:0, amber:0, red:0, grey:0}; 474 _inboxData.forEach(function(c) { counts[c.tz_color||'grey']++; }); 475 var pills = [ 476 {key:'green', label:'\u{1F7E2} Active', color:'#22c55e'}, 477 {key:'amber', label:'\u{1F7E1} Closing', color:'#f59e0b'}, 478 {key:'red', label:'\u{1F534} Outside', color:'#ef4444'} 479 ]; 480 return '<div style="padding:8px 12px;display:flex;gap:6px;flex-wrap:wrap;border-bottom:1px solid rgba(255,255,255,0.08);">' + 481 pills.map(function(p) { 482 var isActive = _tzFilter === p.key; 483 return '<button onclick="setTzFilter(\'' + p.key + '\')" style="padding:4px 10px;border-radius:12px;font-size:11px;cursor:pointer;border:1px solid ' + p.color + ';background:' + (isActive ? p.color : 'transparent') + ';color:' + (isActive ? '#fff' : p.color) + ';">' + p.label + ' (' + counts[p.key] + ')</button>'; 484 }).join('') + 485 (_tzFilter ? '<button onclick="setTzFilter(null)" style="padding:4px 10px;border-radius:12px;font-size:11px;cursor:pointer;border:1px solid rgba(255,255,255,0.2);background:transparent;color:#94a3b8;">\u2715 Clear</button>' : '') + 486 '</div>'; 487} 488 489function setTzFilter(f) { 490 _tzFilter = (_tzFilter === f) ? null : f; 491 renderInboxList(_inboxData); 492}
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.