← back to Commercialrealestate
public/agent.html
155 lines
<!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">
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</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>Current 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 active listings on record for this agent in our data.</div>
<div id="pastwrap" hidden>
<div class="toolbar"><h3>Past · closed (sold) <span class="count" id="pastcount"></span></h3></div>
<div id="pastgrid" class="grid"></div>
</div>
<div id="firmwrap" hidden>
<div class="toolbar"><h3>Firm listings <span class="count" id="firmcount"></span> <span style="font-weight:400;font-size:11px;color:var(--mut)">— from <span id="firmname"></span>'s own site (firm inventory, not this agent's individual book)</span></h3></div>
<div id="firmgrid" class="grid"></div>
</div>
<div id="agentNotes"></div>
<div class="note" id="prov"></div>
</div>
<script>
const $=s=>document.querySelector(s);
const esc=s=>(s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
const money=n=>n==null||isNaN(+n)?null:'$'+Math.round(+n).toLocaleString();
const qs=new URLSearchParams(location.search);
const NAME=(qs.get('name')||'').trim();
const ID=(qs.get('id')||'').trim(); // when present, resolves the EXACT agent (names collide)
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(' · ')||' '}</div>`+
`<div class="contact">${phone}${email}${lin}</div></div>`+
`<div class="stats">`+
`<div class="stat"><b>${(a.count||0).toLocaleString()}</b><span>current</span></div>`+
`<div class="stat"><b>${(a.closed_count||0).toLocaleString()}</b><span>closed (sold)</span></div>`+
`<div class="stat"><b class="g">$${((a.total_value||0)/1e6).toFixed(1)}M</b><span>active value</span></div>`+
`<div class="stat"><b>${(a.cities||[]).length}</b><span>cities</span></div>`+
`</div>`;
}
function closedCard(l){
return `<div class="lcard"><div class="addr">${esc(l.address||'—')}</div>`+
`<div class="sub">${esc(l.city||'')} · ${esc(l.type||'—')}${l.sold_date?' · sold '+esc(String(l.sold_date).slice(0,10)):''}</div>`+
`<div class="price">${money(l.sold_price)||'—'}<span class="k" style="font-weight:400;font-size:11px"> sold</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 && !ID){ $('#loading').textContent='No agent specified.'; }
else fetch('/api/agent-profile?'+(ID?'id='+encodeURIComponent(ID):'name='+encodeURIComponent(NAME))).then(r=>r.json()).then(a=>{
document.title=(a.name||NAME||'Agent')+' · Agent · CRCP';
if(a.found===false && !a.name){ $('#hero').innerHTML=`<div class="who"><h2>${esc(NAME||'This agent')}</h2><div class="meta">No record for this agent in our data.</div></div>`; $('#empty').hidden=false; return; }
renderHero(a);
LISTINGS=a.listings||[];
render();
const closed=a.closed||[];
if(closed.length){
$('#pastgrid').innerHTML=closed.map(closedCard).join('');
$('#pastcount').textContent=`(${closed.length})`;
$('#pastwrap').hidden=false;
}
const firm=a.firm_listings||[];
if(firm.length){
$('#firmgrid').innerHTML=firm.map(cardHtml).join('');
$('#firmcount').textContent=`(${firm.length})`;
$('#firmname').textContent=a.firm||'the firm';
$('#firmwrap').hidden=false;
}
$('#prov').innerHTML=`Profile built from Designer/CRCP's own broker graph — agent resolved by name; current + past (closed/sold) listings from our records; <b>Firm listings</b> scraped directly from the firm's own website (openclaw + local model), attributed at the firm level. No aggregator (Crexi/LoopNet/etc.) data is used or shown.`;
// Private per-user notes on this agent (Steve 2026-08-19). Stable key: the broker DB id when we
// resolved one (names collide), else the name. updated_at drives the "last updated" stamp.
const noteKey='agent:'+(a.id!=null?a.id:('name:'+(NAME||ID||'unknown')));
if(window.CRCPNotes) CRCPNotes.mount({id:noteKey, title:'Private Notes', subject:a.name||NAME, mount:'#agentNotes'});
}).catch(()=>{ $('#hero').innerHTML=`<div class="who"><h2>${esc(NAME)}</h2><div class="meta">Could not load this agent.</div></div>`; });
</script>
<script src="/agent-notes.js" defer></script>
<script src="/corner-nav.js" defer></script>
<script src="/crcp-theme.js" defer></script>
<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body></html>