← back to Commercialrealestate

public/licensed.html

373 lines

<!doctype html><html lang="en"><head>
<script>(function(){try{var t=localStorage.getItem('crcp-theme');document.documentElement.setAttribute('data-theme',t==='light'?'light':'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();</script>
<script>/* credential-safe fetch (2026-08-18): resolve EVERY relative fetch (with or without a leading slash) against a credential-free base so creds embedded in the page URL (http://user:pass@host/...) don't poison document.baseURI and throw "Request cannot be constructed from a URL that includes credentials". */(function(){var of=window.fetch,O=location.origin;window.fetch=function(u,o){try{if(typeof u==='string'){if(/^[a-z][a-z0-9+.-]*:/i.test(u)){var x=new URL(u);if(x.username||x.password){x.username='';x.password='';u=x.href;}}else{u=new URL(u,O+location.pathname+location.search).href;}}else if(u&&typeof u.url==='string'){var y=new URL(u.url,O+location.pathname+location.search);if(y.username||y.password){y.username='';y.password='';u=new Request(y.href,u);}}}catch(e){}return of.call(this,u,o);};try{if(location.href.indexOf('@')>=0){history.replaceState(null,'',location.pathname+location.search+location.hash);}}catch(e){}})();</script>
<!-- Self-heal: nav-agent grid controls can persist a "removed"/"collapsed" state that hides the
     whole grid with no obvious way back. Clear it before nav-agent runs so a grid can never stay hidden. -->
<script>(function(){try{for(var i=localStorage.length-1;i>=0;i--){var k=localStorage.key(i);if(k&&k.indexOf('navagent:')===0){var v=localStorage.getItem(k);if(v&&v.indexOf('"removed":true')>=0){localStorage.removeItem(k);}}}}catch(e){}})();</script>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Licensed Directory — CRCP</title>
<style>
:root{--bg:#0f1216;--card:#161b22;--line:#262d38;--ink:#e6e9ef;--mut:#8a93a3;--blue:#4c8dff;--good:#3fb984;--warn:#d8a24a;--cols:4;}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,Segoe UI,Roboto,sans-serif}
a{color:var(--blue);text-decoration:none}
header{position:sticky;top:0;z-index:5;background:var(--bg);border-bottom:1px solid var(--line);padding:10px 16px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
h1{font-size:17px;margin:0;font-weight:600}.sub{color:var(--mut);font-size:12px}
.tabs{display:flex;gap:6px}.tab{padding:5px 11px;border:1px solid var(--line);border-radius:20px;background:var(--card);color:var(--ink);cursor:pointer;font-size:12.5px}
.tab.on{background:var(--blue);border-color:var(--blue);color:#fff}
.ctrl{display:flex;gap:10px;align-items:center;margin-left:auto;flex-wrap:wrap}
input,select{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:6px 9px;font-size:13px}
input#q{min-width:230px}
.rng{display:flex;align-items:center;gap:6px;color:var(--mut);font-size:12px}
.wrap{padding:14px 16px}.count{color:var(--mut);font-size:12px;margin-bottom:10px}
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px}
.card{border:1px solid var(--line);border-radius:12px;background:var(--card);padding:12px;display:flex;flex-direction:column;gap:7px}
.ctop{display:flex;justify-content:space-between;gap:8px;align-items:flex-start}
.nm{font-weight:600;font-size:14px;line-height:1.25}
.kind{font-size:10px;text-transform:uppercase;letter-spacing:.05em;padding:2px 7px;border-radius:20px;border:1px solid var(--line);color:var(--mut);white-space:nowrap}
.kind.firm{color:var(--blue);border-color:var(--blue)}.kind.title{color:var(--warn);border-color:var(--warn)}.kind.escrow{color:var(--good);border-color:var(--good)}.kind.agent{color:var(--mut)}
.meta{color:var(--mut);font-size:12px}
.actions{display:flex;gap:7px;margin-top:2px}
.btn{background:transparent;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:5px 10px;font-size:12px;cursor:pointer}.btn:hover{border-color:var(--blue)}
.chip{font-size:11px;color:var(--good);border:1px solid var(--good);border-radius:20px;padding:2px 8px}
/* firm-IG inline strip (link-free) */
.figwrap{margin-top:4px;display:flex;flex-direction:column;gap:4px}
.fighd{font-size:11px;color:var(--mut)}
.figli{display:flex;gap:8px;align-items:center;padding:5px 6px;border:1px solid var(--line);border-radius:8px;background:rgba(255,255,255,.02)}
.figt{width:34px;height:34px;flex:0 0 34px;border-radius:6px;object-fit:cover;background:var(--line);display:inline-block}
.figb{display:flex;flex-direction:column;min-width:0;gap:1px}
.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}
/* contact strip — Steve HARD RULE: ALWAYS show phone + email, clickable, on every card */
.contact{display:flex;flex-wrap:wrap;gap:5px 12px;margin-top:2px;font-size:12.5px}
.contact a{display:inline-flex;align-items:center;gap:4px;color:var(--blue)}
.contact a:hover{text-decoration:underline}
.contact .none{color:var(--mut);display:inline-flex;align-items:center;gap:4px}
/* 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}
/* ── 3-view segmented [Grid|List|Table] + all-field sort (additive, mirrors residential-brokers.html) ── */
#viewseg{display:inline-flex}
#viewseg button{background:var(--card);color:var(--mut);border:1px solid var(--line);padding:6px 11px;font-size:12px;cursor:pointer}
#viewseg button:first-child{border-radius:8px 0 0 8px}
#viewseg button:last-child{border-radius:0 8px 8px 0}
#viewseg button:not(:last-child){border-right:0}
#viewseg button.active{color:#fff;background:var(--blue);border-color:var(--blue)}
#tableView{overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--card);max-height:calc(100vh - 200px)}
#tableView table{border-collapse:collapse;width:100%;font-size:12.5px}
#tableView thead th{position:sticky;top:0;background:var(--card);z-index:2;text-align:left;padding:9px 11px;border-bottom:1px solid var(--line);white-space:nowrap;color:var(--mut);font-weight:700;font-size:10.5px;text-transform:uppercase;letter-spacing:.4px}
#tableView tbody td{padding:7px 11px;border-bottom:1px solid var(--line);vertical-align:top;line-height:1.35}
#tableView tbody tr{cursor:pointer}
#tableView tbody tr:hover{background:rgba(76,141,255,.07)}
#tableView td a{color:var(--blue);text-decoration:none}
#tableView td a:hover{text-decoration:underline}
</style>  <link rel="stylesheet" href="/three-view.css"><!-- 3-view helper -->
  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
  <link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so html[data-theme] wins -->
</head><body>
<header>
  <div><h1>📋 Licensed Directory <span class="sub" id="scope"></span></h1></div>
  <div class="tabs" id="tabs"></div>
  <div class="ctrl">
    <input id="q" placeholder="Search name / firm / license #…">
    <select id="sort">
      <option value="name">Name A→Z</option>
      <option value="count">Most agents</option>
      <option value="recent">License newest</option>
    </select>
    <span data-tvseg id="viewseg"></span>
    <select id="tvSort"></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 id="tvList"></div><div id="tableView"><table><thead></thead><tbody></tbody></table></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 src="/three-view.js" defer></script><!-- 3-view + all-field sort helper (loaded before the page script's guarded startViews) -->
<script>
const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const $=s=>document.querySelector(s);
let DATA={agents:[],firms:[],title:[],escrow:[]}, FIRM_IG={}, FIRM_IDX={}, CONTACTS={};
let BROKERS={}; // normalized firm/name -> {name,firm,phone,email,website} from /api/brokers/all (join for real contacts)
// ── broker contact join (Steve HARD RULE: ALWAYS show phone + email) ──
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 normBk=s=>String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'').replace(/(inc|llc|corp|corporation|company|co|realty|realestate|group|properties|partners|associates)$/,'');
// Match a directory row to a broker record. Cody gate 2026-08-18: an AGENT contact is trusted
// ONLY when a broker's NAME **and** FIRM both agree — firm-first matching glued one broker's
// personal number onto every agent at that firm (published wrong contact as fact). Unconfirmed
// -> null -> DRE fallback. Firm/title/escrow rows are orgs: a broker at that org is a valid contact.
function brokerFor(r){
  if(!r) return null;
  if(r.kind==='agent'){
    const b=BROKERS[normBk(r.name)];
    if(b && (b.phone||b.email) && normBk(b.name)===normBk(r.name) && b.firm && normBk(b.firm)===normBk(r.firm)) return b;
    return null;
  }
  const b=BROKERS[normBk(r.name)];
  if(b && (b.phone||b.email)) return b;
  return null;
}
// ALWAYS-visible phone + email. Real tel:/mailto: when we have a broker match; otherwise a
// graceful fallback link to the authoritative source (CA-DRE license lookup, else a firm web search).
function contactBlock(r){
  const b=brokerFor(r);
  const lic=clean(r.license_number);
  const dreUrl=lic?`https://www2.dre.ca.gov/PublicASP/pplinfo.asp?License_id=${encodeURIComponent(lic)}`
    :`https://www.google.com/search?q=${encodeURIComponent((r.name||'')+' '+((r.city||'')+' ')+'real estate contact')}`;
  const ph = b&&b.phone
    ? `<a href="tel:${esc(b.phone.replace(/[^0-9+]/g,''))}" onclick="event.stopPropagation()" title="${esc(b.phone)}">📞 ${esc(b.phone)}</a>`
    : `<a href="${esc(dreUrl)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()" title="No phone on file — look it up">📞 <span class="none">look up ↗</span></a>`;
  const em = b&&b.email
    ? `<a href="mailto:${esc(b.email)}" onclick="event.stopPropagation()" title="${esc(b.email)}">✉️ ${esc(b.email)}</a>`
    : `<a href="${esc(dreUrl)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()" title="No email on file — look it up">✉️ <span class="none">look up ↗</span></a>`;
  return `<div class="contact">${ph}${em}</div>`;
}
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;}
function igStrip(firm){const h=firmHandle(firm);if(!h)return '';const c=FIRM_IG[h];if(!c||!c.posts||!c.posts.length)return '';
  const items=c.posts.slice(0,3).map(p=>{const cap=esc((p.caption||'').replace(/\s+/g,' ').slice(0,80))||'(no caption)';
    const when=p.timestamp?String(p.timestamp).slice(0,10):'';const vid=p.media_type==='VIDEO'?'▶ ':'';
    const thumb=p.image?`<img class="figt" loading="lazy" src="${esc(p.image)}" alt="">`:'<span class="figt"></span>';
    return `<div class="figli">${thumb}<span class="figb"><span class="figc">${vid}${cap}</span><span class="figm">${when?when+' · ':''}♥ ${p.likes||0} · 💬 ${p.comments||0}</span></span></div>`;}).join('');
  return `<div class="figwrap"><div class="fighd">📷 @${esc(h)} · firm’s latest 3</div>${items}</div>`;}

async function boot(){
  const [ld,cache,map,contacts,brk]=await Promise.all([
    fetch('data/license-directory.json').then(r=>r.json()),
    fetch('data/firm-ig-cache.json').then(r=>r.json()).catch(()=>({})),
    fetch('data/firm-ig-map.json').then(r=>r.json()).catch(()=>({handles:{}})),
    fetch('/api/contacts').then(r=>r.json()).then(j=>j.contacts||{}).catch(()=>({})),
    fetch('/api/brokers/all').then(r=>r.json()).then(j=>j.brokers||j.results||[]).catch(()=>[]),
  ]);
  DATA={agents:ld.agents||[],firms:ld.firms||[],title:ld.title||[],escrow:ld.escrow||[]};
  FIRM_IG=cache; CONTACTS=contacts;
  // index brokers by normalized firm AND name so directory rows can join for real phone/email
  (Array.isArray(brk)?brk:[]).forEach(b=>{
    const ph=clean(b.phone),em=clean(b.email);
    [b.firm,b.name].forEach(key=>{const k=normBk(key);if(!k)return;
      if(!BROKERS[k] || ((ph||em)&&!(BROKERS[k].phone||BROKERS[k].email)))
        BROKERS[k]={name:clean(b.name),firm:b.firm,phone:ph,email:em,website:weburl(b.website)};});
  });
  for(const[h,al]of Object.entries(map.handles||{}))for(const a of al)FIRM_IDX[a]=h;
  $('#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));buildFieldToggles();remountTV();};
  $('#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();
  mountTV();
}
// Re-mount ThreeView with the current tab's fields. Clear seg/sort DOM first so
// each mount's fresh listeners don't stack (ThreeView adds a listener per mount).
function remountTV(){
  const seg=$('#viewseg'), sel=$('#tvSort');
  if(seg){const n=seg.cloneNode(false);seg.parentNode.replaceChild(n,seg);}
  if(sel){const n=sel.cloneNode(false);sel.parentNode.replaceChild(n,sel);}
  TV=null; _tvTries=0; mountTV();
}
// ── ⚙ Fields popout wiring ──
$('#dpBtn').onclick=e=>{e.stopPropagation();$('#dpPop').classList.toggle('on');};
$('#dpPop').onclick=e=>e.stopPropagation();
document.addEventListener('click',()=>$('#dpPop').classList.remove('on'));
// field-toggle changes which columns/list-fields/sort-options exist → re-mount so
// list + table + sort menu track it (grid reads fieldVis live; re-mount also re-renders it).
$('#dpList').addEventListener('change',e=>{const cb=e.target.closest('input[data-fk]');if(!cb)return;setFieldVis(cb.dataset.fk,cb.checked);remountTV();});
$('#dpAll').onclick=()=>{tabFieldKeys().forEach(k=>setFieldVis(k,true));buildFieldToggles();remountTV();};
$('#dpNone').onclick=()=>{tabFieldKeys().forEach(k=>setFieldVis(k,false));buildFieldToggles();remountTV();};
$('#dpReset').onclick=()=>{if(VISFIELDS[tab])delete VISFIELDS[tab];try{localStorage.setItem('ldFields',JSON.stringify(VISFIELDS));}catch(e){}buildFieldToggles();remountTV();};
$('#ov').addEventListener('click',e=>{if(e.target.id==='ov')$('#ov').classList.remove('on');});
// Current tab's filtered + (page-default) sorted rows, capped. ThreeView's all-field
// sort is layered ON TOP of this via applySort, so getRows keeps the page's own order.
const CAP=300;
function getRows(){
  const q=$('#q').value.trim().toLowerCase();
  let rows=DATA[tab]||[];
  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||'')));
  return rows.slice(0,CAP);
}
function renderCount(rows){
  // rows are already capped; recompute the uncapped total for the label
  const q=$('#q').value.trim().toLowerCase();
  let all=(DATA[tab]||[]);
  if(q){const terms=q.split(/\s+/).filter(Boolean);all=all.filter(r=>{const hay=JSON.stringify(r).toLowerCase();return terms.every(t=>hay.includes(t));});}
  const n=all.length;
  $('#count').textContent=`${n.toLocaleString()} ${tab}${n>CAP?` · showing first ${CAP} (search to narrow)`:''}`;
}
// GRID view = the page's EXISTING card renderer (unchanged look + contact + IG + mark).
function renderGrid(rows){
  renderCount(rows);
  $('#grid').innerHTML = rows.length? rows.map(card).join('') : '<div class="empty">No matches.</div>';
}
// TABLE view = the enabled data-point fields as columns + always-visible phone/email
// (resolved through the SAME contactBlock()/brokerFor() path — guard intact).
function renderTable(rows){
  renderCount(rows);
  const cols=tvFields();
  const t=$('#tableView').querySelector('table');
  t.querySelector('thead').innerHTML='<tr>'+cols.map(c=>`<th>${esc(c.l)}</th>`).join('')+'</tr>';
  t.querySelector('tbody').innerHTML = rows.length
    ? rows.map(r=>'<tr data-id="'+esc(r.id)+'">'+cols.map(c=>`<td>${c.html?c.calc(r):esc(c.calc(r))}</td>`).join('')+'</tr>').join('')
    : `<tr><td colspan="${cols.length}"><div class="empty">No matches.</div></td></tr>`;
}
// Called by search/sort/density/field-toggle — delegates to ThreeView if mounted.
function render(){ if(TV){TV.render();} else { const r=getRows(); renderGrid(r); renderTable(r); } }
// ── ThreeView field metadata for the CURRENT tab ──
// Data-point fields (the enabled ⚙ Fields keys) PLUS locked Phone + Email columns
// that resolve via brokerFor()/contactBlock() — NEVER a firm-first or name-only join.
function bkFor(r){return brokerFor(r);} // keep the Cody-gated guard as the single source
function tvFields(){
  const keys=tabFieldKeys().filter(fieldVis);
  const fields=[{k:'name',l:'Name',calc:r=>r.name}];
  keys.forEach(k=>fields.push({k,l:fieldLabel(k),calc:r=>{
    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 String(v);
  }}));
  // ALWAYS-visible contact columns (Steve hard rule) — real tel:/mailto: only on a
  // confirmed broker match (brokerFor); otherwise a graceful look-up link.
  fields.push({k:'__phone',l:'Phone',lock:true,html:true,calc:r=>{const b=bkFor(r);const lic=clean(r.license_number);
    const dre=lic?`https://www2.dre.ca.gov/PublicASP/pplinfo.asp?License_id=${encodeURIComponent(lic)}`:`https://www.google.com/search?q=${encodeURIComponent((r.name||'')+' real estate contact')}`;
    return b&&b.phone?`<a href="tel:${esc(b.phone.replace(/[^0-9+]/g,''))}" onclick="event.stopPropagation()">📞 ${esc(b.phone)}</a>`:`<a href="${esc(dre)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">📞 <span class="none">look up ↗</span></a>`;}});
  fields.push({k:'__email',l:'Email',lock:true,html:true,calc:r=>{const b=bkFor(r);const lic=clean(r.license_number);
    const dre=lic?`https://www2.dre.ca.gov/PublicASP/pplinfo.asp?License_id=${encodeURIComponent(lic)}`:`https://www.google.com/search?q=${encodeURIComponent((r.name||'')+' real estate contact')}`;
    return b&&b.email?`<a href="mailto:${esc(b.email)}" onclick="event.stopPropagation()">✉️ ${esc(b.email)}</a>`:`<a href="${esc(dre)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">✉️ <span class="none">look up ↗</span></a>`;}});
  return fields;
}
// mount/re-mount ThreeView for the current tab (fields differ per tab → re-mount on switch)
let TV=null,_tvTries=0;
function mountTV(){
  if(typeof ThreeView==='undefined'){ if(++_tvTries>100){const r=getRows();renderGrid(r);renderTable(r);return;} return setTimeout(mountTV,40); }
  TV=ThreeView.mount({
    seg:'#viewseg', sortSel:'#tvSort',
    listMount:'#tvList', gridMount:'#grid', tableMount:'#tableView',
    fields:tvFields(),
    getRows,
    title:r=>({name:r.name||'—',sub:r.kind==='agent'?(r.firm||''):'',href:null}),
    badgeHtml:r=>`<span class="kind ${esc(r.kind)}">${esc(r.kind)}</span>`,
    fvis:k=>{const f=tvFields().find(x=>x.k===k);return f?(f.lock||fieldVis(k)):false;},
    renderGrid, renderTable,
    onSort:()=>{}, rowClick:r=>openDetail(r.id),
    storageKey:'ldview', defaultView:'grid', views:['grid','list','table'],
  });
  TV.render();
}
// 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=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.map(esc).join(' · ')}</div>
    ${contactBlock(r)}
    ${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>`:'';
  const contactRow=`<div class="mrow"><span class="mk">contact</span><span class="mv">${contactBlock(r)}</span></div>`;
  mb.innerHTML=`<h3>${esc(r.name)} <span class="kind ${esc(r.kind)}">${esc(r.kind)}</span></h3>${contactRow}${rowsHtml}${contacted}`;
  ov.classList.add('on');
}
document.addEventListener('click',async e=>{
  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); return; }
  // table-row click (not on a link) → same detail modal
  const trEl=e.target.closest('#tableView tbody tr[data-id]');
  if(trEl && !e.target.closest('a')) openDetail(trEl.dataset.id);
});
boot().catch(e=>{$('#count').textContent='Failed to load: '+e.message;});
</script>  <script src="/global-search.js" defer></script>
  <script src="/sort-bar.js" defer></script>
<script src="/corner-nav.js" defer></script>
  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
  <script src="/crcp-theme.js" defer></script><!-- CRCP day/night toggle (auto-injects into header) -->
</body></html>