← 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
M public/broker-grid.htmlM public/condos.htmlM public/deals.htmlM public/fha-loans.htmlA public/global-search.jsM public/index.htmlM public/just-listed.htmlM public/licensed.htmlM public/mls.htmlM public/rent-rolls.htmlM public/sales.htmlM scripts/serve.js
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); };
+ 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 →