[object Object]

← back to Ventura Claw Leads

yolo tick 25: /find auto-suggest dropdown — debounced AJAX, keyboard nav

870584ada3bf47927646c4ac26af7427e6e777c6 · 2026-05-07 01:34:42 -0700 · Steve Abrams

routes/public.js: GET /api/businesses/suggest?q=… returns top 8 active
businesses matching name/headline/neighborhood/city via ILIKE wildcard.
ORDER BY: prefix matches first (LOWER(business_name) LIKE 'ol%'), then
tier-priority (premier > standard > rest), then claimed > unclaimed,
then alphabetical. Cache-Control: no-store. Skips queries < 2 chars.

views/public/find.ejs: hidden <ul role=listbox> below the search input.
JS:
  - debounce 150ms on input event
  - drop stale responses (currentQ guard prevents out-of-order races)
  - render <li> with name + vertical chip + neighborhood line
  - ArrowDown/Up/Enter/Escape keyboard nav (first item active by default)
  - mousedown handler so click registers before input loses focus
  - close on outside-click
  - XSS-safe via escHtml() — no innerHTML of unescaped data

public/css/public.css: .find-suggest with shadow + scroll-cap at 360px,
2-column grid (name left + vertical right + location row spans both).

Verified live: /api/businesses/suggest?q=ol returns Olive & Salt Trattoria
first (prefix match), 5 total. Markup present. 46/46 tests pass.

Files touched

Diff

commit 870584ada3bf47927646c4ac26af7427e6e777c6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 7 01:34:42 2026 -0700

    yolo tick 25: /find auto-suggest dropdown — debounced AJAX, keyboard nav
    
    routes/public.js: GET /api/businesses/suggest?q=… returns top 8 active
    businesses matching name/headline/neighborhood/city via ILIKE wildcard.
    ORDER BY: prefix matches first (LOWER(business_name) LIKE 'ol%'), then
    tier-priority (premier > standard > rest), then claimed > unclaimed,
    then alphabetical. Cache-Control: no-store. Skips queries < 2 chars.
    
    views/public/find.ejs: hidden <ul role=listbox> below the search input.
    JS:
      - debounce 150ms on input event
      - drop stale responses (currentQ guard prevents out-of-order races)
      - render <li> with name + vertical chip + neighborhood line
      - ArrowDown/Up/Enter/Escape keyboard nav (first item active by default)
      - mousedown handler so click registers before input loses focus
      - close on outside-click
      - XSS-safe via escHtml() — no innerHTML of unescaped data
    
    public/css/public.css: .find-suggest with shadow + scroll-cap at 360px,
    2-column grid (name left + vertical right + location row spans both).
    
    Verified live: /api/businesses/suggest?q=ol returns Olive & Salt Trattoria
    first (prefix match), 5 total. Markup present. 46/46 tests pass.
---
 public/css/public.css | 11 ++++++++
 routes/public.js      | 23 ++++++++++++++++
 views/public/find.ejs | 74 +++++++++++++++++++++++++++++++++++++++++++++++++--
 3 files changed, 106 insertions(+), 2 deletions(-)

diff --git a/public/css/public.css b/public/css/public.css
index 212065c..199f398 100644
--- a/public/css/public.css
+++ b/public/css/public.css
@@ -30,6 +30,17 @@
 .vertical-chip .v-label { font-size: 14px; font-weight: 500; color: var(--fg); }
 .vertical-chip .v-count { font-size: 11px; color: var(--fg-muted); }
 
+/* Auto-suggest dropdown on /find search box */
+.find-suggest { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 30;
+  list-style: none; margin: 0; padding: 4px 0; max-height: 360px; overflow-y: auto;
+  background: var(--bg); border: 1px solid var(--border); border-radius: 4px;
+  box-shadow: 0 8px 24px rgba(0,0,0,0.12); font-size: 13px; }
+.find-suggest li { padding: 8px 12px; cursor: pointer; display: grid; grid-template-columns: 1fr auto; row-gap: 2px; }
+.find-suggest li.is-active, .find-suggest li:hover { background: var(--bg-alt); }
+.find-suggest li strong { font-weight: 500; color: var(--fg); grid-column: 1 / 2; }
+.find-suggest li .suggest-vert { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--brass); grid-column: 2 / 3; align-self: center; }
+.find-suggest li .suggest-loc { font-size: 11px; color: var(--fg-muted); grid-column: 1 / -1; }
+
 /* Find page */
 .find-page { padding: 48px 28px; max-width: 1440px; margin: 0 auto; }
 .find-filters { background: var(--bg-alt); padding: 18px 20px; border-radius: 8px; margin: 24px 0; }
diff --git a/routes/public.js b/routes/public.js
index 4e8646e..cfa97f6 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -236,6 +236,29 @@ router.get('/map', async (req, res, next) => {
   } catch (err) { next(err); }
 });
 
