← back to Nationalrealestate

public/property.html

365 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%230a0d13'/%3E%3Ctext x='8' y='12' font-size='10' text-anchor='middle' fill='%23c8a24b'%3EUS%3C/text%3E%3C/svg%3E">
<title>USRealEstate — Property Detail</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
      integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<style>
  :root{--bg:#0a0d13;--panel:#11151d;--line:#222936;--fg:#e8ecf2;--muted:#8a93a3;--gold:#c8a24b;
    --green:#5fae7c;--red:#c96a5a;}
  *{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;
    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);}
  main{max-width:1180px;margin:0 auto;padding:24px 18px 60px;}
  #search{display:flex;gap:10px;margin-bottom:14px;}
  #search input{flex:1;max-width:520px;background:var(--panel);border:1px solid var(--line);
    border-radius:10px;color:var(--fg);padding:10px 14px;font-size:14px;outline:none;}
  #search input:focus{border-color:var(--gold);}
  #search button{background:var(--gold);color:#0a0d13;border:0;border-radius:10px;padding:10px 18px;
    font-weight:700;cursor:pointer;font-size:14px;}
  #results{margin-bottom:18px;}
  .result{display:flex;gap:14px;align-items:baseline;padding:8px 12px;border:1px solid var(--line);
    border-radius:8px;margin-bottom:6px;cursor:pointer;background:var(--panel);flex-wrap:wrap;}
  .result:hover{border-color:var(--gold);}
  .result .addr{font-weight:700;}
  .result .facts{color:var(--muted);font-size:12px;font-variant-numeric:tabular-nums;}
  #hdr{display:flex;align-items:baseline;gap:18px;flex-wrap:wrap;margin-bottom:6px;}
  #hdr h1{margin:0;font-size:24px;font-weight:700;}
  #hdr .st{color:var(--muted);font-size:14px;}
  #hdr .val{font-size:20px;color:var(--gold);font-variant-numeric:tabular-nums;font-weight:700;}
  #sub{color:var(--muted);font-size:12px;margin-bottom:18px;}
  .grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(440px,1fr));gap:16px;}
  .panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;}
  .panel h3{margin:0 0 8px;font-size:13px;color:var(--gold);font-weight:700;text-transform:uppercase;
    letter-spacing:.5px;}
  .pending{color:var(--muted);font-size:12px;padding:14px 0;font-style:italic;}
  .kv{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;}
  .stat{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:10px 12px;}
  .stat .k{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.4px;}
  .stat .v{font-size:16px;font-weight:700;font-variant-numeric:tabular-nums;margin-top:2px;}
  table{width:100%;border-collapse:collapse;font-size:12px;font-variant-numeric:tabular-nums;}
  th{color:var(--muted);text-align:left;font-weight:600;padding:5px 8px;border-bottom:1px solid var(--line);
    text-transform:uppercase;font-size:10px;letter-spacing:.4px;}
  td{padding:6px 8px;border-bottom:1px solid var(--line);}
  tr:last-child td{border-bottom:0;}
  td a{color:var(--gold);text-decoration:none;}
  .note{color:var(--muted);font-size:11px;margin-top:8px;font-style:italic;}
  .badge{display:inline-block;padding:3px 10px;border-radius:14px;font-size:12px;font-weight:700;
    border:1px solid var(--line);margin-right:6px;}
  .badge.warn{color:var(--red);border-color:var(--red);}
  .badge.ok{color:var(--green);border-color:var(--green);}
  #map_el{height:260px;border-radius:8px;}
  .leaflet-container{background:var(--bg);}
  #foot{margin-top:22px;color:var(--muted);font-size:11px;}
</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> — Property Detail</div>
  <nav>
    <a href="/">Map</a>
    <a href="/markets.html">Markets</a><a href="/commercial.html">Commercial</a>
    <a href="/watchlist.html">Watchlist</a>
    <a href="/brokers.html">Brokers</a>
    <a href="/sources.html">Sources</a>
    <a href="/admin.html">Admin</a>
  </nav>
