← back to Nationalrealestate

public/listings.html

195 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>USRealEstate — Listings by Brokerage</title>
<link rel="stylesheet" href="/crcp-grid.css">
<style>
  :root{--bg:#0a0d13;--panel:#11151d;--line:#222936;--fg:#e8ecf2;--muted:#8a93a3;--gold:#c8a24b;
    --cg-card:#11151d;--cg-line:#222936;--cg-ink:#e8ecf2;--cg-mut:#8a93a3;
    --cg-acc:#c8a24b;--cg-accbg:rgba(200,162,75,.12);--cg-railbg:#0a0d13;--cg-topbg:rgba(17,21,29,.94);}
  *{box-sizing:border-box;}
  html,body{margin:0;background:var(--bg);color:var(--fg);
    font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;}
  #topbar{position:sticky;top:0;z-index:1000;display:flex;align-items:center;gap:18px;flex-wrap:wrap;
    padding:10px 18px;background:rgba(17,21,29,.94);border-bottom:1px solid var(--line);backdrop-filter:blur(6px);}
  #topbar .brand{font-size:16px;font-weight:700;white-space:nowrap;}
  #topbar .brand b{color:var(--gold);}
  nav{display:flex;gap:14px;font-size:13px;}
  nav a{color:var(--muted);text-decoration:none;} nav a:hover,nav a.active{color:var(--gold);}
  #coverage{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-left:auto;font-size:12px;}
  #coverage .cov-lbl{color:var(--muted);}
  .st-chip{border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--muted);
    padding:4px 10px;white-space:nowrap;font-size:12px;}
  .st-chip b{color:var(--fg);} .st-chip .n{color:var(--gold);font-weight:700;margin-left:5px;}
  #srvbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:10px 18px;border-bottom:1px solid var(--line);
    background:var(--panel);font-size:13px;}
  #srvbar select,#srvbar input{background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;
    padding:7px 10px;font-size:13px;}
  #srvbar select:focus,#srvbar input:focus{outline:none;border-color:var(--gold);}
  #srvbar .lbl{color:var(--muted);}
  #loading{padding:60px;text-align:center;color:var(--muted);}
  .rail-note{padding:10px 12px;font-size:11px;color:var(--muted);border-top:1px solid var(--line);line-height:1.45;}
  /* the BROKERAGE FIRM is the key relationship — make it prominent on every row/card */
  .firm-chip{display:inline-block;background:var(--cg-accbg);border:1px solid #3a3320;color:var(--gold);
    border-radius:999px;padding:1px 9px;font-size:11px;font-weight:600;white-space:nowrap;}
  .viewon{color:var(--gold);text-decoration:none;font-size:12px;white-space:nowrap;}
  .viewon:hover{text-decoration:underline;}
  .stat{color:var(--fg);} .stat b{color:var(--gold);}
  .src-tag{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--muted);}
</style>
<link rel="stylesheet" href="/nav-drawer.css">
<link rel="stylesheet" href="/geo-search.css">
</head>
<body>
<div id="topbar">
  <div class="brand"><b>USRealEstate</b> — Listings by Brokerage</div>
  <nav>
    <a href="/">Map</a>
    <a href="/markets.html">Markets</a><a href="/commercial.html">Commercial</a>
    <a href="/brokers.html">Brokers</a>
    <a href="/listings.html" class="active">Listings</a>
    <a href="/sources.html">Sources</a>
    <a href="/watchlist.html">Watchlist</a>
    <a href="/admin.html">Admin</a>
  </nav>
  <span id="coverage"><span class="cov-lbl">coverage:</span><span style="color:var(--muted)">loading…</span></span>
</div>
<div id="srvbar">
  <span class="lbl">Brokerage firm</span>
  <select id="firmSel"><option value="">All firms</option></select>
  <span class="lbl">County</span>
  <select id="countySel"><option value="">All counties</option></select>
  <span class="lbl">Source</span>
  <select id="sourceSel"><option value="">All sources</option></select>
  <input type="text" id="q" placeholder="search address / firm">
</div>
<div class="cg-shell">
  <div class="cg-rail" id="cgRail"></div>
  <div class="cg-main" id="cgMain"><div id="loading">loading listings…</div></div>
</div>

<script src="/crcp-grid.js"></script>
<script>
const esc = s => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
const money = v => v == null ? '—' : '$' + Number(v).toLocaleString();
let grid = null;
let ALL = [];

function firmChip(r){ return r.firm_name ? `<span class="firm-chip">🏢 ${esc(r.firm_name)}</span>` : `<span class="src-tag">firm —</span>`; }

const FIELDS = [
  { k:'address', l:'Address', def:true },
  { k:'price',   l:'Price',   def:true, money:true, num:true },
  { k:'beds',    l:'Beds',    def:true, num:true },
  { k:'baths',   l:'Baths',   def:true, num:true },
  { k:'sqft',    l:'Sq Ft',   def:true, num:true },
  { k:'firm_name', l:'Brokerage', def:true, badge:true },
  { k:'county_name', l:'County', def:true },
  { k:'status',  l:'Status',  def:true },
  { k:'source',  l:'Source',  def:true },
];

