← back to Nationalrealestate

public/listings.html

313 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);}
  /* TK-10155: lifecycle state chips — color-coded */
  .lc-chip{display:inline-block;border-radius:999px;padding:1px 9px;font-size:10.5px;font-weight:700;
    text-transform:uppercase;letter-spacing:.03em;white-space:nowrap;border:1px solid transparent;}
  .lc-new{background:rgba(46,160,67,.16);color:#4ac26b;border-color:#2a5c38;}
  .lc-active{background:rgba(56,139,253,.16);color:#6aa8ff;border-color:#264a7a;}
  .lc-stale{background:rgba(210,153,34,.16);color:#e0b04b;border-color:#6b5320;}
  .lc-withdrawn{background:rgba(200,60,60,.18);color:#ff6b6b;border-color:#7a2a2a;}
  .lc-closed{background:rgba(120,130,145,.16);color:#9aa6b5;border-color:#3a424e;}
  .lc-reappeared{background:rgba(160,110,220,.16);color:#b78af0;border-color:#4a3a6b;}
  /* withdrawn/preserved section */
  #withdrawnWrap{display:none;padding:14px 18px;border-top:1px solid var(--line);}
  #withdrawnWrap.on{display:block;}
  #withdrawnWrap h3{margin:0 0 4px;font-size:15px;color:var(--gold);}
  #withdrawnWrap .sub{color:var(--muted);font-size:12px;margin-bottom:12px;}
  .wd-card{border:1px solid var(--line);border-radius:10px;background:var(--panel);margin-bottom:10px;overflow:hidden;}
  .wd-head{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:10px 14px;cursor:pointer;}
  .wd-head:hover{background:rgba(255,255,255,.02);}
  .wd-addr{font-weight:600;color:var(--fg);}
  .wd-meta{color:var(--muted);font-size:12px;}
  .wd-meta b{color:var(--fg);}
  .wd-when{margin-left:auto;color:var(--muted);font-size:11px;white-space:nowrap;}
  .wd-body{display:none;padding:10px 14px 14px;border-top:1px solid var(--line);background:var(--bg);}
  .wd-body.on{display:block;}
  .wd-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:6px 18px;font-size:12px;}
  .wd-grid .k{color:var(--muted);} .wd-grid .v{color:var(--fg);word-break:break-word;}
  .wd-empty{padding:30px;text-align:center;color:var(--muted);border:1px dashed var(--line);border-radius:10px;}
  .toggle-btn{background:var(--bg);border:1px solid var(--line);color:var(--muted);border-radius:8px;
    padding:7px 12px;font-size:13px;cursor:pointer;}
  .toggle-btn.on{color:var(--gold);border-color:var(--gold);}
</style>
<link rel="stylesheet" href="/nav-drawer.css">
<link rel="stylesheet" href="/geo-search.css">
  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head>
<body data-nav-agent="off"><!-- nav-agent off (TK-10525 Cycle-4 VERIFIED): this page uses CrcpGrid, which ALREADY provides sort/search/field-visibility/density(view-modes)/facet-filtering AND rebuilds .cg-grid via wholesale innerHTML on every filter/sort change (crcp-grid.js render(), ~L285-299). nav-agent would be redundant AND its MutationObserver would fight CrcpGrid's re-render. CrcpGrid IS the grid-controls standard on this page. -->
<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>
  <span class="lbl">Lifecycle</span>
  <select id="stateSel">
    <option value="">All states</option>
    <option value="new">New</option>
    <option value="active">Active</option>
    <option value="stale">Stale</option>
    <option value="withdrawn">Withdrawn</option>
    <option value="closed">Closed</option>
    <option value="reappeared">Reappeared</option>
  </select>
  <input type="text" id="q" placeholder="search address / firm">
  <button id="wdToggle" class="toggle-btn" title="Show withdrawn listings with their preserved snapshot">🗄️ Withdrawn (preserved)</button>
</div>
<!-- TK-10155: withdrawn (preserved) view — pulled listings whose full property info was saved -->
<div id="withdrawnWrap">
  <h3>🗄️ Withdrawn — Preserved Snapshots</h3>
  <div class="sub">Listings that disappeared from the source feed (were pulled/withdrawn). Their full property info was saved at withdrawal time, so the data is still browsable after the listing vanished from the vendor. Expand a row to see the preserved snapshot.</div>
  <div id="withdrawnList"><div id="loading">loading withdrawn…</div></div>
</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 src="/col-resize.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>`; }
// TK-10155: color-coded lifecycle state chip
function lcChip(state){
  if (!state) return '';
  return `<span class="lc-chip lc-${esc(state)}">${esc(state)}</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:'lifecycle_state', l:'Lifecycle', def:true },
  { k:'last_firm_of_record', l:'Last Firm of Record', def:false },
  { k:'last_broker_of_record', l:'Last Broker of Record', def:false },
  { 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 `${lcChip(r.lifecycle_state)} ${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 state = document.getElementById('stateSel').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);
  if (state) p.set('state', state);
  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: 'lifecycle_state', l: 'Lifecycle State', type: 'chip', max: 8 },
      { k: 'last_firm_of_record', l: 'Last Firm of Record', 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','stateSel'].forEach(id => document.getElementById(id).addEventListener('change', load));
document.getElementById('q').addEventListener('input', render);

// ── TK-10155: Withdrawn (preserved) view ────────────────────────────────────
const fmtWhen = ts => { try { return new Date(ts).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); } catch(e){ return ts||'—'; } };
// fields worth surfacing first in a snapshot (rest follow)
const SNAP_ORDER = ['address','price','beds','baths','sqft','firm_name','broker_name','county_name','region_state_code','status','source','source_id','url','first_seen','last_seen','id'];
function snapGrid(snap){
  if (!snap) return '<div class="wd-empty">No snapshot saved for this listing.</div>';
  const keys = [...SNAP_ORDER.filter(k => k in snap), ...Object.keys(snap).filter(k => !SNAP_ORDER.includes(k))];
  const cells = keys.map(k => {
    let v = snap[k];
    if (v == null || v === '') v = '—';
    if (k === 'price' && snap[k] != null) v = money(snap[k]);
    if (k === 'url' && snap[k]) v = `<a class="viewon" href="${esc(snap[k])}" target="_blank" rel="noopener noreferrer">link ↗</a>`;
    else v = esc(v);
    return `<div class="k">${esc(k)}</div><div class="v">${v}</div>`;
  }).join('');
  return `<div class="wd-grid">${cells}</div>`;
}
function withdrawnCard(r, i){
  const s = r.snapshot || {};
  const addr = s.address || '(no address)';
  const firm = s.firm_name ? `<span class="wd-meta">🏢 <b>${esc(s.firm_name)}</b></span>` : '';
  const price = s.price != null ? `<span class="wd-meta">${money(s.price)}</span>` : '';
  const county = s.county_name ? `<span class="wd-meta">${esc(s.county_name)}${s.region_state_code?', '+esc(s.region_state_code):''}</span>` : '';
  return `<div class="wd-card">
    <div class="wd-head" data-i="${i}">
      <span class="wd-addr">${esc(addr)}</span>${firm}${price}${county}
      <span class="wd-when" title="withdrawn ${esc(r.withdrawn_at||'')}">🗄️ withdrawn ${fmtWhen(r.withdrawn_at)}${r.captured_reason?` · snapshot: ${esc(r.captured_reason)}`:''}</span>
    </div>
    <div class="wd-body" id="wdbody-${i}">${snapGrid(r.snapshot)}</div>
  </div>`;
}
let WITHDRAWN_LOADED = false;
async function loadWithdrawn(){
  const el = document.getElementById('withdrawnList');
  try {
    const d = await fetch(location.origin + '/api/withdrawn?limit=500').then(r=>r.json());
    if (!d.rows || !d.rows.length){
      el.innerHTML = '<div class="wd-empty">No withdrawn listings yet. When a listing disappears from a source feed for '+
        '30+ days (or is marked withdrawn/cancelled/expired), it is preserved here with its full property info.</div>';
      return;
    }
    el.innerHTML = d.rows.map((r,i)=>withdrawnCard(r,i)).join('');
    el.querySelectorAll('.wd-head').forEach(h => h.addEventListener('click', () => {
      document.getElementById('wdbody-'+h.dataset.i).classList.toggle('on');
    }));
  } catch(e){
    el.innerHTML = '<div class="wd-empty">Failed to load withdrawn listings: '+esc(e.message||e)+'</div>';
  }
}
document.getElementById('wdToggle').addEventListener('click', () => {
  const wrap = document.getElementById('withdrawnWrap');
  const btn = document.getElementById('wdToggle');
  const on = wrap.classList.toggle('on');
  btn.classList.toggle('on', on);
  if (on && !WITHDRAWN_LOADED){ WITHDRAWN_LOADED = true; loadWithdrawn(); }
});

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