[object Object]

← 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

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)