[object Object]

← back to Corkwallcovering

auto-data-snapshot: 2026-08-24T04:13:07 (1 data files) — server.js.ldrbak.1787569812

d68bada205ebcd56ff8528e28327cf2e784f1a92 · 2026-08-24 04:13:09 -0700 · auto-commit-fleet

Files touched

Diff

commit d68bada205ebcd56ff8528e28327cf2e784f1a92
Author: auto-commit-fleet <steve@designerwallcoverings.com>
Date:   Mon Aug 24 04:13:09 2026 -0700

    auto-data-snapshot: 2026-08-24T04:13:07 (1 data files) — server.js.ldrbak.1787569812
---
 server.js.ldrbak.1787569812 | 452 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 452 insertions(+)

diff --git a/server.js.ldrbak.1787569812 b/server.js.ldrbak.1787569812
new file mode 100644
index 0000000..6f03c3e
--- /dev/null
+++ b/server.js.ldrbak.1787569812
@@ -0,0 +1,452 @@
+/**
+ * CORK WALLCOVERING — DW family vertical
+ * Curated slice from live designerwallcoverings.com Shopify catalog.
+ */
+try { require('dotenv').config({ path: require('path').join(__dirname, '.env') }); } catch (e) {}
+const express = require('express');
+const helmet = require('helmet');
+const path = require('path');
+const fs = require('fs');
+
+const PORT = process.env.PORT || 9848;
+const DW_SHOPIFY = 'https://designerwallcoverings.com';
+const __SITE = path.basename(__dirname);
+// Admin catalog (PG-backed) is opt-in via MICROSITE_ADMIN_ENABLED=true. Prod
+// stays static-only (data/products.json) so Kamatera doesn't need `pg` or the
+// _shared/ tree. Dev (Mac2) flips the flag on to use dw_unified + /admin/catalog.
+const ADMIN_ENABLED = process.env.MICROSITE_ADMIN_ENABLED === 'true';
+let catalog = null;
+if (ADMIN_ENABLED) {
+  try { catalog = require('../_shared/admin-catalog'); }
+  catch (e) { console.error(`[${__SITE}] admin-catalog unavailable (${e.message}) — falling back to static JSON`); }
+}
+const siteCfg = JSON.parse(fs.readFileSync(path.join(__dirname, 'site.config.json'), 'utf8'));
+const SITE_SLUG = siteCfg.slug || __SITE;
+const SITE_RAILS = Array.isArray(siteCfg.rails) ? siteCfg.rails : [];
+
+// Read-time rail membership: a product belongs to a rail if its `aesthetic` OR
+// any `tag` equals the rail key (or a declared synonym). Sister-site catalogs
+// have a degenerate `aesthetic`, so naive
+// `p.aesthetic === key` matching collapses every rail to empty — the richer
+// signal lives in `tags`. Prefer the shared matcher on dev; fall back to a
+// self-contained copy on prod where the _shared/ tree isn't deployed.
+let railMatch;
+try {
+  railMatch = require('../_shared/rail-match');
+} catch (e) {
+  const norm = s => String(s == null ? '' : s).trim().toLowerCase();
+  const productInRail = (p, key, syn) => {
+    const vals = [norm(key)].concat((syn && Array.isArray(syn[key]) ? syn[key] : []).map(norm));
+    if (vals.includes(norm(p && p.aesthetic))) return true;
+    const tags = p && Array.isArray(p.tags) ? p.tags : [];
+    return tags.some(t => vals.includes(norm(t)));
+  };
+  railMatch = {
+    productInRail,
+    buildRails(products, railKeys, opts) {
+      opts = opts || {}; const syn = opts.synonyms || null;
+      const minShare = opts.minShare != null ? opts.minShare : 0.08;
+      const minItems = opts.minItems != null ? opts.minItems : 4;
+      const perRail = opts.perRail != null ? opts.perRail : 12;
+      const maxShare = opts.maxShare != null ? opts.maxShare : 0.99;
+      const list = Array.isArray(products) ? products : []; const N = list.length || 1; const out = [];
+      for (const key of (railKeys || [])) {
+        const m = list.filter(p => productInRail(p, key, syn)); const c = m.length;
+        if (c >= minItems && c / N >= minShare && c / N <= maxShare) out.push({ key, aesthetic: key, count: c, items: m.slice(0, perRail) });
+      }
+      return out;
+    },
+    railFacets(products, railKeys, syn) {
+      const list = Array.isArray(products) ? products : []; const f = {};
+      for (const key of (railKeys || [])) f[key] = list.filter(p => productInRail(p, key, syn || null)).length;
+      return f;
+    }
+  };
+}
+const { productInRail, buildRails, railFacets } = railMatch;
+// Synonyms come from the site's own config (prod-safe); the shared default map
+// is a dev-only convenience when _shared/ is present.
+let RAIL_SYN = siteCfg.railSynonyms || {};
+if (!siteCfg.railSynonyms) { try { RAIL_SYN = require('../_shared/rail-synonyms.json'); } catch (e) {} }
+
+function isJunk(p) {
+  if (!p.image_url || !p.image_url.trim()) return true;
+  if (!p.handle && !p.sku) return true;
+  const t = p.title || '';
+  if (/lamp|rug|pillow|throw|tripod|frame|mirror|vase|candle|sculpture|figurine/i.test(t)) return true;
+  if (/visual.{0,3}merchandiser/i.test(t)) return true;
+  if (/(?:^|\W)image[ _-]?4(?:\W|$)/i.test(t)) return true;
+  if (/bh.?90210|beverly.?hills.?90210|iconic.{0,4}bh/i.test(t)) return true;
+  return false;
+}
+
+// Catalog now lives in dw_unified.microsite_products — populated async at
+// startup (see bottom of file). Falls back to data/products.json if PG is down.
+let PRODUCTS = [];
+let DROPPED = 0;
+
+// graphics-loop pass 12: niche keyword filter — only show products that fit this site's niche
+const NICHE_POS = ["cork","bark","sustainable","natural"];
+const NICHE_NEG = ["solid","blank","plain","mica","silk","grasscloth","grass cloth","sisal","seagrass","jute","raffia","linen","flax","mylar","suede","leather","hemp","abaca"]; // +competing-fiber exclusions (Steve fleet fix 2026-08-12: 'it is not {niche}')
+// Titles that name a NON-cork material leak in via cork's broad "natural"/"sustainable"
+// POS tags (yoloforever cycle 2: 3 "Durable Vinyl" products showed on a CORK site). We
+// can't blunt-NEG "vinyl" — 37 legit cork products (Oxel Cork, Cambium Faux Cork, prints
+// ON cork) carry a vinyl backing TAG. So reject only when the TITLE names another material
+// AND the title doesn't also say cork/bark.
+const TITLE_COMPETITORS = ["vinyl","grasscloth","grass cloth","sisal","seagrass","mylar","silk","linen","suede","bamboo","paper weave","paperweave"];
+function nicheFit(p) {
+  const blob = ((p.title || '') + ' ' + (p.tags || []).join(' ')).toLowerCase();
+  if (NICHE_NEG.some(n => blob.includes(n.toLowerCase()))) return false;
+  const title = (p.title || '').toLowerCase();
+  if (TITLE_COMPETITORS.some(m => title.includes(m)) && !/cork|bark/.test(title)) return false;
+  return NICHE_POS.length === 0 || NICHE_POS.some(k => blob.includes(k.toLowerCase()));
+}
+// Niche-filtered list, populated at startup alongside PRODUCTS.
+let PRODUCTS_NICHE = [];
+
+const app = express();
+// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
+app.use(helmet({ contentSecurityPolicy: false }));
+app.use(express.json({ limit: '256kb' }));
+
+// Fleet-wide DW vendor-name redactor on /api/* (drops `vendors` facet, replaces
+// any `vendor` field with "Designer Wallcoverings", strips `?vendor=` query).
+app.use(require('../_shared/api-vendor-redact'));
+// Universal contact module — modals, /api/send-inquiry, /api/send-sample, /zd-loader.js
+require('./_universal-contact')(app, { siteName: "Cork Wallcovering", zdColor: "#a87030", zdPosition: 'right' });
+require('./_universal-auth')(app, { siteName: "corkwallcovering" });
+// Universal new-products promo banner — injects /dw-promo-banner.js into HTML
+// responses (text-only rotating "NEW · <title> →" strip). Mounted BEFORE static.
+require('../_shared/_universal-promo-banner')(app, { slug: SITE_SLUG });
+
+// Clean-URL canonicalization — 301 legacy /foo.html to /foo. Runs BEFORE
+// express.static so the .html form never serves directly (avoids duplicate
+// content). The {extensions:['html']} option below resolves /foo -> foo.html.
+app.get(/^\/(.+)\.html$/i, (req, res, next) => {
+  const clean = '/' + req.params[0];
+  if (clean === '/index') return res.redirect(301, '/');
+  res.redirect(301, clean);
+});
+app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));
+
+// DW STANDARD: sort by color/style/sku/title for any product-rendering site.
+// Color rank groups similar hues; style rank pulls common DW design tags.
+const COLOR_RANK = {
+  black:0,gray:1,grey:1,silver:2,white:3,ivory:4,cream:5,beige:6,brown:7,tan:8,khaki:9,copper:10,
+  red:20,pink:21,coral:22,orange:23,peach:24,salmon:25,
+  yellow:30,gold:31,mustard:32,olive:33,
+  green:40,lime:41,mint:42,teal:50,aqua:51,turquoise:52,cyan:53,
+  blue:60,navy:61,indigo:62,periwinkle:63,
+  purple:70,violet:71,lavender:72,plum:73,magenta:74,fuchsia:75,
+};
+function colorRank(p) {
+  const tags = (p.tags || []).map(t => String(t).toLowerCase());
+  for (const t of tags) for (const k of Object.keys(COLOR_RANK)) if (t.includes(k)) return COLOR_RANK[k];
+  return 999;
+}
+const STYLE_TAGS = ['traditional','transitional','modern','contemporary','minimalist','art deco','victorian','mid-century','mid century','rustic','industrial','farmhouse','bohemian','boho','geometric','floral','botanical','damask','toile','grasscloth','silk','linen','vinyl','natural'];
+function styleKey(p) {
+  const tags = (p.tags || []).map(t => String(t).toLowerCase());
+  for (const s of STYLE_TAGS) if (tags.some(t => t.includes(s))) return s;
+  return 'zzz';
+}
+
+const COLOR_HEX = {
+  black:'#0a0a0a',charcoal:'#2a2a2a',gray:'#888',grey:'#888',silver:'#c0c0c0',
+  white:'#fff',ivory:'#fffff0',cream:'#f5e9c8',champagne:'#f0d8a8',beige:'#e7d6b6',sand:'#dccfa6',blonde:'#e8d8a0',
+  brown:'#6b4a2b',chestnut:'#5a3825',umber:'#5a3a1f',tan:'#c9a36a',khaki:'#bda464',honey:'#d6a23c',
+  copper:'#b87333',brass:'#b5a642',bronze:'#9c6b30',gold:'#d4af37',amber:'#c98a32',butterscotch:'#d6943c',saffron:'#d6a93c',ochre:'#b88c3a',mustard:'#caa53d',
+  red:'#c92a2a',coral:'#e87a6b',pink:'#e6a4b4',salmon:'#e08e7c',rose:'#d68a9a',magenta:'#c5358a',fuchsia:'#d63aaf',
+  orange:'#e07a32',peach:'#f0bb96',apricot:'#e0a373',
+  yellow:'#e8c84a',butter:'#f0e1a0',
+  olive:'#7a7a36','olive ':'#7a7a36',
+  green:'#3d8a4f',lime:'#92c84a',mint:'#aedcc1',
+  teal:'#2a8a8a',aqua:'#3ab4b4',turquoise:'#3ab8b0',cyan:'#3acac4',aquamarine:'#9ed5c8',
+  blue:'#3a5fb8',navy:'#1a2c52',indigo:'#37327a',periwinkle:'#8a93d4',
+  purple:'#6a3a8a',violet:'#7a4aa8',lavender:'#b0a3d6',plum:'#693a55',gilver:'#bfb9b3'
+};
+function rgbOfTag(tag) {
+  const k = String(tag||'').toLowerCase().trim();
+  for (const name of Object.keys(COLOR_HEX)) if (k.includes(name)) return COLOR_HEX[name];
+  return null;
+}
+function hexToRgb(h){const m=/^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(h||'');return m?[parseInt(m[1],16),parseInt(m[2],16),parseInt(m[3],16)]:null;}
+function rgbToHsl(r,g,b){r/=255;g/=255;b/=255;const mx=Math.max(r,g,b),mn=Math.min(r,g,b);let h=0,s=0,l=(mx+mn)/2;if(mx!==mn){const d=mx-mn;s=l>.5?d/(2-mx-mn):d/(mx+mn);switch(mx){case r:h=(g-b)/d+(g<b?6:0);break;case g:h=(b-r)/d+2;break;case b:h=(r-g)/d+4;break;}h*=60;}return[h,s,l];}
+function dominantHex(p){
+  if (p.dominant_hex_real && /^#[\da-f]{6}$/i.test(p.dominant_hex_real)) return p.dominant_hex_real;
+  const tags=(p.tags||[]).map(t=>String(t).toLowerCase());
+  for (const t of tags){const hx=rgbOfTag(t);if(hx)return hx;}
+  return '#888';
+}
+// Up to 4 distinct color dots: real-pixel hex first, then tag-derived hexes.
+function colorDots(p){
+  const seen=new Set();const out=[];
+  if (p.dominant_hex_real && /^#[\da-f]{6}$/i.test(p.dominant_hex_real)) {
+    out.push(p.dominant_hex_real); seen.add(p.dominant_hex_real.toLowerCase());
+  }
+  for (const t of (p.tags||[])){
+    const hx=rgbOfTag(t);
+    if(hx && !seen.has(hx.toLowerCase())){seen.add(hx.toLowerCase());out.push(hx);if(out.length>=4)break;}
+  }
+  return out;
+}
+function luminance(p){const rgb=hexToRgb(dominantHex(p));return rgb?(0.2126*rgb[0]+0.7152*rgb[1]+0.0722*rgb[2]):128;}
+function hue(p){const rgb=hexToRgb(dominantHex(p));if(!rgb)return 999;const [h,s]=rgbToHsl(rgb[0],rgb[1],rgb[2]);return s<0.08?999:h;}
+
+// ── LEFT-NAV FACETS (Amazon/Kravet style, tag-driven) ─────────────────────────
+// Canonical 12 DW color families + representative dot hex (shared with the
+// japan-enrich viewer). One family per SKU for the Color facet + swatch dots.
+const FAMILY_ORDER = ['Red','Orange/Brown','Yellow','Green','Teal','Blue','Purple','Pink','Neutral/Beige','Black','Grey','White'];
+const FAMILY_DOT = {
+  'Red':'#c0392b','Orange/Brown':'#a8642a','Yellow':'#d4ac0d','Green':'#4e8b3b',
+  'Teal':'#1aa5a5','Blue':'#2e6fb0','Purple':'#7d5ba6','Pink':'#d87aa5',
+  'Neutral/Beige':'#cdbd96','Black':'#1a1a1a','Grey':'#9a9aa2','White':'#f2f2f2',
+};
+// color tag/word -> family (most reliable when an explicit color tag exists)
+const TAG_FAMILY = {
+  black:'Black',charcoal:'Black',ebony:'Black',noir:'Black',onyx:'Black',
+  gray:'Grey',grey:'Grey',silver:'Grey',pewter:'Grey',slate:'Grey',ash:'Grey',smoke:'Grey',graphite:'Grey',
+  white:'White',ivory:'White',cream:'White',chalk:'White',alabaster:'White',porcelain:'White',
+  beige:'Neutral/Beige',sand:'Neutral/Beige',taupe:'Neutral/Beige',greige:'Neutral/Beige',oatmeal:'Neutral/Beige',linen:'Neutral/Beige',flax:'Neutral/Beige',stone:'Neutral/Beige',mushroom:'Neutral/Beige',putty:'Neutral/Beige',wheat:'Neutral/Beige',champagne:'Neutral/Beige',blonde:'Neutral/Beige',natural:'Neutral/Beige',
+  brown:'Orange/Brown',chestnut:'Orange/Brown',umber:'Orange/Brown',tan:'Orange/Brown',khaki:'Orange/Brown',honey:'Orange/Brown',copper:'Orange/Brown',bronze:'Orange/Brown',camel:'Orange/Brown',caramel:'Orange/Brown',cognac:'Orange/Brown',walnut:'Orange/Brown',espresso:'Orange/Brown',mocha:'Orange/Brown',terracotta:'Orange/Brown',rust:'Orange/Brown',sienna:'Orange/Brown',orange:'Orange/Brown',peach:'Orange/Brown',apricot:'Orange/Brown',clay:'Orange/Brown',
+  gold:'Yellow',brass:'Yellow',mustard:'Yellow',ochre:'Yellow',saffron:'Yellow',amber:'Yellow',yellow:'Yellow',butter:'Yellow',citrine:'Yellow',maize:'Yellow',
+  green:'Green',olive:'Green',lime:'Green',mint:'Green',sage:'Green',moss:'Green',fern:'Green',forest:'Green',emerald:'Green',celadon:'Green',hunter:'Green',
+  teal:'Teal',aqua:'Teal',turquoise:'Teal',cyan:'Teal',aquamarine:'Teal',peacock:'Teal',
+  blue:'Blue',navy:'Blue',indigo:'Blue',periwinkle:'Blue',cerulean:'Blue',denim:'Blue',cornflower:'Blue',sky:'Blue',
+  purple:'Purple',violet:'Purple',lavender:'Purple',plum:'Purple',aubergine:'Purple',orchid:'Purple',mauve:'Purple',amethyst:'Purple',
+  red:'Red',crimson:'Red',brick:'Red',burgundy:'Red',oxblood:'Red',garnet:'Red',claret:'Red',cinnabar:'Red',
+  pink:'Pink',rose:'Pink',blush:'Pink',coral:'Pink',salmon:'Pink',magenta:'Pink',fuchsia:'Pink',
+};
+function familyFromTags(tags){
+  const ts=(tags||[]).map(t=>String(t).toLowerCase());
+  for (const t of ts) for (const k of Object.keys(TAG_FAMILY)) if (t.includes(k)) return TAG_FAMILY[k];
+  return null;
+}
+function familyFromHex(hex){
+  const rgb=hexToRgb(hex); if(!rgb) return null;
+  const [h,s,l]=rgbToHsl(rgb[0],rgb[1],rgb[2]);
+  if (s<0.12){ if(l>0.82) return 'White'; if(l<0.20) return 'Black';
+    return (h>=20&&h<=60&&l>0.55)?'Neutral/Beige':'Grey'; }
+  if (s<0.25 && h>=20 && h<=55 && l>0.5) return 'Neutral/Beige';
+  if (h<15||h>=345) return 'Red';
+  if (h<45)  return 'Orange/Brown';
+  if (h<70)  return 'Yellow';
+  if (h<160) return 'Green';
+  if (h<200) return 'Teal';
+  if (h<255) return 'Blue';
+  if (h<290) return 'Purple';
+  return 'Pink';
+}
+function colorFamily(p){ return familyFromTags(p.tags) || familyFromHex(dominantHex(p)); }
+
+// Controlled vocab (Kravet/Amazon facet wording, from dw-seo-optimizer). Multi-value.
+const PATTERN_VOCAB  = ['Botanical','Floral','Geometric','Damask','Palm','Trellis','Stripe','Abstract','Toile','Scenic','Paisley','Plaid','Animal Print','Medallion','Chevron','Herringbone','Solid','Textured','Faux','Mural'];
+const STYLE_VOCAB    = ['Traditional','Contemporary','Modern','Coastal','Tropical','Art Deco','Mid-Century Modern','Bohemian','Transitional','Victorian','Chinoiserie','Minimalist','Maximalist','Rustic','Industrial','Scandinavian','Hollywood Regency','Japandi','Farmhouse'];
+const MATERIAL_VOCAB = ['Grasscloth','Cork','Silk','Vinyl','Non-Woven','Paper','Suede','Metallic','Glass Bead','Mica','Linen','Jute','Raffia','Mylar','String','Bamboo'];
+function matchVocab(p, vocab){
+  const blob=(p.tags||[]).map(t=>String(t).toLowerCase()).join(' | ');
+  const out=[]; for (const term of vocab) if (blob.includes(term.toLowerCase())) out.push(term);
+  return out;
+}
+// USD price bands (comma-free labels — multi-select is CSV-encoded in the query).
+const PRICE_BANDS = [
+  {key:'Under $50', t:v=>v<50},{key:'$50–100', t:v=>v>=50&&v<100},
+  {key:'$100–200', t:v=>v>=100&&v<200},{key:'$200–400', t:v=>v>=200&&v<400},{key:'$400+', t:v=>v>=400},
+];
+const PRICE_ORDER = PRICE_BANDS.map(b=>b.key);
+function priceBand(v){ v=Number(v); if(!v||isNaN(v)||v<=0) return null; const b=PRICE_BANDS.find(x=>x.t(v)); return b?b.key:null; }
+
+// Attach all facet fields once (precompute at load → filter/facets/cards share).
+function decorateFacets(p){
+  return Object.assign({}, p, {
+    color_family: colorFamily(p),
+    color_dots:   colorDots(p),
+    patterns:     matchVocab(p, PATTERN_VOCAB),
+    styles:       matchVocab(p, STYLE_VOCAB),
+    materials:    matchVocab(p, MATERIAL_VOCAB),
+    type:         p.product_type || null,
+    price_band:   priceBand(p.max_price),
+  });
+}
+const csvSet = v => new Set(String(v||'').split(',').map(s=>s.trim()).filter(Boolean));
+
+function sortProducts(list, mode) {
+  if (mode === 'sku') return [...list].sort((a,b) => String(a.sku || a.handle || '').localeCompare(String(b.sku || b.handle || '')));
+  if (mode === 'title') return [...list].sort((a,b) => String(a.title || '').localeCompare(String(b.title || '')));
+  if (mode === 'color') return [...list].sort((a,b) => colorRank(a) - colorRank(b) || String(a.title || '').localeCompare(String(b.title || '')));
+  if (mode === 'style') return [...list].sort((a,b) => styleKey(a).localeCompare(styleKey(b)) || String(a.title || '').localeCompare(String(b.title || '')));
+  if (mode === 'light-dark') return [...list].sort((a,b) => luminance(b) - luminance(a)); // bright first
+  if (mode === 'dark-light') return [...list].sort((a,b) => luminance(a) - luminance(b)); // dark first
+  if (mode === 'wheel') return [...list].sort((a,b) => hue(a) - hue(b));
+  if (mode === 'price-asc')  return [...list].sort((a,b) => (Number(a.max_price) || 0) - (Number(b.max_price) || 0));
+  if (mode === 'price-desc') return [...list].sort((a,b) => (Number(b.max_price) || 0) - (Number(a.max_price) || 0));
+  // List-view sortable columns — every column sortable in both directions.
+  if (mode === 'title-desc')     return [...list].sort((a,b) => String(b.title || '').localeCompare(String(a.title || '')));
+  if (mode === 'sku-desc')       return [...list].sort((a,b) => String(b.sku || b.handle || '').localeCompare(String(a.sku || a.handle || '')));
+  if (mode === 'vendor')         return [...list].sort((a,b) => String(a.vendor || '').localeCompare(String(b.vendor || '')) || String(a.title || '').localeCompare(String(b.title || '')));
+  if (mode === 'vendor-desc')    return [...list].sort((a,b) => String(b.vendor || '').localeCompare(String(a.vendor || '')) || String(a.title || '').localeCompare(String(b.title || '')));
+  if (mode === 'aesthetic')      return [...list].sort((a,b) => String(a.aesthetic || '').localeCompare(String(b.aesthetic || '')) || String(a.title || '').localeCompare(String(b.title || '')));
+  if (mode === 'aesthetic-desc') return [...list].sort((a,b) => String(b.aesthetic || '').localeCompare(String(a.aesthetic || '')) || String(a.title || '').localeCompare(String(b.title || '')));
+  return list;
+}
+
+
+// Shared filter — applies the active query + facet selections to the niche
+// catalog. Used by both /api/products (the grid) and /api/facets (the chip
+// counts) so the counts always reflect the FILTERED result set, not the raw
+// catalog. `skip` lets each facet exclude its own dimension for drill-down.
+function filterProducts({ q, aesthetic, vendor, colors, patterns, styles, materials, types, prices } = {}, skip = null) {
+  let list = PRODUCTS_NICHE;
+  if (q) {
+    const needle = String(q).toLowerCase();
+    list = list.filter(p => (p.title || '').toLowerCase().includes(needle) || (p.description || p.body_html || '').toLowerCase().includes(needle) || (p.product_type || '').toLowerCase().includes(needle) || (p.vendor || '').toLowerCase().includes(needle) || (p.sku || '').toLowerCase().includes(needle) || (p.tags || []).some(t => t.toLowerCase().includes(needle)));
+  }
+  if (skip !== 'aesthetic' && aesthetic && aesthetic !== 'all') list = list.filter(p => productInRail(p, aesthetic, RAIL_SYN));
+  if (skip !== 'vendor' && vendor && vendor !== 'all') list = list.filter(p => p.vendor === vendor);
+  // tag-derived left-nav facets (multi-select Sets; array dims match if ANY selected value is present)
+  if (skip !== 'color'    && colors    && colors.size)    list = list.filter(p => p.color_family && colors.has(p.color_family));
+  if (skip !== 'pattern'  && patterns  && patterns.size)  list = list.filter(p => p.patterns && p.patterns.some(x => patterns.has(x)));
+  if (skip !== 'style'    && styles    && styles.size)    list = list.filter(p => p.styles && p.styles.some(x => styles.has(x)));
+  if (skip !== 'material' && materials && materials.size) list = list.filter(p => p.materials && p.materials.some(x => materials.has(x)));
+  if (skip !== 'type'     && types     && types.size)     list = list.filter(p => p.type && types.has(p.type));
+  if (skip !== 'price'    && prices    && prices.size)    list = list.filter(p => p.price_band && prices.has(p.price_band));
+  return list;
+}
+// Build the full filter spec from a request's query string (shared by products + facets).
+function facetSpec(q){
+  return { q:q.q, aesthetic:q.aesthetic, vendor:q.vendor,
+    colors:csvSet(q.colors), patterns:csvSet(q.patterns), styles:csvSet(q.styles),
+    materials:csvSet(q.materials), types:csvSet(q.types), prices:csvSet(q.prices) };
+}
+
+app.get('/api/products', (req, res) => {
+  const { sort = 'newest', page = 1, limit = 24 } = req.query;
+  let list = filterProducts(facetSpec(req.query));
+  list = sortProducts(list, sort);
+  const total = list.length;
+  const pageNum = Math.max(1, parseInt(page) || 1);
+  const lim = Math.min(60, parseInt(limit) || 24);
+  const start = (pageNum - 1) * lim;
+  res.json({ total, page: pageNum, limit: lim, pages: Math.ceil(total / lim), sort, items: list.slice(start, start + lim) });
+});
+
+app.get('/api/sliders', (req, res) => {
+  const rails = buildRails(PRODUCTS_NICHE, SITE_RAILS, { synonyms: RAIL_SYN });
+  res.json({ rails: rails.map(r => ({ aesthetic: r.aesthetic, items: r.items })) });
+});
+
+app.get('/api/facets', (req, res) => {
+  const f = facetSpec(req.query);
+  // Each facet dimension is counted over the set filtered by the OTHER active
+  // selections (drill-down via `skip`), so a count reflects what selecting that
+  // value would yield. `total` honors ALL active filters.
+  const tally    = (rows, key) => { const m = {}; for (const p of rows) { const v = p[key]; if (v) m[v] = (m[v]||0)+1; } return m; };
+  const tallyArr = (rows, key) => { const m = {}; for (const p of rows) for (const v of (p[key]||[])) m[v] = (m[v]||0)+1; return m; };
+  res.json({
+    color_family: tally(filterProducts(f, 'color'),    'color_family'),
+    pattern:      tallyArr(filterProducts(f, 'pattern'),  'patterns'),
+    style:        tallyArr(filterProducts(f, 'style'),    'styles'),
+    material:     tallyArr(filterProducts(f, 'material'), 'materials'),
+    type:         tally(filterProducts(f, 'type'),     'type'),
+    price_band:   tally(filterProducts(f, 'price'),    'price_band'),
+    // keep the legacy aesthetic facet for the footer links (degrades to {} on most sites)
+    aesthetics:   railFacets(filterProducts(f, 'aesthetic'), SITE_RAILS, RAIL_SYN),
+    family_order: FAMILY_ORDER, family_dot: FAMILY_DOT, price_order: PRICE_ORDER,
+    total: filterProducts(f).length,
+  });
+});
+
+app.get('/api/health', (req, res) => res.json({ status: 'ok', count: PRODUCTS_NICHE.length, dropped: DROPPED }));
+
+// Step 2 of the vendor handle/sku text-leak fix (2026-06-09): the client's
+// /sample/ hrefs now point at the vendor-scrubbed `handle_display` slug
+// (emitted by _shared/api-vendor-redact since Step 1), so the raw vendor-bearing
+// handle never rides in the URL bar. This route is DUAL-KEY — it resolves the raw
+// handle/sku FIRST (old bookmarks + buy path, byte-identical behavior), then falls
+// back to a redacted->product map built after catalog load. If two raw handles
+// redact to the same display slug, that slug is logged and left raw-only.
+const { redactVendorText } = require('../_shared/vendor-text-redact');
+let SAMPLE_DISPLAY = new Map(); // redacted display slug -> product
+function buildSampleDisplayMap() {
+  const map = new Map();
+  const collided = new Set();
+  for (const p of PRODUCTS_NICHE) {
+    for (const key of [p.handle, p.sku]) {
+      if (!key) continue;
+      const d = redactVendorText(String(key));
+      if (!d || d === key) continue;           // nothing redacted — raw route already matches
+      const prev = map.get(d);
+      if (prev && prev !== p) { collided.add(d); continue; }
+      map.set(d, p);
+    }
+  }
+  for (const d of collided) {
+    map.delete(d);
+    console.warn(`[corkwallcovering] /sample display-slug collision — keeping raw-only for "${d}"`);
+  }
+  SAMPLE_DISPLAY = map;
+  console.log(`[corkwallcovering] /sample dual-key map: ${map.size} display slugs, ${collided.size} collisions (raw-only)`);
+}
+
+app.get('/sample/:handle', (req, res) => {
+  const key = req.params.handle;
+  const p = PRODUCTS_NICHE.find(x => x.handle === key || x.sku === key) // raw first — old behavior intact
+    || SAMPLE_DISPLAY.get(key);                                        // then the redacted display form
+  if (!p) return res.status(404).send('Not found');
+  res.redirect(302, p.product_url || `${DW_SHOPIFY}/products/${encodeURIComponent(p.handle)}#sample`);
+});
+
+// sitemap.xml + robots.txt for SEO
+app.get('/robots.txt', (req, res) => {
+  res.type('text/plain').send(`User-agent: *
+Allow: /
+Sitemap: https://corkwallcovering.com/sitemap.xml
+`);
+});
+app.get('/sitemap.xml', (req, res) => {
+  const urls = ['/'];
+  const xml = `<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+${urls.map(u => `  <url><loc>https://corkwallcovering.com${u}</loc><changefreq>weekly</changefreq></url>`).join('\n')}
+</urlset>`;
+  res.type('application/xml').send(xml);
+});
+
+// Admin catalog CRUD — /admin/catalog (basic-auth) + /api/admin/* REST.
+if (catalog) catalog.mount(app, { siteSlug: SITE_SLUG, rails: SITE_RAILS });
+
+// 404 guard — JSON for /api/*, plain text otherwise. Registered last so it
+// only fires when no route or static asset matched.
+app.use((req, res) => {
+  if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'not_found', path: req.path });
+  res.status(404).type('text/plain').send('Not found');
+});
+
+// Load the catalog. When admin is enabled, prefer dw_unified; else static JSON.
+(async () => {
+  let loaded = false;
+  if (catalog) {
+    try {
+      const rows = await catalog.getProducts(SITE_SLUG);
+      PRODUCTS = rows.filter(p => !isJunk(p));
+      DROPPED = rows.length - PRODUCTS.length;
+      console.log(`[${__SITE}] loaded ${rows.length} from dw_unified, kept ${PRODUCTS.length}, dropped ${DROPPED}`);
+      loaded = true;
+    } catch (e) {
+      console.error(`[${__SITE}] PG catalog load failed (${e.message}) — falling back to data/products.json`);
+    }
+  }
+  if (!loaded) {
+    try {
+      const raw = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8'));
+      PRODUCTS = (Array.isArray(raw) ? raw : []).filter(p => !isJunk(p));
+      console.log(`[${__SITE}] loaded ${PRODUCTS.length} from data/products.json (static mode)`);
+    } catch (_) { PRODUCTS = []; }
+  }
+  PRODUCTS_NICHE = PRODUCTS.filter(nicheFit).map(decorateFacets);
+  console.log(`[${__SITE}] niche filter: kept ${PRODUCTS_NICHE.length} of ${PRODUCTS.length} (facet-decorated)`);
+  buildSampleDisplayMap(); // Step 2: redacted->raw /sample resolver keys
+  app.listen(PORT, '127.0.0.1', () => {
+    console.log(`corkwallcovering listening on http://127.0.0.1:${PORT}`);
+  });
+})();

← 8480fb6 Add p.visible!==false guard to product load filter (cluster5  ·  back to Corkwallcovering  ·  TK-11341: add AdSense Auto-Ads loader + ads.txt (revert to r b166ae1 →