[object Object]

← back to Commercialrealestate

agent profiles: /api/agent-profile?name= (our broker graph -> their listings, ZERO crexi) + public/agent.html CRCP-styled profile page (Steve: click agent -> our-own page, no crexi)

36bec4dd13b8dc056349efcb1c4dbd4d92bfc386 · 2026-08-19 09:03:01 -0700 · Steve Abrams

Files touched

Diff

commit 36bec4dd13b8dc056349efcb1c4dbd4d92bfc386
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 19 09:03:01 2026 -0700

    agent profiles: /api/agent-profile?name= (our broker graph -> their listings, ZERO crexi) + public/agent.html CRCP-styled profile page (Steve: click agent -> our-own page, no crexi)
---
 public/agent.html | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 118 insertions(+)

diff --git a/public/agent.html b/public/agent.html
new file mode 100644
index 0000000..2193923
--- /dev/null
+++ b/public/agent.html
@@ -0,0 +1,118 @@
+<!doctype html><html lang="en"><head>
+<script>/* credential-safe fetch */(function(){var of=window.fetch,O=location.origin;window.fetch=function(u,o){try{if(typeof u==='string'){if(/^[a-z][a-z0-9+.-]*:/i.test(u)){var x=new URL(u);if(x.username||x.password){x.username='';x.password='';u=x.href;}}else{u=new URL(u,O+location.pathname+location.search).href;}}else if(u&&typeof u.url==='string'){var y=new URL(u.url,O+location.pathname+location.search);if(y.username||y.password){y.username='';y.password='';u=new Request(y.href,u);}}}catch(e){}return of.call(this,u,o);};try{if(location.href.indexOf('@')>=0){history.replaceState(null,'',location.pathname+location.search+location.hash);}}catch(e){}})();</script>
+<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>Agent Profile · CRCP</title>
+<style>
+:root{--bg:#0e1116;--bg2:#0b0e13;--card:#161b22;--line:#2a313c;--ink:#e6edf3;--mut:#8b949e;--acc:#3fb950;--blue:#58a6ff;--gold:#ffa600;--onacc:#0e1116;--cols:3;--rowpad:7px}
+*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:-apple-system,Helvetica,Arial,sans-serif;font-size:13px}
+a{color:var(--blue);text-decoration:none}a:hover{text-decoration:underline}
+header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;flex-wrap:wrap;gap:10px;align-items:center}
+h1{font-size:17px;margin:0}
+.nav{display:flex;align-items:center;gap:2px;flex-wrap:wrap}
+.nav a{color:var(--blue);font-size:12px;margin-left:12px}
+.wrap{max-width:1200px;margin:0 auto;padding:18px}
+.hero{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start;border:1px solid var(--line);border-radius:14px;background:var(--card);padding:18px 20px;margin-bottom:16px}
+.avatar{width:64px;height:64px;flex:0 0 64px;border-radius:50%;background:linear-gradient(135deg,var(--blue),var(--acc));display:flex;align-items:center;justify-content:center;font-size:26px;font-weight:800;color:var(--onacc)}
+.who{flex:1;min-width:220px}
+.who h2{margin:0 0 2px;font-size:22px;color:var(--ink)}
+.who .firm{color:var(--gold);font-size:14px;font-weight:600}
+.who .meta{color:var(--mut);font-size:12.5px;margin-top:3px}
+.contact{display:flex;flex-wrap:wrap;gap:8px 18px;margin-top:10px;font-size:13.5px}
+.contact a{display:inline-flex;align-items:center;gap:5px}
+.contact .none{color:var(--mut)}
+.stats{display:flex;gap:10px;flex-wrap:wrap}
+.stat{background:var(--bg2);border:1px solid var(--line);border-radius:10px;padding:9px 14px;text-align:center}
+.stat b{display:block;font-size:20px;color:var(--ink)}.stat span{font-size:10px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut)}
+.stat b.g{color:var(--gold)}
+.toolbar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin:0 0 10px}
+.toolbar h3{margin:0;font-size:14px}
+select{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 9px;font-size:12px}
+.count{color:var(--mut);font-size:12px}
+.grid{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:12px}
+.lcard{border:1px solid var(--line);border-radius:12px;background:var(--card);padding:12px}
+.lcard .addr{font-weight:700;font-size:13.5px}
+.lcard .sub{color:var(--mut);font-size:11.5px;margin-bottom:7px}
+.lcard .price{color:var(--gold);font-weight:800;font-size:16px}
+.lcard .m{display:flex;flex-wrap:wrap;gap:5px 14px;margin-top:6px;font-size:12px}
+.lcard .m .k{color:var(--mut);font-size:10px;text-transform:uppercase;letter-spacing:.4px;margin-right:4px}
+.empty{color:var(--mut);padding:44px;text-align:center}
+.note{color:var(--mut);font-size:11.5px;margin:14px 0 0;line-height:1.5}
+</style>
+<link rel="stylesheet" href="/crcp-theme.css">
+</head><body>
+<header>
+  <h1>📇 Agent Profile</h1>
+  <div class="nav"><a href="/brokers.html">← Brokers</a><a href="/just-listed.html">Just Listed</a><a href="/">Explorer</a></div>
+</header>
+<div class="wrap">
+  <div id="hero" class="hero"><div class="who"><h2 id="loading">Loading…</h2></div></div>
+  <div class="toolbar">
+    <h3>Their listings <span class="count" id="count"></span></h3>
+    <span style="margin-left:auto"></span>
+    <select id="sort"></select>
+  </div>
+  <div id="grid" class="grid"></div>
+  <div class="empty" id="empty" hidden>No listings on record for this agent in our data.</div>
+  <div class="note" id="prov"></div>
+</div>
+<script>
+const $=s=>document.querySelector(s);
+const esc=s=>(s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
+const money=n=>n==null||isNaN(+n)?null:'$'+Math.round(+n).toLocaleString();
+const qs=new URLSearchParams(location.search);
+const NAME=(qs.get('name')||'').trim();
+let LISTINGS=[], sortKey='price', sortDir=-1;
+const initials=n=>String(n||'?').trim().split(/\s+/).slice(0,2).map(w=>w[0]||'').join('').toUpperCase()||'?';
+
+function renderHero(a){
+  const phone=a.phone?`<a href="tel:${esc(String(a.phone).replace(/[^0-9+]/g,''))}">📞 ${esc(a.phone)}</a>`:'<span class="none">📞 no phone on file</span>';
+  const email=a.email?`<a href="mailto:${esc(a.email)}">✉️ ${esc(a.email)}</a>`:'<span class="none">✉️ no email on file</span>';
+  const lin=a.linkedin?`<a href="${esc(a.linkedin)}" target="_blank" rel="noopener noreferrer">in ↗</a>`:'';
+  const type=a.agent_type?esc(a.agent_type[0].toUpperCase()+a.agent_type.slice(1)):'';
+  $('#hero').innerHTML=
+    `<div class="avatar">${esc(initials(a.name))}</div>`+
+    `<div class="who"><h2>${esc(a.name)}</h2>`+
+      (a.firm?`<div class="firm">${esc(a.firm)}</div>`:'')+
+      `<div class="meta">${[type,a.license?'DRE '+esc(a.license):'',a.office?esc(a.office):''].filter(Boolean).join(' · ')||'&nbsp;'}</div>`+
+      `<div class="contact">${phone}${email}${lin}</div></div>`+
+    `<div class="stats">`+
+      `<div class="stat"><b>${(a.count||0).toLocaleString()}</b><span>listings</span></div>`+
+      `<div class="stat"><b class="g">$${((a.total_value||0)/1e6).toFixed(1)}M</b><span>total value</span></div>`+
+      `<div class="stat"><b>${(a.cities||[]).length}</b><span>cities</span></div>`+
+    `</div>`;
+}
+function cardHtml(l){
+  const ppu=(l.price&&l.units)?Math.round(l.price/l.units):null;
+  return `<div class="lcard"><div class="addr">${esc(l.address||'—')}</div>`+
+    `<div class="sub">${esc(l.city||'')}${l.zip?' · '+esc(l.zip):''} · ${esc(l.type||'—')}</div>`+
+    `<div class="price">${money(l.price)||'—'}</div>`+
+    `<div class="m"><span><span class="k">Units</span>${l.units!=null?esc(l.units):'—'}</span>`+
+      `<span><span class="k">$/Unit</span>${ppu?money(ppu):'—'}</span>`+
+      `<span><span class="k">Cap</span>${(l.cap_rate!=null&&l.cap_rate!=='')?(+l.cap_rate).toFixed(2)+'%':'—'}</span></div></div>`;
+}
+function render(){
+  const rows=LISTINGS.slice().sort((a,b)=>{let x=a[sortKey],y=b[sortKey];const xm=(x==null||x===''),ym=(y==null||y==='');
+    if(xm&&ym)return 0;if(xm)return 1;if(ym)return -1;
+    if(!isNaN(+x)&&!isNaN(+y)){x=+x;y=+y;}else{x=String(x).toLowerCase();y=String(y).toLowerCase();}
+    return x<y?-1*sortDir:x>y?sortDir:0;});
+  $('#grid').innerHTML=rows.map(cardHtml).join('');
+  $('#count').textContent=rows.length?`(${rows.length})`:'';
+  $('#empty').hidden=rows.length>0;
+}
+$('#sort').innerHTML=[['price:desc','Price ↓'],['price:asc','Price ↑'],['units:desc','Units ↓'],['cap_rate:desc','Cap rate ↓'],['city:asc','City A→Z'],['address:asc','Address A→Z']].map(([k,l])=>`<option value="${k}">Sort: ${l}</option>`).join('');
+$('#sort').addEventListener('change',()=>{const v=$('#sort').value.split(':');sortKey=v[0];sortDir=v[1]==='asc'?1:-1;render();});
+
+if(!NAME){ $('#loading').textContent='No agent specified.'; }
+else fetch('/api/agent-profile?name='+encodeURIComponent(NAME)).then(r=>r.json()).then(a=>{
+  document.title=(a.name||NAME)+' · Agent · CRCP';
+  if(a.found===false && !a.name){ $('#hero').innerHTML=`<div class="who"><h2>${esc(NAME)}</h2><div class="meta">No record for this agent in our data.</div></div>`; $('#empty').hidden=false; return; }
+  renderHero(a);
+  LISTINGS=a.listings||[];
+  render();
+  $('#prov').innerHTML=`Profile built from Designer/CRCP's own broker graph — agent resolved by name, listings from our records. No aggregator (Crexi/LoopNet/etc.) data is used or shown.`;
+}).catch(()=>{ $('#hero').innerHTML=`<div class="who"><h2>${esc(NAME)}</h2><div class="meta">Could not load this agent.</div></div>`; });
+</script>
+<script src="/corner-nav.js" defer></script>
+<script src="/crcp-theme.js" defer></script>
+</body></html>

← 23372d3 Add Crexi-style individual broker profile page (broker.html)  ·  back to Commercialrealestate  ·  Add crawl-broker-comps.js: $0 no-auth Crexi broker book-expa 31c55f5 →