[object Object]

← back to Commercialrealestate

global search at top: /api/search across brokers+condos+SFRs (address/name/firm/license/DRE#, allSettled-resilient) + global-search.js nav search box w/ grouped dropdown + keyboard nav; included on 10 CRE pages

7f384b2760694107721fc1b65319c3b0f08f55e1 · 2026-08-03 12:16:20 -0700 · Steve Abrams

Files touched

Diff

commit 7f384b2760694107721fc1b65319c3b0f08f55e1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 3 12:16:20 2026 -0700

    global search at top: /api/search across brokers+condos+SFRs (address/name/firm/license/DRE#, allSettled-resilient) + global-search.js nav search box w/ grouped dropdown + keyboard nav; included on 10 CRE pages
---
 public/broker-grid.html |  1 +
 public/condos.html      |  1 +
 public/deals.html       |  1 +
 public/fha-loans.html   |  1 +
 public/global-search.js | 70 +++++++++++++++++++++++++++++++++++++++++++++++++
 public/index.html       |  1 +
 public/just-listed.html |  3 ++-
 public/licensed.html    |  3 ++-
 public/mls.html         |  1 +
 public/rent-rolls.html  |  1 +
 public/sales.html       |  1 +
 scripts/serve.js        | 35 +++++++++++++++++++++++++
 12 files changed, 117 insertions(+), 2 deletions(-)

diff --git a/public/broker-grid.html b/public/broker-grid.html
index ba6b26e..10cfcf8 100644
--- a/public/broker-grid.html
+++ b/public/broker-grid.html
@@ -446,5 +446,6 @@ fetch('/api/brokers/all').then(r=>r.json()).then(d=>{DATA=d.brokers||[];autoCols
   .catch(()=>{$('#count').textContent='failed to load /api/brokers/all';});
 </script>
   <script src="/col-resize.js" defer></script>
+  <script src="/global-search.js" defer></script>
 </body>
 </html>
diff --git a/public/condos.html b/public/condos.html
index 05c556f..d54a979 100644
--- a/public/condos.html
+++ b/public/condos.html
@@ -580,5 +580,6 @@ if(railEl){ railEl.addEventListener('click',e=>{ const h=e.target.closest('h4');
 boot();
 </script>
   <script src="/col-resize.js" defer></script>
+  <script src="/global-search.js" defer></script>
 </body>
 </html>
diff --git a/public/deals.html b/public/deals.html
index 72e261c..1de9556 100644
--- a/public/deals.html
+++ b/public/deals.html
@@ -289,5 +289,6 @@ document.getElementById('metric').addEventListener('click', e=>{
 pull();   // initial load
 </script>
   <script src="/col-resize.js" defer></script>
+  <script src="/global-search.js" defer></script>
 </body>
 </html>
diff --git a/public/fha-loans.html b/public/fha-loans.html
index dac70a3..39dcd15 100644
--- a/public/fha-loans.html
+++ b/public/fha-loans.html
@@ -496,5 +496,6 @@ async function load(){
 load();
 </script>
   <script src="/col-resize.js" defer></script>
+  <script src="/global-search.js" defer></script>
 </body>
 </html>
diff --git a/public/global-search.js b/public/global-search.js
new file mode 100644
index 0000000..853db6c
--- /dev/null
+++ b/public/global-search.js
@@ -0,0 +1,70 @@
+/* global-search.js — one search box in the top nav, searches ACROSS brokers + condos + SFRs
+ * (Steve 2026-08-03: "give search global at top"). Hits /api/search, shows grouped results.
+ * Self-contained: injects its own styles + markup into the first .nav (or body top). */
+(function () {
+  'use strict';
+  if (window.__gsearchInit) return; window.__gsearchInit = true;
+
+  var css = document.createElement('style');
+  css.textContent = [
+    '.gsearch{position:relative;margin-left:auto;display:flex;align-items:center}',
+    '.gsearch input{width:230px;max-width:46vw;background:rgba(255,255,255,.06);border:1px solid var(--line,#2a313c);',
+    'color:var(--ink,#e6edf3);border-radius:9px;padding:6px 10px;font-size:12.5px;outline:none}',
+    '.gsearch input:focus{border-color:var(--blue,#58a6ff);width:300px}',
+    '.gsearch .gres{position:absolute;top:110%;right:0;width:380px;max-width:92vw;max-height:70vh;overflow:auto;',
+    'background:var(--card,#161b22);border:1px solid var(--line,#2a313c);border-radius:10px;box-shadow:0 12px 40px rgba(0,0,0,.5);',
+    'z-index:9999;display:none;padding:4px}',
+    '.gsearch .gres.on{display:block}',
+    '.gsearch .ghead{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut,#8b949e);padding:7px 9px 3px}',
+    '.gsearch a.gitem{display:block;padding:6px 9px;border-radius:7px;text-decoration:none;color:var(--ink,#e6edf3)}',
+    '.gsearch a.gitem:hover,.gsearch a.gitem.sel{background:rgba(88,166,255,.14)}',
+    '.gsearch .gitem .gl{font-size:13px;font-weight:600}',
+    '.gsearch .gitem .gs{font-size:11px;color:var(--mut,#8b949e)}',
+    '.gsearch .gempty{padding:10px;color:var(--mut,#8b949e);font-size:12px}'
+  ].join('');
+  document.head.appendChild(css);
+
+  var wrap = document.createElement('div'); wrap.className = 'gsearch';
+  wrap.innerHTML = '<input type="search" placeholder="🔍 Search everything — address, broker, firm, DRE#…" aria-label="Global search">'
+    + '<div class="gres" role="listbox"></div>';
+  var host = document.querySelector('.nav') || document.querySelector('header nav') || document.querySelector('nav') || document.body;
+  if (host === document.body) { wrap.style.cssText = 'position:fixed;top:8px;right:12px;z-index:9999'; document.body.appendChild(wrap); }
+  else host.appendChild(wrap);
+
+  var input = wrap.querySelector('input'), box = wrap.querySelector('.gres');
+  var esc = function (s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); };
+  var timer = null, items = [], sel = -1, lastQ = '';
+
+  function group(title, arr) {
+    if (!arr.length) return '';
+    return '<div class="ghead">' + esc(title) + ' (' + arr.length + ')</div>' + arr.map(function (r) {
+      return '<a class="gitem" href="' + esc(r.href) + '"' + (/^https?:/.test(r.href) ? ' target="_blank" rel="noopener noreferrer"' : '') + '>'
+        + '<div class="gl">' + esc(r.label || '—') + '</div>' + (r.sub ? '<div class="gs">' + esc(r.sub) + '</div>' : '') + '</a>';
+    }).join('');
+  }
+  function render(d) {
+    var html = group('Brokers', d.brokers || []) + group('Condos', d.condos || []) + group('SFR / listings', d.sfrs || []);
+    box.innerHTML = html || '<div class="gempty">No matches for “' + esc(d.q) + '”</div>';
+    box.classList.add('on');
+    items = Array.prototype.slice.call(box.querySelectorAll('a.gitem')); sel = -1;
+  }
+  function run() {
+    var q = input.value.trim(); if (q === lastQ) return; lastQ = q;
+    if (q.length < 2) { box.classList.remove('on'); return; }
+    fetch('/api/search?q=' + encodeURIComponent(q), { headers: { 'Accept': 'application/json' } })
+      .then(function (r) { return r.json(); }).then(render).catch(function () { });
+  }
+  input.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(run, 160); });
+  input.addEventListener('focus', function () { if (box.innerHTML && input.value.trim().length >= 2) box.classList.add('on'); });
+  input.addEventListener('keydown', function (e) {
+    if (e.key === 'Escape') { box.classList.remove('on'); input.blur(); return; }
+    if (!items.length) return;
+    if (e.key === 'ArrowDown') { e.preventDefault(); sel = Math.min(sel + 1, items.length - 1); }
+    else if (e.key === 'ArrowUp') { e.preventDefault(); sel = Math.max(sel - 1, 0); }
+    else if (e.key === 'Enter') { if (sel >= 0 && items[sel]) items[sel].click(); return; }
+    else return;
+    items.forEach(function (el, i) { el.classList.toggle('sel', i === sel); });
+    if (items[sel]) items[sel].scrollIntoView({ block: 'nearest' });
+  });
+  document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) box.classList.remove('on'); });
+})();
diff --git a/public/index.html b/public/index.html
index 99d78c8..a99ae3e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -2013,5 +2013,6 @@ fetch('data/ranked.json').then(r=>r.json()).then(async d=>{
 }).catch(e=>{ $('#sub').textContent='Failed to load data/ranked.json — run: npm run analyze'; });
 </script>
   <script src="/col-resize.js" defer></script>
+  <script src="/global-search.js" defer></script>
 </body>
 </html>
diff --git a/public/just-listed.html b/public/just-listed.html
index cd55d72..32a5112 100644
--- a/public/just-listed.html
+++ b/public/just-listed.html
@@ -179,4 +179,5 @@ fetch('/data/listings.json').then(r=>r.json()).then(d=>{
   $('#prov').innerHTML=`Active listings sourced from broker/portal feeds${META.sourced?` · batch sourced <b>${esc(String(META.sourced).slice(0,10))}</b>`:''}. This feed carries no per-listing "date listed", so there's no true recency sort — use <b>Cap rate</b>, <b>Price</b>, or <b>Newest built</b>. Verify status against the source before acting.`;
   buildRail();render();
 }).catch(()=>{$('#count').textContent='failed to load /data/listings.json';});
-</script></body></html>
+</script>  <script src="/global-search.js" defer></script>
+</body></html>
diff --git a/public/licensed.html b/public/licensed.html
index fe2435b..78d2455 100644
--- a/public/licensed.html
+++ b/public/licensed.html
@@ -207,4 +207,5 @@ document.addEventListener('click',async e=>{
   if(cardEl && !e.target.closest('button,a,.figt')) openDetail(cardEl.dataset.id);
 });
 boot().catch(e=>{$('#count').textContent='Failed to load: '+e.message;});
-</script></body></html>
+</script>  <script src="/global-search.js" defer></script>
+</body></html>
diff --git a/public/mls.html b/public/mls.html
index db5a034..a4d60a6 100644
--- a/public/mls.html
+++ b/public/mls.html
@@ -572,5 +572,6 @@ fetch('/data/ranked.json').then(r=>r.json()).then(async d=>{ DATA=(d.ranked||d)|
 </script>
   <script src="/column-manager.js" defer></script>
   <script src="/col-resize.js" defer></script>
+  <script src="/global-search.js" defer></script>
 </body>
 </html>
diff --git a/public/rent-rolls.html b/public/rent-rolls.html
index ca94149..d857223 100644
--- a/public/rent-rolls.html
+++ b/public/rent-rolls.html
@@ -432,5 +432,6 @@ fetch('/api/rent-rolls').then(r=>r.json()).then(d=>{
 }).catch(e=>{ $('#rolls').innerHTML='<div class="glass empty">Failed to load rent rolls: '+esc(e.message)+'</div>'; });
 </script>
   <script src="/col-resize.js" defer></script>
+  <script src="/global-search.js" defer></script>
 </body>
 </html>
diff --git a/public/sales.html b/public/sales.html
index 526cdcc..5f94b0a 100644
--- a/public/sales.html
+++ b/public/sales.html
@@ -383,5 +383,6 @@ fetch('/api/closed-sales?limit=5000').then(r=>r.json()).then(d=>{
 </script>
   <script src="/column-manager.js" defer></script>
   <script src="/col-resize.js" defer></script>
+  <script src="/global-search.js" defer></script>
 </body>
 </html>
diff --git a/scripts/serve.js b/scripts/serve.js
index aa2293b..53664fb 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -368,6 +368,41 @@ function snapFirmDetail(res, name) {
     expiredNote: (fd.expired || []).length ? 'Firm listings that now appear in the sold feed (best-effort match — no MLS off-market feed).' : 'No MLS off-market/withdrawn feed matched this firm’s listings.',
     source: 'snapshot' });
 }
+// ── GLOBAL search (Steve 2026-08-03): one box, searches across brokers + condos + SFRs by
+//    address / name / firm / license / DRE#. Backs the top-nav global search on every page. ──
+app.get('/api/search', async (req, res) => {
+  const q = String(req.query.q || '').trim();
+  if (q.length < 2) return res.json({ q, brokers: [], condos: [], sfrs: [] });
+  if (!brokerdb) return res.json({ q, brokers: [], condos: [], sfrs: [], note: 'db offline' });
+  const like = '%' + q.replace(/[%_\\]/g, '\\$&') + '%';
+  const N = 8;
+  const j = s => encodeURIComponent(s || '');
+  // allSettled so one table's failure can't zero out the others.
+  const [brokers, condos, sfrs] = await Promise.allSettled([
+    brokerdb.pool.query(
+      `SELECT b.id, b.name, f.name AS firm, COALESCE(NULLIF(b.dre_license,''), b.license) AS license
+         FROM broker b LEFT JOIN firm f ON f.id = b.firm_id
+        WHERE b.name ILIKE $1 OR f.name ILIKE $1 OR b.license ILIKE $1 OR b.dre_license ILIKE $1
+        ORDER BY (b.name ILIKE $1) DESC, b.name LIMIT ${N}`, [like]),
+    brokerdb.pool.query(
+      `SELECT id, address, city, project_name, broker_name
+         FROM condo_card
+        WHERE address ILIKE $1 OR project_name ILIKE $1 OR broker_name ILIKE $1 OR city ILIKE $1
+        ORDER BY (status = 'active') DESC LIMIT ${N}`, [like]),
+    brokerdb.pool.query(
+      `SELECT id, address, city, broker_name, source
+         FROM sfr
+        WHERE address ILIKE $1 OR broker_name ILIKE $1 OR city ILIKE $1
+        LIMIT ${N}`, [like]),
+  ]);
+  const rows = r => (r.status === 'fulfilled' ? r.value.rows : []);
+  res.json({
+    q,
+    brokers: rows(brokers).map(b => ({ label: b.name, sub: [b.firm, b.license && ('DRE# ' + b.license)].filter(Boolean).join(' · '), href: '/broker-grid.html?q=' + j(b.name) })),
+    condos: rows(condos).map(c => ({ label: c.project_name || c.address, sub: [c.address, c.city, c.broker_name].filter(Boolean).join(' · '), href: '/condos.html?q=' + j(c.address || c.project_name) })),
+    sfrs: rows(sfrs).map(s => ({ label: s.address, sub: [s.city, s.broker_name].filter(Boolean).join(' · '), href: (s.source && /^https?:/.test(s.source)) ? s.source : ('/mls.html?q=' + j(s.address)) })),
+  });
+});
 app.get('/api/brokers/all', async (req, res) => {
   // Default lists ALL brokers (Steve: "list all, ok if not ca"). Geography is retained as a
   // queryable label (b.state / b.dre_match) rather than a hard filter — pass ?scope=ca to get

← ddf3ff7 commercial-broker CA-DRE hook: daily $0 cron (Steve-authoriz  ·  back to Commercialrealestate  ·  global search (Cody gate): deep-link ?q= readers in broker-g 02f9715 →