← back to Designerlookbooks

server.js.tmp

975 lines

/**
 * designerlookbooks.com — visual lookbook hub.
 *
 * Groups the DW-Shopify catalog (8,227 active SKUs, snapshot under
 * data/products.json) into aesthetic clusters via title/type keyword
 * rules, then renders each cluster as a magazine-style lookbook with
 * hero, masthead, and editorial grid. Each cluster page carries the
 * mandated sort + density slider, dark/light toggle, GA4 gtag, and a
 * compliant footer.
 *
 * Memo-sample CTAs hand the buyer back to the canonical DW product
 * page on Shopify (#sample anchor), so all order-flow + payment lives
 * upstream. This site is a pure storefront/discovery surface.
 */
const express = require('express');
const path = require('path');
const fs = require('fs');

const PORT = process.env.PORT || 9923;
const BIND = process.env.BIND || '127.0.0.1';
const GA_ID = 'G-SDQQKBE3GY';
const DW_SHOPIFY = 'https://designerwallcoverings.com';
const DATA = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8'));

const app = express();


app.get(['/favicon.ico','/favicon.svg'], (req, res) => res.sendFile(require('path').join(__dirname, 'public', req.path.slice(1))));
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');
  // Storefront is regenerated from a static JSON snapshot, but the
  // memory rule says no-store for HTML behind CF — let nginx/CF set
  // that header in production. We keep an internal short cache here
  // so dev reloads stay snappy without overriding the prod policy.
  res.set('Cache-Control', 'public, max-age=300');
  next();
});

// Snapshot/backup files should never be reachable from any static path.
// Even though public/ is currently empty, sweep up any future
// `.bak` / `.bak.*` / `.pre-*` / `.orig` filenames before they can be
// served. Standing rule across the DW fleet.
app.use((req, res, next) => {
  if (/\.(bak|orig)(\.|$)|\.pre-/i.test(req.path)) return res.status(404).end();
  next();
});
app.use('/static', express.static(path.join(__dirname, 'public'), { maxAge: '7d' }));

// ---------------------------------------------------------------------------
// Aesthetic clusters
// ---------------------------------------------------------------------------
// Each cluster carries a slug, an editorial title + tagline, an accent
// color used for the magazine-style cover, and a rule function that
// classifies products. Rules read the product title + product_type
// (case-insensitive substring matches). A product can appear in
// multiple clusters; we de-duplicate inside each cluster but allow
// cross-cluster overlap so the lookbooks each feel curated.
const CLUSTERS = [
  {
    slug: 'soft-modernism',
    title: 'Soft Modernism',
    tagline: 'Plaster textures, paper-stocks, and quiet linens for rooms that breathe.',
    accent: '#a89878',
    keywords: ['linen', 'plaster', 'paperweave', 'paper weave', 'natural', 'grasscloth', 'sisal', 'jute', 'raffia', 'cork'],
  },
  {
    slug: 'maximalist-flora',
    title: 'Maximalist Flora',
    tagline: 'Botanicals turned up loud — chinoiserie, garden murals, hothouse motifs.',
    accent: '#5a7a3c',
    keywords: ['floral', 'flower', 'chinoiserie', 'garden', 'botanical', 'foliage', 'leaf', 'palm', 'fern', 'orchid', 'peony', 'rose', 'bloom', 'aviary', 'bird'],
  },
  {
    slug: 'architectural-line',
    title: 'Architectural Line',
    tagline: 'Stripes, lattices, herringbones — geometry as the only ornament needed.',
    accent: '#1c2638',
    keywords: ['stripe', 'pinstripe', 'lattice', 'herringbone', 'chevron', 'grid', 'geometric', 'fret', 'trellis', 'tile', 'diamond', 'hexagon', 'plaid', 'check'],
  },
  {
    slug: 'global-craft',
    title: 'Global Craft',
    tagline: 'Block-printed, ikat, suzani — handwork translated to the wall.',
    accent: '#a04015',
    keywords: ['ikat', 'suzani', 'kilim', 'paisley', 'medallion', 'moroccan', 'persian', 'block print', 'block-print', 'tribal', 'batik', 'kasuri', 'shibori', 'mudcloth'],
  },
  {
    slug: 'metallic-light',
    title: 'Metallic Light',
    tagline: 'Mica, glass-bead, gold-leaf, and silver grounds that catch every lumen.',
    accent: '#8a7228',
    keywords: ['gold', 'silver', 'metallic', 'mica', 'foil', 'glass bead', 'glass-bead', 'glassbeaded', 'gilded', 'pearlescent', 'shimmer', 'bronze', 'copper', 'platinum'],
  },
  {
    slug: 'velvet-noir',
    title: 'Velvet Noir',
    tagline: 'Damasks, brocades, and saturated jewel tones for the after-dark room.',
    accent: '#3a1530',
    keywords: ['damask', 'brocade', 'velvet', 'jacquard', 'baroque', 'fleur', 'scroll', 'arabesque', 'noir', 'midnight', 'oxblood', 'aubergine', 'plum', 'wine', 'merlot'],
  },
  {
    slug: 'coastal-lab',
    title: 'Coastal Lab',
    tagline: 'Sea-glass blues, sand, salt-bleached neutrals — California-modern light.',
    accent: '#3d6e80',
    keywords: ['ocean', 'sea', 'coral', 'wave', 'tide', 'seafoam', 'sand', 'shore', 'beach', 'aqua', 'turquoise', 'aegean', 'sky', 'driftwood', 'shell'],
  },
  {
    slug: 'monochrome-study',
    title: 'Monochrome Study',
    tagline: 'Grayscale, black-on-white, and ink-on-paper — discipline in a single tone.',
    accent: '#222222',
    keywords: ['black', 'white', 'ivory', 'cream', 'charcoal', 'graphite', 'ash', 'smoke', 'monochrome', 'noir blanc'],
  },
];

const NORM_TYPE = (t) => {
  if (!t) return 'Other';
  const x = t.replace(/s$/i, '').trim();
  if (/wallcovering/i.test(x)) return 'Wallcovering';
  if (/natural/i.test(x)) return 'Natural';
  if (/drapery/i.test(x)) return 'Drapery';
  if (/fabric/i.test(x)) return 'Fabric';
  return x;
};