function badgeHtml(r){
  // brokerage firm chip + the outbound "View on <brokerage>" link — never present as ours
  const host = (() => { try { return new URL(r.url).host.replace(/^www\./,''); } catch(e){ return r.source; } })();
  const viewOn = r.url ? `<a class="viewon" href="${esc(r.url)}" target="_blank" rel="noopener noreferrer">View on ${esc(host)} ↗</a>` : '';
  return `${firmChip(r)} ${viewOn}`;
}

async function loadCoverage(){
  const d = await fetch(location.origin + '/api/listings/stats').then(r=>r.json());
  const el = document.getElementById('coverage');
  const bits = (d.by_source||[]).map(s => `<span class="st-chip"><b>${esc(s.source)}</b><span class="n">${s.listings}</span></span>`);
  el.innerHTML = `<span class="cov-lbl">coverage:</span>` + bits.join(' ') +
    ` <span class="st-chip"><b>tied to firm</b><span class="n">${d.totals.with_firm}/${d.totals.total}</span></span>`;
}

async function loadFilters(){
  const [firms, rows] = await Promise.all([
    fetch(location.origin + '/api/listings/firms').then(r=>r.json()),
    fetch(location.origin + '/api/listings?limit=2000').then(r=>r.json()),
  ]);
  const fsel = document.getElementById('firmSel');
  for (const f of firms.firms) {
    const o = document.createElement('option');
    o.value = f.firm_id; o.textContent = `${f.firm_name} (${f.listings})`;
    fsel.appendChild(o);
  }
  // county + source options derived from the loaded rows
  const counties = new Map(), sources = new Set();
  for (const r of rows.rows) {
    if (r.region_id && r.county_name) counties.set(String(r.region_id), `${r.county_name}, ${r.state_code}`);
    if (r.source) sources.add(r.source);
  }
  const csel = document.getElementById('countySel');
  [...counties.entries()].sort((a,b)=>a[1].localeCompare(b[1])).forEach(([id,name])=>{
    const o=document.createElement('option'); o.value=id; o.textContent=name; csel.appendChild(o);
  });
  const ssel = document.getElementById('sourceSel');
  [...sources].sort().forEach(s=>{ const o=document.createElement('option'); o.value=s; o.textContent=s; ssel.appendChild(o); });
}

async function load(){
  const firm = document.getElementById('firmSel').value;
  const county = document.getElementById('countySel').value;
  const source = document.getElementById('sourceSel').value;
  const p = new URLSearchParams({ limit: '2000' });
  if (firm) p.set('firm', firm);
  if (county) p.set('county', county);
  if (source) p.set('source', source);
  const d = await fetch(location.origin + '/api/listings?' + p).then(r=>r.json());
  ALL = d.rows;
  render();
}

function render(){
  const q = document.getElementById('q').value.trim().toLowerCase();
  const rows = q ? ALL.filter(r => `${r.address||''} ${r.firm_name||''}`.toLowerCase().includes(q)) : ALL;
  if (grid) { grid.setData(rows); return; }
  grid = CrcpGrid.mount({
    data: rows,
    fields: FIELDS,
    mount: '#cgMain',
    rail: '#cgRail',
    storageKey: 'usreListings',
    cols: 3,
    view: 'grid',
    // address links OUT to the broker's own page (grid wraps href in target=_blank rel=noopener noreferrer);
    // the sub-line names the holding brokerage FIRM — so every row links out + names the firm in BOTH views.
    title: r => ({ name: r.address || '(no address)', href: r.url || undefined, sub: (r.firm_name ? '🏢 ' + r.firm_name : r.source) + (r.county_name ? ' · ' + r.county_name + ', ' + r.state_code : '') }),
    titleCol: 'Address',
    titleSortK: 'address',
    search: r => `${r.address||''} ${r.firm_name||''} ${r.county_name||''} ${r.source}`,
    badgeHtml,
    sort: { k: 'price', dir: -1 },
    facets: [
      { k: 'firm_name', l: 'Brokerage Firm', type: 'chip', max: 20 },
      { k: 'source', l: 'Source Site', type: 'chip', max: 10 },
      { k: 'status', l: 'Status', type: 'chip', max: 8 },
      { k: 'state_code', l: 'State', type: 'chip', max: 10 },
    ],
  });
  // rail note — the legal framing, always visible
  const rail = document.getElementById('cgRail');
  const note = document.createElement('div');
  note.className = 'rail-note';
  note.innerHTML = 'Facts only (address · price · beds · baths · sqft · location). ' +
    'Each listing links out to the <b>holding brokerage\'s own page</b> — we never present a listing as ours. ' +
    'Pilot sample; not a full catalog.';
  rail.appendChild(note);
}

['firmSel','countySel','sourceSel'].forEach(id => document.getElementById(id).addEventListener('change', load));
document.getElementById('q').addEventListener('input', render);

loadCoverage();
loadFilters();
load();
</script>
<script src="/nav-drawer.js"></script>
<script src="/geo-search.js"></script>
</body>
</html>