← back to Rentv Licensed Targets

public/index.html

216 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>RENTV Commercial Targets — 102k Licensed Prospects</title>
<style>
  :root{
    --bg:#0e1116; --panel:#161b22; --line:#232b36; --ink:#e6edf3; --dim:#8b98a8;
    --accent:#2f9e77; --chip:#1d2530; --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:14px;flex-wrap:wrap}
  header h1{font-size:16px;margin:0;font-weight:650;letter-spacing:.2px}
  header .sub{color:var(--dim);font-size:12px}
  .bar{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}
  .bar label{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.4px;margin-right:-4px}
  .density{display:flex;align-items:center;gap:7px;margin-left:auto}
  .density input[type=range]{accent-color:var(--accent)}
  .count{color:var(--dim);font-size:12px;white-space:nowrap}
  .count b{color:var(--ink)}
  .wrap{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;user-select:none;font-weight:600;color:#cdd6e0}
  thead th .ar{color:var(--accent);font-size:10px;margin-left:3px}
  tbody td{border-bottom:1px solid var(--line);padding:0 10px;height:var(--rowh);white-space:nowrap;max-width:340px;overflow:hidden;text-overflow:ellipsis}
  tbody tr:hover{background:#131922}
  .role{display:inline-block;padding:1px 7px;border-radius:10px;background:var(--chip);color:#bcd;font-size:11px}
  .status{font-size:11px;color:var(--dim)}
  .st-CLEAR,.st-Active,.st-Issued,.st-Licensed{color:#5fd0a0}
  .st-Denied,.st-Pending{color:#e0a35f}
  a{color:#6cb7ff;text-decoration:none} a:hover{text-decoration:underline}
  .when{color:var(--dim);font-size:11px}
  .pager{display:flex;gap:8px;align-items:center;padding:9px 18px;border-top:1px solid var(--line);background:var(--bg)}
  .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;cursor:default}
  .muted{color:var(--dim)}
  .empty{padding:40px;text-align:center;color:var(--dim)}
</style>
</head>
<body>
<header>
  <h1>RENTV Commercial Targets</h1>
  <span class="sub">Licensed advertiser-prospect universe · CSLB · AZ ROC · DFPI · DRE · State Bar · DOI — all commercial, within 300mi</span>
</header>

<div class="bar">
  <input id="q" placeholder="Search entity, contact, license #, city, phone…" autocomplete="off">
  <label>Role</label><select id="role"></select>
  <label>Market</label><select id="market"></select>
  <label>Status</label><select id="license_status"></select>
  <label>Source</label><select id="source"></select>
  <span class="count" id="count">…</span>
  <div class="density">
    <span class="muted" style="font-size:11px">Density</span>
    <input type="range" id="density" min="0" max="4" step="1" value="1" title="Row density">
  </div>
</div>

<div class="wrap">
  <table>
    <thead><tr id="head"></tr></thead>
    <tbody id="rows"></tbody>
  </table>
  <div class="empty" id="empty" hidden>No matches.</div>
</div>

<div class="pager">
  <button id="prev">‹ Prev</button>
  <span class="count">Page <b id="page">1</b> <span class="muted">of</span> <b id="pages">1</b></span>
  <button id="next">Next ›</button>
  <span class="muted" id="range"></span>
</div>

<script>
const LS = 'rentv-targets-view';
const COLS = [
  ['entity_name','Entity'], ['role','Role'], ['contact_name','Contact'],
  ['license_no','License #'], ['license_type','Type'], ['license_status','Status'],
  ['city','City'], ['county','County'], ['market','Market'],
  ['phone','Phone'], ['website','Website'], ['source','Source'], ['scraped_at','Imported'],
];
const DENSITIES = [ // [rowHeight, fontSize]
  ['26px','12px'], ['34px','13px'], ['40px','14px'], ['48px','15px'], ['58px','16px'],
];

const state = Object.assign(
  { q:'', role:'', market:'', license_status:'', source:'', sort:'entity_name', dir:'asc', page:1, limit:50, density:1 },
  JSON.parse(localStorage.getItem(LS) || '{}')
);
const save = () => localStorage.setItem(LS, JSON.stringify(state));
const $ = (id) => document.getElementById(id);

function applyDensity(){
  const [h,f] = DENSITIES[state.density] || DENSITIES[1];
  document.documentElement.style.setProperty('--rowh', h);
  document.documentElement.style.setProperty('--fs', f);
  $('density').value = state.density;
}

function renderHead(){
  $('head').innerHTML = COLS.map(([k,label])=>{
    const ar = state.sort===k ? `<span class="ar">${state.dir==='asc'?'▲':'▼'}</span>` : '';
    return `<th data-k="${k}">${label}${ar}</th>`;
  }).join('');
  $('head').querySelectorAll('th').forEach(th=>th.onclick=()=>{
    const k = th.dataset.k;
    if (state.sort===k) state.dir = state.dir==='asc'?'desc':'asc';
    else { state.sort=k; state.dir='asc'; }
    state.page=1; save(); load();
  });
}

function esc(s){ return (s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function fmtDate(s){
  if(!s) return '';
  try{ return new Date(s).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }
  catch{ return s; }
}
function web(u){
  if(!u) return '<span class="muted">—</span>';
  const href = /^https?:/.test(u)?u:'https://'+u;
  return `<a href="${esc(href)}" target="_blank" rel="noopener noreferrer">${esc(u.replace(/^https?:\/\//,''))}</a>`;
}

function renderRows(data){
  const empty = data.rows.length===0;
  $('empty').hidden = !empty;
  $('rows').innerHTML = data.rows.map(r=>`<tr>
    <td title="${esc(r.entity_name)}"><b>${esc(r.entity_name)}</b></td>
    <td><span class="role">${esc(r.role)}</span></td>
    <td>${esc(r.contact_name)||'<span class=muted>—</span>'}</td>
    <td>${esc(r.license_no)||'<span class=muted>—</span>'}</td>
    <td class="muted">${esc(r.license_type)}</td>
    <td class="status st-${esc(r.license_status)}">${esc(r.license_status)}</td>
    <td>${esc(r.city)}</td>
    <td class="muted">${esc(r.county)}</td>
    <td>${esc(r.market)}</td>
    <td>${esc(r.phone)||'<span class=muted>—</span>'}</td>
    <td>${web(r.website)}</td>
    <td class="muted">${esc(r.source)}</td>
    <td class="when" title="${esc(r.scraped_at)}">${fmtDate(r.scraped_at)}</td>
  </tr>`).join('');
}

async function load(){
  const p = new URLSearchParams();
  for (const k of ['q','role','market','license_status','source','sort','dir','page','limit'])
    if (state[k]!=='' && state[k]!=null) p.set(k, state[k]);
  const data = await (await fetch('/api/targets?'+p)).json();
  renderHead();
  renderRows(data);
  const pages = Math.max(1, Math.ceil(data.total/data.limit));
  const start = data.total? (data.page-1)*data.limit+1 : 0;
  const end = Math.min(data.page*data.limit, data.total);
  $('count').innerHTML = `<b>${data.total.toLocaleString()}</b> targets`;
  $('page').textContent = data.page; $('pages').textContent = pages.toLocaleString();
  $('range').textContent = data.total? `showing ${start.toLocaleString()}–${end.toLocaleString()}` : '';
  $('prev').disabled = data.page<=1; $('next').disabled = data.page>=pages;
}

function fillSelect(id, rows, label){
  const cur = state[id];
  $(id).innerHTML = `<option value="">All ${label}</option>` +
    rows.map(r=>`<option value="${esc(r.v)}">${esc(r.v)} (${r.n.toLocaleString()})</option>`).join('');
  $(id).value = cur;
  $(id).onchange = ()=>{ state[id]=$(id).value; state.page=1; save(); load(); };
}

async function boot(){
  applyDensity();
  $('q').value = state.q;
  const f = await (await fetch('/api/facets')).json();
  fillSelect('role', f.role, 'roles');
  fillSelect('market', f.market, 'markets');
  fillSelect('license_status', f.license_status, 'statuses');
  fillSelect('source', f.source, 'sources');
  let t; $('q').oninput = ()=>{ clearTimeout(t); t=setTimeout(()=>{ state.q=$('q').value.trim(); state.page=1; save(); load(); },250); };
  $('density').oninput = ()=>{ state.density=+$('density').value; save(); applyDensity(); };
  $('prev').onclick = ()=>{ if(state.page>1){state.page--;save();load();} };
  $('next').onclick = ()=>{ const pages=Math.max(1,Math.ceil(+$('pages').textContent||1)); if(state.page<pages){state.page++;save();load();} };
  load();
}
boot();
</script>
</body>
</html>