← back to Commercialrealestate

public/property.html

149 lines

<!doctype html>
<html lang="en">
<head>
<script>(function(){try{var t=localStorage.getItem('crcp-theme');document.documentElement.setAttribute('data-theme',t==='light'?'light':'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Property Lookup · CRCP</title>
<style>
  :root{--bg:#0d1117;--panel:#161b22;--line:#30363d;--txt:#e6edf3;--mut:#8b949e;--gold:#d4a24e;--bad:#f85149;--accent:#3b82f6}
  *{box-sizing:border-box}
  body{margin:0;background:var(--bg);color:var(--txt);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
  header{padding:16px 22px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
  h1{margin:0;font-size:17px;font-weight:600;letter-spacing:.02em}
  header .sub{color:var(--mut);font-size:12px}
  header a{color:var(--accent);font-size:12px;text-decoration:none;margin-left:auto}
  header a:hover{text-decoration:underline}
  main{max-width:760px;margin:0 auto;padding:26px 22px}
  .lookup{display:flex;gap:8px;flex-wrap:wrap}
  .lookup input{flex:1;min-width:240px;background:var(--panel);border:1px solid var(--accent);border-radius:9px;color:var(--txt);padding:11px 13px;font-size:15px}
  .lookup input::placeholder{color:var(--mut)}
  .lookup button{background:var(--accent);border:0;border-radius:9px;color:#fff;padding:0 18px;font-size:14px;font-weight:600;cursor:pointer}
  .lookup button:hover{filter:brightness(1.08)}
  .hint{color:var(--mut);font-size:11.5px;margin:8px 2px 0}
  .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:18px 20px;margin-top:20px;min-height:40px}
  .card h2{margin:0 0 10px;font-size:15px;font-weight:600}
  .mut{color:var(--mut)}
  .pdpick{cursor:pointer;padding:8px 10px;border:1px solid var(--line);border-radius:8px;margin-bottom:6px}
  .pdpick:hover{border-color:var(--accent)}
  .rail{color:var(--mut);font-size:11px;margin-top:22px;border-top:1px solid var(--line);padding-top:12px}
</style>
  <link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so html[data-theme] wins -->
</head>
<body>
<header>
  <h1>🔍 Property Lookup</h1>
  <span class="sub">LA County public-record dossier — address or AIN</span>
  <a href="/deals-flow.html">← Deal Flow</a>
</header>
<main>
  <div class="lookup">
    <input id="q" placeholder="Address (e.g. 4150 Madison Ave) or AIN (e.g. 4207016028)" autofocus autocomplete="off">
    <button id="go">Look up</button>
  </div>
  <div class="hint">Public records only — LA County Assessor parcel roll + LA City Measure ULA sales. Buyer/seller from the recorded deed is a separate (gated) source and is declared, never guessed.</div>
  <div class="card" id="out"><span class="mut">Enter an LA County address or a 10-digit AIN to pull its public-record dossier.</span></div>
  <div class="rail">CRCP · public-record / address-level facts only, nothing proprietary. Data: LA County Assessor (public) · LA City Measure ULA transfer tax (public).</div>
</main>
<script>
const $=s=>document.querySelector(s), esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
async function api(u){const r=await fetch(u,{headers:{'Accept':'application/json'}});if(!r.ok)throw new Error('HTTP '+r.status);return r.json();}
let _seq=0;   // guards a stale slow response from overwriting a newer lookup

// ── listing-agent contact join (Steve HARD RULE: always show the agent's phone + email when a
//    real listing agent exists for this address). NON-FABRICATION: the ONLY source is
//    /api/listing-brokers → rows[]{address,name,phone,email,website}, joined on the NORMALIZED
//    address (authoritative). No address match → source/DRE fallback link, NEVER an invented number.
// Normalize to a street-only key. Split on the FIRST comma OR run of 2+ spaces so it handles BOTH
// the listing-brokers "6651 Beck" form AND the LA Assessor "6651 BECK AVE  LOS ANGELES CA  91606"
// double-space form (comma-only stripping never matched the Assessor tail — TK-10703 contrarian fix).
const na=s=>String(s||'').split(/,|\s{2,}/)[0].toLowerCase().replace(/\b(street|st|avenue|ave|boulevard|blvd|road|rd|drive|dr|lane|ln|place|pl|court|ct|suite|ste|unit|#|north|south|east|west|n|s|e|w)\b/g,'').replace(/[^a-z0-9]+/g,'');
// A multi-address range ("1704, 1710, 1728 N Sierra Bonita St…") truncates to a bare number → ambiguous,
// would wrong-join. Reject all-digit keys (better a missing contact than the WRONG agent's number).
const naOk=k=>!!k && !/^\d+$/.test(k);
const cleanv=v=>{const s=(v==null?'':String(v)).trim();return(!s||/^null/i.test(s))?null:s;};
const weburl=w=>{w=cleanv(w);return w?(/^https?:\/\//i.test(w)?w:'http://'+w):null;};
const ADDRC={};   // normalized address -> {name,phone,email,website}
window.ADDRC=ADDRC; window.na=na;   // exposed for the TK-10703 verification harness (read-only hooks)
(async function loadBrokers(){
  try{
    const lb=await api('/api/listing-brokers');
    ((lb&&lb.rows)||[]).forEach(row=>{
      const k=na(row.address); if(!naOk(k))return;
      const ph=cleanv(row.phone),em=cleanv(row.email);
      if(!ADDRC[k] || ((ph||em) && !(ADDRC[k].phone||ADDRC[k].email)))
        ADDRC[k]={name:cleanv(row.name),phone:ph,email:em,website:weburl(row.website)};
    });
  }catch(e){ /* brokers unavailable — dossier still renders, strip falls back to source link */ }
})();
// Build the always-on contact strip for a rendered property (never fabricates).
function contactStrip(p){
  const pk=na(p&&p.address); const b=naOk(pk)?ADDRC[pk]:null;
  if(b && (b.phone||b.email)){
    const ph=b.phone?`<a href="tel:${esc(b.phone.replace(/[^0-9+]/g,''))}">📞 ${esc(b.phone)}</a>`:'<span class="mut">phone via source ↗</span>';
    const em=b.email?`<a href="mailto:${esc(b.email)}">✉️ ${esc(b.email)}</a>`:'<span class="mut">email via source ↗</span>';
    const web=b.website?` · <a href="${esc(b.website)}" target="_blank" rel="noopener noreferrer">🌐 site</a>`:'';
    return `<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--line)">`
      +`<div style="font-weight:600;margin-bottom:6px">Listing agent${b.name?` · ${esc(b.name)}`:''}</div>`
      +`<div style="display:flex;gap:14px;flex-wrap:wrap;font-size:13px">${ph} ${em}${web}</div></div>`;
  }
  // No confirmed listing agent for this address — DRE / source fallback, no invented contact.
  const q=encodeURIComponent(p&&p.address||'');
  return `<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--line)">`
    +`<div style="font-weight:600;margin-bottom:4px">Listing agent</div>`
    +`<div class="mut" style="font-size:12px">No confirmed listing agent on record for this address. `
    +`<a href="https://www.dre.ca.gov/PublicASP/pplinfo.asp" target="_blank" rel="noopener noreferrer">CA DRE license lookup ↗</a></div></div>`;
}

// Render a dossier object exactly like deals-flow.html's renderDossier (single source of truth for the shape).
function renderDossier(d,fallbackTitle){
  const p=d.parcel||{};
  const row=(k,v)=>(v||v===0)?`<div style="display:flex;gap:8px;padding:3px 0;border-bottom:1px solid var(--line)"><span style="color:var(--mut);min-width:150px;flex-shrink:0">${esc(k)}</span><span>${esc(String(v))}</span></div>`:'';
  const dollar=v=>v?('$'+Number(v).toLocaleString()):'';
  let html=`<h2>${esc(p.address||fallbackTitle||'Property')}</h2><div style="font-size:12.5px">`
    +row('AIN',p.ain)+row('Address',p.address)+row('City',p.city)+row('ZIP',p.zip)
    +row('Use',p.use_desc||p.use_type)+row('Year built',p.year_built)
    +row('Sq ft',p.sqft)+row('Units',p.units)+row('Beds / baths',(p.bedrooms||p.bathrooms)?((p.bedrooms||'?')+' / '+(p.bathrooms||'?')):'')
    +row('Assessed land',dollar(p.assessed_land))+row('Assessed improvement',dollar(p.assessed_improvement))+row('Assessed total',dollar(p.assessed_total))
    +row('Roll year',p.assessed_as_of)+row('Last transfer recorded',p.last_transfer_recorded);
  if(d.ula_zip_candidates&&d.ula_zip_candidates.length){
    html+=`<div style="margin-top:14px;font-weight:600;color:var(--gold)">Recent $5.3M+ sales in ZIP ${esc(p.zip||'')} (ULA · candidates)</div>`;
    d.ula_zip_candidates.slice(0,6).forEach(c=>{html+=`<div style="padding:3px 0;border-bottom:1px solid var(--line)">${esc(c.date||'')} · <b style="color:var(--gold)">$${Number(c.price).toLocaleString()}</b> · ${esc(c.use||'')}${c.instrument?` · <span class="mut">instr ${esc(c.instrument)}</span>`:''}</div>`;});
    if(d.ula_note)html+=`<div class="mut" style="font-size:11px;margin-top:4px">${esc(d.ula_note)}</div>`;
  }
  if(d.deed&&!d.deed.available){ html+=`<div style="margin-top:14px;font-weight:600">Buyer / seller (recorded deed)</div><div class="mut" style="font-size:11.5px">${esc(d.deed.reason)}</div>`; }
  html+=contactStrip(p);   // always-on listing-agent phone+email (address-join, non-fabricated)
  html+=`<div class="mut" style="font-size:10.5px;margin-top:12px">Sources: ${esc((d.sources||[]).join(' · '))}</div></div>`;
  $('#out').innerHTML=html;
}
async function lookup(raw){
  const val=String(raw||'').trim(); if(!val)return;
  const seq=++_seq, out=$('#out');
  // a bare 10+ digit string is an AIN; anything else is an address search
  const isAin=/^\d[\d\s-]{6,}$/.test(val);
  out.innerHTML='<span class="mut">Searching public records…</span>';
  try{
    const d = isAin
      ? await api('/api/property/'+encodeURIComponent(val.replace(/\D/g,'')))
      : await api('/api/property/by-address?q='+encodeURIComponent(val));
    if(seq!==_seq)return;                     // a newer lookup superseded this one
    if(!d||d.found===false){ out.innerHTML='<span class="mut">No LA County record matched “'+esc(val)+'”. Try a house number + street (e.g. 4150 Madison Ave), or a 10-digit AIN.</span>'; return; }
    if(d.multiple&&Array.isArray(d.candidates)){
      out.innerHTML='<h2>Matches for “'+esc(val)+'”</h2><div class="mut" style="margin-bottom:8px">'+d.candidates.length+' parcels matched — pick one:</div>'
        +d.candidates.map(c=>`<div class="pdpick" data-ain="${esc(String(c.ain||'').replace(/\D/g,''))}"><b>${esc(c.address||c.ain)}</b><div class="mut" style="font-size:11px">${esc(c.use_desc||c.use_type||'')} · AIN ${esc(c.ain)}</div></div>`).join('');
      return;
    }
    renderDossier(d,val);
  }catch(err){ if(seq===_seq)out.innerHTML='<span style="color:var(--bad)">Lookup failed: '+esc(err.message)+'</span>'; }
}
$('#go').addEventListener('click',()=>lookup($('#q').value));
$('#q').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();lookup(e.target.value);}});
$('#out').addEventListener('click',e=>{const pk=e.target.closest('.pdpick');if(pk){$('#q').value=pk.dataset.ain;lookup(pk.dataset.ain);}});
// deep-link: /property?q=... or /property?ain=... runs the lookup on load (bookmarkable)
(function(){const p=new URLSearchParams(location.search),v=p.get('ain')||p.get('q');if(v){$('#q').value=v;lookup(v);}})();
</script>
  <script src="/crcp-theme.js" defer></script><!-- CRCP day/night toggle (auto-injects into header) -->
  <script src="/corner-nav.js" defer></script>
</body>
</html>