</div>
<main>
  <form id="search" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
    <select id="countysel" style="background:var(--bg,#0a0d13);border:1px solid var(--line,#222936);color:var(--fg,#e8ecf2);border-radius:8px;padding:8px 10px;font-size:13px;"></select>
    <input id="q" type="text" placeholder="Search an address" autocomplete="off" style="flex:1;min-width:240px;">
    <button type="submit">Search</button>
  </form>
  <div id="results"></div>

  <div id="prop">
    <div id="hdr"><h1></h1></div>
    <div id="sub"></div>
    <div class="grid2">
      <div class="panel" id="s_history"><h3>Property History</h3><div class="body"></div></div>
      <div class="panel" id="s_tax"><h3>Tax History</h3><div class="body"></div></div>
      <div class="panel" id="s_contact"><h3>Contact Information</h3><div class="body"></div></div>
      <div class="panel" id="s_ownership"><h3>Ownership Information</h3><div class="body"></div></div>
      <div class="panel" id="s_zoning"><h3>Zoning</h3><div class="body"></div></div>
      <div class="panel" id="s_details"><h3>Property Details</h3><div class="body"></div></div>
      <div class="panel" id="s_contacts"><h3>Contacts</h3><div class="body"></div></div>
      <div class="panel" id="s_map"><h3>Map</h3><div class="body"></div></div>
    </div>
    <div class="panel" style="margin-top:16px;" id="s_comps"><h3>Comparable Properties</h3><div class="body"></div></div>
    <div class="grid2" style="margin-top:16px;">
      <div class="panel" id="s_climate"><h3>Climate Risk</h3><div class="body"></div></div>
      <div class="panel" id="s_rental"><h3>Rental Market Overview</h3><div class="body"></div></div>
      <div class="panel" id="s_demo"><h3>Demographics</h3><div class="body"></div></div>
      <div class="panel" id="s_location"><h3>Location Insights</h3><div class="body"></div></div>
    </div>
    <div id="foot">Parcel facts: LA County assessor roll (via CRCP) · NYC MapPLUTO (owner + zoning + assessed) ·
      King County WA EXTR (deed sale history + appraised values) · Cook County IL Assessor (owner + assessed).
      Climate: FEMA National Risk Index v1.20.0. Rents: HUD FMR FY2026 + Zillow ZORI. Demographics: Census ACS 2023 5-yr. $0 — all free public data.</div>
  </div>
</main>

<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
        integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
const qs = new URLSearchParams(location.search);
let county = qs.get('county') || '06037';
const COUNTY_LABELS = {
  '06037': 'Los Angeles County, CA', '36061': 'Manhattan (New York County), NY',
  '36047': 'Brooklyn (Kings County), NY', '36081': 'Queens County, NY',
  '36005': 'Bronx County, NY', '36085': 'Staten Island (Richmond County), NY',
  '53033': 'King County (Seattle), WA', '17031': 'Cook County (Chicago), IL',
};
const PLACEHOLDER = { '06037': 'e.g. 9321 Ventura Way', '53033': 'e.g. 400 Broad St',
  '17031': 'e.g. 1412 N Kingsbury St', dflt: 'e.g. 1 5 Avenue' };

const usd0 = v => v == null ? '—' : '$' + Math.round(v).toLocaleString();
const int0 = v => v == null ? '—' : Math.round(v).toLocaleString();
const pct1 = v => v == null ? '—' : (v * 100).toFixed(1) + '%';
const num1 = v => v == null ? '—' : (+v).toFixed(1);
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));

const PENDING = '<div class="pending">Coverage pending for this county.</div>';
function pendingNote(note){ return '<div class="pending">' + esc(note || 'Coverage pending for this county.') + '</div>'; }
function stats(pairs){
  return '<div class="kv">' + pairs.map(([k, v]) =>
    `<div class="stat"><div class="k">${esc(k)}</div><div class="v">${v}</div></div>`).join('') + '</div>';
}
function body(id, html){ document.querySelector('#' + id + ' .body').innerHTML = html; }

let mapObj = null;

