[object Object]

← back to Stars of Design

starsofdesign: sort dropdown on /firms + /clients (per standing sort+density rule)

10f3ae0c7d9cc86258fc428bde4166db9dd8dbb3 · 2026-05-12 15:34:57 -0700 · Steve Abrams

Files touched

Diff

commit 10f3ae0c7d9cc86258fc428bde4166db9dd8dbb3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 12 15:34:57 2026 -0700

    starsofdesign: sort dropdown on /firms + /clients (per standing sort+density rule)
---
 lib/clients.js           | 16 +++++++++++++---
 lib/firms.js             | 23 ++++++++++++++++++-----
 routes/public.js         | 10 +++++++---
 views/public/clients.ejs | 11 +++++++++++
 views/public/firms.ejs   | 12 ++++++++++++
 5 files changed, 61 insertions(+), 11 deletions(-)

diff --git a/lib/clients.js b/lib/clients.js
index 31c8ed0..e7b4c7e 100644
--- a/lib/clients.js
+++ b/lib/clients.js
@@ -20,7 +20,17 @@ const pool = new Pool({
   max: 4,
 });
 
-async function listClients({ limit = 200, q = null, area = null } = {}) {
+// Per Steve's standing rule: every product/listing grid needs sort + density.
+const SORT_KEYS = {
+  default:  `(d.premium_tier IS NOT NULL) DESC, d.full_name`,
+  name_az:  `d.full_name`,
+  name_za:  `d.full_name DESC`,
+  city:     `COALESCE(f.city, d.city) NULLS LAST, d.full_name`,
+  firm:     `f.name NULLS LAST, d.full_name`,
+  claimed:  `(d.claimed_at IS NOT NULL) DESC, d.full_name`,
+};
+
+async function listClients({ limit = 200, q = null, area = null, sort = 'default' } = {}) {
   const params = [];
   let where = `(d.role IS NULL OR d.role <> 'Manufacturer Rep')`;
   if (q) {
@@ -58,7 +68,7 @@ async function listClients({ limit = 200, q = null, area = null } = {}) {
       LEFT JOIN sod_designer_firm df ON df.designer_id = d.id AND df.is_current = true
       LEFT JOIN sod_firms f          ON f.id = df.firm_id
      WHERE ${where}
-     ORDER BY (d.premium_tier IS NOT NULL) DESC, d.full_name
+     ORDER BY ${SORT_KEYS[sort] || SORT_KEYS.default}
      LIMIT $${params.length}`;
   const r = await pool.query(sql, params);
   return r.rows.map(row => {
@@ -136,4 +146,4 @@ async function totalCount() {
   return parseInt(r.rows[0].n, 10);
 }
 
-module.exports = { listClients, getClient, totalCount };
+module.exports = { listClients, getClient, totalCount, SORT_KEYS };
diff --git a/lib/firms.js b/lib/firms.js
index 990d79b..63a76bb 100644
--- a/lib/firms.js
+++ b/lib/firms.js
@@ -13,7 +13,22 @@ const pool = new Pool({
   max: 4,
 });
 
-async function listFirms({ q = null, limit = 300 } = {}) {
+// Per Steve's standing rule: every product/listing grid needs sort + density.
+// SORT_KEYS controls the firm-tier sort dropdown — claimed-first is default
+// because verified profiles deserve top-of-feed.
+const SORT_KEYS = {
+  claimed:    `(f.claimed_at IS NOT NULL) DESC, f.size_band DESC NULLS LAST, f.name`,
+  name_az:    `f.name`,
+  name_za:    `f.name DESC`,
+  founded_new:`f.founded_year DESC NULLS LAST, f.name`,
+  founded_old:`f.founded_year ASC NULLS LAST, f.name`,
+  city:       `f.city NULLS LAST, f.name`,
+  size:       `f.size_band DESC NULLS LAST, f.name`,
+};
+
+async function listFirms({ q = null, sort = 'claimed', limit = 300 } = {}) {
+  const sortKey = SORT_KEYS[sort] ? sort : 'claimed';
+  const orderBy = SORT_KEYS[sortKey];
   const params = [];
   let where = `1=1`;
   if (q) {
@@ -33,9 +48,7 @@ async function listFirms({ q = null, limit = 300 } = {}) {
            ) AS links
       FROM sod_firms f
      WHERE ${where}
-     ORDER BY (f.claimed_at IS NOT NULL) DESC,
-              f.size_band DESC NULLS LAST,
-              f.name
+     ORDER BY ${orderBy}
      LIMIT $${params.length}`;
   const r = await pool.query(sql, params);
   return r.rows.map(row => {
@@ -104,4 +117,4 @@ async function totalCount() {
   return parseInt(r.rows[0].n, 10);
 }
 
-module.exports = { listFirms, getFirm, totalCount };
+module.exports = { listFirms, getFirm, totalCount, SORT_KEYS };
diff --git a/routes/public.js b/routes/public.js
index 2e24ad8..86ea5a7 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -80,8 +80,9 @@ router.get('/clients', async (req, res, next) => {
   try {
     const q    = typeof req.query.q === 'string' && req.query.q.length < 80 ? req.query.q : null;
     const area = typeof req.query.area === 'string' && req.query.area.length < 80 ? req.query.area : null;
+    const sort = clientsLib.SORT_KEYS[req.query.sort] ? req.query.sort : 'default';
     const [clients, total] = await Promise.all([
-      clientsLib.listClients({ q, area, limit: 300 }),
+      clientsLib.listClients({ q, area, sort, limit: 300 }),
       clientsLib.totalCount(),
     ]);
     res.render('public/clients', {
@@ -89,6 +90,7 @@ router.get('/clients', async (req, res, next) => {
       meta_desc_override: 'The active list of interior designers and architecture firms who buy wallcoverings from Designer Wallcoverings. Pulled from order history, trade signups, and sample requests. City + state + areas served per profile (no street addresses).',
       clients,
       total,
+      sortKey: sort,
       filter: { q, area },
     });
   } catch (e) { next(e); }
@@ -115,9 +117,10 @@ router.get('/clients/:slug', async (req, res, next) => {
 // auto-materialized from sig-extract. Keyed on sod_firms not sod_designers.
 router.get('/firms', async (req, res, next) => {
   try {
-    const q = typeof req.query.q === 'string' && req.query.q.length < 80 ? req.query.q : null;
+    const q    = typeof req.query.q === 'string' && req.query.q.length < 80 ? req.query.q : null;
+    const sort = firmsLib.SORT_KEYS[req.query.sort] ? req.query.sort : 'claimed';
     const [firms, total] = await Promise.all([
-      firmsLib.listFirms({ q, limit: 400 }),
+      firmsLib.listFirms({ q, sort, limit: 400 }),
       firmsLib.totalCount(),
     ]);
     res.render('public/firms', {
@@ -125,6 +128,7 @@ router.get('/firms', async (req, res, next) => {
       meta_desc_override: 'Top US architecture and interior design firms — Gensler, HOK, Perkins&Will, Studio Sofield, Kelly Wearstler, Studio McGee, and more. Public-data seed; firms may claim and edit their profile.',
       firms,
       total,
+      sortKey: sort,
       filter: { q },
     });
   } catch (e) { next(e); }
diff --git a/views/public/clients.ejs b/views/public/clients.ejs
index 1cfed11..f1b4091 100644
--- a/views/public/clients.ejs
+++ b/views/public/clients.ejs
@@ -26,6 +26,17 @@
           <button type="submit" class="btn ghost" style="margin-left:8px">Search</button>
         </div>
         <div class="grid-controls-right">
+          <label class="control">
+            <span>Sort</span>
+            <select id="sort-select" name="sort" data-sort onchange="this.form.submit()">
+              <option value="default" <%= sortKey==='default' ? 'selected' : '' %>>Premium first</option>
+              <option value="name_az" <%= sortKey==='name_az' ? 'selected' : '' %>>Name A→Z</option>
+              <option value="name_za" <%= sortKey==='name_za' ? 'selected' : '' %>>Name Z→A</option>
+              <option value="firm"    <%= sortKey==='firm'    ? 'selected' : '' %>>By firm</option>
+              <option value="city"    <%= sortKey==='city'    ? 'selected' : '' %>>By city</option>
+              <option value="claimed" <%= sortKey==='claimed' ? 'selected' : '' %>>Claimed first</option>
+            </select>
+          </label>
           <label class="control">
             <span>Density</span>
             <input id="density-slider" type="range" min="220" max="440" step="20" value="320" data-density>
diff --git a/views/public/firms.ejs b/views/public/firms.ejs
index 9a032c7..44aa7c6 100644
--- a/views/public/firms.ejs
+++ b/views/public/firms.ejs
@@ -27,6 +27,18 @@
           <button type="submit" class="btn ghost" style="margin-left:8px">Search</button>
         </div>
         <div class="grid-controls-right">
+          <label class="control">
+            <span>Sort</span>
+            <select id="sort-select" name="sort" data-sort onchange="this.form.submit()">
+              <option value="claimed"     <%= sortKey==='claimed'     ? 'selected' : '' %>>Claimed first</option>
+              <option value="name_az"     <%= sortKey==='name_az'     ? 'selected' : '' %>>Name A→Z</option>
+              <option value="name_za"     <%= sortKey==='name_za'     ? 'selected' : '' %>>Name Z→A</option>
+              <option value="founded_new" <%= sortKey==='founded_new' ? 'selected' : '' %>>Founded — newest</option>
+              <option value="founded_old" <%= sortKey==='founded_old' ? 'selected' : '' %>>Founded — oldest</option>
+              <option value="city"        <%= sortKey==='city'        ? 'selected' : '' %>>By city</option>
+              <option value="size"        <%= sortKey==='size'        ? 'selected' : '' %>>By size</option>
+            </select>
+          </label>
           <label class="control">
             <span>Density</span>
             <input id="density-slider" type="range" min="220" max="440" step="20" value="320" data-density>

← f55b3a7 feat(starsofdesign): sitemap.xml now includes /firms/:slug ×  ·  back to Stars of Design  ·  starsofdesign: +40 firms (20 arch, 20 interior) — Selldorf, cca9c68 →