← back to All Designerwallcoverings
full-catalog browser: in-memory dw_unified snapshot (153k products), word-anchored search, 9 sortable fields, chip-toggle facets (vendor/type/color/style/material/price/status/image) w/ type-ahead + expanders, density + infinite scroll; vendor directory preserved at /vendors.html
0ff51f2c63b2a99eefc49709d30ab38e4fb13196 · 2026-07-02 16:09:31 -0700 · Steve Abrams
Files touched
A package-lock.jsonM package.jsonM public/index.htmlM server.js
Diff
commit 0ff51f2c63b2a99eefc49709d30ab38e4fb13196
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 2 16:09:31 2026 -0700
full-catalog browser: in-memory dw_unified snapshot (153k products), word-anchored search, 9 sortable fields, chip-toggle facets (vendor/type/color/style/material/price/status/image) w/ type-ahead + expanders, density + infinite scroll; vendor directory preserved at /vendors.html
---
package-lock.json | 174 +++++++++++++++++++++++
package.json | 9 +-
public/index.html | 405 ++++++++++++++++++++++++++++++++++--------------------
server.js | 248 +++++++++++++++++++++++++++------
4 files changed, 649 insertions(+), 187 deletions(-)
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..b2dfa99
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,174 @@
+{
+ "name": "all-designerwallcoverings",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "all-designerwallcoverings",
+ "version": "1.0.0",
+ "dependencies": {
+ "dotenv": "^17.4.2",
+ "pg": "^8.22.0"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "17.4.2",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
+ "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/pg": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
+ "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.15.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
+ "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
index e818dff..a28682c 100644
--- a/package.json
+++ b/package.json
@@ -3,5 +3,12 @@
"version": "1.0.0",
"description": "Front-facing vendor directory for all dw_unified vendors — all.designerwallcoverings.com",
"main": "server.js",
- "scripts": { "start": "node server.js", "build-data": "python3 scripts/build-data.py" }
+ "scripts": {
+ "start": "node server.js",
+ "build-data": "python3 scripts/build-data.py"
+ },
+ "dependencies": {
+ "dotenv": "^17.4.2",
+ "pg": "^8.22.0"
+ }
}
diff --git a/public/index.html b/public/index.html
index c625495..c3a2523 100644
--- a/public/index.html
+++ b/public/index.html
@@ -3,176 +3,287 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>All Vendors — Designer Wallcoverings</title>
-<meta name="description" content="The complete vendor index at Designer Wallcoverings — every wallcovering and fabric house we carry, in one place.">
+<title>All Products — Designer Wallcoverings</title>
<style>
- :root { --cols: 4; --ink:#161412; --paper:#f7f5f1; --gold:#a08a5f; --line:#dedad2; }
- * { box-sizing: border-box; margin: 0; }
- body { background: var(--paper); color: var(--ink); font-family: "Optima", "Segoe UI", system-ui, sans-serif; }
- a { color: inherit; text-decoration: none; }
-
- header { display:flex; justify-content:space-between; align-items:center;
- padding: 22px 34px; position: sticky; top:0; background: rgba(247,245,241,.92);
- backdrop-filter: blur(8px); z-index: 20; border-bottom: 1px solid var(--line); }
- .wordmark { letter-spacing: .22em; font-size: 15px; text-transform: uppercase; font-weight: 600; }
- .wordmark small { display:block; letter-spacing:.34em; font-size:9px; color:var(--gold); margin-top:3px; }
- .burger { cursor:pointer; background:none; border:none; padding:8px; }
- .burger span { display:block; width:24px; height:1.5px; background:var(--ink); margin:5px 0; }
- nav.menu { display:none; position:fixed; top:0; right:0; bottom:0; width:290px; background:#141210;
- color:#eee9df; z-index:40; padding:80px 40px; flex-direction:column; gap:26px;
- font-size:13px; letter-spacing:.18em; text-transform:uppercase; }
- nav.menu.open { display:flex; }
- nav.menu .close { position:absolute; top:24px; right:28px; font-size:26px; cursor:pointer; background:none; border:none; color:#eee9df; }
- nav.menu a:hover { color: var(--gold); }
-
- .hero { padding: 74px 34px 58px; text-align:center; background:#141210; color:#f2eee6; }
- .hero h1 { font-family: Georgia, serif; font-weight: 400; font-size: clamp(30px, 5vw, 52px); letter-spacing:.04em; }
- .hero p { margin-top:14px; color:#b6ad9c; font-size:15px; letter-spacing:.08em; }
- .hero .stats { margin-top: 26px; display:flex; gap:44px; justify-content:center; font-size:12px; letter-spacing:.24em; text-transform:uppercase; color:var(--gold); }
- .hero .stats b { display:block; font-size:26px; color:#f2eee6; font-family: Georgia, serif; font-weight:400; letter-spacing:.02em; }
-
- .controls { display:flex; flex-wrap:wrap; gap:14px 22px; align-items:center;
- padding: 20px 34px; border-bottom:1px solid var(--line); background:var(--paper);
- position: sticky; top: 69px; z-index: 15; }
- .controls input[type=search] { flex:1 1 220px; max-width: 340px; padding:10px 14px; border:1px solid var(--line);
- background:#fff; font-size:14px; border-radius:2px; }
- .controls select { padding:10px 12px; border:1px solid var(--line); background:#fff; font-size:13px; border-radius:2px; }
- .seg { display:flex; border:1px solid var(--line); border-radius:2px; overflow:hidden; }
- .seg button { padding:9px 16px; background:#fff; border:none; font-size:12px; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; color:#7d766a; }
- .seg button.on { background:var(--ink); color:#f2eee6; }
- .dens { display:flex; align-items:center; gap:10px; font-size:11px; letter-spacing:.16em; text-transform:uppercase; color:#7d766a; }
- .dens input { accent-color: var(--gold); width: 130px; }
-
- .grid { display:grid; grid-template-columns: repeat(var(--cols), 1fr); gap: 26px; padding: 34px; }
- .card { background:#fff; border:1px solid var(--line); overflow:hidden; display:flex; flex-direction:column;
- transition: box-shadow .25s, transform .25s; }
- .card:hover { box-shadow: 0 14px 34px rgba(22,20,18,.14); transform: translateY(-3px); }
- .collage { display:grid; grid-template-columns:1fr 1fr; grid-template-rows:1fr 1fr; aspect-ratio: 4/3; gap:1px; background:var(--line); }
- .collage.single { grid-template-columns:1fr; grid-template-rows:1fr; }
- .collage img { width:100%; height:100%; object-fit:cover; display:block; }
- .collage .ph { display:flex; align-items:center; justify-content:center; background:#efece6; color:#c6bfb1;
- font-family:Georgia,serif; font-size:34px; }
- .card .meta { padding: 16px 18px 18px; }
- .card h3 { font-family: Georgia, serif; font-weight:400; font-size:17px; letter-spacing:.03em; }
- .card .sub { margin-top:6px; font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:#9a927f; }
- .card .cta { margin-top:12px; font-size:11px; letter-spacing:.22em; text-transform:uppercase; color:var(--gold); }
-
- .sentinel { height: 10px; }
- footer { padding: 44px 34px 60px; text-align:center; font-size:11px; letter-spacing:.2em; text-transform:uppercase; color:#9a927f; border-top:1px solid var(--line); }
- @media (max-width: 900px){ .grid{ --cols: 2 !important; } .controls{ top:63px } }
+ :root { --cols: 6; --bg:#0d0d0f; --card:#17171b; --line:#2a2a30; --txt:#e8e8ea; --mut:#9a9aa2; --accent:#c8a96a; }
+ * { box-sizing: border-box; }
+ body { margin:0; background:var(--bg); color:var(--txt); font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
+ header { position:sticky; top:0; z-index:10; background:rgba(13,13,15,.96); backdrop-filter:blur(8px); border-bottom:1px solid var(--line); padding:14px 20px; max-height:78vh; overflow:auto; }
+ h1 { margin:0 0 10px; font-size:18px; font-weight:600; letter-spacing:.02em; }
+ h1 small { color:var(--mut); font-weight:400; font-size:13px; margin-left:8px; }
+ h1 a { color:var(--mut); font-size:12px; font-weight:400; margin-left:14px; text-decoration:none; border-bottom:1px dotted var(--mut); }
+ .controls { display:flex; flex-wrap:wrap; gap:10px 14px; align-items:center; }
+ select, input[type=search], input[type=text] { background:var(--card); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:7px 10px; font-size:13px; }
+ input[type=search] { min-width:260px; }
+ .dens { display:flex; align-items:center; gap:8px; color:var(--mut); }
+ details.facets { margin-top:12px; }
+ details.facets > summary { cursor:pointer; color:var(--mut); font-size:12px; letter-spacing:.04em; text-transform:uppercase; list-style:none; user-select:none; padding:2px 0; }
+ details.facets > summary::-webkit-details-marker { display:none; }
+ details.facets > summary::before { content:'▸ '; color:var(--accent); }
+ details.facets[open] > summary::before { content:'▾ '; }
+ summary b { color:var(--accent); }
+ .facetbody { display:flex; flex-direction:column; gap:9px; margin-top:9px; }
+ .frow { display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
+ .flabel { color:var(--mut); font-size:11px; text-transform:uppercase; letter-spacing:.06em; min-width:62px; }
+ .chip { background:var(--card); border:1px solid var(--line); border-radius:20px; padding:4px 10px; cursor:pointer; font-size:11.5px; color:var(--mut); }
+ .chip.on { background:var(--accent); color:#1a1407; border-color:var(--accent); font-weight:600; }
+ .chip .ct { opacity:.65; margin-left:4px; font-size:10px; }
+ .chip.zero { opacity:.3; }
+ .chip.allchip { font-weight:600; border-style:dashed; }
+ .chip.allchip.on { border-style:solid; }
+ .chip.more { border-style:dotted; color:var(--accent); }
+ .ffind { padding:3px 9px !important; font-size:11px !important; border-radius:20px !important; min-width:110px; }
+ .active { display:flex; flex-wrap:wrap; gap:6px; align-items:center; padding-top:2px; }
+ .afilter { background:#23211a; border:1px solid var(--accent); color:var(--txt); border-radius:20px; padding:3px 9px; font-size:11px; cursor:pointer; }
+ .afilter b { color:var(--accent); font-weight:600; }
+ .clearall { background:none; border:1px solid var(--line); color:var(--mut); border-radius:20px; padding:3px 11px; font-size:11px; cursor:pointer; }
+ .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:12px; padding:18px 20px 60px; }
+ .card { background:var(--card); border:1px solid var(--line); border-radius:10px; overflow:hidden; display:flex; flex-direction:column; }
+ .thumb { aspect-ratio:1/1; background:#000 center/cover no-repeat; position:relative; }
+ .thumb.empty { display:flex; align-items:center; justify-content:center; color:#444; font-size:11px; }
+ .badge { position:absolute; top:6px; left:6px; font-size:10px; font-weight:700; letter-spacing:.04em; padding:2px 7px; border-radius:20px; text-transform:uppercase; }
+ .badge.st-ARCHIVED { background:#5a3d3d; color:#e8c9c9; }
+ .badge.st-DRAFT { background:#3d4a5a; color:#c9dae8; }
+ .meta { padding:8px 10px; }
+ .sku { font-weight:700; font-size:13px; color:var(--accent); word-break:break-all; }
+ .ttl { color:var(--txt); font-size:11.5px; margin-top:3px; max-height:2.8em; overflow:hidden; }
+ .vt { color:var(--mut); font-size:10.5px; margin-top:3px; }
+ .fac { margin-top:4px; font-size:10.5px; color:var(--accent); }
+ .priceline { margin-top:4px; }
+ .price { color:#e8e8ea; font-size:12.5px; font-weight:600; }
+ #sentinel { height:1px; }
+ #status { text-align:center; color:var(--mut); padding:16px; font-size:13px; }
</style>
</head>
<body>
-
<header>
- <a class="wordmark" href="https://www.designerwallcoverings.com">Designer Wallcoverings<small>The Complete Vendor Index</small></a>
- <button class="burger" aria-label="Menu" onclick="menu.classList.add('open')"><span></span><span></span><span></span></button>
+ <h1>All Products <small id="sub">Designer Wallcoverings — the whole catalog, one place</small><a href="/vendors.html">vendor directory →</a></h1>
+ <div class="controls">
+ <input type="search" id="q" placeholder="Search anything — red, damask, DWKR, Thibaut…">
+ <label class="dens">Sort
+ <select id="sort">
+ <option value="newest">Newest</option>
+ <option value="oldest">Oldest</option>
+ <option value="updated">Recently updated</option>
+ <option value="title">Title A→Z</option>
+ <option value="vendor">Vendor A→Z</option>
+ <option value="type">Type A→Z</option>
+ <option value="sku">SKU A→Z</option>
+ <option value="pattern">Pattern A→Z</option>
+ <option value="color">Color (family)</option>
+ <option value="style">Style A→Z</option>
+ <option value="material">Material A→Z</option>
+ <option value="price_asc">Price ↑</option>
+ <option value="price_desc">Price ↓</option>
+ <option value="status">Status</option>
+ </select>
+ </label>
+ <label class="dens">Density
+ <input type="range" id="dens" min="3" max="10" value="6">
+ </label>
+ </div>
+
+ <details class="facets" id="facets">
+ <summary>Filters <b id="facetCount"></b></summary>
+ <div class="facetbody">
+ <div class="frow"><span class="flabel">Vendor</span><div class="frow" id="vendorRow"></div></div>
+ <div class="frow"><span class="flabel">Type</span><div class="frow" id="typeRow"></div></div>
+ <div class="frow"><span class="flabel">Color</span><div class="frow" id="colorRow"></div></div>
+ <div class="frow"><span class="flabel">Style</span><div class="frow" id="styleRow"></div></div>
+ <div class="frow"><span class="flabel">Material</span><div class="frow" id="matRow"></div></div>
+ <div class="frow"><span class="flabel">Price</span><div class="frow" id="priceRow"></div></div>
+ <div class="frow"><span class="flabel">Status</span><div class="frow" id="statusRow"></div></div>
+ <div class="frow"><span class="flabel">Image</span><div class="frow" id="imgRow"></div></div>
+ <div class="active" id="activeRow"></div>
+ </div>
+ </details>
</header>
+<div class="grid" id="grid"></div>
+<div id="sentinel"></div>
+<div id="status">loading…</div>
-<nav class="menu" id="menu">
- <button class="close" aria-label="Close" onclick="menu.classList.remove('open')">×</button>
- <a href="https://www.designerwallcoverings.com">Shop the Store</a>
- <a href="https://www.designerwallcoverings.com/collections/trending-wallcoverings-collection-2026">New & Trending</a>
- <a href="https://www.designerwallcoverings.com/pages/contact-us">Contact a Designer</a>
-</nav>
-
-<section class="hero">
- <h1>Every Line We Carry</h1>
- <p>The world's wallcovering & textile houses — indexed in one place.</p>
- <div class="stats">
- <span><b id="statVendors">—</b>Vendors</span>
- <span><b id="statDesigns">—</b>Designs Live</span>
- </div>
-</section>
+<script>
+const grid = document.getElementById('grid');
+const statusEl = document.getElementById('status');
+const subEl = document.getElementById('sub');
+const LIMIT = 120;
+const loadSet = (k) => { try { return new Set(JSON.parse(localStorage.getItem(k) || '[]')); } catch { return new Set(); } };
-<div class="controls">
- <input type="search" id="q" placeholder="Search vendors…" aria-label="Search vendors">
- <div class="seg" role="tablist">
- <button id="fLive" class="on">Available Now</button>
- <button id="fAll">All Vendors</button>
- </div>
- <select id="sort" aria-label="Sort">
- <option value="designs">Most Designs</option>
- <option value="az">Name A→Z</option>
- <option value="za">Name Z→A</option>
- </select>
- <label class="dens">Density <input type="range" id="density" min="2" max="8" step="1" value="4"></label>
-</div>
+let state = {
+ sort: localStorage.getItem('all_sort') || 'newest',
+ q: '',
+ vendors: loadSet('all_vendors'), types: loadSet('all_types'), statuses: loadSet('all_statuses'),
+ colors: loadSet('all_colors'), styles: loadSet('all_styles'), materials: loadSet('all_materials'),
+ prices: loadSet('all_prices'), images: loadSet('all_images'),
+ offset: 0, total: 0, loading: false, done: false,
+};
+// per-row UI state: expanded? + type-ahead needle (vendor/type rows have hundreds of values)
+const rowUI = { vendorRow: { expand: false, find: '' }, typeRow: { expand: false, find: '' } };
-<main class="grid" id="grid"></main>
-<div class="sentinel" id="sentinel"></div>
+const dens = document.getElementById('dens');
+dens.value = localStorage.getItem('all_cols') || 6;
+document.documentElement.style.setProperty('--cols', dens.value);
+dens.oninput = () => { document.documentElement.style.setProperty('--cols', dens.value); localStorage.setItem('all_cols', dens.value); };
-<footer>Designer Wallcoverings & Fabrics — To The Trade & Beyond</footer>
+const facetsEl = document.getElementById('facets');
+facetsEl.open = localStorage.getItem('all_facets_open') !== '0';
+facetsEl.ontoggle = () => localStorage.setItem('all_facets_open', facetsEl.open ? '1' : '0');
-<script>
-const grid = document.getElementById('grid'), sentinel = document.getElementById('sentinel');
-const menu = document.getElementById('menu');
-const state = {
- all: [], view: [], rendered: 0, BATCH: 40,
- q: '', filter: localStorage.getItem('adw_filter') || 'live',
- sort: localStorage.getItem('adw_sort') || 'designs',
- cols: localStorage.getItem('adw_cols') || '4',
+document.getElementById('sort').value = state.sort;
+document.getElementById('sort').onchange = (e) => { state.sort = e.target.value; localStorage.setItem('all_sort', state.sort); reset(); };
+let qt; document.getElementById('q').oninput = (e) => { clearTimeout(qt); qt = setTimeout(() => { state.q = e.target.value.trim(); applyFilterChange(); }, 250); };
+
+const SETS = {
+ vendors: 'all_vendors', types: 'all_types', statuses: 'all_statuses', colors: 'all_colors',
+ styles: 'all_styles', materials: 'all_materials', prices: 'all_prices', images: 'all_images',
};
+function persistFacets() { for (const [k, key] of Object.entries(SETS)) localStorage.setItem(key, JSON.stringify([...state[k]])); }
+function facetParams(extra = {}) {
+ return new URLSearchParams({
+ sort: state.sort, q: state.q,
+ vendors: [...state.vendors].join(','), types: [...state.types].join(','),
+ statuses: [...state.statuses].join(','), colors: [...state.colors].join(','),
+ styles: [...state.styles].join(','), materials: [...state.materials].join(','),
+ prices: [...state.prices].join(','), images: [...state.images].join(','), ...extra,
+ });
+}
-document.documentElement.style.setProperty('--cols', state.cols);
-density.value = state.cols;
-sort.value = state.sort;
-setSeg();
+function reset() { grid.innerHTML = ''; state.offset = 0; state.done = false; statusEl.textContent = 'loading…'; load(); }
+function applyFilterChange() { persistFacets(); refreshFacets(); reset(); }
+function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); applyFilterChange(); }
-function setSeg(){
- fLive.classList.toggle('on', state.filter==='live');
- fAll.classList.toggle('on', state.filter==='all');
+function allChip(set, label = 'All') {
+ const el = document.createElement('span');
+ el.className = 'chip allchip' + (set.size === 0 ? ' on' : '');
+ el.textContent = label;
+ el.title = set.size ? 'Clear this category' : 'Showing all';
+ el.onclick = () => { if (set.size) { set.clear(); applyFilterChange(); } };
+ return el;
}
-fetch('/api/vendors').then(r=>r.json()).then(d=>{
- state.all = d.vendors || [];
- statVendors.textContent = state.all.length.toLocaleString();
- statDesigns.textContent = state.all.reduce((s,v)=>s+(v.active_count||0),0).toLocaleString();
- apply();
-});
-
-function apply(){
- let v = state.all;
- if (state.filter==='live') v = v.filter(x=>x.active_count>0);
- if (state.q) { const q=state.q.toLowerCase(); v = v.filter(x=>x.name.toLowerCase().includes(q)); }
- if (state.sort==='az') v=[...v].sort((a,b)=>a.name.localeCompare(b.name));
- else if (state.sort==='za') v=[...v].sort((a,b)=>b.name.localeCompare(a.name));
- else v=[...v].sort((a,b)=>b.active_count-a.active_count || a.name.localeCompare(b.name));
- state.view = v; state.rendered = 0; grid.innerHTML=''; renderMore();
+// count-chip row. Big rows (vendor/type) get a type-ahead + top-N cap with "+N more" expander;
+// selected values ALWAYS render so an active filter can't hide off-screen.
+function chipRow(rowId, counts, set, opts = {}) {
+ const row = document.getElementById(rowId); row.innerHTML = '';
+ const ui = rowUI[rowId];
+ row.appendChild(allChip(set, opts.allLabel));
+ if (ui) {
+ const find = document.createElement('input');
+ find.type = 'text'; find.className = 'ffind'; find.placeholder = 'find…'; find.value = ui.find;
+ find.oninput = () => { ui.find = find.value; renderFacets(LAST_FACETS); const f2 = document.querySelector(`#${rowId} .ffind`); if (f2) { f2.focus(); f2.setSelectionRange(f2.value.length, f2.value.length); } };
+ row.appendChild(find);
+ }
+ let entries = Object.entries(counts || {});
+ entries = opts.order
+ ? entries.sort((a, b) => opts.order.indexOf(a[0]) - opts.order.indexOf(b[0]))
+ : entries.sort((a, b) => b[1] - a[1]);
+ if (ui && ui.find) entries = entries.filter(([v]) => v.toLowerCase().includes(ui.find.toLowerCase()));
+ const CAP = 30;
+ let shown = entries, hidden = 0;
+ if (ui && !ui.expand && entries.length > CAP) { shown = entries.slice(0, CAP); hidden = entries.length - CAP; }
+ // active selections must always be visible
+ const shownVals = new Set(shown.map(([v]) => v));
+ for (const v of set) if (!shownVals.has(v)) { const e = entries.find(([x]) => x === v); shown.push(e || [v, 0]); }
+ if (!shown.length) { const s = document.createElement('span'); s.style.cssText = 'font-size:10px;color:#6a6a72'; s.textContent = 'none'; row.appendChild(s); return; }
+ shown.forEach(([val, ct]) => {
+ const el = document.createElement('span');
+ el.className = 'chip' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
+ el.innerHTML = `${val}<span class="ct">${ct.toLocaleString()}</span>`;
+ el.onclick = () => toggleSet(set, val);
+ row.appendChild(el);
+ });
+ if (hidden > 0) {
+ const el = document.createElement('span'); el.className = 'chip more'; el.textContent = `+${hidden.toLocaleString()} more`;
+ el.onclick = () => { ui.expand = true; renderFacets(LAST_FACETS); };
+ row.appendChild(el);
+ } else if (ui && ui.expand && entries.length > CAP) {
+ const el = document.createElement('span'); el.className = 'chip more'; el.textContent = 'show less';
+ el.onclick = () => { ui.expand = false; renderFacets(LAST_FACETS); };
+ row.appendChild(el);
+ }
}
-function card(v){
- const el = document.createElement('a');
- el.className='card'; el.href=v.shop_url; el.target='_blank'; el.rel='noopener noreferrer';
- const imgs=(v.images||[]).slice(0,4);
- const cells = imgs.length
- ? imgs.map(u=>`<img loading="lazy" src="${u.replace(/"/g,'')}" alt="${v.name} design">`).join('')
- : `<div class="ph">${v.name.charAt(0)}</div>`;
- el.innerHTML = `
- <div class="collage${imgs.length<=1?' single':''}">${cells}</div>
- <div class="meta">
- <h3></h3>
- <div class="sub">${v.active_count>0 ? v.active_count.toLocaleString()+' designs live' : 'By inquiry'}</div>
- <div class="cta">Shop the Line →</div>
- </div>`;
- el.querySelector('h3').textContent = v.name;
- return el;
+let LAST_FACETS = {};
+function renderFacets(f) {
+ LAST_FACETS = f;
+ chipRow('vendorRow', f.vendor, state.vendors);
+ chipRow('typeRow', f.type, state.types);
+ chipRow('colorRow', f.color, state.colors, { order: f.family_order });
+ chipRow('styleRow', f.style, state.styles);
+ chipRow('matRow', f.material, state.materials);
+ chipRow('priceRow', f.price_band, state.prices, { order: f.price_order });
+ chipRow('statusRow', f.status, state.statuses, { allLabel: 'Active (default)' });
+ chipRow('imgRow', f.image_state, state.images);
+ renderActive();
+}
+
+function renderActive() {
+ const row = document.getElementById('activeRow'); row.innerHTML = '';
+ const dims = [['Vendor', state.vendors], ['Type', state.types], ['Color', state.colors], ['Style', state.styles],
+ ['Material', state.materials], ['Price', state.prices], ['Status', state.statuses], ['Image', state.images]];
+ let n = 0;
+ for (const [label, set] of dims) {
+ set.forEach((v) => {
+ n++;
+ const el = document.createElement('span'); el.className = 'afilter';
+ el.innerHTML = `<b>${label}:</b> ${v} ✕`;
+ el.onclick = () => { set.delete(v); applyFilterChange(); };
+ row.appendChild(el);
+ });
+ }
+ document.getElementById('facetCount').textContent = n ? `· ${n} active` : '';
+ if (n) {
+ const c = document.createElement('button'); c.className = 'clearall'; c.textContent = 'Clear all';
+ c.onclick = () => { dims.forEach(([, s]) => s.clear()); applyFilterChange(); };
+ row.appendChild(c);
+ }
}
-function renderMore(){
- const next = state.view.slice(state.rendered, state.rendered + state.BATCH);
- next.forEach(v=>grid.appendChild(card(v)));
- state.rendered += next.length;
+async function refreshFacets() {
+ try {
+ const res = await fetch('/api/facets?' + facetParams());
+ renderFacets(await res.json());
+ } catch (e) { /* facets degrade silently — grid still works */ }
}
-new IntersectionObserver(es=>{ if(es[0].isIntersecting) renderMore(); }, {rootMargin:'800px'}).observe(sentinel);
+function card(r) {
+ const el = document.createElement('div'); el.className = 'card';
+ const stBadge = (r.status && r.status !== 'ACTIVE') ? `<span class="badge st-${r.status}">${r.status}</span>` : '';
+ const thumb = r.image
+ ? `<div class="thumb" style="background-image:url('${r.image}')">${stBadge}</div>`
+ : `<div class="thumb empty">${stBadge}no image</div>`;
+ const vt = [r.vendor, r.type].filter(Boolean).join(' · ');
+ const fac = [...(r.colors || []), ...(r.styles || []), ...(r.materials || [])].slice(0, 4).join(' · ');
+ const price = r.price ? `<div class="priceline"><span class="price">$${r.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span></div>` : '';
+ el.innerHTML = `${thumb}<div class="meta"><div class="sku">${r.sku || '—'}</div><div class="ttl" title="${(r.title || '').replace(/"/g, '"')}">${r.title || ''}</div>${vt ? `<div class="vt">${vt}</div>` : ''}${fac ? `<div class="fac">${fac}</div>` : ''}${price}</div>`;
+ if (r.url) { el.style.cursor = 'pointer'; el.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
+ return el;
+}
+
+async function load() {
+ if (state.loading || state.done) return;
+ state.loading = true;
+ try {
+ const res = await fetch('/api/products?' + facetParams({ offset: state.offset, limit: LIMIT }));
+ const data = await res.json();
+ state.total = data.total;
+ const frag = document.createDocumentFragment();
+ data.rows.forEach((r) => frag.appendChild(card(r)));
+ grid.appendChild(frag);
+ state.offset += data.rows.length;
+ state.done = state.offset >= data.total || data.rows.length === 0;
+ subEl.textContent = `${data.catalog.toLocaleString()} products in catalog · ${data.total.toLocaleString()} matching · showing ${state.offset.toLocaleString()}`;
+ statusEl.textContent = data.total === 0 ? '— no matching products —'
+ : (state.done ? `— end · ${state.total.toLocaleString()} matching —` : 'scroll for more…');
+ } catch (e) {
+ statusEl.textContent = 'Load error — check server';
+ } finally {
+ state.loading = false;
+ }
+}
-q.addEventListener('input', e=>{ state.q=e.target.value.trim(); apply(); });
-sort.addEventListener('change', e=>{ state.sort=e.target.value; localStorage.setItem('adw_sort',state.sort); apply(); });
-density.addEventListener('input', e=>{ state.cols=e.target.value; localStorage.setItem('adw_cols',state.cols);
- document.documentElement.style.setProperty('--cols', state.cols); });
-fLive.onclick=()=>{ state.filter='live'; localStorage.setItem('adw_filter','live'); setSeg(); apply(); };
-fAll.onclick=()=>{ state.filter='all'; localStorage.setItem('adw_filter','all'); setSeg(); apply(); };
+new IntersectionObserver((es) => { if (es[0].isIntersecting) load(); }, { rootMargin: '600px' }).observe(document.getElementById('sentinel'));
+refreshFacets(); load();
</script>
</body>
</html>
diff --git a/server.js b/server.js
index 830c400..7ac86e0 100644
--- a/server.js
+++ b/server.js
@@ -1,57 +1,227 @@
-// all.designerwallcoverings.com — front-facing vendor directory (all dw_unified vendor ids)
-// Zero-dependency node server: static public/ + /api/vendors from data/vendors.json
-const http = require("http");
-const fs = require("fs");
-const path = require("path");
+// all.designerwallcoverings.com — the WHOLE catalog, searchable in one place.
+// Pattern: japan-staging-viewer (in-memory rows + paginated /api/products +
+// drill-down /api/facets + chip-toggle filters + sort-any-field + infinite scroll).
+// Data: dw_unified.shopify_products loaded into RAM at boot, refreshed every 10 min —
+// the request path never touches PG, so search over the full catalog is instant and
+// the canonical DB needs no new indexes. Public surface: cost/net/wholesale never
+// leave the server; only retail price ships.
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const { Pool } = require('pg');
+try { require('dotenv').config(); } catch {}
const PORT = process.env.PORT || 9958;
-const PUB = path.join(__dirname, "public");
-const DATA = path.join(__dirname, "data", "vendors.json");
+const PUB = path.join(__dirname, 'public');
+const DATA = path.join(__dirname, 'data', 'vendors.json'); // legacy vendor directory (kept at /vendors.html)
+const REFRESH_MS = 10 * 60 * 1000;
+const DSN = process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp';
const MIME = {
- ".html": "text/html; charset=utf-8",
- ".js": "text/javascript; charset=utf-8",
- ".css": "text/css; charset=utf-8",
- ".json": "application/json; charset=utf-8",
- ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml",
- ".ico": "image/x-icon", ".webp": "image/webp",
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8',
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml',
+ '.ico': 'image/x-icon', '.webp': 'image/webp',
};
+// ── tag lexicons → derived facets ────────────────────────────────────────────────
+// Tags are ", "-separated words like "Blue, Coastal, Grasscloth, color:Ecru, …".
+// Color families follow the interior-designer wheel order; style + material use the
+// working DW tag vocabulary. A tag must match exactly (case-insensitive).
+const FAMILY_ORDER = ['Red', 'Orange', 'Brown', 'Yellow', 'Gold', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Beige', 'Cream', 'Black', 'Gray', 'Grey', 'White', 'Silver', 'Metallic', 'Multi'];
+const COLOR_TAGS = new Set(FAMILY_ORDER.concat(['Light Blue', 'Dark Blue', 'Navy', 'Light Gray', 'Dark Gray', 'Charcoal', 'Ivory', 'Ecru', 'Taupe', 'Tan', 'Rust', 'Burgundy', 'Coral', 'Lavender', 'Mint', 'Sage', 'Olive', 'Turquoise', 'Aqua', 'Bronze', 'Copper', 'Neutral']).map((s) => s.toLowerCase()));
+const STYLE_TAGS = new Set(['Traditional', 'Modern', 'Contemporary', 'Transitional', 'Floral', 'Damask', 'Geometric', 'Stripe', 'Stripes', 'Striped', 'Toile', 'Ikat', 'Chinoiserie', 'Coastal', 'Tropical', 'Botanical', 'Art Deco', 'Mid-Century', 'Minimalist', 'Rustic', 'Vintage', 'Abstract', 'Animal', 'Bird', 'Paisley', 'Plaid', 'Trellis', 'Lattice', 'Medallion', 'Ombre', 'Mural', 'Scenic', 'Texture', 'Textured', 'Solid', 'Novelty', 'Kids', 'Basketweave', 'Herringbone', 'Chevron', 'Moroccan', 'Asian', 'French', 'English', 'Farmhouse', 'Glam', 'Bohemian', 'Industrial'].map((s) => s.toLowerCase()));
+const MATERIAL_TAGS = new Set(['Grasscloth', 'Cork', 'Silk', 'Linen', 'Vinyl', 'Paper', 'Non-Woven', 'Nonwoven', 'Fabric', 'Textile', 'Sisal', 'Jute', 'Raffia', 'Hemp', 'Bamboo', 'Wood', 'Leather', 'Suede', 'Velvet', 'Flocked', 'Foil', 'Mylar', 'Mica', 'Glass Beads', 'Glass Beaded', 'Metal', 'Natural', 'Naturals', 'Paperweave', 'Arrowroot', 'Wool', 'Felt', 'Acoustic', 'Type II', 'Type I', 'Peel and Stick', 'Peel & Stick', 'Removable'].map((s) => s.toLowerCase()));
+
+const PRICE_BANDS = [
+ { key: '≤$50', test: (p) => p <= 50 },
+ { key: '$50–100', test: (p) => p > 50 && p <= 100 },
+ { key: '$100–200', test: (p) => p > 100 && p <= 200 },
+ { key: '$200–400', test: (p) => p > 200 && p <= 400 },
+ { key: '$400+', test: (p) => p > 400 },
+];
+const PRICE_ORDER = PRICE_BANDS.map((b) => b.key);
+const priceBand = (p) => { if (!p || p <= 0) return null; const b = PRICE_BANDS.find((x) => x.test(p)); return b ? b.key : null; };
+
+const titleCase = (s) => s.replace(/\w\S*/g, (t) => t[0].toUpperCase() + t.slice(1));
+
+// ── snapshot load ────────────────────────────────────────────────────────────────
+// Public-safe columns ONLY — cost_price / net_price / cost never selected.
+const SNAP_SQL = `
+ SELECT id, handle, title, vendor, product_type, dw_sku, variant_sku, sku, pattern_name,
+ tags, upper(coalesce(status,'')) AS status, image_url,
+ coalesce(price, min_variant_price) AS price,
+ online_store_published,
+ created_at_shopify, updated_at_shopify
+ FROM shopify_products
+ WHERE coalesce(status,'') <> 'DELETED_FROM_SHOPIFY'`;
+
+let ROWS = [];
+let LOADED_AT = null;
+let pool = null;
+
+function deriveRow(r) {
+ const tags = (r.tags || '').split(',').map((t) => t.trim()).filter(Boolean);
+ const colors = [], styles = [], materials = [];
+ for (const t of tags) {
+ const lc = t.toLowerCase();
+ const bare = lc.startsWith('color:') ? lc.slice(6).trim() : lc;
+ if (COLOR_TAGS.has(bare)) colors.push(titleCase(bare));
+ if (STYLE_TAGS.has(lc)) styles.push(titleCase(lc));
+ if (MATERIAL_TAGS.has(lc)) materials.push(titleCase(lc));
+ }
+ const price = r.price != null ? parseFloat(r.price) : null;
+ return {
+ id: r.id,
+ sku: r.dw_sku || r.variant_sku || r.sku || null,
+ title: r.title || '',
+ vendor: r.vendor || null,
+ type: r.product_type || null,
+ pattern: r.pattern_name || null,
+ status: r.status || null,
+ image: r.image_url || null,
+ price: price && price > 0 ? price : null,
+ price_band: priceBand(price),
+ colors: [...new Set(colors)],
+ styles: [...new Set(styles)],
+ materials: [...new Set(materials)],
+ image_state: r.image_url ? 'Has image' : 'No image',
+ url: (r.online_store_published && r.handle) ? `https://designerwallcoverings.com/products/${r.handle}` : null,
+ created: r.created_at_shopify ? new Date(r.created_at_shopify).getTime() : 0,
+ updated: r.updated_at_shopify ? new Date(r.updated_at_shopify).getTime() : 0,
+ // one lowercase haystack per row so search is a single .includes() pass
+ hay: [r.dw_sku, r.variant_sku, r.sku, r.title, r.vendor, r.product_type, r.pattern_name, r.tags].filter(Boolean).join(' ').toLowerCase(),
+ };
+}
+
+async function loadSnapshot() {
+ if (!pool) pool = new Pool({ connectionString: DSN, max: 2 });
+ const t0 = Date.now();
+ const res = await pool.query(SNAP_SQL);
+ ROWS = res.rows.map(deriveRow);
+ LOADED_AT = new Date().toISOString();
+ console.log(`snapshot: ${ROWS.length.toLocaleString()} products in ${Date.now() - t0}ms`);
+}
+
+// ── filtering / sorting (japan-viewer pattern) ──────────────────────────────────
+function parseFilters(u) {
+ const csv = (k) => new Set((u.searchParams.get(k) || '').split(',').map((s) => s.trim()).filter(Boolean));
+ return {
+ q: (u.searchParams.get('q') || '').trim().toLowerCase(),
+ vendors: csv('vendors'), types: csv('types'), statuses: csv('statuses'),
+ colors: csv('colors'), styles: csv('styles'), materials: csv('materials'),
+ prices: csv('prices'), images: csv('images'),
+ };
+}
+// Default status scope = ACTIVE unless the user explicitly picked statuses.
+function applyFilters(rows, f, opts = {}) {
+ let out = rows;
+ if (opts.skip !== 'status') {
+ out = f.statuses.size ? out.filter((r) => f.statuses.has(r.status)) : out.filter((r) => r.status === 'ACTIVE');
+ }
+ if (f.q) {
+ // word-START match per term so "red" hits "Red"/"redwood" but not "weatheRED"/"bordeRED"
+ const terms = f.q.split(/\s+/).filter(Boolean)
+ .map((t) => new RegExp('\\b' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
+ out = out.filter((r) => terms.every((re) => re.test(r.hay)));
+ }
+ if (opts.skip !== 'vendor' && f.vendors.size) out = out.filter((r) => r.vendor && f.vendors.has(r.vendor));
+ if (opts.skip !== 'type' && f.types.size) out = out.filter((r) => r.type && f.types.has(r.type));
+ if (opts.skip !== 'color' && f.colors.size) out = out.filter((r) => r.colors.some((c) => f.colors.has(c)));
+ if (opts.skip !== 'style' && f.styles.size) out = out.filter((r) => r.styles.some((c) => f.styles.has(c)));
+ if (opts.skip !== 'material' && f.materials.size) out = out.filter((r) => r.materials.some((c) => f.materials.has(c)));
+ if (opts.skip !== 'price' && f.prices.size) out = out.filter((r) => r.price_band && f.prices.has(r.price_band));
+ if (opts.skip !== 'image' && f.images.size) out = out.filter((r) => f.images.has(r.image_state));
+ return out;
+}
+
+const str = (v) => String(v == null ? '' : v); // nulls sink to the end on A→Z sorts
+const sorters = {
+ newest: (a, b) => b.created - a.created,
+ oldest: (a, b) => a.created - b.created,
+ updated: (a, b) => b.updated - a.updated,
+ title: (a, b) => str(a.title).localeCompare(str(b.title)),
+ vendor: (a, b) => str(a.vendor).localeCompare(str(b.vendor)) || str(a.title).localeCompare(str(b.title)),
+ type: (a, b) => str(a.type).localeCompare(str(b.type)) || str(a.title).localeCompare(str(b.title)),
+ sku: (a, b) => str(a.sku).localeCompare(str(b.sku)),
+ pattern: (a, b) => str(a.pattern).localeCompare(str(b.pattern)),
+ status: (a, b) => str(a.status).localeCompare(str(b.status)),
+ price_asc: (a, b) => (a.price ?? 1e12) - (b.price ?? 1e12),
+ price_desc: (a, b) => (b.price ?? -1) - (a.price ?? -1),
+ color: (a, b) => { const i = (x) => { const c = x.colors[0]; const k = c ? FAMILY_ORDER.indexOf(c) : -1; return k < 0 ? 99 : k; }; return i(a) - i(b) || str(a.sku).localeCompare(str(b.sku)); },
+ style: (a, b) => str(a.styles[0]).localeCompare(str(b.styles[0])) || str(a.sku).localeCompare(str(b.sku)),
+ material: (a, b) => str(a.materials[0]).localeCompare(str(b.materials[0])) || str(a.sku).localeCompare(str(b.sku)),
+};
+
+const tally = (rows, key) => { const m = {}; for (const r of rows) { const v = r[key]; if (v) m[v] = (m[v] || 0) + 1; } return m; };
+const tallyArr = (rows, key) => { const m = {}; for (const r of rows) { for (const v of r[key]) m[v] = (m[v] || 0) + 1; } return m; };
+
let vendorsCache = null, vendorsMtime = 0;
function loadVendors() {
const st = fs.statSync(DATA);
- if (!vendorsCache || st.mtimeMs !== vendorsMtime) {
- vendorsCache = fs.readFileSync(DATA);
- vendorsMtime = st.mtimeMs;
- }
+ if (!vendorsCache || st.mtimeMs !== vendorsMtime) { vendorsCache = fs.readFileSync(DATA); vendorsMtime = st.mtimeMs; }
return vendorsCache;
}
-http.createServer((req, res) => {
- const url = new URL(req.url, "http://x");
- const p = url.pathname;
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, `http://localhost:${PORT}`);
- if (p === "/healthz") {
- res.writeHead(200, { "Content-Type": "text/plain" });
- return res.end("ok");
+ if (u.pathname === '/api/products') {
+ const f = parseFilters(u);
+ const sort = u.searchParams.get('sort') || 'newest';
+ const offset = parseInt(u.searchParams.get('offset') || '0', 10) || 0;
+ const limit = Math.min(parseInt(u.searchParams.get('limit') || '120', 10) || 120, 500);
+ let rows = applyFilters(ROWS, f);
+ if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ total: rows.length, catalog: ROWS.length, loaded_at: LOADED_AT, offset, limit,
+ rows: rows.slice(offset, offset + limit).map(({ hay, created, updated, ...r }) => r),
+ }));
+ return;
}
- if (p === "/api/vendors") {
+
+ if (u.pathname === '/api/facets') {
+ const f = parseFilters(u);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ vendor: tally(applyFilters(ROWS, f, { skip: 'vendor' }), 'vendor'),
+ type: tally(applyFilters(ROWS, f, { skip: 'type' }), 'type'),
+ status: tally(applyFilters(ROWS, f, { skip: 'status' }), 'status'),
+ color: tallyArr(applyFilters(ROWS, f, { skip: 'color' }), 'colors'),
+ style: tallyArr(applyFilters(ROWS, f, { skip: 'style' }), 'styles'),
+ material: tallyArr(applyFilters(ROWS, f, { skip: 'material' }), 'materials'),
+ price_band: tally(applyFilters(ROWS, f, { skip: 'price' }), 'price_band'),
+ image_state: tally(applyFilters(ROWS, f, { skip: 'image' }), 'image_state'),
+ price_order: PRICE_ORDER, family_order: FAMILY_ORDER,
+ total: applyFilters(ROWS, f).length,
+ }));
+ return;
+ }
+
+ if (u.pathname === '/api/vendors') { // legacy vendor directory API (vendors.html)
try {
- const body = loadVendors();
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "public, max-age=300" });
- return res.end(body);
- } catch (e) {
- res.writeHead(500, { "Content-Type": "application/json" });
- return res.end('{"error":"data unavailable"}');
- }
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'public, max-age=300' });
+ return res.end(loadVendors());
+ } catch { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end('{"error":"data unavailable"}'); }
+ }
+
+ if (u.pathname === '/healthz') {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ok: ROWS.length > 0, products: ROWS.length, loaded_at: LOADED_AT }));
}
// static, path-traversal-safe
- let file = path.normalize(path.join(PUB, p === "/" ? "index.html" : p));
- if (!file.startsWith(PUB)) { res.writeHead(403); return res.end(); }
- fs.readFile(file, (err, buf) => {
- if (err) { res.writeHead(404, { "Content-Type": "text/plain" }); return res.end("Not found"); }
- res.writeHead(200, { "Content-Type": MIME[path.extname(file)] || "application/octet-stream" });
- res.end(buf);
- });
-}).listen(PORT, () => console.log(`all-designerwallcoverings on :${PORT}`));
+ const p = u.pathname === '/' ? '/index.html' : u.pathname;
+ const file = path.join(PUB, path.normalize(p).replace(/^(\.\.[/\\])+/, ''));
+ if (!file.startsWith(PUB) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { res.writeHead(404); return res.end('not found'); }
+ res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
+ res.end(fs.readFileSync(file));
+});
+
+loadSnapshot()
+ .then(() => {
+ server.listen(PORT, () => console.log(`all. catalog → http://127.0.0.1:${PORT} (${ROWS.length.toLocaleString()} products)`));
+ setInterval(() => loadSnapshot().catch((e) => console.error('snapshot refresh failed:', e.message)), REFRESH_MS);
+ })
+ .catch((e) => { console.error('FATAL: initial snapshot load failed:', e.message); process.exit(1); });
← 455cb72 snapshot before full-catalog rebuild (vendor directory prese
·
back to All Designerwallcoverings
·
banned front-facing vendor scrub on the public snapshot (Bre c8c8cd7 →