[object Object]

← back to All Designerwallcoverings

api: carry series (Book) facet + website/internal_url per product

3876a0da9322b58611fcda518734c876122eb700 · 2026-07-07 07:13:35 -0700 · Steve

Files touched

Diff

commit 3876a0da9322b58611fcda518734c876122eb700
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 7 07:13:35 2026 -0700

    api: carry series (Book) facet + website/internal_url per product
---
 server.js | 27 +++++++++++++++++++++++----
 1 file changed, 23 insertions(+), 4 deletions(-)

diff --git a/server.js b/server.js
index 74247a0..ba0eb42 100644
--- a/server.js
+++ b/server.js
@@ -49,6 +49,16 @@ const priceBand = (p) => { if (!p || p <= 0) return null; const b = PRICE_BANDS.
 
 const titleCase = (s) => s.replace(/\w\S*/g, (t) => t[0].toUpperCase() + t.slice(1));
 
+// Public storefront (custom domain) + internal Shopify admin store handle. The internal
+// per-SKU page is the Shopify admin product view, keyed by the product's numeric id
+// (extracted from the shopify_id GID). Public surface still ships only public-safe cols.
+const STOREFRONT = 'https://designerwallcoverings.com';
+const ADMIN_STORE = process.env.SHOPIFY_ADMIN_STORE || 'designer-laboratory-sandbox';
+// DW SKU series/book code = the leading alpha run of the SKU (DWTT, RML, GRS, XJG …).
+// Deterministic, in-RAM, and never leaks an upstream/private-label name (it's a house code).
+const seriesOf = (sku) => { const m = (sku || '').match(/^[A-Za-z]{2,}/); return m ? m[0].toUpperCase() : null; };
+const adminUrl = (gid) => { const m = (gid || '').match(/(\d+)\s*$/); return m ? `https://admin.shopify.com/store/${ADMIN_STORE}/products/${m[1]}` : null; };
+
 // HARD front-facing rule (same list build-data.py enforced for the vendor directory):
 // banned upstream/private-label source names never ship on this public surface — rows
 // whose vendor OR title leaks one are excluded (their products surface under the label).
@@ -67,7 +77,7 @@ async function snapSql() {
   const priceExpr = has.has('min_variant_price') ? 'coalesce(price, min_variant_price)' : 'price';
   const pubExpr = has.has('online_store_published') ? 'online_store_published' : "(upper(coalesce(status,'')) = 'ACTIVE')";
   return `
-  SELECT id, handle, title, vendor, product_type, dw_sku, variant_sku, sku, pattern_name,
+  SELECT id, shopify_id, handle, title, vendor, product_type, dw_sku, variant_sku, sku, pattern_name,
          tags, upper(coalesce(status,'')) AS status, image_url,
          ${priceExpr} AS price,
          ${pubExpr} AS online_store_published,
@@ -98,9 +108,11 @@ function deriveRow(r) {
     : r.status === 'DRAFT' ? 'Staged'
     : (r.status === 'ACTIVE' && r.online_store_published) ? 'Active on site'
     : 'Staged';
+  const sku = r.dw_sku || r.variant_sku || r.sku || null;
   return {
     id: r.id,
-    sku: r.dw_sku || r.variant_sku || r.sku || null,
+    sku,
+    series: seriesOf(sku),
     title: r.title || '',
     vendor: r.vendor || null,
     type: r.product_type || null,
@@ -114,7 +126,10 @@ function deriveRow(r) {
     styles: [...new Set(styles)],
     materials: [...new Set(materials)],
     image_state: r.image_url ? 'Has image' : 'No image',
-    url: (r.online_store_published && r.handle) ? `https://designerwallcoverings.com/products/${r.handle}` : null,
+    // Website = customer-facing storefront page (only when actually live on the Online Store).
+    url: (r.online_store_published && r.handle) ? `${STOREFRONT}/products/${r.handle}` : null,
+    // Internal = Shopify admin product view (resolves for any row that has a shopify_id GID).
+    internal_url: adminUrl(r.shopify_id),
     created: r.created_at_shopify ? new Date(r.created_at_shopify).getTime() : 0,
     updated: r.updated_at_shopify ? new Date(r.updated_at_shopify).getTime() : 0,
     // one lowercase haystack per row so search is a single .includes() pass
@@ -139,6 +154,7 @@ function parseFilters(u) {
   return {
     q: (u.searchParams.get('q') || '').trim().toLowerCase(),
     vendors: csv('vendors'), types: csv('types'), statuses: csv('statuses'), lifecycles: csv('lifecycles'),
+    series: csv('series'),
     colors: csv('colors'), styles: csv('styles'), materials: csv('materials'),
     prices: csv('prices'), images: csv('images'),
   };
@@ -158,6 +174,7 @@ function applyFilters(rows, f, opts = {}) {
   }
   if (opts.skip !== 'vendor' && f.vendors.size) out = out.filter((r) => r.vendor && f.vendors.has(r.vendor));
   if (opts.skip !== 'type' && f.types.size) out = out.filter((r) => r.type && f.types.has(r.type));
+  if (opts.skip !== 'series' && f.series.size) out = out.filter((r) => r.series && f.series.has(r.series));
   if (opts.skip !== 'color' && f.colors.size) out = out.filter((r) => r.colors.some((c) => f.colors.has(c)));
   if (opts.skip !== 'style' && f.styles.size) out = out.filter((r) => r.styles.some((c) => f.styles.has(c)));
   if (opts.skip !== 'material' && f.materials.size) out = out.filter((r) => r.materials.some((c) => f.materials.has(c)));
@@ -174,6 +191,7 @@ const sorters = {
   title: (a, b) => str(a.title).localeCompare(str(b.title)),
   vendor: (a, b) => str(a.vendor).localeCompare(str(b.vendor)) || str(a.title).localeCompare(str(b.title)),
   type: (a, b) => str(a.type).localeCompare(str(b.type)) || str(a.title).localeCompare(str(b.title)),
+  series: (a, b) => str(a.series).localeCompare(str(b.series)) || str(a.sku).localeCompare(str(b.sku)),
   sku: (a, b) => str(a.sku).localeCompare(str(b.sku)),
   pattern: (a, b) => str(a.pattern).localeCompare(str(b.pattern)),
   status: (a, b) => str(a.status).localeCompare(str(b.status)),
@@ -190,7 +208,7 @@ const sorters = {
 // per-sort "row actually has this value" — used to keep empties last on dir=desc
 const HAS_VALUE = {
   sku: (r) => r.sku != null && r.sku !== '', title: (r) => !!r.title, vendor: (r) => !!r.vendor,
-  type: (r) => !!r.type, pattern: (r) => !!r.pattern, status: (r) => !!r.status, lifecycle: (r) => !!r.lifecycle,
+  type: (r) => !!r.type, series: (r) => !!r.series, pattern: (r) => !!r.pattern, status: (r) => !!r.status, lifecycle: (r) => !!r.lifecycle,
   price: (r) => r.price != null, price_asc: (r) => r.price != null, price_desc: (r) => r.price != null,
   color: (r) => r.colors.length > 0, style: (r) => r.styles.length > 0, material: (r) => r.materials.length > 0,
   created: (r) => r.created > 0,
@@ -262,6 +280,7 @@ const server = http.createServer((req, res) => {
     res.end(JSON.stringify({
       vendor: tally(applyFilters(ROWS, f, { skip: 'vendor' }), 'vendor'),
       type: tally(applyFilters(ROWS, f, { skip: 'type' }), 'type'),
+      series: tally(applyFilters(ROWS, f, { skip: 'series' }), 'series'),
       lifecycle: tally(applyFilters(ROWS, f, { skip: 'lifecycle' }), 'lifecycle'),
       status: tally(applyFilters(ROWS, f, { skip: 'lifecycle' }), 'status'),
       color: tallyArr(applyFilters(ROWS, f, { skip: 'color' }), 'colors'),

← 7f71cd1 microsites.html: density slider 1-30 with live readout + sor  ·  back to All Designerwallcoverings  ·  grid: Book/Series facet in left rail + Website/Internal butt a5f3545 →