← back to Commercialrealestate
CRCP list sweep (round 2): deferred pages + Explorer flagship
add9ada94a20990dcb482e8e3a420075dd069f42 · 2026-07-31 09:01:36 -0700 · Steve Abrams
- index.html (Explorer flagship): primary ranked-properties List table gets the
full 7 controls (tight default, drag-reorder idxColOrder, toggles, expand,
data-no-sort click-sort, search-ALL-fields, Reset); the two secondary tables
(broker-detail mls, compare cmptbl) deliberately left untouched
- linkedin.html + licensed.html (cards): cards subset — sort, field-toggle,
start-tight, card-expand modal, search-all
- crcp.html: judged a HUB (not a browsable list); minimal change = made the
Call Segments result table click-sortable, no column machinery forced
Verified: all 4 parse clean + HTTP 200 + search-all on a clean serve.js boot.
Note: index List+Grid share one field-visibility set (cre_fields) by design.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Files touched
M public/crcp.htmlM public/index.htmlM public/licensed.htmlM public/linkedin.html
Diff
commit add9ada94a20990dcb482e8e3a420075dd069f42
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Jul 31 09:01:36 2026 -0700
CRCP list sweep (round 2): deferred pages + Explorer flagship
- index.html (Explorer flagship): primary ranked-properties List table gets the
full 7 controls (tight default, drag-reorder idxColOrder, toggles, expand,
data-no-sort click-sort, search-ALL-fields, Reset); the two secondary tables
(broker-detail mls, compare cmptbl) deliberately left untouched
- linkedin.html + licensed.html (cards): cards subset — sort, field-toggle,
start-tight, card-expand modal, search-all
- crcp.html: judged a HUB (not a browsable list); minimal change = made the
Call Segments result table click-sortable, no column machinery forced
Verified: all 4 parse clean + HTTP 200 + search-all on a clean serve.js boot.
Note: index List+Grid share one field-visibility set (cre_fields) by design.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
public/crcp.html | 28 ++++++++--
public/index.html | 107 ++++++++++++++++++++++++++++++++---
public/licensed.html | 113 +++++++++++++++++++++++++++++++++----
public/linkedin.html | 155 +++++++++++++++++++++++++++++++++++++++++++++++----
4 files changed, 370 insertions(+), 33 deletions(-)
diff --git a/public/crcp.html b/public/crcp.html
index db70931..afe4b7a 100644
--- a/public/crcp.html
+++ b/public/crcp.html
@@ -95,7 +95,7 @@
<div class="wrap">
<div class="tiles" id="tiles"></div>
<div class="panel"><h3>📞 Call Segments — prospects by property category <span class="c">click a segment for the call list</span></h3>
- <table id="segTbl"><thead><tr><th>Segment</th><th>Category / note</th><th class="n">Properties</th><th class="n">Price range (med)</th><th class="n">Agents to call</th></tr></thead><tbody></tbody></table>
+ <table id="segTbl"><thead><tr><th class="srt" data-col="label">Segment</th><th class="srt" data-col="product">Category / note</th><th class="srt n" data-col="props">Properties</th><th class="srt n" data-col="priceMed">Price range (med)</th><th class="srt n" data-col="agents">Agents to call</th></tr></thead><tbody></tbody></table>
</div>
<div class="cols">
<div>
@@ -177,17 +177,37 @@ const LEAD_SOURCES=[
['⚖ Compliance','ECOA/fair-lending (no protected-class targeting), TCPA (no cold calls/texts w/o consent), CAN-SPAM, NMLS disclosure. Inbound/referral only — never scrape/buy a consumer list.']
];
function leadSourcesHtml(){ return `<div class="sec"><h4>Lead sources — finding self-employed buyers (compliant)</h4>${LEAD_SOURCES.map(([t,d])=>`<div style="margin-bottom:8px"><b style="font-size:13px">${t}</b><div class="meta" style="margin-top:2px">${d}</div></div>`).join('')}</div>`; }
-async function loadSegments(){
- let d; try{ d=await (await fetch('/api/segments')).json(); }catch(e){ return; }
+// Segments are a fixed summary/lookup set (a handful of rows), NOT a browsable catalog —
+// so no column machinery, just page-owned click-to-sort on the multi-row result table
+// (Steve list-build rule: any multi-row result table must be click-sortable). Segment
+// rows stay click-to-drill; empty/null values always sink to the bottom.
+let SEGMENTS=[], segSort={col:null,dir:-1};
+function renderSegments(){
const kM=n=>n==null?null:(n>=1e6?'$'+(n/1e6).toFixed(n>=1e7?0:1)+'M':'$'+Math.round(n/1e3)+'k');
const priceCell=g=>{ if(g.priceMed==null) return '<span style="color:var(--muted)">—</span>';
const range=(g.priceMin!=null&&g.priceMax!=null&&g.priceMax>g.priceMin)?`<div class="meta" style="margin:0">${kM(g.priceMin)}–${kM(g.priceMax)}</div>`:'';
return `<b style="color:var(--gold)">${kM(g.priceMed)}</b>${range}`; };
- $('#segTbl tbody').innerHTML=(d.segments||[]).map(g=>`<tr class="srow" data-key="${g.key}" style="cursor:${g.props?'pointer':'default'}${g.props?'':';opacity:.5'}">
+ let rows=SEGMENTS.slice();
+ if(segSort.col){ const c=segSort.col, dir=segSort.dir;
+ rows.sort((a,b)=>{ let x=a[c], y=b[c];
+ const xE=(x==null||x===''), yE=(y==null||y===''); if(xE&&yE) return 0; if(xE) return 1; if(yE) return -1;
+ const xn=!isNaN(+x)&&x!=='', yn=!isNaN(+y)&&y!=='';
+ if(xn&&yn) return dir*((+x)-(+y));
+ return dir*String(x).toLowerCase().localeCompare(String(y).toLowerCase()); }); }
+ $('#segTbl tbody').innerHTML=rows.map(g=>`<tr class="srow" data-key="${g.key}" style="cursor:${g.props?'pointer':'default'}${g.props?'':';opacity:.5'}">
<td><b>${g.label}</b></td><td style="color:var(--muted)">${g.product}</td>
<td class="n">${(g.props||0).toLocaleString()}</td><td class="n">${priceCell(g)}</td><td class="n" style="color:var(--draft)">${g.agents!=null?g.agents.toLocaleString():'—'}</td></tr>`).join('');
document.querySelectorAll('.srow').forEach(r=>r.onclick=()=>openSegment(r.dataset.key));
+ markHdr('segTbl',segSort);
+}
+async function loadSegments(){
+ let d; try{ d=await (await fetch('/api/segments')).json(); }catch(e){ return; }
+ SEGMENTS=d.segments||[]; renderSegments();
}
+// wire segment header sort once (page-owned, same pattern as brTbl/fmTbl)
+document.querySelectorAll('#segTbl th.srt').forEach(th=>th.onclick=()=>{ const c=th.dataset.col;
+ if(segSort.col===c) segSort.dir*=-1; else { segSort.col=c; segSort.dir=(th.classList.contains('n'))?-1:1; }
+ renderSegments(); });
async function openSegment(key){
const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on'); mb.innerHTML='<div class="meta">loading…</div>';
let d; try{ d=await (await fetch('/api/segment?key='+encodeURIComponent(key))).json(); }catch(e){ mb.innerHTML='error'; return; }
diff --git a/public/index.html b/public/index.html
index 44c3ac4..50a3214 100644
--- a/public/index.html
+++ b/public/index.html
@@ -174,6 +174,11 @@
.listtbl td.stick, .listtbl th.stick { text-align:left; position:sticky; left:0; background:var(--card); }
.listtbl th.stick { background:#0b0e13; z-index:2; }
.listtbl th.sortable { cursor:pointer; } .listtbl th.sortable:hover { color:var(--ink); }
+ /* draggable column reorder on the PRIMARY list table (Steve list-build rule) */
+ .listtbl th.dragcol { cursor:grab; }
+ .listtbl th.dragging { opacity:.45; }
+ .listtbl th.dropL { box-shadow:inset 3px 0 0 var(--blue); }
+ .listtbl th.dropR { box-shadow:inset -3px 0 0 var(--blue); }
.listtbl tr.lrow:hover td { background:rgba(88,166,255,.06); }
.listtbl tr.lrow:hover td.stick { background:#1b222c; }
.listtbl .la { font-weight:600; color:var(--ink); text-decoration:none; }
@@ -438,8 +443,8 @@
</div>
<div class="fsec"><h4>Fields — show / hide</h4>
<div class="fieldtoggles" id="fFields"></div>
- <div style="margin-top:8px"><button class="minibtn" id="fAll">All</button><button class="minibtn" id="fNone">None</button><button class="minibtn" id="fDef">Default</button></div>
- <div class="small" style="margin-top:6px">▤ = also a column in List view</div>
+ <div style="margin-top:8px"><button class="minibtn" id="fAll">All</button><button class="minibtn" id="fNone">None</button><button class="minibtn" id="fDef">Default</button><button class="minibtn" id="idxReset" title="Reset List columns to the tight default — order, widths & visibility">↺ Reset layout</button></div>
+ <div class="small" style="margin-top:6px">▤ = also a column in List view · drag a List header to reorder · drag its right edge to resize</div>
</div>
<button class="resetbtn" id="reset">Reset all filters</button>
</aside>
@@ -604,6 +609,7 @@
<button data-view="list">☰ List</button>
</span>
<div class="right">
+ <input id="idxSearch" type="text" placeholder="🔎 search all fields — address, firm, agent, type… (space = AND)" style="background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 11px;font-size:12.5px;min-width:230px" title="Filters across EVERY field on each listing (nested-safe). Space = AND.">
<button id="shortbtn" class="ex" title="Show only your shortlisted (starred) listings">⭐ Shortlist (0)</button>
<button id="cmpbtn" class="ex" title="Compare your shortlisted listings side by side">⚖ Compare</button>
<button id="csvbtn" class="ex" title="Download the current filtered + sorted view (visible columns) as CSV">⬇ CSV</button>
@@ -799,7 +805,30 @@ function grm(p){ const r=rentEstimate(p); if(!r||!r.std) return null; const annu
function daysOnMarket(p){ const ld=listingDate(p); if(!ld||!ld.exact) return null; const d=Math.round((Date.now()-ld.d.getTime())/86400000); return d>=0?d:null; }
function shortStatus(p){ if(/Under Contract|Pending|Escrow/i.test(p.status)) return p.status; if(/Sold|Closed/i.test(p.status)) return 'Sold'; if(/OUT OF BUDGET/i.test(p.status)) return 'Over budget'; return 'Active'; }
// ---- list-view renderer ----
-function listCols(){ return FIELDS.filter(f=>f.col && vis(f.k)); }
+// ── PRIMARY-TABLE column ORDER (Steve list-build rule: draggable + persisted per-browser) ──
+// COLORDER is the display order over the col-eligible FIELDS keys; new/removed keys are
+// reconciled on every read. Persisted as idxColOrder. The always-first "Property" (addr)
+// stick column is NOT part of COLORDER — it's a fixed anchor, reordering happens among the rest.
+let COLORDER=(function(){ let a=[]; try{ a=JSON.parse(localStorage.getItem('idxColOrder')||'[]'); }catch(e){} return Array.isArray(a)?a:[]; })();
+function colKeys(){ return FIELDS.filter(f=>f.col).map(f=>f.k); } // every col-eligible field key
+function syncColOrder(){ const keys=colKeys();
+ COLORDER=COLORDER.filter(k=>keys.includes(k)); // prune removed
+ keys.forEach(k=>{ if(!COLORDER.includes(k)) COLORDER.push(k); }); // append new
+}
+function saveColOrder(){ try{ localStorage.setItem('idxColOrder',JSON.stringify(COLORDER)); }catch(e){} }
+// Ordered, col-eligible FIELDS in the user's chosen order.
+function orderedCols(){ syncColOrder(); return COLORDER.map(k=>FIELDS.find(f=>f.k===k)).filter(Boolean); }
+// The visible List columns, in order — drives the header + body + resize signature.
+function listCols(){ return orderedCols().filter(f=>vis(f.k)); }
+// Column-set/order signature guard: when it changes, hand the table back to col-resize.js so
+// it rebuilds its <colgroup> + drag handles for the current columns (Steve list-build rule 1).
+let lastColSig='';
+function reinitResize(){
+ const tbl=document.querySelector('#grid .listtbl'); if(!tbl) return;
+ const cg=tbl.querySelector('colgroup[data-cr]'); if(cg) cg.remove();
+ delete tbl.dataset.crDone; tbl.style.tableLayout=''; tbl.style.width='';
+ if(window.ColResize && window.ColResize.refresh) window.ColResize.refresh();
+}
function colCell(k,p){ const f=p.finance||{};
switch(k){
case 'score': return p.composite??'—';
@@ -840,11 +869,12 @@ function colCell(k,p){ const f=p.finance||{};
function renderList(rows){
const cols=listCols();
const arrow=k=>colSort&&colSort.key===k?(colSort.dir>0?' ▲':' ▼'):'';
- const head='<th scope="col" class="stick sortable" data-sortk="addr" title="Sort by address">Property'+arrow('addr')+'</th>'+cols.map(c=>`<th scope="col" class="sortable" data-sortk="${c.k}" title="Sort by ${esc(c.label)}">${esc(c.label)}${arrow(c.k)}</th>`).join('');
+ // "Property" (addr) stays a fixed anchor; the rest are draggable to reorder + click to sort.
+ const head='<th scope="col" class="stick sortable" data-sortk="addr" title="Sort by address">Property'+arrow('addr')+'</th>'+cols.map(c=>`<th scope="col" class="sortable dragcol" draggable="true" data-sortk="${c.k}" data-k="${c.k}" title="Drag to reorder · click to sort by ${esc(c.label)}">${esc(c.label)}${arrow(c.k)}</th>`).join('');
const body=rows.map(p=>`<tr class="lrow" data-id="${p.id}" title="Click to open the agent window (contact · letters · find · comps · history)">`
+`<td class="stick"><span class="exptog" aria-hidden="true" title="Open agent window">⧉</span><button class="starbtn ${SHORT.has(p.id)?'on':''}" data-star="${p.id}" aria-label="${SHORT.has(p.id)?'Remove from':'Add to'} shortlist" aria-pressed="${SHORT.has(p.id)}" title="Shortlist">★</button> <span class="la">${esc(p.address)}</span><div class="lc">${esc(p.city)}, CA ${p.zip||''}</div></td>`
+cols.map(c=>`<td>${colCell(c.k,p)}</td>`).join('')+`</tr>`).join('');
- return `<div class="listwrap"><table class="listtbl"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
+ return `<div class="listwrap"><table class="listtbl" data-no-sort><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
}
// The inline panel that opens under a list row: deep property/agent searches + the agent CRM
// (editable contact info, mark-contacted, saved-letter history, compose). Reuses the same
@@ -938,9 +968,32 @@ function loopnetLink(p){
const F = { types:new Set(), firms:new Set(), cityQuery:'', priceMin:null, priceMax:null,
capMin:0, unitsMin:null, yearMin:null, status:'all', afford:false, unwarrantable:false, nonqm:false, shortlist:false };
+// ── SEARCH ALL DATA (Steve list-build rule 7) ──────────────────────────────────
+// Free-text search across EVERY field on a listing — not just the visible/defined columns,
+// nested objects included (finance/qwen/deal…) — via JSON.stringify. Space = AND.
+// A per-row haystack is memoized on `p.__idxHay` and invalidated when the row identity changes.
+let idxQuery='';
+function idxHay(p){
+ if(p.__idxHay!=null) return p.__idxHay;
+ let s;
+ try{ s=JSON.stringify(p, (k,v)=> k==='__idxHay'?undefined:v); }
+ catch(e){ s=Object.keys(p).map(k=>{ const v=p[k]; return v==null?'':(typeof v==='object'?'':String(v)); }).join(' '); }
+ // fold in the CRM-saved contact (agent name/phone/email) which lives outside the row object
+ const c=window.CONTACTS&&window.CONTACTS[p.id]; if(c) s+=' '+JSON.stringify(c);
+ s=s.toLowerCase();
+ try{ Object.defineProperty(p,'__idxHay',{value:s,enumerable:false,configurable:true,writable:true}); }catch(_){ p.__idxHay=s; }
+ return s;
+}
+function idxSearchPass(p){
+ if(!idxQuery) return true;
+ const hay=idxHay(p);
+ return idxQuery.split(/\s+/).filter(Boolean).every(t=>hay.includes(t));
+}
+
function recompute(){
const A = SCEN[scenario];
DATA.ranked.forEach(p=>{
+ try{ delete p.__idxHay; }catch(_){} // finance/deal recomputed → refresh the search haystack
p.finance = CREFin.finance(p, A);
const fsc = CREFin.financeScore(p, p.finance);
p.financeScore = fsc.score; p.financeConf = fsc.conf;
@@ -1166,6 +1219,7 @@ function enrichAssessor(){
// ---- the rich filter pass ----
function applyFilters(list){
return list.filter(p=>{
+ if(!idxSearchPass(p)) return false;
if(F.types.size && !F.types.has(p.type)) return false;
if(F.firms.size && !F.firms.has(firmLabel(p))) return false;
if(F.cityQuery){ const hay=((p.city||'')+' '+(p.address||'')).toLowerCase(); if(!hay.includes(F.cityQuery)) return false; }
@@ -1184,6 +1238,7 @@ function applyFilters(list){
}
// Match everything EXCEPT one facet dimension — powers live facet counts (Amazon-style narrowing).
function passExcept(p, skip){
+ if(!idxSearchPass(p)) return false;
if(skip!=='type' && F.types.size && !F.types.has(p.type)) return false;
if(skip!=='firm' && F.firms.size && !F.firms.has(firmLabel(p))) return false;
if(skip!=='city' && F.cityQuery){ const hay=((p.city||'')+' '+(p.address||'')).toLowerCase(); if(!hay.includes(F.cityQuery)) return false; }
@@ -1363,7 +1418,11 @@ function moreBtn(){ return _shown<_rows.length ? `<div class="showmore-wrap"><bu
function paintPage(){
const g=$('#grid'); const r=_rows; const slice=r.slice(0,_shown);
if(!r.length){ g.className='grid'; g.innerHTML=`<div class="empty">No properties match these filters.<br><span class="small">Loosen a filter or hit “Reset all”.</span></div>`; return; }
- if(view==='list'){ g.className='listhost'; g.innerHTML=renderList(slice)+moreBtn(); }
+ if(view==='list'){ g.className='listhost'; g.innerHTML=renderList(slice)+moreBtn();
+ // When the visible column SET or ORDER changes, rebuild col-resize.js's colgroup + handles.
+ const csig=listCols().map(c=>c.k).join(',');
+ if(csig!==lastColSig){ lastColSig=csig; reinitResize(); }
+ }
else { g.className='grid'; g.innerHTML=slice.map(card).join('')+moreBtn(); enrichAssessor(); }
applyHeat(slice);
}
@@ -1423,7 +1482,8 @@ function render(){
{ const sb=$('#shortbtn'); if(sb){ sb.textContent='⭐ Shortlist ('+SHORT.size+')'; sb.classList.toggle('shorton',F.shortlist); } }
renderActiveBar(); stateToURL(); updateFacetCounts();
// Wait-for-selection: on a clean load (no filter chosen) don't dump 2,134 cards — prompt for a pick.
- if(fc===0 && !F.cityQuery){
+ // A free-text search counts as a selection, so typing in the search box shows results immediately.
+ if(fc===0 && !F.cityQuery && !idxQuery){
g.className='grid';
g.innerHTML=`<div class="empty">👈 Expand a filter tab on the left — or start with one of these:
<div class="quickstart">
@@ -1606,6 +1666,8 @@ function toast(msg){ let t=document.getElementById('crmToast'); if(!t){ t=docume
// expanded rows). Works for every asset type in either view.
function refreshContactUI(id){
const p=DATA.ranked.find(x=>x.id===id); if(!p) return;
+ try{ delete p.__idxHay; }catch(_){} // CRM changed → let the search haystack rebuild
+
const row=[...document.querySelectorAll('.listtbl tr.lrow')].find(r=>r.dataset.id===id);
if(row){ const cols=listCols(); const ai=cols.findIndex(c=>c.k==='agent');
if(ai>=0 && row.children[ai+1]) row.children[ai+1].innerHTML=colCell('agent',p);
@@ -1808,6 +1870,16 @@ fetch('data/ranked.json').then(r=>r.json()).then(async d=>{
$('#savebtn').onclick=()=>{ const name=(prompt('Name this saved search:')||'').trim(); if(!name) return; SAVED.push({name, qs:location.search||''}); saveSaved(); buildSavedList(); };
$('#linkbtn').onclick=copyShareLink;
$('#csvbtn').onclick=exportCSV;
+ // search-all-fields box (Steve list-build rule 7) — filters across EVERY field, space = AND
+ { const si=$('#idxSearch'); if(si) si.oninput=()=>{ idxQuery=si.value.trim().toLowerCase(); render(); }; }
+ // Reset the PRIMARY List table layout back to the tight default: column order, resize widths,
+ // and visibility. Clears idxColOrder + col-resize's cr:<pathname> widths + resets VIS to defaults.
+ { const rb=$('#idxReset'); if(rb) rb.onclick=()=>{
+ COLORDER=[]; saveColOrder();
+ try{ Object.keys(localStorage).forEach(k=>{ if(k.indexOf('cr:'+location.pathname+':')===0) localStorage.removeItem(k); }); }catch(e){}
+ FIELDS.forEach(f=>VIS[f.k]=!!f.def); saveVis();
+ lastColSig=''; buildFieldToggles(); render();
+ }; }
// facet chip handlers (multi-select toggles)
$('#fType').onclick=e=>{ const c=e.target.closest('.chip'); if(!c) return;
if(c.dataset.warr==='nonqm'){ F.nonqm=!F.nonqm; syncChips(); render(); return; }
@@ -1857,6 +1929,27 @@ fetch('data/ranked.json').then(r=>r.json()).then(async d=>{
const si=e.target.closest('[data-saveinfo]'); if(si){ saveAgentInfo(si.dataset.saveinfo); return; }
const ld=e.target.closest('[data-letdel]'); if(ld){ deleteLetter(ld.dataset.letdel, ld.dataset.idx); return; }
const row=e.target.closest('.listtbl tr.lrow'); if(row && !e.target.closest('a,button,input,textarea,select,[data-firmfilter],[data-copyval]')){ openContact(row.dataset.id); return; } };
+ // ── drag-to-reorder PRIMARY List columns (Steve list-build rule 2) ──
+ // Delegated on #grid since the list table is re-rendered each render(). Property (addr) is a
+ // fixed anchor (no data-k) so it never participates as a drag source or drop target.
+ let idxDragKey=null;
+ $('#grid').addEventListener('dragstart',e=>{ const th=e.target.closest('.listtbl th.dragcol'); if(!th||!th.dataset.k) return;
+ if(e.target.closest && e.target.closest('.cr-handle')){ e.preventDefault(); return; } // that grab is a resize, not a move
+ idxDragKey=th.dataset.k; e.dataTransfer.effectAllowed='move'; try{ e.dataTransfer.setData('text/plain',idxDragKey); }catch(_){}
+ th.classList.add('dragging'); });
+ $('#grid').addEventListener('dragover',e=>{ if(!idxDragKey) return; const th=e.target.closest('.listtbl th.dragcol'); if(!th||!th.dataset.k||th.dataset.k===idxDragKey) return;
+ e.preventDefault(); e.dataTransfer.dropEffect='move';
+ const r=th.getBoundingClientRect(), after=(e.clientX-r.left)>r.width/2;
+ th.classList.toggle('dropR',after); th.classList.toggle('dropL',!after); });
+ $('#grid').addEventListener('dragleave',e=>{ const th=e.target.closest('.listtbl th.dragcol'); if(th) th.classList.remove('dropL','dropR'); });
+ $('#grid').addEventListener('drop',e=>{ if(!idxDragKey) return; const th=e.target.closest('.listtbl th.dragcol'); if(!th||!th.dataset.k){ idxDragKey=null; return; }
+ e.preventDefault(); const tgt=th.dataset.k;
+ if(tgt!==idxDragKey){ const r=th.getBoundingClientRect(), after=(e.clientX-r.left)>r.width/2;
+ syncColOrder(); const from=COLORDER.indexOf(idxDragKey);
+ if(from>=0){ COLORDER.splice(from,1); let to=COLORDER.indexOf(tgt); if(after) to+=1; COLORDER.splice(to,0,idxDragKey);
+ saveColOrder(); lastColSig=''; render(); } }
+ idxDragKey=null; });
+ $('#grid').addEventListener('dragend',()=>{ document.querySelectorAll('.listtbl th.dragging,.listtbl th.dropL,.listtbl th.dropR').forEach(x=>x.classList.remove('dragging','dropL','dropR')); idxDragKey=null; });
$('#shortbtn').onclick=()=>{ F.shortlist=!F.shortlist; render(); };
$('#cmpbtn').onclick=openCompare;
$('#heat').value=heat; $('#heat').onchange=()=>{ heat=$('#heat').value; localStorage.setItem('cre_heat',heat); render(); };
diff --git a/public/licensed.html b/public/licensed.html
index d6725f2..fe2435b 100644
--- a/public/licensed.html
+++ b/public/licensed.html
@@ -33,6 +33,26 @@ input#q{min-width:230px}
.figc{font-size:11.5px;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.figm{font-size:10px;color:var(--mut);font-variant-numeric:tabular-nums}
.empty{color:var(--mut);padding:40px;text-align:center}
+/* data-point show/hide toggles (Steve list-build rule 2026-07-31) */
+.dpwrap{display:flex;align-items:center;gap:6px;position:relative}
+.dpbtn{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:6px 10px;font-size:12.5px;cursor:pointer}
+.dpbtn:hover{border-color:var(--blue)}
+.dppop{display:none;position:absolute;top:calc(100% + 6px);right:0;z-index:20;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:9px 11px;min-width:190px;box-shadow:0 8px 26px rgba(0,0,0,.5)}
+.dppop.on{display:block}
+.dppop .dphd{font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut);margin:0 0 6px}
+.dppop label{display:flex;align-items:center;gap:7px;font-size:12.5px;padding:3px 2px;cursor:pointer;color:var(--ink)}
+.dppop label:hover{color:var(--blue)}.dppop input{accent-color:var(--blue)}
+.dppop .dpmini{margin-top:8px;display:flex;gap:6px}
+.dppop .dpmini button{flex:1;background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:7px;padding:4px 0;font-size:11px;cursor:pointer}
+.dppop .dpmini button:hover{border-color:var(--blue);color:var(--ink)}
+.card{cursor:pointer}
+/* detail modal (card-click expand → 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}
+#modal{width:min(560px,96vw);background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px 22px;position:relative}
+#modal h3{margin:0 0 3px;font-size:19px}#modal .x{position:absolute;top:11px;right:14px;background:none;border:0;color:var(--mut);font-size:22px;cursor:pointer;line-height:1}
+#modal .mrow{display:flex;justify-content:space-between;gap:12px;padding:5px 0;border-bottom:1px solid var(--line);font-size:13px}
+#modal .mrow .mk{color:var(--mut);text-transform:capitalize}#modal .mrow .mv{text-align:right;word-break:break-word}
</style></head><body>
<header>
<div><h1>📋 Licensed Directory <span class="sub" id="scope"></span></h1></div>
@@ -45,15 +65,42 @@ input#q{min-width:230px}
<option value="recent">License newest</option>
</select>
<span class="rng">Density <input type="range" id="dens" min="2" max="7" value="4"></span>
+ <div class="dpwrap"><button class="dpbtn" id="dpBtn" title="Show / hide data points on the cards">⚙ Fields</button>
+ <div class="dppop" id="dpPop"><div class="dphd">Data points on cards</div><div id="dpList"></div>
+ <div class="dpmini"><button id="dpAll">All</button><button id="dpNone">None</button><button id="dpReset">Reset</button></div></div></div>
<a href="/" class="sub">← CRCP</a>
</div>
</header>
<div class="wrap"><div class="count" id="count">Loading…</div><div class="grid" id="grid"></div></div>
+<div id="ov"><div id="modal"><button class="x" aria-label="Close" onclick="document.getElementById('ov').classList.remove('on')">✕</button><div id="mbody"></div></div></div>
<script>
const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
const $=s=>document.querySelector(s);
let DATA={agents:[],firms:[],title:[],escrow:[]}, FIRM_IG={}, FIRM_IDX={}, CONTACTS={};
let tab='firms', sort=localStorage.getItem('ld_sort')||'name';
+// ── Data-point show/hide (Steve list-build rule 2026-07-31) ──
+// Cards render as key·value chips per enabled field. START TIGHT: only the essentials
+// show on first load; the rest are toggled on from the ⚙ Fields popout. Per-tab pref
+// persists under ldFields. Internal keys (id/kind/name) are never card data points.
+const SKIP_FIELDS=new Set(['id','kind','name','__row']);
+// Tight defaults per tab — a few fields only.
+const TIGHT_DEFAULTS={
+ firms:new Set(['agent_count','metro']),
+ agents:new Set(['firm','license_type','license_number','city']),
+ title:new Set(['underwriter']),
+ escrow:new Set(['underwriter']),
+};
+let VISFIELDS=(function(){let s={};try{s=JSON.parse(localStorage.getItem('ldFields')||'{}');}catch(e){}return s;})();
+function tabFieldKeys(){ // union of non-internal keys present in the current tab's rows
+ const seen=[],has=new Set();
+ (DATA[tab]||[]).slice(0,400).forEach(r=>Object.keys(r||{}).forEach(k=>{
+ if(SKIP_FIELDS.has(k)||has.has(k))return; const v=r[k]; if(v==null||v==='')return; has.add(k); seen.push(k);
+ }));
+ return seen;
+}
+function fieldVis(k){ const t=VISFIELDS[tab]||{}; if(k in t) return !!t[k]; return (TIGHT_DEFAULTS[tab]||new Set()).has(k); }
+function setFieldVis(k,v){ (VISFIELDS[tab]=VISFIELDS[tab]||{})[k]=v; try{localStorage.setItem('ldFields',JSON.stringify(VISFIELDS));}catch(e){} }
+function fieldLabel(k){ return k.replace(/_/g,' '); }
const normFirm=s=>String(s||'').toLowerCase().replace(/[^a-z0-9]/g,'');
function firmHandle(firm){const n=normFirm(firm);if(!n)return null;if(FIRM_IDX[n])return FIRM_IDX[n];
for(const[a,h]of Object.entries(FIRM_IDX)){if(n===a||(a.length>=5&&n.includes(a))||(a.length>=3&&n.startsWith(a)))return h;}return null;}
@@ -77,16 +124,31 @@ async function boot(){
$('#scope').textContent=`· ${(ld.scope&&ld.scope.state)||'CA'} · ${(ld.scope&&ld.scope.metros||[]).join(', ')}`;
const T=[['firms','Firms',DATA.firms.length],['title','Title',DATA.title.length],['escrow','Escrow',DATA.escrow.length],['agents','Agents',DATA.agents.length]];
$('#tabs').innerHTML=T.map(([k,l,n])=>`<span class="tab${k===tab?' on':''}" data-tab="${k}">${l} <b>${n}</b></span>`).join('');
- $('#tabs').onclick=e=>{const t=e.target.closest('[data-tab]');if(!t)return;tab=t.dataset.tab;document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on',x.dataset.tab===tab));render();};
+ $('#tabs').onclick=e=>{const t=e.target.closest('[data-tab]');if(!t)return;tab=t.dataset.tab;document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on',x.dataset.tab===tab));buildFieldToggles();render();};
$('#q').oninput=render; $('#sort').value=sort; $('#sort').onchange=e=>{sort=e.target.value;localStorage.setItem('ld_sort',sort);render();};
const dens=$('#dens'); dens.value=localStorage.getItem('ld_dens')||4; document.documentElement.style.setProperty('--cols',dens.value);
dens.oninput=e=>{document.documentElement.style.setProperty('--cols',e.target.value);localStorage.setItem('ld_dens',e.target.value);};
+ buildFieldToggles();
render();
}
+// ── ⚙ Fields popout wiring ──
+$('#dpBtn').onclick=e=>{e.stopPropagation();$('#dpPop').classList.toggle('on');};
+$('#dpPop').onclick=e=>e.stopPropagation();
+document.addEventListener('click',()=>$('#dpPop').classList.remove('on'));
+$('#dpList').addEventListener('change',e=>{const cb=e.target.closest('input[data-fk]');if(!cb)return;setFieldVis(cb.dataset.fk,cb.checked);render();});
+$('#dpAll').onclick=()=>{tabFieldKeys().forEach(k=>setFieldVis(k,true));buildFieldToggles();render();};
+$('#dpNone').onclick=()=>{tabFieldKeys().forEach(k=>setFieldVis(k,false));buildFieldToggles();render();};
+$('#dpReset').onclick=()=>{if(VISFIELDS[tab])delete VISFIELDS[tab];try{localStorage.setItem('ldFields',JSON.stringify(VISFIELDS));}catch(e){}buildFieldToggles();render();};
+$('#ov').addEventListener('click',e=>{if(e.target.id==='ov')$('#ov').classList.remove('on');});
function render(){
const q=$('#q').value.trim().toLowerCase();
let rows=DATA[tab]||[];
- if(q) rows=rows.filter(r=>((r.name||'')+' '+(r.firm||'')+' '+(r.license_number||'')).toLowerCase().includes(q));
+ if(q){
+ // Search ALL fields (Steve list-build rule): every field on a row via JSON.stringify,
+ // incl. nested objects. Space = AND (every term must match somewhere on the row).
+ const terms=q.split(/\s+/).filter(Boolean);
+ rows=rows.filter(r=>{const hay=JSON.stringify(r).toLowerCase();return terms.every(t=>hay.includes(t));});
+ }
rows=[...rows].sort((a,b)=> sort==='count'?((b.agent_count||0)-(a.agent_count||0))
: sort==='recent'?String(b.expiration||'').localeCompare(String(a.expiration||''))
: String(a.name||'').localeCompare(String(b.name||'')));
@@ -94,26 +156,55 @@ function render(){
$('#count').textContent=`${rows.length.toLocaleString()} ${tab}${rows.length>CAP?` · showing first ${CAP} (search to narrow)`:''}`;
$('#grid').innerHTML = shown.length? shown.map(card).join('') : '<div class="empty">No matches.</div>';
}
+// Card meta = only the ENABLED data points (⚙ Fields), rendered "label: value".
+function fieldDisplay(r,k){
+ const v=r[k]; if(v==null||v==='')return '';
+ if(k==='agent_count') return `${v} licensed agent${v===1?'':'s'}`;
+ if(k==='license_number') return '#'+v;
+ return fieldLabel(k)+': '+v;
+}
function card(r){
const id=r.id, c=CONTACTS[id]||{};
const firmForIg = r.kind==='agent' ? r.firm : r.name;
- const metaBits=[];
- if(r.kind==='firm') metaBits.push(`${r.agent_count} licensed agent${r.agent_count===1?'':'s'}`, r.metro);
- if(r.kind==='agent') metaBits.push(r.firm||'—', r.license_type, r.license_number?('#'+r.license_number):'', r.city);
- if(r.kind==='title'||r.kind==='escrow') metaBits.push(r.underwriter?('underwriter: '+r.underwriter):'deal-side');
+ const metaBits=tabFieldKeys().filter(fieldVis).map(k=>fieldDisplay(r,k)).filter(Boolean);
const contacted = c.contacted_at?`<span class="chip">✓ ${esc(String(c.contacted_at).slice(0,10))}</span>`:'';
return `<div class="card" data-id="${esc(id)}">
<div class="ctop"><div class="nm">${esc(r.name)}</div><span class="kind ${r.kind}">${r.kind}</span></div>
- <div class="meta">${metaBits.filter(Boolean).map(esc).join(' · ')}</div>
+ <div class="meta">${metaBits.map(esc).join(' · ')}</div>
${igStrip(firmForIg)}
<div class="actions"><button class="btn mark" data-id="${esc(id)}">${c.contacted_at?'↻ Update':'✓ Mark contacted'}</button>${contacted}</div>
</div>`;
}
+// ── ⚙ Fields popout: build per-tab toggle list ──
+function buildFieldToggles(){
+ const keys=tabFieldKeys();
+ $('#dpList').innerHTML=keys.map(k=>`<label><input type="checkbox" data-fk="${esc(k)}" ${fieldVis(k)?'checked':''}><span>${esc(fieldLabel(k))}</span></label>`).join('')||'<div class="dphd">no fields</div>';
+}
+// ── card-click → detail modal (ALL fields for the row) ──
+function openDetail(id){
+ const r=(DATA[tab]||[]).find(x=>String(x.id)===String(id)); if(!r) return;
+ const ov=$('#ov'), mb=$('#mbody');
+ const rowsHtml=Object.keys(r).filter(k=>!SKIP_FIELDS.has(k)).map(k=>{
+ const v=r[k]; if(v==null||v==='')return '';
+ const vs=(typeof v==='object')?JSON.stringify(v):String(v);
+ return `<div class="mrow"><span class="mk">${esc(fieldLabel(k))}</span><span class="mv">${esc(vs)}</span></div>`;
+ }).filter(Boolean).join('');
+ const c=CONTACTS[id]||{};
+ const contacted=c.contacted_at?`<div class="mrow"><span class="mk">contacted</span><span class="mv">${esc(String(c.contacted_at).slice(0,10))}</span></div>`:'';
+ mb.innerHTML=`<h3>${esc(r.name)} <span class="kind ${esc(r.kind)}">${esc(r.kind)}</span></h3>${rowsHtml}${contacted}`;
+ ov.classList.add('on');
+}
document.addEventListener('click',async e=>{
- const b=e.target.closest('.mark'); if(!b) return;
- b.disabled=true;
- try{ const j=await fetch('/api/contacts/'+encodeURIComponent(b.dataset.id)+'/contacted',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(r=>r.json());
- if(j.contact){ CONTACTS[b.dataset.id]=j.contact; render(); } }catch{} finally{ b.disabled=false; }
+ const b=e.target.closest('.mark');
+ if(b){
+ b.disabled=true;
+ try{ const j=await fetch('/api/contacts/'+encodeURIComponent(b.dataset.id)+'/contacted',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(r=>r.json());
+ if(j.contact){ CONTACTS[b.dataset.id]=j.contact; render(); } }catch{} finally{ b.disabled=false; }
+ return;
+ }
+ // card click (not on a button, anchor, or IG thumb) → expand-to-detail modal (all fields)
+ const cardEl=e.target.closest('.card');
+ if(cardEl && !e.target.closest('button,a,.figt')) openDetail(cardEl.dataset.id);
});
boot().catch(e=>{$('#count').textContent='Failed to load: '+e.message;});
</script></body></html>
diff --git a/public/linkedin.html b/public/linkedin.html
index 2f65c7d..5a1af0d 100644
--- a/public/linkedin.html
+++ b/public/linkedin.html
@@ -37,6 +37,25 @@
.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;}
+ /* sort + data-points controls (Steve list-build rule 2026-07-31) */
+ #sortsel{background:var(--panel2);color:var(--fg);border:1px solid var(--line);border-radius:9px;padding:8px 10px;font-size:12px;}
+ .dpbtn{background:var(--panel2);color:var(--muted);border:1px solid var(--line);border-radius:9px;padding:8px 11px;font-size:12px;cursor:pointer;} .dpbtn:hover{color:var(--fg);border-color:var(--gold);}
+ .dppop{position:absolute;z-index:30;margin-top:6px;background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:10px 12px;box-shadow:0 8px 30px rgba(0,0,0,.5);min-width:180px;display:none;}
+ .dppop.on{display:block;}
+ .dptog{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--fg);cursor:pointer;padding:3px 4px;border-radius:6px;} .dptog:hover{background:rgba(200,162,75,.08);} .dptog input{accent-color:var(--gold);}
+ .dppop .minibtn{background:var(--panel2);color:var(--muted);border:1px solid var(--line);border-radius:7px;padding:4px 9px;font-size:11px;cursor:pointer;margin-right:5px;margin-top:8px;} .dppop .minibtn:hover{border-color:var(--gold);color:var(--fg);}
+ /* 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);}
+ /* 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;}
+ #modal{width:min(560px,96vw);background:var(--panel);border:1px solid var(--line);border-radius:15px;padding:20px 22px;position:relative;}
+ #modal h3{margin:0 0 2px;font-size:20px;} #modal .mfirm{color:var(--gold);font-size:13px;}
+ #modal .x{position:absolute;top:12px;right:14px;background:none;border:0;color:var(--muted);font-size:22px;cursor:pointer;}
+ #modal .litem{display:flex;justify-content:space-between;gap:12px;padding:6px 0;border-bottom:1px solid #20262f;font-size:13px;}
+ #modal .litem .m{color:var(--muted);} #modal .litem a{color:var(--draft);text-decoration:none;} #modal .litem a:hover{text-decoration:underline;}
+ #modal .mact{display:flex;gap:10px;margin-top:14px;flex-wrap:wrap;}
@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>
@@ -54,63 +73,177 @@
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">
+ <input type="text" id="q" placeholder="search ALL fields — name, firm, type, listings… (space = AND)">
<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>
+ <select id="sortsel" title="Sort the list"></select>
+ <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>
+ </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 in this browser.
Actual connecting/messaging happens in LinkedIn by hand (or via Sales Navigator) — never automated here.</div>
</div>
+<div id="ov"><div id="modal"><button class="x" aria-label="Close" onclick="document.getElementById('ov').classList.remove('on')">✕</button><div id="mbody"></div></div></div>
<script>
const $=s=>document.querySelector(s);
const FOLLOW_KEY='crcp_li_followed';
// one-time migration off the legacy persona key
try{const _o=localStorage.getItem('frank_li_followed');if(_o!==null&&localStorage.getItem(FOLLOW_KEY)===null){localStorage.setItem(FOLLOW_KEY,_o);localStorage.removeItem('frank_li_followed');}}catch(_){}
let followed=new Set(JSON.parse(localStorage.getItem(FOLLOW_KEY)||'[]'));
-let people=[], filt='all', q='';
+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||'').replace(/&/g,'&').replace(/</g,'<').replace(/"/g,'"');
+const esc=s=>(s==null?'':String(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(); }
+
+// ── Data points (card fields) — START TIGHT: only a couple show on first load, the
+// user toggles the rest on. A saved pref in liFields always wins. (Steve list-build rule)
+const FIELDS=[
+ {k:'agent_type',l:'Type',t:'atype'},
+ {k:'firm',l:'Company / Firm',t:'s'},
+ {k:'listings',l:'Listings',t:'n'},
+ {k:'linkedin',l:'Profile',t:'prof'},
+];
+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); }
+function saveFields(){ try{localStorage.setItem('liFields',JSON.stringify(VISF));}catch(e){} }
+const dpFmt=(p,f)=>{
+ const v=p[f.k];
+ if(f.t==='atype') return v==='residential'?'Residential':'Commercial';
+ if(f.t==='n') return (v||0)+' listing'+((v||0)===1?'':'s');
+ if(f.t==='prof') return p.linkedin?'has profile':'search only';
+ return v==null||v===''?'—':String(v);
+};
+
function visible(){
- return people.filter(p=>{
+ let rows=people.filter(p=>{
if(filt==='profile' && !p.linkedin) return false;
if(filt==='followed' && !followed.has(String(p.id))) return false;
return true;
});
+ if(q){
+ // Search ALL fields (Steve list-build rule): every field the API returned on a row,
+ // 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();
+ return terms.every(t=>hay.includes(t));
+ });
+ }
+ rows=rows.slice().sort((a,b)=>{
+ let x=a[sortKey], y=b[sortKey];
+ const xE=(x==null||x===''), yE=(y==null||y==='');
+ if(xE&&yE) return 0; if(xE) return 1; if(yE) return -1;
+ const xn=!isNaN(+x), yn=!isNaN(+y);
+ if(xn&&yn){ x=+x; y=+y; } else { x=String(x).toLowerCase(); y=String(y).toLowerCase(); }
+ if(x<y) return -sortDir; if(x>y) return sortDir; return 0;
+ });
+ return rows;
}
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; }
+ const dpCols=FIELDS.filter(f=>fVis(f.k) && f.k!=='firm'); // firm renders in the .who block
$('#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>
+ const dps=dpCols.map(f=>`<span class="dp">${esc(f.l)}: ${esc(dpFmt(p,f))}</span>`).join('');
+ const firmLine=fVis('firm')?`<div class="fm">${esc(p.firm||'—')}</div>`:'';
+ 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}
${link}
- <button class="fbtn${isF?' on':''}" onclick="toggle('${p.id}')">${isF?'✓ Followed':'+ Follow'}</button>
+ <button class="fbtn${isF?' on':''}" onclick="event.stopPropagation();toggle('${p.id}')">${isF?'✓ Followed':'+ Follow'}</button>
</div>`;
}).join('');
}
-let t;
+// ── card-click → detail modal (ALL fields) ──
+function openDetail(id){
+ const p=people.find(x=>String(x.id)===String(id)); if(!p) return;
+ const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on');
+ const skip=new Set(['name','firm','agent_type']);
+ 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>`;
+ const isF=followed.has(String(p.id));
+ const rowsHtml=Object.keys(p).filter(k=>!skip.has(k)).map(k=>{
+ let v=p[k]; if(v==null||v==='') v='—';
+ else if(typeof v==='object') v=JSON.stringify(v);
+ else if(/^https?:\/\//.test(v)) v=`<a href="${esc(v)}" target="_blank" rel="noopener noreferrer">${esc(v)}↗</a>`;
+ else v=esc(v);
+ return `<div class="litem"><span class="m">${esc(k)}</span><span>${v}</span></div>`;
+ }).join('');
+ mb.innerHTML=`<h3>${esc(p.name)}${atype(p.agent_type)}</h3>`
+ +`<div class="mfirm">${esc(p.firm||'—')}</div>`
+ +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 load(){
$('#count').textContent='loading…';
- try{ const d=await (await fetch('/api/brokers/linkedin?limit=800'+(q?'&q='+encodeURIComponent(q):''))).json();
+ try{ 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>'; }
}
-$('#q').oninput=()=>{ q=$('#q').value.trim(); clearTimeout(t); t=setTimeout(load,250); };
+// Search is client-side now (search ALL fields) — no re-fetch; the API also filters by name/firm
+// but we keep the full 800-row set client-side so JSON.stringify search covers every field.
+$('#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(); };
+
+// ── sort <select> (Steve list-build rule: every grid gets a sort control) ──
+(function(){
+ const sel=$('#sortsel');
+ const opts=[
+ {k:'listings',dir:-1,l:'Listings ↓'},
+ {k:'listings',dir:1,l:'Listings ↑'},
+ {k:'name',dir:1,l:'Name A→Z'},
+ {k:'name',dir:-1,l:'Name Z→A'},
+ {k:'firm',dir:1,l:'Firm A→Z'},
+ {k:'agent_type',dir:1,l:'Type'},
+ ];
+ sel.innerHTML=opts.map((o,i)=>`<option value="${i}">${esc(o.l)}</option>`).join('');
+ const sv=localStorage.getItem('liSort'); if(sv!==null && opts[+sv]){ sel.value=sv; sortKey=opts[+sv].k; sortDir=opts[+sv].dir; }
+ sel.onchange=()=>{ const o=opts[+sel.value]; sortKey=o.k; sortDir=o.dir; localStorage.setItem('liSort',sel.value); render(); };
+})();
+
+// ── data-points show/hide popover ──
+(function(){
+ const pop=$('#dppop'), btn=$('#dpbtn');
+ function build(){
+ pop.innerHTML=FIELDS.map(f=>`<label class="dptog"><input type="checkbox" data-fk="${f.k}" ${fVis(f.k)?'checked':''}><span>${esc(f.l)}</span></label>`).join('')
+ +`<div><button class="minibtn" id="dpAll">All</button><button class="minibtn" id="dpNone">None</button><button class="minibtn" id="dpReset">↺ Reset</button></div>`;
+ }
+ build();
+ btn.onclick=e=>{ e.stopPropagation(); pop.classList.toggle('on'); };
+ pop.onclick=e=>{ e.stopPropagation();
+ const cb=e.target.closest('input[data-fk]');
+ if(cb){ VISF[cb.dataset.fk]=cb.checked; saveFields(); render(); return; }
+ if(e.target.id==='dpAll'){ FIELDS.forEach(f=>VISF[f.k]=true); saveFields(); build(); render(); }
+ if(e.target.id==='dpNone'){ FIELDS.forEach(f=>VISF[f.k]=false); saveFields(); build(); render(); }
+ if(e.target.id==='dpReset'){ VISF={}; saveFields(); build(); render(); }
+ };
+ document.addEventListener('click',()=>pop.classList.remove('on'));
+})();
+
+// ── card click → detail modal ──
+$('#list').addEventListener('click',e=>{
+ if(e.target.closest('a')||e.target.closest('button')) return;
+ const row=e.target.closest('.row'); if(row&&row.dataset.id) openDetail(row.dataset.id);
+});
+$('#ov').addEventListener('click',e=>{ if(e.target.id==='ov') $('#ov').classList.remove('on'); });
+document.addEventListener('keydown',e=>{ if(e.key==='Escape') $('#ov').classList.remove('on'); });
+
load();
</script>
</body>
← 4efa70a auto-save: 2026-07-31T08:55:43 (5 files) — public/brokers.ht
·
back to Commercialrealestate
·
broker mind-map: add firm-relationship graph (/api/graph?his 7c67770 →