[object Object]

← back to Rentv 2026

search: global site search across news, deals, videos, REview & LA firms (/search + /api/search, internal-only result links)

4a4b2d18605d7e3d4626f4517a3596dd2187628f · 2026-07-23 15:41:03 -0700 · Steve

Files touched

Diff

commit 4a4b2d18605d7e3d4626f4517a3596dd2187628f
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 23 15:41:03 2026 -0700

    search: global site search across news, deals, videos, REview & LA firms (/search + /api/search, internal-only result links)
---
 data/markets.json  |   2 +-
 data/news.json     |   2 +-
 data/ticker.json   |   2 +-
 public/index.html  |   2 +
 public/search.html | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js          |  52 ++++++++++++++++++
 6 files changed, 209 insertions(+), 3 deletions(-)

diff --git a/data/markets.json b/data/markets.json
index 94133ae6..d2ae3c32 100644
--- a/data/markets.json
+++ b/data/markets.json
@@ -1,5 +1,5 @@
 {
-  "fetched_at": "2026-07-23T22:06:01.752Z",
+  "fetched_at": "2026-07-23T22:26:02.989Z",
   "source": "Yahoo Finance — live (CBOE Treasury yields, Dow Jones/MSCI REIT indices)",
   "curve": [
     {
diff --git a/data/news.json b/data/news.json
index c497c366..06296b06 100644
--- a/data/news.json
+++ b/data/news.json
@@ -1,6 +1,6 @@
 {
  "source": "rentv.com — live",
- "fetched_at": "2026-07-23T22:06:01.463Z",
+ "fetched_at": "2026-07-23T22:26:02.605Z",
  "count": 30,
  "items": [
   {
diff --git a/data/ticker.json b/data/ticker.json
index 90668006..576abe63 100644
--- a/data/ticker.json
+++ b/data/ticker.json
@@ -1,5 +1,5 @@
 {
-  "fetched_at": "2026-07-23T22:06:01.722Z",
+  "fetched_at": "2026-07-23T22:26:02.963Z",
   "la_office_vac": {
     "label": "LA OFFICE VAC",
     "value": "23.1%",
diff --git a/public/index.html b/public/index.html
index e6242a4a..1c385064 100644
--- a/public/index.html
+++ b/public/index.html
@@ -103,6 +103,8 @@
 <aside class="drawer" id="drawer">
   <div class="dh"><span class="w">REN<span>TV</span></span><button id="dclose" aria-label="Close">✕</button></div>
   <div class="snap" id="snap"></div>
+  <div class="grp"><div class="gt">Discover</div></div>
+  <nav><a href="/search">🔎 Search</a></nav>
   <div class="grp"><div class="gt">Sections</div></div>
   <nav><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a></nav>
   <div class="grp"><div class="gt">Intelligence</div></div>
diff --git a/public/search.html b/public/search.html
new file mode 100644
index 00000000..b9a97afb
--- /dev/null
+++ b/public/search.html
@@ -0,0 +1,152 @@
+<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Search — RENTV.com</title>
+<meta name="description" content="Search every commercial real estate story, deal, market, video and firm across RENTV.com from one box.">
+<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,700;0,800;1,600&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
+<style>
+  :root{--red:#c8102e;--ink:#14171a;--sub:#5b636b;--line:#e4e7ea;--wash:#f5f6f8}
+  *{box-sizing:border-box;margin:0}
+  body{font-family:'Inter',system-ui,sans-serif;color:var(--ink);background:#fbfbfc;line-height:1.45}
+  a{color:inherit;text-decoration:none}
+  .top{position:sticky;top:0;z-index:900;display:flex;align-items:center;justify-content:space-between;height:54px;padding:0 16px;background:#fff;border-bottom:1px solid var(--line)}
+  .burger{display:inline-flex;align-items:center;gap:10px;border:0;background:none;cursor:pointer;font:inherit;color:inherit;padding:7px 9px;border-radius:8px}
+  .burger:hover{background:rgba(0,0,0,.06)}
+  .burger .lbl{font-size:12px;font-weight:800;letter-spacing:.14em;text-transform:uppercase;color:var(--sub)}
+  .brand{position:absolute;left:50%;transform:translateX(-50%);font-family:'Playfair Display',Georgia,serif;font-weight:800;font-size:25px;letter-spacing:.02em}.brand span{color:var(--red)}
+  .user{width:37px;height:37px;border-radius:50%;border:1.5px solid var(--line);background:#fff;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;color:var(--ink)}
+  .user:hover{border-color:var(--red);color:var(--red)}
+  .scrim{position:fixed;inset:0;background:rgba(6,9,12,.5);opacity:0;pointer-events:none;transition:opacity .22s;z-index:950}
+  .scrim.open{opacity:1;pointer-events:auto}
+  .drawer{position:fixed;top:0;left:0;height:100%;width:360px;max-width:88vw;background:#0b0e12;color:#e7ecf1;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;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid #1c232b}
+  .drawer .dh .w{font-family:'Playfair Display',serif;font-size:23px;font-weight:800}.drawer .dh .w span{color:var(--red)}
+  .drawer .dh button{background:none;border:0;color:#8b949e;font-size:22px;cursor:pointer;line-height:1}
+  .grp{padding:14px 18px 4px}.grp .gt{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:#6b747d;margin-bottom:6px}
+  .drawer nav a{display:block;padding:9px 18px;color:#dbe1e7;font-size:14.5px;border-left:3px solid transparent}
+  .drawer nav a:hover{background:#141a21;border-left-color:var(--red);color:#fff}
+  .drawer nav a.active{background:#141a21;border-left-color:var(--red);color:#fff;font-weight:700}
+  .drawer .df{margin-top:auto;padding:16px 18px;border-top:1px solid #1c232b;font-size:11px;color:#6b747d}
+
+  .wrap{max-width:900px;margin:0 auto;padding:0 22px;width:100%}
+  .searchhero{padding:46px 0 20px;text-align:center}
+  .searchhero .kick{color:var(--red);font-weight:800;font-size:12px;letter-spacing:.16em;text-transform:uppercase}
+  .searchhero h1{font-family:'Playfair Display',serif;font-size:clamp(32px,4vw,46px);font-weight:700;letter-spacing:-.01em;margin:6px 0 4px}
+  .searchhero p{color:var(--sub);font-size:15px}
+  .sbox{position:relative;margin:22px auto 0;max-width:640px}
+  .sbox svg{position:absolute;left:18px;top:50%;transform:translateY(-50%);color:var(--sub)}
+  .sbox input{width:100%;font:600 18px/1 'Inter';padding:18px 20px 18px 52px;border:2px solid var(--line);border-radius:12px;outline:0;transition:border-color .16s,box-shadow .16s;background:#fff}
+  .sbox input:focus{border-color:var(--red);box-shadow:0 0 0 4px rgba(200,16,46,.09)}
+  .meta{max-width:640px;margin:12px auto 0;color:var(--sub);font-size:12.5px;font-weight:600;letter-spacing:.02em;min-height:18px}
+  .chips{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin:14px auto 0;max-width:640px}
+  .chips b{font-size:11px;color:var(--sub);letter-spacing:.1em;text-transform:uppercase;align-self:center}
+  .chip{border:1px solid var(--line);background:#fff;border-radius:20px;padding:6px 13px;font:600 12.5px 'Inter';color:var(--ink);cursor:pointer}
+  .chip:hover{border-color:var(--red);color:var(--red)}
+
+  .results{padding:14px 0 60px}
+  .grp2{margin-top:26px}
+  .grp2 .gt{font-size:12px;text-transform:uppercase;letter-spacing:.09em;border-top:2px solid var(--ink);padding-top:9px;margin-bottom:10px;font-weight:800;display:flex;justify-content:space-between}
+  .grp2 .gt .n{color:var(--sub);font-weight:700}
+  .hit{display:grid;grid-template-columns:64px 1fr;gap:14px;padding:12px 0;border-bottom:1px solid var(--line);align-items:center}
+  .hit .im{width:64px;height:48px;border-radius:5px;background:#12161a center/cover}
+  .hit .im.none{display:flex;align-items:center;justify-content:center;color:#59636d;font-size:9px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;background:var(--wash);border:1px solid var(--line)}
+  .hit h4{font:600 16px/1.28 'Inter'}
+  .hit .sub{color:var(--sub);font-size:12.5px;margin-top:2px}
+  .hit:hover h4{color:var(--red)}
+  mark{background:rgba(200,16,46,.15);color:inherit;border-radius:2px;padding:0 1px}
+  .empty{padding:56px 0;text-align:center;color:var(--sub)}
+  .empty h3{font-family:'Playfair Display',serif;font-size:24px;color:var(--ink);margin-bottom:6px}
+  .spin{display:inline-block;width:15px;height:15px;border:2px solid var(--line);border-top-color:var(--red);border-radius:50%;animation:sp .7s linear infinite;vertical-align:-2px;margin-right:6px}@keyframes sp{to{transform:rotate(360deg)}}
+</style></head><body>
+
+<header class="top">
+  <button class="burger" id="burger" aria-label="Menu">
+    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
+    <span class="lbl">Menu</span>
+  </button>
+  <a class="brand" href="/">REN<span>TV</span></a>
+  <a class="user" href="/search" aria-label="Search" title="Search">
+    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
+  </a>
+</header>
+
+<div class="scrim" id="scrim"></div>
+<aside class="drawer" id="drawer">
+  <div class="dh"><div class="w">REN<span>TV</span></div><button id="dx" aria-label="Close">&times;</button></div>
+  <div class="grp"><div class="gt">Discover</div></div>
+  <nav><a href="/search" class="active">Search</a></nav>
+  <div class="grp"><div class="gt">Sections</div></div>
+  <nav><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a></nav>
+  <div class="grp"><div class="gt">Intelligence</div></div>
+  <nav><a href="/deals">Deals</a><a href="/markets">Markets</a><a href="/la-commercial">LA Commercial</a><a href="/desk">Broker &amp; Owner Desk</a><a href="/review">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences">Conferences</a><a href="/blog">Blog</a></nav>
+  <div class="grp"><div class="gt">Company</div></div>
+  <nav><a href="/about">About</a><a href="/advertise">Advertise</a><a href="/subscribe">Subscribe / Newsletters</a></nav>
+  <div class="df">RENTV.com — Commercial real estate news, Western U.S.</div>
+</aside>
+
+<main>
+  <section class="searchhero wrap">
+    <div class="kick">Search RENTV</div>
+    <h1>One box. Every deal, story &amp; market.</h1>
+    <p>Search across news, transactions, The REview, videos and LA firms.</p>
+    <div class="sbox">
+      <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
+      <input id="q" type="search" autocomplete="off" placeholder="Try “office”, “Los Angeles”, “refinance”, “industrial sale”…" autofocus>
+    </div>
+    <div class="meta" id="meta"></div>
+    <div class="chips" id="chips"><b>Try</b></div>
+  </section>
+  <section class="results wrap" id="results"></section>
+</main>
+
+<script>
+const $=id=>document.getElementById(id);
+const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+// drawer
+const drawer=$('drawer'),scrim=$('scrim');
+const open=()=>{drawer.classList.add('open');scrim.classList.add('open')};
+const close=()=>{drawer.classList.remove('open');scrim.classList.remove('open')};
+$('burger').onclick=open;$('dx').onclick=close;scrim.onclick=close;
+document.addEventListener('keydown',e=>{if(e.key==='Escape')close()});
+
+const SUGG=['Office','Industrial','Los Angeles','Multifamily','Refinance','Retail','Development'];
+$('chips').insertAdjacentHTML('beforeend',SUGG.map(s=>`<button class="chip" data-q="${esc(s)}">${esc(s)}</button>`).join(''));
+$('chips').addEventListener('click',e=>{const b=e.target.closest('.chip');if(b){$('q').value=b.dataset.q;run(b.dataset.q)}});
+
+function hl(text,q){ const t=esc(text); const ts=q.trim().split(/\s+/).filter(x=>x.length>1).map(x=>x.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')); if(!ts.length)return t; return t.replace(new RegExp('('+ts.join('|')+')','ig'),'<mark>$1</mark>'); }
+
+let timer=null, seq=0;
+$('q').addEventListener('input',e=>{const v=e.target.value;clearTimeout(timer);timer=setTimeout(()=>run(v),160)});
+
+// deep-link ?q=
+const initial=new URLSearchParams(location.search).get('q');
+if(initial){$('q').value=initial;run(initial);}
+
+async function run(q){
+  q=(q||'').trim();
+  const my=++seq;
+  const hist=new URLSearchParams(location.search);
+  if(q){hist.set('q',q)}else{hist.delete('q')}
+  history.replaceState(null,'','/search'+(q?('?'+hist.toString()):''));
+  if(q.length<2){$('meta').textContent='';$('results').innerHTML='';return;}
+  $('meta').innerHTML='<span class="spin"></span>Searching…';
+  let data;
+  try{ const r=await fetch('/api/search?q='+encodeURIComponent(q)); data=await r.json(); }
+  catch(err){ if(my===seq)$('meta').textContent='Search unavailable — try again.'; return; }
+  if(my!==seq) return; // a newer keystroke won
+  if(!data.total){
+    $('meta').textContent='';
+    $('results').innerHTML='<div class="empty"><h3>No matches for “'+esc(q)+'”</h3><div>Try a broader term — a city, property type, or firm.</div></div>';
+    return;
+  }
+  $('meta').textContent=data.total+' result'+(data.total===1?'':'s')+' across '+data.groups.length+' section'+(data.groups.length===1?'':'s');
+  $('results').innerHTML=data.groups.map(g=>`
+    <div class="grp2"><div class="gt">${esc(g.type)}<span class="n">${g.items.length}</span></div>
+    ${g.items.map(it=>`<a class="hit" href="${esc(it.href)}"${/^https?:/.test(it.href)?' target="_blank" rel="noopener noreferrer"':''}>
+      ${it.image?`<span class="im" style="background-image:url('${esc(it.image)}')"></span>`:`<span class="im none">${esc(g.type)}</span>`}
+      <span><h4>${hl(it.title,q)}</h4>${it.sub?`<div class="sub">${hl(it.sub,q)}</div>`:''}</span></a>`).join('')}
+    </div>`).join('');
+}
+</script>
+</body></html>
diff --git a/server.js b/server.js
index 816fa9e6..51ab22ae 100644
--- a/server.js
+++ b/server.js
@@ -407,5 +407,57 @@ app.get('/api/audience', async (_q, res) => {
 });
 app.get('/audience', (_q, r) => r.sendFile(path.join(PUB, 'audience.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.
+app.get('/api/search', (req, res) => {
+  const q = String(req.query.q || '').trim().toLowerCase();
+  res.set('Cache-Control', 'private, max-age=30');
+  if (q.length < 2) return res.json({ q, total: 0, groups: [] });
+  const terms = q.split(/\s+/).filter(Boolean);
+  const hit = (hay) => { const h = String(hay || '').toLowerCase(); return terms.every((t) => h.includes(t)); };
+  const score = (hay) => { const h = String(hay || '').toLowerCase(); let s = 0; terms.forEach((t) => { if (h.includes(t)) s += (h.startsWith(t) ? 3 : 1); }); return s; };
+
+  const out = {};
+  const push = (type, row) => { (out[type] = out[type] || []).push(row); };
+
+  // News
+  ((readJSON('news.json', {}).items) || []).forEach((n) => {
+    const blob = [n.title, n.cat].join(' ');
+    if (hit(blob)) push('News', { title: n.title, sub: n.cat || 'News', href: '/article.html?id=' + encodeURIComponent(n.id), image: n.image || '', s: score(blob) + 1 });
+  });
+  // Deals
+  ((readJSON('deals.json', {}).deals) || []).forEach((d) => {
+    const blob = [d.title, d.city, d.state, d.property_type, d.txn_type, d.amount_label].join(' ');
+    if (hit(blob)) push('Deals', { title: d.title, sub: [d.property_type, [d.city, d.state].filter(Boolean).join(', '), d.amount_label].filter(Boolean).join(' · '), href: '/deals', image: d.image || '', s: score(blob) });
+  });
+  // Videos
+  ((readJSON('videos.json', {}).items) || []).forEach((v) => {
+    const blob = [v.title, v.desc, v.cat].join(' ');
+    if (hit(blob)) push('Videos', { title: v.title, sub: v.cat || 'Video', href: v.view_url || v.embed || '#', image: v.thumb || '', s: score(blob) });
+  });
+  // Editorial posts
+  (readJSON('posts.json', []) || []).forEach((p) => {
+    if (p.status && p.status !== 'published' && p.status !== 'live') return;
+    const blob = [p.title, p.dek, p.author, p.cat].join(' ');
+    if (hit(blob)) push('The REview', { title: p.title, sub: [p.cat, p.author].filter(Boolean).join(' · '), href: '/post.html?id=' + encodeURIComponent(p.id), image: p.image || '', s: score(blob) });
+  });
+  // LA Commercial firms
+  const la = readJSON('la-commercial.json', {}) || {};
+  (la.firms || []).forEach((f) => {
+    const name = f.name || f.firm || f;
+    const blob = [name, f.focus, f.city].join(' ');
+    if (hit(blob)) push('LA Commercial', { title: name, sub: [f.focus, f.city].filter(Boolean).join(' · ') || 'Firm', href: '/la-commercial', image: '', s: score(blob) });
+  });
+
+  const order = ['News', 'Deals', 'The REview', 'Videos', 'LA Commercial'];
+  const groups = order.filter((t) => out[t]).map((t) => ({
+    type: t,
+    items: out[t].sort((a, b) => b.s - a.s).slice(0, 12),
+  }));
+  const total = groups.reduce((n, g) => n + g.items.length, 0);
+  res.json({ q, total, groups });
+});
+
 app.use(express.static(PUB, { extensions: ['html'] }));
 app.listen(PORT, () => console.log('rentv-v1 (full site, gated, live data) on ' + PORT));

← 2035105c auto-save: 2026-07-23T15:22:12 (3 files) — data/markets.json  ·  back to Rentv 2026  ·  tools: reusable $0 local feature-video generator (Chrome sho 80231abd →