[object Object]

← 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,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
+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 →