← back to Customdigitalmurals
server.js
806 lines
/**
* customdigitalmurals.com — Custom digital wall murals storefront.
* Off-White aesthetic: oversized all-caps headlines, sharp grid, bold type.
* 3,663 mural/scenic/digital/panoramic products from dw_unified snapshot.
* DW spec: memo-sample CTA, no specs in body, GA4, dark/light, grid search,
* density slider, Cloudflare-aware, pm2 + systemd.
*/
'use strict';
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 || 9896;
const DATA_FILE = path.join(__dirname, 'data', 'products.json');
/* Vendor-slug redaction — copied verbatim from dw-domain-fleet/shared/render.js
* (commit ee30c6a). The product handle carries 3rd-party vendor tokens
* (e.g. "…-arte-international"); these must never reach a customer-facing URL.
* redactVendorSlug strips any hyphen-delimited segment matching a denylisted
* vendor. The /product/:handle route dual-keys (raw handle OR redacted slug) so
* both old and new URLs resolve. The real handle is KEPT on main-store
* (designerwallcoverings.com) links so they still resolve there. */
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 module — read-path from dw_unified.microsite_products + admin CRUD.
// 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(`[customdigitalmurals] 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 : [];
// Static JSON snapshot — keeps the rich fields (images[], price, body_html) the
// migration into microsite_products didn't carry over, and is the PG-down fallback.
const STATIC = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
const STATIC_BY_HANDLE = new Map(STATIC.map(p => [p.handle, p]));
// ALL is populated async at startup (see bottom). Seed with the static snapshot
// so the very first requests before PG resolves still have data.
let ALL = STATIC;
const DW_BASE = 'https://designerwallcoverings.com';
const GA4_ID = process.env.GA4_MEASUREMENT_ID || '';
const app = express();
// Security + cache headers
app.use((_req, res, next) => {
res.set('X-Content-Type-Options', 'nosniff');
res.set('X-Frame-Options', 'SAMEORIGIN');
res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
res.set('Cache-Control', 'public, max-age=3600, s-maxage=3600');
next();
});
// 404-guard — backup/snapshot files (*.bak, *.bak.*, *.pre-*) must never serve
// from the static root, even if one slips into public/ by accident.
app.use((req, res, next) => {
if (/\.bak(\.|$)|\.pre-|(^|\/)\.pre-/i.test(req.path)) {
return res.status(404).send(page404());
}
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: 'customdigitalmurals' });
app.use(express.static(path.join(__dirname, 'public'), { maxAge: '1d' }));
// Health check (both paths — /health for direct probe, /api/health for fleet standard)
app.get('/health', (_req, res) => res.json({ ok: true, count: ALL.length }));
app.get('/api/health', (_req, res) => res.json({ ok: true, count: ALL.length }));
// ── DW STANDARD sort helpers ─────────────────────────────────────────────
// color bucket — group similar hues from the title + tags + aesthetic
function colorBucket(p) {
const n = (String(p.title || '') + ' ' + (p.tags || []).join(' ') + ' ' + String(p.aesthetic || '')).toLowerCase();
if (/white|ivory|cream|pearl|champagne|opal|oyster|parchment|linen|cloud|dove|fog|mist|snow|bleach/.test(n)) return 0; // white
if (/black|onyx|ebony|charcoal|carbon|graphite|midnight|jet/.test(n)) return 6; // black
if (/gray|grey|pewter|slate|silver|platinum|\bash\b|smoke|stone|shadow|steel/.test(n)) return 5; // gray
if (/green|sage|olive|moss|forest|jade|mint|seafoam|teal|emerald/.test(n)) return 2; // green
if (/blue|navy|\bsky\b|azure|cobalt|indigo|cerulean|aqua/.test(n)) return 3; // blue
if (/plum|mauve|purple|burgundy|blush|pink|lilac|violet|magenta|rose/.test(n)) return 4; // purple/pink
return 1; // warm/brown/tan/gold/rust/red/orange/yellow/etc.
}
// style bucket — group by stylistic family via tag/title match
function styleBucket(p) {
const n = (String(p.title || '') + ' ' + (p.tags || []).join(' ') + ' ' + String(p.product_type || '')).toLowerCase();
if (/traditional|damask|toile|chinoiserie|classic|victorian|baroque|ornate/.test(n)) return 0;
if (/floral|botanical|leaf|leaves|flower|tropical|garden|foliage|vine/.test(n)) return 1;
if (/geometric|stripe|chevron|trellis|lattice|grid|abstract|art deco|modern|contemporary/.test(n)) return 2;
if (/animal|insect|bird|fauna|wildlife|scenic|landscape|mural/.test(n)) return 3;
return 4; // other / textural
}
function sortProducts(list, sort) {
const skuOf = (p) => String(p.handle || '');
const titleOf = (p) => String(p.title || '');
const priceOf = (p) => { const v = parseFloat(p.price); return isNaN(v) ? 0 : v; };
switch (sort) {
case 'newest':
return list; // server's natural order — no re-sort
case 'color':
return [...list].sort((a, b) => { const d = colorBucket(a) - colorBucket(b); return d !== 0 ? d : titleOf(a).localeCompare(titleOf(b)); });
case 'style':
return [...list].sort((a, b) => { const d = styleBucket(a) - styleBucket(b); return d !== 0 ? d : titleOf(a).localeCompare(titleOf(b)); });
case 'sku':
return [...list].sort((a, b) => skuOf(a).localeCompare(skuOf(b), undefined, { numeric: true }));
case 'title':
case 'az': // legacy alias
return [...list].sort((a, b) => titleOf(a).localeCompare(titleOf(b)));
case 'za': // legacy alias (Title Z→A) — kept for old localStorage values
return [...list].sort((a, b) => titleOf(b).localeCompare(titleOf(a)));
case 'price_asc':
return [...list].sort((a, b) => priceOf(a) - priceOf(b));
case 'price_desc':
return [...list].sort((a, b) => priceOf(b) - priceOf(a));
default:
return list; // unknown key → natural order
}
}
// API — search + filter
app.get('/api/products', (req, res) => {
const q = (req.query.q || '').toLowerCase().trim();
const vendor = (req.query.vendor || '').toLowerCase().trim();
const aesthetic = (req.query.aesthetic || '').toLowerCase().trim();
const sort = req.query.sort || 'newest';
const page = Math.max(1, parseInt(req.query.page) || 1);
const per = Math.min(120, Math.max(12, parseInt(req.query.per) || 60));
let results = ALL;
if (q) {
results = results.filter(p =>
p.title.toLowerCase().includes(q) ||
(p.vendor || '').toLowerCase().includes(q) ||
(p.product_type || '').toLowerCase().includes(q) ||
(p.tags || []).some(t => t.toLowerCase().includes(q))
);
}
if (vendor) {
results = results.filter(p => (p.vendor || '').toLowerCase() === vendor);
}
if (aesthetic && aesthetic !== 'all') {
results = results.filter(p => String(p.aesthetic || '').toLowerCase() === aesthetic);
}
// Sort — DW STANDARD sort set (Newest is default / server's natural order)
results = sortProducts(results, sort);
const total = results.length;
const pages = Math.ceil(total / per);
// Expose a vendor-redacted `slug` for the client to build the /product/ href
// with (the raw handle leaks vendor tokens). The real handle stays on the
// object for the designerwallcoverings.com sample link the card also renders.
const slice = results.slice((page - 1) * per, page * per)
.map(p => ({ ...p, slug: redactVendorSlug(p.handle || '') }));
res.json({ total, pages, page, per, products: slice });
});
// Aesthetic rails — used by homepage rail-sections render.
app.get('/api/sliders', (_req, res) => {
const out = [];
for (const a of SITE_RAILS) {
const items = ALL.filter(p => String(p.aesthetic || '').toLowerCase() === a.toLowerCase())
.slice(0, 12)
.map(p => ({ ...p, slug: redactVendorSlug(p.handle || '') })); // vendor-redacted href key for the client
if (items.length >= 4) out.push({ aesthetic: a, items });
}
res.json({ rails: out });
});
// Vendor list (LEGACY — kept for back-compat; new code should use /api/facets).
// Vendor names are admin-only metadata and must NOT render on customer-facing UI.
app.get('/api/vendors', (_req, res) => {
const counts = {};
ALL.forEach(p => { counts[p.vendor] = (counts[p.vendor] || 0) + 1; });
const list = Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.map(([name, count]) => ({ name, count }));
res.json(list);
});
// Facets — counts reflect the CURRENT FILTERED result set (q + vendor), so the
// numbers shown next to each facet option match what will actually be returned
// if the user selects it. NOTE: `vendors` here is admin-only metadata; never
// render vendor names on customer-facing UI (DW rule).
app.get('/api/facets', (req, res) => {
const q = (req.query.q || '').toLowerCase().trim();
const vendor = (req.query.vendor || '').toLowerCase().trim();
let filtered = ALL;
if (q) {
filtered = filtered.filter(p =>
p.title.toLowerCase().includes(q) ||
(p.vendor || '').toLowerCase().includes(q) ||
(p.product_type || '').toLowerCase().includes(q) ||
(p.tags || []).some(t => t.toLowerCase().includes(q))
);
}
if (vendor) {
filtered = filtered.filter(p => (p.vendor || '').toLowerCase() === vendor);
}
const vendors = {};
const productTypes = {};
const aesthetics = {};
filtered.forEach(p => {
if (p.vendor) vendors[p.vendor] = (vendors[p.vendor] || 0) + 1;
if (p.product_type) productTypes[p.product_type] = (productTypes[p.product_type] || 0) + 1;
if (p.aesthetic) aesthetics[String(p.aesthetic).toLowerCase()] = (aesthetics[String(p.aesthetic).toLowerCase()] || 0) + 1;
});
const toList = obj => Object.entries(obj)
.sort((a, b) => b[1] - a[1])
.map(([name, count]) => ({ name, count }));
res.json({
total: filtered.length,
vendors: toList(vendors),
product_types: toList(productTypes),
aesthetics,
});
});
// Single product page
app.get('/product/:handle', (req, res) => {
// Dual-key: resolve by the RAW handle (old URLs) OR the vendor-redacted slug
// (new vendor-clean URLs the cards/rails now link to). Both round-trip.
const key = req.params.handle;
const p = ALL.find(x => x.handle === key) || ALL.find(x => redactVendorSlug(x.handle) === key);
if (!p) return res.status(404).send(page404());
res.send(productPage(p));
});
// /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 = ALL.find(x => x.handle === key) || ALL.find(x => redactVendorSlug(x.handle) === key);
if (!p) return res.status(404).send(page404());
const frag = req.query.sample != null ? '#sample' : '';
res.redirect(302, `${DW_BASE}/products/${p.handle}${frag}`);
});
// Homepage
app.get('/', (_req, res) => res.send(homePage()));
app.get('/sitemap.xml', (_req, res) => {
res.set('Content-Type', 'application/xml');
const urls = ALL.map(p =>
`<url><loc>https://customdigitalmurals.com/product/${redactVendorSlug(p.handle)}</loc><changefreq>monthly</changefreq></url>`
).join('\n');
res.send(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>`);
});
// Admin catalog CRUD — /admin/catalog (basic-auth) + /api/admin/* REST.
if (catalog) catalog.mount(app, { siteSlug: SITE_SLUG, rails: SITE_RAILS });
// Merge a PG microsite_products row with the rich static snapshot (by handle),
// so the storefront still has images[], price and body_html the migration dropped.
function enrich(row) {
const s = STATIC_BY_HANDLE.get(row.handle) || {};
return {
id: s.id,
title: row.title || s.title,
handle: row.handle,
vendor: row.vendor || s.vendor,
product_type: row.product_type || s.product_type,
tags: (Array.isArray(row.tags) && row.tags.length) ? row.tags : (s.tags || []),
body_html: s.body_html || '',
images: s.images || (row.image_url ? [row.image_url] : []),
price: row.max_price != null ? String(row.max_price) : (s.price || '0'),
dw_url: row.product_url || s.dw_url,
};
}
// 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);
if (!rows.length) throw new Error('microsite_products returned 0 rows');
ALL = rows.map(enrich);
console.log(`[${SITE_SLUG}] loaded ${ALL.length} products from dw_unified`);
loaded = true;
} catch (e) {
console.error(`[${SITE_SLUG}] PG catalog load failed (${e.message}) — using data/products.json`);
}
}
if (!loaded) {
ALL = STATIC;
console.log(`[${SITE_SLUG}] loaded ${ALL.length} from data/products.json (static mode)`);
}
app.listen(PORT, () => console.log(`customdigitalmurals.com :${PORT} (${ALL.length} products)`));
})();
// ── Templates ──────────────────────────────────────────────────────────────
function ga4Script() {
if (!GA4_ID) return '';
return `<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=${GA4_ID}"></script>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${GA4_ID}');</script>`;
}
function headCommon(title, desc, canonical='', ogImage='') {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escHtml(title)}</title>
<meta name="description" content="${escHtml(desc)}">
${canonical?`<link rel="canonical" href="${escHtml(canonical)}">\n`:''}<meta property="og:type" content="website">
<meta property="og:site_name" content="Custom Digital Murals">
<meta property="og:title" content="${escHtml(title)}">
<meta property="og:description" content="${escHtml(desc)}">
${canonical?`<meta property="og:url" content="${escHtml(canonical)}">\n`:''}${ogImage?`<meta property="og:image" content="${escHtml(ogImage)}">\n`:''}<meta name="twitter:card" content="${ogImage?'summary_large_image':'summary'}">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)">
<script>(function(){try{var t=localStorage.getItem('cdm-theme');if(!t){t=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>
<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=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
${ga4Script()}
<style>
/* ── Design tokens — Off-White aesthetic ── */
:root {
--bg: #ffffff;
--ink: #0a0a0a;
--ink-soft: #3a3a3a;
--ink-faint: #888888;
--line: #e0e0e0;
--card-bg: #f5f5f5;
--header-bg: #ffffff;
--accent: #0a0a0a;
--accent-hover: #333333;
--tag-bg: #f0f0f0;
--tag-ink: #0a0a0a;
}
[data-theme="dark"] {
--bg: #0a0a0a;
--ink: #f0f0f0;
--ink-soft: #c0c0c0;
--ink-faint: #666666;
--line: #2a2a2a;
--card-bg: #141414;
--header-bg: #0a0a0a;
--tag-bg: #1e1e1e;
--tag-ink: #c0c0c0;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
html{scroll-behavior:smooth;}
body{background:var(--bg);color:var(--ink);font-family:'Space Grotesk',system-ui,sans-serif;font-size:15px;line-height:1.55;-webkit-font-smoothing:antialiased;transition:background .2s,color .2s;}
/* ── Header ── */
.site-header{position:sticky;top:0;z-index:100;background:var(--header-bg);border-bottom:1.5px solid var(--ink);display:flex;align-items:center;justify-content:space-between;padding:0 32px;height:56px;transition:background .2s;}
.site-header a.brand{font-size:13px;font-weight:700;letter-spacing:.18em;text-transform:uppercase;text-decoration:none;color:var(--ink);}
.site-header nav{display:flex;align-items:center;gap:24px;}
.site-header nav a{font-size:12px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;text-decoration:none;color:var(--ink-soft);}
.site-header nav a:hover{color:var(--ink);}
.theme-toggle{background:none;border:1.5px solid var(--line);cursor:pointer;padding:6px 8px;border-radius:3px;color:var(--ink);display:flex;align-items:center;gap:4px;line-height:1;}
.theme-toggle:hover{border-color:var(--ink);}
[data-theme="dark"] .icon-sun{display:block;}
[data-theme="dark"] .icon-moon{display:none;}
[data-theme="light"] .icon-sun,.icon-sun{display:none;}
[data-theme="light"] .icon-moon,.icon-moon{display:block;}
[data-theme="dark"] .icon-sun{display:block;}
/* ── Hero ── */
.hero{position:relative;height:72vh;min-height:460px;overflow:hidden;background:#111;display:flex;align-items:flex-end;}
.hero img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:.55;}
.hero-content{position:relative;padding:48px 48px 56px;max-width:900px;}
.hero-eyebrow{font-size:11px;font-weight:600;letter-spacing:.2em;text-transform:uppercase;color:rgba(255,255,255,.7);margin-bottom:16px;}
.hero h1{font-size:clamp(42px,6vw,88px);font-weight:700;letter-spacing:-.01em;line-height:.95;text-transform:uppercase;color:#fff;margin-bottom:24px;}
.hero-sub{font-size:16px;font-weight:400;color:rgba(255,255,255,.8);max-width:520px;margin-bottom:36px;line-height:1.5;}
.hero-cta{display:inline-block;background:#fff;color:#0a0a0a;font-size:12px;font-weight:700;letter-spacing:.16em;text-transform:uppercase;text-decoration:none;padding:14px 32px;border:2px solid #fff;}
.hero-cta:hover{background:transparent;color:#fff;}
/* ── Grid toolbar ── */
.toolbar{border-bottom:1.5px solid var(--line);padding:16px 32px;display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap;background:var(--bg);}
.toolbar .tb-sort{display:flex;align-items:center;gap:12px;flex:1;min-width:260px;}
.toolbar .tb-density{display:flex;align-items:center;gap:12px;flex:1;min-width:260px;justify-content:flex-end;}
.toolbar input[type=text]{flex:1;min-width:160px;border:1.5px solid var(--line);background:var(--bg);color:var(--ink);font-family:inherit;font-size:13px;padding:9px 14px;outline:none;border-radius:0;}
.toolbar input[type=text]:focus{border-color:var(--ink);}
.toolbar select{border:1.5px solid var(--line);background:var(--bg);color:var(--ink);font-family:inherit;font-size:12px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;padding:9px 14px;outline:none;cursor:pointer;border-radius:0;}
.toolbar select:focus{border-color:var(--ink);}
.toolbar .count{font-size:11px;color:var(--ink-faint);letter-spacing:.06em;text-transform:uppercase;white-space:nowrap;}
.density-label{font-size:11px;color:var(--ink-faint);letter-spacing:.06em;text-transform:uppercase;white-space:nowrap;}
.density-slider{cursor:pointer;flex:1;min-width:140px;max-width:200px;accent-color:var(--ink);}
/* ── Product grid ── */
.grid-wrap{padding:32px;}
.product-grid{display:grid;gap:2px;grid-template-columns:repeat(var(--cols,4),1fr);}
.product-card{background:var(--card-bg);cursor:pointer;overflow:hidden;position:relative;aspect-ratio:3/4;transition:opacity .15s;}
.product-card:hover{opacity:.85;}
.product-card img{width:100%;height:75%;object-fit:cover;display:block;}
.card-body{padding:12px 14px 14px;}
.card-vendor{font-size:10px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-faint);margin-bottom:4px;}
.card-title{font-size:13px;font-weight:500;color:var(--ink);line-height:1.3;margin-bottom:8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
.card-price{font-size:12px;font-weight:600;color:var(--ink);margin-bottom:10px;}
.card-sample{display:inline-block;background:var(--ink);color:var(--bg);font-size:10px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;text-decoration:none;padding:6px 12px;}
.card-sample:hover{opacity:.8;}
/* ── Pagination ── */
.pagination{display:flex;justify-content:center;gap:8px;padding:40px 32px;align-items:center;}
.pagination button{background:none;border:1.5px solid var(--line);color:var(--ink);font-family:inherit;font-size:12px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;padding:8px 18px;cursor:pointer;}
.pagination button:hover,.pagination button.active{border-color:var(--ink);background:var(--ink);color:var(--bg);}
.pagination button:disabled{opacity:.3;cursor:default;}
/* ── Product detail ── */
.product-layout{display:grid;grid-template-columns:1fr 1fr;gap:0;min-height:calc(100vh - 56px);}
.product-gallery{position:sticky;top:56px;height:calc(100vh - 56px);overflow:hidden;background:#f0f0f0;}
.product-gallery img{width:100%;height:100%;object-fit:cover;}
.product-info{padding:60px 56px;border-left:1.5px solid var(--line);overflow-y:auto;}
.product-info .brand-line{font-size:11px;font-weight:600;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-faint);margin-bottom:12px;}
.product-info h1{font-size:clamp(24px,3vw,42px);font-weight:700;letter-spacing:-.01em;text-transform:uppercase;line-height:1.05;margin-bottom:20px;}
.product-info .price{font-size:22px;font-weight:600;margin-bottom:28px;color:var(--ink);}
.product-info .body-prose{font-size:15px;line-height:1.65;color:var(--ink-soft);margin-bottom:36px;}
.sample-cta{display:inline-block;background:var(--ink);color:var(--bg);font-size:12px;font-weight:700;letter-spacing:.15em;text-transform:uppercase;text-decoration:none;padding:16px 36px;margin-bottom:16px;}
.sample-cta:hover{opacity:.8;}
.dw-link{display:block;font-size:12px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-soft);text-decoration:none;margin-bottom:36px;}
.dw-link:hover{color:var(--ink);}
.tags{display:flex;flex-wrap:wrap;gap:8px;}
.tag{background:var(--tag-bg);color:var(--tag-ink);font-size:10px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;padding:5px 10px;}
/* ── Footer ── */
.site-footer{border-top:1.5px solid var(--ink);padding:40px 32px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px;}
.site-footer .brand-block{font-size:11px;font-weight:700;letter-spacing:.18em;text-transform:uppercase;}
.site-footer .links{display:flex;gap:20px;}
.site-footer .links a{font-size:11px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;text-decoration:none;color:var(--ink-soft);}
.site-footer .links a:hover{color:var(--ink);}
.site-footer .tagline{font-size:11px;color:var(--ink-faint);}
/* ── Responsive ── */
@media(max-width:768px){
.site-header{padding:0 16px;}
.hero-content{padding:32px 24px 40px;}
.product-layout{grid-template-columns:1fr;}
.product-gallery{position:static;height:60vw;}
.product-info{padding:32px 24px;}
.product-grid{grid-template-columns:repeat(2,1fr)!important;}
.toolbar{padding:12px 16px;}
.grid-wrap{padding:16px;}
.site-footer{flex-direction:column;text-align:center;}
}
@media(max-width:480px){
.hero h1{font-size:36px;}
.site-header nav a:not(:last-child){display:none;}
}
</style>
</head>`;
}
function headerHtml(active) {
return `<header class="site-header">
<a class="brand" href="/">Custom Digital Murals</a>
<nav>
<a href="/" ${active==='home'?'style="color:var(--ink)"':''}>Shop</a>
<a href="/#about">About</a>
<a href="mailto:info@customdigitalmurals.com">Contact</a>
<button id="theme-toggle" class="theme-toggle" type="button" 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.8" 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.8" 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>
</header>
<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('cdm-theme',nxt);}catch(e){}
sync();
});
})();
</script>`;
}
function footerHtml() {
return `<footer class="site-footer">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"LocalBusiness","name":"Custom Digital Murals","url":"https://customdigitalmurals.com","email":"info@customdigitalmurals.com","description":"Printed-to-order custom digital wall murals for hospitality, retail, and residential. Free memo samples. Trade service.","address":{"@type":"PostalAddress","addressCountry":"US"}}</script>
<div class="brand-block">Custom Digital Murals</div>
<div class="links">
<a href="mailto:info@customdigitalmurals.com">info@customdigitalmurals.com</a>
<a href="https://designerwallcoverings.com" target="_blank" rel="noopener noreferrer">Order via DW</a>
</div>
<div class="tagline">Printed-to-order · Free memo samples · Trade service available</div>
</footer>`;
}
function homePage() {
// Use a high-quality scenic image from the product catalog for the hero
const heroProduct = ALL.find(p => p.images && p.images[0]);
const heroImg = heroProduct ? heroProduct.images[0] : '';
return `${headCommon(
'Custom Digital Murals — Free Samples, Trade Service | Designer Wallcoverings',
'Order custom digital wall murals printed to size. Hospitality, retail buildouts, residential statement walls. Free memo samples via Designer Wallcoverings. Expert trade service.',
'https://customdigitalmurals.com/',
heroImg
)}
<body>
${headerHtml('home')}
<section class="hero">
${heroImg ? `<img src="${escHtml(heroImg)}" alt="Custom mural installation" loading="eager">` : ''}
<div class="hero-content">
<div class="hero-eyebrow">Printed To Order — Free Memo Samples</div>
<h1>Make the<br>Wall<br>the Art.</h1>
<p class="hero-sub">Hospitality. Retail. Residential. Large-format digital murals from the world's top studios, printed to your exact dimensions with trade-level service.</p>
<a class="hero-cta" href="#shop">Shop All Murals</a>
</div>
</section>
<!-- Aesthetic rails (added 2026-05-26 fleet rails sweep) -->
<style>
.cdm-rails { max-width: 1400px; margin: 0 auto; padding: 48px 32px 0; }
.rail-section { margin-bottom: 56px; }
.rail-head { display:flex; align-items:baseline; justify-content:space-between; margin-bottom:18px; padding-bottom:10px; border-bottom:1px solid var(--line); }
.rail-title-h { font-size: 22px; letter-spacing: .18em; text-transform: uppercase; font-weight: 700; margin: 0; color: var(--ink); }
.rail-all { font-size: 11px; letter-spacing: .18em; text-transform: uppercase; color: var(--ink-soft); font-weight: 600; cursor: pointer; background: transparent; border: 0; padding: 0; font-family: inherit; }
.rail-all:hover { color: var(--ink); }
.rail-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 18px; }
.rail-card { display: block; aspect-ratio: 1/1; overflow: hidden; position: relative; cursor: pointer; }
.rail-card img { width: 100%; height: 100%; object-fit: cover; object-position: center 12%; transition: transform .4s ease; }
.rail-card:hover img { transform: scale(1.04); }
.rail-card-label { position: absolute; bottom: 0; left: 0; right: 0; padding: 12px 14px; background: linear-gradient(to top, rgba(0,0,0,0.7), transparent); color: #fff; font-size: 11px; letter-spacing: .12em; text-transform: uppercase; }
.cdm-pills { display: flex; gap: 10px; flex-wrap: wrap; padding: 18px 32px 0; max-width: 1400px; margin: 0 auto; }
.cdm-pill { display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; border: 1px solid var(--line); background: transparent; color: var(--ink-soft); font: inherit; font-size: 11px; letter-spacing: .16em; text-transform: uppercase; font-weight: 600; cursor: pointer; border-radius: 0; transition: border-color .15s, color .15s, background .15s; }
.cdm-pill:hover { border-color: var(--ink); color: var(--ink); }
.cdm-pill.active { border-color: var(--ink); color: var(--bg); background: var(--ink); }
.cdm-pill span { font-size: 10px; opacity: .7; font-weight: 700; }
</style>
<section class="cdm-rails" id="railSections" aria-label="Browse by aesthetic"></section>
<div id="shop">
<nav class="cdm-pills" id="cdmPills" aria-label="Filter by aesthetic"></nav>
<div class="toolbar">
<div class="tb-sort">
<input type="text" id="search" placeholder="Search by title, keyword..." autocomplete="off">
<select id="sort" aria-label="Sort products">
<option value="newest">Newest</option>
<option value="color">Color</option>
<option value="style">Style</option>
<option value="sku">SKU A → Z</option>
<option value="title">Title A → Z</option>
<option value="price_asc">Price ↑</option>
<option value="price_desc">Price ↓</option>
</select>
</div>
<div class="tb-density">
<span class="density-label">Density</span>
<input type="range" id="density" class="density-slider" min="2" max="6" value="4" step="1" aria-label="Grid density">
<span class="count" id="count"></span>
</div>
</div>
<div class="grid-wrap">
<div class="product-grid" id="grid"></div>
</div>
<div class="pagination" id="pagination"></div>
</div>
<section id="about" style="padding:80px 48px;border-top:1.5px solid var(--line);max-width:800px;margin:0 auto;">
<div style="font-size:11px;font-weight:600;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-faint);margin-bottom:20px;">About</div>
<h2 style="font-size:clamp(28px,4vw,52px);font-weight:700;text-transform:uppercase;letter-spacing:-.01em;line-height:1;margin-bottom:28px;">Custom-Printed.<br>Trade-Serviced.<br>Free Samples.</h2>
<p style="font-size:16px;line-height:1.7;color:var(--ink-soft);max-width:580px;">Every mural here ships through Designer Wallcoverings' trade service — free memo samples on request, white-glove specification support, and install coordination available. No minimum order. Hospitality POs welcome.</p>
</section>
${footerHtml()}
<script>
var page=1, per=60, total=0, pages=0;
var q='', sort='newest', aesthetic='all';
var LS_SORT='cdm-sort', LS_DENSITY='cdm-density', LS_AESTHETIC='cdm-aesthetic';
try { var savedA = localStorage.getItem(LS_AESTHETIC); if (savedA) aesthetic = savedA; } catch(e){}
// Hydrate saved sort + density BEFORE the first grid load.
try {
var savedSort = localStorage.getItem(LS_SORT);
if (savedSort) {
var sel = document.getElementById('sort');
var has = Array.prototype.some.call(sel.options, function(o){ return o.value === savedSort; });
if (has) { sort = savedSort; sel.value = savedSort; }
}
} catch(e){}
var savedCols = 4;
try {
var d = parseInt(localStorage.getItem(LS_DENSITY), 10);
if (d >= 2 && d <= 6) { savedCols = d; document.getElementById('density').value = String(d); }
} catch(e){}
function setCols(n){document.querySelector('.product-grid').style.setProperty('--cols',n);}
document.getElementById('density').addEventListener('input',function(){
setCols(+this.value);
try { localStorage.setItem(LS_DENSITY, String(this.value)); } catch(e){}
});
setCols(savedCols);
function load(){
var url='/api/products?page='+page+'&per='+per+'&q='+encodeURIComponent(q)+'&sort='+sort+'&aesthetic='+encodeURIComponent(aesthetic);
fetch(url).then(r=>r.json()).then(data=>{
total=data.total; pages=data.pages;
document.getElementById('count').textContent=total.toLocaleString()+' murals';
var grid=document.getElementById('grid');
grid.innerHTML=data.products.map(function(p){
var img=p.images&&p.images[0]?'<img src="'+esc(p.images[0])+'" alt="'+esc(p.title)+'" loading="lazy">':'<div style="height:75%;background:var(--line)"></div>';
var price=parseFloat(p.price||0)>0?'$'+parseFloat(p.price).toFixed(2)+' / roll':'';
return '<div class="product-card" onclick="location.href=\\'/product/'+esc(p.slug||p.handle)+'\\'">'+
img+
'<div class="card-body">'+
'<div class="card-vendor">Designer Wallcoverings</div>'+
'<div class="card-title">'+esc(p.title)+'</div>'+
(price?'<div class="card-price">'+price+'</div>':'')+
'<a class="card-sample" href="'+esc('/buy/'+(p.slug||p.handle)+'?sample=1')+'" onclick="event.stopPropagation()" target="_blank" rel="noopener noreferrer">Order Free Sample</a>'+
'</div>'+
'</div>';
}).join('');
renderPagination();
});
}
function renderPagination(){
var el=document.getElementById('pagination');
var html='<button '+(page<=1?'disabled':'')+' onclick="page--;load()">Prev</button>';
var start=Math.max(1,page-2), end=Math.min(pages,page+2);
for(var i=start;i<=end;i++){
html+='<button class="'+(i===page?'active':'')+'" onclick="page='+i+';load()">'+i+'</button>';
}
html+='<button '+(page>=pages?'disabled':'')+' onclick="page++;load()">Next</button>';
if(pages>0) html+='<span style="font-size:11px;color:var(--ink-faint);margin-left:8px;">Page '+page+' of '+pages+'</span>';
el.innerHTML=html;
}
function esc(s){return String(s).replace(/[&<>"']/g,function(c){return{'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});}
document.getElementById('search').addEventListener('input',function(){q=this.value;page=1;load();});
document.getElementById('sort').addEventListener('change',function(){
sort=this.value;page=1;
try { localStorage.setItem(LS_SORT, sort); } catch(e){}
load();
});
// ── Aesthetic rails + pills (fleet rails sweep 2026-05-26) ───────────────
function setAesthetic(a){
aesthetic = a || 'all';
try { localStorage.setItem(LS_AESTHETIC, aesthetic); } catch(e){}
page = 1;
document.querySelectorAll('#cdmPills .cdm-pill').forEach(function(p){
p.classList.toggle('active', p.dataset.aesthetic === aesthetic);
});
load();
var shop = document.getElementById('shop');
if (shop) shop.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function renderPills(facets){
var bar = document.getElementById('cdmPills');
if (!bar) return;
var aesth = (facets && facets.aesthetics) || {};
var total = (facets && facets.total) || 0;
var entries = Object.entries(aesth).filter(function(e){ return e[0] && e[1]>=4; }).sort(function(a,b){ return b[1]-a[1]; }).slice(0,8);
var html = '<button type="button" class="cdm-pill'+(aesthetic==='all'?' active':'')+'" data-aesthetic="all">All <span>'+total.toLocaleString()+'</span></button>';
entries.forEach(function(e){
html += '<button type="button" class="cdm-pill'+(aesthetic===e[0]?' active':'')+'" data-aesthetic="'+esc(e[0])+'">'+esc(e[0].charAt(0).toUpperCase()+e[0].slice(1))+' <span>'+e[1].toLocaleString()+'</span></button>';
});
bar.innerHTML = html;
bar.querySelectorAll('.cdm-pill').forEach(function(p){
p.addEventListener('click', function(){ setAesthetic(p.dataset.aesthetic); });
});
}
function renderRailSections(rails){
var host = document.getElementById('railSections');
if (!host || !rails || !rails.length) return;
host.innerHTML = rails.slice(0,4).map(function(r){
var cards = (r.items||[]).slice(0,8).map(function(p){
var img = p.images && p.images[0] ? p.images[0] : '';
var href = '/product/'+encodeURIComponent(p.slug||p.handle||'');
return '<a class="rail-card" href="'+esc(href)+'">'+(img?'<img src="'+esc(img)+'" alt="'+esc(p.title||'')+'" loading="lazy">':'')+'<span class="rail-card-label">'+esc(p.title||'')+'</span></a>';
}).join('');
var label = r.aesthetic.charAt(0).toUpperCase()+r.aesthetic.slice(1);
return '<section class="rail-section"><div class="rail-head"><h2 class="rail-title-h">'+esc(label)+'</h2><button type="button" class="rail-all" data-aesthetic="'+esc(r.aesthetic)+'">View all →</button></div><div class="rail-grid">'+cards+'</div></section>';
}).join('');
host.querySelectorAll('.rail-all').forEach(function(b){
b.addEventListener('click', function(){ setAesthetic(b.dataset.aesthetic); });
});
}
Promise.all([
fetch('/api/facets').then(function(r){return r.json();}).catch(function(){return null;}),
fetch('/api/sliders').then(function(r){return r.json();}).catch(function(){return null;})
]).then(function(rs){
if (rs[0]) renderPills(rs[0]);
if (rs[1] && rs[1].rails) renderRailSections(rs[1].rails);
});
load();
</script>
</body></html>`;
}
function productPage(p) {
const imgs = p.images || [];
const mainImg = imgs[0] || '';
const price = parseFloat(p.price || 0) > 0 ? `$${parseFloat(p.price).toFixed(2)} / roll` : 'Contact for pricing';
const sampleUrl = `/buy/${redactVendorSlug(p.handle)}?sample=1`;
const dwUrl = `/buy/${redactVendorSlug(p.handle)}`;
// Strip HTML tags from body for safe display
const prose = (p.body_html || '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 600);
const tags = (p.tags || []).filter(t => !t.match(/^[0-9]+$/) && t.length < 40).slice(0, 12);
return `${headCommon(
`${p.title} — Order Free Sample | Custom Digital Murals`,
`${p.title} from Designer Wallcoverings. Order a free memo sample. Trade service available. Large-format digital mural printed to your exact dimensions.`,
`https://customdigitalmurals.com/product/${redactVendorSlug(p.handle)}`,
mainImg
)}
<body>
${headerHtml('')}
<script type="application/ld+json">${JSON.stringify({
"@context": "https://schema.org",
"@type": "Product",
"name": p.title,
"brand": { "@type": "Brand", "name": "Designer Wallcoverings" },
"offers": { "@type": "Offer", "priceCurrency": "USD", "price": p.price || "0", "availability": "https://schema.org/InStock" },
"image": mainImg,
"url": `https://customdigitalmurals.com/product/${redactVendorSlug(p.handle)}`
})}</script>
<div class="product-layout">
<div class="product-gallery">
${mainImg ? `<img src="${escHtml(mainImg)}" alt="${escHtml(p.title)}" loading="eager">` : '<div style="width:100%;height:100%;background:var(--line)"></div>'}
</div>
<div class="product-info">
<div class="brand-line">Designer Wallcoverings</div>
<h1>${escHtml(p.title)}</h1>
<div class="price">${escHtml(price)}</div>
${prose ? `<div class="body-prose">${escHtml(prose)}</div>` : ''}
<a class="sample-cta" href="${escHtml(sampleUrl)}" target="_blank" rel="noopener noreferrer">Order Memo Sample (free)</a>
<a class="dw-link" href="${escHtml(dwUrl)}" target="_blank" rel="noopener noreferrer">View on Designer Wallcoverings →</a>
${tags.length ? `<div class="tags">${tags.map(t => `<span class="tag">${escHtml(t)}</span>`).join('')}</div>` : ''}
${imgs.length > 1 ? `<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;margin-top:28px;">${imgs.slice(1,4).map(img => `<img src="${escHtml(img)}" alt="${escHtml(p.title)}" loading="lazy" style="width:100%;aspect-ratio:1;object-fit:cover;">`).join('')}</div>` : ''}
</div>
</div>
${footerHtml()}
</body></html>`;
}
function page404() {
return `${headCommon('404 — Custom Digital Murals','Page not found')}
<body>${headerHtml('')}
<div style="padding:120px 48px;text-align:center;">
<div style="font-size:72px;font-weight:700;letter-spacing:-.03em;text-transform:uppercase;">404</div>
<p style="margin:16px 0 32px;color:var(--ink-soft);">This page doesn't exist.</p>
<a href="/" style="font-size:12px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--ink);">Back to shop</a>
</div>${footerHtml()}</body></html>`;
}
function escHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}