← back to Commercialrealestate
public/broker.html
296 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>
<script>/* credential-safe fetch (2026-08-18): resolve EVERY relative fetch (with or without a leading slash) against a credential-free base so creds embedded in the page URL (http://user:pass@host/...) don't poison document.baseURI and throw "Request cannot be constructed from a URL that includes credentials". */(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>
<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='11' text-anchor='middle' fill='%23c8a24b'%3E%24%3C/text%3E%3C/svg%3E">
<title>Broker Profile — CRCP</title>
<style>
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased}
a{color:var(--blue);text-decoration:none} a:hover{text-decoration:underline}
.wrap{max-width:1120px;margin:0 auto;padding:0 18px 60px}
.topbar{display:flex;align-items:center;gap:14px;padding:12px 18px;max-width:1120px;margin:0 auto}
.topbar a.back{color:var(--mut);font-size:13px}
/* ---- HERO ---- */
.hero{position:relative;border:1px solid var(--line);border-radius:16px;overflow:hidden;background:var(--card);margin-top:6px}
.hero .banner{height:132px;background:linear-gradient(120deg,#0f2740,#123a2b 60%,#2a2411);position:relative}
html[data-theme=light] .hero .banner{background:linear-gradient(120deg,#dfeafc,#e2f3ea 60%,#f5ecd4)}
.hero .body{padding:0 26px 22px;display:flex;gap:22px;flex-wrap:wrap;align-items:flex-end}
.avatar{width:118px;height:118px;border-radius:50%;margin-top:-58px;border:4px solid var(--card);display:flex;align-items:center;justify-content:center;font-size:40px;font-weight:700;color:#fff;letter-spacing:1px;box-shadow:0 6px 22px rgba(0,0,0,.28);flex:0 0 auto}
.hid{flex:1 1 300px;min-width:260px;padding-bottom:4px}
.hid h1{margin:8px 0 2px;font-size:27px;line-height:1.15;letter-spacing:-.3px}
.hid .title{color:var(--ink);font-weight:600;font-size:15px}
.hid .firm{color:var(--mut);font-size:14px;margin-top:2px}
.hid .loc{color:var(--mut);font-size:13px;margin-top:6px}
.badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:10px}
.badge{font-size:11.5px;padding:3px 9px;border-radius:999px;border:1px solid var(--line2);color:var(--mut);background:var(--stripe);white-space:nowrap}
.badge.acc{color:var(--acc);border-color:color-mix(in srgb,var(--acc) 45%,transparent)}
.badge.gold{color:var(--gold);border-color:color-mix(in srgb,var(--gold) 45%,transparent)}
.actions{display:flex;gap:8px;flex-wrap:wrap;align-self:flex-end;padding-bottom:4px}
.btn{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600;padding:8px 13px;border-radius:9px;border:1px solid var(--line2);color:var(--ink);background:var(--bg2);cursor:pointer}
.btn:hover{border-color:var(--blue);text-decoration:none}
.btn.primary{background:var(--acc);color:var(--onacc);border-color:var(--acc)}
.btn.primary:hover{filter:brightness(1.06)}
.btn[disabled]{opacity:.4;pointer-events:none}
/* ---- STATS ---- */
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:16px 0}
.stat{border:1px solid var(--line);border-radius:12px;background:var(--card);padding:14px 16px}
.stat .n{font-size:24px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:-.5px}
.stat .l{font-size:12px;color:var(--mut);margin-top:2px}
/* ---- LAYOUT ---- */
.cols{display:grid;grid-template-columns:1fr 320px;gap:20px;align-items:start}
.card{border:1px solid var(--line);border-radius:14px;background:var(--card);padding:18px 20px;margin-bottom:18px}
.card h2{margin:0 0 12px;font-size:15px;letter-spacing:.02em;text-transform:uppercase;color:var(--mut)}
.side{position:sticky;top:14px}
.kv{display:flex;gap:9px;align-items:flex-start;font-size:14px;padding:7px 0;border-top:1px solid var(--line)}
.kv:first-of-type{border-top:0}
.kv .ic{width:20px;flex:0 0 auto;text-align:center;opacity:.85}
.chips{display:flex;gap:7px;flex-wrap:wrap}
.chip{font-size:12.5px;padding:4px 11px;border-radius:8px;background:var(--stripe);border:1px solid var(--line);color:var(--ink)}
.bio{color:var(--ink);font-size:14.5px;line-height:1.6}
.indexed{font-size:11.5px;color:var(--mut);margin-top:10px}
/* ---- LISTINGS GRID (sort + density, per house rule) ---- */
.gridhead{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:12px}
.gridhead h2{margin:0}
.ctl{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--mut)}
.ctl select{background:var(--bg2);color:var(--ink);border:1px solid var(--line2);border-radius:7px;padding:5px 8px;font-size:12.5px}
.ctl input[type=range]{accent-color:var(--acc);width:96px}
.lgrid{display:grid;grid-template-columns:repeat(var(--cols,2),minmax(0,1fr));gap:12px}
.lcard{border:1px solid var(--line);border-radius:11px;background:var(--bg2);padding:13px 14px;display:flex;flex-direction:column;gap:6px;min-width:0}
.lcard .type{font-size:11px;color:var(--gold);text-transform:uppercase;letter-spacing:.04em}
.lcard .price{font-size:18px;font-weight:700;font-variant-numeric:tabular-nums}
.lcard .addr{font-size:13.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.lcard .city{font-size:12px;color:var(--mut)}
.lcard .go{font-size:12px;margin-top:2px}
.lcard .when{font-size:10.5px;color:var(--mut);margin-top:4px}
.empty{color:var(--mut);font-size:13.5px;padding:22px 4px}
.load{padding:60px 0;text-align:center;color:var(--mut)}
@media(max-width:820px){
.cols{grid-template-columns:1fr}
.side{position:static}
.stats{grid-template-columns:repeat(2,1fr)}
.lgrid{--cols:1 !important}
.hero .body{padding:0 18px 18px}
}
</style>
<link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so html[data-theme] wins -->
</head>
<body>
<div class="topbar"><a class="back" href="/brokers.html">← back to broker directory</a></div>
<div class="wrap" id="root"><div class="load">Loading broker profile…</div></div>
<script src="/agent-notes.js" defer></script>
<script src="/corner-nav.js" defer></script>
<script src="/crcp-theme.js" defer></script>
<script>
const esc = s => String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
const qs = new URLSearchParams(location.search);
const ID = qs.get('id') || qs.get('broker') || qs.get('dbId');
const DRE = qs.get('dre') || qs.get('license'); // condo / residential directories link by DRE#
const NM = qs.get('name') || ''; // optional display name for the not-found fallback
const fmtMoney = v => { const n=Number(String(v).replace(/[^0-9.]/g,'')); if(!n||!isFinite(n)) return null;
if(n>=1e6) return '$'+(n/1e6).toFixed(n>=1e7?0:1)+'M'; if(n>=1e3) return '$'+Math.round(n/1e3)+'K'; return '$'+n.toLocaleString(); };
const initials = name => (name||'?').split(/[\s,]+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
const hue = s => { let h=0; for(const c of (s||'')) h=(h*31+c.charCodeAt(0))>>>0; return h%360; };
const avatarBg = name => { const h=hue(name); return `linear-gradient(135deg,hsl(${h} 55% 40%),hsl(${(h+42)%360} 60% 30%))`; };
const fmtWhen = d => { const t=Date.parse(d); if(!t) return null;
return new Date(t).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); };
// State + prefs (localStorage-persisted, per house grid rule)
let LISTINGS = [];
const PREF = { sort: localStorage.getItem('brokerLxSort')||'priceDesc', cols: +(localStorage.getItem('brokerLxCols')||2) };
function normalizeListings(b){
const out=[];
(b.our_listings||[]).forEach(l=>out.push({ title:l.address, addr:l.address, city:l.city, state:l.state||'', price:l.price,
type:l.type||'Listing', url:l.address?('/mls.html?q='+encodeURIComponent(l.address)):null, own:true, when:l.created_at||l.first_seen }));
(b.other_listings||[]).forEach(l=>out.push({ title:l.title||l.address, addr:l.address||l.title, city:l.city, state:l.state||'',
price:l.price, type:l.asset_type||'Listing', url:l.url||null, own:false, when:l.created_at }));
// closed_listings — the broker's Crexi closed-deal book (sold price + sold date). Tagged `sold`.
(b.closed_listings||[]).forEach(l=>out.push({ title:l.address, addr:l.address, city:l.city, state:'',
price:l.sold_price, type:l.type||'Closed', url:null, own:false, sold:true, when:l.sold_date }));
return out;
}
function sortListings(arr,mode){
const p=x=>Number(String(x.price==null?'':x.price).replace(/[^0-9.]/g,''))||0;
const a=[...arr];
if(mode==='priceDesc') a.sort((x,y)=>p(y)-p(x));
else if(mode==='priceAsc') a.sort((x,y)=>p(x)-p(y));
else if(mode==='type') a.sort((x,y)=>String(x.type).localeCompare(String(y.type)));
else if(mode==='city') a.sort((x,y)=>String(x.city||'').localeCompare(String(y.city||'')));
return a;
}
function renderListings(){
const host=document.getElementById('lgrid'); if(!host) return;
host.style.setProperty('--cols',PREF.cols);
const arr=sortListings(LISTINGS,PREF.sort);
if(!arr.length){ host.outerHTML='<div class="empty" id="lgrid">No active listings indexed for this broker in the CRCP feed. (We index public Crexi + MLS listings; a broker may have listings not yet harvested.)</div>'; return; }
host.innerHTML=arr.map(l=>{
const price=fmtMoney(l.price); const when=fmtWhen(l.when);
const inner=`
<div class="type">${esc(l.type)}${l.sold?' <span style="background:#7a1f1f;color:#fff;font-size:9px;padding:1px 5px;border-radius:3px;vertical-align:middle">SOLD</span>':''}</div>
${price?`<div class="price">${l.sold?'Sold ':''}${price}</div>`:'<div class="price" style="color:var(--mut);font-size:14px">Price on request</div>'}
<div class="addr" title="${esc(l.addr)}">${esc(l.addr||'—')}</div>
<div class="city">${esc([l.city,l.state].filter(Boolean).join(', ')||'')}</div>
${l.url?`<div class="go"><a href="${esc(l.url)}"${l.own?'':' target="_blank" rel="noopener noreferrer"'}>${l.own?'View in CRCP →':'View on Crexi →'}</a></div>`:''}
${when?`<div class="when" title="${esc(l.when)}">${l.sold?'🔴 closed ':'🕓 indexed '}${when}</div>`:''}`;
return `<div class="lcard"${l.sold?' style="opacity:.82"':''}>${inner}</div>`;
}).join('');
}
function render(b){
const root=document.getElementById('root');
const cities=[...new Set(normalizeListings(b).map(l=>l.city).filter(Boolean))];
LISTINGS=normalizeListings(b);
const dealN = b.total_assets!=null ? Number(b.total_assets).toLocaleString() : '—';
const lic = b.license || b.dre_license || null;
const specs = b.specialties ? String(b.specialties).split(/[;,|]/).map(s=>s.trim()).filter(Boolean) : [];
const typeLabel = b.agent_type ? (b.agent_type[0].toUpperCase()+b.agent_type.slice(1)) : null;
const indexed = fmtWhen(b.enriched_at||b.created_at);
// Honest, field-derived summary — no invented awards/claims.
const bioParts=[];
bioParts.push(`<b>${esc(b.name)}</b> is a ${typeLabel?typeLabel.toLowerCase()+' ':''}real estate ${esc((b.title||'broker')).toLowerCase()}${b.firm?` with <b>${esc(b.firm)}</b>`:''}.`);
if(b.total_assets) bioParts.push(`The CRCP index associates <b>${dealN}</b> propert${b.total_assets==1?'y':'ies'} with this broker on Crexi${cities.length?`, across ${cities.length} market${cities.length>1?'s':''}: ${esc(cities.slice(0,6).join(', '))}${cities.length>6?'…':''}`:''}.`);
if(specs.length) bioParts.push(`Specialties: ${esc(specs.join(', '))}.`);
const contactRows=[
b.phone ?`<div class="kv"><span class="ic">📞</span><a href="tel:${esc(b.phone)}">${esc(b.phone)}</a></div>`:'',
b.email ?`<div class="kv"><span class="ic">✉️</span><a href="mailto:${esc(b.email)}">${esc(b.email)}</a></div>`:'',
b.website ?`<div class="kv"><span class="ic">🌐</span><a href="${esc(b.website)}" target="_blank" rel="noopener noreferrer">${esc(String(b.website).replace(/^https?:\/\//,'').replace(/\/$/,''))}</a></div>`:'',
b.linkedin?`<div class="kv"><span class="ic">💼</span><a href="${esc(b.linkedin)}" target="_blank" rel="noopener noreferrer">LinkedIn profile</a></div>`:'',
b.office_addr?`<div class="kv"><span class="ic">🏢</span><span>${esc(b.office_addr)}</span></div>`:''
// TK-10709: removed the "Crexi profile →" link-out — spec hard rule is ingest-and-display-as-ours,
// never link users out to Crexi/LoopNet on any CRCP page.
].filter(Boolean).join('') || '<div class="empty">No public contact info has been enriched for this broker yet.</div>';
const badges=[
typeLabel?`<span class="badge acc">${esc(typeLabel)}</span>`:'',
lic?`<span class="badge gold">DRE #${esc(lic)}</span>`:'',
b.state?`<span class="badge">${esc(b.state)}</span>`:'',
`<span class="badge">Data via Crexi</span>`
].filter(Boolean).join('');
const emailCta = b.email
? `<a class="btn primary" href="mailto:${esc(b.email)}?subject=${encodeURIComponent('Inquiry via CRCP — '+(b.name||''))}">✦ Request info</a>`
: `<span class="btn primary" disabled>✦ Request info</span>`;
root.innerHTML = `
<div class="hero">
<div class="banner"></div>
<div class="body">
<div class="avatar" style="background:${avatarBg(b.name)}">${esc(initials(b.name))}</div>
<div class="hid">
<h1>${esc(b.name||'Unknown broker')}</h1>
${b.title?`<div class="title">${esc(b.title)}</div>`:''}
${b.firm?`<div class="firm">🏛 ${esc(b.firm)}</div>`:''}
${b.office_addr?`<div class="loc">📍 ${esc(b.office_addr)}</div>`:''}
<div class="badges">${badges}</div>
</div>
<div class="actions">
${b.phone?`<a class="btn" href="tel:${esc(b.phone)}">📞 Call</a>`:''}
${b.email?`<a class="btn" href="mailto:${esc(b.email)}">✉️ Email</a>`:''}
${b.linkedin?`<a class="btn" href="${esc(b.linkedin)}" target="_blank" rel="noopener noreferrer">💼 LinkedIn</a>`:''}
${emailCta}
</div>
</div>
</div>
<div class="stats">
<div class="stat"><div class="n">${LISTINGS.length||'0'}</div><div class="l">Active listings (indexed)</div></div>
<div class="stat"><div class="n">${dealN}</div><div class="l">Properties on Crexi</div></div>
<div class="stat"><div class="n">${cities.length||'—'}</div><div class="l">Markets</div></div>
<div class="stat"><div class="n">${lic?'#'+esc(lic):(b.state||'—')}</div><div class="l">${lic?'License':'State'}</div></div>
</div>
<div class="cols">
<div class="main">
<div class="card">
<h2>About</h2>
<div class="bio">${bioParts.join(' ')}</div>
${specs.length?`<div class="chips" style="margin-top:12px">${specs.map(s=>`<span class="chip">${esc(s)}</span>`).join('')}</div>`:''}
</div>
<div class="card">
<div class="gridhead">
<h2>Active Listings</h2>
<div class="ctl">Sort
<select id="lsort">
<option value="priceDesc">Price ↓</option>
<option value="priceAsc">Price ↑</option>
<option value="type">Type A→Z</option>
<option value="city">City A→Z</option>
</select>
</div>
<div class="ctl">Density <input type="range" id="ldens" min="1" max="4" step="1" value="${PREF.cols}"></div>
${b.exported_at?`<div class="ctl" style="margin-left:auto" title="Listings are from the CRCP snapshot and refresh on its regen cadence — not live.">📅 as of ${new Date(b.exported_at).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'})}</div>`:''}
</div>
<div class="lgrid" id="lgrid"></div>
</div>
</div>
<div class="side">
<div class="card">
<h2>Contact</h2>
${contactRows}
${indexed?`<div class="indexed" title="${esc(b.enriched_at||b.created_at)}">🕓 Profile indexed ${indexed}</div>`:''}
<div class="indexed">Source: ${esc(b.source||'crexi')} · CRCP broker #${esc(b.id)}</div>
</div>
</div>
</div>
<div id="agentNotes"></div>`;
document.title = (b.name||'Broker')+' — CRCP Profile';
const sortSel=document.getElementById('lsort'); if(sortSel){ sortSel.value=PREF.sort; sortSel.onchange=()=>{PREF.sort=sortSel.value;localStorage.setItem('brokerLxSort',PREF.sort);renderListings();}; }
const dens=document.getElementById('ldens'); if(dens){ dens.oninput=()=>{PREF.cols=+dens.value;localStorage.setItem('brokerLxCols',PREF.cols);renderListings();}; }
renderListings();
// Private per-user notes on this broker (Steve 2026-08-19), keyed by the stable CRCP broker id.
if(window.CRCPNotes && b.id!=null) CRCPNotes.mount({id:'broker:'+b.id, title:'Private Notes', subject:b.name||('Broker #'+b.id), mount:'#agentNotes'});
}
function loadById(id){
const root=document.getElementById('root');
return fetch('/api/brokers/contact/'+encodeURIComponent(id))
.then(r=>{ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); })
.then(b=>{ if(!b||b.error||b.id==null){ throw new Error(b&&b.error||'broker not found'); } render(b); })
.catch(e=>{ root.innerHTML='<div class="empty">Could not load broker <b>'+esc(id)+'</b>: '+esc(e.message)+'.<br><a href="/brokers.html">← back to directory</a></div>'; });
}
// Graceful "not in the CRCP commercial index" state — shown when a ?dre= link (from the condo /
// residential directories) points at a license we haven't harvested as a Crexi commercial broker.
// Never a dead end: shows the name we were handed + a live CA DRE record link.
function renderNotInIndex(dre,name){
const root=document.getElementById('root'); const digits=String(dre||'').replace(/\D/g,'');
root.innerHTML='<div class="card" style="margin-top:14px">'
+'<h2>Not in the CRCP commercial index</h2>'
+'<div class="bio">'+(name?'<b>'+esc(name)+'</b> ':'This license ')+'isn’t in our Crexi-sourced commercial broker index yet'
+(digits?', so there’s no CRCP deal profile to show.':'.')+'</div>'
+(digits?'<div class="kv" style="margin-top:12px"><span class="ic">🪪</span><span>DRE #'+esc(digits)
+' · <a href="https://www2.dre.ca.gov/PublicASP/pplinfo.asp?License_id='+encodeURIComponent(digits)
+'" target="_blank" rel="noopener noreferrer">view the CA DRE license record →</a></span></div>':'')
+'<div class="indexed" style="margin-top:12px">CRCP indexes ~2,500 commercial brokers from Crexi. Residential / condo listing agents are matched by DRE# only when they also appear in that set.</div>'
+'<div style="margin-top:12px"><a href="/brokers.html">← back to broker directory</a></div></div>';
document.title=(name||'Broker')+' — not in CRCP index';
}
(function boot(){
const root=document.getElementById('root');
if(ID){ loadById(ID); return; }
if(DRE){
fetch('/api/brokers/by-dre/'+encodeURIComponent(String(DRE).replace(/\D/g,'')))
.then(r=>r.json())
.then(m=>{ if(m && m.id!=null) loadById(m.id); else renderNotInIndex(DRE,NM); })
.catch(()=>renderNotInIndex(DRE,NM));
return;
}
root.innerHTML='<div class="empty">No broker supplied. Open this page as <code>broker.html?id=<brokerId></code> or <code>broker.html?dre=<license#></code> — e.g. from the <a href="/brokers.html">broker directory</a>.</div>';
})();
</script>
</body>
</html>