← back to Dw Chat Analyzer

public/index.html

155 lines

<!doctype html>
<html lang="en"><head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
  var _fetch = window.fetch.bind(window);
  var cleanBase = function () { return location.origin + location.pathname; };
  window.fetch = function (input, init) {
    try {
      if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
        input = new URL(input, cleanBase()).href;
      } else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
        input = new Request(new URL(input.url, cleanBase()).href, input);
      }
    } catch (_) { /* fall through to native */ }
    return _fetch(input, init);
  };
})();
</script>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>DW Chat Traffic Analyzer</title>
<style>
  :root{--bg:#0e1116;--panel:#161b22;--line:#232b36;--ink:#e6edf3;--dim:#8b98a8;--accent:#3fae9a;--lead:#e0a35f;--new:#5fd0a0;--rowh:34px;--fs:13px}
  *{box-sizing:border-box}
  body{margin:0;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--ink)}
  header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:16px;flex-wrap:wrap}
  header h1{font-size:16px;margin:0;font-weight:650}
  .tabs{display:flex;gap:4px;margin-left:8px}
  .tab{padding:5px 12px;border-radius:7px;cursor:pointer;color:var(--dim);font-size:13px;border:1px solid transparent}
  .tab.on{background:var(--panel);color:var(--ink);border-color:var(--line)}
  .sub{color:var(--dim);font-size:12px;margin-left:auto}
  .wrap{padding:16px 18px}
  /* dashboard */
  .cards{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px}
  .card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 18px;min-width:150px}
  .card .k{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.5px}
  .card .v{font-size:26px;font-weight:700;margin-top:4px}
  .card.lead .v{color:var(--lead)} .card.new .v{color:var(--new)}
  .grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px}
  .box{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 14px}
  .box h3{margin:0 0 8px;font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.5px}
  .row{display:flex;justify-content:space-between;gap:10px;padding:3px 0;font-size:13px;border-bottom:1px solid #1b222c}
  .row:last-child{border:0} .row .n{color:var(--accent);font-variant-numeric:tabular-nums}
  .bar{height:8px;background:#1b222c;border-radius:4px;overflow:hidden;margin-top:2px}.bar>i{display:block;height:100%;background:var(--accent)}
  /* grid */
  .bar2{display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:12px 18px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:5}
  input,select{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:7px 9px;font-size:13px}
  input#q{min-width:280px}
  .chk{display:flex;align-items:center;gap:6px;color:var(--dim);font-size:12px}
  .count{color:var(--dim);font-size:12px}.count b{color:var(--ink)}
  .tblwrap{overflow:auto;height:calc(100vh - 118px)}
  table{border-collapse:collapse;width:100%;font-size:var(--fs)}
  thead th{position:sticky;top:0;background:var(--panel);border-bottom:1px solid var(--line);text-align:left;padding:8px 10px;white-space:nowrap;cursor:pointer;font-weight:600;color:#cdd6e0}
  tbody td{border-bottom:1px solid var(--line);padding:0 10px;height:var(--rowh);white-space:nowrap;max-width:320px;overflow:hidden;text-overflow:ellipsis}
  tbody tr:hover{background:#131922}
  .badge{display:inline-block;padding:1px 6px;border-radius:9px;font-size:10px;font-weight:600}
  .b-new{background:#12321f;color:var(--new)} .b-lead{background:#3a2c17;color:var(--lead)}
  a.zd{color:#6cb7ff;text-decoration:none}a.zd:hover{text-decoration:underline}
  .muted{color:var(--dim)} .pager{display:flex;gap:8px;align-items:center;padding:9px 18px;border-top:1px solid var(--line)}
  .pager button{background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:7px;padding:6px 12px;cursor:pointer}
  .pager button:disabled{opacity:.4} .hidden{display:none}
</style></head>
<body>
<header>
  <h1>DW Chat Traffic Analyzer</h1>
  <div class="tabs"><span class="tab on" data-t="dash">Dashboard</span><span class="tab" data-t="chats">Chats &amp; Leads</span></div>
  <span class="sub" id="hsub">Zendesk Chat · designerwallcoverings.com</span>
</header>

<!-- DASHBOARD -->
<div class="wrap" id="dash">
  <div class="cards">
    <div class="card"><div class="k">Total chats</div><div class="v" id="c-total">…</div></div>
    <div class="card lead"><div class="k">HOT leads (contact left)</div><div class="v" id="c-hot">…</div></div>
    <div class="card new"><div class="k">🔥 Hot &amp; new (act now)</div><div class="v" id="c-hotnew">…</div></div>
    <div class="card"><div class="k">New chats (48h)</div><div class="v" id="c-new">…</div></div>
  </div>
  <div class="grid2">
    <div class="box"><h3>Top search terms (how they found DW)</h3><div id="b-terms"></div></div>
    <div class="box"><h3>Top landing pages</h3><div id="b-land"></div></div>
    <div class="box"><h3>Referrer engines</h3><div id="b-eng"></div></div>
    <div class="box"><h3>Department</h3><div id="b-dept"></div></div>
    <div class="box"><h3>Visitor country</h3><div id="b-geo"></div></div>
    <div class="box"><h3>Ratings</h3><div id="b-rating"></div></div>
    <div class="box" style="grid-column:1/-1"><h3>Chats per day (recent)</h3><div id="b-day"></div></div>
  </div>
</div>

<!-- CHATS -->
<div class="hidden" id="chats">
  <div class="bar2">
    <input id="q" placeholder="Search intent, search terms, landing, visitor, tags…" autocomplete="off">
    <label class="chk"><input type="checkbox" id="f-lead"> HOT only</label>
    <label class="chk"><input type="checkbox" id="f-new"> new (48h)</label>
    <button id="actnow" class="pager" style="padding:6px 10px;cursor:pointer" title="HOT leads from the last 48h — the real call-first list">🔥 Act now</button>
    <select id="department"></select>
    <span class="count" id="ccount">…</span>
  </div>
  <div class="tblwrap"><table><thead><tr id="head"></tr></thead><tbody id="rows"></tbody></table></div>
  <div class="pager"><button id="prev">‹ Prev</button><span class="count">Page <b id="page">1</b> / <b id="pages">1</b></span><button id="next">Next ›</button><span class="muted" id="range"></span></div>
</div>

<script>
const $=id=>document.getElementById(id);
function esc(s){return(s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function bars(el,list){const mx=Math.max(1,...list.map(r=>r.n));$(el).innerHTML=list.map(r=>`<div class="row"><span title="${esc(r.v)}" style="overflow:hidden;text-overflow:ellipsis;max-width:78%">${esc(r.v)||'<span class=muted>—</span>'}</span><span class="n">${r.n.toLocaleString()}</span></div><div class="bar"><i style="width:${(r.n/mx*100).toFixed(1)}%"></i></div>`).join('')||'<div class="muted">no data yet</div>';}
async function loadDash(){const d=await(await fetch('/api/stats')).json();
  $('c-total').textContent=d.total.toLocaleString();$('c-hot').textContent=(d.hot||0).toLocaleString();$('c-hotnew').textContent=(d.hot_recent||0).toLocaleString();$('c-new').textContent=d.newc.toLocaleString();
  bars('b-terms',d.terms);bars('b-land',d.land);bars('b-eng',d.eng);bars('b-dept',d.dept);bars('b-geo',d.geo);bars('b-rating',d.rating);bars('b-day',(d.byday||[]).slice().reverse());}

// ---- chats grid ----
const COLS=[['started_at','When'],['flags','⚑'],['visitor_city','Visitor loc'],['ref_terms','Search term'],['landing_page','Landing'],['visitor_intent','Intent (visitor msg)'],['department','Dept'],['rating','Rating'],['zd','Zendesk']];
const st={q:'',department:'',lead:false,new:false,sort:'started_at',dir:'desc',page:1,limit:50};
function head(){$('head').innerHTML=COLS.map(([k,l])=>`<th data-k="${k}">${l}${st.sort===k?(st.dir==='asc'?' ▲':' ▼'):''}</th>`).join('');
  $('head').querySelectorAll('th').forEach(th=>th.onclick=()=>{const k=th.dataset.k;if(k==='flags'||k==='zd')return;if(st.sort===k)st.dir=st.dir==='asc'?'desc':'asc';else{st.sort=k;st.dir='desc';}st.page=1;loadChats();});}
function fmt(s){if(!s)return'';try{return new Date(s).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}catch{return s;}}
function loc(r){return[r.visitor_city,r.visitor_region,r.visitor_country].filter(Boolean).join(', ');}
async function loadChats(){const p=new URLSearchParams();if(st.q)p.set('q',st.q);if(st.department)p.set('department',st.department);
  if(st.lead)p.set('lead_tier','HOT');if(st.new)p.set('new','1');p.set('sort',st.sort);p.set('dir',st.dir);p.set('page',st.page);p.set('limit',st.limit);
  const d=await(await fetch('/api/chats?'+p)).json();head();
  $('rows').innerHTML=d.rows.map(r=>`<tr>
    <td>${fmt(r.started_at)}</td>
    <td>${r.is_new?'<span class="badge b-new">NEW</span> ':''}${r.lead_tier==='HOT'?'<span class="badge b-lead">HOT</span>':r.lead_tier==='WARM'?'<span class="badge" style="background:#242c18;color:#a9b06a">WARM</span>':''}</td>
    <td class="muted">${esc(loc(r))||'—'}</td>
    <td>${esc(r.ref_terms)||'<span class=muted>—</span>'}</td>
    <td class="muted" title="${esc(r.landing_page)}">${esc((r.landing_page||'').replace(/^https?:\/\/[^/]+/,''))||'—'}</td>
    <td title="${esc(r.visitor_intent)}">${esc(r.visitor_intent)||'<span class=muted>—</span>'}</td>
    <td class="muted">${esc(r.department)||'—'}</td>
    <td>${esc(r.rating)||'<span class=muted>—</span>'}</td>
    <td><a class="zd" href="${esc(r.zendesk_link||'https://dashboard.zopim.com/#chats/agent/history')}" target="_blank" rel="noopener">↗ open</a></td>
  </tr>`).join('');
  const pages=Math.max(1,Math.ceil(d.total/d.limit)),s=d.total?(d.page-1)*d.limit+1:0,e=Math.min(d.page*d.limit,d.total);
  $('ccount').innerHTML=`<b>${d.total.toLocaleString()}</b> chats`;$('page').textContent=d.page;$('pages').textContent=pages.toLocaleString();
  $('range').textContent=d.total?`showing ${s.toLocaleString()}–${e.toLocaleString()}`:'';$('prev').disabled=d.page<=1;$('next').disabled=d.page>=pages;}
async function fillDept(){const d=await(await fetch('/api/stats')).json();$('department').innerHTML='<option value="">All departments</option>'+d.dept.filter(x=>x.v!=='(none)').map(x=>`<option>${esc(x.v)}</option>`).join('');$('department').onchange=()=>{st.department=$('department').value;st.page=1;loadChats();};}

// tabs
document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('on'));t.classList.add('on');
  const c=t.dataset.t==='chats';$('chats').classList.toggle('hidden',!c);$('dash').classList.toggle('hidden',c);if(c)loadChats();else loadDash();});
let tmr;$('q').oninput=()=>{clearTimeout(tmr);tmr=setTimeout(()=>{st.q=$('q').value.trim();st.page=1;loadChats();},250);};
$('f-lead').onchange=()=>{st.lead=$('f-lead').checked;st.page=1;loadChats();};
$('f-new').onchange=()=>{st.new=$('f-new').checked;st.page=1;loadChats();};
$('actnow').onclick=()=>{st.lead=true;st.new=true;st.page=1;$('f-lead').checked=true;$('f-new').checked=true;loadChats();};
$('prev').onclick=()=>{if(st.page>1){st.page--;loadChats();}};$('next').onclick=()=>{st.page++;loadChats();};
loadDash();fillDept();
</script>
</body></html>