← back to Screenprintedwallpaper

server.js

330 lines

/**
 * SCREEN-PRINTED WALLPAPER — 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 path = require('path');
const fs = require('fs');

const PORT = process.env.PORT || 9876;
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` (here: 986/986 "all"), 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 = ["silkscreen","screen print","screen-printed","screen-print","block print","block-print","hand-pulled","artisan"];
const NICHE_NEG = ["solid","blank","plain"];
function nicheFit(p) {
  const blob = ((p.title || '') + ' ' + (p.tags || []).join(' ')).toLowerCase();
  if (NICHE_NEG.some(n => blob.includes(n.toLowerCase()))) return false;
  return NICHE_POS.length === 0 || NICHE_POS.some(k => blob.includes(k.toLowerCase()));
}
// Niche-filtered view of PRODUCTS — recomputed after the async catalog load.
let PRODUCTS_NICHE = [];


const app = express();
// Fleet-wide standing rule: strip vendor field + vendors facet from /api/* JSON
// (Steve directive "DW vendor names NEVER in customer-facing UI"). MUST mount
// before any /api/* route registration so the res.json wrapper catches them.
try { app.use(require('../_shared/api-vendor-redact')); }
catch (e) { console.error(`[${__SITE}] api-vendor-redact unavailable (${e.message}) — continuing without it`); }
app.use(express.json({ limit: "256kb" }));
// Site config — prefer _shared/site-config (canonical) but fall back to an
// inline minimal loader so this server.js is self-contained on Kamatera.
let cfg;
try {
  cfg = require('../_shared/site-config').load(__dirname);
} catch (_) {
  const domain = siteCfg.domain || `${SITE_SLUG}.com`;
  cfg = Object.freeze({
    slug: SITE_SLUG, siteName: siteCfg.siteName || SITE_SLUG,
    domain, siteEmail: siteCfg.siteEmail || `info@${domain}`,
    contact: { siteName: siteCfg.siteName || SITE_SLUG, domain,
               siteEmail: siteCfg.siteEmail || `info@${domain}`,
               zdColor: (siteCfg.theme && siteCfg.theme.accent) || '#C9A14B',
               zdPosition: siteCfg.zdPosition || 'right' },
    auth: { siteName: SITE_SLUG },
  });
}
require("./_universal-contact")(app, cfg.contact);
require("./_universal-auth")(app, cfg.auth);
app.locals.siteConfig = cfg;
// Block access to editor snapshot files that linger in public/ (source disclosure).
app.use((req, res, next) => {
  if (/\.(bak[-.]?[^/]*|pre-[^/]*)(\.[^/]*)?$/i.test(req.path) || /\.bak[-.]|\.pre-/i.test(req.path)) {
    return res.status(404).send('Not found');
  }
  next();
});
// Canonical-URL 301: the editorial pages exist in public/ as *.html and are
// also served clean (see CLEAN_PAGES below), so express.static would otherwise
// serve BOTH /history and /history.html = duplicate content. Redirect the
// legacy .html form (and /index.html) to the clean URL before static handles it.
app.get(/^\/(index|history|care|sourcing|trade|vocabulary)\.html$/i, (req, res) => {
  const base = req.params[0].toLowerCase();
  res.redirect(301, base === 'index' ? '/' : `/${base}`);
});
require('../_shared/_universal-promo-banner')(app, {}); // new-arrivals promo strip (Tier B)
app.use(express.static(path.join(__dirname, 'public')));

// Color buckets for the "Color" sort — group similar hues via tag-match.
const COLOR_ORDER = ['white','cream','beige','grey','gray','black','brown','tan','gold','yellow','orange','red','pink','purple','blue','teal','green'];
function colorRank(p) {
  const tags = (p.tags || []).map(t => t.toLowerCase());
  for (let i = 0; i < COLOR_ORDER.length; i++) {
    if (tags.some(t => t.includes(COLOR_ORDER[i]))) return i;
  }
  return COLOR_ORDER.length;
}

function sortProducts(list, sort) {
  if (!sort || sort === 'newest') return list; // server's natural order
  const arr = list.slice();
  switch (sort) {
    case 'sku':
      return arr.sort((a, b) => String(a.sku || a.handle || '').localeCompare(String(b.sku || b.handle || '')));
    case 'title':
      return arr.sort((a, b) => String(a.title || '').localeCompare(String(b.title || '')));
    case 'color':
      return arr.sort((a, b) => colorRank(a) - colorRank(b) || String(a.title || '').localeCompare(String(b.title || '')));
    case 'style':
      return arr.sort((a, b) => String(a.aesthetic || '').localeCompare(String(b.aesthetic || '')) || String(a.title || '').localeCompare(String(b.title || '')));
    // List-view sortable columns — every column sortable in both directions.
    case 'title-desc':
      return arr.sort((a, b) => String(b.title || '').localeCompare(String(a.title || '')));
    case 'sku-desc':
      return arr.sort((a, b) => String(b.sku || b.handle || '').localeCompare(String(a.sku || a.handle || '')));
    case 'vendor':
      return arr.sort((a, b) => String(a.vendor || '').localeCompare(String(b.vendor || '')) || String(a.title || '').localeCompare(String(b.title || '')));
    case 'vendor-desc':
      return arr.sort((a, b) => String(b.vendor || '').localeCompare(String(a.vendor || '')) || String(a.title || '').localeCompare(String(b.title || '')));
    case 'aesthetic':
      return arr.sort((a, b) => String(a.aesthetic || '').localeCompare(String(b.aesthetic || '')) || String(a.title || '').localeCompare(String(b.title || '')));
    case 'aesthetic-desc':
      return arr.sort((a, b) => String(b.aesthetic || '').localeCompare(String(a.aesthetic || '')) || String(a.title || '').localeCompare(String(b.title || '')));
    case 'price-asc':
      return arr.sort((a, b) => (Number(a.max_price) || 0) - (Number(b.max_price) || 0));
    case 'price-desc':
      return arr.sort((a, b) => (Number(b.max_price) || 0) - (Number(a.max_price) || 0));
    default:
      return list;
  }
}

app.get('/api/products', (req, res) => {
  const { q, aesthetic, vendor, sort, page = 1, limit = 24 } = req.query;
  let list = PRODUCTS_NICHE;
  if (q) {
    const needle = q.toLowerCase();
    list = list.filter(p => (p.title || '').toLowerCase().includes(needle) || (p.tags || []).some(t => t.toLowerCase().includes(needle)));
  }
  if (aesthetic && aesthetic !== 'all') list = list.filter(p => productInRail(p, aesthetic, RAIL_SYN));
  if (vendor && vendor !== 'all') list = list.filter(p => p.vendor === vendor);
  list = sortProducts(list, sort);
  const total = list.length;
  const pageNum = Math.max(1, parseInt(page));
  const lim = Math.min(60, parseInt(limit));
  const start = (pageNum - 1) * lim;
  res.json({ total, page: pageNum, limit: lim, pages: Math.ceil(total / lim), 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) => {
  // Counts reflect the CURRENT filtered list (same q/aesthetic/vendor pipeline
  // as /api/products) so the facet rail tells the user "if you also apply X,
  // here's what's left" instead of always showing the full-catalog count.
  const { q, aesthetic, vendor } = req.query;
  let list = PRODUCTS_NICHE;
  if (q) {
    const needle = String(q).toLowerCase();
    list = list.filter(p => (p.title || '').toLowerCase().includes(needle) || (p.tags || []).some(t => t.toLowerCase().includes(needle)));
  }
  if (aesthetic && aesthetic !== 'all') list = list.filter(p => productInRail(p, aesthetic, RAIL_SYN));
  if (vendor && vendor !== 'all') list = list.filter(p => p.vendor === vendor);
  const aesthetics = {}; const vendors = {};
  for (const p of list) {
    aesthetics[p.aesthetic] = (aesthetics[p.aesthetic] || 0) + 1;
    vendors[p.vendor] = (vendors[p.vendor] || 0) + 1;
  }
  res.json({ aesthetics, vendors, total: list.length });
});

app.get('/api/health', (req, res) => res.json({ status: 'ok', count: PRODUCTS_NICHE.length, dropped: DROPPED }));

// Clean-URL routes for the static editorial pages. /about maps to the
// history page (the nav links to /about, but the page on disk is history.html).
const CLEAN_PAGES = { '/about': 'history.html', '/history': 'history.html', '/care': 'care.html', '/sourcing': 'sourcing.html', '/trade': 'trade.html', '/vocabulary': 'vocabulary.html' };
for (const [route, file] of Object.entries(CLEAN_PAGES)) {
  const abs = path.join(__dirname, 'public', file);
  if (fs.existsSync(abs)) {
    app.get(route, (req, res) => res.sendFile(abs));
  }
}

// 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(`[screenprintedwallpaper] /sample display-slug collision — keeping raw-only for "${d}"`);
  }
  SAMPLE_DISPLAY = map;
  console.log(`[screenprintedwallpaper] /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`);
});

// /about → served by the "short history" page (no dedicated about.html exists)
app.get('/about', (req, res) => res.sendFile(path.join(__dirname, 'public', 'history.html')));

// sitemap.xml + robots.txt for SEO
app.get('/robots.txt', (req, res) => {
  res.type('text/plain').send(`User-agent: *
Allow: /
Sitemap: https://screenprintedwallpaper.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://screenprintedwallpaper.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 });

// 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);
  console.log(`[${__SITE}] niche filter: kept ${PRODUCTS_NICHE.length} of ${PRODUCTS.length}`);
  buildSampleDisplayMap(); // Step 2: redacted->raw /sample resolver keys
  app.listen(PORT, '127.0.0.1', () => {
    console.log(`screenprintedwallpaper listening on http://127.0.0.1:${PORT}`);
  });
})();