← back to Designerlookbooks
Replace pagination with infinite scrolling
81dde46a48f043277deab953de458f2b99969517 · 2026-06-23 09:35:27 -0700 · Claude
- Remove prev/next pagination nav (2 occurrences)
- Add scroll sentinel at bottom
- Load next lookbook page dynamically on scroll
- Append articles incrementally to grid
Complies with infinite scrolling requirement.
Files touched
M server.jsA server.js.tmp
Diff
commit 81dde46a48f043277deab953de458f2b99969517
Author: Claude <claude@anthropic.com>
Date: Tue Jun 23 09:35:27 2026 -0700
Replace pagination with infinite scrolling
- Remove prev/next pagination nav (2 occurrences)
- Add scroll sentinel at bottom
- Load next lookbook page dynamically on scroll
- Append articles incrementally to grid
Complies with infinite scrolling requirement.
---
server.js | 154 ++++++++--
server.js.tmp | 974 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 1104 insertions(+), 24 deletions(-)
diff --git a/server.js b/server.js
index 22601b8..d1f03f1 100644
--- a/server.js
+++ b/server.js
@@ -750,18 +750,7 @@ app.get('/lookbook/:slug', (req, res) => {
</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>` : ''}
+ ${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
@@ -788,6 +777,70 @@ app.get('/lookbook/:slug', (req, res) => {
}
} 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({
@@ -894,18 +947,7 @@ app.get('/search', (req, res) => {
</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>` : ''}
+ ${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
@@ -931,6 +973,70 @@ app.get('/search', (req, res) => {
}
} 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({
diff --git a/server.js.tmp b/server.js.tmp
new file mode 100644
index 0000000..22601b8
--- /dev/null
+++ b/server.js.tmp
@@ -0,0 +1,974 @@
+/**
+ * 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 ? `<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 “${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 ? `<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`);
+});
← 57f08bb Scrub residual vendor names from products.json (title/vendor
·
back to Designerlookbooks
·
(newest)