[object Object]

← back to Wallco Ai

marketplace: designer follow + /trade/projects board listing (files)

67e4cc85e88b6867e8b6fbaf11287ffab8404627 · 2026-05-13 14:56:58 -0700 · SteveStudio2

Sister-commit to b74ae4b — actual code (mount line landed in 8350976 via
parallel-session staging race; this lands the modules + page + tests).

- POST/DELETE/GET /api/marketplace/designers/:id/follow (id or slug, idempotent)
- GET /api/marketplace/projects — current user's project boards joined to mp_project_saves
- /trade/projects page (public/marketplace/projects.html) with create-board form + thumbnail grid
- Follow button on designer-profile.html and storefront.html, calls follow API
- Routes split into src/marketplace/follow-projects.js to keep index.js small
- Tests: structure (mount + page wiring) + HTTP smoke (auth gating + public count)

Files touched

Diff

commit 67e4cc85e88b6867e8b6fbaf11287ffab8404627
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Wed May 13 14:56:58 2026 -0700

    marketplace: designer follow + /trade/projects board listing (files)
    
    Sister-commit to b74ae4b — actual code (mount line landed in 8350976 via
    parallel-session staging race; this lands the modules + page + tests).
    
    - POST/DELETE/GET /api/marketplace/designers/:id/follow (id or slug, idempotent)
    - GET /api/marketplace/projects — current user's project boards joined to mp_project_saves
    - /trade/projects page (public/marketplace/projects.html) with create-board form + thumbnail grid
    - Follow button on designer-profile.html and storefront.html, calls follow API
    - Routes split into src/marketplace/follow-projects.js to keep index.js small
    - Tests: structure (mount + page wiring) + HTTP smoke (auth gating + public count)
---
 public/marketplace/designer-profile.html  |  41 +++++++
 public/marketplace/projects.html          | 174 +++++++++++++++++++++++++++
 public/marketplace/storefront.html        |  44 ++++++-
 src/marketplace/challenges.js             |   4 +-
 src/marketplace/follow-projects.js        | 158 +++++++++++++++++++++++++
 tests/marketplace/challenges.test.js      | 188 ++++++++++++++++++++++++++++++
 tests/marketplace/follow-projects.test.js | 179 ++++++++++++++++++++++++++++
 7 files changed, 786 insertions(+), 2 deletions(-)

