[object Object]

← back to Ventura Claw Leads

neighborhood: add sort + density grid controls

34665fbbc984510f90866c3c7cf753ef0ecea1be · 2026-05-19 21:32:40 -0700 · Steve Abrams

The /neighborhood/:slug page renders a primary business-grid but was
missing the sort <select> + density slider that /find already has.
Standing rule — every site that renders a product/business grid MUST
ship both controls, persisted to localStorage.

Adds a server-side sort whitelist (featured / newest / updated /
name_asc / name_desc / vertical) mirroring /find's options exactly, and
a frontend JS block that hydrates the saved value from localStorage
BEFORE the first grid load. The 'vcl-grid-sort' + 'vcl-grid-density'
keys are deliberately shared with /find so a user's preference carries
across both surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 34665fbbc984510f90866c3c7cf753ef0ecea1be
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 19 21:32:40 2026 -0700

    neighborhood: add sort + density grid controls
    
    The /neighborhood/:slug page renders a primary business-grid but was
    missing the sort <select> + density slider that /find already has.
    Standing rule — every site that renders a product/business grid MUST
    ship both controls, persisted to localStorage.
    
    Adds a server-side sort whitelist (featured / newest / updated /
    name_asc / name_desc / vertical) mirroring /find's options exactly, and
    a frontend JS block that hydrates the saved value from localStorage
    BEFORE the first grid load. The 'vcl-grid-sort' + 'vcl-grid-density'
    keys are deliberately shared with /find so a user's preference carries
    across both surfaces.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 routes/public.js              | 22 ++++++++++++----
 views/public/neighborhood.ejs | 60 +++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 77 insertions(+), 5 deletions(-)

diff --git a/routes/public.js b/routes/public.js
index 50015fe..1fab759 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -313,6 +313,20 @@ router.get('/neighborhood/:slug', async (req, res, next) => {
     const meta = NEIGHBORHOODS.find(n => n.slug === slug);
     if (!meta) return res.status(404).render('public/404', { title: 'Not found' });
 
+    // Sort whitelist — matches the /find route's options so the JS layer can
+    // reuse the same persisted localStorage key shape. 'featured' is the
+    // server's natural directory order (tier-priority + claimed-first + photo).
+    const sortKey = String(req.query.sort || 'featured').trim();
+    const orderClauses = {
+      featured:   `tier = 'premier' DESC, tier = 'standard' DESC, claim_status IN ('self','claimed') DESC, (photo_path IS NOT NULL) 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, photo_path
@@ -320,10 +334,7 @@ router.get('/neighborhood/:slug', async (req, res, next) => {
        WHERE status = 'active'
          AND (LOWER(REPLACE(neighborhood, ' ', '-')) = $1
               OR LOWER(REPLACE(city, ' ', '-')) = $1)
-       ORDER BY tier = 'premier' DESC, tier = 'standard' DESC,
-                claim_status IN ('self','claimed') DESC,
-                (photo_path IS NOT NULL) DESC,
-                business_name ASC
+       ORDER BY ${orderBy}, id ASC
        LIMIT 200
     `, [slug]);
 
@@ -349,7 +360,8 @@ router.get('/neighborhood/:slug', async (req, res, next) => {
       neighborhood: meta,
       businesses, totalCount,
       verticals: VERTICALS,
-      verticalCountMap
+      verticalCountMap,
+      sort: orderClauses[sortKey] ? sortKey : 'featured'
     });
   } catch (err) { next(err); }
 });
diff --git a/views/public/neighborhood.ejs b/views/public/neighborhood.ejs
index 97c0f54..37f8362 100644
--- a/views/public/neighborhood.ejs
+++ b/views/public/neighborhood.ejs
@@ -72,6 +72,22 @@
         </a>
       <% }); %>
     </div>
+    <% var _sort = (typeof sort === 'string') ? sort : 'featured'; %>
+    <div class="grid-controls-right">
+      <label class="grid-control" for="grid-sort"><span>Sort</span>
+        <select id="grid-sort" aria-label="Sort businesses by">
+          <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 (_activeVerticals.length > 1) { %>
@@ -119,4 +135,48 @@
   <% } %>
 </section>
 
+<script>
+  // Sort + density: same pattern as /find — persist to localStorage,
+  // hydrate the saved value BEFORE the first grid render, sort change
+  // reloads with ?sort=, density updates a CSS var live (no reload).
+  // Reuses the 'vcl-grid-density' / 'vcl-grid-sort' keys so preferences
+  // carry between /find and /neighborhood/:slug.
+  (function(){
+    var grid = document.getElementById('grid');
+    var sortSel = document.getElementById('grid-sort');
+    var dens = document.getElementById('grid-density');
+    if (!grid || !sortSel || !dens) 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') {
+      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') %>

← 7c04e29 admin: add rel=noopener noreferrer on target=_blank links  ·  back to Ventura Claw Leads  ·  Add per-site favicon (kills /favicon.ico 404) 61d2e0e →