← back to Carmelwallpapers
server.js
828 lines
'use strict';
try { require('dotenv').config({ path: require('path').join(__dirname, '.env') }); } catch (e) {}
const express = require('express');
const fs = require('fs');
const path = require('path');
const { createImgProxy } = require('../_shared/image-proxy');
const IMGP = createImgProxy(); // vendor-neutral /img/<token> image proxy
const PORT = process.env.PORT || 9832;
const DATA_PATH = path.join(__dirname, 'data', 'products.json');
// ── Vendor-slug redactor (canonical: dw-domain-fleet shared/render.js @ee30c6a) ─
// Vendor names leak into the product HANDLE, which surfaces in /p/<slug> hrefs.
// redactVendorSlug strips any hyphen-delimited segment carrying a 3rd-party vendor
// token. Used to derive each product's `slug` (the /p/ route key) + the dual-key
// resolver below. The REAL p.handle is left intact for the main-store buy/sample
// links (they legitimately point at designerwallcoverings.com/products/<handle>).
// House brands carry no denylist token, so they pass through untouched. Longer
// vendor names precede their prefixes ("lee jofa modern" before "lee jofa") so the
// longer match strips first.
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',
];
// 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); // never empty (route key must be non-empty)
}
// ── Shared catalog: products live in dw_unified.microsite_products ───────────
// 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(`[carmelwallpapers] 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 || path.basename(__dirname);
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). Naive
// `p.aesthetic === key` matching collapses rails when the aesthetic field is
// sparse — the richer signal lives in `tags`. Prefer the shared matcher on dev;
// fall back to a self-contained copy on prod where _shared/ 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;
let RAIL_SYN = siteCfg.railSynonyms || {};
if (!siteCfg.railSynonyms) { try { RAIL_SYN = require('../_shared/rail-synonyms.json'); } catch (e) {} }
// The PG read-path returns the canonical microsite_products columns. The Carmel
// templates also expect `sample_url` + `images` (legacy products.json fields),
// so derive them: sample = product page anchored to #sample; images = [image_url].
function shapeForSite(p) {
const productUrl = p.product_url
|| (p.handle ? `https://designerwallcoverings.com/products/${p.handle}` : '#');
return {
...p,
product_url: productUrl,
sample_url: p.sample_url || `${productUrl}#sample`,
images: Array.isArray(p.images) && p.images.length ? p.images
: (p.image_url ? [p.image_url] : []),
// Vendor-redacted /p/ route key. Client card templates link via p.slug (not
// p.handle) so vendor tokens never reach the browser URL; the REAL p.handle
// stays on the object for the dual-key resolver + main-store buy links.
slug: redactVendorSlug(p.handle),
};
}
// ── Junk filter ──────────────────────────────────────────────────────────────
const JUNK_TITLE = /(lamp|rug|pillow|throw|tripod|frame|mirror|vase|candle|sculpture|figurine|stool|table|chair|sofa|curtain|bedding|towel|plate|bowl|mug)/i;
function isJunk(p) {
if (!p.image_url) return true;
if (!p.product_type || !/wallcovering/i.test(p.product_type)) return true;
if (JUNK_TITLE.test(p.title || '')) return true;
return false;
}
// ── Sort helpers ─────────────────────────────────────────────────────────────
const COLOR_RANK = {
black:0,gray:1,grey:1,silver:2,white:3,ivory:4,cream:5,beige:6,brown:7,tan:8,sand:9,stone:10,
copper:11,driftwood:12,taupe:13,oatmeal:14,linen:15,parchment:16,wheat:17,
red:20,pink:21,coral:22,orange:23,peach:24,salmon:25,
yellow:30,gold:31,mustard:32,olive:33,sage:34,
green:40,lime:41,mint:42,seafoam:43,teal:50,aqua:51,turquoise:52,cyan:53,
blue:60,navy:61,indigo:62,periwinkle:63,
purple:70,violet:71,lavender:72,plum:73,
};
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 = ['grasscloth','linen','raffia','sisal','seagrass','jute','coastal','botanical','woven','natural','organic','bamboo','rattan','texture'];
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 === 'vendor') return [...list].sort((a,b) => String(a.vendor || '').localeCompare(String(b.vendor || '')) || 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;
}
// ── Load + filter data ───────────────────────────────────────────────────────
let PRODUCTS = [];
let DROPPED = 0;
let AESTHETICS = {};
function applyProducts(raw) {
const clean = raw.map(shapeForSite).filter(p => !isJunk(p));
DROPPED = raw.length - clean.length;
PRODUCTS = clean;
AESTHETICS = {};
for (const p of PRODUCTS) {
AESTHETICS[p.aesthetic] = (AESTHETICS[p.aesthetic] || 0) + 1;
}
// (Re)build the /img token->CDN map whenever products (re)load — covers the
// admin-catalog runtime reload path, not just boot. Map is additive (set), so
// re-registering is idempotent and never strands a previously-issued token.
IMGP.registerAll(PRODUCTS);
}
function loadFromJson() {
const raw = JSON.parse(fs.readFileSync(DATA_PATH, 'utf8'));
applyProducts(Array.isArray(raw) ? raw : []);
}
// PG-first load when ADMIN_ENABLED, else static-JSON only.
async function loadData() {
if (catalog) {
try {
const rows = await catalog.getProducts(SITE_SLUG);
applyProducts(rows);
console.log(`Loaded ${PRODUCTS.length} products from dw_unified (dropped ${DROPPED} junk)`);
return;
} catch (e) {
console.error(`PG catalog load failed (${e.message}) — falling back to data/products.json`);
}
}
try {
loadFromJson();
console.log(`Loaded ${PRODUCTS.length} products from data/products.json (static mode, dropped ${DROPPED} junk)`);
} catch (e2) {
console.error('Failed to load products.json:', e2.message);
PRODUCTS = [];
}
}
const app = express();
app.set('trust proxy', 1);
// ── Static guard: never serve snapshot files (*.bak / *.pre-*) ────────────────
app.use((req, res, next) => {
if (/\.(bak|bak\.[^/]*)$/i.test(req.path) || /\.pre-[^/]*$/i.test(req.path)) {
return res.status(404).send('<h1>Not found</h1>');
}
next();
});
// 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'));
require('../_shared/_universal-promo-banner')(app, { slug: 'carmelwallpapers' });
app.use(express.static(path.join(__dirname, 'public')));
// Vendor-neutral image proxy: GET /img/<token> streams the real CDN bytes so
// the 3rd-party vendor filename never reaches the client. (see _shared/image-proxy.js)
IMGP.mount(app);
// ── HTML helpers ─────────────────────────────────────────────────────────────
const HTML_HEAD = (title, desc, canonical='', ogImage='') => `<!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('cw_theme')||((window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light');document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>
<!-- GA4 -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-ENN0QV40F8"></script>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','G-ENN0QV40F8');</script>
<title>${title}</title>
<meta name="description" content="${desc}">
${canonical?`<link rel="canonical" href="${canonical}">\n`:''}<meta property="og:type" content="website">
<meta property="og:site_name" content="Carmel Wallpapers">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${desc}">
${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="#f7f3ec" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#12100d" media="(prefers-color-scheme: dark)">
<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=Inter:wght@300;400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/site.css">
<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>`;
const THEME_TOGGLE_SCRIPT = `<script>
(function(){
var btn=document.getElementById('theme-toggle');if(!btn)return;
function sync(){var t=document.documentElement.getAttribute('data-theme')||'light';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('cw_theme',nxt);}catch(e){}
sync();
});
})();
</script>`;
const SUN_MOON_SVG = `<button type="button" id="theme-toggle" class="theme-toggle" aria-label="Toggle dark mode" aria-pressed="false" title="Toggle dark / light">
<svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"/></svg>
</button>`;
const HEADER_HTML = (q, sort, density) => `
<header class="site-header">
<a href="/" class="brand">CARMEL<small>Wallpapers for the California Coast</small></a>
<nav class="site-nav">
<a href="/?type=grasscloth">Grasscloth</a>
<a href="/?type=linen">Linen</a>
<a href="/?type=botanical">Botanical</a>
<a href="/?type=coastal">Coastal</a>
<a href="/?type=natural">Natural</a>
<a href="mailto:info@carmelwallpapers.com" class="nav-contact">Contact</a>
${SUN_MOON_SVG}
</nav>
</header>`;
// ── API: products (infinite scroll) ──────────────────────────────────────────
app.get('/api/products', (req, res) => {
const { q, type, 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 hay = ((p.title||'') + ' ' + (p.vendor||'') + ' ' + (p.tags||[]).join(' ')).toLowerCase();
return tokens.every(tok => hay.includes(tok));
});
}
if (type && type !== 'all') {
list = list.filter(p => productInRail(p, type, RAIL_SYN));
}
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;
const items = list.slice(start, start+lim).map(p => ({ ...IMGP.scrubProduct(p), slug: p.slug || redactVendorSlug(p.handle) }));
res.json({ total, page: pageNum, limit: lim, pages: Math.ceil(total/lim), sort, items });
});
// ── API: sliders ─────────────────────────────────────────────────────────────
app.get('/api/sliders', (req, res) => {
const RAIL_ORDER = ['grasscloth','linen','botanical','coastal','raffia','natural'];
// Multi-membership rails via aesthetic-OR-tags + hybrid share gate (was naive
// p.aesthetic === a, which collapsed to empty on a sparse aesthetic field).
const rails = buildRails(PRODUCTS, RAIL_ORDER, { synonyms: RAIL_SYN });
res.json({ rails: rails.map(r => ({ aesthetic: r.aesthetic, items: r.items.map(IMGP.scrubProduct) })) });
});
// ── Health ────────────────────────────────────────────────────────────────────
app.get('/health', (req, res) => {
res.json({ ok: true, products: PRODUCTS.length, dropped: DROPPED, aesthetics: AESTHETICS });
});
// ── Product detail ────────────────────────────────────────────────────────────
// /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('<h1>Not found</h1>');
const dest = p.product_url || `https://designerwallcoverings.com/products/${p.handle}`;
res.redirect(302, dest + (req.query.sample != null ? '#sample' : ''));
});
app.get('/p/:handle', (req, res) => {
// Dual-key: new redacted-slug URLs AND legacy real-handle URLs both resolve.
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('<h1>Not found</h1>');
const title = `${p.title} — Carmel Wallpapers | Free Memo Samples, Trade Service`;
const desc = `${p.title} by Designer Wallcoverings. Order a free memo sample. Coastal California's curated wallpaper source — expert trade service, hand-holding from Carmel to Pebble Beach.`;
const imgs = (p.images||[p.image_url]).filter(Boolean).map(IMGP.proxyImg);
const imgHtml = imgs.map((src,i) =>
`<img src="${src}" alt="${p.title}${i?` view ${i+1}`:''}${i===0?' (main)':''}" loading="${i===0?'eager':'lazy'}" class="detail-img${i===0?' detail-img--main':''}">`
).join('\n');
const tags = (p.tags||[]).filter(t => !/^\d+$/.test(t));
const canonical = `https://carmelwallpapers.com/p/${encodeURIComponent(redactVendorSlug(p.handle))}`;
res.send(`${HTML_HEAD(title, desc, canonical, imgs[0]||'')}
<body>
${HEADER_HTML()}
<main class="detail-wrap">
<a href="/" class="back-link">← Back to collection</a>
<div class="detail-grid">
<div class="detail-images">${imgHtml}</div>
<div class="detail-info">
<p class="detail-vendor">Designer Wallcoverings</p>
<h1 class="detail-title">${p.title}</h1>
<div class="detail-tags">${tags.slice(0,10).map(t=>`<span class="tag">${t}</span>`).join('')}</div>
<div class="detail-ctas">
<a href="/buy/${p.slug}?sample" class="btn btn-sample" target="_blank" rel="noopener noreferrer">Order Memo Sample (free)</a>
<a href="/buy/${p.slug}" class="btn btn-full" target="_blank" rel="noopener noreferrer">View Full Product</a>
</div>
<div class="detail-service">
<h3>Carmel Trade Service</h3>
<p>Designer Wallcoverings provides complimentary trade hand-holding for every Carmel and Pebble Beach project — from sampling through installation. <a href="mailto:info@carmelwallpapers.com">Reach us at info@carmelwallpapers.com</a>.</p>
</div>
</div>
</div>
</main>
<footer class="site-footer">
<div class="footer-inner">
<p>Carmel Wallpapers — a Designer Wallcoverings service</p>
<p><a href="mailto:info@carmelwallpapers.com">info@carmelwallpapers.com</a></p>
<p class="footer-fine">Serving Carmel-by-the-Sea, Pebble Beach & Big Sur</p>
</div>
</footer>
<script type="application/ld+json">${JSON.stringify({
"@context":"https://schema.org","@type":"Product","name":p.title,"brand":{"@type":"Brand","name":"Designer Wallcoverings"},
"image":imgs[0]||'',
"offers":{"@type":"Offer","priceCurrency":"USD","price":"0","description":"Free memo sample"},
"description":desc
})}</script>
${THEME_TOGGLE_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@carmelwallpapers.com?subject=Carmelwallpapers%20inquiry"><span class="ico">✉</span><div class="txt"><b>Email Us</b><span>info@carmelwallpapers.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>`);
});
// ── Index (catalog) ──────────────────────────────────────────────────────────
app.get('/', (req, res) => {
const { q='', type='', sort='newest', density='220' } = req.query;
const pageTitle = q
? `"${q}" — Carmel Wallpapers`
: type
? `${type.charAt(0).toUpperCase()+type.slice(1)} Wallpapers — Carmel, Pebble Beach & Big Sur`
: 'Carmel Wallpapers — Coastal California Luxury Wallcoverings';
const pageDesc = 'Curated grasscloth, linen, raffia and coastal-botanical wallcoverings for Carmel-by-the-Sea, Pebble Beach and Big Sur residences. Free memo samples. Expert trade service from Designer Wallcoverings.';
// Facet counts for filter chips
const aestheticCounts = {};
for (const p of PRODUCTS) {
aestheticCounts[p.aesthetic] = (aestheticCounts[p.aesthetic]||0)+1;
}
const aestheticChips = ['grasscloth','linen','botanical','coastal','raffia','natural']
.filter(a => aestheticCounts[a])
.map(a => {
const active = type === a ? ' chip--active' : '';
const href = type === a ? '/' : `/?type=${a}${q?'&q='+encodeURIComponent(q):''}`;
return `<a href="${href}" class="chip${active}">${a.charAt(0).toUpperCase()+a.slice(1)} <span class="chip-count">${aestheticCounts[a]}</span></a>`;
}).join('');
const canonical = 'https://carmelwallpapers.com/' + (type ? `?type=${encodeURIComponent(type)}` : '');
const ogImg = (PRODUCTS[0] && PRODUCTS[0].image_url) ? IMGP.proxyImg(PRODUCTS[0].image_url) : '';
res.send(`${HTML_HEAD(pageTitle, pageDesc, canonical, ogImg)}
<body>
${HEADER_HTML(q, sort, density)}
<section class="hero" aria-label="Collection introduction">
<div class="hero-text">
<h1 class="hero-headline">CARMEL</h1>
<p class="hero-sub">Quiet luxury for the California coast.</p>
<p class="hero-body">Natural fibers. Coastal botanicals. The restraint of Carmel-by-the-Sea captured in texture and tone — curated from the world's finest wallcovering houses.</p>
<a href="#catalog" class="hero-cta">Explore the Collection</a>
</div>
</section>
<section class="sliders-section" id="sliders" aria-label="Browse by aesthetic">
<div class="sliders-wrap">
<div id="sliders-container"></div>
</div>
</section>
<section class="catalog-section" id="catalog">
<div class="catalog-controls">
<form class="search-form" method="GET" action="/" role="search">
${type?`<input type="hidden" name="type" value="${type}">`:''}
<input type="search" name="q" value="${q.replace(/"/g,'"')}" placeholder="Search ${PRODUCTS.length.toLocaleString()} wallcoverings…" class="search-input" aria-label="Search wallcoverings">
<button type="submit" class="search-btn" aria-label="Search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
</button>
</form>
<div class="sort-density">
<select id="sortSelect" class="sort-select" aria-label="Sort products">
<option value="newest" ${sort==='newest'?'selected':''}>Newest</option>
<option value="color" ${sort==='color'?'selected':''}>Color</option>
<option value="style" ${sort==='style'?'selected':''}>Style</option>
<option value="sku" ${sort==='sku'?'selected':''}>SKU A→Z</option>
<option value="title" ${sort==='title'?'selected':''}>Title A→Z</option>
</select>
<label class="density-label" aria-label="Grid density">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><rect x="0" y="0" width="4" height="4"/><rect x="6" y="0" width="4" height="4"/><rect x="12" y="0" width="4" height="4"/><rect x="0" y="6" width="4" height="4"/><rect x="6" y="6" width="4" height="4"/><rect x="12" y="6" width="4" height="4"/></svg>
<input type="range" id="densitySlider" min="140" max="320" step="20" value="${density}" aria-label="Grid column width">
</label>
</div>
</div>
<div class="facet-chips" aria-label="Filter by aesthetic">
<a href="/${q?'?q='+encodeURIComponent(q):''}" class="chip${!type?' chip--active':''}">All <span class="chip-count">${PRODUCTS.length}</span></a>
${aestheticChips}
</div>
<div id="grid" class="product-grid" role="list" style="--cols:${density}px"></div>
<div id="sentinel" class="sentinel" aria-hidden="true"></div>
<div id="grid-status" class="grid-status"></div>
</section>
<footer class="site-footer">
<div class="footer-inner">
<div class="footer-col">
<strong>Carmel Wallpapers</strong>
<p>A Designer Wallcoverings service for the Carmel Coast.</p>
<p><a href="mailto:info@carmelwallpapers.com">info@carmelwallpapers.com</a></p>
</div>
<div class="footer-col">
<strong>Our Service</strong>
<ul>
<li>Free memo samples on every pattern</li>
<li>Trade pricing available</li>
<li>Installation guidance</li>
<li>Serving Carmel, Pebble Beach & Big Sur</li>
</ul>
</div>
<div class="footer-col">
<strong>Aesthetics</strong>
<ul>
${['grasscloth','linen','botanical','coastal','natural'].map(a=>`<li><a href="/?type=${a}">${a.charAt(0).toUpperCase()+a.slice(1)}</a></li>`).join('')}
</ul>
</div>
</div>
<p class="footer-legal">© 2026 Carmel Wallpapers · Curated by Designer Wallcoverings · <a href="mailto:info@carmelwallpapers.com">info@carmelwallpapers.com</a></p>
</footer>
<script type="application/ld+json">${JSON.stringify({
"@context":"https://schema.org",
"@type":"LocalBusiness",
"name":"Carmel Wallpapers",
"description":pageDesc,
"url":"https://carmelwallpapers.com",
"email":"info@carmelwallpapers.com",
"areaServed":["Carmel-by-the-Sea","Pebble Beach","Big Sur","Monterey County"],
"sameAs":["https://designerwallcoverings.com"]
})}</script>
<script>
// ── State ────────────────────────────────────────────────────────────────────
const STATE = {
q: ${JSON.stringify(q)},
type: ${JSON.stringify(type)},
sort: ${JSON.stringify(sort)},
page: 1,
pages: 1,
total: 0,
loading: false,
exhausted: false
};
// ── Persist sort + density ───────────────────────────────────────────────────
const sortSel = document.getElementById('sortSelect');
const densSlider = document.getElementById('densitySlider');
const grid = document.getElementById('grid');
let savedSort = localStorage.getItem('cw_sort');
if (savedSort === 'vendor') { savedSort = null; try { localStorage.removeItem('cw_sort'); } catch(e){} }
const savedDensity = localStorage.getItem('cw_density');
const validSortValues = new Set(['newest','color','style','sku','title']);
if(savedSort && validSortValues.has(savedSort) && !${JSON.stringify(!!req.query.sort)}) { sortSel.value = savedSort; STATE.sort = savedSort; }
if(savedDensity) { densSlider.value = savedDensity; grid.style.setProperty('--cols', savedDensity+'px'); }
sortSel.addEventListener('change', () => {
STATE.sort = sortSel.value;
localStorage.setItem('cw_sort', STATE.sort);
const url = new URL(location.href);
url.searchParams.set('sort', STATE.sort);
location.href = url.toString();
});
densSlider.addEventListener('input', () => {
const v = densSlider.value;
grid.style.setProperty('--cols', v+'px');
localStorage.setItem('cw_density', v);
});
// ── Card renderer ────────────────────────────────────────────────────────────
function renderCard(p) {
const div = document.createElement('div');
div.className = 'product-card';
div.setAttribute('role','listitem');
div.innerHTML = \`
<a href="/p/\${p.slug || p.handle}" class="card-link" aria-label="\${p.title.replace(/"/g,'"')}">
<div class="card-img-wrap">
<img src="\${p.image_url}" alt="\${p.title.replace(/"/g,'"')}" loading="lazy" class="card-img">
</div>
<div class="card-body">
<p class="card-vendor">Designer Wallcoverings</p>
<p class="card-title">\${p.title}</p>
<p class="card-aesthetic">\${p.aesthetic}</p>
</div>
</a>
<a href="/buy/\${p.slug||p.handle}?sample" class="card-sample" target="_blank" rel="noopener noreferrer" aria-label="Order free memo sample of \${p.title.replace(/"/g,'"')}">Order Memo Sample (free)</a>
\`;
return div;
}
// ── Load page ────────────────────────────────────────────────────────────────
async function loadPage() {
if (STATE.loading || STATE.exhausted) return;
STATE.loading = true;
const status = document.getElementById('grid-status');
status.textContent = 'Loading…';
const params = new URLSearchParams({ sort: STATE.sort, page: STATE.page, limit: 24 });
if (STATE.q) params.set('q', STATE.q);
if (STATE.type) params.set('type', STATE.type);
try {
const res = await fetch('/api/products?' + params);
const data = await res.json();
STATE.pages = data.pages;
STATE.total = data.total;
if (STATE.page === 1) {
status.textContent = \`\${data.total.toLocaleString()} wallcoverings\`;
}
for (const p of data.items) grid.appendChild(renderCard(p));
if (STATE.page >= data.pages) {
STATE.exhausted = true;
status.textContent = \`— end of archive · \${data.total.toLocaleString()} patterns —\`;
} else {
STATE.page++;
status.textContent = \`\${data.total.toLocaleString()} wallcoverings · \${STATE.page-1} of \${data.pages} pages loaded\`;
}
} catch(e) {
status.textContent = 'Error loading products.';
console.error(e);
}
STATE.loading = false;
}
// ── Infinite scroll ──────────────────────────────────────────────────────────
const sentinel = document.getElementById('sentinel');
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) loadPage();
}, { rootMargin: '600px 0px' });
observer.observe(sentinel);
// ── Grid sliders ─────────────────────────────────────────────────────────────
async function loadSliders() {
try {
const res = await fetch('/api/sliders');
const data = await res.json();
const container = document.getElementById('sliders-container');
if (!data.rails || !data.rails.length) { container.closest('.sliders-section').style.display='none'; return; }
for (const rail of data.rails) {
const label = rail.aesthetic.charAt(0).toUpperCase() + rail.aesthetic.slice(1);
const trackId = 'track-' + rail.aesthetic;
const section = document.createElement('div');
section.className = 'rail';
section.innerHTML = \`
<div class="rail-header">
<h2 class="rail-title"><a href="/?type=\${rail.aesthetic}" class="rail-link">\${label}</a></h2>
<div class="rail-controls">
<button class="rail-btn rail-prev" aria-label="Scroll \${label} left">←</button>
<button class="rail-btn rail-next" aria-label="Scroll \${label} right">→</button>
</div>
</div>
<div class="rail-track" id="\${trackId}">
\${rail.items.map(p=>\`
<a href="/p/\${p.slug || p.handle}" class="rail-card" aria-label="\${(p.title||'').replace(/"/g,'"')}">
<div class="rail-img-wrap"><img src="\${p.image_url}" alt="\${(p.title||'').replace(/"/g,'"')}" loading="lazy" class="rail-img"></div>
<p class="rail-card-title">\${p.title}</p>
<p class="rail-card-vendor">Designer Wallcoverings</p>
</a>
\`).join('')}
</div>
\`;
const track = section.querySelector('.rail-track');
section.querySelector('.rail-prev').addEventListener('click', () => track.scrollBy({ left: -track.clientWidth*.85, behavior:'smooth' }));
section.querySelector('.rail-next').addEventListener('click', () => track.scrollBy({ left: track.clientWidth*.85, behavior:'smooth' }));
container.appendChild(section);
}
} catch(e) { console.error('Sliders error:', e); }
}
loadSliders();
</script>
${THEME_TOGGLE_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@carmelwallpapers.com?subject=Carmelwallpapers%20inquiry"><span class="ico">✉</span><div class="txt"><b>Email Us</b><span>info@carmelwallpapers.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>`);
});
// ── Admin catalog CRUD — /admin/catalog (basic-auth) + /api/admin/* REST ─────
if (catalog) catalog.mount(app, { siteSlug: SITE_SLUG, rails: SITE_RAILS });
// ── Startup: load catalog from PG, then listen ───────────────────────────────
(async () => {
await loadData();
IMGP.registerAll(PRODUCTS); // build the /img token->CDN map for every product image (idempotent; applyProducts also registers)
console.log(`[carmel] image-proxy map: ${IMGP.size()} urls`);
app.listen(PORT, () => console.log(`carmelwallpapers listening on :${PORT}`));
})();