[object Object]

← back to Interiordesignershowroom

auto-save: 2026-08-01T11:36:26 (2 files) — server.js lib/catalog.js

86a89da725c17589cd296353525e14bc08e2dff1 · 2026-08-01 11:36:32 -0700 · Steve Abrams

Files touched

Diff

commit 86a89da725c17589cd296353525e14bc08e2dff1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 11:36:32 2026 -0700

    auto-save: 2026-08-01T11:36:26 (2 files) — server.js lib/catalog.js
---
 lib/catalog.js | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js      |  1 +
 2 files changed, 92 insertions(+)

diff --git a/lib/catalog.js b/lib/catalog.js
new file mode 100644
index 0000000..709cf5a
--- /dev/null
+++ b/lib/catalog.js
@@ -0,0 +1,91 @@
+// Faceted catalog: filter + facet-count logic shared by /shop and /rooms.
+// Filters are URL-addressable (?room=&style=&color=&network=&max=&q=) so every
+// facet link is a real, crawlable, drillable URL — per Steve's "every data point
+// hrefs to deeper data" rule. Server-rendered for SEO.
+const db = require('./db');
+const { esc, productCard } = require('./render');
+
+const DIMENSIONS = ['room', 'style', 'color', 'network'];
+const LABELS = { room: 'Room', style: 'Style', color: 'Color', network: 'Source' };
+const PRICE_BUCKETS = [
+  ['150', 'Under $150'], ['300', 'Under $300'], ['600', 'Under $600'], ['999999', 'Any price'],
+];
+
+// Build a parameterized WHERE from active filters, optionally EXCLUDING one
+// dimension (so a facet's own counts reflect the rest of the query, not itself).
+function buildWhere(f, exclude) {
+  const where = ['in_stock'];
+  const params = [];
+  for (const d of DIMENSIONS) {
+    if (f[d] && d !== exclude) { params.push(f[d]); where.push(`${d} = $${params.length}`); }
+  }
+  if (f.max && exclude !== 'max') { params.push(f.max); where.push(`COALESCE(sale_price, price) <= $${params.length}`); }
+  if (f.q) { params.push(`%${f.q}%`); where.push(`(title ILIKE $${params.length} OR brand ILIKE $${params.length} OR advertiser ILIKE $${params.length})`); }
+  return { sql: where.join(' AND '), params };
+}
+
+// Serialize filters back to a query string, applying a change (set/clear one key).
+function toQuery(f, change = {}) {
+  const merged = { ...f, ...change };
+  const parts = [];
+  for (const k of [...DIMENSIONS, 'max', 'q', 'sort']) {
+    if (merged[k]) parts.push(`${k}=${encodeURIComponent(merged[k])}`);
+  }
+  return parts.length ? '?' + parts.join('&') : '';
+}
+
+async function facetCounts(f) {
+  const out = {};
+  for (const d of DIMENSIONS) {
+    const { sql, params } = buildWhere(f, d);
+    const r = await db.query(`SELECT ${d} AS val, count(*) AS n FROM products WHERE ${sql} AND ${d} IS NOT NULL GROUP BY ${d} ORDER BY n DESC`, params);
+    out[d] = r.rows;
+  }
+  return out;
+}
+
+function facetRail(f, counts, basePath) {
+  const groups = DIMENSIONS.map((d) => {
+    const items = counts[d].map((row) => {
+      const active = f[d] === row.val;
+      const href = basePath + toQuery(f, { [d]: active ? '' : row.val });
+      return `<a class="facet${active ? ' active' : ''}" href="${esc(href)}">${esc(row.val)} <span class="fn">${row.n}</span></a>`;
+    }).join('');
+    return items ? `<div class="facet-group"><h4>${LABELS[d]}</h4>${items}</div>` : '';
+  }).join('');
+  const priceItems = PRICE_BUCKETS.map(([v, label]) => {
+    const active = f.max === v;
+    const href = basePath + toQuery(f, { max: active ? '' : v });
+    return `<a class="facet${active ? ' active' : ''}" href="${esc(href)}">${esc(label)}</a>`;
+  }).join('');
+  return `<aside class="facets">${groups}<div class="facet-group"><h4>Price</h4>${priceItems}</div></aside>`;
+}
+
+function activeChips(f, basePath) {
+  const chips = [];
+  for (const d of DIMENSIONS) if (f[d]) chips.push([d, f[d]]);
+  if (f.max) chips.push(['max', `≤ $${f.max}`]);
+  if (f.q) chips.push(['q', `“${f.q}”`]);
+  if (!chips.length) return '';
+  const html = chips.map(([k, label]) =>
+    `<a class="chip" href="${esc(basePath + toQuery(f, { [k]: '' }))}">${esc(label)} ✕</a>`).join('');
+  return `<div class="active-filters">${html}<a class="chip clear" href="${esc(basePath)}">Clear all</a></div>`;
+}
+
+// Pull filters out of req.query, whitelisting values.
+function parseFilters(query) {
+  const f = {};
+  for (const d of DIMENSIONS) if (query[d]) f[d] = String(query[d]).slice(0, 40);
+  if (query.max) f.max = String(query.max).replace(/[^0-9]/g, '').slice(0, 7);
+  if (query.q) f.q = String(query.q).slice(0, 60);
+  return f;
+}
+
+async function fetchProducts(f, limit = 200) {
+  const { sql, params } = buildWhere(f);
+  params.push(limit);
+  const r = await db.query(`SELECT * FROM products WHERE ${sql} ORDER BY featured DESC, created_at DESC LIMIT $${params.length}`, params);
+  return r.rows;
+}
+
+module.exports = { parseFilters, fetchProducts, facetCounts, facetRail, activeChips, toQuery, productCard };
diff --git a/server.js b/server.js
index e663fdc..0fd8162 100644
--- a/server.js
+++ b/server.js
@@ -3,6 +3,7 @@ const express = require('express');
 const path = require('path');
 const db = require('./lib/db');
 const { SITE, esc, layout, productCard } = require('./lib/render');
+const catalog = require('./lib/catalog');
 
 const app = express();
 const PORT = process.env.PORT || 9820;

← fee7e91 Add robots.txt (block /admin + /go), go-live staging checkli  ·  back to Interiordesignershowroom  ·  Add faceted filtering (room/style/color/price) to /shop + /r d55b94c →