← back to Nationalrealestate
public/geo-search.js
113 lines
/* geo-search.js — universal top location search. Type an address / ZIP / city / county /
* state, get a typeahead (via /api/geo-search), and jump to that market page.
* Mounts two ways so it fits every page:
* - grid pages (have .cg-shell): INLINE as the first control in the grid's .cg-top bar
* - everything else: a fixed top-center pill
* Include on any page + the matching geo-search.css. */
(function () {
const esc = s => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
const badge = ty => ({ county: 'County', metro: 'Metro', state: 'State', zip: 'ZIP', address: 'Address' }[ty] || ty);
function build(host, mode) {
host.className = 'geo-host geo-' + mode;
host.innerHTML =
'<div class="geo-box"><span class="geo-ic">⌕</span>' +
'<input class="geo-input" type="text" autocomplete="off" spellcheck="false" ' +
'placeholder="Search address, ZIP, city, or county…" aria-label="Location search">' +
'<button class="geo-clear" type="button" aria-label="Clear" style="display:none">✕</button></div>' +
'<div class="geo-results"></div>';
const input = host.querySelector('.geo-input');
const results = host.querySelector('.geo-results');
const clear = host.querySelector('.geo-clear');
let items = [], active = -1, t = null, lastQ = '';
const close = () => { results.innerHTML = ''; results.classList.remove('open'); items = []; active = -1; };
const go = u => { if (u) location.href = u; };
// recent searches (client-side, per browser) — shown when the box is focused + empty
const RKEY = 'usre.recentSearches';
const readRecent = () => { try { return JSON.parse(localStorage.getItem(RKEY) || '[]'); } catch (e) { return []; } };
const remember = it => {
try {
if (!it || !it.url) return;
const a = readRecent().filter(x => x.url !== it.url);
a.unshift({ name: it.name, url: it.url, type: it.type, state: it.state || null, sub: it.sub || null });
localStorage.setItem(RKEY, JSON.stringify(a.slice(0, 6)));
} catch (e) {}
};
const rowHtml = (r, i) =>
`<a class="geo-item" data-i="${i}" href="${esc(r.url)}">` +
`<span class="geo-name">${esc(r.name)}${r.sub ? `<span class="geo-subline">${esc(r.sub)}</span>` : ''}</span>` +
(r.state ? `<span class="geo-state">${esc(r.state)}</span>` : '') +
`<span class="geo-badge">${badge(r.type)}</span></a>`;
function showRecent() {
const rec = readRecent();
if (!rec.length) { close(); return; }
items = rec; active = -1;
results.innerHTML = '<div class="geo-rechdr">Recent<span class="geo-recclr">clear</span></div>' + rec.map(rowHtml).join('');
results.classList.add('open');
}
function render(rows) {
items = rows;
if (!rows.length) {
const zipish = /^\d{5}$/.test(lastQ), addrish = /^\d+\s+\S/.test(lastQ);
results.innerHTML = '<div class="geo-empty">' +
(zipish ? `No county found for ZIP ${esc(lastQ)}.`
: addrish ? 'Address not recognized — try including city + state.'
: 'No matches.') + '</div>';
results.classList.add('open'); return;
}
results.innerHTML = rows.map(rowHtml).join('');
results.classList.add('open'); active = -1;
}
function setActive(n) {
const a = results.querySelectorAll('.geo-item'); if (!a.length) return;
active = (n + a.length) % a.length;
a.forEach((x, i) => x.classList.toggle('active', i === active));
a[active].scrollIntoView({ block: 'nearest' });
}
function search(q) {
fetch('/api/geo-search?q=' + encodeURIComponent(q)).then(r => r.json()).then(d => {
if (d.q !== input.value.trim()) return; // ignore stale responses
render(d.results || []);
}).catch(() => {});
}
input.addEventListener('input', () => {
const q = input.value.trim(); lastQ = q;
clear.style.display = q ? 'block' : 'none';
clearTimeout(t);
if (q.length < 2) { showRecent(); return; } // empty box shows recent searches
t = setTimeout(() => search(q), 180);
});
input.addEventListener('focus', () => { if (input.value.trim().length < 2) showRecent(); });
input.addEventListener('keydown', e => {
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(active + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(active - 1); }
else if (e.key === 'Enter') { const it = (active >= 0 && items[active]) ? items[active] : items[0]; if (it) { remember(it); go(it.url); } }
else if (e.key === 'Escape') { close(); input.blur(); }
});
clear.addEventListener('click', () => { input.value = ''; clear.style.display = 'none'; input.focus(); showRecent(); });
results.addEventListener('click', e => {
if (e.target.closest('.geo-recclr')) { e.preventDefault(); try { localStorage.removeItem(RKEY); } catch (x) {} close(); return; }
const a = e.target.closest('.geo-item'); if (a) { const it = items[+a.dataset.i]; if (it) remember(it); } // store before the browser follows href
});
document.addEventListener('click', e => { if (!host.contains(e.target)) close(); });
}
function init() {
if (document.querySelector('.geo-host')) return;
const mountPill = () => { const h = document.createElement('div'); document.body.appendChild(h); build(h, 'pill'); };
if (document.querySelector('.cg-shell')) {
// grid page: wait for CrcpGrid to build .cg-top, then mount inline as its first control
let tries = 0;
const iv = setInterval(() => {
const top = document.querySelector('.cg-top');
if (top) { clearInterval(iv); const h = document.createElement('div'); top.insertBefore(h, top.firstChild); build(h, 'inline'); }
else if (++tries > 80) { clearInterval(iv); mountPill(); } // fallback if grid never mounts
}, 100);
} else mountPill();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
})();