← 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
A lib/catalog.jsM server.js
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 →