← 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
M routes/public.jsM views/public/neighborhood.ejs
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 →