[object Object]

← back to Interiordesignershowroom

feat(build): infinite-scroll the products panel — offset pagination in searchProducts/API, scroll-to-load-more in build.js (48/page, stable id-tiebreak sort, no dup rows); left filters reset to page 0

99063892cd3936f211df063d29823cecad1ac997 · 2026-08-01 23:24:02 -0700 · Steve Abrams

Files touched

Diff

commit 99063892cd3936f211df063d29823cecad1ac997
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 23:24:02 2026 -0700

    feat(build): infinite-scroll the products panel — offset pagination in searchProducts/API, scroll-to-load-more in build.js (48/page, stable id-tiebreak sort, no dup rows); left filters reset to page 0
---
 lib/rooms.js       |  6 ++++--
 public/js/build.js | 21 +++++++++++++++++++--
 server.js          |  1 +
 3 files changed, 24 insertions(+), 4 deletions(-)

diff --git a/lib/rooms.js b/lib/rooms.js
index b198118..8f84617 100644
--- a/lib/rooms.js
+++ b/lib/rooms.js
@@ -4,7 +4,7 @@ const db = require('./db');
 
 const slugify = (s) => (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'room';
 
-async function searchProducts({ q, room, style, color, colors, advertiser, cat, max, limit = 40 } = {}) {
+async function searchProducts({ q, room, style, color, colors, advertiser, cat, max, limit = 40, offset = 0 } = {}) {
   const where = ['in_stock', 'is_wall_paint = FALSE', 'image_url IS NOT NULL'];
   const params = [];
   if (room) { params.push(room); where.push(`room = $${params.length}`); }
@@ -16,9 +16,11 @@ async function searchProducts({ q, room, style, color, colors, advertiser, cat,
   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 LIMIT $${params.length}`, params);
+     FROM products WHERE ${where.join(' AND ')} ORDER BY featured DESC, created_at DESC, id DESC LIMIT $${limIdx} OFFSET $${params.length}`, params);
   return rows;
 }
 
diff --git a/public/js/build.js b/public/js/build.js
index 09353c9..f808073 100644
--- a/public/js/build.js
+++ b/public/js/build.js
@@ -68,11 +68,24 @@
     var s = $('rbSearch').value.trim(); if (s) q.set('q', s);
     return q.toString();
   }
+  // Products panel: infinite scroll. A filter change calls loadProducts() (resets to
+  // page 0); scrolling near the bottom calls loadMoreProducts() (appends the next page).
+  var PAGE = 48, prodOffset = 0, prodLoading = false, prodMore = true;
   function loadProducts() {
+    prodOffset = 0; prodMore = true; prodLoading = true;
     $('rbProducts').innerHTML = '<p class="empty">Loading…</p>';
-    fetch('/api/catalog/search?' + query()).then(function (r) { return r.json(); }).then(function (rows) {
+    fetch('/api/catalog/search?' + query() + '&offset=0').then(function (r) { return r.json(); }).then(function (rows) {
       $('rbProducts').innerHTML = rows.length ? rows.map(card).join('') : '<p class="empty">No matches.</p>';
-    });
+      prodMore = rows.length >= PAGE; prodLoading = false; $('rbProducts').scrollTop = 0;
+    }).catch(function () { prodLoading = false; });
+  }
+  function loadMoreProducts() {
+    if (prodLoading || !prodMore) return;
+    prodLoading = true; prodOffset += PAGE;
+    fetch('/api/catalog/search?' + query() + '&offset=' + prodOffset).then(function (r) { return r.json(); }).then(function (rows) {
+      if (rows.length) $('rbProducts').insertAdjacentHTML('beforeend', rows.map(card).join(''));
+      prodMore = rows.length >= PAGE; prodLoading = false;
+    }).catch(function () { prodLoading = false; prodOffset -= PAGE; });
   }
   function loadPaints() { fetch('/api/paints').then(function (r) { return r.json(); }).then(function (rows) { $('rbPaints').innerHTML = rows.map(function (p) { return '<button class="paint" data-id="' + p.id + '" title="' + (p.title || '').replace(/"/g, '') + '"><img src="' + p.image_url + '" alt=""></button>'; }).join(''); }); }
   function loadBrands() { fetch('/api/brands').then(function (r) { return r.json(); }).then(function (rows) { $('rbBrands').innerHTML = rows.map(function (b) { return '<button class="rb-opt" data-brand="' + b.advertiser.replace(/"/g, '') + '">' + b.advertiser + ' <span class="subtle">' + b.n + '</span></button>'; }).join('') || '<span class="empty">—</span>'; }); }
@@ -138,6 +151,10 @@
 
   $('rbRoom').addEventListener('change', function () { room = this.value; chips(); loadProducts(); });
   $('rbSearch').addEventListener('keydown', function (e) { if (e.key === 'Enter') loadProducts(); });
+  // infinite scroll: load the next page as the products panel nears its bottom
+  $('rbProducts').addEventListener('scroll', function () {
+    if (this.scrollTop + this.clientHeight >= this.scrollHeight - 260) loadMoreProducts();
+  });
 
   // Generate a room setting -> add to the middle gallery
   $('rbRenderBtn').addEventListener('click', function () {
diff --git a/server.js b/server.js
index 6b9aa4d..f76b107 100644
--- a/server.js
+++ b/server.js
@@ -232,6 +232,7 @@ app.get('/api/catalog/search', async (req, res, next) => {
       colors: req.query.colors ? String(req.query.colors).split(',').filter(Boolean) : null,
       advertiser: req.query.brand, cat: req.query.cat,
       max: req.query.max ? parseInt(req.query.max, 10) : null, limit: 48,
+      offset: req.query.offset ? parseInt(req.query.offset, 10) : 0,
     });
     res.json(r);
   } catch (e) { next(e); }

← bede8a7 feat(room): thumbnail rail beside the scene — stacked right  ·  back to Interiordesignershowroom  ·  feat(build): right panel shows the products IN the generated a9db00f →