[object Object]

← back to Ventura Claw Leads

yolo tick 5: sort + density slider on /find (per global standing rule)

20a0eb64b46419fd206239c2952c83df0a626429 · 2026-05-06 17:41:30 -0700 · Steve Abrams

CLAUDE.md updated 2026-05-06 with: 'Every site that renders a product grid
MUST have sort + density slider'. VCL's /find renders a directory grid —
adding compliance.

Sort whitelist (server-side ORDER BY clauses, anything else falls back to
'featured'):
  featured  — server's natural order: tier=premier→standard, claimed-first, name asc
  newest    — created_at DESC
  updated   — updated_at DESC
  name_asc  — business_name ASC
  name_desc — business_name DESC
  vertical  — group by vertical, then name asc

Density: <input type=range min=220 max=480 step=20 default=300> drives
the --vcl-card-min CSS var on .business-grid (smaller=denser, larger=fewer-bigger).
Both sort + density persist in localStorage (vcl-grid-sort, vcl-grid-density).
On page load, if user has a saved non-default sort and the URL has none, we
redirect to ?sort=… so server-side ordering matches.

UI: grid-controls bar above the cards — left side keeps the count, right
side has the sort + density. Brass-accent range thumb.

46/46 tests still pass. All 6 sorts return 200 on prod.

Files touched

Diff

commit 20a0eb64b46419fd206239c2952c83df0a626429
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 6 17:41:30 2026 -0700

    yolo tick 5: sort + density slider on /find (per global standing rule)
    
    CLAUDE.md updated 2026-05-06 with: 'Every site that renders a product grid
    MUST have sort + density slider'. VCL's /find renders a directory grid —
    adding compliance.
    
    Sort whitelist (server-side ORDER BY clauses, anything else falls back to
    'featured'):
      featured  — server's natural order: tier=premier→standard, claimed-first, name asc
      newest    — created_at DESC
      updated   — updated_at DESC
      name_asc  — business_name ASC
      name_desc — business_name DESC
      vertical  — group by vertical, then name asc
    
    Density: <input type=range min=220 max=480 step=20 default=300> drives
    the --vcl-card-min CSS var on .business-grid (smaller=denser, larger=fewer-bigger).
    Both sort + density persist in localStorage (vcl-grid-sort, vcl-grid-density).
    On page load, if user has a saved non-default sort and the URL has none, we
    redirect to ?sort=… so server-side ordering matches.
    
    UI: grid-controls bar above the cards — left side keeps the count, right
    side has the sort + density. Brass-accent range thumb.
    
    46/46 tests still pass. All 6 sorts return 200 on prod.
---
 public/css/public.css |  7 +++++-
 routes/public.js      | 19 ++++++++++++++--
 views/public/find.ejs | 63 +++++++++++++++++++++++++++++++++++++++++++++++++--
 3 files changed, 84 insertions(+), 5 deletions(-)

diff --git a/public/css/public.css b/public/css/public.css
index 45821ce..e6b7e97 100644
--- a/public/css/public.css
+++ b/public/css/public.css
@@ -22,7 +22,12 @@
 .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; }
 .filter-row { display: grid; grid-template-columns: 2fr 1fr 1fr auto; gap: 12px; align-items: end; }
-.business-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 18px; margin-top: 24px; }
+.business-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--vcl-card-min, 300px), 1fr)); gap: 18px; margin-top: 24px; }
+.grid-controls { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin: 16px 0 8px; }
+.grid-controls-right { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
+.grid-control { display: inline-flex; align-items: center; gap: 8px; font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--fg-muted); margin: 0; }
+.grid-control select { padding: 5px 10px; font-size: 13px; min-width: 140px; }
+.grid-control input[type="range"] { width: 120px; accent-color: var(--brass); }
 .business-card { display: flex; flex-direction: column; gap: 8px; padding: 18px 20px; border: 1px solid var(--border); background: var(--bg); border-radius: 4px; transition: border-color 0.15s, transform 0.15s; }
 .business-card:hover { border-color: var(--brass); transform: translateY(-1px); }
 .business-card .biz-vertical { font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--brass); }
diff --git a/routes/public.js b/routes/public.js
index 5374312..afbe1ea 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -53,18 +53,33 @@ router.get('/find', async (req, res, next) => {
       where.push(`city ILIKE $${params.length}`);
     }
 
+    // Sort whitelist — anything else falls back to 'featured' (server's natural order
+    // = tier-priority + claimed-first + name-asc). This is the directory-equivalent
+    // of the standing 'sort + density slider' rule for product grids.
+    const sortKey = String(req.query.sort || 'featured').trim();
+    const orderClauses = {
+      featured:   `tier = 'premier' DESC, tier = 'standard' DESC, claim_status IN ('self','claimed') DESC, business_name ASC`,
+      newest:     `created_at DESC`,
+      updated:    `updated_at DESC`,
+      name_asc:   `business_name ASC`,
+      name_desc:  `business_name DESC`,
+      vertical:   `vertical ASC, business_name ASC`
+    };
+    const orderBy = orderClauses[sortKey] || orderClauses.featured;
+
     const businesses = await db.many(`
       SELECT id, slug, business_name, vertical, headline, neighborhood, city, state, tier, claim_status, verified
         FROM businesses
        WHERE ${where.join(' AND ')}
-       ORDER BY tier = 'premier' DESC, tier = 'standard' DESC, claim_status IN ('self','claimed') DESC, business_name ASC
+       ORDER BY ${orderBy}
        LIMIT 200
     `, params);
 
     res.render('public/find', {
       title: 'Find a business — Ventura Claw',
       metaDescription: `Browse ${businesses.length} local businesses on Ventura Blvd.${v ? ' Filtered by ' + (BY_KEY[v]?.label || v) + '.' : ''}`,
-      businesses, q, v, city, verticals: VERTICALS
+      businesses, q, v, city, verticals: VERTICALS,
+      sort: orderClauses[sortKey] ? sortKey : 'featured'
     });
   } catch (err) { next(err); }
 });
