[object Object]

← back to Wallco Ai

colorways: admin Publish badge on chip + new_design_published in /api/colorways

3bcf7db1af481faa982d937fe528401728be10e7 · 2026-05-29 17:52:16 -0700 · Steve

Round 4 UI work. /dtd verdicts (Codex+Claude; qwen err on both panels):
- badge placement: A (inline overlay, top-right corner of chip)
- state source: A (LEFT JOIN spoon_all_designs in GET /api/colorways)

Backend:
- GET /api/colorways now LEFT JOINs spoon_all_designs on new_design_id and
  returns new_design_published per colorway, plus top-level isAdmin so the
  frontend can gate the badge without a second auth probe.
- No change to POST publish/unpublish endpoints (Round 3, a730f34b).

Frontend (public/js/color-dots.js):
- chip(cw) now wraps the <a> in position:relative <span>.
- When window.__COLORWAYS_IS_ADMIN AND cw.new_design_id, render a 16px eye
  badge at top-right (green=published, gray=unpublished). Click flips state
  via POST /api/colorways/<id>/(un)publish; toast on success/failure;
  preventDefault+stopPropagation so it does not trigger the chip's
  click-to-preview or dblclick-to-navigate handlers.
- load() reads j.isAdmin and stashes on window.__COLORWAYS_IS_ADMIN.
- Non-admins never see the badge (default false).

E2E verified on loopback: GET shows new_design_published toggles correctly
through publish/unpublish round-trip.

Files touched

Diff

commit 3bcf7db1af481faa982d937fe528401728be10e7
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri May 29 17:52:16 2026 -0700

    colorways: admin Publish badge on chip + new_design_published in /api/colorways
    
    Round 4 UI work. /dtd verdicts (Codex+Claude; qwen err on both panels):
    - badge placement: A (inline overlay, top-right corner of chip)
    - state source: A (LEFT JOIN spoon_all_designs in GET /api/colorways)
    
    Backend:
    - GET /api/colorways now LEFT JOINs spoon_all_designs on new_design_id and
      returns new_design_published per colorway, plus top-level isAdmin so the
      frontend can gate the badge without a second auth probe.
    - No change to POST publish/unpublish endpoints (Round 3, a730f34b).
    
    Frontend (public/js/color-dots.js):
    - chip(cw) now wraps the <a> in position:relative <span>.
    - When window.__COLORWAYS_IS_ADMIN AND cw.new_design_id, render a 16px eye
      badge at top-right (green=published, gray=unpublished). Click flips state
      via POST /api/colorways/<id>/(un)publish; toast on success/failure;
      preventDefault+stopPropagation so it does not trigger the chip's
      click-to-preview or dblclick-to-navigate handlers.
    - load() reads j.isAdmin and stashes on window.__COLORWAYS_IS_ADMIN.
    - Non-admins never see the badge (default false).
    
    E2E verified on loopback: GET shows new_design_published toggles correctly
    through publish/unpublish round-trip.
---
 public/js/color-dots.js | 69 ++++++++++++++++++++++++++++++++++++++++++++++++-
 src/colorways.js        | 21 +++++++++++----
 2 files changed, 84 insertions(+), 6 deletions(-)

diff --git a/public/js/color-dots.js b/public/js/color-dots.js
index 7e60d59..3739c5b 100644
--- a/public/js/color-dots.js
+++ b/public/js/color-dots.js
@@ -206,6 +206,11 @@
       // href on hover). Steve 2026-05-29: "click into it as a new product".
       // Plain click stays in-page (instant preview + URL replace, no reload).
       // Modifier/middle clicks fall through to browser-native open-in-tab.
+      // Wrapped in a position:relative <span> so the admin Publish badge can
+      // absolutely-position itself at the chip's top-right corner without
+      // breaking the existing 32x22 link layout.
+      var wrap = document.createElement('span');
+      wrap.style.cssText = 'position:relative;display:inline-block;line-height:0';
       var b = document.createElement('a');
       // PREFERRED — promoted colorway has its own spoon_all_designs row
       // (server baked the PNG at save time). Clean product URL.
@@ -249,7 +254,64 @@
         ev.stopPropagation();
         location.href = b.href;
       });
