← back to Butlr
views/public/calls.ejs
266 lines
<%- include('../partials/head', { title, meta_desc }) %>
<%- include('../partials/header') %>
<main class="calls-page">
<section class="section">
<div class="wrap">
<header class="page-head">
<h1>Your call queue</h1>
<a class="btn primary" href="/new">Start a new call →</a>
</header>
<!-- QUICK DIAL — minimal form, place a call without the full wizard -->
<section class="quick-dial">
<strong class="quick-dial-title">⚡ Quick dial</strong>
<p class="muted small quick-dial-sub">Enter business + goal, we'll dial right now.</p>
<form id="quick-dial-form" class="quick-dial-form">
<label class="qd-field">
<span class="muted">Business name</span>
<input name="business_name" required placeholder="e.g. NIST Time">
</label>
<label class="qd-field">
<span class="muted">Phone (E.164, +1...)</span>
<input name="business_phone" id="quick-dial-phone" required placeholder="+13034997111" pattern="\+[1-9][0-9]{1,14}">
<span id="dnc-status" class="muted small qd-dnc"></span>
</label>
<label class="qd-field qd-field-full">
<span class="muted">Goal (what Butlr should accomplish)</span>
<textarea name="goal" required rows="2" placeholder="e.g. Get the current store hours, then hang up."></textarea>
</label>
<div class="qd-actions">
<button class="btn primary" type="submit" id="quick-dial-btn">📞 Dial now</button>
<span id="quick-dial-status" class="muted small"></span>
</div>
</form>
</section>
<% if (!calls.length) { %>
<div class="empty-state empty-state-rich">
<div class="empty-state-icon" aria-hidden="true">🎩</div>
<h2>No calls yet</h2>
<p class="muted">Butlr hasn't dialed anyone for you yet. Pick how you want to start:</p>
<div class="empty-state-actions">
<a class="btn primary" href="#quick-dial-form" onclick="document.querySelector('input[name=business_name]').focus()">⚡ Quick dial</a>
<a class="btn ghost" href="/new">Full wizard →</a>
</div>
</div>
<% } else { %>
<ul class="call-list">
<% calls.forEach(function(c) { %>
<li class="call-row" data-call-id="<%= c.id %>">
<a class="call-row-main" href="/calls/<%= c.id %>">
<div class="call-row-head">
<strong><%= c.business_name %></strong>
<span class="status status-<%= c.status %>" data-status-pill><%= c.status %></span>
<% if (c.dnc_flagged) { %>
<span class="dnc-pill" title="Blocked by DNC gate: <%= c.dnc_reason || '?' %> (<%= c.dnc_source || '?' %>)" style="display:inline-block;padding:2px 8px;border-radius:9999px;background:#fee2e2;color:#991b1b;font-size:11px;font-weight:600;letter-spacing:0.03em;margin-left:6px;">🚫 DNC</span>
<% } %>
</div>
<p class="muted small">
<%= c.category_label %> ·
<%= c.business_phone %> ·
queued <time class="call-time" datetime="<%= c.created_at %>" title="<%= c.created_at.slice(0,16).replace('T',' ') %> UTC"><%= c.created_at.slice(0,16).replace('T',' ') %></time>
</p>
<p class="call-row-goal"><%= c.goal.length > 140 ? c.goal.slice(0,140)+'…' : c.goal %></p>
<% if (c.dnc_flagged) { %>
<p class="muted small" style="margin-top:.4rem;color:#991b1b;">
🚫 Blocked: <%= c.dnc_field || 'phone' %> on <%= c.dnc_source || 'unknown' %> list — never dispatched to Twilio/Vapi. To allow this number, remove from <a href="/admin/dnc">/admin/dnc</a>.
</p>
<% } %>
</a>
<!-- IN-FLIGHT: listen-live link (hidden unless status is active) -->
<div class="call-row-live" data-status-target="<%= c.id %>" style="margin-top:.4rem; <%= ['queued','dialing','on_hold','connected'].includes(c.status) ? '' : 'display:none' %>">
<a class="btn ghost" href="/calls/<%= c.id %>/live" style="font-size:12px; padding:6px 12px;">🔊 Listen live →</a>
</div>
<!-- COMPLETED: inline recording + transcript -->
<div class="call-row-audio" data-call-id="<%= c.id %>" style="margin-top:.5rem; padding:.5rem .75rem; background:var(--panel-2); border:1px solid var(--line); border-radius:6px; display:none;">
<div style="display:flex; align-items:center; gap:.5rem; font-size:11px; color:var(--ink-dim); margin-bottom:4px;">
<span>📼 Recording</span>
<button class="transcript-toggle" type="button" data-call-id="<%= c.id %>" style="display:none; margin-left:auto; padding:2px 10px; border-radius:9999px; background:var(--accent,#1a56db); color:#fff; border:none; cursor:pointer; font-size:10px;">📝 transcript</button>
</div>
<audio controls preload="none" src="/api/calls/<%= c.id %>/recording" style="width:100%; height:32px;"></audio>
<div class="transcript-body" data-call-id="<%= c.id %>" style="display:none; margin-top:.5rem; padding:.5rem .65rem; max-height:240px; overflow-y:auto; background:var(--panel); border:1px solid var(--line); border-radius:6px; font-size:12px; line-height:1.5;"></div>
</div>
</li>
<% }); %>
</ul>
<% } %>
</div>
</section>
</main>
<script>
(function () {
// ── Quick dial ──────────────────────────────────────────────────────
const form = document.getElementById('quick-dial-form');
const btn = document.getElementById('quick-dial-btn');
const stat = document.getElementById('quick-dial-status');
const phoneEl = document.getElementById('quick-dial-phone');
const dncStat = document.getElementById('dnc-status');
// DNC pre-check on phone-blur. If listed, warn + disable dial button.
if (phoneEl && dncStat) {
phoneEl.addEventListener('blur', async () => {
const v = (phoneEl.value || '').trim();
if (!v) { dncStat.textContent = ''; btn.disabled = false; return; }
dncStat.textContent = 'checking DNC…';
try {
const r = await fetch('/api/dnc-check?phone=' + encodeURIComponent(v));
const j = await r.json();
if (j.allowed) {
dncStat.textContent = j.dry_run_bypass ? '✓ ok (dry-run mode)' : '✓ not on DNC';
dncStat.style.color = 'var(--accent,#1a56db)';
btn.disabled = false;
} else {
dncStat.textContent = '🚫 ' + (j.reason === 'federal_dnc' ? 'on federal DNC' : j.reason === 'internal_suppression' ? 'in internal suppression' : j.reason === 'gate_error' ? 'DNC list unavailable' : 'blocked: ' + j.reason);
dncStat.style.color = '#991b1b';
btn.disabled = true;
}
} catch (e) {
dncStat.textContent = '(DNC check failed; will re-check on submit)';
dncStat.style.color = 'var(--ink-dim)';
btn.disabled = false;
}
});
}
if (form) {
form.addEventListener('submit', async (e) => {
e.preventDefault();
btn.disabled = true;
stat.textContent = 'placing call…';
const fd = new FormData(form);
const body = new URLSearchParams();
body.set('business_name', fd.get('business_name'));
body.set('business_phone', fd.get('business_phone'));
body.set('goal', fd.get('goal'));
try {
const r = await fetch('/api/quick-dial', { method: 'POST', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
const j = await r.json();
if (!j.ok) { stat.textContent = '✗ ' + (j.error || 'failed'); btn.disabled = false; return; }
stat.textContent = '✓ queued ' + j.id + ' — reloading…';
setTimeout(() => location.reload(), 600);
} catch (err) {
stat.textContent = '✗ network error: ' + err.message;
btn.disabled = false;
}
});
}
// ── Probe each completed row for recording/transcript ──────────────
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]); }
function renderTranscript(t) {
if (!t) return '<em class="muted">no transcript</em>';
if (typeof t === 'string') return '<pre style="white-space:pre-wrap; margin:0;">' + escapeHtml(t) + '</pre>';
if (t.text) return '<pre style="white-space:pre-wrap; margin:0;">' + escapeHtml(t.text) + '</pre>';
if (Array.isArray(t.segments)) {
return t.segments.map(s => {
const start = (s.start !== undefined) ? '<span class="muted" style="font-size:10px; min-width:48px; display:inline-block;">[' + Number(s.start).toFixed(1) + 's]</span> ' : '';
return start + escapeHtml(s.text || '');
}).join('<br>');
}
return '<pre style="white-space:pre-wrap; margin:0;">' + escapeHtml(JSON.stringify(t, null, 2)) + '</pre>';
}
document.querySelectorAll('.call-row-audio').forEach(async (el) => {
const id = el.dataset.callId;
try {
const r = await fetch('/api/calls/' + id + '/has-recording');
const j = await r.json();
if (j && j.ok && j.exists) el.style.display = '';
} catch {}
});
document.querySelectorAll('.transcript-toggle').forEach(async (btn) => {
const id = btn.dataset.callId;
try {
const r = await fetch('/api/calls/' + id + '/has-transcript');
const j = await r.json();
if (!(j && j.ok && j.exists)) return;
btn.style.display = '';
let loaded = false, cached = null;
btn.addEventListener('click', async () => {
const body = document.querySelector('.transcript-body[data-call-id="' + id + '"]');
if (!body) return;
const show = body.style.display === 'none';
if (show && !loaded) {
body.innerHTML = '<em class="muted">loading…</em>';
try {
const rr = await fetch('/api/calls/' + id + '/transcript');
const jj = await rr.json();
cached = jj.ok ? renderTranscript(jj.transcript) : '<em class="muted">' + (jj.error || 'load failed') + '</em>';
} catch (e) { cached = '<em class="muted">network error</em>'; }
loaded = true;
}
if (show) { body.innerHTML = cached; body.style.display = ''; }
else { body.style.display = 'none'; }
});
} catch {}
});
// ── Live status polling for in-flight rows ──────────────────────────
// Every 5s: for each row whose pill is queued/dialing/on_hold/connected,
// refetch /api/calls/:id/status. When it flips to done/failed, swap the
// listen-live chip for the recording block (HEAD-probe + reveal).
async function pollLive() {
const liveRows = document.querySelectorAll('.call-row-live[data-status-target]');
for (const row of liveRows) {
if (row.style.display === 'none') continue;
const id = row.dataset.statusTarget;
try {
const r = await fetch('/api/calls/' + id + '/status', { cache: 'no-store' });
const j = await r.json();
if (!j.ok) continue;
// Update pill
const pill = document.querySelector('.call-row[data-call-id="' + id + '"] [data-status-pill]');
if (pill && pill.textContent !== j.status) {
pill.textContent = j.status;
pill.className = 'status status-' + j.status;
}
if (['done','completed','failed'].includes(j.status)) {
row.style.display = 'none';
// Reveal recording block when MP3 exists
const audio = document.querySelector('.call-row-audio[data-call-id="' + id + '"]');
if (audio && audio.style.display === 'none') {
const hr = await fetch('/api/calls/' + id + '/has-recording');
const hj = await hr.json();
if (hj && hj.ok && hj.exists) audio.style.display = '';
}
}
} catch {}
}
}
pollLive();
setInterval(pollLive, 5000);
// ── Relative timestamps ────────────────────────────────────────────
// Replace the raw "YYYY-MM-DD HH:MM" with friendly relative ("3m ago",
// "Today 2:14 PM", "Tue 9:08 AM", "Mar 14"). Keep raw UTC in the
// title attribute so power users can hover.
function relTime(iso) {
const t = new Date(iso); if (isNaN(t)) return null;
const now = new Date();
const diffSec = Math.floor((now - t) / 1000);
if (diffSec < 45) return 'just now';
if (diffSec < 90) return '1m ago';
if (diffSec < 3600) return Math.floor(diffSec / 60) + 'm ago';
if (diffSec < 5400) return '1h ago';
if (diffSec < 86400) return Math.floor(diffSec / 3600) + 'h ago';
const sameDay = t.toDateString() === now.toDateString();
const time = t.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
if (sameDay) return 'Today ' + time;
const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1);
if (t.toDateString() === yesterday.toDateString()) return 'Yesterday ' + time;
if ((now - t) < 7 * 86400 * 1000) return t.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
return t.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
document.querySelectorAll('time.call-time').forEach((el) => {
const rel = relTime(el.getAttribute('datetime'));
if (rel) el.textContent = rel;
});
})();
</script>
<%- include('../partials/footer') %>