diff --git a/views/public/find.ejs b/views/public/find.ejs
index a0a3295..fd5cb14 100644
--- a/views/public/find.ejs
+++ b/views/public/find.ejs
@@ -44,7 +44,24 @@
     </div>
   </form>
 
-  <p class="muted" style="margin: 16px 0 8px"><%= businesses.length %> business<%= businesses.length === 1 ? '' : 'es' %> found</p>
+  <div class="grid-controls" role="region" aria-label="Sort and density">
+    <p class="muted" style="margin: 0"><%= businesses.length %> business<%= businesses.length === 1 ? '' : 'es' %> found</p>
+    <div class="grid-controls-right">
+      <label class="grid-control"><span>Sort</span>
+        <select id="grid-sort">
+          <option value="featured" <%= sort === 'featured' ? 'selected' : '' %>>Featured</option>
+          <option value="newest"   <%= sort === 'newest'   ? 'selected' : '' %>>Newest</option>
+          <option value="updated"  <%= sort === 'updated'  ? 'selected' : '' %>>Recently updated</option>
+          <option value="name_asc" <%= sort === 'name_asc' ? 'selected' : '' %>>Name A→Z</option>
+          <option value="name_desc"<%= sort === 'name_desc'? 'selected' : '' %>>Name Z→A</option>
+          <option value="vertical" <%= sort === 'vertical' ? 'selected' : '' %>>By category</option>
+        </select>
+      </label>
+      <label class="grid-control"><span>Density</span>
+        <input type="range" id="grid-density" min="220" max="480" step="20" value="300" aria-label="Card width">
+      </label>
+    </div>
+  </div>
 
   <% if (businesses.length === 0) { %>
     <div style="padding:40px 0;text-align:center">
@@ -52,7 +69,7 @@
       <p class="muted">Try a broader search, or <a href="/find">browse all listings</a>. We're adding new businesses every week.</p>
     </div>
   <% } else { %>
-    <div class="business-grid">
+    <div class="business-grid" id="grid">
       <% businesses.forEach(function(b){ %>
         <a class="business-card" href="/business/<%= b.slug %>">
           <p class="biz-vertical"><%= verticals.find(function(vv){return vv.key===b.vertical}) ? verticals.find(function(vv){return vv.key===b.vertical}).label : b.vertical %></p>
@@ -71,4 +88,46 @@
   <% } %>
 </section>
 
+<script>
+  // Sort + density: persist to localStorage, sort change reloads with ?sort=,
+  // density updates a CSS var live (no reload).
+  (function(){
+    var grid = document.getElementById('grid');
+    var sortSel = document.getElementById('grid-sort');
+    var dens = document.getElementById('grid-density');
+    if (!grid) return;
+
+    function applyDensity(px){
+      grid.style.setProperty('--vcl-card-min', px + 'px');
+      try { localStorage.setItem('vcl-grid-density', String(px)); } catch(e){}
+    }
+    var savedDens = null;
+    try { savedDens = localStorage.getItem('vcl-grid-density'); } catch(e){}
+    if (savedDens && Number(savedDens) >= 220 && Number(savedDens) <= 480) {
+      dens.value = savedDens;
+      applyDensity(Number(savedDens));
+    } else {
+      applyDensity(Number(dens.value));
+    }
+    dens.addEventListener('input', function(){ applyDensity(Number(this.value)); });
+
+    var savedSort = null;
+    try { savedSort = localStorage.getItem('vcl-grid-sort'); } catch(e){}
+    var urlSort = new URLSearchParams(window.location.search).get('sort');
+    if (!urlSort && savedSort && savedSort !== 'featured') {
+      // User had a non-default saved — apply it via URL.
+      var p = new URLSearchParams(window.location.search);
+      p.set('sort', savedSort);
+      window.location.search = p.toString();
+      return;
+    }
+    sortSel.addEventListener('change', function(){
+      try { localStorage.setItem('vcl-grid-sort', this.value); } catch(e){}
+      var p = new URLSearchParams(window.location.search);
+      if (this.value === 'featured') p.delete('sort'); else p.set('sort', this.value);
+      window.location.search = p.toString();
+    });
+  })();
+</script>
+
 <%- include('../partials/footer') %>

← 713a5b4 yolo tick 4: /robots.txt + /sitemap.xml — Google indexing in  ·  back to Ventura Claw Leads  ·  yolo tick 6: /admin/leads.csv export + mobile audit clean 81d7dec →