← 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
M public/index.htmlA public/linkedin.htmlM scripts/serve.js
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 & 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,'&').replace(/</g,'<').replace(/"/g,'"');
+ 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 & 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 & 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 & 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,'&').replace(/</g,'<').replace(/"/g,'"');
+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 →