← 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
M public/audience.htmlM server.js
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`:' '}</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`:' '}</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 →