[object Object]

← back to Commercialrealestate

CRCP: real phone reveal — resolve broker/firm phone+email+website from usre, inline in agent cell + Has phone/email filters (TK-10687)

8215dbe4a954e9ca614dd64e25d407d7c7426d4e · 2026-08-18 16:49:47 -0700 · Steve Abrams

- serve.js: /api/broker-contacts batch proxy resolves each listing's agent to a real, city-scoped contact from the usre DRE registry (Google-Places phones); prefers same-city branch (closest to listing), flags toll-free/national numbers for suppression; snapshot fallback for prod/offline
- index.html: resolveContacts() batches visible page's agents on paint; agentInfo overlays real phone (national suppressed), email, firm website; contactBits renders phone+FIRM tier badge+email+clickable firm website inline
- filters: Has broker phone / Has broker email toggles (full F-model + URL + localStorage + chips + reset wiring)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 8215dbe4a954e9ca614dd64e25d407d7c7426d4e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 18 16:49:47 2026 -0700

    CRCP: real phone reveal — resolve broker/firm phone+email+website from usre, inline in agent cell + Has phone/email filters (TK-10687)
    
    - serve.js: /api/broker-contacts batch proxy resolves each listing's agent to a real, city-scoped contact from the usre DRE registry (Google-Places phones); prefers same-city branch (closest to listing), flags toll-free/national numbers for suppression; snapshot fallback for prod/offline
    - index.html: resolveContacts() batches visible page's agents on paint; agentInfo overlays real phone (national suppressed), email, firm website; contactBits renders phone+FIRM tier badge+email+clickable firm website inline
    - filters: Has broker phone / Has broker email toggles (full F-model + URL + localStorage + chips + reset wiring)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/index.html | 44 +++++++++++++++++++++++----
 scripts/serve.js  | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 128 insertions(+), 5 deletions(-)

diff --git a/public/index.html b/public/index.html
index f1c2785..5bfc157 100644
--- a/public/index.html
+++ b/public/index.html
@@ -220,6 +220,8 @@
   .agentcell .ainrow { display:flex; flex-wrap:wrap; gap:8px; }
   .agentcell .ain { color:var(--mut); text-decoration:none; font-size:11px; }
   .agentcell .ain:hover { color:var(--blue); }
