← 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 };