← 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
M public/css/public.cssM routes/public.jsM views/public/find.ejs
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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 →