← back to Rentv
audience/CRM: unify newsletter + sublease-broker contacts into /audience with auto-derived segments, contact profiles, and segment performance
40316ce5b0b32dd705da853e874ed2b2509b79f0 · 2026-07-23 13:46:26 -0700 · Steve Abrams
Files touched
A public/audience.htmlM public/desk.htmlM server.js
Diff
commit 40316ce5b0b32dd705da853e874ed2b2509b79f0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 23 13:46:26 2026 -0700
audience/CRM: unify newsletter + sublease-broker contacts into /audience with auto-derived segments, contact profiles, and segment performance
---
public/audience.html | 263 +++++++++++++++++++++++++++++++++++++++++++++++++++
public/desk.html | 2 +-
server.js | 113 ++++++++++++++++++++++
3 files changed, 377 insertions(+), 1 deletion(-)
diff --git a/public/audience.html b/public/audience.html
new file mode 100644
index 00000000..a4404b32
--- /dev/null
+++ b/public/audience.html
@@ -0,0 +1,263 @@
+<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>RENTV · Audience & CRM</title>
+<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@800&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
+<style>
+ :root{--red:#c8102e;--line:#232a32}*{box-sizing:border-box;margin:0}
+ body{background:#0d1014;color:#e7ecf1;font-family:'Inter',system-ui,sans-serif;font-size:13.5px}
+ a{color:inherit;text-decoration:none}
+ .top{position:sticky;top:0;z-index:900;display:flex;align-items:center;justify-content:space-between;height:52px;padding:0 14px;background:#0b0e12;border-bottom:1px solid var(--line)}
+ .burger{display:inline-flex;align-items:center;gap:9px;border:0;background:none;color:#e7ecf1;cursor:pointer;font:inherit;padding:6px 8px;border-radius:8px}.burger:hover{background:rgba(255,255,255,.08)}
+ .burger .lbl{font-size:12px;font-weight:800;letter-spacing:.14em;text-transform:uppercase;color:#8b939b}
+ .brand{position:absolute;left:50%;transform:translateX(-50%);font-family:'Playfair Display',serif;font-weight:800;font-size:19px}.brand span{color:var(--red)}
+ .user{width:35px;height:35px;border-radius:50%;border:1.5px solid #2a323b;background:#11161c;color:#cfd6dd;cursor:pointer;display:inline-flex;align-items:center;justify-content:center}.user:hover{border-color:var(--red);color:#fff}
+ .scrim{position:fixed;inset:0;background:rgba(6,9,12,.5);opacity:0;pointer-events:none;transition:.22s;z-index:950}.scrim.open{opacity:1;pointer-events:auto}
+ .drawer{position:fixed;top:0;left:0;height:100%;width:340px;max-width:88vw;background:#0b0e12;transform:translateX(-102%);transition:transform .26s cubic-bezier(.4,0,.2,1);z-index:951;display:flex;flex-direction:column;overflow-y:auto;box-shadow:2px 0 40px rgba(0,0,0,.4)}
+ .drawer.open{transform:none}
+ .drawer .dh{display:flex;justify-content:space-between;padding:16px 18px;border-bottom:1px solid var(--line)}.drawer .dh .w{font-family:'Playfair Display',serif;font-size:21px;font-weight:800}.drawer .dh .w span{color:var(--red)}.drawer .dh button{background:none;border:0;color:#8b949e;font-size:22px;cursor:pointer}
+ .gt{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:#6b747d;padding:14px 18px 6px}
+ .drawer nav a{display:block;padding:9px 18px;color:#dbe1e7;border-left:3px solid transparent}.drawer nav a:hover{background:#141a21;border-left-color:var(--red);color:#fff}
+ .shell{display:grid;grid-template-columns:210px 1fr;height:calc(100vh - 52px)}
+ .rail{border-right:1px solid var(--line);padding:14px 10px;display:flex;flex-direction:column;gap:6px;overflow-y:auto}
+ .rail .h{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:#6b747d;padding:8px 10px 4px}
+ .rail button{display:flex;align-items:center;gap:10px;width:100%;background:none;border:0;color:#c3cbd3;font:700 13px 'Inter';padding:10px 12px;border-radius:9px;cursor:pointer;text-align:left}
+ .rail button:hover{background:#12161c}.rail button.on{background:#12161c;color:#fff;box-shadow:inset 3px 0 0 var(--red)}
+ .rail button .i{font-size:15px;width:18px;text-align:center}.rail .c{margin-left:auto;font-size:11px;color:#8b939b}
+ .content{overflow:hidden;position:relative}
+ .view{position:absolute;inset:0;overflow-y:auto;display:none}.view.on{display:block}
+ .act{display:inline-flex;align-items:center;gap:6px;background:#12161c;border:1px solid #2a333d;border-radius:8px;padding:7px 11px;color:#dbe1e7;font-weight:700;font-size:12.5px;white-space:nowrap}.act:hover{border-color:var(--red);color:#fff}.act .i{font-size:14px}
+ .acts{display:flex;gap:7px;flex-wrap:wrap}
+ .field-link{color:#7cc0ff;border-bottom:1px dotted #3a5064}.field-link:hover{color:#a9d5ff}
+ .fresh{color:#6b747d;font-size:12px;padding:10px 14px}
+ /* master-detail (prospects + profiles) */
+ .md{display:grid;grid-template-columns:380px 1fr;height:100%}
+ .md .list{border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0}
+ .md .search{padding:12px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:9px}
+ .md .search input,.md .search select{width:100%;background:#11161c;border:1px solid var(--line);border-radius:9px;color:#e7ecf1;padding:9px 12px;font:inherit}
+ .srow{display:flex;gap:8px}.srow select{flex:1}
+ .md .rows{overflow-y:auto;flex:1}
+ .md .row{padding:11px 14px;border-bottom:1px solid #171d24;cursor:pointer}.md .row:hover{background:#141b22}.md .row.sel{background:#11161c;border-left:3px solid var(--red)}
+ .md .row .nm{font-weight:700;font-size:14px;display:flex;align-items:center;gap:7px}
+ .md .row .f{font-size:12px;color:#9aa4ad;margin-top:2px}
+ .md .row .when{font-size:11px;color:#6b747d;margin-top:3px}
+ .pill{display:inline-block;background:#0e141a;border:1px solid #2a333d;border-radius:20px;padding:2px 9px;font-size:11px;font-weight:700;color:#c3cbd3}
+ .pill.persona{color:#ffd39b;border-color:#4a3a24}
+ .md .detail{overflow-y:auto;padding:30px 36px}
+ .md .detail .k{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--red);font-weight:800}
+ .md .detail h2{font-size:30px;font-weight:800;margin:5px 0}.md .detail .meta{color:#9aa4ad;font-size:15px}
+ .md .detail .acts{margin:20px 0}
+ .md .detail .grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:16px}.md .detail .cell{background:#11161c;border:1px solid var(--line);border-radius:10px;padding:13px}.md .detail .cell .kk{font-size:11px;color:#8b939b;text-transform:uppercase}.md .detail .cell .vv{font-size:16px;font-weight:700;margin-top:3px;word-break:break-word}
+ .seglist{display:flex;flex-wrap:wrap;gap:7px;margin-top:14px}
+ .seg-chip{background:#0e141a;border:1px solid #2a333d;border-radius:8px;padding:5px 10px;font-size:12px;font-weight:700;color:#c3cbd3;cursor:pointer}.seg-chip:hover{border-color:var(--red);color:#fff}
+ .h3{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#8b939b;margin:22px 0 10px}
+ .lcard{background:#11161c;border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin-bottom:10px}
+ .lcard .a{font-weight:700}.lcard .b{font-size:12px;color:#9aa4ad;margin-top:3px}
+ /* segments grid */
+ .segwrap{padding:20px 22px}
+ .grp-h{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--red);font-weight:800;margin:22px 0 12px}.grp-h:first-child{margin-top:2px}
+ .segcards{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px}
+ .segcard{background:#11161c;border:1px solid var(--line);border-radius:12px;padding:15px;cursor:pointer;transition:.15s}.segcard:hover{border-color:var(--red);transform:translateY(-1px)}
+ .segcard .e{font-size:20px}.segcard .l{font-weight:800;font-size:15px;margin-top:6px}
+ .segcard .n{font-size:26px;font-weight:800;margin-top:6px}.segcard .n small{font-size:13px;color:#8b939b;font-weight:600}
+ .segcard .track{height:6px;background:#0e141a;border-radius:4px;overflow:hidden;margin-top:10px}.segcard .fill{height:100%;background:linear-gradient(90deg,var(--red),#e0455f)}
+ .segcard .g{font-size:11px;color:#8b939b;margin-top:8px}.segcard .g b{color:#37d67a}
+ /* analytics */
+ .an{padding:18px 22px}.kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px}.kpi{background:#11161c;border:1px solid var(--line);border-radius:12px;padding:16px}.kpi .k{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b939b}.kpi .v{font-size:28px;font-weight:800;margin-top:4px}
+ .p2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
+ .panel{background:#11161c;border:1px solid var(--line);border-radius:12px;padding:16px;margin-bottom:14px}.panel h3{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#8b939b;margin-bottom:14px}
+ .barwrap .bar{display:flex;align-items:center;gap:10px;margin-bottom:9px;cursor:pointer}.bar .lab{width:150px;font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.bar .track{flex:1;height:20px;background:#0e141a;border-radius:5px;overflow:hidden}.bar .fill{height:100%;background:linear-gradient(90deg,var(--red),#e0455f)}.bar .val{width:52px;text-align:right;font-weight:800}
+ .empty{color:#6b747d;font-size:13px;padding:30px 20px;line-height:1.7;max-width:560px}
+ @media(max-width:900px){.shell{grid-template-columns:1fr;height:auto}.md{grid-template-columns:1fr}.kpis,.p2{grid-template-columns:1fr 1fr}}
+</style></head>
+<body>
+<header class="top">
+ <button class="burger" id="burger" aria-label="Menu"><svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg><span class="lbl">Menu</span></button>
+ <a class="brand" href="/">REN<span>TV</span> · Audience</a>
+ <button class="user" id="user" title="Home"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="3.5"/><path d="M4.5 20a7.5 7.5 0 0 1 15 0" stroke-linecap="round"/></svg></button>
+</header>
+<div class="scrim" id="scrim"></div>
+<aside class="drawer" id="drawer">
+ <div class="dh"><span class="w">REN<span>TV</span></span><button id="dclose">✕</button></div>
+ <div class="gt">Intelligence</div><nav><a href="/desk">Broker & Owner Desk</a><a href="/audience">Audience & CRM</a><a href="/la-commercial">LA Commercial</a><a href="/markets">Markets</a></nav>
+ <div class="gt">Newsroom</div><nav><a href="/">Home</a><a href="/review">The REview</a><a href="/blog">Blog</a><a href="/subscribe">Newsletter</a></nav>
+</aside>
+<div class="shell">
+ <nav class="rail" id="rail">
+ <div class="h">Audience</div>
+ <button data-v="prospects" class="on"><span class="i">👥</span>Clients & Prospects <span class="c" id="cP">0</span></button>
+ <button data-v="segments"><span class="i">🎯</span>Audience Segments <span class="c" id="cS">0</span></button>
+ <button data-v="profiles"><span class="i">👤</span>Contact Profiles <span class="c" id="cC">0</span></button>
+ <button data-v="performance"><span class="i">📈</span>Segment Performance</button>
+ <div class="fresh" id="fresh"></div>
+ </nav>
+ <div class="content">
+ <div class="view on" id="v-prospects"></div>
+ <div class="view" id="v-segments"></div>
+ <div class="view" id="v-profiles"></div>
+ <div class="view" id="v-performance"></div>
+ </div>
+</div>
+<script>
+const $=s=>document.getElementById(s), enc=encodeURIComponent;
+const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
+const dstr=d=>d?new Date(d).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'—';
+const fmtWhen=d=>{try{return new Date(d).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}catch(e){return d||'—';}};
+const LI_P=(n,f)=>`https://www.linkedin.com/search/results/people/?keywords=${enc([n,f].filter(Boolean).join(' '))}`,LI_C=f=>`https://www.linkedin.com/search/results/companies/?keywords=${enc(f)}`,GOOG=q=>`https://www.google.com/search?q=${enc(q)}`,MAPS=q=>`https://www.google.com/maps/search/?api=1&query=${enc(q)}`;
+const A=(h,i,l)=>`<a class="act" href="${h}" target="_blank" rel="noopener" onclick="event.stopPropagation()"><span class="i">${i}</span>${l}</a>`;
+function contactActions(c){
+ return [
+ c.email?A('mailto:'+c.email,'✉️','Email'):'',
+ c.phone?A('tel:'+String(c.phone).replace(/[^\d+]/g,''),'📞','Call'):'',
+ A(LI_P(c.name,c.firm),'👤','LinkedIn'),
+ c.firm?A(LI_C(c.firm),'🏢','Firm'):'',
+ (c.listings&&c.listings[0]&&c.listings[0].listing_url&&/^https?:/i.test(c.listings[0].listing_url))?A(c.listings[0].listing_url,'🔗','Listing'):'',
+ A(GOOG(c.name+' '+(c.firm||'')+' commercial real estate'),'🔎','Google'),
+ ].filter(Boolean).join('');
+}
+const personaEmoji=c=>c.origin==='sublease'?'🔑':'📩';
+let A_={data:null,view:'prospects',selP:0,selC:0,filterSeg:null,sort:localStorage.getItem('aud.sort')||'newest'};
+// chrome
+$('burger').onclick=()=>{$('scrim').classList.add('open');$('drawer').classList.add('open');};
+const cd=()=>{$('scrim').classList.remove('open');$('drawer').classList.remove('open');};$('dclose').onclick=cd;$('scrim').onclick=cd;
+$('user').onclick=()=>location.href='/';
+async function jget(u,fb){try{const r=await fetch(u,{credentials:'same-origin'});if(!r.ok)return fb;return await r.json();}catch(e){return fb;}}
+(async()=>{
+ A_.data=await jget('/api/audience',{stats:{},segments:[],contacts:[]});
+ const st=A_.data.stats||{};
+ $('cP').textContent=A_.data.contacts.length;$('cS').textContent=A_.data.segments.length;$('cC').textContent=A_.data.contacts.length;
+ $('fresh').textContent=(st.newsletter||0)+' newsletter · '+(st.subleaseLinked||0)+' sublease brokers';
+ renderAll();
+})();
+function switchView(v){A_.view=v;
+ document.querySelectorAll('#rail [data-v]').forEach(x=>x.classList.toggle('on',x.dataset.v===v));
+ document.querySelectorAll('.view').forEach(x=>x.classList.toggle('on',x.id==='v-'+v));
+}
+$('rail').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;switchView(b.dataset.v);renderAll();});
+function renderAll(){prospects();segments();profiles();performance();}
+// ── sort + filter helpers shared by prospects/profiles ──
+function sortContacts(rows){
+ const s=A_.sort;const cp=rows.slice();
+ if(s==='name')cp.sort((a,b)=>a.name.localeCompare(b.name));
+ else if(s==='persona')cp.sort((a,b)=>a.persona.localeCompare(b.persona)||a.name.localeCompare(b.name));
+ else if(s==='segments')cp.sort((a,b)=>(b.segments.length-a.segments.length));
+ else cp.sort((a,b)=>String(b.created_at||'').localeCompare(String(a.created_at||''))); // newest
+ return cp;
+}
+function filteredContacts(term){
+ let r=A_.data.contacts.slice();
+ if(A_.filterSeg)r=r.filter(c=>c.segments.includes(A_.filterSeg));
+ const v=(term||'').trim().toLowerCase();
+ if(v)r=r.filter(c=>JSON.stringify(c).toLowerCase().includes(v));
+ return sortContacts(r);
+}
+// ── 1. Clients & Prospects (master-detail) ──
+function prospects(){
+ const segName=A_.filterSeg?(A_.data.segments.find(s=>s.key===A_.filterSeg)||{}).label:'';
+ $('v-prospects').innerHTML=`<div class="md"><div class="list"><div class="search">
+ <input id="pq" placeholder="Search name, email, firm, interest…">
+ <div class="srow">
+ <select id="psort">
+ <option value="newest">Newest first</option><option value="name">Name A→Z</option>
+ <option value="persona">Persona</option><option value="segments">Most segments</option>
+ </select>
+ </div>
+ ${A_.filterSeg?`<div class="fresh" style="padding:2px 2px 0">Filtered to <b style="color:#e7ecf1">${esc(segName)}</b> · <a class="field-link" id="clrf" href="#">clear</a></div>`:''}
+ <div class="fresh" id="pcount" style="padding:2px 2px 0"></div>
+ </div><div class="rows" id="prows"></div></div><div class="detail" id="pdetail"></div></div>`;
+ $('psort').value=A_.sort;
+ $('psort').onchange=e=>{A_.sort=e.target.value;localStorage.setItem('aud.sort',A_.sort);draw();};
+ $('pq').oninput=debounce(()=>{A_.selP=0;draw();},250);
+ if($('clrf'))$('clrf').onclick=e=>{e.preventDefault();A_.filterSeg=null;prospects();};
+ function detail(c){if(!c){$('pdetail').innerHTML='';return;}
+ const grid=[['Email',c.email||'—'],['Phone',c.phone||'—'],['Firm',c.firm||'—'],['Source',c.source||'—'],['Interest',c.interest||'—'],['Listings',c.origin==='sublease'?String(c.listings.length):'—']];
+ $('pdetail').innerHTML=`<div class="k">${esc(c.persona)}</div><h2>${esc(c.name)}</h2>
+ <div class="meta">${esc([c.firm,c.email].filter(Boolean).join(' · ')||'No contact info on file')}</div>
+ <div class="acts">${contactActions(c)}</div>
+ <div class="grid">${grid.map(([k,v])=>`<div class="cell"><div class="kk">${k}</div><div class="vv">${esc(v)}</div></div>`).join('')}</div>
+ <div class="h3">Segments (${c.segments.length})</div>
+ <div class="seglist">${c.segmentLabels.map(s=>`<span class="seg-chip" data-k="${esc(s.key)}">${s.emoji} ${esc(s.label)}</span>`).join('')}</div>
+ <div class="h3">Added</div><div class="pill">🕓 ${esc(fmtWhen(c.created_at))}</div>
+ <div style="margin-top:16px"><a class="act" id="toprofile" href="#"><span class="i">👤</span>Open full profile</a></div>`;
+ $('pdetail').querySelectorAll('.seg-chip').forEach(ch=>ch.onclick=()=>{A_.filterSeg=ch.dataset.k;A_.selP=0;prospects();});
+ $('toprofile').onclick=e=>{e.preventDefault();A_.selC=A_.data.contacts.indexOf(c);switchView('profiles');profiles();};
+ }
+ function draw(){const r=filteredContacts($('pq').value);
+ $('pcount').textContent=r.length?`${r.length} contact${r.length===1?'':'s'}`:'';
+ $('prows').innerHTML=r.length?r.map((c,i)=>`<div class="row${i===A_.selP?' sel':''}" data-i="${i}">
+ <div class="nm">${personaEmoji(c)} ${esc(c.name)} <span class="pill persona">${esc(c.persona)}</span></div>
+ <div class="f">${esc([c.firm,c.interest].filter(Boolean).join(' · ')||c.email||'—')}</div>
+ <div class="when">🕓 ${esc(fmtWhen(c.created_at))}</div></div>`).join('')
+ :emptyState();
+ $('prows').querySelectorAll('.row').forEach(row=>row.onclick=()=>{A_.selP=+row.dataset.i;$('prows').querySelectorAll('.row').forEach(x=>x.classList.remove('sel'));row.classList.add('sel');detail(r[A_.selP]);});
+ detail(r[A_.selP]||r[0]||null);
+ if(!r.length)$('pdetail').innerHTML='';
+ }
+ draw();
+}
+function emptyState(){
+ return `<div class="empty">No contacts yet.<br><br>This CRM fills automatically from two live streams:<br>• <b style="color:#e7ecf1">Newsletter signups</b> (the subscribe form across the site)<br>• <b style="color:#e7ecf1">Sublease listing brokers</b> (added on the <a class="field-link" href="/desk-admin">Sublease Desk</a>)<br><br>Every contact is auto-segmented by interest, source, and recency.</div>`;
+}
+const debounce=(fn,ms)=>{let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms);};};
+// ── 2. Audience Segments (grid of cards, grouped) ──
+function segments(){
+ const segs=A_.data.segments;
+ if(!segs.length){$('v-segments').innerHTML=`<div class="segwrap"><div class="empty">No segments yet — they appear as soon as the first contact lands.</div></div>`;return;}
+ const groups=[...new Set(segs.map(s=>s.group))];
+ const max=Math.max(...segs.map(s=>s.size),1);
+ $('v-segments').innerHTML=`<div class="segwrap">${groups.map(g=>`
+ <div class="grp-h">${esc(g)}</div>
+ <div class="segcards">${segs.filter(s=>s.group===g).map(s=>`
+ <div class="segcard" data-k="${esc(s.key)}">
+ <div class="e">${s.emoji}</div><div class="l">${esc(s.label)}</div>
+ <div class="n">${s.size}<small> · ${s.share}%</small></div>
+ <div class="track"><div class="fill" style="width:${Math.round(s.size/max*100)}%"></div></div>
+ <div class="g">${s.new30?`<b>${s.new30} new</b> in 30d`:' '}</div>
+ </div>`).join('')}</div>`).join('')}</div>`;
+ $('v-segments').querySelectorAll('.segcard').forEach(c=>c.onclick=()=>{A_.filterSeg=c.dataset.k;A_.selP=0;switchView('prospects');prospects();});
+}
+// ── 3. Contact Profiles (deep single-contact view + picker) ──
+function profiles(){
+ const all=sortContacts(A_.data.contacts);
+ if(!all.length){$('v-profiles').innerHTML=`<div class="md"><div class="list"><div class="search"><input id="cq" placeholder="Search contacts…"></div><div class="rows" id="crows"></div></div><div class="detail">${emptyState()}</div></div>`;return;}
+ $('v-profiles').innerHTML=`<div class="md"><div class="list"><div class="search"><input id="cq" placeholder="Search contacts…"><div class="fresh" id="ccount" style="padding:2px 2px 0"></div></div><div class="rows" id="crows"></div></div><div class="detail" id="cdetail"></div></div>`;
+ function detail(c){if(!c){$('cdetail').innerHTML='';return;}
+ const grid=[['Email',c.email||'—'],['Phone',c.phone||'—'],['Firm',c.firm||'—'],['Source',c.source||'—'],['Persona',c.persona],['First seen',dstr(c.created_at)]];
+ const listings=(c.listings||[]).filter(l=>l&&(l.address||l.submarket));
+ $('cdetail').innerHTML=`<div class="k">${esc(c.persona)}</div><h2>${esc(c.name)}</h2>
+ <div class="meta">${esc([c.firm,c.email,c.phone].filter(Boolean).join(' · ')||'No contact info on file')}</div>
+ <div class="acts">${contactActions(c)}</div>
+ <div class="grid">${grid.map(([k,v])=>`<div class="cell"><div class="kk">${k}</div><div class="vv">${esc(v)}</div></div>`).join('')}</div>
+ <div class="h3">Segment membership (${c.segments.length})</div>
+ <div class="seglist">${c.segmentLabels.map(s=>`<span class="seg-chip" data-k="${esc(s.key)}">${s.emoji} ${esc(s.label)}</span>`).join('')}</div>
+ ${listings.length?`<div class="h3">Sublease listings (${listings.length})</div>${listings.map(l=>`<div class="lcard"><div class="a">${esc(l.address||l.submarket||'Listing')}</div><div class="b">${esc([l.submarket,l.city,l.sqft?Number(l.sqft).toLocaleString()+' SF':'',l.asking_rate?'$'+Number(l.asking_rate).toFixed(2)+'/'+(l.rate_period||'yr')+' SF':''].filter(Boolean).join(' · '))}</div></div>`).join('')}`:''}
+ <div class="h3">Timeline</div><div class="pill">🕓 Added ${esc(fmtWhen(c.created_at))}</div>`;
+ $('cdetail').querySelectorAll('.seg-chip').forEach(ch=>ch.onclick=()=>{A_.filterSeg=ch.dataset.k;A_.selP=0;switchView('prospects');prospects();});
+ }
+ function draw(){let r=all;const v=($('cq').value||'').trim().toLowerCase();if(v)r=all.filter(c=>JSON.stringify(c).toLowerCase().includes(v));
+ $('ccount').textContent=`${r.length} contact${r.length===1?'':'s'}`;
+ $('crows').innerHTML=r.map((c,i)=>`<div class="row${c===all[A_.selC]?' sel':''}" data-i="${A_.data.contacts.indexOf(c)}"><div class="nm">${personaEmoji(c)} ${esc(c.name)}</div><div class="f">${esc([c.firm,c.email].filter(Boolean).join(' · ')||'—')}</div></div>`).join('')||'<div class="fresh">No matches.</div>';
+ $('crows').querySelectorAll('.row').forEach(row=>row.onclick=()=>{A_.selC=+row.dataset.i;$('crows').querySelectorAll('.row').forEach(x=>x.classList.remove('sel'));row.classList.add('sel');detail(A_.data.contacts[A_.selC]);});
+ }
+ $('cq').oninput=debounce(draw,200);draw();detail(A_.data.contacts[A_.selC]||all[0]);
+}
+// ── 4. Segment Performance (analytics) ──
+function performance(){
+ const st=A_.data.stats||{},segs=A_.data.segments;
+ const kpis=[['Total contacts',st.total||0],['New (30d)',st.newThisMonth||0],['Segments',st.segmentCount||0],['Sublease brokers',st.subleaseLinked||0]];
+ const top=segs.filter(s=>s.group!=='Lifecycle').slice().sort((a,b)=>b.size-a.size).slice(0,10);
+ const maxT=Math.max(...top.map(s=>s.size),1);
+ const life=segs.filter(s=>s.group==='Lifecycle');const maxL=Math.max(...life.map(s=>s.size),1);
+ const sources=Object.entries(st.sources||{}).sort((a,b)=>b[1]-a[1]);const maxS=Math.max(...sources.map(s=>s[1]),1);
+ $('v-performance').innerHTML=`<div class="an">
+ <div class="kpis">${kpis.map(([k,v])=>`<div class="kpi"><div class="k">${k}</div><div class="v">${Number(v).toLocaleString()}</div></div>`).join('')}</div>
+ <div class="panel"><h3>Top segments by size</h3><div class="barwrap" id="tbars">${top.length?top.map(s=>bar(s.emoji+' '+s.label,s.size,maxT,s.key)):'<div class="fresh">No segments yet.</div>'}</div></div>
+ <div class="p2">
+ <div class="panel"><h3>Lifecycle mix</h3><div class="barwrap" id="lbars">${life.length?life.map(s=>bar(s.label,s.size,maxL,s.key)):'<div class="fresh">—</div>'}</div></div>
+ <div class="panel"><h3>Acquisition source</h3><div class="barwrap">${sources.length?sources.map(([k,v])=>bar(prettify(k),v,maxS)):'<div class="fresh">—</div>'}</div></div>
+ </div></div>`;
+ $('v-performance').querySelectorAll('.bar[data-k]').forEach(b=>b.onclick=()=>{A_.filterSeg=b.dataset.k;A_.selP=0;switchView('prospects');prospects();});
+}
+const prettify=s=>String(s).replace(/[-_]+/g,' ').replace(/\b\w/g,c=>c.toUpperCase());
+function bar(lab,val,max,key){return `<div class="bar"${key?` data-k="${esc(key)}" style="cursor:pointer"`:''}><span class="lab" title="${esc(lab)}">${esc(lab)}</span><span class="track"><span class="fill" style="width:${Math.round(val/max*100)}%"></span></span><span class="val">${Number(val).toLocaleString()}</span></div>`;}
+</script>
+</body></html>
diff --git a/public/desk.html b/public/desk.html
index 5a969d03..e0a44dea 100644
--- a/public/desk.html
+++ b/public/desk.html
@@ -67,7 +67,7 @@
<div class="scrim" id="scrim"></div>
<aside class="drawer" id="drawer">
<div class="dh"><span class="w">REN<span>TV</span></span><button id="dclose">✕</button></div>
- <div class="gt">Intelligence</div><nav><a href="/desk">Broker & Owner Desk</a><a href="/la-commercial">LA Commercial</a><a href="/markets">Markets</a></nav>
+ <div class="gt">Intelligence</div><nav><a href="/desk">Broker & Owner Desk</a><a href="/audience">Audience & CRM</a><a href="/la-commercial">LA Commercial</a><a href="/markets">Markets</a></nav>
<div class="gt">Newsroom</div><nav><a href="/">Home</a><a href="/review">The REview</a><a href="/blog">Blog</a><a href="/conferences">Conferences</a></nav>
</aside>
<div class="shell">
diff --git a/server.js b/server.js
index bafc3a79..99ce2dc2 100644
--- a/server.js
+++ b/server.js
@@ -291,5 +291,118 @@ app.delete('/api/admin/sublease/:id', adminGate, (req, res) => {
return usreWrite('DELETE', '/api/admin/sublease/' + id, req, res);
});
+// ── AUDIENCE / CRM (/audience): unify the two real contact streams RENTV owns —
+// newsletter subscribers (subscribers.jsonl) + sublease listing brokers
+// (/api/sublease) — into one prospect list with AUTO-DERIVED segments.
+// Read-only: no writes, no external send-to-list. Gated by the same auth above. ──
+const CRE_INTERESTS = [
+ ['office', 'Office', '🏢', /office/i],
+ ['retail', 'Retail', '🛍️', /retail|shopping|mall|storefront/i],
+ ['industrial', 'Industrial', '🏭', /industrial|warehouse|logistics|distribution/i],
+ ['multifamily', 'Multifamily', '🏘️', /multifamily|apartment|residential|multi[- ]?family/i],
+ ['financing', 'Financing', '💰', /financ|loan|debt|capital|lending|mortgage/i],
+ ['development', 'Development', '🏗️', /develop|construction|ground[- ]?up/i],
+ ['hospitality', 'Hospitality', '🏨', /hotel|hospitality|resort|lodging/i],
+ ['land', 'Land', '🌄', /\bland\b|\bacre/i],
+];
+const PERSONA = {
+ newsletter: { label: 'Newsletter Subscriber', emoji: '📩' },
+ sublease: { label: 'Sublease Broker', emoji: '🔑' },
+};
+const LIFE = {
+ new: { label: 'New (last 30 days)', emoji: '✨' },
+ recent: { label: 'Recent (30–90 days)', emoji: '🟢' },
+ dormant: { label: 'Dormant (90 days+)', emoji: '💤' },
+};
+const prettySource = (s) => String(s || 'site').replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
+// key → { label, group, emoji } so the front end never has to re-derive segment names.
+function segMeta(key) {
+ const [ns, rest] = key.split(/:(.+)/);
+ if (ns === 'persona') return { key, group: 'Persona', label: (PERSONA[rest] || {}).label || rest, emoji: (PERSONA[rest] || {}).emoji || '👤' };
+ if (ns === 'interest') { if (rest === 'all-cre') return { key, group: 'Interest', label: 'All CRE', emoji: '🧭' }; const m = CRE_INTERESTS.find((x) => x[0] === rest); return { key, group: 'Interest', label: m ? m[1] : rest, emoji: m ? m[2] : '📌' }; }
+ if (ns === 'source') return { key, group: 'Source', label: prettySource(rest), emoji: '📍' };
+ if (ns === 'life') return { key, group: 'Lifecycle', label: (LIFE[rest] || {}).label || rest, emoji: (LIFE[rest] || {}).emoji || '🕓' };
+ return { key, group: 'Other', label: rest || key, emoji: '•' };
+}
+function deriveSegments(c) {
+ const segs = ['persona:' + c.origin];
+ const text = [c.interest, c.firm, c.notes, c.submarket, c.property_type].filter(Boolean).join(' ');
+ const hits = CRE_INTERESTS.filter(([, , , re]) => re.test(text)).map(([k]) => 'interest:' + k);
+ if (!hits.length && /all|western|every|general|\bcre\b/i.test(String(c.interest || ''))) hits.push('interest:all-cre');
+ segs.push(...hits);
+ if (c.source) segs.push('source:' + String(c.source).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''));
+ const days = c.created_at ? (Date.now() - new Date(c.created_at).getTime()) / 864e5 : 999;
+ segs.push('life:' + (days <= 30 ? 'new' : days <= 90 ? 'recent' : 'dormant'));
+ return [...new Set(segs)];
+}
+async function fetchSubleaseRows() {
+ try {
+ const r = await fetch(`${USRE}/api/sublease?state=CA&limit=500`, { headers: { Authorization: USRE_AUTH }, signal: AbortSignal.timeout(8000) });
+ if (!r.ok) return [];
+ const j = await r.json();
+ return Array.isArray(j.rows) ? j.rows : [];
+ } catch { return []; }
+}
+function buildAudience(subs, subleaseRows) {
+ const contacts = [];
+ // 1) Newsletter subscribers → prospects
+ subs.forEach((s, i) => {
+ if (!s || !s.email) return;
+ contacts.push({
+ id: 'sub-' + i, origin: 'newsletter', persona: PERSONA.newsletter.label,
+ name: (s.name || '').trim() || '(newsletter reader)', email: s.email, phone: '', firm: '',
+ interest: s.interest || '', source: s.source || 'site', created_at: s.at || null, listings: [],
+ });
+ });
+ // 2) Sublease listing brokers → prospects (deduped across listings by email|name+firm)
+ const byKey = new Map();
+ subleaseRows.forEach((x) => {
+ const name = (x.broker_name || x.firm_name || x.sublandlord || '').trim();
+ if (!name && !x.broker_email) return;
+ const key = (x.broker_email || (name + '|' + (x.firm_name || ''))).toLowerCase();
+ const listing = { address: x.address, city: x.city, submarket: x.submarket, sqft: x.sqft, asking_rate: x.asking_rate, rate_period: x.rate_period, lease_type: x.lease_type, listing_url: x.listing_url, sublandlord: x.sublandlord, created_at: x.created_at };
+ if (byKey.has(key)) {
+ const c = byKey.get(key); c.listings.push(listing);
+ if (x.created_at && (!c.created_at || x.created_at < c.created_at)) c.created_at = x.created_at;
+ return;
+ }
+ byKey.set(key, {
+ id: 'brk-' + (byKey.size), origin: 'sublease', persona: PERSONA.sublease.label,
+ name: name || '(broker)', email: x.broker_email || '', phone: x.broker_phone || '', firm: x.firm_name || '',
+ interest: [x.submarket, x.lease_type === 'direct' ? 'Direct Lease' : 'Sublease'].filter(Boolean).join(' '),
+ source: 'sublease-desk', submarket: x.submarket || '', property_type: x.property_type || '',
+ created_at: x.created_at || x.available_date || null, listings: [listing],
+ });
+ });
+ contacts.push(...byKey.values());
+ // Derive segments per contact
+ contacts.forEach((c) => { c.segments = deriveSegments(c); c.segmentLabels = c.segments.map((k) => segMeta(k)); });
+ // Segment roll-up
+ const segMap = new Map();
+ contacts.forEach((c) => { const isNew = c.segments.includes('life:new'); c.segments.forEach((k) => { const m = segMap.get(k) || { ...segMeta(k), size: 0, new30: 0 }; m.size++; if (isNew) m.new30++; segMap.set(k, m); }); });
+ const total = contacts.length || 1;
+ const groupOrder = { Persona: 0, Interest: 1, Source: 2, Lifecycle: 3, Other: 4 };
+ const segments = [...segMap.values()].map((m) => ({ ...m, share: +(m.size / total * 100).toFixed(1) }))
+ .sort((a, b) => (groupOrder[a.group] - groupOrder[b.group]) || (b.size - a.size));
+ const sources = {};
+ contacts.forEach((c) => { sources[c.source] = (sources[c.source] || 0) + 1; });
+ const stats = {
+ total: contacts.length,
+ newsletter: contacts.filter((c) => c.origin === 'newsletter').length,
+ subleaseLinked: contacts.filter((c) => c.origin === 'sublease').length,
+ newThisMonth: contacts.filter((c) => c.segments.includes('life:new')).length,
+ segmentCount: segments.length, sources,
+ };
+ return { generated_at: new Date().toISOString(), stats, segments, contacts };
+}
+app.get('/api/audience', async (_q, res) => {
+ let subs = [];
+ try { subs = fs.readFileSync(SUBS, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { /* none */ }
+ const subleaseRows = await fetchSubleaseRows();
+ res.set('Cache-Control', 'private, max-age=60');
+ res.json(buildAudience(subs, subleaseRows));
+});
+app.get('/audience', (_q, r) => r.sendFile(path.join(PUB, 'audience.html')));
+
app.use(express.static(PUB, { extensions: ['html'] }));
app.listen(PORT, () => console.log('rentv-v1 (full site, gated, live data) on ' + PORT));
← 29ad9294 auto-save: 2026-07-23T13:21:35 (3 files) — data/markets.json
·
back to Rentv
·
auto-save: 2026-07-23T13:51:43 (3 files) — data/markets.json 65b2eece →