← back to Labj Cre Banks

public/index.html

257 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>LABJ CRE + Banking — RENTV Prospect Database</title>
<style>
  :root { --scale: 13px; --pad: 6px; }
  * { box-sizing: border-box; }
  body { margin: 0; font: var(--scale)/1.45 -apple-system, "Segoe UI", Roboto, sans-serif;
         color: #1a1a1a; background: #f4f2ee; }
  header { padding: 14px 18px; background: #12223a; color: #fff; }
  header h1 { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: .3px; }
  header .sub { opacity: .75; font-size: 12px; margin-top: 2px; }
  .bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
         padding: 10px 18px; background: #fff; border-bottom: 1px solid #e2ddd4; position: sticky; top: 0; z-index: 5; }
  .bar input[type=search] { flex: 1 1 240px; min-width: 180px; padding: 7px 10px;
         border: 1px solid #cbc4b8; border-radius: 6px; font-size: 13px; }
  .bar label { font-size: 12px; color: #555; display: inline-flex; align-items: center; gap: 6px; }
  .bar button { padding: 6px 11px; border: 1px solid #cbc4b8; background: #fbfaf7; border-radius: 6px;
         cursor: pointer; font-size: 12px; }
  .bar button:hover { background: #efece5; }
  .chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 18px; background: #faf8f4; border-bottom: 1px solid #e8e3da; }
  .chip { font-size: 11px; padding: 3px 9px; border: 1px solid #cbc4b8; border-radius: 999px;
          background: #fff; cursor: pointer; user-select: none; }
  .chip.on { background: #12223a; color: #fff; border-color: #12223a; }
  .colpanel { display: none; flex-wrap: wrap; gap: 4px 16px; padding: 10px 18px; background: #fffdf8; border-bottom: 1px solid #e8e3da; }
  .colpanel.show { display: flex; }
  .colpanel label { font-size: 12px; display: inline-flex; align-items: center; gap: 5px; }
  .wrap { overflow: auto; max-height: calc(100vh - 150px); }
  table { border-collapse: collapse; width: 100%; background: #fff; }
  th, td { padding: var(--pad) 10px; border-bottom: 1px solid #ece7de; text-align: left;
           vertical-align: top; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 340px; }
  th { position: sticky; top: 0; background: #f0ece3; cursor: pointer; user-select: none;
       border-bottom: 2px solid #d8d1c4; font-weight: 600; resize: horizontal; overflow: auto; }
  th.drag-over { background: #dfe7f2; }
  tr:hover td { background: #fbf9f4; }
  td a { color: #123f7a; text-decoration: none; }
  td a:hover { text-decoration: underline; }
  .score { font-weight: 700; text-align: right; font-variant-numeric: tabular-nums; }
  .s-hi { color: #0a7d3c; } .s-mid { color: #b06a00; } .s-lo { color: #999; }
  .cat { font-size: 11px; color: #444; }
  tr.detail td { background: #f7f4ee; white-space: normal; }
  .detail-grid { display: grid; grid-template-columns: 160px 1fr; gap: 2px 14px; font-size: 12px; }
  .detail-grid b { color: #555; font-weight: 600; }
  .count { padding: 6px 18px; font-size: 12px; color: #666; background: #faf8f4; }
  .prov { padding: 8px 18px; font-size: 12px; line-height: 1.5; color: #3a3a3a;
          background: #eef3ea; border-bottom: 1px solid #d6e0cf; }
  .prov b { color: #1a1a1a; }
  .pill { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 999px; letter-spacing: .3px; }
  .orig-labj { background: #12223a; color: #fff; }
  .orig-research { background: #e7e2d6; color: #5a5344; }
</style>
</head>
<body>
<header>
  <h1>LABJ CRE + Banking — RENTV Prospect Database</h1>
  <div class="sub" id="meta">loading…</div>
</header>
<div class="bar">
  <input type="search" id="q" placeholder="Search all fields (space = AND)…">
  <label>Density <input type="range" id="density" min="11" max="18" value="13"></label>
  <button id="toggleCols">Columns ▾</button>
  <button id="reset">Reset</button>
  <span style="font-size:12px;color:#888">tip: click a row to expand · click a header to sort · drag headers to reorder</span>
</div>
<div class="prov" id="prov">loading provenance…</div>
<div class="chips" id="chips"></div>
<div class="colpanel" id="colpanel"></div>
<div class="count" id="count"></div>
<div class="wrap"><table><thead><tr id="head"></tr></thead><tbody id="body"></tbody></table></div>

<script>
const LS = "labj_viewer_v1";
const ALL_COLS = [
  ["company_name","Company"], ["categories","Lists"], ["advertiser_fit_score","Fit"],
  ["city","City"], ["website","Website"], ["main_phone","Phone"],
  ["top_local_executive","Top Exec"], ["exec_title","Title"],
  ["marketing_pr_contact","Mktg/PR"], ["public_email","Email"],
  ["specialties","Specialties"], ["key_metric","Metric"],
  ["state","State"], ["zip","Zip"], ["address","Address"],
  ["advertiser_fit_reason","Fit reason"], ["source_company","Sources"],
  ["origin","Source"], ["last_verified","Verified"]
];
const DEFAULT_COLS = ["company_name","origin","categories","advertiser_fit_score","city",
  "website","main_phone","top_local_executive","marketing_pr_contact","public_email","last_verified"];
let DATA = [], state = load();

function load(){ try { return JSON.parse(localStorage.getItem(LS)) || {}; } catch(e){ return {}; } }
function save(){ localStorage.setItem(LS, JSON.stringify(state)); }
function cols(){ return (state.cols && state.cols.length ? state.cols : DEFAULT_COLS.slice()); }

fetch("./labj_master.json").then(r=>r.json()).then(d=>{
  DATA = d.records || d;
  document.getElementById("meta").textContent =
    `${d.count||DATA.length} companies · generated ${d.generated||""} · sorted by RENTV advertiser-fit`;
  const labj = DATA.filter(r=>r.origin==="labj_seed").length;
  const research = DATA.length - labj;
  const withSrc = DATA.filter(r=>(r.source_company||"").trim()).length;
  document.getElementById("prov").innerHTML =
    `<b>Where this data comes from:</b> LABJ's paid "Book of Lists" is used only as a <i>discovery/ranking pointer</i> — every field is rebuilt from independent public sources with attribution (no LABJ paid product is reproduced). ` +
    `<b>${labj}</b> firms were captured from LABJ's <i>public</i> list rows (ranks 1–3, the only rows LABJ shows for free); ` +
    `<b>${research}</b> were rebuilt from each firm's own public web presence (LABJ used only to know which firms/categories to research). ` +
    `<b>${withSrc}/${DATA.length}</b> carry public source URLs — click any row to see its <i>LABJ list pointer</i> and its <i>data sources</i>. The <span class="pill orig-labj">LABJ</span> / <span class="pill orig-research">Research</span> badge on each row marks which.`;
  buildChips(); buildColPanel(); applyDensity(); render();
});

function catList(){
  const s = new Set();
  DATA.forEach(r => (r.categories||"").split(" | ").forEach(c => c && s.add(c)));
  return [...s].sort();
}
function buildChips(){
  const box = document.getElementById("chips");
  state.cats = state.cats || [];
  box.innerHTML = "";
  catList().forEach(c=>{
    const el = document.createElement("span");
    el.className = "chip" + (state.cats.includes(c) ? " on":"");
    el.textContent = c;
    el.onclick = ()=>{ const i=state.cats.indexOf(c); i<0?state.cats.push(c):state.cats.splice(i,1); save(); buildChips(); render(); };
    box.appendChild(el);
  });
}
function buildColPanel(){
  const p = document.getElementById("colpanel"); p.innerHTML="";
  const on = new Set(cols());
  ALL_COLS.forEach(([k,label])=>{
    const l=document.createElement("label");
    l.innerHTML=`<input type="checkbox" ${on.has(k)?"checked":""}> ${label}`;
    l.querySelector("input").onchange=e=>{
      let c=cols();
      if(e.target.checked){ if(!c.includes(k)) c.push(k); } else c=c.filter(x=>x!==k);
      state.cols=c; save(); render();
    };
    p.appendChild(l);
  });
}
document.getElementById("toggleCols").onclick=()=>document.getElementById("colpanel").classList.toggle("show");
document.getElementById("q").oninput=render;
document.getElementById("density").oninput=e=>{ state.density=e.target.value; save(); applyDensity(); };
document.getElementById("reset").onclick=()=>{ state={}; save();
  document.getElementById("q").value=""; document.getElementById("density").value=13;
  buildChips(); buildColPanel(); applyDensity(); render(); };
function applyDensity(){
  const v = state.density || 13;
  document.getElementById("density").value=v;
  document.documentElement.style.setProperty("--scale", v+"px");
  document.documentElement.style.setProperty("--pad", Math.round(v*0.5)+"px");
}

function scoreClass(s){ return s>=70?"s-hi":s>=40?"s-mid":"s-lo"; }
function cell(r,k){
  let v = r[k]==null?"":String(r[k]);
  if(k==="website" && v) return `<a href="${v.startsWith('http')?v:'https://'+v}" target="_blank" rel="noopener noreferrer">${v.replace(/^https?:\/\/(www\.)?/,'')}</a>`;
  if(k==="public_email" && v) return `<a href="mailto:${v}">${v}</a>`;
  if(k==="main_phone" && v) return `<a href="tel:${v.replace(/[^0-9+]/g,'')}">${v}</a>`;
  if(k==="advertiser_fit_score") return `<span class="score ${scoreClass(+v)}">${v}</span>`;
  if(k==="categories") return `<span class="cat">${v.replace(/ \| /g,'<br>')}</span>`;
  if(k==="origin"){
    if(v==="labj_seed") return `<span class="pill orig-labj" title="Captured from LABJ's public list (top-3 rows)">LABJ</span>`;
    return `<span class="pill orig-research" title="Rebuilt from independent public sources; LABJ used as a category pointer">Research</span>`;
  }
  return v;
}

function render(){
  const q = document.getElementById("q").value.toLowerCase().split(/\s+/).filter(Boolean);
  const cs = cols();
  let rows = DATA.filter(r=>{
    if(state.cats && state.cats.length){
      const rc=(r.categories||"").split(" | ");
      if(!state.cats.some(c=>rc.includes(c))) return false;
    }
    if(q.length){ const blob=Object.values(r).join(" ").toLowerCase(); return q.every(t=>blob.includes(t)); }
    return true;
  });
  const sk = state.sortKey || "advertiser_fit_score", dir = state.sortDir==null?-1:state.sortDir;
  rows.sort((a,b)=>{
    let x=a[sk], y=b[sk];
    if(sk==="advertiser_fit_score"){ x=+x||0; y=+y||0; return (x-y)*dir; }
    return String(x||"").localeCompare(String(y||""))*dir;
  });
  // head
  const head=document.getElementById("head"); head.innerHTML="";
  cs.forEach(k=>{
    const label=(ALL_COLS.find(c=>c[0]===k)||[k,k])[1];
    const th=document.createElement("th");
    th.textContent=label + (sk===k?(dir<0?" ↓":" ↑"):""); th.dataset.k=k; th.draggable=true;
    th.onclick=()=>{ if(state.sortKey===k) state.sortDir=(dir<0?1:-1); else {state.sortKey=k; state.sortDir=(k==="advertiser_fit_score"?-1:1);} save(); render(); };
    th.ondragstart=e=>e.dataTransfer.setData("k",k);
    th.ondragover=e=>{e.preventDefault(); th.classList.add("drag-over");};
    th.ondragleave=()=>th.classList.remove("drag-over");
    th.ondrop=e=>{ e.preventDefault(); th.classList.remove("drag-over");
      const from=e.dataTransfer.getData("k"); let c=cols(); const fi=c.indexOf(from), ti=c.indexOf(k);
      if(fi>=0&&ti>=0){ c.splice(ti,0,c.splice(fi,1)[0]); state.cols=c; save(); render(); } };
    head.appendChild(th);
  });
  // body
  const body=document.getElementById("body"); body.innerHTML="";
  rows.forEach((r,i)=>{
    const tr=document.createElement("tr");
    cs.forEach(k=>{ const td=document.createElement("td"); td.innerHTML=cell(r,k);
      if(k==="company_name") td.title=r.company_name; body; tr.appendChild(td); });
    tr.onclick=e=>{ if(e.target.tagName==="A") return; toggleDetail(tr, r); };
    body.appendChild(tr);
  });
  document.getElementById("count").textContent = `${rows.length} of ${DATA.length} companies shown`;
}
function toggleDetail(tr, r){
  if(tr.nextSibling && tr.nextSibling.classList && tr.nextSibling.classList.contains("detail")){ tr.nextSibling.remove(); return; }
  const dt=document.createElement("tr"); dt.className="detail";
  const td=document.createElement("td"); td.colSpan=cols().length;
  const flds=[["Company","company_name"],["Aliases","aliases"],["Lists","categories"],
    ["Fit score","advertiser_fit_score"],["Fit reason","advertiser_fit_reason"],
    ["Address","address"],["City/State/Zip",null],["Website","website"],["Phone","main_phone"],
    ["Top exec","top_local_executive"],["Exec title","exec_title"],
    ["Mktg/PR contact","marketing_pr_contact"],["Mktg/PR title","marketing_pr_title"],["Email","public_email"],
    ["Specialties","specialties"],["Key metric","key_metric"],["LA presence","la_presence"],
    ["Data sources (public)","source_company"],["LABJ list (discovery pointer)","source_labj"],["Record source","origin"],["Verified","last_verified"]];
  let h='<div class="detail-grid">';
  flds.forEach(([lab,k])=>{
    let v = k? (r[k]||"") : `${r.city||""} ${r.state||""} ${r.zip||""}`.trim();
    if(k==="website"&&v) v=`<a href="${v}" target="_blank" rel="noopener noreferrer">${v}</a>`;
    if((k==="source_company"||k==="source_labj")&&v) v=v.split(/;\s*/).map(u=>u.startsWith("http")?`<a href="${u}" target="_blank" rel="noopener noreferrer">${u}</a>`:u).join("<br>");
    if(v) h+=`<b>${lab}</b><span>${v}</span>`;
  });
  h+="</div>"; td.innerHTML=h; dt.appendChild(td); tr.after(dt);
}
</script>
</body>
</html>