function matchesCluster(product, cluster) {
  const hay = `${product.title || ''} ${product.product_type || ''}`.toLowerCase();
  return cluster.keywords.some(k => hay.includes(k));
}

// Pre-compute cluster membership so route handlers don't re-scan 8k
// rows on every request. Each cluster owns a snapshot of products
// that pass its keyword rule, in the order the snapshot was produced
// (which preserves the upstream "newest first" ordering).
const CLUSTER_INDEX = Object.fromEntries(
  CLUSTERS.map(c => [c.slug, { ...c, products: DATA.filter(p => matchesCluster(p, c)) }])
);

// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));

function paginate(arr, page, perPage = 60) {
  const total = arr.length;
  const pages = Math.max(1, Math.ceil(total / perPage));
  const p = Math.min(Math.max(1, page), pages);
  return { items: arr.slice((p - 1) * perPage, p * perPage), page: p, pages, total };
}

function shopifyUrl(handle) { return handle ? `${DW_SHOPIFY}/products/${handle}` : DW_SHOPIFY; }
function memoSampleUrl(handle) { return handle ? `${DW_SHOPIFY}/products/${handle}#sample` : DW_SHOPIFY; }

// Sort modes — implemented server-side so deep-linked URLs reproduce.
// "Newest" = snapshot order; "Color"/"Style" sort by tag-bucket name to
// pull similar hues together; the rest are straight string/number sorts.
const COLOR_BUCKETS = [
  ['black', /\b(black|noir|onyx|jet|midnight)\b/i],
  ['white', /\b(white|ivory|cream|alabaster|chalk)\b/i],
  ['gray',  /\b(gray|grey|charcoal|graphite|ash|smoke|silver)\b/i],
  ['red',   /\b(red|crimson|scarlet|ruby|brick|oxblood|cinnabar)\b/i],
  ['orange',/\b(orange|terracotta|copper|rust|amber|tangerine)\b/i],
  ['yellow',/\b(yellow|gold|ochre|saffron|honey|mustard|citrine)\b/i],
  ['green', /\b(green|sage|olive|moss|emerald|jade|fern|forest|seafoam)\b/i],
  ['blue',  /\b(blue|navy|cobalt|indigo|sapphire|aqua|teal|turquoise|aegean|sky)\b/i],
  ['purple',/\b(purple|violet|lavender|plum|aubergine|mauve|lilac)\b/i],
  ['pink',  /\b(pink|rose|blush|coral|fuchsia|magenta)\b/i],
  ['brown', /\b(brown|tan|taupe|sand|khaki|camel|chocolate|caramel|sepia)\b/i],
];
function colorBucket(p) {
  const hay = `${p.title || ''}`.toLowerCase();
  for (const [name, rx] of COLOR_BUCKETS) if (rx.test(hay)) return name;
  return 'zz';
}
const STYLE_BUCKETS = [
  ['Floral',     /\b(floral|flower|botanical|peony|rose|garden|aviary|bird|chinoiserie)\b/i],
  ['Geometric',  /\b(stripe|geometric|lattice|herringbone|chevron|grid|fret|trellis|diamond|hex)\b/i],
  ['Damask',     /\b(damask|brocade|fleur|scroll|arabesque|baroque|jacquard)\b/i],
  ['Texture',    /\b(linen|grasscloth|sisal|cork|paperweave|paper weave|plaster|raffia|jute)\b/i],
  ['Metallic',   /\b(metallic|gold|silver|mica|foil|gilded|shimmer|bead)\b/i],
  ['Global',     /\b(ikat|suzani|kilim|paisley|medallion|moroccan|persian|block.?print|tribal)\b/i],
];
function styleBucket(p) {
  const hay = `${p.title || ''} ${p.product_type || ''}`.toLowerCase();
  for (const [name, rx] of STYLE_BUCKETS) if (rx.test(hay)) return name;
  return 'Other';
}
function sortProducts(arr, mode) {
  const a = arr.slice();
  switch (mode) {
    case 'color':    return a.sort((x, y) => colorBucket(x).localeCompare(colorBucket(y)) || (x.title || '').localeCompare(y.title || ''));
    case 'style':    return a.sort((x, y) => styleBucket(x).localeCompare(styleBucket(y)) || (x.title || '').localeCompare(y.title || ''));
    case 'sku':      return a.sort((x, y) => (x.dw_sku || x.handle || '').localeCompare(y.dw_sku || y.handle || ''));
    case 'title':    return a.sort((x, y) => (x.title || '').localeCompare(y.title || ''));
    case 'price-up':   return a.sort((x, y) => (parseFloat(x.retail_price) || Infinity) - (parseFloat(y.retail_price) || Infinity));
    case 'price-down': return a.sort((x, y) => (parseFloat(y.retail_price) || -Infinity) - (parseFloat(x.retail_price) || -Infinity));
    default: return a;
  }
}

function pickPreviewImages(products, n = 4) {
  const out = [];
  for (const p of products) {
    if (p.image_url && !out.includes(p.image_url)) out.push(p.image_url);
    if (out.length >= n) break;
  }
  return out;
}

// ---------------------------------------------------------------------------
// layout (vanilla HTML — no template engine, easier to audit + ship)
// ---------------------------------------------------------------------------
function layout({ title, description, body, og }) {
  const pageTitle = `${title} — Designer Lookbooks`;
  const desc = description || 'A visual lookbook of modern wallcovering aesthetics — curated clusters of texture, color, and pattern from the Designer Wallcoverings catalog.';
  return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(pageTitle)}</title>
<meta name="description" content="${esc(desc)}">
<meta property="og:title" content="${esc(pageTitle)}">
<meta property="og:description" content="${esc(desc)}">
<meta property="og:type" content="website">
${og && og.image ? `<meta property="og:image" content="${esc(og.image)}">` : ''}
<!-- Inline anti-flash theme bootstrap — sets the data-theme before paint. -->
<script>
  (function() {
    try {
      var saved = localStorage.getItem('dl-theme');
      var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
      var theme = saved || (prefersDark ? 'dark' : 'light');
      document.documentElement.setAttribute('data-theme', theme);
    } catch (e) {}
  })();
