← 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, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
  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();
})();