← back to Commercialrealestate
add standalone /property public-record lookup page (reuses cached assessor+ULA endpoints); v0.18.1
0f9ec8bde430ab1290ac8775fa0bb036ac335930 · 2026-08-07 07:14:18 -0700 · Steve Abrams
Files touched
M package.jsonA public/property.htmlM scripts/serve.js
Diff
commit 0f9ec8bde430ab1290ac8775fa0bb036ac335930
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Aug 7 07:14:18 2026 -0700
add standalone /property public-record lookup page (reuses cached assessor+ULA endpoints); v0.18.1
---
package.json | 2 +-
public/property.html | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++
scripts/serve.js | 2 ++
3 files changed, 101 insertions(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 234cab7..9844cb2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "commercialrealestate",
- "version": "0.18.0",
+ "version": "0.18.1",
"private": true,
"description": "LA County CRE investment explorer — multi-firm sourced, Census + assessor enriched, Qwen-analyzed",
"scripts": {
diff --git a/public/property.html b/public/property.html
new file mode 100644
index 0000000..c007e98
--- /dev/null
+++ b/public/property.html
@@ -0,0 +1,98 @@
+<!doctype html>
+<html lang="en">
+<head>
+<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>
+</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=>({'&':'&','<':'<','>':'>','"':'"'}[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
+
+// 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+=`<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>
+</body>
+</html>
diff --git a/scripts/serve.js b/scripts/serve.js
index 421fa27..06062f1 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -1555,6 +1555,8 @@ app.get('/api/rent-rolls', (req, res) => {
// Clean URL + JSON API; the page itself (public/fha-loans.html) and data/fha-loans.json are also
// served by the static middleware below.
app.get(['/fha-loans', '/fha'], (req, res) => res.sendFile(path.join(ROOT, 'public', 'fha-loans.html')));
+// Standalone public-record property lookup (reuses the cached /api/property endpoints). Bookmarkable: /property?q=... or ?ain=...
+app.get(['/property', '/lookup'], (req, res) => res.sendFile(path.join(ROOT, 'public', 'property.html')));
app.get('/api/fha-loans', (req, res) => {
try {
const file = path.join(ROOT, 'data', 'fha-loans.json');
← e82c03c auto-data-snapshot: 2026-08-07T06:58:19 (3 data files) — dat
·
back to Commercialrealestate
·
auto-data-snapshot: 2026-08-07T07:29:27 (2 data files) — dat a6093d9 →