[object Object]

← back to Interiordesignershowroom

IDS admin: implement EZ-Join DTD verdict (C+relabel), fix smart-quote regression, sync rooms.hotspots schema (v0.2.1)

3e86ee5dfc6aa3ca83d76a6c47d505fb2846c261 · 2026-08-03 09:49:33 -0700 · Steve

- EZ-Join verdict C-as-superset-of-A: joinTarget() resolves a per-network
  marketplace SEARCH deep-link (brand pre-filled) where searchVerified, else the
  plain landing (safe fallback). All networks land today (unverified); flip
  searchVerified per network after a live test. Honest relabel 'EZ Join' ->
  'Join via <Network>'.
- Fix: refactor pass had introduced curly quotes as HTML attr delimiters on the
  join button (class="ezjoin"/href broke styling+link) — restored straight quotes.
- db/rooms.sql: declare + idempotently ALTER-add the hotspots JSONB column that
  createRoom() writes (schema file was drifted from the live DB).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 3e86ee5dfc6aa3ca83d76a6c47d505fb2846c261
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Aug 3 09:49:33 2026 -0700

    IDS admin: implement EZ-Join DTD verdict (C+relabel), fix smart-quote regression, sync rooms.hotspots schema (v0.2.1)
    
    - EZ-Join verdict C-as-superset-of-A: joinTarget() resolves a per-network
      marketplace SEARCH deep-link (brand pre-filled) where searchVerified, else the
      plain landing (safe fallback). All networks land today (unverified); flip
      searchVerified per network after a live test. Honest relabel 'EZ Join' ->
      'Join via <Network>'.
    - Fix: refactor pass had introduced curly quotes as HTML attr delimiters on the
      join button (class="ezjoin"/href broke styling+link) — restored straight quotes.
    - db/rooms.sql: declare + idempotently ALTER-add the hotspots JSONB column that
      createRoom() writes (schema file was drifted from the live DB).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 db/rooms.sql      |  3 +++
 package-lock.json |  4 ++--
 package.json      |  2 +-
 routes/admin.js   | 48 +++++++++++++++++++++++++++++++++++-------------
 4 files changed, 41 insertions(+), 16 deletions(-)

diff --git a/db/rooms.sql b/db/rooms.sql
index 08ac8ae..1f87f39 100644
--- a/db/rooms.sql
+++ b/db/rooms.sql
@@ -15,9 +15,12 @@ CREATE TABLE IF NOT EXISTS rooms (
   created_by   TEXT DEFAULT 'visitor',        -- 'curator' (featured) | 'visitor'
   featured     BOOLEAN DEFAULT FALSE,
   public       BOOLEAN DEFAULT TRUE,
+  hotspots     JSONB NOT NULL DEFAULT '[]',    -- [{id,box:{x,y,w,h},...}] shoppable pins on the scene
   created_at   TIMESTAMPTZ DEFAULT now(),
   updated_at   TIMESTAMPTZ DEFAULT now()
 );
+-- Existing DBs predating the column: add it idempotently (createRoom() writes hotspots).
+ALTER TABLE rooms ADD COLUMN IF NOT EXISTS hotspots JSONB NOT NULL DEFAULT '[]';
 CREATE INDEX IF NOT EXISTS idx_rooms_featured ON rooms (featured, created_at DESC);
 CREATE INDEX IF NOT EXISTS idx_rooms_style ON rooms (style);
 
