[object Object]

← back to Commercialrealestate

CRE :9911 — front-page broker contacts (name/company/phone/email) + ready-pitch popup, Frank Arcstone demo, LinkedIn follow-tab

ee71adf49b20c885463a520f4a7f9f8b21422319 · 2026-06-30 13:34:17 -0700 · Steve Abrams

- index.html: self-contained Brokers & Agents panel above the deals grid — featured Frank Arcstone card (full contact + one-click pitch popup) + top-25 roster (name/company/phone/email), click any row -> contact modal with the ready-to-send Arcstone pitch (DRAFT; send stays George/Steve-gated). Header links: 👤 Brokers, 🔗 LinkedIn.
- linkedin.html: TOS-compliant follow-list for Frank — every broker/agent with stored LinkedIn profile or a people-search fallback URL, client-side followed-state, search + filter. Links only, no automation.
- serve.js: GET /api/brokers/linkedin (profile/search links + counts).
Frank pitch pre-generated (ready to send). Verified front page + tab 200, route + Frank contact/pitch OK.

Files touched

Diff

commit ee71adf49b20c885463a520f4a7f9f8b21422319
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 30 13:34:17 2026 -0700

    CRE :9911 — front-page broker contacts (name/company/phone/email) + ready-pitch popup, Frank Arcstone demo, LinkedIn follow-tab
    
    - index.html: self-contained Brokers & Agents panel above the deals grid — featured Frank Arcstone card (full contact + one-click pitch popup) + top-25 roster (name/company/phone/email), click any row -> contact modal with the ready-to-send Arcstone pitch (DRAFT; send stays George/Steve-gated). Header links: 👤 Brokers, 🔗 LinkedIn.
    - linkedin.html: TOS-compliant follow-list for Frank — every broker/agent with stored LinkedIn profile or a people-search fallback URL, client-side followed-state, search + filter. Links only, no automation.
    - serve.js: GET /api/brokers/linkedin (profile/search links + counts).
    Frank pitch pre-generated (ready to send). Verified front page + tab 200, route + Frank contact/pitch OK.
---
 public/index.html    |  90 ++++++++++++++++++++++++++++++++++++++++
 public/linkedin.html | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++
 scripts/serve.js     |  31 ++++++++++++++
 3 files changed, 236 insertions(+)

diff --git a/public/index.html b/public/index.html
index 58692dc..8d33dfb 100644
--- a/public/index.html
+++ b/public/index.html
@@ -130,6 +130,8 @@
   <a href="/brokers.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="Broker / agent relationship mind-map">🕸️ Broker map</a>
   <a href="/graphics.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="Broker / firm / warrantability charts">📊 Graphics</a>
   <a href="/condos.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="LA condos — FHA warrantability">🏢 Condos</a>
+  <a href="#brokers" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="Broker / agent contacts — name, company, phone, email + pitch">👤 Brokers</a>
+  <a href="/linkedin.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="LinkedIn follow-list for Frank">🔗 LinkedIn</a>
   <span class="seg" id="scenario">
     <button data-s="25" class="active">25% down</button>
     <button data-s="30">30% down</button>
@@ -187,6 +189,94 @@
 </aside>
 
 <main>
