← back to Commercialrealestate
CRCP real-phone reveal: standalone Phone/Email/Firm-website columns, tel: click-to-call, CA-area-code suppression, no row pop-out, kill Open button (TK-10687)
b648ef971a687311422e88f58a4205c07157ebd6 · 2026-08-18 16:57:22 -0700 · Steve Abrams
- Phone/Email/Firm-website as toggleable fields+columns, inserted next to Agent·contact (syncColOrder now positions new col keys by FIELDS neighbor, not appended to end)
- phone renders as real tel: link (click-to-call) + copy button; email as mailto:
- CA-area-code guard: suppress toll-free AND any non-CA area code (out-of-region HQ lines like Compass 212) — show firm website instead (Steve: closest-to-listing, not national)
- removed the Open agent-window button + row hover tooltip + row-click pop-out — contact info is fixed in place, nothing moves
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
Diff
commit b648ef971a687311422e88f58a4205c07157ebd6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Aug 18 16:57:22 2026 -0700
CRCP real-phone reveal: standalone Phone/Email/Firm-website columns, tel: click-to-call, CA-area-code suppression, no row pop-out, kill Open button (TK-10687)
- Phone/Email/Firm-website as toggleable fields+columns, inserted next to Agent·contact (syncColOrder now positions new col keys by FIELDS neighbor, not appended to end)
- phone renders as real tel: link (click-to-call) + copy button; email as mailto:
- CA-area-code guard: suppress toll-free AND any non-CA area code (out-of-region HQ lines like Compass 212) — show firm website instead (Steve: closest-to-listing, not national)
- removed the Open agent-window button + row hover tooltip + row-click pop-out — contact info is fixed in place, nothing moves
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/index.html | 54 +++++++++++++++++++++++++++++++++++++++++++-----------
1 file changed, 43 insertions(+), 11 deletions(-)
diff --git a/public/index.html b/public/index.html
index 5bfc157..9134d7e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -789,6 +789,9 @@ const FIELDS = [
{k:'score', label:'Investment score', card:1, col:1, def:1},
{k:'deal', label:'Deal score', card:1, col:1, def:1},
{k:'agent', label:'Agent · contact', card:1, col:1, def:1},
+ {k:'phone', label:'Phone', card:1, col:1, def:1},
+ {k:'email', label:'Email', card:1, col:1, def:0},
+ {k:'firmweb', label:'Firm website', card:1, col:1, def:0},
{k:'firm', label:'Listing firm', card:1, col:1, def:1},
{k:'type', label:'Asset type', card:1, col:1, def:1},
{k:'units', label:'Units', card:1, col:1, def:1},
@@ -883,7 +886,15 @@ let COLORDER=(function(){ let a=[]; try{ a=JSON.parse(localStorage.getItem('idxC
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
+ // Insert any NEW col key next to its canonical FIELDS neighbor (its preceding col-field), NOT at the
+ // end — so newly-added fields (Phone/Email/Firm website) surface beside Agent·contact where they
+ // belong, instead of stranded at the bottom of a persisted column order (Steve 2026-08-18).
+ keys.forEach(k=>{ if(COLORDER.includes(k)) return;
+ const ci=keys.indexOf(k); let anchor=null;
+ for(let j=ci-1;j>=0;j--){ if(COLORDER.includes(keys[j])){ anchor=keys[j]; break; } }
+ if(anchor==null){ COLORDER.unshift(k); }
+ else { COLORDER.splice(COLORDER.indexOf(anchor)+1,0,k); }
+ });
}
function saveColOrder(){ try{ localStorage.setItem('idxColOrder',JSON.stringify(COLORDER)); }catch(e){} }
// Ordered, col-eligible FIELDS in the user's chosen order.
@@ -904,6 +915,13 @@ function colCell(k,p){ const f=p.finance||{};
case 'score': return p.composite??'—';
case 'deal': return (p.deal&&p.deal.score!=null)?p.deal.score:'—';
case 'agent': return agentContactCell(p);
+ case 'phone': { const a=agentInfo(p); if(!a.phone) return '—';
+ const tier=a.phoneTier==='firm'?' <span class="tierb" title="Firm branch line'+(a.phoneCity?' — '+esc(a.phoneCity)+' office':'')+'">firm</span>':'';
+ return `<span class="ain"><a href="${telHref(a.phone)}" title="Call ${esc(a.phone)}">📞 ${esc(a.phone)}</a>${tier} <button class="copyb" data-copyval="${esc(a.phone)}" title="Copy phone">⧉</button></span>`; }
+ case 'email': { const a=agentInfo(p); return a.email?`<span class="ain"><a href="mailto:${esc(a.email)}" title="Email ${esc(a.email)}">✉ ${esc(a.email)}</a> <button class="copyb" data-copyval="${esc(a.email)}" title="Copy email">⧉</button></span>`:'—'; }
+ case 'firmweb': { const a=agentInfo(p); const w=a.firmWebsite; if(!w) return '—';
+ const disp=String(w).replace(/^https?:\/\//,'').replace(/\/$/,''); const href=/^https?:\/\//.test(w)?w:'https://'+w;
+ return `<a class="ain" href="${esc(href)}" target="_blank" rel="noopener noreferrer" title="Open the listing firm’s website">🔗 ${esc(disp)}</a>`; }
case 'firm': return firmCell(p);
case 'type': return esc(p.type||'—');
case 'units': return p.units||'—';
@@ -943,8 +961,8 @@ function renderList(rows){
const arrow=k=>colSort&&colSort.key===k?(colSort.dir>0?' asc':' desc'):'';
// "Property" (addr) stays a fixed anchor; the rest are draggable to reorder + click to sort.
const head='<th scope="col" class="stick sortable'+arrow('addr')+'" data-sortk="addr" title="Sort by address">Property</th>'+cols.map(c=>`<th scope="col" class="sortable dragcol${arrow(c.k)}" draggable="true" data-sortk="${c.k}" data-k="${c.k}" title="Drag to reorder · click to sort by ${esc(c.label)}">${esc(c.label)}</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>`
+ const body=rows.map(p=>`<tr class="lrow" data-id="${p.id}">`
+ +`<td class="stick"><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" data-no-sort><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
}
@@ -978,6 +996,9 @@ function fieldVal(p,k){ const f=p.finance||{};
switch(k){
case 'addr': return p.address; case 'score': return p.composite; case 'deal': return p.deal&&p.deal.score;
case 'agent': { const a=agentInfo(p); return a.name||('~'); } // contactless rows sort last (~ > letters)
+ case 'phone': { const a=agentInfo(p); return a.phone||'~'; } // rows without a resolved phone sort last
+ case 'email': { const a=agentInfo(p); return a.email||'~'; }
+ case 'firmweb': { const a=agentInfo(p); return a.firmWebsite||'~'; }
case 'firm': return firmLabel(p); case 'type': return p.type; case 'units': return p.units; case 'price': return p.price;
case 'ppu': return ppu(p); case 'pricePerSf': return ppsf(p); case 'cap': return p.cap_rate; case 'yield': return f.grossYield;
case 'grm': return grm(p); case 'yearBuilt': return p.year_built; case 'dom': return daysOnMarket(p); case 'zip': return p.zip;
@@ -1101,7 +1122,12 @@ fetch('/api/contacts').then(r=>r.json()).then(j=>{ window.CONTACTS=j.contacts||{
// 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)); };
+// CRCP is LA-County-only, so the "closest to the listing" phone (Steve's rule) must be a CALIFORNIA
+// number. Every CA area code — a firm phone outside this set (e.g. a Compass 212 NY HQ line) is a
+// national/HQ leak and is SUPPRESSED (the firm website stands in) exactly like a toll-free number.
+const _CA_AREA=new Set(['209','213','279','310','323','341','350','408','415','424','442','510','530','559','562','619','626','628','650','657','661','669','707','714','747','760','805','818','820','831','858','909','916','925','935','949','951']);
+const _area=ph=>{ const d=String(ph||'').replace(/\D/g,'').replace(/^1(?=\d{10}$)/,''); return d.slice(0,3); };
+const isNat=ph=>{ const a=_area(ph); return _TOLLFREE.has(a) || (a.length===3 && !_CA_AREA.has(a)); };
const _resolveDone=new Set();
async function resolveContacts(rows){
const items=[];
@@ -1141,7 +1167,7 @@ function agentInfo(p){
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:'';
+ const rvPhone=(rv.phone && !rv.phone_national && !isNat(rv.phone))?rv.phone:''; // isNat now also drops non-CA area codes (out-of-region HQ lines)
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,
@@ -1180,11 +1206,13 @@ function firmCell(p){ const n=firmLabel(p); if(!n||n==='Unknown') return esc(n||
return `<span class="flink" data-firmfilter="${esc(n).replace(/"/g,'"')}" title="Show only ${esc(n)} listings">${esc(n)}</span>`; }
// Agent name — plain in-app text (no external search).
function agentCell(name){ return name?esc(name):'—'; }
-// Inline phone/email: shown as text with a copy button — no tel:/mailto: that launch outside apps.
+// Inline phone/email. The phone is a REAL tel: link (Steve 2026-08-18 "make phone icon actually call
+// out") so clicking 📞 or the number places the call; email is a mailto:. Copy buttons stay for both.
+const telHref=ph=>'tel:'+String(ph||'').replace(/[^0-9+]/g,'');
function contactBits(a){
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>`:'';
+ const tel=a.phone?`<span class="ain"><a href="${telHref(a.phone)}" title="Call ${esc(a.phone)}">📞 ${esc(a.phone)}</a> ${tier} <button class="copyb" data-copyval="${esc(a.phone)}" title="Copy phone">⧉</button></span>`:'';
+ const eml=a.email?`<span class="ain"><a href="mailto:${esc(a.email)}" title="Email ${esc(a.email)}">✉ ${esc(a.email)}</a> <button class="copyb" data-copyval="${esc(a.email)}" title="Copy email">⧉</button></span>`:'';
// 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(/\/$/,''):'';
@@ -1199,8 +1227,10 @@ function agentContactCell(p){
const contacted=a.contacted_at?`<span class="achip done" title="Marked contacted ${esc(a.contacted_at)}">✓ ${fmtWhen(a.contacted_at)}</span>`:'';
const lc=a.letters.length?`<span class="achip" title="${a.letters.length} saved letter(s)">✎ ${a.letters.length}</span>`:'';
const nt=a.notes?`<span class="achip" title="Has notes">🗒 notes</span>`:'';
+ // Steve 2026-08-18 "phone on front page. no OPEN": the contact info (phone/email/firm website) shows
+ // DIRECTLY in the cell — no "Open" button gating it. Only a compact Contacted mark remains (click the
+ // ROW to open the full agent window if needed; the row click handler already does that).
const btns=`<span class="acts">`
- +`<button class="abtn contactbtn" data-contact="${p.id}" title="Open the inline agent window — notes, letters, draft">✎ Open</button>`
+`<button class="abtn markbtn" data-mark="${p.id}" title="${a.contacted_at?'Update the contacted date':'Mark contacted (saves the date)'}">${a.contacted_at?'↻':'✓'} Contacted</button>`
+`</span>`;
return `<div class="agentcell">${nm}${contactBits(a)}<div class="chiprow">${contacted}${lc}${nt}</div>${btns}</div>`;
@@ -2162,7 +2192,7 @@ function loadRanked(_retry){ return fetch('data/ranked.json').then(r=>{ if(!r.ok
$('#examples').innerHTML = EX.map(x=>`<button class="ex">${x}</button>`).join('');
$('#examples').onclick=e=>{ if(e.target.classList.contains('ex')){ $('#q').value=e.target.textContent; $('#q').focus(); } };
$('#chatform').onsubmit=e=>{ e.preventDefault(); const t=$('#q').value.trim(); if(!t) return; $('#q').value=''; ask(t); };
- $('#grid').onclick=e=>{ const cp=e.target.closest('[data-copy]'); if(cp){ copySummary(cp.dataset.copy, cp); return; } const qk=e.target.closest('[data-quick]'); if(qk){ applyQuick(qk.dataset.quick); return; } const more=e.target.closest('#showmore'); if(more){ _shown=Math.min(_shown+PAGE(), _rows.length); paintPage(); return; } const st=e.target.closest('[data-star]'); if(st){ toggleStar(st.dataset.star); st.classList.toggle('on',SHORT.has(st.dataset.star)); const sb=$('#shortbtn'); if(sb) sb.textContent='⭐ Shortlist ('+SHORT.size+')'; if(F.shortlist) render(); return; } const th=e.target.closest('.listtbl th[data-sortk]'); if(th){ const k=th.dataset.sortk; if(colSort&&colSort.key===k) colSort.dir*=-1; else colSort={key:k,dir:['type','firm','warr','broker','status','zip','addr','rec'].includes(k)?1:-1}; render(); return; } const b=e.target.closest('.lcbtn'); if(b){ fetchLiveComps(b.dataset.id); return; } const h=e.target.closest('.histbtn'); if(h){ fetchHistory(h); return; }
+ $('#grid').onclick=e=>{ const cp=e.target.closest('[data-copy]'); if(cp){ copySummary(cp.dataset.copy, cp); return; } const qk=e.target.closest('[data-quick]'); if(qk){ applyQuick(qk.dataset.quick); return; } const more=e.target.closest('#showmore'); if(more){ _shown=Math.min(_shown+PAGE(), _rows.length); paintPage(); return; } const st=e.target.closest('[data-star]'); if(st){ toggleStar(st.dataset.star); st.classList.toggle('on',SHORT.has(st.dataset.star)); const sb=$('#shortbtn'); if(sb) sb.textContent='⭐ Shortlist ('+SHORT.size+')'; if(F.shortlist) render(); return; } const th=e.target.closest('.listtbl th[data-sortk]'); if(th){ const k=th.dataset.sortk; if(colSort&&colSort.key===k) colSort.dir*=-1; else colSort={key:k,dir:['type','firm','warr','broker','status','zip','addr','rec','phone','email','firmweb'].includes(k)?1:-1}; render(); return; } const b=e.target.closest('.lcbtn'); if(b){ fetchLiveComps(b.dataset.id); return; } const h=e.target.closest('.histbtn'); if(h){ fetchHistory(h); return; }
const cv=e.target.closest('[data-copyval]'); if(cv){ navigator.clipboard.writeText(cv.dataset.copyval).then(()=>toast('📋 copied')).catch(()=>toast('⚠ copy failed')); return; }
const ff=e.target.closest('[data-firmfilter]'); if(ff){ if(e.metaKey||e.ctrlKey||e.shiftKey) F.firms.add(ff.dataset.firmfilter); else F.firms=new Set([ff.dataset.firmfilter]); syncChips(); render(); return; }
// drill atoms: card asset-type + city → filter the grid to that value (URL updates via render→stateToURL)
@@ -2172,7 +2202,9 @@ function loadRanked(_retry){ return fetch('data/ranked.json').then(r=>{ if(!r.ok
const mk=e.target.closest('[data-mark]'); if(mk){ markContacted(mk.dataset.mark); return; }
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-typefilter],[data-cityfilter],[data-copyval]')){ openContact(row.dataset.id); return; } };
+ // Row-click NO LONGER opens the agent pop-out (Steve 2026-08-18: "do not allow this to pop out, or
+ // move round. fix in place"). Contact info renders directly in the row; explicit links/buttons still work.
+ };
// ── 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.
← 8215dbe CRCP: real phone reveal — resolve broker/firm phone+email+we
·
back to Commercialrealestate
·
snapshot before CRCP UI standardization 0914826 →