← back to Commercialrealestate
Agent-contact CRM now on EVERY listing + card view (one modal everywhere)
f503eb1c47c6cc2a5798c0aa4f7fed74df9f138b · 2026-07-17 08:30:29 -0700 · Steve
- Card view: every card (all asset types) gets a cardAgentBlock — name/phone/email +
contacted/letters chips + Contact and Mark-contacted buttons.
- Promoted the compose popup into one full per-listing contact modal (openContact):
editable name/phone/email, mark-contacted, compose + save letter, saved-letter log
with delete. Opens from list cell, list-row expansion, AND cards.
- refreshContactUI() repaints whichever surfaces are visible (list agent cell, open
expansion, card block) + repaintModal() keeps the open modal fresh — no full re-render.
- List-row expansion CRM simplified to a summary + Contact/Mark buttons (no duplicate form).
Verified headless: Multifamily (non-condo) list + card, modal save-info/letter/mark all
persist and refresh in place across surfaces; 60 cards each carry the block; 0 errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
Diff
commit f503eb1c47c6cc2a5798c0aa4f7fed74df9f138b
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jul 17 08:30:29 2026 -0700
Agent-contact CRM now on EVERY listing + card view (one modal everywhere)
- Card view: every card (all asset types) gets a cardAgentBlock — name/phone/email +
contacted/letters chips + Contact and Mark-contacted buttons.
- Promoted the compose popup into one full per-listing contact modal (openContact):
editable name/phone/email, mark-contacted, compose + save letter, saved-letter log
with delete. Opens from list cell, list-row expansion, AND cards.
- refreshContactUI() repaints whichever surfaces are visible (list agent cell, open
expansion, card block) + repaintModal() keeps the open modal fresh — no full re-render.
- List-row expansion CRM simplified to a summary + Contact/Mark buttons (no duplicate form).
Verified headless: Multifamily (non-condo) list + card, modal save-info/letter/mark all
persist and refresh in place across surfaces; 60 cards each carry the block; 0 errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
public/index.html | 142 ++++++++++++++++++++++++++++++++++++------------------
1 file changed, 95 insertions(+), 47 deletions(-)
diff --git a/public/index.html b/public/index.html
index 7a1c495..c9f98ed 100644
--- a/public/index.html
+++ b/public/index.html
@@ -234,6 +234,18 @@
.composeActs button.primary { border-color:#2f5233; color:#8fdfa0; }
.crmToast { position:fixed; bottom:22px; left:50%; transform:translateX(-50%) translateY(12px); background:#11161d; border:1px solid #2a3340; color:var(--ink); font-size:12.5px; padding:9px 16px; border-radius:20px; opacity:0; transition:opacity .2s,transform .2s; z-index:9500; pointer-events:none; }
.crmToast.on { opacity:1; transform:translateX(-50%) translateY(0); }
+ /* Card-view agent-contact block (every card, every asset type) */
+ .card .cardagent { margin-top:8px; border-top:1px solid var(--line); padding-top:8px; font-size:12px; }
+ .card .cardagent .ca-top { display:flex; flex-wrap:wrap; align-items:center; gap:6px; }
+ .card .cardagent .ainrow { display:flex; flex-wrap:wrap; gap:10px; margin-top:3px; }
+ .card .cardagent .ain { color:var(--mut); text-decoration:none; font-size:11px; }
+ .card .cardagent .ain:hover { color:var(--blue); }
+ .card .cardagent .acts { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; }
+ /* Full contact modal info row */
+ .composeCard .cm-info { display:flex; flex-wrap:wrap; gap:6px; align-items:center; margin-bottom:10px; }
+ .composeCard .cm-info .crmf { background:#0b0e13; border:1px solid #2a3340; border-radius:8px; color:var(--ink); font-size:12px; padding:6px 8px; min-width:140px; flex:1 1 140px; margin:0; }
+ .composeCard .cm-info .crmf:focus { outline:none; border-color:var(--blue); }
+ .composeH .flink { font-size:11px; font-weight:400; margin-left:8px; }
/* ---- Amazon-style docked left filter rail (desktop) ---- */
.shell { display:flex; align-items:flex-start; }
main { flex:1 1 auto; min-width:0; }
@@ -819,25 +831,19 @@ function expandBody(p){
ds.push(`<a href="https://www.linkedin.com/search/results/people/?keywords=${encodeURIComponent(a.name+(firm&&firm!=='Unknown'?' '+firm:''))}" target="_blank" rel="noopener noreferrer">LinkedIn</a>`);
ds.push(`<a href="${gsearch('"'+a.name+'" DRE license California real estate')}" target="_blank" rel="noopener noreferrer">DRE license</a>`);
}
- // Editable contact form (Steve fills what the scrape didn't capture; saves to the CRM store).
- const info=`<div class="crm-info">
- <input class="crmf" data-info="name" data-id="${id}" placeholder="Agent name" value="${esc(a.name||'')}">
- <input class="crmf" data-info="phone" data-id="${id}" placeholder="Phone" value="${esc(a.phone||'')}">
- <input class="crmf" data-info="email" data-id="${id}" placeholder="Email" value="${esc(a.email||'')}">
- <button class="abtn saveinfobtn" data-saveinfo="${id}" title="Save this agent's contact info">💾 Save info</button>
- </div>`;
- const letters=a.letters.length?`<div class="crm-letters"><div class="deeplbl">Saved letters (${a.letters.length}) — saved, not sent</div>`
- +a.letters.map((l,i)=>`<div class="lett"><div class="letth"><b>${esc(l.subject||'(no subject)')}</b><span class="letts">${fmtWhen(l.ts)}</span><button class="letdel" data-letdel="${id}" data-idx="${i}" title="Delete this saved letter">✕</button></div><div class="lettb">${esc(l.body||'')}</div></div>`).join('')
- +`</div>`:'';
+ // Agent summary + the two contact actions. Full editing (info fields, letter log) lives in the
+ // single contact modal that ✎ Contact opens — no duplicate form here.
+ const tel=a.phone?`<a class="ain" href="tel:${esc(a.phone).replace(/[^0-9+]/g,'')}">📞 ${esc(a.phone)}</a>`:'';
+ const eml=a.email?`<a class="ain" href="mailto:${esc(a.email)}">✉ ${esc(a.email)}</a>`:'';
+ const summary=`<div class="crm-summary">${a.name?agentCell(a.name,firm)+' ':'<span class="amut">no agent on file — add via ✎ Contact</span> '}${tel} ${eml}`
+ +`${a.contacted_at?`<span class="achip done">contacted ${fmtWhen(a.contacted_at)}</span>`:''}${a.letters.length?`<span class="achip">✎ ${a.letters.length} saved</span>`:''}</div>`;
return `<div class="src">${deep.join(' · ')}</div>`
+(ds.length?`<div class="deep"><span class="deeplbl">Deeper searches</span> ${ds.join(' ')}</div>`:'')
- +`<div class="crm">${info}
+ +`<div class="crm">${summary}
<div class="crm-acts">
- <button class="abtn contactbtn" data-contact="${id}">✎ Write & save letter</button>
+ <button class="abtn contactbtn" data-contact="${id}">✎ Contact / manage</button>
<button class="abtn markbtn" data-mark="${id}">${a.contacted_at?'↻ Update contacted date':'✓ Mark contacted'}</button>
- ${a.contacted_at?`<span class="achip done">contacted ${fmtWhen(a.contacted_at)}</span>`:''}
</div>
- ${letters}
</div>`
+`<div class="lcout" id="lc-${id}"></div><div class="histout" id="hist-${id}"></div>`;
}
@@ -1049,10 +1055,26 @@ function card(p){
${vis('rent')?rentLine(p):''}
${vis('links')?`<div class="src"><a href="${p.source}" target="_blank" rel="noopener noreferrer">View listing ↗</a> · ${loopnetLink(p)} · <button class="lcbtn" data-id="${p.id}" title="Pull fresh comps from LoopNet via a cloud browser (~$0.03)">🔄 Live comps</button> · <button class="histbtn" data-id="${p.id}" data-address="${(p.address||'').replace(/"/g,'"')}" data-city="${(p.city||'').replace(/"/g,'"')}" data-zip="${p.zip||''}" title="LA County assessor + permit + film history (free)">📜 Property history</button></div>`:''}
${anyAssessorVisible()?`<div class="assessline small" data-addr="${(p.address||'').replace(/"/g,'"')}" data-city="${(p.city||'').replace(/"/g,'"')}">📋 assessor…</div>`:''}
+ ${cardAgentBlock(p)}
<div class="lcout" id="lc-${p.id}"></div>
<div class="histout" id="hist-${p.id}"></div>
</div>`;
}
+// Per-listing agent-contact block for the CARD view — every card (any asset type) gets the same
+// contact modal + mark-contacted as the list rows. Compact: name/phone/email + chips + buttons.
+function cardAgentBlock(p){
+ const a=agentInfo(p), id=p.id;
+ const nm=a.name?agentCell(a.name,a.firm):`<span class="amut">no agent on file</span>`;
+ const tel=a.phone?`<a class="ain" href="tel:${esc(a.phone).replace(/[^0-9+]/g,'')}" title="Call ${esc(a.phone)}">📞 ${esc(a.phone)}</a>`:'';
+ const eml=a.email?`<a class="ain" href="mailto:${esc(a.email)}" title="Email ${esc(a.email)}">✉ ${esc(a.email)}</a>`:'';
+ const chips=`${a.contacted_at?`<span class="achip done" title="Contacted ${esc(a.contacted_at)}">✓ ${fmtWhen(a.contacted_at)}</span>`:''}${a.letters.length?`<span class="achip" title="${a.letters.length} saved letter(s)">✎ ${a.letters.length}</span>`:''}`;
+ return `<div class="cardagent" id="cardagent-${id}">`
+ +`<div class="ca-top">🧑💼 ${nm} ${chips}</div>`
+ +(tel||eml?`<div class="ainrow">${tel} ${eml}</div>`:'')
+ +`<div class="acts"><button class="abtn contactbtn" data-contact="${id}" title="Open the contact modal — write & save a letter (saved, not sent)">✎ Contact</button>`
+ +`<button class="abtn markbtn" data-mark="${id}" title="${a.contacted_at?'Update contacted date':'Mark contacted (saves the date)'}">${a.contacted_at?'↻':'✓'} Contacted</button></div>`
+ +`</div>`;
+}
// ---- Non-QM target set: unwarrantable condos + small multifamily (1–9u) + mixed-use ----
function isNonQM(p){
@@ -1542,9 +1564,10 @@ function toggleExpand(row){
const open=exp.classList.toggle('open'); row.classList.toggle('open',open);
const tog=row.querySelector('.exptog'); if(tog) tog.textContent=open?'▾':'▸';
}
-// In-place refresh after a CRM save — updates the Agent cell + the open panel WITHOUT a full
-// re-render (a full render() would collapse every expanded row).
-function rerenderRow(id){
+// In-place refresh after any CRM save — repaints EVERY visible surface for this listing (list
+// agent cell + open list-row panel + card block) WITHOUT a full render() (which would collapse
+// 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;
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');
@@ -1554,52 +1577,77 @@ function rerenderRow(id){
const body=exp.querySelector('.lexpbody'); if(body){ body.innerHTML=expandBody(p); body.dataset.loaded='1'; }
}
}
+ const cb=document.getElementById('cardagent-'+id); if(cb) cb.outerHTML=cardAgentBlock(p);
}
async function markContacted(id){
- try{ const j=await postJSON('/api/contacts/'+encodeURIComponent(id)+'/contacted',{}); if(j.contact){ window.CONTACTS[id]=j.contact; rerenderRow(id); toast('✓ marked contacted '+fmtWhen(j.contact.contacted_at)); } else toast('⚠ '+(j.error||'failed')); }
+ try{ const j=await postJSON('/api/contacts/'+encodeURIComponent(id)+'/contacted',{}); if(j.contact){ window.CONTACTS[id]=j.contact; refreshContactUI(id); repaintModal(id); toast('✓ marked contacted '+fmtWhen(j.contact.contacted_at)); } else toast('⚠ '+(j.error||'failed')); }
catch(e){ toast('⚠ '+e.message); }
}
async function saveAgentInfo(id){
- const inputs=[...document.querySelectorAll('.crmf')].filter(i=>i.dataset.id===id);
+ const scope=document.getElementById('composeM')&&document.getElementById('composeM').classList.contains('on')?document.getElementById('composeM'):document;
+ const inputs=[...scope.querySelectorAll('.crmf')].filter(i=>i.dataset.id===id);
const body={}; inputs.forEach(i=>body[i.dataset.info]=i.value.trim());
- try{ const j=await postJSON('/api/contacts/'+encodeURIComponent(id)+'/info',body); if(j.contact){ window.CONTACTS[id]=j.contact; rerenderRow(id); toast('💾 contact info saved'); } else toast('⚠ '+(j.error||'failed')); }
+ try{ const j=await postJSON('/api/contacts/'+encodeURIComponent(id)+'/info',body); if(j.contact){ window.CONTACTS[id]=j.contact; refreshContactUI(id); toast('💾 contact info saved'); } else toast('⚠ '+(j.error||'failed')); }
catch(e){ toast('⚠ '+e.message); }
}
async function deleteLetter(id,idx){
- try{ const j=await postJSON('/api/contacts/'+encodeURIComponent(id)+'/letter/'+idx+'/delete',{}); if(j.contact){ window.CONTACTS[id]=j.contact; rerenderRow(id); toast('letter deleted'); } }
+ try{ const j=await postJSON('/api/contacts/'+encodeURIComponent(id)+'/letter/'+idx+'/delete',{}); if(j.contact){ window.CONTACTS[id]=j.contact; refreshContactUI(id); repaintModal(id); toast('letter deleted'); } }
catch(e){ toast('⚠ '+e.message); }
}
-// Compose-and-SAVE a letter (never sends — sending is Steve-gated via George).
-function openCompose(id){
- const p=DATA.ranked.find(x=>x.id===id); if(!p) return; const a=agentInfo(p);
- const to=[a.name,a.email].filter(Boolean).join(' · ')||'this agent';
- const subj='Interest in '+(p.address||'your listing')+(p.city?', '+p.city:'');
- const draft=`Hi ${a.name||'there'},\n\nI'm interested in ${p.address||'your listing'}${p.city?' in '+p.city:''}${p.price?' (listed '+fmt(p.price)+')':''}. Could you share the setup — current rents / in-place NOI, any financing in place, and whether an offer near list would be entertained?\n\nThanks,\nFrank`;
- let m=document.getElementById('composeM');
- if(!m){ m=document.createElement('div'); m.id='composeM'; m.className='composeM'; document.body.appendChild(m); }
- m.innerHTML=`<div class="composeCard">
- <div class="composeH">✎ Letter to <b>${esc(to)}</b><button class="composeX" title="Close">✕</button></div>
- <div class="composeGate">⚑ Saved to this listing's contact log — <b>not sent</b>. Sending to an agent is Steve-gated (George external-send).</div>
- <input id="composeSubj" placeholder="Subject" value="${esc(subj)}">
- <textarea id="composeBody" rows="10">${esc(draft)}</textarea>
+function agentDraft(p,a){ return `Hi ${a.name||'there'},\n\nI'm interested in ${p.address||'your listing'}${p.city?' in '+p.city:''}${p.price?' (listed '+fmt(p.price)+')':''}. Could you share the setup — current rents / in-place NOI, any financing in place, and whether an offer near list would be entertained?\n\nThanks,\nFrank`; }
+// The full per-listing contact modal body (editable info + mark-contacted + compose/save + letter log).
+function contactModalBody(p){
+ const a=agentInfo(p), id=p.id, firm=a.firm;
+ const web=a.name?` <a class="flink" href="${gsearch('"'+a.name+'"'+(firm&&firm!=='Unknown'?' "'+firm+'"':'')+' real estate agent')}" target="_blank" rel="noopener noreferrer">find agent ↗</a>`:'';
+ const letters=a.letters.length?`<div class="crm-letters"><div class="deeplbl">Saved letters (${a.letters.length}) — saved, not sent</div>`
+ +a.letters.map((l,i)=>`<div class="lett"><div class="letth"><b>${esc(l.subject||'(no subject)')}</b><span class="letts">${fmtWhen(l.ts)}</span><button class="letdel" data-letdel="${id}" data-idx="${i}" title="Delete this saved letter">✕</button></div><div class="lettb">${esc(l.body||'')}</div></div>`).join('')+`</div>`:'';
+ return `<div class="composeCard">
+ <div class="composeH">✎ Agent contact — <b>${esc(p.address||'listing')}</b>${p.city?', '+esc(p.city):''}${web}<button class="composeX" title="Close">✕</button></div>
+ <div class="cm-info">
+ <input class="crmf" data-info="name" data-id="${id}" placeholder="Agent name" value="${esc(a.name||'')}">
+ <input class="crmf" data-info="phone" data-id="${id}" placeholder="Phone" value="${esc(a.phone||'')}">
+ <input class="crmf" data-info="email" data-id="${id}" placeholder="Email" value="${esc(a.email||'')}">
+ <button class="abtn saveinfobtn" data-saveinfo="${id}">💾 Save info</button>
+ <button class="abtn markbtn" data-mark="${id}">${a.contacted_at?'↻ Update contacted date':'✓ Mark contacted'}</button>
+ ${a.contacted_at?`<span class="achip done">contacted ${fmtWhen(a.contacted_at)}</span>`:''}
+ </div>
+ <div class="composeGate">⚑ Letters are SAVED to this listing's log — <b>not sent</b>. Sending to an agent is Steve-gated (George external-send).</div>
+ <input id="composeSubj" placeholder="Subject" value="${esc('Interest in '+(p.address||'your listing')+(p.city?', '+p.city:''))}">
+ <textarea id="composeBody" rows="8">${esc(agentDraft(p,a))}</textarea>
<div class="composeActs">
- <button id="composeSave" class="primary">💾 Save letter</button>
+ <button id="composeSave" class="primary" data-id="${id}">💾 Save letter</button>
<button id="composeCopy">📋 Copy</button>
<button class="composeX2">Cancel</button>
- </div></div>`;
- m.classList.add('on');
- m.querySelector('.composeX').onclick=closeCompose;
- m.querySelector('.composeX2').onclick=closeCompose;
- m.onclick=e=>{ if(e.target===m) closeCompose(); };
- m.querySelector('#composeCopy').onclick=()=>{ navigator.clipboard.writeText(m.querySelector('#composeBody').value); toast('📋 copied'); };
- m.querySelector('#composeSave').onclick=async()=>{
- const subject=m.querySelector('#composeSubj').value, bodyv=m.querySelector('#composeBody').value;
- try{ const j=await postJSON('/api/contacts/'+encodeURIComponent(id)+'/letter',{subject,body:bodyv,channel:'letter'});
- if(j.contact){ window.CONTACTS[id]=j.contact; rerenderRow(id); closeCompose(); toast('💾 letter saved to contact log'); }
- else toast('⚠ '+(j.error||'save failed')); }
- catch(e){ toast('⚠ '+e.message); }
+ </div>
+ ${letters}
+ </div>`;
+}
+// One modal, opens for EVERY listing from any surface. Its own click delegation (it lives on
+// document.body, outside #grid) handles save-info / mark / save-letter / delete-letter.
+function openContact(id){
+ const p=DATA.ranked.find(x=>x.id===id); if(!p) return;
+ let m=document.getElementById('composeM');
+ if(!m){ m=document.createElement('div'); m.id='composeM'; m.className='composeM'; document.body.appendChild(m); }
+ m.dataset.id=id; m.innerHTML=contactModalBody(p); m.classList.add('on');
+ m.onclick=async(e)=>{
+ if(e.target===m || e.target.closest('.composeX,.composeX2')){ closeCompose(); return; }
+ if(e.target.closest('#composeCopy')){ navigator.clipboard.writeText(m.querySelector('#composeBody').value); toast('📋 copied'); return; }
+ if(e.target.closest('#composeSave')){
+ const subject=m.querySelector('#composeSubj').value, body=m.querySelector('#composeBody').value;
+ try{ const j=await postJSON('/api/contacts/'+encodeURIComponent(id)+'/letter',{subject,body,channel:'letter'});
+ if(j.contact){ window.CONTACTS[id]=j.contact; refreshContactUI(id); repaintModal(id); toast('💾 letter saved to contact log'); }
+ else toast('⚠ '+(j.error||'save failed')); }catch(err){ toast('⚠ '+err.message); }
+ return;
+ }
+ if(e.target.closest('[data-saveinfo]')){ saveAgentInfo(id); return; }
+ if(e.target.closest('[data-mark]')){ markContacted(id); return; }
+ const ld=e.target.closest('[data-letdel]'); if(ld){ deleteLetter(id,+ld.dataset.idx); return; }
};
}
+// Repaint the open modal in place (after mark/letter changes) — preserves it being open.
+function repaintModal(id){ const m=document.getElementById('composeM'); const p=DATA.ranked.find(x=>x.id===id);
+ if(m && m.classList.contains('on') && m.dataset.id===id && p) m.innerHTML=contactModalBody(p); }
+function openCompose(id){ openContact(id); } // back-compat alias
function closeCompose(){ const m=document.getElementById('composeM'); if(m) m.classList.remove('on'); }
// ---- Broker-history pilot index ----
← 123b0e3 CRCP list view: sortable + fully hrefd rows, inline row expa
·
back to Commercialrealestate
·
Add gated Send-via-George button — creates a Gmail DRAFT (ne c164c3f →