← back to Interiordesignershowroom
IDS admin: 'Suggested lines to join' affiliate tab (TK-10171)
95495fd48aa9281a47ff1c3262356484ce815cbb · 2026-08-03 08:49:58 -0700 · Steve
Adds a second tab under /admin/affiliates: a curated gap-finder of on-brand
home/decor affiliate programs across CJ + other networks. Flags lines already
in the catalog as Joined, gives an EZ Join deep-link into the right network's
marketplace for the rest, filterable by network. Roster is data-driven
(data/suggested-affiliates.json), read live so it extends without a restart.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
Diff
commit 95495fd48aa9281a47ff1c3262356484ce815cbb
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Aug 3 08:49:58 2026 -0700
IDS admin: 'Suggested lines to join' affiliate tab (TK-10171)
Adds a second tab under /admin/affiliates: a curated gap-finder of on-brand
home/decor affiliate programs across CJ + other networks. Flags lines already
in the catalog as Joined, gives an EZ Join deep-link into the right network's
marketplace for the rest, filterable by network. Roster is data-driven
(data/suggested-affiliates.json), read live so it extends without a restart.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
routes/admin.js | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 151 insertions(+), 9 deletions(-)
diff --git a/routes/admin.js b/routes/admin.js
index 77cbd0a..5348807 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -3,11 +3,45 @@
// which links are actually converting (click analytics).
const express = require('express');
const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
const db = require('../lib/db');
const { esc, money } = require('../lib/render');
+const { detailPref } = require('../lib/adminview');
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.
+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/' },
+};
+
+// 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() {
+ try {
+ const raw = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'suggested-affiliates.json'), 'utf8'));
+ return Array.isArray(raw.lines) ? raw.lines : [];
+ } catch (_) { return []; }
+}
+
+// Sub-nav for the Affiliates section: two tabs, current one highlighted.
+function affiliateTabs(active) {
+ const tab = (href, label, on) =>
+ `<a href="${href}" class="tab${on ? ' tab-on' : ''}">${label}</a>`;
+ return `<div class="tabs">
+ ${tab('/admin/affiliates', 'Active affiliates', active === 'active')}
+ ${tab('/admin/affiliates/suggested', 'Suggested lines to join', active === 'suggested')}
+ </div>`;
+}
+
// --- Basic auth (timing-safe) --------------------------------------------
const USER = process.env.ADMIN_USER || 'admin';
const PASS = process.env.ADMIN_PASS || 'DW2024!';
@@ -21,7 +55,12 @@ function auth(req, res, next) {
const m = h.match(/^Basic (.+)$/);
if (m) {
const [u, p] = Buffer.from(m[1], 'base64').toString().split(':');
- if (u != null && p != null && safeEq(u, USER) && safeEq(p, PASS)) return next();
+ if (u != null && p != null && safeEq(u, USER) && safeEq(p, PASS)) {
+ // Non-secret UI hint: tells the public storefront to show the admin "see-more"
+ // bar. NOT a security boundary — every mutation still requires this Basic auth.
+ res.cookie('ids_admin_ui', '1', { maxAge: 12 * 3600e3, sameSite: 'lax', path: '/' });
+ return next();
+ }
}
res.set('WWW-Authenticate', 'Basic realm="IDS Admin"').status(401).send('Auth required');
}
@@ -76,17 +115,29 @@ tr.row-off td{background:#faf5f2;color:#9a8f86}
tr.row-off td:first-child{box-shadow:inset 3px 0 0 #d9bcbc}
.pill.on{color:#2f7a2f}.pill.off{color:#b1483c}
header nav a{margin-left:2px}
+.tabs{display:flex;gap:4px;border-bottom:1px solid var(--line);margin:6px 0 18px}
+.tabs .tab{padding:8px 16px;text-decoration:none;color:#8a8178;font-size:.9rem;border:1px solid transparent;border-bottom:none;border-radius:6px 6px 0 0;position:relative;top:1px}
+.tabs .tab:hover{color:var(--ink)}
+.tabs .tab-on{background:#fff;border-color:var(--line);color:var(--ink);font-weight:600}
+.chips{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 14px}
+.chips a{padding:3px 11px;border:1px solid var(--line);border-radius:20px;text-decoration:none;color:#6b6259;font-size:.78rem;background:#fff}
+.chips a.on{background:#221e19;color:#e6c98a;border-color:#221e19}
+a.ezjoin{display:inline-block;background:#8a7250;color:#fff;padding:4px 12px;border-radius:5px;text-decoration:none;font-size:.8rem;white-space:nowrap}
+a.ezjoin:hover{background:#6f5c40}
+.why{color:#6b6259;font-size:.82rem}
</style></head><body>
<header><h1>Interior Designer’s Showroom — Admin</h1>
<nav><a href="/admin">Dashboard</a> · <a href="/admin/affiliates">Affiliates</a> · <a href="/">View site ↗</a></nav></header>
<main>${body}</main></body></html>`;
-router.get('/admin', async (_req, res, next) => {
+router.get('/admin', async (req, res, next) => {
try {
+ const details = detailPref(req, res); // "see more on the backend" toggle
const [counts, topClicks, products, guides] = await Promise.all([
db.query(`SELECT
(SELECT count(*) FROM products) AS products,
(SELECT count(*) FROM products WHERE featured) AS featured,
+ (SELECT count(*) FROM products WHERE suppressed) AS suppressed,
(SELECT count(*) FROM guides WHERE published) AS guides,
(SELECT count(*) FROM clicks) AS clicks,
(SELECT count(*) FROM clicks WHERE clicked_at > now() - interval '7 days') AS clicks7,
@@ -95,14 +146,15 @@ router.get('/admin', async (_req, res, next) => {
db.query(`SELECT p.id,p.title,p.advertiser,count(c.*) AS n
FROM clicks c JOIN products p ON p.id=c.product_id
GROUP BY p.id,p.title,p.advertiser ORDER BY n DESC LIMIT 10`),
- db.query(`SELECT id,title,advertiser,network,price,sale_price,image_url,featured,room,created_at
- FROM products ORDER BY featured DESC, created_at DESC LIMIT 200`),
+ db.query(`SELECT id,title,advertiser,network,external_id,brand,category,price,sale_price,image_url,featured,suppressed,in_stock,room,created_at,updated_at
+ FROM products ORDER BY featured DESC, suppressed, created_at DESC LIMIT 200`),
db.query(`SELECT id,slug,title,published,created_at FROM guides ORDER BY created_at DESC`),
]);
const c = counts.rows[0];
const stats = `<div class="stats">
<div class="stat"><b>${c.products}</b><span>Products</span></div>
<div class="stat"><b>${c.featured}</b><span>Featured</span></div>
+ <div class="stat"><b>${c.suppressed}</b><span>Suppressed${Number(c.suppressed) ? ' · hidden' : ''}</span></div>
<div class="stat"><b>${c.guides}</b><span>Published guides</span></div>
<div class="stat"><b>${c.clicks}</b><span>Total clicks</span></div>
<div class="stat"><b>${c.clicks7}</b><span>Clicks · 7 days</span></div>
@@ -113,14 +165,20 @@ router.get('/admin', async (_req, res, next) => {
? topClicks.rows.map((r) => `<tr><td>${esc(r.title)}</td><td>${esc(r.advertiser || '')}</td><td><b>${r.n}</b></td></tr>`).join('')
: `<tr><td colspan="3" style="color:#8a8178">No clicks logged yet.</td></tr>`;
- const prodRows = products.rows.map((p) => `<tr>
+ // Extra <th>/<td> columns only rendered when "Show details" is on ("see more").
+ const detailHead = details ? `<th>Brand</th><th>Category</th><th>Ext ID</th><th>Stock</th><th>Updated</th>` : '';
+ const prodRows = products.rows.map((p) => `<tr class="${p.suppressed ? 'row-off' : ''}">
<td>${p.image_url ? `<img class="thumb" src="${esc(p.image_url)}" alt="">` : ''}</td>
<td>${esc(p.title)}<div class="net">${esc(p.network)} · ${esc(p.advertiser || '')}</div></td>
<td>${esc(p.room || '')}</td>
<td>${money(p.sale_price || p.price)}</td>
<td>${when(p.created_at)}</td>
- <td><span class="pill ${p.featured ? 'on' : 'off'}">${p.featured ? 'Featured' : '—'}</span></td>
- <td><form method="post" action="/admin/products/${p.id}/featured"><button>${p.featured ? 'Unfeature' : 'Feature'}</button></form></td>
+ <td><span class="pill ${p.featured ? 'on' : 'off'}">${p.featured ? 'Featured' : '—'}</span>${p.suppressed ? ' <span class="pill off">Hidden</span>' : ''}</td>
+ ${details ? `<td class="subtle">${esc(p.brand || '')}</td><td class="subtle">${esc(p.category || '')}</td><td class="subtle">${esc(p.external_id || '')}</td><td class="subtle">${p.in_stock === false ? 'out' : 'in'}</td><td>${when(p.updated_at)}</td>` : ''}
+ <td style="white-space:nowrap">
+ <form method="post" action="/admin/products/${p.id}/featured" style="display:inline;margin:0"><button>${p.featured ? 'Unfeature' : 'Feature'}</button></form>
+ <form method="post" action="/admin/products/${p.id}/suppress" style="display:inline;margin:0"><button title="${p.suppressed ? 'Show this product on the storefront again' : 'Hide this product everywhere (kept in DB)'}">${p.suppressed ? 'Unhide' : 'Hide'}</button></form>
+ </td>
</tr>`).join('');
const guideRows = guides.rows.map((g) => `<tr>
@@ -135,8 +193,10 @@ router.get('/admin', async (_req, res, next) => {
<p style="margin:16px 0 0"><a href="/admin/affiliates" style="display:inline-block;background:#221e19;color:#e6c98a;padding:8px 14px;border-radius:6px;text-decoration:none;font-size:.85rem">⚙︎ Manage affiliates — turn sources on / off →</a></p>
<h2>Top-clicked products</h2>
<table><tr><th>Product</th><th>Advertiser</th><th>Clicks</th></tr>${topRows}</table>
- <h2>Products — curate featured (${products.rows.length})</h2>
- <table><tr><th></th><th>Product</th><th>Room</th><th>Price</th><th>Added</th><th>Status</th><th></th></tr>${prodRows}</table>
+ <h2>Products — curate featured (${products.rows.length})
+ <a href="/admin?details=${details ? 0 : 1}" style="float:right;font-size:.75rem;font-weight:400;text-decoration:none;color:${details ? '#b1483c' : '#8a7250'}">${details ? '− Hide details' : '+ Show details'}</a>
+ </h2>
+ <table><tr><th></th><th>Product</th><th>Room</th><th>Price</th><th>Added</th><th>Status</th>${detailHead}<th></th></tr>${prodRows}</table>
<h2>Guides</h2>
<table><tr><th>Guide</th><th>Created</th><th>Status</th><th></th></tr>${guideRows}</table>
`));
@@ -147,6 +207,12 @@ router.post('/admin/products/:id/featured', async (req, res, next) => {
try { await db.query(`UPDATE products SET featured = NOT featured, updated_at=now() WHERE id=$1`, [req.params.id]); res.redirect('/admin'); }
catch (e) { next(e); }
});
+// Hide/show one product everywhere (kept in the DB, just flagged off — the reversible
+// "check it off as no"). The storefront gate lives in lib/catalog.js + lib/rooms.js.
+router.post('/admin/products/:id/suppress', async (req, res, next) => {
+ try { await db.query(`UPDATE products SET suppressed = NOT suppressed, updated_at=now() WHERE id=$1`, [req.params.id]); res.redirect('/admin'); }
+ catch (e) { next(e); }
+});
router.post('/admin/guides/:id/published', async (req, res, next) => {
try { await db.query(`UPDATE guides SET published = NOT published, updated_at=now() WHERE id=$1`, [req.params.id]); res.redirect('/admin'); }
catch (e) { next(e); }
@@ -258,6 +324,7 @@ router.get('/admin/affiliates', async (_req, res, next) => {
res.send(shell(`
<h1 style="font-size:1.15rem;margin:0 0 4px">Affiliates</h1>
+ ${affiliateTabs('active')}
${intro}
<h2>Networks — master switch per program</h2>
<table>
@@ -273,6 +340,81 @@ router.get('/admin/affiliates', async (_req, res, next) => {
} catch (e) { next(e); }
});
+// 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.
+router.get('/admin/affiliates/suggested', async (req, res, next) => {
+ try {
+ const lines = loadSuggestions();
+ const { rows: advRows } = await db.query(
+ `SELECT DISTINCT advertiser, network FROM products WHERE advertiser IS NOT NULL AND advertiser <> ''`);
+ const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
+ const advs = advRows.map((a) => ({ ...a, n: norm(a.advertiser) }));
+ // A suggestion is "joined" if a live advertiser name overlaps it either direction
+ // ("Wayfair" ⊂ "Wayfair North America"). Returns the networks it's live on.
+ const joinedOn = (name) => {
+ const n = norm(name);
+ if (!n) return [];
+ const hits = advs.filter((a) => a.n && (a.n.includes(n) || n.includes(a.n)));
+ return [...new Set(hits.map((h) => h.network))];
+ };
+
+ const netFilter = NETWORKS[req.query.network] ? req.query.network : '';
+ const enriched = lines.map((l) => {
+ const on = joinedOn(l.name);
+ const nets = [l.network, ...(l.also || [])].filter((x) => NETWORKS[x]);
+ return { ...l, nets, joined: on.length > 0, joinedNets: on };
+ });
+ const shown = netFilter ? enriched.filter((l) => l.nets.includes(netFilter)) : enriched;
+
+ const joinedCount = enriched.filter((l) => l.joined).length;
+ const openCount = enriched.length - joinedCount;
+
+ // network filter chips
+ const chip = (key, label, on) => `<a href="/admin/affiliates/suggested${key ? `?network=${key}` : ''}" class="${on ? 'on' : ''}">${esc(label)}</a>`;
+ const chips = `<div class="chips">
+ ${chip('', 'All networks', !netFilter)}
+ ${Object.keys(NETWORKS).map((k) => chip(k, NETWORKS[k].label, netFilter === k)).join('')}
+ </div>`;
+
+ const netBadges = (nets) => nets.map((k, i) =>
+ `<span class="net"${i ? ' style="margin-left:6px"' : ''}>${esc(NETWORKS[k].label)}</span>`).join('');
+
+ const rows = shown.map((l) => {
+ const primary = l.network;
+ const joinBtn = l.joined
+ ? `<span class="subtle" title="Already in your catalog on ${esc(l.joinedNets.map((k) => (NETWORKS[k] ? NETWORKS[k].label : k)).join(', '))}">on ${esc(l.joinedNets.map((k) => (NETWORKS[k] ? NETWORKS[k].label : k)).join(', '))}</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>`;
+ return `<tr class="${l.joined ? 'row-off' : ''}">
+ <td><b>${esc(l.name)}</b></td>
+ <td>${netBadges(l.nets)}</td>
+ <td>${esc(l.category || '')}</td>
+ <td class="why">${esc(l.why || '')}</td>
+ <td>${l.joined ? '<span class="pill on">✓ Joined</span>' : '<span class="pill off">Not joined</span>'}</td>
+ <td>${joinBtn}</td>
+ </tr>`;
+ }).join('');
+
+ 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>`;
+
+ res.send(shell(`
+ <h1 style="font-size:1.15rem;margin:0 0 4px">Affiliates</h1>
+ ${affiliateTabs('suggested')}
+ ${intro}
+ ${chips}
+ <table>
+ <tr><th>Line</th><th>Network(s)</th><th>Category</th><th>Why it fits</th><th>Status</th><th>Join</th></tr>
+ ${rows || `<tr><td colspan="6" class="subtle">No suggestions${netFilter ? ` on ${esc(NETWORKS[netFilter].label)}` : ''}. Add some to data/suggested-affiliates.json.</td></tr>`}
+ </table>
+ `));
+ } catch (e) { next(e); }
+});
+
// Toggle one affiliate (network-wide when advertiser is blank, else that merchant).
// Default state is ENABLED, so we only ever persist a row once it's been touched;
// the flip is idempotent and reversible. We guard against junk keys by requiring
← e72a006 moodboard drawer: Pieces tab defaults open (board visible on
·
back to Interiordesignershowroom
·
auto-save: 2026-08-03T08:52:19 (7 files) — db/schema.sql lib 99f7508 →