← 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
M public/marketplace/designer-profile.htmlA public/marketplace/projects.htmlM public/marketplace/storefront.htmlM src/marketplace/challenges.jsA src/marketplace/follow-projects.jsA tests/marketplace/challenges.test.jsA tests/marketplace/follow-projects.test.js
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 →