[object Object]

← back to Customdigitalmurals

auto-save: 2026-06-23T09:50:51 (1 files) — server.js.tmp

91fb733c1c019b876c8e4256effb49bb0bb2f9ee · 2026-06-23 09:50:52 -0700 · Steve Abrams

Files touched

Diff

commit 91fb733c1c019b876c8e4256effb49bb0bb2f9ee
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 23 09:50:52 2026 -0700

    auto-save: 2026-06-23T09:50:51 (1 files) — server.js.tmp
---
 server.js.tmp | 805 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 805 insertions(+)

diff --git a/server.js.tmp b/server.js.tmp
new file mode 100644
index 0000000..37f32f5
--- /dev/null
+++ b/server.js.tmp
@@ -0,0 +1,805 @@
+/**
+ * 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{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 &rarr;</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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+}

← 70ea38b deploy: scaffold .deploy.conf for banner deploy  ·  back to Customdigitalmurals  ·  (newest)