← 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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=&lt;brokerId&gt;</code> or <code>broker.html?dre=&lt;license#&gt;</code> — e.g. from the <a href="/brokers.html">broker directory</a>.</div>';
})();
</script>
</body>
</html>