</script>
<!-- GA4 gtag (auto) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=${GA_ID}"></script>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${GA_ID}');</script>
<!-- /GA4 gtag -->
<link rel="preconnect" href="https://cdn.shopify.com">
<style>
:root[data-theme="light"] {
  --bg:#f8f5ee; --fg:#181614; --muted:#7a7167; --rule:#d8cec0; --card:#ffffff;
  --accent:#7d3c00; --accent-soft:#b8865c; --shadow:0 6px 24px rgba(0,0,0,.06);
}
:root[data-theme="dark"] {
  --bg:#13110f; --fg:#ebe6dc; --muted:#8e857a; --rule:#2c2823; --card:#1c1916;
  --accent:#cf964a; --accent-soft:#7d6238; --shadow:0 6px 24px rgba(0,0,0,.5);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
  font-family: -apple-system, 'Inter', 'Segoe UI', system-ui, sans-serif;
  background: var(--bg); color: var(--fg); line-height: 1.55;
  -webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
img { max-width: 100%; display: block; }

/* ---- masthead ---- */
header.mast {
  border-bottom: 1px solid var(--rule);
  background: var(--bg);
  position: sticky; top: 0; z-index: 50;
  backdrop-filter: saturate(140%) blur(8px);
}
.mast-inner {
  max-width: 1480px; margin: 0 auto; padding: 18px 32px;
  display: flex; align-items: center; gap: 32px;
}
.brand {
  font-family: 'Cormorant Garamond', 'Playfair Display', Georgia, serif;
  font-weight: 600; font-size: 28px; letter-spacing: .02em;
  white-space: nowrap;
}
.brand small {
  display: block; font-family: inherit; font-size: 10px; letter-spacing: .26em;
  font-weight: 500; color: var(--muted); margin-top: 2px;
  font-family: -apple-system, 'Inter', sans-serif;
}
nav.types {
  display: flex; gap: 18px; flex: 1; flex-wrap: wrap; align-items: center;
}
nav.types a {
  font-size: 12px; font-weight: 500; color: var(--muted); padding: 4px 0;
  border-bottom: 2px solid transparent; letter-spacing: .08em; text-transform: uppercase;
}
nav.types a:hover { color: var(--fg); }
nav.types a.active { color: var(--accent); border-bottom-color: var(--accent); }
form.search {
  display: flex; gap: 6px; align-items: center;
}
form.search input {
  border: 1px solid var(--rule); background: var(--card); padding: 8px 12px;
  border-radius: 0; min-width: 220px; font: inherit; font-size: 13px;
  outline: none; color: var(--fg);
}
form.search input:focus { border-color: var(--accent); }
form.search button {
  border: 1px solid var(--accent); background: var(--accent); color: #fff;
  padding: 8px 16px; font: inherit; font-size: 12px; text-transform: uppercase;
  letter-spacing: .1em; font-weight: 500; cursor: pointer;
}
button.theme-toggle {
  border: 1px solid var(--rule); background: var(--card); color: var(--fg);
  width: 36px; height: 36px; cursor: pointer; font-size: 16px;
  display: inline-flex; align-items: center; justify-content: center;
}
button.theme-toggle:hover { border-color: var(--accent); }

/* ---- magazine cover (home) ---- */
.cover {
  max-width: 1480px; margin: 0 auto; padding: 56px 32px 24px;
}
.cover h1 {
  font-family: 'Cormorant Garamond', Georgia, serif;
  font-size: clamp(40px, 8vw, 96px); font-weight: 500; line-height: .98;
  letter-spacing: -.01em; max-width: 980px;
}
.cover .lede {
  margin-top: 24px; max-width: 740px; font-size: 17px; color: var(--muted);
}
.cover .issue {
  font-size: 11px; letter-spacing: .3em; text-transform: uppercase;
  color: var(--muted); margin-bottom: 18px;
}
.divider {
  border: 0; border-top: 1px solid var(--rule);
  max-width: 1480px; margin: 36px auto 0; padding: 0 32px;
}

/* ---- editor's letter (home) ---- */
.letter {
  max-width: 1480px; margin: 0 auto; padding: 56px 32px 24px;
}
.letter-inner {
  display: grid; grid-template-columns: 280px 1fr; gap: 56px;
  border-top: 1px solid var(--rule); padding-top: 36px;
}
@media (max-width: 880px) {
  .letter-inner { grid-template-columns: 1fr; gap: 18px; }
}
.letter-meta .kicker {
  font-family: 'Cormorant Garamond', Georgia, serif;
  font-size: 26px; font-weight: 500; line-height: 1.1;
  color: var(--accent); margin-bottom: 6px;
}
.letter-meta .byline {
  font-size: 11px; letter-spacing: .22em; text-transform: uppercase;
  color: var(--muted);
}
.letter-body p {
  font-family: 'Cormorant Garamond', Georgia, serif;
  font-size: 20px; line-height: 1.55; color: var(--fg);
  max-width: 640px; margin-bottom: 18px;
}
.letter-body p:first-child::first-letter {
  font-size: 64px; line-height: .9; float: left;
  padding: 6px 12px 0 0; font-weight: 500; color: var(--accent);
}

/* ---- cluster index (home) ---- */
.spread {
  max-width: 1480px; margin: 0 auto; padding: 48px 32px 80px;
  display: grid; gap: 48px;
  grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 980px) { .spread { grid-template-columns: 1fr; } }
.feature {
  display: grid; grid-template-rows: auto auto auto;
  border-top: 1px solid var(--rule); padding-top: 24px;
}
.feature .num {
  font-size: 11px; letter-spacing: .26em; text-transform: uppercase;
  color: var(--muted); margin-bottom: 8px; font-variant-numeric: tabular-nums;
}
.feature h2 {
  font-family: 'Cormorant Garamond', Georgia, serif;
  font-weight: 500; font-size: 38px; line-height: 1.06; margin-bottom: 8px;
}
.feature .accent-bar { width: 48px; height: 3px; background: var(--accent-color, var(--accent)); margin-bottom: 14px; }
.feature .tag { color: var(--muted); font-size: 14px; max-width: 520px; margin-bottom: 18px; }
.feature .preview {
  display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 16px;
}
.feature .preview a {
  aspect-ratio: 1/1; background: var(--rule); overflow: hidden; display: block;
}
.feature .preview img {
  width: 100%; height: 100%; object-fit: cover;
  transition: transform .4s ease;
}
.feature .preview a:hover img { transform: scale(1.06); }
.feature .cta {
  display: inline-flex; align-items: center; gap: 8px; font-size: 12px;
  letter-spacing: .14em; text-transform: uppercase; color: var(--accent);
  font-weight: 600; border-bottom: 1px solid var(--accent); padding-bottom: 4px;
  align-self: start;
}
.feature .cta:hover { color: var(--fg); border-color: var(--fg); }

/* ---- cluster page ---- */
.cluster-hero {
  max-width: 1480px; margin: 0 auto; padding: 56px 32px 24px;
  display: grid; grid-template-columns: 1.05fr 1fr; gap: 48px;
}
@media (max-width: 980px) { .cluster-hero { grid-template-columns: 1fr; } }
.cluster-hero .copy h1 {
  font-family: 'Cormorant Garamond', Georgia, serif;
  font-size: clamp(40px, 6vw, 72px); font-weight: 500; line-height: 1.02;
  letter-spacing: -.01em;
}
.cluster-hero .copy .crumb {
  font-size: 11px; letter-spacing: .26em; text-transform: uppercase;
  color: var(--muted); margin-bottom: 16px;
}
.cluster-hero .copy .accent-bar { width: 56px; height: 4px; background: var(--accent-color); margin: 22px 0; }
.cluster-hero .copy p { color: var(--muted); font-size: 17px; max-width: 540px; }
.cluster-hero .copy .stat { margin-top: 28px; font-size: 13px; color: var(--muted); letter-spacing: .06em; text-transform: uppercase; }
.cluster-hero .collage {
  display: grid; grid-template-columns: 2fr 1fr 1fr; grid-template-rows: 1fr 1fr;
  gap: 4px; aspect-ratio: 4/3;
}
.cluster-hero .collage a {
  background: var(--rule); overflow: hidden; display: block;
}
.cluster-hero .collage a:first-child { grid-row: 1 / 3; }
.cluster-hero .collage img { width: 100%; height: 100%; object-fit: cover; }

/* ---- toolbar (sort + density) ---- */
.toolbar {
  max-width: 1480px; margin: 0 auto; padding: 24px 32px;
  border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule);
  display: flex; gap: 24px; align-items: center; flex-wrap: wrap;
  background: var(--bg); position: sticky; top: 73px; z-index: 40;
}
.toolbar label {
  font-size: 11px; letter-spacing: .14em; text-transform: uppercase;
  color: var(--muted); font-weight: 500;
}
.toolbar select, .toolbar input[type="range"] {
  font: inherit; border: 1px solid var(--rule); background: var(--card);
  color: var(--fg); padding: 6px 10px;
}
.toolbar select { font-size: 13px; }
.toolbar .group { display: flex; align-items: center; gap: 10px; }
.toolbar .meta { margin-left: auto; font-size: 12px; color: var(--muted); letter-spacing: .04em; text-transform: uppercase; }

