← 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, '&').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+}
+
+// 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 →