+router.get('/api/businesses/suggest', async (req, res, next) => {
+  try {
+    const q = String(req.query.q || '').trim().toLowerCase().slice(0, 60);
+    if (q.length < 2) return res.json({ q, results: [] });
+    const results = await db.many(`
+      SELECT slug, business_name, vertical, neighborhood, city, photo_path
+        FROM businesses
+       WHERE status = 'active'
+         AND (LOWER(business_name) LIKE $1
+              OR LOWER(headline) LIKE $1
+              OR LOWER(neighborhood) LIKE $1
+              OR LOWER(city) LIKE $1)
+       ORDER BY
+         (LOWER(business_name) LIKE $2) DESC,                            -- prefix match wins
+         tier = 'premier' DESC, tier = 'standard' DESC,
+         claim_status IN ('self','claimed') DESC,
+         business_name ASC
+       LIMIT 8
+    `, [`%${q}%`, `${q}%`]);
+    res.set('Cache-Control', 'no-store').json({ q, results });
+  } catch (err) { next(err); }
+});
+
 router.get('/api/businesses.geo', async (req, res, next) => {
   try {
     const rows = await db.many(`
diff --git a/views/public/find.ejs b/views/public/find.ejs
index 9ee53ef..0399cc3 100644
--- a/views/public/find.ejs
+++ b/views/public/find.ejs
@@ -28,9 +28,12 @@
   <p class="kicker">Find a business</p>
   <h1 class="display-sm"><%= customH1 %></h1>
 
-  <form action="/find" method="get" class="find-filters" role="search">
+  <form action="/find" method="get" class="find-filters" role="search" autocomplete="off">
     <div class="filter-row">
-      <label>Search<input type="search" name="q" value="<%= q %>" placeholder="Name, neighborhood, or category"></label>
+      <label style="position:relative">Search
+        <input type="search" name="q" value="<%= q %>" placeholder="Name, neighborhood, or category" id="find-q" autocomplete="off">
+        <ul id="find-suggest" class="find-suggest" role="listbox" hidden></ul>
+      </label>
       <label>Category
         <select name="vertical">
           <option value="">All categories</option>
@@ -112,6 +115,73 @@
 </section>
 
 <script>
+  // Auto-suggest on the search box. Debounced 150ms, fetches /api/businesses/suggest,
+  // arrow-key + enter navigation, click-outside dismiss.
+  (function(){
+    var input = document.getElementById('find-q');
+    var list = document.getElementById('find-suggest');
+    if (!input || !list) return;
+    var debounceTimer = null;
+    var currentQ = '';
+    var activeIdx = -1;
+    function escHtml(s){ return String(s||'').replace(/[&<>"']/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }
+    function close(){ list.hidden = true; activeIdx = -1; list.innerHTML = ''; }
+    function open(){ list.hidden = false; }
+    function setActive(idx) {
+      var items = list.querySelectorAll('li');
+      items.forEach(function(li, i){ li.classList.toggle('is-active', i === idx); });
+      activeIdx = idx;
+    }
+    async function fetchSuggest(q){
+      try {
+        var r = await fetch('/api/businesses/suggest?q=' + encodeURIComponent(q));
+        if (!r.ok) return [];
+        var d = await r.json();
+        return (d.q === currentQ) ? d.results : null;
+      } catch(e){ return []; }
+    }
+    function render(items){
+      if (!items.length) { close(); return; }
+      list.innerHTML = items.map(function(b, i){
+        return '<li role="option" data-href="/business/' + escHtml(b.slug) + '"' + (i === 0 ? ' class="is-active"' : '') + '>' +
+          '<strong>' + escHtml(b.business_name) + '</strong>' +
+          '<span class="suggest-vert">' + escHtml((b.vertical||'').replace(/_/g,' ')) + '</span>' +
+          '<span class="suggest-loc">' + escHtml([b.neighborhood, b.city].filter(Boolean).join(' · ')) + '</span>' +
+          '</li>';
+      }).join('');
+      activeIdx = 0;
+      open();
+    }
+    input.addEventListener('input', function(){
+      var v = input.value.trim();
+      currentQ = v;
+      clearTimeout(debounceTimer);
+      if (v.length < 2) { close(); return; }
+      debounceTimer = setTimeout(async function(){
+        var items = await fetchSuggest(v);
+        if (items === null) return;
+        render(items);
+      }, 150);
+    });
+    input.addEventListener('keydown', function(e){
+      var items = list.querySelectorAll('li');
+      if (e.key === 'ArrowDown') { if (items.length) { setActive(Math.min(activeIdx + 1, items.length - 1)); e.preventDefault(); } }
+      else if (e.key === 'ArrowUp') { if (items.length) { setActive(Math.max(activeIdx - 1, 0)); e.preventDefault(); } }
+      else if (e.key === 'Enter' && activeIdx >= 0 && items[activeIdx]) {
+        var href = items[activeIdx].getAttribute('data-href');
+        if (href) { window.location = href; e.preventDefault(); }
+      }
+      else if (e.key === 'Escape') { close(); }
+    });
+    list.addEventListener('mousedown', function(e){
+      var li = e.target.closest('li[data-href]');
+      if (li) { window.location = li.getAttribute('data-href'); }
+    });
+    document.addEventListener('click', function(e){
+      if (!input.contains(e.target) && !list.contains(e.target)) close();
+    });
+  })();
+
   // Sort + density: persist to localStorage, sort change reloads with ?sort=,
   // density updates a CSS var live (no reload).
   (function(){

← e1590f8 yolo tick 24: home featured grid — photo-priority within tie  ·  back to Ventura Claw Leads  ·  yolo tick 26: per-neighborhood landing pages — /neighborhood f41ef50 →