← back to Interiordesignershowroom
lib/catalog.js
122 lines
// 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 COLS = require('./cols');
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'],
];
// Storefront visibility gate: hide any product whose network — or whose specific
// (network, advertiser) — has been switched OFF in the admin's affiliate_settings.
// Correlates on the outer `products` row; carries no params (admin state, not user
// input) so it slots into every buildWhere caller without renumbering placeholders.
const AFFILIATE_ENABLED = `NOT EXISTS (
SELECT 1 FROM affiliate_settings a
WHERE a.enabled = FALSE
AND a.network = products.network
AND (a.advertiser = '' OR a.advertiser = COALESCE(products.advertiser, ''))
)`;
// 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) {
// `NOT suppressed` = the product-level hide flag (a brand/item switched OFF in
// admin, e.g. Honiture). Distinct from AFFILIATE_ENABLED, which hides by source.
const where = ['in_stock', 'NOT suppressed', 'is_wall_paint = FALSE', AFFILIATE_ENABLED];
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) {
// Each dimension is a native <details> so the rail is collapsible with zero JS.
// ALL groups start collapsed on load (Steve's spec); a group is auto-opened only
// when it holds the currently-active filter, so an applied facet is never hidden.
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 ? `<details class="facet-group"${f[d] ? ' open' : ''}><summary>${LABELS[d]}</summary><div class="facet-list">${items}</div></details>` : '';
}).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}<details class="facet-group"${f.max ? ' open' : ''}><summary>Price</summary><div class="facet-list">${priceItems}</div></details></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 ${COLS.PRODUCT} FROM products WHERE ${sql} ORDER BY featured DESC, created_at DESC LIMIT $${params.length}`, params);
return r.rows;
}
// True (un-capped) count of products matching a filter, plus the total visible
// catalog size — used to decide whether a facet page is a genuine landing page or
// a near-duplicate of /shop (a facet that IS most of the catalog isn't a filter).
// One round-trip; the gate-only total subquery carries no params.
async function matchAndTotal(f) {
const { sql, params } = buildWhere(f);
const gateOnly = buildWhere({}).sql;
const r = await db.query(
`SELECT (SELECT count(*) FROM products WHERE ${sql}) AS n,
(SELECT count(*) FROM products WHERE ${gateOnly}) AS total`, params);
return { n: Number(r.rows[0].n), total: Number(r.rows[0].total) };
}
module.exports = { parseFilters, fetchProducts, matchAndTotal, facetCounts, facetRail, activeChips, toQuery, productCard, AFFILIATE_ENABLED, DIMENSIONS };