← back to Rentv
Add unified CRM console (/crm) — streamlined for easy use + sorting
47a5111ba8df1a3e4048dd1ee5a6d53564a644f7 · 2026-08-12 12:41:58 -0700 · Steve Abrams
- New /api/crm: merges owned contacts (newsletter subs + sublease brokers + pr-intelligence people via buildAudience) with LA commercial owners + CRCP commercial brokers into ONE normalized schema (type + created_at + search blob) + per-type counts; CA-DRE 382k/34k registries offered as in-console server-search (not merged client-side)
- New public/crm.html: single contact console with sort dropdown (newest/name/firm/type/city/segments), density slider, list/grid toggle, per-field column toggles, created date+time chips (admin rule), and drill links (LinkedIn/DRE/Google/Maps/email/call) — all localStorage-persisted
- Wired /crm card into backend.html hub (primary), keeps legacy /desk + /audience reachable
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M data/deals-registry.jsonM data/deals.jsonM data/markets.jsonM data/news.jsonM data/ticker.jsonM public/backend.htmlA public/crm.htmlM server.js
Diff
commit 47a5111ba8df1a3e4048dd1ee5a6d53564a644f7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 12 12:41:58 2026 -0700
Add unified CRM console (/crm) — streamlined for easy use + sorting
- New /api/crm: merges owned contacts (newsletter subs + sublease brokers + pr-intelligence people via buildAudience) with LA commercial owners + CRCP commercial brokers into ONE normalized schema (type + created_at + search blob) + per-type counts; CA-DRE 382k/34k registries offered as in-console server-search (not merged client-side)
- New public/crm.html: single contact console with sort dropdown (newest/name/firm/type/city/segments), density slider, list/grid toggle, per-field column toggles, created date+time chips (admin rule), and drill links (LinkedIn/DRE/Google/Maps/email/call) — all localStorage-persisted
- Wired /crm card into backend.html hub (primary), keeps legacy /desk + /audience reachable
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
data/deals-registry.json | 122 +++++++++----------
data/deals.json | 2 +-
data/markets.json | 10 +-
data/news.json | 2 +-
data/ticker.json | 6 +-
public/backend.html | 1 +
public/crm.html | 298 +++++++++++++++++++++++++++++++++++++++++++++++
server.js | 74 ++++++++++++
8 files changed, 444 insertions(+), 71 deletions(-)
diff --git a/data/deals-registry.json b/data/deals-registry.json
index 942625b1..a23605a3 100644
--- a/data/deals-registry.json
+++ b/data/deals-registry.json
@@ -1,5 +1,5 @@
{
- "updated_at": "2026-08-12T19:18:22.433Z",
+ "updated_at": "2026-08-12T19:38:25.277Z",
"total": 55,
"deals": {
"34086": {
@@ -1292,8 +1292,8 @@
"summary": "Shorenstein has closed on a Bay Area office buy, acquiring 550 Allerton, a 76.6k sf, Class A property in the heart of Downtown Redwood City. The asset was sold by Premia Capital."
},
"first_seen": "2026-07-23T04:07:13.798Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 494,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 495,
"revisions": 3,
"history": [
{
@@ -1764,8 +1764,8 @@
"acquirer": "Tailwind Investment Group"
},
"first_seen": "2026-07-23T04:07:13.798Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 494,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 495,
"revisions": 2,
"history": [
{
@@ -1842,8 +1842,8 @@
"summary": "Mosaic, a six building, 148.4k sf retail portfolio spanning multiple city blocks in Downtown Long Beach, traded hands in a recent $30 mil ($202/sf transaction. At the time of sale, the portfolio was 78% occupied."
},
"first_seen": "2026-07-23T04:07:13.798Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 494,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 495,
"revisions": 4,
"history": [
{
@@ -1981,8 +1981,8 @@
"summary": "RENTV.com - Commercial real estate news and property information window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', 'UA-148799896-1'); The FREE online platform to search for, watch and add real estate videos Home About Us Executive Subscriber Me"
},
"first_seen": "2026-07-23T04:07:13.798Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 494,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 495,
"revisions": 4,
"history": [
{
@@ -2120,8 +2120,8 @@
"summary": "A joint venture between Phoenix Capital Management and P.B. Bell has obtained a $51.8 mil senior loan refi on Everly Morrison Ranch, a 236-unit, Class A garden-style multifamily community in Gilbert, AZ."
},
"first_seen": "2026-07-23T04:07:13.798Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 494,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 495,
"revisions": 4,
"history": [
{
@@ -2259,8 +2259,8 @@
"summary": "In a Fresno multifamily trade, Maroa Park Apartments, a 248-unit community, was purchased by a private investor for $44 mil ($177.4k/unit). The asset, located at 475-585 West Sierra Ave, was sold by JBT Property Management."
},
"first_seen": "2026-07-23T04:07:13.798Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 494,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 495,
"revisions": 4,
"history": [
{
@@ -2403,8 +2403,8 @@
"summary": "Velo3D has leased a 288.7k sf facility in Livermore, CA in a $55.8 mil transaction. Velo3D is a leader in metal additive manufacturing."
},
"first_seen": "2026-08-05T19:53:22.976Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 493,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 494,
"revisions": 3,
"history": [
{
@@ -2514,8 +2514,8 @@
"summary": "SFF Realty recently acquired a four-story, 104.5k sf, Class A office/R&D building in Sunnyvale. Located at 384 Santa Trinita Ave, in the heart of Silicon Valley, the property was sold by Clarion Partners."
},
"first_seen": "2026-08-05T19:53:22.976Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 493,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 494,
"revisions": 4,
"history": [
{
@@ -2658,8 +2658,8 @@
"acquirer": "Granite Towers Equity Group"
},
"first_seen": "2026-08-05T19:53:22.976Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 493,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 494,
"revisions": 3,
"history": [
{
@@ -2764,8 +2764,8 @@
"summary": "Building 1 at CapRock Central Point III, a four-building, 2.7 msf speculative industrial development in Visalia, has been fully leased to an undisclosed global corporate tenant. The 1.27 msf commitment ranks among the largest industrial lease transactions completed in California's Central Valley in recent years."
},
"first_seen": "2026-08-05T19:53:22.976Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 493,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 494,
"revisions": 4,
"history": [
{
@@ -2903,8 +2903,8 @@
"summary": "A 782.8k sf industrial facility in Frederickson, WA traded hands in a recent $174 mil ($222/sf) transaction. The property is fully leased to Harbor Freight Tools, an American tool and equipment retailer and offers an average weighted lease term of 8.8 years."
},
"first_seen": "2026-08-05T19:53:22.976Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 493,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 494,
"revisions": 3,
"history": [
{
@@ -3014,8 +3014,8 @@
"acquirer": null
},
"first_seen": "2026-08-05T19:53:22.976Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 493,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 494,
"revisions": 5,
"history": [
{
@@ -3181,8 +3181,8 @@
"acquirer": "The Dinerstein Companies and PGIM"
},
"first_seen": "2026-08-05T19:53:22.976Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 493,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 494,
"revisions": 3,
"history": [
{
@@ -3292,8 +3292,8 @@
"summary": "A 28-unit coastal multifamily investment property located along Santa Monicas Ocean Avenue corridor sold for $23.46 mil, with the buyer assuming approximately $2.4 mil in transaction-related obligations, bringing the total consideration to approximately $26 mil ($928.5k/unit). The property, located at 901 Ocean Ave, w"
},
"first_seen": "2026-08-05T19:53:22.976Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 493,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 494,
"revisions": 4,
"history": [
{
@@ -3436,8 +3436,8 @@
"summary": "Longpoint Partners has purchased Eastpoint I & II, a two-building, 136.6k sf institutional-quality industrial business park in the highly sought-after Chandler submarket of Phoenix. The property, located at 3191 N."
},
"first_seen": "2026-08-05T19:53:22.976Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 493,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 494,
"revisions": 4,
"history": [
{
@@ -3580,8 +3580,8 @@
"acquirer": "Sentinel Real Estate"
},
"first_seen": "2026-08-05T19:53:22.976Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 493,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 494,
"revisions": 2,
"history": [
{
@@ -3658,8 +3658,8 @@
"summary": "A joint venture between PCCP and Stonemont has acquired a 38-asset, 5.9 msf industrial portfolio diversified across 14 U.S. logistics markets in 10 states. The portfolio, which was 95% occupied at time of sale, traded in an off-market deal."
},
"first_seen": "2026-08-05T19:53:22.976Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 493,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 494,
"revisions": 3,
"history": [
{
@@ -3769,8 +3769,8 @@
"summary": "GO Industrial has broken ground on a 1.1 msf industrial building in Phoenix. The project, located at 9797 W."
},
"first_seen": "2026-08-05T19:53:22.976Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 493,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 494,
"revisions": 4,
"history": [
{
@@ -3908,8 +3908,8 @@
"acquirer": "Gemdale USA"
},
"first_seen": "2026-08-05T19:53:22.976Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 493,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 494,
"revisions": 3,
"history": [
{
@@ -4019,8 +4019,8 @@
"summary": "Step Up Housing recently acquired two California multifamily communities totaling 406 units -- Copper Creek, a 268-unit community in Sacramento, and Terramonte at Foothill, a 138-unit community in Pomona for a combined price of $91.35 mil. Sack Capital Partners and Align Finance Partners provided structured financing"
},
"first_seen": "2026-08-05T19:53:22.976Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 493,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 494,
"revisions": 3,
"history": [
{
@@ -4130,8 +4130,8 @@
"summary": "San Diego-based ColRich acquired Avia 266, a 267-unit multifamily community in Mesa, AZ, in a $52.4 mil ($196k/unit) transaction, including the assumption of an existing HUD loan. The buyer intends to rename the property The Remy."
},
"first_seen": "2026-08-05T19:53:22.976Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 493,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 494,
"revisions": 2,
"history": [
{
@@ -4208,8 +4208,8 @@
"summary": "In a major institutional multifamily trade, a BlackRock-managed vehicle acquired an 11-property, 3,620-unit residential portfolio in Southern California for $1.625 bil ($449k/unit). The assets were sold by Camden Property Trust."
},
"first_seen": "2026-08-06T00:14:52.638Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 480,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 481,
"revisions": 4,
"history": [
{
@@ -4352,8 +4352,8 @@
"summary": "OC-based investor EEW Holdings LLC has purchased Bernardo Executive Center, a three-building, 173.1k sf office property in Rancho Bernardo, for $26.5 mil ($153/sf). The property is located at 16835, 16855 and 16875 W Bernardo Drive, south of Rancho Bernardo Rd on the west side of I-15."
},
"first_seen": "2026-08-06T00:14:52.638Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 480,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 481,
"revisions": 4,
"history": [
{
@@ -4496,8 +4496,8 @@
"summary": "A Steinhauer Properties affiliate has obtained a refinance loan in the amount of $107 mil on a 717.7k sf, LEED Silver-certified property in Seattle. The property, located at 255 South King St, consists of a 209.5k sf office tower and a 282-room Hilton Embassy Suites hotel with 10k sf of meeting and event space, a pool,"
},
"first_seen": "2026-08-07T00:18:03.700Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 408,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 409,
"revisions": 1,
"history": [
{
@@ -4596,8 +4596,8 @@
"summary": "Rockwell Property Company purchased Hayden Park, a 182-unit garden-style apartment community in Scottsdale, AZ, for $42 mil ($231k/unit). The property is located at 3015 North Hayden Rd, just outside of Old Town Scottsdale."
},
"first_seen": "2026-08-10T00:09:12.417Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 202,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 203,
"revisions": 1,
"history": [
{
@@ -4646,8 +4646,8 @@
"summary": "Rockefeller Group purchased a 34.1-acre logistics site in Glendale, AZ from IndiCap and their capital partner. Rockefeller Group and IndiCap will partner to develop West Olive Logistics Center, a 552k sf industrial building located at the northwest corner of West Olive Ave and North Reems Rd in Glendale."
},
"first_seen": "2026-08-07T01:58:16.825Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 403,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 404,
"revisions": 3,
"history": [
{
@@ -4772,8 +4772,8 @@
"summary": "BH Properties has purchased Tollway North Office Park, a 297.6k sf, seven-building business park in Plano, TX. The property covers more than 27 acres at 6501-6513 Windcrest Dr and 5200-5208 Tennyson Pkwy."
},
"first_seen": "2026-08-10T00:09:12.417Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 202,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 203,
"revisions": 1,
"history": [
{
@@ -4822,8 +4822,8 @@
"summary": "A two-hotel campus in Sunnyvale has been refinanced with a new loan for $105 mil. The loan is secured by the 186-key TETRA Hotel, Autograph Collection, and the 164-key AC Hotel by Marriott Sunnyvale Moffett Park, both institutional-quality properties that opened in 2022."
},
"first_seen": "2026-08-11T00:12:29.621Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 130,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 131,
"revisions": 1,
"history": [
{
@@ -4872,8 +4872,8 @@
"summary": "A two-property, 277k sf industrial portfolio in Metro Phoenix sold for $79.35 mil ($286/sf). The properties, featuring mission-critical facilities with highly specialized infrastructure and long-term lease commitments, were acquired by funds managed by Morgan Stanley Real Estate Investing (MSREI)."
},
"first_seen": "2026-08-11T00:12:29.621Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 130,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 131,
"revisions": 1,
"history": [
{
@@ -4922,8 +4922,8 @@
"summary": "Terra Nova Plaza, a fully leased, 96.1k sf retail property in Chula Vista, has traded for $30.4 mil ($316/sf). The property is located at 390 E H St, within a 301k sf, high-performing, grocery-anchored center."
},
"first_seen": "2026-08-12T00:15:48.170Z",
- "last_seen": "2026-08-12T19:18:22.433Z",
- "seen_count": 58,
+ "last_seen": "2026-08-12T19:38:25.277Z",
+ "seen_count": 59,
"revisions": 1,
"history": [
{
diff --git a/data/deals.json b/data/deals.json
index 798da7be..06903983 100644
--- a/data/deals.json
+++ b/data/deals.json
@@ -1,6 +1,6 @@
{
"source": "rentv.com — live deal listings",
- "fetched_at": "2026-08-12T19:18:22.433Z",
+ "fetched_at": "2026-08-12T19:38:25.277Z",
"count": 30,
"deals": [
{
diff --git a/data/markets.json b/data/markets.json
index 6bca25d2..709e1431 100644
--- a/data/markets.json
+++ b/data/markets.json
@@ -1,5 +1,5 @@
{
- "fetched_at": "2026-08-12T19:18:20.698Z",
+ "fetched_at": "2026-08-12T19:38:23.621Z",
"source": "Yahoo Finance — live (CBOE Treasury yields, Dow Jones/MSCI REIT indices)",
"curve": [
{
@@ -42,8 +42,8 @@
"reits": [
{
"label": "DJ U.S. Real Estate",
- "value": "389.96",
- "change_pct": 0.81,
+ "value": "390.27",
+ "change_pct": 0.89,
"dir": "up"
},
{
@@ -54,8 +54,8 @@
},
{
"label": "Vanguard REIT ETF (VNQ)",
- "value": "$97.17",
- "change_pct": 0.06,
+ "value": "$97.27",
+ "change_pct": 0.16,
"dir": "up"
}
],
diff --git a/data/news.json b/data/news.json
index 8906f204..6f025d39 100644
--- a/data/news.json
+++ b/data/news.json
@@ -1,6 +1,6 @@
{
"source": "rentv.com — live",
- "fetched_at": "2026-08-12T19:18:20.437Z",
+ "fetched_at": "2026-08-12T19:38:23.375Z",
"count": 30,
"items": [
{
diff --git a/data/ticker.json b/data/ticker.json
index 49ad08f3..decb57a8 100644
--- a/data/ticker.json
+++ b/data/ticker.json
@@ -1,5 +1,5 @@
{
- "fetched_at": "2026-08-12T19:18:20.668Z",
+ "fetched_at": "2026-08-12T19:38:23.593Z",
"la_office_vac": {
"label": "LA OFFICE VAC",
"value": "23.1%",
@@ -14,8 +14,8 @@
},
"cre_index": {
"label": "CRE INDEX",
- "value": "4,098",
- "change_pct": 0.81,
+ "value": "4,102",
+ "change_pct": 0.89,
"dir": "up",
"source": "Dow Jones U.S. Real Estate"
},
diff --git a/public/backend.html b/public/backend.html
index c2ed222d..71db6062 100644
--- a/public/backend.html
+++ b/public/backend.html
@@ -34,6 +34,7 @@
<div class="sect">Intelligence & CRM</div>
<div class="grid">
+ <a class="card" href="/crm"><div class="ico">👥</div><b>Unified CRM</b><span>One console — subscribers, brokers, owners, PR contacts · sort + density + drill</span></a>
<a class="card" href="/desk"><div class="ico">🗂️</div><b>Broker & Owner Desk</b><span>382k brokers · 34k firms · sublease registry</span></a>
<a class="card" href="/summit-leads"><div class="ico">🎟️</div><b>Summit Leads</b><span>Invite + sponsor-target lists from the registry</span></a>
<a class="card" href="/audience"><div class="ico">🎯</div><b>Audience & CRM</b><span>Subscribers + sublease brokers, auto-segmented</span></a>
diff --git a/public/crm.html b/public/crm.html
new file mode 100644
index 00000000..1cf357bb
--- /dev/null
+++ b/public/crm.html
@@ -0,0 +1,298 @@
+<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>RENTV · 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;--cols:3;--fs:13.5px}
+ *{box-sizing:border-box;margin:0}
+ body{background:#0d1014;color:#e7ecf1;font-family:'Inter',system-ui,sans-serif;font-size:var(--fs)}
+ 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:320px;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}
+ .drawer nav a{display:block;padding:10px 18px;color:#dbe1e7;border-left:3px solid transparent}.drawer nav a:hover{background:#141a21;border-left-color:var(--red);color:#fff}
+ .gt{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:#6b747d;padding:14px 18px 6px}
+ .shell{display:grid;grid-template-columns:216px 1fr;height:calc(100vh - 52px)}
+ .rail{border-right:1px solid var(--line);padding:12px 10px;display:flex;flex-direction:column;gap:5px;overflow-y:auto}
+ .rail .h{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:#6b747d;padding:10px 10px 4px}
+ .rail button.chip{display:flex;align-items:center;gap:10px;width:100%;background:none;border:0;color:#c3cbd3;font:700 13px 'Inter';padding:9px 12px;border-radius:9px;cursor:pointer;text-align:left}
+ .rail button.chip:hover{background:#12161c}.rail button.chip.on{background:#12161c;color:#fff;box-shadow:inset 3px 0 0 var(--red)}
+ .rail button.chip .i{font-size:15px;width:18px;text-align:center}.rail .c{margin-left:auto;font-size:11px;color:#8b939b}
+ .content{overflow:hidden;position:relative;display:flex;flex-direction:column}
+ /* control bar */
+ .controls{display:flex;flex-wrap:wrap;align-items:center;gap:10px;padding:11px 14px;border-bottom:1px solid var(--line);background:#0b0e12}
+ .controls input[type=search],.controls select{background:#11161c;border:1px solid var(--line);border-radius:9px;color:#e7ecf1;padding:8px 11px;font:inherit}
+ .controls input[type=search]{flex:1;min-width:180px}
+ .controls label{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:#9aa4ad}
+ .controls input[type=range]{accent-color:var(--red);width:110px}
+ .seg-toggle{display:inline-flex;border:1px solid var(--line);border-radius:9px;overflow:hidden}
+ .seg-toggle button{background:#11161c;border:0;color:#9aa4ad;padding:8px 12px;cursor:pointer;font:700 12px 'Inter'}
+ .seg-toggle button.on{background:var(--red);color:#fff}
+ .fieldtoggles{display:flex;flex-wrap:wrap;gap:6px}
+ .ft{border:1px solid var(--line);background:#11161c;color:#8b939b;border-radius:20px;padding:4px 10px;font-size:11px;font-weight:700;cursor:pointer}
+ .ft.on{background:#0e141a;color:#e7ecf1;border-color:var(--red)}
+ .countline{padding:7px 14px;font-size:12px;color:#6b747d;border-bottom:1px solid #171d24}
+ .main{flex:1;display:grid;grid-template-columns:1fr 0;min-height:0;transition:grid-template-columns .2s}
+ .main.detail-open{grid-template-columns:1fr 420px}
+ .listwrap{overflow-y:auto;min-height:0}
+ /* LIST mode */
+ .rows .row{display:flex;align-items:flex-start;gap:12px;padding:11px 16px;border-bottom:1px solid #171d24;cursor:pointer}
+ .rows .row:hover{background:#141b22}.rows .row.sel{background:#11161c;box-shadow:inset 3px 0 0 var(--red)}
+ .row .nm{font-weight:700;font-size:calc(var(--fs) + .5px);display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+ .row .f{font-size:12px;color:#9aa4ad;margin-top:2px}
+ .row .when{font-size:11px;color:#6b747d;margin-top:3px;white-space:nowrap}
+ .row .rmeta{flex:1;min-width:0}
+ .badge{display:inline-block;background:#0e141a;border:1px solid #2a333d;border-radius:20px;padding:2px 9px;font-size:10.5px;font-weight:700;color:#c3cbd3;white-space:nowrap}
+ .badge.t-newsletter{color:#8fd3ff;border-color:#274a5e}.badge.t-sublease{color:#ffd39b;border-color:#4a3a24}
+ .badge.t-crm{color:#c6b3ff;border-color:#3d3357}.badge.t-commercial{color:#8fe3b0;border-color:#245039}
+ .badge.t-owner{color:#ffb0c2;border-color:#5a2733}.badge.t-brokers,.badge.t-firms{color:#cfd6dd}
+ /* GRID mode */
+ .grid .rows{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px;padding:14px}
+ .grid .row{flex-direction:column;border:1px solid var(--line);border-radius:12px;background:#11161c;gap:6px}
+ .grid .row.sel{border-color:var(--red)}
+ .drill{display:flex;flex-wrap:wrap;gap:6px;margin-top:7px}
+ .drill a{display:inline-flex;align-items:center;gap:5px;background:#12161c;border:1px solid #2a333d;border-radius:8px;padding:5px 9px;color:#dbe1e7;font-weight:700;font-size:11.5px;white-space:nowrap}
+ .drill a:hover{border-color:var(--red);color:#fff}
+ /* detail */
+ .detail{overflow-y:auto;border-left:1px solid var(--line);padding:24px 26px;display:none}
+ .main.detail-open .detail{display:block}
+ .detail .k{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--red);font-weight:800}
+ .detail h2{font-size:24px;font-weight:800;margin:5px 0}.detail .meta{color:#9aa4ad;font-size:14px}
+ .detail .close{float:right;background:none;border:0;color:#8b949e;font-size:20px;cursor:pointer}
+ .detail .grid2{display:grid;grid-template-columns:1fr 1fr;gap:11px;margin-top:16px}
+ .detail .cell{background:#0e141a;border:1px solid var(--line);border-radius:10px;padding:12px}.detail .cell .kk{font-size:10.5px;color:#8b939b;text-transform:uppercase}.detail .cell .vv{font-size:15px;font-weight:700;margin-top:3px;word-break:break-word}
+ .detail .acts{margin:18px 0}
+ .seglist{display:flex;flex-wrap:wrap;gap:6px;margin-top:14px}
+ .seg-chip{background:#0e141a;border:1px solid #2a333d;border-radius:8px;padding:4px 9px;font-size:11.5px;font-weight:700;color:#c3cbd3}
+ .empty{padding:50px 20px;text-align:center;color:#6b747d}
+ .hint{padding:9px 14px;font-size:12px;color:#8b939b;background:#10161d;border-bottom:1px solid var(--line)}
+</style>
+</head>
+<body>
+<div class="top">
+ <button class="burger" id="burger"><span style="font-size:18px">☰</span><span class="lbl">Menu</span></button>
+ <a href="/crm" class="brand">REN<span>TV</span> · CRM</a>
+ <button class="user" id="user" title="Backend">◎</button>
+</div>
+<div class="scrim" id="scrim"></div>
+<aside class="drawer" id="drawer">
+ <div class="dh"><div class="w">REN<span>TV</span></div><button id="dclose">✕</button></div>
+ <div class="gt">CRM</div>
+ <nav>
+ <a href="/crm">👥 Unified CRM</a>
+ <a href="/desk">🗂️ Legacy Desk</a>
+ <a href="/audience">📩 Legacy Audience</a>
+ </nav>
+ <div class="gt">Admin</div>
+ <nav>
+ <a href="/backend">⚙️ Backend</a>
+ <a href="/desk-admin">✍️ Desk Admin</a>
+ <a href="/">← Site</a>
+ </nav>
+</aside>
+
+<div class="shell">
+ <nav class="rail" id="rail">
+ <div class="h">Contact Types</div>
+ <div id="typeChips"></div>
+ <div class="h">Registries</div>
+ <div id="regChips"></div>
+ </nav>
+ <div class="content">
+ <div class="controls">
+ <input type="search" id="q" placeholder="Search name, firm, email, city…">
+ <select id="sort" title="Sort">
+ <option value="newest">Newest</option>
+ <option value="name">Name A–Z</option>
+ <option value="firm">Firm A–Z</option>
+ <option value="type">Type</option>
+ <option value="city">City A–Z</option>
+ <option value="segments">Segments (most)</option>
+ </select>
+ <div class="seg-toggle" id="modeToggle">
+ <button data-mode="list" class="on">☰ List</button>
+ <button data-mode="grid">▦ Grid</button>
+ </div>
+ <label title="Density">🔍 <input type="range" id="dens" min="1" max="6" step="1" value="3"></label>
+ <div class="fieldtoggles" id="fieldToggles"></div>
+ </div>
+ <div class="countline" id="countline"></div>
+ <div class="hint" id="hint" style="display:none"></div>
+ <div class="main" id="main">
+ <div class="listwrap"><div class="rows" id="rows"></div></div>
+ <div class="detail" id="detail"></div>
+ </div>
+ </div>
+</div>
+
+<script>
+const $=s=>document.getElementById(s), enc=encodeURIComponent;
+const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
+// drill-link builders (every data point hrefs to its deeper data)
+const LI_P=(n,f)=>`https://www.linkedin.com/search/results/people/?keywords=${enc([n,f].filter(Boolean).join(' '))}`;
+const LI_C=f=>`https://www.linkedin.com/search/results/companies/?keywords=${enc(f)}`;
+const GOOG=q=>`https://www.google.com/search?q=${enc(q)}`;
+const MAPS=q=>`https://www.google.com/maps/search/?api=1&query=${enc(q)}`;
+const DRE=l=>`https://www2.dre.ca.gov/PublicASP/pplinfo.asp?License_id=${enc(l)}`;
+const fmtWhen=d=>{ if(!d) return '—'; const t=new Date(d); if(isNaN(t)) return '—';
+ return t.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); };
+const A=(h,i,l)=>h?`<a href="${h}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()"><span>${i}</span>${l}</a>`:'';
+
+// ── persisted UI state ──
+const LS=(k,d)=>{try{const v=localStorage.getItem('crm.'+k);return v==null?d:v;}catch{return d;}};
+const setLS=(k,v)=>{try{localStorage.setItem('crm.'+k,v);}catch{}};
+const FIELDS=[['type','Type'],['firm','Firm'],['email','Email'],['phone','Phone'],['city','City'],['created','Created'],['drill','Actions']];
+let S={
+ type: LS('type','all'), q:'', sort: LS('sort','newest'), mode: LS('mode','list'),
+ dens: +LS('dens','3'), sel:null,
+ fields: (()=>{ try{return JSON.parse(LS('fields',''))||null;}catch{return null;} })() || {type:1,firm:1,email:1,phone:1,city:1,created:1,drill:1},
+};
+let DATA={contacts:[],counts:{all:0},types:[],registries:[],segments:[]};
+let REG={}; // cached registry search results by type
+
+// ── chrome ──
+const openD=()=>{$('scrim').classList.add('open');$('drawer').classList.add('open');};
+const closeD=()=>{$('scrim').classList.remove('open');$('drawer').classList.remove('open');};
+$('burger').onclick=openD;$('user').onclick=()=>location.href='/backend';$('dclose').onclick=closeD;$('scrim').onclick=closeD;
+
+// ── density → CSS vars (list: font scale · grid: columns) ──
+function applyDensity(){
+ document.documentElement.style.setProperty('--cols', String(8 - S.dens)); // 1→7cols(dense) .. 6→2cols
+ document.documentElement.style.setProperty('--fs', (11.5 + S.dens*0.9).toFixed(1)+'px');
+}
+
+// ── data ──
+async function load(){
+ try{ const r=await fetch('/api/crm',{credentials:'same-origin'}); DATA=await r.json(); }
+ catch{ DATA={contacts:[],counts:{all:0},types:[],registries:[],segments:[],error:'load failed'}; }
+ renderRail(); render();
+}
+function renderRail(){
+ const tc=$('typeChips');
+ const chips=[['all','All','🌐']].concat((DATA.types||[]).map(t=>[t.key,t.label,t.emoji]));
+ tc.innerHTML=chips.map(([k,l,e])=>`<button class="chip ${S.type===k?'on':''}" data-type="${k}"><span class="i">${e}</span>${esc(l)}<span class="c">${DATA.counts[k]!=null?Number(DATA.counts[k]).toLocaleString():''}</span></button>`).join('');
+ $('regChips').innerHTML=(DATA.registries||[]).map(t=>`<button class="chip ${S.type===t.key?'on':''}" data-type="${t.key}"><span class="i">${t.emoji}</span>${esc(t.label)}</button>`).join('');
+ document.querySelectorAll('#rail .chip').forEach(b=>b.onclick=()=>{ S.type=b.dataset.type; setLS('type',S.type); S.sel=null; document.querySelectorAll('#rail .chip').forEach(x=>x.classList.toggle('on',x===b)); render(); });
+}
+function renderFieldToggles(){
+ $('fieldToggles').innerHTML=FIELDS.map(([k,l])=>`<button class="ft ${S.fields[k]?'on':''}" data-f="${k}">${l}</button>`).join('');
+ document.querySelectorAll('#fieldToggles .ft').forEach(b=>b.onclick=()=>{ S.fields[b.dataset.f]=S.fields[b.dataset.f]?0:1; setLS('fields',JSON.stringify(S.fields)); b.classList.toggle('on'); render(); });
+}
+const isRegistry=k=>(DATA.registries||[]).some(r=>r.key===k);
+
+// current working rows (client contacts, or cached registry search rows)
+async function currentRows(){
+ if(isRegistry(S.type)){
+ const term=S.q.trim();
+ if(term.length<2){ return {rows:[], hint:'Type at least 2 characters to search the '+(DATA.registries.find(r=>r.key===S.type)?.label||'registry')+' (382k+ rows — server search).'}; }
+ const key=S.type+'|'+term;
+ if(!REG[key]){
+ const ep=DATA.registries.find(r=>r.key===S.type).endpoint;
+ try{ const r=await fetch(ep+'?state=CA&q='+enc(term)+'&limit=100',{credentials:'same-origin'}); const j=await r.json();
+ REG[key]=(j.rows||[]).map((x,i)=>({ id:S.type+'-'+i, type:S.type, name:x.name||x.firm_name||'(record)', firm:x.firm_name||'', email:x.email||'', phone:x.phone||'', city:x.city||x.hq_city||'', state:x.state_code||'CA', title:x.license_type||'', dre_license:x.license_no||'', created_at:null, agent_count:x.agent_count, source:'ca-dre' }));
+ }catch{ REG[key]=[]; }
+ }
+ return {rows:REG[key], hint:REG[key].length?'':'No matches in the '+S.type+' registry for “'+esc(term)+'”.'};
+ }
+ let rows=DATA.contacts.slice();
+ if(S.type!=='all') rows=rows.filter(c=>c.type===S.type);
+ const term=S.q.trim().toLowerCase();
+ if(term) rows=rows.filter(c=>(c._s||[c.name,c.firm,c.email,c.city].filter(Boolean).join(' ').toLowerCase()).includes(term));
+ return {rows, hint:''};
+}
+function sortRows(rows){
+ const s=S.sort, cp=rows.slice(), cmp=(a,b)=>String(a||'').localeCompare(String(b||''));
+ if(s==='name') cp.sort((a,b)=>cmp(a.name,b.name));
+ else if(s==='firm') cp.sort((a,b)=>cmp(a.firm,b.firm)||cmp(a.name,b.name));
+ else if(s==='type') cp.sort((a,b)=>cmp(a.type,b.type)||cmp(a.name,b.name));
+ else if(s==='city') cp.sort((a,b)=>cmp(a.city,b.city)||cmp(a.name,b.name));
+ else if(s==='segments') cp.sort((a,b)=>((b.segments||[]).length-(a.segments||[]).length)||cmp(a.name,b.name));
+ else cp.sort((a,b)=>String(b.created_at||'').localeCompare(String(a.created_at||''))); // newest
+ return cp;
+}
+function drillFor(c){
+ const links=[];
+ if(c.type==='owner'){ const ad=[c.name,c.city].filter(Boolean).join(', ');
+ links.push(A(MAPS(ad),'📍','Map'), A(GOOG(ad+' '+(c.title||'')+' owner'),'🔎','Owner'), c.ain?A('https://portal.assessor.lacounty.gov/parceldetail/'+enc(c.ain),'🏛️','Assessor'):''); }
+ else {
+ if(c.email) links.push(A('mailto:'+c.email,'✉️','Email'));
+ if(c.phone) links.push(A('tel:'+String(c.phone).replace(/[^\d+]/g,''),'📞','Call'));
+ if(c.website) links.push(A(/^https?:/i.test(c.website)?c.website:'https://'+c.website,'🌐','Site'));
+ links.push(A(c.linkedin||LI_P(c.name,c.firm),'👤','LinkedIn'));
+ if(c.firm) links.push(A(LI_C(c.firm),'🏢','Firm'));
+ if(c.dre_license) links.push(A(DRE(c.dre_license),'🪪','DRE'));
+ (c.listings||[]).forEach(l=>{ if(l.listing_url&&/^https?:/i.test(l.listing_url)) links.push(A(l.listing_url,'🔗','Listing')); });
+ links.push(A(GOOG(c.name+' '+(c.firm||'')+' commercial real estate'),'🔎','Google'));
+ }
+ return links.filter(Boolean).join('');
+}
+function typeLabel(t){ const m=(DATA.types||[]).concat(DATA.registries||[]).find(x=>x.key===t); return m?m.label:t; }
+function rowHTML(c){
+ const F=S.fields;
+ const bits=[];
+ if(F.firm&&c.firm) bits.push(esc(c.firm));
+ if(F.city&&c.city) bits.push(esc(c.city));
+ if(F.email&&c.email) bits.push(esc(c.email));
+ if(F.phone&&c.phone) bits.push(esc(c.phone));
+ return `<div class="row ${S.sel===c.id?'sel':''}" data-id="${esc(c.id)}">
+ <div class="rmeta">
+ <div class="nm">${esc(c.name)}${F.type?`<span class="badge t-${esc(c.type)}">${esc(typeLabel(c.type))}</span>`:''}</div>
+ ${bits.length?`<div class="f">${bits.join(' · ')}</div>`:''}
+ ${F.created?`<div class="when" title="${esc(c.created_at||'')}">🕓 ${esc(fmtWhen(c.created_at))}</div>`:''}
+ ${F.drill?`<div class="drill">${drillFor(c)}</div>`:''}
+ </div>
+ </div>`;
+}
+async function render(){
+ renderFieldToggles(); applyDensity();
+ $('sort').value=S.sort; $('dens').value=S.dens;
+ document.querySelectorAll('#modeToggle button').forEach(b=>b.classList.toggle('on',b.dataset.mode===S.mode));
+ const {rows,hint}=await currentRows();
+ const sorted=sortRows(rows);
+ document.querySelector('.content').classList.toggle('grid', S.mode==='grid');
+ $('hint').style.display=hint?'block':'none'; $('hint').innerHTML=hint;
+ $('countline').textContent = isRegistry(S.type)
+ ? `${sorted.length.toLocaleString()} match${sorted.length===1?'':'es'} · ${typeLabel(S.type)} (registry search)`
+ : `${sorted.length.toLocaleString()} of ${Number(DATA.counts[S.type]??DATA.counts.all??0).toLocaleString()} · ${S.type==='all'?'all contacts':typeLabel(S.type)}`;
+ $('rows').innerHTML = sorted.length ? sorted.map(rowHTML).join('') : (hint?'':'<div class="empty">No contacts match.</div>');
+ document.querySelectorAll('#rows .row').forEach(el=>el.onclick=()=>openDetail(el.dataset.id, sorted));
+}
+function openDetail(id, rows){
+ const c=(rows||DATA.contacts).find(x=>x.id===id); if(!c) return;
+ S.sel=id; document.querySelectorAll('#rows .row').forEach(el=>el.classList.toggle('sel',el.dataset.id===id));
+ document.querySelector('.main').classList.add('detail-open');
+ const cells=[];
+ const add=(k,v)=>{ if(v!=null&&v!=='') cells.push(`<div class="cell"><div class="kk">${k}</div><div class="vv">${esc(v)}</div></div>`); };
+ add('Type',typeLabel(c.type)); add('Firm',c.firm); add('Email',c.email); add('Phone',c.phone);
+ add('City',[c.city,c.state].filter(Boolean).join(', ')); add('Title',c.title);
+ if(c.dre_license) add('DRE License',c.dre_license);
+ if(c.assessed_total) add('Assessed', '$'+Number(c.assessed_total).toLocaleString());
+ if(c.sqft) add('SqFt', Number(c.sqft).toLocaleString());
+ if(c.year_built) add('Built', c.year_built);
+ if(c.listings_count!=null) add('Listings', c.listings_count);
+ add('Source', c.source); add('Created', fmtWhen(c.created_at));
+ const segs=(c.segmentLabels||[]).map(s=>`<span class="seg-chip">${esc(s.emoji||'•')} ${esc(s.label)}</span>`).join('');
+ $('detail').innerHTML=`<button class="close" onclick="closeDetail()">✕</button>
+ <div class="k">${esc(typeLabel(c.type))}</div><h2>${esc(c.name)}</h2>
+ <div class="meta">${esc([c.firm,[c.city,c.state].filter(Boolean).join(', ')].filter(Boolean).join(' · '))}</div>
+ <div class="acts"><div class="drill">${drillFor(c)}</div></div>
+ <div class="grid2">${cells.join('')}</div>
+ ${segs?`<div class="seglist">${segs}</div>`:''}`;
+}
+window.closeDetail=()=>{ document.querySelector('.main').classList.remove('detail-open'); S.sel=null; document.querySelectorAll('#rows .row').forEach(el=>el.classList.remove('sel')); };
+
+// ── control wiring ──
+let qt; $('q').addEventListener('input',e=>{ clearTimeout(qt); qt=setTimeout(()=>{ S.q=e.target.value; render(); },160); });
+$('sort').onchange=e=>{ S.sort=e.target.value; setLS('sort',S.sort); render(); };
+$('dens').oninput=e=>{ S.dens=+e.target.value; setLS('dens',S.dens); applyDensity(); };
+document.querySelectorAll('#modeToggle button').forEach(b=>b.onclick=()=>{ S.mode=b.dataset.mode; setLS('mode',S.mode); render(); });
+
+load();
+</script>
+</body></html>
diff --git a/server.js b/server.js
index 300bce50..f4bca816 100644
--- a/server.js
+++ b/server.js
@@ -2288,6 +2288,80 @@ app.get('/api/audience', adminOnly, async (_q, res) => {
});
app.get('/audience', adminOnly, (_q, r) => sendPage(r, path.join(PUB, 'audience.html')));
+// ── UNIFIED CRM (/crm): one contact console over every RENTV-owned stream, replacing the
+// split /desk + /audience surfaces (Steve 2026-08-12: "streamlined CRM for easy use and
+// sorting"). Owned contacts (newsletter subs + sublease brokers + pr-intelligence people,
+// via buildAudience) are merged with the two smaller desk sets (LA commercial owners +
+// CRCP commercial brokers), all normalized to ONE schema with a `type`, a `created_at`,
+// and a `_s` search blob. The huge CA-DRE registries (382k brokers / 34k firms) are NOT
+// merged client-side — the same console offers them as server-search types via the
+// existing /api/brokers + /api/firms. Everything sortable; counts drive the type chips. ──
+function crmNormalizeOwners(la) {
+ return (la && Array.isArray(la.marquee) ? la.marquee : []).map((x, i) => ({
+ id: 'own-' + (x.ain || i), type: 'owner', persona: 'Property Owner',
+ name: x.address || x.ain || 'LA commercial parcel', firm: '', email: '', phone: '',
+ city: x.city || '', state: 'CA', title: x.type_label || x.use_desc || '',
+ interest: [x.use_desc, x.type_label].filter(Boolean).join(' '), source: 'la-assessor',
+ ain: x.ain || '', assessed_total: x.assessed_total || null, sqft: x.sqft || null,
+ year_built: x.year_built || null, created_at: null, segments: [], listings: [],
+ }));
+}
+function crmNormalizeCommercial(rows) {
+ return (Array.isArray(rows) ? rows : []).map((x, i) => ({
+ id: 'com-' + (x.id || x.dre_license || i), type: 'commercial', persona: 'Commercial Broker',
+ name: x.name || x.firm || '(broker)', firm: x.firm || '', email: x.email || '', phone: x.phone || '',
+ city: (x.office_addr || '').split(',').slice(-2, -1)[0]?.trim() || x.city || '', state: 'CA',
+ title: x.agent_type || '', website: x.website || '', linkedin: x.linkedin || '',
+ interest: [x.agent_type, x.specialties].filter(Boolean).join(' '), source: 'crcp',
+ dre_license: x.dre_license || x.license || '', office_addr: x.office_addr || '',
+ listings_count: x.listings ?? null, created_at: x.created_at || null, segments: [], listings: [],
+ }));
+}
+app.get('/api/crm', adminOnly, async (req, res) => {
+ try {
+ 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 */ }
+ // Owned streams (guarded) + the two small desk sets in parallel.
+ const commercialFetch = fetch(`${CRCP}/api/cre/brokers/all?limit=3000`, { headers: { Authorization: CRCP_AUTH }, signal: AbortSignal.timeout(8000) })
+ .then((r) => r.ok ? r.json() : null).then((j) => (j && (j.brokers || j.rows)) || (Array.isArray(j) ? j : [])).catch(() => []);
+ const [subleaseRows, crmPeople, commercialRows] = await Promise.all([fetchSubleaseRows(), fetchCrmContacts(), commercialFetch]);
+ const owned = buildAudience(subs, subleaseRows, crmPeople); // { stats, segments, contacts }
+ const ownedContacts = owned.contacts.map((c) => ({ ...c, type: c.origin })); // origin: newsletter|sublease|crm
+ const owners = crmNormalizeOwners(readJSON('la-commercial.json', { marquee: [] }));
+ const commercial = crmNormalizeCommercial(commercialRows);
+ const all = [...ownedContacts, ...commercial, ...owners].map((c) => ({
+ ...c,
+ // one precomputed lowercase search blob per contact (no per-keystroke JSON.stringify).
+ _s: [c.name, c.firm, c.email, c.phone, c.city, c.title, c.interest, c.source, c.dre_license, c.ain].filter(Boolean).join(' ').toLowerCase(),
+ }));
+ const TYPES = [
+ { key: 'newsletter', label: 'Subscribers', emoji: '📩' },
+ { key: 'sublease', label: 'Sublease Brokers', emoji: '🔑' },
+ { key: 'crm', label: 'PR / CRE Contacts', emoji: '🏢' },
+ { key: 'commercial', label: 'Commercial Brokers', emoji: '🏬' },
+ { key: 'owner', label: 'Property Owners', emoji: '🏛️' },
+ ];
+ const counts = { all: all.length };
+ TYPES.forEach((t) => { counts[t.key] = all.filter((c) => c.type === t.key).length; });
+ res.set('Cache-Control', 'private, max-age=60');
+ res.json({
+ generated_at: new Date().toISOString(),
+ types: TYPES,
+ counts,
+ segments: owned.segments, // segment roll-up (owned contacts only)
+ // server-search registries surfaced in the SAME console (not merged — too large)
+ registries: [
+ { key: 'brokers', label: 'CA DRE Brokers', emoji: '👤', endpoint: '/api/brokers', note: 'registry — server search' },
+ { key: 'firms', label: 'CA DRE Firms', emoji: '🏢', endpoint: '/api/firms', note: 'registry — server search' },
+ ],
+ contacts: all,
+ });
+ } catch (e) {
+ res.json({ generated_at: new Date().toISOString(), types: [], counts: { all: 0 }, segments: [], registries: [], contacts: [], error: 'crm unavailable' });
+ }
+});
+app.get('/crm', adminOnly, (_q, r) => sendPage(r, path.join(PUB, 'crm.html')));
+
// ── Global site search — one box across every content surface. ──
// Result hrefs are ALWAYS internal (or external YouTube for videos); we never
// link out to rentv.com (hard rule). News/posts open in our own reader shells.
← 40efe407 auto-data-snapshot: 2026-08-12T12:29:13 (7 data files) — dat
·
back to Rentv
·
auto-data-snapshot: 2026-08-12T12:59:45 (7 data files) — dat 33a281e4 →