← 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=>({'&':'&','<':'<','>':'>','"':'"'}[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>