← back to Rentv
rentv: make /closings customer-facing (public card grid, date-forward, nav link)
28220187d7c4bc0c228cf1541f773103eb542810 · 2026-08-04 15:30:57 -0700 · Steve Abrams
Files touched
A data/hubs.jsonM data/markets.jsonM data/news.jsonM data/ticker.jsonM public/closings.htmlM server.js
Diff
commit 28220187d7c4bc0c228cf1541f773103eb542810
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Aug 4 15:30:57 2026 -0700
rentv: make /closings customer-facing (public card grid, date-forward, nav link)
---
data/hubs.json | 126 ++++++++++++++++++++++
data/markets.json | 2 +-
data/news.json | 2 +-
data/ticker.json | 2 +-
public/closings.html | 288 +++++++++++++++++++++++++++------------------------
server.js | 81 +++++++++++++--
6 files changed, 355 insertions(+), 146 deletions(-)
diff --git a/data/hubs.json b/data/hubs.json
new file mode 100644
index 00000000..5349f93b
--- /dev/null
+++ b/data/hubs.json
@@ -0,0 +1,126 @@
+{
+ "_note": "Topical-authority hub manifest for RENTV. SSOT for /topics + /topic/:slug. Basis: re-props CRE SEO keyword library (~/.claude/skills/re-props/references/cre-seo-keyword-library.md). An article belongs to a hub if its `cat` is in `cats` OR a `match` keyword appears in its title. A hub is indexable ONLY when it has >=1 live article (else noindex, so we never ship thin doorway pages). `sections` lists legacy /section routes that 301 into this hub.",
+ "groups": [
+ { "id": "asset", "label": "Property Types & Asset Classes" },
+ { "id": "deal", "label": "Transactions & Capital" },
+ { "id": "who", "label": "Participants" },
+ { "id": "market", "label": "Markets & Data" },
+ { "id": "editorial", "label": "Editorial & Research" }
+ ],
+ "hubs": [
+ { "slug": "office", "group": "asset", "title": "Office", "h1": "Office Real Estate News",
+ "dek": "Office building sales, leasing, and financing across CBD, suburban, medical office, and life science / laboratory space — occupancy, absorption, asking rent, and repositioning coverage.",
+ "keywords": ["office", "office building", "medical office", "life science", "laboratory", "CBD", "sublease"],
+ "cats": ["Office"], "match": ["office", "life science", "laboratory"], "sections": [] },
+
+ { "slug": "industrial-logistics", "group": "asset", "title": "Industrial & Logistics", "h1": "Industrial & Logistics Real Estate News",
+ "dek": "Warehouse, distribution, fulfillment, cold storage, flex, and last-mile logistics — the industrial asset class driven by e-commerce, supply chain, and intermodal freight.",
+ "keywords": ["industrial", "warehouse", "logistics", "distribution center", "fulfillment center", "cold storage", "last mile", "flex space"],
+ "cats": ["Industrial"], "match": ["industrial", "warehouse", "logistics", "distribution", "fulfillment", "cold storage"], "sections": ["industrial"] },
+
+ { "slug": "retail", "group": "asset", "title": "Retail", "h1": "Retail Real Estate News",
+ "dek": "Shopping center, strip mall, power center, lifestyle center, and grocery-anchored retail — plus experiential retail, QSR / restaurant space, and flagship store activity.",
+ "keywords": ["retail", "shopping center", "strip mall", "power center", "grocery anchored", "QSR", "restaurant space"],
+ "cats": ["Retail"], "match": ["retail", "shopping center", "mall", "grocery"], "sections": ["retail"] },
+
+ { "slug": "multifamily", "group": "asset", "title": "Multifamily", "h1": "Multifamily Real Estate News",
+ "dek": "Apartment, build-to-rent, student housing, senior living, and affordable / workforce housing — lease-up, occupancy, concessions, and rent growth across the multifamily sector.",
+ "keywords": ["multifamily", "apartment", "build-to-rent", "student housing", "senior housing", "affordable housing", "workforce housing", "rent growth"],
+ "cats": ["Multifamily"], "match": ["multifamily", "apartment", "housing", "build-to-rent"], "sections": ["multifamily"] },
+
+ { "slug": "hospitality", "group": "asset", "title": "Hospitality", "h1": "Hospitality & Hotel Real Estate News",
+ "dek": "Hotel investment across boutique, luxury, select-service, extended-stay, resort, and convention properties — acquisitions, financing, and repositioning in the hospitality sector.",
+ "keywords": ["hospitality", "hotel", "hotel investment", "boutique hotel", "luxury hotel", "select service", "extended stay", "resort", "convention hotel"],
+ "cats": ["Hospitality"], "match": ["hotel", "hospitality", "resort"], "sections": [] },
+
+ { "slug": "data-centers", "group": "asset", "title": "Data Centers", "h1": "Data Center Real Estate News",
+ "dek": "Data center development, powered-shell sites, and digital-infrastructure investment — the fastest-growing CRE asset class driven by AI compute, hyperscale demand, and power constraints.",
+ "keywords": ["data center", "digital infrastructure", "hyperscale", "powered shell", "AI compute"],
+ "cats": ["Data Centers"], "match": ["data center", "data centre", "hyperscale"], "sections": [] },
+
+ { "slug": "development", "group": "asset", "title": "Development", "h1": "Development & Construction News",
+ "dek": "Ground-up development, redevelopment, adaptive reuse, entitlement, and master-planned communities — groundbreakings, deliveries, and construction across all asset classes.",
+ "keywords": ["development", "redevelopment", "adaptive reuse", "entitlement", "rezoning", "groundbreaking", "master planned community", "construction"],
+ "cats": ["Development"], "match": ["development", "redevelop", "adaptive reuse", "groundbreak", "breaks ground"], "sections": ["development"] },
+
+ { "slug": "transactions", "group": "deal", "title": "Transactions", "h1": "CRE Transactions News",
+ "dek": "Investment sales, acquisitions, dispositions, portfolio and asset sales, sale-leasebacks, and leasing — the closed-deal transaction flow across the commercial real estate market.",
+ "keywords": ["acquisition", "disposition", "investment sale", "portfolio sale", "sale leaseback", "lease", "leasing", "sublease", "transaction volume"],
+ "cats": ["Sales", "Leases"], "match": ["acquires", "sells", "buys", "sale", "leases", "acquisition", "disposition", "trades"], "sections": ["sales", "leases"] },
+
+ { "slug": "capital-markets", "group": "deal", "title": "Capital Markets", "h1": "CRE Capital Markets & Financing News",
+ "dek": "Financing, refinancing, construction and bridge loans, CMBS, mezzanine and preferred equity, recapitalizations, and joint ventures — the debt and equity powering CRE deals.",
+ "keywords": ["financing", "refinancing", "construction loan", "bridge loan", "CMBS", "mezzanine financing", "preferred equity", "recapitalization", "joint venture"],
+ "cats": ["Financing"], "match": ["financing", "refinanc", "loan", "cmbs", "mortgage", "recapitaliz", "joint venture"], "sections": ["financing"] },
+
+ { "slug": "lenders-banks", "group": "deal", "title": "Lenders & Banks", "h1": "CRE Lenders & Banks News",
+ "dek": "Commercial mortgage lenders, debt funds, bridge and life-insurance lenders, CMBS and bank lending — plus foreclosure, distressed debt, and special servicing coverage.",
+ "keywords": ["commercial lender", "commercial mortgage", "debt fund", "bridge lender", "life insurance lender", "CMBS lender", "distressed debt", "special servicing", "foreclosure"],
+ "cats": [], "match": ["lender", "bank", "debt fund", "special servic", "foreclosure", "distressed"], "sections": [] },
+
+ { "slug": "companies", "group": "who", "title": "Companies", "h1": "CRE Companies News",
+ "dek": "The firms shaping commercial real estate — brokerages, REITs, private equity, developers, and investment managers driving the market's biggest deals.",
+ "keywords": ["CBRE", "JLL", "Cushman & Wakefield", "Colliers", "Newmark", "Blackstone", "Brookfield", "Prologis", "REIT", "private equity", "developer"],
+ "cats": [], "match": ["cbre", "jll", "cushman", "colliers", "newmark", "blackstone", "brookfield", "prologis", "hines", "greystar", "starwood", "kkr"], "sections": [] },
+
+ { "slug": "people", "group": "who", "title": "People", "h1": "CRE People & Executives News",
+ "dek": "The executives, brokers, and developers behind the deals — hires, promotions, and moves across the commercial real estate industry.",
+ "keywords": ["executive", "broker", "developer", "hire", "promotion", "appointment"],
+ "cats": [], "match": ["joins", "hires", "promoted", "named", "appoints", "appointed", "ceo", "president"], "sections": [] },
+
+ { "slug": "architecture-construction", "group": "who", "title": "Architecture & Construction", "h1": "Architecture & Construction News",
+ "dek": "Architects, general contractors, engineers, and design-build teams — construction management, materials, and the delivery of major CRE projects.",
+ "keywords": ["architecture", "architect", "general contractor", "engineering", "design build", "construction management"],
+ "cats": [], "match": ["architect", "contractor", "design-build", "engineering", "tops out"], "sections": [] },
+
+ { "slug": "title-escrow", "group": "who", "title": "Title & Escrow", "h1": "Title & Escrow News",
+ "dek": "Title companies, escrow, and closing services — the transaction infrastructure that moves commercial real estate deals from contract to close.",
+ "keywords": ["title company", "escrow", "title insurance", "closing"],
+ "cats": [], "match": ["title company", "escrow", "title insurance"], "sections": [] },
+
+ { "slug": "legal-regulation", "group": "who", "title": "Legal & Regulation", "h1": "CRE Legal & Regulation News",
+ "dek": "Zoning, land use, entitlements, eminent domain, environmental review, and CRE litigation — the legal and regulatory forces shaping development and ownership.",
+ "keywords": ["zoning", "land use", "eminent domain", "environmental review", "CEQA", "litigation", "tax appeal", "regulation"],
+ "cats": [], "match": ["zoning", "land use", "lawsuit", "litigation", "eminent domain", "regulation", "ceqa", "entitlement"], "sections": [] },
+
+ { "slug": "markets", "group": "market", "title": "Markets", "h1": "CRE Markets News",
+ "dek": "Commercial real estate by geography — national, regional, state, metro, and county markets, from primary CBDs to secondary and tertiary markets.",
+ "keywords": ["national", "regional", "metro", "county", "CBD", "secondary markets", "tertiary markets", "market"],
+ "cats": [], "match": [], "sections": [] },
+
+ { "slug": "ai-proptech", "group": "market", "title": "AI & PropTech", "h1": "AI & PropTech News",
+ "dek": "Artificial intelligence, machine learning, smart buildings, IoT, digital twins, and BIM — the technology reshaping how commercial real estate is built and operated.",
+ "keywords": ["proptech", "AI", "artificial intelligence", "machine learning", "smart buildings", "IoT", "digital twin", "BIM", "ESG software"],
+ "cats": [], "match": ["proptech", "artificial intelligence", " ai ", "machine learning", "smart building", "digital twin"], "sections": [] },
+
+ { "slug": "economic-indicators", "group": "market", "title": "Economic Indicators", "h1": "CRE Economic Indicators",
+ "dek": "The macro signals driving commercial real estate — interest rates, the Federal Reserve, inflation, jobs reports, and tariffs and their impact on CRE.",
+ "keywords": ["interest rates", "Federal Reserve", "inflation", "jobs report", "tariffs", "cap rate", "10-year treasury"],
+ "cats": [], "match": ["interest rate", "federal reserve", "the fed", "inflation", "jobs report", "tariff", "rate cut", "rate hike"], "sections": [] },
+
+ { "slug": "rankings-lists", "group": "editorial", "title": "Rankings & Lists", "h1": "CRE Rankings & Lists",
+ "dek": "Data-driven rankings and lists — top deals, most active markets, biggest owners, and leading firms across commercial real estate.",
+ "keywords": ["rankings", "lists", "top deals", "most active", "leaderboard"],
+ "cats": [], "match": ["ranking", "top 10", "top 25", "biggest", "most active"], "sections": [] },
+
+ { "slug": "awards", "group": "editorial", "title": "Awards", "h1": "CRE Awards",
+ "dek": "Recognition across the commercial real estate industry — deal of the year, developer and broker awards, and project honors.",
+ "keywords": ["awards", "deal of the year", "recognition", "honors"],
+ "cats": [], "match": ["award", "deal of the year", "honored", "wins"], "sections": [] },
+
+ { "slug": "podcasts", "group": "editorial", "title": "Podcasts", "h1": "RENTV Podcasts",
+ "dek": "Conversations with the operators, investors, and brokers moving commercial real estate — the RENTV podcast and interview archive.",
+ "keywords": ["podcast", "interview", "cre talk"],
+ "cats": [], "match": ["podcast", "interview", "episode"], "sections": [] },
+
+ { "slug": "research-reports", "group": "editorial", "title": "Research Reports", "h1": "CRE Research Reports",
+ "dek": "Market research and data — vacancy, absorption, rent, cap-rate, and transaction-volume reports across sectors and metros.",
+ "keywords": ["research report", "market report", "vacancy rate", "absorption", "cap rate", "transaction volume"],
+ "cats": [], "match": ["report", "research", "survey", "index rose", "index fell"], "sections": [] },
+
+ { "slug": "press-releases", "group": "editorial", "title": "Press Releases", "h1": "CRE Press Releases",
+ "dek": "Official announcements from firms, developers, and lenders across commercial real estate — sourced from public press releases.",
+ "keywords": ["press release", "announcement"],
+ "cats": [], "match": ["announces", "announced", "press release"], "sections": [] }
+ ]
+}
diff --git a/data/markets.json b/data/markets.json
index a54e3358..33f7d624 100644
--- a/data/markets.json
+++ b/data/markets.json
@@ -1,5 +1,5 @@
{
- "fetched_at": "2026-08-04T21:53:15.145Z",
+ "fetched_at": "2026-08-04T22:13:15.867Z",
"source": "Yahoo Finance — live (CBOE Treasury yields, Dow Jones/MSCI REIT indices)",
"curve": [
{
diff --git a/data/news.json b/data/news.json
index cf8a66d8..4774914e 100644
--- a/data/news.json
+++ b/data/news.json
@@ -1,6 +1,6 @@
{
"source": "rentv.com — live",
- "fetched_at": "2026-08-04T21:53:14.917Z",
+ "fetched_at": "2026-08-04T22:13:15.632Z",
"count": 30,
"items": [
{
diff --git a/data/ticker.json b/data/ticker.json
index e038c3b1..97608f0f 100644
--- a/data/ticker.json
+++ b/data/ticker.json
@@ -1,5 +1,5 @@
{
- "fetched_at": "2026-08-04T21:53:15.116Z",
+ "fetched_at": "2026-08-04T22:13:15.842Z",
"la_office_vac": {
"label": "LA OFFICE VAC",
"value": "23.1%",
diff --git a/public/closings.html b/public/closings.html
index 404c542a..55e7b1b6 100644
--- a/public/closings.html
+++ b/public/closings.html
@@ -1,157 +1,171 @@
-<!DOCTYPE html>
+<!doctype html>
<html lang="en">
<head>
-<meta charset="UTF-8">
-<meta name="viewport" content="width=device-width, initial-scale=1.0">
-<title>Property Closings — RENTV Backend</title>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Recent Property Closings — RENTV.com</title>
+<meta name="description" content="Recent commercial real estate closings across the Western U.S. — closed sales with address, price, property type and date, parsed from live RENTV.com transaction reporting.">
+<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
<style>
- :root{--ink:#14171a;--sub:#5b6570;--line:#e4e7ea;--wash:#f4f6f8;--red:#c0392b;--green:#1a7f4b;
- --serif:Georgia,'Times New Roman',serif}
+ :root{--red:#c8102e;--ink:#14171a;--sub:#5b636b;--line:#e4e7ea;--bg:#fff;--wash:#f5f6f8;--sans:'Inter',system-ui,sans-serif;--serif:'Playfair Display',Georgia,serif}
*{box-sizing:border-box;margin:0;padding:0}
- body{background:var(--wash);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
- header{background:#fff;border-bottom:1px solid var(--line);padding:14px 22px;display:flex;align-items:center;gap:14px;flex-wrap:wrap;position:sticky;top:0;z-index:20}
- header h1{font-family:var(--serif);font-size:20px;flex:1;min-width:180px}
- header .tag{background:var(--ink);color:#fff;font-size:11px;font-weight:700;padding:3px 8px;border-radius:4px;text-transform:uppercase;letter-spacing:.05em}
- header a.back{color:var(--sub);text-decoration:none;font-weight:600;font-size:13px}
- header a.back:hover{color:var(--red)}
- .stats{display:flex;gap:12px;padding:16px 22px 0;flex-wrap:wrap}
- .stat{background:#fff;border:1px solid var(--line);border-radius:8px;padding:10px 16px;min-width:130px}
- .stat .l{font-size:11px;color:var(--sub);text-transform:uppercase;letter-spacing:.04em}
- .stat .v{font-family:var(--serif);font-size:22px;font-weight:700;margin-top:2px}
- .controls{display:flex;gap:10px;align-items:center;padding:16px 22px;flex-wrap:wrap}
- .controls input[type=text]{flex:1;min-width:220px;padding:9px 12px;border:1px solid var(--line);border-radius:6px;font-size:14px}
- .controls .hint{font-size:12px;color:var(--sub)}
- .controls button{background:#fff;border:1px solid var(--line);border-radius:6px;padding:8px 12px;font-size:12px;font-weight:600;cursor:pointer;color:var(--sub)}
- .controls button:hover{border-color:var(--red);color:var(--red)}
- main{padding:0 22px 40px}
- table{width:100%;border-collapse:collapse;background:#fff;border:1px solid var(--line);border-radius:10px;overflow:hidden}
- th,td{padding:10px 12px;text-align:left;border-bottom:1px solid var(--line);font-size:13px;vertical-align:top}
- th{background:var(--wash);font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--sub);cursor:pointer;user-select:none;white-space:nowrap;position:sticky;top:57px}
- th:hover{color:var(--red)}
- th .arr{opacity:.35;font-size:10px}
- th.sorted .arr{opacity:1;color:var(--red)}
- td.addr{font-weight:600}
- td.addr a{color:var(--ink);text-decoration:none;border-bottom:1px dotted var(--sub)}
- td.addr a:hover{color:var(--red)}
- td.price{font-family:var(--serif);font-weight:700;white-space:nowrap}
- .badge{display:inline-block;background:var(--wash);border:1px solid var(--line);border-radius:4px;padding:1px 7px;font-size:11px;font-weight:600;color:var(--sub)}
- tr.deal{cursor:pointer}
- tr.deal:hover{background:#fbfcfd}
- tr.detail td{background:#fbfcfd;color:var(--sub);font-size:13px;line-height:1.6;padding-top:0}
- tr.detail .meta{margin-top:6px;font-size:12px}
- tr.detail .meta b{color:var(--ink)}
- .empty{text-align:center;color:var(--sub);padding:50px}
- .loading{text-align:center;color:var(--sub);padding:60px;font-size:16px}
+ body{font-family:var(--sans);color:var(--ink);background:var(--wash);line-height:1.45}
+ a{color:inherit;text-decoration:none}
+ .wrap{max-width:1200px;margin:0 auto;padding:0 20px}
+ .util{background:var(--ink);color:#cfd4d9;font-size:12px}
+ .util .wrap{display:flex;justify-content:space-between;align-items:center;height:34px}
+ .util .live{display:inline-flex;align-items:center;gap:7px;letter-spacing:.05em;text-transform:uppercase;font-size:11px}
+ .util .dot{width:7px;height:7px;border-radius:50%;background:#22c55e;animation:pulse 2s infinite}
+ @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(34,197,94,.6)}70%{box-shadow:0 0 0 7px rgba(34,197,94,0)}100%{box-shadow:0 0 0 0 rgba(34,197,94,0)}}
+ .util .acts a{margin-left:14px;color:#cfd4d9}.util .acts a.sub{color:#fff;background:var(--red);padding:4px 12px;border-radius:3px;font-weight:700}
+ .mast{background:#fff;border-bottom:2px solid var(--ink);padding:16px 0 12px}
+ .mast .wrap{display:flex;align-items:center;justify-content:space-between}
+ .logo{font-family:var(--serif);font-weight:700;font-size:32px;letter-spacing:-1px}.logo span{color:var(--red)}
+ .logo small{display:block;font-size:11px;font-weight:600;letter-spacing:3px;color:var(--sub);text-transform:uppercase;margin-top:2px;font-family:var(--sans)}
+ nav{background:#fff;border-bottom:1px solid var(--line)}
+ nav .wrap{display:flex;gap:2px;align-items:center;height:46px;overflow-x:auto}
+ nav a{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:13px 8px;border-bottom:3px solid transparent;white-space:nowrap}
+ nav a:hover,nav a.on{color:var(--red);border-color:var(--red)}
+ .head{padding:30px 0 6px}
+ .head h1{font-family:var(--serif);font-size:34px;letter-spacing:-.5px}
+ .head p{color:var(--sub);font-size:14px;margin-top:4px;max-width:820px}
+ .head .fresh{font-size:12px;color:var(--sub);margin-top:8px}
+ .controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:18px 0 6px}
+ .pill{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:7px 13px;border:1px solid var(--line);border-radius:999px;background:#fff;cursor:pointer;color:var(--sub)}
+ .pill.on{background:var(--ink);color:#fff;border-color:var(--ink)}
+ .ctl{display:inline-flex;align-items:center;gap:7px;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--sub)}
+ .ctl select{font-family:var(--sans);font-size:13px;font-weight:600;text-transform:none;letter-spacing:0;color:var(--ink);padding:7px 10px;border:1px solid var(--line);border-radius:6px;background:#fff;cursor:pointer}
+ .ctl.dens input[type=range]{width:110px;accent-color:var(--red);cursor:pointer}
+ .controls input[type=search]{margin-left:auto;font-family:var(--sans);font-size:13px;padding:8px 12px;border:1px solid var(--line);border-radius:6px;min-width:200px}
+ .count{font-size:12px;color:var(--sub);margin:6px 0 16px}
+ .grid{display:grid;grid-template-columns:repeat(var(--cols,3),1fr);gap:16px}
+ .card{background:#fff;border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column;transition:box-shadow .15s,transform .15s}
+ .card:hover{box-shadow:0 8px 24px rgba(0,0,0,.09);transform:translateY(-2px)}
+ .card .ph{aspect-ratio:16/9;background:var(--wash) center/cover no-repeat;position:relative}
+ .card .badges{position:absolute;top:10px;left:10px;display:flex;gap:6px}
+ .badge{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;padding:4px 8px;border-radius:4px;color:#fff;background:var(--ink)}
+ .badge.txn{background:var(--red)}
+ .card .date{position:absolute;top:10px;right:10px;background:rgba(255,255,255,.94);color:var(--ink);font-size:11px;font-weight:800;letter-spacing:.03em;padding:4px 9px;border-radius:4px}
+ .card .amt{position:absolute;bottom:10px;right:10px;background:rgba(20,23,26,.92);color:#fff;font-family:var(--serif);font-weight:700;font-size:18px;padding:4px 10px;border-radius:5px}
+ .card .body{padding:14px 16px 16px;display:flex;flex-direction:column;gap:7px;flex:1}
+ .card .addr{font-family:var(--serif);font-size:18px;line-height:1.2;font-weight:700}
+ .card h3{font-size:13px;color:var(--sub);font-weight:600;line-height:1.35}
+ .card .meta{font-size:12px;color:var(--sub);font-weight:600;display:flex;flex-wrap:wrap;gap:4px 10px}
+ .card .meta b{color:var(--ink)}
+ .card .more{margin-top:auto;font-size:12px;font-weight:700;color:var(--red);text-transform:uppercase;letter-spacing:.04em}
+ .empty{background:#fff;border:1px dashed var(--line);border-radius:10px;padding:40px;text-align:center;color:var(--sub)}
+ footer{background:var(--ink);color:#8b939b;margin-top:40px;padding:26px 0;font-size:12px}
+ footer .wrap{display:flex;justify-content:space-between;flex-wrap:wrap;gap:10px}
+ @media(max-width:900px){.grid{grid-template-columns:1fr 1fr}}
+ @media(max-width:600px){.grid{grid-template-columns:1fr!important}.controls input[type=search]{margin-left:0;width:100%}}
</style>
</head>
<body>
-<header>
- <h1>Property Closings</h1><span class="tag">Backend</span>
- <a class="back" href="/backend">← Backend</a>
-</header>
-
-<div class="stats" id="stats"></div>
-<div class="controls">
- <input type="text" id="q" placeholder="Search all fields — address, city, buyer, type… (space = AND)">
- <span class="hint">click a row for the full story · click a column to sort</span>
- <button id="reset">Reset</button>
-</div>
-<main>
- <div id="wrap"><div class="loading">Loading closings…</div></div>
-</main>
-
+ <div class="util"><div class="wrap">
+ <span class="live"><span class="dot"></span> Live CRE closings</span>
+ <div class="acts"><a href="/subscribe">Newsletters</a><a href="/advertise">Advertise</a><a class="sub" href="/subscribe">Subscribe</a></div>
+ </div></div>
+ <header class="mast"><div class="wrap">
+ <a class="logo" href="/">REN<span>TV</span>.com<small>Commercial Real Estate News</small></a>
+ </div></header>
+ <nav><div class="wrap">
+ <a href="/">Home</a><a href="/deals">Deals</a><a href="/closings" class="on">Closings</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/markets">Markets</a><a href="/desk">Broker & Owner Desk</a><a href="/conferences">Conferences</a><a href="/blog">Blog</a>
+ </div></nav>
+ <main class="wrap">
+ <div class="head">
+ <h1>Recent Property Closings</h1>
+ <p>Every closed commercial real estate sale RENTV.com is reporting — with the property address, closing price, type, size and date. Sorted newest first. Each card links to the full story.</p>
+ <div class="fresh" id="fresh">Loading…</div>
+ </div>
+ <div class="controls" id="types"></div>
+ <div class="controls">
+ <label class="ctl"><span>Sort</span>
+ <select id="sort">
+ <option value="newest">Newest closing</option>
+ <option value="price-desc">Price ↓</option>
+ <option value="price-asc">Price ↑</option>
+ <option value="type">Property Type A→Z</option>
+ <option value="loc">Location A→Z</option>
+ <option value="addr">Address A→Z</option>
+ </select>
+ </label>
+ <label class="ctl dens"><span>Density</span>
+ <input id="dens" type="range" min="2" max="5" step="1" value="3" aria-label="Grid density (columns)">
+ </label>
+ <input id="q" type="search" placeholder="Search address, city, property, party…" autocomplete="off">
+ </div>
+ <div class="count" id="count"></div>
+ <div class="grid" id="grid"></div>
+ </main>
+ <footer><div class="wrap"><span>© RENTV.com — Commercial Real Estate News</span><span>Closings parsed from live RENTV.com reporting, refreshed every 20 min</span></div></footer>
<script>
+const $=s=>document.querySelector(s);
const MONTHS={Jan:0,Feb:1,Mar:2,Apr:3,May:4,Jun:5,Jul:6,Aug:7,Sep:8,Oct:9,Nov:10,Dec:11};
-const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
-// Date is encoded in the image filename, e.g. ...Jul182026.jpg -> 2026-07-18.
-function parseDate(img){
- // Month is capitalized, immediately before the day+year+ext. Lazy so it grabs ONLY the
- // month (not trailing name letters), case-sensitive so it anchors on the capital M.
- // Handles 3-letter abbrev (Jul) AND full names (June/July) — key off the first 3 letters.
- const m=String(img||'').match(/([A-Z][a-z]+?)(\d{1,2})(\d{4})\.(?:jpg|jpeg|png|webp)/);
- if(!m) return null;
- const mo=MONTHS[m[1].slice(0,3)];
- if(mo==null) return null;
- return new Date(Date.UTC(+m[3], mo, +m[2]));
-}
-const fmtDate=d=>d?d.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}):'—';
-const fmtISO=d=>d?d.toISOString().slice(0,10):'';
+let ALL=[], fType='', fQ='',
+ sVal=(localStorage.getItem('rentv.closings.sort')||'newest'),
+ dens=(localStorage.getItem('rentv.closings.dens')||'3');
+const TYPES=['Multifamily','Office','Industrial','Retail','Hospitality','Mixed-Use','Land'];
+function esc(s){return String(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
+function amtNum(d){const s=String(d.amount_label||d.amount||'').replace(/[,\s$]/g,'');const m=s.match(/([\d.]+)\s*([MmKkBb]?)/);if(!m)return -1;let n=parseFloat(m[1]);const u=m[2].toUpperCase();if(u==='B')n*=1e9;else if(u==='M')n*=1e6;else if(u==='K')n*=1e3;return isNaN(n)?-1:n;}
+// closing date encoded in the image filename, e.g. ...Jul182026.jpg / ...June22026.jpg
+function dateOf(d){const m=String(d.image||'').match(/([A-Z][a-z]+?)(\d{1,2})(\d{4})\.(?:jpg|jpeg|png|webp)/);if(!m)return null;const mo=MONTHS[m[1].slice(0,3)];if(mo==null)return null;return new Date(Date.UTC(+m[3],mo,+m[2]));}
+const fmtDate=dt=>dt?dt.toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
-let ROWS=[];
-let sort={key:'date',dir:-1};
-const COLS=[
- {key:'date', label:'Date', get:r=>r.date?r.date.getTime():0, num:true},
- {key:'address',label:'Address', get:r=>r.address||'zzz'},
- {key:'place', label:'City / State',get:r=>(r.city||'')+r.state},
- {key:'ptype', label:'Type', get:r=>r.property_type||''},
- {key:'amount',label:'Price', get:r=>r.amount||0, num:true},
- {key:'size', label:'Size', get:r=>r.units||r.sqft||0, num:true},
- {key:'title', label:'Deal', get:r=>r.title||''},
-];
-
-function loadState(){ try{const s=JSON.parse(localStorage.getItem('rentv_closings')||'{}');if(s.sort)sort=s.sort;if(s.q)document.getElementById('q').value=s.q;}catch{} }
-function saveState(){ localStorage.setItem('rentv_closings',JSON.stringify({sort,q:document.getElementById('q').value})); }
-
-function filtered(){
- const terms=document.getElementById('q').value.toLowerCase().split(' ').filter(Boolean);
- let rows=ROWS.filter(r=>{
- if(!terms.length) return true;
- const hay=[r.address,r.city,r.state,r.property_type,r.title,r.summary,r.amount_label].join(' ').toLowerCase();
- return terms.every(t=>hay.includes(t));
+function pills(){
+ $('#types').innerHTML='<button class="pill'+(fType?'':' on')+'" data-v="">All types</button>'+
+ TYPES.map(v=>`<button class="pill${fType===v?' on':''}" data-v="${v}">${v}</button>`).join('');
+ $('#types').querySelectorAll('.pill').forEach(b=>b.onclick=()=>{fType=b.dataset.v;pills();render();});
+}
+function sorted(list){
+ const s=sVal;
+ return list.slice().sort((a,b)=>{
+ if(s==='price-desc')return amtNum(b)-amtNum(a);
+ if(s==='price-asc'){const x=amtNum(a),y=amtNum(b);return (x<0?1e18:x)-(y<0?1e18:y);}
+ if(s==='type')return (a.property_type||'').localeCompare(b.property_type||'');
+ if(s==='loc')return `${a.city} ${a.state}`.localeCompare(`${b.city} ${b.state}`);
+ if(s==='addr')return (a.address||'zzz').localeCompare(b.address||'zzz');
+ // newest: by parsed closing date desc (dateless sort last)
+ const da=dateOf(a),db=dateOf(b);return (db?db.getTime():0)-(da?da.getTime():0);
});
- const col=COLS.find(c=>c.key===sort.key)||COLS[0];
- rows.sort((a,b)=>{const x=col.get(a),y=col.get(b);const c=col.num?(x-y):String(x).localeCompare(String(y));return c*sort.dir;});
- return rows;
}
-
function render(){
- const rows=filtered();
- const total=ROWS.reduce((s,r)=>s+(r.amount||0),0);
- const shownVol=rows.reduce((s,r)=>s+(r.amount||0),0);
- document.getElementById('stats').innerHTML=`
- <div class="stat"><div class="l">Closings</div><div class="v">${rows.length}<span style="font-size:13px;color:var(--sub)"> / ${ROWS.length}</span></div></div>
- <div class="stat"><div class="l">Volume (shown)</div><div class="v">$${(shownVol/1e6).toFixed(1)}M</div></div>
- <div class="stat"><div class="l">Total volume</div><div class="v">$${(total/1e6).toFixed(1)}M</div></div>`;
- if(!rows.length){document.getElementById('wrap').innerHTML='<div class="empty">No closings match your search.</div>';return;}
- const head='<tr>'+COLS.map(c=>`<th data-k="${c.key}" class="${sort.key===c.key?'sorted':''}">${c.label} <span class="arr">${sort.key===c.key?(sort.dir<0?'▼':'▲'):'↕'}</span></th>`).join('')+'</tr>';
- const body=rows.map((r,i)=>{
- const link=r.id?('/news/'+encodeURIComponent(r.id)):(r.url||'#');
- const addr=r.address?`<a href="${link}">${esc(r.address)}</a>`:'<span style="color:var(--sub)">—</span>';
- const size=r.units?`${r.units} units`:(r.sqft?`${r.sqft.toLocaleString()} SF`:'—');
- return `<tr class="deal" data-i="${i}">
- <td style="white-space:nowrap" title="${fmtISO(r.date)}">${fmtDate(r.date)}</td>
- <td class="addr">${addr}</td>
- <td>${esc(r.city)}${r.state?', '+esc(r.state):''}</td>
- <td><span class="badge">${esc(r.property_type||'—')}</span></td>
- <td class="price">${esc(r.amount_label||(r.amount?'$'+r.amount.toLocaleString():'—'))}</td>
- <td style="white-space:nowrap">${size}</td>
- <td>${esc(r.title||'')}</td>
- </tr>
- <tr class="detail" id="det-${i}" style="display:none"><td colspan="${COLS.length}">
- ${esc(r.summary||'No summary.')}
- <div class="meta"><b>Txn:</b> ${esc(r.txn_type)} · <b>Occupancy:</b> ${r.occupancy_pct!=null?r.occupancy_pct+'%':'—'} · <b>Year built:</b> ${r.year_built||'—'} · <a href="${link}" style="color:var(--red);font-weight:700">Full story →</a></div>
- </td></tr>`;
- }).join('');
- document.getElementById('wrap').innerHTML=`<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
- document.querySelectorAll('th[data-k]').forEach(th=>th.onclick=()=>{
- const k=th.dataset.k; sort.dir=(sort.key===k)?-sort.dir:(COLS.find(c=>c.key===k).num?-1:1); sort.key=k; saveState(); render();
- });
- document.querySelectorAll('tr.deal').forEach(tr=>tr.onclick=e=>{
- if(e.target.closest('a'))return; const d=document.getElementById('det-'+tr.dataset.i); d.style.display=d.style.display==='none'?'':'none';
- });
+ let rows=ALL.filter(d=>(!fType||d.property_type===fType)&&(!fQ||
+ `${d.address} ${d.title} ${d.city} ${d.state} ${d.property_type} ${d.summary}`.toLowerCase().includes(fQ)));
+ rows=sorted(rows);
+ document.documentElement.style.setProperty('--cols',dens);
+ $('#count').textContent=`${rows.length} closing${rows.length===1?'':'s'}${fType?' · '+fType:''}`;
+ $('#grid').innerHTML=rows.length?rows.map(d=>{
+ const loc=[d.city,d.state].filter(Boolean).join(', ');
+ const ph=d.image?`background-image:url('${esc(d.image)}')`:'';
+ const dt=fmtDate(dateOf(d));
+ // HARD RULE: never link out to rentv.com — open OUR in-app reader (/news/:id).
+ return `<a class="card" href="${d.id?('/news/'+encodeURIComponent(d.id)):'/closings'}">
+ <div class="ph" style="${ph}">
+ <div class="badges"><span class="badge txn">Closed</span><span class="badge">${esc(d.property_type)}</span></div>
+ ${dt?`<span class="date">🗓 ${esc(dt)}</span>`:''}
+ ${d.amount_label?`<span class="amt">${esc(d.amount_label)}</span>`:''}
+ </div>
+ <div class="body">
+ <div class="addr">${esc(d.address||d.title)}</div>
+ ${loc?`<div class="meta"><span>📍 <b>${esc(loc)}</b></span>${d.size_label?`<span>📐 ${esc(d.size_label)}</span>`:''}${d.year_built?`<span>🏗 ${d.year_built}</span>`:''}</div>`:''}
+ ${d.address?`<h3>${esc(d.title)}</h3>`:''}
+ <div class="more">Read the full story →</div>
+ </div></a>`;
+ }).join(''):'<div class="empty">No closings match your filters yet. Clear them or check back — the feed refreshes every 20 minutes.</div>';
}
-
-document.getElementById('q').oninput=()=>{saveState();render();};
-document.getElementById('reset').onclick=()=>{document.getElementById('q').value='';sort={key:'date',dir:-1};saveState();render();};
-
+$('#sort').value=sVal; $('#dens').value=dens;
+$('#sort').onchange=e=>{sVal=e.target.value;localStorage.setItem('rentv.closings.sort',sVal);render();};
+$('#dens').oninput=e=>{dens=e.target.value;localStorage.setItem('rentv.closings.dens',dens);render();};
+$('#q').oninput=e=>{fQ=e.target.value.toLowerCase().trim();render();};
(async()=>{
- loadState();
+ pills();
try{
- const res=await fetch('/api/deals?txn=Sale&limit=200');
- const data=await res.json();
- ROWS=(data.deals||[]).map(d=>({...d,date:parseDate(d.image)}));
+ const r=await fetch('/api/deals?txn=Sale&limit=200');const data=await r.json();
+ ALL=data.deals||[];
+ const dated=ALL.map(dateOf).filter(Boolean).sort((a,b)=>b-a);
+ $('#fresh').textContent=`${ALL.length} closings${dated[0]?' · latest '+fmtDate(dated[0]):''} · live from RENTV.com reporting`;
render();
- }catch(e){ document.getElementById('wrap').innerHTML='<div class="empty">Failed to load: '+esc(e.message)+'</div>'; }
+ }catch(e){$('#grid').innerHTML='<div class="empty">Failed to load closings: '+esc(e.message)+'</div>';}
})();
</script>
</body>
diff --git a/server.js b/server.js
index 29474d77..ddac8652 100644
--- a/server.js
+++ b/server.js
@@ -318,19 +318,88 @@ app.get('/api/article/:id', async (req, res) => {
}
});
-// ── Section routes → the home feed filtered by category (DTD-C: a "section" IS a filtered
-// live feed, one consistent model site-wide; no thin static section pages). ──
+// ── Topical-authority hubs (SEO basis: re-props CRE keyword library) ─────────────
+// Upgrades the legacy /section filters into REAL indexable hub landing pages
+// (article list + JSON-LD + sibling interlinks). SSOT = data/hubs.json. A hub is
+// indexable only when it has >=1 live article, so we never ship thin doorway pages.
+const HUBS_FB = { groups: [], hubs: [] };
+const readHubs = () => readJSON('hubs.json', HUBS_FB);
+const newsItems = () => { const n = readJSON('news.json', { items: [] }); return Array.isArray(n) ? n : (n.items || n.news || []); };
+const hubMatches = (hub, items) => {
+ const cats = (hub.cats || []).map(c => String(c).toLowerCase());
+ const kws = (hub.match || []).map(k => String(k).toLowerCase());
+ return items.filter(a => {
+ const cat = String(a.cat || a.category || '').toLowerCase();
+ if (cats.includes(cat)) return true;
+ const title = ' ' + String(a.title || '').toLowerCase() + ' ';
+ return kws.some(k => title.includes(k));
+ });
+};
+const findHub = slug => (readHubs().hubs || []).find(h => h.slug === slug) || null;
+
+// /api/hubs — manifest + live article counts (drives /topics index + nav)
+app.get('/api/hubs', (_q, r) => {
+ r.set('Cache-Control', 'public, max-age=120');
+ const data = readHubs(), items = newsItems();
+ const hubs = (data.hubs || []).map(h => {
+ const count = hubMatches(h, items).length;
+ return { slug: h.slug, title: h.title, h1: h.h1, dek: h.dek, group: h.group, count, indexable: count > 0 };
+ });
+ r.json({ groups: data.groups || [], hubs });
+});
+
+// /api/hub/:slug — hub meta + its matched articles (drill each → /news/:id)
+app.get('/api/hub/:slug', (req, r) => {
+ const hub = findHub(req.params.slug);
+ if (!hub) return r.status(404).json({ error: 'no such hub' });
+ const items = newsItems();
+ const matched = hubMatches(hub, items);
+ const siblings = (readHubs().hubs || []).filter(h => h.group === hub.group && h.slug !== hub.slug)
+ .map(h => ({ slug: h.slug, title: h.title }));
+ r.set('Cache-Control', 'public, max-age=120');
+ r.json({
+ slug: hub.slug, title: hub.title, h1: hub.h1, dek: hub.dek, group: hub.group,
+ keywords: hub.keywords || [], indexable: matched.length > 0,
+ count: matched.length, articles: matched, siblings
+ });
+});
+
+// Public hub pages (new names — do NOT collide with the admin hub.html/sector.html)
+app.get('/topics', (_q, r) => sendPage(r, path.join(PUB, 'topics.html')));
+app.get('/topic/:slug', (_q, r) => sendPage(r, path.join(PUB, 'topic.html')));
+
+// robots + sitemap (both were missing — new SEO infra). Only indexable hubs listed.
+app.get('/robots.txt', (req, r) => {
+ const base = (req.protocol + '://' + req.get('host')).replace(/\/$/, '');
+ r.type('text/plain').send(`User-agent: *\nAllow: /\nSitemap: ${base}/sitemap.xml\n`);
+});
+app.get('/sitemap.xml', (req, r) => {
+ const base = (req.protocol + '://' + req.get('host')).replace(/\/$/, '');
+ const items = newsItems();
+ const urls = ['/', '/topics', '/deals', '/blog'];
+ (readHubs().hubs || []).forEach(h => { if (hubMatches(h, items).length > 0) urls.push('/topic/' + h.slug); });
+ const body = urls.map(u => ` <url><loc>${base}${u}</loc></url>`).join('\n');
+ r.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`);
+});
+
+// ── Legacy /section routes → their topical hub (301). Falls back to ?cat= if a
+// section isn't mapped, preserving old behavior. ──
const SECTIONS = ['financing', 'leases', 'development', 'retail', 'multifamily', 'industrial', 'sales'];
-SECTIONS.forEach(s => app.get('/' + s, (_q, r) => r.redirect(301, '/?cat=' + s.charAt(0).toUpperCase() + s.slice(1))));
+const sectionHub = sec => (readHubs().hubs || []).find(h => (h.sections || []).includes(sec));
+SECTIONS.forEach(s => app.get('/' + s, (_q, r) => {
+ const h = sectionHub(s);
+ return r.redirect(301, h ? '/topic/' + h.slug : '/?cat=' + s.charAt(0).toUpperCase() + s.slice(1));
+}));
app.get('/deals', (_q, r) => sendPage(r, path.join(PUB, 'deals.html')));
app.get('/news/:id', (_q, r) => sendPage(r, path.join(PUB, 'article.html')));
// The REview — the folded-in video platform, served natively in the RENTV shell
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 — admin view of every reported closed sale (address · date · price),
-// sourced from the live deals feed filtered to txn_type=Sale (see /api/deals?txn=Sale).
-app.get('/closings', adminOnly, (_q, r) => sendPage(r, path.join(PUB, 'closings.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')));
// 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
← 89f3741f auto-save: 2026-08-04T15:04:21 (1 files) — public/closings.h
·
back to Rentv
·
auto-save: 2026-08-04T15:34:32 (47 files) — data/markets.jso 00c04564 →