← 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 (&lt;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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[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>