+  <!-- ===== Brokers & agents — front-page contacts (name / company / phone / email) + ready pitch ===== -->
+  <style>
+    #brokers{background:var(--card);border:1px solid var(--line);border-radius:13px;padding:14px 16px;margin-bottom:18px;}
+    #brokers h2{margin:0 0 2px;font-size:14px;text-transform:uppercase;letter-spacing:.6px;color:var(--warn);}
+    #brokers .bksub{color:var(--mut);font-size:12px;margin-bottom:12px;}
+    .bkfeat{display:flex;align-items:center;gap:14px;flex-wrap:wrap;background:linear-gradient(90deg,rgba(210,153,34,.10),var(--card));border:1px solid rgba(210,153,34,.4);border-radius:11px;padding:12px 14px;margin-bottom:12px;cursor:pointer;}
+    .bkfeat .star{font-size:12px;font-weight:700;color:var(--warn);text-transform:uppercase;letter-spacing:.5px;}
+    .bkfeat .nm{font-weight:700;font-size:16px;} .bkfeat .fm{color:var(--mut);font-size:12px;}
+    .bkfeat .ci{display:flex;gap:14px;flex-wrap:wrap;font-size:13px;margin-left:auto;}
+    .bkfeat .ci a{color:var(--blue);text-decoration:none;} .bkfeat .pill{background:var(--warn);color:#1a1205;border-radius:8px;padding:6px 11px;font-size:12px;font-weight:700;}
+    #bktbl{width:100%;border-collapse:collapse;font-size:13px;}
+    #bktbl td,#bktbl th{text-align:left;padding:7px 8px;border-top:1px solid var(--line);}
+    #bktbl th{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.4px;border-top:0;}
+    #bktbl tr.bkrow{cursor:pointer;} #bktbl tr.bkrow:hover td{background:rgba(88,166,255,.06);}
+    #bktbl a{color:var(--blue);text-decoration:none;} #bktbl .miss{color:#6e7681;}
+    .bkatype{display:inline-block;font-size:10px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;padding:1px 6px;border-radius:9px;margin-left:6px;}
+    .bkatype.res{background:rgba(88,166,255,.16);color:var(--blue);} .bkatype.com{background:rgba(210,153,34,.16);color:var(--warn);}
+    #bkov{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:60;align-items:flex-start;justify-content:center;padding:40px 14px;overflow-y:auto;}
+    #bkov.on{display:flex;}
+    #bkmodal{width:min(720px,96vw);background:var(--card);border:1px solid var(--line);border-radius:15px;padding:20px 22px;position:relative;}
+    #bkmodal h3{margin:0 0 2px;font-size:20px;} #bkmodal .mfirm{color:var(--warn);font-size:13px;}
+    #bkmodal .x{position:absolute;top:12px;right:14px;background:none;border:0;color:var(--mut);font-size:22px;cursor:pointer;}
+    #bkmodal .ci{display:flex;gap:16px;flex-wrap:wrap;margin:10px 0;font-size:14px;} #bkmodal .ci a{color:var(--blue);text-decoration:none;}
+    #bkmodal .sec{margin-top:14px;} #bkmodal .sec h4{margin:0 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);}
+    #bkmodal .litem{display:flex;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px solid var(--line);font-size:13px;} #bkmodal .litem .m{color:var(--mut);}
+    .bkbtn{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:7px 12px;font-size:13px;cursor:pointer;margin-right:8px;} .bkbtn:hover{border-color:var(--blue);}
+    .pitch{background:#0a0d13;border:1px solid var(--line);border-radius:10px;padding:12px;font-size:13px;white-space:pre-wrap;margin-top:10px;}
+    .pitch .subj{color:var(--warn);font-weight:700;margin-bottom:6px;}
+    .gate{font-size:12px;color:var(--mut);margin-top:8px;}
+  </style>
+  <section id="brokers">
+    <h2>👤 Brokers &amp; Agents</h2>
+    <div class="bksub">Everyone connected to these properties — <b>name, company, phone, email</b>. Click anyone for full contact + a ready-to-send Arcstone pitch.</div>
+    <div id="bkfeat"></div>
+    <table id="bktbl"><thead><tr><th>Name</th><th>Company</th><th>Phone</th><th>Email</th><th style="text-align:right">Listings</th></tr></thead><tbody id="bkbody"></tbody></table>
+  </section>
+  <div id="bkov"><div id="bkmodal"><button class="x" aria-label="Close" onclick="document.getElementById('bkov').classList.remove('on')">✕</button><div id="bkmbody"></div></div></div>
+  <script>
+  (function(){
+    const bq=s=>document.querySelector(s);
+    const money=n=>n?'$'+Number(n).toLocaleString():'—';
+    const esc=s=>(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
+    const atype=t=>t==='residential'?'<span class="bkatype res">Residential</span>':'<span class="bkatype com">Commercial</span>';
+    const FRANK_ID=5356;
+    function renderPitch(p){ return '<div class="pitch">'+(p.subject?'<div class="subj">'+esc(p.subject)+'</div>':'')+esc(p.body)+'</div><div class="gate">⚑ DRAFT, ready to send — actual sending is George-gated + Steve-approved, not sent from here.</div>'; }
+    async function openContact(name,id){
+      const ov=bq('#bkov'), mb=bq('#bkmbody'); ov.classList.add('on'); mb.innerHTML='<div class="gate">loading '+esc(name)+'…</div>';
+      const qs=id?'id='+encodeURIComponent(id):'name='+encodeURIComponent(name);
+      let d; try{ d=await (await fetch('/api/broker?'+qs)).json(); }catch(e){ mb.innerHTML='<div class="gate">error</div>'; return; }
+      if(d.error){ mb.innerHTML='<div class="gate">'+esc(d.error)+'</div>'; return; }
+      const b=d.broker, w=b.website?(/^https?:/.test(b.website)?b.website:'https://'+b.website):null;
+      mb.innerHTML='<h3>'+esc(b.name)+atype(b.agent_type)+'</h3><div class="mfirm">'+esc(b.firm||'—')+(b.title?' · '+esc(b.title):'')+'</div>'
+        +'<div class="ci">'
+        +(b.phone?'<a href="tel:'+esc(b.phone)+'">☎ '+esc(b.phone)+'</a>':'<span class="gate">☎ —</span>')
+        +(b.email?'<a href="mailto:'+esc(b.email)+'">✉ '+esc(b.email)+'</a>':'<span class="gate">✉ —</span>')
+        +(w?'<a href="'+esc(w)+'" target="_blank" rel="noopener noreferrer">🌐 website</a>':'')+'</div>'
+        +'<div class="sec"><h4>Current listings ('+d.current.length+')</h4>'+(d.current.length?d.current.map(l=>'<div class="litem"><span>'+esc(l.address)+', '+esc(l.city)+'</span><span class="m">'+esc(l.type)+' · '+money(l.price)+(l.cap_rate?' · '+l.cap_rate+'% cap':'')+'</span></div>').join(''):'<div class="gate">none in our set</div>')+'</div>'
+        +'<div class="sec"><h4>Arcstone pitch <span class="gate">(ready to send)</span></h4>'
+        +'<button class="bkbtn" id="bkgen">✉ Generate / refresh pitch</button>'
+        +'<div id="bkpitch">'+(d.pitches&&d.pitches.length?renderPitch(d.pitches[0]):'<div class="gate">no draft yet — click Generate</div>')+'</div></div>';
+      bq('#bkgen').onclick=()=>genPitch(b.name,b.id);
+    }
+    async function genPitch(name,id){
+      const out=bq('#bkpitch'); out.innerHTML='<div class="gate">drafting…</div>';
+      try{ const r=await (await fetch('/api/broker/pitch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,id,channel:'email'})})).json();
+        out.innerHTML=r.error?('<div class="gate">'+esc(r.error)+'</div>'):renderPitch(r.pitch);
+      }catch(e){ out.innerHTML='<div class="gate">'+esc(e.message)+'</div>'; }
+    }
+    bq('#bkov').addEventListener('click',e=>{ if(e.target.id==='bkov') e.currentTarget.classList.remove('on'); });
+    // Featured demo body: Frank Arcstone — full name/company/phone/email + one-click pitch popup.
+    fetch('/api/broker?id='+FRANK_ID).then(r=>r.json()).then(d=>{
+      if(!d||!d.broker) return; const b=d.broker;
+      bq('#bkfeat').innerHTML='<div class="bkfeat" id="bkfeatcard"><div><div class="star">★ Test body</div><div class="nm">'+esc(b.name)+atype(b.agent_type)+'</div><div class="fm">'+esc(b.firm||'')+(b.title?' · '+esc(b.title):'')+'</div></div>'
+        +'<div class="ci">'+(b.phone?'<a href="tel:'+esc(b.phone)+'">☎ '+esc(b.phone)+'</a>':'')+(b.email?'<a href="mailto:'+esc(b.email)+'">✉ '+esc(b.email)+'</a>':'')+'<span class="pill">View pitch →</span></div></div>';
+      bq('#bkfeatcard').onclick=()=>openContact(b.name,b.id);
+    }).catch(()=>{});
+    // Contact roster: name / company / phone / email for the most-connected brokers.
+    fetch('/api/crcp/stats').then(r=>r.json()).then(s=>{
+      const rows=(s.topBrokers||[]).filter(b=>b.id!==FRANK_ID).slice(0,25);
+      bq('#bkbody').innerHTML=rows.map(b=>'<tr class="bkrow" data-id="'+b.id+'" data-name="'+esc(b.name)+'">'
+        +'<td>'+esc(b.name)+atype(b.agent_type)+'</td><td style="color:var(--mut)">'+esc(b.firm||'—')+'</td>'
+        +'<td>'+(b.phone?'<a href="tel:'+esc(b.phone)+'">'+esc(b.phone)+'</a>':'<span class="miss">—</span>')+'</td>'
+        +'<td>'+(b.email?'<a href="mailto:'+esc(b.email)+'">'+esc(b.email)+'</a>':'<span class="miss">—</span>')+'</td>'
+        +'<td style="text-align:right;color:var(--acc);font-weight:600">'+(b.listings||0)+'</td></tr>').join('');
+      bq('#bkbody').onclick=e=>{ const tr=e.target.closest('.bkrow'); if(!tr||e.target.tagName==='A') return; openContact(tr.dataset.name,tr.dataset.id); };
+    }).catch(()=>{ bq('#bkbody').innerHTML='<tr><td colspan="5" class="gate">broker DB unavailable</td></tr>'; });
+  })();
+  </script>
   <div class="bar">
     <span class="count" id="count">…</span>
     <div class="right">
diff --git a/public/linkedin.html b/public/linkedin.html
new file mode 100644
index 0000000..7f5bc91
--- /dev/null
+++ b/public/linkedin.html
@@ -0,0 +1,115 @@
+<!doctype html>
+<html lang="en">
+<head>
+<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='%230a66c2'%3Ein%3C/text%3E%3C/svg%3E">
+<title>LinkedIn — Frank's follow list</title>
+<style>
+  :root{ --bg:#0c0f15; --panel:#141925; --panel2:#1d2230; --fg:#E6E8EC; --muted:#8A93A2; --gold:#C8A24B;
+         --active:#70AD47; --draft:#5B9BD5; --in:#0a66c2; --line:#232a38; --del:#E74C3C; }
+  *{box-sizing:border-box;} body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;}
+  header{display:flex;align-items:center;gap:14px;padding:14px 22px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#10141d,#0c0f15);position:sticky;top:0;z-index:10;}
+  header h1{margin:0;font-size:18px;letter-spacing:.5px;} header h1 b{color:var(--in);}
+  header .nav{margin-left:auto;display:flex;gap:14px;} header .nav a{color:var(--draft);text-decoration:none;font-size:13px;} header .nav a:hover{color:var(--gold);}
+  .wrap{padding:18px 22px 50px;max-width:1100px;margin:0 auto;}
+  .intro{color:var(--muted);font-size:13px;margin-bottom:14px;border-left:2px solid var(--in);padding-left:10px;}
+  .bar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:14px;}
+  .bar input[type=text]{flex:1;min-width:220px;background:var(--panel2);border:1px solid var(--line);color:var(--fg);border-radius:9px;padding:9px 12px;font-size:14px;}
+  .seg{display:flex;border:1px solid var(--line);border-radius:9px;overflow:hidden;}
+  .seg button{background:var(--panel2);border:0;color:var(--muted);padding:8px 12px;font-size:12px;cursor:pointer;}
+  .seg button.on{background:var(--in);color:#fff;}
+  .count{font-size:12px;color:var(--muted);} .count b{color:var(--active);}
+  .list{display:flex;flex-direction:column;gap:8px;}
+  .row{display:flex;align-items:center;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:11px 14px;}
+  .row.followed{border-color:rgba(112,173,71,.5);background:linear-gradient(90deg,rgba(112,173,71,.07),var(--panel));}
+  .row .who{flex:1;min-width:0;}
+  .row .nm{font-weight:600;}
+  .row .fm{color:var(--muted);font-size:12px;}
+  .atype{display:inline-block;font-size:10px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;padding:1px 6px;border-radius:9px;margin-left:6px;vertical-align:middle;}
+  .atype.res{background:rgba(91,155,213,.16);color:var(--draft);border:1px solid rgba(91,155,213,.4);}
+  .atype.com{background:rgba(200,162,75,.14);color:var(--gold);border:1px solid rgba(200,162,75,.4);}
+  .lc{font-size:11px;color:var(--muted);white-space:nowrap;}
+  .open{display:inline-flex;align-items:center;gap:5px;background:var(--in);color:#fff;text-decoration:none;border-radius:8px;padding:7px 12px;font-size:12px;font-weight:600;white-space:nowrap;}
+  .open.srch{background:var(--panel2);color:var(--draft);border:1px solid var(--line);}
+  .open:hover{filter:brightness(1.1);}
+  .fbtn{background:var(--panel2);border:1px solid var(--line);color:var(--muted);border-radius:8px;padding:7px 11px;font-size:12px;cursor:pointer;white-space:nowrap;}
+  .fbtn.on{background:rgba(112,173,71,.16);border-color:var(--active);color:var(--active);}
+  .gate{margin-top:16px;font-size:12px;color:var(--muted);border-top:1px solid var(--line);padding-top:12px;}
+  .empty{color:var(--muted);padding:30px;text-align:center;}
+  @media(max-width:680px){ header{flex-wrap:wrap;} header .nav{flex-wrap:wrap;gap:10px;width:100%;} .row{flex-wrap:wrap;} .row .lc{order:5;width:100%;} }
+</style>
+</head>
+<body>
+<header>
+  <h1><b>in</b> Frank's Follow List</h1>
+  <div class="nav">
+    <a href="/" title="Investment Explorer">← Explorer</a>
+    <a href="/crcp.html" title="Control Panel">🛰️ CRCP</a>
+    <a href="/brokers.html" title="Broker map">🕸️ Broker map</a>
+  </div>
+</header>
+<div class="wrap">
+  <div class="intro">Every broker &amp; agent in the CRE graph, ready for <b>Frank Arcstone</b> to follow on LinkedIn.
+    Open each profile and follow manually — this list tracks who you've followed (saved in this browser).
+    <b>No automated following</b> — LinkedIn's TOS prohibits 3rd-party automation, so this is a links-only playbook.</div>
+  <div class="bar">
+    <input type="text" id="q" placeholder="search name or firm… e.g. Marcus &amp; Millichap, Luther">
+    <div class="seg" id="filt">
+      <button data-f="all" class="on">All</button>
+      <button data-f="profile">Has profile</button>
+      <button data-f="followed">Followed</button>
+    </div>
+    <div class="count" id="count">loading…</div>
+  </div>
+  <div class="list" id="list"></div>
+  <div class="gate">⚑ Manual &amp; TOS-compliant: profile/search links only; the followed state is stored locally for Frank.
+    Actual connecting/messaging happens in LinkedIn by hand (or via Sales Navigator) — never automated here.</div>
+</div>
+<script>
+const $=s=>document.querySelector(s);
+const FOLLOW_KEY='frank_li_followed';
+let followed=new Set(JSON.parse(localStorage.getItem(FOLLOW_KEY)||'[]'));
+let people=[], filt='all', q='';
+const atype=t=>t==='residential'?'<span class="atype res">Residential</span>':'<span class="atype com">Commercial</span>';
+const esc=s=>(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
+function saveFollowed(){ localStorage.setItem(FOLLOW_KEY, JSON.stringify([...followed])); }
+function toggle(id){ id=String(id); followed.has(id)?followed.delete(id):followed.add(id); saveFollowed(); render(); }
+function visible(){
+  return people.filter(p=>{
+    if(filt==='profile' && !p.linkedin) return false;
+    if(filt==='followed' && !followed.has(String(p.id))) return false;
+    return true;
+  });
+}
+function render(){
+  const rows=visible();
+  $('#count').innerHTML=`<b>${followed.size}</b> followed · ${rows.length} shown · ${people.length} total`;
+  if(!rows.length){ $('#list').innerHTML='<div class="empty">no matches</div>'; return; }
+  $('#list').innerHTML=rows.map(p=>{
+    const isF=followed.has(String(p.id));
+    const link=p.linkedin
+      ? `<a class="open" href="${esc(p.linkedin)}" target="_blank" rel="noopener noreferrer">in · Open profile</a>`
+      : `<a class="open srch" href="${esc(p.search)}" target="_blank" rel="noopener noreferrer">🔎 Find on LinkedIn</a>`;
+    return `<div class="row${isF?' followed':''}">
+      <div class="who"><span class="nm">${esc(p.name)}</span>${atype(p.agent_type)}<div class="fm">${esc(p.firm||'—')}</div></div>
+      <span class="lc">${p.listings||0} listing${p.listings===1?'':'s'}</span>
+      ${link}
+      <button class="fbtn${isF?' on':''}" onclick="toggle('${p.id}')">${isF?'✓ Followed':'+ Follow'}</button>
+    </div>`;
+  }).join('');
+}
+let t;
+async function load(){
+  $('#count').textContent='loading…';
+  try{ const d=await (await fetch('/api/brokers/linkedin?limit=800'+(q?'&q='+encodeURIComponent(q):''))).json();
+    people=d.people||[]; render();
+  }catch(e){ $('#list').innerHTML='<div class="empty">failed to load — is the server up? '+e.message+'</div>'; }
+}
+$('#q').oninput=()=>{ q=$('#q').value.trim(); clearTimeout(t); t=setTimeout(load,250); };
+$('#filt').onclick=e=>{ const b=e.target.closest('button'); if(!b) return;
+  document.querySelectorAll('#filt button').forEach(x=>x.classList.toggle('on',x===b)); filt=b.dataset.f; render(); };
+load();
+</script>
+</body>
+</html>
diff --git a/scripts/serve.js b/scripts/serve.js
index 0125ab6..c542a35 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -94,6 +94,37 @@ app.get('/api/brokers/enrich-stats', async (req, res) => {
   catch (e) { res.status(502).json({ error: String(e.message).split('\n')[0] }); }
 });
 
+// LinkedIn follow-list for Frank — every broker/agent with their stored LinkedIn profile, or a
+// LinkedIn people-search URL fallback when we don't have one. MANUAL + TOS-compliant by design:
+// returns profile/search LINKS only; the "followed" state is tracked client-side (localStorage).
+// No automated connecting/following — LinkedIn TOS prohibits 3rd-party automation (see linkedin-agent).
+app.get('/api/brokers/linkedin', async (req, res) => {
+  if (!brokerdb) return res.json({ people: [], unavailable: true });
+  try {
+    const term = String(req.query.q || '').trim();
+    const args = []; let where = '';
+    if (term) { args.push('%' + term + '%'); where = 'WHERE (b.name ILIKE $1 OR f.name ILIKE $1)'; }
+    const rows = (await brokerdb.pool.query(
+      `SELECT b.id, b.name, f.name firm, b.agent_type, b.linkedin,
+              (SELECT count(*) FROM broker_listing bl WHERE bl.broker_id=b.id)
+              + (SELECT count(*) FROM broker_condo bc WHERE bc.broker_id=b.id) AS listings
+         FROM broker b LEFT JOIN firm f ON f.id=b.firm_id ${where}
+         ORDER BY listings DESC NULLS LAST, b.name LIMIT $${args.length + 1}`,
+      [...args, Math.min(+(req.query.limit) || 500, 2000)])).rows;
+    const people = rows.map(b => ({
+      id: b.id, name: b.name, firm: b.firm, agent_type: b.agent_type, listings: b.listings,
+      linkedin: b.linkedin || null,
+      search: b.linkedin ? null
+        : 'https://www.linkedin.com/search/results/people/?keywords=' +
+          encodeURIComponent([b.name, b.firm].filter(Boolean).join(' '))
+    }));
+    res.json({
+      people, total: people.length, withProfile: people.filter(p => p.linkedin).length,
+      note: 'Manual follow list for Frank — open each profile and follow in LinkedIn. No automation (LinkedIn TOS).'
+    });
+  } catch (e) { res.status(502).json({ error: String(e.message).split('\n')[0], people: [] }); }
+});
+
 // Firm market-share: listings per firm (the broker graph's listing.firm_name). Powers the share chart.
 app.get('/api/firms/share', async (req, res) => {
   if (!brokerdb) return res.json([]);

← aafcd09 Assessor parcel roll: streaming Socrata/eGIS fetcher + cre.a  ·  back to Commercialrealestate  ·  auto-save: 2026-06-30T13:40:46 (1 files) — scripts/seed-fran 6d18e70 →