+  .ain a { color:var(--blue); text-decoration:none; } .ain a:hover { text-decoration:underline; }
+  .tierb { font-size:9px; text-transform:uppercase; letter-spacing:.4px; color:var(--mut); border:1px solid var(--line); border-radius:4px; padding:0 4px; vertical-align:middle; }
   .agentcell .chiprow:empty { display:none; }
   .agentcell .achip { display:inline-block; font-size:10px; color:var(--mut); background:var(--card); border:1px solid var(--line); border-radius:10px; padding:1px 6px; margin-right:4px; }
   .agentcell .achip.done { color:#3fb950; border-color:#264f2e; }
@@ -1092,6 +1094,24 @@ function firmLabel(p){
 window.CONTACTS = window.CONTACTS || {};
 fetch('/api/contacts').then(r=>r.json()).then(j=>{ window.CONTACTS=j.contacts||{}; if(window.DATA_READY) render(); }).catch(()=>{});
 
+// ── Real phone reveal (TK-10687) ── each listing carries a broker/agent NAME but no phone; the real
+// numbers live in the usre DRE registry. resolveContacts() batches the visible page's agents to the
+// /api/broker-contacts proxy, which returns a REAL city-scoped contact (see serve.js). National
+// toll-free numbers arrive flagged phone_national and are SUPPRESSED below (Steve: "not a national
+// number — closest to the listing"); the firm website link stands in so the local branch is reachable.
+window.BROKER_RESOLVED = window.BROKER_RESOLVED || {};
+const _TOLLFREE=new Set(['800','833','844','855','866','877','888','822','880','887','889']);
+const isNat=ph=>{ const d=String(ph||'').replace(/\D/g,'').replace(/^1(?=\d{10}$)/,''); return _TOLLFREE.has(d.slice(0,3)); };
+const _resolveDone=new Set();
+async function resolveContacts(rows){
+  const items=[];
+  for(const p of (rows||[])){ if(p&&p.id!=null&&!_resolveDone.has(p.id)){ _resolveDone.add(p.id);
+    const a=agentInfo(p); if(a.name) items.push({id:p.id,name:a.name,firm:a.firm||'',city:p.city||''}); } }
+  if(!items.length) return;
+  try{ const j=await postJSON('/api/broker-contacts',{items});
+    if(j&&j.contacts&&Object.keys(j.contacts).length){ Object.assign(window.BROKER_RESOLVED,j.contacts); paintPage(); } }catch(_){}
+}
+
 // ── Firm Instagram (global, link-free) ── each brokerage firm's last 3 IG posts,
 // rendered INLINE under the agent-contact card (no outside links — Business
 // Discovery, thumbnails cached same-origin). Loads once at boot, re-renders on ready.
@@ -1116,10 +1136,17 @@ window.firmIgInline=function(firm){ const h=window.firmHandleOf(firm); if(!h) re
 // Best-known contact info for a listing: the saved CRM record wins, else what the scrape gave us.
 function agentInfo(p){
   const c=window.CONTACTS[p.id]||{};
+  const rv=window.BROKER_RESOLVED[p.id]||{};                       // real contact resolved from usre (TK-10687)
   const bh=window.BROKER_HIST[p.id]||window.BROKER_HIST_ADDR[(p.address||'').toLowerCase().trim()];
-  const dName=p.broker_name||(bh&&bh.current_broker&&(bh.current_broker.name||bh.current_broker.firm))||'';
-  return { name:c.name||dName||'', phone:c.phone||p.broker_phone||'', email:c.email||p.broker_email||'',
-           firm:c.firm||firmLabel(p), contacted_at:c.contacted_at||null, notes:c.notes||'', letters:(c.letters||[]) };
+  const dName=p.broker_name||p.broker_agent||(Array.isArray(p.broker_agents)&&p.broker_agents[0])||(bh&&bh.current_broker&&(bh.current_broker.name||bh.current_broker.firm))||'';
+  // Phone precedence: hand-saved CRM > scraped row > resolved usre line — but a NATIONAL/toll-free
+  // number is never shown as the phone (Steve: "not a national number"); the firm website stands in.
+  const rvPhone=(rv.phone && !rv.phone_national)?rv.phone:'';
+  return { name:c.name||dName||'', phone:c.phone||p.broker_phone||rvPhone||'', email:c.email||p.broker_email||rv.email||'',
+           firm:c.firm||(rv.firm)||firmLabel(p),
+           phoneTier:rv.phone_tier||null, phoneCity:rv.phone_city||null, phoneNational:!!rv.phone_national,
+           firmWebsite:rv.firm_website||'', firmPhone:(rv.firm_phone && !isNat(rv.firm_phone))?rv.firm_phone:'', firmAddress:rv.firm_address||'',
+           contacted_at:c.contacted_at||null, notes:c.notes||'', letters:(c.letters||[]) };
 }
 function gsearch(q){ return 'https://www.google.com/search?q='+encodeURIComponent(q); } // retained; no longer linked out
 function safeUrl(u){ u=String(u==null?'':u); return /^https?:\/\//i.test(u)?u:'#'; }
@@ -1155,9 +1182,15 @@ function firmCell(p){ const n=firmLabel(p); if(!n||n==='Unknown') return esc(n||
 function agentCell(name){ return name?esc(name):'—'; }
 // Inline phone/email: shown as text with a copy button — no tel:/mailto: that launch outside apps.
 function contactBits(a){
-  const tel=a.phone?`<span class="ain">📞 ${esc(a.phone)} <button class="copyb" data-copyval="${esc(a.phone)}" title="Copy phone">⧉</button></span>`:'';
+  const tier=a.phone&&a.phoneTier==='firm'?`<span class="tierb" title="Firm branch line${a.phoneCity?' — '+esc(a.phoneCity)+' office':''}">firm</span>`:'';
+  const tel=a.phone?`<span class="ain">📞 ${esc(a.phone)} ${tier} <button class="copyb" data-copyval="${esc(a.phone)}" title="Copy phone">⧉</button></span>`:'';
   const eml=a.email?`<span class="ain">✉ ${esc(a.email)} <button class="copyb" data-copyval="${esc(a.email)}" title="Copy email">⧉</button></span>`:'';
-  return (tel||eml)?`<div class="ainrow">${tel} ${eml}</div>`:'';
+  // Firm website — "open listing firm website" (and the way to reach the LOCAL branch when we only
+  // have a national/missing number). Opens in a new tab; noopener for safety.
+  const w=a.firmWebsite?String(a.firmWebsite).replace(/^https?:\/\//,'').replace(/\/$/,''):'';
+  const web=w?`<span class="ain">🔗 <a href="${esc(a.firmWebsite.match(/^https?:\/\//)?a.firmWebsite:'https://'+a.firmWebsite)}" target="_blank" rel="noopener noreferrer" title="Open the listing firm’s website — find the local office">${esc(w)}</a></span>`:'';
+  const inner=[tel,eml,web].filter(Boolean).join(' ');
+  return inner?`<div class="ainrow">${inner}</div>`:'';
 }
 // The Agent · contact column cell: name + copyable phone/email + Open/Mark buttons + chips.
 function agentContactCell(p){
@@ -1547,6 +1580,7 @@ function paintPage(){
   }
   else { g.className='grid'; g.innerHTML=slice.map(card).join('')+moreBtn(); enrichAssessor(); }
   applyHeat(slice);
+  resolveContacts(slice);   // fetch REAL phones for the visible agents, then repaint (TK-10687)
 }
 // ---- saved searches (name + restore the full shareable-URL state) ----
 const SAVED=(function(){ try{ return JSON.parse(localStorage.getItem('cre_saved')||'[]'); }catch(e){ return []; } })();
diff --git a/scripts/serve.js b/scripts/serve.js
index 9a00071..d4fe629 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -1769,6 +1769,95 @@ app.get('/api/residential-firms', async (req, res) => {
   res.json({ total: j.total || 0, rows: j.rows || [], upstream_down: j.ok === false });
 });
 
+// ── Real broker/firm PHONE reveal (TK-10687) ────────────────────────────────────────
+// Goal (Steve 2026-08-18): show REAL phone numbers on CRCP. Each listing carries a broker/agent
+// NAME + firm NAME but no phone; the numbers live in the usre DRE registry (Google-Places-resolved
+// firm lines + any direct broker line — the "big phone reveal" enrichment). This batch endpoint
+// resolves a page of listings' agents to a real, CITY-SCOPED contact. Two hard rules from Steve:
+//   1. "closest to the listing" — prefer a same-city branch row over the firm's HQ line.
+//   2. "not a national phone number" — toll-free area codes are FLAGGED phone_national so the UI
+//      suppresses them and shows the firm website link instead of a 1-800/1-844.
+// Source of truth = usre (cached 30 min); brokers-snapshot.json is the prod/offline fallback.
+const TOLLFREE = new Set(['800', '833', '844', '855', '866', '877', '888', '822', '880', '887', '889']);
+const cnorm = s => String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]/g, '');
+const areaCodeOf = ph => { const digits = String(ph || '').replace(/\D/g, '').replace(/^1(?=\d{10}$)/, ''); return digits.slice(0, 3); };
+const isNationalPhone = ph => TOLLFREE.has(areaCodeOf(ph));
+
+let _usreIdx = null, _usreIdxAt = 0;
+const USRE_IDX_TTL = 30 * 60 * 1000;
+// Pull the usre CA commercial broker registry once and index it by normalized name. Kept stale (up
+// to 6× TTL) if a refresh fails, so a transient usre blip doesn't drop every phone off the grid.
+async function usreBrokerIndex() {
+  if (_usreIdx && (Date.now() - _usreIdxAt) < USRE_IDX_TTL) return _usreIdx;
+  const byName = new Map();
+  let offset = 0, total = Infinity, pages = 0;
+  while (offset < total && pages < 30) {
+    const j = await usreFetch(`/api/brokers?asset_class=commercial&limit=1000&offset=${offset}`, 15000);
+    if (!j.ok || !Array.isArray(j.rows) || !j.rows.length) break;
+    total = j.total || j.rows.length;
+    for (const row of j.rows) { const nk = cnorm(row.name); if (!nk) continue; if (!byName.has(nk)) byName.set(nk, []); byName.get(nk).push(row); }
+    offset += 1000; pages++;
+  }
+  if (!byName.size) return (_usreIdx && (Date.now() - _usreIdxAt) < 6 * USRE_IDX_TTL) ? _usreIdx : null;
+  _usreIdx = byName; _usreIdxAt = Date.now();
+  return _usreIdx;
+}
+// Among the usre rows for one agent name, pick the best contact for THIS listing's city.
+function pickUsreContact(rows, city) {
+  if (!rows || !rows.length) return null;
+  const ck = cnorm(city);
+  const scored = rows.map(r => { const ph = r.phone || ''; let s = 0;
+    if (ck && cnorm(r.city) === ck) s += 4;          // same-city branch = closest to the listing
+    if (ph && !isNationalPhone(ph)) s += 2;           // a real local line beats a toll-free one
+    if (r.phone_tier === 'direct') s += 3;            // the agent's own line beats the firm fallback
+    if (ph) s += 1;
+    return { r, s };
+  }).sort((a, b) => b.s - a.s);
+  return scored[0].r;
+}
+function shapeUsreContact(r) {
+  if (!r) return null;
+  const ph = r.phone || '';
+  return { name: r.name, firm: r.firm_name || '', phone: ph, phone_tier: r.phone_tier || null,
+    phone_national: !!(ph && isNationalPhone(ph)), phone_city: r.city || null,
+    email: r.email || r.broker_email || r.firm_email || '',
+    firm_phone: r.firm_phone || '', firm_email: r.firm_email || '',
+    firm_website: r.firm_website || r.broker_website || '', firm_address: r.firm_address || r.address || '',
+    source: 'usre' };
+}
+// Snapshot fallback (prod has no usre / DB): index the flat broker rows by name, CA-local phones only.
+let _snapByName = null, _snapByNameAt = 0;
+function snapNameIndex() {
+  const s = readBrokerSnap();
+  if (!s || !Array.isArray(s.brokers)) return null;
+  if (_snapByName && (Date.now() - _snapByNameAt) < USRE_IDX_TTL) return _snapByName;
+  const m = new Map();
+  for (const b of s.brokers) { const nk = cnorm(b.name); if (nk && !m.has(nk)) m.set(nk, b); }
+  _snapByName = m; _snapByNameAt = Date.now();
+  return m;
+}
+function snapContactByName(name) {
+  const idx = snapNameIndex(); if (!idx) return null;
+  const b = idx.get(cnorm(name)); if (!b) return null;
+  const ph = (b.phone && !isNationalPhone(b.phone)) ? b.phone : '';   // suppress national in fallback too
+  return { name: b.name, firm: b.firm || '', phone: ph, phone_tier: 'firm', phone_national: !!(b.phone && isNationalPhone(b.phone)),
+    phone_city: null, email: b.email || '', firm_phone: '', firm_email: '', firm_website: b.website || '', firm_address: b.office_addr || '', source: 'snapshot' };
+}
+app.post('/api/broker-contacts', async (req, res) => {
+  const items = Array.isArray(req.body && req.body.items) ? req.body.items.slice(0, 300) : [];
+  const idx = await usreBrokerIndex();
+  const out = {};
+  for (const it of items) {
+    if (it == null || it.id == null || !it.name) continue;
+    const nk = cnorm(it.name);
+    let c = (idx && idx.has(nk)) ? shapeUsreContact(pickUsreContact(idx.get(nk), it.city)) : null;
+    if (!c || (!c.phone && !c.email && !c.firm_website)) { const sc = snapContactByName(it.name); if (sc) c = c ? { ...sc, ...c, phone: c.phone || sc.phone, email: c.email || sc.email, firm_website: c.firm_website || sc.firm_website } : sc; }
+    if (c) out[it.id] = c;
+  }
+  res.set('Cache-Control', 'private, max-age=120');
+  res.json({ contacts: out, usre_up: !!idx });
+});
+
 // Listing/search proxy. On upstream-down: 200 with {count:0, results:[], upstream_down:true} so the
 // page renders its empty-state (the wiring is what matters) rather than throwing.
 app.get('/api/contractors', async (req, res) => {

← 884b8b7 CRCP root: center the top chat bar (constrained width, cente  ·  back to Commercialrealestate  ·  CRCP real-phone reveal: standalone Phone/Email/Firm-website b648ef9 →