PageSourceSearch

https://app.replicants.ai/js/inbox.js

js replicants.ai collected 2026-10-05 12:31:44 UTC 23,795 bytes, 492 lines download raw bytes

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
17    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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.