-      return b;
+      wrap.appendChild(b);
+      // Admin Publish badge — only renders when (1) the caller is an admin
+      // (window.__COLORWAYS_IS_ADMIN, set by load() from /api/colorways.isAdmin)
+      // AND (2) the colorway has a promoted spoon_all_designs row
+      // (cw.new_design_id is set — ink_map-only saves have no row to publish).
+      // Eye icon (visible: green = published, gray = unpublished). Click flips
+      // is_published via POST /api/colorways/<id>/(un)publish (admin-gated
+      // backend, /dtd verdict 2026-05-29 — only explicit admin click publishes
+      // a colorway to the public catalog).
+      if (window.__COLORWAYS_IS_ADMIN && cw.new_design_id) {
+        var badge = document.createElement('button');
+        badge.type = 'button';
+        var pubState = !!cw.new_design_published;
+        function paint() {
+          badge.textContent = '👁';
+          badge.title = pubState
+            ? ('Published — click to unpublish #' + cw.new_design_id)
+            : ('Not published — click to publish #' + cw.new_design_id);
+          badge.style.cssText = 'position:absolute;top:-6px;right:-6px;z-index:2;' +
+            'width:16px;height:16px;border-radius:50%;border:1.5px solid #fff;' +
+            'padding:0;margin:0;cursor:pointer;font-size:9px;line-height:1;' +
+            'display:flex;align-items:center;justify-content:center;' +
+            'box-shadow:0 1px 3px rgba(0,0,0,.4);' +
+            (pubState ? 'background:#22a06b;color:#fff;' : 'background:#9aa0a6;color:#fff;');
+        }
+        paint();
+        badge.addEventListener('click', function (ev) {
+          // Prevent the click from bubbling to the chip (which would apply the
+          // colorway) and from triggering native navigation on the anchor.
+          ev.preventDefault();
+          ev.stopPropagation();
+          if (badge.disabled) return;
+          badge.disabled = true;
+          var action = pubState ? 'unpublish' : 'publish';
+          fetch('/api/colorways/' + cw.id + '/' + action, {
+            method: 'POST', credentials: 'same-origin'
+          })
+            .then(function (r) { return r.json(); })
+            .then(function (j) {
+              if (j && j.ok) {
+                pubState = !!j.is_published;
+                cw.new_design_published = pubState;
+                paint();
+                toast(host, pubState ? 'Published v' + cw.version : 'Unpublished v' + cw.version);
+              } else {
+                toast(host, (j && j.error) || (action + ' failed'));
+              }
+            })
+            .catch(function () { toast(host, action + ' failed'); })
+            .then(function () { badge.disabled = false; });
+        });
+        // Stop dblclick from bubbling so it doesn't navigate.
+        badge.addEventListener('dblclick', function (ev) {
+          ev.preventDefault(); ev.stopPropagation();
+        });
+        wrap.appendChild(badge);
+      }
+      return wrap;
     }
     // Auto-apply ?cw=<id> from URL on load — makes /design/<id>?cw=<cwid>
     // behave like a distinct "new product" page that opens in that colorway.
@@ -265,6 +327,11 @@
       fetch('/api/colorways?design_id=' + did, { credentials: 'same-origin' })
         .then(function (r) { return r.json(); })
         .then(function (j) {
+          // /api/colorways now returns isAdmin (loopback OR dw_auth JWT OR
+          // ?admin=<token>). Stash on window so chip() can gate the Publish
+          // badge without re-probing auth. Defaults to false on non-admins so
+          // customer sessions never see the badge.
+          window.__COLORWAYS_IS_ADMIN = !!(j && j.isAdmin);
           vers.innerHTML = '';
           (j.colorways || []).forEach(function (cw) { vers.appendChild(chip(cw)); });
           applyFromUrl(j.colorways);
diff --git a/src/colorways.js b/src/colorways.js
index 6602c55..b438c5c 100644
--- a/src/colorways.js
+++ b/src/colorways.js
@@ -235,22 +235,33 @@ RETURNING id;`).trim();
   }
 
   // GET /api/colorways?design_id=N — current user's exclusive colorways for a design.
+  // 2026-05-29: LEFT JOIN spoon_all_designs on new_design_id so the response
+  // carries new_design_published per row. Lets the admin Publish badge render
+  // its state in one round-trip instead of n+1 lazy fetches. /dtd verdict A.
+  // The JOIN is harmless for non-promoted rows (new_design_id IS NULL → field
+  // is null on the wire). Also exposes is_admin so the frontend can gate the
+  // badge without a second auth probe (admins always render isAdmin=true).
   app.get('/api/colorways', (req, res) => {
     const u = getEffectiveUser(req);
-    if (!u) return res.json({ ok: true, authenticated: false, colorways: [] });
+    if (!u) return res.json({ ok: true, authenticated: false, isAdmin: false, colorways: [] });
     const designId = parseInt(req.query.design_id, 10);
     if (!Number.isFinite(designId) || designId <= 0) return res.status(400).json({ ok: false, error: 'design_id required.' });
     try {
       const raw = psql(`SELECT COALESCE(json_agg(t ORDER BY t.created_at ASC), '[]'::json) FROM (
-        SELECT id, name, ink_map, created_at, new_design_id FROM wallco_user_colorways
-        WHERE user_id=${parseInt(u.id, 10)} AND design_id=${designId}) t;`);
+        SELECT c.id, c.name, c.ink_map, c.created_at, c.new_design_id,
+               s.is_published AS new_design_published
+          FROM wallco_user_colorways c
+          LEFT JOIN spoon_all_designs s ON s.id = c.new_design_id
+         WHERE c.user_id=${parseInt(u.id, 10)} AND c.design_id=${designId}) t;`);
       let rows = [];
       try { rows = JSON.parse(raw || '[]'); } catch (e) { rows = []; }
       const colorways = rows.map((r, i) => ({
         id: r.id, name: r.name, ink_map: r.ink_map, created_at: r.created_at,
-        new_design_id: r.new_design_id, version: i + 1
+        new_design_id: r.new_design_id,
+        new_design_published: r.new_design_published === true || r.new_design_published === 't',
+        version: i + 1
       }));
-      res.json({ ok: true, authenticated: true, colorways });
+      res.json({ ok: true, authenticated: true, isAdmin: isAdmin(req), colorways });
     } catch (e) {
       console.error('[colorways/list] failed:', e.message);
       res.status(500).json({ ok: false, error: 'Lookup failed.' });

← a730f34 colorways: add admin Publish/Unpublish endpoints for promote  ·  back to Wallco Ai  ·  PDP: fall back to wallco_rooms table for room_mockups when d a049441 →