async function search(q){
  const el = document.getElementById('results');
  el.innerHTML = '<div class="pending">Searching…</div>';
  const res = await fetch(`/api/property/search?county=${county}&q=${encodeURIComponent(q)}`);
  const d = await res.json();
  if (!res.ok){ el.innerHTML = pendingNote(d.error); return; }
  if (!d.matches.length){ el.innerHTML = '<div class="pending">No parcels matched. Try "number street", e.g. 9321 Ventura.</div>'; return; }
  el.innerHTML = d.matches.map(m =>
    `<div class="result" data-ain="${esc(m.ain)}">
       <span class="addr">${esc(m.address)}</span>
       <span class="facts">${esc(m.use || '')} · ${m.bedrooms ?? '—'}bd/${m.bathrooms ?? '—'}ba ·
         ${int0(m.sqft)} sqft · built ${m.year_built || '—'} · assessed ${usd0(m.assessed_value)}</span>
     </div>`).join('');
  el.querySelectorAll('.result').forEach(r => r.addEventListener('click', () => {
    history.replaceState(null, '', `?county=${county}&loc=${encodeURIComponent(r.dataset.ain)}`);
    load(r.dataset.ain);
    el.innerHTML = '';
  }));
}

async function load(loc){
  const res = await fetch(`/api/property?county=${county}&loc=${encodeURIComponent(loc)}`);
  const d = await res.json();
  if (!res.ok){
    document.querySelector('#hdr h1').textContent = 'Not found: ' + loc;
    return;
  }

  const p = d.property_details;
  document.title = 'USRealEstate — ' + (p.address || p.ain);
  document.querySelector('#hdr h1').textContent = p.address || 'AIN ' + p.ain;
  document.querySelector('#hdr').innerHTML =
    `<h1>${esc(p.address || 'AIN ' + p.ain)}</h1><span class="st">${esc(p.use || '')}</span>` +
    `<span class="val">${usd0(d.tax_history.rolls[0]?.total_value)} assessed</span>`;
  document.getElementById('sub').textContent =
    `AIN ${p.ain} · county ${d.county} · ${p.bedrooms ?? '—'} bd / ${p.bathrooms ?? '—'} ba · ` +
    `${int0(p.sqft)} sqft · built ${p.year_built || '—'}`;

  // 1 Property History — recorded-document events, sale records, and public-record links
  const sales = d.property_history.sales || [];
  const events = d.property_history.events || [];
  const pr = d.public_records || [];
  const recLinks = pr.length
    ? `<div class="note"><b>Public records:</b> ` + pr.map(r =>
        `<a href="${esc(r.url)}" target="_blank" rel="noopener noreferrer">${esc(r.label)}</a>${r.deep_link ? ' ↗' : ''}`).join(' &nbsp;·&nbsp; ')
      + (pr.some(r => r.deep_link === false) ? `<br><span style="opacity:.7">↗ = direct record; others are search portals (enter the APN)</span>` : '') + `</div>`
    : '';
  const histCore = events.length
    ? `<table><tr><th>Date</th><th>Type</th><th>Doc #</th><th>Record</th></tr>` +
      events.map(e => `<tr><td>${esc(e.date)}</td><td>${esc(e.event_type)}</td><td>${esc(e.doc_number || '—')}</td>
        <td>${e.source_url ? `<a href="${esc(e.source_url)}" target="_blank" rel="noopener noreferrer">view</a>` : '—'}</td></tr>`).join('') +
      `</table><div class="note">${esc(d.property_history.note)}</div>`
    : sales.length
    ? `<table><tr><th>Date</th><th>Price</th><th>Buyer</th><th>Seller</th></tr>` +
      sales.map(s => `<tr><td>${esc(s.date)}</td><td>${usd0(s.price)}</td>
        <td>${esc(s.buyer || '—')}</td><td>${esc(s.seller || '—')}</td></tr>`).join('') +
      `</table><div class="note">${esc(d.property_history.note)}</div>`
    : d.property_history.last_recording_date
    ? stats([['Last Recording Date', esc(d.property_history.last_recording_date)],
             ['Sale Price', usd0(d.property_history.sale_price)]]) +
      `<div class="note">${esc(d.property_history.note)}</div>`
    : pendingNote(d.property_history.note);
  body('s_history', histCore + recLinks);

  // 2 Tax History
  const rolls = d.tax_history.rolls.filter(r => r.roll_year);
  body('s_tax', rolls.length
    ? `<table><tr><th>Roll Year</th><th>Total</th><th>Land</th><th>Improvements</th></tr>` +
      rolls.map(r => `<tr><td>${esc(r.roll_year)}</td><td>${usd0(r.total_value)}</td>
        <td>${usd0(r.land_value)}</td><td>${usd0(r.improvement_value)}</td></tr>`).join('') +
      `</table><div class="note">${esc(d.tax_history.note)}</div>`
    : pendingNote(d.tax_history.note));

  // 3 Contact Information
  body('s_contact', (d.contact_information.phone || d.contact_information.email)
    ? stats([['Phone', esc(d.contact_information.phone)], ['Email', esc(d.contact_information.email)]])
    : pendingNote(d.contact_information.note));

  // 4 Ownership Information
  body('s_ownership', d.ownership_information.owner_name
    ? stats([['Owner', esc(d.ownership_information.owner_name)],
             ['Mailing Address', esc(d.ownership_information.mailing_address)]])
    : pendingNote(d.ownership_information.note));

  // 5 Zoning
  body('s_zoning', d.zoning.code
    ? stats([['Zoning', esc(d.zoning.code)]])
    : pendingNote(d.zoning.note));

  // 6 Property Details
  body('s_details', stats([
    ['Use', esc(p.use)], ['Category', esc(p.use_category)],
    ['Year Built', p.year_built || '—'], ['Effective Year', p.effective_year_built || '—'],
    ['Sqft (main)', int0(p.sqft)], ['Bedrooms', p.bedrooms ?? '—'],
    ['Bathrooms', p.bathrooms ?? '—'], ['Units', p.units ?? '—'],
  ]));

  // 7 Contacts
  const ca = d.contacts.county_assessor;
  body('s_contacts',
    stats([['County Assessor', esc(ca.name)], ['Phone', esc(ca.phone)]]) +
    `<div class="note"><a href="${esc(ca.website)}" target="_blank" rel="noopener noreferrer" style="color:var(--gold)">${esc(ca.website)}</a>
      · <a href="${esc(d.contacts.brokers_link)}" style="color:var(--gold)">CA brokers directory</a></div>` +
    `<div class="note">${esc(d.contacts.note)}</div>`);

  // 8 Map
  if (d.map.lat != null && d.map.lng != null){
    body('s_map', `<div id="map_el"></div><div class="note">${esc(d.map.note)}</div>`);
    if (mapObj) mapObj.remove();
    mapObj = L.map('map_el', { scrollWheelZoom: false }).setView([d.map.lat, d.map.lng], d.map.precision === 'parcel' ? 17 : 9);
    L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
      { attribution: '&copy; OpenStreetMap &copy; CARTO', maxZoom: 19 }).addTo(mapObj);
    L.marker([d.map.lat, d.map.lng]).addTo(mapObj)
      .bindPopup(esc(p.address || p.ain) + '<br><small>' + esc(d.map.precision) + '</small>');
  } else {
    body('s_map', pendingNote(d.map.note));
  }

  // 9 Comparable Properties
  body('s_comps', d.comps.length
    ? `<table><tr><th>Address</th><th>Built</th><th>Sqft</th><th>Bd/Ba</th><th>Assessed</th><th>Recorded</th></tr>` +
      d.comps.map(c => `<tr><td><a href="?county=${county}&loc=${encodeURIComponent(c.ain)}">${esc(c.address)}</a></td>
        <td>${c.year_built || '—'}</td><td>${int0(c.sqft)}</td>
        <td>${c.bedrooms ?? '—'}/${c.bathrooms ?? '—'}</td>
        <td>${usd0(c.assessed_value)}</td><td>${esc(c.recording_date || '—')}</td></tr>`).join('') +
      `</table><div class="note">Same use type, sqft ±30%, most recent recordings; same-street matches first.</div>`
    : pendingNote('No comparable parcels matched (needs sqft + use type on the subject parcel).'));

  // 10 Climate Risk
  if (d.climate && d.climate.risk_score != null){
    const rt = d.climate.risk_rating || '—';
    const cls = /high/i.test(rt) ? 'warn' : 'ok';
    body('s_climate',
      `<div style="margin-bottom:10px;"><span class="badge ${cls}">${esc(rt)}</span>
        ${(d.climate.top_hazards || []).map(h => `<span class="badge">${esc(h.hazard)} ${num1(h.score)}</span>`).join('')}</div>` +
      stats([
        ['Risk Score', num1(d.climate.risk_score)], ['Expected Annual Loss', num1(d.climate.eal_score)],
        ['Social Vulnerability', num1(d.climate.social_vulnerability)],
        ['Community Resilience', num1(d.climate.community_resilience)],
      ]) + `<div class="note">${esc(d.climate.source)}</div>`);
  } else body('s_climate', PENDING);

  // 11 Rental Market Overview
  if (d.rental && (d.rental.fmr || d.rental.zori != null)){
    const f = d.rental.fmr || {};
    body('s_rental', stats([
      ['FMR Studio', usd0(f.fmr_0br)], ['FMR 1BR', usd0(f.fmr_1br)], ['FMR 2BR', usd0(f.fmr_2br)],
      ['FMR 3BR', usd0(f.fmr_3br)], ['FMR 4BR', usd0(f.fmr_4br)],
      ['ZORI (observed rent)', usd0(d.rental.zori)], ['Rent Yield', pct1(d.rental.rent_yield)],
      ['Median Gross Rent', usd0(d.rental.median_gross_rent)],
    ]) + `<div class="note">${esc(d.rental.source)}</div>`);
  } else body('s_rental', PENDING);

  // 12 Demographics
  if (d.demographics && d.demographics.population != null){
    body('s_demo', stats([
      ['Population', int0(d.demographics.population)],
      ['Median HH Income', usd0(d.demographics.median_hh_income)],
      ['Median Gross Rent', usd0(d.demographics.median_gross_rent)],
      ['Vacancy Rate', pct1(d.demographics.vacancy_rate)],
      ['Renter Share', pct1(d.demographics.renter_share)],
    ]) + `<div class="note">${esc(d.demographics.source)}</div>`);
  } else body('s_demo', PENDING);

  // 13 Location Insights
  if (d.location){
    body('s_location', stats([
      ['County', esc(d.location.county_name)], ['State', esc(d.location.state)],
      ['Metro', esc(d.location.metro || '—')], ['FIPS', esc(d.location.fips)],
      ['County ZHVI', usd0(d.location.zhvi)], ['ZHVI YoY', pct1(d.location.zhvi_yoy)],
    ]) + `<div class="note"><a href="${esc(d.location.links.market)}" style="color:var(--gold)">Full county market detail →</a></div>`);
  } else body('s_location', PENDING);
}

