← back to Interiordesignershowroom

lib/rooms.js

108 lines

// Room Builder data layer: catalog search for the builder, the Samplize wall-paint
// palette, and room create/fetch/list.
const db = require('./db');
const COLS = require('./cols');
const { intIds } = require('./ids');

const slugify = (s) => (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'room';

// Same storefront affiliate on/off gate the faceted catalog uses (see lib/catalog.js).
// Applied to the LIVE browse surfaces (Room Builder search + brand facet) so a
// switched-off advertiser disappears everywhere a shopper can discover it. Saved
// rooms + the paint palette are deliberately NOT gated — they're curated artifacts.
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, '')))`;

async function searchProducts({ q, room, style, color, colors, advertiser, cat, max, limit = 40, offset = 0 } = {}) {
  const where = ['in_stock', 'NOT suppressed', 'is_wall_paint = FALSE', 'image_url IS NOT NULL', AFFILIATE_ENABLED];
  const params = [];
  if (room) { params.push(room); where.push(`room = $${params.length}`); }
  if (style) { params.push(style); where.push(`style = $${params.length}`); }
  if (color) { params.push(color); where.push(`color = $${params.length}`); }
  if (colors && colors.length) { params.push(colors); where.push(`color = ANY($${params.length})`); }
  if (advertiser) { params.push(advertiser); where.push(`advertiser = $${params.length}`); }
  if (cat) { params.push('%' + cat + '%'); where.push(`title ILIKE $${params.length}`); }
  if (max) { params.push(max); where.push(`COALESCE(sale_price, price) <= $${params.length}`); }
  if (q) { params.push('%' + q + '%'); where.push(`(title ILIKE $${params.length} OR brand ILIKE $${params.length} OR advertiser ILIKE $${params.length})`); }
  params.push(Math.min(limit, 80));
  const limIdx = params.length;
  params.push(Math.max(0, offset | 0));
  const { rows } = await db.query(
    `SELECT id,title,brand,advertiser,price,sale_price,image_url,room,style,color,affiliate_url
     FROM products WHERE ${where.join(' AND ')} ORDER BY featured DESC, created_at DESC, id DESC LIMIT $${limIdx} OFFSET $${params.length}`, params);
  return rows;
}

async function getPaints(limit = 80) {
  const { rows } = await db.query(
    `SELECT id,title,image_url,affiliate_url,price FROM products WHERE is_wall_paint ORDER BY title LIMIT $1`, [limit]);
  return rows;
}

async function createRoom(d = {}) {
  const base = slugify(d.title || `${d.style || ''} ${d.room_type || 'room'}`);
  let slug = base, n = 1;
  while ((await db.query('SELECT 1 FROM rooms WHERE slug=$1', [slug])).rowCount) slug = `${base}-${++n}`;
  const cb = ['curator', 'auto', 'visitor'].includes(d.created_by) ? d.created_by : 'visitor';
  const isCurator = cb === 'curator';
  const title = (d.title || `${d.style || ''} ${d.room_type || 'Room'}`).trim().replace(/\b\w/g, c => c.toUpperCase());
  const ids = intIds(d.product_ids);
  // hotspots: [{id,box:{x,y,w,h},...}] — stored as jsonb so the saved room stays shoppable
  const hotspots = Array.isArray(d.hotspots) ? d.hotspots : [];
  const { rows } = await db.query(
    `INSERT INTO rooms (slug,title,room_type,style,wall_paint_id,product_ids,scene_image,note,created_by,featured,public,hotspots)
     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE,$11) RETURNING slug`,
    [slug, title, d.room_type || null, d.style || null, d.wall_paint_id || null, ids,
     d.scene_image || null, d.note || null, cb, isCurator, JSON.stringify(hotspots)]);
  return rows[0].slug;
}

async function getRoom(slug) {
  const { rows } = await db.query(`SELECT ${COLS.ROOM} FROM rooms WHERE slug=$1`, [slug]);
  if (!rows.length) return null;
  const r = rows[0];
  let products = [];
  if (r.product_ids && r.product_ids.length) {
    const pr = await db.query(`SELECT ${COLS.PRODUCT} FROM products WHERE id = ANY($1) AND NOT suppressed`, [r.product_ids]);
    // preserve the saved order
    products = r.product_ids.map(id => pr.rows.find(p => p.id === id)).filter(Boolean);
  }
  let paint = null;
  if (r.wall_paint_id) paint = (await db.query(`SELECT ${COLS.PRODUCT} FROM products WHERE id=$1 AND NOT suppressed`, [r.wall_paint_id])).rows[0] || null;
  // drop any scene hotspot whose product has since been suppressed, so a hidden item
  // (e.g. Honiture) can't stay tappable on the scene while it's gone from the grid.
  if (r.hotspots && r.hotspots.length) {
    const hotIds = r.hotspots.map((h) => h.id).filter(Boolean);
    if (hotIds.length) {
      const sup = await db.query('SELECT id FROM products WHERE id = ANY($1) AND suppressed', [hotIds]);
      if (sup.rows.length) { const gone = new Set(sup.rows.map((x) => x.id)); r.hotspots = r.hotspots.filter((h) => !gone.has(h.id)); }
    }
  }
  return { room: r, products, paint };
}

async function listRooms({ limit = 60 } = {}) {
  const { rows } = await db.query(`SELECT ${COLS.ROOM}, (SELECT count(*) FROM products p WHERE p.id = ANY(rooms.product_ids) AND NOT p.suppressed) AS piece_count FROM rooms WHERE public ORDER BY featured DESC, created_at DESC LIMIT $1`, [limit]);
  // attach up to 4 thumbnails per room
  for (const r of rows) {
    r.thumbs = [];
    if (r.product_ids && r.product_ids.length) {
      const t = await db.query('SELECT image_url FROM products WHERE id = ANY($1) AND image_url IS NOT NULL AND NOT suppressed LIMIT 4', [r.product_ids.slice(0, 4)]);
      r.thumbs = t.rows.map(x => x.image_url);
    }
  }
  return rows;
}

async function listBrands(limit = 40) {
  const { rows } = await db.query(
    `SELECT advertiser, count(*) n FROM products WHERE advertiser IS NOT NULL AND NOT is_wall_paint AND image_url IS NOT NULL
     AND NOT suppressed AND ${AFFILIATE_ENABLED}
     GROUP BY advertiser HAVING count(*) >= 3 ORDER BY n DESC LIMIT $1`, [limit]);
  return rows;
}

module.exports = { slugify, searchProducts, getPaints, createRoom, getRoom, listRooms, listBrands };