[object Object]

← back to Wallco Ai

wallco.ai · trade login + saved designs — magic-link auth (no passwords, no autonomous email), wallco_trade_users + wallco_saved_designs tables init at start, separate `wallco_trade_session` JWT cookie (won't collide w/ admin `dw_auth`), routes /trade/login /api/trade/login /trade/auth /trade/logout /api/trade/me /api/saved /api/saved/state /me/saved, ♡ Save button on PDP pro-tools row (toggles via POST /api/saved; prompts /trade/login when anon), Sign-In ↔ name+Saved-(N) hydrated nav element in htmlHeader (single fetch shared via window.__tradeMe + trade-me-loaded event), /me/saved grid w/ Remove button + Build-proposal-PDF link to /brief?ids=… — verified: /trade/login 200, /api/trade/login returns magic_link JSON, /trade/auth sets HttpOnly cookie, /api/saved toggle round-trips, anon POST 401, /me/saved redirects 302→/trade/login when anon, schema confirmed via psql dw_unified

6db3b6be10e2fabed10aeda5dd6aeed4f51a30b7 · 2026-05-12 08:51:57 -0700 · Steve Abrams

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

Files touched

Diff

commit 6db3b6be10e2fabed10aeda5dd6aeed4f51a30b7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 12 08:51:57 2026 -0700

    wallco.ai · trade login + saved designs — magic-link auth (no passwords, no autonomous email), wallco_trade_users + wallco_saved_designs tables init at start, separate `wallco_trade_session` JWT cookie (won't collide w/ admin `dw_auth`), routes /trade/login /api/trade/login /trade/auth /trade/logout /api/trade/me /api/saved /api/saved/state /me/saved, ♡ Save button on PDP pro-tools row (toggles via POST /api/saved; prompts /trade/login when anon), Sign-In ↔ name+Saved-(N) hydrated nav element in htmlHeader (single fetch shared via window.__tradeMe + trade-me-loaded event), /me/saved grid w/ Remove button + Build-proposal-PDF link to /brief?ids=… — verified: /trade/login 200, /api/trade/login returns magic_link JSON, /trade/auth sets HttpOnly cookie, /api/saved toggle round-trips, anon POST 401, /me/saved redirects 302→/trade/login when anon, schema confirmed via psql dw_unified
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 server.js | 581 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 581 insertions(+)

diff --git a/server.js b/server.js
index 500bc58..1aa7343 100644
--- a/server.js
+++ b/server.js
@@ -583,6 +583,460 @@ setInterval(() => load(true), 30000);
 // check as a CRITICAL bypass (anyone could mint a token with {role:"admin"}).
 const { isAdmin, requireAdmin } = require('./src/admin-gate');
 
+// ── TRADE LOGIN (separate from admin layer) — magic-link, no passwords.
+// Sister cookie to dw_auth; different name (`wallco_trade_session`), different
+// table (wallco_trade_users), so admin and trade tiers never collide.
+// Per Steve's standing rule: NO autonomous email sending. The magic link is
+// logged to console + echoed in the API response. Wire SES/sendgrid later.
+const jwt = require('jsonwebtoken');
+const WALLCO_JWT_SECRET = process.env.WALLCO_JWT_SECRET
+  || process.env.JWT_SECRET
+  || (function(){
+       console.warn('[trade-login] WALLCO_JWT_SECRET / JWT_SECRET unset — using dev fallback. Set one of these in .env for prod.');
+       return 'wallco-trade-dev-secret-CHANGE-IN-PROD';
+     })();
+const TRADE_COOKIE = 'wallco_trade_session';
+
+// Minimal cookie reader. cookie-parser isn't in deps and `req.cookies` is
+// undefined on non-admin paths. Pull what we need straight from the header.
+function readCookie(req, name) {
+  const raw = req.headers.cookie || '';
+  if (!raw) return null;
+  for (const part of raw.split(/;\s*/)) {
+    const eq = part.indexOf('=');
+    if (eq < 0) continue;
+    const k = part.slice(0, eq);
+    if (k === name) return decodeURIComponent(part.slice(eq + 1));
+  }
+  return null;
+}
+
+// Returns { id, email, name } or null. Never throws.
+function getTradeUser(req) {
+  const token = readCookie(req, TRADE_COOKIE);
+  if (!token) return null;
+  try {
+    const decoded = jwt.verify(token, WALLCO_JWT_SECRET, { algorithms: ['HS256'] });
+    if (!decoded || !decoded.id || !decoded.email) return null;
+    return { id: decoded.id, email: decoded.email, name: decoded.name || '' };
+  } catch {
+    return null;  // expired, bad signature, malformed
+  }
+}
+
+// Migrations — wallco_trade_users + wallco_saved_designs.
+// Idempotent CREATE IF NOT EXISTS, run at server start. Mirrors the
+// design_ratings / studio_renders init pattern below.
+function ensureTradeUserTables() {
+  try {
+    psqlQuery(`CREATE TABLE IF NOT EXISTS wallco_trade_users (
+      id          SERIAL PRIMARY KEY,
+      email       TEXT UNIQUE NOT NULL,
+      name        TEXT,
+      company     TEXT,
+      phone       TEXT,
+      magic_token TEXT,
+      token_exp   TIMESTAMPTZ,
+      last_login  TIMESTAMPTZ,
+      created_at  TIMESTAMPTZ DEFAULT NOW()
+    );`);
+    psqlQuery(`CREATE TABLE IF NOT EXISTS wallco_saved_designs (
+      user_id    INTEGER REFERENCES wallco_trade_users(id) ON DELETE CASCADE,
+      design_id  INTEGER NOT NULL,
+      note       TEXT,
+      saved_at   TIMESTAMPTZ DEFAULT NOW(),
+      PRIMARY KEY (user_id, design_id)
+    );`);
+    psqlQuery(`CREATE INDEX IF NOT EXISTS wallco_saved_designs_user_idx
+                 ON wallco_saved_designs(user_id, saved_at DESC);`);
+    psqlQuery(`CREATE INDEX IF NOT EXISTS wallco_trade_users_token_idx
+                 ON wallco_trade_users(magic_token) WHERE magic_token IS NOT NULL;`);
+    console.log('  Trade-user tables ready (wallco_trade_users + wallco_saved_designs)');
+  } catch (e) {
+    console.error('[trade-login] table init failed:', e.message);
+  }
+}
+ensureTradeUserTables();
+
+// HTML-escape helper for safe interpolation into trade-login templates.
+function _escTrade(s) {
+  return String(s == null ? '' : s)
+    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+}
+
+// Small helper — set cookie `secure` flag only on HTTPS (so dev still works).
+function tradeProtoIsHttps(req) {
+  const proto = (req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim();
+  return proto === 'https';
+}
+
+// GET /trade/login — minimal email-only form. `return` query param round-trips
+// the user back to /design/:id (or wherever they came from) after auth.
+app.get('/trade/login', (req, res) => {
+  const returnTo = String(req.query.return || '/').slice(0, 500);
+  const existing = getTradeUser(req);
+  if (existing) {
+    return res.redirect(returnTo || '/');
+  }
+  res.type('html').send(`${htmlHead({
+    title: 'Trade Login — wallco.ai',
+    description: 'Sign in to save designs, request samples, and build proposals.',
+    canonical: 'https://wallco.ai/trade/login'
+  })}
+<body>
+${htmlHeader('/trade/login')}
+<main class="prose-main" style="max-width:480px;margin:48px auto;padding:0 24px">
+  <article class="prose">
+    <h1 style="font:300 32px/1.2 'Cormorant Garamond',serif;margin:0 0 8px">Trade sign-in</h1>
+    <p style="color:var(--ink-soft,#555);font:14px/1.5 var(--sans,system-ui);margin:0 0 24px">
+      Enter your email — we'll send you a one-tap sign-in link. No passwords.
+      For designers, architects, and trade pros.
+    </p>
+    <form id="trade-login-form" autocomplete="on" novalidate>
+      <input type="hidden" name="return" value="${_escTrade(returnTo)}">
+      <label for="trade-email" style="display:block;font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint,#888);margin-bottom:6px">Email</label>
+      <input type="email" id="trade-email" name="email" required autofocus
+             placeholder="you@studio.com"
+             style="width:100%;padding:14px 16px;font:16px var(--sans,system-ui);border:1px solid var(--line,#ddd);border-radius:6px;margin-bottom:10px;box-sizing:border-box">
+      <button type="submit" class="btn-primary" style="width:100%;padding:14px;font:14px var(--sans);cursor:pointer">
+        Send sign-in link
+      </button>
+    </form>
+    <div id="trade-login-msg" role="status" aria-live="polite" style="margin-top:16px;font:13px/1.5 var(--sans);color:var(--ink-soft,#555)"></div>
+    <p style="margin-top:32px;font:11.5px/1.5 var(--sans);color:var(--ink-faint,#888)">
+      By signing in, you agree to receive trade-only communications from wallco.ai
+      (part of Designer Wallcoverings). We never sell your data.
+    </p>
+  </article>
+</main>
+${FOOTER}
+<script>
+(function(){
+  var form = document.getElementById('trade-login-form');
+  var msg  = document.getElementById('trade-login-msg');
+  if (!form) return;
+  form.addEventListener('submit', function(e){
+    e.preventDefault();
+    var email = document.getElementById('trade-email').value.trim();
+    var returnTo = form.querySelector('input[name=return]').value;
+    if (!email) { msg.textContent = 'Email required.'; return; }
+    msg.textContent = 'Generating sign-in link...';
+    fetch('/api/trade/login', {
+      method:'POST', headers:{'Content-Type':'application/json'},
+      body: JSON.stringify({ email: email, return: returnTo })
+    })
+      .then(function(r){ return r.json().then(function(j){ return {ok:r.ok, j:j}; }); })
+      .then(function(out){
+        if (!out.ok || !out.j.magic_link) {
+          msg.textContent = (out.j && out.j.error) || 'Sign-in failed. Try again.';
+          msg.style.color = '#b03a2e';
+          return;
+        }
+        msg.style.color = '';
+        msg.innerHTML = '<div style="padding:14px;background:#faf8f3;border:1px solid #e6e2d8;border-radius:6px">'
+          + '<div style="font-weight:500;margin-bottom:6px">Sign-in link ready</div>'
+          + '<div style="font:12px/1.4 var(--sans);color:var(--ink-soft);margin-bottom:8px">'
+          + 'Email sending isn’t wired yet — copy this link to sign in:'
+          + '</div>'
+          + '<a href="' + out.j.magic_link + '" style="display:block;font:12px/1.5 ui-monospace,monospace;word-break:break-all;color:var(--accent,#d2b15c);text-decoration:underline;margin-bottom:8px">'
+          + out.j.magic_link + '</a>'
+          + '<a href="' + out.j.magic_link + '" class="btn-primary" style="display:inline-block;padding:8px 14px;font:13px var(--sans);text-decoration:none">Sign in now →</a>'
+          + '</div>';
+      })
+      .catch(function(err){ msg.style.color='#b03a2e'; msg.textContent = 'Network error: ' + err.message; });
+  });
+})();
+</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
+// POST /api/trade/login — generate token, store, return magic link.
+// NOTE: Steve standing rule — NO autonomous email sending. The link is
+// console-logged + returned in JSON. Steve will wire SES/sendgrid later.
+app.post('/api/trade/login', (req, res) => {
+  const email = String((req.body && req.body.email) || '').trim().toLowerCase();
+  const returnTo = String((req.body && req.body.return) || '/').slice(0, 500);
+  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+    return res.status(400).json({ ok: false, error: 'Valid email required.' });
+  }
+  // 32-byte token, 30-minute expiry.
+  const token = crypto.randomBytes(32).toString('hex');
+  const expSql = `NOW() + INTERVAL '30 minutes'`;
+  try {
+    // Upsert user; bind a fresh token.
+    psqlQuery(`INSERT INTO wallco_trade_users (email, magic_token, token_exp)
+               VALUES (${pgEsc(email)}, ${pgEsc(token)}, ${expSql})
+               ON CONFLICT (email) DO UPDATE
+                 SET magic_token = EXCLUDED.magic_token,
+                     token_exp   = EXCLUDED.token_exp;`);
+  } catch (e) {
+    console.error('[trade-login] upsert failed:', e.message);
+    return res.status(500).json({ ok: false, error: 'Could not generate sign-in link.' });
+  }
+  // Build link from request host so dev (localhost:9792) + prod (wallco.ai) both work.
+  const proto = (req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim();
+  const host = req.headers['x-forwarded-host'] || req.get('host');
+  const magicLink = `${proto}://${host}/trade/auth?token=${token}&return=${encodeURIComponent(returnTo)}`;
+  console.log(`[trade-login] magic link: ${magicLink} (for ${email})`);
+  res.json({
+    ok: true,
+    magic_link: magicLink,
+    expires_in: '30 minutes',
+    note: 'Email sending not yet configured — copy the link above.'
+  });
+});
+
+// GET /trade/auth?token=... — validate token, mint JWT, set cookie, redirect.
+app.get('/trade/auth', (req, res) => {
+  const token = String(req.query.token || '').trim();
+  const returnTo = String(req.query.return || '/').slice(0, 500);
+  if (!token || !/^[a-f0-9]{20,128}$/i.test(token)) {
+    return res.status(400).type('html').send('<h1>Invalid sign-in link</h1><p>The token is malformed. <a href="/trade/login">Try again →</a></p>');
+  }
+  let row;
+  try {
+    const raw = psqlQuery(`SELECT row_to_json(t) FROM (
+      SELECT id, email, name, token_exp, (token_exp > NOW()) AS still_valid
+      FROM wallco_trade_users WHERE magic_token = ${pgEsc(token)} LIMIT 1
+    ) t;`);
+    row = raw ? JSON.parse(raw) : null;
+  } catch (e) {
+    console.error('[trade-login] auth lookup failed:', e.message);
+    return res.status(500).type('html').send('<h1>Sign-in failed</h1><p>Server error.</p>');
+  }
+  if (!row) {
+    return res.status(404).type('html').send('<h1>Sign-in link not found</h1><p>It may have already been used. <a href="/trade/login">Get a new one →</a></p>');
+  }
+  if (!row.still_valid) {
+    return res.status(410).type('html').send('<h1>Sign-in link expired</h1><p>Links are valid for 30 minutes. <a href="/trade/login">Get a new one →</a></p>');
+  }
+  // Burn the token, stamp last_login.
+  try {
+    psqlQuery(`UPDATE wallco_trade_users
+                 SET magic_token = NULL, token_exp = NULL, last_login = NOW()
+                 WHERE id = ${parseInt(row.id, 10)};`);
+  } catch (e) {
+    console.error('[trade-login] burn-token update failed:', e.message);
+  }
+  // Mint a 14-day JWT.
+  const jwtToken = jwt.sign(
+    { id: row.id, email: row.email, name: row.name || '' },
+    WALLCO_JWT_SECRET,
+    { algorithm: 'HS256', expiresIn: '14d' }
+  );
+  res.cookie(TRADE_COOKIE, jwtToken, {
+    httpOnly: true,
+    sameSite: 'lax',
+    secure: tradeProtoIsHttps(req),
+    maxAge: 14 * 24 * 3600 * 1000,
+    path: '/'
+  });
+  // Whitelist returnTo to relative paths (prevent open-redirect).
+  const safeReturn = (returnTo && returnTo.startsWith('/') && !returnTo.startsWith('//')) ? returnTo : '/';
+  res.redirect(safeReturn);
+});
+
+// GET /trade/logout — clear cookie, send home.
+app.get('/trade/logout', (req, res) => {
+  res.clearCookie(TRADE_COOKIE, { path: '/' });
+  res.redirect('/');
+});
+
+// GET /api/trade/me — current user + saved-count, for header rendering.
+app.get('/api/trade/me', (req, res) => {
+  const u = getTradeUser(req);
+  if (!u) return res.json({ authenticated: false });
+  let savedCount = 0;
+  try {
+    const raw = psqlQuery(`SELECT count(*) FROM wallco_saved_designs WHERE user_id=${parseInt(u.id, 10)};`);
+    savedCount = parseInt(raw, 10) || 0;
+  } catch (e) { /* tolerate; header just shows 0 */ }
+  res.json({
+    authenticated: true,
+    user: { id: u.id, email: u.email, name: u.name || '' },
+    saved_count: savedCount
+  });
+});
+
+// GET /api/saved/state?design_id=... — idempotent probe for the PDP heart icon.
+// Returns { authenticated, saved }. Anonymous users get { authenticated:false, saved:false }.
+app.get('/api/saved/state', (req, res) => {
+  const u = getTradeUser(req);
+  if (!u) return res.json({ authenticated: false, saved: false });
+  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 hit = psqlQuery(`SELECT 1 FROM wallco_saved_designs
+                             WHERE user_id=${parseInt(u.id, 10)} AND design_id=${designId} LIMIT 1;`);
+    res.json({ authenticated: true, saved: !!hit });
+  } catch (e) {
+    console.error('[saved/state] failed:', e.message);
+    res.status(500).json({ ok: false, error: 'Lookup failed.' });
+  }
+});
+
+// POST /api/saved  { design_id } — toggle save state for current trade user.
+// Returns { ok, saved: bool, saved_count }. 401 if not logged in.
+app.post('/api/saved', (req, res) => {
+  const u = getTradeUser(req);
+  if (!u) return res.status(401).json({ ok: false, error: 'Sign in to save designs.' });
+  const designId = parseInt((req.body && req.body.design_id), 10);
+  if (!Number.isFinite(designId) || designId <= 0) {
+    return res.status(400).json({ ok: false, error: 'design_id required.' });
+  }
+  try {
+    // Check existing → toggle.
+    const exists = psqlQuery(`SELECT 1 FROM wallco_saved_designs
+                                WHERE user_id=${parseInt(u.id, 10)} AND design_id=${designId} LIMIT 1;`);
+    let nowSaved;
+    if (exists) {
+      psqlQuery(`DELETE FROM wallco_saved_designs
+                   WHERE user_id=${parseInt(u.id, 10)} AND design_id=${designId};`);
+      nowSaved = false;
+    } else {
+      psqlQuery(`INSERT INTO wallco_saved_designs (user_id, design_id)
+                   VALUES (${parseInt(u.id, 10)}, ${designId})
+                   ON CONFLICT DO NOTHING;`);
+      nowSaved = true;
+    }
+    const cnt = parseInt(psqlQuery(`SELECT count(*) FROM wallco_saved_designs WHERE user_id=${parseInt(u.id, 10)};`), 10) || 0;
+    res.json({ ok: true, saved: nowSaved, saved_count: cnt });
+  } catch (e) {
+    console.error('[saved] toggle failed:', e.message);
+    res.status(500).json({ ok: false, error: 'Save failed.' });
+  }
+});
+
+// GET /me/saved — auth-gated page listing all the user's saved designs.
+// Uses the same .design-grid CSS class as /designs so it inherits the same
+// layout + density-slider system.
+app.get('/me/saved', (req, res) => {
+  const u = getTradeUser(req);
+  if (!u) {
+    return res.redirect('/trade/login?return=' + encodeURIComponent('/me/saved'));
+  }
+  // Pull save rows joined to live DESIGNS array (DESIGNS is the in-memory
+  // snapshot we already render everywhere). Designs that no longer exist in
+  // the catalog are silently filtered out.
+  let savedIds = [];
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.saved_at DESC), '[]'::json) FROM (
+      SELECT design_id, note, saved_at FROM wallco_saved_designs
+      WHERE user_id=${parseInt(u.id, 10)}) t;`);
+    const rows = JSON.parse(raw || '[]');
+    savedIds = rows.map(r => r.design_id);
+  } catch (e) {
+    console.error('[me/saved] query failed:', e.message);
+  }
+  const byId = new Map(DESIGNS.map(d => [d.id, d]));
+  const designs = savedIds.map(id => byId.get(id)).filter(Boolean);
+
+  const cards = designs.map(d => {
+    const roomKey = (d.room_mockups || []).includes('living_room') ? 'living_room'
+                  : (d.room_mockups || []).includes('bedroom')     ? 'bedroom'
+                  : (d.room_mockups || []).includes('office')      ? 'office'
+                  : (d.room_mockups || [])[0];
+    const roomOverlay = roomKey
+      ? `<img class="card-room" src="/designs/room/design_${d.id}_${roomKey}.png" loading="lazy" alt="" aria-hidden="true">`
+      : '';
+    return `
+    <div class="saved-card-wrap" data-design-id="${d.id}" style="position:relative">
+      <a href="/design/${d.id}" class="design-card${roomKey ? ' has-room' : ''}" aria-label="${_escTrade(d.title || ('Design #' + d.id))}">
+        <div class="card-img" style="background-image:url('${_escTrade(d.image_url)}')" role="img" aria-label="${_escTrade(d.title)} pattern thumbnail">${roomOverlay}</div>
+        <div class="card-meta">
+          <span class="card-title">${_escTrade(d.title)}</span>
+          <span class="card-cat">${_escTrade(d.category)}</span>
+          <span class="color-dot" style="background:${_escTrade(d.dominant_hex || '#ccc')}" aria-hidden="true"></span>
+        </div>
+      </a>
+      <button type="button" class="btn-remove-saved" data-id="${d.id}" title="Remove from saved"
+              style="position:absolute;top:8px;right:8px;background:rgba(15,14,12,.85);color:#e8e2d6;border:0;border-radius:4px;padding:5px 9px;font:11px var(--sans,system-ui);cursor:pointer;letter-spacing:.04em;text-transform:uppercase">
+        Remove
+      </button>
+    </div>`;
+  }).join('');
+
+  const proposalIds = designs.map(d => d.id).join(',');
+  const proposalHref = proposalIds ? ('/brief?ids=' + proposalIds) : '#';
+
+  res.type('html').send(`${htmlHead({
+    title: 'Saved Designs — wallco.ai',
+    description: 'Your saved wallco.ai designs.',
+    canonical: 'https://wallco.ai/me/saved'
+  })}
+<body>
+${htmlHeader('/me/saved')}
+<main>
+<section style="padding:32px 40px 16px">
+  <div style="display:flex;flex-wrap:wrap;align-items:baseline;gap:16px;justify-content:space-between;margin-bottom:18px">
+    <div>
+      <h1 style="font:300 28px/1.2 'Cormorant Garamond',serif;margin:0 0 4px">Saved designs</h1>
+      <div style="font:13px var(--sans,system-ui);color:var(--ink-soft,#555)">
+        ${designs.length} ${designs.length === 1 ? 'design' : 'designs'} saved
+        · <a href="/trade/logout" style="color:var(--ink-soft,#555)">Sign out</a>
+      </div>
+    </div>
+    <div style="display:flex;gap:10px;flex-wrap:wrap">
+      ${designs.length ? `<a href="${proposalHref}" class="btn-primary" style="padding:10px 18px;font:13px var(--sans);text-decoration:none">Build proposal PDF →</a>` : ''}
+      <a href="/designs" class="btn-outline" style="padding:10px 18px;font:13px var(--sans);text-decoration:none">Browse catalog</a>
+    </div>
+  </div>
+  ${designs.length
+    ? `<div class="design-grid catalog-grid" id="saved-grid">${cards}</div>`
+    : `<div style="padding:48px 24px;text-align:center;color:var(--ink-soft,#555);font:14px/1.6 var(--sans);background:var(--card-bg,#faf8f3);border-radius:6px">
+         <p style="margin:0 0 12px">You haven't saved any designs yet.</p>
+         <p style="margin:0"><a href="/designs" style="color:var(--accent,#d2b15c)">Browse the catalog →</a> Tap the <strong>♥ Save</strong> button on any design.</p>
+       </div>`}
+</section>
+</main>
+${FOOTER}
+<script>
+(function(){
+  // Remove from saved — POST /api/saved toggles, then fade the card out.
+  document.querySelectorAll('.btn-remove-saved').forEach(function(btn){
+    btn.addEventListener('click', function(e){
+      e.preventDefault();
+      var id = parseInt(btn.dataset.id, 10);
+      if (!id) return;
+      btn.disabled = true;
+      fetch('/api/saved', {
+        method:'POST', headers:{'Content-Type':'application/json'},
+        body: JSON.stringify({ design_id: id })
+      })
+        .then(function(r){ return r.json(); })
+        .then(function(j){
+          if (j.ok && !j.saved) {
+            var wrap = btn.closest('.saved-card-wrap');
+            if (wrap) {
+              wrap.style.transition = 'opacity .25s, transform .25s';
+              wrap.style.opacity = '0';
+              wrap.style.transform = 'scale(.94)';
+              setTimeout(function(){ wrap.remove();
+                if (!document.querySelectorAll('.saved-card-wrap').length) {
+                  location.reload();
+                }
+              }, 260);
+            }
+          } else {
+            btn.disabled = false;
+            alert(j.error || 'Could not remove.');
+          }
+        })
+        .catch(function(err){ btn.disabled = false; alert('Network error: ' + err.message); });
+    });
+  });
+})();
+</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
 // ── /admin/dashboard — key metrics for wallco.ai operations
 app.get('/admin/dashboard', (req, res) => {
   if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
@@ -822,6 +1276,17 @@ function htmlHeader(active) {
   return `<a class="skip-link" href="#main-content">Skip to main content</a>
 <header class="site-header">
   <a class="brand" href="/">wallco.ai</a>
+  <div class="trade-nav" id="trade-nav" style="position:absolute;right:64px;top:50%;transform:translateY(-50%);font:12px var(--sans,system-ui);display:flex;align-items:center;gap:10px;z-index:5">
+    <a id="trade-nav-signin" href="/trade/login" style="color:var(--ink,#111);text-decoration:none;padding:6px 12px;border:1px solid var(--line,#ddd);border-radius:999px;background:transparent">Sign In</a>
+    <span id="trade-nav-user" style="display:none;align-items:center;gap:8px">
+      <a href="/me/saved" style="color:var(--ink,#111);text-decoration:none">
+        <span id="trade-nav-name" style="font-weight:500"></span>
+        <span style="margin-left:6px;color:var(--ink-faint,#888)">·</span>
+        Saved (<span id="trade-nav-saved-count">0</span>)
+      </a>
+      <a href="/trade/logout" title="Sign out" style="color:var(--ink-faint,#888);text-decoration:none;font-size:11px;margin-left:4px">⎋</a>
+    </span>
+  </div>
   <button class="hamburger" id="nav-toggle" aria-controls="nav-panel" aria-expanded="false" aria-label="Open menu">
     <span></span><span></span><span></span>
   </button>
@@ -830,11 +1295,44 @@ function htmlHeader(active) {
   ${navLinks.map(([href, label]) =>
     `<a href="${href}" class="${active === href ? 'active' : ''}"${href === '/basket' ? ' id="nav-basket-link"' : ''}>${label}${href === '/basket' ? '<span id="nav-basket-count" style="display:none;margin-left:6px;font:11px var(--sans,system-ui);background:var(--gold,#c9a14b);color:var(--accent,#0d0d0d);padding:1px 7px;border-radius:999px;vertical-align:middle"></span>' : ''}</a>`
   ).join('\n  ')}
+  <a href="/me/saved" class="${active === '/me/saved' ? 'active' : ''}">My Saved</a>
   <button class="theme-toggle" id="theme-toggle" aria-label="Toggle dark/light mode">
     <svg class="sun-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
     <svg class="moon-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
   </button>
 </nav>
+<script>
+(function(){
+  // Trade-user nav hydration. Hits /api/trade/me; swaps Sign-In ↔ name+saved-count.
+  // window.__tradeMe is shared with the PDP save-button + /me/saved so we hit
+  // the endpoint only once per page.
+  if (window.__tradeMeLoaded) return;
+  window.__tradeMeLoaded = true;
+  fetch('/api/trade/me', { credentials: 'same-origin' })
+    .then(function(r){ return r.json(); })
+    .then(function(j){
+      window.__tradeMe = j;
+      var signin = document.getElementById('trade-nav-signin');
+      var userBox = document.getElementById('trade-nav-user');
+      if (j && j.authenticated) {
+        if (signin) signin.style.display = 'none';
+        if (userBox) userBox.style.display = 'inline-flex';
+        var nameEl = document.getElementById('trade-nav-name');
+        var countEl = document.getElementById('trade-nav-saved-count');
+        if (nameEl) nameEl.textContent = (j.user && (j.user.name || j.user.email.split('@')[0])) || '';
+        if (countEl) countEl.textContent = j.saved_count || 0;
+      } else {
+        if (signin) {
+          var ret = encodeURIComponent(location.pathname + location.search);
+          signin.setAttribute('href', '/trade/login?return=' + ret);
+        }
+      }
+      // Notify other scripts on this page (e.g. PDP save button)
+      try { window.dispatchEvent(new CustomEvent('trade-me-loaded', { detail: j })); } catch(e){}
+    })
+    .catch(function(){});
+})();
+</script>
 <span id="main-content" tabindex="-1"></span>`;
 }
 
@@ -3442,6 +3940,12 @@ ${htmlHeader('/designs')}
         <a id="cta-view-compare" href="/compare" class="btn-outline" style="font:12px var(--sans);padding:7px 14px;text-decoration:none;display:none;align-items:center;gap:6px;background:var(--gold,#c9a14b);color:var(--accent,#0d0d0d);border-color:var(--gold,#c9a14b)" title="View your compare list">
           View Compare (<span id="cta-compare-count">0</span>)
         </a>
+        <button id="cta-trade-save" type="button" data-design-id="${design.id}" class="btn-outline" style="font:12px var(--sans);padding:7px 14px;cursor:pointer;display:inline-flex;align-items:center;gap:6px" title="Save to your trade library (sign-in required)">
+          <span class="cta-trade-save-icon" aria-hidden="true">♡</span> <span class="cta-trade-save-label">Save</span>
+        </button>
+        <a id="cta-trade-view-saved" href="/me/saved" class="btn-outline" style="font:12px var(--sans);padding:7px 14px;text-decoration:none;display:none;align-items:center;gap:6px" title="View your saved designs">
+          View saved (<span id="cta-trade-saved-count">0</span>)
+        </a>
         <button id="cta-add-basket" type="button" data-design-id="${design.id}" class="btn-outline" style="font:12px var(--sans);padding:7px 14px;cursor:pointer;display:inline-flex;align-items:center;gap:6px" title="Add to your sample basket (up to 12)">
           <span aria-hidden="true">+</span> <span class="cta-add-basket-label">Sample</span>
         </button>
@@ -3533,6 +4037,83 @@ ${htmlHeader('/designs')}
       })();
       </script>
       <script>
+      // Trade save toggle — server-side (POST /api/saved). Falls back to /trade/login
+      // when not authenticated. Hydrates initial state from /api/trade/me payload.
+      (function(){
+        var saveBtn = document.getElementById('cta-trade-save');
+        var viewBtn = document.getElementById('cta-trade-view-saved');
+        var countEl = document.getElementById('cta-trade-saved-count');
+        if (!saveBtn) return;
+        var thisId = parseInt(saveBtn.dataset.designId, 10);
+        var iconEl  = saveBtn.querySelector('.cta-trade-save-icon');
+        var labelEl = saveBtn.querySelector('.cta-trade-save-label');
+
+        function setSavedState(saved) {
+          if (saved) {
+            if (labelEl) labelEl.textContent = 'Saved';
+            if (iconEl)  iconEl.textContent = '♥';
+            saveBtn.style.background = 'var(--gold, #c9a14b)';
+            saveBtn.style.color = 'var(--accent, #0d0d0d)';
+            saveBtn.style.borderColor = 'var(--gold, #c9a14b)';
+          } else {
+            if (labelEl) labelEl.textContent = 'Save';
+            if (iconEl)  iconEl.textContent = '♡';
+            saveBtn.style.background = '';
+            saveBtn.style.color = '';
+            saveBtn.style.borderColor = '';
+          }
+        }
+        function updateCount(n) {
+          if (countEl) countEl.textContent = (n == null ? 0 : n);
+          if (viewBtn) viewBtn.style.display = (n && n > 0) ? 'inline-flex' : 'none';
+        }
+        function hydrate(me) {
+          if (!me || !me.authenticated) { updateCount(0); return; }
+          updateCount(me.saved_count || 0);
+          // Probe save state for this design — cheap, single design_id-only payload.
+          // (We piggyback on the toggle endpoint with HEAD-like idempotent GET later
+          // if needed; for now refresh on click.)
+          fetch('/api/saved/state?design_id=' + thisId, { credentials:'same-origin' })
+            .then(function(r){ return r.ok ? r.json() : { saved:false }; })
+            .then(function(j){ setSavedState(!!j.saved); })
+            .catch(function(){});
+        }
+        // If header has already loaded /api/trade/me, use cached payload; else listen.
+        if (window.__tradeMe) hydrate(window.__tradeMe);
+        else window.addEventListener('trade-me-loaded', function(ev){ hydrate(ev.detail); });
+
+        saveBtn.addEventListener('click', function(){
+          var me = window.__tradeMe;
+          if (!me || !me.authenticated) {
+            var ret = encodeURIComponent('/design/' + thisId);
+            if (confirm('Sign in to save designs?')) location.href = '/trade/login?return=' + ret;
+            return;
+          }
+          saveBtn.disabled = true;
+          fetch('/api/saved', {
+            method:'POST', headers:{'Content-Type':'application/json'},
+            credentials:'same-origin',
+            body: JSON.stringify({ design_id: thisId })
+          })
+            .then(function(r){ return r.json(); })
+            .then(function(j){
+              if (j.ok) {
+                setSavedState(j.saved);
+                updateCount(j.saved_count);
+                // bump header count, too
+                var nav = document.getElementById('trade-nav-saved-count');
+                if (nav) nav.textContent = j.saved_count;
+                if (window.__tradeMe) window.__tradeMe.saved_count = j.saved_count;
+              } else {
+                alert(j.error || 'Save failed.');
+              }
+            })
+            .catch(function(err){ alert('Network error: ' + err.message); })
+            .finally(function(){ saveBtn.disabled = false; });
+        });
+      })();
+      </script>
+      <script>
       (function(){
         var btn = document.getElementById('cta-share');
         if (!btn) return;

← 542e977 wallco.ai · sample basket — multi-design trade samples (mirr  ·  back to Wallco Ai  ·  wallco.ai · 3 more pro functions inline · (1) /api/designs/b 1799198 →