← back to Naturaltextilewallpaper
Hydrate saved sort from localStorage before first grid load
f812ade524e565267b60f74cf6e184f1ff678f51 · 2026-05-19 07:52:12 -0700 · Steve Abrams
The sort <select> persisted its value but never re-applied it on a
fresh visit, so the catalog always loaded in 'newest' order regardless
of the user's last choice. hydrateSort() now checks localStorage and
issues a window.location.replace with the saved sort BEFORE the grid's
initial loadPage() runs, gating the IntersectionObserver setup on it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M server.jsD server.js.pre-sort-skill
Diff
commit f812ade524e565267b60f74cf6e184f1ff678f51
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue May 19 07:52:12 2026 -0700
Hydrate saved sort from localStorage before first grid load
The sort <select> persisted its value but never re-applied it on a
fresh visit, so the catalog always loaded in 'newest' order regardless
of the user's last choice. hydrateSort() now checks localStorage and
issues a window.location.replace with the saved sort BEFORE the grid's
initial loadPage() runs, gating the IntersectionObserver setup on it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
server.js | 63 ++++---
server.js.pre-sort-skill | 480 -----------------------------------------------
2 files changed, 42 insertions(+), 501 deletions(-)
diff --git a/server.js b/server.js
index fa159d2..93bdf16 100644
--- a/server.js
+++ b/server.js
@@ -138,6 +138,13 @@ const app = express();
app.use(helmet({
contentSecurityPolicy: false // allow inline styles/scripts for the toggle
}));
+// Never serve editor/snapshot files from the static root.
+app.use((req, res, next) => {
+ if (/\.(bak|pre-[\w.-]*|orig|swp|tmp)$|\.bak\.|~$/i.test(req.path)) {
+ return res.status(404).send(page404());
+ }
+ next();
+});
app.use(express.static(path.join(__dirname, 'public')));
// --- API: products (JSON for infinite scroll) ---------------------------
@@ -257,7 +264,7 @@ function headerHTML(q='', material='all', sort='newest') {
<a href="/?material=linen" class="nav-link">Linen</a>
<a href="/?material=grasscloth" class="nav-link">Grasscloth</a>
<a href="/?material=jute" class="nav-link">Jute</a>
- <a href="${DW}" class="nav-link" target="_blank" rel="noopener">Free Samples</a>
+ <a href="${DW}" class="nav-link" target="_blank" rel="noopener noreferrer">Free Samples</a>
<button id="theme-toggle" class="theme-toggle" aria-label="Toggle dark mode">
<svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
<svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"/></svg>
@@ -341,11 +348,11 @@ function renderIndex(q, material, sort) {
</div>
<div class="footer-links">
<a href="mailto:${SITE_EMAIL}">${SITE_EMAIL}</a>
- <a href="${DW}" target="_blank" rel="noopener">Designer Wallcoverings</a>
- <a href="${DW}/pages/trade-program" target="_blank" rel="noopener">Trade Program</a>
- <a href="${DW}/pages/contact" target="_blank" rel="noopener">Contact</a>
+ <a href="${DW}" target="_blank" rel="noopener noreferrer">Designer Wallcoverings</a>
+ <a href="${DW}/pages/trade-program" target="_blank" rel="noopener noreferrer">Trade Program</a>
+ <a href="${DW}/pages/contact" target="_blank" rel="noopener noreferrer">Contact</a>
</div>
- <p class="footer-legal">All patterns and images courtesy of <a href="${DW}" target="_blank" rel="noopener">designerwallcoverings.com</a>. Free memo samples available on every product.</p>
+ <p class="footer-legal">All patterns and images courtesy of <a href="${DW}" target="_blank" rel="noopener noreferrer">designerwallcoverings.com</a>. Free memo samples available on every product.</p>
</div>
</footer>
@@ -398,7 +405,7 @@ function renderIndex(q, material, sort) {
+ '<span class="card-title">'+escHtml(p.title)+'</span>'
+ priceStr
+ '</div>'
- + '<a href="https://designerwallcoverings.com/products/'+p.handle+'#sample" class="sample-cta" target="_blank" rel="noopener" onclick="event.stopPropagation()">Order Memo Sample (free)</a>'
+ + '<a href="https://designerwallcoverings.com/products/'+p.handle+'#sample" class="sample-cta" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">Order Memo Sample (free)</a>'
+ '</a>';
}
@@ -436,20 +443,34 @@ function renderIndex(q, material, sort) {
.catch(function(){NTW.loading=false;document.getElementById('gridLoader').style.display='none';});
}
- // IntersectionObserver sentinel
- var obs = new IntersectionObserver(function(entries){
- if (entries[0].isIntersecting) loadPage();
- }, { rootMargin: '600px 0px' });
- obs.observe(document.getElementById('sentinel'));
- loadPage(); // initial load
-
- // Restore sort from localStorage
- (function(){
- var sel=document.getElementById('sortSelect');if(!sel)return;
+ // Restore sort from localStorage — sort is server-driven, so if the
+ // saved value differs from what the server rendered, reload with it
+ // BEFORE the first grid paints. Returns true if a redirect was issued.
+ function hydrateSort(){
+ var sel=document.getElementById('sortSelect');if(!sel)return false;
var saved=localStorage.getItem('ntw_sort');
- // Sort is server-driven, just persist selection
sel.addEventListener('change',function(){localStorage.setItem('ntw_sort',this.value);});
- })();
+ if(saved && saved!==NTW.sort){
+ var has=Array.prototype.some.call(sel.options,function(o){return o.value===saved;});
+ if(has){
+ var u=new URL(window.location.href);
+ u.searchParams.set('sort',saved);
+ window.location.replace(u.toString());
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // IntersectionObserver sentinel — only wire up + initial load if we are
+ // NOT about to redirect to the saved sort.
+ if(!hydrateSort()){
+ var obs = new IntersectionObserver(function(entries){
+ if (entries[0].isIntersecting) loadPage();
+ }, { rootMargin: '600px 0px' });
+ obs.observe(document.getElementById('sentinel'));
+ loadPage(); // initial load
+ }
</script>
</body>
</html>`;
@@ -478,8 +499,8 @@ function renderDetail(p, matLabel) {
${p.vendor ? `<p class="detail-vendor">by ${escHtml(p.vendor)}</p>` : ''}
${p.max_price > 0 ? `<p class="detail-price">From <strong>$${Number(p.min_price).toFixed(2)}</strong></p>` : ''}
<div class="detail-ctas">
- <a href="${sample_url}" class="btn-primary" target="_blank" rel="noopener">Order Memo Sample (free)</a>
- <a href="${dw_url}" class="btn-secondary" target="_blank" rel="noopener">View on Designer Wallcoverings</a>
+ <a href="${sample_url}" class="btn-primary" target="_blank" rel="noopener noreferrer">Order Memo Sample (free)</a>
+ <a href="${dw_url}" class="btn-secondary" target="_blank" rel="noopener noreferrer">View on Designer Wallcoverings</a>
</div>
<div class="detail-spec-panel">
<p class="spec-label">MATERIAL</p><p class="spec-value">${matLabel}</p>
@@ -500,7 +521,7 @@ function renderDetail(p, matLabel) {
</div>
<div class="footer-links">
<a href="mailto:${SITE_EMAIL}">${SITE_EMAIL}</a>
- <a href="${DW}" target="_blank" rel="noopener">Designer Wallcoverings</a>
+ <a href="${DW}" target="_blank" rel="noopener noreferrer">Designer Wallcoverings</a>
</div>
</div>
</footer>
diff --git a/server.js.pre-sort-skill b/server.js.pre-sort-skill
deleted file mode 100644
index cc18cc2..0000000
--- a/server.js.pre-sort-skill
+++ /dev/null
@@ -1,480 +0,0 @@
-/**
- * NATURAL TEXTILE WALLPAPER — DW family category aggregator
- * Curated from live designerwallcoverings.com Shopify catalog.
- * Covers: linen, jute, raffia, sisal, hemp, grasscloth, woven, seagrass, abaca, bamboo.
- * Design: Loro Piana heirloom mill aesthetic — warm cream/oatmeal/charcoal, Futura-feel sans.
- * Port: 9835
- */
-const express = require('express');
-const helmet = require('helmet');
-const path = require('path');
-const fs = require('fs');
-
-const PORT = process.env.PORT || 9835;
-const DW = 'https://designerwallcoverings.com';
-const SITE_NAME = 'Natural Textile Wallpaper';
-const SITE_DOMAIN = 'naturaltextilewallpaper.com';
-const SITE_EMAIL = 'info@naturaltextilewallpaper.com';
-const GA_ID = process.env.GA_ID || '';
-
-// --- Data load ----------------------------------------------------------
-let DATA_RAW;
-try {
- DATA_RAW = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8'));
- if (!Array.isArray(DATA_RAW)) throw new Error('products.json must be an array');
-} catch(e) {
- console.error(`[NTW] FATAL: products.json load failed — ${e.message}`);
- DATA_RAW = [];
-}
-
-// --- Junk filter --------------------------------------------------------
-function isJunk(p) {
- if (!p.image_url || !p.image_url.trim()) return true;
- if (!p.handle) return true;
- const t = p.title || '';
- if (/lamp|rug|pillow|throw|tripod|frame|mirror|vase|candle|sculpture|figurine/i.test(t)) return true;
- if (/visual.{0,3}merchandiser|bh.?90210|beverly.?hills.?90210/i.test(t)) return true;
- if (/(?:^|\W)image[ _-]?4(?:\W|$)/i.test(t)) return true;
- const sku = p.sku || p.handle || '';
- if (/^\d{13,}$/.test(sku)) return true; // variant_id leak
- return false;
-}
-
-const PRODUCTS = DATA_RAW.filter(p => !isJunk(p));
-console.log(`[NTW] Loaded ${DATA_RAW.length} → kept ${PRODUCTS.length} after junk filter`);
-
-// --- Material labels (the 7 filter pills) --------------------------------
-const MATERIALS = [
- { key: 'linen', label: 'Linen' },
- { key: 'jute', label: 'Jute' },
- { key: 'raffia', label: 'Raffia' },
- { key: 'sisal', label: 'Sisal' },
- { key: 'hemp', label: 'Hemp' },
- { key: 'grasscloth', label: 'Grasscloth' },
- { key: 'bamboo', label: 'Bamboo' },
- { key: 'woven', label: 'Woven' },
- { key: 'natural', label: 'Natural' },
-];
-
-// --- Sort ---------------------------------------------------------------
-const COLOR_RANK = {
- black:0,gray:1,grey:1,silver:2,white:3,ivory:4,cream:5,beige:6,brown:7,tan:8,khaki:9,copper:10,
- red:20,pink:21,coral:22,orange:23,peach:24,salmon:25,
- yellow:30,gold:31,mustard:32,olive:33,
- green:40,lime:41,mint:42,teal:50,aqua:51,turquoise:52,cyan:53,
- blue:60,navy:61,indigo:62,periwinkle:63,
- purple:70,violet:71,lavender:72,plum:73,magenta:74,fuchsia:75,
-};
-function colorRank(p) {
- const tags = (p.tags||[]).map(t=>String(t).toLowerCase());
- for (const t of tags) for (const k of Object.keys(COLOR_RANK)) if (t.includes(k)) return COLOR_RANK[k];
- return 999;
-}
-const STYLE_TAGS = ['traditional','transitional','modern','contemporary','minimalist','art deco','victorian','mid-century','rustic','industrial','farmhouse','bohemian','geometric','floral','botanical','damask','toile','grasscloth','linen','natural'];
-function styleKey(p) {
- const tags = (p.tags||[]).map(t=>String(t).toLowerCase());
- for (const s of STYLE_TAGS) if (tags.some(t=>t.includes(s))) return s;
- return 'zzz';
-}
-function sortProducts(list, mode) {
- if (mode==='sku') return [...list].sort((a,b)=>String(a.sku||a.handle||'').localeCompare(String(b.sku||b.handle||'')));
- if (mode==='title') return [...list].sort((a,b)=>String(a.title||'').localeCompare(String(b.title||'')));
- if (mode==='color') return [...list].sort((a,b)=>colorRank(a)-colorRank(b)||String(a.title||'').localeCompare(String(b.title||'')));
- if (mode==='style') return [...list].sort((a,b)=>styleKey(a).localeCompare(styleKey(b))||String(a.title||'').localeCompare(String(b.title||'')));
- if (mode==='price-asc') return [...list].sort((a,b)=>(Number(a.max_price)||0)-(Number(b.max_price)||0));
- if (mode==='price-desc') return [...list].sort((a,b)=>(Number(b.max_price)||0)-(Number(a.max_price)||0));
- return list; // newest = loaded order
-}
-
-// --- Express app --------------------------------------------------------
-const app = express();
-app.use(helmet({
- contentSecurityPolicy: false // allow inline styles/scripts for the toggle
-}));
-app.use(express.static(path.join(__dirname, 'public')));
-
-// --- API: products (JSON for infinite scroll) ---------------------------
-app.get('/api/products', (req, res) => {
- const { q, material, sort='newest', page=1, limit=24 } = req.query;
- let list = PRODUCTS;
- if (q) {
- const tokens = q.toLowerCase().split(/\s+/).filter(Boolean);
- list = list.filter(p => {
- const blob = ((p.title||'')+' '+(p.sku||'')+' '+(p.tags||[]).join(' ')).toLowerCase();
- return tokens.every(tok => blob.includes(tok));
- });
- }
- if (material && material !== 'all') {
- list = list.filter(p => p.material === material);
- }
- list = sortProducts(list, sort);
- const total = list.length;
- const pg = Math.max(1, parseInt(page)||1);
- const lim = Math.min(60, parseInt(limit)||24);
- const start = (pg-1)*lim;
- res.json({ total, page:pg, limit:lim, pages: Math.ceil(total/lim), sort, items: list.slice(start, start+lim) });
-});
-
-// --- API: sliders (rails per material) ----------------------------------
-app.get('/api/sliders', (req, res) => {
- const rails = [];
- for (const m of MATERIALS) {
- const items = PRODUCTS.filter(p => p.material === m.key).slice(0, 12);
- if (items.length >= 4) rails.push({ material: m.key, label: m.label, items });
- }
- res.json({ rails });
-});
-
-// --- API: material counts for filter pills ------------------------------
-app.get('/api/facets', (req, res) => {
- const counts = {};
- for (const p of PRODUCTS) counts[p.material] = (counts[p.material]||0) + 1;
- res.json({ counts, total: PRODUCTS.length });
-});
-
-// --- Product detail page ------------------------------------------------
-app.get('/p/:handle', (req, res) => {
- const p = PRODUCTS.find(x => x.handle === req.params.handle);
- if (!p) return res.status(404).send(page404());
- const matLabel = MATERIALS.find(m=>m.key===p.material)?.label || 'Natural Textile';
- res.send(renderDetail(p, matLabel));
-});
-
-// --- Health check -------------------------------------------------------
-app.get('/health', (req, res) => {
- const counts = {};
- for (const p of PRODUCTS) counts[p.material] = (counts[p.material]||0) + 1;
- res.json({ ok: true, products: PRODUCTS.length, materials: counts, domain: SITE_DOMAIN });
-});
-
-// --- Main catalog page --------------------------------------------------
-app.get('/', (req, res) => {
- const { q='', material='all', sort='newest' } = req.query;
- res.send(renderIndex(q, material, sort));
-});
-
-// --- HTML templates -----------------------------------------------------
-const GTAG = GA_ID ? `
- <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>
-` : '';
-
-function headHTML(title, description) {
- return `<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <script>
- (function(){try{var t=localStorage.getItem('ntw_theme');if(!t){t=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.setAttribute('data-theme',t);}catch(e){}})();
- </script>
- <title>${title}</title>
- <meta name="description" content="${description}">
- <meta name="theme-color" content="#f5ede0" media="(prefers-color-scheme: light)">
- <meta name="theme-color" content="#1a1410" media="(prefers-color-scheme: dark)">
- ${GTAG}
- <link rel="preconnect" href="https://fonts.googleapis.com">
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
- <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,300;1,400&family=Montserrat:wght@300;400;500;600&display=swap" rel="stylesheet">
- <link rel="stylesheet" href="/css/site.css">
- <script type="application/ld+json">
- {"@context":"https://schema.org","@type":"Organization","name":"Natural Textile Wallpaper","url":"https://${SITE_DOMAIN}","email":"${SITE_EMAIL}","description":"${description}","sameAs":["https://designerwallcoverings.com"]}
- </script>
-</head>
-<body>`;
-}
-
-function headerHTML(q='', material='all', sort='newest') {
- const matOptions = [
- {key:'all', label:'All Materials'},
- ...MATERIALS
- ];
- const sortOptions = [
- {key:'newest', label:'Newest'},
- {key:'color', label:'Color'},
- {key:'style', label:'Style'},
- {key:'sku', label:'SKU A→Z'},
- {key:'title', label:'Title A→Z'},
- {key:'price-asc', label:'Price ↑'},
- {key:'price-desc', label:'Price ↓'},
- ];
- return `
- <header class="site-header">
- <div class="header-inner">
- <a href="/" class="brand">
- <span class="brand-mark">Natural Textile<br>Wallpaper</span>
- <small class="brand-sub">Curated by Designer Wallcoverings</small>
- </a>
- <nav class="header-nav">
- <a href="/" class="nav-link">Collection</a>
- <a href="/?material=linen" class="nav-link">Linen</a>
- <a href="/?material=grasscloth" class="nav-link">Grasscloth</a>
- <a href="/?material=jute" class="nav-link">Jute</a>
- <a href="${DW}" class="nav-link" target="_blank" rel="noopener">Free Samples</a>
- <button id="theme-toggle" class="theme-toggle" aria-label="Toggle dark mode">
- <svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
- <svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"/></svg>
- </button>
- </nav>
- </div>
- </header>
-
- <div class="search-bar-wrap">
- <form class="search-form" method="GET" action="/" id="searchForm">
- <div class="search-row">
- <div class="search-input-wrap">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" class="search-icon"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
- <input type="text" name="q" id="searchInput" value="${escHtml(q)}" placeholder="Search 4,000+ natural fiber wallcoverings…" class="search-input" autocomplete="off">
- ${q ? `<button type="button" class="search-clear" onclick="document.getElementById('searchInput').value='';document.getElementById('searchForm').submit();">✕</button>` : ''}
- </div>
- <div class="controls-row">
- <select name="sort" id="sortSelect" class="ctrl-select" onchange="this.form.submit()">
- ${sortOptions.map(o=>`<option value="${o.key}"${sort===o.key?' selected':''}>${o.label}</option>`).join('')}
- </select>
- <div class="density-wrap">
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
- <input type="range" id="densitySlider" min="120" max="300" value="220" class="density-slider" title="Card size">
- </div>
- </div>
- </div>
- <input type="hidden" name="material" value="${escHtml(material)}">
- </form>
- </div>
-
- <div class="material-pills">
- ${matOptions.map(m=>`
- <a href="/?material=${m.key}${q?'&q='+encodeURIComponent(q):''}${sort!=='newest'?'&sort='+sort:''}"
- class="pill${material===m.key?' pill-active':''}" data-mat="${m.key}">${m.label}</a>
- `).join('')}
- </div>`;
-}
-
-function renderIndex(q, material, sort) {
- const matLabel = material==='all' ? 'All Natural Fibers' : (MATERIALS.find(m=>m.key===material)?.label || material);
- const heroMaterial = material!=='all' ? material : 'linen';
- const heroProduct = PRODUCTS.find(p => p.material === heroMaterial) || PRODUCTS[0];
-
- return headHTML(
- `Natural Textile Wallpaper — Free Memo Samples from Designer Wallcoverings | ${matLabel}`,
- `Shop linen, jute, raffia, sisal, hemp, grasscloth and woven natural-fiber wallcoverings. Free memo samples, trade service, and expert hand-holding from Designer Wallcoverings.`
- ) + headerHTML(q, material, sort) + `
-
- ${q==='' && material==='all' ? `
- <section class="hero">
- <div class="hero-image-wrap">
- ${heroProduct ? `<img src="${escHtml(heroProduct.image_url)}" alt="Natural fiber weave detail — ${escHtml(heroProduct.title)}" class="hero-img" loading="eager">` : ''}
- <div class="hero-overlay"></div>
- </div>
- <div class="hero-content">
- <p class="hero-eyebrow">Heirloom Natural Materials</p>
- <h1 class="hero-headline">Where Earth<br>Meets Wall</h1>
- <p class="hero-sub">Linen. Jute. Raffia. Sisal. Hemp. Grasscloth.<br>The complete collection of natural-fiber wallcoverings, curated for biophilic interiors.</p>
- <a href="#catalog" class="hero-cta">Explore the Collection</a>
- </div>
- </section>
- ` : ''}
-
- <div id="catalog" class="catalog-wrap">
- <div class="catalog-meta" id="catalogMeta">
- <span class="loading-text">Loading collection…</span>
- </div>
- <div class="product-grid" id="productGrid"></div>
- <div class="grid-sentinel" id="sentinel"></div>
- <div class="grid-end" id="gridEnd" style="display:none">— end of archive · <span id="totalCount">0</span> patterns —</div>
- <div class="grid-loader" id="gridLoader">
- <div class="loader-dots"><span></span><span></span><span></span></div>
- </div>
- </div>
-
- <footer class="site-footer">
- <div class="footer-inner">
- <div class="footer-brand">
- <span class="footer-name">Natural Textile Wallpaper</span>
- <span class="footer-tagline">Curated natural-fiber wallcoverings for biophilic interiors</span>
- </div>
- <div class="footer-links">
- <a href="mailto:${SITE_EMAIL}">${SITE_EMAIL}</a>
- <a href="${DW}" target="_blank" rel="noopener">Designer Wallcoverings</a>
- <a href="${DW}/pages/trade-program" target="_blank" rel="noopener">Trade Program</a>
- <a href="${DW}/pages/contact" target="_blank" rel="noopener">Contact</a>
- </div>
- <p class="footer-legal">All patterns and images courtesy of <a href="${DW}" target="_blank" rel="noopener">designerwallcoverings.com</a>. Free memo samples available on every product.</p>
- </div>
- </footer>
-
- <script>
- // ---- Dark/Light toggle ----
- (function(){
- var btn=document.getElementById('theme-toggle');if(!btn)return;
- function sync(){var t=document.documentElement.getAttribute('data-theme');btn.setAttribute('aria-pressed',t==='dark'?'true':'false');}
- sync();
- btn.addEventListener('click',function(){
- var cur=document.documentElement.getAttribute('data-theme')||'light';
- var nxt=cur==='dark'?'light':'dark';
- document.documentElement.setAttribute('data-theme',nxt);
- try{localStorage.setItem('ntw_theme',nxt);}catch(e){}
- sync();
- });
- })();
-
- // ---- Density slider ----
- (function(){
- var slider=document.getElementById('densitySlider');if(!slider)return;
- var saved=localStorage.getItem('ntw_density');
- if(saved)slider.value=saved;
- function applyDensity(v){document.documentElement.style.setProperty('--card-min',''+v+'px');}
- applyDensity(slider.value);
- slider.addEventListener('input',function(){applyDensity(this.value);localStorage.setItem('ntw_density',this.value);});
- })();
-
- // ---- Infinite scroll catalog ----
- var NTW = {
- page: 1,
- pages: 999,
- total: 0,
- loading: false,
- exhausted: false,
- q: ${JSON.stringify(q)},
- material: ${JSON.stringify(material)},
- sort: ${JSON.stringify(sort)}
- };
-
- function productCard(p) {
- var priceStr = '';
- if (p.max_price > 0) priceStr = '<span class="card-price">From $' + p.min_price.toFixed(2) + '</span>';
- var matLabel = ${JSON.stringify(MATERIALS.reduce((a,m)=>({...a,[m.key]:m.label}),{}))};
- var mat = matLabel[p.material] || p.material || '';
- return '<a href="/p/'+p.handle+'" class="product-card">'
- + '<div class="card-img-wrap"><img src="'+p.image_url+'" alt="'+escHtml(p.title)+'" loading="lazy" class="card-img"></div>'
- + '<div class="card-info">'
- + '<span class="card-material">'+mat+'</span>'
- + '<span class="card-title">'+escHtml(p.title)+'</span>'
- + priceStr
- + '</div>'
- + '<a href="https://designerwallcoverings.com/products/'+p.handle+'#sample" class="sample-cta" target="_blank" rel="noopener" onclick="event.stopPropagation()">Order Memo Sample (free)</a>'
- + '</a>';
- }
-
- function escHtml(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
-
- function loadPage() {
- if (NTW.loading || NTW.exhausted) return;
- NTW.loading = true;
- document.getElementById('gridLoader').style.display = 'flex';
- var params = new URLSearchParams({
- q: NTW.q,
- material: NTW.material,
- sort: NTW.sort,
- page: NTW.page,
- limit: 24
- });
- fetch('/api/products?' + params.toString())
- .then(function(r){return r.json();})
- .then(function(data){
- NTW.pages = data.pages;
- NTW.total = data.total;
- document.getElementById('totalCount').textContent = data.total;
- document.getElementById('catalogMeta').textContent = data.total + ' patterns' + (NTW.q ? ' for "' + NTW.q + '"' : '') + (NTW.material!=='all' ? ' in ' + NTW.material : '');
- var grid = document.getElementById('productGrid');
- data.items.forEach(function(p){ grid.insertAdjacentHTML('beforeend', productCard(p)); });
- NTW.page++;
- NTW.loading = false;
- document.getElementById('gridLoader').style.display = 'none';
- if (NTW.page > NTW.pages) {
- NTW.exhausted = true;
- document.getElementById('sentinel').style.display = 'none';
- document.getElementById('gridEnd').style.display = 'block';
- }
- })
- .catch(function(){NTW.loading=false;document.getElementById('gridLoader').style.display='none';});
- }
-
- // IntersectionObserver sentinel
- var obs = new IntersectionObserver(function(entries){
- if (entries[0].isIntersecting) loadPage();
- }, { rootMargin: '600px 0px' });
- obs.observe(document.getElementById('sentinel'));
- loadPage(); // initial load
-
- // Restore sort from localStorage
- (function(){
- var sel=document.getElementById('sortSelect');if(!sel)return;
- var saved=localStorage.getItem('ntw_sort');
- // Sort is server-driven, just persist selection
- sel.addEventListener('change',function(){localStorage.setItem('ntw_sort',this.value);});
- })();
- </script>
-</body>
-</html>`;
-}
-
-function renderDetail(p, matLabel) {
- const dw_url = `${DW}/products/${p.handle}`;
- const sample_url = `${dw_url}#sample`;
- const images = p.images && p.images.length > 1 ? p.images : [p.image_url];
- return headHTML(
- `${p.title} — Free Memo Sample | Natural Textile Wallpaper`,
- `Explore ${p.title} — a ${matLabel} wallcovering available with free memo samples and trade service from Designer Wallcoverings. Order yours today.`
- ) + headerHTML() + `
- <div class="detail-wrap">
- <nav class="breadcrumb">
- <a href="/">Collection</a> / <a href="/?material=${p.material}">${matLabel}</a> / <span>${escHtml(p.title)}</span>
- </nav>
- <div class="detail-grid">
- <div class="detail-images">
- <img src="${escHtml(images[0])}" alt="${escHtml(p.title)}" class="detail-hero-img" id="detailMainImg">
- ${images.length > 1 ? `<div class="detail-thumbs">${images.map((img,i)=>`<img src="${escHtml(img)}" alt="View ${i+1}" class="detail-thumb" onclick="document.getElementById('detailMainImg').src='${escHtml(img)}';">`).join('')}</div>` : ''}
- </div>
- <div class="detail-info">
- <span class="detail-material-pill">${matLabel}</span>
- <h1 class="detail-title">${escHtml(p.title)}</h1>
- ${p.vendor ? `<p class="detail-vendor">by ${escHtml(p.vendor)}</p>` : ''}
- ${p.max_price > 0 ? `<p class="detail-price">From <strong>$${Number(p.min_price).toFixed(2)}</strong></p>` : ''}
- <div class="detail-ctas">
- <a href="${sample_url}" class="btn-primary" target="_blank" rel="noopener">Order Memo Sample (free)</a>
- <a href="${dw_url}" class="btn-secondary" target="_blank" rel="noopener">View on Designer Wallcoverings</a>
- </div>
- <div class="detail-spec-panel">
- <p class="spec-label">MATERIAL</p><p class="spec-value">${matLabel}</p>
- ${p.product_type ? `<p class="spec-label">TYPE</p><p class="spec-value">${escHtml(p.product_type)}</p>` : ''}
- ${p.tags && p.tags.length ? `<p class="spec-label">TAGS</p><p class="spec-value spec-tags">${p.tags.slice(0,12).map(t=>`<span class="spec-tag">${escHtml(t)}</span>`).join('')}</p>` : ''}
- <p class="spec-label">SKU</p><p class="spec-value">${escHtml(p.sku||p.handle)}</p>
- </div>
- <p class="detail-sample-note">Free memo samples ship within 2–3 business days. No commitment required. Designer Wallcoverings offers white-glove trade service for all natural-fiber installations.</p>
- </div>
- </div>
- <div class="detail-back"><a href="/?material=${p.material}" class="back-link">← Back to ${matLabel}</a></div>
- </div>
- <footer class="site-footer">
- <div class="footer-inner">
- <div class="footer-brand">
- <span class="footer-name">Natural Textile Wallpaper</span>
- <span class="footer-tagline">Curated by Designer Wallcoverings</span>
- </div>
- <div class="footer-links">
- <a href="mailto:${SITE_EMAIL}">${SITE_EMAIL}</a>
- <a href="${DW}" target="_blank" rel="noopener">Designer Wallcoverings</a>
- </div>
- </div>
- </footer>
- <script>
- (function(){var btn=document.getElementById('theme-toggle');if(!btn)return;function sync(){var t=document.documentElement.getAttribute('data-theme');btn.setAttribute('aria-pressed',t==='dark'?'true':'false');}sync();btn.addEventListener('click',function(){var cur=document.documentElement.getAttribute('data-theme')||'light';var nxt=cur==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',nxt);try{localStorage.setItem('ntw_theme',nxt);}catch(e){}sync();});})();
- </script>
-</body>
-</html>`;
-}
-
-function page404() {
- return headHTML('Not Found | Natural Textile Wallpaper', 'Page not found') + `
- <div style="text-align:center;padding:80px 20px">
- <h1>Pattern not found</h1>
- <p><a href="/">Browse all natural textile wallcoverings →</a></p>
- </div>
- </body></html>`;
-}
-
-function escHtml(s) {
- return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
-}
-
-app.listen(PORT, () => console.log(`[NTW] Running on port ${PORT} — ${PRODUCTS.length} products`));
← 10d0c18 snapshot: 1 file(s) changed, +1 new
·
back to Naturaltextilewallpaper
·
Untrack server.js.pre-sort-skill snapshot; broaden .gitignor d25f742 →