← 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[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') %>