← back to Lawyer Directory Builder
public/dashboard.html
554 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lawyer Directory · dashboard</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<style>
:root {
--bg:#0a0a0c; --panel:#131316; --panel-2:#1a1a1f; --border:#2a2724;
--fg:#f4f1ea; --fg-soft:#d8d2c5; --muted:#8b857a;
--accent:#b89968; --accent-glow:#d4b683; --accent-deep:#8a7044;
--good:#10b981; --warn:#facc15; --bad:#ef4444; --info:#60a5fa;
}
*,*::before,*::after { box-sizing:border-box; }
body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif; background:var(--bg); color:var(--fg); -webkit-font-smoothing:antialiased; }
a { color:var(--accent); text-decoration:none; }
a:hover { color:var(--accent-glow); }
button { font:inherit; cursor:pointer; }
header { padding:18px 28px; background:var(--panel); border-bottom:1px solid var(--border); display:flex; align-items:baseline; gap:24px; flex-wrap:wrap; }
h1 { margin:0; font-size:18px; font-weight:500; letter-spacing:.02em; }
h1 .accent { color:var(--accent); }
.stats { display:flex; gap:18px; flex-wrap:wrap; color:var(--muted); font-size:12px; }
.stats span { cursor:pointer; transition:color .15s; }
.stats span:hover { color:var(--fg); }
.stats b { color:var(--fg); font-weight:600; font-size:13px; }
.controls { padding:14px 28px; background:var(--panel); border-bottom:1px solid var(--border); display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
.seg { display:inline-flex; background:var(--bg); border:1px solid var(--border); border-radius:8px; overflow:hidden; }
.seg button { background:transparent; color:var(--muted); border:0; padding:7px 14px; }
.seg button.active { background:var(--accent); color:var(--bg); font-weight:600; }
select, input[type=text] { background:var(--bg); color:var(--fg); border:1px solid var(--border); border-radius:6px; padding:7px 10px; font:inherit; }
.slider-group { display:flex; align-items:center; gap:8px; color:var(--muted); font-size:12px; }
.slider-group input[type=range] { accent-color:var(--accent); width:140px; }
.slider-group .val { color:var(--fg); font-variant-numeric:tabular-nums; min-width:24px; text-align:right; }
.pager { margin-left:auto; color:var(--muted); display:flex; align-items:center; gap:8px; }
.pager button { background:var(--bg); border:1px solid var(--border); color:var(--fg); border-radius:6px; padding:6px 10px; }
.pager button:disabled { opacity:.4; cursor:not-allowed; }
.pager .info { font-variant-numeric:tabular-nums; min-width:80px; text-align:center; }
/* GRID */
#results.grid { display:grid; gap:14px; padding:18px 28px; grid-template-columns:repeat(var(--cols,5),minmax(0,1fr)); }
.card { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:14px; display:flex; flex-direction:column; gap:6px; min-height:88px; }
.card .name { font-weight:600; font-size:14px; color:var(--fg); display:flex; justify-content:space-between; gap:8px; align-items:flex-start; }
.card .meta { color:var(--muted); font-size:12px; line-height:1.5; }
.card .meta a { color:var(--accent); }
.badge { display:inline-block; padding:1px 6px; border-radius:4px; background:#1c2237; color:var(--accent); font-size:10.5px; margin-right:4px; vertical-align:middle; }
.badge.size { background:#1c2237; color:var(--fg-soft); }
.badge.has { background:#173b2a; color:var(--good); }
.badge.needs { background:#3b3015; color:var(--warn); }
.badge.audit { color:var(--bg); font-weight:700; padding:2px 7px; }
.swatch { display:inline-block; width:12px; height:12px; border-radius:2px; vertical-align:middle; margin-right:4px; border:1px solid var(--border); }
.actions { display:flex; gap:10px; margin-top:6px; flex-wrap:wrap; font-size:12px; }
.actions button { background:none; border:0; color:var(--accent); padding:0; }
.actions button.muted { color:var(--muted); }
.actions button:hover { color:var(--accent-glow); }
.pitch-btn { background:var(--accent) !important; color:var(--bg) !important; padding:4px 10px !important; border-radius:4px !important; font-weight:600 !important; }
.pitch-btn:hover { background:var(--accent-glow) !important; }
/* LIST */
#results.list { padding:0 28px 28px; overflow-x:auto; }
#results.list table { width:100%; border-collapse:collapse; font-size:13px; }
#results.list th { text-align:left; padding:10px 12px; background:var(--panel); border-bottom:1px solid var(--border); position:sticky; top:0; font-weight:600; color:var(--muted); text-transform:uppercase; font-size:11px; letter-spacing:.5px; cursor:pointer; user-select:none; white-space:nowrap; }
#results.list th:hover { color:var(--fg); }
#results.list th .sort-arrow { color:var(--accent); margin-left:4px; }
#results.list td { padding:8px 12px; border-bottom:1px solid var(--border); white-space:nowrap; max-width:280px; overflow:hidden; text-overflow:ellipsis; }
#results.list tr:hover td { background:var(--panel); }
/* MAP */
#results.map { padding:0; height:calc(100vh - 180px); }
#results.map #leaflet { width:100%; height:100%; background:var(--panel-2); }
.leaflet-container { background:#0a0a0c !important; }
.leaflet-popup-content-wrapper { background:var(--panel) !important; color:var(--fg) !important; border:1px solid var(--border); }
.leaflet-popup-tip { background:var(--panel) !important; border:1px solid var(--border); }
.leaflet-popup-content { font:13px/1.45 system-ui; color:var(--fg); }
.leaflet-popup-content .pname { font-weight:600; font-size:14px; margin-bottom:4px; }
.empty { padding:40px 28px; color:var(--muted); text-align:center; }
</style>
</head>
<body>
<header>
<h1>Lawyer Directory <span class="accent">·</span> dashboard</h1>
<div class="stats" id="stats-bar">
<span data-filter="firms"> <b id="s-firms">…</b> firms</span>
<span data-filter="attorneys"> <b id="s-attorneys">…</b> attorneys</span>
<span data-filter="with_site"> <b id="s-with_site">…</b> with sites</span>
<span data-filter="with_phone"> <b id="s-with_phone">…</b> with phone</span>
<span data-filter="phones"> <b id="s-phones">…</b> phones</span>
<span data-filter="emails"> <b id="s-emails">…</b> emails</span>
<span data-filter="audits"> <b id="s-audits">…</b> audited</span>
<span data-filter="cities"> <b id="s-cities">…</b> cities</span>
<span style="margin-left:auto"><a href="/audit.html">/audit.html</a> · <a href="/directory">/directory</a> · <a href="/" >/marketing</a></span>
</div>
</header>
<div class="controls">
<div class="seg" id="entity-toggle">
<button data-entity="firms" class="active">Firms</button>
<button data-entity="attorneys">Attorneys</button>
</div>
<select id="prospect-select" title="Lead-quality filter (firms only)">
<option value="">Any prospect</option>
<option value="has_site">Has website</option>
<option value="needs_site">Needs website</option>
<option value="has_phone">Has phone</option>
<option value="audited">Audited</option>
<option value="high_score">High audit score (≥70)</option>
<option value="low_score">Low audit score (<50)</option>
</select>
<select id="size-select" title="Firm size band">
<option value="">Any size</option>
<option value="solo">Solo</option>
<option value="small">Small (2–10)</option>
<option value="mid">Mid (11–50)</option>
<option value="large">Large (51–200)</option>
<option value="biglaw">BigLaw (200+)</option>
</select>
<select id="sort-select" title="Sort">
<option value="score">Sort: lead score</option>
<option value="attorneys">Sort: # attorneys</option>
<option value="recent">Sort: recently crawled</option>
<option value="name">Sort: name (A–Z)</option>
<option value="zip">Sort: ZIP</option>
</select>
<div class="seg" id="view-toggle">
<button data-view="grid" class="active">Grid</button>
<button data-view="list">List</button>
<button data-view="map">Map</button>
</div>
<div class="slider-group" id="grid-controls">
<span>Per row</span>
<input type="range" min="3" max="10" value="5" id="grid-cols">
<span class="val" id="grid-cols-val">5</span>
</div>
<input type="text" id="filter-q" placeholder="Filter by name…" style="width:180px">
<div class="pager">
<button id="prev">← Prev</button>
<span class="info" id="pageinfo">0–0</span>
<button id="next">Next →</button>
</div>
</div>
<div id="results" class="grid"></div>
<script>
(() => {
const $ = (s) => document.querySelector(s);
const results = $('#results');
const gridColsEl = $('#grid-cols');
const gridColsVal = $('#grid-cols-val');
const gridControls = $('#grid-controls');
const filterQ = $('#filter-q');
const pageInfo = $('#pageinfo');
const prevBtn = $('#prev');
const nextBtn = $('#next');
const PAGE_BY_VIEW = { grid: 100, list: 250, map: 1000 };
const SAVE = (k, v) => localStorage.setItem('ldb.' + k, String(v));
const LOAD = (k, d) => { const v = localStorage.getItem('ldb.' + k); return v == null ? d : v; };
const state = {
entity: LOAD('entity', 'firms'),
view: LOAD('view', 'grid'),
cols: Number(LOAD('cols', 5)),
prospect: LOAD('prospect', ''),
size: LOAD('size', ''),
sort: LOAD('sort', 'score'),
listSortCol: LOAD('listSortCol', ''),
listSortDir: LOAD('listSortDir', 'asc'),
offset: 0,
q: '',
rows: [],
};
function pageSize() { return PAGE_BY_VIEW[state.view] || 100; }
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
async function loadStats() {
const r = await fetch('/api/dashboard/stats').then(r => r.json());
for (const k of ['firms','attorneys','with_site','with_phone','phones','emails','audits','cities']) {
const el = document.getElementById('s-' + k);
if (el && r[k] != null) el.textContent = Number(r[k]).toLocaleString();
}
}
function buildQS() {
const p = new URLSearchParams({ limit: pageSize(), offset: state.offset });
if (state.q) p.set('q', state.q);
if (state.entity === 'firms') {
if (state.prospect) p.set('prospect', state.prospect);
if (state.size) p.set('firm_size_band', state.size);
if (state.sort) p.set('sort', state.sort);
} else {
if (state.sort === 'name' || state.sort === 'admission' || state.sort === 'bar') p.set('sort', state.sort);
}
return p;
}
async function load() {
const url = (state.entity === 'firms' ? '/api/firms' : '/api/attorneys') + '?' + buildQS();
try {
const r = await fetch(url).then(r => r.json());
state.rows = r.rows || [];
pageInfo.textContent = state.rows.length
? (state.offset + 1) + '–' + (state.offset + state.rows.length)
: '0–0';
prevBtn.disabled = state.offset === 0;
nextBtn.disabled = state.rows.length < pageSize();
render();
} catch (e) {
results.innerHTML = '<div class="empty">Error loading data: ' + escapeHtml(e.message) + '</div>';
}
}
function applyView() {
results.className = state.view;
document.documentElement.style.setProperty('--cols', String(state.cols));
gridColsVal.textContent = state.cols;
gridControls.style.display = state.view === 'grid' ? '' : 'none';
document.querySelectorAll('#view-toggle button').forEach(b => b.classList.toggle('active', b.dataset.view === state.view));
document.querySelectorAll('#entity-toggle button').forEach(b => b.classList.toggle('active', b.dataset.entity === state.entity));
const isFirms = state.entity === 'firms';
document.getElementById('prospect-select').style.display = isFirms ? '' : 'none';
document.getElementById('size-select').style.display = isFirms ? '' : 'none';
}
// ─── GRID ─────────────────────────────────────────────────────────────────
function renderGridFirms() {
if (!state.rows.length) return '<div class="empty">No firms match.</div>';
return state.rows.map(r => {
const sc = r.audit_score == null ? null : Number(r.audit_score);
const scColor = sc == null ? '#3a3a44' : sc >= 70 ? '#10b981' : sc >= 50 ? '#facc15' : '#ef4444';
const scoreBadge = sc == null
? '<span class="badge" style="background:#1c2237;color:#737373">no audit</span>'
: '<span class="badge audit" style="background:' + scColor + '">' + sc + '</span>';
const swatch = r.audit_color ? '<span class="swatch" style="background:' + escapeHtml(r.audit_color) + '"></span>' : '';
const siteBadge = r.website
? '<span class="badge has">site</span>'
: '<span class="badge needs">★ needs site</span>';
const sizeBadge = r.firm_size_band ? '<span class="badge size">' + escapeHtml(r.firm_size_band) + '</span>' : '';
const lawyerBadge = r.lawyer_count > 0 ? '<span class="badge size">' + r.lawyer_count + ' atty</span>' : '';
const phoneBadge = (r.phone_count > 0 || r.phone) ? '<span class="badge" style="color:#10b981">📞 ' + (r.phone_count || 1) + '</span>' : '';
const emailBadge = r.email_count > 0 ? '<span class="badge" style="color:#60a5fa">✉ ' + r.email_count + '</span>' : '';
const phoneLink = r.phone ? '<a href="tel:' + escapeHtml(r.phone.replace(/[^+0-9]/g,'')) + '">' + escapeHtml(r.phone) + '</a>' : '';
const siteLink = r.website ? '<a href="' + escapeHtml(r.website) + '" target="_blank" rel="noopener">' + escapeHtml(r.website.replace(/^https?:\/\//,'').slice(0,40)) + '</a>' : '';
const mapLink = (r.lat && r.lng)
? '<a href="https://www.google.com/maps/search/?api=1&query=' + r.lat + ',' + r.lng + '" target="_blank" rel="noopener">🗺 map</a>'
: '';
return '<div class="card">'
+ '<div class="name"><span>' + escapeHtml(r.name) + '</span><span style="display:flex;gap:4px;align-items:center">' + swatch + scoreBadge + '</span></div>'
+ '<div>' + sizeBadge + lawyerBadge + siteBadge + phoneBadge + emailBadge + '</div>'
+ '<div class="meta">'
+ (r.address ? escapeHtml(r.address) + '<br>' : '')
+ escapeHtml([r.neighborhood || r.city, r.zip].filter(Boolean).join(', '))
+ (phoneLink ? '<br>' + phoneLink : '')
+ (siteLink ? '<br>' + siteLink : '')
+ '</div>'
+ '<div class="actions">'
+ (mapLink ? mapLink + ' · ' : '')
+ '<button class="pitch-btn" data-pitch="' + r.id + '">✉ pitch</button>'
+ '<button class="muted" data-copy="' + r.id + '">📋 copy</button>'
+ '</div>'
+ '</div>';
}).join('');
}
function renderGridAttorneys() {
if (!state.rows.length) return '<div class="empty">No attorneys match.</div>';
return state.rows.map(r => {
const statusBadge = r.license_status === 'Active'
? '<span class="badge has">Active</span>'
: '<span class="badge needs">' + escapeHtml(r.license_status || '?') + '</span>';
const barBadge = r.bar_number ? '<span class="badge size">Bar #' + escapeHtml(r.bar_number) + '</span>' : '';
return '<div class="card">'
+ '<div class="name">' + escapeHtml(r.full_name) + '</div>'
+ '<div>' + barBadge + statusBadge + '</div>'
+ '<div class="meta">'
+ (r.law_school ? escapeHtml(r.law_school) + '<br>' : '')
+ (r.firm_name ? '<a href="#" data-firm="' + r.firm_id + '">' + escapeHtml(r.firm_name) + '</a><br>' : '')
+ escapeHtml([r.city, r.zip].filter(Boolean).join(', '))
+ (r.firm_phone ? '<br><a href="tel:' + escapeHtml(r.firm_phone.replace(/[^+0-9]/g,'')) + '">' + escapeHtml(r.firm_phone) + '</a>' : '')
+ '</div>'
+ '</div>';
}).join('');
}
// ─── LIST (sortable headers) ─────────────────────────────────────────────
const FIRM_COLS = [
{ key: 'audit_score', label: 'Score', acc: r => r.audit_score == null ? -1 : Number(r.audit_score) },
{ key: 'name', label: 'Name', acc: r => (r.name || '').toLowerCase() },
{ key: 'firm_size_band', label: 'Size', acc: r => (r.firm_size_band || '') },
{ key: 'lawyer_count', label: '# Atty',acc: r => Number(r.lawyer_count || r.attorney_count || 0) },
{ key: 'phone_count', label: '# Phone',acc: r => Number(r.phone_count || 0) },
{ key: 'email_count', label: '# Email',acc: r => Number(r.email_count || 0) },
{ key: 'phone', label: 'Phone', acc: r => r.phone || '' },
{ key: 'address', label: 'Address',acc: r => r.address || '' },
{ key: 'city', label: 'City', acc: r => (r.neighborhood || r.city || '') },
{ key: 'zip', label: 'ZIP', acc: r => r.zip || '' },
{ key: 'website', label: 'Website',acc: r => r.website || '' },
];
const ATTORNEY_COLS = [
{ key: 'full_name', label: 'Name', acc: r => (r.full_name || '').toLowerCase() },
{ key: 'last_name', label: 'Last', acc: r => (r.last_name || '').toLowerCase() },
{ key: 'bar_number', label: 'Bar #', acc: r => Number(r.bar_number) || 0 },
{ key: 'license_status', label: 'Status', acc: r => r.license_status || '' },
{ key: 'admission_date', label: 'Admitted',acc: r => r.admission_date || '' },
{ key: 'law_school', label: 'School', acc: r => r.law_school || '' },
{ key: 'firm_name', label: 'Firm', acc: r => r.firm_name || '' },
{ key: 'city', label: 'City', acc: r => r.city || '' },
{ key: 'zip', label: 'ZIP', acc: r => r.zip || '' },
{ key: 'firm_phone', label: 'Phone', acc: r => r.firm_phone || '' },
];
function renderList() {
const cols = state.entity === 'firms' ? FIRM_COLS : ATTORNEY_COLS;
if (!state.rows.length) return '<div class="empty">No matches.</div>';
let rows = state.rows.slice();
if (state.listSortCol) {
const col = cols.find(c => c.key === state.listSortCol);
if (col) {
const dir = state.listSortDir === 'desc' ? -1 : 1;
rows.sort((a, b) => {
const av = col.acc(a), bv = col.acc(b);
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return 0;
});
}
}
const head = '<tr>' + cols.map(c => {
const arrow = state.listSortCol === c.key ? (state.listSortDir === 'desc' ? '▼' : '▲') : '';
return '<th data-col="' + c.key + '">' + escapeHtml(c.label) + ' <span class="sort-arrow">' + arrow + '</span></th>';
}).join('') + '</tr>';
let body;
if (state.entity === 'firms') {
body = rows.map(r => {
const sc = r.audit_score == null ? null : Number(r.audit_score);
const scColor = sc == null ? '#737373' : sc >= 70 ? '#10b981' : sc >= 50 ? '#facc15' : '#ef4444';
return '<tr>'
+ '<td><span style="color:' + scColor + ';font-weight:600">' + (sc == null ? '—' : sc) + '</span></td>'
+ '<td>' + escapeHtml(r.name) + '</td>'
+ '<td>' + escapeHtml(r.firm_size_band || '') + '</td>'
+ '<td>' + (r.lawyer_count || r.attorney_count || '') + '</td>'
+ '<td>' + (r.phone_count || '') + '</td>'
+ '<td>' + (r.email_count || '') + '</td>'
+ '<td>' + escapeHtml(r.phone || '') + '</td>'
+ '<td>' + escapeHtml(r.address || '') + '</td>'
+ '<td>' + escapeHtml(r.neighborhood || r.city || '') + '</td>'
+ '<td>' + escapeHtml(r.zip || '') + '</td>'
+ '<td>' + (r.website ? '<a href="' + escapeHtml(r.website) + '" target="_blank" rel="noopener">' + escapeHtml(r.website.replace(/^https?:\/\//,'').slice(0,40)) + '</a>' : '') + '</td>'
+ '</tr>';
}).join('');
} else {
body = rows.map(r => {
return '<tr>'
+ '<td>' + escapeHtml(r.full_name) + '</td>'
+ '<td>' + escapeHtml(r.last_name || '') + '</td>'
+ '<td>' + escapeHtml(r.bar_number || '') + '</td>'
+ '<td>' + escapeHtml(r.license_status || '') + '</td>'
+ '<td>' + escapeHtml(r.admission_date ? String(r.admission_date).slice(0, 10) : '') + '</td>'
+ '<td>' + escapeHtml(r.law_school || '') + '</td>'
+ '<td>' + escapeHtml(r.firm_name || '') + '</td>'
+ '<td>' + escapeHtml(r.city || '') + '</td>'
+ '<td>' + escapeHtml(r.zip || '') + '</td>'
+ '<td>' + escapeHtml(r.firm_phone || '') + '</td>'
+ '</tr>';
}).join('');
}
return '<table>' + head + body + '</table>';
}
// ─── MAP ─────────────────────────────────────────────────────────────────
let map = null, mapLayer = null;
function renderMap() {
results.innerHTML = '<div id="leaflet"></div>';
const el = document.getElementById('leaflet');
if (!window.L) { el.innerHTML = '<div class="empty">Leaflet failed to load</div>'; return; }
map = L.map(el, { preferCanvas: true }).setView([34.05, -118.25], 10);
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap, © CartoDB', maxZoom: 18,
}).addTo(map);
mapLayer = L.layerGroup().addTo(map);
addPins();
}
function addPins() {
if (!mapLayer) return;
mapLayer.clearLayers();
const bounds = [];
for (const r of state.rows) {
if (!r.lat || !r.lng) continue;
const sc = r.audit_score == null ? null : Number(r.audit_score);
const color = sc == null ? '#8b857a' : sc >= 70 ? '#10b981' : sc >= 50 ? '#facc15' : '#ef4444';
const dot = L.circleMarker([r.lat, r.lng], {
radius: 5, color, weight: 1, fillColor: color, fillOpacity: 0.85,
});
const safe = (s) => String(s == null ? '' : s).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c]));
const popup = '<div class="pname">' + safe(r.name) + '</div>'
+ (r.firm_size_band ? '<div>' + safe(r.firm_size_band) + '</div>' : '')
+ (r.address ? '<div>' + safe(r.address) + '</div>' : '')
+ (r.phone ? '<div>📞 ' + safe(r.phone) + '</div>' : '')
+ (r.website ? '<div>🔗 <a href="' + safe(r.website) + '" target="_blank" rel="noopener noreferrer">' + safe(r.website.replace(/^https?:\/\//,'')) + '</a></div>' : '')
+ '<div style="margin-top:6px"><a href="/p/' + r.id + '" target="_blank" rel="noopener noreferrer">→ pitch</a></div>';
dot.bindPopup(popup);
dot.addTo(mapLayer);
bounds.push([r.lat, r.lng]);
}
if (bounds.length) map.fitBounds(bounds, { padding: [40, 40] });
}
function render() {
if (state.view === 'map') {
if (map) { try { map.remove(); } catch(_){} map = null; mapLayer = null; }
renderMap();
} else {
if (map) { try { map.remove(); } catch(_){} map = null; mapLayer = null; }
results.innerHTML = state.view === 'grid'
? (state.entity === 'firms' ? renderGridFirms() : renderGridAttorneys())
: renderList();
}
}
// ─── delegated handlers ──────────────────────────────────────────────────
results.addEventListener('click', async (e) => {
// sortable column header
const th = e.target.closest('th[data-col]');
if (th) {
const col = th.getAttribute('data-col');
if (state.listSortCol === col) {
state.listSortDir = state.listSortDir === 'asc' ? 'desc' : 'asc';
} else {
state.listSortCol = col; state.listSortDir = 'asc';
}
SAVE('listSortCol', state.listSortCol);
SAVE('listSortDir', state.listSortDir);
render();
return;
}
// pitch
const pBtn = e.target.closest('[data-pitch]');
if (pBtn) {
const id = pBtn.getAttribute('data-pitch');
window.open('/p/' + id, '_blank');
return;
}
// copy contact card
const cBtn = e.target.closest('[data-copy]');
if (cBtn) {
const id = cBtn.getAttribute('data-copy');
const r = state.rows.find(x => String(x.id) === id);
if (!r) return;
const text = [
r.name,
r.phone || '',
[r.address, r.city, r.zip].filter(Boolean).join(' · '),
r.website || ''
].filter(Boolean).join('\n');
try {
await navigator.clipboard.writeText(text);
cBtn.textContent = '✓ copied';
setTimeout(() => { cBtn.textContent = '📋 copy'; }, 1200);
} catch (_) {}
}
});
// ─── stats bar click → quick filter ─────────────────────────────────────
document.getElementById('stats-bar').addEventListener('click', (e) => {
const span = e.target.closest('span[data-filter]');
if (!span) return;
const f = span.getAttribute('data-filter');
if (f === 'attorneys') { state.entity = 'attorneys'; }
else if (f === 'firms') { state.entity = 'firms'; state.prospect = ''; }
else if (f === 'with_site') { state.entity = 'firms'; state.prospect = 'has_site'; }
else if (f === 'with_phone') { state.entity = 'firms'; state.prospect = 'has_phone'; }
else if (f === 'audits') { state.entity = 'firms'; state.prospect = 'audited'; }
SAVE('entity', state.entity); SAVE('prospect', state.prospect);
document.getElementById('prospect-select').value = state.prospect;
state.offset = 0; applyView(); load();
});
// ─── controls wiring ─────────────────────────────────────────────────────
document.querySelectorAll('#view-toggle button').forEach(b => {
b.addEventListener('click', () => {
const prev = state.view;
state.view = b.dataset.view;
SAVE('view', state.view);
applyView();
if (PAGE_BY_VIEW[prev] !== PAGE_BY_VIEW[state.view]) {
state.offset = 0; load();
} else {
render();
}
});
});
document.querySelectorAll('#entity-toggle button').forEach(b => {
b.addEventListener('click', () => {
state.entity = b.dataset.entity;
SAVE('entity', state.entity);
state.offset = 0; applyView(); load();
});
});
gridColsEl.addEventListener('input', () => {
state.cols = Number(gridColsEl.value);
SAVE('cols', state.cols);
document.documentElement.style.setProperty('--cols', String(state.cols));
gridColsVal.textContent = state.cols;
});
let qTimer;
filterQ.addEventListener('input', () => {
state.q = filterQ.value;
clearTimeout(qTimer);
qTimer = setTimeout(() => { state.offset = 0; load(); }, 250);
});
document.getElementById('prospect-select').addEventListener('change', (e) => {
state.prospect = e.target.value; SAVE('prospect', state.prospect); state.offset = 0; load();
});
document.getElementById('size-select').addEventListener('change', (e) => {
state.size = e.target.value; SAVE('size', state.size); state.offset = 0; load();
});
document.getElementById('sort-select').addEventListener('change', (e) => {
state.sort = e.target.value; SAVE('sort', state.sort); state.offset = 0; load();
});
prevBtn.addEventListener('click', () => { state.offset = Math.max(0, state.offset - pageSize()); load(); });
nextBtn.addEventListener('click', () => { state.offset += pageSize(); load(); });
// ─── init ───────────────────────────────────────────────────────────────
gridColsEl.value = state.cols;
document.getElementById('prospect-select').value = state.prospect;
document.getElementById('size-select').value = state.size;
document.getElementById('sort-select').value = state.sort;
applyView();
loadStats();
load();
})();
</script>
</body>
</html>