/* ---- product grid ---- */
main.grid-wrap { max-width: 1480px; margin: 0 auto; padding: 32px; }
.grid {
  display: grid; gap: 22px;
  grid-template-columns: repeat(auto-fill, minmax(var(--card-min, 240px), 1fr));
}
.card {
  background: var(--card); border: 1px solid var(--rule);
  transition: box-shadow .15s, transform .12s;
}
.card:hover { box-shadow: var(--shadow); transform: translateY(-2px); }
.card a.thumb { display: block; aspect-ratio: 1/1; background: var(--rule); overflow: hidden; }
.card a.thumb img { width: 100%; height: 100%; object-fit: cover; }
.card .info { padding: 14px 14px 14px; }
.card .info .t {
  font-family: 'Cormorant Garamond', Georgia, serif;
  font-size: 17px; line-height: 1.22; margin-bottom: 4px;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.card .info .sub { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
.card .actions { display: flex; border-top: 1px solid var(--rule); }
.card .actions a { flex: 1; padding: 10px 12px; text-align: center; font-size: 11px;
  text-transform: uppercase; letter-spacing: .08em; font-weight: 500; color: var(--muted); transition: all .12s; }
.card .actions a + a { border-left: 1px solid var(--rule); }
.card .actions a:hover { background: var(--bg); color: var(--fg); }
.card .actions a.cta { color: var(--accent); font-weight: 600; }
.card .actions a.cta:hover { background: var(--accent); color: #fff; }
.empty { padding: 80px 20px; text-align: center; color: var(--muted); }
.pager { display: flex; justify-content: center; gap: 8px; margin-top: 48px; }
.pager a, .pager span { padding: 8px 14px; border: 1px solid var(--rule); background: var(--card); font-size: 13px; min-width: 44px; text-align: center; }
.pager a:hover { border-color: var(--accent); color: var(--accent); }
.pager .cur { background: var(--accent); color: #fff; border-color: var(--accent); }

/* ---- product detail ---- */
.detail { display: grid; grid-template-columns: 1.2fr 1fr; gap: 48px; padding: 48px 32px;
  max-width: 1480px; margin: 0 auto; }
.detail .hero { background: var(--rule); aspect-ratio: 1/1; }
.detail .hero img { width: 100%; height: 100%; object-fit: cover; }
.detail h1 { font-family: 'Cormorant Garamond', Georgia, serif; font-size: 48px; font-weight: 500; line-height: 1.05; margin-bottom: 8px; }
.detail .crumb { font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin-bottom: 18px; }
.detail .pt { font-size: 12px; letter-spacing: .1em; text-transform: uppercase; color: var(--accent); font-weight: 600; margin-bottom: 32px; }
.detail .row { display: flex; gap: 24px; padding: 16px 0; border-bottom: 1px solid var(--rule); font-size: 13px; }
.detail .row .k { flex: 0 0 140px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; font-size: 11px; font-weight: 500; }
.detail .actions { display: flex; gap: 12px; margin-top: 32px; flex-wrap: wrap; }
.detail .btn { padding: 14px 28px; font-size: 12px; letter-spacing: .12em; text-transform: uppercase; font-weight: 600; border: 1px solid var(--fg); display: inline-block; }
.detail .btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.detail .btn-primary:hover { background: #5e2d00; }
.detail .btn-secondary:hover { background: var(--fg); color: var(--bg); }
@media (max-width: 780px) { .detail { grid-template-columns: 1fr; padding: 24px; } }

footer.fp {
  border-top: 1px solid var(--rule);
  background: var(--card); padding: 56px 32px;
  display: grid; gap: 28px; grid-template-columns: 2fr 1fr 1fr 1fr;
}
@media (max-width: 880px) { footer.fp { grid-template-columns: 1fr 1fr; } }
footer.fp h4 { font-size: 11px; letter-spacing: .22em; text-transform: uppercase; color: var(--muted); margin-bottom: 12px; font-weight: 500; }
footer.fp p, footer.fp a { font-size: 13px; color: var(--fg); line-height: 1.7; }
footer.fp .col-brand .name { font-family: 'Cormorant Garamond', Georgia, serif; font-size: 22px; font-weight: 500; margin-bottom: 8px; }
footer.fp ul { list-style: none; }
footer.fp ul li { margin-bottom: 6px; }
footer.fp .legal {
  grid-column: 1 / -1; padding-top: 28px; border-top: 1px solid var(--rule);
  font-size: 11px; color: var(--muted); letter-spacing: .04em;
  display: flex; gap: 24px; flex-wrap: wrap;
}
</style>
</head>
<body>
<header class="mast">
  <div class="mast-inner">
    <a href="/" class="brand">Designer Lookbooks<small>EDITORIAL · WALLCOVERINGS</small></a>
    <nav class="types">
      ${CLUSTERS.slice(0, 6).map(c => `<a href="/lookbook/${c.slug}">${esc(c.title)}</a>`).join('')}
      <a href="/lookbooks">All Lookbooks</a>
    </nav>
    <form class="search" method="GET" action="/search">
      <input name="q" placeholder="Search 8,200+ wallcoverings…" aria-label="Search">
      <button>Go</button>
    </form>
    <button class="theme-toggle" onclick="(function(){var r=document.documentElement;var t=r.getAttribute('data-theme')==='dark'?'light':'dark';r.setAttribute('data-theme',t);try{localStorage.setItem('dl-theme',t);}catch(e){}})()" aria-label="Toggle theme" title="Toggle theme">
      <span style="display:none">[t]</span>
      <span class="theme-icon-light" style="display:inline">☀</span>
      <span class="theme-icon-dark"  style="display:none">☾</span>
    </button>
  </div>
</header>
<style>
  :root[data-theme="dark"] .theme-icon-light { display:none !important; }
  :root[data-theme="dark"] .theme-icon-dark  { display:inline !important; }
</style>
${body}
<footer class="fp">
  <div class="col-brand">
    <div class="name">Designer Lookbooks</div>
    <p>An editorial visual hub for wallcoverings, organized by aesthetic cluster. Curated from the Designer Wallcoverings catalog.</p>
  </div>
  <div>
    <h4>Lookbooks</h4>
    <ul>${CLUSTERS.slice(0, 4).map(c => `<li><a href="/lookbook/${c.slug}">${esc(c.title)}</a></li>`).join('')}</ul>
  </div>
  <div>
    <h4>More</h4>
    <ul>
      <li><a href="/lookbooks">All Lookbooks</a></li>
      <li><a href="${DW_SHOPIFY}" target="_blank" rel="noopener noreferrer">Shop on DW</a></li>
      <li><a href="/about">About</a></li>
    </ul>
  </div>
  <div>
    <h4>Contact</h4>
    <p>
      <a href="mailto:info@designerlookbooks.com">info@designerlookbooks.com</a><br>
      <a href="tel:+18883734564">888-373-4564</a><br>
      Designer Wallcoverings<br>
      15442 Ventura Bl #102<br>
      Sherman Oaks CA 91403
    </p>
  </div>
  <div class="legal">
    <span>© ${new Date().getFullYear()} Designer Wallcoverings. All rights reserved.</span>
    <span>Memo samples are always free.</span>
  </div>
</footer>
</body>
</html>`;
}

// ---------------------------------------------------------------------------
// routes
// ---------------------------------------------------------------------------
app.get('/', (_req, res) => {
  const features = CLUSTERS.map((c, i) => {
    const cluster = CLUSTER_INDEX[c.slug];
    const previews = pickPreviewImages(cluster.products, 4);
    return `
      <article class="feature" style="--accent-color:${c.accent}">
        <div class="num">No. ${String(i + 1).padStart(2, '0')} · ${cluster.products.length} works</div>
        <h2>${esc(c.title)}</h2>
        <div class="accent-bar"></div>
        <p class="tag">${esc(c.tagline)}</p>
        <div class="preview">
          ${previews.map(u => `<a href="/lookbook/${c.slug}"><img loading="lazy" src="${esc(u)}" alt=""></a>`).join('')}
        </div>
        <a class="cta" href="/lookbook/${c.slug}">Open Lookbook →</a>
      </article>
    `;
  }).join('');

  const body = `
    <section class="cover">
      <div class="issue">Issue 01 · Spring 2026</div>
      <h1>Wallpaper, organized the way <em>designers</em> actually shop it &mdash; <span style="color:var(--accent)">by mood.</span></h1>
      <p class="lede">
        Eight aesthetic clusters drawn from over eight thousand wallcoverings.
        Skip the SKU lists; open a lookbook, read the spread, request memo
        samples free.
      </p>
    </section>
    <hr class="divider">
    <section class="letter">
      <div class="letter-inner">
        <div class="letter-meta">
          <div class="kicker">Editor's Letter</div>
          <div class="byline">Spring 2026 &middot; Designer Lookbooks</div>
        </div>
        <div class="letter-body">
          <p>
            Most wallpaper catalogs are organized for the warehouse &mdash; by
            vendor, by SKU, by collection name no one outside the trade
            recognizes. That's fine if you already know what you want. It is
            not how a room actually gets imagined.
          </p>
          <p>
            Designer Lookbooks reorganizes the same eight thousand patterns
            around the way clients describe a brief: "soft and modern," "loud
            florals," "lots of metallic light," "something a little noir."
            Each cluster reads like a magazine feature &mdash; a hero, a
            tagline, an editorial spread &mdash; so you can browse the
            <em>feeling</em> first and the specs second. Memo samples are
            always free.
          </p>
        </div>
      </div>
    </section>
    <hr class="divider">
    <section class="spread">
      ${features}
    </section>
  `;
  res.set('Content-Type', 'text/html; charset=utf-8').send(layout({
    title: 'Visual Lookbooks for Wallcoverings',
    description: 'Eight aesthetic clusters drawn from 8,000+ wallcoverings — a magazine-style index of texture, color, and pattern. Free memo samples.',
    body,
  }));
});

app.get('/lookbooks', (_req, res) => {
  const cards = CLUSTERS.map((c, i) => {
    const cluster = CLUSTER_INDEX[c.slug];
    const previews = pickPreviewImages(cluster.products, 4);
    return `
      <article class="feature" style="--accent-color:${c.accent}">
        <div class="num">No. ${String(i + 1).padStart(2, '0')} · ${cluster.products.length} works</div>
        <h2>${esc(c.title)}</h2>
        <div class="accent-bar"></div>
        <p class="tag">${esc(c.tagline)}</p>
        <div class="preview">
          ${previews.map(u => `<a href="/lookbook/${c.slug}"><img loading="lazy" src="${esc(u)}" alt=""></a>`).join('')}
        </div>
        <a class="cta" href="/lookbook/${c.slug}">Open Lookbook →</a>
      </article>
    `;
  }).join('');

  const body = `
    <section class="cover">
      <div class="issue">Index · All Lookbooks</div>
      <h1>The full directory.</h1>
      <p class="lede">Every aesthetic cluster, side by side.</p>
    </section>
    <hr class="divider">
    <section class="spread">${cards}</section>
  `;
  res.set('Content-Type', 'text/html; charset=utf-8').send(layout({
    title: 'All Lookbooks',
    body,
  }));
});

app.get('/lookbook/:slug', (req, res) => {
  const cluster = CLUSTER_INDEX[req.params.slug];
  if (!cluster) return res.status(404).set('Content-Type', 'text/html').send(layout({
    title: 'Lookbook not found',
    body: '<main class="grid-wrap"><div class="empty">That lookbook does not exist. <a href="/lookbooks">See all lookbooks →</a></div></main>'
  }));

  const sort = (req.query.sort || 'newest').toString();
  const cardMin = parseInt(req.query.cm) || 240;
  const page = parseInt(req.query.page) || 1;

  const sorted = sortProducts(cluster.products, sort);
  const { items, page: cur, pages, total } = paginate(sorted, page, 60);

  const collage = pickPreviewImages(cluster.products, 5);

  const body = `
    <section class="cluster-hero" style="--accent-color:${cluster.accent}">
      <div class="copy">
        <div class="crumb"><a href="/">Designer Lookbooks</a> / Lookbook</div>
        <h1>${esc(cluster.title)}</h1>
        <div class="accent-bar"></div>
        <p>${esc(cluster.tagline)}</p>
        <div class="stat">${cluster.products.length.toLocaleString()} works · ${new Set(cluster.products.map(p => NORM_TYPE(p.product_type))).size} categories</div>
      </div>
      <div class="collage">
        ${collage.slice(0, 5).map(u => `<a href="#grid"><img loading="lazy" src="${esc(u)}" alt=""></a>`).join('')}
      </div>
    </section>
    <div class="toolbar" id="grid">
      <div class="group">
        <label for="sort">Sort</label>
        <select id="sort" onchange="(function(s){try{localStorage.setItem('dl-sort',s.value);}catch(e){}var u=new URL(location);u.searchParams.set('sort',s.value);u.searchParams.delete('page');location=u.toString();})(this)">
          ${[
            ['newest', 'Newest'],
            ['color', 'Color'],
            ['style', 'Style'],
            ['sku', 'SKU A→Z'],
            ['title', 'Title A→Z'],
            ['price-up', 'Price ↑'],
            ['price-down', 'Price ↓'],
          ].map(([v, label]) => `<option value="${v}" ${sort === v ? 'selected' : ''}>${label}</option>`).join('')}
        </select>
      </div>
      <div class="group">
        <label for="cm">Density</label>
        <input type="range" id="cm" min="160" max="420" step="20" value="${cardMin}"
          oninput="document.documentElement.style.setProperty('--card-min', this.value+'px'); try{localStorage.setItem('dl-cm', this.value);}catch(e){}"
          onchange="(function(v){var u=new URL(location);u.searchParams.set('cm',v);location=u.toString();})(this.value)">
      </div>
      <div class="meta">${total.toLocaleString()} works · page ${cur}/${pages}</div>
    </div>
    <main class="grid-wrap">
      ${items.length === 0 ? '<div class="empty">No products in this lookbook yet.</div>' : `<div class="grid" style="--card-min:${cardMin}px">${items.map(p => `
        <article class="card">
          <a class="thumb" href="/p/${esc(p.handle || '')}">
            ${p.image_url ? `<img loading="lazy" src="${esc(p.image_url)}" alt="${esc(p.title)}">` : ''}
          </a>
          <div class="info">
            <a href="/p/${esc(p.handle || '')}">
              <div class="t">${esc(p.title)}</div>
              <div class="sub">${esc(p.dw_sku || '')} · ${esc(NORM_TYPE(p.product_type))}</div>
            </a>
          </div>
          <div class="actions">
            <a href="/p/${esc(p.handle || '')}">View</a>
            <a class="cta" href="${esc(memoSampleUrl(p.handle))}" target="_blank" rel="noopener noreferrer">Order Memo</a>
          </div>
        </article>
      `).join('')}</div>`}
      ${pages > 1 ? `<nav class="pager">
        ${cur > 1 ? `<a href="?${new URLSearchParams({ ...req.query, page: cur - 1 }).toString()}">‹ Prev</a>` : ''}
        ${[...Array(Math.min(pages, 7)).keys()].map(i => {
          let p;
          if (pages <= 7) p = i + 1;
          else if (cur <= 4) p = i + 1;
          else if (cur >= pages - 3) p = pages - 6 + i;
          else p = cur - 3 + i;
          return p === cur ? `<span class="cur">${p}</span>` : `<a href="?${new URLSearchParams({ ...req.query, page: p }).toString()}">${p}</a>`;
        }).join('')}
        ${cur < pages ? `<a href="?${new URLSearchParams({ ...req.query, page: cur + 1 }).toString()}">Next ›</a>` : ''}
      </nav>` : ''}
    </main>
    <script>
      // Hydrate sort + density preferences from localStorage when the URL
      // doesn't already specify them. Sort needs a server round-trip
      // because the grid is server-rendered — redirect once with the
      // saved value so the page renders in the user's chosen order.
      (function() {
        try {
          var url = new URL(location);
          var qs = url.searchParams;
          var savedSort = localStorage.getItem('dl-sort');
          var validSorts = ['newest','color','style','sku','title','price-up','price-down'];
          if (savedSort && validSorts.indexOf(savedSort) >= 0 && !qs.get('sort') && savedSort !== 'newest') {
            qs.set('sort', savedSort);
            location.replace(url.toString());
            return;
          }
          var savedCm = localStorage.getItem('dl-cm');
          if (savedCm && !qs.get('cm')) {
            document.documentElement.style.setProperty('--card-min', savedCm + 'px');
            var grid = document.querySelector('.grid');
            if (grid) grid.style.setProperty('--card-min', savedCm + 'px');
            var el = document.getElementById('cm'); if (el) el.value = savedCm;
          }
        } catch (e) {}
      })();
    </script>
  `;
  res.set('Content-Type', 'text/html; charset=utf-8').send(layout({
    title: cluster.title,
    description: cluster.tagline,
    body,
    og: { image: collage[0] },
  }));
});

app.get('/p/:handle', (req, res) => {
  const p = DATA.find(x => x.handle === req.params.handle);
  if (!p) return res.status(404).set('Content-Type', 'text/html').send(layout({
    title: 'Not found',
    body: '<main class="grid-wrap"><div class="empty">That product is not in this lookbook. <a href="/">Back to home →</a></div></main>',
  }));

  // Find every cluster this product belongs to so we can credit it.
  const inClusters = CLUSTERS.filter(c => matchesCluster(p, c));
  const primaryCluster = inClusters[0];

  const body = `
    <div class="detail">
      <div class="hero">${p.image_url ? `<img src="${esc(p.image_url)}" alt="${esc(p.title)}">` : ''}</div>
      <div>
        <div class="crumb"><a href="/">Designer Lookbooks</a>${primaryCluster ? ` / <a href="/lookbook/${primaryCluster.slug}">${esc(primaryCluster.title)}</a>` : ''}</div>
        ${primaryCluster ? `<div class="pt">${esc(primaryCluster.title)}</div>` : `<div class="pt">${esc(NORM_TYPE(p.product_type))}</div>`}
        <h1>${esc(p.title)}</h1>
        <div class="row"><div class="k">SKU</div><div>${esc(p.dw_sku || '—')}</div></div>
        ${p.mfr_sku ? `<div class="row"><div class="k">Mfr SKU</div><div>${esc(p.mfr_sku)}</div></div>` : ''}
        <div class="row"><div class="k">Type</div><div>${esc(p.product_type || '—')}</div></div>
        ${p.retail_price ? `<div class="row"><div class="k">Retail</div><div>$${parseFloat(p.retail_price).toFixed(2)} /yd</div></div>` : ''}
        ${inClusters.length > 1 ? `<div class="row"><div class="k">Also in</div><div>${inClusters.slice(1).map(c => `<a href="/lookbook/${c.slug}" style="color:var(--accent)">${esc(c.title)}</a>`).join(', ')}</div></div>` : ''}
        <div class="actions">
          <a class="btn btn-primary" href="${esc(memoSampleUrl(p.handle))}" target="_blank" rel="noopener noreferrer">Order Memo Sample (free)</a>
          <a class="btn btn-secondary" href="${esc(shopifyUrl(p.handle))}" target="_blank" rel="noopener noreferrer">Shop on DW</a>
        </div>
      </div>
    </div>
  `;
  res.set('Content-Type', 'text/html; charset=utf-8').send(layout({
    title: p.title,
    description: `${p.title} — ${primaryCluster ? primaryCluster.title : 'Designer Lookbooks'}. Order a free memo sample.`,
    body,
    og: { image: p.image_url },
  }));
});

app.get('/search', (req, res) => {
  const q = (req.query.q || '').toString().toLowerCase().trim();
  const sort = (req.query.sort || 'newest').toString();
  const cardMin = parseInt(req.query.cm) || 240;
  const page = parseInt(req.query.page) || 1;

  let filtered = DATA;
  if (q) {
    const tokens = q.split(/\s+/);
    filtered = filtered.filter(p => {
      const hay = `${p.title} ${p.dw_sku || ''} ${p.mfr_sku || ''} ${p.product_type || ''}`.toLowerCase();
      return tokens.every(t => hay.includes(t));
    });
  }
  const sorted = sortProducts(filtered, sort);
  const { items, page: cur, pages, total } = paginate(sorted, page, 60);

  const body = `
    <section class="cover">
      <div class="issue">Search</div>
      <h1>${q ? `Results for &ldquo;${esc(q)}&rdquo;` : 'Search the catalog'}</h1>
      <p class="lede">${total.toLocaleString()} ${total === 1 ? 'work' : 'works'}.</p>
    </section>
    <div class="toolbar">
      <div class="group">
        <label for="sort">Sort</label>
        <select id="sort" onchange="(function(s){try{localStorage.setItem('dl-sort',s.value);}catch(e){}var u=new URL(location);u.searchParams.set('sort',s.value);u.searchParams.delete('page');location=u.toString();})(this)">
          ${[
            ['newest', 'Newest'],
            ['color', 'Color'],
            ['style', 'Style'],
            ['sku', 'SKU A→Z'],
            ['title', 'Title A→Z'],
          ].map(([v, label]) => `<option value="${v}" ${sort === v ? 'selected' : ''}>${label}</option>`).join('')}
        </select>
      </div>
      <div class="group">
        <label for="cm">Density</label>
        <input type="range" id="cm" min="160" max="420" step="20" value="${cardMin}"
          oninput="document.documentElement.style.setProperty('--card-min', this.value+'px'); try{localStorage.setItem('dl-cm', this.value);}catch(e){}"
          onchange="(function(v){var u=new URL(location);u.searchParams.set('cm',v);location=u.toString();})(this.value)">
      </div>
      <div class="meta">${total.toLocaleString()} works · page ${cur}/${pages}</div>
    </div>
    <main class="grid-wrap">
      ${items.length === 0 ? '<div class="empty">Nothing matched. Try a broader term or <a href="/lookbooks">browse the lookbooks →</a></div>' : `<div class="grid" style="--card-min:${cardMin}px">${items.map(p => `
        <article class="card">
          <a class="thumb" href="/p/${esc(p.handle || '')}">${p.image_url ? `<img loading="lazy" src="${esc(p.image_url)}" alt="${esc(p.title)}">` : ''}</a>
          <div class="info"><a href="/p/${esc(p.handle || '')}">
            <div class="t">${esc(p.title)}</div>
            <div class="sub">${esc(p.dw_sku || '')} · ${esc(NORM_TYPE(p.product_type))}</div>
          </a></div>
          <div class="actions">
            <a href="/p/${esc(p.handle || '')}">View</a>
            <a class="cta" href="${esc(memoSampleUrl(p.handle))}" target="_blank" rel="noopener noreferrer">Order Memo</a>
          </div>
        </article>
      `).join('')}</div>`}
      ${pages > 1 ? `<nav class="pager">
        ${cur > 1 ? `<a href="?${new URLSearchParams({ ...req.query, page: cur - 1 }).toString()}">‹ Prev</a>` : ''}
        ${[...Array(Math.min(pages, 7)).keys()].map(i => {
          let p;
          if (pages <= 7) p = i + 1;
          else if (cur <= 4) p = i + 1;
          else if (cur >= pages - 3) p = pages - 6 + i;
          else p = cur - 3 + i;
          return p === cur ? `<span class="cur">${p}</span>` : `<a href="?${new URLSearchParams({ ...req.query, page: p }).toString()}">${p}</a>`;
        }).join('')}
        ${cur < pages ? `<a href="?${new URLSearchParams({ ...req.query, page: cur + 1 }).toString()}">Next ›</a>` : ''}
      </nav>` : ''}
    </main>
    <script>
      // Hydrate sort + density from localStorage when the URL doesn't
      // already specify them. Mirrors the lookbook page so user choice
      // sticks across the whole storefront.
      (function() {
        try {
          var url = new URL(location);
          var qs = url.searchParams;
          var savedSort = localStorage.getItem('dl-sort');
          var validSorts = ['newest','color','style','sku','title'];
          if (savedSort && validSorts.indexOf(savedSort) >= 0 && !qs.get('sort') && savedSort !== 'newest') {
            qs.set('sort', savedSort);
            location.replace(url.toString());
            return;
          }
          var savedCm = localStorage.getItem('dl-cm');
          if (savedCm && !qs.get('cm')) {
            document.documentElement.style.setProperty('--card-min', savedCm + 'px');
            var grid = document.querySelector('.grid');
            if (grid) grid.style.setProperty('--card-min', savedCm + 'px');
            var el = document.getElementById('cm'); if (el) el.value = savedCm;
          }
        } catch (e) {}
      })();
    </script>
  `;
  res.set('Content-Type', 'text/html; charset=utf-8').send(layout({
    title: q ? `Search: ${q}` : 'Search',
    body,
  }));
});

app.get('/about', (_req, res) => {
  const body = `
    <section class="cover">
      <div class="issue">About</div>
      <h1>An editorial index for the wallcovering world.</h1>
      <p class="lede">
        Designer Lookbooks groups thousands of wallcoverings into eight aesthetic clusters —
        Soft Modernism, Maximalist Flora, Architectural Line, Global Craft, Metallic Light,
        Velvet Noir, Coastal Lab, and Monochrome Study. Each cluster reads like a feature
        spread: a hero image, a tagline, and the full grid of works that fit the mood.
      </p>
      <p class="lede" style="margin-top:18px">
        Memo samples are always free and ship from
        <a href="${DW_SHOPIFY}" style="color:var(--accent);text-decoration:underline">Designer Wallcoverings</a>.
        Questions? <a href="mailto:info@designerlookbooks.com" style="color:var(--accent);text-decoration:underline">info@designerlookbooks.com</a>.
      </p>
    </section>
  `;
  res.set('Content-Type', 'text/html; charset=utf-8').send(layout({
    title: 'About',
    body,
  }));
});

app.get('/health', (_req, res) => res.json({
  ok: true,
  products: DATA.length,
  clusters: CLUSTERS.map(c => ({ slug: c.slug, count: CLUSTER_INDEX[c.slug].products.length })),
}));

app.listen(PORT, BIND, () => {
  console.log(`Designer Lookbooks on http://${BIND}:${PORT} · ${DATA.length} products · ${CLUSTERS.length} clusters`);
});