[object Object]

← back to Dw Collections Viewer

add inline .env loader + Shopify pagination + all-products page

0b73541a6fcdccca729c833297cceb3bcdde0f21 · 2026-05-06 10:54:02 -0700 · Steve Abrams

Files touched

Diff

commit 0b73541a6fcdccca729c833297cceb3bcdde0f21
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 6 10:54:02 2026 -0700

    add inline .env loader + Shopify pagination + all-products page
---
 public/products.html | 28 +++++++++++++++-------
 server.js            | 67 +++++++++++++++++++++++++++++++++++++++++++++-------
 2 files changed, 78 insertions(+), 17 deletions(-)

diff --git a/public/products.html b/public/products.html
index 07bddcf..b5a1dd0 100644
--- a/public/products.html
+++ b/public/products.html
@@ -131,9 +131,14 @@ async function load() {
     fetch('/api/admin/collections').then(async x => {
       if (x.ok) {
         const c = await x.json(); COLLECTIONS = c.collections || [];
-        $('admin-pill').textContent = `admin: ${COLLECTIONS.length} collections`; $('admin-pill').classList.add('live');
-        // Populate the collection filter dropdown
-        $('coll-filter').innerHTML = '<option value="">all collections</option>' + COLLECTIONS.map(c => `<option value="${c.id}">${esc(c.title)} (${c.products_count || 0})</option>`).join('');
+        $('admin-pill').textContent = `admin: ${c.custom||0} custom + ${c.smart||0} smart`; $('admin-pill').classList.add('live');
+        // Group filter dropdown by type. Custom listed first (writable). Smart marked "auto" (read-only filter).
+        const custom = COLLECTIONS.filter(c => c.type === 'custom');
+        const smart  = COLLECTIONS.filter(c => c.type === 'smart');
+        let html = '<option value="">all collections</option>';
+        if (custom.length) html += '<optgroup label="Custom (manual)">' + custom.map(c => `<option value="${c.id}" data-type="custom">${esc(c.title)} (${c.products_count || 0})</option>`).join('') + '</optgroup>';
+        if (smart.length)  html += '<optgroup label="Smart (auto-rules)">' + smart.map(c => `<option value="${c.id}" data-type="smart">${esc(c.title)} (${c.products_count || 0}) ⚙</option>`).join('') + '</optgroup>';
+        $('coll-filter').innerHTML = html;
       }
       else { $('admin-pill').textContent = 'admin token missing'; $('admin-pill').classList.add('warn'); }
     }).catch(() => { $('admin-pill').textContent = 'admin: offline'; $('admin-pill').classList.add('warn'); });
@@ -211,14 +216,16 @@ $('batch-clear').addEventListener('click', () => { SELECTED.clear(); render(); }
 $('batch-add').addEventListener('click', () => {
   if (!SELECTED.size) return;
   $('add-count').textContent = SELECTED.size;
-  if (!COLLECTIONS.length) {
+  // Add-to: only custom collections are writable. Smart collections auto-include via rules.
+  const writable = COLLECTIONS.filter(c => c.type === 'custom');
+  if (!writable.length) {
     $('add-notice').style.display = 'block';
-    $('add-notice').textContent = 'No admin collections loaded. Set SHOPIFY_ADMIN_TOKEN in dw-collections-viewer .env and restart.';
-    $('coll-pick').innerHTML = '<option>(no admin access)</option>';
+    $('add-notice').textContent = 'No custom (writable) collections found. Smart collections are auto-rule-based — products land in them automatically based on tags/title.';
+    $('coll-pick').innerHTML = '<option>(no writable collections)</option>';
     $('add-confirm').disabled = true;
   } else {
     $('add-notice').style.display = 'none';
-    $('coll-pick').innerHTML = COLLECTIONS.map(c => `<option value="${c.id}">${esc(c.title)} (${c.products_count || 0} items)</option>`).join('');
+    $('coll-pick').innerHTML = writable.map(c => `<option value="${c.id}">${esc(c.title)} (${c.products_count || 0} items)</option>`).join('');
     $('add-confirm').disabled = false;
   }
   $('add-modal').classList.add('show');
@@ -226,8 +233,13 @@ $('batch-add').addEventListener('click', () => {
 $('add-cancel').addEventListener('click', () => $('add-modal').classList.remove('show'));
 $('batch-remove').addEventListener('click', async () => {
   if (!SELECTED.size) return;
-  const cid = $('coll-filter').value;
+  const sel = $('coll-filter');
+  const cid = sel.value;
   if (!cid) return alert('Pick a collection in the filter dropdown first — the "Remove" action removes from that collection.');
+  // Smart collections aren't manually editable; rules drive membership.
+  if (sel.options[sel.selectedIndex]?.dataset.type === 'smart') {
+    return alert("Smart collections are auto-rule-based — Shopify won't let us remove products manually. Edit the collection's tag/title rules to change membership.");
+  }
   if (!confirm(`Remove ${SELECTED.size} products from this collection? Products themselves are not deleted.`)) return;
   try {
     const r = await fetch('/api/products/remove-from-collection', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ product_ids:[...SELECTED], collection_id: parseInt(cid,10) }) });
diff --git a/server.js b/server.js
index 47c2517..980e43b 100644
--- a/server.js
+++ b/server.js
@@ -1,6 +1,19 @@
 // DW Collections Viewer — local-only tool for managing which Shopify collections feed each DW family site.
 // Browse all 49 sites with hero thumbnails, attach Shopify collections to a site, regenerate products.json.
 
+// Load local .env (no dotenv dep — minimal inline parser)
+(() => {
+  const _fs = require('fs'), _p = require('path');
+  const envFile = _p.join(__dirname, '.env');
+  if (!_fs.existsSync(envFile)) return;
+  for (const line of _fs.readFileSync(envFile, 'utf8').split('\n')) {
+    const m = line.match(/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
+    if (!m) continue;
+    let v = m[2];
+    if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
+    if (v && !process.env[m[1]]) process.env[m[1]] = v;
+  }
+})();
 const express = require('express');
 const fs = require('fs');
 const fsp = require('fs').promises;
@@ -438,11 +451,28 @@ app.get('/api/products/all', async (req, res) => {
 
 async function shopifyAdmin(method, path, body) {
   if (!SHOPIFY_ADMIN_TOKEN) throw new Error('SHOPIFY_ADMIN_TOKEN not set in env');
-  const url = `https://${SHOPIFY_STORE}/admin/api/2024-10${path}`;
+  const url = path.startsWith('https://') ? path : `https://${SHOPIFY_STORE}/admin/api/2024-10${path}`;
   const r = await fetch(url, { method, headers: { 'X-Shopify-Access-Token': SHOPIFY_ADMIN_TOKEN, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
   const text = await r.text();
-  if (!r.ok) throw new Error(`shopify ${method} ${path} ${r.status}: ${text.slice(0, 200)}`);
-  return text ? JSON.parse(text) : {};
+  if (!r.ok) throw new Error(`shopify ${method} ${path.slice(0,100)} ${r.status}: ${text.slice(0, 200)}`);
+  const link = r.headers.get('link') || '';
+  const nextMatch = link.match(/<([^>]+)>;\s*rel="next"/);
+  const result = text ? JSON.parse(text) : {};
+  result._next = nextMatch ? nextMatch[1] : null;
+  return result;
+}
+// Cursor-paginate a Shopify list endpoint (max pages safety cap = 10).
+async function shopifyAdminAll(initialPath, listKey, maxPages = 10) {
+  let all = [];
+  let url = initialPath;
+  for (let i = 0; i < maxPages; i++) {
+    const r = await shopifyAdmin('GET', url);
+    const items = r[listKey] || [];
+    all = all.concat(items);
+    if (!r._next) break;
+    url = r._next;
+  }
+  return all;
 }
 
 app.post('/api/products/delete', async (req, res) => {
@@ -458,10 +488,20 @@ app.post('/api/products/delete', async (req, res) => {
   res.json({ count: ids.length, ok: results.filter(r => r.ok).length, results });
 });
 
+// All collections (custom + smart) with cursor-based pagination. 5-min cache.
+let _collCache = { at: 0, data: null };
 app.get('/api/admin/collections', async (req, res) => {
+  if (_collCache.data && (Date.now() - _collCache.at) < 5 * 60_000) return res.json(_collCache.data);
   try {
-    const r = await shopifyAdmin('GET', '/custom_collections.json?limit=250');
-    res.json({ collections: r.custom_collections || [] });
+    const customs = await shopifyAdminAll('/custom_collections.json?limit=250', 'custom_collections', 5);
+    const smarts  = await shopifyAdminAll('/smart_collections.json?limit=250',  'smart_collections',  5);
+    const out = [
+      ...customs.map(c => ({ id: c.id, title: c.title, handle: c.handle, products_count: c.products_count, type: 'custom' })),
+      ...smarts.map(c => ({ id: c.id, title: c.title, handle: c.handle, products_count: c.products_count, type: 'smart' })),
+    ].sort((a, b) => a.title.localeCompare(b.title));
+    const data = { collections: out, custom: customs.length, smart: smarts.length };
+    _collCache = { at: Date.now(), data };
+    res.json(data);
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 
@@ -486,10 +526,19 @@ app.get('/api/admin/collection/:id/products', async (req, res) => {
   try {
     const { id } = req.params;
     if (!/^\d+$/.test(id)) return res.status(400).json({ error: 'numeric id required' });
-    // First get the collection's handle so we can use the public endpoint
-    const c = await shopifyAdmin('GET', `/custom_collections/${id}.json`);
-    const handle = c.custom_collection?.handle;
-    if (!handle) return res.status(404).json({ error: 'collection not found' });
+    // Try custom_collections first, fall back to smart_collections
+    let handle = null;
+    try {
+      const c = await shopifyAdmin('GET', `/custom_collections/${id}.json`);
+      handle = c.custom_collection?.handle;
+    } catch {}
+    if (!handle) {
+      try {
+        const s = await shopifyAdmin('GET', `/smart_collections/${id}.json`);
+        handle = s.smart_collection?.handle;
+      } catch {}
+    }
+    if (!handle) return res.status(404).json({ error: 'collection not found (custom or smart)' });
     const products = await fetchCollectionProducts(handle, 1000);
     res.json({ collection_id: id, handle, count: products.length, ids: products.map(p => p.id) });
   } catch (e) { res.status(500).json({ error: e.message }); }

← 9a73dc0 initial scaffold  ·  back to Dw Collections Viewer  ·  add Save & Deploy combined button (one-click save + render + 9c53f65 →