[object Object]

← back to Rentv 2026

Audience: surface the 2,162-contact pr-intelligence CRM (was showing only empty newsletter+sublease); add Category segment by org type; segments view now collapsible tabs (open collapsed, persisted)

f6d63f544e00bf180cad0edf0a7d88e030b2573b · 2026-08-05 13:21:32 -0700 · Steve Abrams

Files touched

Diff

commit f6d63f544e00bf180cad0edf0a7d88e030b2573b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 5 13:21:32 2026 -0700

    Audience: surface the 2,162-contact pr-intelligence CRM (was showing only empty newsletter+sublease); add Category segment by org type; segments view now collapsible tabs (open collapsed, persisted)
---
 public/audience.html | 37 +++++++++++++++++++++++++----------
 server.js            | 55 ++++++++++++++++++++++++++++++++++++++++++----------
 2 files changed, 72 insertions(+), 20 deletions(-)

diff --git a/public/audience.html b/public/audience.html
index a4404b32..388a8075 100644
--- a/public/audience.html
+++ b/public/audience.html
@@ -55,6 +55,12 @@
   /* 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}
+  .seggroup{border:1px solid var(--line);border-radius:12px;margin:0 0 12px;overflow:hidden;background:#0e141a}
+  .grp-tab{width:100%;display:flex;align-items:center;gap:9px;background:#11161c;border:0;color:#e7ecf1;font:inherit;font-weight:800;font-size:13px;letter-spacing:.06em;text-transform:uppercase;padding:13px 16px;cursor:pointer;text-align:left}
+  .grp-tab:hover{background:#151b23}.seggroup.open .grp-tab{color:var(--red);border-bottom:1px solid var(--line)}
+  .grp-tab .caret{color:var(--red);font-size:12px;width:12px}
+  .grp-tab .cnt{margin-left:auto;font-weight:600;font-size:11px;letter-spacing:.02em;text-transform:none;color:#8b939b}
+  .seggroup .segcards{padding:14px}
   .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}
@@ -125,7 +131,7 @@ async function jget(u,fb){try{const r=await fetch(u,{credentials:'same-origin'})
   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';
+  $('fresh').textContent=(st.crm||0).toLocaleString()+' CRM contacts · '+(st.newsletter||0)+' newsletter · '+(st.subleaseLinked||0)+' sublease';
   renderAll();
 })();
 function switchView(v){A_.view=v;
@@ -204,15 +210,26 @@ function 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`:'&nbsp;'}</div>
-      </div>`).join('')}</div>`).join('')}</div>`;
+  // Collapsible tabs — each segment group opens/closes; default COLLAPSED, choice persisted.
+  const open=JSON.parse(localStorage.getItem('aud.segopen')||'{}');
+  $('v-segments').innerHTML=`<div class="segwrap">${groups.map(g=>{
+    const gs=segs.filter(s=>s.group===g);
+    const isOpen=!!open[g], gtot=gs.reduce((n,s)=>n+s.size,0);
+    return `<div class="seggroup${isOpen?' open':''}" data-g="${esc(g)}">
+      <button class="grp-tab" data-gt="${esc(g)}"><span class="caret">${isOpen?'▾':'▸'}</span> ${esc(g)} <span class="cnt">${gs.length} segment${gs.length===1?'':'s'} · ${gtot.toLocaleString()}</span></button>
+      <div class="segcards"${isOpen?'':' style="display:none"'}>${gs.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`:'&nbsp;'}</div>
+        </div>`).join('')}</div>
+    </div>`;}).join('')}</div>`;
+  $('v-segments').querySelectorAll('.grp-tab').forEach(t=>t.onclick=()=>{
+    const g=t.dataset.gt;open[g]=!open[g];localStorage.setItem('aud.segopen',JSON.stringify(open));
+    const grp=t.closest('.seggroup'),cards=grp.querySelector('.segcards'),now=open[g];
+    grp.classList.toggle('open',now);cards.style.display=now?'':'none';t.querySelector('.caret').textContent=now?'▾':'▸';
+  });
   $('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) ──
diff --git a/server.js b/server.js
index 59af8a0a..c3058f13 100644
--- a/server.js
+++ b/server.js
@@ -94,7 +94,7 @@ footer:not(#rentv-footer){display:none!important}
   <div class="rf-bar"><span>© 2026 RENTV.com — Commercial Real Estate News across the Western U.S.</span><span>All rights reserved.</span></div>
 </footer>`;
 // Internal/admin shells never get the customer footer.
-const INTERNAL_PAGE = /(^|\/)(admin|versions|consulting|press|desk|desk-admin|audience|social|backend|deals-log|summit-leads)(\.html)?$|\/(admin|versions|consulting|press)\//i;
+const INTERNAL_PAGE = /(^|\/)(admin|versions|consulting|press|desk|desk-admin|audience|social|backend|deals-log|closings|summit-leads)(\.html)?$|\/(admin|versions|consulting|press)\//i;
 function sendPage(res, absFile) {
   let html;
   try { html = fs.readFileSync(absFile, 'utf8'); }
@@ -115,7 +115,7 @@ function sendPage(res, absFile) {
   // hide their nav links from user-tier so the front page shows no dead links (zero-flash,
   // server-rendered off req.role — no dependency on JS or per-page nav edits).
   if (res.req && res.req.role !== 'admin' && html.indexOf('rentv-hide-admin-nav') === -1) {
-    const hideCss = '<style id="rentv-hide-admin-nav">a[href^="/la-commercial"],a[href^="/pulse"],a[href^="/sector"],a[href^="/hub"],a[href^="/brief"]{display:none!important}</style>';
+    const hideCss = '<style id="rentv-hide-admin-nav">a[href^="/la-commercial"],a[href^="/pulse"],a[href^="/sector"],a[href^="/hub"],a[href^="/brief"],a[href^="/closings"]{display:none!important}</style>';
     html = html.indexOf('</head>') !== -1 ? html.replace('</head>', hideCss + '</head>') : hideCss + html;
   }
   res.type('html').send(html);
@@ -815,10 +815,11 @@ app.get('/news/:id', (_q, r) => sendPage(r, path.join(PUB, 'article.html')));
 app.get('/review', (_q, r) => sendPage(r, path.join(PUB, 'review.html')));
 // Backend hub — admin-only landing linking every internal tool (the "Backend" toggle target)
 app.get('/backend', adminOnly, (_q, r) => sendPage(r, path.join(PUB, 'backend.html')));
-// Property Closings — CUSTOMER-FACING page of every reported closed sale (address · date ·
-// price), from the live deals feed filtered to txn_type=Sale (/api/deals?txn=Sale).
-// User-tier accessible (no adminOnly) — surfaced in the public nav alongside Deals.
-app.get('/closings', (_q, r) => sendPage(r, path.join(PUB, 'closings.html')));
+// Property Closings — BACKEND-ONLY editor (Steve 2026-08-05, TK-10251). Every reported
+// closed sale (address · date · price) from the live deals feed filtered to txn_type=Sale,
+// PLUS a manual override layer (add / edit / hide) served by /api/closings. Admin-only:
+// gated here, hidden from user-tier nav, and treated as an INTERNAL_PAGE. Not public.
+app.get('/closings', adminOnly, (_q, r) => sendPage(r, path.join(PUB, 'closings.html')));
 // 10 site VERSIONS — internal design/layout tool → admin-only
 app.get('/versions', adminOnly, (_q, r) => sendPage(r, path.join(PUB, 'versions', 'index.html')));
 // 10 ADMIN versions — dashboards over the real internal data → admin-only
@@ -1053,6 +1054,10 @@ app.get('/api/cre/crcp/stats', adminOnly, (req, res) => backendGet(CRCP, CRCP_AU
 //    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. ──
+// The pr-intelligence CRM (599 orgs / 2,162 people) is the richest contact stream RENTV owns —
+// surface it in /audience alongside newsletter + sublease. Its own rentv_pr pool + category labels.
+const prDb = require('./src/pr/db');
+const { ORG_TYPE_LABEL } = require('./src/pr/lib/taxonomy');
 const CRE_INTERESTS = [
   ['office', 'Office', '🏢', /office/i],
   ['retail', 'Retail', '🛍️', /retail|shopping|mall|storefront/i],
@@ -1066,6 +1071,7 @@ const CRE_INTERESTS = [
 const PERSONA = {
   newsletter: { label: 'Newsletter Subscriber', emoji: '📩' },
   sublease: { label: 'Sublease Broker', emoji: '🔑' },
+  crm: { label: 'CRE Contact (CRM)', emoji: '🏢' },
 };
 const LIFE = {
   new: { label: 'New (last 30 days)', emoji: '✨' },
@@ -1078,6 +1084,7 @@ 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 === 'orgtype') return { key, group: 'Category', label: ORG_TYPE_LABEL[rest] || rest.replace(/_/g, ' '), emoji: '🏷️' };
   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: '•' };
@@ -1088,6 +1095,7 @@ function deriveSegments(c) {
   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.organization_type) segs.push('orgtype:' + c.organization_type);
   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'));
@@ -1101,7 +1109,19 @@ async function fetchSubleaseRows() {
     return Array.isArray(j.rows) ? j.rows : [];
   } catch { return []; }
 }
-function buildAudience(subs, subleaseRows) {
+async function fetchCrmContacts() {
+  // The pr-intelligence CRM people joined to their org (category + metros). Degrades to [] if rentv_pr is down.
+  try {
+    return await prDb.rows(
+      `SELECT p.id, p.full_name, p.public_work_email AS email, p.exact_title, p.department,
+              o.display_name AS firm, o.organization_type, o.metros, o.state_presence, p.created_at
+         FROM pr_people p JOIN pr_organizations o ON o.id = p.organization_id
+        WHERE p.lifecycle_status NOT IN ('duplicate','suppressed')
+          AND o.lifecycle_status NOT IN ('duplicate','archived')
+        ORDER BY p.created_at DESC NULLS LAST LIMIT 3000`, []);
+  } catch { return []; }
+}
+function buildAudience(subs, subleaseRows, crmPeople) {
   const contacts = [];
   // 1) Newsletter subscribers → prospects
   subs.forEach((s, i) => {
@@ -1133,13 +1153,27 @@ function buildAudience(subs, subleaseRows) {
     });
   });
   contacts.push(...byKey.values());
+  // 3) pr-intelligence CRM people → prospects (the richest RENTV-owned contact stream)
+  (crmPeople || []).forEach((x) => {
+    const dept = String(x.department || '');
+    const isPR = /communication|agency|press|media|market/i.test(dept);
+    contacts.push({
+      id: 'crm-' + x.id, origin: 'crm',
+      persona: isPR ? 'PR / Comms Contact' : 'CRE Professional',
+      name: (x.full_name || '').trim() || '(contact)', email: x.email || '', phone: '',
+      firm: x.firm || '', title: x.exact_title || '',
+      interest: [ORG_TYPE_LABEL[x.organization_type] || '', (x.metros || []).join(' ')].filter(Boolean).join(' '),
+      source: 'crm', organization_type: x.organization_type || null, metros: x.metros || [],
+      created_at: x.created_at || null, listings: [],
+    });
+  });
   // 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 groupOrder = { Persona: 0, Category: 1, Interest: 2, Source: 3, Lifecycle: 4, Other: 5 };
   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 = {};
@@ -1148,6 +1182,7 @@ function buildAudience(subs, subleaseRows) {
     total: contacts.length,
     newsletter: contacts.filter((c) => c.origin === 'newsletter').length,
     subleaseLinked: contacts.filter((c) => c.origin === 'sublease').length,
+    crm: contacts.filter((c) => c.origin === 'crm').length,
     newThisMonth: contacts.filter((c) => c.segments.includes('life:new')).length,
     segmentCount: segments.length, sources,
   };
@@ -1156,9 +1191,9 @@ function buildAudience(subs, subleaseRows) {
 app.get('/api/audience', adminOnly, 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();
+  const [subleaseRows, crmPeople] = await Promise.all([fetchSubleaseRows(), fetchCrmContacts()]);
   res.set('Cache-Control', 'private, max-age=60');
-  res.json(buildAudience(subs, subleaseRows));
+  res.json(buildAudience(subs, subleaseRows, crmPeople));
 });
 app.get('/audience', adminOnly, (_q, r) => sendPage(r, path.join(PUB, 'audience.html')));
 

← 1658be9d auto-save: 2026-08-05T13:12:15 (5 files) — data/deals.json d  ·  back to Rentv 2026  ·  PR intel: better editorial-contact extraction for news publi ccbda3ed →