← back to Commercialrealestate
TK-10703 cycle7: linkedin → CRCP standard (List/Grid view toggle + always-on firm-confirmed phone/email via brokers/all id-join)
05883e8b15fc12b0343d152bef35292cf87f8191 · 2026-08-21 06:57:22 -0700 · Steve Abrams
Files touched
Diff
commit 05883e8b15fc12b0343d152bef35292cf87f8191
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Aug 21 06:57:22 2026 -0700
TK-10703 cycle7: linkedin → CRCP standard (List/Grid view toggle + always-on firm-confirmed phone/email via brokers/all id-join)
---
public/linkedin.html | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 75 insertions(+), 2 deletions(-)
diff --git a/public/linkedin.html b/public/linkedin.html
index e247f54..2358312 100644
--- a/public/linkedin.html
+++ b/public/linkedin.html
@@ -23,6 +23,10 @@
.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;}
+ /* Grid view — same cards, laid out as a responsive grid (view toggle, Steve grid-controls rule) */
+ .list.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px;}
+ .list.grid .row{flex-direction:column;align-items:flex-start;gap:7px;}
+ .list.grid .row .who{width:100%;}
.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;}
@@ -49,6 +53,13 @@
/* card-field rows (toggled by data-points) */
.row .dp{font-size:11px;color:var(--muted);white-space:nowrap;}
.row.clk{cursor:pointer;} .row.clk:hover{border-color:rgba(200,162,75,.5);}
+ /* ALWAYS-ON contact strip (Steve HARD RULE: phone + email on every people row, non-fabricated) */
+ .row .contact{display:flex;flex-wrap:wrap;gap:3px 12px;font-size:11.5px;white-space:nowrap;}
+ .row .contact a{color:var(--draft);text-decoration:none;} .row .contact a:hover{text-decoration:underline;}
+ .row .contact .none{color:var(--muted);}
+ #modal .mcontact{border:1px solid var(--line);border-radius:10px;padding:10px 12px;background:var(--panel2);margin:8px 0 6px;}
+ #modal .mcontact h4{margin:0 0 5px;font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--muted);}
+ #modal .mcontact a{color:var(--draft);text-decoration:none;margin-right:14px;} #modal .mcontact .none{color:var(--muted);}
/* detail modal — all fields */
#ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,.62);z-index:60;align-items:flex-start;justify-content:center;padding:44px 14px;overflow-y:auto;}
#ov.on{display:flex;}
@@ -83,6 +94,10 @@
<button data-f="followed">Followed</button>
</div>
<select id="sortsel" title="Sort the list"></select>
+ <div class="seg" id="viewseg" title="List / Grid view">
+ <button data-v="list" class="on">☰ List</button>
+ <button data-v="grid">▦ Grid</button>
+ </div>
<div style="position:relative">
<button class="dpbtn" id="dpbtn" title="Show / hide data points on each card">⚙ Data points</button>
<div class="dppop" id="dppop"></div>
@@ -103,6 +118,22 @@ let followed=new Set(JSON.parse(localStorage.getItem(FOLLOW_KEY)||'[]'));
let people=[], filt='all', q='', sortKey='listings', sortDir=-1;
const atype=t=>t==='residential'?'<span class="atype res">Residential</span>':'<span class="atype com">Commercial</span>';
const esc=s=>(s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/"/g,'"');
+const clean=v=>{const s=(v==null?'':String(v)).trim();return(!s||/^null/i.test(s))?null:s;};
+const weburl=w=>{w=clean(w);return w?(/^https?:\/\//i.test(w)?w:'http://'+w):null;};
+const bnorm=s=>String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'').replace(/(inc|llc|corp|corporation|company|co|realty|realestate|group|properties|partners|associates)$/,'');
+// ── firm-confirmed contact join (Steve HARD RULE + non-fabrication gate) ──
+// Authoritative source: /api/brokers/all, which shares the SAME broker `id` as /api/brokers/linkedin
+// (verified: 800/800 name-agreement, 0 mismatch) → id-join is a same-record match, not name-alone.
+// Fallback: name + firm agreement (never name alone). Never invent a phone/email.
+let CBYID={}, CBYNF={};
+function contactFor(p){
+ if(!p) return null;
+ const b=CBYID[String(p.id)];
+ if(b && (b.phone||b.email)) return b; // same-record id match (authoritative)
+ const nf=CBYNF[bnorm(p.name)+'|'+bnorm(p.firm)]; // name + firm agreement
+ if(nf && (nf.phone||nf.email)) return nf;
+ return null;
+}
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(); }
@@ -114,6 +145,8 @@ const FIELDS=[
{k:'listings',l:'Listings',t:'n'},
{k:'linkedin',l:'Profile',t:'prof'},
];
+// Type/Firm/Listings/Profile are card fields; phone+email render in the ALWAYS-ON .contact strip
+// (they are NOT user-hideable — Steve hard rule).
const DEFAULT_ON=new Set(['firm','listings']);
let VISF=(function(){let s={};try{s=JSON.parse(localStorage.getItem('liFields')||'{}');}catch(e){}return s;})();
function fVis(k){ if(k in VISF) return !!VISF[k]; return DEFAULT_ON.has(k); }
@@ -125,6 +158,13 @@ const dpFmt=(p,f)=>{
if(f.t==='prof') return p.linkedin?'has profile':'search only';
return v==null||v===''?'—':String(v);
};
+// ALWAYS-ON contact strip — phone + email, firm-confirmed (id/name+firm join), never fabricated.
+function contactStrip(p){
+ const c=contactFor(p);
+ const ph=c&&c.phone?`<a href="tel:${esc(String(c.phone).replace(/[^0-9+]/g,''))}" onclick="event.stopPropagation()">📞 ${esc(c.phone)}</a>`:'<span class="none">📞 via profile ↗</span>';
+ const em=c&&c.email?`<a href="mailto:${esc(c.email)}" onclick="event.stopPropagation()">✉️ ${esc(c.email)}</a>`:'<span class="none">✉️ via profile ↗</span>';
+ return `<span class="contact">${ph}${em}</span>`;
+}
function visible(){
let rows=people.filter(p=>{
@@ -137,7 +177,9 @@ function visible(){
// incl. nested objects, via JSON.stringify. Space = AND.
const terms=q.toLowerCase().split(/\s+/).filter(Boolean);
rows=rows.filter(p=>{
- const hay=Object.keys(p).map(k=>{const v=p[k];return v==null?'':(typeof v==='object'?JSON.stringify(v):String(v));}).join(' ').toLowerCase();
+ const c=contactFor(p);
+ const hay=(Object.keys(p).map(k=>{const v=p[k];return v==null?'':(typeof v==='object'?JSON.stringify(v):String(v));}).join(' ')
+ +' '+[c&&c.phone,c&&c.email,c&&c.name].filter(Boolean).join(' ')).toLowerCase();
return terms.every(t=>hay.includes(t));
});
}
@@ -166,6 +208,7 @@ function render(){
return `<div class="row clk${isF?' followed':''}" data-id="${esc(p.id)}">
<div class="who"><span class="nm">${esc(p.name)}</span>${atype(p.agent_type)}${firmLine}</div>
${dps}
+ ${contactStrip(p)}
${link}
<button class="fbtn${isF?' on':''}" onclick="event.stopPropagation();toggle('${p.id}')">${isF?'✓ Followed':'+ Follow'}</button>
</div>`;
@@ -187,14 +230,37 @@ function openDetail(id){
else v=esc(v);
return `<div class="litem"><span class="m">${esc(k)}</span><span>${v}</span></div>`;
}).join('');
+ const c=contactFor(p);
+ const cph=c&&c.phone?`<a href="tel:${esc(String(c.phone).replace(/[^0-9+]/g,''))}">📞 ${esc(c.phone)}</a>`:'<span class="none">📞 phone via profile ↗</span>';
+ const cem=c&&c.email?`<a href="mailto:${esc(c.email)}">✉️ ${esc(c.email)}</a>`:'<span class="none">✉️ email via profile ↗</span>';
+ const cweb=c&&c.website?`<a href="${esc(c.website)}" target="_blank" rel="noopener noreferrer">🌐 site</a>`:'';
+ const contactBlock=`<div class="mcontact"><h4>Contact${c&&c.name&&bnorm(c.name)!==bnorm(p.name)?` — ${esc(c.name)}`:''} (firm-confirmed)</h4><div>${cph}${cem}${cweb}</div></div>`;
mb.innerHTML=`<h3>${esc(p.name)}${atype(p.agent_type)}</h3>`
+`<div class="mfirm">${esc(p.firm||'—')}</div>`
+ +contactBlock
+rowsHtml
+`<div class="mact">${link}<button class="fbtn${isF?' on':''}" onclick="toggle('${p.id}');document.getElementById('ov').classList.remove('on')">${isF?'✓ Followed':'+ Follow'}</button></div>`;
}
+async function loadContacts(){
+ // Build the firm-confirmed contact index from /api/brokers/all (same broker ids as the linkedin feed).
+ try{
+ const d=await (await fetch('/api/brokers/all')).json();
+ const arr=(d&&(d.brokers||d.results||d))||[];
+ (Array.isArray(arr)?arr:[]).forEach(b=>{
+ const ph=clean(b.phone),em=clean(b.email);
+ if(!ph&&!em) return;
+ const rec={name:clean(b.name),firm:clean(b.firm),phone:ph,email:em,website:weburl(b.website)};
+ if(b.id!=null && !CBYID[String(b.id)]) CBYID[String(b.id)]=rec; // authoritative same-record id join
+ const k=bnorm(b.name)+'|'+bnorm(b.firm); // name + firm agreement fallback
+ if(k.length>2 && !CBYNF[k]) CBYNF[k]=rec;
+ });
+ }catch(e){ /* contacts optional — rows still render with the via-profile fallback */ }
+}
async function load(){
$('#count').textContent='loading…';
- try{ const d=await (await fetch('/api/brokers/linkedin?limit=800')).json();
+ try{
+ await loadContacts();
+ const d=await (await fetch('/api/brokers/linkedin?limit=800')).json();
people=d.people||[]; render();
}catch(e){ $('#list').innerHTML='<div class="empty">failed to load — is the server up? '+e.message+'</div>'; }
}
@@ -203,6 +269,13 @@ async function load(){
$('#q').oninput=()=>{ q=$('#q').value.trim(); render(); };
$('#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(); };
+// ── view toggle (List / Grid) — persisted (Steve grid-controls rule) ──
+(function(){
+ const seg=$('#viewseg'), list=$('#list');
+ const apply=v=>{ list.classList.toggle('grid',v==='grid'); document.querySelectorAll('#viewseg button').forEach(b=>b.classList.toggle('on',b.dataset.v===v)); };
+ let v=localStorage.getItem('liView')||'list'; apply(v);
+ seg.onclick=e=>{ const b=e.target.closest('button'); if(!b) return; v=b.dataset.v; localStorage.setItem('liView',v); apply(v); };
+})();
// ── sort <select> (Steve list-build rule: every grid gets a sort control) ──
(function(){
← cc4510a TK-10703 cycle7: direct-listings → CRCP standard (grid-contr
·
back to Commercialrealestate
·
TK-10703 cycle7: contrarian-gate fixes — robust address norm 1d2233c →