← back to Designerlookbooks
server.js
1081 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 — <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 · Designer Lookbooks</div>
</div>
<div class="letter-body">
<p>
Most wallpaper catalogs are organized for the warehouse — 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 — a hero, a
tagline, an editorial spread — 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 ? `<div id="scroll-sentinel" style="padding:20px;text-align:center;color:var(--ink-faint);font-size:13px;"><span id="loading-status">Loading more…</span></div>` : ''}
</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) {}
})();
// Infinite scroll
(function(){
const grid = document.querySelector('.grid');
const sentinel = document.getElementById('scroll-sentinel');
if (!grid || !sentinel) return;
let currentPage = ${cur};
let totalPages = ${pages};
let isLoading = false;
const scrollObserver = new IntersectionObserver(entries => {
for (const e of entries) {
if (e.isIntersecting && currentPage < totalPages && !isLoading) {
loadNextPage();
}
}
}, { rootMargin: '400px 0px' });
scrollObserver.observe(sentinel);
function loadNextPage() {
if (isLoading || currentPage >= totalPages) return;
isLoading = true;
const nextPage = currentPage + 1;
const url = new URL(location.href);
url.searchParams.set('page', nextPage);
fetch(url.toString())
.then(r => r.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const nextRows = doc.querySelectorAll('.grid > article');
if (nextRows.length === 0) {
currentPage = totalPages;
document.getElementById('loading-status').textContent = 'All lookbooks loaded';
sentinel.style.color = '#999';
return;
}
nextRows.forEach(row => {
grid.appendChild(row.cloneNode(true));
});
currentPage = nextPage;
if (currentPage >= totalPages) {
document.getElementById('loading-status').textContent = 'All lookbooks loaded';
sentinel.style.color = '#999';
} else {
document.getElementById('loading-status').textContent = 'Loading more…';
}
isLoading = false;
})
.catch(err => {
console.error('Failed to load more:', err);
document.getElementById('loading-status').textContent = 'Error loading more';
sentinel.style.color = '#d84315';
isLoading = false;
});
}
})();
</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 “${esc(q)}”` : '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 ? `<div id="scroll-sentinel" style="padding:20px;text-align:center;color:var(--ink-faint);font-size:13px;"><span id="loading-status">Loading more…</span></div>` : ''}
</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) {}
})();
// Infinite scroll
(function(){
const grid = document.querySelector('.grid');
const sentinel = document.getElementById('scroll-sentinel');
if (!grid || !sentinel) return;
let currentPage = ${cur};
let totalPages = ${pages};
let isLoading = false;
const scrollObserver = new IntersectionObserver(entries => {
for (const e of entries) {
if (e.isIntersecting && currentPage < totalPages && !isLoading) {
loadNextPage();
}
}
}, { rootMargin: '400px 0px' });
scrollObserver.observe(sentinel);
function loadNextPage() {
if (isLoading || currentPage >= totalPages) return;
isLoading = true;
const nextPage = currentPage + 1;
const url = new URL(location.href);
url.searchParams.set('page', nextPage);
fetch(url.toString())
.then(r => r.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const nextRows = doc.querySelectorAll('.grid > article');
if (nextRows.length === 0) {
currentPage = totalPages;
document.getElementById('loading-status').textContent = 'All lookbooks loaded';
sentinel.style.color = '#999';
return;
}
nextRows.forEach(row => {
grid.appendChild(row.cloneNode(true));
});
currentPage = nextPage;
if (currentPage >= totalPages) {
document.getElementById('loading-status').textContent = 'All lookbooks loaded';
sentinel.style.color = '#999';
} else {
document.getElementById('loading-status').textContent = 'Loading more…';
}
isLoading = false;
})
.catch(err => {
console.error('Failed to load more:', err);
document.getElementById('loading-status').textContent = 'Error loading more';
sentinel.style.color = '#d84315';
isLoading = false;
});
}
})();
</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`);
});