[object Object]

← 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 &amp; 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,'&quot;')}" data-city="${(p.city||'').replace(/"/g,'&quot;')}" 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,'&quot;')}" data-city="${(p.city||'').replace(/"/g,'&quot;')}">📋 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 &amp; 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 →