← back to Zuber Internal
zuber-internal: add Memo/Stock/Price chip actions (shared vendor-requests module)
3f028dd854c271e053bbc4e6a17230bfda49b80d · 2026-07-15 15:02:19 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
M package-lock.jsonM package.jsonM public/index.htmlM server.js
Diff
commit 3f028dd854c271e053bbc4e6a17230bfda49b80d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 15 15:02:19 2026 -0700
zuber-internal: add Memo/Stock/Price chip actions (shared vendor-requests module)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
package-lock.json | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
package.json | 10 +++-
public/index.html | 41 +++++++++++++++
server.js | 12 +++++
4 files changed, 209 insertions(+), 3 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index d2e6339..1148700 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,8 @@
"version": "1.0.0",
"dependencies": {
"compression": "^1.7.4",
- "express": "^4.19.2"
+ "express": "^4.19.2",
+ "pg": "^8.22.0"
}
},
"node_modules/accepts": {
@@ -622,6 +623,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
+ "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/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -824,6 +953,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "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/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -881,6 +1019,15 @@
"engines": {
"node": ">= 0.8"
}
+ },
+ "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 72bd919..a7ff318 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,12 @@
"private": true,
"description": "Zuber internal line viewer (Designer Wallcoverings, basic-auth, reads dw_unified.zuber_catalog)",
"main": "server.js",
- "scripts": { "start": "node server.js" },
- "dependencies": { "express": "^4.19.2", "compression": "^1.7.4" }
+ "scripts": {
+ "start": "node server.js"
+ },
+ "dependencies": {
+ "compression": "^1.7.4",
+ "express": "^4.19.2",
+ "pg": "^8.22.0"
+ }
}
diff --git a/public/index.html b/public/index.html
index 9afd948..e7bc30f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -59,8 +59,19 @@
.card .meta { color: #777; font-size: calc(var(--fs) - 2px); }
.card .when { color: #999; font-size: 10px; margin-top: 2px; }
.card a { color: var(--teal); text-decoration: none; font-size: 11px; }
+ /* purchasing actions on each card: Memo / Stock / Price */
+ .card .acts { display: flex; gap: 4px; padding: 0 9px 8px; }
+ .card .acts .act { flex: 1; font: inherit; font-size: 10px; color: var(--mut); background: #fff; border: 1px solid var(--line); border-radius: 5px; padding: 4px 2px; cursor: pointer; }
+ .card .acts .act:hover { border-color: var(--teal); color: var(--ink); }
+ .card .acts .act:disabled { opacity: .5; cursor: default; }
+ body.imgonly .card .acts { display: none; }
body.imgonly .card .body { display: none; }
body.imgonly .grid { gap: 3px; }
+ #toast { position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%) translateY(20px); max-width: min(560px,92vw);
+ background: #fff; color: var(--ink); border: 1px solid var(--line); border-radius: 8px; padding: 10px 16px; font-size: 13px;
+ box-shadow: 0 8px 30px rgba(0,0,0,.18); opacity: 0; pointer-events: none; transition: .25s; z-index: 50; }
+ #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
+ #toast.ok { border-color: #2a6a4a; } #toast.err { border-color: #a33; }
.fields { display: inline-flex; gap: 10px; font-size: 11px; color: #666; }
.fields label { display: inline-flex; gap: 3px; }
</style>
@@ -277,6 +288,10 @@ async function load() {
const grid = $('#grid'); grid.innerHTML = '';
for (const r of data.products) {
const c = document.createElement('div'); c.className = 'card';
+ // data-attrs the delegated purchasing handler reads (dataset assignment is injection-safe)
+ c.dataset.sku = r.dw_sku || '';
+ c.dataset.mfr = r.mfr_sku || '';
+ c.dataset.title = r.pattern_name || r.mfr_sku || '';
const img = document.createElement('div'); img.className = 'img';
if (r.image) img.style.backgroundImage = `url("${r.image}")`;
c.appendChild(img);
@@ -290,12 +305,38 @@ async function load() {
<div class="when" title="${r.created_at || ''}">🕓 ${fmtDate(r.created_at)}</div>
${r.product_url ? `<a href="${r.product_url}" target="_blank" rel="noopener noreferrer">Source ↗</a>` : ''}`;
c.appendChild(body);
+ const acts = document.createElement('div'); acts.className = 'acts';
+ acts.innerHTML = `
+ <button class="act" data-act="memo" title="Log a memo-sample request">Memo</button>
+ <button class="act" data-act="stock" title="Email the vendor a stock check">Stock</button>
+ <button class="act" data-act="price" title="Email the vendor for current price">Price</button>`;
+ c.appendChild(acts);
grid.appendChild(c);
}
['pat','fr','meta','when'].forEach(f => applyField(f, document.querySelector('.ff[data-f="'+f+'"]').checked));
}
refreshFacets().then(load);
setInterval(load, 60000);
+
+// ── Purchasing actions on each card: Memo / Stock / Price → /api/request ──
+$('#grid').addEventListener('click', async (e) => {
+ const btn = e.target.closest('.act'); if (!btn) return;
+ const card = btn.closest('.card'); const type = btn.dataset.act;
+ const payload = { type, dw_sku: card.dataset.sku, mfr_sku: card.dataset.mfr, title: card.dataset.title };
+ const old = btn.textContent; btn.disabled = true; btn.textContent = '…';
+ try {
+ const r = await fetch(location.origin + '/api/request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).then(r => r.json());
+ toast(r.ok ? (r.message || ('Logged ' + (r.req_no || ''))) : (r.error || 'request failed'), !!r.ok);
+ } catch (err) { toast('request failed: ' + err.message, false); }
+ btn.disabled = false; btn.textContent = old;
+});
+let toastT = null;
+function toast(msg, ok) {
+ let t = document.getElementById('toast');
+ if (!t) { t = document.createElement('div'); t.id = 'toast'; document.body.appendChild(t); }
+ t.textContent = msg; t.className = 'show ' + (ok ? 'ok' : 'err');
+ clearTimeout(toastT); toastT = setTimeout(() => t.className = '', 6000);
+}
</script>
</body>
</html>
diff --git a/server.js b/server.js
index 2eb265b..dedddf8 100644
--- a/server.js
+++ b/server.js
@@ -270,4 +270,16 @@ app.get('/api/products', (req, res) => {
app.use(express.static(path.join(__dirname, 'public')));
+// ---- Internal purchasing requests: Request Memo / Check Stock / Get Price ----
+// Shared module every internal vendor viewer uses (astek/crezana reference). Logs a
+// REQ to dw_unified.vendor_requests; stock/price auto-email the vendor when an email
+// is on file. Behind Basic Auth (the click is the approval). Zuber is a quote-only,
+// made-to-order heritage line with no vendor email on file → requests log + prompt to call.
+app.use(express.json());
+require('./lib/vendor-requests').mountVendorRequests(app, {
+ vendorCode: 'zuber',
+ getVendor: () => ({ name: 'Zuber & Cie' }),
+ dataDir: __dirname + '/data',
+});
+
app.listen(PORT, () => console.log(`Zuber internal viewer -> http://127.0.0.1:${PORT} (basic-auth ${AUTH_USER}, source=${USE_JSONL ? 'jsonl' : 'psql'})`));
← 8f9e02b auto-save: 2026-07-15T13:06:41 (1 files) — lib/
·
back to Zuber Internal
·
(newest)