diff --git a/package-lock.json b/package-lock.json
index 72ddafa..1e1c124 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "interiordesignershowroom",
-  "version": "0.2.0",
+  "version": "0.2.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "interiordesignershowroom",
-      "version": "0.2.0",
+      "version": "0.2.1",
       "dependencies": {
         "dotenv": "^17.4.2",
         "express": "^4.19.2",
diff --git a/package.json b/package.json
index 556bf61..21564f7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "interiordesignershowroom",
-  "version": "0.2.0",
+  "version": "0.2.1",
   "private": true,
   "description": "Curated, editorial affiliate showroom for interior design — multi-network (CJ, Amazon, Rakuten, ShareASale) feed-driven catalog + buying guides.",
   "main": "server.js",
diff --git a/routes/admin.js b/routes/admin.js
index 7b4b727..4968b83 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -13,16 +13,34 @@ const router = express.Router();
 
 // Affiliate networks we can send Steve to in order to JOIN a program. There is no
 // true one-click affiliate join (every network makes you log in as a publisher and
-// click "Join" on the advertiser), so "EZ Join" deep-links straight into that
-// network's marketplace — one search-and-click away, not a fabricated brand URL.
+// click "Join" on the advertiser). Per the DTD verdict (2026-08-03) the "Join via
+// <Network>" button resolves option C-as-a-superset-of-A: use a per-network
+// marketplace SEARCH deep-link (brand pre-filled, `{q}` placeholder) ONLY where that
+// pattern is verified to survive the network's login redirect (searchVerified:true);
+// everywhere else fall back to the plain marketplace `join` landing. Networks stay on
+// the landing until each `search` URL is confirmed (CJ's is login-gated and unverified,
+// so it lands — exactly the dissent's concern). To enable a search deep-link after a
+// 5-second live test, just flip that network's `searchVerified` to true.
 const NETWORKS = {
-  cj:         { label: 'CJ',         join: 'https://members.cj.com/member/publisher/marketplace' },
-  rakuten:    { label: 'Rakuten',    join: 'https://rakutenadvertising.com/en-us/affiliate-marketing-for-publishers/' },
-  shareasale: { label: 'ShareASale', join: 'https://account.shareasale.com/a-loginpage.cfm' },
-  impact:     { label: 'Impact',     join: 'https://app.impact.com/' },
-  amazon:     { label: 'Amazon',     join: 'https://affiliate-program.amazon.com/' },
+  cj:         { label: 'CJ',         join: 'https://members.cj.com/member/publisher/marketplace', search: 'https://members.cj.com/member/publisher/marketplace?keyword={q}', searchVerified: false },
+  rakuten:    { label: 'Rakuten',    join: 'https://rakutenadvertising.com/en-us/affiliate-marketing-for-publishers/', search: null, searchVerified: false },
+  shareasale: { label: 'ShareASale', join: 'https://account.shareasale.com/a-loginpage.cfm', search: 'https://account.shareasale.com/a-searchmerchants.cfm?keyword={q}', searchVerified: false },
+  impact:     { label: 'Impact',     join: 'https://app.impact.com/', search: null, searchVerified: false },
+  amazon:     { label: 'Amazon',     join: 'https://affiliate-program.amazon.com/', search: null, searchVerified: false },
 };
 
+// Resolve the join target for a (network, brand): a brand-prefilled marketplace SEARCH
+// where that network has a verified search pattern, else the plain marketplace landing
+// (the safe A-fallback that never 404s). Returns { url, prefilled }.
+function joinTarget(network, brand) {
+  const net = NETWORKS[network];
+  if (!net) return { url: '#', prefilled: false };
+  if (net.searchVerified && net.search && brand) {
+    return { url: net.search.replace('{q}', encodeURIComponent(brand)), prefilled: true };
+  }
+  return { url: net.join, prefilled: false };
+}
+
 // Load the curated suggestion roster (data/suggested-affiliates.json). Read live so
 // the list can be extended without a restart; never throws the request on a bad file.
 function loadSuggestions() {
@@ -569,7 +587,7 @@ router.get('/admin/affiliates', async (_req, res, next) => {
 
 // Suggested lines to join — a curated gap-finder. Shows on-brand affiliate programs
 // across CJ + other networks, flags the ones already in the catalog (✓ Joined), and
-// gives an "EZ Join" deep-link into the right network's marketplace for the rest.
+// gives a "Join via <Network>" deep-link into the right network's marketplace for the rest.
 router.get('/admin/affiliates/suggested', async (req, res, next) => {
   try {
     const lines = loadSuggestions();
@@ -610,9 +628,13 @@ router.get('/admin/affiliates/suggested', async (req, res, next) => {
     const rows = shown.map((l) => {
       const primary = l.network;
       const joinedLabel = l.joinedNets.map((k) => (NETWORKS[k] ? NETWORKS[k].label : k)).join(', ');
+      const t = joinTarget(primary, l.name);
+      const tip = t.prefilled
+        ? `Opens ${NETWORKS[primary].label} marketplace search pre-filled for "${l.name}" — click Join Program`
+        : `Opens ${NETWORKS[primary].label} — search "${l.name}" and click Join Program`;
       const joinBtn = l.joined
-        ? `<span class=”subtle” title=”Already in your catalog on ${esc(joinedLabel)}”>on ${esc(joinedLabel)}</span>`
-        : `<a class=”ezjoin” href=”${esc(NETWORKS[primary].join)}” target=”_blank” rel=”noopener nofollow” title=”Opens ${esc(NETWORKS[primary].label)} — search “${esc(l.name)}” and click Join”>EZ Join on ${esc(NETWORKS[primary].label)} →</a>`;
+        ? `<span class="subtle" title="Already in your catalog on ${esc(joinedLabel)}">on ${esc(joinedLabel)}</span>`
+        : `<a class="ezjoin" href="${esc(t.url)}" target="_blank" rel="noopener nofollow" title="${esc(tip)}">Join via ${esc(NETWORKS[primary].label)} →</a>`;
       return `<tr class="${l.joined ? 'row-off' : ''}">
         <td><b>${esc(l.name)}</b></td>
         <td>${netBadges(l.nets)}</td>
@@ -626,9 +648,9 @@ router.get('/admin/affiliates/suggested', async (req, res, next) => {
     const intro = `<p class="subtle" style="margin:0 0 10px">
       On-brand affiliate programs worth adding. <b>${openCount}</b> not yet joined ·
       <b>${joinedCount}</b> already in your catalog. There's no true one-click join —
-      <b>EZ Join</b> opens the network's publisher marketplace so you can search the brand
-      and hit <i>Join Program</i> in one step. Extend the list in
-      <code>data/suggested-affiliates.json</code>.</p>`;
+      <b>Join via &lt;Network&gt;</b> drops you into that network's publisher marketplace
+      (pre-filled search where the network supports it) so you can hit <i>Join Program</i>.
+      Extend the list in <code>data/suggested-affiliates.json</code>.</p>`;
 
     res.send(shell(`
       <h1 style="font-size:1.15rem;margin:0 0 4px">Affiliates</h1>

← 562d723 chore: lint, refactor, v0.2.0 (session close)  ·  back to Interiordesignershowroom  ·  seo: indexable facet landing pages — sitemap + self-canonica 6a15502 →