document.getElementById('search').addEventListener('submit', e => {
  e.preventDefault();
  const q = document.getElementById('q').value.trim();
  if (q) search(q);
});

// full 13-section skeleton always visible — never blank/hidden
document.querySelector('#hdr h1').textContent = 'Search an address to load a property';
document.querySelectorAll('#prop .panel .body').forEach(b => { b.innerHTML = PENDING; });

function applyCounty(){
  document.getElementById('q').placeholder = 'Search an address — ' + (PLACEHOLDER[county] || PLACEHOLDER.dflt);
  const lbl = COUNTY_LABELS[county] || county;
  document.getElementById('sub').textContent =
    'Parcel coverage: ' + lbl + '. Switch counties above; more counties added over time.';
}

async function initCounties(){
  const sel = document.getElementById('countysel');
  try {
    const d = await fetch('/api/property/coverage').then(r => r.json());
    const opts = (d.counties || []).map(c =>
      `<option value="${c.county_fips}"${c.county_fips === county ? ' selected' : ''}>${COUNTY_LABELS[c.county_fips] || c.county_fips} · ${(+c.parcel_count).toLocaleString()} parcels</option>`).join('');
    sel.innerHTML = opts || `<option value="06037">Los Angeles County, CA</option>`;
  } catch { sel.innerHTML = `<option value="06037">Los Angeles County, CA</option>`; }
  sel.onchange = () => {
    county = sel.value;
    document.getElementById('results').innerHTML = '';
    document.querySelectorAll('#prop .panel .body').forEach(b => { b.innerHTML = PENDING; });
    document.querySelector('#hdr h1').textContent = 'Search an address to load a property';
    applyCounty();
    history.replaceState(null, '', `?county=${county}`);
  };
  applyCounty();
}

initCounties();
const loc = qs.get('loc');
const prefillQ = qs.get('q');
if (loc) load(loc);
else if (prefillQ) { // deep-linked from the top location search — prefill + auto-run the address search
  const qi = document.getElementById('q'); qi.value = prefillQ; search(prefillQ);
}
</script>
<script src="/nav-drawer.js"></script>
<script src="/geo-search.js"></script>
</body>
</html>