diff --git a/public/marketplace/designer-profile.html b/public/marketplace/designer-profile.html
index 3c2b96f..d53ff27 100644
--- a/public/marketplace/designer-profile.html
+++ b/public/marketplace/designer-profile.html
@@ -59,6 +59,8 @@ async function load() {
           ${d.is_verified ? '<span class="mp-pill verified">Verified</span>' : ''}
           <span>${Number(d.points||0).toLocaleString()} pts</span>
           <span>${(j.patterns || []).length} patterns</span>
+          <button class="mp-btn tiny" id="mp-follow-btn" data-designer-id="${d.id}" type="button" style="display:none">Follow</button>
+          <span id="mp-follow-count" class="mp-pill" style="display:none"></span>
           <a class="mp-btn tiny" href="/store/${d.slug}">Visit storefront →</a>
         </div>
       </div>
@@ -89,6 +91,45 @@ async function load() {
       </div>`;
     pat.appendChild(a);
   });
+
+  setupFollow(d.id, d.slug);
+}
+
+async function setupFollow(designerId, designerSlug) {
+  const btn = document.getElementById('mp-follow-btn');
+  const cntEl = document.getElementById('mp-follow-count');
+  if (!btn) return;
+  let st;
+  try {
+    st = await (await fetch('/api/marketplace/designers/' + encodeURIComponent(designerSlug) + '/follow', { credentials: 'include' })).json();
+  } catch (_) { return; }
+  btn.style.display = '';
+  cntEl.style.display = '';
+  render(st);
+  btn.addEventListener('click', async () => {
+    const next = !btn.dataset.following || btn.dataset.following === 'false';
+    btn.disabled = true;
+    try {
+      const r = await fetch('/api/marketplace/designers/' + designerId + '/follow', {
+        method: next ? 'POST' : 'DELETE',
+        credentials: 'include',
+      });
+      if (r.status === 401) { window.location = '/marketplace/apply'; return; }
+      const j = await r.json();
+      if (!r.ok) throw new Error(j.error || 'Follow failed');
+      render(j);
+    } catch (err) {
+      alert(err.message);
+    } finally {
+      btn.disabled = false;
+    }
+  });
+  function render(s) {
+    btn.dataset.following = String(!!s.following);
+    btn.textContent = s.following ? 'Following ✓' : 'Follow';
+    btn.classList.toggle('ghost', !!s.following);
+    cntEl.textContent = (Number(s.count) || 0).toLocaleString() + ' follower' + (Number(s.count) === 1 ? '' : 's');
+  }
 }
 load();
 </script>
diff --git a/public/marketplace/projects.html b/public/marketplace/projects.html
new file mode 100644
index 0000000..ed41a25
--- /dev/null
+++ b/public/marketplace/projects.html
@@ -0,0 +1,174 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<title>Trade Projects · Wallco</title>
+<meta name="viewport" content="width=device-width,initial-scale=1" />
+<link rel="stylesheet" href="/marketplace/_layout.css" />
+<script src="/marketplace/_partials.js" defer></script>
+<style>
+  .mp-proj-head { display:flex; align-items:flex-end; justify-content:space-between; gap: 16px; flex-wrap: wrap; padding: 24px 0; border-bottom: 1px solid var(--mp-border); margin-bottom: 18px; }
+  .mp-proj-head h1 { font-size: clamp(28px, 3.4vw, 42px); margin: 0 0 4px; letter-spacing: -.02em; }
+  .mp-proj-head .lede { color: var(--mp-muted); font-size: 14px; max-width: 540px; }
+  .mp-proj-card { background: var(--mp-card); border: 1px solid var(--mp-border); border-radius: var(--mp-radius); box-shadow: var(--mp-glass-shadow); padding: 18px 20px; margin-bottom: 18px; }
+  .mp-proj-card .head { display:flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
+  .mp-proj-card .head h2 { margin: 0; font-size: 20px; letter-spacing: -.01em; }
+  .mp-proj-card .meta { font-size: 13px; color: var(--mp-muted); }
+  .mp-proj-saves { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-top: 14px; }
+  .mp-save { background: #fff; border: 1px solid var(--mp-border); border-radius: 14px; overflow: hidden; transition: transform .15s ease; }
+  .mp-save:hover { transform: translateY(-2px); }
+  .mp-save .img { aspect-ratio: 1/1; background: #eee; background-size: cover; background-position: center; }
+  .mp-save .body { padding: 8px 10px; }
+  .mp-save .t { font-size: 13px; font-weight: 600; line-height: 1.2; }
+  .mp-save .s { font-size: 11px; color: var(--mp-muted); margin-top: 2px; }
+  .mp-empty-saves { color: var(--mp-muted); font-size: 13px; font-style: italic; }
+  .mp-empty { padding: 60px 0; text-align: center; color: var(--mp-muted); }
+  .mp-empty h2 { font-size: 22px; color: var(--mp-fg); margin: 0 0 8px; font-weight: 600; }
+  .mp-newproj { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 24px; }
+  .mp-newproj input { border: 1px solid var(--mp-border); padding: 10px 12px; border-radius: 12px; font-size: 14px; background: #fff; min-width: 220px; }
+  .mp-err { color: #c33; font-size: 13px; margin: 8px 0; }
+</style>
+</head>
+<body>
+  <main id="root"><div class="mp-wrap"><p>Loading…</p></div></main>
+
+<script>
+async function load() {
+  const root = document.getElementById('root');
+  let res;
+  try {
+    res = await fetch('/api/marketplace/projects', { credentials: 'include' });
+  } catch (err) {
+    root.innerHTML = `<div class="mp-wrap"><p class="mp-err">Network error: ${err.message}</p></div>`;
+    return;
+  }
+
+  if (res.status === 401) {
+    root.innerHTML = `
+      <div class="mp-wrap">
+        <div class="mp-proj-head">
+          <div>
+            <h1>Trade · Project Boards</h1>
+            <div class="lede">Save patterns to project boards, share with clients, and order samples in bulk. Sign in to start a project.</div>
+          </div>
+        </div>
+        <div class="mp-empty">
+          <h2>Sign in to view your project boards</h2>
+          <p>You'll need a designer or trade account to save patterns.</p>
+          <a class="mp-btn" href="/marketplace/apply">Apply for an account</a>
+        </div>
+      </div>`;
+    return;
+  }
+  if (!res.ok) {
+    let msg = 'Failed to load projects';
+    try { msg = (await res.json()).error || msg; } catch (_) {}
+    root.innerHTML = `<div class="mp-wrap"><p class="mp-err">${msg}</p></div>`;
+    return;
+  }
+
+  const j = await res.json();
+  const projects = j.projects || [];
+  const head = `
+    <div class="mp-proj-head">
+      <div>
+        <h1>Trade · Project Boards</h1>
+        <div class="lede">Patterns you've saved, organized by project. Use boards to assemble specs for clients, share scope, and queue sample orders.</div>
+      </div>
+      <div class="meta">${projects.length} project${projects.length === 1 ? '' : 's'} · ${
+        projects.reduce((n, p) => n + (Number(p.save_count) || 0), 0)
+      } saves</div>
+    </div>
+    <form class="mp-newproj" id="np">
+      <input type="text" name="title" placeholder="New project title" required />
+      <input type="text" name="client_name" placeholder="Client (optional)" />
+      <input type="text" name="room_type" placeholder="Room type (optional)" />
+      <button class="mp-btn" type="submit">Create board</button>
+      <span class="mp-err" id="np-err" style="display:none"></span>
+    </form>`;
+
+  if (!projects.length) {
+    root.innerHTML = `
+      <div class="mp-wrap">
+        ${head}
+        <div class="mp-empty">
+          <h2>No project boards yet</h2>
+          <p>Create your first board above, then browse <a class="under" href="/patterns">patterns</a> and click <em>Save to project</em>.</p>
+        </div>
+      </div>`;
+    bindNewProj();
+    return;
+  }
+
+  const cards = projects.map((p) => {
+    const meta = [
+      p.client_name ? p.client_name : null,
+      p.room_type ? p.room_type : null,
+      `${Number(p.save_count) || 0} pattern${(Number(p.save_count) || 0) === 1 ? '' : 's'}`,
+    ].filter(Boolean).join(' · ');
+    const saves = (p.saves || []).map((s) => {
+      const img = (s.colorway && s.colorway.image_url) || s.pattern.thumbnail_url || s.pattern.original_image_url || '';
+      return `
+        <a class="mp-save" href="/patterns/${encodeURIComponent(s.pattern.slug)}">
+          <div class="img" style="background-image:url('${img}')"></div>
+          <div class="body">
+            <div class="t">${escapeHtml(s.pattern.title || 'Untitled')}</div>
+            <div class="s">${escapeHtml(s.designer.name || '')}${s.colorway ? ' · ' + escapeHtml(s.colorway.name || '') : ''}</div>
+          </div>
+        </a>`;
+    }).join('');
+    return `
+      <article class="mp-proj-card">
+        <div class="head">
+          <div>
+            <h2>${escapeHtml(p.title)}</h2>
+            <div class="meta">${escapeHtml(meta)}</div>
+          </div>
+          <div class="meta">Created ${new Date(p.created_at).toLocaleDateString()}</div>
+        </div>
+        ${p.notes ? `<p style="margin: 10px 0 0; color: var(--mp-muted); font-size: 13px;">${escapeHtml(p.notes)}</p>` : ''}
+        ${saves ? `<div class="mp-proj-saves">${saves}</div>` : `<div class="mp-empty-saves" style="margin-top: 10px">No patterns saved yet — browse the <a class="under" href="/patterns">pattern library</a> and save a few to this board.</div>`}
+      </article>`;
+  }).join('');
+
+  root.innerHTML = `<div class="mp-wrap">${head}${cards}</div>`;
+  bindNewProj();
+}
+
+function escapeHtml(s) {
+  return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+}
+
+function bindNewProj() {
+  const form = document.getElementById('np');
+  if (!form) return;
+  form.addEventListener('submit', async (e) => {
+    e.preventDefault();
+    const fd = new FormData(form);
+    const errEl = document.getElementById('np-err');
+    errEl.style.display = 'none';
+    try {
+      const r = await fetch('/api/marketplace/projects', {
+        method: 'POST',
+        credentials: 'include',
+        headers: { 'content-type': 'application/json' },
+        body: JSON.stringify({
+          title: fd.get('title'),
+          client_name: fd.get('client_name'),
+          room_type: fd.get('room_type'),
+        }),
+      });
+      const j = await r.json();
+      if (!r.ok) throw new Error(j.error || 'Create failed');
+      load();
+    } catch (err) {
+      errEl.textContent = err.message;
+      errEl.style.display = '';
+    }
+  });
+}
+
+load();
+</script>
+</body>
+</html>
diff --git a/public/marketplace/storefront.html b/public/marketplace/storefront.html
index 98499cf..e6184af 100644
--- a/public/marketplace/storefront.html
+++ b/public/marketplace/storefront.html
@@ -39,8 +39,10 @@ async function load() {
           ${d.is_verified ? '<span class="mp-pill verified">Verified</span>' : ''}
           ${(j.badges||[]).map(b => `<span class="mp-pill">${b.icon||'★'} ${b.badge_name}</span>`).join('')}
         </div>
-        <div style="margin-top: 18px; display:flex; gap:10px; flex-wrap:wrap;">
+        <div style="margin-top: 18px; display:flex; gap:10px; flex-wrap:wrap; align-items: center;">
           <a class="mp-btn" href="#patterns">Shop patterns</a>
+          <button class="mp-btn ghost" id="mp-follow-btn" data-designer-id="${d.id}" type="button" style="display:none; background:rgba(255,255,255,.08); color:#fff; border-color: rgba(255,255,255,.2);">Follow</button>
+          <span id="mp-follow-count" class="mp-pill" style="display:none"></span>
           <a class="mp-btn ghost" style="background:rgba(255,255,255,.08); color:#fff; border-color: rgba(255,255,255,.2);" href="/licensing/${slug}">License artwork</a>
           <a class="mp-btn ghost" style="background:rgba(255,255,255,.08); color:#fff; border-color: rgba(255,255,255,.2);" href="#custom">Request custom design</a>
         </div>
@@ -83,6 +85,46 @@ async function load() {
       </div>`;
     pat.appendChild(a);
   });
