← back to Naturaltextilewallpaper
server.js
900 lines
/**
* NATURAL TEXTILE WALLPAPER — DW family category aggregator
* Curated from live designerwallcoverings.com Shopify catalog.
* Covers: linen, jute, raffia, sisal, hemp, grasscloth, woven, seagrass, abaca, bamboo.
* Design: Loro Piana heirloom mill aesthetic — warm cream/oatmeal/charcoal, Futura-feel sans.
* Port: 9835
*/
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 { createImgProxy } = require('../_shared/image-proxy');
const IMGP = createImgProxy(); // vendor-neutral /img/<token> image proxy
// 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(`[NTW] admin-catalog unavailable (${e.message}) — falling back to static JSON`); }
}
const PORT = process.env.PORT || 9835;
const DW = 'https://designerwallcoverings.com';
const SITE_NAME = 'Natural Textile Wallpaper';
const SITE_DOMAIN = 'naturaltextilewallpaper.com';
const SITE_EMAIL = 'info@naturaltextilewallpaper.com';
const GA_ID = process.env.GA_ID || '';
// --- Vendor-name slug scrub (copied verbatim from dw-domain-fleet
// shared/render.js @ ee30c6a) -------------------------------------------
// Customer-facing surfaces must never carry a 3rd-party vendor name. The
// product handle/sku leaks vendor tokens into the /p/<slug> href and the
// visible SKU spec. redactVendorSlug() strips any hyphen-delimited segment
// that is a denylisted vendor; the REAL p.handle is kept for the main-store
// (designerwallcoverings.com/products/<real-handle>) buy link. The /p/ route
// resolver is dual-keyed so both the redacted slug and any old real-handle
// URL still resolve.
const VENDORS_DENYLIST = [
'wolf gordon', 'nina campbell', 'jeffrey stevens', 'versace',
'arte international', 'arte', 'lee jofa modern', 'lee jofa', 'innovations usa',
'innovations', 'koroseal', 'schumacher', 'candice olson', 'thibaut',
'maya romanoff', 'scalamandre', 'dedar', 'carnegie', 'cole and son',
'kravet', 'clarke and clarke', 'brunschwig', 'gp j baker', 'sandberg',
'designers guild', 'graham and brown', 'harlequin', 'andrew martin',
'ronald redding', 'blithfield', 'coordonne', 'fentucci', 'sister parish',
'phillip jeffries', 'fromental', 'westport', 'breegan', 'les ensembliers',
'roger thomas', 'stacy garcia', 'de gournay', 'romo', 'farrow and ball',
'colefax and fowler', 'sanderson', 'morris and co', 'osborne and little',
// 2026-05-31 audit gap-close: these leaked even on scrubbed surfaces.
'cowtan', 'designtex', 'knoll', 'brewster', 'york', 'fabricut', 'stout',
'hygge', 'designerwallcoverings',
];
// sort longest-first so multi-word vendors strip before their abbreviations
// (e.g. "arte international" before bare "arte" → no "international" residue)
const VENDOR_SLUG_RES = VENDORS_DENYLIST.slice().sort((a, b) => b.length - a.length).map(v => {
const s = String(v).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return new RegExp('(^|-)' + s + '(?=-|$)', 'gi');
});
function redactVendorSlug(slug) {
let out = String(slug == null ? '' : slug);
for (let i = 0; i < VENDOR_SLUG_RES.length; i++) out = out.replace(VENDOR_SLUG_RES[i], '$1');
out = out.replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '');
return out || String(slug == null ? '' : slug);
}
// --- Site config (slug + rails for the shared catalog module) -----------
let siteCfg = {};
try {
siteCfg = JSON.parse(fs.readFileSync(path.join(__dirname, 'site.config.json'), 'utf8'));
} catch (e) { console.error(`[NTW] site.config.json read failed — ${e.message}`); }
const SITE_SLUG = siteCfg.slug || path.basename(__dirname);
const SITE_RAILS = Array.isArray(siteCfg.rails) ? siteCfg.rails : [];
// --- Junk filter --------------------------------------------------------
function isJunk(p) {
if (!p.image_url || !p.image_url.trim()) return true;
if (!p.handle) 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|bh.?90210|beverly.?hills.?90210/i.test(t)) return true;
if (/(?:^|\W)image[ _-]?4(?:\W|$)/i.test(t)) return true;
const sku = p.sku || p.handle || '';
if (/^\d{13,}$/.test(sku)) return true; // variant_id leak
return false;
}
// --- Material labels (the 7 filter pills) --------------------------------
const MATERIALS = [
{ key: 'linen', label: 'Linen' },
{ key: 'jute', label: 'Jute' },
{ key: 'raffia', label: 'Raffia' },
{ key: 'sisal', label: 'Sisal' },
{ key: 'hemp', label: 'Hemp' },
{ key: 'grasscloth', label: 'Grasscloth' },
{ key: 'bamboo', label: 'Bamboo' },
{ key: 'woven', label: 'Woven' },
{ key: 'natural', label: 'Natural' },
];
// --- Material derivation ------------------------------------------------
// The dw_unified.microsite_products read-path does NOT carry a `material`
// column (nor `min_price`/`images`). Derive `material` from tags/title/type,
// and backfill `min_price`/`images` so the rest of this server is untouched.
const MAT_KEYS = MATERIALS.map(m => m.key);
function deriveMaterial(p) {
if (p.material && MAT_KEYS.includes(p.material)) return p.material;
const blob = ((p.title || '') + ' ' + (p.product_type || '') + ' ' +
(Array.isArray(p.tags) ? p.tags.join(' ') : '')).toLowerCase();
// most specific first; 'woven'/'natural' are catch-alls
for (const k of ['grasscloth','seagrass','linen','jute','raffia','sisal','hemp','bamboo','abaca']) {
if (blob.includes(k)) {
if (k === 'seagrass') return 'grasscloth';
if (k === 'abaca') return 'jute';
return k;
}
}
if (/\bwoven\b/.test(blob)) return 'woven';
return 'natural';
}
function normalizeProduct(p) {
const tags = Array.isArray(p.tags) ? p.tags
: (typeof p.tags === 'string' ? p.tags.split(',').map(t => t.trim()).filter(Boolean) : []);
const out = { ...p, tags };
out.material = deriveMaterial(out);
if (out.max_price == null && p.price != null) out.max_price = Number(p.price);
if (out.min_price == null) out.min_price = out.max_price != null ? Number(out.max_price) : 0;
if (!Array.isArray(out.images) || !out.images.length) {
out.images = out.image_url ? [out.image_url] : [];
}
return out;
}
// PRODUCTS is loaded async at startup from dw_unified (JSON fallback if PG down).
let PRODUCTS = [];
// --- Sort ---------------------------------------------------------------
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','rustic','industrial','farmhouse','bohemian','geometric','floral','botanical','damask','toile','grasscloth','linen','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;}
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));
return list;
}
// --- Express app --------------------------------------------------------
const app = express();
app.use(helmet({
contentSecurityPolicy: false // allow inline styles/scripts for the toggle
}));
// Never serve editor/snapshot files from the static root.
app.use((req, res, next) => {
if (/\.(bak|pre-[\w.-]*|orig|swp|tmp)$|\.bak\.|~$/i.test(req.path)) {
return res.status(404).send(page404());
}
next();
});
// Fleet-wide DW vendor-name redactor on /api/* (drops vendors facet, replaces vendor field).
app.use(require('../_shared/api-vendor-redact'));
require('../_shared/_universal-promo-banner')(app, { slug: 'naturaltextilewallpaper' });
app.use(express.static(path.join(__dirname, 'public')));
// Vendor-neutral image proxy — render + APIs emit /img/<token>; this streams the
// real CDN bytes so the vendor filename never reaches the client. (see _shared/image-proxy.js)
IMGP.mount(app);
// --- API: products (JSON for infinite scroll) ---------------------------
app.get('/api/products', (req, res) => {
const { q, material, sort='newest', page=1, limit=24 } = req.query;
let list = PRODUCTS;
if (q) {
const tokens = q.toLowerCase().split(/\s+/).filter(Boolean);
list = list.filter(p => {
const blob = ((p.title||'')+' '+(p.sku||'')+' '+(p.tags||[]).join(' ')).toLowerCase();
return tokens.every(tok => blob.includes(tok));
});
}
if (material && material !== 'all') {
list = list.filter(p => p.material === material);
}
list = sortProducts(list, sort);
const total = list.length;
const pg = Math.max(1, parseInt(page)||1);
const lim = Math.min(60, parseInt(limit)||24);
const start = (pg-1)*lim;
// slug = vendor-scrubbed handle for the customer-facing /p/ href + visible
// SKU. The REAL handle is kept on the item so the main-store buy link
// (designerwallcoverings.com/products/<real-handle>) still resolves.
const items = list.slice(start, start+lim).map(p => ({ ...IMGP.scrubProduct(p), slug: redactVendorSlug(p.handle) }));
res.json({ total, page:pg, limit:lim, pages: Math.ceil(total/lim), sort, items });
});
// --- API: sliders (rails per material) ----------------------------------
app.get('/api/sliders', (req, res) => {
const rails = [];
for (const m of MATERIALS) {
const items = PRODUCTS.filter(p => p.material === m.key).slice(0, 12);
if (items.length >= 4) rails.push({ material: m.key, label: m.label, items: items.map(IMGP.scrubProduct) });
}
res.json({ rails });
});
// --- API: material counts for filter pills ------------------------------
app.get('/api/facets', (req, res) => {
const counts = {};
for (const p of PRODUCTS) counts[p.material] = (counts[p.material]||0) + 1;
res.json({ counts, total: PRODUCTS.length });
});
// --- Product detail page ------------------------------------------------
// /buy/:slug — internal 302 to the main store. Resolves a vendor-redacted slug
// to the real handle SERVER-SIDE so the raw vendor-bearing handle never appears
// in customer-facing HTML (mirrors dw-domain-fleet a85eb02). ?sample → #sample.
app.get('/buy/:slug', (req, res) => {
const key = req.params.slug;
const p = PRODUCTS.find(x => x.handle === key)
|| PRODUCTS.find(x => redactVendorSlug(x.handle) === key);
if (!p) return res.status(404).send(page404());
res.redirect(302, `${DW}/products/${p.handle}` + (req.query.sample != null ? '#sample' : ''));
});
app.get('/p/:handle', (req, res) => {
const key = req.params.handle;
const p = PRODUCTS.find(x => x.handle === key)
|| PRODUCTS.find(x => redactVendorSlug(x.handle) === key);
if (!p) return res.status(404).send(page404());
const matLabel = MATERIALS.find(m=>m.key===p.material)?.label || 'Natural Textile';
res.send(renderDetail(p, matLabel));
});
// --- Health check -------------------------------------------------------
app.get('/health', (req, res) => {
const counts = {};
for (const p of PRODUCTS) counts[p.material] = (counts[p.material]||0) + 1;
res.json({ ok: true, products: PRODUCTS.length, materials: counts, domain: SITE_DOMAIN });
});
// --- Main catalog page --------------------------------------------------
app.get('/', (req, res) => {
const { q='', material='all', sort='newest' } = req.query;
res.send(renderIndex(q, material, sort));
});
// --- HTML templates -----------------------------------------------------
const GTAG = GA_ID ? `
<script async src="https://www.googletagmanager.com/gtag/js?id=${GA_ID}"></script>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${GA_ID}');</script>
` : '';
function headHTML(title, description, canonical='', ogImage='') {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script>
(function(){try{var t=localStorage.getItem('ntw_theme');if(!t){t=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.setAttribute('data-theme',t);}catch(e){}})();
</script>
<title>${title}</title>
<meta name="description" content="${description}">
${canonical?`<link rel="canonical" href="${canonical}">\n `:''}<meta property="og:type" content="website">
<meta property="og:site_name" content="Natural Textile Wallpaper">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${description}">
${canonical?`<meta property="og:url" content="${canonical}">\n `:''}${ogImage?`<meta property="og:image" content="${ogImage}">\n `:''}<meta name="twitter:card" content="${ogImage?'summary_large_image':'summary'}">
<meta name="theme-color" content="#f5ede0" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#1a1410" media="(prefers-color-scheme: dark)">
${GTAG}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,300;1,400&family=Montserrat:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/site.css">
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"Organization","name":"Natural Textile Wallpaper","url":"https://${SITE_DOMAIN}","email":"${SITE_EMAIL}","description":"${description}","sameAs":["https://designerwallcoverings.com"]}
</script>
<script src="/corner-nav.js" defer></script>
<style id="ns-modal-css">
/* ── Contact modal (.ns-modal) — added by fanout-12 ── */
.ns-modal {
display: none;
position: fixed; inset: 0; z-index: 1000;
background: rgba(10,10,10,0.78);
backdrop-filter: blur(6px);
align-items: center; justify-content: center;
padding: 24px;
font-family: -apple-system, "SF Pro Text", system-ui, sans-serif;
}
.ns-modal.show { display: flex; }
.ns-modal-card {
position: relative;
background: #f5f1e8;
color: #0a0a0a;
width: 100%; max-width: 480px;
padding: 36px 32px 32px;
border-radius: 4px;
box-shadow: 0 30px 80px rgba(0,0,0,0.55);
animation: nsModalIn 220ms cubic-bezier(.2,.7,.2,1);
}
@keyframes nsModalIn { from { opacity:0; transform: translateY(8px) scale(.985); } to { opacity:1; transform:none; } }
.ns-modal-card .close-x {
position: absolute; top: 10px; right: 12px;
width: 32px; height: 32px;
background: transparent; border: 0; cursor: pointer;
font-size: 22px; line-height: 1; color: #6e6e68;
}
.ns-modal-card .close-x:hover { color: #0a0a0a; }
.ns-modal-card h3 {
margin: 0 0 4px;
font: 300 22px/1.2 -apple-system, "SF Pro Display", system-ui, sans-serif;
letter-spacing: -0.005em;
color: #0a0a0a;
}
.ns-modal-card .sub {
margin: 0 0 22px;
font: 13px/1.45 -apple-system, "SF Pro Text", system-ui, sans-serif;
color: #6e6e68;
letter-spacing: 0.005em;
}
.ns-actions { display: flex; flex-direction: column; gap: 10px; }
.ns-action {
display: flex; align-items: center; gap: 14px;
padding: 14px 16px;
background: #ffffff;
border: 1px solid rgba(10,10,10,0.08);
border-radius: 3px;
color: #0a0a0a !important;
text-decoration: none !important;
font: 400 14px/1.3 -apple-system, "SF Pro Text", system-ui, sans-serif;
cursor: pointer;
}
.ns-action:hover { background: #fffdf7; border-color: rgba(10,10,10,0.18); }
.ns-action .ico {
flex-shrink: 0;
width: 38px; height: 38px;
display: inline-flex; align-items: center; justify-content: center;
background: rgba(10,10,10,0.04);
border-radius: 50%;
font-size: 18px;
}
.ns-action .txt { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.ns-action .txt b { font-weight: 500; font-size: 14px; color: #0a0a0a; }
.ns-action .txt span {
font-weight: 400; font-size: 12px; color: #6e6e68;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
@media (max-width: 480px) {
.ns-modal { padding: 14px; }
.ns-modal-card { padding: 28px 22px 22px; }
}
[data-theme="dark"] .ns-modal-card { background: #14110c; color: #f5f1e8; }
[data-theme="dark"] .ns-modal-card h3 { color: #f5f1e8; }
[data-theme="dark"] .ns-modal-card .sub { color: #a39c8b; }
[data-theme="dark"] .ns-action { background: rgba(245,241,232,0.04); border-color: rgba(245,241,232,0.10); color: #f5f1e8 !important; }
[data-theme="dark"] .ns-action:hover { background: rgba(245,241,232,0.08); border-color: rgba(245,241,232,0.22); }
[data-theme="dark"] .ns-action .ico { background: rgba(245,241,232,0.08); }
[data-theme="dark"] .ns-action .txt b { color: #f5f1e8; }
[data-theme="dark"] .ns-action .txt span { color: #a39c8b; }
</style>
</head>
<body>
<!-- Theme toggle (theme1 / theme2) — query-param-gated preview surface
(Steve 2026-05-29). Mirror of dw-domain-fleet shared/render.js productPage()
block (commit 34fbe0e). No admin auth → security-by-obscurity: ?theme=2
flips to theme2 and persists in localStorage 'wallco-page-theme'. Toggle UI
hidden until ?theme=N or localStorage opt-in. Uses 'data-page-theme'
attribute + 'wallco-page-theme' localStorage key (distinct from this
site's existing data-theme dark/light system). -->
<script>
(function(){
var qs = new URLSearchParams(location.search);
var fromUrl = qs.get('theme');
var saved = null; try { saved = localStorage.getItem('wallco-page-theme'); } catch(e){}
var theme = null;
if (fromUrl === '2' || fromUrl === 'theme2') theme = 'theme2';
else if (fromUrl === '1' || fromUrl === 'theme1') theme = 'theme1';
else if (saved === 'theme2' || saved === 'theme1') theme = saved;
if (theme) {
document.documentElement.setAttribute('data-page-theme', theme);
if (fromUrl) { try { localStorage.setItem('wallco-page-theme', theme); } catch(e){} }
}
/* No opt-in → no attribute set, toggle stays display:none (gate works). */
})();
</script>
<style id="page-theme-css-fleet">
[data-page-theme="theme2"] body{
--bg:#ffffff !important; --ink:#0a0a0a !important;
--muted:#5a5a5a !important; --line:#e5e5e5 !important;
--card-bg:#fafafa !important; --accent:#c14a2e !important;
background:#fff !important; color:#0a0a0a !important;
}
[data-page-theme="theme2"] h1,
[data-page-theme="theme2"] h2,
[data-page-theme="theme2"] h3{ letter-spacing:-0.01em; font-weight:500; }
[data-page-theme="theme2"] .card{
background:#fafafa !important; border:1px solid #e5e5e5 !important; box-shadow:none !important;
}
#page-theme-toggle{ display:none; }
[data-page-theme] #page-theme-toggle{ display:inline-flex; }
#page-theme-toggle{
position:fixed; top:96px; right:14px; z-index:90;
gap:0; align-items:center;
background:rgba(255,255,255,.92); border:1px solid #d8d2c5;
border-radius:999px; padding:3px; backdrop-filter:blur(6px);
box-shadow:0 2px 10px rgba(0,0,0,.08);
font:600 11px ui-sans-serif,system-ui; letter-spacing:.06em; text-transform:uppercase;
}
#page-theme-toggle button{
border:0; background:transparent; color:#5a5048; cursor:pointer;
padding:6px 14px; border-radius:999px; font:inherit; transition:all .15s;
}
#page-theme-toggle button[aria-pressed="true"]{ background:#1a1714; color:#faf7f2; }
#page-theme-toggle button:hover:not([aria-pressed="true"]){ color:#1a1714; }
</style>
<div id="page-theme-toggle" role="group" aria-label="Page theme">
<button type="button" data-theme="theme1" aria-pressed="false">Theme 1</button>
<button type="button" data-theme="theme2" aria-pressed="false">Theme 2</button>
</div>
<script>
(function(){
var cur = document.documentElement.getAttribute('data-page-theme') || 'theme1';
var btns = document.querySelectorAll('#page-theme-toggle button');
function sync(t){ btns.forEach(function(b){ b.setAttribute('aria-pressed', b.dataset.theme === t ? 'true' : 'false'); }); }
sync(cur);
btns.forEach(function(b){
b.addEventListener('click', function(){
var t = b.dataset.theme;
document.documentElement.setAttribute('data-page-theme', t);
try { localStorage.setItem('wallco-page-theme', t); } catch(e){}
sync(t);
});
});
})();
</script>`;
}
function headerHTML(q='', material='all', sort='newest') {
const matOptions = [
{key:'all', label:'All Materials'},
...MATERIALS
];
const sortOptions = [
{key:'newest', label:'Newest'},
{key:'color', label:'Color'},
{key:'style', label:'Style'},
{key:'sku', label:'SKU A→Z'},
{key:'title', label:'Title A→Z'},
{key:'price-asc', label:'Price ↑'},
{key:'price-desc', label:'Price ↓'},
];
return `
<header class="site-header">
<div class="header-inner">
<a href="/" class="brand">
<span class="brand-mark">Natural Textile<br>Wallpaper</span>
<small class="brand-sub">Curated by Designer Wallcoverings</small>
</a>
<nav class="header-nav">
<a href="/" class="nav-link">Collection</a>
<a href="/?material=linen" class="nav-link">Linen</a>
<a href="/?material=grasscloth" class="nav-link">Grasscloth</a>
<a href="/?material=jute" class="nav-link">Jute</a>
<a href="${DW}" class="nav-link" target="_blank" rel="noopener noreferrer">Free Samples</a>
<button id="theme-toggle" class="theme-toggle" aria-label="Toggle dark mode">
<svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
<svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"/></svg>
</button>
</nav>
</div>
</header>
<div class="search-bar-wrap">
<form class="search-form" method="GET" action="/" id="searchForm">
<div class="search-row">
<div class="search-input-wrap">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" class="search-icon"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input type="text" name="q" id="searchInput" value="${escHtml(q)}" placeholder="Search 4,000+ natural fiber wallcoverings…" class="search-input" autocomplete="off">
${q ? `<button type="button" class="search-clear" onclick="document.getElementById('searchInput').value='';document.getElementById('searchForm').submit();">✕</button>` : ''}
</div>
<div class="controls-row">
<select name="sort" id="sortSelect" class="ctrl-select" onchange="this.form.submit()">
${sortOptions.map(o=>`<option value="${o.key}"${sort===o.key?' selected':''}>${o.label}</option>`).join('')}
</select>
<div class="density-wrap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
<input type="range" id="densitySlider" min="120" max="300" value="220" class="density-slider" title="Card size">
</div>
</div>
</div>
<input type="hidden" name="material" value="${escHtml(material)}">
</form>
</div>
<div class="material-pills">
${matOptions.map(m=>`
<a href="/?material=${m.key}${q?'&q='+encodeURIComponent(q):''}${sort!=='newest'?'&sort='+sort:''}"
class="pill${material===m.key?' pill-active':''}" data-mat="${m.key}">${m.label}</a>
`).join('')}
</div>`;
}
function renderIndex(q, material, sort) {
const matLabel = material==='all' ? 'All Natural Fibers' : (MATERIALS.find(m=>m.key===material)?.label || material);
const heroMaterial = material!=='all' ? material : 'linen';
const heroProduct = PRODUCTS.find(p => p.material === heroMaterial) || PRODUCTS[0];
return headHTML(
`Natural Textile Wallpaper — Free Memo Samples from Designer Wallcoverings | ${matLabel}`,
`Shop linen, jute, raffia, sisal, hemp, grasscloth and woven natural-fiber wallcoverings. Free memo samples, trade service, and expert hand-holding from Designer Wallcoverings.`,
'https://naturaltextilewallpaper.com/' + (material && material!=='all' ? `?material=${encodeURIComponent(material)}` : ''),
(heroProduct && heroProduct.image_url) ? IMGP.proxyImg(heroProduct.image_url) : ''
) + headerHTML(q, material, sort) + `
${q==='' && material==='all' ? `
<section class="hero">
<div class="hero-image-wrap">
${heroProduct ? `<img src="${escHtml(IMGP.proxyImg(heroProduct.image_url))}" alt="Natural fiber weave detail — ${escHtml(heroProduct.title)}" class="hero-img" loading="eager">` : ''}
<div class="hero-overlay"></div>
</div>
<div class="hero-content">
<p class="hero-eyebrow">Heirloom Natural Materials</p>
<h1 class="hero-headline">Where Earth<br>Meets Wall</h1>
<p class="hero-sub">Linen. Jute. Raffia. Sisal. Hemp. Grasscloth.<br>The complete collection of natural-fiber wallcoverings, curated for biophilic interiors.</p>
<a href="#catalog" class="hero-cta">Explore the Collection</a>
</div>
</section>
` : ''}
<div id="catalog" class="catalog-wrap">
<div class="catalog-meta" id="catalogMeta">
<span class="loading-text">Loading collection…</span>
</div>
<div class="product-grid" id="productGrid"></div>
<div class="grid-sentinel" id="sentinel"></div>
<div class="grid-end" id="gridEnd" style="display:none">— end of archive · <span id="totalCount">0</span> patterns —</div>
<div class="grid-loader" id="gridLoader">
<div class="loader-dots"><span></span><span></span><span></span></div>
</div>
</div>
<footer class="site-footer">
<div class="footer-inner">
<div class="footer-brand">
<span class="footer-name">Natural Textile Wallpaper</span>
<span class="footer-tagline">Curated natural-fiber wallcoverings for biophilic interiors</span>
</div>
<div class="footer-links">
<a href="mailto:${SITE_EMAIL}">${SITE_EMAIL}</a>
<a href="${DW}" target="_blank" rel="noopener noreferrer">Designer Wallcoverings</a>
<a href="${DW}/pages/trade-program" target="_blank" rel="noopener noreferrer">Trade Program</a>
<a href="${DW}/pages/contact" target="_blank" rel="noopener noreferrer">Contact</a>
</div>
<p class="footer-legal">All patterns and images courtesy of <a href="${DW}" target="_blank" rel="noopener noreferrer">designerwallcoverings.com</a>. Free memo samples available on every product.</p>
</div>
</footer>
<script>
// ---- Dark/Light toggle ----
(function(){
var btn=document.getElementById('theme-toggle');if(!btn)return;
function sync(){var t=document.documentElement.getAttribute('data-theme');btn.setAttribute('aria-pressed',t==='dark'?'true':'false');}
sync();
btn.addEventListener('click',function(){
var cur=document.documentElement.getAttribute('data-theme')||'light';
var nxt=cur==='dark'?'light':'dark';
document.documentElement.setAttribute('data-theme',nxt);
try{localStorage.setItem('ntw_theme',nxt);}catch(e){}
sync();
});
})();
// ---- Density slider ----
(function(){
var slider=document.getElementById('densitySlider');if(!slider)return;
var saved=localStorage.getItem('ntw_density');
if(saved)slider.value=saved;
function applyDensity(v){document.documentElement.style.setProperty('--card-min',''+v+'px');}
applyDensity(slider.value);
slider.addEventListener('input',function(){applyDensity(this.value);localStorage.setItem('ntw_density',this.value);});
})();
// ---- Infinite scroll catalog ----
var NTW = {
page: 1,
pages: 999,
total: 0,
loading: false,
exhausted: false,
q: ${JSON.stringify(q)},
material: ${JSON.stringify(material)},
sort: ${JSON.stringify(sort)}
};
function productCard(p) {
var priceStr = '';
if (p.max_price > 0) priceStr = '<span class="card-price">From $' + p.min_price.toFixed(2) + '</span>';
var matLabel = ${JSON.stringify(MATERIALS.reduce((a,m)=>({...a,[m.key]:m.label}),{}))};
var mat = matLabel[p.material] || p.material || '';
return '<a href="/p/'+encodeURIComponent(p.slug||p.handle)+'" class="product-card">'
+ '<div class="card-img-wrap"><img src="'+p.image_url+'" alt="'+escHtml(p.title)+'" loading="lazy" class="card-img"></div>'
+ '<div class="card-info">'
+ '<span class="card-material">'+mat+'</span>'
+ '<span class="card-title">'+escHtml(p.title)+'</span>'
+ priceStr
+ '</div>'
+ '<a href="/buy/'+encodeURIComponent(p.slug||p.handle)+'?sample=1" class="sample-cta" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">Order Memo Sample (free)</a>'
+ '</a>';
}
function escHtml(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
function loadPage() {
if (NTW.loading || NTW.exhausted) return;
NTW.loading = true;
document.getElementById('gridLoader').style.display = 'flex';
var params = new URLSearchParams({
q: NTW.q,
material: NTW.material,
sort: NTW.sort,
page: NTW.page,
limit: 24
});
fetch('/api/products?' + params.toString())
.then(function(r){return r.json();})
.then(function(data){
NTW.pages = data.pages;
NTW.total = data.total;
document.getElementById('totalCount').textContent = data.total;
document.getElementById('catalogMeta').textContent = data.total + ' patterns' + (NTW.q ? ' for "' + NTW.q + '"' : '') + (NTW.material!=='all' ? ' in ' + NTW.material : '');
var grid = document.getElementById('productGrid');
data.items.forEach(function(p){ grid.insertAdjacentHTML('beforeend', productCard(p)); });
NTW.page++;
NTW.loading = false;
document.getElementById('gridLoader').style.display = 'none';
if (NTW.page > NTW.pages) {
NTW.exhausted = true;
document.getElementById('sentinel').style.display = 'none';
document.getElementById('gridEnd').style.display = 'block';
}
})
.catch(function(){NTW.loading=false;document.getElementById('gridLoader').style.display='none';});
}
// Restore sort from localStorage — sort is server-driven, so if the
// saved value differs from what the server rendered, reload with it
// BEFORE the first grid paints. Returns true if a redirect was issued.
function hydrateSort(){
var sel=document.getElementById('sortSelect');if(!sel)return false;
var saved=localStorage.getItem('ntw_sort');
sel.addEventListener('change',function(){localStorage.setItem('ntw_sort',this.value);});
if(saved && saved!==NTW.sort){
var has=Array.prototype.some.call(sel.options,function(o){return o.value===saved;});
if(has){
var u=new URL(window.location.href);
u.searchParams.set('sort',saved);
window.location.replace(u.toString());
return true;
}
}
return false;
}
// IntersectionObserver sentinel — only wire up + initial load if we are
// NOT about to redirect to the saved sort.
if(!hydrateSort()){
var obs = new IntersectionObserver(function(entries){
if (entries[0].isIntersecting) loadPage();
}, { rootMargin: '600px 0px' });
obs.observe(document.getElementById('sentinel'));
loadPage(); // initial load
}
</script>
<!-- Contact modal — invisible until nsContactOpen() adds .show class. Added by fanout-12. -->
<div class="ns-modal" id="nsContactModal" role="dialog" aria-modal="true" aria-labelledby="nsContactTitle" onclick="if(event.target===this)nsContactClose()">
<div class="ns-modal-card">
<button type="button" class="close-x" onclick="nsContactClose()" aria-label="Close">×</button>
<h3 id="nsContactTitle">How can we help?</h3>
<p class="sub">Trade-account replies within one business day.</p>
<div class="ns-actions">
<a class="ns-action" href="mailto:info@naturaltextilewallpaper.com?subject=Naturaltextilewallpaper%20inquiry"><span class="ico">✉</span><div class="txt"><b>Email Us</b><span>info@naturaltextilewallpaper.com</span></div></a>
<a class="ns-action" href="tel:888-373-4564"><span class="ico">📞</span><div class="txt"><b>Call Us</b><span>(888) 373-4564 · M–F, 9–5 PT</span></div></a>
</div>
</div>
</div>
<script>
if (typeof window.nsContactOpen !== 'function') {
window.nsContactOpen = function () { var m = document.getElementById('nsContactModal'); if (m) m.classList.add('show'); };
window.nsContactClose = function () { var m = document.getElementById('nsContactModal'); if (m) m.classList.remove('show'); };
}
</script>
</body>
</html>`;
}
function renderDetail(p, matLabel) {
const buySlug = encodeURIComponent(redactVendorSlug(p.handle));
const dw_url = `/buy/${buySlug}`;
const sample_url = `/buy/${buySlug}?sample=1`;
const images = (p.images && p.images.length > 1 ? p.images : [p.image_url]).map(IMGP.proxyImg);
return headHTML(
`${p.title} — Free Memo Sample | Natural Textile Wallpaper`,
`Explore ${p.title} — a ${matLabel} wallcovering available with free memo samples and trade service from Designer Wallcoverings. Order yours today.`,
`https://naturaltextilewallpaper.com/p/${encodeURIComponent(redactVendorSlug(p.handle))}`,
images[0] || ''
) + headerHTML() + `
<div class="detail-wrap">
<nav class="breadcrumb">
<a href="/">Collection</a> / <a href="/?material=${p.material}">${matLabel}</a> / <span>${escHtml(p.title)}</span>
</nav>
<div class="detail-grid">
<div class="detail-images">
<img src="${escHtml(images[0])}" alt="${escHtml(p.title)}" class="detail-hero-img" id="detailMainImg">
${images.length > 1 ? `<div class="detail-thumbs">${images.map((img,i)=>`<img src="${escHtml(img)}" alt="View ${i+1}" class="detail-thumb" onclick="document.getElementById('detailMainImg').src='${escHtml(img)}';">`).join('')}</div>` : ''}
</div>
<div class="detail-info">
<span class="detail-material-pill">${matLabel}</span>
<h1 class="detail-title">${escHtml(p.title)}</h1>
<p class="detail-vendor">by Designer Wallcoverings</p>
${p.max_price > 0 ? `<p class="detail-price">From <strong>$${Number(p.min_price).toFixed(2)}</strong></p>` : ''}
<div class="detail-ctas">
<a href="${sample_url}" class="btn-primary" target="_blank" rel="noopener noreferrer">Order Memo Sample (free)</a>
<a href="${dw_url}" class="btn-secondary" target="_blank" rel="noopener noreferrer">View on Designer Wallcoverings</a>
</div>
<div class="detail-spec-panel">
<p class="spec-label">MATERIAL</p><p class="spec-value">${matLabel}</p>
${p.product_type ? `<p class="spec-label">TYPE</p><p class="spec-value">${escHtml(p.product_type)}</p>` : ''}
${p.tags && p.tags.length ? `<p class="spec-label">TAGS</p><p class="spec-value spec-tags">${p.tags.slice(0,12).map(t=>`<span class="spec-tag">${escHtml(t)}</span>`).join('')}</p>` : ''}
<p class="spec-label">SKU</p><p class="spec-value">${escHtml(redactVendorSlug(p.sku||p.handle))}</p>
</div>
<p class="detail-sample-note">Free memo samples ship within 2–3 business days. No commitment required. Designer Wallcoverings offers white-glove trade service for all natural-fiber installations.</p>
</div>
</div>
<div class="detail-back"><a href="/?material=${p.material}" class="back-link">← Back to ${matLabel}</a></div>
</div>
<footer class="site-footer">
<div class="footer-inner">
<div class="footer-brand">
<span class="footer-name">Natural Textile Wallpaper</span>
<span class="footer-tagline">Curated by Designer Wallcoverings</span>
</div>
<div class="footer-links">
<a href="mailto:${SITE_EMAIL}">${SITE_EMAIL}</a>
<a href="${DW}" target="_blank" rel="noopener noreferrer">Designer Wallcoverings</a>
</div>
</div>
</footer>
<script>
(function(){var btn=document.getElementById('theme-toggle');if(!btn)return;function sync(){var t=document.documentElement.getAttribute('data-theme');btn.setAttribute('aria-pressed',t==='dark'?'true':'false');}sync();btn.addEventListener('click',function(){var cur=document.documentElement.getAttribute('data-theme')||'light';var nxt=cur==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',nxt);try{localStorage.setItem('ntw_theme',nxt);}catch(e){}sync();});})();
</script>
<!-- Contact modal — invisible until nsContactOpen() adds .show class. Added by fanout-12. -->
<div class="ns-modal" id="nsContactModal" role="dialog" aria-modal="true" aria-labelledby="nsContactTitle" onclick="if(event.target===this)nsContactClose()">
<div class="ns-modal-card">
<button type="button" class="close-x" onclick="nsContactClose()" aria-label="Close">×</button>
<h3 id="nsContactTitle">How can we help?</h3>
<p class="sub">Trade-account replies within one business day.</p>
<div class="ns-actions">
<a class="ns-action" href="mailto:info@naturaltextilewallpaper.com?subject=Naturaltextilewallpaper%20inquiry"><span class="ico">✉</span><div class="txt"><b>Email Us</b><span>info@naturaltextilewallpaper.com</span></div></a>
<a class="ns-action" href="tel:888-373-4564"><span class="ico">📞</span><div class="txt"><b>Call Us</b><span>(888) 373-4564 · M–F, 9–5 PT</span></div></a>
</div>
</div>
</div>
<script>
if (typeof window.nsContactOpen !== 'function') {
window.nsContactOpen = function () { var m = document.getElementById('nsContactModal'); if (m) m.classList.add('show'); };
window.nsContactClose = function () { var m = document.getElementById('nsContactModal'); if (m) m.classList.remove('show'); };
}
</script>
</body>
</html>`;
}
function page404() {
return headHTML('Not Found | Natural Textile Wallpaper', 'Page not found') + `
<div style="text-align:center;padding:80px 20px">
<h1>Pattern not found</h1>
<p><a href="/">Browse all natural textile wallcoverings →</a></p>
</div>
<!-- Contact modal — invisible until nsContactOpen() adds .show class. Added by fanout-12. -->
<div class="ns-modal" id="nsContactModal" role="dialog" aria-modal="true" aria-labelledby="nsContactTitle" onclick="if(event.target===this)nsContactClose()">
<div class="ns-modal-card">
<button type="button" class="close-x" onclick="nsContactClose()" aria-label="Close">×</button>
<h3 id="nsContactTitle">How can we help?</h3>
<p class="sub">Trade-account replies within one business day.</p>
<div class="ns-actions">
<a class="ns-action" href="mailto:info@naturaltextilewallpaper.com?subject=Naturaltextilewallpaper%20inquiry"><span class="ico">✉</span><div class="txt"><b>Email Us</b><span>info@naturaltextilewallpaper.com</span></div></a>
<a class="ns-action" href="tel:888-373-4564"><span class="ico">📞</span><div class="txt"><b>Call Us</b><span>(888) 373-4564 · M–F, 9–5 PT</span></div></a>
</div>
</div>
</div>
<script>
if (typeof window.nsContactOpen !== 'function') {
window.nsContactOpen = function () { var m = document.getElementById('nsContactModal'); if (m) m.classList.add('show'); };
window.nsContactClose = function () { var m = document.getElementById('nsContactModal'); if (m) m.classList.remove('show'); };
}
</script>
</body></html>`;
}
function escHtml(s) {
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
// --- Admin catalog CRUD — /admin/catalog (basic-auth) + /api/admin/* -----
if (catalog) catalog.mount(app, { siteSlug: SITE_SLUG, rails: SITE_RAILS });
// --- Async startup: load 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.map(normalizeProduct).filter(p => !isJunk(p));
console.log(`[NTW] Loaded ${rows.length} from dw_unified → kept ${PRODUCTS.length} after junk filter`);
loaded = true;
} catch (e) {
console.error(`[NTW] 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 : []).map(normalizeProduct).filter(p => !isJunk(p));
console.log(`[NTW] Loaded ${PRODUCTS.length} from data/products.json (static mode)`);
} catch (_) { PRODUCTS = []; }
}
IMGP.registerAll(PRODUCTS); // build the /img token->CDN map for every product image
console.log(`[NTW] image-proxy map: ${IMGP.size()} urls`);
app.listen(PORT, () => console.log(`[NTW] Running on port ${PORT} — ${PRODUCTS.length} products`));
})();