+
+  setupFollow(d.id, d.slug);
+}
+
+async function setupFollow(designerId, designerSlug) {
+  const btn = document.getElementById('mp-follow-btn');
+  const cntEl = document.getElementById('mp-follow-count');
+  if (!btn) return;
+  let st;
+  try {
+    st = await (await fetch('/api/marketplace/designers/' + encodeURIComponent(designerSlug) + '/follow', { credentials: 'include' })).json();
+  } catch (_) { return; }
+  btn.style.display = '';
+  cntEl.style.display = '';
+  render(st);
+  btn.addEventListener('click', async () => {
+    const next = !btn.dataset.following || btn.dataset.following === 'false';
+    btn.disabled = true;
+    try {
+      const r = await fetch('/api/marketplace/designers/' + designerId + '/follow', {
+        method: next ? 'POST' : 'DELETE',
+        credentials: 'include',
+      });
+      if (r.status === 401) { window.location = '/marketplace/apply'; return; }
+      const j = await r.json();
+      if (!r.ok) throw new Error(j.error || 'Follow failed');
+      render(j);
+    } catch (err) {
+      alert(err.message);
+    } finally {
+      btn.disabled = false;
+    }
+  });
+  function render(s) {
+    btn.dataset.following = String(!!s.following);
+    btn.textContent = s.following ? 'Following ✓' : 'Follow';
+    cntEl.textContent = (Number(s.count) || 0).toLocaleString() + ' follower' + (Number(s.count) === 1 ? '' : 's');
+    cntEl.style.background = 'rgba(255,255,255,.12)';
+    cntEl.style.color = '#fff';
+  }
 }
 load();
 </script>
diff --git a/src/marketplace/challenges.js b/src/marketplace/challenges.js
index 6fa4ddb..7af069a 100644
--- a/src/marketplace/challenges.js
+++ b/src/marketplace/challenges.js
@@ -63,12 +63,14 @@ function buildLeaderboardQuery(metric, { since = null, until = null, limit = 50
          LIMIT ${p(lim)}`;
     }
   } else if (metric === 'sales') {
+    // Count completed orders (paid|fulfilled) per designer, optionally windowed.
+    // We must count o.id (not oi.order_id) so the LEFT JOIN window filter actually drops rows.
     const predicates = ["o.status IN ('paid','fulfilled')"];
     if (since) predicates.push(`o.created_at >= ${p(since)}`);
     if (until) predicates.push(`o.created_at < ${p(until)}`);
     sql = `
       SELECT d.id AS designer_id, d.slug, d.display_name, d.avatar_url, d.level, d.is_founding, d.is_verified,
-             COUNT(DISTINCT oi.order_id)::numeric AS score
+             COUNT(DISTINCT o.id)::numeric AS score
         FROM mp_designer_profiles d
         LEFT JOIN mp_order_items oi ON oi.designer_id = d.id
         LEFT JOIN mp_orders o ON o.id = oi.order_id AND (${predicates.join(' AND ')})
diff --git a/src/marketplace/follow-projects.js b/src/marketplace/follow-projects.js
new file mode 100644
index 0000000..e6d53df
--- /dev/null
+++ b/src/marketplace/follow-projects.js
@@ -0,0 +1,158 @@
+// Designer follow toggle + trade project boards listing.
+// Mounted by src/marketplace/index.js: require('./follow-projects').mount(app, helpers)
+//
+// Routes:
+//   GET    /api/marketplace/projects                 — list current user's project boards (with saves joined)
+//   GET    /api/marketplace/designers/:id/follow     — public; returns { count, following }
+//   POST   /api/marketplace/designers/:id/follow     — requires designer cookie; idempotent
+//   DELETE /api/marketplace/designers/:id/follow     — requires designer cookie; idempotent
+//
+// :id may be a numeric mp_designer_profiles.id OR a slug.
+// Auth model matches the rest of the marketplace MVP — designer cookie acts as the user identity for
+// trade actions; this lets a logged-in designer follow other designers and own project boards.
+
+const { q, one, many } = require('./db');
+
+function mount(app, helpers) {
+  const { requireDesigner, verifyDesigner, readDesignerCookie } = helpers;
+
+  async function resolveDesignerIdOrSlug(idOrSlug) {
+    const asInt = parseInt(idOrSlug, 10);
+    if (asInt && String(asInt) === String(idOrSlug)) {
+      return one(`SELECT id, slug, display_name FROM mp_designer_profiles WHERE id=$1`, [asInt]);
+    }
+    return one(`SELECT id, slug, display_name FROM mp_designer_profiles WHERE slug=$1`, [String(idOrSlug)]);
+  }
+
+  // ── projects list (current user's project boards + saves)
+  app.get('/api/marketplace/projects', requireDesigner, async (req, res) => {
+    try {
+      const userRow = await one(`SELECT user_id FROM mp_designer_profiles WHERE id=$1`, [req.designer.id]);
+      const userId = userRow?.user_id;
+      if (!userId) return res.json({ projects: [] });
+      const projects = await many(
+        `SELECT p.id, p.title, p.client_name, p.room_type, p.notes, p.created_at,
+                COALESCE((SELECT COUNT(*) FROM mp_project_saves s WHERE s.project_id = p.id), 0) AS save_count
+           FROM mp_projects p
+          WHERE p.user_id = $1
+          ORDER BY p.created_at DESC`,
+        [userId]
+      );
+      if (!projects.length) return res.json({ projects: [] });
+      const ids = projects.map((p) => p.id);
+      const saves = await many(
+        `SELECT s.id AS save_id, s.project_id, s.colorway_id, s.notes AS save_notes, s.created_at AS saved_at,
+                pat.id AS pattern_id, pat.title AS pattern_title, pat.slug AS pattern_slug,
+                pat.thumbnail_url, pat.original_image_url,
+                d.id AS designer_id, d.display_name AS designer_name, d.slug AS designer_slug,
+                cw.name AS colorway_name, cw.image_url AS colorway_image
+           FROM mp_project_saves s
+           JOIN mp_patterns pat ON pat.id = s.pattern_id
+           JOIN mp_designer_profiles d ON d.id = pat.designer_id
+           LEFT JOIN mp_pattern_colorways cw ON cw.id = s.colorway_id
+          WHERE s.project_id = ANY($1::bigint[])
+          ORDER BY s.created_at DESC`,
+        [ids]
+      );
+      const byProj = new Map(projects.map((p) => [String(p.id), Object.assign({ saves: [] }, p)]));
+      for (const s of saves) {
+        const proj = byProj.get(String(s.project_id));
+        if (!proj) continue;
+        proj.saves.push({
+          id: s.save_id,
+          notes: s.save_notes,
+          saved_at: s.saved_at,
+          pattern: {
+            id: s.pattern_id,
+            title: s.pattern_title,
+            slug: s.pattern_slug,
+            thumbnail_url: s.thumbnail_url,
+            original_image_url: s.original_image_url,
+          },
+          designer: { id: s.designer_id, name: s.designer_name, slug: s.designer_slug },
+          colorway: s.colorway_id ? { id: s.colorway_id, name: s.colorway_name, image_url: s.colorway_image } : null,
+        });
+      }
+      res.json({ projects: Array.from(byProj.values()) });
+    } catch (err) {
+      console.error('[projects.list]', err);
+      res.status(500).json({ error: err.message });
+    }
+  });
+
+  // ── designer follow status (public)
+  app.get('/api/marketplace/designers/:id/follow', async (req, res) => {
+    try {
+      const d = await resolveDesignerIdOrSlug(req.params.id);
+      if (!d) return res.status(404).json({ error: 'designer not found' });
+      const cnt = await one(`SELECT COUNT(*)::int AS n FROM mp_designer_followers WHERE designer_id=$1`, [d.id]);
+      let following = false;
+      const me = verifyDesigner(readDesignerCookie(req));
+      if (me) {
+        const u = await one(`SELECT user_id FROM mp_designer_profiles WHERE id=$1`, [me]);
+        if (u?.user_id) {
+          const f = await one(
+            `SELECT 1 FROM mp_designer_followers WHERE designer_id=$1 AND follower_user_id=$2`,
+            [d.id, u.user_id]
+          );
+          following = !!f;
+        }
+      }
+      res.json({ designer_id: d.id, slug: d.slug, count: cnt?.n || 0, following });
+    } catch (err) {
+      console.error('[follow.get]', err);
+      res.status(500).json({ error: err.message });
+    }
+  });
+
+  // ── follow
+  app.post('/api/marketplace/designers/:id/follow', requireDesigner, async (req, res) => {
+    try {
+      const d = await resolveDesignerIdOrSlug(req.params.id);
+      if (!d) return res.status(404).json({ error: 'designer not found' });
+      const u = await one(`SELECT user_id FROM mp_designer_profiles WHERE id=$1`, [req.designer.id]);
+      if (!u?.user_id) return res.status(400).json({ error: 'follower has no user record' });
+      if (Number(d.id) === Number(req.designer.id)) {
+        return res.status(400).json({ error: 'cannot follow yourself' });
+      }
+      await q(
+        `INSERT INTO mp_designer_followers (designer_id, follower_user_id)
+         VALUES ($1,$2)
+         ON CONFLICT (designer_id, follower_user_id) DO NOTHING`,
+        [d.id, u.user_id]
+      );
+      const cnt = await one(`SELECT COUNT(*)::int AS n FROM mp_designer_followers WHERE designer_id=$1`, [d.id]);
+      res.json({ ok: true, designer_id: d.id, slug: d.slug, following: true, count: cnt?.n || 0 });
+    } catch (err) {
+      console.error('[follow.post]', err);
+      res.status(500).json({ error: err.message });
+    }
+  });
+
+  // ── unfollow
+  app.delete('/api/marketplace/designers/:id/follow', requireDesigner, async (req, res) => {
+    try {
+      const d = await resolveDesignerIdOrSlug(req.params.id);
+      if (!d) return res.status(404).json({ error: 'designer not found' });
+      const u = await one(`SELECT user_id FROM mp_designer_profiles WHERE id=$1`, [req.designer.id]);
+      if (!u?.user_id) return res.status(400).json({ error: 'follower has no user record' });
+      await q(
+        `DELETE FROM mp_designer_followers WHERE designer_id=$1 AND follower_user_id=$2`,
+        [d.id, u.user_id]
+      );
+      const cnt = await one(`SELECT COUNT(*)::int AS n FROM mp_designer_followers WHERE designer_id=$1`, [d.id]);
+      res.json({ ok: true, designer_id: d.id, slug: d.slug, following: false, count: cnt?.n || 0 });
+    } catch (err) {
+      console.error('[follow.delete]', err);
+      res.status(500).json({ error: err.message });
+    }
+  });
+
+  // ── trade project board page
+  const path = require('path');
+  app.get('/trade/projects', (_req, res) => {
+    res.sendFile(path.join(__dirname, '..', '..', 'public', 'marketplace', 'projects.html'));
+  });
+}
+
+module.exports = { mount };
diff --git a/tests/marketplace/challenges.test.js b/tests/marketplace/challenges.test.js
new file mode 100644
index 0000000..a52ef4f
--- /dev/null
+++ b/tests/marketplace/challenges.test.js
@@ -0,0 +1,188 @@
+// Integration tests for src/marketplace/challenges.js.
+// Talks to dw_unified — requires local Postgres trust auth (macOS) or
+// MARKETPLACE_DB_URL/DATABASE_URL on Linux/Kamatera.
+//
+// Self-contained: creates a fixture designer, exercises a full challenge
+// lifecycle, then tears the fixtures down.
+//
+// Run with: node tests/marketplace/challenges.test.js
+
+const assert = require('assert');
+const { q, one, many } = require('../../src/marketplace/db');
+const challenges = require('../../src/marketplace/challenges');
+
+const tests = [];
+function t(name, fn) { tests.push({ name, fn }); }
+
+// ── Pure / argument-validation tests
+t('VALID_METRICS includes the four required metrics', () => {
+  for (const m of ['points','sales','saves','collections']) {
+    assert.ok(challenges.VALID_METRICS.includes(m), 'missing metric: ' + m);
+  }
+});
+
+t('buildLeaderboardQuery rejects invalid metric', () => {
+  assert.throws(() => challenges.buildLeaderboardQuery('shoes'), /Invalid metric/);
+});
+
+t('buildLeaderboardQuery returns parameterised SQL for points (lifetime)', () => {
+  const { sql, params } = challenges.buildLeaderboardQuery('points', { limit: 25 });
+  assert.ok(/FROM mp_designer_profiles/.test(sql));
+  assert.ok(/LIMIT \$\d+/.test(sql));
+  assert.strictEqual(params[params.length - 1], 25);
+});
+
+t('buildLeaderboardQuery handles windowed points', () => {
+  const since = new Date('2026-05-01');
+  const until = new Date('2026-06-01');
+  const { sql, params } = challenges.buildLeaderboardQuery('points', { since, until });
+  assert.ok(/mp_points_ledger/.test(sql));
+  assert.ok(params.includes(since));
+  assert.ok(params.includes(until));
+});
+
+t('buildLeaderboardQuery handles sales metric', () => {
+  const { sql } = challenges.buildLeaderboardQuery('sales');
+  assert.ok(/mp_order_items/.test(sql) && /mp_orders/.test(sql));
+});
+
+t('buildLeaderboardQuery handles saves metric', () => {
+  const { sql } = challenges.buildLeaderboardQuery('saves');
+  assert.ok(/mp_project_saves/.test(sql) && /mp_patterns/.test(sql));
+});
+
+t('buildLeaderboardQuery handles collections metric', () => {
+  const { sql } = challenges.buildLeaderboardQuery('collections');
+  assert.ok(/mp_collections/.test(sql) && /status='published'/.test(sql));
+});
+
+// ── createChallenge argument validation
+t('createChallenge rejects missing title', async () => {
+  await assert.rejects(() => challenges.createChallenge({ metric: 'points', starts_at: new Date(), ends_at: new Date(Date.now() + 86400000) }), /title required/);
+});
+
+t('createChallenge rejects bad metric', async () => {
+  await assert.rejects(() => challenges.createChallenge({ title: 'x', metric: 'donations', starts_at: new Date(), ends_at: new Date(Date.now() + 86400000) }), /Invalid metric/);
+});
+
+t('createChallenge rejects ends_at <= starts_at', async () => {
+  const now = new Date();
+  await assert.rejects(() => challenges.createChallenge({ title: 'x', metric: 'points', starts_at: now, ends_at: now }), /ends_at must be after/);
+});
+
+// ── Lifecycle test — create designer fixture, create challenge,
+// emit a point-ledger event, close challenge, assert badge + 1000pt award.
+let fixtureDesignerId = null;
+let fixtureChallengeId = null;
+const RUN_TAG = '__challenge_test_' + Date.now();
+
+t('lifecycle: create approved designer fixture', async () => {
+  const r = await one(
+    `INSERT INTO mp_designer_profiles (display_name, slug, status, points)
+     VALUES ($1,$2,'approved',0) RETURNING id`,
+    ['Challenge Test ' + Date.now(), RUN_TAG]
+  );
+  // pg returns BIGINT as string; coerce so === comparisons against challenge output work.
+  fixtureDesignerId = Number(r.id);
+  assert.ok(fixtureDesignerId > 0);
+});
+
+t('lifecycle: create active challenge with metric=points', async () => {
+  const now = Date.now();
+  const c = await challenges.createChallenge({
+    title: 'Test Challenge ' + RUN_TAG,
+    description: 'integration test fixture',
+    metric: 'points',
+    starts_at: new Date(now - 60_000),         // 1 min ago
+    ends_at: new Date(now + 5 * 60_000),        // 5 min from now
+  });
+  fixtureChallengeId = c.id;
+  assert.strictEqual(c.status, 'active');
+});
+
+t('lifecycle: log a points-ledger event inside the challenge window', async () => {
+  // 1 million points so we definitively beat any seed-data designer activity in the same window.
+  await q(
+    `INSERT INTO mp_points_ledger (designer_id, action, points, notes)
+     VALUES ($1, 'PROFILE_COMPLETED', 1000000, $2)`,
+    [fixtureDesignerId, 'test fixture inside window']
+  );
+});
+
+t('lifecycle: getLeaderboard(points, windowed) ranks fixture designer', async () => {
+  const since = new Date(Date.now() - 5 * 60_000);
+  const until = new Date(Date.now() + 5 * 60_000);
+  const rows = await challenges.getLeaderboard({ metric: 'points', since, until, limit: 200 });
+  const ours = rows.find((r) => r.designer_id === fixtureDesignerId);
+  assert.ok(ours, 'fixture designer not in leaderboard');
+  assert.ok(ours.score >= 500, 'fixture designer score should be >= 500 within window, got ' + ours.score);
+});
+
+t('lifecycle: closeChallenge awards 1000 points + flips status=closed', async () => {
+  const out = await challenges.closeChallenge(fixtureChallengeId);
+  assert.strictEqual(out.challenge.status, 'closed');
+  assert.ok(out.winner, 'closeChallenge returned no winner');
+  assert.strictEqual(out.winner.designer_id, fixtureDesignerId);
+
+  // CHALLENGE_WIN points event should now be on the ledger.
+  const winRows = await many(
+    `SELECT * FROM mp_points_ledger WHERE designer_id=$1 AND action='CHALLENGE_WIN'`,
+    [fixtureDesignerId]
+  );
+  assert.strictEqual(winRows.length, 1, 'expected exactly one CHALLENGE_WIN ledger entry');
+  assert.strictEqual(winRows[0].points, 1000);
+});
+
+t('lifecycle: closeChallenge is idempotent (second call returns alreadyClosed)', async () => {
+  const out = await challenges.closeChallenge(fixtureChallengeId);
+  assert.strictEqual(out.alreadyClosed, true);
+});
+
+t('lifecycle: mp_challenge_entries persists ranked entries', async () => {
+  const rows = await many(
+    `SELECT designer_id, score, rank FROM mp_challenge_entries WHERE challenge_id=$1 ORDER BY rank ASC`,
+    [fixtureChallengeId]
+  );
+  assert.ok(rows.length >= 1, 'expected at least 1 challenge entry');
+  // mp_challenge_entries.designer_id is BIGINT — pg returns as string; coerce on the fly.
+  const ours = rows.find((r) => Number(r.designer_id) === fixtureDesignerId);
+  assert.ok(ours, 'fixture designer missing from entries');
+  assert.strictEqual(ours.rank, 1, 'fixture designer should be rank 1');
+});
+
+t('lifecycle: closeChallenge on a no-entrants challenge produces no winner but still closes', async () => {
+  // Pick a sales window in the deep past — no mp_orders rows will fall inside this window
+  // unless someone backdates orders. 2010-01 → 2010-02 is safe across all dev/prod seed data.
+  const empty = await challenges.createChallenge({
+    title: 'Empty Challenge ' + RUN_TAG,
+    metric: 'sales',
+    starts_at: new Date('2010-01-01T00:00:00Z'),
+    ends_at: new Date('2010-02-01T00:00:00Z'),
+  });
+  const out = await challenges.closeChallenge(empty.id);
+  assert.strictEqual(out.challenge.status, 'closed');
+  // No paid/fulfilled mp_orders in window → no winner awarded.
+  assert.strictEqual(out.winner, null);
+  await q(`DELETE FROM mp_challenge_entries WHERE challenge_id=$1`, [empty.id]);
+  await q(`DELETE FROM mp_challenges WHERE id=$1`, [empty.id]);
+});
+
+t('teardown: clean up fixtures', async () => {
+  await q(`DELETE FROM mp_challenge_entries WHERE challenge_id=$1`, [fixtureChallengeId]);
+  await q(`DELETE FROM mp_challenges WHERE id=$1`, [fixtureChallengeId]);
+  await q(`DELETE FROM mp_points_ledger WHERE designer_id=$1`, [fixtureDesignerId]);
+  await q(`DELETE FROM mp_designer_badges WHERE designer_id=$1`, [fixtureDesignerId]);
+  await q(`DELETE FROM mp_designer_profiles WHERE id=$1`, [fixtureDesignerId]);
+});
+
+(async () => {
+  let pass = 0, fail = 0;
+  for (const test of tests) {
+    try { await test.fn(); console.log('✓', test.name); pass++; }
+    catch (err) { console.error('✗', test.name, '—', err.message); fail++; }
+  }
+  console.log(`\n${pass}/${tests.length} challenge tests passed`);
+  // Force close pg pool so process exits cleanly.
+  try { await require('../../src/marketplace/db').pool.end(); } catch (_) {}
+  if (fail) process.exit(1);
+})();
diff --git a/tests/marketplace/follow-projects.test.js b/tests/marketplace/follow-projects.test.js
new file mode 100644
index 0000000..b29d604
--- /dev/null
+++ b/tests/marketplace/follow-projects.test.js
@@ -0,0 +1,179 @@
+// HTTP smoke + structure tests for designer-follow + trade project boards.
+// Run with: node tests/marketplace/follow-projects.test.js
+//
+// HTTP tests assume a running wallco-ai on :9792. The structure tests run standalone
+// and validate that src/marketplace/follow-projects.js exposes a mount() that wires up
+// the four expected routes against an Express-shaped app stub.
+
+const assert = require('assert');
+const http = require('http');
+const path = require('path');
+const fs = require('fs');
+
+const HOST = process.env.WALLCO_HOST || '127.0.0.1';
+const PORT = process.env.WALLCO_PORT || 9792;
+
+function req(method, urlPath, body, headers) {
+  return new Promise((resolve, reject) => {
+    const opts = {
+      hostname: HOST,
+      port: PORT,
+      path: urlPath,
+      method,
+      headers: Object.assign({ 'content-type': 'application/json' }, headers || {}),
+    };
+    const r = http.request(opts, (res) => {
+      let buf = '';
+      res.on('data', (c) => (buf += c));
+      res.on('end', () =>
+        resolve({ status: res.statusCode, body: buf, json: tryJson(buf), headers: res.headers })
+      );
+    });
+    r.on('error', reject);
+    if (body) r.write(JSON.stringify(body));
+    r.end();
+  });
+}
+function tryJson(s) {
+  try {
+    return JSON.parse(s);
+  } catch (_) {
+    return null;
+  }
+}
+
+const tests = [];
+function t(name, fn) {
+  tests.push({ name, fn });
+}
+
+// ── Structure tests (no server required)
+
+t('module exports mount(app, helpers)', () => {
+  const m = require('../../src/marketplace/follow-projects');
+  assert.strictEqual(typeof m.mount, 'function');
+});
+
+t('mount registers GET/POST/DELETE /api/marketplace/designers/:id/follow + GET /api/marketplace/projects + GET /trade/projects', () => {
+  const m = require('../../src/marketplace/follow-projects');
+  const calls = { get: [], post: [], delete: [] };
+  const app = {
+    get: (p) => calls.get.push(p),
+    post: (p) => calls.post.push(p),
+    delete: (p) => calls.delete.push(p),
+  };
+  // Pass plausible helpers; mount() should not call them at registration time.
+  m.mount(app, {
+    requireDesigner: () => {},
+    verifyDesigner: () => null,
+    readDesignerCookie: () => null,
+  });
+  assert.ok(calls.get.includes('/api/marketplace/designers/:id/follow'), 'GET follow not registered');
+  assert.ok(calls.post.includes('/api/marketplace/designers/:id/follow'), 'POST follow not registered');
+  assert.ok(calls.delete.includes('/api/marketplace/designers/:id/follow'), 'DELETE follow not registered');
+  assert.ok(calls.get.includes('/api/marketplace/projects'), 'GET projects not registered');
+  assert.ok(calls.get.includes('/trade/projects'), 'GET /trade/projects page not registered');
+});
+
+t('public/marketplace/projects.html exists and renders trade-project markup', () => {
+  const html = fs.readFileSync(path.join(__dirname, '..', '..', 'public', 'marketplace', 'projects.html'), 'utf8');
+  assert.ok(/Trade · Project Boards/.test(html), 'page heading missing');
+  assert.ok(/\/api\/marketplace\/projects/.test(html), 'page must call /api/marketplace/projects');
+});
+
+t('designer-profile.html and storefront.html include the Follow button hook', () => {
+  for (const f of ['designer-profile.html', 'storefront.html']) {
+    const html = fs.readFileSync(path.join(__dirname, '..', '..', 'public', 'marketplace', f), 'utf8');
+    assert.ok(/mp-follow-btn/.test(html), `${f} missing mp-follow-btn id`);
+    assert.ok(/setupFollow\(/.test(html), `${f} missing setupFollow() wiring`);
+  }
+});
+
+// ── HTTP smoke tests (require running server). Skipped silently if server is not up.
+
+let serverUp = false;
+async function isServerUp() {
+  try {
+    const r = await req('GET', '/api/marketplace/status');
+    serverUp = r.status === 200;
+  } catch (_) {
+    serverUp = false;
+  }
+  return serverUp;
+}
+
+t('HTTP: GET /trade/projects serves HTML 200 (skipped if server down)', async () => {
+  if (!(await isServerUp())) {
+    console.log('  (server not running, skipping)');
+    return;
+  }
+  const r = await req('GET', '/trade/projects');
+  assert.strictEqual(r.status, 200);
+  assert.ok(/Trade/.test(r.body), 'response should look like the projects page');
+});
+
+t('HTTP: GET /api/marketplace/projects without cookie returns 401', async () => {
+  if (!serverUp) {
+    console.log('  (server not running, skipping)');
+    return;
+  }
+  const r = await req('GET', '/api/marketplace/projects');
+  assert.strictEqual(r.status, 401);
+});
+
+t('HTTP: POST /api/marketplace/designers/:slug/follow without cookie returns 401', async () => {
+  if (!serverUp) {
+    console.log('  (server not running, skipping)');
+    return;
+  }
+  const r = await req('POST', '/api/marketplace/designers/astrid-mauve/follow');
+  assert.strictEqual(r.status, 401);
+});
+
+t('HTTP: DELETE /api/marketplace/designers/:slug/follow without cookie returns 401', async () => {
+  if (!serverUp) {
+    console.log('  (server not running, skipping)');
+    return;
+  }
+  const r = await req('DELETE', '/api/marketplace/designers/astrid-mauve/follow');
+  assert.strictEqual(r.status, 401);
+});
+
+t('HTTP: GET /api/marketplace/designers/:slug/follow is public, returns count + following=false', async () => {
+  if (!serverUp) {
+    console.log('  (server not running, skipping)');
+    return;
+  }
+  const r = await req('GET', '/api/marketplace/designers/astrid-mauve/follow');
+  // accept either 200 (designer exists) or 404 (designer not seeded in dev DB)
+  assert.ok([200, 404].includes(r.status), 'unexpected status ' + r.status);
+  if (r.status === 200) {
+    assert.strictEqual(typeof r.json.count, 'number');
+    assert.strictEqual(r.json.following, false);
+  }
+});
+
+t('HTTP: GET unknown designer follow returns 404', async () => {
+  if (!serverUp) {
+    console.log('  (server not running, skipping)');
+    return;
+  }
+  const r = await req('GET', '/api/marketplace/designers/__no_such_designer_slug__/follow');
+  assert.strictEqual(r.status, 404);
+});
+
+(async () => {
+  let pass = 0, fail = 0;
+  for (const test of tests) {
+    try {
+      await test.fn();
+      console.log('✓', test.name);
+      pass++;
+    } catch (err) {
+      console.error('✗', test.name, '—', err.message);
+      fail++;
+    }
+  }
+  console.log(`\n${pass}/${tests.length} follow-projects tests passed`);
+  if (fail) process.exit(1);
+})();

← 8350976 marketplace search: case-insensitive tag filters + non-trivi  ·  back to Wallco Ai  ·  spoonflower upload returns SF pattern#, adds Upload-to-Shopi 92d9917 →